//! 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]); } }