From 8ad5c86ff973c474e36404baa1e8c304beb6bbb3 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Aug 2026 21:11:38 +0200 Subject: [PATCH] Add the library, collections, and trash views; theme from style.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI gains the views the catalog work was building toward: a windowed library grid with ratings and flags, the collection tree with drag-to-add, and trash with restore. derived_sync pushes thumbnail shards and the catalog snapshot to the server's derived folder. Tokens now have one source of truth. build.rs reads style.yaml and generates theme.slint into OUT_DIR, which answers every existing `import { Theme } from "theme.slint"` unchanged, because Slint resolves imports against the importing file's directory first and the include paths after. Generating into OUT_DIR rather than beside the hand-written Slint is the point: a generated file sitting in ui/ looks exactly like the files around it that are meant to be edited, and an edit to it would survive until the next touch of style.yaml — a bug that hides for weeks. build.rs fails loudly if a stale ui/theme.slint exists, which would otherwise shadow the generated one silently and make every palette change vanish with no error. The palette moves to near-neutral dark with achromatic signalling, so the accent means "modified" or "active" rather than "heading". Shared components land in widgets.slint: a token that binds several values into one concept is a component, not a row in a YAML file. Adds an optional live-style feature that makes the tokens in-out so they can be written at startup — a feature rather than the default because it stops the properties being constant-folded. serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated, and its mappings preserve insertion order, which is what lets the generated Slint keep the token ordering the author chose. Assisted-by: LLM --- ui/dr-ui/Cargo.toml | 29 +- ui/dr-ui/build.rs | 316 ++++- ui/dr-ui/src/collections_ui.rs | 2215 +++++++++++++++++++++++++++++++ ui/dr-ui/src/derived_sync.rs | 387 ++++++ ui/dr-ui/src/develop.rs | 320 ++++- ui/dr-ui/src/launch.rs | 66 + ui/dr-ui/src/launch_ui.rs | 8 - ui/dr-ui/src/lib.rs | 525 +++++++- ui/dr-ui/src/library.rs | 2279 ++++++++++++++++++++++++++++++++ ui/dr-ui/src/library_ui.rs | 1976 +++++++++++++++++++++++++++ ui/dr-ui/src/live_style.rs | 181 +++ ui/dr-ui/src/trash.rs | 496 +++++++ ui/dr-ui/style.yaml | 163 +++ ui/dr-ui/ui/adjust.slint | 317 +++-- ui/dr-ui/ui/app.slint | 724 ++++++++-- ui/dr-ui/ui/collections.slint | 566 ++++++++ ui/dr-ui/ui/launch.slint | 268 ++-- ui/dr-ui/ui/library.slint | 1213 +++++++++++++++++ ui/dr-ui/ui/theme.slint | 32 - ui/dr-ui/ui/widgets.slint | 600 +++++++++ 20 files changed, 12192 insertions(+), 489 deletions(-) create mode 100644 ui/dr-ui/src/collections_ui.rs create mode 100644 ui/dr-ui/src/derived_sync.rs create mode 100644 ui/dr-ui/src/library.rs create mode 100644 ui/dr-ui/src/library_ui.rs create mode 100644 ui/dr-ui/src/live_style.rs create mode 100644 ui/dr-ui/src/trash.rs create mode 100644 ui/dr-ui/style.yaml create mode 100644 ui/dr-ui/ui/collections.slint create mode 100644 ui/dr-ui/ui/library.slint delete mode 100644 ui/dr-ui/ui/theme.slint create mode 100644 ui/dr-ui/ui/widgets.slint diff --git a/ui/dr-ui/Cargo.toml b/ui/dr-ui/Cargo.toml index 69b0f5d..59ce4ed 100644 --- a/ui/dr-ui/Cargo.toml +++ b/ui/dr-ui/Cargo.toml @@ -19,16 +19,43 @@ dr-plat.workspace = true dr-sync.workspace = true dr-sync-nextcloud.workspace = true dr-pipeline.workspace = true -slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg", "backend-winit"] } +dr-catalog.workspace = true +dr-thumbs.workspace = true +# The library module writes scan results straight into the catalog, so it +# needs the same SQLite types dr-catalog exposes. +rusqlite.workspace = true +# The renderer is shared, but the backend is not: winit on desktop, +# android-activity on Android, and enabling both makes the backend selector +# pick at random. So the backend features live on the target-specific +# dependencies below rather than here. +slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg"] } wgpu.workspace = true anyhow.workspace = true log.workspace = true pollster.workspace = true +# Runtime YAML only for `live-style`; release builds read the tokens the +# Slint compiler folded in at build time and never touch style.yaml. +serde_norway = { workspace = true, optional = true } + +# Backend per platform. Slint already declares its android-activity backend +# under `cfg(target_os = "android")`, so this only has to name the feature; +# cargo resolves it away entirely on desktop. +[target.'cfg(not(target_os = "android"))'.dependencies] +slint = { workspace = true, features = ["backend-winit"] } + +[target.'cfg(target_os = "android")'.dependencies] +slint = { workspace = true, features = ["backend-android-activity-06"] } [build-dependencies] slint-build.workspace = true +# build.rs generates theme.slint from style.yaml (S2). +serde_norway.workspace = true [features] default = [] # Temporary CPU readback path; see dr-ui docs and spike S1. readback = ["dr-gpu/readback"] +# Debug convenience: re-read style.yaml at startup so a palette can be tuned +# without rebuilding. Costs the constant-folding of every token, so it stays +# off by default and has no business in a release build. +live-style = ["dep:serde_norway"] diff --git a/ui/dr-ui/build.rs b/ui/dr-ui/build.rs index 0017917..7f8d039 100644 --- a/ui/dr-ui/build.rs +++ b/ui/dr-ui/build.rs @@ -1,3 +1,317 @@ +//! Generates `theme.slint` from `style.yaml`, then compiles the UI. +//! +//! The generated file lands in `OUT_DIR`, not beside the hand-written Slint. +//! That is the whole point: a generated file sitting in `ui/` looks exactly +//! like the five files around it that *are* meant to be edited, and an edit +//! to it survives until the next `touch style.yaml` — a bug that hides for +//! weeks. In `OUT_DIR` it cannot be edited by accident and cannot be +//! committed by accident, so no `.gitignore` entry is needed either. +//! +//! Slint resolves `import ... from "theme.slint"` against the importing +//! file's directory first and the compiler's include paths after, so adding +//! `OUT_DIR` as an include path makes the generated file answer every +//! existing `import { Theme } from "theme.slint"` unchanged. This only works +//! while no `ui/theme.slint` exists to shadow it — see the guard below. + +use std::collections::BTreeSet; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use serde_norway::Value; + +const STYLE_YAML: &str = "style.yaml"; +const GENERATED: &str = "theme.slint"; + fn main() { - slint_build::compile("ui/app.slint").expect("compiling app.slint"); + println!("cargo:rerun-if-changed={STYLE_YAML}"); + println!("cargo:rerun-if-changed=ui"); + println!("cargo:rustc-check-cfg=cfg(live_style)"); + + // Under `live-style` the tokens become `in-out` so Rust can write them at + // startup. Call sites are unaffected — `Theme.ground` reads the same + // either way — but the properties stop being constant-folded, which is + // why this is a feature and not the default. + let live = std::env::var_os("CARGO_FEATURE_LIVE_STYLE").is_some(); + if live { + println!("cargo:rustc-cfg=live_style"); + } + + let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")); + let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir")); + + // A hand-written `ui/theme.slint` would shadow the generated one silently + // — the importing file's own directory wins over the include path — and + // every palette change would then be ignored with no error anywhere. + let shadow = manifest_dir.join("ui").join(GENERATED); + if shadow.exists() { + fail(format!( + "{} exists and would shadow the generated theme.\n\ + Tokens now come from {STYLE_YAML}; delete the stale file.", + shadow.display() + )); + } + + let source = manifest_dir.join(STYLE_YAML); + let generated = out_dir.join(GENERATED); + match generate(&source, live) { + Ok(slint) => std::fs::write(&generated, slint) + .unwrap_or_else(|e| fail(format!("writing {}: {e}", generated.display()))), + Err(e) => fail(format!("{STYLE_YAML}: {e}")), + } + + // The live-reload path parses the same YAML at runtime, so the crate has + // to be able to find it from an installed binary too. + println!("cargo:rustc-env=DR_STYLE_YAML={}", source.display()); + + let config = slint_build::CompilerConfiguration::new() + .with_include_paths(vec![out_dir.clone(), manifest_dir.join("ui")]); + slint_build::compile_with_config(entry(&out_dir, live), config).expect("compiling app.slint"); +} + +/// The file handed to the Slint compiler. +/// +/// Normally `ui/app.slint` itself. Under `live-style` it is a generated +/// shim that re-exports `app.slint` *and* `Theme`, because Slint emits Rust +/// accessors only for globals exported from the entry document — and +/// `app.slint` has no business carrying a line that exists to serve a debug +/// feature. Everything the crate names (`AppWindow`, `ParamRow`, the cell +/// and row structs) comes through the wildcard, so `include_modules!` sees +/// exactly what it saw before plus the theme. +fn entry(out_dir: &Path, live: bool) -> PathBuf { + let app = PathBuf::from("ui/app.slint"); + if !live { + return app; + } + let shim = out_dir.join("live-entry.slint"); + std::fs::write( + &shim, + "// GENERATED — see ui/dr-ui/build.rs. Entry point for `live-style` only.\n\ + export * from \"app.slint\";\n\ + import { Theme } from \"theme.slint\";\n\ + export { Theme }\n", + ) + .unwrap_or_else(|e| fail(format!("writing {}: {e}", shim.display()))); + shim +} + +/// Build scripts report failure through stderr and a non-zero exit; a panic +/// buries the message under a backtrace and the "process didn't exit +/// successfully" boilerplate, which is exactly the wrong thing when the +/// message is the name of the key the author got wrong. +fn fail(message: String) -> ! { + eprintln!("\nerror: {message}\n"); + std::process::exit(1); +} + +// --- codegen ------------------------------------------------------------- + +fn generate(source: &Path, live: bool) -> Result { + let text = std::fs::read_to_string(source).map_err(|e| format!("cannot read: {e}"))?; + let doc: Value = serde_norway::from_str(&text).map_err(|e| format!("not valid YAML: {e}"))?; + let doc = doc.as_mapping().ok_or("top level must be a mapping")?; + + let mut out = String::new(); + let banner = source + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| STYLE_YAML.into()); + writeln!( + out, + "// GENERATED FILE — DO NOT EDIT.\n\ + //\n\ + // Written by ui/dr-ui/build.rs from ui/dr-ui/{banner}. Edits here are\n\ + // discarded the next time that file changes. Change a token there.\n" + ) + .unwrap(); + + if let Some(preamble) = doc.get("preamble") { + let preamble = preamble + .as_str() + .ok_or("`preamble` must be a block string")?; + out.push_str(&comment(preamble, "//", 0)); + out.push('\n'); + } + + // `out` normally, `in-out` under live-style so the reloader can write + // them. Reading a token is `Theme.` under both, which is the whole + // reason live reload is possible without touching a single call site. + let direction = if live { + out.push_str( + "// Built with `live-style`: tokens are `in-out` so style.yaml can be\n\ + // re-read at startup. Release builds generate `out` and fold these\n\ + // to constants.\n", + ); + "in-out" + } else { + "out" + }; + out.push_str("export global Theme {\n"); + + let mut names = BTreeSet::new(); + emit_group(&mut out, doc, "colors", "color", direction, &mut names, parse_color)?; + emit_group(&mut out, doc, "lengths", "length", direction, &mut names, parse_length)?; + + if names.is_empty() { + return Err("defines no tokens; expected `colors:` and `lengths:` maps".into()); + } + out.push_str("}\n"); + Ok(out) +} + +/// Emits one YAML map as a run of `out property` declarations. +/// +/// `parse` turns a scalar into Slint syntax; everything else about a token — +/// its prose, its aliasing, the section headings between groups — is shared +/// between colours and lengths and lives here. +fn emit_group( + out: &mut String, + doc: &serde_norway::Mapping, + key: &str, + slint_type: &str, + direction: &str, + names: &mut BTreeSet, + parse: fn(&Value) -> Result, +) -> Result<(), String> { + let Some(group) = doc.get(key) else { + return Err(format!("missing `{key}:` map")); + }; + let group = group + .as_mapping() + .ok_or_else(|| format!("`{key}` must be a mapping of token name to value"))?; + + let mut first = true; + for (name, spec) in group { + let name = name + .as_str() + .ok_or_else(|| format!("`{key}` has a non-string token name"))?; + + // A divider is not a token. `section:` opens a named run with the + // prose that introduces it; `break: true` is a bare blank line, which + // is how the file groups related tokens (the three inks, the four + // text sizes) without a heading each time. YAML discards the author's + // blank lines, so the grouping has to be said rather than shown. + if let Some(map) = spec.as_mapping() { + if let Some(title) = map.get("section") { + let title = title + .as_str() + .ok_or_else(|| format!("`{key}.{name}.section` must be a string"))?; + let rule = "-".repeat(64usize.saturating_sub(title.len()).max(3)); + writeln!(out, "\n // --- {title} {rule}").unwrap(); + if let Some(note) = prose(spec, "note", &format!("{key}.{name}"))? { + out.push_str(" //\n"); + out.push_str(&comment(¬e, "//", 4)); + } + first = false; + continue; + } + if map.contains_key("break") { + out.push('\n'); + first = true; + continue; + } + } + + if !names.insert(name.to_string()) { + return Err(format!("`{name}` is defined twice")); + } + + // Blank line between prose-carrying tokens, so a comment attaches + // visibly to the token below it rather than the run above. + let doc_comment = prose(spec, "doc", &format!("{key}.{name}"))?; + let note = prose(spec, "note", &format!("{key}.{name}"))?; + if !first && (doc_comment.is_some() || note.is_some()) { + out.push('\n'); + } + if let Some(note) = ¬e { + out.push_str(&comment(note, "//", 4)); + } + if let Some(doc_comment) = &doc_comment { + out.push_str(&comment(doc_comment, "///", 4)); + } + + let value = token_value(spec, name, key, parse)?; + writeln!(out, " {direction} property <{slint_type}> {name}: {value};").unwrap(); + first = false; + } + Ok(()) +} + +/// A token is either a bare scalar or a mapping carrying `value:`/`alias:` +/// alongside its prose. +fn token_value( + spec: &Value, + name: &str, + key: &str, + parse: fn(&Value) -> Result, +) -> Result { + let Some(map) = spec.as_mapping() else { + return parse(spec).map_err(|e| format!("`{key}.{name}`: {e}")); + }; + if let Some(alias) = map.get("alias") { + let alias = alias + .as_str() + .ok_or_else(|| format!("`{key}.{name}.alias` must name another token"))?; + // `root.` rather than a bare name: inside a global, an unqualified + // reference to a sibling property does not resolve. + return Ok(format!("root.{alias}")); + } + let value = map + .get("value") + .ok_or_else(|| format!("`{key}.{name}` has neither `value:` nor `alias:`"))?; + parse(value).map_err(|e| format!("`{key}.{name}`: {e}")) +} + +fn prose(spec: &Value, field: &str, path: &str) -> Result, String> { + let Some(map) = spec.as_mapping() else { + return Ok(None); + }; + match map.get(field) { + None => Ok(None), + Some(v) => v + .as_str() + .map(|s| Some(s.trim_end().to_string())) + .ok_or_else(|| format!("`{path}.{field}` must be a string")), + } +} + +fn parse_color(value: &Value) -> Result { + let hex = value + .as_str() + .ok_or("must be a quoted hex colour such as \"#1B1C1E\"")?; + let digits = hex.strip_prefix('#').ok_or_else(|| { + format!("`{hex}` is not a hex colour; expected a leading `#`") + })?; + if !matches!(digits.len(), 3 | 4 | 6 | 8) || !digits.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "`{hex}` is not a hex colour; expected #RGB, #RGBA, #RRGGBB or #RRGGBBAA" + )); + } + Ok(hex.to_string()) +} + +fn parse_length(value: &Value) -> Result { + let px = value + .as_f64() + .ok_or("must be a number of pixels, such as 12")?; + if px.fract() == 0.0 { + Ok(format!("{}px", px as i64)) + } else { + Ok(format!("{px}px")) + } +} + +/// Wraps a block of prose as Slint comments at a given indent, keeping the +/// author's own line breaks — the paragraphs in `style.yaml` are already +/// wrapped to the width the codebase reads at. +fn comment(text: &str, marker: &str, indent: usize) -> String { + let pad = " ".repeat(indent); + let mut out = String::new(); + for line in text.trim_end().lines() { + if line.trim().is_empty() { + writeln!(out, "{pad}{marker}").unwrap(); + } else { + writeln!(out, "{pad}{marker} {line}").unwrap(); + } + } + out } diff --git a/ui/dr-ui/src/collections_ui.rs b/ui/dr-ui/src/collections_ui.rs new file mode 100644 index 0000000..b54e21f --- /dev/null +++ b/ui/dr-ui/src/collections_ui.rs @@ -0,0 +1,2215 @@ +//! TRACES: FR-CAT-7 | FR-UI-5 | NFR-P9 +//! The collections sidebar, grid selection, and the drag between them. +//! +//! `dr_catalog::collections` owns the data rules — hierarchy, membership, +//! revisions, cycles. This module owns the *interaction*: what is selected, +//! what a drag is carrying, and where a release lands. +//! +//! # What this module does and does not own +//! +//! Slint's `DragArea`/`DropArea` own the *gesture* — pointer capture, the +//! threshold separating a click from a drag, arbitration against the grid's +//! Flickable, the image under the cursor, and hit-testing the release. So the +//! drop arrives already addressed to a collection, and nothing here tracks +//! pointer positions or guesses a target. +//! +//! What is left here is what Slint cannot know: +//! +//! - **the payload** — which images the drag carries, built from the selection +//! at the moment the drag starts; +//! - **the spring** — a dwell timer that opens a collapsed collection so a +//! nested child can be reached mid-drag, and closes again what the drag only +//! passed over. +//! +//! An earlier version hand-rolled the whole gesture on `TouchArea` and did not +//! work, for a reason worth keeping: an interactive `Flickable` claims any drag +//! starting inside it for scrolling and cancels the child TouchArea's press, so +//! the drag could never leave the grid. +//! +//! # Selection +//! +//! Selection is by **catalog image id**, never by row index. The grid is a +//! window over the catalog (FR-CAT-4) and scrubbing replaces every row, so an +//! index-based selection would silently come to mean forty different +//! photographs after a scrub. Ids survive that; they also survive a rescan. + +use std::cell::RefCell; +use std::collections::BTreeSet; +use std::rc::Rc; + +use dr_catalog::collections::{self as coll, CollectionKind}; +use dr_catalog::Catalog; +use dr_types::{CollectionId, ImageId}; +use rusqlite::OptionalExtension as _; +use slint::{ComponentHandle, Model as _}; + +use crate::{AppWindow, CollectionRow}; + +/// Selection, drag, and tree state for the running window. +/// +/// Everything is `RefCell` because Slint callbacks are `Fn`, not `FnMut`, and +/// they all run on the one event-loop thread — the same shape +/// [`crate::library_ui::LibraryController`] uses. +#[derive(Default)] +pub struct CollectionsController { + /// Selected images, by catalog id. A `BTreeSet` rather than a `Vec` so + /// membership tests are cheap during a rubber-band and the order a drop + /// applies in is stable across runs. + selection: RefCell>, + /// Where a shift-click extends from. The last cell *clicked*, not the last + /// added — extending from the far end of a previous range is not what the + /// gesture means anywhere else. + anchor: RefCell>, + /// Whether the press that began the current gesture carried ctrl or shift. + /// + /// A modified click is a *selection* gesture and must not also open the + /// image: building a selection would otherwise throw the user into the + /// develop view on the second ctrl-click. Slint does not report modifiers on + /// `clicked`, so the press records them and the click consults this. + modified_press: std::cell::Cell, + /// Collection ids parallel to the sidebar's rows, so a hovered row index + /// resolves to an id without another query. + row_ids: RefCell>, + /// Which rows are saved filters, so a drop onto one is refused *before* the + /// release rather than after. + row_smart: RefCell>, + /// Which rows have children, so the spring knows there is anything to open. + row_has_children: RefCell>, + /// Collapsed collections, by id. Collapse is a view preference and + /// deliberately not persisted to the catalog — it is not something to sync + /// between devices. + collapsed: RefCell>, + /// Which collection scopes the grid. `None` is the whole library. + scope: RefCell>, + /// The collection whose name is being edited in the sidebar, if any. + /// + /// Held here rather than in Slint because a rename can also be *started* + /// from Rust — creating a collection opens its field — and because a + /// commit that the catalog refuses has to leave the field open on the name + /// the user typed rather than silently closing over a rejected edit. + renaming: RefCell>, + /// Whether the grid is showing the trash rather than the library. + /// + /// Separate from `scope` because the trash is not a collection: its contents + /// come from `trashed_at`, not from membership, and every other query in the + /// library *excludes* exactly what this view exists to show. Folding it into + /// `scope` as a sentinel id would put that inversion inside a type that + /// means "a collection". + viewing_trash: std::cell::Cell, + /// The live drag: what it carries. Empty means no drag. + dragging: RefCell>, + /// The collection a drag is currently over, by id. + /// + /// Only the spring needs this — the *drop* is hit-tested by Slint and + /// arrives with its own id, so nothing here has to remember where the + /// pointer was. + hover_id: RefCell>, + /// Spring-loaded expansion: the timer that opens a collapsed parent the + /// pointer has been dwelling on mid-drag. + /// + /// One timer, restarted per row, so moving on cancels the pending + /// expansion rather than leaving a queue of them to fire later. + spring_timer: RefCell>, + /// Collections the spring opened during *this* drag, so they can be closed + /// again if the drag ends elsewhere. Without this, dragging across a deep + /// tree leaves every parent it passed over hanging open. + spring_opened: RefCell>, + /// Images dropped on the trash, recorded by `dropped-on-trash` and acted on + /// in `drag-finished` — the same deferral, for the same reason. + trash_requested: RefCell>>, + /// Drains a trash worker. Held so a second operation replaces the first + /// rather than two timers fighting over the same model. + trash_timer: RefCell>, + /// Which collection a drop landed on, recorded by `dropped` and acted on in + /// `drag-finished`. + /// + /// Deferred because every consequence of a drop replaces a Slint model — + /// the tree, the grid cells — and doing that from inside the `dropped` + /// handler destroys the elements Slint is still using to deliver the event. + dropped_on: RefCell>, +} + +impl CollectionsController { + pub fn new() -> Rc { + Rc::new(Self::default()) + } + + /// Which collection the grid is scoped to, for [`crate::library_ui`] to + /// build its query from. + pub fn scope(&self) -> Option { + *self.scope.borrow() + } + + /// Whether the grid should be showing the trash. + pub fn viewing_trash(&self) -> bool { + self.viewing_trash.get() + } + + /// Whether the gesture in progress began with ctrl or shift held. + /// + /// A modified click selects and nothing more — opening the image as well + /// would eject the user from the grid they are selecting in. + pub fn press_was_modified(&self) -> bool { + self.modified_press.get() + } + + /// Selected image ids, in a stable order. + pub fn selected(&self) -> Vec { + self.selection.borrow().iter().copied().collect() + } + + /// Drop the selection — after a scrub, or when the scope changes. + /// + /// Selection is by id and survives a window change, but a selection the + /// user cannot see is a selection they will act on by accident. Clearing on + /// a deliberate navigation is the safer of the two behaviours. + pub fn clear_selection(&self) { + self.selection.borrow_mut().clear(); + *self.anchor.borrow_mut() = None; + } +} + +/// Apply a press to the selection. +/// +/// Split from the callback so the policy is testable without a window: this is +/// the part a user notices being wrong. +/// +/// - **plain** — replace the selection with this one image +/// - **ctrl** — toggle this image, keeping the rest, and move the anchor here +/// - **shift** — select the range from the anchor to here, *replacing* what was +/// selected; the anchor stays put, so an overshoot is corrected by +/// shift-clicking the right cell rather than starting again +/// - **ctrl+shift** — the same range, *added* to the selection, for picking up a +/// second run without losing the first +/// +/// A plain press on an image that is *already* selected leaves the selection +/// alone. That is what makes dragging a multi-selection possible at all — the +/// press that begins the drag would otherwise collapse the selection to one. +pub fn apply_press( + selection: &mut BTreeSet, + anchor: &mut Option, + ids: &[ImageId], + row: usize, + ctrl: bool, + shift: bool, +) { + let Some(&id) = ids.get(row) else { return }; + + if shift { + let Some(from) = *anchor else { + // No anchor to extend from: behave like a plain click and become + // the anchor, so the *next* shift-click has a range to describe. + selection.clear(); + selection.insert(id); + *anchor = Some(row); + return; + }; + + // The anchor deliberately does **not** move. Shift-clicking again + // re-describes the range from the same origin, so a user who overshoots + // corrects by shift-clicking the right cell rather than starting over. + // That also means the previous range must be cleared first — extending + // without clearing turns a correction into a union, and the user ends up + // dragging cells they thought they had deselected. + // + // Ctrl+shift is the exception: it *adds* a range to what is already + // selected, which is how a second run is picked up without losing the + // first. + if !ctrl { + selection.clear(); + } + + let (lo, hi) = if from <= row { (from, row) } else { (row, from) }; + let hi = hi.min(ids.len().saturating_sub(1)); + for id in &ids[lo..=hi] { + selection.insert(*id); + } + return; + } + + if ctrl { + if !selection.remove(&id) { + selection.insert(id); + } + *anchor = Some(row); + return; + } + + // Plain press on something already selected: leave it. The drag that may + // follow carries the whole selection, and collapsing it here would make a + // multi-image drag impossible to start. + if selection.contains(&id) { + *anchor = Some(row); + return; + } + + selection.clear(); + selection.insert(id); + *anchor = Some(row); +} + +/// Rebuild the sidebar from the catalog. +/// +/// Called after every edit. The whole tree rather than a patch: a rename can +/// reorder siblings, a delete promotes children, and a drop changes counts on +/// every ancestor — diffing that against the model would be more code than the +/// query costs, and the tree is tens of rows, not thousands. +pub fn refresh_tree(window: &AppWindow, ctl: &Rc, catalog: &Catalog) { + let rows = match coll::tree(catalog.connection()) { + Ok(r) => r, + Err(e) => { + window.set_collection_error(format!("reading collections: {e}").into()); + return; + } + }; + + let collapsed = ctl.collapsed.borrow(); + + // A row is hidden when any ancestor is collapsed. The tree arrives + // depth-first, so tracking the shallowest collapsed depth seen is enough — + // no ancestor lookup per row. + let mut hide_below: Option = None; + let mut ids = Vec::new(); + let mut smart = Vec::new(); + let mut has_kids = Vec::new(); + let mut out = Vec::new(); + + for row in &rows { + if let Some(depth) = hide_below { + if row.depth > depth { + continue; + } + hide_below = None; + } + + let id = row.collection.id; + let expanded = !collapsed.contains(&id); + if row.has_children && !expanded { + hide_below = Some(row.depth); + } + + // Deep counts are one query per row. That is fine at sidebar scale and + // wrong at grid scale, which is why the grid does not do this. + let deep = coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count); + + ids.push(id); + smart.push(row.collection.kind == CollectionKind::Smart); + has_kids.push(row.has_children); + out.push(CollectionRow { + id: id.0 as i32, + name: row.collection.name.as_str().into(), + depth: row.depth as i32, + direct_count: row.collection.direct_count as i32, + deep_count: deep as i32, + has_children: row.has_children, + expanded, + smart: row.collection.kind == CollectionKind::Smart, + }); + } + + *ctl.row_ids.borrow_mut() = ids; + *ctl.row_smart.borrow_mut() = smart; + *ctl.row_has_children.borrow_mut() = has_kids; + window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out))); +} + +/// Build the bitmap that travels under the cursor. +/// +/// One image is drawn as itself. Several are **fanned**, back to front with the +/// topmost last, so the cursor carries a visibly thicker stack the more is being +/// dragged — the count is legible from the shape rather than needing a number. +/// +/// Composited here rather than in Slint because `DragArea.drag-image` takes a +/// single bitmap, and Slint cannot render a pile of thumbnails into one. +/// +/// Only the top few are drawn. A forty-image drag would otherwise be forty +/// composites for a stack whose lower layers are hidden by the ones above. +fn compose_drag_image(thumbs: &[slint::Image]) -> slint::Image { + /// Layers drawn, at most. Past this the stack looks no thicker. + const MAX_LAYERS: usize = 4; + /// Pixel step between layers, in the composite's own space. + const FAN: u32 = 10; + /// Long edge of the composed bitmap. + const EDGE: u32 = 160; + + let layers: Vec<&slint::Image> = thumbs.iter().rev().take(MAX_LAYERS).collect(); + let Some(top) = layers.first() else { + return slint::Image::default(); + }; + + // The whole composite is the top image's box plus room for the fan. + let offset = FAN * (layers.len().saturating_sub(1)) as u32; + let size = top.size(); + if size.width == 0 || size.height == 0 { + return slint::Image::default(); + } + // Scale the top thumbnail so its long edge is EDGE, then add the fan. + let scale = EDGE as f32 / size.width.max(size.height) as f32; + let tw = ((size.width as f32 * scale) as u32).max(1); + let th = ((size.height as f32 * scale) as u32).max(1); + + let mut canvas = + slint::SharedPixelBuffer::::new(tw + offset, th + offset); + let cw = canvas.width(); + let stride = cw as usize; + let pixels = canvas.make_mut_slice(); + + // Back to front: `layers` is already reversed, so the last drawn is the + // image the user grabbed and it lands on top. + for (n, layer) in layers.iter().enumerate().rev() { + // The furthest-back layer sits at the largest offset, so the stack fans + // down and right from the top image at (0, 0). + let dx = FAN * n as u32; + let dy = FAN * n as u32; + + // Each layer is fitted to the *top* image's box rather than stretched to + // it: a portrait frame behind a landscape one would otherwise be visibly + // distorted, and the stack stops reading as a pile of photographs. + let s = layer.size(); + let (lw, lh) = if s.width == 0 || s.height == 0 { + (tw, th) + } else { + let fit = (tw as f32 / s.width as f32).min(th as f32 / s.height as f32); + ( + ((s.width as f32 * fit) as u32).max(1), + ((s.height as f32 * fit) as u32).max(1), + ) + }; + // Centred in the slot, so a narrower frame is not pinned to one edge. + let cx = dx + (tw - lw.min(tw)) / 2; + let cy = dy + (th - lh.min(th)) / 2; + + blit_scaled(layer, pixels, stride, cx, cy, lw, lh, n > 0); + } + + slint::Image::from_rgba8_premultiplied(canvas) +} + +/// Draw one thumbnail into the composite, scaled to `tw`×`th` at `dx`,`dy`. +/// +/// Nearest-neighbour: this is a transient 160px cursor bitmap, and a filtered +/// resample would cost more than it could visibly buy. `dim` darkens the layers +/// beneath the top one so the stack reads as depth rather than as a smear. +/// +/// The buffer is premultiplied, so the alpha applied here is baked into the +/// colour channels as well. +#[allow(clippy::too_many_arguments)] +fn blit_scaled( + src: &slint::Image, + dst: &mut [slint::Rgba8Pixel], + stride: usize, + dx: u32, + dy: u32, + tw: u32, + th: u32, + dim: bool, +) { + let Some(buf) = src.to_rgba8() else { return }; + let (sw, sh) = (buf.width(), buf.height()); + if sw == 0 || sh == 0 { + return; + } + let src_px = buf.as_slice(); + + for y in 0..th { + let sy = (y * sh / th).min(sh - 1); + for x in 0..tw { + let sx = (x * sw / tw).min(sw - 1); + let s = src_px[(sy * sw + sx) as usize]; + + // Clipped per pixel on both axes. A row-major index alone would let + // an overhanging right edge wrap onto the next line, which draws as + // a smear rather than as an out-of-bounds panic. + let (px, py) = (dx + x, dy + y); + if px as usize >= stride { + continue; + } + let out = py as usize * stride + px as usize; + if out >= dst.len() { + continue; + } + // Layers below the top are darkened, not made transparent: the + // composite sits over whatever is on screen, and translucency there + // would show the desktop through the stack. + let f = if dim { 0.55 } else { 1.0 }; + dst[out] = slint::Rgba8Pixel { + r: (s.r as f32 * f) as u8, + g: (s.g as f32 * f) as u8, + b: (s.b as f32 * f) as u8, + a: s.a, + }; + } + } +} + +/// Mark which cells are currently lifted out by a drag. +/// +/// Separate from [`sync_selection`] because the two differ: the selection +/// persists after the drag, the lift lasts only while it is in flight. +pub fn sync_lifted(window: &AppWindow, lifted: &[ImageId], ids: &[ImageId]) { + let model = window.get_library_cells(); + for (row, id) in ids.iter().enumerate() { + let want = lifted.contains(id); + if let Some(mut cell) = model.row_data(row) { + if cell.lifted != want { + cell.lifted = want; + model.set_row_data(row, cell); + } + } + } +} + +/// Push the current selection into the grid model's `selected` flags. +/// +/// The model carries the flag per cell so Slint can style without a lookup; +/// this is what keeps the two in step after a scrub, a drop, or a click. +pub fn sync_selection(window: &AppWindow, ctl: &Rc, ids: &[ImageId]) { + let selection = ctl.selection.borrow(); + let model = window.get_library_cells(); + + for (row, id) in ids.iter().enumerate() { + let want = selection.contains(id); + if let Some(mut cell) = model.row_data(row) { + if cell.selected != want { + cell.selected = want; + model.set_row_data(row, cell); + } + } + } + + window.set_library_selected_count(selection.len() as i32); +} + +/// Refresh the per-cell "in this many collections" badges. +/// +/// One query for the whole window rather than one per cell: 120 cells is 120 +/// round trips otherwise, on every drop. +pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) { + if ids.is_empty() { + return; + } + + let placeholders = std::iter::repeat_n("?", ids.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT m.image_id, count(*) + FROM collection_members m + JOIN collections c ON c.id = m.collection_id + WHERE m.image_id IN ({placeholders}) AND c.deleted = 0 + GROUP BY m.image_id" + ); + + let params: Vec = ids + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) + .collect(); + + let mut counts = std::collections::HashMap::new(); + if let Ok(mut stmt) = catalog.connection().prepare(&sql) { + if let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)) + }) { + for (id, n) in rows.flatten() { + counts.insert(id, n as i32); + } + } + } + + let model = window.get_library_cells(); + for (row, id) in ids.iter().enumerate() { + let want = counts.get(&(id.0 as i64)).copied().unwrap_or(0); + if let Some(mut cell) = model.row_data(row) { + if cell.collection_count != want { + cell.collection_count = want; + model.set_row_data(row, cell); + } + } + } +} + +/// How long the pointer must dwell on a collapsed parent before it springs +/// open, mid-drag. +/// +/// Long enough that crossing a parent on the way somewhere else does not open +/// it — a tree that flaps open under every passing pointer is worse than one +/// that never opens. Short enough to feel like a response rather than a wait; +/// this is the range file managers have settled on for the same gesture. +const SPRING_DELAY_MS: u64 = 500; + +/// Whether hovering this row should schedule a spring expansion. +/// +/// Pure so the rule is testable: only a *collapsed parent* has anything to +/// open. A leaf would flash a pointless rebuild, and one already expanded is +/// where the user can already see the children. +fn should_spring( + row: Option, + row_ids: &[CollectionId], + row_has_children: &[bool], + collapsed: &std::collections::HashSet, +) -> Option { + let row = row?; + let &id = row_ids.get(row)?; + let has_children = row_has_children.get(row).copied().unwrap_or(false); + (has_children && collapsed.contains(&id)).then_some(id) +} + +/// Start (or restart) the dwell timer that opens a collapsed collection. +/// +/// Called on every hover change during a drag. Restarting on each change is +/// what makes the dwell a dwell: moving to another row cancels the pending +/// expansion instead of queueing a second one. +fn arm_spring( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + row: Option, +) { + // Dropping the old timer cancels it. Anything already scheduled for the row + // the pointer has just left must not fire. + *ctl.spring_timer.borrow_mut() = None; + + let Some(row) = row else { return }; + + // Only a collapsed parent has anything to spring. A leaf, or one already + // open, is left alone rather than being pointlessly "expanded". + let target = should_spring( + Some(row), + &ctl.row_ids.borrow(), + &ctl.row_has_children.borrow(), + &ctl.collapsed.borrow(), + ); + let Some(id) = target else { return }; + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + let catalog = catalog.clone(); + + timer.start( + slint::TimerMode::SingleShot, + std::time::Duration::from_millis(SPRING_DELAY_MS), + move || { + let Some(w) = weak.upgrade() else { return }; + // The drag may have ended, or moved on, during the dwell. + // Expanding then would rearrange the sidebar for no reason the user + // can connect to what they did. `dragging` being non-empty *is* the + // "a drag is live" test — Slint owns the gesture now, so there is no + // window flag to consult. + if ctl_cb.dragging.borrow().is_empty() || *ctl_cb.hover_id.borrow() != Some(id) { + return; + } + + ctl_cb.collapsed.borrow_mut().remove(&id); + // Remembered so it can be closed again if the drag ends elsewhere. + ctl_cb.spring_opened.borrow_mut().push(id); + + let borrow = catalog.borrow(); + if let Some(cat) = borrow.as_ref() { + // The rebuild inserts the children below this row. The pointer + // is still over this same collection, and its own `DropArea` + // re-establishes the highlight — there is no index to re-point, + // which is the second thing the native drag API removed. + refresh_tree(&w, &ctl_cb, cat); + } + }, + ); + + *ctl.spring_timer.borrow_mut() = Some(timer); +} + +/// Close whatever the spring opened during a drag that did not land in it. +/// +/// A collection the user dropped into stays open — they are working in it. One +/// merely passed over is put back, so a drag across a deep tree does not leave +/// it unfolded. +fn collapse_spring_opened( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + keep: Option, +) { + *ctl.spring_timer.borrow_mut() = None; + let opened = std::mem::take(&mut *ctl.spring_opened.borrow_mut()); + if opened.is_empty() { + return; + } + + { + let mut collapsed = ctl.collapsed.borrow_mut(); + for id in opened { + // The collection dropped into stays open, and so does every + // ancestor of it — closing a parent would hide the very row that + // just received the images. + let keep_this = keep.is_some_and(|k| { + k == id + || catalog + .borrow() + .as_ref() + .and_then(|cat| coll::descendants(cat.connection(), id).ok()) + .is_some_and(|d| d.contains(&k)) + }); + if !keep_this { + collapsed.insert(id); + } + } + } + + let borrow = catalog.borrow(); + if let Some(cat) = borrow.as_ref() { + refresh_tree(window, ctl, cat); + } +} + +/// Begin a soft delete: plan the moves, then hand them to a worker. +/// +/// The plan is built here because it reads the catalog, which is not `Send`; the +/// worker gets paths and ids and needs no catalog to do its half. +#[allow(clippy::too_many_arguments)] +fn start_trash( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + session: &Rc Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)>>, + images: &[ImageId], + reload: &Rc, +) { + let Some((creds, sess)) = session() else { + window.set_collection_error("Open a library first.".into()); + return; + }; + + let moves = { + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + match crate::trash::plan_trash(cat, &sess.root, images) { + Ok(m) => m, + Err(e) => { + window.set_collection_error(format!("planning delete: {e}").into()); + return; + } + } + }; + + if moves.is_empty() { + return; + } + + log::info!("moving {} image(s) to the trash", moves.len()); + window.set_library_status(format!("Moving {} to the trash…", moves.len()).into()); + // The images are leaving the grid; a selection pointing at them would + // survive as a set of ids the user can no longer see. + ctl.clear_selection(); + + let rx = crate::trash::spawn_move( + creds, + sess.user_id.clone(), + moves, + crate::trash::Direction::ToTrash, + crate::library::catalog_path(&sess.server, &sess.user_id), + ); + + drain_trash( + window.as_weak(), + ctl.clone(), + catalog.clone(), + rx, + reload.clone(), + ); +} + +/// TRACES: FR-CAT-15 +/// Put trashed images back where they came from. +/// +/// The mirror of [`start_trash`], and separate from it rather than a `direction` +/// parameter on one function: the two differ in what they plan, what they report +/// and what they say when the plan comes back empty, and the shared part is the +/// three lines that spawn the worker. +/// +/// An image whose origin was never recorded is skipped by +/// [`crate::trash::plan_restore`] rather than guessed at. That can make the plan +/// shorter than the selection, which is why an empty plan is reported here +/// instead of returning silently — the user pressed a button and is owed an +/// answer either way. +fn start_restore( + window: &AppWindow, + ctl: &Rc, + catalog: &Rc>>, + session: &Rc Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)>>, + images: &[ImageId], + reload: &Rc, +) { + let Some((creds, sess)) = session() else { + window.set_collection_error("Open a library first.".into()); + return; + }; + + let moves = { + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + match crate::trash::plan_restore(cat, images) { + Ok(m) => m, + Err(e) => { + window.set_collection_error(format!("planning restore: {e}").into()); + return; + } + } + }; + + if moves.is_empty() { + // Said out loud rather than passed over in silence: a button that does + // nothing visible reads as broken, and the reason here is specific. + window.set_collection_error( + "Nothing to restore — no record of where these came from.".into(), + ); + return; + } + + log::info!("restoring {} image(s) from the trash", moves.len()); + window.set_library_status(format!("Restoring {}…", moves.len()).into()); + // The images are leaving the trash view, so a selection pointing at them + // would survive as ids the user can no longer see. + ctl.clear_selection(); + + let rx = crate::trash::spawn_move( + creds, + sess.user_id.clone(), + moves, + crate::trash::Direction::Restore, + crate::library::catalog_path(&sess.server, &sess.user_id), + ); + + drain_trash( + window.as_weak(), + ctl.clone(), + catalog.clone(), + rx, + reload.clone(), + ); +} + +/// Drain a trash worker on the UI thread. +/// +/// Same shape as the scan and thumbnail drains: an mpsc channel polled by a +/// Slint timer, so nothing blocks the event loop (NFR-P9). +fn drain_trash( + weak: slint::Weak, + ctl: Rc, + catalog: Rc>>, + rx: std::sync::mpsc::Receiver, + reload: Rc, +) { + use crate::trash::TrashMessage; + + let timer = slint::Timer::default(); + let ctl_cb = ctl.clone(); + + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(120), + move || { + let Some(w) = weak.upgrade() else { return }; + + loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + // A worker that died without reporting must not leave the + // status line mid-sentence. + stop_trash(&ctl_cb); + return; + } + }; + + match msg { + TrashMessage::Progress { done, total, failed } => { + let status = if failed > 0 { + format!("{done} / {total} · {failed} failed") + } else { + format!("{done} / {total}") + }; + w.set_library_status(status.into()); + } + TrashMessage::Done { moved, failed } => { + // Reported honestly, including the partial case: "38 of + // 40" is the truth when two files could not be moved, + // and claiming 40 would hide a real problem. + let status = if failed.is_empty() { + format!("{moved} image(s) done") + } else { + format!("{moved} done · {} failed", failed.len()) + }; + w.set_library_status(status.into()); + if let Some(first) = failed.first() { + w.set_collection_error(first.as_str().into()); + } + + let borrow = catalog.borrow(); + if let Some(cat) = borrow.as_ref() { + refresh_trash(&w, cat); + refresh_tree(&w, &ctl_cb, cat); + } + drop(borrow); + + // The grid changed: images left the library, or came + // back into it. + reload(); + stop_trash(&ctl_cb); + return; + } + } + } + }, + ); + + *ctl.trash_timer.borrow_mut() = Some(timer); +} + +fn stop_trash(ctl: &Rc) { + if let Some(t) = ctl.trash_timer.borrow().as_ref() { + t.stop(); + } +} + +/// Refresh the sidebar's trash count and size. +/// +/// The size is formatted here rather than in Slint, which has no byte-size +/// formatting — and the number is what tells the user whether emptying is worth +/// it. +pub fn refresh_trash(window: &AppWindow, catalog: &Catalog) { + let (n, bytes) = dr_catalog::trash::summary(catalog.connection()).unwrap_or((0, 0)); + window.set_trash_count(n as i32); + window.set_trash_label(if n == 0 { + slint::SharedString::new() + } else { + format!("{n} · {}", format_bytes(bytes)).into() + }); +} + +/// Bytes as a human-readable size. +/// +/// Binary units, one decimal place past a kilobyte: a RAW library is measured in +/// gigabytes and "3.4 GB" is the figure a photographer reasons about, where +/// 3_650_722_201 is not. +fn format_bytes(bytes: u64) -> String { + const KB: f64 = 1024.0; + let b = bytes as f64; + if bytes < 1024 { + return format!("{bytes} B"); + } + for (limit, unit) in [ + (KB * KB, "kB"), + (KB * KB * KB, "MB"), + (KB * KB * KB * KB, "GB"), + ] { + if b < limit { + return format!("{:.1} {unit}", b / (limit / KB)); + } + } + format!("{:.1} TB", b / (KB * KB * KB * KB)) +} + +/// Connect the sidebar and drag callbacks. +/// +/// `on_scope_changed` reloads the grid — that lives in [`crate::library_ui`], +/// which owns the window and the thumbnail workers, so it is passed in rather +/// than reached for. +pub fn wire( + window: &AppWindow, + ctl: Rc, + catalog: Rc>>, + on_scope_changed: S, + visible_ids: R, + session: C, +) where + S: Fn() + 'static, + R: Fn() -> Vec + 'static, + C: Fn() -> Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)> + 'static, +{ + // Coerced to trait objects here rather than at each use: `start_trash` and + // `drain_trash` are shared by three callbacks, and a generic parameter would + // make each of them a separate instantiation for no gain. + let on_scope_changed: Rc = Rc::new(on_scope_changed); + let visible_ids = Rc::new(visible_ids); + let session: Rc< + dyn Fn() -> Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)>, + > = Rc::new(session); + + // --- selection --------------------------------------------------------- + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let visible = visible_ids.clone(); + window.on_library_cell_pressed(move |row, ctrl_held, shift_held| { + let Some(w) = weak.upgrade() else { return }; + let ids = visible(); + + // Consulted by the click that follows: a modified press is building + // a selection and must not also navigate to develop. + ctl.modified_press.set(ctrl_held || shift_held); + + apply_press( + &mut ctl.selection.borrow_mut(), + &mut ctl.anchor.borrow_mut(), + &ids, + row as usize, + ctrl_held, + shift_held, + ); + sync_selection(&w, &ctl, &ids); + }); + } + + // --- drag -------------------------------------------------------------- + // + // Slint owns the gesture (see the preamble). What is left here is the + // payload — the image ids the drop will act on — and the spring. + + // The payload is built when the drag starts, so it is the selection as it + // stands at that moment rather than whatever it becomes mid-flight. + { + let ctl = ctl.clone(); + window.on_library_drag_payload(move || { + let carried = ctl.dragging.borrow().clone(); + let mut data = slint::DataTransfer::default(); + // `user_data` rather than plain text: these are catalog ids for our + // own drop handler, not something another application should be + // able to interpret as a paste. + data.set_user_data(Rc::new(carried)); + data + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let visible = visible_ids.clone(); + window.on_library_drag_started(move |row| { + let Some(w) = weak.upgrade() else { return }; + let ids = visible(); + + // Dragging an *unselected* cell carries only that one, and makes it + // the selection — otherwise the images that travel are not the ones + // the user grabbed. Dragging a selected cell carries the whole + // selection, which is the multi-image gesture. + let carried: Vec = { + let mut selection = ctl.selection.borrow_mut(); + match ids.get(row as usize) { + Some(id) if !selection.contains(id) => { + selection.clear(); + selection.insert(*id); + vec![*id] + } + _ => selection.iter().copied().collect(), + } + }; + + // The bitmap under the cursor, built from the thumbnails already in + // the model — the drag carries what the user can see, and a cell + // whose preview has not landed yet contributes nothing rather than + // a placeholder. + let thumbs: Vec = { + let model = w.get_library_cells(); + carried + .iter() + .filter_map(|id| ids.iter().position(|v| v == id)) + .filter_map(|row| model.row_data(row)) + .filter(|c| c.has_thumb) + .map(|c| c.thumbnail) + .collect() + }; + w.set_library_drag_image(compose_drag_image(&thumbs)); + + *ctl.dragging.borrow_mut() = carried.clone(); + sync_selection(&w, &ctl, &ids); + // The lift-out: these cells fade and shrink in place, so the grid + // shows where the photographs came from while the cursor shows them + // in full colour. + sync_lifted(&w, &carried, &ids); + }); + } + + // A drag dwelling over a collapsed collection springs it open, so a nested + // child can be reached without putting the images down first. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog_for_spring = catalog.clone(); + window.on_collection_drag_over(move |id, over| { + let Some(w) = weak.upgrade() else { return }; + let id = CollectionId(id as u64); + + if !over { + // Left this row. Cancel its pending expansion rather than + // letting it fire over whatever the pointer moved on to. + if *ctl.hover_id.borrow() == Some(id) { + *ctl.hover_id.borrow_mut() = None; + *ctl.spring_timer.borrow_mut() = None; + } + return; + } + + *ctl.hover_id.borrow_mut() = Some(id); + let row = ctl.row_ids.borrow().iter().position(|&c| c == id); + arm_spring(&w, &ctl, &catalog_for_spring, row); + }); + } + + // A drop. Slint hit-tested the release and `can-drop` already refused a + // saved filter, so reaching here means this collection accepted. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + // No model refresh or reload here on purpose — see the note at the end + // of this handler. `drag-finished` owns those. + window.on_collection_dropped(move |id| { + let Some(w) = weak.upgrade() else { return }; + let id = CollectionId(id as u64); + let carried = ctl.dragging.borrow().clone(); + if carried.is_empty() { + return; + } + + // Scoped so the borrow is released before the spring cleanup below, + // which needs the catalog itself. + let result = { + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + coll::add_images(cat.connection(), id, &carried) + }; + + match result { + Ok(added) => { + w.set_collection_error(slint::SharedString::new()); + // "Added 3 of 12" is the honest report when nine were + // already there; claiming 12 would teach the user to + // distrust the count. + let msg = if added == carried.len() { + format!("Added {added} to collection") + } else { + format!( + "Added {added} of {} — the rest were already there", + carried.len() + ) + }; + w.set_library_status(msg.into()); + } + Err(e) => w.set_collection_error(format!("adding to collection: {e}").into()), + } + + // Recorded, not acted on. Every visible consequence — rebuilding + // the tree, refreshing the badges, rereading the grid — happens in + // `drag-finished`, because all three replace models that Slint is + // *currently walking* to deliver this very event. Tearing down a + // live `DropArea` from inside its own `dropped` handler is the same + // hazard `sync_rows` in lib.rs documents for the adjust panel. + *ctl.dropped_on.borrow_mut() = Some(id); + }); + } + + // The drag ended: dropped, or abandoned. This is where the consequences of + // a drop land, once Slint has finished with the elements involved. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let visible = visible_ids.clone(); + let reload = on_scope_changed.clone(); + let session = session.clone(); + window.on_library_drag_finished(move || { + let Some(w) = weak.upgrade() else { return }; + + let landed = ctl.dropped_on.borrow_mut().take(); + let to_trash = ctl.trash_requested.borrow_mut().take(); + ctl.dragging.borrow_mut().clear(); + *ctl.hover_id.borrow_mut() = None; + *ctl.spring_timer.borrow_mut() = None; + + // A soft delete, deferred out of the drop handler so the models it + // replaces are no longer being walked. + if let Some(images) = to_trash { + start_trash(&w, &ctl, &catalog, &session, &images, &reload); + } + + // The cells settle back into the grid, and the cursor bitmap is + // released — it holds a copy of every thumbnail it composited. + sync_lifted(&w, &[], &visible()); + w.set_library_drag_image(slint::Image::default()); + + if landed.is_some() { + let borrow = catalog.borrow(); + if let Some(cat) = borrow.as_ref() { + // Counts changed on the target and every ancestor, and the + // dropped images now carry one more collection badge. + refresh_tree(&w, &ctl, cat); + sync_badges(&w, cat, &visible()); + } + } + + // Put back whatever the spring opened on the way. The collection + // that received the images — and its ancestors — stay open, since + // that is where the user is now working; on an abandoned drag + // `landed` is `None` and everything closes. + collapse_spring_opened(&w, &ctl, &catalog, landed); + + // A drop into the collection currently being shown changes what that + // collection holds, so the grid has to be reread. + if landed.is_some() && landed == *ctl.scope.borrow() { + reload(); + } + }); + } + + // --- trash ------------------------------------------------------------- + // + // TRACES: FR-CAT-15 + // A drop here is a *soft delete*: the file moves to a trash folder on the + // server and the catalog records where it came from. Nothing is destroyed + // until the user empties it, which is a separate, deliberate action. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let session = session.clone(); + window.on_trash_dropped(move || { + let Some(w) = weak.upgrade() else { return }; + let carried = ctl.dragging.borrow().clone(); + if carried.is_empty() { + return; + } + // Recorded like a collection drop, and acted on in `drag-finished` + // for the same reason: the work replaces Slint models that are + // still being walked to deliver this event. + *ctl.trash_requested.borrow_mut() = Some(carried); + let _ = session; + w.set_collection_error(slint::SharedString::new()); + }); + } + + // Restore. Only reachable while the trash is being looked at, and it acts + // on the selection rather than on everything — the trash is where a user + // goes to recover *one* mistake, not usually to undo the lot. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let session = session.clone(); + let reload = on_scope_changed.clone(); + window.on_trash_restore(move || { + let Some(w) = weak.upgrade() else { return }; + let chosen = ctl.selected(); + if chosen.is_empty() { + return; + } + start_restore(&w, &ctl, &catalog, &session, &chosen, &reload); + }); + } + + // --- trash from the grid ---------------------------------------------- + // + // Until now the only route to the trash was dragging onto the sidebar row. + // These two are the direct gestures: the trash target on a cell's rating + // strip, and the `Delete` key. + // + // Both land here rather than in `library_ui` because everything the + // operation needs — the selection, the session closure, `start_trash` and + // its drain — already lives in this module. Reaching them from the grid + // side would mean either duplicating the worker plumbing or moving it, and + // trash is one feature whichever component happens to trigger it. + + // The trash glyph on one cell. Acts on that photograph alone: the pointer + // named it, and a click that silently trashed an entire selection would be + // exactly the trap the strip's other targets are laid out to avoid. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let session = session.clone(); + let reload = on_scope_changed.clone(); + let visible = visible_ids.clone(); + window.on_library_cell_trashed(move |row| { + let Some(w) = weak.upgrade() else { return }; + // Resolved through the visible ids rather than the row index alone: + // the grid is a window over the catalog, so a stale index from + // before a scroll would name a different photograph — and here that + // would move the wrong file. + let Some(&id) = visible().get(row as usize) else { + return; + }; + start_trash(&w, &ctl, &catalog, &session, &[id], &reload); + }); + } + + // `Delete` on the selection — the bulk gesture. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let session = session.clone(); + let reload = on_scope_changed.clone(); + window.on_library_trash_selection(move || { + let Some(w) = weak.upgrade() else { return }; + let chosen = ctl.selected(); + if chosen.is_empty() { + // A keystroke that does nothing reads as a broken key, so it + // says why rather than failing silently. + w.set_library_status("Select an image first".into()); + return; + } + start_trash(&w, &ctl, &catalog, &session, &chosen, &reload); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let session = session.clone(); + let reload = on_scope_changed.clone(); + window.on_trash_empty(move || { + let Some(w) = weak.upgrade() else { return }; + + let (creds, sess) = match session() { + Some(s) => s, + None => return, + }; + + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + + // Everything in the trash, with the path and stable id each delete + // needs. Read here rather than in the worker: the catalog is not + // `Send`, and the worker opens its own connection only to write back. + let listed = match dr_catalog::trash::list(cat.connection(), usize::MAX) { + Ok(l) => l, + Err(e) => { + w.set_collection_error(format!("reading trash: {e}").into()); + return; + } + }; + if listed.is_empty() { + return; + } + + let paths: Vec<(ImageId, Option, String)> = listed + .iter() + .map(|t| (t.image_id, t.file_id, t.source_ref.clone())) + .collect(); + let ids: Vec = listed.iter().map(|t| t.image_id).collect(); + + log::info!("emptying trash: {} image(s)", ids.len()); + w.set_library_status(format!("Deleting {} image(s)…", ids.len()).into()); + + let rx = crate::trash::spawn_purge( + creds, + sess.user_id.clone(), + ids, + paths, + crate::library::catalog_path(&sess.server, &sess.user_id), + crate::library::thumbs_dir(&sess.server, &sess.user_id), + ); + + drain_trash(w.as_weak(), ctl.clone(), catalog.clone(), rx, reload.clone()); + }); + } + + // --- tree navigation --------------------------------------------------- + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let reload = on_scope_changed.clone(); + window.on_collection_select(move |id| { + let Some(w) = weak.upgrade() else { return }; + + // -1 is the trash. It is not a collection, so it clears `scope` + // rather than setting it — see `viewing_trash`. + ctl.viewing_trash.set(id == -1); + *ctl.scope.borrow_mut() = if id <= 0 { + None + } else { + Some(CollectionId(id as u64)) + }; + + // A selection the user cannot see is one they will act on by + // accident, and the new scope shows different images. + ctl.clear_selection(); + + let label = if id == -1 { + "Trash".to_string() + } else if id == 0 { + String::new() + } else { + let rows = w.get_collection_rows(); + (0..rows.row_count()) + .filter_map(|i| rows.row_data(i)) + .find(|r| r.id == id) + .map(|r| r.name.to_string()) + .unwrap_or_default() + }; + w.set_collection_selected(id); + w.set_collection_scope_label(label.into()); + reload(); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + window.on_collection_toggle(move |id| { + let Some(w) = weak.upgrade() else { return }; + let id = CollectionId(id as u64); + { + let mut collapsed = ctl.collapsed.borrow_mut(); + if !collapsed.remove(&id) { + collapsed.insert(id); + } + } + let borrow = catalog.borrow(); + if let Some(cat) = borrow.as_ref() { + refresh_tree(&w, &ctl, cat); + } + }); + } + + // --- create ------------------------------------------------------------ + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + window.on_collection_new(move || { + let Some(w) = weak.upgrade() else { return }; + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { + w.set_collection_error("Open a library first.".into()); + return; + }; + + // Created inside whatever is selected, which is how a hierarchy + // gets built without a separate "new child" command: select the + // parent, press +. + let parent = *ctl.scope.borrow(); + let name = unique_name(cat.connection(), parent); + + match coll::create(cat.connection(), &name, parent, CollectionKind::Manual) { + Ok(id) => { + w.set_collection_error(slint::SharedString::new()); + // A new child is useless if its parent is collapsed. + if let Some(p) = parent { + ctl.collapsed.borrow_mut().remove(&p); + } + // Straight into the name field, with "New collection" + // selected. The name is a placeholder nobody wants to + // keep, so making the user find the rename gesture + // afterwards is asking them to finish a job we started. + // + // Opened *after* the rebuild, and the order is load-bearing: + // `refresh_tree` replaces the row model, which destroys and + // recreates every row. A field opened before it would be + // torn down along with the `init` that focuses it, leaving + // an edit box nothing had typed into. Setting the property + // afterwards puts the field on a row that already exists. + refresh_tree(&w, &ctl, cat); + *ctl.renaming.borrow_mut() = Some(id); + w.set_collection_renaming(id.0 as i32); + log::info!("created collection {} ({name})", id.0); + } + Err(e) => w.set_collection_error(format!("creating collection: {e}").into()), + } + }); + } + + // --- rename ------------------------------------------------------------ + // + // Inline in the row, opened by a double-click or `F2`. The gesture is worth + // the field rather than a dialog: renaming is how a hierarchy gets tidied, + // and it is done in runs of several — a modal per collection would make + // that a chore. + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_collection_rename_start(move |id| { + let Some(w) = weak.upgrade() else { return }; + // `F2` arrives with whatever the sidebar has selected, which may be + // "All photographs" (0) or the trash (-1). Neither has a name to + // change, so the key does nothing rather than opening a field on a + // row that is not a collection. + if id <= 0 { + return; + } + let id = CollectionId(id as u64); + + // A saved filter is renameable like any other — its *membership* is + // computed, its name is not — so there is no kind check here. + *ctl.renaming.borrow_mut() = Some(id); + w.set_collection_renaming(id.0 as i32); + w.set_collection_error(slint::SharedString::new()); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + window.on_collection_rename_commit(move |id, name| { + let Some(w) = weak.upgrade() else { return }; + let id = CollectionId(id as u64); + + // The field reports a commit when it loses focus as well as on + // Enter, so a second one can arrive for a rename already closed — + // Enter commits, and the focus the field then gives up commits + // again. Ignored rather than reapplied: the second would bump the + // revision for no change and beat a real edit on another device. + if *ctl.renaming.borrow() != Some(id) { + return; + } + + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + + match apply_rename(cat.connection(), id, name.as_str()) { + Ok(Rename::Applied(name)) => { + close_rename(&w, &ctl); + w.set_collection_error(slint::SharedString::new()); + refresh_tree(&w, &ctl, cat); + // The header names the collection being shown, so a rename + // of the current scope has to reach it too. + if *ctl.scope.borrow() == Some(id) { + w.set_collection_scope_label(name.as_str().into()); + } + log::info!("renamed collection {} to {name}", id.0); + } + Ok(Rename::Unchanged) => close_rename(&w, &ctl), + Err(e) => { + // The field stays open on what the user typed. Closing it + // would drop their text and leave the old name showing, + // with only a line of red to explain where it went. + w.set_collection_error(format!("renaming: {e}").into()); + } + } + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_collection_rename_cancel(move || { + let Some(w) = weak.upgrade() else { return }; + close_rename(&w, &ctl); + w.set_collection_error(slint::SharedString::new()); + }); + } + + // --- remove from the collection being shown --------------------------- + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let visible = visible_ids.clone(); + let reload = on_scope_changed.clone(); + window.on_library_remove_from_collection(move || { + let Some(w) = weak.upgrade() else { return }; + let Some(scope) = *ctl.scope.borrow() else { + // Unscoped, there is no collection to remove from. The button + // is hidden in that state; this guards the callback anyway. + return; + }; + let chosen = ctl.selected(); + if chosen.is_empty() { + return; + } + + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + + match coll::remove_images(cat.connection(), scope, &chosen) { + Ok(n) => { + w.set_collection_error(slint::SharedString::new()); + // Named explicitly as a membership change: the images are + // still in the library, and a user who reads this as a + // delete will not trust the feature again. + w.set_library_status( + format!("Removed {n} from this collection; still in the library").into(), + ); + ctl.clear_selection(); + refresh_tree(&w, &ctl, cat); + sync_badges(&w, cat, &visible()); + reload(); + } + Err(e) => w.set_collection_error(format!("removing: {e}").into()), + } + }); + } + + // Right-click. A real context menu needs a popup with keyboard handling and + // a rename field; until that exists the gesture deletes an *empty* + // collection, which is the one destructive action safe without a + // confirmation dialog, and says why when it declines. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let reload = on_scope_changed.clone(); + window.on_collection_menu(move |id| { + let Some(w) = weak.upgrade() else { return }; + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + let id = CollectionId(id as u64); + + let holds = coll::deep_count(cat.connection(), id).unwrap_or(1); + if holds > 0 { + w.set_collection_error( + format!("{holds} photograph(s) in there — empty it first.").into(), + ); + return; + } + + match coll::delete(cat.connection(), id) { + Ok(()) => { + w.set_collection_error(slint::SharedString::new()); + if *ctl.scope.borrow() == Some(id) { + *ctl.scope.borrow_mut() = None; + w.set_collection_selected(0); + w.set_collection_scope_label(slint::SharedString::new()); + reload(); + } + refresh_tree(&w, &ctl, cat); + } + Err(e) => w.set_collection_error(format!("deleting: {e}").into()), + } + }); + } +} + +/// Close the rename field, whatever the outcome. +/// +/// Both halves together, always: the controller's copy is what a stray second +/// commit is tested against, and the window property is what draws the field. +/// Clearing one without the other either leaves a field open that nothing will +/// close, or closes one that Rust still believes is open. +fn close_rename(window: &AppWindow, ctl: &Rc) { + *ctl.renaming.borrow_mut() = None; + window.set_collection_renaming(0); +} + +/// What a committed rename did. +#[derive(Debug, PartialEq, Eq)] +enum Rename { + /// Written, with the name as stored — trimmed. + Applied(String), + /// The name was the one it already had, so nothing was written. + /// + /// Distinguished from `Applied` because every write bumps the revision, and + /// a rename to the same name would let a device that changed nothing win a + /// merge against one that did real work. + Unchanged, +} + +/// Validate a typed name and store it. +/// +/// Split out so the rules are testable without a window — they are the part a +/// user runs into: +/// +/// - **blank is refused.** A nameless row is unclickable and unfindable, and +/// the schema is happy to store one. +/// - **a sibling's name is refused.** Two identically-named collections in one +/// parent are indistinguishable in the sidebar, which is how images end up in +/// the wrong one. The same reasoning as [`unique_name`], enforced here rather +/// than silently suffixing: the user typed a specific name and quietly +/// storing a different one is worse than saying no. +/// +/// Case-insensitive against siblings, because the sidebar sorts that way and +/// "Iceland" beside "iceland" is the same trap as an exact duplicate. +fn apply_rename( + conn: &rusqlite::Connection, + id: CollectionId, + typed: &str, +) -> Result { + let name = typed.trim(); + if name.is_empty() { + return Err(dr_catalog::CatalogError::BadName( + "a collection needs a name".into(), + )); + } + + // The current name, which also proves the collection is still there. + let current: String = conn.query_row( + "SELECT name FROM collections WHERE id = ?1 AND deleted = 0", + [id.0 as i64], + |r| r.get(0), + )?; + if current == name { + return Ok(Rename::Unchanged); + } + + // Siblings, excluding this collection: a rename that only changes case is a + // real rename, and must not be refused as a clash with itself. + let clash: Option = conn + .query_row( + "SELECT 1 FROM collections + WHERE deleted = 0 + AND id != ?1 + AND name = ?2 COLLATE NOCASE + AND parent_id IS (SELECT parent_id FROM collections WHERE id = ?1)", + rusqlite::params![id.0 as i64, name], + |r| r.get(0), + ) + .optional()?; + if clash.is_some() { + return Err(dr_catalog::CatalogError::BadName(format!( + "there is already a “{name}” here" + ))); + } + + coll::rename(conn, id, name)?; + Ok(Rename::Applied(name.to_string())) +} + +/// A name no sibling is already using. +/// +/// Duplicate names are legal in the schema, and two identically-named +/// collections in one parent are indistinguishable in the sidebar — which is +/// how images end up in the wrong one. +fn unique_name(conn: &rusqlite::Connection, parent: Option) -> String { + let taken: Vec = { + let sql = match parent { + Some(_) => "SELECT name FROM collections WHERE deleted = 0 AND parent_id = ?1", + None => "SELECT name FROM collections WHERE deleted = 0 AND parent_id IS NULL", + }; + let Ok(mut stmt) = conn.prepare(sql) else { + return "New collection".into(); + }; + // Collected inside each arm: the two `query_map` calls bind different + // parameter types, so their iterators are different types and cannot + // be the arms of one `match`. + match parent { + Some(p) => stmt + .query_map([p.0 as i64], |r| r.get::<_, String>(0)) + .map(|rows| rows.flatten().collect()) + .unwrap_or_default(), + None => stmt + .query_map([], |r| r.get::<_, String>(0)) + .map(|rows| rows.flatten().collect()) + .unwrap_or_default(), + } + }; + + let base = "New collection"; + if !taken.iter().any(|t| t == base) { + return base.into(); + } + for n in 2..1000 { + let candidate = format!("{base} {n}"); + if !taken.iter().any(|t| *t == candidate) { + return candidate; + } + } + base.into() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(n: u64) -> Vec { + (1..=n).map(ImageId).collect() + } + + #[test] + fn a_plain_press_replaces_the_selection() { + let all = ids(5); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + apply_press(&mut sel, &mut anchor, &all, 0, false, false); + apply_press(&mut sel, &mut anchor, &all, 2, false, false); + + assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(3)]); + } + + #[test] + fn ctrl_press_adds_and_then_removes() { + let all = ids(5); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + apply_press(&mut sel, &mut anchor, &all, 0, false, false); + apply_press(&mut sel, &mut anchor, &all, 3, true, false); + assert_eq!(sel.len(), 2); + + // Toggling: a second ctrl-press on the same cell takes it out again. + apply_press(&mut sel, &mut anchor, &all, 3, true, false); + assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(1)]); + } + + #[test] + fn shift_press_extends_a_contiguous_range() { + let all = ids(10); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + apply_press(&mut sel, &mut anchor, &all, 2, false, false); + apply_press(&mut sel, &mut anchor, &all, 6, false, true); + + assert_eq!(sel.len(), 5, "rows 2..=6 inclusive"); + assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(7))); + } + + #[test] + fn shift_extends_backwards_too() { + let all = ids(10); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + apply_press(&mut sel, &mut anchor, &all, 6, false, false); + apply_press(&mut sel, &mut anchor, &all, 2, false, true); + assert_eq!(sel.len(), 5); + } + + #[test] + fn a_second_shift_click_re_describes_the_range_rather_than_adding_to_it() { + // Overshooting and correcting is the common case. Extending without + // clearing would turn the correction into a union, and the user would + // drag cells they believed they had just deselected. + let all = ids(20); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + apply_press(&mut sel, &mut anchor, &all, 5, false, false); + apply_press(&mut sel, &mut anchor, &all, 15, false, true); + assert_eq!(sel.len(), 11, "rows 5..=15"); + + // Corrected to a shorter range from the same anchor. + apply_press(&mut sel, &mut anchor, &all, 8, false, true); + assert_eq!(sel.len(), 4, "rows 5..=8, and nothing from the first range"); + assert!(!sel.contains(&ImageId(16)), "row 15 is no longer selected"); + } + + #[test] + fn the_anchor_stays_put_across_shift_clicks() { + // If the anchor moved to each shift-click, a range could only ever be + // grown, never corrected inward. + let all = ids(20); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + apply_press(&mut sel, &mut anchor, &all, 10, false, false); + apply_press(&mut sel, &mut anchor, &all, 14, false, true); + apply_press(&mut sel, &mut anchor, &all, 12, false, true); + + assert_eq!(anchor, Some(10)); + assert_eq!(sel.len(), 3, "rows 10..=12"); + } + + #[test] + fn ctrl_shift_adds_a_second_range_to_the_selection() { + // Picking up a second run without losing the first: the one case where + // a shift-click must not clear. + let all = ids(20); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + apply_press(&mut sel, &mut anchor, &all, 0, false, false); + apply_press(&mut sel, &mut anchor, &all, 2, false, true); + assert_eq!(sel.len(), 3); + + // A new anchor by ctrl-click, then a ctrl+shift range from it. + apply_press(&mut sel, &mut anchor, &all, 10, true, false); + apply_press(&mut sel, &mut anchor, &all, 12, true, true); + + assert_eq!(sel.len(), 6, "rows 0..=2 and 10..=12"); + assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(13))); + } + + #[test] + fn a_plain_press_on_a_selected_cell_keeps_the_selection() { + // This is what makes a multi-image drag possible: the press that starts + // the drag must not collapse what it is about to carry. + let all = ids(5); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + apply_press(&mut sel, &mut anchor, &all, 0, false, false); + apply_press(&mut sel, &mut anchor, &all, 1, true, false); + apply_press(&mut sel, &mut anchor, &all, 2, true, false); + assert_eq!(sel.len(), 3); + + // Pressing one of the three to begin a drag. + apply_press(&mut sel, &mut anchor, &all, 1, false, false); + assert_eq!(sel.len(), 3, "the selection survived the press"); + } + + #[test] + fn a_press_past_the_end_of_the_window_is_ignored() { + // The grid is windowed and a stale row index can arrive after a scrub. + let all = ids(3); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + apply_press(&mut sel, &mut anchor, &all, 99, false, false); + assert!(sel.is_empty()); + } + + #[test] + fn shift_without_an_anchor_selects_just_the_one() { + let all = ids(5); + let mut sel = BTreeSet::new(); + let mut anchor = None; + + apply_press(&mut sel, &mut anchor, &all, 3, false, true); + assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(4)]); + } + + /// A solid test thumbnail. + fn thumb(w: u32, h: u32) -> slint::Image { + let mut buf = slint::SharedPixelBuffer::::new(w, h); + for p in buf.make_mut_slice() { + *p = slint::Rgba8Pixel { + r: 200, + g: 120, + b: 60, + a: 255, + }; + } + slint::Image::from_rgba8(buf) + } + + #[test] + fn one_dragged_image_composites_to_a_single_frame() { + let img = compose_drag_image(&[thumb(64, 64)]); + let size = img.size(); + // No fan for one image: the bitmap is just the thumbnail's own box. + assert_eq!(size.width, size.height, "a square thumbnail stays square"); + assert!(size.width > 0); + } + + #[test] + fn a_stack_is_wider_than_a_single_image() { + // The fan is what makes the count legible from the shape rather than + // needing a number drawn on it. + let one = compose_drag_image(&[thumb(64, 64)]); + let many = compose_drag_image(&[thumb(64, 64), thumb(64, 64), thumb(64, 64)]); + + assert!( + many.size().width > one.size().width, + "three images fan wider than one" + ); + assert!(many.size().height > one.size().height); + } + + #[test] + fn the_stack_stops_growing_past_the_layer_cap() { + // A forty-image drag must not composite forty thumbnails for a pile + // whose lower layers are hidden anyway. + let five: Vec = (0..5).map(|_| thumb(64, 64)).collect(); + let forty: Vec = (0..40).map(|_| thumb(64, 64)).collect(); + + assert_eq!( + compose_drag_image(&five).size().width, + compose_drag_image(&forty).size().width, + "past the cap the stack looks no thicker" + ); + } + + #[test] + fn an_empty_drag_composites_to_nothing() { + // Every cell in the selection may still be waiting for its preview. + assert_eq!(compose_drag_image(&[]).size().width, 0); + } + + #[test] + fn a_portrait_thumbnail_keeps_its_proportions() { + // Fitted, not stretched: a distorted frame stops the stack reading as + // photographs. + let img = compose_drag_image(&[thumb(60, 120)]); + let size = img.size(); + assert!( + size.height > size.width, + "a tall thumbnail composites tall, {}x{}", + size.width, + size.height + ); + } + + #[test] + fn mixed_orientations_do_not_panic_or_wrap() { + // The layers below the top are fitted into its box and clipped. Getting + // that wrong draws as a smear across the next row, or panics. + let img = compose_drag_image(&[thumb(120, 60), thumb(60, 120), thumb(90, 90)]); + assert!(img.size().width > 0 && img.size().height > 0); + } + + #[test] + fn a_zero_sized_thumbnail_is_not_composited() { + // A decode that produced nothing must not become a zero-divide. + assert_eq!(compose_drag_image(&[thumb(0, 0)]).size().width, 0); + } + + /// The spring's inputs: rows, which have children, and which are collapsed. + fn spring_fixture() -> (Vec, Vec, std::collections::HashSet) { + let ids = vec![CollectionId(1), CollectionId(2), CollectionId(3)]; + // 1 is a collapsed parent, 2 an expanded parent, 3 a leaf. + let has_children = vec![true, true, false]; + let collapsed = [CollectionId(1)].into_iter().collect(); + (ids, has_children, collapsed) + } + + #[test] + fn hovering_a_collapsed_parent_springs_it_open() { + // The point of the gesture: reaching a child of something closed. + let (ids, kids, collapsed) = spring_fixture(); + assert_eq!( + should_spring(Some(0), &ids, &kids, &collapsed), + Some(CollectionId(1)) + ); + } + + #[test] + fn hovering_an_already_open_parent_springs_nothing() { + // Its children are already reachable; rebuilding the tree would move + // rows under the pointer for no gain. + let (ids, kids, collapsed) = spring_fixture(); + assert_eq!(should_spring(Some(1), &ids, &kids, &collapsed), None); + } + + #[test] + fn hovering_a_leaf_springs_nothing() { + // A collection with no children has nothing to open, and flashing a + // rebuild would just shift the row the user is aiming at. + let (ids, kids, collapsed) = spring_fixture(); + assert_eq!(should_spring(Some(2), &ids, &kids, &collapsed), None); + } + + #[test] + fn hovering_nothing_springs_nothing() { + let (ids, kids, collapsed) = spring_fixture(); + assert_eq!(should_spring(None, &ids, &kids, &collapsed), None); + } + + #[test] + fn a_stale_row_index_springs_nothing() { + // The hover can outlive the row model it referred to. + let (ids, kids, collapsed) = spring_fixture(); + assert_eq!(should_spring(Some(99), &ids, &kids, &collapsed), None); + } + + #[test] + fn the_spring_dwell_is_long_enough_not_to_trigger_in_passing() { + // A tree that flaps open under every passing pointer is worse than one + // that never opens. This pins the intent rather than the number: a + // reflex-speed value here would be a regression, not a tuning choice. + assert!( + SPRING_DELAY_MS >= 300, + "a pointer crossing a parent must not open it" + ); + assert!( + SPRING_DELAY_MS <= 900, + "and a deliberate dwell must not feel like a hang" + ); + } + + #[test] + fn new_collections_do_not_share_a_name_with_a_sibling() { + // Two identically-named collections in one parent are + // indistinguishable in the sidebar, which is how images land in the + // wrong one. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + + let first = unique_name(c, None); + coll::create(c, &first, None, CollectionKind::Manual).unwrap(); + let second = unique_name(c, None); + coll::create(c, &second, None, CollectionKind::Manual).unwrap(); + + assert_ne!(first, second); + assert_eq!(first, "New collection"); + assert_eq!(second, "New collection 2"); + } + + /// A catalog holding one top-level collection, and its id. + fn with_one(name: &str) -> (Catalog, CollectionId) { + let cat = Catalog::in_memory().unwrap(); + let id = coll::create(cat.connection(), name, None, CollectionKind::Manual).unwrap(); + (cat, id) + } + + fn name_of(cat: &Catalog, id: CollectionId) -> String { + cat.connection() + .query_row( + "SELECT name FROM collections WHERE id = ?1", + [id.0 as i64], + |r| r.get(0), + ) + .unwrap() + } + + #[test] + fn a_rename_stores_the_new_name() { + let (cat, id) = with_one("Untitled"); + + let out = apply_rename(cat.connection(), id, "Iceland").unwrap(); + assert_eq!(out, Rename::Applied("Iceland".into())); + assert_eq!(name_of(&cat, id), "Iceland"); + } + + #[test] + fn surrounding_whitespace_is_trimmed_rather_than_stored() { + // A trailing space is invisible in the sidebar and makes two + // collections look identical while sorting them apart. + let (cat, id) = with_one("Untitled"); + + assert_eq!( + apply_rename(cat.connection(), id, " Iceland ").unwrap(), + Rename::Applied("Iceland".into()) + ); + assert_eq!(name_of(&cat, id), "Iceland"); + } + + #[test] + fn a_blank_name_is_refused() { + // A nameless row cannot be read or aimed at, and the schema would take + // one happily. + let (cat, id) = with_one("Iceland"); + + assert!(matches!( + apply_rename(cat.connection(), id, " "), + Err(dr_catalog::CatalogError::BadName(_)) + )); + assert_eq!(name_of(&cat, id), "Iceland", "the old name stands"); + } + + #[test] + fn renaming_to_the_current_name_writes_nothing() { + // Every write bumps the revision, and a no-op rename would let an idle + // device beat one that did real work at the next merge. + let (cat, id) = with_one("Iceland"); + let rev = |c: &Catalog| -> i64 { + c.connection() + .query_row( + "SELECT revision FROM collections WHERE id = ?1", + [id.0 as i64], + |r| r.get(0), + ) + .unwrap() + }; + + let before = rev(&cat); + assert_eq!( + apply_rename(cat.connection(), id, "Iceland").unwrap(), + Rename::Unchanged + ); + assert_eq!(rev(&cat), before); + } + + #[test] + fn a_siblings_name_is_refused() { + // Two identically-named collections in one parent are + // indistinguishable in the sidebar, which is how images land in the + // wrong one. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); + let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); + + assert!(matches!( + apply_rename(c, japan, "Iceland"), + Err(dr_catalog::CatalogError::BadName(_)) + )); + assert_eq!(name_of(&cat, japan), "Japan"); + } + + #[test] + fn a_siblings_name_is_refused_in_a_different_case_too() { + // The sidebar sorts case-insensitively, so "iceland" beside "Iceland" + // is the same trap as an exact duplicate. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); + let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); + + assert!(matches!( + apply_rename(c, japan, "iceland"), + Err(dr_catalog::CatalogError::BadName(_)) + )); + } + + #[test] + fn changing_only_the_case_of_a_name_is_allowed() { + // The clash check excludes the collection itself, or fixing the + // capitalisation of a name would be refused as a clash with itself. + let (cat, id) = with_one("iceland"); + + assert_eq!( + apply_rename(cat.connection(), id, "Iceland").unwrap(), + Rename::Applied("Iceland".into()) + ); + assert_eq!(name_of(&cat, id), "Iceland"); + } + + #[test] + fn a_name_used_under_a_different_parent_is_free() { + // Uniqueness is per parent: "Selects" inside two different trips is + // unambiguous and normal. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + let trips = coll::create(c, "Trips", None, CollectionKind::Manual).unwrap(); + coll::create(c, "Selects", Some(trips), CollectionKind::Manual).unwrap(); + let top = coll::create(c, "Loose", None, CollectionKind::Manual).unwrap(); + + assert_eq!( + apply_rename(c, top, "Selects").unwrap(), + Rename::Applied("Selects".into()) + ); + } + + #[test] + fn two_top_level_collections_still_clash_despite_a_null_parent() { + // `parent_id IS NULL` never matches with `=`, so a naive clash query + // would silently allow every duplicate at the top level — which is + // where most collections live. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); + let other = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); + + assert!( + apply_rename(c, other, "Iceland").is_err(), + "two top-level collections must not share a name" + ); + } + + #[test] + fn renaming_a_deleted_collection_fails_rather_than_resurrecting_it() { + let (cat, id) = with_one("Gone"); + coll::delete(cat.connection(), id).unwrap(); + + assert!(apply_rename(cat.connection(), id, "Back").is_err()); + } + + #[test] + fn a_saved_filter_can_be_renamed() { + // Its *membership* is computed; its name is not, and a smart + // collection the user cannot label is worse than no smart collection. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + let s = coll::create(c, "Untitled", None, CollectionKind::Smart).unwrap(); + + assert_eq!( + apply_rename(c, s, "Five star").unwrap(), + Rename::Applied("Five star".into()) + ); + } + + #[test] + fn the_same_name_is_free_again_under_a_different_parent() { + // Uniqueness is per parent, not global: "Selects" inside two different + // trips is unambiguous and normal. + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + + let top = coll::create(c, "New collection", None, CollectionKind::Manual).unwrap(); + assert_eq!(unique_name(c, Some(top)), "New collection"); + } +} diff --git a/ui/dr-ui/src/derived_sync.rs b/ui/dr-ui/src/derived_sync.rs new file mode 100644 index 0000000..d02e984 --- /dev/null +++ b/ui/dr-ui/src/derived_sync.rs @@ -0,0 +1,387 @@ +//! TRACES: FR-CAT-3 | FR-CAT-7 | FR-NC-7 +//! Pushing derived state to Nextcloud: thumbnail shards and the catalog. +//! +//! # What travels, and why only this +//! +//! Sidecars are handled elsewhere ([`crate::library::spawn_sidecar_writes`]) +//! and are the *authoritative* store — they are the reason a catalog can be +//! deleted and rebuilt (ARCH §6.12). What moves here is derived state that is +//! merely expensive: +//! +//! - **Thumbnail shards.** A thumbnail costs a range fetch plus a decode, and +//! is byte-identical for every client looking at the same file. A second +//! device that downloads the shards gets a full grid without touching a +//! single RAW — hours of indexing against a few hundred MB of transfer. +//! - **The catalog**, for its collections. Every other thing the catalog holds +//! has authoritative backing in a sidecar; a manually assembled collection +//! does not, so without this it exists on one machine only. +//! +//! # Why sealed shards make this cheap +//! +//! A shard stops being written once it reaches its cap, and is never rewritten +//! after — deleting a thumbnail tombstones it in the index rather than editing +//! the sealed blob. So a client that has downloaded a sealed shard never needs +//! to ask about it again, and an up-to-date client transfers only the index and +//! whichever shard is currently open. That is the whole reason for sharding at +//! 25 MB rather than keeping one growing file. +//! +//! # Where it lives +//! +//! Under the library root, in a dotted folder beside the trash. The root is the +//! only place the user granted access to, and writing outside it may cross a +//! share boundary the account cannot write to. The scanner excludes it by the +//! same mechanism that excludes the trash. + +use std::path::{Path, PathBuf}; + +use dr_sync::{RemoteBackend, RemoteId, RemotePath}; +use dr_sync_nextcloud::{AppCredentials, NextcloudBackend}; +use dr_thumbs::ThumbStore; + +/// Folder under the library root holding derived state. +/// +/// Defined by the scanner, which must exclude it: a walk that indexed this +/// folder would pay a listing for it on every sync of every device. +pub use dr_sync::scan::DERIVED_DIR; + +/// What a sync pass did, for logging and for telling the user. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct SyncReport { + pub shards_uploaded: usize, + pub shards_downloaded: usize, + pub thumbnails_adopted: usize, + pub catalog_uploaded: bool, + pub catalog_merged: bool, + pub collections_gained: usize, +} + +impl SyncReport { + pub fn did_anything(&self) -> bool { + self.shards_uploaded > 0 + || self.shards_downloaded > 0 + || self.catalog_uploaded + || self.catalog_merged + } +} + +/// Progress from the sync worker. +#[derive(Debug)] +pub enum SyncMessage { + Status(String), + Finished(Box), + Failed(String), +} + +/// Push shards and the catalog, and take anything newer from the server. +/// +/// Runs on its own thread with its own runtime, like every other network path +/// here — the Slint loop must never block (NFR-P9). +pub fn spawn_sync( + creds: AppCredentials, + user_id: String, + root: String, + thumbs_dir: PathBuf, + catalog_path: PathBuf, + scratch: PathBuf, +) -> std::sync::mpsc::Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(SyncMessage::Failed(e.to_string())); + return; + } + }; + + rt.block_on(async { + let backend = match NextcloudBackend::new(&creds, &user_id) { + Ok(b) => b, + Err(e) => { + let _ = tx.send(SyncMessage::Failed(e.to_string())); + return; + } + }; + + match run(&backend, &root, &thumbs_dir, &catalog_path, &scratch, &tx).await { + Ok(report) => { + let _ = tx.send(SyncMessage::Finished(Box::new(report))); + } + Err(e) => { + let _ = tx.send(SyncMessage::Failed(e)); + } + } + }); + }); + + rx +} + +async fn run( + backend: &NextcloudBackend, + root: &str, + thumbs_dir: &Path, + catalog_path: &Path, + scratch: &Path, + tx: &std::sync::mpsc::Sender, +) -> Result { + let mut report = SyncReport::default(); + let base = derived_path(root); + + // The folder may not exist on a first sync. Creating it unconditionally is + // cheaper than probing, and an existing folder is not an error. + let _ = backend.create_dir(&base).await; + + let _ = tx.send(SyncMessage::Status("checking thumbnails…".into())); + sync_shards(backend, &base, thumbs_dir, scratch, &mut report).await?; + + let _ = tx.send(SyncMessage::Status("checking collections…".into())); + sync_catalog(backend, &base, catalog_path, scratch, &mut report).await?; + + Ok(report) +} + +/// The derived folder for a library root. +fn derived_path(root: &str) -> RemotePath { + if root.is_empty() { + RemotePath::new(DERIVED_DIR) + } else { + RemotePath::new(format!("{root}/{DERIVED_DIR}")) + } +} + +/// Exchange thumbnail shards with the server. +/// +/// Upload what the server lacks, download what we lack. Sealed shards are +/// immutable, so a name match is a content match and nothing needs comparing +/// beyond existence — which is what keeps a steady-state sync to one listing. +async fn sync_shards( + backend: &NextcloudBackend, + base: &RemotePath, + thumbs_dir: &Path, + scratch: &Path, + report: &mut SyncReport, +) -> Result<(), String> { + let store = match ThumbStore::open(thumbs_dir) { + Ok(s) => s, + Err(e) => { + // No local store is not a failure: a fresh device has nothing to + // upload and everything to gain from downloading. + log::debug!("thumbnail store unavailable: {e}"); + return Ok(()); + } + }; + + let remote: std::collections::HashMap = backend + .list(base, None) + .await + .map(|entries| { + entries + .into_iter() + .filter(|e| e.kind == dr_sync::EntryKind::File) + .map(|e| (e.path.name().to_string(), e.size)) + .collect() + }) + // A missing folder lists as an error on some servers; treat it as empty + // rather than aborting a first sync. + .unwrap_or_default(); + + let local = store.shards().map_err(|e| e.to_string())?; + + // ---- upload ---------------------------------------------------------- + for shard in &local { + let path = store.shard_path(shard.id); + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let name = shard_name(shard.id); + + // A sealed shard the server already has is byte-identical by + // construction, so its presence is proof enough. The open shard is + // re-uploaded whenever its size differs, which is the only way it + // changes. + let skip = match remote.get(&name) { + Some(_) if shard.sealed => true, + Some(size) => *size == bytes.len() as u64, + None => false, + }; + if skip { + continue; + } + + let target = RemotePath::new(format!("{}/{name}", base.as_str())); + match backend.put(&target, bytes, None).await { + Ok(_) => report.shards_uploaded += 1, + // One shard failing must not abort the rest: they are independent + // and the next pass retries. + Err(e) => log::warn!("uploading {name}: {e}"), + } + } + + // ---- download -------------------------------------------------------- + let have: std::collections::HashSet = local.iter().map(|s| s.id).collect(); + let mut store = store; + + for (name, _) in &remote { + let Some(id) = shard_id(name) else { continue }; + if have.contains(&id) { + continue; + } + + let source = RemotePath::new(format!("{}/{name}", base.as_str())); + let bytes = match backend.get(&RemoteId::Path(source), None).await { + Ok(b) => b, + Err(e) => { + log::warn!("downloading {name}: {e}"); + continue; + } + }; + + // Written to scratch and merged, rather than dropped into the store + // directory: a downloaded shard's *id* is the other device's numbering, + // and two devices independently fill shard 0. + let tmp = scratch.join(name); + if std::fs::write(&tmp, &bytes).is_err() { + continue; + } + match store.merge_shard(&tmp) { + Ok(n) => { + report.shards_downloaded += 1; + report.thumbnails_adopted += n; + } + Err(e) => log::warn!("merging {name}: {e}"), + } + let _ = std::fs::remove_file(&tmp); + } + + Ok(()) +} + +/// Exchange the catalog, for its collections. +/// +/// Only collections merge — see [`dr_catalog::sync`]. The rest of a catalog +/// describes local state (folder ETags, cache paths, job rows) and importing +/// another device's version would be actively wrong. +async fn sync_catalog( + backend: &NextcloudBackend, + base: &RemotePath, + catalog_path: &Path, + scratch: &Path, + report: &mut SyncReport, +) -> Result<(), String> { + let remote_name = "catalog.sqlite"; + let target = RemotePath::new(format!("{}/{remote_name}", base.as_str())); + + // ---- take theirs first ----------------------------------------------- + // + // Merging before uploading means our upload carries the union rather than + // only our own half, so a third device syncing next gets everything in one + // fetch. + if let Ok(bytes) = backend.get(&RemoteId::Path(target.clone()), None).await { + let downloaded = scratch.join("catalog-remote.sqlite"); + if std::fs::write(&downloaded, &bytes).is_ok() { + match dr_catalog::Catalog::open(catalog_path) { + Ok(catalog) => match catalog.merge_remote_catalog(&downloaded) { + Ok(merge) => { + report.catalog_merged = true; + report.collections_gained = merge.inserted + merge.updated; + } + Err(e) => log::warn!("merging remote catalog: {e}"), + }, + Err(e) => log::warn!("opening catalog to merge: {e}"), + } + let _ = std::fs::remove_file(&downloaded); + } + } + + // ---- then push ours -------------------------------------------------- + // + // Never the live file: committed transactions can sit in the `-wal` with + // the main file lagging, so copying it uploads a torn snapshot. The backup + // API serialises against writers instead of racing them. + let snapshot = scratch.join("catalog-upload.sqlite"); + let catalog = dr_catalog::Catalog::open(catalog_path).map_err(|e| e.to_string())?; + catalog + .snapshot_for_upload(&snapshot) + .map_err(|e| e.to_string())?; + + let bytes = std::fs::read(&snapshot).map_err(|e| e.to_string())?; + match backend.put(&target, bytes, None).await { + Ok(_) => report.catalog_uploaded = true, + Err(e) => log::warn!("uploading catalog: {e}"), + } + let _ = std::fs::remove_file(&snapshot); + + Ok(()) +} + +fn shard_name(id: u32) -> String { + format!("shard-{id:04}.sqlite") +} + +/// The shard id in a filename, or `None` if it is not a shard. +/// +/// Guards the download loop against adopting the catalog, a stray file, or +/// anything else the folder happens to contain. +fn shard_id(name: &str) -> Option { + name.strip_prefix("shard-")? + .strip_suffix(".sqlite")? + .parse() + .ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derived_folder_sits_under_the_library_root() { + // Outside the root the account may not have write access — the root is + // the only thing the user granted. + assert_eq!( + derived_path("PhotosRaw").as_str(), + "PhotosRaw/.darkroom-derived" + ); + // A library at the account root still gets a relative path. + assert_eq!(derived_path("").as_str(), ".darkroom-derived"); + } + + #[test] + fn shard_names_round_trip() { + assert_eq!(shard_name(0), "shard-0000.sqlite"); + assert_eq!(shard_name(42), "shard-0042.sqlite"); + assert_eq!(shard_id("shard-0042.sqlite"), Some(42)); + assert_eq!(shard_id(&shard_name(7)), Some(7)); + } + + #[test] + fn non_shard_files_are_not_adopted() { + // The folder also holds the catalog; downloading it as a shard would + // hand a catalog to the thumbnail merger. + assert_eq!(shard_id("catalog.sqlite"), None); + assert_eq!(shard_id("shard-0000.sqlite-wal"), None); + assert_eq!(shard_id("notes.txt"), None); + assert_eq!(shard_id("shard-abc.sqlite"), None); + } + + #[test] + fn a_report_that_did_nothing_says_so() { + assert!(!SyncReport::default().did_anything()); + assert!(SyncReport { + shards_uploaded: 1, + ..Default::default() + } + .did_anything()); + // Adopting thumbnails without moving a shard cannot happen, but the + // report must not claim work on collections alone either. + assert!(SyncReport { + catalog_merged: true, + ..Default::default() + } + .did_anything()); + } +} diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 19274fc..a6ecd95 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -31,12 +31,36 @@ impl DevelopSession { pub fn open(ctx: &GpuContext, raw: &RawImage) -> Result { let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?; let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?; + Ok(Self::with_source(ctx, demosaiced)) + } - Ok(Self { + /// Prepare an edit graph over an already-processed RGB image. + /// + /// The JPEG path. A JPEG is already demosaiced, so there is no sensor + /// stage to run — but everything after it is identical, which is why this + /// shares [`Self::with_source`] rather than duplicating the session. + /// + /// Worth being honest about what this cannot recover: an 8-bit JPEG has + /// clipped highlights and quantised shadows that no edit brings back, so + /// exposure has far less latitude here than on sensor data. The controls + /// are the same controls; the file simply carries less to work with. + pub fn open_rgb( + ctx: &GpuContext, + rgba: &[u8], + width: u32, + height: u32, + ) -> Result { + let source = + DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?; + Ok(Self::with_source(ctx, source)) + } + + fn with_source(ctx: &GpuContext, demosaiced: DemosaicedImage) -> Self { + Self { graph: EditGraph::default_chain(), demosaiced, adjust: AdjustPass::new(ctx), - }) + } } /// The controls the interface should show. @@ -46,6 +70,9 @@ impl DevelopSession { pub fn rows(&self) -> Vec { let mut rows = Vec::new(); for (op_index, op) in self.graph.capabilities().iter().enumerate() { + // Where this operation's rows begin. The panel groups by walking + // back to it, so it has to be taken before any row is pushed. + let group_head = rows.len(); // An operation may ask for one widget spanning several // parameters. Honouring it is optional — dropping this block // renders the same parameters as ordinary sliders, and the edit @@ -55,7 +82,7 @@ impl DevelopSession { // kind is added, this stops compiling until it is handled, // rather than silently falling through to sliders. let row = match presentation.widget { - WidgetKind::Curve => self.curve_row(op_index, op, presentation), + WidgetKind::Curve => self.curve_row(op_index, group_head, op, presentation), }; if let Some(row) = row { rows.push(row); @@ -63,6 +90,17 @@ impl DevelopSession { } } + // Whether anything in this operation has been touched, aggregated + // before the rows are built so every row of the group can carry + // the same answer — the panel's heading is one of them and cannot + // see the others. + // + // Derived here rather than asked of the core: a group is a + // composition this side invented, so whether one is modified is + // this side's question to answer (ARCH §4.3a). + let group_modified = op.params.iter().any(|p| p.value != p.default); + let group_len = op.params.len() as i32; + for (param_index, p) in op.params.iter().enumerate() { let (kind, min, max, precision, unit) = match &p.kind { ParamKind::Scalar { @@ -86,9 +124,9 @@ impl DevelopSession { param_index: param_index as i32, op_label: labels::resolve(op.label.0).into(), param_label: labels::resolve(p.label.0).into(), - // The panel draws a heading wherever this is set, without - // needing to know what an operation is. - starts_group: param_index == 0, + group_head: group_head as i32, + group_len, + group_modified, kind: kind.into(), value: p.value, default_value: p.default, @@ -112,12 +150,13 @@ impl DevelopSession { fn curve_row( &self, op_index: usize, + group_head: usize, op: &OpCapability, presentation: &Presentation, ) -> Option { // Points are x/y pairs, so an odd count means the operation and this // code disagree about the layout. - if presentation.params.len() < 2 || presentation.params.len() % 2 != 0 { + if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) { log::warn!("{}: curve widget needs an even parameter count", op.id); return None; } @@ -148,7 +187,11 @@ impl DevelopSession { param_index: base as i32, op_label: labels::resolve(op.label.0).into(), param_label: String::new().into(), - starts_group: true, + group_head: group_head as i32, + // One widget standing for every parameter of the operation, so + // the group it heads is itself and nothing else. + group_len: 1, + group_modified: op.params.iter().any(|p| p.value != p.default), kind: "curve".into(), value: 0.0, default_value: 0.0, @@ -207,8 +250,14 @@ impl DevelopSession { .collect() } - /// Return every point of a curve operation to its default. - pub fn reset_curve(&mut self, op_index: i32) { + /// Return every parameter of one operation to its default. + /// + /// What both a section's reset and a curve's reset do — a curve is one + /// widget spanning all of its operation's parameters, so "reset this + /// curve" and "reset this operation" were always the same action. Nothing + /// here is curve-shaped; it walks whatever parameters the operation + /// declares. + pub fn reset_op(&mut self, op_index: i32) { let caps = self.graph.capabilities(); let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else { return; @@ -218,6 +267,15 @@ impl DevelopSession { } } + /// Reset a curve, which is to reset its operation. + /// + /// Kept as its own name because the call site is a curve widget's own + /// double-click, and reading `reset_curve` there says why it resets ten + /// parameters at once rather than the one that was clicked. + pub fn reset_curve(&mut self, op_index: i32) { + self.reset_op(op_index); + } + /// Apply a change from the interface. /// /// Indices are positions in [`Self::rows`]; the mapping back to ids stays @@ -290,6 +348,46 @@ impl DevelopSession { Ok(slint::Image::from_rgba8(buffer)) } + /// Render the *whole* frame for the crop overlay to be drawn over. + /// + /// Crop mode cannot use [`Self::render`]: that applies the crop, so the + /// area being cropped away would not be on screen and there would be + /// nothing to drag the handles across. This renders as though the crop + /// were full, and the interface draws the rect and greys the surround. + /// + /// Zoom is suspended too. Panning a zoomed view while also dragging crop + /// handles is two conflicting meanings for one drag, and the handles are + /// placed against the whole frame in any case. + /// + /// Returns the image together with the size it was rendered at, since the + /// overlay has to place its rect against exactly those pixels. + pub fn render_uncropped( + &mut self, + width: u32, + height: u32, + ) -> Result<(slint::Image, u32, u32), String> { + let saved_crop = self.graph.crop(); + let saved_view = self.graph.framing().view(); + + self.graph.set_crop(CropRect::default()); + self.graph.framing_mut().set_view(CropRect::default()); + + let result = self.render(width, height); + + // Restored whatever happened: leaving the graph cropped-to-full on a + // render error would silently discard the user's crop. + self.graph.set_crop(saved_crop); + self.graph.framing_mut().set_view(saved_view); + + let image = result?; + let (sw, sh) = self.demosaiced.size(); + // The uncropped frame still turns with the quarter turns, so the + // overlay's box comes from the framing rather than the sensor. + let (fw, fh) = self.graph.framing().output_size_uncropped(sw, sh); + let (rw, rh) = fit(fw, fh, width.max(1), height.max(1)); + Ok((image, rw, rh)) + } + /// The displayed size, for sizing the viewport. /// /// The *framed* size, not the sensor's: cropping and quarter turns change @@ -322,6 +420,87 @@ impl DevelopSession { self.graph.rotate_quarters(turns); } + /// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×. + pub fn zoom(&self) -> f32 { + let v = self.graph.framing().view(); + if v.width <= 0.0 { + 1.0 + } else { + 1.0 / v.width + } + } + + pub fn is_zoomed(&self) -> bool { + self.graph.framing().is_zoomed() + } + + /// Zoom about a point, given in fractions of the *visible* area. + /// + /// Anchoring matters: zooming about the pointer keeps whatever is under + /// it stationary, which is what makes a scroll-wheel zoom feel like it is + /// magnifying the photograph rather than sliding it around. + /// + /// `factor` multiplies the current zoom — above 1 moves in. + pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) { + const MAX_ZOOM: f32 = 16.0; + + let view = self.graph.framing().view(); + let current = if view.width > 0.0 { + 1.0 / view.width + } else { + 1.0 + }; + let target = (current * factor).clamp(1.0, MAX_ZOOM); + // Snapped so scrolling back out reliably reaches "fit" rather than + // stopping a fraction short and leaving the image imperceptibly + // panned. + let target = if (target - 1.0).abs() < 0.01 { + 1.0 + } else { + target + }; + + let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0); + + // The point under the cursor, in framed coordinates, must land back + // under the cursor afterwards. + let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width; + let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height; + + self.set_view_clamped( + anchor_x - at_x.clamp(0.0, 1.0) * extent, + anchor_y - at_y.clamp(0.0, 1.0) * extent, + extent, + ); + } + + /// Pan by a fraction of the *visible* area — what a drag reports. + pub fn pan_by(&mut self, dx: f32, dy: f32) { + let view = self.graph.framing().view(); + self.set_view_clamped(view.x + dx * view.width, view.y + dy * view.height, view.width); + } + + /// Back to fitting the whole frame. + pub fn reset_zoom(&mut self) { + self.graph.framing_mut().set_view(CropRect::default()); + } + + /// Place a square view of `extent`, keeping it inside the frame. + /// + /// Clamped rather than allowed to run off the edge: panning past the + /// boundary would show undefined area beside the photograph, which reads + /// as a rendering fault rather than as the end of the image. + fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) { + let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0); + let max = 1.0 - extent; + self.graph.framing_mut().set_view(CropRect { + x: x.clamp(0.0, max.max(0.0)), + y: y.clamp(0.0, max.max(0.0)), + width: extent, + height: extent, + }); + } + /// The largest centred crop that, at the current straightening angle, /// contains no undefined area. What a "straighten and fill" action /// applies. @@ -421,20 +600,119 @@ mod tests { } #[test] - fn each_operation_starts_exactly_one_group() { - // The panel draws a heading per group; two groups for one operation - // would duplicate the heading, none would merge two operations under - // one. + fn each_operation_becomes_exactly_one_group() { + // The panel draws one section per group, and derives the boundary + // from `group_head` rather than from a flag the core supplies. Two + // heads for one operation would draw its heading twice; none would + // swallow the operation into the section above it. let graph = EditGraph::default_chain(); - let mut groups = 0; - for op in graph.capabilities() { - for (i, _) in op.params.iter().enumerate() { - if i == 0 { - groups += 1; - } + let caps = graph.capabilities(); + + // A row heads its group exactly when its own index equals its + // `group_head` — the same test `adjust.slint` makes. + let mut heads = 0; + for (i, row) in rows_of(&caps).iter().enumerate() { + if row.0 == i { + heads += 1; } } - assert_eq!(groups, graph.capabilities().len()); + assert_eq!(heads, caps.len()); + } + + #[test] + fn a_group_spans_exactly_its_operations_rows() { + // `group_len` is how many rows the section reaches forward over. Too + // few silently drops controls off the bottom of a section; too many + // reads past the model and renders a neighbouring operation's + // parameters under the wrong heading. + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + let rows = rows_of(&caps); + + for (i, row) in rows.iter().enumerate() { + let (head, len) = *row; + assert!(head <= i, "row {i} claims a head after itself"); + assert!( + head + len <= rows.len(), + "group at {head} reaches past the model" + ); + // Every row the group spans must agree it belongs to that group. + for span in head..head + len { + assert_eq!(rows[span].0, head, "row {span} disagrees about its group"); + } + } + } + + #[test] + fn a_group_is_modified_when_any_of_its_parameters_is() { + // The dot on a collapsed section is the only thing saying an edit is + // hidden inside it, and it is derived here rather than asked of the + // core (ARCH §4.3a). + let mut graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + // A fresh chain is at its defaults, so nothing is modified. + assert!( + caps.iter() + .all(|c| c.params.iter().all(|p| p.value == p.default)), + "a fresh chain must start neutral" + ); + + // Move one parameter of one operation off its default; only that + // operation's group may light up. + let (op_id, param_id, default) = caps + .iter() + .find_map(|c| { + c.params + .iter() + .find(|p| matches!(p.kind, ParamKind::Scalar { .. })) + .map(|p| (c.id, p.id, p.default)) + }) + .expect("the chain has a scalar parameter"); + graph.set_param(op_id, param_id, default + 1.0); + + let caps = graph.capabilities(); + let modified: Vec = caps + .iter() + .map(|c| c.params.iter().any(|p| p.value != p.default)) + .collect(); + assert_eq!( + modified.iter().filter(|m| **m).count(), + 1, + "one edit must mark exactly one group" + ); + + // And it goes out again when the value returns. + graph.set_param(op_id, param_id, default); + assert!( + graph + .capabilities() + .iter() + .all(|c| c.params.iter().all(|p| p.value == p.default)), + "returning a value to its default must clear the group" + ); + } + + /// `(group_head, group_len)` per row, flattened as + /// [`DevelopSession::rows`] flattens — without needing a GPU to build a + /// session. + /// + /// A widget hint only collapses an operation to one row when it is + /// *honoured*; `rows` falls back to sliders otherwise, and mirroring that + /// here is what keeps the test honest when a hint stops applying. + fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> { + let mut rows = Vec::new(); + for op in caps { + let head = rows.len(); + let collapses = op + .presentation + .as_ref() + .is_some_and(|p| p.params.len() == op.params.len()); + let len = if collapses { 1 } else { op.params.len() }; + for _ in 0..len { + rows.push((head, len)); + } + } + rows } #[test] diff --git a/ui/dr-ui/src/launch.rs b/ui/dr-ui/src/launch.rs index f85a41b..e876144 100644 --- a/ui/dr-ui/src/launch.rs +++ b/ui/dr-ui/src/launch.rs @@ -29,6 +29,23 @@ pub enum LaunchState { }, } +/// What the app opens on. +/// +/// Three outcomes, and the middle one is easy to lose: a configured library +/// means the launch screen is skipped, and skipping it must not also skip +/// opening the library — otherwise the app lands on an empty view with no +/// route back to the grid, because the "Open library" button is on the screen +/// that was never shown. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Startup { + /// Files were named on the command line; show those. + ShowLocalFiles, + /// An account and a folder are configured; scan and show the grid. + OpenLibrary, + /// Nothing configured; ask the user to sign in. + ShowLaunchScreen, +} + /// TRACES: FR-NC-1 | FR-NC-4 | M-1 | M-3 | M-4 /// Everything the launch screen renders from. #[derive(Debug, Clone)] @@ -197,6 +214,23 @@ impl LaunchModel { self.is_signed_in() && !self.library_root().is_empty() } + /// What the app should do on startup. + /// + /// Pure so it can be tested without a secret store or a display server — + /// and it needs testing, because the interesting case is the one with no + /// visible symptom until you are staring at an empty window. + pub fn startup_action(&self, have_local_paths: bool) -> Startup { + if have_local_paths { + // Files named on the command line win: the user asked for those + // specifically, not for their library. + Startup::ShowLocalFiles + } else if self.can_open_library() { + Startup::OpenLibrary + } else { + Startup::ShowLaunchScreen + } + } + pub fn format_filter(&self) -> FormatFilter { FormatFilter::from_formats(self.formats.iter().filter(|(_, on)| *on).map(|(f, _)| *f)) } @@ -393,6 +427,38 @@ mod tests { assert!(m.can_open_library()); } + #[test] + fn a_configured_library_opens_rather_than_showing_nothing() { + // The regression this guards: skipping the launch screen because a + // library is configured used to mean the library was never opened, + // since `on_open_library` only fires from a button nobody saw. + let mut m = LaunchModel::default(); + m.signed_in(session_with_root("PhotosRaw")); + assert_eq!(m.startup_action(false), Startup::OpenLibrary); + } + + #[test] + fn no_configuration_shows_the_launch_screen() { + let m = LaunchModel::default(); + assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen); + } + + #[test] + fn a_signed_in_account_without_a_folder_still_needs_the_screen() { + // Opening without a chosen folder would scan the whole account. + let mut m = LaunchModel::default(); + m.signed_in(session_with_root("")); + assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen); + } + + #[test] + fn command_line_files_win_over_a_configured_library() { + // The user asked for those files specifically. + let mut m = LaunchModel::default(); + m.signed_in(session_with_root("PhotosRaw")); + assert_eq!(m.startup_action(true), Startup::ShowLocalFiles); + } + #[test] fn a_failure_never_strands_the_screen_in_busy() { let mut m = LaunchModel::default(); diff --git a/ui/dr-ui/src/launch_ui.rs b/ui/dr-ui/src/launch_ui.rs index 214ec1c..6ee2877 100644 --- a/ui/dr-ui/src/launch_ui.rs +++ b/ui/dr-ui/src/launch_ui.rs @@ -37,14 +37,6 @@ impl LaunchController { }) } - /// Whether the app should open on the launch screen. - /// - /// Only when there is nothing to show: a configured library goes straight - /// to the images, since making someone click past a login screen they - /// already completed is pure friction. - pub fn should_show(&self, have_local_paths: bool) -> bool { - !have_local_paths && !self.model.borrow().can_open_library() - } } /// Push the model into the window's properties. diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index cbe8678..2dd17eb 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -14,8 +14,15 @@ //! pipeline what parameters it has and builds a control per answer; no code //! in `ui/` names an operation or knows a shader exists (FR-DEV-3a). +mod collections_ui; +mod derived_sync; mod develop; mod labels; +mod library; +mod library_ui; +mod trash; +#[cfg(live_style)] +mod live_style; use std::cell::RefCell; use std::path::{Path, PathBuf}; @@ -48,10 +55,10 @@ const EXPANDED_MIN_WIDTH: f32 = 820.0; /// Everything loaded for the currently displayed image. struct Loaded { - /// A develop session where the file could be decoded to sensor data. - /// `None` for a JPEG or a body rawler cannot decode, in which case - /// `fallback` carries an embedded preview and the adjust panel is - /// disabled rather than shown doing nothing. + /// A develop session. `None` only where the file could not be opened for + /// editing at all — a body rawler cannot decode, or a corrupt JPEG — in + /// which case `fallback` carries an embedded preview and the adjust panel + /// is disabled rather than shown doing nothing. session: Option, fallback: Option, meta: Metadata, @@ -77,33 +84,73 @@ fn load(ctx: Option<&dr_gpu::GpuContext>, path: &Path) -> Result } let bytes = std::fs::read(path).map_err(|e| e.to_string())?; - let meta = dr_decode::metadata(&bytes).unwrap_or_default(); + load_bytes(ctx, &bytes) +} + +/// Open already-fetched bytes. +/// +/// Split from [`load`] because a library image has no local file: it arrives +/// as a WebDAV response body, and writing it to disk purely to read it back +/// would be a round-trip for nothing. +fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result { + let meta = dr_decode::metadata(bytes).unwrap_or_default(); + + // Route by what the bytes actually are, not by extension (M-9). + // + // A JPEG has no sensor data and never will, so trying the RAW decoder + // first would be a guaranteed failure whose log line reads like a fault. + // It goes straight to the RGB path instead, which is what makes develop + // mode work on the JPEGs the library already indexes. + let is_jpeg = dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg); - // Try sensor data first. A failure here is expected for JPEGs and for - // bodies rawler does not know, and must not stop the image displaying - // (FR-RAW-4). if let Some(ctx) = ctx { - match dr_decode::decode(&bytes) { - Ok(raw) => match DevelopSession::open(ctx, &raw) { - Ok(session) => { - let (width, height) = session.source_size(); - return Ok(Loaded { - session: Some(session), - fallback: None, - meta, - width, - height, - }); - } - Err(e) => log::info!("develop unavailable, showing preview: {e}"), - }, - Err(e) => log::info!("no sensor data ({e}); showing preview"), + let opened = if is_jpeg { + dr_decode::decode_jpeg(bytes) + .map_err(|e| e.to_string()) + .and_then(|mut p| { + // Fit the device before uploading. A film scan runs to + // 13728×8928, well past the 8192 a typical GPU can hold, + // and refusing it would drop the image back to a + // read-only preview — the very thing this path exists to + // avoid. 8192 is still four times a 4K long edge. + let limit = dr_gpu::DemosaicedImage::max_dimension(ctx); + if p.width.max(p.height) > limit { + log::info!( + "{}×{} exceeds the {limit} texture limit; fitting to it", + p.width, + p.height + ); + p.downscale_to(limit); + } + DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height) + }) + } else { + // A failure here is expected for bodies rawler does not know, and + // must not stop the image displaying (FR-RAW-4). + dr_decode::decode(bytes) + .map_err(|e| e.to_string()) + .and_then(|raw| DevelopSession::open(ctx, &raw)) + }; + + match opened { + Ok(session) => { + let (width, height) = session.source_size(); + return Ok(Loaded { + session: Some(session), + fallback: None, + meta, + width, + height, + }); + } + Err(e) => log::info!("develop unavailable, showing preview: {e}"), } } - // Fall back to the embedded preview, which is all a JPEG has anyway. + // Fall back to the embedded preview: no GPU, or a file neither decoder + // could open for editing. Read-only, and the adjust panel is disabled. let mut preview = - dr_decode::extract_preview(&bytes, PreviewSize::Screen).map_err(|e| e.to_string())?; + dr_decode::extract_preview(bytes, PreviewSize::Screen).map_err(|e| e.to_string())?; preview.downscale_to(MAX_DISPLAY_DIM); let buffer = slint::SharedPixelBuffer::::clone_from_slice( @@ -162,6 +209,21 @@ fn is_supported(p: &Path) -> bool { .is_some() } +/// Return the view to its opening state for a newly loaded image. +/// +/// Zoom and crop mode are properties of *looking at one photograph*, so +/// carrying them to the next one would leave the second image cropped to a +/// rect chosen for the first. +fn reset_view_state(window: &AppWindow) { + window.set_crop_mode(false); + window.set_zoom(1.0); + window.set_zoomed(false); + window.set_crop_x(0.0); + window.set_crop_y(0.0); + window.set_crop_w(1.0); + window.set_crop_h(1.0); +} + /// Push current parameter values back to the interface. /// /// The controls are not self-updating: the core clamps values, so what the @@ -186,10 +248,27 @@ fn sync_rows( }; if current.len() == rows.row_count() { - for (i, row) in current.into_iter().enumerate() { + for (i, mut row) in current.into_iter().enumerate() { + let existing = rows.row_data(i); + + // A curve row carries a *nested* model of point coordinates, and + // `rows()` builds a fresh one each call. Swapping it in would + // destroy the point elements — including the `TouchArea` holding + // the current drag — so the existing model is kept and its values + // written through instead. + // + // It also makes the equality test below meaningful: `ModelRc` + // compares by identity, so a brand-new points model would make + // every curve row look changed on every event. + if let Some(previous) = existing.as_ref() { + if update_points_in_place(&previous.points, &row.points) { + row.points = previous.points.clone(); + } + } + // Only touch rows that actually changed, so unrelated controls // are not needlessly invalidated. - if rows.row_data(i).as_ref() != Some(&row) { + if existing.as_ref() != Some(&row) { rows.set_row_data(i, row); } } @@ -204,29 +283,167 @@ fn sync_rows( window.set_curve_samples(slint::ModelRc::new(slint::VecModel::from(samples))); } +/// Copy `fresh`'s values into `existing`, keeping the model identity. +/// +/// Returns `false` where the two differ in length, in which case the caller +/// must take the new model wholesale — the control set itself has changed and +/// there is no drag worth preserving. +fn update_points_in_place( + existing: &slint::ModelRc, + fresh: &slint::ModelRc, +) -> bool { + use slint::Model as _; + + if existing.row_count() != fresh.row_count() { + return false; + } + for i in 0..fresh.row_count() { + let (Some(new), Some(old)) = (fresh.row_data(i), existing.row_data(i)) else { + continue; + }; + // Guarded so an unchanged coordinate does not invalidate its element + // — the same reasoning as the row-level check above. + if new != old { + existing.set_row_data(i, new); + } + } + true +} + /// TRACES: M-13 | M-14 /// Build and run the viewer. pub fn run(paths: Vec) -> Result<()> { - let entries = Rc::new(collect(&paths)); - log::info!("{} image(s) to browse", entries.len()); + // Mutable because the browsing list has two sources: the command line at + // startup, and whatever the library grid is showing when a cell is + // clicked. Opening from the grid replaces this so next/previous walk the + // library the user is actually looking at rather than the arguments they + // launched with. + let entries = Rc::new(RefCell::new(collect(&paths))); + log::info!("{} image(s) to browse", entries.borrow().len()); let window = AppWindow::new()?; + // Set once `show` exists; see where the library grid is wired below. + #[allow(clippy::type_complexity)] + let open_from_library: Rc>>> = + Rc::new(RefCell::new(None)); + + // Before anything binds to a token: the compiled palette is already in + // place, so this only overwrites what style.yaml currently says. + #[cfg(live_style)] + live_style::apply(&window); + + // The library grid: scan the remote tree into the catalog, then show what + // was found. Clicking a cell opens it in develop. + // + // Declared out here rather than inside the launch block below because the + // develop side reads `paths` to rebuild its browsing list when an image is + // opened from the grid. + let library = library_ui::LibraryController::new(); + // Launch screen: shown when there is nothing to display — no local paths // and no configured library. A user who has already signed in and chosen // a folder goes straight to their images (FR-NC-1). { let controller = launch_ui::LaunchController::new(); - let show = controller.should_show(!paths.is_empty()); - window.set_show_launch(show); - launch_ui::wire(&window, controller, |session| { - // Opening a remote library needs the scan-and-cache path, which - // lands with the catalog. Reporting that plainly beats a button - // that silently does nothing. - log::info!("open library requested for {}", session.describe()); + let startup = controller + .model + .borrow() + .startup_action(!paths.is_empty()); + window.set_show_launch(startup == launch::Startup::ShowLaunchScreen); + + let library = library.clone(); + let collections = collections_ui::CollectionsController::new(); + + // The click handler needs `show`, which is built further down because + // it captures the develop session and the GPU context. This cell is + // the knot between them: wired empty here, filled once `show` exists. + // A click before then is a no-op rather than a panic — the grid cannot + // be reached until the window is running, by which point it is set. + let open_from_library = open_from_library.clone(); + library_ui::wire( + &window, + library.clone(), + collections.clone(), + move |path| { + let Some(f) = open_from_library.borrow().clone() else { + log::warn!("open requested before the viewer was ready: {path}"); + return; + }; + f(path); + }, + ); + + // The collections sidebar shares the library's catalog handle rather + // than opening its own: one SQLite connection, so an edit here is + // visible to the grid's next read without a reopen. + // + // The reload closure is the seam between the two controllers. The + // sidebar decides *what* is scoped; the library owns the window, the + // offset and the thumbnail workers, so it is what actually reloads — + // and it must be told the scope before it reads, which is why both + // happen here in one place rather than each controller reaching for the + // other. + { + let weak = window.as_weak(); + let lib = library.clone(); + let coll = collections.clone(); + let lib_ids = library.clone(); + let lib_session = library.clone(); + collections_ui::wire( + &window, + collections.clone(), + library.catalog(), + move || { + let Some(w) = weak.upgrade() else { return }; + // Order matters: `set_scope` clears the trash flag, because + // picking a collection is how you leave the trash. Setting + // the flag second is what lets selecting the trash itself + // survive the call. + lib.set_scope(coll.scope()); + lib.set_viewing_trash(coll.viewing_trash()); + library_ui::reload(&w, &lib); + }, + move || lib_ids.visible_ids(), + // The trash's MOVE and DELETE go to the same account the scan + // and thumbnail workers use. + move || lib_session.session(), + ); + } + + let weak = window.as_weak(); + let store_ctl = controller.clone(); + let lib = library.clone(); + let coll = collections.clone(); + launch_ui::wire(&window, controller.clone(), move |session| { + let Some(w) = weak.upgrade() else { return }; + log::info!("opening library for {}", session.describe()); + library_ui::open(&w, lib.clone(), coll.clone(), &store_ctl.store, session); }); - if show { - log::info!("no library configured — showing the launch screen"); + + match startup { + launch::Startup::ShowLaunchScreen => { + log::info!("no library configured — showing the launch screen"); + } + launch::Startup::ShowLocalFiles => { + log::info!("{} file(s) named on the command line", paths.len()); + } + // Skipping the launch screen must not mean skipping the library: + // the "Open library" button lives on the screen we just bypassed, + // so nothing else would ever start the scan. + launch::Startup::OpenLibrary => { + let session = controller.model.borrow().session().cloned(); + if let Some(session) = session { + log::info!("resuming library for {}", session.describe()); + library_ui::open( + &window, + library.clone(), + collections.clone(), + &controller.store, + session, + ); + } + } } } @@ -246,7 +463,7 @@ pub fn run(paths: Vec) -> Result<()> { } }; - window.set_total(entries.len() as i32); + window.set_total(entries.borrow().len() as i32); let index = Rc::new(RefCell::new(0usize)); // The current develop session, if the file yielded sensor data. let session: Rc>> = Rc::new(RefCell::new(None)); @@ -269,10 +486,25 @@ pub fn run(paths: Vec) -> Result<()> { let mut slot = session.borrow_mut(); let Some(s) = slot.as_mut() else { return }; let (w, h) = *viewport.borrow(); - match s.render(w, h) { + + // Crop mode shows the whole frame, or the area being cropped away + // would not be on screen for the handles to drag across. The + // overlay draws the rect on top of it. + let rendered = if window.get_crop_mode() { + s.render_uncropped(w, h).map(|(image, _, _)| image) + } else { + s.render(w, h) + }; + + match rendered { Ok(image) => { window.set_canvas(image); window.set_load_error("".into()); + // The readout and the "Fit" button follow the session + // rather than the gesture, so a clamped zoom shows the + // value that was actually applied. + window.set_zoom(s.zoom()); + window.set_zoomed(s.is_zoomed()); } Err(e) => { log::warn!("render failed: {e}"); @@ -291,13 +523,20 @@ pub fn run(paths: Vec) -> Result<()> { let rows = rows.clone(); Rc::new(move |window: &AppWindow| { let i = *index.borrow(); - let Some(path) = entries.get(i) else { return }; + // Cloned rather than held: `load` below is slow, and keeping the + // list borrowed across it would panic the moment anything else + // touched `entries`. + let Some(path) = entries.borrow().get(i).cloned() else { + return; + }; + let path = path.as_path(); let name = path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); + reset_view_state(window); window.set_filename(name.clone().into()); window.set_index(i as i32); @@ -349,6 +588,108 @@ pub fn run(paths: Vec) -> Result<()> { }) }; + // Now `show` exists, close the knot left open at the library wiring. + // + // The grid's paths are *remote*: there is no local file to open, so the + // click starts a download and the image appears when it lands. That is a + // whole RAW file over WebDAV, so the wait is real and has to be visible — + // the status line says so rather than leaving a blank frame. + { + let weak = window.as_weak(); + let library = library.clone(); + let session = session.clone(); + let redraw = redraw.clone(); + let rows = rows.clone(); + let gpu = gpu.clone(); + *open_from_library.borrow_mut() = Some(Rc::new(move |path: String| { + let Some(w) = weak.upgrade() else { return }; + + let name = path.rsplit('/').next().unwrap_or(&path).to_string(); + reset_view_state(&w); + w.set_filename(name.clone().into()); + w.set_load_error("".into()); + w.set_camera("".into()); + w.set_exposure("".into()); + w.set_dimensions("".into()); + // The grid is one image at a time, so next/previous have nothing + // to walk. Shown as 1 of 1 rather than left reading 0. + w.set_index(0); + w.set_total(1); + + let Some((creds, user_id)) = library.credentials() else { + w.set_load_error("no library session".into()); + return; + }; + + log::info!("fetching {path} for develop"); + w.set_load_error("Downloading…".into()); + + let rx = library::spawn_full_fetch(creds, user_id, path.clone()); + + // Polled on the UI thread rather than joined: a join would freeze + // the window for the length of the download. + let weak = w.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + let rows = rows.clone(); + let gpu = gpu.clone(); + let timer = Rc::new(slint::Timer::default()); + let held = timer.clone(); + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(50), + move || { + let Ok(got) = rx.try_recv() else { return }; + // Landed — this timer has done its job. + held.stop(); + let Some(w) = weak.upgrade() else { return }; + + let bytes = match got { + Ok(b) => b, + Err(e) => { + log::warn!("{name}: {e}"); + w.set_load_error(e.into()); + return; + } + }; + log::info!("{name}: {} bytes fetched", bytes.len()); + + match load_bytes(gpu.as_ref(), &bytes) { + Ok(l) => { + w.set_load_error("".into()); + w.set_camera(describe_camera(&l.meta).into()); + w.set_exposure(describe_exposure(&l.meta).into()); + w.set_dimensions(format!("{} × {}", l.width, l.height).into()); + match l.session { + Some(s) => { + w.set_adjust_enabled(true); + *session.borrow_mut() = Some(s); + sync_rows(&w, &rows, &session); + redraw(&w); + } + None => { + *session.borrow_mut() = None; + rows.set_vec(Vec::::new()); + w.set_adjust_enabled(false); + if let Some(image) = l.fallback { + w.set_canvas(image); + } + } + } + log::info!("{name}: {}×{}", l.width, l.height); + } + Err(e) => { + log::warn!("{name}: {e}"); + *session.borrow_mut() = None; + w.set_adjust_enabled(false); + w.set_load_error(e.into()); + } + } + }, + ); + })); + } + // ---- Adjustment callbacks ------------------------------------------ // // Generic by construction: they carry indices into the capability list, @@ -413,6 +754,96 @@ pub fn run(paths: Vec) -> Result<()> { }); } + // ---- zoom, pan and crop --------------------------------------------- + // + // Zoom and pan are viewing state and touch no parameter, so unlike the + // handlers above they do not `sync_rows`. + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + window.on_zoom_at(move |factor, at_x, at_y| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.zoom_about(factor, at_x, at_y); + } + redraw(&w); + }); + } + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + window.on_pan_by(move |dx, dy| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.pan_by(dx, dy); + } + redraw(&w); + }); + } + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + window.on_zoom_reset(move || { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.reset_zoom(); + } + redraw(&w); + }); + } + { + // Entering crop mode drops the zoom: the handles are placed against + // the whole frame, and a zoomed view would put most of that frame off + // screen where it cannot be dragged. + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + window.on_crop_mode_toggled(move |on| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + if on { + s.reset_zoom(); + let c = s.crop(); + w.set_crop_x(c.x); + w.set_crop_y(c.y); + w.set_crop_w(c.width); + w.set_crop_h(c.height); + } + } + w.set_crop_mode(on); + redraw(&w); + }); + } + { + // The rect arrives raw from the drag; the session normalises it, and + // the properties are written back from what it actually stored. That + // round trip is what makes an over-drag slide along the edge rather + // than letting the overlay and the pipeline disagree. + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + window.on_crop_changed(move |x, y, width, height| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.set_crop(dr_pipeline::CropRect { + x, + y, + width, + height, + }); + let c = s.crop(); + w.set_crop_x(c.x); + w.set_crop_y(c.y); + w.set_crop_w(c.width); + w.set_crop_h(c.height); + } + redraw(&w); + }); + } + { let weak = window.as_weak(); let index = index.clone(); @@ -420,14 +851,15 @@ pub fn run(paths: Vec) -> Result<()> { let show = show.clone(); window.on_next_image(move || { let Some(w) = weak.upgrade() else { return }; - if entries.is_empty() { + let len = entries.borrow().len(); + if len == 0 { return; } // Read, then write — `*x.borrow_mut() = *x.borrow() + 1` holds // both borrows at once and panics. let next = { let cur = *index.borrow(); - (cur + 1) % entries.len() + (cur + 1) % len }; *index.borrow_mut() = next; show(&w); @@ -440,13 +872,14 @@ pub fn run(paths: Vec) -> Result<()> { let show = show.clone(); window.on_prev_image(move || { let Some(w) = weak.upgrade() else { return }; - if entries.is_empty() { + let len = entries.borrow().len(); + if len == 0 { return; } let prev = { let cur = *index.borrow(); if cur == 0 { - entries.len() - 1 + len - 1 } else { cur - 1 } @@ -489,7 +922,7 @@ pub fn run(paths: Vec) -> Result<()> { apply_layout_class(&window, size.width as f32 / scale); } - if !entries.is_empty() { + if !entries.borrow().is_empty() { show(&window); } diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs new file mode 100644 index 0000000..97a2615 --- /dev/null +++ b/ui/dr-ui/src/library.rs @@ -0,0 +1,2279 @@ +//! TRACES: FR-CAT-1 | FR-CAT-4 | FR-NC-3 | NFR-P9 +//! Opening a remote library: scan → catalog → grid. +//! +//! This is the wire between three pieces that already worked separately — +//! `dr_sync::scan` walks the tree, `dr_catalog` indexes it, and +//! `dr_decode::preview` turns bytes into pixels. Until now the "Open library" +//! button logged its intent and stopped. +//! +//! # Threading +//! +//! Slint's event loop is single-threaded and must never block (NFR-P9), so +//! every network and decode operation runs on a worker thread and results +//! return through an mpsc channel drained by a Slint timer. That is the same +//! shape the login flow uses; it is repeated rather than shared because the +//! message types differ and a generic version would obscure both. +//! +//! # Why thumbnails are fetched, not derived from the scan +//! +//! A scan yields paths and sizes, nothing visual. Each thumbnail costs its own +//! range request, so they are fetched **only for cells the grid actually +//! wants** — never for the whole library up front. On the reference library +//! that is the difference between a few MB and ~370 GB (ARCH §6.7). + +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, Sender}; + +use dr_catalog::{Catalog, JobKind, Priority}; +use dr_sync::{RemoteBackend, RemoteId, RemotePath}; +use dr_sync_nextcloud::{AppCredentials, NextcloudBackend}; +use dr_thumbs::ThumbStore; +use dr_types::FormatFilter; + +/// Largest preview worth fetching whole. +/// +/// A located preview above this is skipped rather than transferred: past a few +/// MB the saving over the full file stops justifying the wait, and a 256px +/// thumbnail needs nothing like that much detail. +const MAX_PREVIEW_BYTES: u64 = 8 * 1024 * 1024; + +/// Long edge of a grid thumbnail, in pixels. +const THUMBNAIL_EDGE: u32 = 256; + +/// Progress and results from the scan worker. +#[derive(Debug)] +pub enum ScanMessage { + /// Directories walked so far, and images found. + Progress { + directories: usize, + pruned: usize, + images: usize, + }, + /// The scan finished and the catalog is populated. + /// + /// `found` counts what this scan *listed*, which on an incremental rescan + /// is only what changed — pruned directories contribute nothing. `total` + /// is what the catalog actually holds, which is what the grid shows. + /// Conflating them made a successful no-op rescan report "0 images" and + /// blank the library. + Done { + found: usize, + total: usize, + pruned: usize, + elapsed_ms: u64, + }, + Failed(String), +} + +/// One decoded thumbnail, ready for the grid. +#[derive(Debug)] +pub struct ThumbnailReady { + /// Index into the grid model this belongs to. + pub row: usize, + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +/// Capture metadata read from the same header the thumbnail needed. +/// +/// Free: the header fetch happens either way, so parsing EXIF out of it costs +/// no extra transfer. That is what fills the timeline as the user browses, +/// rather than a separate 6 GB sweep over the library. +#[derive(Debug, Clone)] +pub struct MetadataFound { + pub image_id: i64, + pub captured_at: Option, + pub captured_offset: Option, + pub camera: Option, + pub lens: Option, + pub iso: Option, +} + +/// Messages from the thumbnail worker. +#[derive(Debug)] +pub enum ThumbnailMessage { + Ready(Box), + /// No preview could be extracted. The cell stays a placeholder rather than + /// silently retrying forever. + Unavailable { row: usize, reason: String }, + /// How the batch split between the store and the network. + /// + /// Sent once, before any fetch. Without it there is no way to tell a + /// working cache from a broken one — both fill the grid, one just costs + /// nothing. + Plan { + cached: usize, + fetching: usize, + dating: usize, + }, + /// One header-only date read is starting. + /// + /// Reported separately from thumbnail progress: this work produces no + /// visible cell, so without it the window looks idle while it runs. + DateProgress, + /// Capture dates were written to the catalog. + /// + /// The timeline is rebuilt on this rather than per image — a histogram + /// that redrew 120 times during a batch would flicker for no benefit. + DatesRecorded(usize), +} + +/// TRACES: FR-CAT-15 | FR-CAT-11 +/// What it means for an image to be visible in the library. +/// +/// Two exclusions, for two different reasons, and both must appear in *every* +/// query that counts or lists cells — the grid, the timeline, the metadata +/// sweep. A predicate present in four of five places is worse than absent: the +/// counts disagree with the cells and neither looks wrong on its own. +/// +/// - `shadowed_by IS NULL` — a JPEG the camera wrote beside its RAW is that +/// same frame, not a second photograph. +/// - `trashed_at IS NULL` — a soft-deleted image has been moved to the trash +/// folder and is listed only by the trash view. +const VISIBLE: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NULL"; + +/// [`VISIBLE`] for queries that do not alias `images`. +const VISIBLE_UNALIASED: &str = "shadowed_by IS NULL AND trashed_at IS NULL"; + +/// TRACES: FR-CAT-15 +/// What the *trash view* lists: exactly what [`VISIBLE`] excludes on the second +/// clause, and still excludes on the first. +/// +/// The inversion is deliberate and only correct on `trashed_at`. A shadowed JPEG +/// is not a separate photograph in the trash any more than it is in the library +/// — trashing a RAW takes its sibling with it, and listing both would offer to +/// restore the same frame twice. +const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL"; + +/// TRACES: FR-CAT-6 | FR-CULL-4 +/// What the grid is narrowed to by the rating filter bar. +/// +/// Applied in **SQL**, not by filtering the rows after reading them. On a +/// remote library a drawn-then-hidden cell has already cost a thumbnail +/// fetch, which is the transfer FR-NC-3 exists to avoid — and the count in the +/// header has to agree with the cells, which it cannot if the two are computed +/// at different stages. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct RatingFilter { + /// Minimum stars. 0 means no star constraint. + pub min_rating: u8, + /// Only images nothing has judged yet — neither starred nor flagged. + /// This is what lets a culling session resume where it stopped. + pub unjudged: bool, + /// `None` for no flag constraint, otherwise exactly that flag. + pub flag: Option, +} + +impl RatingFilter { + /// Whether this narrows anything, so the caller can skip the join. + pub fn is_unfiltered(&self) -> bool { + self.min_rating == 0 && !self.unjudged && self.flag.is_none() + } + + /// The SQL predicate, against an `images` aliased as `i`. + /// + /// Returns a `String` of conditions ANDed together, or an empty string + /// where nothing is constrained. Every branch is built from integers this + /// code owns — no caller text reaches the SQL, so there is nothing to + /// escape. + /// + /// A correlated subquery per term rather than a join to `versions`: an + /// image with no version row must still be *findable* as unrated, and an + /// inner join would silently drop exactly those images — the ones a + /// library scanned before ratings existed consists entirely of. + fn sql(&self) -> String { + let mut terms = Vec::new(); + + if self.min_rating > 0 { + terms.push(format!( + "coalesce((SELECT dv.rating FROM versions dv + WHERE dv.image_id = i.id AND dv.is_default = 1 + LIMIT 1), 0) >= {}", + self.min_rating + )); + } + + if self.unjudged { + // Both axes: a frame that was picked but never starred has been + // judged, and re-presenting it would undo the user's decision to + // move past it. + terms.push( + "coalesce((SELECT dv.rating FROM versions dv + WHERE dv.image_id = i.id AND dv.is_default = 1 + LIMIT 1), 0) = 0 + AND coalesce((SELECT dv.flag FROM versions dv + WHERE dv.image_id = i.id AND dv.is_default = 1 + LIMIT 1), 0) = 0" + .to_string(), + ); + } + + if let Some(flag) = self.flag { + terms.push(format!( + "coalesce((SELECT dv.flag FROM versions dv + WHERE dv.image_id = i.id AND dv.is_default = 1 + LIMIT 1), 0) = {}", + flag_code(flag) + )); + } + + if terms.is_empty() { + String::new() + } else { + format!(" AND ({})", terms.join(") AND (")) + } + } +} + +/// The stored integer for a flag, matching `dr_catalog::rating`'s encoding. +fn flag_code(f: dr_types::FlagState) -> i64 { + match f { + dr_types::FlagState::Unflagged => 0, + dr_types::FlagState::Pick => 1, + dr_types::FlagState::Reject => 2, + } +} + +/// TRACES: FR-CAT-8 | FR-NC-8 | FR-CULL-4 +/// One image's judgement, on its way to a sidecar. +#[derive(Debug, Clone)] +pub struct JudgementWrite { + /// Remote path of the *image*. The sidecar sits beside it, with the + /// extension replaced — that adjacency is what makes a sidecar findable + /// without an index (ARCH §6.12). + pub image_path: String, + pub version_uuid: String, + pub rating: u8, + pub flag: u8, +} + +/// Where an image's sidecar lives. +/// +/// The image's own path with the extension replaced, not appended: `a.CR2` +/// becomes `a.drsc`, so a RAW and the JPEG beside it share one sidecar and +/// therefore one judgement. That is the intended behaviour — they are the same +/// photograph (FR-CAT-11), and the pairing logic in `dr_catalog::schema` +/// already treats them so. +pub fn sidecar_path(image_path: &str) -> String { + let stem = match image_path.rsplit_once('.') { + // Only an extension in the final segment counts; a dot in a directory + // name must not truncate the path. + Some((stem, ext)) if !ext.contains('/') => stem, + _ => image_path, + }; + format!("{stem}.{}", dr_pipeline::sidecar::EXTENSION) +} + +/// Persist judgements to sidecars beside their images. +/// +/// # Why this reads before it writes +/// +/// A sidecar is the authoritative store and may already hold an edit made on +/// this or another device. Writing a fresh document containing only a rating +/// would delete that edit — the exact silent data loss the format's +/// unknown-key preservation exists to prevent. So each file is fetched, +/// parsed, amended, and written back; a fetch that 404s simply means there is +/// no sidecar yet and a new one is created. +/// +/// # Why failure here is logged rather than surfaced +/// +/// The catalog write has already succeeded by the time this runs, so the star +/// is on screen and will survive a restart. A network failure costs +/// *durability across a catalog rebuild*, not the judgement itself, and +/// interrupting a cull with an error dialog per frame would be far worse than +/// the risk. The count of failures is reported once, at the end. +pub fn spawn_sidecar_writes( + creds: AppCredentials, + user_id: String, + writes: Vec, +) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(SidecarMessage::Finished { + written: 0, + failed: writes.len(), + last_error: Some(e.to_string()), + }); + return; + } + }; + + rt.block_on(async { + let backend = match NextcloudBackend::new(&creds, &user_id) { + Ok(b) => b, + Err(e) => { + let _ = tx.send(SidecarMessage::Finished { + written: 0, + failed: writes.len(), + last_error: Some(e.to_string()), + }); + return; + } + }; + + let (mut written, mut failed) = (0usize, 0usize); + let mut last_error = None; + + for w in &writes { + match write_one_sidecar(&backend, w).await { + Ok(()) => written += 1, + Err(e) => { + log::debug!("sidecar for {}: {e}", w.image_path); + last_error = Some(e); + failed += 1; + } + } + } + + let _ = tx.send(SidecarMessage::Finished { + written, + failed, + last_error, + }); + }); + }); + + rx +} + +/// The outcome of a batch of sidecar writes. +#[derive(Debug)] +pub enum SidecarMessage { + Finished { + written: usize, + failed: usize, + /// Reported once rather than per file: a network that is down fails + /// every write with the same message, and forty identical lines in the + /// status bar say nothing forty times. + last_error: Option, + }, +} + +/// Read-modify-write one sidecar. +async fn write_one_sidecar( + backend: &NextcloudBackend, + w: &JudgementWrite, +) -> Result<(), String> { + let path = RemotePath::new(sidecar_path(&w.image_path)); + let id = RemoteId::Path(path.clone()); + + // An existing sidecar may hold an edit. Absent is the normal case on a + // library that has never been edited, and is not an error. + let existing = backend.get(&id, None).await.ok(); + let mut sidecar = existing + .as_deref() + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) + .and_then(|text| match dr_pipeline::Sidecar::parse(&text) { + Ok(s) => Some(s), + // A corrupt sidecar is *not* overwritten silently: that would + // destroy an edit this build merely failed to understand. The + // judgement stays in the catalog and the file is left alone. + Err(e) => { + log::warn!("sidecar at {} is unreadable ({e}); not overwriting", path.as_str()); + None + } + }) + .unwrap_or_default(); + + // A parse failure above means we must not touch the file at all. + if existing.is_some() && sidecar.versions.is_empty() && existing.as_deref() != Some(b"") { + // Distinguish "empty file" from "unparseable": only the latter is a + // refusal, and it already logged. + let text = existing + .as_deref() + .map(|b| String::from_utf8_lossy(b).into_owned()) + .unwrap_or_default(); + if dr_pipeline::Sidecar::parse(&text).is_err() { + return Err("existing sidecar is unreadable".into()); + } + } + + // Amend the version this judgement belongs to, creating it if the file did + // not have one. The uuid comes from the catalog, so the same photograph + // keeps one identity across devices (FR-NC-8). + let mut version = sidecar + .versions + .get(&w.version_uuid) + .cloned() + .unwrap_or_else(|| dr_pipeline::sidecar::Version { + uuid: w.version_uuid.clone(), + name: "Default".to_string(), + is_default: true, + revision: 0, + ..Default::default() + }); + + version.rating = w.rating; + version.flag = w.flag; + // A judgement is an edit as far as the merge is concerned: without the + // bump, a device that rated the same frame earlier would win on revision + // and this rating would be discarded at the next sync (FR-NC-9). + version.revision = version.revision.saturating_add(1); + version.modified = now_secs(); + + sidecar.put(version); + + backend + .put(&path, sidecar.to_text().into_bytes(), None) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +/// Where the catalog for an account lives. +/// +/// Keyed by server and user so two accounts do not share an index. Under the +/// XDG data directory, not cache: the catalog is rebuildable but rebuilding it +/// costs a full rescan, so it is not something to discard on a cache sweep. +pub fn catalog_path(server: &str, user_id: &str) -> PathBuf { + let slug: String = server + .trim_start_matches("https://") + .trim_start_matches("http://") + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + + let base = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))) + .unwrap_or_else(std::env::temp_dir); + + base.join("darkroom") + .join(format!("{slug}-{user_id}")) + .join("catalog.sqlite") +} + +/// Run a scan on a worker thread, writing results into the catalog. +/// +/// Returns the receiver the UI drains. The worker owns its own tokio runtime +/// and backend; nothing here touches the Slint event loop. +pub fn spawn_scan( + creds: AppCredentials, + user_id: String, + root: String, + filter: FormatFilter, + catalog_path: PathBuf, +) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let started = std::time::Instant::now(); + if let Err(e) = run_scan(&tx, creds, user_id, root, filter, catalog_path, started) { + let _ = tx.send(ScanMessage::Failed(e)); + } + }); + + rx +} + +fn run_scan( + tx: &Sender, + creds: AppCredentials, + user_id: String, + root: String, + filter: FormatFilter, + catalog_path: PathBuf, + started: std::time::Instant, +) -> Result<(), String> { + if let Some(dir) = catalog_path.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?; + } + let catalog = Catalog::open(&catalog_path).map_err(|e| e.to_string())?; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string())?; + + rt.block_on(async { + let backend = NextcloudBackend::new(&creds, &user_id).map_err(|e| e.to_string())?; + + // Stored folder ETags, so an unchanged subtree is skipped whole. On a + // first run this is empty and the walk is complete; on every run after + // it is what keeps cost proportional to what changed (ARCH §8.4). + let known = load_folder_etags(&catalog, &root); + + let result = dr_sync::scan( + &backend, + &RemotePath::new(&root), + &filter, + &known, + |p| { + let _ = tx.send(ScanMessage::Progress { + directories: p.directories_listed, + pruned: p.directories_pruned, + images: p.images_found, + }); + }, + ) + .await + .map_err(|e| e.to_string())?; + + persist(&catalog, &root, &result).map_err(|e| e.to_string())?; + + // Report what the catalog holds, not what this pass listed. An + // incremental rescan lists only what changed, so its own count is + // near zero on a healthy library. + let total = total_images(&catalog).unwrap_or(result.images.len()); + + let _ = tx.send(ScanMessage::Done { + found: result.images.len(), + total, + pruned: result.progress.directories_pruned, + elapsed_ms: started.elapsed().as_millis() as u64, + }); + Ok(()) + }) +} + +/// Read back the folder ETags stored by a previous scan. +/// +/// A failure here is not fatal — an empty map simply means no pruning, which +/// is correct but slower. Refusing to scan because the last scan's bookkeeping +/// is unreadable would be the worse outcome. +fn load_folder_etags( + catalog: &Catalog, + root: &str, +) -> std::collections::HashMap { + let mut out = std::collections::HashMap::new(); + let sql = "SELECT f.path, f.etag FROM folders f + JOIN roots r ON r.id = f.root_id + WHERE r.label = ?1 AND f.etag IS NOT NULL"; + + let Ok(mut stmt) = catalog.connection().prepare(sql) else { + return out; + }; + let rows = stmt.query_map([root], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + }); + if let Ok(rows) = rows { + for (path, etag) in rows.flatten() { + out.insert(RemotePath::new(path), dr_sync::Validator::new(etag)); + } + } + out +} + +/// Write a scan's findings into the catalog. +/// +/// Images insert at `metadata_state = 1` (stat-only): the scan knows name and +/// size but has read no EXIF, and pretending otherwise would make a date +/// filter silently wrong. A `Thumbnail` job is enqueued per image, coalescing +/// with anything already pending. +fn persist( + catalog: &Catalog, + root: &str, + result: &dr_sync::ScanResult, +) -> Result<(), dr_catalog::CatalogError> { + let conn = catalog.connection(); + let tx = conn.unchecked_transaction()?; + + // One root row per library folder, reused across scans. + tx.execute( + "INSERT INTO roots(kind, label, last_seen) VALUES ('remote', ?1, ?2) + ON CONFLICT DO NOTHING", + rusqlite::params![root, now_secs()], + )?; + let root_id: i64 = tx.query_row( + "SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'", + [root], + |r| r.get(0), + )?; + + // Folder ETags first — without these persisted, the next scan prunes + // nothing and walks the whole tree again (ARCH §6.6). + for (path, validator) in &result.directories { + tx.execute( + "INSERT INTO folders(root_id, path, etag) VALUES (?1, ?2, ?3) + ON CONFLICT(root_id, path) DO UPDATE SET etag = excluded.etag", + rusqlite::params![root_id, path.as_str(), validator.as_str()], + )?; + } + + for entry in &result.images { + let folder_id: Option = entry.path.parent().and_then(|p| { + tx.query_row( + "SELECT id FROM folders WHERE root_id = ?1 AND path = ?2", + rusqlite::params![root_id, p.as_str()], + |r| r.get(0), + ) + .ok() + }); + + tx.execute( + "INSERT INTO images(root_id, folder_id, source_ref, format, file_size, + availability, metadata_state, added_at) + VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6) + ON CONFLICT(root_id, source_ref) DO UPDATE SET + file_size = excluded.file_size, + folder_id = excluded.folder_id", + rusqlite::params![ + root_id, + folder_id, + entry.path.as_str(), + entry + .path + .name() + .rsplit_once('.') + .map(|(_, e)| e.to_ascii_lowercase()), + entry.size as i64, + now_secs(), + ], + )?; + + let image_id: i64 = tx.query_row( + "SELECT id FROM images WHERE root_id = ?1 AND source_ref = ?2", + rusqlite::params![root_id, entry.path.as_str()], + |r| r.get(0), + )?; + + // Remote identity, keyed on oc:fileid so a server-side move is a move + // rather than a re-download (FR-NC-5). + if let RemoteId::Stable(file_id) = entry.id { + tx.execute( + "INSERT INTO remote(image_id, file_id, etag, remote_path) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(image_id) DO UPDATE SET + etag = excluded.etag, remote_path = excluded.remote_path", + rusqlite::params![ + image_id, + file_id as i64, + entry.validator.as_str(), + entry.path.as_str() + ], + )?; + } + } + + tx.commit()?; + + // Thumbnail jobs after the commit, so a failure mid-insert does not leave + // jobs pointing at rows that never landed. + for entry in &result.images { + if let Ok(image_id) = conn.query_row( + "SELECT id FROM images WHERE root_id = ?1 AND source_ref = ?2", + rusqlite::params![root_id, entry.path.as_str()], + |r| r.get::<_, i64>(0), + ) { + let _ = dr_catalog::jobs::enqueue( + conn, + JobKind::Thumbnail, + Some(image_id), + Priority::Background, + None, + ); + } + } + + Ok(()) +} + +/// What the grid wants a thumbnail for. +/// +/// Carries the `oc:fileid` as well as the path, because that is what the +/// shared store keys on — stable across a server-side move, and the same id +/// every other client sees (FR-NC-5). +#[derive(Debug, Clone)] +pub struct ThumbnailRequest { + pub row: usize, + pub path: String, + /// `None` where the scan found no stable id; such an image is fetched but + /// not stored, since there is no durable key to store it under. + pub file_id: Option, + /// File length, needed to reject a preview range that points past the end + /// of the file (NFR-SEC-1). + pub size: u64, + /// Catalog row, so EXIF read from the header can be written back. + pub image_id: i64, + /// Whether this image still needs its EXIF read. Where false the header is + /// still fetched — the preview needs it — but nothing is parsed or written. + pub needs_metadata: bool, +} + +/// Fetch one file in full, for opening it in develop. +/// +/// Deliberately *not* the preview path. Browsing fetches a range and decodes +/// an embedded JPEG (FR-NC-3); develop needs every byte, because demosaic +/// needs every photosite. On a RAW file that is tens of megabytes, which is +/// why this is a click-triggered download and not something the grid does. +/// +/// Returns the bytes on a channel rather than blocking: the download runs on +/// its own thread and the UI stays live, exactly as thumbnail fetching does. +pub fn spawn_full_fetch( + creds: AppCredentials, + user_id: String, + path: String, +) -> Receiver, String>> { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(Err(e.to_string())); + return; + } + }; + + rt.block_on(async { + let backend = match NextcloudBackend::new(&creds, &user_id) { + Ok(b) => b, + Err(e) => { + let _ = tx.send(Err(e.to_string())); + return; + } + }; + + let id = RemoteId::Path(RemotePath::new(&path)); + let got = backend + .get(&id, None) + .await + .map_err(|e| e.to_string()); + let _ = tx.send(got); + }); + }); + + rx +} + +/// Serve thumbnails for a set of rows: store first, network second. +/// +/// The store is consulted before any request goes out, so a second launch — +/// or a second device that synced the shards — fills the grid with no transfer +/// at all. Only genuine misses reach the network. +pub fn spawn_thumbnails( + creds: AppCredentials, + user_id: String, + wanted: Vec, + store_dir: PathBuf, + catalog_path: PathBuf, +) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let mut store = match ThumbStore::open(&store_dir) { + Ok(s) => Some(s), + Err(e) => { + // A broken store costs speed, never correctness — every + // thumbnail can still be fetched. + log::warn!("thumbnail store unavailable, fetching everything: {e}"); + None + } + }; + + // Split the batch before delivering anything, so the plan can be + // reported first and the UI knows the shape of the work up front. + // Decoding happens here rather than in the split, because a corrupt + // blob turns a hit into a miss. + let mut hits = Vec::new(); + let mut to_fetch = Vec::new(); + // Images whose thumbnail is cached but whose date is still unknown. + // + // These need a header read even though no pixels are wanted. Without + // this pass an image is dated *only* on the one visit that produced + // its thumbnail — so a library browsed once before the EXIF code + // existed, or synced from another device's shards, stays permanently + // undated and never appears on the timeline. + let mut metadata_only = Vec::new(); + + for req in wanted { + let stored = req + .file_id + .zip(store.as_ref()) + .and_then(|(id, s)| s.get(id).ok().flatten()); + + match stored.map(|t| dr_thumbs::decode_rgba(&t.bytes)) { + Some(Ok((width, height, rgba))) => { + if req.needs_metadata { + metadata_only.push(req.clone()); + } + hits.push(ThumbnailReady { + row: req.row, + width, + height, + rgba, + }); + } + // A corrupt stored blob is a miss, not a failure. + Some(Err(e)) => { + log::debug!("stored thumbnail unreadable, refetching: {e}"); + to_fetch.push(req); + } + None => to_fetch.push(req), + } + } + + log::info!( + "thumbnails: {} from store, {} to fetch{}", + hits.len(), + to_fetch.len(), + if metadata_only.is_empty() { + String::new() + } else { + format!(" · {} dates to read", metadata_only.len()) + } + ); + if tx + .send(ThumbnailMessage::Plan { + cached: hits.len(), + fetching: to_fetch.len(), + dating: metadata_only.len(), + }) + .is_err() + { + return; + } + + for hit in hits { + if tx.send(ThumbnailMessage::Ready(Box::new(hit))).is_err() { + return; + } + } + + if to_fetch.is_empty() && metadata_only.is_empty() { + return; + } + + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + for req in &to_fetch { + let _ = tx.send(ThumbnailMessage::Unavailable { + row: req.row, + reason: e.to_string(), + }); + } + return; + } + }; + + rt.block_on(async { + let backend = match NextcloudBackend::new(&creds, &user_id) { + Ok(b) => b, + Err(e) => { + for req in &to_fetch { + let _ = tx.send(ThumbnailMessage::Unavailable { + row: req.row, + reason: e.to_string(), + }); + } + return; + } + }; + + // Batched rather than written per image: one transaction per + // batch instead of 120, and the grid does not need each date the + // instant it is read. + let mut found = Vec::new(); + + for req in to_fetch { + let msg = fetch_one(&backend, store.as_mut(), &req, &mut found).await; + // A closed channel means the window went away mid-fetch. + if tx.send(msg).is_err() { + break; + } + } + + // Dates for images whose pixels were already cached. Header only — + // no preview range, no decode. + // + // Flushed in chunks rather than once at the end: 119 sequential + // header fetches take tens of seconds, and a single write at the + // finish loses every one of them if the window closes first. It + // also lets the timeline appear while the rest are still arriving. + const FLUSH_EVERY: usize = 16; + log::info!("reading dates for {} image(s)", metadata_only.len()); + for req in metadata_only { + if tx.send(ThumbnailMessage::DateProgress).is_err() { + break; + } + read_metadata_only(&backend, &req, &mut found).await; + + if found.len() >= FLUSH_EVERY { + flush_metadata(&catalog_path, &mut found, &tx); + } + } + + flush_metadata(&catalog_path, &mut found, &tx); + }); + }); + + rx +} + +/// Fetch a preview in two stages: header, then the exact preview range. +/// +/// This is what FR-NC-3 specifies, and the single-stage version it replaces +/// was wrong in a way that looked like corruption: fetching a fixed prefix cut +/// the embedded JPEG partway through, and decoders render a truncated JPEG as +/// the top fraction of the frame rather than reporting an error. +async fn fetch_one( + backend: &NextcloudBackend, + store: Option<&mut ThumbStore>, + req: &ThumbnailRequest, + found_metadata: &mut Vec, +) -> ThumbnailMessage { + let id = RemoteId::Path(RemotePath::new(&req.path)); + let fail = |reason: String| ThumbnailMessage::Unavailable { + row: req.row, + reason, + }; + + // Stage one: the header, enough to parse the container's IFDs. + let header = match backend.get(&id, Some(0..dr_decode::HEADER_BYTES)).await { + Ok(b) => b, + Err(e) => return fail(e.to_string()), + }; + + // The same bytes carry EXIF. Reading it here is free — the alternative is + // a second 256 KB fetch per image over the whole library. + if req.needs_metadata { + collect_metadata(&header, req, found_metadata); + } + + // A plain JPEG is its own preview; anything else needs locating. + let bytes = if header.starts_with(&[0xFF, 0xD8, 0xFF]) { + match backend.get(&id, None).await { + Ok(b) => b, + Err(e) => return fail(e.to_string()), + } + } else { + let Some(loc) = dr_decode::locate_preview(&header, req.size) else { + // No locatable preview. Declining beats fetching the whole file: + // that is the 370 GB path FR-NC-3 exists to avoid. + return fail("no locatable embedded preview".into()); + }; + if loc.len() > MAX_PREVIEW_BYTES { + return fail(format!("preview is {} bytes, too large", loc.len())); + } + + // Stage two: exactly the preview's bytes. + match backend.get(&id, Some(loc.range.clone())).await { + Ok(b) => b, + Err(e) => return fail(e.to_string()), + } + }; + + // Verify before decoding. A truncated JPEG decodes "successfully" into a + // partial frame, so without this the broken result reaches the cache and + // the screen looking like a corrupt file. + if !dr_decode::is_complete_jpeg(&bytes) { + return fail("preview bytes are incomplete".into()); + } + + // Decode on the worker, never the UI thread. + let mut preview = match dr_decode::decode_jpeg(&bytes) { + Ok(p) => p, + Err(e) => return fail(e.to_string()), + }; + preview.downscale_to(THUMBNAIL_EDGE); + + // Persist for next time, and for every other client that syncs the shard. + // A store failure is logged and dropped: the pixels are already in hand, + // and refusing to display them because they could not be cached would be + // the wrong trade. + if let (Some(store), Some(file_id)) = (store, req.file_id) { + match dr_thumbs::encode_rgba(preview.width, preview.height, &preview.rgba) { + Ok(encoded) => { + let thumb = dr_thumbs::Thumbnail { + width: preview.width, + height: preview.height, + bytes: encoded, + }; + if let Err(e) = store.put(file_id, &thumb) { + log::debug!("storing thumbnail {file_id}: {e}"); + } + } + Err(e) => log::debug!("encoding thumbnail {file_id}: {e}"), + } + } + + ThumbnailMessage::Ready(Box::new(ThumbnailReady { + row: req.row, + width: preview.width, + height: preview.height, + rgba: preview.rgba, + })) +} + +/// Parse EXIF out of a header and record it. +/// +/// Shared by both paths — the thumbnail fetch, which gets the header anyway, +/// and the header-only pass for images whose pixels were already cached. +fn collect_metadata(header: &[u8], req: &ThumbnailRequest, out: &mut Vec) { + let Ok(md) = dr_decode::metadata(header) else { + return; + }; + out.push(MetadataFound { + image_id: req.image_id, + captured_at: md.captured_at, + captured_offset: md.captured_offset, + camera: match (&md.make, &md.model) { + // Bodies repeat the make inside the model ("Canon EOS 6D"), so + // joining unconditionally yields "Canon Canon EOS 6D". + (Some(make), Some(model)) if model.starts_with(make.as_str()) => { + Some(model.trim().to_string()) + } + (Some(make), Some(model)) => Some(format!("{} {}", make.trim(), model.trim())), + (None, Some(model)) => Some(model.trim().to_string()), + _ => None, + }, + lens: md.lens.map(|l| l.trim().to_string()), + iso: md.iso, + }); +} + +/// Write a batch of dates and tell the UI, draining `found`. +/// +/// Separate from the loop so the same path serves both the periodic flush and +/// the final one, and so a write failure is reported once rather than being +/// silently swallowed by the caller. +fn flush_metadata( + catalog_path: &std::path::Path, + found: &mut Vec, + tx: &Sender, +) { + if found.is_empty() { + return; + } + match Catalog::open(catalog_path) { + Ok(cat) => match write_metadata(&cat, found) { + Ok(n) => { + log::info!("recorded capture dates for {n} of {} image(s)", found.len()); + // Tell the UI so the timeline can appear. Without this the + // histogram only shows up on the next window load, which on a + // fully cached library may be never. + let _ = tx.send(ThumbnailMessage::DatesRecorded(n)); + } + Err(e) => log::warn!("writing metadata: {e}"), + }, + Err(e) => log::warn!("opening catalog to write metadata: {e}"), + } + found.clear(); +} + +/// Read only the date for an image whose thumbnail is already cached. +/// +/// One 256 KB header request, no preview range and no decode. This is what +/// gets a library dated when its thumbnails came from the store — including +/// shards synced from another device, which carry pixels but no metadata. +async fn read_metadata_only( + backend: &NextcloudBackend, + req: &ThumbnailRequest, + found: &mut Vec, +) { + let id = RemoteId::Path(RemotePath::new(&req.path)); + match backend.get(&id, Some(0..dr_decode::HEADER_BYTES)).await { + Ok(header) => collect_metadata(&header, req, found), + // Not worth surfacing: the cell is already showing its thumbnail, and + // a missing date leaves the image off the timeline rather than broken. + Err(e) => log::debug!("reading date for {}: {e}", req.path), + } +} + +/// Write capture metadata read during the thumbnail pass. +/// +/// Promotes each row from `metadata_state = 1` (stat-only) to 2 (full EXIF), +/// which is what makes it eligible for the timeline. A row whose EXIF was +/// unreadable stays at 1 rather than being marked done with empty fields, so a +/// later attempt can retry it. +/// +/// Returns how many rows were promoted. +pub fn write_metadata( + catalog: &Catalog, + found: &[MetadataFound], +) -> Result { + let conn = catalog.connection(); + let tx = conn.unchecked_transaction()?; + let mut promoted = 0; + + for m in found { + // Only a real timestamp counts as fully read. Camera and lens without + // a date leave the image unplaceable on a timeline, which is exactly + // the state the grid needs to distinguish. + let state = if m.captured_at.is_some() { 2 } else { 1 }; + + tx.execute( + "UPDATE images + SET captured_at = coalesce(?2, captured_at), + captured_offset = coalesce(?3, captured_offset), + camera = coalesce(?4, camera), + lens = coalesce(?5, lens), + iso = coalesce(?6, iso), + metadata_state = max(metadata_state, ?7) + WHERE id = ?1", + rusqlite::params![ + m.image_id, + m.captured_at, + m.captured_offset, + m.camera, + m.lens, + m.iso, + state, + ], + )?; + if state == 2 { + promoted += 1; + } + } + + tx.commit()?; + Ok(promoted) +} + +/// Progress from the whole-library sweep. +#[derive(Debug)] +pub enum SweepMessage { + /// How many images still need work, counted once at the start. + Total(usize), + /// Another chunk finished. Carries cumulative counts. + Progress { done: usize, dated: usize }, + Finished { dated: usize }, +} + + +/// Await every future concurrently, returning results in order. +/// +/// A hand-rolled `join_all` rather than a `futures` dependency for one +/// function. Polling a `Vec` of futures in a loop is exactly what the crate's +/// version does; the ordering guarantee is what lets the caller pair results +/// back to their inputs. +async fn futures_join_all(futures: impl IntoIterator) -> Vec +where + F: std::future::Future, +{ + use std::pin::Pin; + use std::task::Poll; + + // Boxed so each future has a stable address while it is polled in place. + let mut pending: Vec>>> = + futures.into_iter().map(|f| Some(Box::pin(f))).collect(); + let mut done: Vec> = (0..pending.len()).map(|_| None).collect(); + + std::future::poll_fn(move |cx| { + let mut all_ready = true; + for (slot, out) in pending.iter_mut().zip(done.iter_mut()) { + let Some(fut) = slot else { continue }; + match fut.as_mut().poll(cx) { + Poll::Ready(v) => { + *out = Some(v); + // Dropped as soon as it completes, so a long-running lane + // does not hold a finished one's resources. + *slot = None; + } + Poll::Pending => all_ready = false, + } + } + if all_ready { + Poll::Ready(done.iter_mut().filter_map(Option::take).collect()) + } else { + Poll::Pending + } + }) + .await +} + +/// How many images one sweep chunk handles before committing. +/// +/// Small enough that a kill loses little, large enough that the catalog is not +/// reopened per image. A multiple of [`SWEEP_LANES`] so every lane gets equal +/// work and no chunk ends with most lanes idle. +const SWEEP_CHUNK: usize = 96; + +/// How many fetches the sweep keeps in flight. +/// +/// Each is ~0.6 s of round-trip latency and almost no bandwidth — a 256 KB +/// header — so the sequential version spent essentially all its time waiting. +/// Twelve lanes turn ~3 hours into ~15 minutes on the reference library. +/// +/// Deliberately bounded rather than unlimited: the grid's own interactive +/// fetches share this server, and a sweep that saturated the connection would +/// make browsing feel broken while it ran. +const SWEEP_LANES: usize = 12; + +/// Date **every** image in the library, not just the ones on screen. +/// +/// Thumbnails are deliberately *not* fetched here. A thumbnail needs the +/// mutable store, which cannot be shared across the parallel lanes below, and +/// it costs 1–3 MB against a date's 256 KB. Dating the whole library is what +/// the timeline needs; thumbnails arrive as cells are actually browsed, which +/// is the FR-NC-3 posture anyway. +/// +/// The grid's own fetches cover what is on screen; this covers the rest, so the +/// timeline describes the whole library rather than the part that happened to +/// be scrolled past. It is resumable by construction — each pass queries for +/// what is still missing, so a kill mid-sweep costs only the current chunk. +/// +/// Runs at the back of the queue by design: it holds no lock the grid needs, +/// and its chunked commits keep write transactions short. +pub fn spawn_sweep( + creds: AppCredentials, + user_id: String, + catalog_path: PathBuf, +) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let catalog = match Catalog::open(&catalog_path) { + Ok(c) => c, + Err(e) => { + // Silent failure here left the sweep looking like it had run + // and found nothing: no progress, no error, 17,397 images + // still unindexed. + log::warn!("sweep: cannot open catalog at {}: {e}", catalog_path.display()); + let _ = tx.send(SweepMessage::Finished { dated: 0 }); + return; + } + }; + + let outstanding = count_outstanding(&catalog).unwrap_or(0); + if outstanding == 0 { + let _ = tx.send(SweepMessage::Finished { dated: 0 }); + return; + } + log::info!("sweep: {outstanding} image(s) need a date or a thumbnail"); + if tx.send(SweepMessage::Total(outstanding)).is_err() { + return; + } + + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + log::warn!("sweep: no runtime: {e}"); + return; + } + }; + + rt.block_on(async { + let Ok(backend) = NextcloudBackend::new(&creds, &user_id) else { + return; + }; + + let (mut done, mut dated) = (0usize, 0usize); + loop { + // Re-queried each pass rather than held as one long list: the + // grid is dating images at the same time, and a stale list + // would refetch what it already covered. + let chunk = match next_outstanding(&catalog, SWEEP_CHUNK) { + Ok(c) if c.is_empty() => break, + Ok(c) => c, + Err(e) => { + log::warn!("sweep: {e}"); + break; + } + }; + + let chunk_started = std::time::Instant::now(); + log::debug!( + "sweep: chunk of {} starting at image {}", + chunk.len(), + chunk[0].image_id + ); + + // Twelve lanes over the chunk. Each lane owns a disjoint slice + // and its own `found` vector, so nothing is shared and no lock + // is needed; the results are concatenated after the join. + // + // The thumbnail store is the exception — it is `&mut` and + // cannot be shared — so lanes only *read* metadata and any + // missing thumbnail is left to the interactive path. Dating the + // library is what the sweep is for; thumbnails arrive as cells + // are browsed. + let lanes: Vec> = (0..SWEEP_LANES) + .map(|lane| chunk.iter().skip(lane).step_by(SWEEP_LANES).collect()) + .collect(); + + let results = futures_join_all(lanes.into_iter().map(|lane| { + let backend = &backend; + async move { + let mut found = Vec::new(); + for req in lane { + read_metadata_only(backend, req, &mut found).await; + } + found + } + })) + .await; + + let mut found: Vec = results.into_iter().flatten().collect(); + done += chunk.len(); + + dated += found.iter().filter(|m| m.captured_at.is_some()).count(); + let read = found.len(); + flush_sweep(&catalog, &mut found); + log::info!( + "sweep: {done} done, {dated} dated ({read} read in {:.1}s)", + chunk_started.elapsed().as_secs_f64() + ); + + if tx.send(SweepMessage::Progress { done, dated }).is_err() { + return; + } + } + + log::info!("sweep complete: {dated} date(s) recorded over {done} image(s)"); + let _ = tx.send(SweepMessage::Finished { dated }); + }); + }); + + rx +} + +/// How many images still lack a date or a thumbnail. +fn count_outstanding(catalog: &Catalog) -> Result { + let n: i64 = catalog.connection().query_row( + &format!( + "SELECT count(*) FROM images + WHERE metadata_state < 2 AND {VISIBLE_UNALIASED}" + ), + [], + |r| r.get(0), + )?; + Ok(n as usize) +} + +/// The next images needing work. +/// +/// Ordered by id so the sweep advances deterministically and a resumed run +/// picks up where it left off rather than revisiting. +fn next_outstanding( + catalog: &Catalog, + limit: usize, +) -> Result, dr_catalog::CatalogError> { + let mut stmt = catalog.connection().prepare(&format!( + "SELECT i.id, i.source_ref, r.file_id, i.file_size + FROM images i + LEFT JOIN remote r ON r.image_id = i.id + WHERE i.metadata_state < 2 AND {VISIBLE} + ORDER BY i.id + LIMIT ?1" + ))?; + let rows = stmt + .query_map([limit as i64], |r| { + Ok(ThumbnailRequest { + // Row index is meaningless here — the sweep touches no grid + // cell, so nothing consumes it. + row: 0, + image_id: r.get(0)?, + path: r.get(1)?, + file_id: r.get::<_, Option>(2)?.map(|v| v as u64), + size: r.get::<_, Option>(3)?.unwrap_or(0) as u64, + needs_metadata: true, + }) + })? + .collect::, _>>()?; + Ok(rows) +} + +/// Commit a sweep chunk. +/// +/// An image whose header yielded no date is still marked done, or the sweep +/// would revisit it forever. `write_metadata` records `metadata_state = 1` for +/// those, so this promotes them explicitly. +fn flush_sweep(catalog: &Catalog, found: &mut Vec) { + if found.is_empty() { + return; + } + if let Err(e) = write_metadata(catalog, found) { + log::warn!("sweep: writing metadata: {e}"); + found.clear(); + return; + } + + // Mark the dateless as examined. Without this they stay at state 1 and the + // sweep loops over them on every pass, never terminating. + let ids: Vec = found + .iter() + .filter(|m| m.captured_at.is_none()) + .map(|m| m.image_id) + .collect(); + for id in ids { + let _ = catalog.connection().execute( + "UPDATE images SET metadata_state = 2 WHERE id = ?1", + [id], + ); + } + found.clear(); +} + +/// Where an account's thumbnail shards live. +/// +/// Beside the catalog rather than in the cache directory: these sync to the +/// server and are shared with other clients, so discarding them on a cache +/// sweep would cost a re-download for everyone. +pub fn thumbs_dir(server: &str, user_id: &str) -> PathBuf { + catalog_path(server, user_id) + .parent() + .map(|p| p.join("thumbs")) + .unwrap_or_else(|| std::env::temp_dir().join("darkroom-thumbs")) +} + +/// One grid cell's data, read from the catalog. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LibraryCell { + pub image_id: i64, + pub name: String, + pub remote_path: String, + /// `oc:fileid`, the key the shared thumbnail store uses. `None` for an + /// image the scan found without a stable id. + pub file_id: Option, + /// File length, for bounds-checking a located preview range. + pub size: u64, + /// 0 = nothing, 1 = stat-only, 2 = full EXIF. + pub metadata_state: u8, + /// UTC seconds, once EXIF has been read. + pub captured_at: Option, +} + +/// Read a window of cells out of the catalog. +/// +/// Windowed rather than wholesale: a 17k-image library must not become 17k +/// rows in a Slint model (FR-CAT-4). +/// The unscoped form, kept as the name the tests and any future caller reach +/// for. The UI goes through [`read_cells_scoped`], because a collection may be +/// selected. +#[cfg(test)] +pub fn read_cells( + catalog: &Catalog, + offset: usize, + limit: usize, +) -> Result, dr_catalog::CatalogError> { + read_cells_scoped(catalog, None, &RatingFilter::default(), offset, limit) +} + +/// Read a window of cells, optionally narrowed to one collection. +/// +/// A collection *set* shows its descendants' images too — a parent whose +/// children hold everything would otherwise read as empty, which makes nesting +/// look broken. The id list comes from +/// [`dr_catalog::collections::descendants`], which is depth-guarded. +/// +/// Ordering matches the unscoped grid (capture time, then name) rather than +/// manual position: position is only meaningful inside one collection and this +/// query also serves sets, where two children's positions are unrelated. +pub fn read_cells_scoped( + catalog: &Catalog, + scope: Option, + filter: &RatingFilter, + offset: usize, + limit: usize, +) -> Result, dr_catalog::CatalogError> { + let Some(scope) = scope else { + return read_cells_all(catalog, filter, offset, limit); + }; + + let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?; + // Placeholders are generated from the *count* of ids, never from user text. + let placeholders = std::iter::repeat_n("?", ids.len()) + .collect::>() + .join(","); + let rated = filter.sql(); + let sql = format!( + "SELECT i.id, i.source_ref, r.file_id, i.file_size, + i.metadata_state, i.captured_at + FROM images i + LEFT JOIN remote r ON r.image_id = i.id + WHERE {VISIBLE}{rated} + AND i.id IN (SELECT image_id FROM collection_members + WHERE collection_id IN ({placeholders})) + ORDER BY i.captured_at IS NULL, i.captured_at ASC, i.source_ref ASC + LIMIT ? OFFSET ?" + ); + + let mut params: Vec = ids + .iter() + .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) + .collect(); + params.push(rusqlite::types::Value::Integer(limit as i64)); + params.push(rusqlite::types::Value::Integer(offset as i64)); + + let mut stmt = catalog.connection().prepare(&sql)?; + let rows = stmt + .query_map(rusqlite::params_from_iter(params.iter()), row_to_cell)? + .collect::, _>>()?; + Ok(rows) +} + +fn read_cells_all( + catalog: &Catalog, + filter: &RatingFilter, + offset: usize, + limit: usize, +) -> Result, dr_catalog::CatalogError> { + let rated = filter.sql(); + let mut stmt = catalog.connection().prepare(&format!( + "SELECT i.id, i.source_ref, r.file_id, i.file_size, + i.metadata_state, i.captured_at + FROM images i + LEFT JOIN remote r ON r.image_id = i.id + WHERE {VISIBLE}{rated} + ORDER BY i.captured_at IS NULL, i.captured_at ASC, i.source_ref ASC + LIMIT ?1 OFFSET ?2" + ))?; + let rows = stmt + .query_map(rusqlite::params![limit as i64, offset as i64], row_to_cell)? + .collect::, _>>()?; + Ok(rows) +} + +/// TRACES: FR-CAT-15 +/// Read a window of *trashed* cells, newest deletion first. +/// +/// Ordered by when it was trashed rather than by capture time, which is what +/// every other view sorts by. The question in the trash is "what did I just +/// delete?", not "when was this taken" — a mistaken delete is corrected within +/// seconds, and burying it among photographs from the same afternoon would make +/// the one row the user is looking for the hardest one to find. +/// +/// The rating filter is deliberately not applied. It narrows a *culling* pass, +/// and a trash that hid rows because of a filter set elsewhere would look like +/// it had lost them. +pub fn read_trashed_cells( + catalog: &Catalog, + offset: usize, + limit: usize, +) -> Result, dr_catalog::CatalogError> { + let mut stmt = catalog.connection().prepare(&format!( + "SELECT i.id, i.source_ref, r.file_id, i.file_size, + i.metadata_state, i.captured_at + FROM images i + LEFT JOIN remote r ON r.image_id = i.id + WHERE {TRASHED} + ORDER BY i.trashed_at DESC, i.source_ref ASC + LIMIT ?1 OFFSET ?2" + ))?; + let rows = stmt + .query_map(rusqlite::params![limit as i64, offset as i64], row_to_cell)? + .collect::, _>>()?; + Ok(rows) +} + +/// TRACES: FR-CAT-15 +/// How many images the trash view would list. +/// +/// Counts exactly what [`read_trashed_cells`] lists — same predicate, no filter +/// — so the scrollbar and the header cannot disagree with the cells. +pub fn total_trashed(catalog: &Catalog) -> Result { + let n: i64 = catalog.connection().query_row( + &format!("SELECT count(*) FROM images i WHERE {TRASHED}"), + [], + |r| r.get(0), + )?; + Ok(n as usize) +} + +/// Shared row mapping, so the scoped and unscoped queries cannot drift. +fn row_to_cell(r: &rusqlite::Row) -> rusqlite::Result { + let path: String = r.get(1)?; + Ok(LibraryCell { + image_id: r.get(0)?, + name: path.rsplit(['/', ':']).next().unwrap_or(&path).to_string(), + remote_path: path, + file_id: r.get::<_, Option>(2)?.map(|v| v as u64), + size: r.get::<_, Option>(3)?.unwrap_or(0) as u64, + metadata_state: r.get::<_, i64>(4)? as u8, + captured_at: r.get(5)?, + }) +} + +/// Total images in the catalog, or in one collection and its descendants. +/// +/// Counts exactly what [`read_cells_scoped`] would list, filter included. The +/// two must agree: the header says "412 images" and the grid's scrollbar is +/// sized from the same number, so a count that ignored the filter would leave +/// the user scrolling through empty rows. +pub fn total_images_scoped( + catalog: &Catalog, + scope: Option, + filter: &RatingFilter, +) -> Result { + let Some(scope) = scope else { + return total_images_filtered(catalog, filter); + }; + + let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?; + let placeholders = std::iter::repeat_n("?", ids.len()) + .collect::>() + .join(","); + let rated = filter.sql(); + // DISTINCT: an image in both a parent and a child is one photograph, and a + // count that disagrees with the number of cells drawn is worse than either + // number alone. + // + // Counted through `images` rather than over `collection_members` alone, so + // `VISIBLE` applies — a trashed photograph is still a member row, and + // counting it made the header claim images the grid would not draw. + let sql = format!( + "SELECT count(DISTINCT i.id) FROM images i + WHERE {VISIBLE}{rated} + AND i.id IN (SELECT image_id FROM collection_members + WHERE collection_id IN ({placeholders}))" + ); + let params: Vec = ids + .iter() + .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) + .collect(); + let n: i64 = catalog.connection().query_row( + &sql, + rusqlite::params_from_iter(params.iter()), + |r| r.get(0), + )?; + Ok(n as usize) +} + +/// Total images in the catalog, honouring the rating filter. +fn total_images_filtered( + catalog: &Catalog, + filter: &RatingFilter, +) -> Result { + let rated = filter.sql(); + let n: i64 = catalog.connection().query_row( + &format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}"), + [], + |r| r.get(0), + )?; + Ok(n as usize) +} + +/// Total images in the catalog, unfiltered. +/// +/// What the scan reports and what the sidebar's "all images" row shows — the +/// size of the library itself, not of the current view. +pub fn total_images(catalog: &Catalog) -> Result { + let n: i64 = catalog + .connection() + .query_row( + &format!("SELECT count(*) FROM images WHERE {VISIBLE_UNALIASED}"), + [], + |r| r.get(0), + )?; + Ok(n as usize) +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use dr_sync::RemoteEntry; + + + #[test] + fn join_all_preserves_order_regardless_of_completion() { + // The ordering guarantee is what lets a caller pair results back to + // their inputs; without it a lane's dates could be attributed to the + // wrong images. + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let out = rt.block_on(async { + futures_join_all(vec![ + Box::pin(async { 1 }) as std::pin::Pin>>, + Box::pin(async { + tokio::task::yield_now().await; + tokio::task::yield_now().await; + 2 + }), + Box::pin(async { + tokio::task::yield_now().await; + 3 + }), + ]) + .await + }); + + assert_eq!(out, vec![1, 2, 3]); + } + + #[test] + fn join_all_of_nothing_completes() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let out: Vec = + rt.block_on(async { futures_join_all(Vec::>::new()).await }); + assert!(out.is_empty()); + } + + #[test] + fn sweep_lanes_divide_a_chunk_without_loss() { + // Every image in a chunk must land in exactly one lane: a striding + // split that dropped or duplicated one would silently under- or + // double-index the library. + let chunk: Vec = (0..SWEEP_CHUNK).collect(); + let lanes: Vec> = (0..SWEEP_LANES) + .map(|l| chunk.iter().skip(l).step_by(SWEEP_LANES).copied().collect()) + .collect(); + + let mut seen: Vec = lanes.iter().flatten().copied().collect(); + seen.sort_unstable(); + assert_eq!(seen, chunk); + // Evenly divided, so no lane sits idle while another finishes. + assert!(lanes.iter().all(|l| l.len() == SWEEP_CHUNK / SWEEP_LANES)); + } + + #[test] + fn a_short_chunk_still_covers_every_image() { + // The last chunk of a library is rarely a full multiple of the lanes. + let chunk: Vec = (0..5).collect(); + let lanes: Vec> = (0..SWEEP_LANES) + .map(|l| chunk.iter().skip(l).step_by(SWEEP_LANES).copied().collect()) + .collect(); + let mut seen: Vec = lanes.iter().flatten().copied().collect(); + seen.sort_unstable(); + assert_eq!(seen, chunk); + } + + #[test] + fn catalog_paths_separate_accounts() { + // Two accounts on one machine must not share an index, or one + // library's images appear in the other. + let a = catalog_path("https://cloud.example", "duncan"); + let b = catalog_path("https://cloud.example", "someone"); + let c = catalog_path("https://other.example", "duncan"); + assert_ne!(a, b); + assert_ne!(a, c); + } + + #[test] + fn catalog_path_is_filesystem_safe() { + let p = catalog_path("https://cloud.example.com:8443/nc", "duncan"); + let s = p.to_string_lossy(); + assert!(!s.contains("://")); + assert!(!s.contains(':') || cfg!(windows)); + } + + #[test] + fn persist_inserts_images_and_folder_etags() { + let catalog = Catalog::in_memory().unwrap(); + let result = dr_sync::ScanResult { + images: vec![entry("PhotosRaw/a.CR2", 1001, 30_000_000)], + directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))], + progress: Default::default(), + }; + + persist(&catalog, "PhotosRaw", &result).unwrap(); + + assert_eq!(total_images(&catalog).unwrap(), 1); + // Folder ETags must persist or the next scan prunes nothing. + let etag: String = catalog + .connection() + .query_row("SELECT etag FROM folders WHERE path = 'PhotosRaw'", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(etag, "e1"); + } + + #[test] + fn images_land_as_stat_only_not_full_metadata() { + // The scan read no EXIF. Claiming otherwise would make a date filter + // silently wrong on a freshly scanned library. + let catalog = Catalog::in_memory().unwrap(); + let result = dr_sync::ScanResult { + images: vec![entry("PhotosRaw/a.CR2", 1001, 30_000_000)], + directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))], + progress: Default::default(), + }; + persist(&catalog, "PhotosRaw", &result).unwrap(); + + let state: i64 = catalog + .connection() + .query_row("SELECT metadata_state FROM images", [], |r| r.get(0)) + .unwrap(); + assert_eq!(state, 1); + } + + #[test] + fn rescanning_updates_rather_than_duplicating() { + let catalog = Catalog::in_memory().unwrap(); + let result = dr_sync::ScanResult { + images: vec![entry("PhotosRaw/a.CR2", 1001, 30_000_000)], + directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))], + progress: Default::default(), + }; + + persist(&catalog, "PhotosRaw", &result).unwrap(); + persist(&catalog, "PhotosRaw", &result).unwrap(); + + assert_eq!(total_images(&catalog).unwrap(), 1, "no duplicate rows"); + let roots: i64 = catalog + .connection() + .query_row("SELECT count(*) FROM roots", [], |r| r.get(0)) + .unwrap(); + assert_eq!(roots, 1, "no duplicate roots"); + } + + #[test] + fn stable_file_ids_are_recorded() { + // FR-NC-5: a server-side move must be a move, not a re-download. + let catalog = Catalog::in_memory().unwrap(); + let result = dr_sync::ScanResult { + images: vec![entry("PhotosRaw/a.CR2", 4242, 30_000_000)], + directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))], + progress: Default::default(), + }; + persist(&catalog, "PhotosRaw", &result).unwrap(); + + let file_id: i64 = catalog + .connection() + .query_row("SELECT file_id FROM remote", [], |r| r.get(0)) + .unwrap(); + assert_eq!(file_id, 4242); + } + + #[test] + fn a_thumbnail_job_is_queued_per_image() { + let catalog = Catalog::in_memory().unwrap(); + let result = dr_sync::ScanResult { + images: vec![ + entry("PhotosRaw/a.CR2", 1, 30_000_000), + entry("PhotosRaw/b.CR2", 2, 30_000_000), + ], + directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))], + progress: Default::default(), + }; + persist(&catalog, "PhotosRaw", &result).unwrap(); + + let jobs: i64 = catalog + .connection() + .query_row("SELECT count(*) FROM jobs WHERE kind = 2", [], |r| r.get(0)) + .unwrap(); + assert_eq!(jobs, 2); + } + + #[test] + fn a_second_scan_does_not_multiply_jobs() { + let catalog = Catalog::in_memory().unwrap(); + let result = dr_sync::ScanResult { + images: vec![entry("PhotosRaw/a.CR2", 1, 30_000_000)], + directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))], + progress: Default::default(), + }; + persist(&catalog, "PhotosRaw", &result).unwrap(); + persist(&catalog, "PhotosRaw", &result).unwrap(); + + let jobs: i64 = catalog + .connection() + .query_row("SELECT count(*) FROM jobs WHERE kind = 2", [], |r| r.get(0)) + .unwrap(); + assert_eq!(jobs, 1, "coalesced, not queued twice"); + } + + #[test] + fn folder_etags_round_trip_for_the_next_scan() { + let catalog = Catalog::in_memory().unwrap(); + let result = dr_sync::ScanResult { + images: vec![], + directories: vec![ + (RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1")), + (RemotePath::new("PhotosRaw/2026"), dr_sync::Validator::new("e2")), + ], + progress: Default::default(), + }; + persist(&catalog, "PhotosRaw", &result).unwrap(); + + let known = load_folder_etags(&catalog, "PhotosRaw"); + assert_eq!(known.len(), 2); + assert_eq!( + known.get(&RemotePath::new("PhotosRaw/2026")).map(|v| v.as_str()), + Some("e2") + ); + } + + #[test] + fn cells_are_windowed_not_wholesale() { + let catalog = Catalog::in_memory().unwrap(); + let images: Vec = (0..50) + .map(|i| entry(&format!("PhotosRaw/img{i:03}.CR2"), i as u64, 1000)) + .collect(); + let result = dr_sync::ScanResult { + images, + directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))], + progress: Default::default(), + }; + persist(&catalog, "PhotosRaw", &result).unwrap(); + + let page = read_cells(&catalog, 10, 5).unwrap(); + assert_eq!(page.len(), 5); + assert_eq!(page[0].name, "img010.CR2"); + } + + /// A catalog with `n` images, ready to file into collections. + fn with_images(n: usize) -> Catalog { + let catalog = Catalog::in_memory().unwrap(); + let images: Vec = (0..n) + .map(|i| entry(&format!("PhotosRaw/img{i:03}.CR2"), i as u64, 1000)) + .collect(); + let result = dr_sync::ScanResult { + images, + directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))], + progress: Default::default(), + }; + persist(&catalog, "PhotosRaw", &result).unwrap(); + catalog + } + + fn image_ids(catalog: &Catalog) -> Vec { + let mut stmt = catalog + .connection() + .prepare("SELECT id FROM images ORDER BY source_ref") + .unwrap(); + stmt.query_map([], |r| Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))) + .unwrap() + .map(Result::unwrap) + .collect() + } + + #[test] + fn a_scoped_grid_shows_only_that_collections_images() { + use dr_catalog::collections::{self as coll, CollectionKind}; + + let catalog = with_images(10); + let ids = image_ids(&catalog); + let c = coll::create(catalog.connection(), "Selects", None, CollectionKind::Manual) + .unwrap(); + coll::add_images(catalog.connection(), c, &ids[2..5]).unwrap(); + + let cells = read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120).unwrap(); + assert_eq!(cells.len(), 3); + assert_eq!(total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), 3); + // Unscoped is still the whole library. + assert_eq!(total_images_scoped(&catalog, None, &RatingFilter::default()).unwrap(), 10); + } + + #[test] + fn a_collection_set_shows_its_childrens_images() { + // A parent whose children hold everything must not read as empty — + // that is what makes nesting look broken. + use dr_catalog::collections::{self as coll, CollectionKind}; + + let catalog = with_images(10); + let ids = image_ids(&catalog); + let trips = + coll::create(catalog.connection(), "Trips", None, CollectionKind::Manual).unwrap(); + let iceland = coll::create( + catalog.connection(), + "Iceland", + Some(trips), + CollectionKind::Manual, + ) + .unwrap(); + coll::add_images(catalog.connection(), iceland, &ids[0..4]).unwrap(); + + // The parent itself has no direct members at all. + let cells = read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap(); + assert_eq!(cells.len(), 4, "the set shows what its children hold"); + assert_eq!(total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), 4); + } + + #[test] + fn an_image_in_both_a_parent_and_a_child_is_shown_once() { + // The count and the number of cells drawn must agree, or neither is + // believable. + use dr_catalog::collections::{self as coll, CollectionKind}; + + let catalog = with_images(10); + let ids = image_ids(&catalog); + let trips = + coll::create(catalog.connection(), "Trips", None, CollectionKind::Manual).unwrap(); + let iceland = coll::create( + catalog.connection(), + "Iceland", + Some(trips), + CollectionKind::Manual, + ) + .unwrap(); + coll::add_images(catalog.connection(), trips, &ids[0..2]).unwrap(); + coll::add_images(catalog.connection(), iceland, &ids[0..3]).unwrap(); + + let cells = read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap(); + assert_eq!(cells.len(), 3, "images 0..3, each once"); + assert_eq!(total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), 3); + } + + #[test] + fn a_scoped_window_still_pages() { + // FR-CAT-4 applies inside a collection too: a 5,000-image collection + // must not become 5,000 rows. + use dr_catalog::collections::{self as coll, CollectionKind}; + + let catalog = with_images(30); + let ids = image_ids(&catalog); + let c = coll::create(catalog.connection(), "Big", None, CollectionKind::Manual).unwrap(); + coll::add_images(catalog.connection(), c, &ids).unwrap(); + + let page = read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 10, 5).unwrap(); + assert_eq!(page.len(), 5); + assert_eq!(page[0].name, "img010.CR2"); + } + + #[test] + fn an_empty_collection_reads_as_empty_rather_than_as_the_whole_library() { + // The failure that would make scoping useless: an empty IN-list + // matching everything. + use dr_catalog::collections::{self as coll, CollectionKind}; + + let catalog = with_images(10); + let c = coll::create(catalog.connection(), "Empty", None, CollectionKind::Manual).unwrap(); + + assert!(read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120).unwrap().is_empty()); + assert_eq!(total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), 0); + } + + #[test] + fn cells_carry_the_file_id_the_thumbnail_store_keys_on() { + // Without this the store can never be hit: every launch would refetch + // every thumbnail over the network. + let catalog = Catalog::in_memory().unwrap(); + let result = dr_sync::ScanResult { + images: vec![entry("PhotosRaw/a.CR2", 7777, 30_000_000)], + directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))], + progress: Default::default(), + }; + persist(&catalog, "PhotosRaw", &result).unwrap(); + + let cells = read_cells(&catalog, 0, 10).unwrap(); + assert_eq!(cells[0].file_id, Some(7777)); + } + + #[test] + fn a_stored_thumbnail_survives_a_restart() { + // The end-to-end property the store exists for: encode, persist, + // reopen, decode. A second launch must not re-fetch. + let dir = std::env::temp_dir().join(format!("dr-ui-thumbs-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + + let rgba: Vec = std::iter::repeat_n([90u8, 140, 200, 255], 32 * 32) + .flatten() + .collect(); + { + let mut store = ThumbStore::open(&dir).unwrap(); + let bytes = dr_thumbs::encode_rgba(32, 32, &rgba).unwrap(); + store + .put( + 4242, + &dr_thumbs::Thumbnail { + width: 32, + height: 32, + bytes, + }, + ) + .unwrap(); + } + + let store = ThumbStore::open(&dir).unwrap(); + let stored = store.get(4242).unwrap().expect("persisted"); + let (w, h, out) = dr_thumbs::decode_rgba(&stored.bytes).unwrap(); + assert_eq!((w, h), (32, 32)); + // Lossy, so compare approximately — a blue-ish pixel must stay blue. + assert!(out[2] > out[0], "channel order survived the round trip"); + } + + #[test] + fn thumbs_live_beside_the_catalog_not_in_the_cache() { + // They sync to the server and are shared with other clients, so a + // cache sweep must not discard them. + let cat = catalog_path("https://cloud.example", "duncan"); + let thumbs = thumbs_dir("https://cloud.example", "duncan"); + assert_eq!(thumbs.parent(), cat.parent()); + } + + // --- the trash view (FR-CAT-15) --------------------------------------- + + /// A catalog with `n` scanned images, none trashed. + fn scanned(n: u64) -> Catalog { + let catalog = Catalog::in_memory().unwrap(); + let images = (1..=n) + .map(|i| entry(&format!("PhotosRaw/IMG_{i:04}.CR2"), 1000 + i, 30_000_000)) + .collect(); + persist( + &catalog, + "PhotosRaw", + &dr_sync::ScanResult { + images, + directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))], + progress: Default::default(), + }, + ) + .unwrap(); + catalog + } + + /// Mark one image trashed, at a given instant. + fn trash_at(catalog: &Catalog, path: &str, when: i64) { + let n = catalog + .connection() + .execute( + "UPDATE images SET trashed_at = ?1, trashed_from = source_ref + WHERE source_ref = ?2", + rusqlite::params![when, path], + ) + .unwrap(); + assert_eq!(n, 1, "fixture should have trashed exactly {path}"); + } + + #[test] + fn the_trash_lists_what_the_library_hides() { + // The whole point of the view: these rows exist and no other query in + // the application will show them. + let catalog = scanned(3); + trash_at(&catalog, "PhotosRaw/IMG_0002.CR2", 100); + + let trashed = read_trashed_cells(&catalog, 0, 50).unwrap(); + assert_eq!(trashed.len(), 1); + assert_eq!(trashed[0].remote_path, "PhotosRaw/IMG_0002.CR2"); + + // And it has left the library in the same move. + let live = read_cells_all(&catalog, &RatingFilter::default(), 0, 50).unwrap(); + assert_eq!(live.len(), 2); + assert!(!live.iter().any(|c| c.remote_path.contains("IMG_0002"))); + } + + #[test] + fn an_untrashed_library_has_an_empty_trash() { + let catalog = scanned(3); + assert!(read_trashed_cells(&catalog, 0, 50).unwrap().is_empty()); + assert_eq!(total_trashed(&catalog).unwrap(), 0); + } + + #[test] + fn the_trash_count_agrees_with_the_cells_it_lists() { + // The header and the scrollbar are sized from the count while the grid + // draws the cells. Two predicates that drift leave the user scrolling + // through rows that are not there. + let catalog = scanned(5); + for (i, when) in [(1, 100), (3, 200), (5, 300)] { + trash_at(&catalog, &format!("PhotosRaw/IMG_{i:04}.CR2"), when); + } + + assert_eq!(total_trashed(&catalog).unwrap(), 3); + assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 3); + } + + #[test] + fn the_most_recently_trashed_image_is_listed_first() { + // A mistaken delete is corrected within seconds, so the row the user + // wants is the one they just made — not the oldest photograph. + let catalog = scanned(3); + trash_at(&catalog, "PhotosRaw/IMG_0001.CR2", 100); + trash_at(&catalog, "PhotosRaw/IMG_0002.CR2", 300); + trash_at(&catalog, "PhotosRaw/IMG_0003.CR2", 200); + + let order: Vec = read_trashed_cells(&catalog, 0, 50) + .unwrap() + .into_iter() + .map(|c| c.remote_path) + .collect(); + assert_eq!( + order, + vec![ + "PhotosRaw/IMG_0002.CR2".to_string(), + "PhotosRaw/IMG_0003.CR2".to_string(), + "PhotosRaw/IMG_0001.CR2".to_string(), + ] + ); + } + + #[test] + fn a_shadowed_jpeg_is_not_listed_beside_the_raw_it_belongs_to() { + // The inversion applies to `trashed_at` only. Trashing a RAW takes its + // sibling JPEG with it, and listing both would offer to restore the + // same frame twice. + let catalog = scanned(2); + let c = catalog.connection(); + let raw: i64 = c + .query_row( + "SELECT id FROM images WHERE source_ref = 'PhotosRaw/IMG_0001.CR2'", + [], + |r| r.get(0), + ) + .unwrap(); + c.execute( + "UPDATE images SET shadowed_by = ?1 WHERE source_ref = 'PhotosRaw/IMG_0002.CR2'", + [raw], + ) + .unwrap(); + + trash_at(&catalog, "PhotosRaw/IMG_0001.CR2", 100); + trash_at(&catalog, "PhotosRaw/IMG_0002.CR2", 100); + + let trashed = read_trashed_cells(&catalog, 0, 50).unwrap(); + assert_eq!(trashed.len(), 1, "one photograph, not two"); + assert_eq!(trashed[0].remote_path, "PhotosRaw/IMG_0001.CR2"); + assert_eq!(total_trashed(&catalog).unwrap(), 1, "and the count agrees"); + } + + #[test] + fn the_trash_window_pages_like_the_grid_does() { + // The trash uses the same windowed read as the library, so a large one + // must not try to draw itself in a single query. + let catalog = scanned(6); + for i in 1..=6 { + trash_at(&catalog, &format!("PhotosRaw/IMG_{i:04}.CR2"), 100 + i as i64); + } + + let first = read_trashed_cells(&catalog, 0, 2).unwrap(); + let second = read_trashed_cells(&catalog, 2, 2).unwrap(); + assert_eq!(first.len(), 2); + assert_eq!(second.len(), 2); + assert!( + first.iter().all(|a| !second.iter().any(|b| b.image_id == a.image_id)), + "pages must not overlap" + ); + } + + #[test] + fn a_rating_filter_does_not_hide_anything_in_the_trash() { + // The filter narrows a culling pass. A trash that dropped rows because + // of a filter set elsewhere would look like it had lost them. + let catalog = scanned(3); + trash_at(&catalog, "PhotosRaw/IMG_0001.CR2", 100); + + // Nothing here is rated, so a four-star library filter would empty any + // view that honoured it. + let strict = RatingFilter { + min_rating: 4, + ..Default::default() + }; + assert!(read_cells_all(&catalog, &strict, 0, 50).unwrap().is_empty()); + assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 1); + } + + fn entry(path: &str, file_id: u64, size: u64) -> RemoteEntry { + RemoteEntry { + id: RemoteId::Stable(file_id), + path: RemotePath::new(path), + kind: dr_sync::EntryKind::File, + validator: dr_sync::Validator::new("v"), + size, + modified: None, + has_preview: false, + } + } +} diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs new file mode 100644 index 0000000..413df20 --- /dev/null +++ b/ui/dr-ui/src/library_ui.rs @@ -0,0 +1,1976 @@ +//! TRACES: FR-CAT-4 | FR-NC-3 | NFR-P9 +//! Drives the library grid from scan and thumbnail workers. +//! +//! Owns the bridge between three background activities and one single-threaded +//! event loop: +//! +//! - a **scan** worker walking the remote tree into the catalog +//! - a **thumbnail** worker range-fetching previews for visible cells +//! - the **grid model** Slint renders +//! +//! Nothing here blocks. Workers post through mpsc channels drained by Slint +//! timers, which is the same shape [`crate::launch_ui`] uses for login. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::mpsc::Receiver; + +use dr_catalog::Catalog; +use dr_sync_nextcloud::{AppCredentials, Session, SessionStore}; +use dr_types::FormatFilter; +use slint::{ComponentHandle, Model as _}; + +use crate::library::{self, ScanMessage, ThumbnailMessage}; +use crate::{AppWindow, LibraryCell, TimelineBar}; + +/// Window size before the grid has reported its geometry. +/// +/// Only used for the very first load; the grid replaces it with its real +/// capacity as soon as it has laid out. +const INITIAL_WINDOW: usize = 60; + +/// Never load fewer than this, whatever the viewport reports. +/// +/// A window collapsed to a sliver would otherwise load one or two cells and +/// re-query on every scroll tick. +const MIN_WINDOW: usize = 24; + +/// Library state for the running window. +pub struct LibraryController { + /// Shared with [`crate::collections_ui`], which edits collections against + /// the same connection. `Rc` rather than a second `Catalog::open`: two + /// handles on one SQLite file would each hold their own WAL view, so a + /// collection edited through one would not be visible through the other + /// until it committed and the reader reopened. + catalog: Rc>>, + /// Remote paths for the rows currently in the model, parallel to it. + paths: RefCell>, + /// `oc:fileid` and file length per row, parallel to the model. + file_ids: RefCell>>, + sizes: RefCell>, + /// Catalog row ids and whether each still needs its EXIF read. + image_ids: RefCell>, + needs_metadata: RefCell>, + /// Where in the catalog the current window starts. Scrubbing moves this. + offset: RefCell, + /// How many cells to load, derived from what the viewport can show. + /// + /// A fixed count is wrong in both directions: too small on a maximised 4K + /// window, wasteful on a narrow one. The grid measures itself and reports + /// its capacity, including a screenful of margin either side. + window: RefCell, + /// Which rows already have a thumbnail fetch issued, so scrolling back + /// does not refetch what is already on screen. + requested: RefCell>, + scan_timer: RefCell>, + thumb_timer: RefCell>, + /// The whole-library sweep, which outlives any one grid window. + sweep_timer: RefCell>, + /// Pushing shards and the catalog to the server. + sync_timer: RefCell>, + /// Kept so a rescan can run without going back through the launch screen. + session: RefCell>, + /// Which collection narrows the grid, owned by [`crate::collections_ui`] + /// and read here. Shared rather than passed per call because a rescan, a + /// scrub and a drop all reload the window and must all honour it. + scope: RefCell>, + /// TRACES: FR-CAT-15 + /// Whether the grid is listing the trash rather than the library. + /// + /// Separate from `scope` rather than a sentinel id in it, for the reason + /// [`crate::collections_ui::CollectionsController`] gives: the trash is not + /// a collection, and its query *inverts* the predicate every other view + /// applies. Folding that into a type meaning "a collection" would put the + /// inversion where nothing reading `scope` expects it. + /// + /// A `Cell` because it is a `bool` read inside callbacks that already hold + /// other borrows. + viewing_trash: std::cell::Cell, + /// Timeline view state: how far zoomed in, and around what instant. + /// + /// Zoom is a level rather than a span so the axis halves and doubles in + /// even steps; the centre is what keeps the thing you were looking at in + /// view as you zoom. + timeline_zoom: RefCell, + timeline_centre: RefCell>, + /// The instant the grid is showing. `None` until the user has moved the + /// timeline, which is what leaves the marker resting at the middle. + current_bucket: RefCell>, + /// What the rating filter bar is narrowed to. + /// + /// Held here beside `scope` and for the same reason: a scrub, a rescan and + /// a drop all reload the window, and every one of them must honour it or + /// the filter silently lapses. + filter: RefCell, + /// Drains the sidecar writer. Held so a second judgement replaces the + /// timer rather than leaving two draining the same finished channel. + sidecar_timer: RefCell>, + /// Which window load the model belongs to, bumped by [`load_window`]. + /// + /// A thumbnail worker addresses cells by *row index into the window that + /// asked for it*. A reload replaces the model, so once the generation has + /// moved on every row still in flight names a different photograph — + /// applying it paints thumbnails onto unrelated cells, or marks a cell + /// "no preview" for a fetch never attempted against it. Worse, a stale + /// drain reaching `Disconnected` would call `stop` on `thumb_timer`, which + /// by then holds the *current* batch's timer: the new worker then fetched + /// into a channel nobody drained and the grid stayed black until a scroll + /// forced another load. + /// + /// A `Cell` rather than a `RefCell`: it is read inside a timer callback + /// that already holds borrows of other fields, and a `u64` needs no borrow + /// tracking. + generation: std::cell::Cell, +} + +impl LibraryController { + pub fn new() -> Rc { + Rc::new(Self { + catalog: Rc::new(RefCell::new(None)), + paths: RefCell::new(Vec::new()), + file_ids: RefCell::new(Vec::new()), + sizes: RefCell::new(Vec::new()), + image_ids: RefCell::new(Vec::new()), + needs_metadata: RefCell::new(Vec::new()), + offset: RefCell::new(0), + window: RefCell::new(INITIAL_WINDOW), + requested: RefCell::new(Default::default()), + scan_timer: RefCell::new(None), + thumb_timer: RefCell::new(None), + sweep_timer: RefCell::new(None), + sync_timer: RefCell::new(None), + session: RefCell::new(None), + scope: RefCell::new(None), + viewing_trash: std::cell::Cell::new(false), + timeline_zoom: RefCell::new(0), + timeline_centre: RefCell::new(None), + current_bucket: RefCell::new(None), + filter: RefCell::new(library::RatingFilter::default()), + sidecar_timer: RefCell::new(None), + generation: std::cell::Cell::new(0), + }) + } + + /// The catalog handle, for [`crate::collections_ui`] to edit through. + pub fn catalog(&self) -> Rc>> { + self.catalog.clone() + } + + /// Credentials and session for the open library. + /// + /// Needed by the trash, whose `MOVE` and `DELETE` go to the same account the + /// scan and thumbnail workers use. `None` before a library is opened. + pub fn session(&self) -> Option<(AppCredentials, Session)> { + self.session + .borrow() + .as_ref() + .map(|(c, s, _)| (c.clone(), s.clone())) + } + + /// Catalog ids of the rows currently in the model, in model order. + /// + /// Selection is keyed on these rather than on row indices: the grid is a + /// window over the catalog and a scrub replaces every row, so an index + /// would silently come to mean a different photograph. + pub fn visible_ids(&self) -> Vec { + self.image_ids + .borrow() + .iter() + .map(|id| dr_types::ImageId(*id as u64)) + .collect() + } + + /// Credentials and account for the open library, if one is open. + /// + /// What a full-file fetch needs: the grid's paths are remote, so opening + /// an image means downloading it, and that needs the same session the + /// thumbnail workers use. + pub fn credentials(&self) -> Option<(AppCredentials, String)> { + self.session + .borrow() + .as_ref() + .map(|(creds, session, _)| (creds.clone(), session.user_id.clone())) + } + + /// Narrow the grid to a collection, or to the whole library with `None`. + pub fn set_scope(&self, scope: Option) { + *self.scope.borrow_mut() = scope; + // Selecting a collection is leaving the trash. Without this, picking a + // collection while the trash was open would keep listing trashed images + // under that collection's name. + self.viewing_trash.set(false); + // A new scope is a different set of images, so the old window's + // position and its issued fetches mean nothing. + *self.offset.borrow_mut() = 0; + self.requested.borrow_mut().clear(); + } + + /// TRACES: FR-CAT-15 + /// Show the trash instead of the library. + /// + /// Clears `scope` as well: the trash is not inside a collection, and leaving + /// a stale scope set would narrow it to one on the way back out. + pub fn set_viewing_trash(&self, viewing: bool) { + self.viewing_trash.set(viewing); + if viewing { + *self.scope.borrow_mut() = None; + } + *self.offset.borrow_mut() = 0; + self.requested.borrow_mut().clear(); + } +} + +/// Reload the grid for the current scope and offset. +/// +/// The reload entry point for everything outside this module — a scope change, +/// or a drop that altered the collection being shown. +pub fn reload(window: &AppWindow, ctl: &Rc) { + load_window(window, ctl); +} + +/// Open a library: show the grid, start a scan, then fill in thumbnails. +/// +/// Called from the launch screen's "Open library" button — the callback that +/// until now only logged its intent. +pub fn open( + window: &AppWindow, + ctl: Rc, + coll_ctl: Rc, + store: &SessionStore, + session: Session, +) { + let creds = match store.credentials(&session) { + Ok(c) => c, + Err(e) => { + window.set_library_error(format!("credentials: {e}").into()); + window.set_show_library(true); + return; + } + }; + + let filter = session.format_filter(); + *ctl.session.borrow_mut() = Some((creds.clone(), session.clone(), filter.clone())); + + window.set_show_library(true); + window.set_library_scanning(true); + window.set_library_error(slint::SharedString::new()); + window.set_library_status("Starting…".into()); + // Always visible: two folders one letter apart are easy to confuse, and a + // scan of the wrong one is indistinguishable from a broken scan. + window.set_library_root_label( + if session.root.is_empty() { + format!("{} · whole account", session.user_id) + } else { + format!("{}/{}", session.user_id, session.root) + } + .into(), + ); + + // An empty filter would walk the whole tree and match nothing, which looks + // exactly like a broken scan. Say so instead. + if filter.is_empty() { + window.set_library_scanning(false); + window.set_library_error("No formats selected — tick at least one.".into()); + return; + } + + let path = library::catalog_path(&session.server, &session.user_id); + log::info!( + "scanning {} for {} format(s) → {}", + if session.root.is_empty() { + "" + } else { + &session.root + }, + filter.iter().count(), + path.display() + ); + + let rx = library::spawn_scan( + creds, + session.user_id.clone(), + session.root.clone(), + filter, + path.clone(), + ); + + drain_scan(window.as_weak(), ctl, coll_ctl, rx, path); +} + +/// Drain scan progress on the UI thread. +fn drain_scan( + weak: slint::Weak, + ctl: Rc, + coll_ctl: Rc, + rx: Receiver, + catalog_path: std::path::PathBuf, +) { + let timer = slint::Timer::default(); + let ctl_cb = ctl.clone(); + + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(120), + move || { + let Some(w) = weak.upgrade() else { return }; + let ctl = &ctl_cb; + + loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + // A worker that died without sending must not leave + // the screen on "Scanning…" forever. + if w.get_library_scanning() { + w.set_library_scanning(false); + w.set_library_error("scan ended unexpectedly".into()); + } + stop(&ctl.scan_timer); + return; + } + }; + + match msg { + ScanMessage::Progress { + directories, + pruned, + images, + } => { + // Pruned folders are reported separately rather than + // folded into the total: they are the ETag walk paying + // off, and hiding them makes an incremental rescan look + // identical to a full one. + let status = if pruned > 0 { + format!( + "{directories} folders · {pruned} unchanged · {images} images" + ) + } else { + format!("{directories} folders · {images} images") + }; + w.set_library_status(status.into()); + } + ScanMessage::Done { + found, + total, + pruned, + elapsed_ms, + } => { + log::info!( + "scan complete: {total} images ({found} listed, \ + {pruned} folders unchanged) in {elapsed_ms} ms" + ); + w.set_library_scanning(false); + + // An incremental rescan lists almost nothing, so + // reporting the listed count would read as "0 images" + // on a library that is simply up to date. + let secs = elapsed_ms as f64 / 1000.0; + let status = if pruned > 0 && found == 0 { + format!("up to date · {total} images · {secs:.1}s") + } else if pruned > 0 { + format!("{found} new or changed · {total} images · {secs:.1}s") + } else { + format!("{total} images · {secs:.1}s") + }; + w.set_library_status(status.into()); + + match Catalog::open(&catalog_path) { + Ok(cat) => { + // The sidebar is built before the grid: the + // grid's badges read collection membership, and + // the tree is where the catalog handle first + // becomes available to it. + crate::collections_ui::refresh_tree(&w, &coll_ctl, &cat); + *ctl.catalog.borrow_mut() = Some(cat); + load_window(&w, ctl); + // Everything the grid did not touch: the rest + // of the library gets a thumbnail and a date, + // so the timeline describes all of it rather + // than the part that was scrolled past. + start_sweep(&w, ctl); + } + Err(e) => { + w.set_library_error(format!("opening catalog: {e}").into()) + } + } + stop(&ctl.scan_timer); + return; + } + ScanMessage::Failed(e) => { + log::warn!("scan failed: {e}"); + w.set_library_scanning(false); + w.set_library_error(e.into()); + stop(&ctl.scan_timer); + return; + } + } + } + }, + ); + + *ctl.scan_timer.borrow_mut() = Some(timer); +} + +/// Fill the model from the catalog and start fetching thumbnails. +/// +/// Reads the window starting at the controller's current offset, which the +/// scrubber moves. Without a movable offset the grid could only ever show the +/// first 120 of 23,971 images. +fn load_window(window: &AppWindow, ctl: &Rc) { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + + // Everything below is scoped to the selected collection, if any: the total, + // the window, and the fetches issued for it. Reading the whole library here + // and filtering later would fetch thumbnails for images the user is not + // looking at, which on a remote library is the cost FR-NC-3 exists to + // avoid. + let scope = *ctl.scope.borrow(); + let filter = *ctl.filter.borrow(); + // The trash lists what every other view excludes, so it takes its own + // query rather than another predicate threaded through the scoped one. + let trash = ctl.viewing_trash.get(); + + let total = if trash { + library::total_trashed(catalog).unwrap_or(0) + } else { + library::total_images_scoped(catalog, scope, &filter).unwrap_or(0) + }; + window.set_library_total(total as i32); + + // Clamp so a scrub to the very end still fills the window rather than + // showing a handful of cells. + let window_size = *ctl.window.borrow(); + let offset = (*ctl.offset.borrow()).min(total.saturating_sub(window_size.min(total))); + *ctl.offset.borrow_mut() = offset; + window.set_library_offset(offset as i32); + + // The date range this window covers, so the scrubber can label itself. + refresh_timeline(window, catalog, ctl); + + let cells = if trash { + library::read_trashed_cells(catalog, offset, window_size) + } else { + library::read_cells_scoped(catalog, scope, &filter, offset, window_size) + }; + let cells = match cells { + Ok(c) => c, + Err(e) => { + window.set_library_error(format!("reading catalog: {e}").into()); + return; + } + }; + + // What the window currently spans, in the photographer's own terms. + let span = cells + .iter() + .filter_map(|c| c.captured_at) + .fold(None::<(i64, i64)>, |acc, t| { + Some(match acc { + None => (t, t), + Some((lo, hi)) => (lo.min(t), hi.max(t)), + }) + }); + window.set_library_window_label( + match span { + Some((lo, hi)) => format!("{} – {}", format_date(lo), format_date(hi)), + // Nothing here has a date yet: EXIF is read as thumbnails load, so + // this fills in rather than being an error. + None => "dates not yet read".to_string(), + } + .into(), + ); + + // Month headings. The grid is ordered by capture time, so without these a + // wall of thumbnails gives no sense of *when* you are looking — the + // sidebar says it, but only if you consult it. + // + // Marked on the cell that both begins a month and begins a row: a heading + // stranded mid-row would label the cells to its left, which belong to the + // previous month. + let columns = window.get_library_columns().max(1) as usize; + let mut previous_month: Option<(i64, i64)> = None; + let headings: Vec = cells + .iter() + .enumerate() + .map(|(i, c)| { + let Some(t) = c.captured_at else { + return String::new(); + }; + let (y, m, _, _) = civil_from_unix(t); + let is_new = previous_month != Some((y, m)); + previous_month = Some((y, m)); + + // A heading is drawn above its row, so it can only sit on a cell + // that begins one — a heading stranded mid-row would appear to + // label the cells to its left, which belong to the month before. + // + // The first cell of the window always carries one, whichever + // column it lands in: a scrolled window would otherwise show no + // date at all until the next month began. + let begins_row = (i + offset) % columns == 0; + if i == 0 || (is_new && begins_row) { + format!("{} {y}", month_name(m)) + } else { + String::new() + } + }) + .collect(); + + let rows: Vec = cells + .iter() + .zip(headings) + .map(|(c, heading)| LibraryCell { + period_heading: heading.into(), + // A freshly loaded window has no drag in flight. + lifted: false, + name: c.name.as_str().into(), + thumbnail: slint::Image::default(), + has_thumb: false, + unavailable: false, + // Both are filled straight after by `collections_ui`, which owns + // the selection and queries the badge counts for the whole window + // in one statement rather than one per cell. + selected: false, + collection_count: 0, + // Likewise filled by `sync_ratings` below — one query for the + // window, not one per cell. + rating: 0, + flag: 0, + }) + .collect(); + + *ctl.paths.borrow_mut() = cells.iter().map(|c| c.remote_path.clone()).collect(); + *ctl.file_ids.borrow_mut() = cells.iter().map(|c| c.file_id).collect(); + *ctl.sizes.borrow_mut() = cells.iter().map(|c| c.size).collect(); + *ctl.image_ids.borrow_mut() = cells.iter().map(|c| c.image_id).collect(); + *ctl.needs_metadata.borrow_mut() = + cells.iter().map(|c| c.metadata_state < 2).collect(); + ctl.requested.borrow_mut().clear(); + // The model is about to be replaced, so every thumbnail still in flight + // addresses a window that no longer exists. Bumping here — before the swap, + // and beside the `requested` clear that already admits the old fetches no + // longer apply — is what lets `drain_thumbnails` recognise itself as stale. + ctl.generation.set(ctl.generation.get().wrapping_add(1)); + window.set_library_cells(slint::ModelRc::new(slint::VecModel::from(rows))); + + // The model is fresh, so the "in this many collections" badges are all zero + // until refilled. One query for the whole window, not one per cell. + let ids: Vec = cells + .iter() + .map(|c| dr_types::ImageId(c.image_id as u64)) + .collect(); + crate::collections_ui::sync_badges(window, catalog, &ids); + sync_ratings(window, catalog, &ids); + // The filter chips' counts describe the whole library, not this window, so + // they are refreshed here rather than per cell. + refresh_rating_counts(window, catalog); + + if total == 0 { + return; + } + request_thumbnails(window, ctl); +} + +/// Push each visible image's stars and flag into the grid model. +/// +/// One query for the window, mirroring `collections_ui::sync_badges` — 120 +/// cells is 120 round trips otherwise, on every scroll and after every +/// keystroke. +pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::ImageId]) { + if ids.is_empty() { + return; + } + + let found = match dr_catalog::rating::judgements(catalog.connection(), ids) { + Ok(j) => j, + Err(e) => { + // The grid is still usable without stars, so this is logged rather + // than surfaced — a failure here must not blank the library. + log::debug!("reading ratings: {e}"); + return; + } + }; + + let model = window.get_library_cells(); + for (row, id) in ids.iter().enumerate() { + // Absent means unrated, which is a real state rather than missing data. + let j = found.get(id).copied().unwrap_or_default(); + let (rating, flag) = (j.rating as i32, flag_code(j.flag)); + + if let Some(mut cell) = model.row_data(row) { + if cell.rating != rating || cell.flag != flag { + cell.rating = rating; + cell.flag = flag; + model.set_row_data(row, cell); + } + } + } +} + +/// Refresh the filter chips' per-star counts. +/// +/// Whole-library figures, deliberately: they say what narrowing to a filter +/// would show, so computing them over the current window would make each chip +/// describe the view it is meant to change. +fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) { + let counts = dr_catalog::rating::rating_histogram(catalog.connection()).unwrap_or_default(); + let as_i32: Vec = counts.iter().map(|n| *n as i32).collect(); + window.set_library_rating_counts(slint::ModelRc::new(slint::VecModel::from(as_i32))); +} + +/// Apply a judgement to a set of images: catalog first, then sidecars. +/// +/// # Order matters +/// +/// The catalog is written **synchronously and first**, so the star appears +/// immediately and survives a restart even if the network is down. The sidecar +/// write is queued behind it on a worker thread — it is what makes the +/// judgement survive a *catalog rebuild* (ARCH §6.12), which is a slower and +/// rarer concern than the user seeing their keystroke take effect. +/// +/// Doing it the other way round would mean a cull that stalls on every +/// keypress waiting for a round trip, on a workflow whose entire premise is +/// speed (FR-CULL-1). +fn apply_judgement( + window: &AppWindow, + ctl: &Rc, + images: &[dr_types::ImageId], + rating: Option, + flag: Option, +) { + if images.is_empty() { + // Nothing selected. Said out loud rather than ignored: a keystroke + // that silently does nothing reads as a broken key. + window.set_library_status("Select an image first".into()); + return; + } + + let writes = { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + let conn = catalog.connection(); + + let wrote = match (rating, flag) { + (Some(r), _) => dr_catalog::rating::set_rating_many(conn, images, r), + (_, Some(f)) => dr_catalog::rating::set_flag_many(conn, images, f), + // Neither axis named: nothing to do, and not an error. + (None, None) => return, + }; + + if let Err(e) = wrote { + window.set_library_error(format!("recording rating: {e}").into()); + return; + } + + // Report what happened, in the user's terms rather than as a count of + // rows. A bulk judgement on a selection is easy to trigger by accident + // and the status line is the only confirmation of its extent. + window.set_library_status(judgement_summary(images.len(), rating, flag).into()); + + // Refresh the grid and the chips from what actually landed, rather + // than assuming the write took: a clamped or coalesced value must show + // as what is stored. + let visible = ctl.visible_ids(); + sync_ratings(window, catalog, &visible); + refresh_rating_counts(window, catalog); + + collect_sidecar_writes(catalog, images) + }; + + // A filtered grid may no longer contain what was just judged — rating an + // image 2 while showing "★4+" means it belongs elsewhere now. Reloading + // keeps the cells and the header count honest. + if !ctl.filter.borrow().is_unfiltered() { + load_window(window, ctl); + } + + start_sidecar_writes(window, ctl, writes); +} + +/// What the status line says about a judgement that just landed. +fn judgement_summary( + n: usize, + rating: Option, + flag: Option, +) -> String { + let what = match (rating, flag) { + (Some(0), _) => "unrated".to_string(), + (Some(r), _) => format!("{r} star{}", if r == 1 { "" } else { "s" }), + (_, Some(dr_types::FlagState::Pick)) => "picked".to_string(), + (_, Some(dr_types::FlagState::Reject)) => "rejected".to_string(), + (_, Some(dr_types::FlagState::Unflagged)) => "unflagged".to_string(), + (None, None) => return String::new(), + }; + if n == 1 { + what + } else { + format!("{n} images · {what}") + } +} + +/// Gather what the sidecar writer needs for each judged image. +/// +/// The version uuid comes from the catalog rather than being generated here: +/// it is the identity a cross-device merge keys on, so the sidecar and the +/// catalog must name the same version or a sync would treat one photograph's +/// judgement as two (FR-NC-8). +fn collect_sidecar_writes( + catalog: &Catalog, + images: &[dr_types::ImageId], +) -> Vec { + let placeholders = std::iter::repeat_n("?", images.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT i.source_ref, v.uuid, v.rating, v.flag + FROM images i + JOIN versions v ON v.image_id = i.id AND v.is_default = 1 + WHERE i.id IN ({placeholders})" + ); + let params: Vec = images + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) + .collect(); + + let Ok(mut stmt) = catalog.connection().prepare(&sql) else { + return Vec::new(); + }; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(library::JudgementWrite { + image_path: r.get(0)?, + version_uuid: r.get(1)?, + rating: r.get::<_, i64>(2)? as u8, + flag: r.get::<_, i64>(3)? as u8, + }) + }); + + match rows { + Ok(rows) => rows.flatten().collect(), + Err(e) => { + log::debug!("collecting sidecar writes: {e}"); + Vec::new() + } + } +} + +/// Push judgements out to sidecars on a worker, reporting once at the end. +fn start_sidecar_writes( + window: &AppWindow, + ctl: &Rc, + writes: Vec, +) { + if writes.is_empty() { + return; + } + let Some((creds, session, _)) = ctl.session.borrow().clone() else { + return; + }; + + let rx = library::spawn_sidecar_writes(creds, session.user_id.clone(), writes); + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(200), + move || { + let Some(w) = weak.upgrade() else { return }; + // One message is all this channel ever carries — the writer reports + // `Finished` once and hangs up — so this drains a single item rather + // than looping like the scan and thumbnail drains do. + match rx.try_recv() { + Ok(library::SidecarMessage::Finished { + written, + failed, + last_error, + }) => { + if failed > 0 { + log::warn!( + "{failed} sidecar write(s) failed: {}", + last_error.clone().unwrap_or_default() + ); + // Said plainly, because the consequence is specific: + // the rating is safe in the catalog but will not + // survive deleting it. + w.set_library_status( + format!( + "{written} saved · {failed} could not be written \ + to the library folder" + ) + .into(), + ); + } else { + log::debug!("{written} sidecar(s) written"); + } + stop(&ctl_cb.sidecar_timer); + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + stop(&ctl_cb.sidecar_timer); + } + } + }, + ); + + *ctl.sidecar_timer.borrow_mut() = Some(timer); +} + +/// Slint carries the flag as an integer, matching the catalog's encoding. +fn flag_code(f: dr_types::FlagState) -> i32 { + match f { + dr_types::FlagState::Unflagged => 0, + dr_types::FlagState::Pick => 1, + dr_types::FlagState::Reject => 2, + } +} + +/// The flag an integer from Slint stands for. +fn flag_from_code(v: i32) -> dr_types::FlagState { + match v { + 1 => dr_types::FlagState::Pick, + 2 => dr_types::FlagState::Reject, + _ => dr_types::FlagState::Unflagged, + } +} + +/// Fetch thumbnails for rows in the model that do not have one yet. +fn request_thumbnails(window: &AppWindow, ctl: &Rc) { + let Some((creds, session, _)) = ctl.session.borrow().clone() else { + return; + }; + + let wanted: Vec = { + let paths = ctl.paths.borrow(); + let file_ids = ctl.file_ids.borrow(); + let sizes = ctl.sizes.borrow(); + let image_ids = ctl.image_ids.borrow(); + let needs_md = ctl.needs_metadata.borrow(); + let mut requested = ctl.requested.borrow_mut(); + paths + .iter() + .enumerate() + .filter(|(i, _)| requested.insert(*i)) + .map(|(i, p)| library::ThumbnailRequest { + row: i, + path: p.clone(), + file_id: file_ids.get(i).copied().flatten(), + size: sizes.get(i).copied().unwrap_or(0), + image_id: image_ids.get(i).copied().unwrap_or(0), + needs_metadata: needs_md.get(i).copied().unwrap_or(false), + }) + .collect() + }; + + if wanted.is_empty() { + return; + } + + // Reset the counter to this batch, so the bar measures the work actually + // outstanding rather than accumulating across batches. + window.set_library_thumbs_total(wanted.len() as i32); + window.set_library_thumbs_done(0); + + let rx = library::spawn_thumbnails( + creds, + session.user_id.clone(), + wanted, + library::thumbs_dir(&session.server, &session.user_id), + library::catalog_path(&session.server, &session.user_id), + ); + drain_thumbnails(window.as_weak(), ctl.clone(), rx); +} + +/// Apply thumbnails to the model as they arrive. +fn drain_thumbnails( + weak: slint::Weak, + ctl: Rc, + rx: Receiver, +) { + let timer = slint::Timer::default(); + let ctl_cb = ctl.clone(); + // Which window this batch was requested for. Captured at spawn, compared on + // every tick. + let mine = ctl.generation.get(); + + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(100), + move || { + let Some(w) = weak.upgrade() else { return }; + + // A reload replaced the model under this worker. Two things must + // not happen now, and both did: + // + // - Applying a row. `t.row` indexes the window that asked for it, + // so after a reload it names a different photograph — thumbnails + // landed on unrelated cells, and `Unavailable` marked cells + // "no preview" for a fetch never attempted against them. + // - Calling `stop`. `thumb_timer` holds the *current* batch's timer + // by now, so a stale drain reaching `Disconnected` killed the + // live drain instead of itself. The new worker then fetched into + // a channel nobody read, and the grid stayed black until a scroll + // forced yet another load — which is the flicker being chased. + // + // Returning without stopping is deliberate: this timer is no longer + // reachable through the controller, so it is dropped with its + // receiver when the slot is overwritten, and the worker exits on + // its next failed send. + if ctl_cb.generation.get() != mine { + return; + } + + let model = w.get_library_cells(); + + loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + // The worker finished or died. Either way nothing more + // is coming, so the bar must not sit part-filled + // forever. + w.set_library_thumbs_done(w.get_library_thumbs_total()); + stop(&ctl_cb.thumb_timer); + return; + } + }; + + match msg { + // Bookkeeping, not an outcome — reports the split between + // store and network without advancing the bar. + ThumbnailMessage::Plan { + cached, + fetching, + dating, + } => { + // Date reads produce no cell, so they are counted into + // the bar's denominator or it finishes while work is + // still running. + w.set_library_thumbs_total( + w.get_library_thumbs_total() + dating as i32, + ); + + let mut parts = Vec::new(); + if cached > 0 { + parts.push(format!("{cached} cached")); + } + if fetching > 0 { + parts.push(format!("fetching {fetching}")); + } + if dating > 0 { + parts.push(format!("reading {dating} dates")); + } + if !parts.is_empty() { + w.set_library_status(parts.join(" · ").into()); + } + } + // A header-only date read. Advances the bar; draws nothing. + ThumbnailMessage::DateProgress => { + w.set_library_thumbs_done(w.get_library_thumbs_done() + 1); + } + // Dates landed, so the histogram can now be built. This is + // what makes the timeline appear on a library whose + // thumbnails were all cached. + ThumbnailMessage::DatesRecorded(n) => { + log::info!("timeline: {n} new dates"); + let borrow = ctl_cb.catalog.borrow(); + if let Some(catalog) = borrow.as_ref() { + refresh_timeline(&w, catalog, &ctl_cb); + } + } + // Every real outcome advances the bar. Counting only + // successes would stall it on a library where some files + // carry no embedded preview. + ThumbnailMessage::Ready(t) => { + w.set_library_thumbs_done(w.get_library_thumbs_done() + 1); + if let Some(mut row) = model.row_data(t.row) { + row.thumbnail = to_slint_image(t.width, t.height, &t.rgba); + row.has_thumb = true; + model.set_row_data(t.row, row); + } + } + ThumbnailMessage::Unavailable { row, reason } => { + w.set_library_thumbs_done(w.get_library_thumbs_done() + 1); + log::debug!("thumbnail {row}: {reason}"); + if let Some(mut r) = model.row_data(row) { + r.unavailable = true; + model.set_row_data(row, r); + } + } + } + } + }, + ); + + *ctl.thumb_timer.borrow_mut() = Some(timer); +} + +/// Push shards and the catalog to the server, and take what it has. +/// +/// Fired after the sweep completes, when there is a finished index worth +/// sharing, and from the Sync button for an explicit exchange. +fn start_derived_sync(window: &AppWindow, ctl: &Rc) { + let Some((creds, session, _)) = ctl.session.borrow().clone() else { + return; + }; + // Already running: a second pass would race the first over the same + // scratch files. + if ctl.sync_timer.borrow().is_some() && window.get_library_syncing() { + return; + } + + let catalog_path = library::catalog_path(&session.server, &session.user_id); + let scratch = catalog_path + .parent() + .map(|p| p.join("scratch")) + .unwrap_or_else(std::env::temp_dir); + let _ = std::fs::create_dir_all(&scratch); + + window.set_library_syncing(true); + let rx = crate::derived_sync::spawn_sync( + creds, + session.user_id.clone(), + session.root.clone(), + library::thumbs_dir(&session.server, &session.user_id), + catalog_path, + scratch, + ); + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(300), + move || { + let Some(w) = weak.upgrade() else { return }; + loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + w.set_library_syncing(false); + stop(&ctl_cb.sync_timer); + return; + } + }; + + match msg { + crate::derived_sync::SyncMessage::Status(s) => { + w.set_library_status(s.into()); + } + crate::derived_sync::SyncMessage::Finished(report) => { + log::info!( + "sync: {} shard(s) up, {} down ({} thumbnails), \ + catalog {}{}", + report.shards_uploaded, + report.shards_downloaded, + report.thumbnails_adopted, + if report.catalog_uploaded { "pushed" } else { "not pushed" }, + if report.collections_gained > 0 { + format!(", {} collection(s) gained", report.collections_gained) + } else { + String::new() + } + ); + w.set_library_syncing(false); + if report.did_anything() { + w.set_library_status( + format!( + "synced · {} shard(s) up, {} down", + report.shards_uploaded, report.shards_downloaded + ) + .into(), + ); + } + // Adopted thumbnails and merged collections both change + // what the grid should show. + if report.thumbnails_adopted > 0 || report.collections_gained > 0 { + load_window(&w, &ctl_cb); + } + stop(&ctl_cb.sync_timer); + return; + } + crate::derived_sync::SyncMessage::Failed(e) => { + log::warn!("sync failed: {e}"); + w.set_library_syncing(false); + // Not an error banner: a failed sync costs nothing — + // everything is still local and the next pass retries. + w.set_library_status(format!("sync failed: {e}").into()); + stop(&ctl_cb.sync_timer); + return; + } + } + } + }, + ); + + *ctl.sync_timer.borrow_mut() = Some(timer); +} + +/// Start the whole-library sweep and report its progress. +/// +/// The grid only ever fetches what is on screen, so without this the timeline +/// describes the fraction of the library that happened to be scrolled past. +/// This covers the rest. +fn start_sweep(window: &AppWindow, ctl: &Rc) { + let Some((creds, session, _)) = ctl.session.borrow().clone() else { + return; + }; + + let rx = library::spawn_sweep( + creds, + session.user_id.clone(), + library::catalog_path(&session.server, &session.user_id), + ); + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + + timer.start( + slint::TimerMode::Repeated, + // Slower than the thumbnail drain: this runs for tens of minutes and + // its progress does not need per-frame accuracy. + std::time::Duration::from_millis(400), + move || { + let Some(w) = weak.upgrade() else { return }; + + loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + w.set_library_sweep_total(0); + stop(&ctl_cb.sweep_timer); + return; + } + }; + + match msg { + library::SweepMessage::Total(n) => { + w.set_library_sweep_total(n as i32); + w.set_library_sweep_done(0); + } + library::SweepMessage::Progress { done, dated } => { + w.set_library_sweep_done(done as i32); + // Rebuild as it goes: the histogram growing while the + // sweep runs is the visible sign it is working. + if dated > 0 { + let borrow = ctl_cb.catalog.borrow(); + if let Some(catalog) = borrow.as_ref() { + refresh_timeline(&w, catalog, &ctl_cb); + } + } + } + library::SweepMessage::Finished { dated } => { + log::info!("sweep finished: {dated} dated"); + w.set_library_sweep_total(0); + { + let borrow = ctl_cb.catalog.borrow(); + if let Some(catalog) = borrow.as_ref() { + refresh_timeline(&w, catalog, &ctl_cb); + } + } + // Now that indexing is complete, hand the result to the + // server so a second device inherits it rather than + // repeating hours of range fetches. + start_derived_sync(&w, &ctl_cb); + stop(&ctl_cb.sweep_timer); + return; + } + } + } + }, + ); + + *ctl.sweep_timer.borrow_mut() = Some(timer); +} + +/// Rebuild the timeline histogram from the catalog. +/// +/// Bucket size follows the span of the library, the way darktable's does: +/// a decade of photographs buckets by year, a single trip by day. Picking it +/// from the data rather than fixing it means the histogram is informative at +/// both scales instead of one flat bar or ten thousand slivers. +fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc) { + let span = match catalog_span(catalog) { + Some(s) => s, + None => { + // No dated images yet. An empty histogram is honest — EXIF is read + // as thumbnails load, so this populates as the user browses. + window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![]))); + window.set_library_timeline_label(slint::SharedString::new()); + return; + } + }; + + // Zoom narrows the span around wherever the view sits rather than around + // the library's midpoint, so zooming in keeps what you were looking at. + let zoom = *ctl.timeline_zoom.borrow(); + let (from, to) = zoomed_span(span, zoom, *ctl.timeline_centre.borrow()); + + let granularity = dr_catalog::Granularity::for_span(to - from); + let buckets = match catalog.timeline_range( + &dr_catalog::Query::default(), + granularity, + from, + to, + now_secs(), + ) { + Ok(b) => b, + Err(e) => { + log::debug!("timeline: {e}"); + return; + } + }; + + // Normalise against the tallest bar. Counts vary by orders of magnitude + // between a quiet month and a wedding, so a linear scale against the total + // would render most buckets invisible. + let peak = buckets.iter().map(|b| b.count).max().unwrap_or(1).max(1); + let mut previous: Option<(i64, i64)> = None; + + let bars: Vec = buckets + .iter() + .map(|b| { + let (y, m, _, _) = civil_from_unix(b.start); + // Label only where a period begins, so a month-bucketed axis reads + // "2024 … Mar … Apr" rather than repeating the year on every bar. + let period = match previous { + None => format!("{y}"), + Some((py, _)) if py != y => format!("{y}"), + Some((_, pm)) if pm != m && granularity_labels_months(granularity) => { + month_abbrev(m).to_string() + } + _ => String::new(), + }; + previous = Some((y, m)); + + TimelineBar { + // Square root rather than linear: it keeps a 3-image day + // visible beside a 400-image one without a log scale's + // misleading flatness. + height: ((b.count as f32 / peak as f32).sqrt()).clamp(0.02, 1.0), + start: b.start as i32, + count: b.count as i32, + label: format_bucket(b.start, granularity).into(), + period_label: period.into(), + } + }) + .collect(); + + // Where the grid currently sits, as an index among these bars. Slint + // cannot search an array, and a component guessing the position would put + // the marker somewhere plausible and wrong. + let current = *ctl.current_bucket.borrow(); + let index = current + .and_then(|t| { + bars.iter() + .rposition(|b| (b.start as i64) <= t) + .map(|i| i as i32) + }) + .unwrap_or(-1); + + window.set_library_current_bucket(current.unwrap_or(0) as i32); + window.set_library_current_index(index); + window.set_library_timeline_anchored(current.is_some()); + window.set_library_timeline_label( + format!("{} – {}", format_date(from), format_date(to)).into(), + ); + window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars))); +} + +/// Whether this bucket size is fine enough for month labels to mean anything. +/// +/// A year-bucketed axis labelled by month would put twelve labels on one bar. +fn granularity_labels_months(g: dr_catalog::Granularity) -> bool { + !matches!(g, dr_catalog::Granularity::Year) +} + +/// Full month name, for the grid's headings. +fn month_name(m: i64) -> &'static str { + const NAMES: [&str; 12] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + NAMES[((m - 1).clamp(0, 11)) as usize] +} + +fn month_abbrev(m: i64) -> &'static str { + const NAMES: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + NAMES[((m - 1).clamp(0, 11)) as usize] +} + +/// Narrow a span by a zoom level, centred on `centre`. +/// +/// Each step halves or doubles the visible duration. Clamped to the library's +/// own extent so zooming out cannot wander past the first or last photograph. +fn zoomed_span(full: (i64, i64), zoom: i32, centre: Option) -> (i64, i64) { + let (lo, hi) = full; + if zoom <= 0 { + return (lo, hi); + } + let duration = (hi - lo).max(1); + // 2^zoom, saturating: a very deep zoom must not shift the duration to zero. + let factor = 1i64 << zoom.min(20); + let window = (duration / factor).max(3600); + + let mid = centre.unwrap_or(lo + duration / 2); + let half = window / 2; + let (mut from, mut to) = (mid - half, mid + half); + + // Slide rather than shrink at the ends, so the window keeps its size. + if from < lo { + to += lo - from; + from = lo; + } + if to > hi { + from -= to - hi; + to = hi; + } + (from.max(lo), to.min(hi)) +} + +/// Earliest and latest capture time in the catalog. +fn catalog_span(catalog: &Catalog) -> Option<(i64, i64)> { + catalog + .connection() + .query_row( + "SELECT min(captured_at), max(captured_at) + FROM images WHERE captured_at IS NOT NULL", + [], + |r| Ok((r.get::<_, Option>(0)?, r.get::<_, Option>(1)?)), + ) + .ok() + .and_then(|(lo, hi)| Some((lo?, hi?))) +} + +/// Jump the grid to the first image at or after `when`. +/// +/// This is the scrub: the window moves, the library does not narrow. Every +/// image stays reachable, which is why this is a position rather than a +/// filter. +fn scrub_to(window: &AppWindow, ctl: &Rc, when: i64) { + let position = { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + // How many dated images precede this instant, in the same order the + // grid uses. That ordinal *is* the scroll offset. + catalog + .connection() + .query_row( + "SELECT count(*) FROM images + WHERE captured_at IS NOT NULL AND captured_at < ?1", + [when], + |r| r.get::<_, i64>(0), + ) + .unwrap_or(0) as usize + }; + + // Record where the grid now sits, which anchors the timeline marker. Until + // the first scrub this stays `None` and the marker rests at the middle + // rather than implying a choice the user has not made. + *ctl.current_bucket.borrow_mut() = Some(when); + *ctl.offset.borrow_mut() = position; + // Move the viewport as well as the window. Cells are drawn at their + // absolute place in the library, so loading rows around image 15,000 while + // the viewport sits at row 0 shows an empty grid until the user scrolls. + window.set_library_scroll_to(position as i32); + window.set_library_scroll_token(window.get_library_scroll_token() + 1); + // A new window means new rows; nothing already fetched applies to them. + ctl.requested.borrow_mut().clear(); + load_window(window, ctl); +} + +/// Format a bucket start for the histogram's hover label. +fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String { + let (y, m, d, h) = civil_from_unix(t); + match g { + dr_catalog::Granularity::Year => format!("{y}"), + dr_catalog::Granularity::Month => format!("{y}-{m:02}"), + dr_catalog::Granularity::Day => format!("{y}-{m:02}-{d:02}"), + dr_catalog::Granularity::Hour => format!("{y}-{m:02}-{d:02} {h:02}:00"), + } +} + +fn format_date(t: i64) -> String { + let (y, m, d, _) = civil_from_unix(t); + format!("{y}-{m:02}-{d:02}") +} + +/// Unix seconds to a civil date, via the usual era-based algorithm. +/// +/// Hand-rolled rather than pulling in chrono for four fields — the same +/// reasoning as the connector's HTTP date parsing. +fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) { + let days = t.div_euclid(86_400); + let secs = t.rem_euclid(86_400); + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { y + 1 } else { y }, m, d, secs / 3600) +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Copy decoded RGBA into a Slint image. +/// +/// This is a CPU copy, which is acceptable here and not in the develop path: +/// a 256px thumbnail is 256 KB and happens once per image, where the canvas +/// would pay per frame (ARCH §6.1). +fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image { + let mut buf = slint::SharedPixelBuffer::::new(width, height); + let expected = (width as usize) * (height as usize) * 4; + let src = &rgba[..expected.min(rgba.len())]; + buf.make_mut_bytes()[..src.len()].copy_from_slice(src); + slint::Image::from_rgba8(buf) +} + +/// Connect the grid's callbacks. +pub fn wire( + window: &AppWindow, + ctl: Rc, + coll_ctl: Rc, + on_open_image: F, +) +where + F: Fn(String) + 'static, +{ + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll_for_click = coll_ctl.clone(); + window.on_library_cell_clicked(move |i| { + // A ctrl- or shift-click is a selection gesture. Opening the image + // too would throw the user out of the grid mid-selection. + if coll_for_click.press_was_modified() { + return; + } + let path = ctl.paths.borrow().get(i as usize).cloned(); + if let Some(path) = path { + // Leave the grid for the develop view. The status bar's + // "‹ Library" button comes back here. + if let Some(w) = weak.upgrade() { + w.set_show_library(false); + } + on_open_image(path); + } + }); + } + + // Explicit sync, for when the user wants the exchange now rather than + // after the next sweep. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_sync_now(move || { + if let Some(w) = weak.upgrade() { + start_derived_sync(&w, &ctl); + } + }); + } + + // A column-count change moves which cells begin a row, and month headings + // sit on row-leading cells. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_columns_changed(move || { + if let Some(w) = weak.upgrade() { + load_window(&w, &ctl); + } + }); + } + + // The viewport changed size, so the window it can usefully hold changed + // with it. Reloading only on growth would leave a maximised-then-restored + // window over-fetching, so both directions are honoured. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_capacity(move |capacity| { + let Some(w) = weak.upgrade() else { return }; + let capacity = (capacity.max(0) as usize).max(MIN_WINDOW); + if capacity == *ctl.window.borrow() { + return; + } + *ctl.window.borrow_mut() = capacity; + // The window's extent changed, so rows outside the old one were + // never requested and rows inside it still hold their thumbnails. + ctl.requested.borrow_mut().clear(); + load_window(&w, &ctl); + }); + } + + // Scrolling moves the loaded window through the library. + // + // The whole catalog is reachable because the Flickable's viewport is sized + // to it; this keeps the 120 loaded rows centred on wherever the view is. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_scrolled(move |first_visible| { + let Some(w) = weak.upgrade() else { return }; + let first_visible = first_visible.max(0) as usize; + + // Centre the window on the view, so scrolling either way has + // loaded rows ahead of it rather than only below. + let window_size = *ctl.window.borrow(); + let desired = first_visible.saturating_sub(window_size / 4); + let current = *ctl.offset.borrow(); + + // Reload only once the view nears an edge of what is loaded. + // Reacting to every scroll event would re-query and re-fetch + // continuously during a drag; this fires a few times per screenful. + let margin = window_size / 4; + let inside = first_visible >= current + margin + && first_visible + margin < current + window_size; + if inside { + return; + } + + *ctl.offset.borrow_mut() = desired; + // A different window means different rows; nothing already + // requested applies to them. + ctl.requested.borrow_mut().clear(); + load_window(&w, &ctl); + }); + } + + // Panning the timeline slides the visible span without changing its width. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_timeline_pan(move |buckets| { + let Some(w) = weak.upgrade() else { return }; + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { return }; + let Some(full) = catalog_span(catalog) else { return }; + + // Shift the centre by whole buckets of the *current* span, so a + // pan moves by what the user can see rather than a fixed duration. + let zoom = *ctl.timeline_zoom.borrow(); + let (from, to) = zoomed_span(full, zoom, *ctl.timeline_centre.borrow()); + let step = ((to - from) / 40).max(1); + + let centre = ctl + .timeline_centre + .borrow() + .unwrap_or((from + to) / 2) + + step * buckets as i64; + *ctl.timeline_centre.borrow_mut() = Some(centre.clamp(full.0, full.1)); + refresh_timeline(&w, catalog, &ctl); + }); + } + + // The wheel zooms the axis: a sidebar is a scale, not a list. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_timeline_zoom(move |delta| { + let Some(w) = weak.upgrade() else { return }; + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { return }; + + // Bounded: past ~2^12 the window is minutes wide and every bucket + // is empty, which reads as a broken axis rather than a deep zoom. + let next = (*ctl.timeline_zoom.borrow() + delta).clamp(0, 12); + if next == *ctl.timeline_zoom.borrow() { + return; + } + *ctl.timeline_zoom.borrow_mut() = next; + + // Zooming fully out forgets the centre, so the axis returns to + // describing the whole library rather than a remembered position. + if next == 0 { + *ctl.timeline_centre.borrow_mut() = None; + } else if ctl.timeline_centre.borrow().is_none() { + // First zoom centres on wherever the grid is, else the middle. + *ctl.timeline_centre.borrow_mut() = *ctl.current_bucket.borrow(); + } + refresh_timeline(&w, catalog, &ctl); + }); + } + + // Dragging the histogram moves the grid through time. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_scrub(move |when| { + if let Some(w) = weak.upgrade() { + scrub_to(&w, &ctl, when as i64); + } + }); + } + + // Grid → launch screen. The route that was missing: once past the launch + // screen there was no way back to it, so a library pointed at the wrong + // folder could not be changed without clearing stored state by hand. + { + let weak = window.as_weak(); + window.on_library_change(move || { + if let Some(w) = weak.upgrade() { + w.set_show_launch(true); + } + }); + } + + // Develop → grid. + { + let weak = window.as_weak(); + window.on_back_to_library(move || { + if let Some(w) = weak.upgrade() { + w.set_show_library(true); + } + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll_ctl = coll_ctl.clone(); + window.on_library_rescan(move || { + let Some(w) = weak.upgrade() else { return }; + let Some((creds, session, filter)) = ctl.session.borrow().clone() else { + return; + }; + + w.set_library_scanning(true); + w.set_library_error(slint::SharedString::new()); + w.set_library_status("Rescanning…".into()); + + let path = library::catalog_path(&session.server, &session.user_id); + let rx = library::spawn_scan( + creds, + session.user_id.clone(), + session.root.clone(), + filter, + path.clone(), + ); + drain_scan(w.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path); + }); + } + + // --- ratings and flags (FR-CAT-5, FR-CULL-4) -------------------------- + + // Clicking a star rates *that cell*, not the selection. The pointer names + // one photograph unambiguously, and a click that silently rated forty + // others would be a trap — the keyboard is the bulk gesture. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_cell_rated(move |row, stars| { + let Some(w) = weak.upgrade() else { return }; + let id = ctl + .image_ids + .borrow() + .get(row as usize) + .map(|id| dr_types::ImageId(*id as u64)); + let Some(id) = id else { return }; + + apply_judgement(&w, &ctl, &[id], Some(stars.clamp(0, 5) as u8), None); + }); + } + + // A rating or flag key. Applies to the whole selection, which is what + // makes judging a run of frames one keystroke rather than forty. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll_for_keys = coll_ctl.clone(); + window.on_library_judged(move |rating, flag| { + let Some(w) = weak.upgrade() else { return }; + let chosen = coll_for_keys.selected(); + + // Exactly one axis is meant per keystroke; the other arrives as + // -1 so a star press cannot disturb a flag or the reverse. + if rating >= 0 { + apply_judgement(&w, &ctl, &chosen, Some(rating.clamp(0, 5) as u8), None); + } else if flag >= 0 { + apply_judgement(&w, &ctl, &chosen, None, Some(flag_from_code(flag))); + } + }); + } + + // --- the filter bar --------------------------------------------------- + // + // Each of these narrows what the grid *queries*, so all three reset the + // scroll offset: the window's position was an ordinal into a different + // set of images and means nothing once the set changes. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_filter_min_rating_changed(move |n| { + let Some(w) = weak.upgrade() else { return }; + ctl.filter.borrow_mut().min_rating = n.clamp(0, 5) as u8; + // Stars and "unrated" are contradictory terms — asking for four + // stars *and* nothing judged matches nothing at all, which reads + // as a broken filter rather than an impossible question. + if n > 0 { + ctl.filter.borrow_mut().unjudged = false; + } + w.set_library_filter_min_rating(n.clamp(0, 5)); + w.set_library_filter_unjudged(ctl.filter.borrow().unjudged); + refilter(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_filter_unjudged_changed(move |on| { + let Some(w) = weak.upgrade() else { return }; + { + let mut f = ctl.filter.borrow_mut(); + f.unjudged = on; + // See above: the two cannot both hold. + if on { + f.min_rating = 0; + f.flag = None; + } + } + w.set_library_filter_unjudged(on); + if on { + w.set_library_filter_min_rating(0); + w.set_library_filter_flag(0); + } + refilter(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_filter_flag_changed(move |f| { + let Some(w) = weak.upgrade() else { return }; + { + let mut filter = ctl.filter.borrow_mut(); + filter.flag = match f { + 1 => Some(dr_types::FlagState::Pick), + 2 => Some(dr_types::FlagState::Reject), + _ => None, + }; + if f > 0 { + filter.unjudged = false; + } + } + w.set_library_filter_flag(f); + w.set_library_filter_unjudged(ctl.filter.borrow().unjudged); + refilter(&w, &ctl); + }); + } +} + +/// Reload the grid after the filter changed. +/// +/// The offset is reset because it is an ordinal into the filtered set: keeping +/// it would land the user in the middle of a narrowed library with no sense of +/// how they got there, or past its end entirely. +fn refilter(window: &AppWindow, ctl: &Rc) { + *ctl.offset.borrow_mut() = 0; + ctl.requested.borrow_mut().clear(); + load_window(window, ctl); +} + +fn stop(slot: &RefCell>) { + if let Some(t) = slot.borrow().as_ref() { + t.stop(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + + #[test] + fn zoom_zero_is_the_whole_library() { + let full = (1_000, 2_000); + assert_eq!(zoomed_span(full, 0, None), full); + // A remembered centre must not narrow the span at zoom 0. + assert_eq!(zoomed_span(full, 0, Some(1_500)), full); + } + + #[test] + fn each_zoom_step_halves_the_span() { + let day = 86_400; + let full = (0, 64 * day); + let (a, b) = zoomed_span(full, 1, Some(32 * day)); + assert_eq!(b - a, 32 * day); + let (a, b) = zoomed_span(full, 2, Some(32 * day)); + assert_eq!(b - a, 16 * day); + } + + #[test] + fn zooming_centres_on_the_given_instant() { + let day = 86_400; + let full = (0, 100 * day); + let (a, b) = zoomed_span(full, 1, Some(60 * day)); + assert_eq!((a + b) / 2, 60 * day, "centred where asked"); + } + + #[test] + fn a_window_at_the_edge_slides_rather_than_shrinking() { + // Clamping both ends would silently halve the span near the start of + // the library, so the axis would show less detail there than in the + // middle for the same zoom level. + let day = 86_400; + let full = (0, 100 * day); + let (a, b) = zoomed_span(full, 1, Some(0)); + assert_eq!(a, 0, "cannot start before the library does"); + assert_eq!(b - a, 50 * day, "keeps its width"); + + let (a, b) = zoomed_span(full, 1, Some(100 * day)); + assert_eq!(b, 100 * day); + assert_eq!(b - a, 50 * day); + } + + #[test] + fn a_deep_zoom_does_not_collapse_to_nothing() { + // A zero-width span would make every bucket empty, which reads as a + // broken axis rather than a deep zoom. + let full = (0, 86_400); + let (a, b) = zoomed_span(full, 12, Some(43_200)); + assert!(b > a, "span stays positive"); + } + + #[test] + fn a_single_instant_library_does_not_divide_by_zero() { + let full = (1_700_000_000, 1_700_000_000); + let (a, b) = zoomed_span(full, 5, None); + assert!(a <= b); + } + + #[test] + fn month_names_cover_the_year_and_clamp() { + assert_eq!(month_name(1), "January"); + assert_eq!(month_name(12), "December"); + assert_eq!(month_abbrev(3), "Mar"); + // A corrupt month must not panic the grid. + assert_eq!(month_name(0), "January"); + assert_eq!(month_name(99), "December"); + } + + #[test] + fn civil_dates_round_trip_at_boundaries() { + // Era-based date maths is silently wrong at year and leap boundaries + // if the algorithm is transcribed slightly off, and the symptom is an + // image landing in the wrong histogram bucket. + assert_eq!(civil_from_unix(0), (1970, 1, 1, 0)); + assert_eq!(civil_from_unix(1_786_285_800), (2026, 8, 9, 14)); + // Leap day. + assert_eq!(civil_from_unix(1_709_164_800), (2024, 2, 29, 0)); + // Last second of a year, and the first of the next. + assert_eq!(civil_from_unix(1_735_689_599), (2024, 12, 31, 23)); + assert_eq!(civil_from_unix(1_735_689_600), (2025, 1, 1, 0)); + } + + #[test] + fn dates_before_the_epoch_do_not_wrap() { + // Scanned film carries capture dates well before 1970; a negative + // timestamp must floor rather than truncate toward zero. + let (y, _, _, _) = civil_from_unix(-1); + assert_eq!(y, 1969); + } + + #[test] + fn bucket_labels_match_their_granularity() { + use dr_catalog::Granularity; + let t = 1_786_285_800; // 2026-08-09 14:30 UTC + assert_eq!(format_bucket(t, Granularity::Year), "2026"); + assert_eq!(format_bucket(t, Granularity::Month), "2026-08"); + assert_eq!(format_bucket(t, Granularity::Day), "2026-08-09"); + assert_eq!(format_bucket(t, Granularity::Hour), "2026-08-09 14:00"); + } + + #[test] + fn rgba_shorter_than_declared_does_not_panic() { + // A truncated decode must degrade to a partial image, not abort the + // grid. Decoders handle untrusted input (NFR-SEC-1). + let img = to_slint_image(4, 4, &[0u8; 8]); + assert_eq!(img.size().width, 4); + } + + #[test] + fn rgba_longer_than_declared_is_truncated() { + let img = to_slint_image(2, 2, &[255u8; 1024]); + assert_eq!(img.size().width, 2); + assert_eq!(img.size().height, 2); + } + + /// A thumbnail drain must be able to tell that the window it was started + /// for has been replaced. + /// + /// This is the whole of the black-grid fix, reduced to the comparison the + /// timer callback makes. `row` is an index into the window that requested + /// the fetch, so a drain that keeps writing after a reload paints + /// thumbnails onto unrelated photographs — and, worse, its `stop` lands on + /// the *current* batch's timer and leaves the new fetches undrained. + #[test] + fn a_reload_makes_an_in_flight_thumbnail_batch_stale() { + let ctl = LibraryController::new(); + + // What `drain_thumbnails` captures when the batch is spawned. + let mine = ctl.generation.get(); + assert_eq!(ctl.generation.get(), mine, "its own batch is live"); + + // What `load_window` does just before swapping the model. + ctl.generation.set(ctl.generation.get().wrapping_add(1)); + + assert_ne!( + ctl.generation.get(), + mine, + "the batch must recognise itself as stale once the model is replaced" + ); + } + + /// Each load is distinct, so two reloads cannot alias back to a live batch. + #[test] + fn every_window_load_takes_a_fresh_generation() { + let ctl = LibraryController::new(); + let seen: Vec = (0..4) + .map(|_| { + let g = ctl.generation.get(); + ctl.generation.set(g.wrapping_add(1)); + g + }) + .collect(); + + assert_eq!(seen, vec![0, 1, 2, 3]); + } +} diff --git a/ui/dr-ui/src/live_style.rs b/ui/dr-ui/src/live_style.rs new file mode 100644 index 0000000..43b0564 --- /dev/null +++ b/ui/dr-ui/src/live_style.rs @@ -0,0 +1,181 @@ +//! Re-reads `style.yaml` at startup, so a palette can be tuned by editing a +//! data file and restarting rather than rebuilding the tree. +//! +//! Compiled only under the `live-style` feature. Release builds have no +//! parser, no file read, and tokens the Slint compiler has folded to +//! constants — this path exists for the ten minutes somebody spends deciding +//! whether `surface` wants two more points of lift. +//! +//! The trick that makes it cheap: under this feature `build.rs` emits the +//! tokens as `in-out` rather than `out`. `Theme.ground` reads identically +//! either way, so not one call site knows this module exists; the only +//! difference is that Slint now generates Rust setters for the global. +//! +//! Failure here is never fatal. A photographer running a debug build with a +//! half-edited YAML should get their window and a warning, not a crash — the +//! build-time path is where a malformed file is an error. +//! +//! The cost of the feature is the table at the bottom: Slint generates one +//! setter per property and nothing indexed, so adding a token to `style.yaml` +//! means adding a line here too. Tolerable because it is debug-only, and the +//! unmatched-name warning says so out loud rather than reloading a no-op — +//! but it is why this is not the primary path. + +use serde_norway::Value; +use slint::{Color, ComponentHandle}; + +use crate::{AppWindow, Theme}; + +/// Absolute path baked in by `build.rs`, so the binary finds the file +/// regardless of the directory it is launched from. +const STYLE_YAML: &str = env!("DR_STYLE_YAML"); + +pub fn apply(window: &AppWindow) { + match load() { + Ok(style) => { + let n = style.apply_to(window); + log::info!("live-style: applied {n} token(s) from {STYLE_YAML}"); + } + Err(e) => log::warn!("live-style: keeping compiled tokens; {STYLE_YAML}: {e}"), + } +} + +struct Style { + colors: Vec<(String, Color)>, + lengths: Vec<(String, f32)>, +} + +fn load() -> Result { + let text = std::fs::read_to_string(STYLE_YAML).map_err(|e| e.to_string())?; + let doc: Value = serde_norway::from_str(&text).map_err(|e| e.to_string())?; + let doc = doc.as_mapping().ok_or("top level is not a mapping")?; + + let mut colors = Vec::new(); + for (name, spec) in group(doc, "colors")? { + // An alias resolves against what has already been read, matching the + // generated Slint where `modified: root.active` refers upward. + if let Some(target) = scalar(spec, "alias") { + if let Some((_, c)) = colors.iter().find(|(n, _): &&(String, Color)| n == &target) { + colors.push((name, *c)); + } + continue; + } + if let Some(hex) = value_of(spec).and_then(|v| v.as_str()) { + if let Some(c) = parse_hex(hex) { + colors.push((name, c)); + } + } + } + + let mut lengths = Vec::new(); + for (name, spec) in group(doc, "lengths")? { + if let Some(px) = value_of(spec).and_then(|v| v.as_f64()) { + lengths.push((name, px as f32)); + } + } + Ok(Style { colors, lengths }) +} + +/// Yields the real tokens of a group, skipping the `section:`/`break:` +/// dividers that exist only to shape the generated file. +fn group<'a>( + doc: &'a serde_norway::Mapping, + key: &str, +) -> Result, String> { + let map = doc + .get(key) + .and_then(Value::as_mapping) + .ok_or_else(|| format!("missing `{key}:` map"))?; + Ok(map + .iter() + .filter(|(_, spec)| { + !spec + .as_mapping() + .is_some_and(|m| m.contains_key("section") || m.contains_key("break")) + }) + .filter_map(|(k, v)| Some((k.as_str()?.to_string(), v))) + .collect()) +} + +fn value_of(spec: &Value) -> Option<&Value> { + match spec.as_mapping() { + Some(map) => map.get("value"), + None => Some(spec), + } +} + +fn scalar(spec: &Value, field: &str) -> Option { + Some(spec.as_mapping()?.get(field)?.as_str()?.to_string()) +} + +fn parse_hex(hex: &str) -> Option { + let d = hex.strip_prefix('#')?; + let byte = |i: usize| u8::from_str_radix(d.get(i..i + 2)?, 16).ok(); + match d.len() { + 6 => Some(Color::from_rgb_u8(byte(0)?, byte(2)?, byte(4)?)), + 8 => Some(Color::from_argb_u8(byte(6)?, byte(0)?, byte(2)?, byte(4)?)), + _ => None, + } +} + +impl Style { + /// Slint generates one setter per property rather than anything indexed, + /// so the mapping from token name to setter has to be spelled out. The + /// `theme_tokens!` macro keeps that to one line per token, and an + /// unmatched name is warned about rather than ignored — a token renamed + /// in the YAML and nowhere else would otherwise reload silently as a + /// no-op. + fn apply_to(&self, window: &AppWindow) -> usize { + let theme = window.global::(); + let mut applied = 0; + + macro_rules! theme_tokens { + ($set:ident, $list:expr, $($name:literal => $setter:ident),* $(,)?) => { + for (name, v) in $list { + match name.as_str() { + $($name => { theme.$setter(v.clone().into()); applied += 1; })* + other => log::warn!("live-style: no such token `{other}`"), + } + } + }; + } + + theme_tokens!(set, &self.colors, + "ground" => set_ground, + "surface" => set_surface, + "surface-raised" => set_surface_raised, + "rule" => set_rule, + "ink" => set_ink, + "ink-dim" => set_ink_dim, + "ink-faint" => set_ink_faint, + "hover" => set_hover, + "pressed" => set_pressed, + "active" => set_active, + "active-dim" => set_active_dim, + "active-pressed" => set_active_pressed, + "modified" => set_modified, + "selected" => set_selected, + "selected-ring" => set_selected_ring, + "warn-ink" => set_warn_ink, + ); + + theme_tokens!(set, &self.lengths, + "gap-sm" => set_gap_sm, + "gap" => set_gap, + "gap-lg" => set_gap_lg, + "text-sm" => set_text_sm, + "text" => set_text, + "text-lg" => set_text_lg, + "text-xl" => set_text_xl, + "radius-sm" => set_radius_sm, + "radius" => set_radius, + "touch-target" => set_touch_target, + "row-height" => set_row_height, + "indent" => set_indent, + "control-height" => set_control_height, + "control-min-width" => set_control_min_width, + ); + + applied + } +} diff --git a/ui/dr-ui/src/trash.rs b/ui/dr-ui/src/trash.rs new file mode 100644 index 0000000..21de435 --- /dev/null +++ b/ui/dr-ui/src/trash.rs @@ -0,0 +1,496 @@ +//! TRACES: FR-CAT-15 | NFR-P9 +//! Soft delete, restore, and permanent delete against the remote. +//! +//! `dr_catalog::trash` owns the catalog side and does no I/O. This is the other +//! half: the remote `MOVE`/`DELETE`, run on a worker thread, paired with the +//! catalog record in the one order that is safe. +//! +//! # Ordering, which is the whole of the correctness here +//! +//! **Soft delete** — `MOVE` first, record second. The reverse would leave the +//! catalog claiming a file is trashed while it sits in the library; the scan +//! excludes the trash folder, so nothing would ever correct the row. +//! +//! **Restore** — `MOVE` first, record second, for the same reason mirrored. +//! +//! **Purge** — `DELETE` the file, then forget the row, then forget the +//! thumbnail. A crash between steps leaves a trashed row whose file is gone, +//! which the next empty resolves as already-deleted. The other order loses the +//! file silently: no row, no listing, and a scan that will never look in the +//! trash folder — a photograph consuming quota that nothing can find. +//! +//! # Partial failure is normal, not exceptional +//! +//! Forty files is forty requests, and one can fail on permissions while the +//! rest succeed. Every operation here is therefore per-image and reports what +//! actually happened rather than aborting the batch — a trash that gives up +//! halfway with no record of where it stopped is worse than one that reports +//! "38 of 40". + +use std::path::PathBuf; +use std::sync::mpsc::Receiver; + +use dr_catalog::{trash, Catalog}; +use dr_sync::{RemoteBackend, RemoteError, RemoteId, RemotePath}; +use dr_sync_nextcloud::{AppCredentials, NextcloudBackend}; +use dr_types::ImageId; + +/// What a trash operation reports back to the UI. +#[derive(Debug)] +pub enum TrashMessage { + /// One image finished, successfully or not. + /// + /// Per-image rather than per-batch so the UI can show progress on a large + /// selection, and so a failure names the file it happened to. + Progress { + done: usize, + total: usize, + failed: usize, + }, + /// The batch finished. `failed` names what did not work, for the status line. + Done { + moved: usize, + failed: Vec, + }, +} + +/// Which way an image is being moved. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + /// Library → trash folder. + ToTrash, + /// Trash folder → where it came from. + Restore, +} + +/// One image to move, resolved before the worker starts. +/// +/// Carries the id the `MOVE` addresses and the destination path, so the worker +/// needs no catalog access to do its half — the catalog is not `Send`, and the +/// worker owns a separate connection only for the write-back. +#[derive(Debug, Clone)] +pub struct Move { + pub image_id: ImageId, + /// `oc:fileid` where known, else the path. A stable id survives the move and + /// keeps the thumbnail and sidecar mapping attached. + pub file_id: Option, + pub from: String, + pub to: String, +} + +/// Plan a soft delete: where each image goes in the trash. +/// +/// Reads the catalog, so it runs on the UI thread before the worker starts. +/// Skips images already trashed — re-trashing is a no-op, not an error, and the +/// UI can hand over a selection that overlaps the trash. +pub fn plan_trash( + catalog: &Catalog, + root: &str, + images: &[ImageId], +) -> Result, dr_catalog::CatalogError> { + let mut out = Vec::new(); + for &image in images { + let row: Option<(String, Option, Option)> = catalog + .connection() + .query_row( + "SELECT i.source_ref, r.file_id, i.trashed_at + FROM images i LEFT JOIN remote r ON r.image_id = i.id + WHERE i.id = ?1", + [image.0 as i64], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .ok(); + + let Some((from, file_id, trashed_at)) = row else { + continue; + }; + if trashed_at.is_some() { + continue; + } + + out.push(Move { + image_id: image, + file_id: file_id.map(|v| v as u64), + to: trash::trash_path(root, image, &from), + from, + }); + } + Ok(out) +} + +/// Plan a restore: where each trashed image goes back to. +/// +/// An image with no recorded origin is skipped rather than guessed at — putting +/// a photograph in the wrong folder is harder to notice, and harder to undo, +/// than leaving it in the trash. +pub fn plan_restore( + catalog: &Catalog, + images: &[ImageId], +) -> Result, dr_catalog::CatalogError> { + let mut out = Vec::new(); + for &image in images { + let row: Option<(String, Option, Option)> = catalog + .connection() + .query_row( + "SELECT i.source_ref, i.trashed_from, r.file_id + FROM images i LEFT JOIN remote r ON r.image_id = i.id + WHERE i.id = ?1 AND i.trashed_at IS NOT NULL", + [image.0 as i64], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .ok(); + + let Some((from, Some(to), file_id)) = row else { + continue; + }; + out.push(Move { + image_id: image, + file_id: file_id.map(|v| v as u64), + from, + to, + }); + } + Ok(out) +} + +/// Move images to or from the trash on a worker thread. +/// +/// The catalog is written by the worker, after each successful move, so an +/// interrupted batch leaves the rows it completed correct rather than losing all +/// of them. +pub fn spawn_move( + creds: AppCredentials, + user_id: String, + moves: Vec, + direction: Direction, + catalog_path: PathBuf, +) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let total = moves.len(); + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(TrashMessage::Done { + moved: 0, + failed: vec![e.to_string()], + }); + return; + } + }; + + rt.block_on(async { + let backend = match NextcloudBackend::new(&creds, &user_id) { + Ok(b) => b, + Err(e) => { + let _ = tx.send(TrashMessage::Done { + moved: 0, + failed: vec![e.to_string()], + }); + return; + } + }; + + let mut succeeded: Vec<(ImageId, String)> = Vec::new(); + let mut failed: Vec = Vec::new(); + + for (i, mv) in moves.iter().enumerate() { + let id = match mv.file_id { + Some(f) => RemoteId::Stable(f), + None => RemoteId::Path(RemotePath::new(&mv.from)), + }; + + match backend.move_to(&id, &RemotePath::new(&mv.to)).await { + Ok(()) => succeeded.push((mv.image_id, mv.to.clone())), + Err(e) => { + // Named by file, not by id: the user recognises the + // filename and cannot do anything with a row number. + let name = mv.from.rsplit('/').next().unwrap_or(&mv.from); + log::warn!("moving {name}: {e}"); + failed.push(format!("{name}: {e}")); + } + } + + let _ = tx.send(TrashMessage::Progress { + done: i + 1, + total, + failed: failed.len(), + }); + } + + // Record after the moves, in one transaction. A crash before this + // leaves the files moved and the catalog stale — recoverable, + // because the next scan cannot see them in the trash folder and the + // rows still point at paths that 404, which the UI reports. + if !succeeded.is_empty() { + match Catalog::open(&catalog_path) { + Ok(cat) => { + let result = match direction { + Direction::ToTrash => { + trash::record_trashed(cat.connection(), &succeeded, now_secs()) + } + Direction::Restore => { + trash::record_restored(cat.connection(), &succeeded) + } + }; + if let Err(e) = result { + log::warn!("recording trash state: {e}"); + failed.push(format!("catalog: {e}")); + } + } + Err(e) => { + log::warn!("opening catalog to record trash state: {e}"); + failed.push(format!("catalog: {e}")); + } + } + } + + let _ = tx.send(TrashMessage::Done { + moved: succeeded.len(), + failed, + }); + }); + }); + + rx +} + +/// Permanently delete trashed images: the file, then the row, then the thumbnail. +/// +/// See the module preamble for why that order. `thumbs_dir` is passed so the +/// worker can drop the previews — the shards sync, so a stale entry would keep +/// serving a preview of a deleted photograph on every device. +pub fn spawn_purge( + creds: AppCredentials, + user_id: String, + images: Vec, + paths: Vec<(ImageId, Option, String)>, + catalog_path: PathBuf, + thumbs_dir: PathBuf, +) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let total = paths.len(); + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(TrashMessage::Done { + moved: 0, + failed: vec![e.to_string()], + }); + return; + } + }; + + rt.block_on(async { + let backend = match NextcloudBackend::new(&creds, &user_id) { + Ok(b) => b, + Err(e) => { + let _ = tx.send(TrashMessage::Done { + moved: 0, + failed: vec![e.to_string()], + }); + return; + } + }; + + let mut deleted: Vec = Vec::new(); + let mut dead_thumbs: Vec = Vec::new(); + let mut failed: Vec = Vec::new(); + + for (i, (image, file_id, path)) in paths.iter().enumerate() { + let id = match file_id { + Some(f) => RemoteId::Stable(*f), + None => RemoteId::Path(RemotePath::new(path)), + }; + + match backend.delete(&id, None).await { + Ok(()) => { + deleted.push(*image); + if let Some(f) = file_id { + dead_thumbs.push(*f); + } + } + // Already gone is the goal state, not a failure. Treating it + // as an error would wedge every future empty-trash on a file + // the user had removed by hand. + Err(e) if is_missing(&e) => { + log::debug!("{path} was already gone"); + deleted.push(*image); + if let Some(f) = file_id { + dead_thumbs.push(*f); + } + } + Err(e) => { + let name = path.rsplit('/').next().unwrap_or(path); + log::warn!("deleting {name}: {e}"); + failed.push(format!("{name}: {e}")); + } + } + + let _ = tx.send(TrashMessage::Progress { + done: i + 1, + total, + failed: failed.len(), + }); + } + + // Rows after the files — see `trash::purge_order`. + if !deleted.is_empty() { + match Catalog::open(&catalog_path) { + Ok(cat) => { + if let Err(e) = trash::forget(cat.connection(), &deleted) { + log::warn!("forgetting purged rows: {e}"); + failed.push(format!("catalog: {e}")); + } + } + Err(e) => failed.push(format!("catalog: {e}")), + } + } + + // Thumbnails last. A failure here is logged and dropped: the + // photographs are gone, which was the point, and a stale preview is + // a cosmetic problem rather than a reason to report the delete + // failed. + if !dead_thumbs.is_empty() { + match dr_thumbs::ThumbStore::open(&thumbs_dir) { + Ok(mut store) => match store.forget(&dead_thumbs) { + Ok(n) => log::info!("dropped {n} thumbnail(s) for purged images"), + Err(e) => log::warn!("dropping thumbnails: {e}"), + }, + Err(e) => log::warn!("opening thumbnail store to drop previews: {e}"), + } + } + + let _ = tx.send(TrashMessage::Done { + moved: deleted.len(), + failed, + }); + }); + + let _ = images; + }); + + rx +} + +/// Whether a remote error means the object is not there. +/// +/// Kept next to its use rather than in `dr-catalog`: it inspects a +/// `dr_sync::RemoteError`, and the catalog crate neither sees nor should see +/// that type. +fn is_missing(e: &RemoteError) -> bool { + matches!(e, RemoteError::NotFound(_)) +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seeded() -> Catalog { + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'PhotosRaw')", + [], + ) + .unwrap(); + for i in 1..=3i64 { + c.execute( + "INSERT INTO images(id, root_id, source_ref, file_size, added_at) + VALUES (?1, 1, ?2, 1000, 0)", + rusqlite::params![i, format!("PhotosRaw/2019/IMG_{i:04}.CR2")], + ) + .unwrap(); + c.execute( + "INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)", + rusqlite::params![i, 500 + i], + ) + .unwrap(); + } + cat + } + + fn img(i: u64) -> ImageId { + ImageId(i) + } + + #[test] + fn a_trash_plan_targets_the_trash_folder_and_keeps_the_stable_id() { + let cat = seeded(); + let plan = plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap(); + + assert_eq!(plan.len(), 1); + assert_eq!(plan[0].from, "PhotosRaw/2019/IMG_0001.CR2"); + assert!(plan[0].to.contains(".darkroom-trash"), "{}", plan[0].to); + // The stable id is what makes the move keep the thumbnail attached. + assert_eq!(plan[0].file_id, Some(501)); + } + + #[test] + fn an_already_trashed_image_is_not_trashed_again() { + // The selection can overlap what is already in the trash; a second move + // would relocate the file *within* the trash and lose its origin. + let cat = seeded(); + let plan = plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap(); + trash::record_trashed(cat.connection(), &[(img(1), plan[0].to.clone())], 100).unwrap(); + + assert!(plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap().is_empty()); + } + + #[test] + fn a_restore_plan_sends_each_image_back_where_it_came_from() { + let cat = seeded(); + let plan = plan_trash(&cat, "PhotosRaw", &[img(1)]).unwrap(); + trash::record_trashed(cat.connection(), &[(img(1), plan[0].to.clone())], 100).unwrap(); + + let back = plan_restore(&cat, &[img(1)]).unwrap(); + assert_eq!(back.len(), 1); + assert_eq!(back[0].to, "PhotosRaw/2019/IMG_0001.CR2"); + // And it moves *from* the trash. + assert!(back[0].from.contains(".darkroom-trash")); + } + + #[test] + fn restoring_an_untrashed_image_plans_nothing() { + let cat = seeded(); + assert!(plan_restore(&cat, &[img(2)]).unwrap().is_empty()); + } + + #[test] + fn a_missing_image_is_skipped_rather_than_planned_against_nothing() { + // The grid's selection can outlive a rescan that removed a row. + let cat = seeded(); + assert!(plan_trash(&cat, "PhotosRaw", &[img(999)]).unwrap().is_empty()); + assert!(plan_restore(&cat, &[img(999)]).unwrap().is_empty()); + } + + #[test] + fn a_not_found_on_delete_counts_as_deleted() { + // Otherwise one hand-removed file wedges every future empty-trash. + assert!(is_missing(&RemoteError::NotFound("x".into()))); + assert!(!is_missing(&RemoteError::Unsupported("x"))); + } + + #[test] + fn planning_over_an_empty_selection_is_a_no_op() { + let cat = seeded(); + assert!(plan_trash(&cat, "PhotosRaw", &[]).unwrap().is_empty()); + assert!(plan_restore(&cat, &[]).unwrap().is_empty()); + } +} diff --git a/ui/dr-ui/style.yaml b/ui/dr-ui/style.yaml new file mode 100644 index 0000000..cc029d0 --- /dev/null +++ b/ui/dr-ui/style.yaml @@ -0,0 +1,163 @@ +# The source of truth for the UI's colour and length tokens. +# +# `build.rs` reads this file and generates `theme.slint` into OUT_DIR at +# compile time; nothing here is read at runtime unless the `live-style` +# feature is on. Edit this file, never the generated one. +# +# Leaf values only. A token that binds several values into one concept — a +# panel heading's colour *and* size *and* weight — is a Slint component, not +# a row in a YAML file (see widgets.slint). +# +# Structure: +# preamble — prose emitted at the top of the generated file +# colors: — name -> "#RRGGBB", or `alias:` naming another colour token +# lengths: — name -> pixels +# Any token may carry `note:` (a `//` comment above it) or `doc:` (a `///` +# doc comment, which Slint surfaces to editors). +# +# Two entries are dividers rather than tokens, because YAML discards the blank +# lines between map entries and the generated file's grouping has to survive: +# `section: ` — a headed run, optionally with its own `note:` +# `break: true` — a bare blank line between related tokens + +preamble: | + Near-neutral dark palette, achromatic signalling. + + Two commitments, both about not lying to the photographer. + + **Dark ground.** A light UI surrounding an image biases how that image is + judged — the eye adapts to the brightest thing in view, and a white panel + makes a correctly-exposed photograph look dark. + + **Near-neutral greys.** The earlier palette was a warm "darkroom safelight" + brown (ground #14120F, R twelve points above B). That is worse than it + sounds: simultaneous contrast pushes perception of the image *away* from the + surround, so a warm chrome makes a neutral photograph read cool, and the + photographer corrects toward warm to compensate. Every export drifts yellow. + It is the same reason a print viewing booth is neutral grey rather than + whatever colour the room happens to be. + + These greys carry a 2–3 point blue lift rather than being flatly achromatic. + Pure R=G=B reads as dead to most eyes; a trace of cool reads as instrument + rather than absence, and biases far less than warmth because the eye is more + tolerant of a cool surround. The lift is small enough not to matter + perceptually and deliberate enough not to be mistaken for drift. + + **No hue in the chrome.** Active and modified states are signalled by + brightness alone. An accent sitting beside the image competes with it for + attention and shifts the perception of nearby colours; a photo editor cannot + afford either. The one exception is `warn-ink` — a caution is genuinely a + different kind of thing from an active state, and hue is the fastest way to + say so. + +colors: + ground: "#121314" + surface: "#1B1C1E" + surface-raised: "#252629" + rule: "#323438" + + _inks: { break: true } + ink: "#EDEEF0" + ink-dim: "#9EA1A6" + ink-faint: "#71747A" + + hover: + value: "#2E3034" + note: | + Interactive states for surfaces. Named rather than written inline so a + button, a section header and a list row cannot drift apart: hover lifts + toward the light, press sinks back past the resting surface so the + control reads as depressed rather than merely lit. + pressed: "#17181A" + + _signalling: + section: signalling + note: | + Achromatic, so the separation has to come from luminance. These are + spaced further apart than a coloured palette would need: with hue + unavailable, a two-step brightness difference is invisible, and a + modified marker that cannot be spotted at a glance is not a marker. + + active: + value: "#FFFFFF" + doc: | + An active or engaged control: a slider fill, a curve line, a checked + box. Brighter than `ink` so it reads as lit rather than merely present. + active-dim: + value: "#C6C9CE" + doc: Active, at rest — a filled control that is not under the pointer. + active-pressed: + value: "#8E9298" + doc: Active, pressed. Sinks rather than lifts, matching `pressed`. + + modified: + alias: active + note: | + "This differs from its default." Deliberately the brightest thing in the + chrome: it is the one piece of state the photographer scans for, and + with no hue to carry it, brightness is all there is. + + selected: + value: "#383B40" + doc: | + A selected grid cell. A lifted neutral rather than a tint — distinct + from `hover` because a multi-selection must stay legible after the + pointer has moved on, which is the whole point of selecting several + before dragging them. + selected-ring: + value: "#D5D8DD" + doc: | + The ring around a selected cell. Brighter than the fill so selection + survives against a pale thumbnail, where the fill alone would vanish. + + warn-ink: + value: "#C9A05A" + note: | + Semantic, and the only hue in the palette. A caution is not an active + state, and it is worth the one exception to say that instantly. Muted + rather than saturated so it does not shift perception of a nearby image. + +lengths: + _gaps: { break: true } + gap-sm: 6 + gap: 12 + gap-lg: 20 + + _text: { break: true } + text-sm: 11 + text: 13 + text-lg: 17 + text-xl: 24 + + radius-sm: + value: 3 + note: | + Corner radii. Two steps only: `radius-sm` for things that sit inside + other things, `radius` for the controls themselves. + radius: 4 + + touch-target: + value: 44 + note: "FR-UI-3: minimum 44pt hit target under touch." + + row-height: + value: 26 + note: | + One row of the collections tree, and one level of nesting. Both are + tokens because a tree's indentation has to stay proportional to its row + height; hard-coding either makes the hierarchy read wrong when the other + changes. + indent: 14 + + control-height: + value: 28 + note: | + The drawn height of a button or section header. Deliberately shorter + than `touch-target` — chrome this tall in every row would crowd the + photograph — so controls grow their TouchArea past their own bounds to + meet FR-UI-3 rather than growing their ink. + control-min-width: + value: 88 + doc: | + Floor on button width, so a one-word label is still a comfortable + target and a row of buttons has an even rhythm. diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index de4b439..9aad8ec 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -8,6 +8,7 @@ // (FR-DEV-3c). import { Theme } from "theme.slint"; +import { PanelHeading, Label, Value, Caption, Section } from "widgets.slint"; // One parameter, flattened for Slint's model system. // @@ -24,9 +25,21 @@ export struct ParamRow { op-label: string, param-label: string, - // True on the first parameter of each operation, so the panel can draw a - // section heading without knowing what the sections are. - starts-group: bool, + // Grouping, derived in Rust from where `op-index` changes. + // + // The model is flat and Slint cannot slice one, so a group says where it + // begins and how long it is and the panel indexes back into `rows` from + // there. `group-head` is this row's group's first index — a row heads its + // group exactly when its own index equals it, which is what replaced the + // core-supplied `starts-group` flag (ARCH §4.3a: the core does not decide + // that the panel has sections). + group-head: int, + group-len: int, + + // Any parameter of this operation differs from its default. Identical on + // every row of a group, because the heading is one of those rows and + // cannot see the others. + group-modified: bool, // Which control to build. Mirrors ParamKind, plus the widget kinds an // operation can request through its presentation. @@ -62,26 +75,25 @@ component ParamSlider inherits Rectangle { spacing: 2px; HorizontalLayout { - Text { + Label { text: root.data.param-label; - color: root.data.value != root.data.default-value - ? Theme.ink : Theme.ink-dim; - font-size: Theme.text-sm; - vertical-alignment: center; + emphasised: root.data.value != root.data.default-value; } Rectangle { horizontal-stretch: 1; } - Text { + Value { // Precision comes from the descriptor, so a control in stops // reads 1.25 while one in whole units reads 25. text: root.data.precision == 0 ? Math.round(root.data.value) + root.data.unit : (Math.round(root.data.value * 100) / 100) + root.data.unit; - color: root.data.value != root.data.default-value - ? Theme.accent : Theme.ink-faint; - font-size: Theme.text-sm; - vertical-alignment: center; + // The one readout in the panel that has moved off its default + // is what the eye is hunting for, and `modified` is the only + // thing left to say it with once hue is gone. + modified: root.data.value != root.data.default-value; + placeholder: root.data.value == root.data.default-value; + compact: true; } } @@ -123,7 +135,10 @@ component ParamSlider inherits Rectangle { width: abs(self.value-x / 1px - self.default-x / 1px) * 1px; y: (parent.height - 3px) / 2; height: 3px; - background: Theme.accent; + // The fill is the engaged part of the control — the span the + // photographer has actually moved — so it takes `active` + // rather than the ink the rest of the track is drawn in. + background: Theme.active; border-radius: 1.5px; } @@ -147,6 +162,16 @@ component ParamSlider inherits Rectangle { property <float> span: root.data.maximum - root.data.minimum; + // Whether this gesture has been claimed as a slider drag. + // + // The panel scrolls vertically and this control acts + // horizontally, so the axis of the movement says which was + // meant. Committing on press-down instead — the obvious + // approach — makes every attempt to scroll from a slider + // jump its value first, which is destructive and happens + // constantly given how much of the panel is sliders. + property <bool> claimed: false; + function value-at(px: length) -> float { return clamp( root.data.minimum + (px / self.width) * self.span, @@ -155,15 +180,23 @@ component ParamSlider inherits Rectangle { } moved => { - // `moved` fires only while pressed, so this is the drag. - root.changed(self.value-at(self.mouse-x)); + // `moved` fires only while pressed, so this is a drag. + if (!self.claimed) { + // Claim once the movement is more horizontal than + // vertical. Until then it might still be a scroll. + if (abs(self.mouse-x - self.pressed-x) + > abs(self.mouse-y - self.pressed-y)) { + self.claimed = true; + } + } + if (self.claimed) { + root.changed(self.value-at(self.mouse-x)); + } } pointer-event(ev) => { - // Jump to the press position, so a click anywhere on the - // track sets the value and a drag continues from there. - if (ev.kind == PointerEventKind.down - && ev.button == PointerEventButton.left) { - root.changed(self.value-at(self.mouse-x)); + if (ev.kind == PointerEventKind.up + || ev.kind == PointerEventKind.cancel) { + self.claimed = false; } // Right-click resets, alongside double-click. if (ev.kind == PointerEventKind.down @@ -171,6 +204,14 @@ component ParamSlider inherits Rectangle { root.reset(); } } + clicked => { + // A press with no meaningful drag: jump to it. Handled + // on release rather than on press so it cannot fire + // during a scroll that merely started here. + if (!self.claimed) { + root.changed(self.value-at(self.mouse-x)); + } + } double-clicked => { root.reset(); } @@ -198,6 +239,10 @@ component CurveEditor inherits Rectangle { property <int> point-count: root.data.points.length / 2; + // The point the pointer is over, or -1. Set by the grab targets below, + // and used only to highlight the marker. + in-out property <int> hovered-point: -1; + // Square: a tone curve is read as a deviation from the 45° diagonal, and // that reading only works if the axes share a scale. height: self.width; @@ -232,7 +277,7 @@ component CurveEditor inherits Rectangle { // Span the segment vertically, so a steep section stays joined. y: parent.height * (1.0 - max(s, self.next)); height: max(parent.height * abs(self.next - s), 1.5px); - background: Theme.accent; + background: Theme.active; } // Control points. @@ -247,61 +292,97 @@ component CurveEditor inherits Rectangle { width: 10px; height: 10px; border-radius: 5px; - background: root.active-point == idx ? Theme.accent : Theme.ink; + // Grown and tinted when grabbable, so it is obvious where the + // curve takes the gesture and where the panel scrolls instead. + property <bool> live: root.active-point == idx + || root.hovered-point == idx; + background: self.live ? Theme.active : Theme.ink; border-width: 1px; border-color: Theme.ground; } - // Catches the release even when the pointer has left the grab - // target, and resets on double-click. - area := TouchArea { - width: 100%; - height: 100%; + // **One grab target per point, and nothing covering the rest.** + // + // Three constraints meet here, and only this arrangement satisfies + // all of them: + // + // 1. The panel scrolls, and Slint cannot hand back a press once + // taken — so an area spanning the plot would swallow every scroll + // gesture beginning over the curve. Small targets leave the rest + // of the plot free. + // 2. `enabled: false` does not work as a gate: a disabled TouchArea + // recognises *no* events at all, hover included, so it cannot + // report where the pointer is in order to decide. + // 3. A target positioned by its own point would slide out from under + // the pointer on the first movement, stalling the drag. So while + // a point is being dragged its target **freezes** at the press + // position and grows to cover the plot, keeping the pointer + // inside it however far the point travels. + for idx in [0, 1, 2, 3, 4]: TouchArea { + property <bool> exists: idx < root.point-count; + property <bool> dragging: root.active-point == idx; + // Frozen and expanded while dragging; tracking the point + // otherwise. + x: self.dragging ? 0px + : parent.width * root.data.points[idx * 2] - 14px; + y: self.dragging ? 0px + : parent.height * (1.0 - root.data.points[idx * 2 + 1]) - 14px; + width: self.dragging ? parent.width : 28px; + height: self.dragging ? parent.height : 28px; + visible: self.exists; + mouse-cursor: pointer; + + // Highlights the marker, so it is visible where the curve takes + // the gesture and where the panel scrolls instead. + changed has-hover => { + if (self.has-hover) { + root.hovered-point = idx; + } else if (root.hovered-point == idx) { + root.hovered-point = -1; + } + } + + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down + && ev.button == PointerEventButton.left) { + root.active-point = idx; + } + if (ev.kind == PointerEventKind.up + || ev.kind == PointerEventKind.cancel) { + root.active-point = -1; + } + } moved => { - if (root.active-point >= 0) { + if (self.dragging) { + // Coordinates are relative to this area, which is the + // whole plot while dragging — so no offset is needed. root.point-moved( - root.active-point, + idx, clamp(self.mouse-x / parent.width, 0.0, 1.0), clamp(1.0 - self.mouse-y / parent.height, 0.0, 1.0)); } } - pointer-event(ev) => { - if (ev.kind == PointerEventKind.up) { - root.active-point = -1; - } - } double-clicked => { root.reset(); } } - - // One grab target per point, above the shared area so a press picks - // the point under the pointer rather than the panel guessing. - for idx in [0, 1, 2, 3, 4]: TouchArea { - property <bool> exists: idx < root.point-count; - x: parent.width * root.data.points[idx * 2] - 11px; - y: parent.height * (1.0 - root.data.points[idx * 2 + 1]) - 11px; - width: 22px; - height: 22px; - enabled: self.exists; - - pointer-event(ev) => { - if (ev.kind == PointerEventKind.down) { - root.active-point = idx; - } - } - moved => { - if (root.active-point == idx) { - root.point-moved( - idx, - clamp((self.x + self.mouse-x) / parent.width, 0.0, 1.0), - clamp(1.0 - (self.y + self.mouse-y) / parent.height, 0.0, 1.0)); - } - } - } } } -// The panel: a heading per operation, a control per parameter. +// The panel: a collapsible section per operation, a control per parameter. +// +// **Why the loop is shaped the way it is.** `rows` is flat, and Slint can +// neither slice a model nor nest a `for` over a run of it. What it *can* do is +// repeat over an integer — `for n in row.group-len` — so each group's heading +// row renders its whole group by indexing back into `rows` from `group-head`, +// and every other row renders nothing. That puts the group's controls genuinely +// *inside* its `Section`, which is what makes collapse a matter of the section +// clipping its own body rather than each row hiding itself. +// +// It also means collapse state is the `Section`'s own — one `expanded` per +// repeated element, keyed by position and so by `op-index`, never by label. +// Slint keeps that state across row-*data* updates, which is what lets a +// collapsed section stay collapsed while a slider elsewhere is dragged: the +// panel is re-fed on every drag event. export component AdjustPanel inherits Rectangle { in property <[ParamRow]> rows; in property <bool> enabled: true; @@ -311,6 +392,9 @@ export component AdjustPanel inherits Rectangle { callback param-changed(int, int, float); callback param-reset(int, int); callback curve-reset(int); + /// Return every parameter of one operation to its default — the reset on + /// a section's own header, beside the panel-wide one. + callback op-reset(int); callback reset-all(); background: Theme.surface; @@ -321,34 +405,21 @@ export component AdjustPanel inherits Rectangle { alignment: start; HorizontalLayout { - Text { - text: "ADJUST"; - color: Theme.accent; - font-size: Theme.text-sm; - font-weight: 700; - letter-spacing: 1.2px; - vertical-alignment: center; - } + PanelHeading { text: "ADJUST"; } Rectangle { horizontal-stretch: 1; } reset := TouchArea { width: 44px; height: 20px; clicked => { root.reset-all(); } - Text { + Label { text: "reset"; - color: reset.has-hover ? Theme.ink : Theme.ink-faint; - font-size: Theme.text-sm; + emphasised: reset.has-hover; horizontal-alignment: right; - vertical-alignment: center; } } } - if !root.enabled: Text { - text: "No image"; - color: Theme.ink-faint; - font-size: Theme.text-sm; - } + if !root.enabled: Caption { text: "No image"; } if root.enabled: Flickable { viewport-height: content.preferred-height; @@ -357,48 +428,64 @@ export component AdjustPanel inherits Rectangle { spacing: 0px; alignment: start; + // One iteration per row, but only a group's *first* row draws + // anything — and it draws the whole group. Every other row + // renders nothing at all. for row[i] in root.rows: VerticalLayout { spacing: 0px; - // Section heading, driven by the flag the core set — this - // file never asks "which operation is this". - if row.starts-group: VerticalLayout { - Rectangle { height: Theme.gap; } - Text { - text: row.op-label; - color: Theme.ink-faint; - font-size: Theme.text-sm; - font-weight: 700; - letter-spacing: 0.8px; - } - Rectangle { height: 2px; } - } + // `if` rather than a zero height: a hidden-but-present + // section would still *build* its whole group, so every + // control would exist once per row of its own group — + // thirty-six live TouchAreas behind the colour mixer's + // twelve visible ones. The conditional builds nothing. + if row.group-head == i: Section { + title: row.op-label; + modified: row.group-modified; - if row.kind == "scalar": ParamSlider { - data: row; - changed(v) => { - root.param-changed(row.op-index, row.param-index, v); - } - reset => { - root.param-reset(row.op-index, row.param-index); - } - } + // The section's reset. Placed here rather than in + // `Section` itself because resetting is what *this* + // panel's sections do; a section in another panel may + // have nothing to reset. + op-reset => { root.op-reset(row.op-index); } - if row.kind == "curve": CurveEditor { - data: row; - samples: root.curve-samples; - // A point carries two parameters, so the parameter - // index is the row's base plus the point's offset. - // This component still knows nothing about which - // operation it belongs to. - point-moved(point, x, y) => { - root.param-changed( - row.op-index, row.param-index + point * 2, x); - root.param-changed( - row.op-index, row.param-index + point * 2 + 1, y); - } - reset => { - root.curve-reset(row.op-index); + // The group's own rows, addressed by offset from its + // head. `root.rows[...]` rather than the loop's `row`: + // this repeats over a count, so `n` is a number and + // the row has to be fetched. + for n in row.group-len: VerticalLayout { + property <ParamRow> entry: root.rows[row.group-head + n]; + + spacing: 0px; + + if entry.kind == "scalar": ParamSlider { + data: entry; + changed(v) => { + root.param-changed( + entry.op-index, entry.param-index, v); + } + reset => { + root.param-reset(entry.op-index, entry.param-index); + } + } + + if entry.kind == "curve": CurveEditor { + data: entry; + samples: root.curve-samples; + // A point carries two parameters, so the + // parameter index is the row's base plus the + // point's offset. This component still knows + // nothing about which operation it belongs to. + point-moved(point, x, y) => { + root.param-changed( + entry.op-index, entry.param-index + point * 2, x); + root.param-changed( + entry.op-index, entry.param-index + point * 2 + 1, y); + } + reset => { + root.curve-reset(entry.op-index); + } + } } } } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index a76e2af..e7d1b80 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -1,6 +1,11 @@ import { Theme } from "theme.slint"; import { AdjustPanel, ParamRow } from "adjust.slint"; import { LaunchScreen } from "launch.slint"; +import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint"; +import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState } from "widgets.slint"; +import { CollectionsPanel, CollectionRow } from "collections.slint"; + +export { LibraryCell, TimelineBar, CollectionRow } // Status strip — surfaces the GPU backend and adapter, which matters during // v0.1 because assumption A1 is exactly "does this compositing path work on @@ -13,8 +18,15 @@ component StatusBar inherits Rectangle { in property <int> fps; in property <string> filename; in property <string> position; + /// Only offered where there is a library to go back to — with files named + /// on the command line there is no grid behind this view. + in property <bool> can-return-to-library: false; - height: 28px; + callback back-to-library(); + + // Tall enough for a Button to sit in without the strip growing: the + // control height and this strip are both 28px by design. + height: Theme.control-height; background: Theme.surface; HorizontalLayout { @@ -23,44 +35,37 @@ component StatusBar inherits Rectangle { spacing: Theme.gap; alignment: start; - Text { - text: root.backend; - color: Theme.accent; - font-size: Theme.text-sm; - font-weight: 700; - vertical-alignment: center; + // Kept instantiated with `visible` rather than wrapped in an `if`: + // the strip is inside the layout that `expanded` feeds, and adding a + // conditional child here is the shape that has already caused binding + // loops in this file (see the panel below). + Button { + text: "‹ Library"; + visible: root.can-return-to-library; + clicked => { root.back-to-library(); } } - Text { - text: root.filename; - color: Theme.ink; - font-size: Theme.text-sm; - font-weight: 600; - vertical-alignment: center; - overflow: elide; - } + // The backend is an identifying label, not a state — it says which + // path is in use, and it says the same thing whether that path is + // fast or slow. The accent it used to carry made every frame look + // like an alert; `fps` below is the thing here that can go wrong. + Label { text: root.backend; } - Text { - text: root.position; - color: Theme.ink-faint; - font-size: Theme.text-sm; - vertical-alignment: center; - } + Value { text: root.filename; compact: true; overflow: elide; } + + Caption { text: root.position; } Rectangle { horizontal-stretch: 1; } - Text { - text: root.layout-class; - color: Theme.ink-faint; - font-size: Theme.text-sm; - vertical-alignment: center; - } + Caption { text: root.layout-class; } - Text { + // Degraded performance, so this is a caution rather than an active + // state: the frame rate has fallen below what the compositing path is + // supposed to sustain, which is exactly the assumption A1 exists to + // test. `warn` is the one hue left in the palette and this earns it. + Caption { text: root.fps + " fps"; - color: root.fps >= 55 ? Theme.ink-dim : Theme.accent; - font-size: Theme.text-sm; - vertical-alignment: center; + warn: root.fps < 55; } } @@ -78,40 +83,26 @@ component InfoPanel inherits Rectangle { in property <string> exposure; in property <string> dimensions; - background: Theme.surface; - height: self.preferred-height; + background: transparent; + height: panel.preferred-height; - VerticalLayout { - padding: Theme.gap; - spacing: Theme.gap-sm; - alignment: start; + panel := Panel { + // Flat: this abuts the adjust panel below it and the rule between + // them is drawn by the column that stacks the two. + flat: true; + width: 100%; - Text { - text: "IMAGE"; - color: Theme.accent; - font-size: Theme.text-sm; - font-weight: 700; - letter-spacing: 1.2px; - } + PanelHeading { text: "IMAGE"; } - Text { + Value { text: root.camera == "" ? "—" : root.camera; - color: Theme.ink; - font-size: Theme.text; + placeholder: root.camera == ""; wrap: word-wrap; } - Text { - text: root.exposure; - color: Theme.ink-dim; - font-size: Theme.text-sm; - } + Label { text: root.exposure; } - Text { - text: root.dimensions; - color: Theme.ink-faint; - font-size: Theme.text-sm; - } + Caption { text: root.dimensions; } } } @@ -138,6 +129,34 @@ export component AppWindow inherits Window { in property <int> total: 0; in property <string> load-error: ""; + // --- zoom, pan and crop (FR-DEV-4) --- + // + // Zoom is a *viewing* state, not an edit: it changes the resolution the + // pipeline renders at, never what the file becomes. Rust owns the actual + // rect; these carry only what the interface has to draw. + in property <float> zoom: 1.0; + in property <bool> zoomed: false; + + /// Whether the crop overlay is active. While it is, the canvas shows the + /// *whole* frame — otherwise the area being cropped away would not be on + /// screen to drag across — and the surround is greyed. + in-out property <bool> crop-mode: false; + + /// The crop rect in fractions of the frame, mirrored from Rust so the + /// overlay draws exactly what the pipeline holds. + in-out property <float> crop-x: 0.0; + in-out property <float> crop-y: 0.0; + in-out property <float> crop-w: 1.0; + in-out property <float> crop-h: 1.0; + + callback crop-mode-toggled(bool); + /// A dragged crop rect, in fractions of the frame. + callback crop-changed(float, float, float, float); + /// Scroll-to-zoom: factor, and the anchor in fractions of the visible area. + callback zoom-at(float, float, float); + callback pan-by(float, float); + callback zoom-reset(); + // --- launch screen (FR-NC-1, FR-NC-4) --- // // The app opens here when no library is configured, and returns here to @@ -172,6 +191,145 @@ export component AppWindow inherits Window { callback launch-browse-confirm(); callback launch-browse-cancel(); + // --- library grid (FR-CAT-4) --- + // + // Shown after a library is opened, before an image is chosen. Cells are a + // window over the catalog, not the whole of it. + in property <bool> show-library: false; + in-out property <[LibraryCell]> library-cells; + in property <int> library-total: 0; + in property <bool> library-scanning: false; + in property <string> library-status: ""; + in property <string> library-error: ""; + in property <int> library-thumbs-done: 0; + in property <int> library-thumbs-total: 0; + + in property <string> library-root-label: ""; + in-out property <[TimelineBar]> library-timeline; + in property <string> library-timeline-label: ""; + in property <string> library-window-label: ""; + in property <int> library-offset: 0; + + in property <int> library-sweep-done: 0; + in property <int> library-sweep-total: 0; + + in property <int> library-current-bucket: 0; + in property <int> library-current-index: -1; + in property <bool> library-timeline-anchored: false; + + callback library-scrub(int); + callback library-timeline-pan(int); + callback library-timeline-zoom(int); + in-out property <int> library-columns: 1; + in property <bool> library-syncing: false; + in property <int> library-scroll-to: 0; + in property <int> library-scroll-token: 0; + callback library-sync-now(); + callback library-columns-changed(); + callback library-scrolled(int); + callback library-capacity(int); + callback library-cell-clicked(int); + callback library-rescan(); + /// Grid → launch screen, to pick a different folder or account. + callback library-change(); + /// Develop → grid. + callback back-to-library(); + + // --- collections (FR-CAT-7) --- + // + // The sidebar's tree, flattened in Rust: Slint cannot instantiate a + // component recursively, so depth arrives as an integer per row. + in-out property <[CollectionRow]> collection-rows; + /// Which collection scopes the grid. 0 is the whole library. + in property <int> collection-selected: 0; + in property <string> collection-scope-label: ""; + in property <string> collection-error: ""; + /// Whether the sidebar is shown at all. Collapsed in the compact layout, + /// where 232px of the window is most of the photograph (FR-UI-1). + in property <bool> collections-visible: true; + + /// Which collection is being renamed in the sidebar, by id. 0 is none. + in property <int> collection-renaming: 0; + + callback collection-select(int); + callback collection-toggle(int); + callback collection-new(); + callback collection-menu(int); + /// Renaming started on a row, by id — a double-click, or `F2`. + callback collection-rename-start(int); + /// A rename was committed: the collection's id and the new name. + callback collection-rename-commit(int, string); + /// Renaming was abandoned with Escape. + callback collection-rename-cancel(); + /// Row index and collection id under the pointer. The id is what the + /// drop highlight follows: a spring expansion rebuilds the row model + /// mid-drag, and an index would then point at a different collection. + /// A drag is dwelling over a collection, or has left it: id, and whether + /// it is over. Drives the spring-loaded expansion, timed in Rust. + callback collection-drag-over(int, bool); + /// Images were dropped on a collection, by id. Slint hit-tests the release, + /// so this is the collection actually under the pointer. + callback collection-dropped(int); + + // --- trash (FR-CAT-15) --- + // + // Not a collection: a drop here moves the file into a trash folder on the + // server, where a collection drop only adds a reference. + in property <int> trash-count: 0; + in property <string> trash-label: ""; + /// A soft delete: move the dragged images to the trash folder. + callback trash-dropped(); + /// A hard delete: permanently remove everything in the trash. + callback trash-empty(); + /// Put the selected trashed images back where they came from. + callback trash-restore(); + + // --- drag and drop --- + // + // Slint's own `DragArea`/`DropArea` carry this: pointer capture, the + // click-versus-drag threshold, arbitration against the grid's Flickable, + // and the image drawn under the cursor. So there is no badge to position + // and no hover state to mirror here — only the payload, which Rust builds + // from the selection, and the count the header shows. + /// The drag payload, built by Rust from the current selection. + pure callback library-drag-payload() -> data-transfer; + /// The bitmap that travels under the cursor: one thumbnail, or a fanned + /// stack where several images are being dragged. Composited in Rust. + in property <image> library-drag-image; + /// A drag began on a cell, so an unselected one can be promoted before the + /// payload is read. + callback library-drag-started(int); + callback library-drag-finished(); + in property <int> library-selected-count: 0; + + callback library-cell-pressed(int, bool, bool); + callback library-remove-from-collection(); + + // --- ratings and flags (FR-CAT-5, FR-CULL-4) --- + // + // Stars and pick/reject, set from the grid and persisted to the catalog + // and the sidecar. Every image enters unrated, which is a state of its + // own rather than a zero score. + /// A star was clicked on a cell: row, then the rating 0..5. + callback library-cell-rated(int, int); + /// The trash target was clicked on one cell, by row. + callback library-cell-trashed(int); + /// Move the grid selection to the trash — the `Delete` key. + callback library-trash-selection(); + /// A judgement key was pressed, applying to the whole selection. One of + /// the two arguments is -1, saying which axis was *not* meant. + callback library-judged(int, int); + + in property <int> library-filter-min-rating: 0; + in property <bool> library-filter-unjudged: false; + in property <int> library-filter-flag: 0; + /// Images per star count, index 0 unrated, for the filter chips. + in-out property <[int]> library-rating-counts; + + callback library-filter-min-rating-changed(int); + callback library-filter-unjudged-changed(bool); + callback library-filter-flag-changed(int); + callback next-image(); callback prev-image(); @@ -238,7 +396,143 @@ export component AppWindow inherits Window { browse-cancel() => { root.launch-browse-cancel(); } } - if !root.show-launch: VerticalLayout { + // The library view sits between the launch screen and develop: a library + // has been opened but no image chosen yet. The collections sidebar and the + // grid are siblings here rather than the sidebar living inside the grid, + // because the drag that connects them has to be owned above both. + if !root.show-launch && root.show-library: Rectangle { + background: Theme.ground; + + HorizontalLayout { + // Always instantiated, width collapsed to zero when hidden — the + // same reasoning as the adjust panel below: an `if` here depends on + // the layout class, which derives from the window width, which the + // layout then influences. Slint flags that loop and it can panic. + Rectangle { + width: root.collections-visible ? 232px : 0px; + visible: root.collections-visible; + horizontal-stretch: 0; + + CollectionsPanel { + width: 100%; + height: 100%; + rows: root.collection-rows; + selected-id: root.collection-selected; + total-images: root.library-total; + error: root.collection-error; + + select(id) => { root.collection-select(id); } + toggle(id) => { root.collection-toggle(id); } + new-collection() => { root.collection-new(); } + row-menu(id) => { root.collection-menu(id); } + + renaming-id: root.collection-renaming; + rename-start(id) => { root.collection-rename-start(id); } + rename-commit(id, name) => { + root.collection-rename-commit(id, name); + } + rename-cancel() => { root.collection-rename-cancel(); } + trash-count: root.trash-count; + trash-label: root.trash-label; + selected-count: root.library-selected-count; + + dropped-on(id) => { root.collection-dropped(id); } + dropped-on-trash() => { root.trash-dropped(); } + empty-trash() => { root.trash-empty(); } + restore-selected() => { root.trash-restore(); } + drag-over(id, over) => { root.collection-drag-over(id, over); } + } + } + + LibraryGrid { + horizontal-stretch: 1; + cells: root.library-cells; + total: root.library-total; + scanning: root.library-scanning; + scan-status: root.library-status; + scan-error: root.library-error; + thumbs-done: root.library-thumbs-done; + thumbs-total: root.library-thumbs-total; + root-label: root.library-root-label; + timeline: root.library-timeline; + timeline-label: root.library-timeline-label; + window-label: root.library-window-label; + offset: root.library-offset; + selected-count: root.library-selected-count; + scope-label: root.collection-scope-label; + + sweep-done: root.library-sweep-done; + sweep-total: root.library-sweep-total; + + current-bucket: root.library-current-bucket; + current-bucket-index: root.library-current-index; + timeline-anchored: root.library-timeline-anchored; + + scrub(t) => { root.library-scrub(t); } + timeline-pan(d) => { root.library-timeline-pan(d); } + timeline-zoom(d) => { root.library-timeline-zoom(d); } + syncing: root.library-syncing; + scroll-to: root.library-scroll-to; + scroll-token: root.library-scroll-token; + sync-now() => { root.library-sync-now(); } + columns-changed(n) => { + root.library-columns = n; + // Which cell begins a row just changed, and month headings sit on + // row-leading cells — so they must be recomputed, not just moved. + root.library-columns-changed(); + } + scrolled(i) => { root.library-scrolled(i); } + capacity-changed(n) => { root.library-capacity(n); } + cell-clicked(i) => { root.library-cell-clicked(i); } + rescan() => { root.library-rescan(); } + change-library() => { root.library-change(); } + + cell-pressed(i, ctrl, shift) => { + root.library-cell-pressed(i, ctrl, shift); + } + drag-image: root.library-drag-image; + drag-payload() => { return root.library-drag-payload(); } + drag-started(i) => { root.library-drag-started(i); } + drag-finished() => { root.library-drag-finished(); } + remove-from-collection() => { + root.library-remove-from-collection(); + } + + filter-min-rating: root.library-filter-min-rating; + filter-unjudged: root.library-filter-unjudged; + filter-flag: root.library-filter-flag; + rating-counts: root.library-rating-counts; + + cell-rated(i, n) => { root.library-cell-rated(i, n); } + cell-trashed(i) => { root.library-cell-trashed(i); } + trash-selection() => { root.library-trash-selection(); } + // Derived from the sidebar's own selection rather than + // mirrored in a second property: `-1` is already the sentinel + // for the trash row (see collections.slint), and two sources + // for one fact is how they come to disagree. + viewing-trash: root.collection-selected == -1; + judged(rating, flag) => { root.library-judged(rating, flag); } + // `F2` renames whatever the grid is scoped to. Answered from + // the sidebar's selection rather than a second property, for + // the same reason `viewing-trash` above is: one fact, one + // source. Ids at or below 0 are "all photographs" and the + // trash, neither of which has a name to change — Rust drops + // those rather than the binding trying to reason about it. + rename-scope() => { + root.collection-rename-start(root.collection-selected); + } + filter-min-rating-changed(n) => { + root.library-filter-min-rating-changed(n); + } + filter-unjudged-toggled(on) => { + root.library-filter-unjudged-changed(on); + } + filter-flag-changed(f) => { root.library-filter-flag-changed(f); } + } + } + } + + if !root.show-launch && !root.show-library: VerticalLayout { StatusBar { adapter: root.adapter; backend: root.backend; @@ -246,6 +540,10 @@ export component AppWindow inherits Window { fps: root.fps; filename: root.filename; position: root.total > 0 ? root.index + 1 + " / " + root.total : ""; + // Only where a library was opened; command-line files have no + // grid to return to. + can-return-to-library: root.library-total > 0; + back-to-library() => { root.back-to-library(); } } HorizontalLayout { @@ -256,7 +554,7 @@ export component AppWindow inherits Window { background: Theme.ground; clip: true; - Image { + canvas-image := Image { width: 100%; height: 100%; source: root.canvas; @@ -264,24 +562,271 @@ export component AppWindow inherits Window { visible: root.total > 0 && root.load-error == ""; } + // Where the photograph actually sits inside this box. + // + // `image-fit: contain` letterboxes, and Slint does not report + // the fitted rect, so it is derived from the source's aspect. + // The overlay is placed against *this*, not against the whole + // area — otherwise the crop rect drifts off the picture on any + // window whose shape differs from the image's. + property <float> src-w: root.canvas.width > 0 ? root.canvas.width : 1; + property <float> src-h: root.canvas.height > 0 ? root.canvas.height : 1; + property <float> box-w: self.width / 1px; + property <float> box-h: self.height / 1px; + property <float> fit-scale: min(self.box-w / self.src-w, self.box-h / self.src-h); + property <length> shown-w: self.src-w * self.fit-scale * 1px; + property <length> shown-h: self.src-h * self.fit-scale * 1px; + property <length> shown-x: (self.width - self.shown-w) / 2; + property <length> shown-y: (self.height - self.shown-h) / 2; + // Empty and error states say what happened rather than // showing a blank canvas. - if root.total == 0 || root.load-error != "": VerticalLayout { - alignment: center; - spacing: Theme.gap; - Text { - text: root.load-error != "" ? "Could not load image" : "No images"; - color: Theme.ink-dim; - font-size: Theme.text-lg; - horizontal-alignment: center; + if root.total == 0 || root.load-error != "": EmptyState { + headline: root.load-error != "" ? "Could not load image" : "No images"; + detail: root.load-error != "" ? root.load-error + : "Pass a folder or file on the command line."; + } + + // --- zoom and pan ------------------------------------------ + // + // Below the crop overlay in z-order so that, in crop mode, the + // handles take the drag instead. Scroll still zooms either way. + if root.total > 0 && root.load-error == "": TouchArea { + x: 0; y: 0; + width: 100%; + height: 100%; + // A pan is only meaningful once there is something outside + // the viewport to reach. + mouse-cursor: root.zoomed ? MouseCursor.grab : MouseCursor.default; + enabled: !root.crop-mode; + + property <length> last-x; + property <length> last-y; + + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down) { + self.last-x = self.mouse-x; + self.last-y = self.mouse-y; + } } - Text { - text: root.load-error != "" ? root.load-error - : "Pass a folder or file on the command line."; - color: Theme.ink-faint; - font-size: Theme.text-sm; - horizontal-alignment: center; - wrap: word-wrap; + + moved => { + if (self.pressed && root.zoomed) { + // Fractions of the *visible* area, which is what + // the session's pan expects. Negated: dragging + // right moves the image right, so the window onto + // it moves left. + root.pan-by( + -(self.mouse-x - self.last-x) / max(parent.shown-w, 1px), + -(self.mouse-y - self.last-y) / max(parent.shown-h, 1px), + ); + self.last-x = self.mouse-x; + self.last-y = self.mouse-y; + } + } + + scroll-event(ev) => { + if (ev.delta-y == 0) { + return reject; + } + // Anchored on the pointer, in fractions of the shown + // image, so whatever is under the cursor stays there. + root.zoom-at( + ev.delta-y > 0 ? 1.15 : 1.0 / 1.15, + (self.mouse-x - parent.shown-x) / max(parent.shown-w, 1px), + (self.mouse-y - parent.shown-y) / max(parent.shown-h, 1px), + ); + return accept; + } + + double-clicked => { root.zoom-reset(); } + } + + // --- crop overlay ------------------------------------------ + // + // Four dimmed, desaturated panels around the crop, then the + // rect itself with handles. Panels rather than one shape with + // a hole: Slint has no cut-out, and four rectangles are exact. + if root.crop-mode && root.total > 0 && root.load-error == "": crop-overlay := Rectangle { + x: parent.shown-x; + y: parent.shown-y; + width: parent.shown-w; + height: parent.shown-h; + + property <length> rx: root.crop-x * self.width; + property <length> ry: root.crop-y * self.height; + property <length> rw: root.crop-w * self.width; + property <length> rh: root.crop-h * self.height; + + // The surround: dimmed and drained of colour, so the crop + // reads as the photograph and everything else as context + // being discarded. Slint cannot desaturate a live image, + // so this is a heavy neutral wash over it — the dimming + // carries the separation and the neutrality kills the + // colour cue. + property <brush> veil: #20242aE0; + + Rectangle { + x: 0; + y: 0; + width: parent.width; + height: crop-overlay.ry; + background: crop-overlay.veil; + } + Rectangle { + x: 0; + y: crop-overlay.ry + crop-overlay.rh; + width: parent.width; + height: parent.height - crop-overlay.ry - crop-overlay.rh; + background: crop-overlay.veil; + } + Rectangle { + x: 0; + y: crop-overlay.ry; + width: crop-overlay.rx; + height: crop-overlay.rh; + background: crop-overlay.veil; + } + Rectangle { + x: crop-overlay.rx + crop-overlay.rw; + y: crop-overlay.ry; + width: parent.width - crop-overlay.rx - crop-overlay.rw; + height: crop-overlay.rh; + background: crop-overlay.veil; + } + + // The rect, its thirds, and the handles. + Rectangle { + x: parent.rx; + y: parent.ry; + width: parent.rw; + height: parent.rh; + border-width: 1px; + border-color: #ffffffCC; + + // Rule of thirds, the reason a crop overlay is worth + // drawing at all rather than typing numbers. + Rectangle { + x: parent.width / 3; + y: 0; width: 1px; height: parent.height; + background: #ffffff44; + } + Rectangle { + x: parent.width * 2 / 3; + y: 0; width: 1px; height: parent.height; + background: #ffffff44; + } + Rectangle { + x: 0; y: parent.height / 3; + width: parent.width; height: 1px; + background: #ffffff44; + } + Rectangle { + x: 0; y: parent.height * 2 / 3; + width: parent.width; height: 1px; + background: #ffffff44; + } + + // Drag the whole rect. + move-area := TouchArea { + width: 100%; + height: 100%; + mouse-cursor: MouseCursor.move; + + property <float> start-x; + property <float> start-y; + + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down) { + self.start-x = root.crop-x; + self.start-y = root.crop-y; + } + } + + moved => { + if (self.pressed) { + root.crop-changed( + self.start-x + + (self.mouse-x - self.pressed-x) + / max(crop-overlay.width, 1px), + self.start-y + + (self.mouse-y - self.pressed-y) + / max(crop-overlay.height, 1px), + root.crop-w, + root.crop-h, + ); + } + } + } + } + + // Corner handles. Each drags one corner while the opposite + // stays put, which is the only behaviour that lets a crop + // be shaped rather than merely moved. + for corner in [ + { hx: 0.0, hy: 0.0 }, + { hx: 1.0, hy: 0.0 }, + { hx: 0.0, hy: 1.0 }, + { hx: 1.0, hy: 1.0 }, + ]: Rectangle { + property <length> size: 18px; + x: parent.rx + corner.hx * parent.rw - self.size / 2; + y: parent.ry + corner.hy * parent.rh - self.size / 2; + width: self.size; + height: self.size; + + Rectangle { + width: 12px; + height: 12px; + background: #ffffff; + border-radius: 2px; + } + + TouchArea { + width: 100%; + height: 100%; + mouse-cursor: (corner.hx == corner.hy) + ? MouseCursor.nwse-resize + : MouseCursor.nesw-resize; + + property <float> ox; + property <float> oy; + property <float> ow; + property <float> oh; + + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down) { + self.ox = root.crop-x; + self.oy = root.crop-y; + self.ow = root.crop-w; + self.oh = root.crop-h; + } + } + + // Movement as a fraction of the frame, live while + // the handle is held. + property <float> dx: + (self.mouse-x - self.pressed-x) / max(crop-overlay.width, 1px); + property <float> dy: + (self.mouse-y - self.pressed-y) / max(crop-overlay.height, 1px); + + moved => { + if (!self.pressed) { + return; + } + // Dragging a left/top handle moves the origin + // and shrinks the extent by the same amount; + // a right/bottom handle moves only the extent. + // Rust clamps the result, so an over-drag + // slides rather than inverting. + root.crop-changed( + corner.hx == 0 ? self.ox + self.dx : self.ox, + corner.hy == 0 ? self.oy + self.dy : self.oy, + corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx, + corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy, + ); + } + } } } @@ -300,6 +845,31 @@ export component AppWindow inherits Window { } } + // Crop and zoom controls, floating over the canvas so they are + // reachable whether or not the side panel is showing. + if root.total > 0 && root.load-error == "": HorizontalLayout { + x: 12px; + y: parent.height - self.preferred-height - 12px; + spacing: 6px; + + Button { + text: root.crop-mode ? "Done" : "Crop"; + active: root.crop-mode; + clicked => { root.crop-mode-toggled(!root.crop-mode); } + } + + // Zoom is a view state, so its readout doubles as the + // control that clears it. + Button { + text: root.zoomed + ? Math.round(root.zoom * 100) + "%" + : "Fit"; + active: root.zoomed; + enabled: root.zoomed; + clicked => { root.zoom-reset(); } + } + } + // Report size changes so the render target can be resized to // match. Width and height are tracked separately because // Slint has no single "geometry changed" hook. @@ -343,6 +913,12 @@ export component AppWindow inherits Window { root.param-reset(op, param); } curve-reset(op) => { root.curve-reset(op); } + // A section's reset and a curve's reset are the same + // action — every parameter of one operation back to + // its default — so they share the one callback rather + // than duplicating a handler that would have to be + // kept in step with it. + op-reset(op) => { root.curve-reset(op); } reset-all => { root.reset-all(); } } } diff --git a/ui/dr-ui/ui/collections.slint b/ui/dr-ui/ui/collections.slint new file mode 100644 index 0000000..a6ce70e --- /dev/null +++ b/ui/dr-ui/ui/collections.slint @@ -0,0 +1,566 @@ +// The collections sidebar: a hierarchy of collections, and a drop target for +// images dragged out of the grid. +// +// TRACES: FR-CAT-7 | FR-UI-3 | FR-UI-5 +// +// # Why the tree is flat here +// +// Slint has no recursive component instantiation, so a `for` cannot nest itself +// to arbitrary depth. The tree arrives from Rust already flattened, each row +// carrying its own `depth` — indentation is drawn from that integer. The +// ordering rules (siblings by name, children after their parent) live in +// `dr_catalog::collections::tree`, which is where the data model already is. +// +// # Drag and drop +// +// Each row is a `DropArea`, and the grid's cells are `DragArea`s. Slint owns the +// gesture: pointer capture, the threshold that separates a click from a drag, +// arbitration against the grid's `Flickable`, the image under the cursor, and +// delivery of the payload to whichever row the pointer is actually over. +// +// This replaced a hand-rolled version that tracked presses through `TouchArea` +// and is worth recording, because the failure was not obvious: an interactive +// `Flickable` claims any drag beginning inside it for scrolling and *cancels* +// the child TouchArea's press, so the gesture could never leave the grid. The +// hand-rolled version also had to decide the drop target from the last row +// hovered, since a captured pointer is invisible to everything else — which +// meant a tree rebuilt mid-drag could redirect the drop. `DropArea` hit-tests +// the release itself, so neither problem exists. +// +// `can-drop` runs while the cursor moves and decides whether this row will +// accept — a saved filter refuses there, so the cursor says no *before* the +// release rather than the drop being silently discarded after it. + +import { Theme } from "theme.slint"; +import { Button } from "widgets.slint"; + +// One row of the collection tree. +export struct CollectionRow { + id: int, + name: string, + // 0 for a top-level collection. Indentation is drawn from this. + depth: int, + // Direct members. A parent shows this and its descendants' total + // separately: an empty set of full children must not read as full. + direct-count: int, + // Distinct images including descendants'. + deep-count: int, + has-children: bool, + expanded: bool, + // A saved filter. Cannot be dropped onto — its membership is its selector. + smart: bool, +} + +// A collection row: disclosure arrow, name, count, and a drop highlight. +component TreeRow inherits Rectangle { + in property <CollectionRow> entry; + in property <bool> selected; + /// Whether a drag hovering here could land. A saved filter's membership is + /// computed from its selector, so it refuses — and says so on hover. + in property <bool> drop-allowed: true; + /// Whether this row is being renamed, in which case its name is drawn as an + /// editable field rather than as text. + in property <bool> renaming: false; + + callback clicked(); + callback toggle(); + /// The pointer is dwelling here mid-drag. What springs a collapsed parent + /// open, so a child can be reached without ending the drag. + callback drag-over(bool); + /// Images were dropped on this row. + callback dropped(); + callback context-menu(); + /// Start renaming this row — a double-click on the name. + callback rename-requested(); + /// The new name, committed with Enter or by clicking away. + callback rename-committed(string); + /// Renaming abandoned with Escape; the old name stands. + callback rename-cancelled(); + + height: Theme.row-height; + + // `drop.has-drag` is only true once `can-drop` has accepted, so a refused + // row never lights up as though it would take the images. + background: drop.has-drag + ? Theme.selected + : (root.selected ? Theme.surface-raised + : (touch.has-hover ? Theme.hover : transparent)); + border-radius: Theme.radius-sm; + + // A drop target is outlined as well as filled: on a dark ground a fill + // change alone is easy to miss mid-drag, when the user is watching the + // thumbnail under the cursor rather than the row. + border-width: drop.has-drag ? 1px : 0px; + border-color: Theme.selected-ring; + + // Behind the content, so the row's own TouchArea still gets ordinary + // clicks. A DropArea only takes part in a drag; it does not block presses. + drop := DropArea { + width: 100%; + height: 100%; + + can-drop(ev) => { + // Refused here rather than after the release: the cursor shows + // "no" while the user can still aim somewhere else. + if (!root.drop-allowed) { + return DragAction.none; + } + return DragAction.copy; + } + + dropped(ev) => { + root.dropped(); + return DragAction.copy; + } + + // Dwelling over a collapsed parent springs it open. Reported rather + // than acted on here, because the dwell timer and the collapse state + // live in Rust with the rest of the tree. + changed has-drag => { root.drag-over(self.has-drag); } + } + + HorizontalLayout { + // Indentation from depth. The guide rail below sits in this space. + padding-left: Theme.gap-sm + root.entry.depth * Theme.indent; + padding-right: Theme.gap-sm; + spacing: Theme.gap-sm; + + // Disclosure arrow, or an equal blank so names stay aligned between + // rows that have children and rows that do not. + Rectangle { + width: 14px; + + if root.entry.has-children: Text { + text: root.entry.expanded ? "▾" : "▸"; + color: Theme.ink-faint; + font-size: Theme.text-sm; + horizontal-alignment: center; + vertical-alignment: center; + } + + // Its own hit area: toggling open must not also select, or every + // expand reloads the grid. + if root.entry.has-children: TouchArea { + clicked => { root.toggle(); } + } + } + + // Smart collections read differently from manual ones — the icon is + // the only cue that its contents are computed, and that dropping + // images on it will be refused. + Text { + text: root.entry.smart ? "◈" : "▤"; + color: root.entry.smart ? Theme.active-dim : Theme.ink-faint; + font-size: Theme.text-sm; + vertical-alignment: center; + } + + // The name, or the field that is replacing it while this row is being + // renamed. Two branches of one `if` rather than a TextInput styled to + // look like text at rest: a live TextInput would swallow the clicks + // that select the collection and the presses that begin a drag. + if !root.renaming: Text { + text: root.entry.name; + color: root.selected ? Theme.ink : Theme.ink-dim; + font-size: Theme.text; + font-weight: root.selected ? 600 : 400; + vertical-alignment: center; + overflow: elide; + horizontal-stretch: 1; + } + + if root.renaming: Rectangle { + horizontal-stretch: 1; + background: Theme.ground; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.active; + + edit := TextInput { + // Seeded once, when the field appears. Bound two-way to the + // row's name it would rewrite the model on every keystroke, + // and an edit abandoned with Escape could not be undone. + text: root.entry.name; + color: Theme.ink; + font-size: Theme.text; + vertical-alignment: center; + single-line: true; + // Inset by hand for the same reason `Field` does it: inside a + // layout the input stops scrolling its own content once the + // text outgrows the box. + x: Theme.gap-sm; + width: parent.width - 2 * Theme.gap-sm; + height: 100%; + + // Focus and a full selection on appearance, so the placeholder + // name a new collection arrives with is replaced by typing + // rather than having to be cleared first. + init => { + self.focus(); + self.select-all(); + } + + accepted => { root.rename-committed(self.text); } + + key-pressed(event) => { + // Escape abandons. Handled here rather than on a + // surrounding FocusScope, which would never see the key — + // the input has focus and consumes it. + if (event.text == Key.Escape) { + root.rename-cancelled(); + return accept; + } + return reject; + } + + // Clicking away commits rather than discarding: the text is + // visible on screen and the user typed it, so throwing it out + // for want of an Enter is the surprising choice. + changed has-focus => { + if (!self.has-focus) { + root.rename-committed(self.text); + } + } + } + } + + // The count. A parent shows its deep total, since its own direct + // membership is usually zero and "0" beside a full subtree reads as + // broken. The distinction is spelled out in the tooltip-less way + // available here: parentheses mean "including children". + Text { + text: root.entry.has-children && root.entry.deep-count != root.entry.direct-count + ? "(" + root.entry.deep-count + ")" + : (root.entry.direct-count > 0 ? root.entry.direct-count : "") + + ""; + color: Theme.ink-faint; + font-size: Theme.text-sm; + vertical-alignment: center; + } + } + + touch := TouchArea { + // Disabled while the field is up, so a click landing on the row rather + // than inside the input does not re-select the collection out from + // under the edit in progress. + enabled: !root.renaming; + + clicked => { root.clicked(); } + // Double-click renames — the gesture a file manager or a Lightroom + // panel uses for the same thing, so it needs no discovering. + double-clicked => { root.rename-requested(); } + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down + && ev.button == PointerEventButton.right) { + root.context-menu(); + } + } + } +} + +export component CollectionsPanel inherits Rectangle { + in property <[CollectionRow]> rows; + // Which collection scopes the grid. 0 means the whole library. + in property <int> selected-id: 0; + in property <int> total-images: 0; + in property <string> error: ""; + + callback select(int); + callback toggle(int); + /// Images were dropped on a collection, by its id. Slint hit-tests the + /// release itself, so this is the collection actually under the pointer — + /// not the last one hovered. + callback dropped-on(int); + /// A drag is dwelling over a collection, or has left it. Drives the + /// spring-loaded expansion, which is timed in Rust. + callback drag-over(int, bool); + callback new-collection(); + // Right-click on a row: rename, delete, new child. + callback row-menu(int); + + /// Which collection is being renamed, by id. 0 is none. + /// + /// Driven from Rust rather than held here, because a rename that fails — + /// or one begun by *creating* a collection, which happens before this + /// panel has the new row — has to be started and ended from that side. + in property <int> renaming-id: 0; + /// Renaming began on a row: its id. + callback rename-start(int); + /// A rename was committed: the id, and the new name. + callback rename-commit(int, string); + /// Renaming was abandoned. + callback rename-cancel(); + + // --- trash (FR-CAT-15) --- + // + // `selected-id == -1` is the trash being viewed. A sentinel rather than a + // separate bool because the sidebar has exactly one selection, and two + // flags could disagree about what the grid is showing. + /// How many images are in the trash. + in property <int> trash-count: 0; + /// The count and the bytes it holds, already formatted — "12 · 340 MB". + /// Formatted in Rust because Slint has no byte-size formatting and the + /// arithmetic would be unreadable inline. + in property <string> trash-label: ""; + + /// How many grid cells are selected, so the trash view can offer to restore + /// them. Read here as well as in the library panel because restoring is a + /// selection action and the button has to say what it will act on. + in property <int> selected-count: 0; + + /// Images were dropped on the trash: a soft delete. + callback dropped-on-trash(); + /// Permanently delete everything in the trash. + callback empty-trash(); + /// Put the selected images back where they came from. + callback restore-selected(); + + width: 232px; + background: Theme.surface; + + VerticalLayout { + padding: Theme.gap-sm; + spacing: Theme.gap-sm; + + // --- header ------------------------------------------------------- + HorizontalLayout { + height: 26px; + spacing: Theme.gap-sm; + + Text { + text: "COLLECTIONS"; + color: Theme.ink-faint; + font-size: Theme.text-sm; + font-weight: 700; + letter-spacing: 1.2px; + vertical-alignment: center; + horizontal-stretch: 1; + } + + // New collection. A glyph rather than a word: the header is 232px + // wide and the label would crowd out the title. + Rectangle { + width: 22px; + height: 22px; + y: (parent.height - self.height) / 2; + background: add-touch.pressed ? Theme.pressed + : (add-touch.has-hover ? Theme.hover : transparent); + border-radius: Theme.radius-sm; + + Text { + text: "+"; + color: Theme.ink-dim; + font-size: Theme.text-lg; + horizontal-alignment: center; + vertical-alignment: center; + } + add-touch := TouchArea { + clicked => { root.new-collection(); } + } + } + } + + // --- the whole library -------------------------------------------- + // + // Always first and never nested: it is how the user gets back to an + // unscoped grid, and burying it inside the tree would make "show me + // everything" a thing you have to find. + Rectangle { + height: Theme.row-height; + background: root.selected-id == 0 ? Theme.surface-raised + : (all-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; + + Text { + text: "All photographs"; + color: root.selected-id == 0 ? Theme.ink : Theme.ink-dim; + font-size: Theme.text; + font-weight: root.selected-id == 0 ? 600 : 400; + vertical-alignment: center; + horizontal-stretch: 1; + } + Text { + text: root.total-images > 0 ? root.total-images : ""; + color: Theme.ink-faint; + font-size: Theme.text-sm; + vertical-alignment: center; + } + } + all-touch := TouchArea { + clicked => { root.select(0); } + // Dropping onto "All photographs" would mean nothing — an + // image is already in the library — so this reports no hover + // during a drag and stays inert. + } + } + + Rectangle { + height: 1px; + background: Theme.rule; + } + + // --- the tree ----------------------------------------------------- + Flickable { + vertical-stretch: 1; + viewport-height: root.rows.length * (Theme.row-height + 2px); + + for row[i] in root.rows: TreeRow { + y: i * (Theme.row-height + 2px); + width: parent.width; + entry: row; + selected: row.id == root.selected-id; + // A saved filter's membership is computed, so a drop cannot + // land there. Refused in `can-drop`, so the cursor says no + // before the release rather than after. + drop-allowed: !row.smart; + renaming: row.id == root.renaming-id; + + clicked => { root.select(row.id); } + toggle => { root.toggle(row.id); } + context-menu => { root.row-menu(row.id); } + dropped => { root.dropped-on(row.id); } + drag-over(over) => { root.drag-over(row.id, over); } + rename-requested => { root.rename-start(row.id); } + rename-committed(name) => { root.rename-commit(row.id, name); } + rename-cancelled => { root.rename-cancel(); } + } + + // Empty state. A blank panel gives no hint that collections exist + // at all, let alone that images can be dragged into them. + if root.rows.length == 0: VerticalLayout { + alignment: center; + spacing: Theme.gap-sm; + padding: Theme.gap; + + Text { + text: "No collections yet"; + color: Theme.ink-dim; + font-size: Theme.text-sm; + horizontal-alignment: center; + } + Text { + text: "Press + to make one, then drag photographs onto it."; + color: Theme.ink-faint; + font-size: Theme.text-sm; + horizontal-alignment: center; + wrap: word-wrap; + } + } + } + + // --- trash -------------------------------------------------------- + // + // TRACES: FR-CAT-15 + // Below the tree and separated from it, because it is not a collection: + // dropping here *moves the file* into a trash folder on the server, + // where every collection above merely references. A destination that + // changes the library has no business sitting in the same list as ones + // that do not. + Rectangle { + height: 1px; + background: Theme.rule; + } + + Rectangle { + height: Theme.row-height; + background: trash-drop.has-drag + ? Theme.selected + : (root.selected-id == -1 ? Theme.surface-raised + : (trash-touch.has-hover ? Theme.hover : transparent)); + border-radius: Theme.radius-sm; + border-width: trash-drop.has-drag ? 1px : 0px; + border-color: Theme.warn-ink; + + trash-drop := DropArea { + width: 100%; + height: 100%; + // Deliberately `move`, where a collection drop is `copy`: this + // one really does take the photograph out of the library, and + // the cursor should say so. + can-drop(ev) => { return DragAction.move; } + dropped(ev) => { + root.dropped-on-trash(); + return DragAction.move; + } + } + + HorizontalLayout { + padding-left: Theme.gap-sm; + padding-right: Theme.gap-sm; + spacing: Theme.gap-sm; + + Text { + text: "🗑"; + color: root.trash-count > 0 ? Theme.warn-ink : Theme.ink-faint; + font-size: Theme.text-sm; + vertical-alignment: center; + } + + Text { + text: "Trash"; + color: root.selected-id == -1 ? Theme.ink : Theme.ink-dim; + font-size: Theme.text; + font-weight: root.selected-id == -1 ? 600 : 400; + vertical-alignment: center; + horizontal-stretch: 1; + } + + // The size, not just the count: "empty trash" is destructive and + // what it frees is what tells the user whether they meant it. + Text { + text: root.trash-count > 0 ? root.trash-label : ""; + color: Theme.ink-faint; + font-size: Theme.text-sm; + vertical-alignment: center; + } + } + + trash-touch := TouchArea { + clicked => { root.select(-1); } + } + } + + // Restore sits *above* Empty, and is the only one of the two that names + // a number. The recoverable action should be the one the hand reaches + // first, and the destructive one should not be what a user finds when + // they open the trash looking for a way back. + if root.selected-id == -1 && root.selected-count > 0: Button { + text: root.selected-count == 1 + ? "Restore 1 image" + : "Restore " + root.selected-count + " images"; + clicked => { root.restore-selected(); } + } + + // Emptying is offered only while the trash is being *looked at*, so it + // cannot be hit in passing. It is the one irreversible action in this + // panel and it should take a deliberate visit to reach. + if root.selected-id == -1 && root.trash-count > 0: Button { + text: "Empty trash"; + clicked => { root.empty-trash(); } + } + + // --- error -------------------------------------------------------- + // + // A refused drop or a failed rename says so here rather than only in + // the log: the gesture succeeded from the user's point of view, so + // silence would read as data loss. + if root.error != "": Text { + text: root.error; + color: Theme.warn-ink; + font-size: Theme.text-sm; + wrap: word-wrap; + } + } + + // Right edge, separating the panel from the grid. + Rectangle { + x: parent.width - 1px; + width: 1px; + background: Theme.rule; + } +} diff --git a/ui/dr-ui/ui/launch.slint b/ui/dr-ui/ui/launch.slint index 719bb92..bb6236d 100644 --- a/ui/dr-ui/ui/launch.slint +++ b/ui/dr-ui/ui/launch.slint @@ -1,4 +1,5 @@ import { Theme } from "theme.slint"; +import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Disclosure } from "widgets.slint"; // Launch screen: connect an account, or resume a saved one. // @@ -35,12 +36,17 @@ component FormatCheck inherits Rectangle { y: (parent.height - self.height) / 2; border-radius: 3px; border-width: 1px; - border-color: root.checked ? Theme.accent : Theme.rule; - background: root.checked ? Theme.accent : transparent; + // A ticked box is an engaged control, so it fills with `active` + // — the same token the slider fill and the focus border take. + border-color: root.checked ? Theme.active : Theme.rule; + background: root.checked ? Theme.active : transparent; Text { text: "✓"; - color: #fff; + // Dark on the fill: `active` is near-white, and the white + // tick this carried against a saturated accent is invisible + // against it. + color: Theme.ground; font-size: 12px; visible: root.checked; horizontal-alignment: center; @@ -50,45 +56,20 @@ component FormatCheck inherits Rectangle { } } - Text { + Label { text: root.label; - color: touch.has-hover ? Theme.ink : Theme.ink-dim; - font-size: Theme.text; - vertical-alignment: center; + emphasised: touch.has-hover; + body: true; } } } -component Button inherits Rectangle { - in property <string> label; - in property <bool> primary: false; - in property <bool> enabled: true; - callback clicked(); - +// This screen's buttons are form actions in a single stacked column, not +// chrome beside a photograph — they are given the full `touch-target` height +// rather than the denser `control-height` the toolbars use. `Button` draws +// its own hit area at least this tall either way; here the ink matches it. +component FormButton inherits Button { height: Theme.touch-target; - border-radius: 4px; - background: root.primary - ? (touch.has-hover && root.enabled ? Theme.accent-dim : Theme.accent) - : (touch.has-hover && root.enabled ? Theme.surface-raised : transparent); - border-width: root.primary ? 0px : 1px; - border-color: Theme.rule; - opacity: root.enabled ? 1.0 : 0.45; - - touch := TouchArea { - enabled: root.enabled; - clicked => { root.clicked(); } - } - - Text { - text: root.label; - color: root.primary ? #fff : Theme.ink; - font-size: Theme.text; - font-weight: 600; - horizontal-alignment: center; - vertical-alignment: center; - width: 100%; - height: 100%; - } } // One folder in the picker. The whole row is the target, not just the text. @@ -108,20 +89,8 @@ component FolderRow inherits Rectangle { padding-right: Theme.gap; spacing: Theme.gap; - Text { - text: root.is-parent ? "↑" : "▸"; - color: Theme.ink-faint; - font-size: Theme.text; - vertical-alignment: center; - width: 14px; - } - Text { - text: root.label; - color: Theme.ink; - font-size: Theme.text; - vertical-alignment: center; - overflow: elide; - } + Disclosure { text: root.is-parent ? "↑" : "▸"; } + Value { text: root.label; overflow: elide; } } } @@ -178,6 +147,9 @@ export component LaunchScreen inherits Rectangle { // --- masthead --- VerticalLayout { spacing: Theme.gap-sm; + // The one piece of type in the app that is a title rather + // than a label, so it is written here rather than given a + // component with a single call site. Text { text: "DarkRoom"; color: Theme.ink; @@ -185,12 +157,11 @@ export component LaunchScreen inherits Rectangle { font-weight: 800; letter-spacing: -0.5px; } - Text { + Label { text: root.signed-in ? "Connected" : "Connect a Nextcloud account to begin"; - color: Theme.ink-dim; - font-size: Theme.text; + body: true; } } @@ -200,63 +171,29 @@ export component LaunchScreen inherits Rectangle { if !root.signed-in && root.login-url == "": VerticalLayout { spacing: Theme.gap; - Text { - text: "SERVER"; - color: Theme.accent; - font-size: Theme.text-sm; - font-weight: 700; - letter-spacing: 1.2px; + PanelHeading { text: "SERVER"; } + + server-input := Field { + text: root.server-url; + placeholder: "https://cloud.example.com"; + accepted(url) => { root.sign-in(url); } } - Rectangle { - height: Theme.touch-target; - border-radius: 4px; - border-width: 1px; - border-color: server-input.has-focus ? Theme.accent : Theme.rule; - background: Theme.surface; - - server-input := TextInput { - text: root.server-url; - color: Theme.ink; - font-size: Theme.text; - vertical-alignment: center; - width: parent.width - 2 * Theme.gap; - x: Theme.gap; - height: 100%; - single-line: true; - accepted => { root.sign-in(self.text); } - } - - // Placeholder, since TextInput has none of its own. - Text { - text: "https://cloud.example.com"; - color: Theme.ink-faint; - font-size: Theme.text; - vertical-alignment: center; - x: Theme.gap; - height: 100%; - visible: server-input.text == ""; - } - } - - if !root.can-remember: Text { + if !root.can-remember: Caption { text: "No system keyring found — you will need to sign in each time."; - color: Theme.warn-ink; - font-size: Theme.text-sm; + warn: true; wrap: word-wrap; } - Button { - label: root.busy ? "Connecting…" : "Sign in"; + FormButton { + text: root.busy ? "Connecting…" : "Sign in"; primary: true; enabled: !root.busy && server-input.text != ""; clicked => { root.sign-in(server-input.text); } } - Text { + Caption { text: "Sign-in happens in your browser. DarkRoom never sees your password."; - color: Theme.ink-faint; - font-size: Theme.text-sm; wrap: word-wrap; } } @@ -265,77 +202,52 @@ export component LaunchScreen inherits Rectangle { if root.login-url != "": VerticalLayout { spacing: Theme.gap; - Text { - text: "APPROVE IN YOUR BROWSER"; - color: Theme.accent; - font-size: Theme.text-sm; - font-weight: 700; - letter-spacing: 1.2px; - } + PanelHeading { text: "APPROVE IN YOUR BROWSER"; } - Rectangle { - background: Theme.surface; - border-radius: 4px; - border-width: 1px; - border-color: Theme.rule; - - VerticalLayout { - padding: Theme.gap; - Text { - text: root.login-url; - color: Theme.ink-dim; - font-size: Theme.text-sm; - wrap: char-wrap; - } + Panel { + Label { + text: root.login-url; + // char-wrap, not word-wrap: a URL has no spaces to + // break at, and word-wrap would run it off the box. + wrap: char-wrap; } } - Button { - label: "Copy link"; + FormButton { + text: "Copy link"; clicked => { root.copy-login-url(); } } - Text { - text: "Waiting for approval…"; - color: Theme.ink-faint; - font-size: Theme.text-sm; - } + Caption { text: "Waiting for approval…"; } } // --- folder picker --- if root.signed-in && root.browsing: VerticalLayout { spacing: Theme.gap; - Text { - text: "CHOOSE LIBRARY FOLDER"; - color: Theme.accent; - font-size: Theme.text-sm; - font-weight: 700; - letter-spacing: 1.2px; - } + PanelHeading { text: "CHOOSE LIBRARY FOLDER"; } // Current location, so it is always clear what // "Use this folder" would select. - Text { + Value { text: root.browse-path == "" ? "/" : "/" + root.browse-path; - color: Theme.ink; - font-size: Theme.text; overflow: elide; } + // Not a `Panel`: this box overlays three mutually + // exclusive states — loading, the list, "nothing here" — + // each filling it, and Panel stacks its children in a + // column. Same surface and rule, drawn directly. Rectangle { height: 220px; background: Theme.surface; - border-radius: 4px; + border-radius: Theme.radius; border-width: 1px; border-color: Theme.rule; - if root.browse-loading: Text { + if root.browse-loading: Caption { text: "Loading…"; - color: Theme.ink-faint; - font-size: Theme.text-sm; horizontal-alignment: center; - vertical-alignment: center; width: 100%; height: 100%; } @@ -364,12 +276,9 @@ export component LaunchScreen inherits Rectangle { } if !root.browse-loading && root.browse-entries.length == 0 - && root.browse-path != "": Text { + && root.browse-path != "": Caption { text: "No subfolders here"; - color: Theme.ink-faint; - font-size: Theme.text-sm; horizontal-alignment: center; - vertical-alignment: center; width: 100%; height: 100%; } @@ -377,13 +286,13 @@ export component LaunchScreen inherits Rectangle { HorizontalLayout { spacing: Theme.gap; - Button { - label: "Cancel"; + FormButton { + text: "Cancel"; horizontal-stretch: 1; clicked => { root.browse-cancel(); } } - Button { - label: "Use this folder"; + FormButton { + text: "Use this folder"; primary: true; horizontal-stretch: 1; clicked => { root.browse-confirm(); } @@ -395,40 +304,22 @@ export component LaunchScreen inherits Rectangle { if root.signed-in && !root.browsing: VerticalLayout { spacing: Theme.gap; - Text { - text: "ACCOUNT"; - color: Theme.accent; - font-size: Theme.text-sm; - font-weight: 700; - letter-spacing: 1.2px; - } - Text { - text: root.account; - color: Theme.ink; - font-size: Theme.text; - } + PanelHeading { text: "ACCOUNT"; } + Value { text: root.account; } Rectangle { height: Theme.gap-sm; } - Text { - text: "LIBRARY FOLDER"; - color: Theme.accent; - font-size: Theme.text-sm; - font-weight: 700; - letter-spacing: 1.2px; - } + PanelHeading { text: "LIBRARY FOLDER"; } HorizontalLayout { spacing: Theme.gap; - Text { + Value { text: root.library-root == "" ? "(not chosen)" : root.library-root; - color: root.library-root == "" ? Theme.ink-faint : Theme.ink; - font-size: Theme.text; - vertical-alignment: center; + placeholder: root.library-root == ""; horizontal-stretch: 1; overflow: elide; } - Button { - label: "Choose…"; + FormButton { + text: "Choose…"; width: 110px; clicked => { root.choose-folder(); } } @@ -436,13 +327,7 @@ export component LaunchScreen inherits Rectangle { Rectangle { height: Theme.gap-sm; } - Text { - text: "SCAN FOR"; - color: Theme.accent; - font-size: Theme.text-sm; - font-weight: 700; - letter-spacing: 1.2px; - } + PanelHeading { text: "SCAN FOR"; } for label[i] in root.format-labels: FormatCheck { label: label; @@ -452,29 +337,30 @@ export component LaunchScreen inherits Rectangle { Rectangle { height: Theme.gap; } - Button { - label: root.busy ? "Scanning…" : "Open library"; + FormButton { + text: root.busy ? "Scanning…" : "Open library"; primary: true; enabled: !root.busy && root.library-root != ""; clicked => { root.open-library(); } } - Button { - label: "Sign out"; + FormButton { + text: "Sign out"; clicked => { root.sign-out(); } } } // --- feedback --- - if root.status != "": Text { + if root.status != "": Label { text: root.status; - color: Theme.ink-dim; - font-size: Theme.text-sm; wrap: word-wrap; } - if root.error != "": Text { + // A failed sign-in or scan is a caution, not an active state: + // something the user has to act on rather than something the + // app is doing. It was the same accent as the headings above + // it, which said nothing had gone wrong. + if root.error != "": Caption { text: root.error; - color: Theme.accent; - font-size: Theme.text-sm; + warn: true; wrap: word-wrap; } } diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint new file mode 100644 index 0000000..89d88b1 --- /dev/null +++ b/ui/dr-ui/ui/library.slint @@ -0,0 +1,1213 @@ +// The library grid: what a scanned library looks like. +// +// Cells come from Rust as a windowed model, never the whole catalog — a 17k +// image library must not become 17k live elements (FR-CAT-4). +// +// A cell with no thumbnail yet shows why rather than an empty box. On a remote +// library a thumbnail is a network round trip, so "nothing there", "not +// fetched yet", and "no preview in this file" are three different states and +// must not look identical (FR-NC-6c). + +import { Theme } from "theme.slint"; +import { Button, Label, Value, Caption, EmptyState, FilterChip } from "widgets.slint"; + +// One bar of the capture-time histogram. +export struct TimelineBar { + // 0..1, relative to the tallest bucket. Square-rooted in Rust so a quiet + // day stays visible beside a wedding. + height: float, + // Bucket start, Unix seconds. What a click on this bar scrubs to. + start: int, + count: int, + label: string, + // "2024", "Mar", or empty. Non-empty only where this bucket begins a new + // year or month, so the axis is labelled at boundaries rather than on + // every bar. Chosen in Rust, which knows the granularity. + period-label: string, +} + +// The capture-time sidebar: the shape of the library over time, and the +// primary way of moving through it. +// +// **Vertical, and a sidebar rather than a strip**, because a scroll position is +// a relative quantity and a date is an absolute one. The grid's scrollbar says +// how far through you are; this says *when* you are. +// +// Three gestures, in the darktable idiom: +// +// - **click or drag** — scrub the grid to that instant +// - **drag with the middle button, or shift-drag** — pan the visible span +// - **wheel** — zoom, which changes the bucket size Rust picks +// +// # One hit area, not one per bar +// +// An earlier version gave every bar its own `TouchArea`. With a few hundred +// buckets that is a few hundred overlapping hit regions, and a drag is +// delivered to whichever bar the press *started* on rather than the one under +// the cursor — so scrubbing jumped and stuttered. There is exactly one +// `TouchArea` here and the bucket is computed from the pointer's position. +export component Timeline inherits Rectangle { + in property <[TimelineBar]> bars; + in property <string> range-label; + /// The bucket the grid is currently showing, highlighted so the position is + /// visible when the grid is moved by scrolling instead. + in property <int> current-start: 0; + /// Index of that bucket among `bars`, supplied by Rust. Slint has no array + /// search, and a component that quietly returned the wrong index would put + /// the marker in a plausible but false position. + in property <int> current-index: -1; + /// True once the user has taken control. Until then the marker rests at the + /// middle rather than pinning to either end, which would imply a selection + /// that has not been made. + in property <bool> anchored: false; + + callback scrub(int); + callback pan(int); + callback zoom(int); + + width: 96px; + background: Theme.surface; + + property <int> hovered: -1; + property <length> track-top: 22px; + property <length> track-height: max(1px, self.height - self.track-top - 6px); + property <length> slot: root.track-height / max(1, root.bars.length); + + /// Bucket index under a y coordinate, clamped to the ends so a drag that + /// leaves the widget still scrubs to the nearest bucket rather than + /// stopping dead. + function bucket-at(y: length) -> int { + return clamp(floor((y - root.track-top) / max(1px, root.slot)), + 0, max(0, root.bars.length - 1)); + } + + // Header: the hovered bucket, else the whole span. + Caption { + x: 8px; + y: 4px; + width: parent.width - 16px; + text: root.hovered >= 0 && root.hovered < root.bars.length + ? root.bars[root.hovered].label + " · " + root.bars[root.hovered].count + : root.range-label; + emphasised: root.hovered >= 0; + overflow: elide; + } + + // The bars. Purely visual — every gesture is handled by the single + // TouchArea below, which sits above them. + for bar[i] in root.bars: Rectangle { + // Bars grow from the *left* edge, so the axis reads like a timeline + // turned on its side and the labels have room on the right. + x: 0; + y: root.track-top + i * root.slot; + width: max(1px, (parent.width - 34px) * bar.height); + height: max(1px, root.slot - 1px); + background: bar.start == root.current-start + ? Theme.active + : (root.hovered == i ? Theme.ink-dim : Theme.ink-faint); + } + + // Year and month labels down the right-hand edge. + // + // Drawn only where a bar *starts* a new period, so a month-bucketed view + // labels each January rather than repeating the year on every bar. The + // label text itself is chosen in Rust, which knows the granularity; an + // empty string means "no boundary here". + for bar[i] in root.bars: Text { + x: parent.width - 32px; + y: root.track-top + i * root.slot - 5px; + width: 30px; + text: bar.period-label; + color: Theme.ink-faint; + font-size: Theme.text-sm; + visible: bar.period-label != ""; + } + + // Where the grid currently sits. Rests at the midpoint until the user has + // actually chosen a position. + Rectangle { + x: 0; + width: parent.width; + height: 1px; + background: Theme.active; + opacity: root.anchored ? 1.0 : 0.35; + y: root.anchored && root.current-index >= 0 + ? root.track-top + root.current-index * root.slot + : root.track-top + root.track-height / 2; + } + + // The single hit area. Everything above is inert. + touch := TouchArea { + width: 100%; + height: 100%; + mouse-cursor: pointer; + + property <length> press-y; + property <bool> panning; + + moved => { + if (self.panning) { + // Pan by whole buckets, so the view moves in the same units it + // is drawn in. + root.pan(round((self.press-y - self.mouse-y) / max(1px, root.slot))); + self.press-y = self.mouse-y; + } else if (self.pressed && root.bars.length > 0) { + // Guarded like the press handler below: the sidebar is now + // always present, so a drag across it on an undated library + // would index an empty array. + root.scrub(root.bars[root.bucket-at(self.mouse-y)].start); + } + 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 or shift pans; anything else scrubs. + // Middle button pans. Shift is not consulted here: the + // modifier state belongs to the key handler, not the pointer + // event, and a middle-drag is the unambiguous gesture. + self.panning = e.button == PointerEventButton.middle; + if (!self.panning && root.bars.length > 0) { + root.scrub(root.bars[root.bucket-at(self.mouse-y)].start); + } + } + if (e.kind == PointerEventKind.up) { self.panning = false; } + } + + scroll-event(e) => { + // Wheel zooms rather than scrolls: the sidebar is an axis, not a + // list, and a scroll gesture over it means "show me more or less + // time" rather than "move down". + root.zoom(e.delta-y > 0 ? 1 : -1); + return accept; + } + + changed has-hover => { + if (!self.has-hover) { root.hovered = -1; } + } + } +} + +export struct LibraryCell { + name: string, + // Non-empty on the first cell of a new month, e.g. "August 2026". The grid + // is ordered by capture time, so these are the only place the date is + // legible without consulting the sidebar — a wall of thumbnails otherwise + // gives no sense of when you are. + period-heading: string, + // Empty until a fetch lands. `has-thumb` disambiguates, because Slint + // cannot test an image against null. + thumbnail: image, + has-thumb: bool, + // A fetch that completed with no usable preview. Distinct from pending. + unavailable: bool, + // Part of the current selection. Selection is what a drag carries, so this + // has to be per-cell state rather than a single "current" index. + selected: bool, + // How many collections this image belongs to. An image can be in many at + // once, and without a cue the grid gives no hint that a photograph has + // already been filed — the user re-files it, or hunts for where it went. + collection-count: int, + // This cell is one of the images currently being dragged. It reads as + // *lifted out*: desaturated and shrunk in place, so the grid shows where + // the photographs came from while the cursor shows them in full colour. + lifted: bool, + // Stars, 0..5. Zero is *unrated* — a state of its own, not a low score, + // and what "filter to unjudged" selects (FR-CULL-4). + rating: int, + // 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a + // four-star frame is a normal thing to do mid-cull. + flag: int, +} + +// A horizontal progress bar with two modes. +// +// Determinate where a real denominator exists (thumbnails: we know how many +// cells we asked for). Indeterminate where one does not — a directory walk +// discovers its own extent, so any percentage would be invented, and inventing +// one is worse than admitting the work is unbounded. +component ProgressBar inherits Rectangle { + in property <float> fraction: 0; + in property <bool> indeterminate: false; + + height: 3px; + background: Theme.rule; + + // Determinate: a bar proportional to real progress. + Rectangle { + x: 0; + width: parent.width * clamp(root.fraction, 0, 1); + height: parent.height; + background: Theme.active-dim; + visible: !root.indeterminate; + } + + // Indeterminate: a sweep that says "working" without claiming a position. + Rectangle { + width: parent.width * 25%; + height: parent.height; + background: Theme.active-dim; + visible: root.indeterminate; + + x: root.indeterminate ? -self.width : 0; + animate x { + duration: 1200ms; + iteration-count: -1; + easing: ease-in-out; + } + states [ + running when root.indeterminate: { x: parent.width; } + ] + } +} + +// A row of five stars, readable at a glance and clickable to set a rating. +// +// **Filled versus empty carries the meaning, not colour.** NFR-A11Y-3 forbids +// status by hue alone, and the palette is achromatic anyway — so a set star is +// a solid glyph at `active` and an unset one is an outline at `ink-faint`. The +// two differ in both shape and luminance, which survives greyscale and low +// vision alike. +// +// **Unrated draws nothing until hovered.** A grid of 120 cells each showing +// five empty stars is a wall of chrome competing with the photographs; a +// freshly scanned library would look like a spreadsheet. The strip appears on +// hover, so an unjudged cell is quiet and a judged one is legible from across +// the room. +export component StarStrip inherits Rectangle { + in property <int> rating: 0; + /// Show the empty stars even at zero — used while the pointer is over the + /// cell, so there is something to aim at. + in property <bool> show-empty: false; + /// Whether clicking sets a rating. False in a read-only context. + in property <bool> interactive: true; + /// Whether to offer the trash target at all. Hidden in the trash view, + /// where these photographs are already there — `plan_trash` would skip + /// them anyway, so the control would be inert, and an inert control that + /// looks live is worse than no control. + in property <bool> can-trash: true; + + /// A star was clicked: the rating it stands for, 1..5. + callback rate(int); + /// The trash target was clicked. Named separately from [`rate`] rather + /// than being rating `-1`: this moves a file on the server, and a callback + /// that could mean either "set a rating" or "delete a photograph" + /// depending on the sign is one typo away from the wrong one. + callback trash(); + + // One star's drawn box. The *ink* stays small — five 44px stars would be + // 220px wide and swamp a 180px cell — while the hit area below grows to + // `Theme.touch-target`, which is the same split `Button` and `FormatCheck` + // make for the same FR-UI-3 reason. + property <length> star: 22px; + + // Separation between the trash target and ★1. + // + // **This gap is load-bearing.** The star targets are 44px over a 22px + // glyph, so they deliberately overlap and a near-miss lands one star out — + // harmless, same control, corrected by clicking again. That reasoning does + // not survive a neighbour that *moves a file*, so trash is held off the + // scale by a gap wider than the overhang it would otherwise share with + // ★1. A slip between them hits nothing at all, which is the correct + // outcome for an ambiguous press next to a destructive target. + property <length> trash-gap: 14px; + + height: root.star; + // Sized to its content so the cell's layout does not reserve space for a + // strip that may be invisible. + width: 5 * root.star + (root.can-trash ? root.star + root.trash-gap : 0px); + // A ground behind the stars: they sit over a photograph that may be white + // at that point, and an outline star on a bright sky is invisible. Also + // makes the strip read as one control rather than five loose glyphs. + background: root.visible ? Theme.surface.with-alpha(0.75) : transparent; + border-radius: Theme.radius; + visible: root.rating > 0 || root.show-empty; + + HorizontalLayout { + // Trash sits at the *left*, before the scale rather than beyond its + // top: reading left to right it is "remove this" and then a rising + // scale, which keeps ★5 at the end where a rating scale is expected + // to peak. Putting it past ★5 would make the strip read as a + // six-point scale whose last stop deletes. + Rectangle { + width: root.can-trash ? root.star : 0px; + height: root.star; + visible: root.can-trash; + + Text { + text: "🗑"; + // Reject and trash are the two destructive ends of this UI and + // share the palette's one hue, so the gesture reads the same + // in both places (NFR-A11Y-3: the glyph carries it, not the + // colour). + color: Theme.warn-ink; + font-size: 13px; + width: 100%; + height: 100%; + horizontal-alignment: center; + vertical-alignment: center; + } + + TouchArea { + enabled: root.interactive; + // Deliberately *not* grown to `touch-target`. Every other + // target here overhangs to meet FR-UI-3, but that requirement + // is about reaching a control with a thumb — it is not a + // reason to make a destructive one easier to hit by accident + // than the thing beside it. 22px plus the gap is a real target + // without reaching into ★1's band. + width: 100%; + height: 100%; + mouse-cursor: root.interactive ? MouseCursor.pointer + : MouseCursor.default; + clicked => { root.trash(); } + } + } + + // The gap itself, inert — no TouchArea, so a press here does nothing + // rather than resolving to whichever neighbour is closer. + Rectangle { width: root.can-trash ? root.trash-gap : 0px; } + + for n[i] in [1, 2, 3, 4, 5]: Rectangle { + width: root.star; + height: root.star; + + Text { + // Solid versus outline: the shape says it, not the colour. + text: root.rating >= n ? "★" : "☆"; + color: root.rating >= n ? Theme.active : Theme.ink-dim; + // Large enough to hit the difference between ★ and ☆ at arm's + // length on a tablet; the glyphs differ in fill, which needs + // more pixels to read than a difference in shape would. + font-size: 15px; + width: 100%; + height: 100%; + horizontal-alignment: center; + vertical-alignment: center; + } + + // Grown past the drawn star to meet FR-UI-3's 44pt minimum, and + // centred on it. The overhang overlaps its neighbours, so the + // *later* star wins the shared band — which is why this is + // acceptable here: adjacent targets belong to the same control and + // a near-miss sets a rating one star out, not something unrelated. + // + // Vertical overhang spills outside the strip onto the thumbnail, + // which carries no hit area of its own — the cell's TouchArea is + // below this in z-order. + TouchArea { + enabled: root.interactive; + width: max(parent.width, Theme.touch-target); + height: max(parent.height, Theme.touch-target); + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + mouse-cursor: root.interactive ? MouseCursor.pointer + : MouseCursor.default; + // Clicking the star already set clears the rating — the + // gesture every photo tool uses for "undo that", and without + // it the only way back to unrated is the keyboard. + clicked => { root.rate(root.rating == n ? 0 : n); } + } + } + } +} + +// The pick/reject mark. +// +// A glyph rather than a colour, for the same NFR-A11Y-3 reason as the stars: +// ✓ and ✗ are distinguishable without hue, and a reject reads as a reject in +// greyscale. A reject also dims its whole cell, which is the cue that carries +// at grid scale — the glyph is confirmation, not the primary signal. +export component FlagMark inherits Rectangle { + in property <int> flag: 0; + + width: 16px; + height: 16px; + border-radius: 8px; + visible: root.flag > 0; + background: Theme.surface; + opacity: 0.92; + + Text { + text: root.flag == 2 ? "✗" : "✓"; + // Reject earns the one hue in the palette: it is the destructive end + // of the axis and the thing a user must not mistake for a pick. + color: root.flag == 2 ? Theme.warn-ink : Theme.active; + font-size: 10px; + font-weight: 700; + width: 100%; + height: 100%; + horizontal-alignment: center; + vertical-alignment: center; + } +} + +export component LibraryGrid inherits Rectangle { + in property <[LibraryCell]> cells; + in property <int> total: 0; + in property <bool> scanning: false; + in property <string> scan-status: ""; + in property <string> scan-error: ""; + /// Which folder is being shown. Visible at all times: two similarly-named + /// folders are easy to confuse, and a scan of the wrong one looks + /// identical to a broken scan. + in property <string> root-label: ""; + + // --- capture-time scrubber --- + in property <[TimelineBar]> timeline; + in property <string> timeline-label: ""; + /// Dates spanned by the cells currently shown. + in property <string> window-label: ""; + in property <int> offset: 0; + /// Where the view should be, as an image ordinal. A scrub sets this; the + /// grid follows it. + /// + /// Bumped by `scroll-token` rather than watched directly: scrubbing twice + /// to the same date must still move the view, and an unchanged property + /// fires no `changed` handler. + in property <int> scroll-to: 0; + in property <int> scroll-token: 0; + + /// Which bucket the grid currently sits in, and where that is among the + /// bars. Slint cannot search an array, so Rust supplies both. + in property <int> current-bucket: 0; + in property <int> current-bucket-index: -1; + /// False until the user has moved the timeline themselves, so the marker + /// rests at the middle rather than implying a choice not yet made. + in property <bool> timeline-anchored: false; + + callback scrub(int); + callback timeline-pan(int); + callback timeline-zoom(int); + callback columns-changed(int); + callback sync-now(); + /// The grid scrolled: the first visible image's ordinal in the library. + /// Rust answers by loading the window around that position. + callback scrolled(int); + /// The viewport can now hold a different number of cells — a resize, or a + /// column-count change. Rust resizes the loaded window to match. + callback capacity-changed(int); + + // Thumbnail progress. Unlike the scan, this has a real denominator — the + // number of cells we asked for — so the bar can be honest about position. + in property <int> thumbs-done: 0; + in property <int> thumbs-total: 0; + + // Whole-library indexing, which runs for far longer than one window's + // thumbnails and is reported separately so the two do not fight over the + // same line. + in property <int> sweep-done: 0; + in property <int> sweep-total: 0; + /// Pushing shards and the catalog to the server. + in property <bool> syncing: false; + property <bool> sweeping: root.sweep-total > 0 && root.sweep-done < root.sweep-total; + + property <bool> thumbs-running: root.thumbs-total > 0 + && root.thumbs-done < root.thumbs-total; + + callback cell-clicked(int); + /// A star was clicked on a cell: row, and the rating 0..5. + callback cell-rated(int, int); + /// Whether the grid is currently listing the trash rather than the + /// library. Suppresses the per-cell trash target, which would be inert + /// there — `plan_trash` skips an already-trashed image — and offering a + /// control that does nothing is worse than offering none. + in property <bool> viewing-trash: false; + /// The trash target was clicked on a cell. Acts on that one photograph, + /// like the stars beside it — the pointer names it unambiguously, and a + /// click that quietly trashed a whole selection would be a trap. `Delete` + /// is the bulk gesture. + callback cell-trashed(int); + /// Move the selection to the trash — the `Delete` key. + callback trash-selection(); + /// A rating or flag key was pressed while the grid had focus. Applies to + /// the whole selection, which is what makes rating forty frames one + /// gesture. + /// + /// Rating and flag travel on one callback because they are one keystroke + /// as far as the user is concerned; Rust decodes which axis was meant. + /// `rating` is -1 where the key was a flag, and `flag` -1 where it was a + /// star, so neither axis is disturbed by a press on the other. + callback judged(int, int); + /// `F2` — rename the collection the grid is scoped to. The key lives with + /// the grid because that is what holds focus in library mode, but the + /// rename itself happens in the sidebar. + callback rename-scope(); + callback rescan(); + /// Back to the launch screen, to change library or account. + callback change-library(); + + // --- selection and drag --- + // + // A click selects; ctrl-click adds to the selection; shift-click extends a + // range. Dragging a selected cell carries the whole selection, which is + // what makes "put these forty photographs in that collection" one gesture. + // + // The drag itself is Slint's own `DragArea`, not a hand-rolled gesture. The + // first attempt here tracked presses and travel through a `TouchArea` and + // failed for a reason worth recording: an interactive `Flickable` claims any + // drag that begins inside it for scrolling, cancelling the child + // TouchArea's press, so the gesture could never leave the grid. `DragArea` + // is arbitrated properly against the Flickable, keeps the pointer capture + // across component boundaries, and draws its own cursor overlay — which is + // also why there is no badge position to compute here any more. + /// Modifier state at press time, so Rust can decide replace / add / extend + /// without the .slint file encoding the selection policy. + callback cell-pressed(int, bool, bool); + /// The drag payload: the selected image ids, wrapped by Rust. Called when a + /// drag starts, so it always reflects the selection as it is at that moment. + pure callback drag-payload() -> data-transfer; + /// What travels under the cursor: the dragged thumbnail, or a fanned stack + /// of them where several are being carried. Composited in Rust, because + /// Slint accepts one bitmap here and cannot draw a pile of images into it. + in property <image> drag-image; + /// A drag began on this cell. Lets Rust promote an unselected cell to the + /// selection before the payload is read. + callback drag-started(int); + /// The drag ended — dropped or cancelled. Clears the transient UI state. + callback drag-finished(); + + // --- rating filter (FR-CAT-6, FR-CULL-4) --- + // + // The filter narrows what the grid *queries*, not what it draws: on a + // remote library, drawing then hiding would still have fetched every + // thumbnail, which is the cost FR-NC-3 exists to avoid. + /// Minimum stars to show. 0 shows everything. + in property <int> filter-min-rating: 0; + /// Show only images nothing has judged yet — FR-CULL-4's "filter to + /// unjudged", which is what lets a culling session resume. + in property <bool> filter-unjudged: false; + /// 0 no flag filter, 1 picks only, 2 rejects only. + in property <int> filter-flag: 0; + /// How many images sit at each star count, index 0 being unrated. Shown + /// on the filter buttons so the user can see there is something behind a + /// filter before narrowing to it — a filter that silently empties the + /// grid reads as broken. + in property <[int]> rating-counts; + + callback filter-min-rating-changed(int); + callback filter-unjudged-toggled(bool); + callback filter-flag-changed(int); + + /// How many images are selected, for the header's count. + in property <int> selected-count: 0; + /// Which collection scopes the grid, for the header. Empty means all. + in property <string> scope-label: ""; + /// Take the selection out of the collection currently being shown. Only + /// offered when the grid is scoped to one — "remove from library" is not a + /// thing this button does. + callback remove-from-collection(); + + // Cell geometry. Columns are derived from the available width so the grid + // reflows with the window rather than fixing a count (FR-UI-1). + property <length> cell-size: 180px; + property <int> columns: max(1, floor((self.width - Theme.gap) / (cell-size + Theme.gap))); + // Reported out so Rust can place month headings: a heading belongs on a + // cell that begins a row, and only the grid knows how wide a row is. + changed columns => { root.columns-changed(root.columns); } + property <int> row-count: ceil(root.cells.length / max(1, columns)); + + // How many cells the viewport holds, plus a screenful either side so + // scrolling has loaded rows to move into rather than blank ones. + // + // Derived rather than a constant: a fixed window is simultaneously too + // small on a maximised 4K window — where three screenfuls fit inside it — + // and wasteful on a narrow one. + property <int> visible-rows: max(1, ceil(self.height / (cell-size + Theme.gap))); + property <int> capacity: root.columns * (root.visible-rows * 3); + changed capacity => { root.capacity-changed(root.capacity); } + /// Rows the *whole library* occupies, which is what the scrollbar spans. + property <int> total-rows: ceil(root.total / max(1, columns)); + + background: Theme.ground; + + VerticalLayout { + // --- header ------------------------------------------------------- + Rectangle { + height: 44px; + background: Theme.surface; + + HorizontalLayout { + padding-left: Theme.gap; + padding-right: Theme.gap; + spacing: Theme.gap; + + Value { + // The collection being shown takes the title when the grid + // is scoped to one: that is what the user narrowed to, and + // the folder is the less specific fact by then. + text: root.scope-label != "" ? root.scope-label + : (root.root-label != "" ? root.root-label : "Library"); + overflow: elide; + } + + Caption { text: root.total > 0 ? root.total + " images" : ""; } + + // The selection count, and how to act on it. Both appear only + // when something is selected — an empty selection has nothing + // to say and the buttons would be permanently greyed chrome. + // A live selection is state, not a label — it is the thing the + // buttons beside it act on — so it keeps `active` rather than + // dropping to ink with the counts around it. + Value { + text: root.selected-count > 0 + ? root.selected-count + " selected" : ""; + modified: true; + compact: true; + font-weight: 600; + } + + Label { + // The scan's own status while it runs; once it is done, + // thumbnail progress takes the line over — that is the + // work the user is actually waiting on by then. + // No per-window preview count: it counted an arbitrary + // batch, so "48 / 120" described the window's size rather + // than anything the user cares about. Whole-library + // indexing is the number worth showing, and the bar below + // already says that the window itself is still filling. + text: root.scanning ? root.scan-status + : (root.sweeping + ? "indexing " + root.sweep-done + " / " + root.sweep-total + : root.scan-status); + } + + // Where in the library the visible window sits. A scrubbable + // grid is disorienting without it. + Caption { + text: root.sweeping + ? "indexing " + root.sweep-done + " / " + root.sweep-total + : root.window-label; + horizontal-alignment: right; + horizontal-stretch: 1; + overflow: elide; + } + + // Removing from a collection is only meaningful while the grid + // is scoped to one. Offering it unscoped would invite the + // reading "remove from the library", which nothing here does. + Button { + text: "Remove from collection"; + y: (parent.height - self.height) / 2; + visible: root.scope-label != "" && root.selected-count > 0; + clicked => { root.remove-from-collection(); } + } + + // The header is taller than a control, so these are centred + // in it rather than stretched to fill it. + Button { + text: "Change library"; + y: (parent.height - self.height) / 2; + visible: !root.scanning; + clicked => { root.change-library(); } + } + + Button { + // Shares the finished index so a second device inherits it + // rather than repeating hours of range fetches. + text: root.syncing ? "Syncing…" : "Sync"; + enabled: !root.syncing && !root.scanning; + y: (parent.height - self.height) / 2; + clicked => { root.sync-now(); } + } + + Button { + text: "Rescan"; + y: (parent.height - self.height) / 2; + visible: !root.scanning; + clicked => { root.rescan(); } + } + } + } + + // --- rating filter ------------------------------------------------ + // + // Hidden while there is nothing to filter: an empty library offering + // six rating buttons is chrome describing data that does not exist. + if root.total > 0 || root.filter-min-rating > 0 || root.filter-unjudged + || root.filter-flag > 0: Rectangle { + height: 34px; + background: Theme.surface; + + HorizontalLayout { + padding-left: Theme.gap; + padding-right: Theme.gap; + spacing: 4px; + alignment: start; + + Caption { + text: "Show"; + vertical-alignment: center; + } + + // Minimum-stars buttons. "All" first, then 1..5 — the same + // left-to-right increasing order as the star strip itself, so + // the two read as the same scale. + FilterChip { + label: "All"; + active: root.filter-min-rating == 0 && !root.filter-unjudged + && root.filter-flag == 0; + y: (parent.height - self.height) / 2; + clicked => { + root.filter-min-rating-changed(0); + root.filter-unjudged-toggled(false); + root.filter-flag-changed(0); + } + } + + // Unrated, which is where a freshly scanned library lives in + // its entirety — and what a resumed cull filters to. + FilterChip { + label: "Unrated"; + count: root.rating-counts.length > 0 ? root.rating-counts[0] : -1; + active: root.filter-unjudged; + y: (parent.height - self.height) / 2; + clicked => { + root.filter-unjudged-toggled(!root.filter-unjudged); + } + } + + for n[i] in [1, 2, 3, 4, 5]: FilterChip { + label: "★" + n + "+"; + count: root.rating-counts.length > n ? root.rating-counts[n] : -1; + active: root.filter-min-rating == n; + y: (parent.height - self.height) / 2; + // Pressing the active one clears it, so the filter is its + // own undo and "All" is not the only way back. + clicked => { + root.filter-min-rating-changed( + root.filter-min-rating == n ? 0 : n); + } + } + + Rectangle { width: Theme.gap; } + + FilterChip { + label: "✓ Picks"; + active: root.filter-flag == 1; + y: (parent.height - self.height) / 2; + clicked => { + root.filter-flag-changed(root.filter-flag == 1 ? 0 : 1); + } + } + + FilterChip { + label: "✗ Rejects"; + active: root.filter-flag == 2; + y: (parent.height - self.height) / 2; + clicked => { + root.filter-flag-changed(root.filter-flag == 2 ? 0 : 2); + } + } + + Rectangle { horizontal-stretch: 1; } + + // What the filter is currently hiding. Without this a narrowed + // grid and an empty library look identical, which is the + // single most confusing state a filter can leave behind. + Caption { + text: (root.filter-min-rating > 0 || root.filter-unjudged + || root.filter-flag > 0) + ? "filtered" : ""; + emphasised: true; + vertical-alignment: center; + } + } + + Rectangle { + y: parent.height - 1px; + height: 1px; + background: Theme.rule; + } + } + + // --- progress ----------------------------------------------------- + // + // Indeterminate during the scan: a directory walk cannot know its own + // extent, so a percentage would be fiction. Determinate for + // thumbnails, where the denominator is the cells we requested. + if root.scanning || root.thumbs-running || root.sweeping: ProgressBar { + // Only the sweep has a denominator worth showing. A scan cannot + // know its extent, and the window's own fetches are sized by the + // viewport rather than by anything meaningful to the user. + indeterminate: root.scanning || (!root.sweeping && root.thumbs-running); + fraction: root.sweep-total > 0 + ? root.sweep-done / root.sweep-total + : 0; + } + + // --- error -------------------------------------------------------- + if root.scan-error != "": Rectangle { + height: 34px; + background: Theme.surface; + Caption { + text: root.scan-error; + warn: true; + horizontal-alignment: center; + overflow: elide; + } + } + + // --- body: sidebar beside the grid -------------------------------- + // + // The capture-time axis is furniture, not a strip under the images: a + // scroll position is relative, a date is absolute, and this is the + // primary way of moving through the library. + HorizontalLayout { + vertical-stretch: 1; + + // Always present, never conditional on having bars. + // + // Creating it on `timeline.length > 0` made the sidebar's 96px + // appear the moment the first dates were recorded, which narrowed + // the grid — changing `columns` and `capacity`, both of which call + // back into Rust to reload the window. The first sweep flush + // therefore landed a reload storm on top of the initial thumbnail + // batch. Reserving the column costs 96px on an undated library and + // keeps the grid's width stable while dates arrive. + // + // An empty `bars` already renders as bare furniture: the `for` + // loops produce nothing and the gestures index an empty array only + // under a pointer that has no bar to land on. + Timeline { + bars: root.timeline; + range-label: root.timeline-label; + current-start: root.current-bucket; + current-index: root.current-bucket-index; + anchored: root.timeline-anchored; + + scrub(t) => { root.scrub(t); } + pan(d) => { root.timeline-pan(d); } + zoom(d) => { root.timeline-zoom(d); } + } + + VerticalLayout { + horizontal-stretch: 1; + + // --- empty state -------------------------------------------------- + // + // "Still scanning" and "scanned, found nothing" are different answers. + // Conflating them is how a working scan looks broken. + if root.total == 0: EmptyState { + headline: root.scanning ? "Scanning…" : "No images found"; + detail: root.scanning ? root.scan-status + : "Check the library folder and which formats are ticked."; + } + + // --- keyboard judgement (FR-CULL-4) ------------------------------- + // + // `0`–`5` set stars, `P`/`X` pick and reject, `U` clears the flag. + // These are the keys every culling tool uses, and muscle memory + // built elsewhere is worth more here than any improvement. + // + // Zero-height rather than wrapping the grid: a FocusScope in this + // layout would claim a slot and push the grid up, and one *around* + // the Flickable competes with it for the arrow keys. This holds + // focus and forwards nothing else. + // + // Applies to the **selection**, not to a cell under the pointer — + // that is what makes rating forty frames a single keystroke, and it + // matches what the header's count says is selected. + judge-keys := FocusScope { + height: 0px; + // The grid is the primary surface of this screen, so it takes + // focus on show rather than waiting for a click. Without this + // the first keystroke of a culling session is swallowed. + init => { self.focus(); } + + key-pressed(event) => { + if (event.text == "0") { root.judged(0, -1); return accept; } + if (event.text == "1") { root.judged(1, -1); return accept; } + if (event.text == "2") { root.judged(2, -1); return accept; } + if (event.text == "3") { root.judged(3, -1); return accept; } + if (event.text == "4") { root.judged(4, -1); return accept; } + if (event.text == "5") { root.judged(5, -1); return accept; } + // Case-insensitive: caps lock during a long cull must not + // silently stop the keys working. + if (event.text == "p" || event.text == "P") { + root.judged(-1, 1); + return accept; + } + if (event.text == "x" || event.text == "X") { + root.judged(-1, 2); + return accept; + } + if (event.text == "u" || event.text == "U") { + root.judged(-1, 0); + return accept; + } + // Delete moves the selection to the trash folder on the + // server. Unlike every other key here it is not metadata — + // it relocates files — but it is also the key every file + // manager binds to exactly this, and the operation is + // reversible from the trash view. + if (event.text == Key.Delete || event.text == Key.Backspace) { + root.trash-selection(); + return accept; + } + // `F2` renames the collection the grid is scoped to — the + // rename key everywhere else, and the reason it is bound + // here is that this scope is what holds focus in library + // mode. Rust ignores it when nothing is scoped. + if (event.text == Key.F2) { + root.rename-scope(); + return accept; + } + return reject; + } + } + + // --- the grid ----------------------------------------------------- + // + // `interactive` stays true: `DragArea` and `Flickable` arbitrate + // properly, so dragging a cell drags the cell and dragging the + // background still flicks the grid. (This is the part a hand-rolled + // TouchArea gesture could not do — see the drag comments above.) + if root.total > 0: grid-scroll := Flickable { + // Follow a requested position. Without this a scrub moves the + // *loaded window* while the viewport stays where it was, so + // the cells are drawn thousands of rows away and the grid + // looks empty until the user scrolls to find them. + property <int> token: root.scroll-token; + changed token => { + self.viewport-y = -min( + max(0px, self.viewport-height - self.height), + floor(root.scroll-to / max(1, root.columns)) + * (root.cell-size + Theme.gap)); + } + + // Sized to the **whole library**, not the loaded window. The + // scrollbar has to represent 23,971 images or there is no way to + // reach image 20,000 — dragging it must be a real address, and the + // window is swapped underneath to match. + viewport-height: root.total-rows * (root.cell-size + Theme.gap) + Theme.gap; + + // Report the first fully-scrolled-past row so Rust can move the + // window. Derived rather than eventful: Slint has no scroll + // callback, and a `changed` handler on a derived integer fires only + // when the row actually changes rather than on every pixel. + property <int> first-visible-row: max(0, + floor((-self.viewport-y - Theme.gap) / (root.cell-size + Theme.gap))); + changed first-visible-row => { + root.scrolled(self.first-visible-row * root.columns); + } + + // Month headings, drawn over the grid at the row where each + // period begins. A separate pass rather than part of the cell, + // because the heading spans the full width and a cell does not. + for cell[i] in root.cells: Text { + x: Theme.gap; + // Sits in the gap above its row, so it labels the row + // rather than displacing it. + y: Theme.gap + + floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap) + - 15px; + width: parent.width - 2 * Theme.gap; + text: cell.period-heading; + color: Theme.ink-dim; + font-size: Theme.text-sm; + font-weight: 700; + visible: cell.period-heading != ""; + } + + for cell[i] in root.cells: DragArea { + // Cells are positioned at their **absolute** place in the + // library, not their index in the loaded window: the window + // starts at `offset`, so a cell drawn at window-index 0 belongs + // wherever `offset` sits in the full grid. + x: Theme.gap + mod(i + root.offset, root.columns) * (root.cell-size + Theme.gap); + y: Theme.gap + floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap); + width: root.cell-size; + height: root.cell-size; + + // Copy, not move: dropping into a collection files the + // photograph there without taking it out of anywhere else. That + // is what a join table means, and it is why the modifier-free + // gesture must not be `move`. + allow-copy: true; + data: root.drag-payload(); + // What travels under the cursor is the photograph itself — and + // where several are being dragged, a stack of them. Composited + // in Rust (`drag_image`), because Slint takes a single bitmap + // here and cannot render a pile of thumbnails into one. + // + // Read on `dragging` rather than bound continuously: the + // composite costs a copy per thumbnail, and the grid must not + // pay it per cell per frame. + drag-image: root.drag-image; + + changed dragging => { + if (self.dragging) { + root.drag-started(i); + } + } + drag-finished(action) => { root.drag-finished(); } + + Rectangle { + // Lifted cells shrink toward their own centre, as though pulled + // off the page. Inset rather than scaled: Slint has no transform + // on a plain Rectangle, and insetting keeps the cell's slot in + // the grid so nothing reflows mid-drag. + x: cell.lifted ? 10px : 0px; + y: cell.lifted ? 10px : 0px; + width: parent.width - 2 * self.x; + height: parent.height - 2 * self.y; + animate x, y, width, height { duration: 120ms; easing: ease-out; } + + background: cell.selected ? Theme.selected : Theme.surface; + border-radius: Theme.radius; + // Selection outranks hover: a selected cell must stay legible + // once the pointer has moved on to the collection it is being + // dragged toward. + border-width: cell.selected ? 2px : (cell-touch.has-hover ? 1px : 0px); + border-color: Theme.selected-ring; + clip: true; + + VerticalLayout { + padding: 6px; + spacing: 4px; + + Rectangle { + vertical-stretch: 1; + background: Theme.ground; + + Image { + width: 100%; + height: 100%; + source: cell.thumbnail; + image-fit: contain; + visible: cell.has-thumb; + // Faded while lifted, so the grid reads as the place + // the photograph came *from* and the cursor as where + // it is now. `colorize` would flatten it to one + // tint, which loses the picture; dropping opacity + // toward the ground keeps it recognisable as a ghost + // of itself. + // A rejected frame is held back rather than + // hidden: the cull is reversible, and a photo + // that vanished on one keypress would make the + // gesture frightening to use. Dimming is the + // cue that reads at grid scale — the ✗ glyph + // confirms it up close. + opacity: cell.lifted ? 0.25 + : (cell.flag == 2 ? 0.4 : 1.0); + animate opacity { duration: 120ms; } + } + + if !cell.has-thumb: Caption { + text: cell.unavailable ? "no preview" : "…"; + horizontal-alignment: center; + } + + // "Already filed, in this many collections." Without it + // there is no way to tell a filed photograph from an + // unfiled one, and the user re-files what is already + // in place. + if cell.collection-count > 0: Rectangle { + x: parent.width - self.width - 4px; + y: 4px; + width: 16px; + height: 16px; + border-radius: 8px; + background: Theme.selected; + opacity: 0.9; + + Text { + text: cell.collection-count; + color: Theme.ink; + font-size: 9px; + font-weight: 700; + width: 100%; + height: 100%; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + // Pick or reject, top left — the opposite corner + // from the collection badge so the two never + // collide on a cell that carries both. + FlagMark { + x: 4px; + y: 4px; + flag: cell.flag; + } + + } + + Label { + text: cell.name; + emphasised: cell.selected; + overflow: elide; + } + } + + // Selection only. The drag is the enclosing `DragArea`'s + // business, and Slint keeps a click distinct from a drag for + // us — which is exactly the arbitration the hand-rolled version + // had to fake with a travel threshold. + cell-touch := TouchArea { + mouse-cursor: pointer; + + // Selected on *press*, not on release: the drag that may + // follow reads the selection to build its payload, and by + // release the pointer is over the sidebar. + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down) { + root.cell-pressed( + i, + ev.modifiers.control, + ev.modifiers.shift, + ); + } + } + + // A *plain* click opens the image; a modified one is purely + // a selection gesture and must not navigate away from the + // grid the user is building a selection in. The modifier + // state is not carried on `clicked`, so the press above + // records it and Rust decides — `cell-clicked` is only + // honoured when the press was unmodified. + clicked => { root.cell-clicked(i); } + } + + // --- the rating strip, ABOVE the cell's own hit area --- + // + // **Declared after `cell-touch` on purpose.** Slint hit-tests + // later siblings first, so a strip nested inside the layout + // above was underneath the cell-wide TouchArea: the click set + // a rating *and* fell through to `cell-clicked`, which threw + // the user into develop on every star press. Z-order is the + // whole fix — there is no "handled" flag to set, and adding a + // travel threshold or a timer would be faking arbitration + // Slint already does correctly once the order is right. + // + // It sits over the foot of the thumbnail rather than below it: + // the caption row is spoken for by the filename, and a third + // row would cost thumbnail height on every cell to show + // something that is usually empty. + StarStrip { + x: (parent.width - self.width) / 2; + // Clear of the caption, which is the cell's last row. + y: parent.height - self.height - 26px; + rating: cell.rating; + // Empty stars appear once the pointer is over the cell, + // so there is something to aim at without filling the + // grid with chrome. On touch there is no hover, so the + // strip is always present where a rating exists — and a + // long-press is not needed to discover it. + show-empty: cell-touch.has-hover; + can-trash: !root.viewing-trash; + rate(n) => { root.cell-rated(i, n); } + trash() => { root.cell-trashed(i); } + } + } + } + } + + } + } + + } +} diff --git a/ui/dr-ui/ui/theme.slint b/ui/dr-ui/ui/theme.slint deleted file mode 100644 index e965d2e..0000000 --- a/ui/dr-ui/ui/theme.slint +++ /dev/null @@ -1,32 +0,0 @@ -// Darkroom safelight palette. Warm neutrals, single red accent. -// Committed to a dark ground — this is a photo editor, and a light UI -// surrounding an image biases how that image is judged. - -export global Theme { - out property <color> ground: #14120F; - out property <color> surface: #1D1A16; - out property <color> surface-raised: #262119; - out property <color> rule: #332C24; - - out property <color> ink: #F0EAE0; - out property <color> ink-dim: #A79E91; - out property <color> ink-faint: #7C7367; - - out property <color> accent: #D9543C; - out property <color> accent-dim: #8F2E1E; - - // Semantic, distinct from the accent: a caution is not an action. - out property <color> warn-ink: #C99A4A; - - out property <length> gap-sm: 6px; - out property <length> gap: 12px; - out property <length> gap-lg: 20px; - - out property <length> text-sm: 11px; - out property <length> text: 13px; - out property <length> text-lg: 17px; - out property <length> text-xl: 24px; - - // FR-UI-3: minimum 44pt hit target under touch. - out property <length> touch-target: 44px; -} diff --git a/ui/dr-ui/ui/widgets.slint b/ui/dr-ui/ui/widgets.slint new file mode 100644 index 0000000..8310746 --- /dev/null +++ b/ui/dr-ui/ui/widgets.slint @@ -0,0 +1,600 @@ +// Shared chrome primitives and the style layer. +// +// Before this file every button was a Rectangle + TouchArea written out where +// it was needed, at 64×20, 110×28 and 88×28 with three near-identical +// hover/press treatments. Any consistency was coincidental. These are the +// pieces that make it deliberate; nothing here draws a colour literal. +// +// **The rule this file establishes.** Screen files consume components; raw +// `Theme.*` is for *composing* a component, not for styling a call site. A +// bare `Theme.ink-faint` or a font-size in `app.slint` means a component is +// missing, not that a screen needs an exception. `theme.slint` says what +// `surface` is; only this file says what a *panel heading* is — and until it +// did, four screens each re-derived one, which is exactly how a single accent +// colour reached forty call sites with no single place to change it. + +import { Theme } from "theme.slint"; + +// A text button. +// +// **The drawn box and the hit target are separate.** FR-UI-3 asks for a 44pt +// minimum target under touch, but a 44px-tall button in a 44px-tall grid +// header leaves no room for the header, and the chrome would grow to meet a +// requirement that is about the *finger*, not the ink. So the rectangle is +// `Theme.control-height` and the TouchArea is grown to `Theme.touch-target` +// and centred over it, exactly as `FormatCheck` in launch.slint does. Callers +// laying these out horizontally get the compact size they expect; a thumb +// still gets 44px. +// +// The overhang is deliberately allowed to spill outside the parent's bounds. +// It only matters when two buttons sit within 8px vertically of each other, +// which no current layout does — buttons live in single rows. +export component Button inherits Rectangle { + in property <string> text; + in property <bool> enabled: true; + /// The one affirmative action in a group. At most one per group, or the + /// emphasis stops meaning anything (see the theme preamble). + in property <bool> primary: false; + /// Sustained state — a toggle that is currently on, not a press. The same + /// meaning [`IconButton`] gives it, so a labelled toggle and a glyph + /// toggle read alike. + in property <bool> active: false; + + callback clicked(); + + height: Theme.control-height; + // A minimum rather than a fixed width: callers that set `width` or hand + // this to a stretching layout still win, and a long label is not clipped. + min-width: Theme.control-min-width; + horizontal-stretch: 0; + + border-radius: Theme.radius; + border-width: root.primary ? 0px : 1px; + border-color: root.active ? Theme.active : Theme.rule; + + // A primary button is filled rather than outlined, and with no hue left to + // fill it with the fill is near-white — so it moves the opposite way to a + // secondary button: hover *brightens* toward `active` and press sinks to + // `active-pressed`, where the neutral variant lifts from `surface-raised`. + background: root.primary + ? (touch.pressed ? Theme.active-pressed + : (touch.has-hover ? Theme.active : Theme.active-dim)) + : (touch.pressed ? Theme.pressed + : (touch.has-hover ? Theme.hover : Theme.surface-raised)); + + // Disabled reads as "not now", not as a second kind of button — the shape + // stays and only the contrast drops. + opacity: root.enabled ? 1.0 : 0.45; + + touch := TouchArea { + enabled: root.enabled; + // Explicit geometry: a TouchArea with none collapses to zero and only + // catches the events that happen to land on it. + width: 100%; + height: max(parent.height, Theme.touch-target); + y: (parent.height - self.height) / 2; + mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default; + clicked => { root.clicked(); } + } + + HorizontalLayout { + padding-left: Theme.gap; + padding-right: Theme.gap; + + Text { + text: root.text; + // Dark on the primary fill: that fill is now near-white, and the + // white label this carried when the fill was a saturated red is + // unreadable against it. + color: root.primary ? Theme.ground + : (root.active ? Theme.active : Theme.ink); + font-size: Theme.text-sm; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + overflow: elide; + } + } +} + +// A square button carrying a single glyph. +// +// Square because it has no label to size against: a toolbar affordance whose +// width tracked its glyph would jitter as the glyph changed. +export component IconButton inherits Rectangle { + in property <string> glyph; + in property <bool> enabled: true; + /// Sustained state — a toggle that is currently on, not a press. + in property <bool> active: false; + + callback clicked(); + + width: Theme.control-height; + height: Theme.control-height; + horizontal-stretch: 0; + + border-radius: Theme.radius; + border-width: 1px; + border-color: root.active ? Theme.active : Theme.rule; + + background: touch.pressed ? Theme.pressed + : (touch.has-hover ? Theme.hover : Theme.surface-raised); + opacity: root.enabled ? 1.0 : 0.45; + + touch := TouchArea { + enabled: root.enabled; + 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.enabled ? MouseCursor.pointer : MouseCursor.default; + clicked => { root.clicked(); } + } + + Text { + text: root.glyph; + color: root.active ? Theme.active : Theme.ink; + font-size: Theme.text; + horizontal-alignment: center; + vertical-alignment: center; + width: 100%; + height: 100%; + } +} + +// A toggle in a row of toggles: one term of a filter. +// +// Distinct from `Button` because it is *state*, not an action — it stays on +// after the click, and a row of them says what the grid is currently showing. +// A `Button` that happened to be styled differently would drift the moment +// either changed. +// +// **Active reads as filled, not merely outlined.** These sit in a row where +// several look alike, so an inactive-versus-active difference carried by a +// border alone is invisible at a glance across six chips. The active one +// inverts — near-white fill, dark text — which is the same treatment +// `Button.primary` uses for "this is the one". +// +// **The count is optional and never fabricated.** `-1` means "not known yet", +// which is different from zero: a filter with no images behind it should say +// `0` so the user knows narrowing to it will empty the grid, but one whose +// count has not been computed must not claim zero. +export component FilterChip inherits Rectangle { + in property <string> label; + in property <bool> active: false; + /// Images behind this term, or -1 where the count is not known. + in property <int> count: -1; + + callback clicked(); + + height: Theme.control-height - 4px; + // A floor on a content-sized chip, expressed as one property: Slint rejects + // `width` and `min-width` together, and the floor is what keeps a chip + // labelled "3" from being a sliver too small to hit. + width: max(34px, row.preferred-width + 2 * Theme.gap-sm); + horizontal-stretch: 0; + + border-radius: Theme.radius; + border-width: 1px; + border-color: root.active ? Theme.active : Theme.rule; + background: root.active + ? (touch.pressed ? Theme.active-pressed : Theme.active-dim) + : (touch.pressed ? Theme.pressed + : (touch.has-hover ? Theme.hover : Theme.surface-raised)); + + touch := TouchArea { + width: 100%; + height: max(parent.height, Theme.touch-target); + y: (parent.height - self.height) / 2; + mouse-cursor: pointer; + clicked => { root.clicked(); } + } + + row := HorizontalLayout { + padding-left: Theme.gap-sm; + padding-right: Theme.gap-sm; + spacing: 4px; + + Text { + text: root.label; + // Dark on the active fill, which is near-white — the same + // inversion `Button.primary` makes for the same reason. + color: root.active ? Theme.ground : Theme.ink; + font-size: Theme.text-sm; + font-weight: root.active ? 700 : 500; + vertical-alignment: center; + } + + Text { + text: root.count >= 0 ? root.count : ""; + // Dimmer than the label on both grounds: the count is supporting + // detail, and a chip whose number shouted louder than its name + // would read as a number with a caption. + color: root.active ? Theme.ground : Theme.ink-faint; + opacity: root.active ? 0.7 : 1.0; + font-size: Theme.text-sm; + vertical-alignment: center; + } + } +} + +// The arrow beside a row that opens into something: a disclosure triangle on +// a section, an "into this folder" marker in the picker. +// +// **Fixed width, and that is the whole point.** The glyphs differ in advance +// width, so a row that sized to its own arrow would shift its label sideways +// as it opened and closed — the one movement that makes a static list look +// like it is being redrawn. Rotating a single glyph would need a transform on +// a Text; two characters render identically and cost nothing. +export component Disclosure inherits Text { + color: Theme.ink-faint; + font-size: Theme.text-sm; + vertical-alignment: center; + horizontal-alignment: center; + width: 14px; +} + +// A collapsible group with a header that reports whether anything inside has +// been touched. +// +// `expanded` is in-out so a caller can key collapse state by something stable +// (an operation index, never a label) and drive it from outside; left alone it +// works standalone as a self-toggling disclosure. +// +// **Collapsing is fiddlier than it looks.** `@children` cannot appear inside +// a conditional element — Slint rejects it outright — so the body cannot be +// dropped from the tree with `if root.expanded`. And `visible: false` alone +// only hides the ink: the element keeps its layout slot, so a stack of +// collapsed sections would be a column of gaps. +// +// So the body is a plain Rectangle that is both hidden *and* clamped to zero +// height when collapsed, with `clip: true` so children taller than the clamp +// cannot paint outside it. The clamp reads `body-inner.preferred-height`, +// which is a *preferred* size — an input to layout, never a result of it — +// so `expanded` feeding the height does not loop back. +export component Section inherits Rectangle { + in property <string> title; + /// Anything inside differs from its default. The caller computes this — + /// the section cannot see into `@children`. + in property <bool> modified: false; + in-out property <bool> expanded: true; + + /// Fired after `expanded` has already been flipped, for callers that + /// persist the state rather than letting this component own it. + callback toggled(bool); + + /// Undo everything inside. The affordance only appears once `modified` is + /// true — a reset on an untouched group is a control that cannot do + /// anything, and a header carrying one permanently is a header that reads + /// as busy rather than as a name. + /// + /// Sections whose contents have nothing to undo simply leave this + /// unconnected, and `has-reset` off. + callback op-reset(); + /// Whether this section's contents can be reset at all. + in property <bool> has-reset: true; + + background: transparent; + // Own height comes from the layout below, so a collapsed section shrinks + // to its header. + height: body.preferred-height; + + body := VerticalLayout { + spacing: 0px; + alignment: start; + + header := Rectangle { + height: Theme.control-height; + background: header-touch.pressed ? Theme.pressed + : (header-touch.has-hover ? Theme.hover : transparent); + border-radius: Theme.radius-sm; + + header-touch := TouchArea { + width: 100%; + height: max(parent.height, Theme.touch-target); + y: (parent.height - self.height) / 2; + mouse-cursor: pointer; + clicked => { + root.expanded = !root.expanded; + root.toggled(root.expanded); + } + } + + HorizontalLayout { + padding-left: Theme.gap-sm; + padding-right: Theme.gap-sm; + spacing: Theme.gap-sm; + + Disclosure { text: root.expanded ? "▾" : "▸"; } + + Text { + text: root.title; + color: header-touch.has-hover ? Theme.ink : Theme.ink-dim; + font-size: Theme.text-sm; + font-weight: 700; + letter-spacing: 0.8px; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + + // The modified dot: the one thing that survives collapsing, + // so a closed section still says whether it holds an edit. + Rectangle { + width: 6px; + height: 6px; + y: (parent.height - self.height) / 2; + border-radius: 3px; + background: Theme.modified; + visible: root.modified; + } + + // The group's reset. Shown only when there is something to + // undo *and* the pointer is on the header, so a panel at rest + // is a column of names rather than a column of buttons. + // + // It declares its width whether or not it is visible: a + // control that appeared on hover and *also* widened the row + // would shift the title sideways under the pointer, which + // reads as the panel flinching away from the cursor. + Rectangle { + width: 28px; + + reset-touch := TouchArea { + // Sits after `header-touch` in the tree, so it takes + // the press first and the section does not toggle out + // from under a reset. + width: 100%; + height: max(parent.height, Theme.touch-target); + y: (parent.height - self.height) / 2; + enabled: root.has-reset && root.modified; + mouse-cursor: pointer; + clicked => { root.op-reset(); } + } + + Text { + text: "reset"; + color: reset-touch.has-hover ? Theme.ink : Theme.ink-faint; + font-size: Theme.text-sm; + vertical-alignment: center; + horizontal-alignment: right; + width: 100%; + height: 100%; + visible: root.has-reset && root.modified + && (header-touch.has-hover || reset-touch.has-hover); + } + } + } + } + + Rectangle { + // Collapsed by height plus clip, deliberately *not* by `visible`: + // Slint treats a visibility-guarded element as conditional, and + // `@children` cannot appear inside one. Zero height with clipping + // hides the body just as completely. + height: root.expanded ? body-inner.preferred-height : 0px; + clip: true; + + body-inner := VerticalLayout { + spacing: 0px; + alignment: start; + @children + } + } + } +} + +// --- the style layer --------------------------------------------------- +// +// Text roles. Four components rather than one with a `role` enum, because a +// role is chosen once at the call site and never switched at runtime — an +// enum would buy nothing and cost a qualified name at every use. + +// The name of a panel or a form section: `IMAGE`, `ADJUST`, `SERVER`. +// +// Caps-with-tracking rather than a larger size: these sit directly above the +// content they name, in a column only 280px wide, and a heading that grew the +// row would push the photograph over for the sake of a label. Tracking does +// the same separating work in the same height. +// +// `ink-faint` rather than the accent these all carried. A heading is a label, +// not a state — it is true whatever the panel is doing, so it has no business +// competing with the slider that *is* doing something. It is also read once +// and then skipped, which is what the faintest ink is for. +export component PanelHeading inherits Text { + /// A heading *inside* a panel that already has one — an operation group + /// under `ADJUST`. Tighter tracking, so the two levels are distinguishable + /// where they stack without either needing a second colour or size. + in property <bool> sub: false; + + color: Theme.ink-faint; + font-size: Theme.text-sm; + font-weight: 700; + letter-spacing: root.sub ? 0.8px : 1.2px; + vertical-alignment: center; +} + +// The name of a thing whose value sits beside it: a parameter name, a form +// field's caption. Dimmer than its value on purpose — the label is constant +// and the value is what changed. +export component Label inherits Text { + /// Lit, for a label under the pointer or one whose value has moved off its + /// default. The caller supplies the condition; this only decides what + /// "lit" looks like. + in property <bool> emphasised: false; + /// Body size rather than the chrome's `text-sm`. For a label the user is + /// reading rather than scanning past — a row in a picker, a tick-box in a + /// form — where the panel is a page rather than an instrument. + in property <bool> body: false; + + color: root.emphasised ? Theme.ink : Theme.ink-dim; + font-size: root.body ? Theme.text : Theme.text-sm; + vertical-alignment: center; +} + +// A datum: a camera name, a file path, a slider's readout. +// +// **`modified` is the reason this is a component.** A value differing from its +// default is the single thing a photographer scans a panel for, and with hue +// gone from the palette the only signal left is luminance — so the gap has to +// be large and it has to be identical everywhere, or it stops reading as a +// signal at all and becomes texture. One definition, one gap. +export component Value inherits Text { + /// Differs from its default. + in property <bool> modified: false; + /// No value yet — a placeholder standing in for one, not a value that + /// happens to be empty. + in property <bool> placeholder: false; + /// The compact readout that sits on a control's own row, rather than a + /// datum on a line of its own. + in property <bool> compact: false; + + color: root.modified ? Theme.modified + : (root.placeholder ? Theme.ink-faint : Theme.ink); + font-size: root.compact ? Theme.text-sm : Theme.text; + vertical-alignment: center; +} + +// Supporting text: a hint under a field, a count beside a title, an empty +// state's second line. The faintest ink, because it is there for the reader +// who stopped to look and should not catch the eye of the one who did not. +export component Caption inherits Text { + /// A caution. The one place hue survives in the chrome — a warning is a + /// different kind of thing from an active state, and saying so instantly + /// is worth the exception (see the theme preamble). + in property <bool> warn: false; + /// Lit, for supporting text the pointer is currently over. Mirrors + /// `Label.emphasised` from one step further down, so the two roles brighten + /// to the same ink and a hover reads identically wherever it lands. + in property <bool> emphasised: false; + + color: root.warn ? Theme.warn-ink + : (root.emphasised ? Theme.ink : Theme.ink-faint); + font-size: Theme.text-sm; + vertical-alignment: center; +} + +// A region of surface holding a **column** of controls. +// +// Two shapes, because the call sites are two shapes. An inset box on the +// launch screen is bordered and rounded — it sits on the ground with air +// around it and needs its own edge. A panel in the develop column is `flat`: +// it abuts its neighbours, so the divider between them belongs to the column +// that stacks them, and a border here would double up with it. +// +// **Not every bordered box is a Panel.** The folder picker's list is the same +// surface and rule but overlays three mutually exclusive states — loading, the +// list, "nothing here" — each filling the box. This stacks its children, so it +// would lay those three out in a row; that site draws its own Rectangle and +// says why. A component that covered both would need a bool selecting between +// a layout and an overlay, which is two components wearing one name. +// +// `@children` goes in a plain VerticalLayout for the same reason `Section`'s +// body does: Slint rejects `@children` inside anything conditional, so the +// two shapes differ only in properties, never in structure. +export component Panel inherits Rectangle { + /// Abuts its neighbours: no border, no radius. The stacking parent draws + /// the dividing rule. + in property <bool> flat: false; + in property <length> spacing: Theme.gap-sm; + /// Named `inset` rather than `padding`: a Rectangle already reserves + /// `padding` for the layout it may contain, and redeclaring it is a + /// compile error rather than an override. + in property <length> inset: Theme.gap; + + background: Theme.surface; + border-radius: root.flat ? 0px : Theme.radius; + border-width: root.flat ? 0px : 1px; + border-color: Theme.rule; + + VerticalLayout { + padding: root.inset; + spacing: root.spacing; + alignment: start; + @children + } +} + +// A single-line text entry. +// +// **The placeholder is a sibling Text, not a property.** Slint's `TextInput` +// has none of its own, and the alternative — seeding `text` and clearing it on +// focus — loses whatever the user typed if focus arrives before a keystroke. +// A Text underneath, hidden the moment anything is entered, cannot. +// +// The focus border is `active`: focus is a live state of the control, the one +// place in a form where something is *engaged*, which is precisely what that +// token is for. +export component Field inherits Rectangle { + in-out property <string> text; + in property <string> placeholder; + /// Whether the entry currently holds focus, so a caller can enable its + /// submit button from the same fact the border is drawn from. + out property <bool> has-focus: input.has-focus; + + callback accepted(string); + + height: Theme.touch-target; + border-radius: Theme.radius; + border-width: 1px; + border-color: input.has-focus ? Theme.active : Theme.rule; + background: Theme.surface; + + input := TextInput { + text <=> root.text; + color: Theme.ink; + font-size: Theme.text; + vertical-alignment: center; + // Inset by hand rather than by a layout: a TextInput inside a + // HorizontalLayout is sized by the layout and stops scrolling its own + // content once the text is longer than the box. + x: Theme.gap; + width: parent.width - 2 * Theme.gap; + height: 100%; + single-line: true; + accepted => { root.accepted(self.text); } + } + + Text { + text: root.placeholder; + color: Theme.ink-faint; + font-size: Theme.text; + vertical-alignment: center; + x: Theme.gap; + height: 100%; + visible: input.text == ""; + } +} + +// What a view says when it has nothing to show. +// +// Not in the S3 brief, but `app.slint` and `library.slint` had the same two +// centred lines — a `text-lg` headline over a `text-sm` explanation — and the +// distinction they draw is the load-bearing one: "still working" and "finished +// and found nothing" are different answers, and a view that conflates them +// makes a working scan look broken. One component, so neither view can drift +// into answering only half of it. +// +// The headline is the only place `text-lg` appears outside a masthead, which +// is why it is here rather than as a `Label` variant: it is a size this file +// otherwise does not hand out. +export component EmptyState inherits VerticalLayout { + in property <string> headline; + in property <string> detail; + + alignment: center; + spacing: Theme.gap; + + Text { + text: root.headline; + color: Theme.ink-dim; + font-size: Theme.text-lg; + horizontal-alignment: center; + } + + Caption { + text: root.detail; + horizontal-alignment: center; + wrap: word-wrap; + } +}