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
1977 lines
76 KiB
Rust
1977 lines
76 KiB
Rust
//! 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]);
|
||
}
|
||
}
|