Split library_ui.rs into a module directory by area of behaviour
controller holds LibraryController and the window-sizing constants every other module reads and writes through pub(super) fields, the same shape collections_ui and develop already use. open is the launch-to-scan cycle and the worker that checks the catalog file before either touches it. offline is what of a collection is on this device and the prompt that offers to change it. window fills the grid model from the catalog and drains the thumbnail fetch, which is the piece the catalog-reads-are-proportional-to-what-changed rule (docs/catalog.md §1) bears on most directly. sync is the background passes that reach beyond the loaded window: the metadata sweep, the whole-library thumbnail pass, and the exchange with the server. ratings_keywords applies a judgement or a keyword to a selection and queues the sidecar and XMP writes behind it. timeline is the capture-time sidebar and the photographer's place together, kept in one file because a restored place ends by moving the timeline marker and a scrub is a restore of one instant, so most calls between the two would otherwise cross a module boundary. grid wires the grid's own callbacks — the keyboard cursor, cell-size zoom, the routes into and out of develop — and filter_bar wires the rating, people, date and offline-scope filters, calling back into whichever of the above owns the work a filter change triggers. Extracted by item rather than by line range, so every doc comment and TRACES/GESTURE annotation stayed attached to the code it describes; the sorted set of TRACES/GESTURE lines in the new directory is identical to the original file's. Tests moved with the code they exercise, including the handful of fixtures — settle, model_of, with_catalog, zoom_cell, pinch_step — that only one target module needed and so were not worth sharing through a test_support module the way the other splits use one. Items that crossed a new module boundary were widened from private to pub(super), narrower than the whole-file access the original gave them; a few items already pub(crate) for recovery_ui or presets stayed there rather than being narrowed, since nothing needed them tightened further. mod.rs re-exports the same surface library_ui:: callers used before, so lib.rs and every other caller needed no change.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,738 @@
|
||||
//! The filter bar: ratings, flags, people, dates and the offline scope, and
|
||||
//! reloading the grid once one of them changes.
|
||||
//!
|
||||
//! Each callback narrows or widens [`LibraryController`]'s
|
||||
//! `RatingFilter` and reloads the window through [`refilter`], which is the
|
||||
//! one place the offset reset and the place record both live — narrowing a
|
||||
//! filter is choosing a different set of images, so a position counted
|
||||
//! against the old set means nothing. See `docs/dev/code-health.md` CH-1.
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::library;
|
||||
use crate::{AppWindow, PersonChip, View};
|
||||
|
||||
use super::controller::LibraryController;
|
||||
use super::offline::{
|
||||
close_offline_prompt, keep_collection_offline, open_offline_prompt, release_collection_offline,
|
||||
};
|
||||
use super::open::start_rescan;
|
||||
use super::timeline::{catalog_span, dragged_range, month_name, note_place, zoomed_span};
|
||||
use super::window::load_window;
|
||||
|
||||
/// --- 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.
|
||||
pub(super) fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
{
|
||||
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_identity_show_photos(move |id, add| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let person = dr_catalog::faces::PersonId(id.max(0) as u64);
|
||||
|
||||
{
|
||||
let mut f = ctl.filter.borrow_mut();
|
||||
if !add {
|
||||
f.people.clear();
|
||||
}
|
||||
// Adding the same person twice is a no-op rather than a
|
||||
// duplicate chip — and, under `All`, rather than a filter that
|
||||
// silently asks for them twice and matches the same pictures.
|
||||
if !f.people.contains(&person.0) {
|
||||
f.people.push(person.0);
|
||||
}
|
||||
}
|
||||
push_people_chips(&w, &ctl);
|
||||
|
||||
// Leaving the Identity screen for the grid is the whole point of
|
||||
// the button: the answer to "who is this" is a set of photographs,
|
||||
// and they are shown where photographs are shown.
|
||||
w.set_active_view(View::Library);
|
||||
refilter(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_filter_person_cleared(move |id| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
ctl.filter
|
||||
.borrow_mut()
|
||||
.people
|
||||
.retain(|p| *p != id.max(0) as u64);
|
||||
drop_eyes_open_without_people(&w, &ctl);
|
||||
push_people_chips(&w, &ctl);
|
||||
refilter(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_people_listed(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
push_people_roster(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_filter_person_toggled(move |id| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let person = id.max(0) as u64;
|
||||
{
|
||||
let mut f = ctl.filter.borrow_mut();
|
||||
if let Some(at) = f.people.iter().position(|p| *p == person) {
|
||||
f.people.remove(at);
|
||||
} else {
|
||||
f.people.push(person);
|
||||
}
|
||||
}
|
||||
drop_eyes_open_without_people(&w, &ctl);
|
||||
push_people_chips(&w, &ctl);
|
||||
refilter(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_filter_eyes_open_toggled(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let on = {
|
||||
let mut f = ctl.filter.borrow_mut();
|
||||
f.eyes_open = !f.eyes_open;
|
||||
f.eyes_open
|
||||
};
|
||||
w.set_library_filter_eyes_open(on);
|
||||
refilter(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_filter_people_mode_toggled(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
{
|
||||
let mut f = ctl.filter.borrow_mut();
|
||||
f.people_mode = match f.people_mode {
|
||||
library::PeopleMode::Any => library::PeopleMode::All,
|
||||
library::PeopleMode::All => library::PeopleMode::Any,
|
||||
};
|
||||
}
|
||||
push_people_chips(&w, &ctl);
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The date-range filter: turned on from the timeline's own visible span,
|
||||
/// dragged along the axis, or typed as two dates.
|
||||
pub(super) fn wire_filter_dates(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// Turn the range on, over the period the histogram is showing.
|
||||
//
|
||||
// The range is taken from the timeline rather than typed into two date
|
||||
// fields: finding the fortnight is what the histogram is *for*, and having
|
||||
// found it the user should not have to read the dates off the axis and key
|
||||
// them back in.
|
||||
//
|
||||
// A starting point rather than the answer. What this puts on the axis is a
|
||||
// band with two draggable ends, and dragging them is how the range is
|
||||
// actually stated — on a phone it is the only way that does not need a
|
||||
// keyboard. Seeding it to the whole visible span means the first drag
|
||||
// narrows from what the user is already looking at.
|
||||
//
|
||||
// Toggling off clears both ends rather than remembering them — a range you
|
||||
// cannot see the extent of is a filter that looks like an empty library.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_toggle_date_range(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// Whether the *controls* are showing, which is not the same as
|
||||
// whether a range is set — and reading it from the filter was why
|
||||
// pressing this did nothing. The fields are how a range gets set,
|
||||
// so requiring one before they appear is a door locked from the
|
||||
// inside.
|
||||
let on = !w.get_library_range_active();
|
||||
if on {
|
||||
// Seeded from the span the axis is drawn over, which is what
|
||||
// the user is looking at when they ask for "this range".
|
||||
//
|
||||
// Only a seed. If there is no catalog yet, or nothing in it
|
||||
// carries a capture date — dates are read from EXIF as
|
||||
// thumbnails load, so a freshly opened library has none — the
|
||||
// fields simply open empty and wait to be typed into. This
|
||||
// used to `return` in both cases, which meant the button
|
||||
// silently did nothing on exactly the libraries where a person
|
||||
// is most likely to be looking for a date.
|
||||
let seeded = {
|
||||
let borrow = ctl.catalog.borrow();
|
||||
borrow
|
||||
.as_ref()
|
||||
.and_then(|catalog| catalog_span(catalog, &ctl))
|
||||
.map(|span| {
|
||||
zoomed_span(
|
||||
span,
|
||||
*ctl.timeline_zoom.borrow(),
|
||||
*ctl.timeline_centre.borrow(),
|
||||
)
|
||||
})
|
||||
};
|
||||
if let Some(span) = seeded {
|
||||
// Through the same days-inclusive conversion a drag uses,
|
||||
// so a seeded range and a dragged one are the same kind of
|
||||
// thing — and so `show_range` reads this one back out as
|
||||
// the two days it covers.
|
||||
let (from, to) = dragged_range(span, 0.0, 1.0);
|
||||
let mut f = ctl.filter.borrow_mut();
|
||||
f.captured_from = Some(from);
|
||||
f.captured_to = Some(to);
|
||||
}
|
||||
} else {
|
||||
let mut f = ctl.filter.borrow_mut();
|
||||
f.captured_from = None;
|
||||
f.captured_to = None;
|
||||
}
|
||||
w.set_library_range_active(on);
|
||||
show_range(&w, &ctl);
|
||||
refilter(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-6 | FR-UI-2
|
||||
// The ends of the range, dragged along the axis they were chosen from.
|
||||
//
|
||||
// This is the control the two typed fields were standing in for. On a
|
||||
// phone they could not do the job: `YYYY-MM-DD` keyed into a 108px field
|
||||
// behind a soft keyboard, to name a day already drawn on the axis a thumb
|
||||
// away. Nor was the chip a way round them — its span comes from the
|
||||
// timeline's zoom and pan, and those are a wheel and a middle button,
|
||||
// neither of which a touch screen has. So on Android the date range was a
|
||||
// filter that could be turned on and not aimed.
|
||||
//
|
||||
// The span is recomputed here rather than remembered from the last draw.
|
||||
// It is the same expression `refresh_timeline` uses, over the same
|
||||
// date-lifted extent, so a handle dropped at a fraction of the track names
|
||||
// the instant that fraction was drawn at.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_timeline_range_changed(move |a, b| {
|
||||
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, &ctl) else {
|
||||
return;
|
||||
};
|
||||
let span = zoomed_span(
|
||||
full,
|
||||
*ctl.timeline_zoom.borrow(),
|
||||
*ctl.timeline_centre.borrow(),
|
||||
);
|
||||
drop(borrow);
|
||||
|
||||
let (from, to) = dragged_range(span, a, b);
|
||||
{
|
||||
let mut f = ctl.filter.borrow_mut();
|
||||
f.captured_from = Some(from);
|
||||
f.captured_to = Some(to);
|
||||
}
|
||||
|
||||
// A dragged range cannot be mistyped, so any standing complaint
|
||||
// about the fields is now about a range that no longer exists.
|
||||
w.set_library_range_invalid(false);
|
||||
w.set_library_range_active(true);
|
||||
show_range(&w, &ctl);
|
||||
// Reloads the window, and `load_window` redraws the timeline —
|
||||
// which is what puts the band back under the finger that dropped
|
||||
// it, at the whole days it was snapped to.
|
||||
refilter(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-6
|
||||
// The ends of the range, typed.
|
||||
//
|
||||
// The chip alone used to be the whole control and it read its span from
|
||||
// the timeline's zoom, which is zero until someone zooms — so "limit to
|
||||
// range" set the range to the entire library and appeared to do nothing.
|
||||
// The span is shown now, and these two fields are how it is corrected.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_range_edited(move |from, to| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
// Both ends, or neither. A range with one end parsed and the other
|
||||
// silently dropped is a filter nobody asked for, and the grid
|
||||
// going empty is a poor way to find out a date was mistyped.
|
||||
let (Some(from), Some(to)) = (
|
||||
dr_types::parse_date(from.as_str()),
|
||||
dr_types::parse_date(to.as_str()),
|
||||
) else {
|
||||
w.set_library_range_invalid(true);
|
||||
return;
|
||||
};
|
||||
|
||||
// Typed the other way round is a slip, not an error: the user has
|
||||
// said which two days they mean and there is exactly one range
|
||||
// between them.
|
||||
let (from, to) = if from <= to { (from, to) } else { (to, from) };
|
||||
|
||||
{
|
||||
let mut f = ctl.filter.borrow_mut();
|
||||
f.captured_from = Some(from);
|
||||
// Inclusive of the last day. "To the 5th" means the whole of
|
||||
// the 5th — `parse_date` returns its midnight, and a range
|
||||
// ending there would exclude every photograph taken on the day
|
||||
// the user named. The same rule `dragged_range` applies.
|
||||
f.captured_to = Some(to + 86_400 - 1);
|
||||
}
|
||||
w.set_library_range_invalid(false);
|
||||
w.set_library_range_active(true);
|
||||
show_range(&w, &ctl);
|
||||
refilter(&w, &ctl);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the grid is scoped to what is available offline, and the
|
||||
/// prompt those controls open.
|
||||
pub(super) fn wire_filter_scope_and_offline(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
) {
|
||||
// TRACES: FR-CAT-9
|
||||
// "On this device" — the images openable without a server. Composes with
|
||||
// the rating terms rather than replacing them: "five-star frames I can
|
||||
// actually edit on this train" is one filter, not a mode.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_toggle_local_only(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let on = !ctl.local_only();
|
||||
ctl.set_local_only(on);
|
||||
ctl.filter.borrow_mut().local_only = on;
|
||||
w.set_library_local_only(on);
|
||||
refilter(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-NC-6a
|
||||
// The header's way in to the offline question, for the collection the grid
|
||||
// is scoped to. It opens the same prompt the sidebar's tray and the long
|
||||
// press open, rather than pinning outright: three affordances that did two
|
||||
// different things — one asking, two acting — is how a user comes to avoid
|
||||
// all three.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_toggle_pin_scope(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(scope) = *ctl.scope.borrow() else {
|
||||
return;
|
||||
};
|
||||
open_offline_prompt(&w, &ctl, scope);
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-NC-6a | FR-UI-4
|
||||
// The sidebar's way in: the tray on a row, tapped.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_collection_offline_menu(move |id| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
open_offline_prompt(&w, &ctl, dr_types::CollectionId(id as u64));
|
||||
});
|
||||
}
|
||||
|
||||
// The hold on a sidebar row — which arms the drag that rearranges the tree
|
||||
// and opens the row menu — is wired in `collections_ui`, with the rest of
|
||||
// what a collection row does. It used to be here because it opened the
|
||||
// offline question and nothing else; that question is now one item in that
|
||||
// menu, so the gesture belongs with the menu rather than with the transfers.
|
||||
|
||||
// The three answers.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let coll = coll_ctl.clone();
|
||||
window.on_offline_prompt_keep(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
keep_collection_offline(&w, &ctl, &coll);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let coll = coll_ctl.clone();
|
||||
window.on_offline_prompt_release(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
release_collection_offline(&w, &ctl, &coll);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_offline_prompt_dismiss(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
close_offline_prompt(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-9
|
||||
// Retry now, rather than waiting out the backoff. A user who has just
|
||||
// reconnected their wifi knows something the backoff does not.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let coll_ctl = coll_ctl.clone();
|
||||
window.on_library_retry_connection(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
log::info!("retrying the connection at the user's request");
|
||||
// A rescan is the probe: it is the same request the scan worker
|
||||
// makes, so a success proves reachability and repopulates the
|
||||
// catalog in one pass rather than proving it twice.
|
||||
start_rescan(&w, &ctl, &coll_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.
|
||||
/// Put the identity filter's people on the filter bar.
|
||||
///
|
||||
/// One chip each, so a selection of three is three things to look at and three
|
||||
/// things to remove — a single chip saying "3 people" would be a filter the
|
||||
/// user can only clear wholesale.
|
||||
///
|
||||
/// The label is the People screen's own wording, unnamed groups included, so
|
||||
/// the same group does not read one way on one screen and another way on the
|
||||
/// next.
|
||||
/// Drop the eyes-open term when the last person leaves the filter.
|
||||
///
|
||||
/// The chip lives beside the people chips and goes with them; a term that
|
||||
/// stayed on after its chip had gone would narrow the grid with nothing on
|
||||
/// the bar to say so, which is the one state a filter bar must never be in.
|
||||
fn drop_eyes_open_without_people(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let mut f = ctl.filter.borrow_mut();
|
||||
if f.people.is_empty() && f.eyes_open {
|
||||
f.eyes_open = false;
|
||||
window.set_library_filter_eyes_open(false);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let ids = ctl.filter.borrow().people.clone();
|
||||
if ids.is_empty() {
|
||||
window
|
||||
.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(
|
||||
Vec::<PersonChip>::new(),
|
||||
)));
|
||||
// The tray, if it is open, has to lose its ticks with the chips: the
|
||||
// roster carries `picked` and is the same fact drawn a second time.
|
||||
push_people_roster(window, ctl);
|
||||
return;
|
||||
}
|
||||
|
||||
let borrow = ctl.catalog.borrow();
|
||||
let people = borrow
|
||||
.as_ref()
|
||||
.and_then(|cat| dr_catalog::faces::people(cat.connection()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let chips: Vec<PersonChip> = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let name = people
|
||||
.iter()
|
||||
.find(|p| p.id.0 == *id)
|
||||
.map(|p| {
|
||||
if p.name.trim().is_empty() {
|
||||
format!("Unnamed ({} faces)", p.confirmed_faces + p.suggested_faces)
|
||||
} else {
|
||||
p.name.clone()
|
||||
}
|
||||
})
|
||||
// A person deleted from under the filter still needs a chip,
|
||||
// or there would be no way to take them back out of it.
|
||||
.unwrap_or_else(|| "Person".to_string());
|
||||
PersonChip {
|
||||
id: *id as i32,
|
||||
name: name.into(),
|
||||
// Not asked for on this side. The bar's job here is to say who
|
||||
// the grid is narrowed to and offer a way out of it; a face
|
||||
// count beside each would be a second number competing with the
|
||||
// image counts already on the bar.
|
||||
faces: -1,
|
||||
picked: true,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
window.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(chips)));
|
||||
window.set_library_filter_people_all(matches!(
|
||||
ctl.filter.borrow().people_mode,
|
||||
library::PeopleMode::All
|
||||
));
|
||||
push_people_roster(window, ctl);
|
||||
}
|
||||
|
||||
/// Fill the filter bar's people tray.
|
||||
///
|
||||
/// Rebuilt whole rather than patched, because `picked` is on every row and a
|
||||
/// toggle changes two things at once — the chip that was pressed and, under
|
||||
/// `All`, what the whole filter means.
|
||||
///
|
||||
/// **Named people first, then by how much of them the library holds.** The
|
||||
/// catalog orders by face count alone, which on a real library puts a dozen
|
||||
/// unnamed strangers ahead of the two people the user has actually named — and
|
||||
/// the tray scrolls horizontally, so anything past the first few chips costs a
|
||||
/// gesture to reach. Naming somebody is the user saying they matter; the order
|
||||
/// says it back.
|
||||
fn push_people_roster(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let picked = ctl.filter.borrow().people.clone();
|
||||
let borrow = ctl.catalog.borrow();
|
||||
let people = borrow
|
||||
.as_ref()
|
||||
.and_then(|cat| dr_catalog::faces::people(cat.connection()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Sorted as (unnamed, -faces) pairs beside the chip rather than by
|
||||
// re-reading the drawn label: "is this person named" is a fact about the
|
||||
// record, and recovering it from the `Unnamed (n faces)` wording would put
|
||||
// a sort key inside a string meant for a human to read.
|
||||
let mut rows: Vec<(bool, i64, PersonChip)> = people
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
let on = picked.contains(&p.id.0);
|
||||
// A person already in the filter always has a chip, whatever else
|
||||
// is true of them: the tray is where the filter is taken apart, and
|
||||
// a term with no control is a term the user cannot remove.
|
||||
//
|
||||
// Otherwise: nobody the user set aside, and nobody with no faces —
|
||||
// a named person emptied by a split would be a chip that narrows
|
||||
// the grid to nothing whatever else is on the bar.
|
||||
on || (!p.ignored && p.confirmed_faces + p.suggested_faces > 0)
|
||||
})
|
||||
.map(|p| {
|
||||
let faces = p.confirmed_faces + p.suggested_faces;
|
||||
let unnamed = p.name.trim().is_empty();
|
||||
(
|
||||
unnamed,
|
||||
faces as i64,
|
||||
PersonChip {
|
||||
id: p.id.0 as i32,
|
||||
name: if unnamed {
|
||||
format!("Unnamed ({faces} faces)").into()
|
||||
} else {
|
||||
p.name.clone().into()
|
||||
},
|
||||
faces: faces as i32,
|
||||
picked: picked.contains(&p.id.0),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Stable, so the catalog's own tiebreak by name survives inside each of the
|
||||
// two blocks.
|
||||
rows.sort_by_key(|(unnamed, faces, _)| (*unnamed, -faces));
|
||||
|
||||
let rows: Vec<PersonChip> = rows.into_iter().map(|(_, _, chip)| chip).collect();
|
||||
window.set_library_people(slint::ModelRc::new(slint::VecModel::from(rows)));
|
||||
}
|
||||
|
||||
pub(super) fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
*ctl.offset.borrow_mut() = 0;
|
||||
ctl.requested.borrow_mut().clear();
|
||||
load_window(window, ctl);
|
||||
// TRACES: FR-UI-8
|
||||
// A narrowed grid is part of where you are: coming back to five-star frames
|
||||
// of one person and finding the whole library is the same loss of place as
|
||||
// coming back to the top of it.
|
||||
note_place(window, ctl);
|
||||
}
|
||||
|
||||
/// The heading each cell of a window carries: a month name on some, nothing
|
||||
/// on most.
|
||||
///
|
||||
/// A heading is drawn above its row, so it can only sit on a cell that
|
||||
/// begins one — stranded mid-row it would appear to label the cells to its
|
||||
/// left, which belong to the month before. So a row carries a heading when
|
||||
/// its first cell's month is not the one last announced. A month that begins
|
||||
/// mid-row is therefore announced at the *next* row it opens, which is one
|
||||
/// row late and still right about every cell under it. It used to be
|
||||
/// announced only if it happened to begin a row, which at seven columns
|
||||
/// meant most months were never named at all and the one heading on screen
|
||||
/// was wrong about everything below its first row.
|
||||
///
|
||||
/// 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.
|
||||
pub(super) fn period_headings(
|
||||
months: impl Iterator<Item = Option<(i64, i64)>>,
|
||||
offset: usize,
|
||||
columns: usize,
|
||||
) -> Vec<String> {
|
||||
let columns = columns.max(1);
|
||||
let mut announced: Option<(i64, i64)> = None;
|
||||
months
|
||||
.enumerate()
|
||||
.map(|(i, month)| {
|
||||
let Some((y, m)) = month else {
|
||||
return String::new();
|
||||
};
|
||||
let begins_row = (i + offset).is_multiple_of(columns);
|
||||
if i == 0 || (begins_row && announced != Some((y, m))) {
|
||||
announced = Some((y, m));
|
||||
format!("{} {y}", month_name(m))
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-6
|
||||
/// Show the range the filter is actually using.
|
||||
///
|
||||
/// Pushed back rather than left as the user typed it, because the filter may
|
||||
/// have adjusted it — ends given backwards are swapped, and the closing day is
|
||||
/// extended to include itself. A field showing something other than what is
|
||||
/// being filtered on is worse than one showing nothing.
|
||||
fn show_range(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let f = ctl.filter.borrow();
|
||||
let text = |t: Option<i64>| -> slint::SharedString {
|
||||
t.map(dr_types::format_date).unwrap_or_default().into()
|
||||
};
|
||||
window.set_library_range_from(text(f.captured_from));
|
||||
// The stored end is the last second of the closing day; naming that day is
|
||||
// what the user typed and what they should read back.
|
||||
window.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_month_that_begins_mid_row_is_still_announced() {
|
||||
// Four columns; August starts in the second column of the second
|
||||
// row and October in the third of the third. Neither begins a row,
|
||||
// and both must still be named — on the next row each opens.
|
||||
let mar = Some((2024, 3));
|
||||
let aug = Some((2024, 8));
|
||||
let oct = Some((2025, 10));
|
||||
let months = [
|
||||
mar, mar, mar, mar, //
|
||||
mar, aug, aug, aug, //
|
||||
aug, aug, oct, oct, //
|
||||
oct,
|
||||
];
|
||||
let h = period_headings(months.into_iter(), 0, 4);
|
||||
assert_eq!(h[0], "March 2024");
|
||||
assert_eq!(h[4], "", "still March at the top of row two");
|
||||
assert_eq!(h[5], "", "never mid-row");
|
||||
assert_eq!(h[8], "August 2024");
|
||||
assert_eq!(h[12], "October 2025");
|
||||
assert_eq!(h.iter().filter(|s| !s.is_empty()).count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scrolled_window_names_its_first_cell_wherever_it_lands() {
|
||||
let jan = Some((2026, 1));
|
||||
let feb = Some((2026, 2));
|
||||
// Offset 2 into a 4-column grid: the window's first cell is in the
|
||||
// third column, and the row after it begins a new month.
|
||||
let h = period_headings([jan, jan, feb, feb, feb].into_iter(), 2, 4);
|
||||
assert_eq!(h[0], "January 2026");
|
||||
assert_eq!(h[2], "February 2026", "cell 2 begins a row at offset 2");
|
||||
assert_eq!(h[3], "");
|
||||
// Undated cells carry nothing and announce nothing.
|
||||
let h = period_headings([None, jan, None, None, jan].into_iter(), 0, 4);
|
||||
assert_eq!(h[0], "");
|
||||
assert_eq!(h[4], "January 2026");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
//! Wiring the grid itself: the keyboard cursor, cell-size zoom, the reload
|
||||
//! triggers a scroll or a geometry change schedules, and the routes into and
|
||||
//! out of the develop view.
|
||||
//!
|
||||
//! Everything here is a `window.on_...` registration or a small helper next
|
||||
//! to the one callback that uses it; the work each callback delegates to —
|
||||
//! loading a window, moving the timeline, saving a place — lives in the
|
||||
//! module that owns that concern. See `docs/dev/code-health.md` CH-1.
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use slint::{ComponentHandle, Model as _};
|
||||
|
||||
use crate::AppWindow;
|
||||
use crate::{GestureRow, View};
|
||||
|
||||
use super::controller::{
|
||||
LibraryController, OpenImage, MAX_CELL_SIZE, MIN_CELL_SIZE, MIN_WINDOW, SCREENFULS,
|
||||
};
|
||||
use super::filter_bar::{
|
||||
wire_filter_dates, wire_filter_ratings_and_people, wire_filter_scope_and_offline,
|
||||
};
|
||||
use super::open::{schedule_reload, start_rescan};
|
||||
use super::ratings_keywords::{start_xmp_reload, wire_keywords, wire_ratings_and_flags};
|
||||
use super::sync::{start_derived_sync, start_thumbnail_sweep};
|
||||
use super::timeline::{
|
||||
capture_time_at, catalog_span, note_place, resume_position, wire_timeline, write_place,
|
||||
zoomed_span,
|
||||
};
|
||||
use super::window::{load_window, window_move, window_start};
|
||||
|
||||
/// Walk the keyboard cursor through the library — the arrow keys.
|
||||
///
|
||||
/// **The cursor is a library ordinal, not a row of the loaded window.** That is
|
||||
/// what lets it walk past the window's edge: the window is a few screenfuls
|
||||
/// around wherever the user is looking, and a cursor held as a row would stop
|
||||
/// at its end or, worse, keep counting into cells belonging to different
|
||||
/// photographs. Moving out of the window reloads it around the new position,
|
||||
/// which is the same thing scrolling does.
|
||||
///
|
||||
/// The grid supplies the step, because how far "down" is depends on how many
|
||||
/// columns the window happens to be showing, and only the grid knows that. It
|
||||
/// does not clamp: `Home` and `End` arrive as a step longer than the library
|
||||
/// and are clamped here, where the total is known.
|
||||
fn move_cursor(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll: &Rc<crate::collections_ui::CollectionsController>,
|
||||
delta: i32,
|
||||
extend: bool,
|
||||
) {
|
||||
let total = window.get_library_total().max(0) as usize;
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(from) = coll.cursor() else {
|
||||
// The first press takes hold of the grid rather than moving in it. A
|
||||
// key that jumped to image zero would lose wherever the user had
|
||||
// scrolled to, and one that started from a cell off the top of the
|
||||
// screen would appear to do nothing but scroll.
|
||||
//
|
||||
// The first *visible* ordinal, not the window's start: the loaded
|
||||
// window deliberately begins a quarter of a screen above the view, so
|
||||
// its first cell is one the user cannot see.
|
||||
let at = ctl.resume_at.get().min(total - 1);
|
||||
place_cursor(window, ctl, coll, at, false);
|
||||
return;
|
||||
};
|
||||
|
||||
// Saturating in `isize`, so a `Home` expressed as minus the library's
|
||||
// length does not wrap round to the end.
|
||||
let next = (from as isize)
|
||||
.saturating_add(delta as isize)
|
||||
.clamp(0, total as isize - 1) as usize;
|
||||
if next == from {
|
||||
// Already at the end being pressed toward. Nothing to move, and
|
||||
// reloading the window would be a visible jerk for no movement.
|
||||
return;
|
||||
}
|
||||
place_cursor(window, ctl, coll, next, extend);
|
||||
}
|
||||
|
||||
/// Put the cursor on one image, bringing the window with it.
|
||||
fn place_cursor(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll: &Rc<crate::collections_ui::CollectionsController>,
|
||||
at: usize,
|
||||
extend: bool,
|
||||
) {
|
||||
// Bring the window to the cursor if it has walked out of it. Centred a
|
||||
// quarter in, exactly as a scroll does it, so continuing in the same
|
||||
// direction has loaded cells to move into rather than another reload on
|
||||
// the very next press.
|
||||
let loaded = window.get_library_cells().row_count();
|
||||
let offset = *ctl.offset.borrow();
|
||||
if at < offset || at >= offset + loaded {
|
||||
let size = *ctl.window.borrow();
|
||||
let total = window.get_library_total().max(0) as usize;
|
||||
*ctl.offset.borrow_mut() = window_start(at, size, total);
|
||||
load_window(window, ctl);
|
||||
}
|
||||
|
||||
// Re-read: `load_window` clamps the offset against the library's end, so
|
||||
// the window may not start where it was asked to.
|
||||
let offset = *ctl.offset.borrow();
|
||||
let ids = ctl.visible_ids();
|
||||
let Some(row) = at.checked_sub(offset).filter(|r| *r < ids.len()) else {
|
||||
// The window could not be brought to the cursor — an empty or
|
||||
// shrinking library. Leaving the cursor where it was is better than
|
||||
// pointing it at nothing.
|
||||
return;
|
||||
};
|
||||
|
||||
if !extend {
|
||||
// An arrow key collapses the selection onto the cursor. A *click* on
|
||||
// an already-selected cell deliberately leaves the selection alone —
|
||||
// that exception exists so a multi-image drag can start from one of
|
||||
// its members — and there is no drag behind a keystroke.
|
||||
coll.clear_selection();
|
||||
}
|
||||
|
||||
crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend);
|
||||
window.set_library_cursor(at as i32);
|
||||
}
|
||||
|
||||
/// Say where in the library the photograph now open sits.
|
||||
///
|
||||
/// **After `on_open_image`, never before.** The generic open path resets the
|
||||
/// readout to "1 of 1" on its way in, because until the photo roll existed the
|
||||
/// grid really did hand develop a single path with nothing to walk to. It has
|
||||
/// a window of them now, so the honest answer is the ordinal in the library —
|
||||
/// not the row in the loaded window, which is an artefact of how much has been
|
||||
/// paged in and would jump about as the window moves.
|
||||
fn report_position(window: &AppWindow, ctl: &Rc<LibraryController>, row: usize) {
|
||||
let offset = *ctl.offset.borrow();
|
||||
window.set_index((offset + row) as i32);
|
||||
window.set_total(window.get_library_total());
|
||||
}
|
||||
|
||||
/// 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,
|
||||
on_leave_develop: Rc<dyn Fn()>,
|
||||
) where
|
||||
F: Fn(String) + 'static,
|
||||
{
|
||||
// So a rebuilt window can put the selection ticks back. Weak, or the two
|
||||
// controllers would hold each other alive for the life of the process.
|
||||
*ctl.coll_ctl.borrow_mut() = Some(Rc::downgrade(&coll_ctl));
|
||||
|
||||
// TRACES: FR-CAT-13
|
||||
// The reload the settings page offers when a sidecar disagrees.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_settings_xmp_reload(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
start_xmp_reload(&w, &ctl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-UI-3 | FR-UI-4
|
||||
// Whether the rating strip waits to be hovered or stands open.
|
||||
//
|
||||
// On Android touch is not evidence to be gathered, it is the platform.
|
||||
// The grid latches it from the first finger it sees as well, which is what
|
||||
// covers a touchscreen on the desktop — but that latch needs a press to
|
||||
// reach a cell, and a quick flick never delivers one because the Flickable
|
||||
// claims the gesture before the delay it would forward after. Seeding it
|
||||
// here means the stars are on screen before the first touch rather than
|
||||
// after it, which is the whole point of showing them.
|
||||
window.set_library_touched(cfg!(target_os = "android"));
|
||||
|
||||
// TRACES: FR-UI-4
|
||||
// The gesture reference. Pushed once, here, rather than on demand: the
|
||||
// table is a compiled-in constant, so there is nothing to be fresh about
|
||||
// and nothing to recompute — a callback to fill it would only be a way for
|
||||
// it to be empty the first time the sheet opens.
|
||||
window.set_library_gestures(slint::ModelRc::new(slint::VecModel::from(
|
||||
crate::gestures::rows()
|
||||
.into_iter()
|
||||
.map(|r| GestureRow {
|
||||
heading: r.heading.into(),
|
||||
title: r.title.into(),
|
||||
touch: r.touch.into(),
|
||||
pointer: r.pointer.into(),
|
||||
keys: r.keys.into(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)));
|
||||
|
||||
// Shared rather than moved: a click and `Return` both open an image, and
|
||||
// they are two callbacks.
|
||||
let on_open_image: OpenImage = Rc::new(on_open_image);
|
||||
// TRACES: FR-UI-8
|
||||
// And a third caller, which is not a callback at all: a place recorded in
|
||||
// develop reopens it at launch. Through the same closure, so the edit-saving
|
||||
// and identity bookkeeping it does are not something a restore can skip.
|
||||
*ctl.open_image.borrow_mut() = Some(on_open_image.clone());
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let coll_for_click = coll_ctl.clone();
|
||||
let on_open_image = on_open_image.clone();
|
||||
window.on_library_cell_clicked(move |i| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
// The press stayed put, so it was a tap and not a drag: whatever it
|
||||
// held back can be applied now. See `collections_ui::Press`.
|
||||
crate::collections_ui::commit_press(&w, &coll_for_click, &ctl.visible_ids());
|
||||
|
||||
// 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.
|
||||
w.set_active_view(View::Develop);
|
||||
// Which cell the develop view is now showing, so the photo
|
||||
// roll opens marking it rather than marking nothing.
|
||||
w.set_library_roll_current(i);
|
||||
// A develop session begins here, so the roll centres on this
|
||||
// photograph the first time it settles rather than merely
|
||||
// scrolling it into view at one edge. Raised here and not in
|
||||
// `on_library_roll_pick`, which is a step *within* a session —
|
||||
// see `PhotoRoll::centre-request`.
|
||||
w.set_library_roll_centre(true);
|
||||
on_open_image(path);
|
||||
report_position(&w, &ctl, i as usize);
|
||||
// TRACES: FR-UI-8
|
||||
// Written now rather than on a settle: which view you are in is
|
||||
// the field a debounce is most likely to lose, and quitting
|
||||
// straight from develop is exactly the case worth getting right.
|
||||
write_place(&w, &ctl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-UI-4
|
||||
// A photograph chosen from the photo roll.
|
||||
//
|
||||
// The same row-to-path lookup a cell click does, without the selection
|
||||
// rules: the roll is a way of moving between photographs, not of building
|
||||
// a set, so there is no modified press to honour and no reason to leave
|
||||
// develop. `open_from_library` persists the outgoing edit before it loads
|
||||
// the next one, which is what makes this safe to fire repeatedly.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let on_open_image = on_open_image.clone();
|
||||
window.on_library_roll_pick(move |i| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(path) = ctl.paths.borrow().get(i as usize).cloned() else {
|
||||
return;
|
||||
};
|
||||
w.set_library_roll_current(i);
|
||||
on_open_image(path);
|
||||
report_position(&w, &ctl, i as usize);
|
||||
// TRACES: FR-UI-8
|
||||
// Debounced, unlike the two entries into develop: the roll is walked
|
||||
// frame by frame and every step is a different photograph.
|
||||
note_place(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
wire_grid_cursor_and_zoom(window, &ctl, &coll_ctl, &on_open_image);
|
||||
wire_grid_sync_and_load(window, &ctl);
|
||||
wire_timeline(window, &ctl);
|
||||
wire_grid_routes(window, &ctl, &coll_ctl, on_leave_develop);
|
||||
wire_ratings_and_flags(window, &ctl, &coll_ctl);
|
||||
wire_keywords(window, &ctl, &coll_ctl);
|
||||
wire_filter_ratings_and_people(window, &ctl);
|
||||
wire_filter_dates(window, &ctl);
|
||||
wire_filter_scope_and_offline(window, &ctl, &coll_ctl);
|
||||
|
||||
// Last, and in its own module: the answers to a damaged catalog have
|
||||
// nothing to do with the library view except that they run before it
|
||||
// exists.
|
||||
crate::recovery_ui::wire(window, &ctl, &coll_ctl);
|
||||
}
|
||||
|
||||
/// --- the keyboard cursor (FR-CULL-4) -----------------------------------
|
||||
///
|
||||
/// Walking the grid with the arrows, and opening with `Return`. Together
|
||||
/// with the judgement keys already bound in the grid, this is what makes a
|
||||
/// culling pass a keyboard job: move, rate, move, open the doubtful one,
|
||||
/// come back. A cull is thousands of decisions, and reaching for the mouse
|
||||
/// between each of them is the difference between an hour and an evening.
|
||||
fn wire_grid_cursor_and_zoom(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
on_open_image: &OpenImage,
|
||||
) {
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let coll = coll_ctl.clone();
|
||||
window.on_library_move_cursor(move |delta, extend| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
move_cursor(&w, &ctl, &coll, delta, extend);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let coll = coll_ctl.clone();
|
||||
let on_open = on_open_image.clone();
|
||||
window.on_library_open_cursor(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// The cursor is a library ordinal and `paths` is the loaded
|
||||
// window, so the row is the difference. A cursor outside the
|
||||
// window cannot happen — moving it loads the window around it —
|
||||
// but a shrinking library could leave one behind, and opening the
|
||||
// wrong photograph is worse than opening none.
|
||||
let Some(cursor) = coll.cursor() else { return };
|
||||
let offset = *ctl.offset.borrow();
|
||||
let row = cursor.checked_sub(offset);
|
||||
let path = row.and_then(|row| ctl.paths.borrow().get(row).cloned());
|
||||
if let Some(path) = path {
|
||||
w.set_active_view(View::Develop);
|
||||
// As on a click: the roll marks what is open, and centres on it
|
||||
// because this too begins a session.
|
||||
w.set_library_roll_current(row.unwrap_or(0) as i32);
|
||||
w.set_library_roll_centre(true);
|
||||
on_open(path);
|
||||
report_position(&w, &ctl, row.unwrap_or(0));
|
||||
write_place(&w, &ctl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Ctrl+wheel or pinch over the grid resizes the cells.
|
||||
//
|
||||
// Geometric steps rather than fixed pixels: the same gesture should feel
|
||||
// the same at 90px and at 400px, and a linear step is imperceptible at one
|
||||
// end and violent at the other.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_zoom_cells(move |delta| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
let current = w.get_library_cell_size();
|
||||
let next = if delta > 0 {
|
||||
current * 1.25
|
||||
} else {
|
||||
current / 1.25
|
||||
}
|
||||
.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
|
||||
|
||||
if (next - current).abs() < 0.5 {
|
||||
return;
|
||||
}
|
||||
w.set_library_cell_size(next);
|
||||
|
||||
// No need to forget anything on a class change: the class is part
|
||||
// of the request key, so cells that now want the large resolution
|
||||
// simply miss and ask for it, while the 256px ones they already
|
||||
// hold stay served.
|
||||
//
|
||||
// Deferred, like every other geometry change. This one was still
|
||||
// reloading inline — a full catalog re-read and model rebuild per
|
||||
// step, which is what a wheel spun through six steps paid six
|
||||
// times over.
|
||||
schedule_reload(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-UI-4
|
||||
// A pinch, which is continuous where the wheel is stepped.
|
||||
//
|
||||
// Given the ratio since the last update rather than a direction, so the
|
||||
// grid tracks the fingers instead of jumping a fixed 25% per threshold
|
||||
// crossing. What the user is setting is the size class — how big they want
|
||||
// a thumbnail to be — and the drawn cell follows from it by dividing the
|
||||
// width, so the visible result still lands on whole column counts. Feeding
|
||||
// a continuous value in is what decides *when* it crosses.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_pinch_cells(move |ratio| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if !(ratio.is_finite() && ratio > 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let current = w.get_library_cell_size();
|
||||
let next = (current * ratio).clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
|
||||
if (next - current).abs() < 0.5 {
|
||||
return;
|
||||
}
|
||||
w.set_library_cell_size(next);
|
||||
schedule_reload(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-UI-4
|
||||
// A pinch has begun, so the press that started it was not a press.
|
||||
//
|
||||
// A pinch opens as one finger on a cell — which selects it — and only
|
||||
// becomes a pinch when the second lands. Without this the user is left
|
||||
// holding a selection they never made, on a photograph they were only
|
||||
// reaching past.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let coll = coll_ctl.clone();
|
||||
window.on_library_pinch_started(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
crate::collections_ui::cancel_press(&w, &coll, &ctl.visible_ids());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload triggers for the grid: an explicit sync or thumbnail sweep, and
|
||||
/// the geometry and scroll changes that move which rows are loaded.
|
||||
fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-3 | FR-NC-3
|
||||
// Thumbnail the whole library, from the settings page. It ends in a sync
|
||||
// of its own, so this is the long version of the button above.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_thumbnail_all(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
start_thumbnail_sweep(&w, &ctl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// A column-count change moves which cells begin a row, and month headings
|
||||
// sit on row-leading cells.
|
||||
//
|
||||
// Guarded like the scroll below, and for the same reason: a grid being
|
||||
// taken down reports its geometry collapsing on the way out, and reloading
|
||||
// the window against that is work done for a page nobody is looking at.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_columns_changed(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
if !w.get_library_visible() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deferred, and re-anchored when it lands — see
|
||||
// [`schedule_reload`]. A column change moves every cell in the
|
||||
// grid, because a cell is drawn at its absolute place in the
|
||||
// library and the row that resolves to is `index / columns`.
|
||||
// The viewport does not move with them, so without the
|
||||
// re-anchor the view is left pointing at rows the loaded window
|
||||
// no longer covers and the grid draws nothing at all — the
|
||||
// "gallery randomly goes blank until I scroll" report, whose
|
||||
// triggers are a resize, the sidebar opening, a zoom step, or
|
||||
// turning the tablet over.
|
||||
schedule_reload(&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_viewport_cells(move |on_screen| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if !w.get_library_visible() {
|
||||
return;
|
||||
}
|
||||
let on_screen = (on_screen.max(0) as usize).max(1);
|
||||
let window_size = (on_screen * SCREENFULS).max(MIN_WINDOW);
|
||||
if window_size == *ctl.window.borrow() && on_screen == ctl.viewport_cells.get() {
|
||||
return;
|
||||
}
|
||||
ctl.viewport_cells.set(on_screen);
|
||||
*ctl.window.borrow_mut() = window_size;
|
||||
// Coalesced with the column change that almost always accompanies
|
||||
// it: resizing the cells alters both, and reloading once per report
|
||||
// meant two full rebuilds per zoom step.
|
||||
schedule_reload(&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;
|
||||
|
||||
// **A report from a grid that is not on screen is not a scroll.**
|
||||
//
|
||||
// The grid is gated on an `if`, so leaving it tears the whole
|
||||
// subtree down — and a Flickable being destroyed passes its viewport
|
||||
// through zero on the way out, which arrives here indistinguishable
|
||||
// from the user having flung the grid to the top. Everything below
|
||||
// then ran on the way *into* develop: the loaded window was reset to
|
||||
// offset zero, the model was rebuilt against the first rows of the
|
||||
// library, and a thumbnail batch was issued for photographs nobody
|
||||
// had asked to see. Those rebuilds landed while the grid was still
|
||||
// being taken apart, which is what flashed the library over the
|
||||
// develop view for the first few frames after a click — and on a
|
||||
// remote library it also spent a burst of requests on the top of the
|
||||
// catalog every single time an image was opened.
|
||||
//
|
||||
// **`library-visible`, not `show-library`, and the difference is the
|
||||
// whole bug.** `show-library` says "the library rather than develop"
|
||||
// and stays true while Settings, Import, People or the launch screen
|
||||
// replaces the window — all four of which take the grid down just as
|
||||
// opening an image does. So the teardown's scroll-to-zero passed this
|
||||
// guard, `resume_at` was set to 0, and coming back from any of those
|
||||
// four screens landed at the top of the library however deep in it the
|
||||
// user had been. The property is computed once in `app.slint` beside
|
||||
// the `if` it is spelled from, so the two cannot drift apart again.
|
||||
//
|
||||
// The guard used to cover only `resume_at`, for a narrower version
|
||||
// of the same reason. It belongs over the whole handler.
|
||||
if !w.get_library_visible() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remember where the view is, so leaving for the develop view and
|
||||
// coming back returns here. Latched on every event rather than read
|
||||
// at departure: by the time the grid is hidden its scroll position
|
||||
// is only in the Flickable, which is about to be destroyed.
|
||||
ctl.resume_at.set(first_visible);
|
||||
|
||||
// TRACES: FR-UI-8
|
||||
// And where the *next launch* will look for it. Debounced: a flick
|
||||
// reports several of these per screenful.
|
||||
note_place(&w, &ctl);
|
||||
|
||||
// The same position where a rebuilt grid will look for it.
|
||||
//
|
||||
// `scroll-to` is read by `seek()`, which runs on a `scroll-token`
|
||||
// change and on `init` — so writing it here without bumping the
|
||||
// token cannot move the grid that is on screen, and *is* what the
|
||||
// next one reads when it is built. That is every route back to the
|
||||
// grid at once: Settings, Import, People and the launch screen all
|
||||
// tear the subtree down and rebuild it, and none of them goes
|
||||
// through `on_back_to_library` to have the position replayed by
|
||||
// hand. Without this they each rebuilt against whatever `scroll-to`
|
||||
// was last *set* to — a stale scrub, or zero — and landed there.
|
||||
w.set_library_scroll_to(first_visible as i32);
|
||||
|
||||
// Move the timeline marker with the view. Scrolling the grid is a
|
||||
// way of moving through time just as scrubbing is, and a marker
|
||||
// that only ever moved on a scrub sat still while the photographs
|
||||
// beside it advanced by months — the axis said "when you are" and
|
||||
// was wrong the moment the user touched the wheel.
|
||||
//
|
||||
// This runs before the reload guard below, which fires only a few
|
||||
// times per screenful; the marker has to follow every event or it
|
||||
// would advance in visible jerks.
|
||||
//
|
||||
// Only the marker is moved, not the whole histogram: rebuilding
|
||||
// the bars means a `GROUP BY strftime` aggregate over the library,
|
||||
// far too much for every event of a flick. The bars do not change
|
||||
// as the grid scrolls anyway — only where the marker sits on them.
|
||||
//
|
||||
// Re-running the scrub would be wrong for a second reason: it sets
|
||||
// `scroll-to`, which would drive the grid from its own scroll.
|
||||
{
|
||||
let borrow = ctl.catalog.borrow();
|
||||
if let Some(catalog) = borrow.as_ref() {
|
||||
if let Some(when) = capture_time_at(&ctl, catalog, first_visible) {
|
||||
*ctl.current_bucket.borrow_mut() = Some(when);
|
||||
|
||||
let zoom = *ctl.timeline_zoom.borrow();
|
||||
let centre = *ctl.timeline_centre.borrow();
|
||||
if let Some(full) = catalog_span(catalog, &ctl) {
|
||||
let (from, to) = zoomed_span(full, zoom, centre);
|
||||
w.set_library_current_bucket(when as i32);
|
||||
w.set_library_current_fraction(
|
||||
((when - from) as f64 / (to - from).max(1) as f64).clamp(0.0, 1.0)
|
||||
as f32,
|
||||
);
|
||||
w.set_library_timeline_anchored(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Centre the window on the view, so scrolling either way has
|
||||
// loaded rows ahead of it rather than only below.
|
||||
let Some(offset) = window_move(
|
||||
first_visible,
|
||||
*ctl.offset.borrow(),
|
||||
*ctl.window.borrow(),
|
||||
ctl.viewport_cells.get(),
|
||||
w.get_library_total().max(0) as usize,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
*ctl.offset.borrow_mut() = offset;
|
||||
load_window(&w, &ctl);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving between the grid and the screens around it: the launch screen,
|
||||
/// develop, and a manual rescan.
|
||||
fn wire_grid_routes(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
on_leave_develop: Rc<dyn Fn()>,
|
||||
) {
|
||||
// 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_active_view(View::Launch);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Develop → grid.
|
||||
//
|
||||
// Returns to where the user left, on the photograph they were editing,
|
||||
// rather than to the top. The grid is gated on an `if` in the markup, so it
|
||||
// is rebuilt from nothing and its Flickable starts at row 0; the position
|
||||
// has to be replayed explicitly. See [`resume_position`] for which of the
|
||||
// two positions wins when they disagree.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let coll_ctl = coll_ctl.clone();
|
||||
window.on_back_to_library(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
// TRACES: FR-CAT-8
|
||||
// The edit is persisted on the way out rather than on every
|
||||
// slider move: a save is a network round-trip, and one per drag
|
||||
// frame would put an upload inside the gesture NFR-P5 governs.
|
||||
// This is the moment the image stops being the open one, so it is
|
||||
// the last moment its edit can be written.
|
||||
on_leave_develop();
|
||||
|
||||
// Where the open photograph sits in the library, which is exactly
|
||||
// what `report_position` keeps `index` holding — including after
|
||||
// every step along the photo roll, which is the case that makes
|
||||
// this differ from `resume_at`.
|
||||
let open = w.get_index().max(0) as usize;
|
||||
resume_position(&w, &ctl, &coll_ctl, Some(open));
|
||||
|
||||
w.set_active_view(View::Library);
|
||||
// TRACES: FR-UI-8
|
||||
// After the view is set, not before: `current_place` reads it to
|
||||
// say which view the record is of, and this is the moment it
|
||||
// becomes the grid.
|
||||
write_place(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
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 };
|
||||
start_rescan(&w, &ctl, &coll_ctl);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One zoom step, as the handler applies it.
|
||||
fn zoom_cell(current: f32, delta: i32) -> f32 {
|
||||
let next = if delta > 0 {
|
||||
current * 1.25
|
||||
} else {
|
||||
current / 1.25
|
||||
};
|
||||
next.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_zoom_steps_geometrically_and_reverses() {
|
||||
// Geometric so the gesture feels the same at either end; a fixed pixel
|
||||
// step is imperceptible at 400px and violent at 90px.
|
||||
let a = zoom_cell(180.0, 1);
|
||||
assert!((a - 225.0).abs() < 0.01);
|
||||
assert!(
|
||||
(zoom_cell(a, -1) - 180.0).abs() < 0.01,
|
||||
"in then out returns"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_zoom_stays_within_its_bounds() {
|
||||
let mut size = 180.0;
|
||||
for _ in 0..40 {
|
||||
size = zoom_cell(size, 1);
|
||||
}
|
||||
assert_eq!(size, MAX_CELL_SIZE);
|
||||
|
||||
for _ in 0..40 {
|
||||
size = zoom_cell(size, -1);
|
||||
}
|
||||
assert_eq!(size, MIN_CELL_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_past_the_grid_class_asks_for_the_large_one() {
|
||||
use dr_thumbs::ThumbSize;
|
||||
// The point of the second class: past 256px a grid thumbnail is being
|
||||
// upscaled, and the softness shows.
|
||||
assert_eq!(ThumbSize::for_cell(180), ThumbSize::Grid);
|
||||
assert_eq!(
|
||||
ThumbSize::for_cell(zoom_cell(225.0, 1) as u32),
|
||||
ThumbSize::Large
|
||||
);
|
||||
// And zooming back down does not keep paying for it.
|
||||
assert_eq!(
|
||||
ThumbSize::for_cell(zoom_cell(281.0, -1) as u32),
|
||||
ThumbSize::Grid
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! # Why thumbnails are fetched, not derived from the scan
|
||||
//!
|
||||
//! A scan yields paths and sizes, nothing visual. Each thumbnail costs its own
|
||||
//! range request, so they are fetched **only for cells the grid actually
|
||||
//! wants** — never for the whole library up front. On the reference library
|
||||
//! that is the difference between a few MB and ~370 GB (ARCH §6.7).
|
||||
//!
|
||||
//! Split into one module per area of behaviour (docs/dev/code-health.md CH-1):
|
||||
//! `controller` holds the running state every other module reads and writes
|
||||
//! through `pub(super)` fields; `open` is the launch-to-scan cycle and the
|
||||
//! worker that checks the catalog file before either touches it; `offline`
|
||||
//! is what of a collection is on this device and the prompt that offers to
|
||||
//! change it; `window` fills the grid model from the catalog and drains the
|
||||
//! thumbnail fetch; `sync` is the background passes that reach beyond the
|
||||
//! loaded window — the metadata sweep, the whole-library thumbnail pass, and
|
||||
//! the exchange with the server; `ratings_keywords` applies a judgement or a
|
||||
//! keyword to a selection and queues the sidecar and XMP writes behind it;
|
||||
//! `timeline` is the capture-time sidebar and the photographer's place,
|
||||
//! which move together closely enough to live in one file; `grid` wires the
|
||||
//! grid's own callbacks — the keyboard cursor, cell-size zoom, the routes
|
||||
//! into and out of develop; `filter_bar` wires the rating, people, date and
|
||||
//! offline-scope filters. `mod.rs` re-exports what the rest of the crate
|
||||
//! calls by `library_ui::` path, so nothing outside this module needs to
|
||||
//! change.
|
||||
|
||||
mod controller;
|
||||
mod filter_bar;
|
||||
mod grid;
|
||||
mod offline;
|
||||
mod open;
|
||||
mod ratings_keywords;
|
||||
mod sync;
|
||||
mod timeline;
|
||||
mod window;
|
||||
|
||||
pub use controller::LibraryController;
|
||||
pub use grid::wire;
|
||||
pub(crate) use open::{forget_catalog, show_catalog_now, start_rescan};
|
||||
pub use open::{open, reload};
|
||||
pub use ratings_keywords::paste_settings_to_selection;
|
||||
pub(crate) use ratings_keywords::start_sidecar_writes;
|
||||
pub use timeline::format_date;
|
||||
pub use window::refresh_thumbnail;
|
||||
@@ -0,0 +1,690 @@
|
||||
//! Keeping a collection's originals on this device: the offline prompt, the
|
||||
//! pin fetch, and the connectivity banner every worker in this directory
|
||||
//! paints through [`refresh_offline`].
|
||||
//!
|
||||
//! Split out because it is one coherent user question — "what of this is on
|
||||
//! my device, and what would keeping or releasing it cost" — with its own
|
||||
//! catalog queries, distinct from loading a window over the grid or scanning
|
||||
//! the tree. See `docs/dev/code-health.md` CH-1.
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use dr_catalog::Catalog;
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::library;
|
||||
use crate::AppWindow;
|
||||
|
||||
use super::controller::{stop, LibraryController};
|
||||
|
||||
/// TRACES: FR-NC-6a | FR-NC-6c
|
||||
/// What a collection would cost to take with you, and what it is holding now.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
struct OfflineSummary {
|
||||
/// Photographs in the collection and its children, deduplicated.
|
||||
total: usize,
|
||||
/// Of those, how many have their original on this device.
|
||||
held: usize,
|
||||
/// Disk those originals occupy — what releasing would give back.
|
||||
held_bytes: u64,
|
||||
/// What the rest would cost to fetch, from the sizes the scan recorded.
|
||||
/// Zero where nothing has been stat-ed yet, which reads as "unknown"
|
||||
/// rather than "free" in the label built from it.
|
||||
missing_bytes: u64,
|
||||
}
|
||||
|
||||
impl OfflineSummary {
|
||||
fn missing(self) -> usize {
|
||||
self.total.saturating_sub(self.held)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the offline summary for a set of images.
|
||||
///
|
||||
/// One query with the ids inlined as placeholders — the same shape
|
||||
/// [`collection_images`] uses, and for the same reason: a collection is tens to
|
||||
/// thousands of rows, and a round trip per photograph to answer one dialogue is
|
||||
/// not a trade worth making.
|
||||
fn offline_summary(catalog: &Catalog, images: &[dr_types::ImageId]) -> OfflineSummary {
|
||||
if images.is_empty() {
|
||||
return OfflineSummary::default();
|
||||
}
|
||||
let placeholders = std::iter::repeat_n("?", images.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
// `tier_actual`, never `tier_desired`: the question is what can be opened
|
||||
// on the aeroplane, and a pin whose download has not run yet answers no.
|
||||
let sql = format!(
|
||||
"SELECT count(*),
|
||||
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 1 ELSE 0 END), 0),
|
||||
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN c.bytes ELSE 0 END), 0),
|
||||
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 0
|
||||
ELSE coalesce(i.file_size, 0) END), 0)
|
||||
FROM images i
|
||||
LEFT JOIN image_cache c ON c.image_id = i.id
|
||||
WHERE i.id IN ({placeholders})"
|
||||
);
|
||||
|
||||
let mut params: Vec<rusqlite::types::Value> = vec![rusqlite::types::Value::Integer(
|
||||
dr_types::Tier::Original.stored(),
|
||||
)];
|
||||
params.extend(
|
||||
images
|
||||
.iter()
|
||||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64)),
|
||||
);
|
||||
|
||||
catalog
|
||||
.connection()
|
||||
.query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok(OfflineSummary {
|
||||
total: r.get::<_, i64>(0)? as usize,
|
||||
held: r.get::<_, i64>(1)? as usize,
|
||||
held_bytes: r.get::<_, i64>(2)? as u64,
|
||||
missing_bytes: r.get::<_, i64>(3)? as u64,
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
log::debug!("reading offline summary: {e}");
|
||||
OfflineSummary::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6a | FR-NC-6c
|
||||
/// Ask what should happen to a collection's local copies.
|
||||
///
|
||||
/// Both answers are expensive — one commits the device to a download of
|
||||
/// gigabytes, the other deletes gigabytes it already holds — so this is a
|
||||
/// question rather than a toggle, and the counts and sizes go in the buttons
|
||||
/// where they are read *before* the tap rather than in a second dialogue after
|
||||
/// it (FR-NC-6c: an operation requiring absent data says so, with the size,
|
||||
/// before starting).
|
||||
pub(super) fn open_offline_prompt(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
id: dr_types::CollectionId,
|
||||
) {
|
||||
let borrow = ctl.catalog.borrow();
|
||||
let Some(catalog) = borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Descendants, matching what the grid shows when scoped to this row:
|
||||
// keeping a parent whose children hold the photographs must keep the
|
||||
// photographs, or the answer would appear to do nothing.
|
||||
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
window.set_library_error(format!("resolving collection: {e}").into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let images = collection_images(catalog, &ids);
|
||||
let summary = offline_summary(catalog, &images);
|
||||
|
||||
let name = dr_catalog::collections::tree(catalog.connection())
|
||||
.ok()
|
||||
.and_then(|rows| {
|
||||
rows.into_iter()
|
||||
.find(|r| r.collection.id == id)
|
||||
.map(|r| r.collection.name)
|
||||
})
|
||||
.unwrap_or_else(|| "This collection".to_string());
|
||||
|
||||
ctl.offline_target.set(Some(id));
|
||||
window.set_offline_prompt_title(name.as_str().into());
|
||||
window.set_offline_prompt_detail(
|
||||
if summary.total == 0 {
|
||||
"Nothing in here yet. Put some photographs in it first.".to_string()
|
||||
} else if summary.held == summary.total {
|
||||
format!(
|
||||
"All {} on this device · {}",
|
||||
summary.total,
|
||||
crate::activity::describe_bytes(summary.held_bytes)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} photographs · {} already on this device",
|
||||
summary.total, summary.held
|
||||
)
|
||||
}
|
||||
.as_str()
|
||||
.into(),
|
||||
);
|
||||
window.set_offline_prompt_keep_label(
|
||||
// The size is named where the scan has recorded one. Where it has not,
|
||||
// the label says what it will do and not what it will cost, which is
|
||||
// honest — a "0 B" download would be a lie about a gigabyte.
|
||||
if summary.missing_bytes > 0 {
|
||||
format!(
|
||||
"Download {} · {}",
|
||||
summary.missing(),
|
||||
crate::activity::describe_bytes(summary.missing_bytes)
|
||||
)
|
||||
} else if summary.missing() > 0 {
|
||||
format!("Download {}", summary.missing())
|
||||
} else {
|
||||
"Everything is already here".to_string()
|
||||
}
|
||||
.as_str()
|
||||
.into(),
|
||||
);
|
||||
window.set_offline_prompt_release_label(
|
||||
format!(
|
||||
"Remove {} local copies · frees {}",
|
||||
summary.held,
|
||||
crate::activity::describe_bytes(summary.held_bytes)
|
||||
)
|
||||
.as_str()
|
||||
.into(),
|
||||
);
|
||||
window.set_offline_prompt_can_keep(summary.missing() > 0);
|
||||
window.set_offline_prompt_can_release(summary.held > 0);
|
||||
window.set_offline_prompt_busy(window.get_library_pin_total() > 0);
|
||||
}
|
||||
|
||||
/// Close the offline question without answering it.
|
||||
pub(super) fn close_offline_prompt(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
ctl.offline_target.set(None);
|
||||
// The title is what the prompt's visibility is bound to: one fact, so a
|
||||
// dialogue cannot be up with nothing written on it.
|
||||
window.set_offline_prompt_title(slint::SharedString::new());
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6a
|
||||
/// Keep a collection on this device: record the pin, then start the transfer.
|
||||
///
|
||||
/// Two separate things, and keeping them separate is what makes the answer feel
|
||||
/// immediate — recording the intent is a local catalog write that completes at
|
||||
/// once, and downloading the bytes may take a very long time. The pin also
|
||||
/// survives the app being closed halfway through, which is what makes the
|
||||
/// transfer resumable rather than something to start again.
|
||||
pub(super) fn keep_collection_offline(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
) {
|
||||
let Some(id) = ctl.offline_target.get() else {
|
||||
return;
|
||||
};
|
||||
let Some(cache) = ctl.cache() else {
|
||||
window.set_library_error("No cache directory for this library.".into());
|
||||
return;
|
||||
};
|
||||
|
||||
let images = {
|
||||
let borrow = ctl.catalog.borrow();
|
||||
let Some(catalog) = borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
window.set_library_error(format!("resolving collection: {e}").into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let images = collection_images(catalog, &ids);
|
||||
if images.is_empty() {
|
||||
window.set_library_error("Nothing in that collection to keep offline.".into());
|
||||
return;
|
||||
}
|
||||
if let Err(e) = cache.pin(catalog.connection(), &images) {
|
||||
window.set_library_error(format!("pinning: {e}").into());
|
||||
return;
|
||||
}
|
||||
images
|
||||
};
|
||||
|
||||
log::info!("pinned {} image(s) for offline use", images.len());
|
||||
window.set_library_error(slint::SharedString::new());
|
||||
if ctl.scope.borrow().as_ref() == Some(&id) {
|
||||
window.set_library_scope_pinned(true);
|
||||
}
|
||||
close_offline_prompt(window, ctl);
|
||||
start_pin_fetch(window, ctl);
|
||||
refresh_collection_tree(window, ctl, coll_ctl);
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6a | FR-NC-6b
|
||||
/// Give the disk back: release the pin *and* delete the originals it held.
|
||||
///
|
||||
/// Deliberately destructive, where unpinning alone is not. "Remove the local
|
||||
/// copies" is asked by someone whose device is full, and answering it by
|
||||
/// withdrawing a promise and leaving the gigabytes for a future eviction to
|
||||
/// notice is not an answer. Nothing is lost that cannot be fetched again: the
|
||||
/// originals are on the server, and the ratings, the edit graph and the
|
||||
/// thumbnails are all untouched — they are authoritative and small.
|
||||
pub(super) fn release_collection_offline(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
) {
|
||||
let Some(id) = ctl.offline_target.get() else {
|
||||
return;
|
||||
};
|
||||
let Some(cache) = ctl.cache() else {
|
||||
window.set_library_error("No cache directory for this library.".into());
|
||||
return;
|
||||
};
|
||||
|
||||
let released = {
|
||||
let borrow = ctl.catalog.borrow();
|
||||
let Some(catalog) = borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
window.set_library_error(format!("resolving collection: {e}").into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let images = collection_images(catalog, &ids);
|
||||
let outcome = match cache.release(catalog.connection(), &images) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
window.set_library_error(format!("removing local copies: {e}").into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// TRACES: FR-NC-6c
|
||||
// On a placeholder library the bookkeeping above owns no files, so it
|
||||
// freed nothing — the originals are materialised in the library folder
|
||||
// and only the sync client may take them back. Asking it to is what
|
||||
// makes unpinning actually return the disk, and it must be a
|
||||
// dehydration rather than a delete: removing a file inside a synced
|
||||
// tree propagates to the server (ARCH §9.0a).
|
||||
//
|
||||
// Fire and forget: it is per-file work over a socket, the user has
|
||||
// already been told the pin is withdrawn, and a client that refuses
|
||||
// leaves the content where it is at no cost but disk.
|
||||
if let Some(conn) = ctl.session() {
|
||||
let path = library::catalog_path(&conn.account);
|
||||
std::mem::drop(library::spawn_dehydrate(conn, path, images));
|
||||
}
|
||||
outcome
|
||||
};
|
||||
|
||||
let (count, freed) = released;
|
||||
log::info!(
|
||||
"released {count} image(s), freeing {}",
|
||||
crate::activity::describe_bytes(freed)
|
||||
);
|
||||
window.set_library_error(slint::SharedString::new());
|
||||
window.set_library_status(
|
||||
format!(
|
||||
"Removed local copies · {} freed",
|
||||
crate::activity::describe_bytes(freed)
|
||||
)
|
||||
.as_str()
|
||||
.into(),
|
||||
);
|
||||
if ctl.scope.borrow().as_ref() == Some(&id) {
|
||||
window.set_library_scope_pinned(false);
|
||||
}
|
||||
// A download that was still running for this collection has just had its
|
||||
// reason withdrawn; the worker checks `pending_pins` per file, so it stops
|
||||
// finding work rather than being killed.
|
||||
window.set_library_pin_total(0);
|
||||
window.set_library_pin_done(0);
|
||||
close_offline_prompt(window, ctl);
|
||||
refresh_local_count(window, ctl);
|
||||
refresh_collection_tree(window, ctl, coll_ctl);
|
||||
}
|
||||
|
||||
/// Redraw the sidebar, so the trays reflect what was just kept or released.
|
||||
fn refresh_collection_tree(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
) {
|
||||
let borrow = ctl.catalog.borrow();
|
||||
if let Some(catalog) = borrow.as_ref() {
|
||||
crate::collections_ui::refresh_tree(window, coll_ctl, catalog);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every image in the given collections, deduplicated.
|
||||
///
|
||||
/// An image in both a parent and a child is one photograph and must be pinned
|
||||
/// once, exactly as the grid draws it once.
|
||||
pub(super) fn collection_images(
|
||||
catalog: &Catalog,
|
||||
ids: &[dr_types::CollectionId],
|
||||
) -> Vec<dr_types::ImageId> {
|
||||
if ids.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let placeholders = std::iter::repeat_n("?", ids.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!(
|
||||
"SELECT DISTINCT image_id FROM collection_members
|
||||
WHERE collection_id IN ({placeholders})"
|
||||
);
|
||||
let params: Vec<rusqlite::types::Value> = ids
|
||||
.iter()
|
||||
.map(|c| rusqlite::types::Value::Integer(c.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(dr_types::ImageId(r.get::<_, i64>(0)? as u64))
|
||||
});
|
||||
match rows {
|
||||
Ok(rows) => rows.flatten().collect(),
|
||||
Err(e) => {
|
||||
log::debug!("listing collection images: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6a
|
||||
/// Download whatever the pins still want, reporting progress.
|
||||
fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(cache_dir) = ctl.cache_dir() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Offline, there is nothing to download from. The pin is already recorded,
|
||||
// so it resumes on reconnect rather than being lost.
|
||||
if ctl.is_offline() {
|
||||
log::info!("offline: the pin is recorded and will download on reconnect");
|
||||
return;
|
||||
}
|
||||
|
||||
let rx = library::spawn_pin_fetch(
|
||||
conn.clone(),
|
||||
library::catalog_path(&conn.account),
|
||||
cache_dir,
|
||||
// Pinned originals are exempt from the budget, but a pin fetch also
|
||||
// stores passively when it finds an image already cached, so the worker
|
||||
// still needs the user's ceiling rather than the catalog's floor.
|
||||
ctl.cache_budget.get(),
|
||||
);
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
let weak = window.as_weak();
|
||||
let ctl_cb = ctl.clone();
|
||||
|
||||
// The longest-running transfer the app does, and the one most likely to be
|
||||
// watched from another view — which is the whole reason the register
|
||||
// exists (FR-NC-6, FR-NC-6c).
|
||||
let job = ctl.activity.begin(
|
||||
crate::activity::Kind::Download,
|
||||
"Keeping photographs on this device",
|
||||
);
|
||||
|
||||
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_pin_total(0);
|
||||
job.fail("stopped without finishing");
|
||||
stop(&ctl_cb.pin_timer);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match msg {
|
||||
library::PinMessage::Planned { total } => {
|
||||
w.set_library_pin_total(total as i32);
|
||||
w.set_library_pin_done(0);
|
||||
job.total(total);
|
||||
}
|
||||
library::PinMessage::Stored { done } => {
|
||||
w.set_library_pin_done(done as i32);
|
||||
job.progress(done, w.get_library_pin_total() as usize);
|
||||
// The "On this device" count grows as they land, so
|
||||
// the chip agrees with the progress line beside it.
|
||||
refresh_local_count(&w, &ctl_cb);
|
||||
}
|
||||
library::PinMessage::Done { stored, bytes } => {
|
||||
log::info!(
|
||||
"pin complete: {stored} original(s), {:.1} MB",
|
||||
bytes as f64 / 1_048_576.0
|
||||
);
|
||||
job.finish(format!(
|
||||
"{stored} photograph(s) · {}",
|
||||
crate::activity::describe_bytes(bytes)
|
||||
));
|
||||
w.set_library_pin_total(0);
|
||||
w.set_library_pin_done(0);
|
||||
refresh_local_count(&w, &ctl_cb);
|
||||
stop(&ctl_cb.pin_timer);
|
||||
return;
|
||||
}
|
||||
library::PinMessage::Failed { message, offline } => {
|
||||
log::warn!("pin fetch stopped: {message}");
|
||||
job.fail(message.clone());
|
||||
w.set_library_pin_total(0);
|
||||
if offline {
|
||||
ctl_cb
|
||||
.reachability
|
||||
.borrow_mut()
|
||||
.mark_unreachable(message, std::time::Instant::now());
|
||||
refresh_offline(&w, &ctl_cb);
|
||||
} else {
|
||||
w.set_library_error(format!("keeping offline: {message}").into());
|
||||
}
|
||||
refresh_local_count(&w, &ctl_cb);
|
||||
stop(&ctl_cb.pin_timer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
*ctl.pin_timer.borrow_mut() = Some(timer);
|
||||
}
|
||||
|
||||
/// Refresh the "On this device" count from the catalog.
|
||||
fn refresh_local_count(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let borrow = ctl.catalog.borrow();
|
||||
let Some(catalog) = borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6a
|
||||
/// Whether every image in the scoped collection is pinned.
|
||||
///
|
||||
/// Read from the catalog rather than remembered, because a pin outlives the
|
||||
/// session that made it: reopening the library must show the button already
|
||||
/// active, or the user would pin the same collection twice.
|
||||
pub(super) fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool {
|
||||
if images.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let placeholders = std::iter::repeat_n("?", images.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let params: Vec<rusqlite::types::Value> = images
|
||||
.iter()
|
||||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||||
.collect();
|
||||
|
||||
let pinned: i64 = catalog
|
||||
.connection()
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT count(*) FROM image_cache
|
||||
WHERE pinned = 1 AND image_id IN ({placeholders})"
|
||||
),
|
||||
rusqlite::params_from_iter(params.iter()),
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
pinned as usize == images.len()
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-9
|
||||
/// Paint the connectivity state into the window.
|
||||
///
|
||||
/// Called wherever reachability may have moved, rather than by the state
|
||||
/// itself: `Reachability` is in `dr-sync` and knows nothing about a window,
|
||||
/// which is what keeps it testable without a display server.
|
||||
pub(super) fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let reach = ctl.reachability.borrow();
|
||||
// TRACES: FR-PLAT-AND-2
|
||||
// A lost root wins over a dead network, and does so even when both are
|
||||
// true — which is the ordinary case, since the scan that discovered the
|
||||
// grant was gone was also the last request the app made. Reported the
|
||||
// other way round the user is told to wait for a connection that is
|
||||
// working, and the thing that would actually fix it is never mentioned.
|
||||
let lost = ctl.root_lost.borrow();
|
||||
let offline = reach.is_offline() || lost.is_some();
|
||||
|
||||
window.set_library_offline(offline);
|
||||
window.set_library_offline_reason(match lost.as_deref() {
|
||||
Some(why) => why.into(),
|
||||
None => reach.reason().unwrap_or_default().into(),
|
||||
});
|
||||
window.set_library_offline_since(
|
||||
// A duration is what a network outage has and a revoked permission
|
||||
// does not: "for 4 minutes" invites waiting, and waiting is precisely
|
||||
// what will not help here.
|
||||
if lost.is_some() {
|
||||
slint::SharedString::default()
|
||||
} else {
|
||||
reach
|
||||
.offline_for(std::time::Instant::now())
|
||||
.map(describe_duration)
|
||||
.unwrap_or_default()
|
||||
.into()
|
||||
},
|
||||
);
|
||||
drop(lost);
|
||||
|
||||
// A stale scan error under an offline banner reports one problem twice.
|
||||
if offline {
|
||||
window.set_library_error("".into());
|
||||
}
|
||||
drop(reach);
|
||||
|
||||
// TRACES: FR-CAT-9
|
||||
// Back online: send whatever the outbox is still holding.
|
||||
//
|
||||
// Hung off the one function that paints connectivity rather than off each
|
||||
// of the seven places that move it — a drain that had to be remembered at
|
||||
// every call site is a drain that will be forgotten at one of them, and
|
||||
// the symptom is an edit that stays queued until the app is restarted.
|
||||
//
|
||||
// `start_outbox_drain` is a no-op when the outbox is empty and while one
|
||||
// is already running, so calling it on every repaint costs a directory
|
||||
// walk that finds nothing.
|
||||
if !offline {
|
||||
start_outbox_drain(window, ctl);
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-9 | FR-NC-10
|
||||
/// Upload the sidecars queued while this device had no connection.
|
||||
///
|
||||
/// Guarded on the timer rather than on a flag: the timer *is* the "a drain is
|
||||
/// running" state, and a second one started beside it would drain the same
|
||||
/// channel twice.
|
||||
fn start_outbox_drain(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
if ctl.outbox_timer.borrow().is_some() {
|
||||
return;
|
||||
}
|
||||
if !ctl.outbox_maybe_dirty.get() {
|
||||
return;
|
||||
}
|
||||
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
|
||||
return;
|
||||
};
|
||||
if crate::sidecar_cache::SidecarCache::open(cache_dir.clone())
|
||||
.pending()
|
||||
.is_empty()
|
||||
{
|
||||
// Nothing there. Recorded so the next hundred repaints skip the walk;
|
||||
// a write that queues sets it again.
|
||||
ctl.outbox_maybe_dirty.set(false);
|
||||
return;
|
||||
}
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let rx = library::spawn_outbox_drain(conn.clone(), cache_dir);
|
||||
let job = ctl
|
||||
.activity
|
||||
.begin(crate::activity::Kind::Upload, "Uploading queued edits");
|
||||
|
||||
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(250),
|
||||
move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
match rx.try_recv() {
|
||||
Ok(library::SidecarMessage::Finished {
|
||||
written,
|
||||
queued,
|
||||
failed,
|
||||
last_error,
|
||||
}) => {
|
||||
if failed > 0 {
|
||||
log::warn!(
|
||||
"{failed} queued sidecar(s) still undelivered: {}",
|
||||
last_error.clone().unwrap_or_default()
|
||||
);
|
||||
// Not `fail`: the edits are still safely queued, and
|
||||
// reporting this as a loss would be wrong.
|
||||
job.finish(format!("{written} uploaded · {queued} still queued"));
|
||||
} else if written > 0 {
|
||||
log::info!("{written} queued sidecar(s) uploaded");
|
||||
job.finish(format!("{written} queued edit(s) uploaded"));
|
||||
w.set_library_status(format!("{written} queued edit(s) uploaded").into());
|
||||
} else {
|
||||
job.finish_quietly();
|
||||
}
|
||||
ctl_cb.outbox_maybe_dirty.set(queued > 0);
|
||||
stop(&ctl_cb.outbox_timer);
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
job.finish_quietly();
|
||||
stop(&ctl_cb.outbox_timer);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
*ctl.outbox_timer.borrow_mut() = Some(timer);
|
||||
}
|
||||
|
||||
/// A coarse "how long ago", for the offline banner.
|
||||
///
|
||||
/// Deliberately imprecise: the user wants to know whether this just happened
|
||||
/// or has been true for a while, and a live-counting seconds display would
|
||||
/// draw the eye to a number that changes without meaning anything.
|
||||
fn describe_duration(d: std::time::Duration) -> String {
|
||||
let secs = d.as_secs();
|
||||
if secs < 60 {
|
||||
"just now".to_string()
|
||||
} else if secs < 3600 {
|
||||
format!("{}m ago", secs / 60)
|
||||
} else {
|
||||
format!("{}h ago", secs / 3600)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
//! Opening a library and bringing its catalog up to date: the launch path,
|
||||
//! the background scan, and the worker that checks the catalog file before
|
||||
//! either touches it.
|
||||
//!
|
||||
//! Split from the loading of one window over the catalog (`window`) because
|
||||
//! this is the coarser cycle around it — once per library open or rescan
|
||||
//! rather than once per scroll — and from the offline/pin bookkeeping
|
||||
//! (`offline`) it hands off to on a failure. See `docs/dev/code-health.md`
|
||||
//! CH-1.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
use dr_catalog::Catalog;
|
||||
use dr_sync::{Account, AccountStore, Connection};
|
||||
use dr_types::FormatFilter;
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::library::{self, ScanMessage};
|
||||
use crate::{AppWindow, View};
|
||||
|
||||
use super::controller::{stop, LibraryController};
|
||||
use super::offline::refresh_offline;
|
||||
use super::ratings_keywords::refresh_xmp_conflicts;
|
||||
use super::sync::start_sweep;
|
||||
use super::timeline::{apply_place, note_place};
|
||||
use super::window::{load_window, window_start};
|
||||
|
||||
/// 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);
|
||||
// TRACES: FR-UI-8
|
||||
// The scope changed, or the collection under it did. Either way this is the
|
||||
// entry point every such change comes through, which is why the record is
|
||||
// taken here rather than at each of the callers.
|
||||
note_place(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: &AccountStore,
|
||||
account: Account,
|
||||
) {
|
||||
// The credential is fetched only where the connector wants one; a folder
|
||||
// library has none, and asking the keyring for it would fail the one
|
||||
// backend that needs nothing.
|
||||
let conn = match store.connection(&account, crate::remote::needs_secret(&account)) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
window.set_library_error(format!("credentials: {e}").into());
|
||||
window.set_active_view(View::Library);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let filter = account.format_filter();
|
||||
*ctl.session.borrow_mut() = Some((conn.clone(), filter.clone()));
|
||||
|
||||
// TRACES: FR-UI-8
|
||||
// Where this library's position is kept, and what this device signs it
|
||||
// with. Before the catalog opens, because `adopt_catalog` is what reads the
|
||||
// record back and it is the next thing to run.
|
||||
*ctl.place_store.borrow_mut() = Some(crate::place::PlaceStore::open_at(library::place_path(
|
||||
&conn.account,
|
||||
)));
|
||||
*ctl.place_device.borrow_mut() = device_id(&conn.account);
|
||||
// A fresh library is one nobody has moved in yet, so a place arriving from
|
||||
// another device may still be applied — see `place_untouched`.
|
||||
ctl.place_untouched.set(true);
|
||||
|
||||
window.set_active_view(View::Library);
|
||||
window.set_library_open(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(library_root_label(&conn.account).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(&conn.account);
|
||||
|
||||
// Before the scan, not after it: the grid can be filled from disk now and
|
||||
// the scan is only ever going to add to it.
|
||||
//
|
||||
// And it is the gate on the scan, not merely a prelude to it — a damaged
|
||||
// catalog has a question on screen, and a scan writing into it while that
|
||||
// question is unanswered is how the last good copy gets destroyed. That
|
||||
// gate is why the scan starts from the drain below rather than from here:
|
||||
// the answer no longer arrives on the next line.
|
||||
// TRACES: FR-UI-8
|
||||
// Started beside the catalog open rather than after it, so the handover has
|
||||
// a round trip's head start on the user deciding what to look at. It
|
||||
// resolves nothing until the catalog is there — see `fetch_remote_place`.
|
||||
fetch_remote_place(window, &ctl, &coll_ctl, &conn);
|
||||
|
||||
open_catalog_soon(window, ctl, coll_ctl, conn, filter, path);
|
||||
}
|
||||
|
||||
/// Open the catalog on a worker, then show it and start the scan.
|
||||
///
|
||||
/// # Why this is not `show_catalog_now` on the spot
|
||||
///
|
||||
/// It was, and it was the largest blocking call on the launch path.
|
||||
/// [`Catalog::open_verified`] runs `PRAGMA quick_check`, which reads **every
|
||||
/// page** of the database, and then `Catalog::open`, which takes a full copy of
|
||||
/// the file before a migration and rewrites its structure. On a 50,000-image
|
||||
/// library that is tens of megabytes of I/O, and all of it happened before the
|
||||
/// window had painted anything.
|
||||
///
|
||||
/// On Android that is not a stutter but an ANR. `dr_ui::run` is called from
|
||||
/// `android_main` and does not reach `window.run()` — the first `poll_events`,
|
||||
/// and so the first time anything drains the activity's input channel — until
|
||||
/// every line above it has finished. Five seconds of that and the system offers
|
||||
/// to kill the app.
|
||||
///
|
||||
/// [`crate::recovery_ui`] and `recovery`'s own module documentation both say the
|
||||
/// check belongs "at startup, where a failure has a user in front of it who can
|
||||
/// answer a question". That was always the intent; this is what makes it true,
|
||||
/// because the question can only be asked once there is an interface to ask it
|
||||
/// in.
|
||||
///
|
||||
/// # What the user sees meanwhile
|
||||
///
|
||||
/// `library-opening`, which the grid's empty state draws as "Opening the
|
||||
/// library…" rather than as "Scanning…". They are different answers — one is
|
||||
/// reading a file on this device, the other is walking a tree over the network
|
||||
/// — and the grid already refuses to conflate the two states it had.
|
||||
///
|
||||
/// `library-scanning` stays true throughout, which is what keeps the gate above
|
||||
/// from being merely advisory: the header hides Rescan while it is set, so
|
||||
/// there is no button that could start a scan into a catalog this has not
|
||||
/// finished checking.
|
||||
fn open_catalog_soon(
|
||||
window: &AppWindow,
|
||||
ctl: Rc<LibraryController>,
|
||||
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
|
||||
conn: Connection,
|
||||
filter: FormatFilter,
|
||||
path: PathBuf,
|
||||
) {
|
||||
// Already open: a library being re-opened within one session, which is the
|
||||
// case `show_catalog_now` short-circuited. There is nothing to wait for and
|
||||
// nothing to say, so the scan starts immediately as it always did.
|
||||
if ctl.catalog.borrow().is_some() {
|
||||
begin_scan(window, &ctl, &coll_ctl, conn, filter, path);
|
||||
return;
|
||||
}
|
||||
|
||||
window.set_library_opening(true);
|
||||
window.set_library_status("Reading the catalog on this device…".into());
|
||||
|
||||
let rx = spawn_catalog_open(path.clone());
|
||||
let weak = window.as_weak();
|
||||
let held = ctl.clone();
|
||||
let timer = slint::Timer::default();
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
// Tighter than the scan's 120 ms: this one lands once, and everything
|
||||
// the grid can show is waiting behind it.
|
||||
std::time::Duration::from_millis(30),
|
||||
move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let got = match rx.try_recv() {
|
||||
Ok(got) => got,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
// The worker died without answering — a panic inside SQLite,
|
||||
// realistically. Treated as "no catalog to show", which is what
|
||||
// the synchronous path did with any error it could not
|
||||
// classify: the scan is the thing that has to work.
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
CatalogOpen::Failed("the catalog worker stopped without answering".into())
|
||||
}
|
||||
};
|
||||
stop(&held.catalog_timer);
|
||||
w.set_library_opening(false);
|
||||
|
||||
match got {
|
||||
CatalogOpen::Opened(cat) => {
|
||||
adopt_catalog(&w, &held, &coll_ctl, cat);
|
||||
begin_scan(
|
||||
&w,
|
||||
&held,
|
||||
&coll_ctl,
|
||||
conn.clone(),
|
||||
filter.clone(),
|
||||
path.clone(),
|
||||
);
|
||||
}
|
||||
CatalogOpen::Corrupt(detail) => {
|
||||
// No scan, and this is the whole reason the scan waits for
|
||||
// this answer: `Catalog::open` succeeds on a file whose
|
||||
// header survived, so a scan would write ETags and image
|
||||
// rows into damaged pages while the user is still reading
|
||||
// the question — turning a file that had a backup into one
|
||||
// where the backup is the only copy left.
|
||||
//
|
||||
// `offer` clears the scanning state itself, for exactly
|
||||
// that reason, so nothing here does it twice.
|
||||
crate::recovery_ui::offer(&w, &path, &detail);
|
||||
}
|
||||
CatalogOpen::Failed(why) => {
|
||||
// Not surfaced: this is a first run more often than it is
|
||||
// anything else, the empty state already says the scan is
|
||||
// running, and an error here would contradict a scan that
|
||||
// is working perfectly. If the scan fails too, it reports
|
||||
// for both of them.
|
||||
log::info!("no catalog to show before the scan: {why}");
|
||||
begin_scan(
|
||||
&w,
|
||||
&held,
|
||||
&coll_ctl,
|
||||
conn.clone(),
|
||||
filter.clone(),
|
||||
path.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
*ctl.catalog_timer.borrow_mut() = Some(timer);
|
||||
}
|
||||
|
||||
/// What the catalog worker found.
|
||||
enum CatalogOpen {
|
||||
/// Checked, opened, migrated forward and backfilled.
|
||||
Opened(Catalog),
|
||||
/// `PRAGMA quick_check` failed. Carries SQLite's own words, because the
|
||||
/// message the user is shown is the diagnosis rather than a paraphrase.
|
||||
Corrupt(String),
|
||||
/// Anything else: no catalog file yet, a permission, a locked database.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Check, open and migrate the catalog on a thread of its own.
|
||||
///
|
||||
/// `Catalog` holds a `rusqlite::Connection`, which is `Send` and not `Sync` —
|
||||
/// exactly the shape that can be built on a worker and handed over once, which
|
||||
/// is what this does. Nothing else touches the file while it runs: this is the
|
||||
/// only opener on the launch path, and the scan that opens its own connection
|
||||
/// does not start until the answer has landed.
|
||||
fn spawn_catalog_open(path: PathBuf) -> Receiver<CatalogOpen> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let started = std::time::Instant::now();
|
||||
let message = match Catalog::open_verified(&path) {
|
||||
Ok(cat) => {
|
||||
// The number this change exists for. Worth an `info` line on
|
||||
// every launch: it is the one figure that says whether a slow
|
||||
// start is the catalog or something else, and it is not
|
||||
// measurable from anywhere else.
|
||||
log::info!(
|
||||
"catalog checked and opened in {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
CatalogOpen::Opened(cat)
|
||||
}
|
||||
Err(dr_catalog::CatalogError::Corrupt { detail }) => CatalogOpen::Corrupt(detail),
|
||||
Err(e) => CatalogOpen::Failed(e.to_string()),
|
||||
};
|
||||
let _ = tx.send(message);
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
/// Start the scan that brings the catalog up to date.
|
||||
///
|
||||
/// Split out of [`open`] because it no longer runs there — see
|
||||
/// [`open_catalog_soon`] — and shared with the two paths that reach it once the
|
||||
/// catalog has been answered for.
|
||||
fn begin_scan(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
conn: Connection,
|
||||
filter: FormatFilter,
|
||||
path: PathBuf,
|
||||
) {
|
||||
log::info!(
|
||||
"scanning {} for {} format(s) → {}",
|
||||
if conn.account.root.is_empty() {
|
||||
"<account root>"
|
||||
} else {
|
||||
&conn.account.root
|
||||
},
|
||||
filter.iter().count(),
|
||||
path.display()
|
||||
);
|
||||
window.set_library_status("Starting…".into());
|
||||
|
||||
let rx = library::spawn_scan(
|
||||
conn.clone(),
|
||||
conn.account.root.clone(),
|
||||
filter,
|
||||
path.clone(),
|
||||
);
|
||||
|
||||
drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), 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();
|
||||
|
||||
// Indeterminate for as long as it runs: a recursive walk discovers its own
|
||||
// extent, so the count it reports is what it has *found*, never a fraction
|
||||
// of what there is (FR-CAT-1).
|
||||
//
|
||||
// Named after the folder, because two accounts or two roots produce rows
|
||||
// that are otherwise identical.
|
||||
let title = match ctl.session.borrow().as_ref() {
|
||||
Some((c, _)) if !c.account.root.is_empty() => {
|
||||
format!("Scanning {}", c.account.root)
|
||||
}
|
||||
_ => "Scanning the library".to_string(),
|
||||
};
|
||||
let job = ctl.activity.begin(crate::activity::Kind::Scan, title);
|
||||
|
||||
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());
|
||||
job.fail("ended unexpectedly");
|
||||
}
|
||||
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")
|
||||
};
|
||||
job.detail(status.clone());
|
||||
w.set_library_status(status.into());
|
||||
}
|
||||
ScanMessage::Done {
|
||||
found,
|
||||
total,
|
||||
pruned,
|
||||
elapsed_ms,
|
||||
judgements,
|
||||
} => {
|
||||
log::info!(
|
||||
"scan complete: {total} images ({found} listed, \
|
||||
{pruned} folders unchanged, {judgements} judgements \
|
||||
taken in) in {elapsed_ms} ms"
|
||||
);
|
||||
w.set_library_scanning(false);
|
||||
|
||||
// A completed scan is the strongest possible evidence
|
||||
// the server is reachable, so it clears offline mode
|
||||
// without needing a probe of its own.
|
||||
if ctl
|
||||
.reachability
|
||||
.borrow_mut()
|
||||
.mark_reachable(std::time::Instant::now())
|
||||
{
|
||||
log::info!("back online");
|
||||
}
|
||||
// TRACES: FR-PLAT-AND-2
|
||||
// And it is the only evidence that clears a lost root,
|
||||
// for the same reason: the walk began by listing the
|
||||
// root, so a scan that finished is a root that opened.
|
||||
// The rows it marked offline are restored one at a
|
||||
// time by `library::persist`, as each file is listed
|
||||
// again — this only stops the banner claiming what is
|
||||
// no longer true.
|
||||
if ctl.root_lost.borrow_mut().take().is_some() {
|
||||
log::info!("library folder is readable again");
|
||||
}
|
||||
refresh_offline(&w, ctl);
|
||||
// TRACES: FR-CAT-13
|
||||
// The pull may have found an `.xmp` that disagrees
|
||||
// with the catalog; the settings page offers the
|
||||
// reload, and this is what tells it how many.
|
||||
refresh_xmp_conflicts(&w, ctl);
|
||||
|
||||
// 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")
|
||||
};
|
||||
// TRACES: FR-CAT-8 | FR-NC-9
|
||||
// Said out loud, because a grid that silently gains
|
||||
// three hundred stars is indistinguishable from one
|
||||
// that has gone wrong — and because for as long as
|
||||
// this number was structurally zero, the photographer
|
||||
// had no way to tell that a cull made on another
|
||||
// device had failed to arrive.
|
||||
let status = if judgements > 0 {
|
||||
format!("{status} · {judgements} from other devices")
|
||||
} else {
|
||||
status
|
||||
};
|
||||
job.finish(status.clone());
|
||||
w.set_library_status(status.into());
|
||||
|
||||
// Usually already open — `show_catalog_now` opened it
|
||||
// before this scan started, so the grid has been
|
||||
// showing the previous run's catalog all along and the
|
||||
// rows this scan found are new rows in that same file.
|
||||
// Only a first run, where there was nothing to open,
|
||||
// reaches the second arm.
|
||||
let opened = if ctl.catalog.borrow().is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Catalog::open(&catalog_path).map(|cat| {
|
||||
*ctl.catalog.borrow_mut() = Some(cat);
|
||||
})
|
||||
};
|
||||
|
||||
match opened {
|
||||
Ok(()) => {
|
||||
// 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.
|
||||
{
|
||||
let borrow = ctl.catalog.borrow();
|
||||
if let Some(cat) = borrow.as_ref() {
|
||||
crate::collections_ui::refresh_tree(&w, &coll_ctl, 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 {
|
||||
message,
|
||||
offline,
|
||||
lost_root,
|
||||
} => {
|
||||
log::warn!("scan failed: {message}");
|
||||
w.set_library_scanning(false);
|
||||
// Recorded as a failure even where it is only the
|
||||
// connection: the grid's offline banner says the server
|
||||
// is unreachable, and this says which piece of work
|
||||
// stopped because of it.
|
||||
job.fail(message.clone());
|
||||
|
||||
if lost_root {
|
||||
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||
// The library folder itself could not be opened —
|
||||
// a share withdrawn, an unplugged drive, and in
|
||||
// time a revoked document-tree grant. The worker
|
||||
// has already marked every row under this root
|
||||
// offline and deleted none of them; this is the
|
||||
// half the user sees.
|
||||
//
|
||||
// Tested first because it is also true that the
|
||||
// library is unreachable, and the generic answer
|
||||
// would be reached first and be less useful.
|
||||
*ctl.root_lost.borrow_mut() = Some(message);
|
||||
refresh_offline(&w, ctl);
|
||||
// Same reason as the offline arm below: without
|
||||
// this a launch that began with a revoked grant
|
||||
// shows an empty grid, which is the one impression
|
||||
// this whole path exists to avoid.
|
||||
open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl);
|
||||
} else if offline {
|
||||
// Not an error state. The catalog from the last
|
||||
// successful scan is still on disk and still
|
||||
// accurate for everything already indexed, so the
|
||||
// grid keeps working — it simply cannot learn about
|
||||
// anything added on the server since.
|
||||
ctl.reachability
|
||||
.borrow_mut()
|
||||
.mark_unreachable(message, std::time::Instant::now());
|
||||
refresh_offline(&w, ctl);
|
||||
// Show whatever the catalog holds. Without this the
|
||||
// grid stays empty on a launch that began offline,
|
||||
// which is precisely the case offline mode exists
|
||||
// for.
|
||||
open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl);
|
||||
} else {
|
||||
w.set_library_error(message.into());
|
||||
}
|
||||
stop(&ctl.scan_timer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
*ctl.scan_timer.borrow_mut() = Some(timer);
|
||||
}
|
||||
|
||||
/// Start a scan against the configured library.
|
||||
///
|
||||
/// Shared by the rescan button and by the offline banner's retry, which are
|
||||
/// the same operation: a scan is the only request that both proves the server
|
||||
/// is reachable and brings the catalog up to date. Keeping them one function
|
||||
/// is what stops "retry" from quietly becoming a weaker probe than "rescan".
|
||||
pub(crate) fn start_rescan(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
) {
|
||||
let Some((conn, filter)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
window.set_library_scanning(true);
|
||||
window.set_library_error(slint::SharedString::new());
|
||||
window.set_library_status("Rescanning…".into());
|
||||
|
||||
let path = library::catalog_path(&conn.account);
|
||||
let rx = library::spawn_scan(
|
||||
conn.clone(),
|
||||
conn.account.root.clone(),
|
||||
filter,
|
||||
path.clone(),
|
||||
);
|
||||
drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-9
|
||||
/// Show the catalog after a scan that could not reach the server.
|
||||
///
|
||||
/// The whole point of offline mode: a scan is how the grid normally gets its
|
||||
/// catalog handle, so a failed one previously left the library empty even
|
||||
/// though a complete catalog was sitting on disk from the last successful run.
|
||||
/// The images are all still there, their thumbnails are in the shards, and
|
||||
/// rating and collecting them are local writes.
|
||||
///
|
||||
/// The sweep is deliberately **not** started — it exists to fetch headers over
|
||||
/// the network, so offline it would do nothing but fail once per image.
|
||||
fn open_catalog_for_offline(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
catalog_path: &std::path::Path,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
) {
|
||||
if ctl.catalog.borrow().is_some() {
|
||||
// Already open — a rescan that failed, rather than a launch that
|
||||
// began offline. The grid is showing the catalog already.
|
||||
load_window(window, ctl);
|
||||
return;
|
||||
}
|
||||
|
||||
match Catalog::open(catalog_path) {
|
||||
Ok(cat) => {
|
||||
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
|
||||
*ctl.catalog.borrow_mut() = Some(cat);
|
||||
load_window(window, ctl);
|
||||
}
|
||||
Err(e) => {
|
||||
// No catalog and no server. This is the one genuinely empty case:
|
||||
// a first run that never reached the server has nothing indexed.
|
||||
log::warn!("offline with no local catalog: {e}");
|
||||
window.set_library_error(
|
||||
"Offline, and this library has not been scanned on this device yet.".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a run of geometry changes has to stop for before the window is
|
||||
/// reloaded against it.
|
||||
///
|
||||
/// Long enough to swallow a whole gesture's worth of steps, short enough that a
|
||||
/// single deliberate step still feels immediate.
|
||||
const GEOMETRY_SETTLE: std::time::Duration = std::time::Duration::from_millis(140);
|
||||
|
||||
/// Reload the window once the grid's geometry has stopped changing.
|
||||
///
|
||||
/// # Why this is deferred when a scroll is not
|
||||
///
|
||||
/// A pinch is not one zoom step, it is a stream of them, and every step
|
||||
/// changes both the column count and the capacity — two reports. Each report
|
||||
/// used to re-query the catalog, rebuild all 360 rows of the model, re-read the
|
||||
/// badges and ratings for every one of them and spawn a thumbnail batch,
|
||||
/// synchronously, on the thread that is trying to draw the frame. Twice per
|
||||
/// step. That is why zooming juddered while scrolling the same grid is smooth:
|
||||
/// a scroll reloads a few times per screenful, a zoom reloaded twice a frame.
|
||||
///
|
||||
/// None of it is urgent, because none of it is about *which* photographs are on
|
||||
/// screen. A column change moves the cells and a zoom resizes them, but the
|
||||
/// window holds the same images either way — the model already has them, and
|
||||
/// the cells re-flow from `columns` and `cell-size` without Rust being involved
|
||||
/// at all. What the reload actually recomputes is which cells begin a row, so
|
||||
/// the month headings land in the right places, and which thumbnail size class
|
||||
/// to ask for now. Both can wait for the gesture to finish.
|
||||
///
|
||||
/// # The anchor
|
||||
///
|
||||
/// Captured on the *first* report of a run rather than read when the timer
|
||||
/// fires. As the grid re-flows, the viewport keeps its pixel offset while the
|
||||
/// rows move underneath it, so the view drifts and reports its drift — reading
|
||||
/// the anchor at the end would faithfully return to wherever it had wandered
|
||||
/// to. Taking it at the start returns to the photograph the user was actually
|
||||
/// looking at when they began the gesture.
|
||||
pub(super) fn schedule_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// Only the first report of a run sets it; the rest of the run reuses it.
|
||||
if ctl.pending_anchor.get().is_none() {
|
||||
ctl.pending_anchor.set(Some(ctl.resume_at.get()));
|
||||
}
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
let weak = window.as_weak();
|
||||
let ctl_cb = ctl.clone();
|
||||
timer.start(slint::TimerMode::SingleShot, GEOMETRY_SETTLE, move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let anchor = ctl_cb.pending_anchor.take().unwrap_or(0);
|
||||
if !w.get_library_visible() {
|
||||
return;
|
||||
}
|
||||
|
||||
// The window re-centred on the anchor, and the viewport sent back to
|
||||
// it: cells are drawn at their absolute place in the library, so a
|
||||
// change to `columns` moves every one of them and a viewport left
|
||||
// where it was would be pointing at rows the window no longer covers.
|
||||
let window_size = *ctl_cb.window.borrow();
|
||||
let total = w.get_library_total().max(0) as usize;
|
||||
*ctl_cb.offset.borrow_mut() = window_start(anchor, window_size, total);
|
||||
load_window(&w, &ctl_cb);
|
||||
w.set_library_scroll_to(anchor as i32);
|
||||
w.set_library_scroll_token(w.get_library_scroll_token() + 1);
|
||||
});
|
||||
|
||||
// Replacing the slot drops the previous timer, which is what makes this
|
||||
// coalesce: only the last report of a run lives long enough to fire.
|
||||
*ctl.geometry_timer.borrow_mut() = Some(timer);
|
||||
}
|
||||
|
||||
/// Show what the catalog already holds, without waiting for the scan.
|
||||
///
|
||||
/// **Blocking, and no longer on the launch path.** [`open_catalog_soon`] is
|
||||
/// what a launch goes through; this is what [`crate::recovery_ui`] goes through
|
||||
/// once the user has answered the damage question, where the event loop is
|
||||
/// already running, the file has just been replaced under a `forget_catalog`,
|
||||
/// and there is a `recovery-busy` state on screen saying so. Same reasoning as
|
||||
/// `recovery_ui::answer` gives for doing the file copy itself in place: nothing
|
||||
/// else is on screen to block, and a worker would buy a spinner and cost the
|
||||
/// guarantee that nothing else touches the file while it is being replaced.
|
||||
///
|
||||
/// # Why a launch should not be a scan
|
||||
///
|
||||
/// A launch does not have to discover the library. The catalog from the last
|
||||
/// run is on disk, complete, with its thumbnails in the shards beside it —
|
||||
/// which is precisely the state [`open_catalog_for_offline`] already relies on
|
||||
/// when the server cannot be reached. Every launch that *could* reach the
|
||||
/// server threw that away and sat on "Scanning…" over an empty grid for as long
|
||||
/// as a recursive WebDAV walk of the whole tree takes. On a real library, and
|
||||
/// especially on a phone's connection, that walk is the entire startup time,
|
||||
/// spent hiding a grid that was ready before it began.
|
||||
///
|
||||
/// The scan still runs and still replaces this the moment it lands. What it no
|
||||
/// longer does is gate the first paint on the network.
|
||||
///
|
||||
/// Silent when there is no catalog yet: that is a genuine first run, the empty
|
||||
/// state already says "Scanning…", and an error here would contradict a scan
|
||||
/// that is working perfectly. `Catalog::open` creates the file in that case, so
|
||||
/// what the grid reads is an empty catalog rather than a failure.
|
||||
/// Returns whether it is safe to go on and scan.
|
||||
///
|
||||
/// `false` means the catalog is damaged and the recovery question is up. The
|
||||
/// caller must not start a scan on that answer: `Catalog::open` succeeds on a
|
||||
/// file whose header survived, so the scan would write ETags and image rows
|
||||
/// into damaged pages while the user is still reading the question — turning a
|
||||
/// file that had a backup into one where the backup is the only copy left.
|
||||
pub(crate) fn show_catalog_now(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
catalog_path: &std::path::Path,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
) -> bool {
|
||||
if ctl.catalog.borrow().is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Verified rather than plain: this is a moment where a full check is
|
||||
// affordable and there is a user in front of it who can answer the
|
||||
// question — they have just answered one. See `dr_catalog::recovery` for
|
||||
// why it is not on every open.
|
||||
let cat = match Catalog::open_verified(catalog_path) {
|
||||
Ok(cat) => cat,
|
||||
Err(dr_catalog::CatalogError::Corrupt { detail }) => {
|
||||
crate::recovery_ui::offer(window, catalog_path, &detail);
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
// Not surfaced: the scan is the thing that has to work, and it is
|
||||
// still running. If it fails too, it reports for both of them.
|
||||
log::info!("no catalog to show before the scan: {e}");
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
adopt_catalog(window, ctl, coll_ctl, cat);
|
||||
true
|
||||
}
|
||||
|
||||
/// Take an opened catalog into the interface.
|
||||
///
|
||||
/// The UI half of opening a catalog, separated from the opening itself so that
|
||||
/// [`open_catalog_soon`] — which does the opening on a worker — and
|
||||
/// [`show_catalog_now`] — which still does it in place, for
|
||||
/// [`crate::recovery_ui`], where the event loop is already running and the file
|
||||
/// has just been replaced — describe the grid the same way.
|
||||
///
|
||||
/// Everything here is UI-thread work by necessity: it fills the sidebar and the
|
||||
/// grid model.
|
||||
fn adopt_catalog(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
cat: Catalog,
|
||||
) {
|
||||
// Before anything else can see this catalog, and exactly once per open —
|
||||
// the callers' early return on an already-open catalog is what makes it
|
||||
// once. The job queue is durable, so a run that was killed mid-job left its
|
||||
// row marked `Running` with nobody holding it; recovery hands those back to
|
||||
// be resumed rather than lost, and drops jobs naming photographs that have
|
||||
// since been deleted.
|
||||
//
|
||||
// Here rather than wherever a runner starts, because there is no owner
|
||||
// column in `jobs`: a second recovery pass while a worker held a claim
|
||||
// would take that claim away from it.
|
||||
match dr_catalog::runner::recover(cat.connection()) {
|
||||
Ok(r) if r.did_anything() => log::info!(
|
||||
"job queue: {} interrupted job(s) resumed, \
|
||||
{} for deleted photographs dropped",
|
||||
r.reclaimed,
|
||||
r.reaped
|
||||
),
|
||||
Ok(_) => {}
|
||||
// Not surfaced. The queue is rebuildable like everything else in the
|
||||
// catalog, and a grid that refuses to open because a background queue
|
||||
// could not be tidied is the worse failure by some way.
|
||||
Err(e) => log::warn!("job queue could not be recovered: {e}"),
|
||||
}
|
||||
|
||||
// The sidebar before the grid, because the grid's badges read collection
|
||||
// membership — the same order the scan's completion uses.
|
||||
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
|
||||
*ctl.catalog.borrow_mut() = Some(cat);
|
||||
|
||||
// TRACES: FR-UI-8
|
||||
// Where the photographer was, before the first window is read rather than
|
||||
// after. The scope and the filter both change what the grid's query
|
||||
// returns, so restoring them afterwards would mean reading the top of the
|
||||
// whole library, drawing it, and then reading again — a visible jump on
|
||||
// every launch, and on a remote library a screenful of thumbnails fetched
|
||||
// for photographs nobody asked to see.
|
||||
//
|
||||
// The sidebar is refreshed above rather than below for the same ordering
|
||||
// reason: a scope is restored by naming a row of a tree that has to exist.
|
||||
let stored = ctl.place_store.borrow().as_ref().and_then(|s| s.load());
|
||||
match stored {
|
||||
Some(place) => apply_place(window, ctl, coll_ctl, &place),
|
||||
None => load_window(window, ctl),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-8
|
||||
/// Ask the server where the photographer was, and use it if they have not
|
||||
/// started working yet.
|
||||
///
|
||||
/// # The two halves of a restore
|
||||
///
|
||||
/// The local record is applied in [`adopt_catalog`], synchronously and before
|
||||
/// the first window is read: it is on this disk, it costs nothing, and it works
|
||||
/// with the network down. This is the other half — the record another device
|
||||
/// left — and it cannot be applied there because it has not arrived yet.
|
||||
///
|
||||
/// # Why it may be ignored when it does arrive
|
||||
///
|
||||
/// A handover is welcome on the way in and unwelcome once the photographer has
|
||||
/// started. A grid that jumped somewhere else mid-scroll, because a round trip
|
||||
/// finally landed, would be worse than never handing over at all — the user did
|
||||
/// not ask for it, cannot see why it happened, and has lost their place to a
|
||||
/// feature whose entire purpose is keeping it.
|
||||
///
|
||||
/// So `place_untouched` gates it: any scroll, scrub, scope change, filter or
|
||||
/// opened photograph closes the latch. The record is still adopted onto disk by
|
||||
/// the exchange in [`crate::derived_sync::sync_place`], so nothing is lost —
|
||||
/// it simply takes effect at the next launch instead of this one.
|
||||
///
|
||||
/// Silent throughout. A first launch against a library nobody has recorded a
|
||||
/// place for is the ordinary case, and being offline is not a failure of
|
||||
/// anything the user asked for.
|
||||
fn fetch_remote_place(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
conn: &Connection,
|
||||
) {
|
||||
if std::env::var_os("DARKROOM_NO_SYNC").is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let rx = crate::derived_sync::spawn_place_fetch(conn.clone(), conn.account.root.clone());
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
let weak = window.as_weak();
|
||||
let held = ctl.clone();
|
||||
let coll = coll_ctl.clone();
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
std::time::Duration::from_millis(120),
|
||||
move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let got = match rx.try_recv() {
|
||||
Ok(got) => got,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
stop(&held.place_fetch_timer);
|
||||
return;
|
||||
}
|
||||
};
|
||||
stop(&held.place_fetch_timer);
|
||||
|
||||
let theirs = match got {
|
||||
Ok(Some(place)) => place,
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
log::debug!("no place from the server: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Newer than what this device has, or there is nothing to learn.
|
||||
//
|
||||
// The borrow is scoped rather than dropped by hand: `apply_place`
|
||||
// below reaches back into the controller, and a `Ref` still held
|
||||
// across it is the shape a `RefCell` panic takes.
|
||||
let adopted = match held.place_store.borrow().as_ref() {
|
||||
Some(store) => store.adopt(&theirs),
|
||||
None => false,
|
||||
};
|
||||
if !adopted {
|
||||
return;
|
||||
}
|
||||
|
||||
// On disk either way; on screen only if nobody has moved.
|
||||
if !held.place_untouched.get() {
|
||||
log::info!("a newer place arrived, and will be used at the next launch");
|
||||
return;
|
||||
}
|
||||
// And only once the catalog is open — the ordinal it resolves to is
|
||||
// a query against it. A record that lands first is left on disk,
|
||||
// where `adopt_catalog` reads it.
|
||||
if held.catalog.borrow().is_none() {
|
||||
return;
|
||||
}
|
||||
log::info!("picking up where another device left off");
|
||||
apply_place(&w, &held, &coll, &theirs);
|
||||
},
|
||||
);
|
||||
*ctl.place_fetch_timer.borrow_mut() = Some(timer);
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-8
|
||||
/// What this device signs a place with.
|
||||
///
|
||||
/// The thumbnail store's client id, which is already this device's name among
|
||||
/// the clients sharing a library — it is what qualifies a shard's remote
|
||||
/// filename. Reusing it means the place and the shards a machine publishes
|
||||
/// carry one name in `.darkroom-derived/`, rather than two that have to be
|
||||
/// correlated by hand.
|
||||
///
|
||||
/// Empty where the store will not open. The field is informational, so an
|
||||
/// unsigned place is worth strictly more than no place at all.
|
||||
fn device_id(account: &Account) -> String {
|
||||
match dr_thumbs::ThumbStore::open(&library::thumbs_dir(account)) {
|
||||
Ok(store) => store.client_id().to_string(),
|
||||
Err(e) => {
|
||||
log::debug!("no device id for the place record: {e}");
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the open catalog, so the next `show_catalog_now` opens the file
|
||||
/// again rather than returning early.
|
||||
///
|
||||
/// Only recovery needs this, and it needs it for a specific reason: the file
|
||||
/// under that connection has been replaced. A handle to the catalog that was
|
||||
/// there before is a handle to a file that no longer has a name, and every
|
||||
/// read through it would return the damaged pages the recovery just moved out
|
||||
/// of the way.
|
||||
pub(crate) fn forget_catalog(ctl: &Rc<LibraryController>) {
|
||||
*ctl.catalog.borrow_mut() = None;
|
||||
}
|
||||
|
||||
/// The line in the library header that says what is being catalogued.
|
||||
///
|
||||
/// For an account with a user, the user and the chosen subtree, or the
|
||||
/// whole account when none was chosen. For a folder there is no user and
|
||||
/// the endpoint is the library, so the folder's own name: `library`, or
|
||||
/// `library/2026` when narrowed — never " · whole account", which is a
|
||||
/// sentence about a server said of a directory.
|
||||
fn library_root_label(account: &Account) -> String {
|
||||
if account.login.is_empty() {
|
||||
let folder = std::path::Path::new(&account.endpoint)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| account.endpoint.clone());
|
||||
if account.root.is_empty() {
|
||||
folder
|
||||
} else {
|
||||
format!("{folder}/{}", account.root)
|
||||
}
|
||||
} else if account.root.is_empty() {
|
||||
format!("{} · whole account", account.user_id)
|
||||
} else {
|
||||
format!("{}/{}", account.user_id, account.root)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_header_names_a_folder_library_by_its_folder() {
|
||||
let mut folder = Account::new("folder", "/var/tmp/dr-demo/library");
|
||||
assert_eq!(library_root_label(&folder), "library");
|
||||
folder.root = "2026".into();
|
||||
assert_eq!(library_root_label(&folder), "library/2026");
|
||||
|
||||
let mut cloud =
|
||||
Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan");
|
||||
assert_eq!(library_root_label(&cloud), "duncan · whole account");
|
||||
cloud.root = "PhotosRaw".into();
|
||||
assert_eq!(library_root_label(&cloud), "duncan/PhotosRaw");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,538 @@
|
||||
//! The background passes that reach beyond the loaded window: the day's
|
||||
//! catalog backup, the metadata sweep that dates every image rather than
|
||||
//! only the ones scrolled past, the whole-library thumbnail pass, and the
|
||||
//! exchange with the server that follows each of them.
|
||||
//!
|
||||
//! Split from `window` because these run on their own timers over the whole
|
||||
//! library rather than in response to what the grid is showing.
|
||||
//! See `docs/dev/code-health.md` CH-1.
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::library;
|
||||
use crate::AppWindow;
|
||||
|
||||
use super::controller::{stop, LibraryController};
|
||||
use super::open::schedule_reload;
|
||||
use super::timeline::refresh_timeline;
|
||||
use super::window::load_window;
|
||||
|
||||
/// TRACES: NFR-R2
|
||||
/// Take the day's catalog backup if one is due, off the UI thread.
|
||||
///
|
||||
/// Decided cheaply first — [`dr_catalog::recovery::backup_due`] reads a
|
||||
/// directory listing, not the catalog — so the ordinary case of "backed up
|
||||
/// this morning already" costs no thread and no connection.
|
||||
fn spawn_scheduled_backup(ctl: &Rc<LibraryController>) {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
let catalog_path = library::catalog_path(&conn.account);
|
||||
if !dr_catalog::recovery::backup_due(&catalog_path) {
|
||||
return;
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
let catalog = match dr_catalog::Catalog::open(&catalog_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("scheduled backup: opening the catalog: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = dr_catalog::recovery::backup_if_due(catalog.connection(), &catalog_path) {
|
||||
log::warn!("scheduled backup: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(super) fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// An escape hatch for running the app against a *copied* library without
|
||||
// touching the account's real server.
|
||||
//
|
||||
// Added after doing exactly that by accident: a local test launch
|
||||
// completed its thumbnail sweep, which fires this, which pushed a test
|
||||
// catalog over the live one. Redirecting `XDG_DATA_HOME` isolates the
|
||||
// catalog and the thumbnails but not the server, and nothing said so.
|
||||
//
|
||||
// The guard belongs here rather than at the call sites: the sweep firing a
|
||||
// sync is correct — the point of building thumbnails is sharing them — and
|
||||
// a flag checked in three places would eventually be missed in a fourth.
|
||||
if std::env::var_os("DARKROOM_NO_SYNC").is_some() {
|
||||
log::info!("derived sync suppressed by DARKROOM_NO_SYNC");
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((conn, _)) = 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;
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-9
|
||||
// Nothing to push to and nothing to take. Attempting it would upload
|
||||
// shards into a timeout and light the "Syncing…" indicator over work that
|
||||
// cannot start; the shards are unchanged on disk and go out on the next
|
||||
// sync once the server is back.
|
||||
if ctl.is_offline() {
|
||||
log::debug!("offline: skipping derived sync");
|
||||
return;
|
||||
}
|
||||
|
||||
let catalog_path = library::catalog_path(&conn.account);
|
||||
let scratch = catalog_path
|
||||
.parent()
|
||||
.map(|p| p.join("scratch"))
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
let _ = std::fs::create_dir_all(&scratch);
|
||||
|
||||
// TRACES: FR-EXP-7 | FR-NC-10
|
||||
// Drain the export outbox on the same pass, and before the shards. An
|
||||
// export the user was told had succeeded is waiting here, and it is the
|
||||
// one thing in this directory that exists nowhere else — a thumbnail
|
||||
// shard can be rebuilt from the originals, and the catalog is an index.
|
||||
//
|
||||
// Fire-and-forget rather than reported: it runs on its own thread and
|
||||
// clears entries as they land, so a partial run leaves the rest queued
|
||||
// for next time and nothing is lost by not watching it. It reports
|
||||
// through the log until an export has a place in the activity list.
|
||||
{
|
||||
let outbox = crate::export::outbox_dir(&conn.account);
|
||||
if crate::export::pending_count(&outbox) > 0 {
|
||||
let rx = crate::export::spawn_upload(conn.clone(), conn.account.root.clone(), outbox);
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(msg) = rx.recv() {
|
||||
match msg {
|
||||
crate::export::UploadMessage::Status(s) => log::info!("export: {s}"),
|
||||
crate::export::UploadMessage::Finished {
|
||||
uploaded,
|
||||
remaining,
|
||||
error,
|
||||
} => {
|
||||
log::info!("export: {uploaded} uploaded, {remaining} still queued");
|
||||
if let Some(e) = error {
|
||||
log::warn!("export upload stopped: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.set_library_syncing(true);
|
||||
let rx = crate::derived_sync::spawn_sync(
|
||||
conn.clone(),
|
||||
conn.account.root.clone(),
|
||||
library::thumbs_dir(&conn.account),
|
||||
catalog_path,
|
||||
library::place_path(&conn.account),
|
||||
scratch,
|
||||
ctl.face_model_id.borrow().clone(),
|
||||
);
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
let weak = window.as_weak();
|
||||
let ctl_cb = ctl.clone();
|
||||
|
||||
// The sync reports stages rather than counts, so it stays indeterminate and
|
||||
// says what stage it is in.
|
||||
let job = ctl
|
||||
.activity
|
||||
.begin(crate::activity::Kind::Sync, "Syncing with the server");
|
||||
|
||||
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);
|
||||
job.fail("stopped without finishing");
|
||||
stop(&ctl_cb.sync_timer);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match msg {
|
||||
crate::derived_sync::SyncMessage::Status(s) => {
|
||||
job.detail(s.clone());
|
||||
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,
|
||||
// Replacing a damaged copy is said apart from an
|
||||
// ordinary push: it is the one push that discarded
|
||||
// something, and a log line that called it "pushed"
|
||||
// would hide the only moment worth going back to.
|
||||
match (report.catalog_uploaded, report.catalog_replaced) {
|
||||
(true, true) => "pushed over a damaged copy",
|
||||
(true, false) => "pushed",
|
||||
_ => "not pushed",
|
||||
},
|
||||
if report.collections_gained > 0 {
|
||||
format!(", {} collection(s) gained", report.collections_gained)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
w.set_library_syncing(false);
|
||||
let summary = format!(
|
||||
"{} shard(s) up, {} down",
|
||||
report.shards_uploaded, report.shards_downloaded
|
||||
);
|
||||
job.finish(if report.did_anything() {
|
||||
summary.clone()
|
||||
} else {
|
||||
"nothing to exchange".to_string()
|
||||
});
|
||||
if report.did_anything() {
|
||||
w.set_library_status(format!("synced · {summary}").into());
|
||||
}
|
||||
// Adopted thumbnails and merged collections both change
|
||||
// what the grid should show.
|
||||
if report.thumbnails_adopted > 0
|
||||
|| report.collections_gained > 0
|
||||
|| report.members_gained > 0
|
||||
{
|
||||
load_window(&w, &ctl_cb);
|
||||
}
|
||||
// TRACES: FR-CAT-7
|
||||
// And the sidebar, which the grid reload does not
|
||||
// touch. Membership counts as a change: a sync that
|
||||
// files 127 photographs into a collection both devices
|
||||
// already had gains no *collection*, so keying this on
|
||||
// `collections_gained` alone left the tree showing no
|
||||
// number beside a collection that had just been filled.
|
||||
//
|
||||
// The scan already does this after it finishes; the
|
||||
// sync merges the same tables and did not.
|
||||
if report.collections_gained > 0 || report.members_gained > 0 {
|
||||
let coll = ctl_cb.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade());
|
||||
if let Some(coll) = coll {
|
||||
let borrow = ctl_cb.catalog();
|
||||
let borrow = borrow.borrow();
|
||||
if let Some(cat) = borrow.as_ref() {
|
||||
crate::collections_ui::refresh_tree(&w, &coll, cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
stop(&ctl_cb.sync_timer);
|
||||
return;
|
||||
}
|
||||
crate::derived_sync::SyncMessage::Failed(e) => {
|
||||
log::warn!("sync failed: {e}");
|
||||
job.fail(e.to_string());
|
||||
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.
|
||||
pub(super) fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// TRACES: FR-CAT-9
|
||||
// The sweep is nothing but network reads — one header fetch per undated
|
||||
// image, across the whole library. Offline it would spend a timeout on
|
||||
// every one of them, running for hours to learn nothing, while the
|
||||
// progress bar implied work was happening. It resumes on reconnect, and
|
||||
// the images it has already dated stay dated.
|
||||
if ctl.is_offline() {
|
||||
log::debug!("offline: not starting the metadata sweep");
|
||||
return;
|
||||
}
|
||||
|
||||
let rx = library::spawn_sweep(conn.clone(), library::catalog_path(&conn.account));
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
let weak = window.as_weak();
|
||||
let ctl_cb = ctl.clone();
|
||||
|
||||
// Hours on a large library, and entirely invisible outside the grid until
|
||||
// now: the register is where a user who has gone to develop can still see
|
||||
// that indexing is running and how far it has got.
|
||||
let job = ctl
|
||||
.activity
|
||||
.begin(crate::activity::Kind::Index, "Indexing capture times");
|
||||
|
||||
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);
|
||||
job.fail("stopped without finishing");
|
||||
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);
|
||||
job.total(n);
|
||||
}
|
||||
library::SweepMessage::Progress { done, dated } => {
|
||||
w.set_library_sweep_done(done as i32);
|
||||
job.progress(done, w.get_library_sweep_total() as usize);
|
||||
// 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");
|
||||
job.finish(format!("{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);
|
||||
}
|
||||
}
|
||||
// TRACES: NFR-R2
|
||||
// The scheduled backup, at the moment the catalog is
|
||||
// quietest and a day's edits have just been folded in.
|
||||
// On its own thread, with its own connection: a copy
|
||||
// of a 130 MB file is a second or two the Slint loop
|
||||
// must not spend, and it runs beside the sync below,
|
||||
// which is only a reader of the same file.
|
||||
spawn_scheduled_backup(&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);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-3 | FR-NC-3 | FR-NC-7
|
||||
/// Thumbnail every photograph in the library, then hand the shards over.
|
||||
///
|
||||
/// # Why it is asked for rather than assumed
|
||||
///
|
||||
/// The grid's own fetching is demand-driven on purpose: a remote library is
|
||||
/// browsed over a connection that must not be saturated to show one screen
|
||||
/// (FR-NC-3). But that leaves the thumbnail store holding only what has been
|
||||
/// looked at, and the store is the one derived thing worth syncing — a second
|
||||
/// device that downloads the shards gets a full grid without touching a RAW.
|
||||
/// So the complete set is worth an hour of transfers *once*, on a machine
|
||||
/// plugged in, at a moment the user chose. That is this.
|
||||
///
|
||||
/// The sync at the end is not a separate courtesy: a filled store that never
|
||||
/// leaves this device is most of the cost for none of the point.
|
||||
pub(super) fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Already running. A second pass would re-fetch everything the first is
|
||||
// part way through — the work list is built from what the store lacks, and
|
||||
// the first pass has not filled it yet.
|
||||
if ctl.thumb_sweep_timer.borrow().is_some() && window.get_library_thumbnailing() {
|
||||
return;
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-9
|
||||
// Every image here is a range fetch. Offline the pass would spend a
|
||||
// timeout per photograph and store nothing, so it is refused rather than
|
||||
// started — and said so, because this one was pressed deliberately and a
|
||||
// button that silently does nothing is worse than one that declines.
|
||||
if ctl.is_offline() {
|
||||
log::debug!("offline: not starting the thumbnail pass");
|
||||
window.set_library_status("Offline — thumbnailing needs the server.".into());
|
||||
return;
|
||||
}
|
||||
|
||||
window.set_library_thumbnailing(true);
|
||||
let rx = library::spawn_thumbnail_sweep(
|
||||
conn.clone(),
|
||||
library::catalog_path(&conn.account),
|
||||
library::thumbs_dir(&conn.account),
|
||||
);
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
let weak = window.as_weak();
|
||||
let ctl_cb = ctl.clone();
|
||||
|
||||
let job = ctl.activity.begin(
|
||||
crate::activity::Kind::Thumbnails,
|
||||
"Thumbnailing the library",
|
||||
);
|
||||
|
||||
// The total arrives in the first message rather than up front — counting
|
||||
// it means asking the store about every image — and the progress messages
|
||||
// after it carry only cumulative counts, so it is kept here rather than
|
||||
// re-derived.
|
||||
let total = std::cell::Cell::new(0usize);
|
||||
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
// As slow as the metadata sweep's drain, and for the same reason: this
|
||||
// runs for tens of minutes and a chunk lands every few seconds.
|
||||
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_thumbnailing(false);
|
||||
job.fail("stopped without finishing");
|
||||
stop(&ctl_cb.thumb_sweep_timer);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match msg {
|
||||
library::ThumbSweepMessage::Total(n) => {
|
||||
total.set(n);
|
||||
job.total(n);
|
||||
w.set_library_status(format!("Thumbnailing {n} photograph(s)…").into());
|
||||
}
|
||||
library::ThumbSweepMessage::Progress { done, stored } => {
|
||||
job.progress(done, total.get());
|
||||
// Dates arrive on the same headers, so the histogram
|
||||
// grows as this runs — the visible sign it is working
|
||||
// on a page that is not the grid.
|
||||
if stored > 0 {
|
||||
let borrow = ctl_cb.catalog.borrow();
|
||||
if let Some(catalog) = borrow.as_ref() {
|
||||
refresh_timeline(&w, catalog, &ctl_cb);
|
||||
}
|
||||
}
|
||||
}
|
||||
library::ThumbSweepMessage::Finished {
|
||||
stored,
|
||||
failed,
|
||||
offline,
|
||||
} => {
|
||||
log::info!(
|
||||
"thumbnail pass finished: {stored} stored, {failed} without a preview"
|
||||
);
|
||||
w.set_library_thumbnailing(false);
|
||||
stop(&ctl_cb.thumb_sweep_timer);
|
||||
|
||||
let summary = if offline {
|
||||
format!("{stored} stored · stopped, server unreachable")
|
||||
} else if failed > 0 {
|
||||
format!("{stored} stored · {failed} with no usable preview")
|
||||
} else {
|
||||
format!("{stored} stored")
|
||||
};
|
||||
w.set_library_status(summary.clone().into());
|
||||
// Finished rather than failed even when the server
|
||||
// dropped: the pass is resumable and what it did build
|
||||
// is stored, and a red row would invite the user to
|
||||
// redo work that is already done.
|
||||
job.finish(summary);
|
||||
|
||||
{
|
||||
let borrow = ctl_cb.catalog.borrow();
|
||||
if let Some(catalog) = borrow.as_ref() {
|
||||
refresh_timeline(&w, catalog, &ctl_cb);
|
||||
}
|
||||
}
|
||||
// Cells that showed a placeholder have pixels now, and
|
||||
// the grid asks the store once per photograph — so
|
||||
// without this the library the pass just thumbnailed
|
||||
// stays blank until something else reloads the window.
|
||||
if w.get_library_visible() {
|
||||
ctl_cb.requested.borrow_mut().clear();
|
||||
schedule_reload(&w, &ctl_cb);
|
||||
}
|
||||
|
||||
// The point of the pass: the shards go to the server so
|
||||
// every other device inherits them. Skipped when the
|
||||
// connection is already gone — the sync would only
|
||||
// discover the same thing more slowly.
|
||||
if !offline {
|
||||
start_derived_sync(&w, &ctl_cb);
|
||||
}
|
||||
// And now every photograph in the library has a
|
||||
// thumbnail, which is the only moment all of its burst
|
||||
// signatures can be computed. See `bursts::start_pass`,
|
||||
// which owns the pass and everything it needs to drain
|
||||
// itself; what it wants from here is the paths and a
|
||||
// way to say the grid has something new to draw.
|
||||
if let Some((conn, _)) = ctl_cb.session.borrow().clone() {
|
||||
let weak_after = w.as_weak();
|
||||
let ctl_after = ctl_cb.clone();
|
||||
crate::bursts::start_pass(
|
||||
library::catalog_path(&conn.account),
|
||||
library::thumbs_dir(&conn.account),
|
||||
move |bursts| {
|
||||
let Some(w) = weak_after.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if bursts > 0 && w.get_library_visible() {
|
||||
schedule_reload(&w, &ctl_after);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
*ctl.thumb_sweep_timer.borrow_mut() = Some(timer);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user