Select photographs with a finger, and take a collection with you

Two things a tablet could not do. Both existed for a pointer and had no
touch form at all, which on Android meant the collection sidebar was
somewhere to look at rather than somewhere to file into.

**Selecting more than one.** Ctrl-click and shift-click are the only ways
into a multi-selection, and touch has neither. Holding a cell now enters
selection mode, where a tap toggles — reported to Rust as a ctrl-press, so
it goes through the same `apply_press` as everything else rather than
growing a second copy of the selection rules. A double tap takes the run
between where selecting began and there: the touch form of shift-click,
and the reason the anchor from *before* the double tap has to be
remembered, since both of its taps move the anchor onto the cell being
tapped. A "Select" button does the same thing where a gesture would go
undiscovered (FR-UI-4).

**Filing without a drag.** A one-finger drag beginning in the grid belongs
to the Flickable that scrolls it — that is the arbitration working, not a
bug to route around — so the selection can now be filed from a sheet
listing the sidebar's own rows. Copy by default, as the drag has always
been; moving out of the collection being shown is a switch, because it is
the one that takes something away.

**Taking a collection offline.** The machinery was there and reachable only
by scoping the grid to a collection and finding a button behind a
disclosure. Holding a collection's name now asks the question directly, and
the tray on a row and the header button ask the same one — three
affordances doing two different things is how a user comes to avoid all
three. The question is asked rather than a toggle flipped because both
answers are expensive: one downloads gigabytes, the other deletes them, and
the counts and sizes go in the buttons where they are read before the tap.

`Cache::release` is new and is the destructive half `unpin` deliberately is
not. "Remove the local copies" is asked by someone whose device is full,
and withdrawing a promise while leaving the bytes for a future eviction to
notice is not an answer to it. It unpins before forgetting, or the next pin
fetch would dutifully download everything it just deleted.

The sidebar's trays read `tier_actual`, never `tier_desired`: the question
is whether these will open on the aeroplane, and a pin whose download has
not run yet answers no.

TRACES: FR-CAT-7 | FR-NC-6a | FR-NC-6b | FR-NC-6c | FR-UI-2 | FR-UI-3 | FR-UI-4

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 12:27:29 +02:00
co-authored by Claude Opus 5
parent a8b28136a6
commit d913e50948
7 changed files with 1739 additions and 52 deletions
+597 -1
View File
@@ -67,6 +67,19 @@ pub struct CollectionsController {
/// Selection is by id for the same reason (see the preamble); this is the
/// same argument applied to the one index that has to survive a move.
anchor: RefCell<Option<usize>>,
/// TRACES: FR-UI-2 | FR-UI-4
/// Where the anchor was *before* the press that moved it.
///
/// The touch equivalent of shift-click needs this. A double tap is two
/// presses, and both of them move the anchor onto the cell being tapped —
/// so by the time the double tap is reported, "the range from the anchor to
/// here" describes a single cell. This remembers the cell the user actually
/// started from, which is the one they mean.
///
/// Updated only when a press *moves* the anchor, so the second tap of a
/// double tap — which lands on the cell that is already the anchor — leaves
/// it pointing where the first tap left it.
previous_anchor: std::cell::Cell<Option<usize>>,
/// Where the keyboard is, as an image ordinal.
///
/// Distinct from the anchor, and it has to be: shift+arrow grows a range
@@ -82,6 +95,23 @@ pub struct CollectionsController {
/// develop view on the second ctrl-click. Slint does not report modifiers on
/// `clicked`, so the press records them and the click consults this.
modified_press: std::cell::Cell<bool>,
/// TRACES: FR-UI-2 | FR-UI-4
/// Whether a tap in the grid selects rather than opens.
///
/// Touch has no ctrl and no shift, so without a mode there is no way to
/// select a second photograph: the first tap would open the first one. In
/// this mode a plain press is reported as a ctrl-press and goes through the
/// same [`apply_press`] as everything else — a separate touch policy would
/// be a second copy of these rules to keep in step.
select_mode: std::cell::Cell<bool>,
/// The timer that turns a held cell into a selection.
///
/// Here rather than in `.slint` because Slint has no long-press gesture and
/// a hand-rolled one would need a `Timer` element per visible cell — a
/// hundred timers to answer a question about one finger. Held so that
/// dropping it cancels: a press that ends, or is taken by the Flickable
/// when the finger travels, must not arrive as a selection a moment later.
hold_timer: RefCell<Option<slint::Timer>>,
/// Collection ids parallel to the sidebar's rows, so a hovered row index
/// resolves to an id without another query.
row_ids: RefCell<Vec<CollectionId>>,
@@ -305,6 +335,55 @@ pub fn apply_press(
*anchor = Some(here);
}
/// TRACES: FR-UI-2 | FR-UI-4
/// Apply a press, and remember where the anchor was before it moved.
///
/// The bookkeeping a double tap depends on, split out from the callback so the
/// touch sequence — hold one cell, double-tap another, get the run between —
/// can be tested without a window. See [`CollectionsController::previous_anchor`]
/// for why the *previous* anchor is the one a double tap means.
#[allow(clippy::too_many_arguments)]
pub fn press_remembering_anchor(
selection: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
previous: &mut Option<usize>,
ids: &[ImageId],
offset: usize,
row: usize,
ctrl: bool,
shift: bool,
) {
let before = *anchor;
apply_press(selection, anchor, ids, offset, row, ctrl, shift);
// Only a press that *moved* the anchor updates this. The second tap of a
// double tap lands on the cell the first tap made the anchor, so it changes
// nothing and the origin survives to be extended from.
if *anchor != before {
*previous = before;
}
}
/// TRACES: FR-UI-2 | FR-UI-4
/// Extend the selection to `row` — the touch form of a shift-click.
///
/// Added to the selection rather than replacing it: this is reached only from
/// selection mode, which the user entered deliberately, and a gesture that
/// silently discarded the run they gathered a moment ago would make gathering
/// two runs impossible.
pub fn extend_to_row(
selection: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
previous: Option<usize>,
ids: &[ImageId],
offset: usize,
row: usize,
) {
// Falling back to the current anchor makes a double tap with no history
// select just that cell, which is what a double tap already did.
*anchor = previous.or(*anchor);
apply_press(selection, anchor, ids, offset, row, true, true);
}
/// Apply a press and push the result into the grid — the whole of what a
/// click, or an arrow key, does to the selection.
///
@@ -321,15 +400,18 @@ pub fn select_row(
ctrl: bool,
shift: bool,
) {
apply_press(
let mut previous = ctl.previous_anchor.get();
press_remembering_anchor(
&mut ctl.selection.borrow_mut(),
&mut ctl.anchor.borrow_mut(),
&mut previous,
ids,
offset,
row,
ctrl,
shift,
);
ctl.previous_anchor.set(previous);
// The cursor follows the press, so an arrow key after a click continues
// from the cell that was clicked rather than from wherever the keyboard
// was last.
@@ -353,6 +435,10 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
};
let collapsed = ctl.collapsed.borrow();
// TRACES: FR-NC-6a | FR-NC-6c
// How much of each collection is already on the device, rolled up the tree
// — computed once for the whole sidebar rather than per row.
let held = offline_state(catalog, &rows);
// A row is hidden when any ancestor is collapsed. The tree arrives
// depth-first, so tracking the shallowest collapsed depth seen is enough —
@@ -382,6 +468,8 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
let deep =
coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count);
let offline = held.get(&id).copied().unwrap_or_default();
ids.push(id);
smart.push(row.collection.kind == CollectionKind::Smart);
has_kids.push(row.has_children);
@@ -394,6 +482,12 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
has_children: row.has_children,
expanded,
smart: row.collection.kind == CollectionKind::Smart,
// An empty collection is never "kept": every one of its zero
// photographs being here is true and says nothing, and a full tray
// beside an empty collection is a lie about what a trip would cost
// to take.
pinned: offline.total > 0 && offline.pinned == offline.total,
partly_pinned: offline.pinned > 0 && offline.pinned < offline.total,
});
}
@@ -403,6 +497,88 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
}
/// TRACES: FR-NC-6a | FR-NC-6c
/// How many of a collection's photographs are pinned, and how many there are.
///
/// Counted over *members*, not distinct images: an image filed in both a parent
/// and a child is counted in each, which is what makes the roll-up below a sum
/// rather than a set union. That inflates the totals of a tree that files the
/// same photograph twice, and it does not matter here — the only questions
/// asked of these numbers are "all of them?" and "none of them?", and both
/// survive the double count.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct OfflineCount {
total: usize,
pinned: usize,
}
/// The offline state of every collection in the tree, descendants included.
///
/// One query for the direct membership, then a single reverse pass to fold each
/// row into its parent. The alternative — `descendants` plus a count per row —
/// is two queries per collection, and the sidebar rebuilds after every drop,
/// rename and rating change.
///
/// The reverse pass is correct because `tree` emits parents before children:
/// walking it backwards means every child has already been folded in by the
/// time its parent is reached, however deep the nesting goes.
fn offline_state(
catalog: &Catalog,
rows: &[coll::TreeRow],
) -> std::collections::HashMap<CollectionId, OfflineCount> {
let mut counts: std::collections::HashMap<CollectionId, OfflineCount> =
std::collections::HashMap::new();
// `tier_actual`, not `pinned`: the question a user is asking of this icon is
// "will these open on the aeroplane", and a pin whose download has not run
// yet answers no (see the note on `tier_actual` in `dr_catalog::cache`).
let sql = "SELECT cm.collection_id,
count(*),
coalesce(sum(CASE WHEN ic.tier_actual >= ?1 THEN 1 ELSE 0 END), 0)
FROM collection_members cm
LEFT JOIN image_cache ic ON ic.image_id = cm.image_id
GROUP BY cm.collection_id";
match catalog.connection().prepare(sql).and_then(|mut stmt| {
let rows = stmt.query_map(rusqlite::params![dr_types::Tier::Original.stored()], |r| {
Ok((
CollectionId(r.get::<_, i64>(0)? as u64),
r.get::<_, i64>(1)? as usize,
r.get::<_, i64>(2)? as usize,
))
})?;
rows.collect::<Result<Vec<_>, _>>()
}) {
Ok(direct) => {
for (id, total, pinned) in direct {
counts.insert(id, OfflineCount { total, pinned });
}
}
Err(e) => {
// Not fatal: the tray then reads "nothing kept", which is the safe
// direction — it offers a download rather than claiming a trip is
// already on the device.
log::debug!("reading offline state: {e}");
return counts;
}
}
for row in rows.iter().rev() {
let Some(parent) = row.collection.parent else {
continue;
};
let child = counts.get(&row.collection.id).copied().unwrap_or_default();
if child.total == 0 {
continue;
}
let entry = counts.entry(parent).or_default();
entry.total += child.total;
entry.pinned += child.pinned;
}
counts
}
/// Build the bitmap that travels under the cursor.
///
/// One image is drawn as itself. Several are **fanned**, back to front with the
@@ -626,6 +802,48 @@ pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) {
/// this is the range file managers have settled on for the same gesture.
const SPRING_DELAY_MS: u64 = 500;
/// TRACES: FR-UI-2 | FR-UI-4
/// How long a cell must be held before the grid enters selection mode.
///
/// The platform convention, and the reason to match it rather than pick: every
/// gallery on the device this exists for opens a selection on a hold of about
/// this length, so a user who has never read a word about DarkRoom already
/// knows the gesture. Shorter and a slow tap becomes a selection; longer and
/// the hand lets go first, having concluded nothing was going to happen.
pub(crate) const HOLD_DELAY_MS: u64 = 450;
/// TRACES: FR-UI-2 | FR-UI-4
/// Start the timer that turns a held cell into a selection.
///
/// Restarted per press, and cancelled by the release — see `hold_timer`. The
/// press that armed it has *already* selected the cell under the finger, so
/// what firing adds is the mode: from here taps toggle rather than open, and
/// the header's buttons appear to act on what has been gathered.
fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>) {
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::SingleShot,
std::time::Duration::from_millis(HOLD_DELAY_MS),
move || {
let Some(w) = weak.upgrade() else { return };
ctl_cb.select_mode.set(true);
w.set_library_select_mode(true);
// The release that follows this hold must not also open the image:
// the user asked for a selection and would land in develop instead.
// Reusing `modified_press` rather than adding a second flag — the
// click already consults it, and it means exactly this: "the press
// was a selection gesture".
ctl_cb.modified_press.set(true);
},
);
*ctl.hold_timer.borrow_mut() = Some(timer);
}
/// Whether hovering this row should schedule a spring expansion.
///
/// Pure so the rule is testable: only a *collapsed parent* has anything to
@@ -1090,6 +1308,160 @@ pub fn wire<S, R, C>(
// the next scroll.
let offset = w.get_library_offset().max(0) as usize;
select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
// TRACES: FR-UI-2 | FR-UI-4
// And start counting, in case this press is a hold. The press has
// already selected this one cell; what the hold adds is the *mode*,
// so the taps that follow go on selecting instead of opening the
// next photograph the user touches.
//
// Not started when the mode is already on: it is on, and a second
// hold would have nothing to do but suppress the tap that ends it.
if !ctl.select_mode.get() {
arm_hold(&w, &ctl);
}
});
}
// TRACES: FR-UI-2 | FR-UI-4
// A double tap in selection mode: take everything between the cell the
// selection started from and this one.
//
// This is shift-click, reached by the one gesture touch has left. Hold to
// start selecting, double-tap the far end, and a run of forty photographs
// is three touches — then the whole selection drags onto a collection as
// one, which is the thing this sequence exists to make possible.
//
// The range is *added*, not replaced, so a second run can be picked up
// without losing the first — ctrl+shift's behaviour, and the right one
// here: a mode the user entered deliberately should accumulate rather than
// throw away what they have already gathered.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_cell_double_clicked(move |row| {
let Some(w) = weak.upgrade() else { return };
// Outside selection mode a double tap is two ordinary clicks, and
// the first has already opened the image. Nothing to do.
if !ctl.select_mode.get() {
return;
}
// The hold that would have fired mid-double-tap.
*ctl.hold_timer.borrow_mut() = None;
let ids = visible();
let offset = w.get_library_offset().max(0) as usize;
// Extended from the cell the user started at, not from the one the
// two taps just moved the anchor onto.
extend_to_row(
&mut ctl.selection.borrow_mut(),
&mut ctl.anchor.borrow_mut(),
ctl.previous_anchor.get(),
&ids,
offset,
row as usize,
);
ctl.set_cursor(Some(offset + row as usize));
sync_selection(&w, &ctl, &ids);
});
}
// TRACES: FR-UI-2 | FR-UI-4
// The press ended — lifted, or taken by the Flickable when the finger
// travelled. Either way the hold is off.
{
let ctl = ctl.clone();
window.on_library_cell_press_ended(move || {
*ctl.hold_timer.borrow_mut() = None;
});
}
// TRACES: FR-UI-2 | FR-UI-4
// The button half of selection mode. The long press is faster and this is
// the one that can be found — a gesture with no visible counterpart is a
// feature only its author knows about.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_toggle_select_mode(move || {
let Some(w) = weak.upgrade() else { return };
let on = !ctl.select_mode.get();
ctl.select_mode.set(on);
w.set_library_select_mode(on);
*ctl.hold_timer.borrow_mut() = None;
// Leaving the mode drops the selection. "Done" reads as finishing
// with these photographs, and a selection that outlived the mode
// would still be acted on by the buttons in the header — which is
// how forty images get exported by a user who thought they had put
// them down.
if !on {
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
}
});
}
// TRACES: FR-CAT-7 | FR-UI-4
// File the selection in a collection without dragging it there.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_file_in_collection(move |id, moves| {
let Some(w) = weak.upgrade() else { return };
let target = CollectionId(id as u64);
let chosen = ctl.selected();
if chosen.is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
// Moving is only a move when there is somewhere to move *from*.
// Unscoped, the sheet does not offer it; this guards the callback
// anyway, and degrades to filing rather than refusing.
let from = if moves { *ctl.scope.borrow() } else { None };
let result = match from {
Some(from) => coll::move_images(cat.connection(), from, target, &chosen),
None => coll::add_images(cat.connection(), target, &chosen),
};
match result {
Ok(filed) => {
w.set_collection_error(slint::SharedString::new());
// The honest count, as the drop reports it: claiming all
// twelve when nine were already there teaches the user to
// distrust every number the app shows them.
let msg = if from.is_some() {
format!("Moved {filed} of {}", chosen.len())
} else if filed == chosen.len() {
format!("Added {filed} to collection")
} else {
format!(
"Added {filed} of {} — the rest were already there",
chosen.len()
)
};
w.set_library_status(msg.into());
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
// A move changes what the collection on screen holds, so
// the grid has to be reread; filing elsewhere does not, but
// the badge count on every cell just changed and the reread
// is one query.
drop(borrow);
reload();
}
Err(e) => w.set_collection_error(format!("filing: {e}").into()),
}
});
}
@@ -1943,6 +2315,105 @@ mod tests {
assert_eq!(sel.len(), 3, "rows 10..=12");
}
/// One tap in selection mode: a press reported as ctrl-held, which is what
/// `library.slint` sends while the mode is on.
fn tap(
sel: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
previous: &mut Option<usize>,
all: &[ImageId],
row: usize,
) {
press_remembering_anchor(sel, anchor, previous, all, 0, row, true, false);
}
#[test]
fn a_double_tap_takes_the_run_between_it_and_where_selecting_began() {
// The whole touch gesture, in the order a finger performs it: hold one
// photograph to start selecting, then double-tap the far end of the run.
// Both taps of that double tap land on the same cell — the first turns
// it on, the second turns it off — and the double tap that follows has
// to select the range anyway.
let all = ids(20);
let mut sel = BTreeSet::new();
let mut anchor = None;
let mut previous = None;
// The long press: an ordinary plain press, which is what the cell got
// before the hold timer fired.
press_remembering_anchor(
&mut sel,
&mut anchor,
&mut previous,
&all,
0,
4,
false,
false,
);
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
assert!(
!sel.contains(&ImageId(12)),
"the two taps cancelled out, which is what makes the double tap's \
job to select the range rather than to add one cell"
);
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 11);
assert_eq!(sel.len(), 8, "rows 4..=11");
assert!(sel.contains(&ImageId(5)) && sel.contains(&ImageId(12)));
}
#[test]
fn a_double_tap_keeps_a_run_gathered_earlier() {
// Two runs, which is why the extension unions rather than replaces: a
// user in selection mode is gathering, and the second gesture must not
// throw away the first.
let all = ids(30);
let mut sel = BTreeSet::new();
let mut anchor = None;
let mut previous = None;
press_remembering_anchor(
&mut sel,
&mut anchor,
&mut previous,
&all,
0,
0,
false,
false,
);
tap(&mut sel, &mut anchor, &mut previous, &all, 3);
tap(&mut sel, &mut anchor, &mut previous, &all, 3);
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 3);
assert_eq!(sel.len(), 4, "rows 0..=3");
// A second run, begun with a plain tap somewhere else.
tap(&mut sel, &mut anchor, &mut previous, &all, 20);
tap(&mut sel, &mut anchor, &mut previous, &all, 25);
tap(&mut sel, &mut anchor, &mut previous, &all, 25);
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 25);
assert_eq!(sel.len(), 10, "rows 0..=3 and 20..=25");
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(26)));
}
#[test]
fn a_double_tap_with_nothing_to_extend_from_selects_only_that_cell() {
// The degenerate case: selection mode entered from the header's button
// rather than by holding a cell, so nothing has anchored yet.
let all = ids(10);
let mut sel = BTreeSet::new();
let mut anchor = None;
let previous = None;
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 6);
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(7)]);
}
#[test]
fn ctrl_shift_adds_a_second_range_to_the_selection() {
// Picking up a second run without losing the first: the one case where
@@ -2244,6 +2715,131 @@ mod tests {
assert_eq!(second, "New collection 2");
}
/// TRACES: FR-NC-6a | FR-NC-6c
/// A catalog with `n` images, for the offline roll-up tests.
fn catalog_with_images(n: usize) -> (Catalog, Vec<ImageId>) {
let cat = Catalog::in_memory().unwrap();
cat.connection()
.execute(
"INSERT INTO roots (id, kind, label) VALUES (1, 'remote', 'test')",
[],
)
.unwrap();
let mut ids = Vec::new();
for i in 0..n {
cat.connection()
.execute(
"INSERT INTO images (root_id, source_ref, added_at)
VALUES (1, ?1, 0)",
rusqlite::params![format!("Photos/img{i:03}.CR2")],
)
.unwrap();
ids.push(ImageId(cat.connection().last_insert_rowid() as u64));
}
(cat, ids)
}
/// Record an image as held on this device, as a finished download would.
fn mark_held(cat: &Catalog, image: ImageId) {
cat.connection()
.execute(
"INSERT INTO image_cache (image_id, tier_actual, tier_desired, bytes, pinned)
VALUES (?1, ?2, ?2, 100, 1)",
rusqlite::params![image.0 as i64, dr_types::Tier::Original.stored()],
)
.unwrap();
}
#[test]
fn a_parent_reads_as_kept_only_when_its_children_are_too() {
// The tray on a parent answers for the subtree, because that is what
// keeping a parent downloads. A parent that showed a full tray over a
// child with nothing downloaded would be a promise the aeroplane
// breaks.
let (cat, images) = catalog_with_images(4);
let c = cat.connection();
let parent = coll::create(c, "Trip", None, CollectionKind::Manual).unwrap();
let child = coll::create(c, "Day one", Some(parent), CollectionKind::Manual).unwrap();
coll::add_images(c, parent, &images[0..2]).unwrap();
coll::add_images(c, child, &images[2..4]).unwrap();
// Everything the parent holds directly, and nothing of the child's.
mark_held(&cat, images[0]);
mark_held(&cat, images[1]);
let rows = coll::tree(c).unwrap();
let state = offline_state(&cat, &rows);
assert_eq!(
state[&parent],
OfflineCount {
total: 4,
pinned: 2
}
);
assert_eq!(
state[&child],
OfflineCount {
total: 2,
pinned: 0
}
);
mark_held(&cat, images[2]);
mark_held(&cat, images[3]);
let state = offline_state(&cat, &coll::tree(c).unwrap());
assert_eq!(
state[&parent],
OfflineCount {
total: 4,
pinned: 4
}
);
}
#[test]
fn a_grandchild_rolls_all_the_way_up() {
// The reverse pass has to survive depth: a photograph three levels down
// is still part of what the top-level collection would download.
let (cat, images) = catalog_with_images(1);
let c = cat.connection();
let top = coll::create(c, "2024", None, CollectionKind::Manual).unwrap();
let mid = coll::create(c, "Corsica", Some(top), CollectionKind::Manual).unwrap();
let leaf = coll::create(c, "Bonifacio", Some(mid), CollectionKind::Manual).unwrap();
coll::add_images(c, leaf, &images).unwrap();
let state = offline_state(&cat, &coll::tree(c).unwrap());
assert_eq!(
state[&top],
OfflineCount {
total: 1,
pinned: 0
}
);
assert_eq!(
state[&mid],
OfflineCount {
total: 1,
pinned: 0
}
);
}
#[test]
fn an_empty_collection_has_nothing_to_keep() {
// Guards the reading that would put a full tray beside an empty
// collection: every one of its zero photographs is here, which is true
// and useless. `refresh_tree` requires a non-zero total before drawing
// one, and this is the fact it relies on.
let (cat, _) = catalog_with_images(0);
let c = cat.connection();
let empty = coll::create(c, "Nothing yet", None, CollectionKind::Manual).unwrap();
let state = offline_state(&cat, &coll::tree(c).unwrap());
assert_eq!(state.get(&empty).copied().unwrap_or_default().total, 0);
}
/// A catalog holding one top-level collection, and its id.
fn with_one(name: &str) -> (Catalog, CollectionId) {
let cat = Catalog::in_memory().unwrap();