Files
DarkRoom/ui/dr-ui/src/library_ui.rs
T
dtourolle 8ad5c86ff9 Add the library, collections, and trash views; theme from style.yaml
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
2026-08-09 21:11:38 +02:00

1977 lines
76 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<RefCell<Option<Catalog>>>,
/// Remote paths for the rows currently in the model, parallel to it.
paths: RefCell<Vec<String>>,
/// `oc:fileid` and file length per row, parallel to the model.
file_ids: RefCell<Vec<Option<u64>>>,
sizes: RefCell<Vec<u64>>,
/// Catalog row ids and whether each still needs its EXIF read.
image_ids: RefCell<Vec<i64>>,
needs_metadata: RefCell<Vec<bool>>,
/// Where in the catalog the current window starts. Scrubbing moves this.
offset: RefCell<usize>,
/// 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<usize>,
/// Which rows already have a thumbnail fetch issued, so scrolling back
/// does not refetch what is already on screen.
requested: RefCell<std::collections::HashSet<usize>>,
scan_timer: RefCell<Option<slint::Timer>>,
thumb_timer: RefCell<Option<slint::Timer>>,
/// The whole-library sweep, which outlives any one grid window.
sweep_timer: RefCell<Option<slint::Timer>>,
/// Pushing shards and the catalog to the server.
sync_timer: RefCell<Option<slint::Timer>>,
/// Kept so a rescan can run without going back through the launch screen.
session: RefCell<Option<(AppCredentials, Session, FormatFilter)>>,
/// 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<Option<dr_types::CollectionId>>,
/// 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<bool>,
/// 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<i32>,
timeline_centre: RefCell<Option<i64>>,
/// 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<Option<i64>>,
/// 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<library::RatingFilter>,
/// Drains the sidecar writer. Held so a second judgement replaces the
/// timer rather than leaving two draining the same finished channel.
sidecar_timer: RefCell<Option<slint::Timer>>,
/// 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<u64>,
}
impl LibraryController {
pub fn new() -> Rc<Self> {
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<RefCell<Option<Catalog>>> {
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<dr_types::ImageId> {
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<dr_types::CollectionId>) {
*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<LibraryController>) {
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<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
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() {
"<account root>"
} 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<AppWindow>,
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
rx: Receiver<ScanMessage>,
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<LibraryController>) {
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<String> = 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<LibraryCell> = 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<dr_types::ImageId> = 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<i32> = 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<LibraryController>,
images: &[dr_types::ImageId],
rating: Option<u8>,
flag: Option<dr_types::FlagState>,
) {
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<u8>,
flag: Option<dr_types::FlagState>,
) -> 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<library::JudgementWrite> {
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.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<rusqlite::types::Value> = 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<LibraryController>,
writes: Vec<library::JudgementWrite>,
) {
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<LibraryController>) {
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
return;
};
let wanted: Vec<library::ThumbnailRequest> = {
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<AppWindow>,
ctl: Rc<LibraryController>,
rx: Receiver<ThumbnailMessage>,
) {
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<LibraryController>) {
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<LibraryController>) {
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<LibraryController>) {
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<TimelineBar> = 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, 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<i64>>(0)?, r.get::<_, Option<i64>>(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<LibraryController>, 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::<slint::Rgba8Pixel>::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<F>(
window: &AppWindow,
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
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<LibraryController>) {
*ctl.offset.borrow_mut() = 0;
ctl.requested.borrow_mut().clear();
load_window(window, ctl);
}
fn stop(slot: &RefCell<Option<slint::Timer>>) {
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<u64> = (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]);
}
}