Merge branch 'android-collections'
Build and test / Desktop (Linux) (push) Failing after 18m53s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Failing after 1m4s
🐳 Android image / Build and push (push) Successful in 6s
Build and test / android-image (push) Successful in 5s
Build and test / Android (aarch64) (push) Failing after 9m40s
Build and test / Desktop (Linux) (push) Failing after 18m53s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Failing after 1m4s
🐳 Android image / Build and push (push) Successful in 6s
Build and test / android-image (push) Successful in 5s
Build and test / Android (aarch64) (push) Failing after 9m40s
Touch multi-selection in the grid, filing a selection into a collection without a drag, and taking a collection offline from a held row.
This commit is contained in:
@@ -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();
|
||||
|
||||
+389
-46
@@ -188,6 +188,19 @@ pub struct LibraryController {
|
||||
/// Drains the pin downloader. Held so a second pin replaces the timer
|
||||
/// rather than leaving two draining the same finished channel.
|
||||
pin_timer: RefCell<Option<slint::Timer>>,
|
||||
/// TRACES: FR-NC-6a
|
||||
/// Which collection the offline question is being asked about.
|
||||
///
|
||||
/// Held rather than passed through the window because the prompt's three
|
||||
/// answers arrive as three separate callbacks, and a dialogue that read its
|
||||
/// subject back out of a string property would act on whatever the sidebar
|
||||
/// had been rebuilt to say since.
|
||||
offline_target: std::cell::Cell<Option<dr_types::CollectionId>>,
|
||||
/// TRACES: FR-NC-6a | FR-UI-2
|
||||
/// The timer that turns a held sidebar row into that question. Dropped on
|
||||
/// release, so a tap — or a press the Flickable takes for a scroll — is not
|
||||
/// a dialogue a moment later.
|
||||
row_hold_timer: RefCell<Option<slint::Timer>>,
|
||||
/// Narrow the grid to images whose original is stored locally.
|
||||
///
|
||||
/// A `Cell` beside `filter` rather than a field inside it: the rating
|
||||
@@ -253,6 +266,8 @@ impl LibraryController {
|
||||
outbox_timer: RefCell::new(None),
|
||||
outbox_maybe_dirty: std::cell::Cell::new(true),
|
||||
pin_timer: RefCell::new(None),
|
||||
offline_target: std::cell::Cell::new(None),
|
||||
row_hold_timer: RefCell::new(None),
|
||||
local_only: std::cell::Cell::new(false),
|
||||
// The catalog's own floor until the settings page reports what the
|
||||
// user has stored, which it does at startup before any fetch.
|
||||
@@ -874,31 +889,102 @@ fn start_rescan(
|
||||
drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path);
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6a
|
||||
/// Pin the scoped collection for offline use, or release it.
|
||||
///
|
||||
/// Pinning is two separate things, and keeping them separate is what makes the
|
||||
/// button feel immediate: recording the *intent* is a local catalog write that
|
||||
/// completes at once, and downloading the bytes is a background transfer that
|
||||
/// may take a very long time. The button reflects the first.
|
||||
fn toggle_pin_scope(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let Some(scope) = *ctl.scope.borrow() else {
|
||||
return;
|
||||
};
|
||||
let Some(cache) = ctl.cache() else {
|
||||
window.set_library_error("No cache directory for this library.".into());
|
||||
return;
|
||||
};
|
||||
/// 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).
|
||||
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 a set: pinning
|
||||
// a parent whose children hold the photographs must pin the photographs,
|
||||
// or the button would appear to do nothing.
|
||||
let ids = match dr_catalog::collections::descendants(catalog.connection(), scope) {
|
||||
// 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());
|
||||
@@ -906,37 +992,212 @@ fn toggle_pin_scope(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
}
|
||||
};
|
||||
let images = collection_images(catalog, &ids);
|
||||
if images.is_empty() {
|
||||
window.set_library_error("Nothing in that collection to keep offline.".into());
|
||||
return;
|
||||
}
|
||||
let summary = offline_summary(catalog, &images);
|
||||
|
||||
let pinning = !window.get_library_scope_pinned();
|
||||
let result = if pinning {
|
||||
cache.pin(catalog.connection(), &images)
|
||||
} else {
|
||||
cache.unpin(catalog.connection(), &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.
|
||||
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.
|
||||
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;
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
window.set_library_error(format!("pinning: {e}").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
|
||||
};
|
||||
|
||||
window.set_library_scope_pinned(pinning);
|
||||
log::info!("pinned {} image(s) for offline use", images.len());
|
||||
window.set_library_error(slint::SharedString::new());
|
||||
drop(borrow);
|
||||
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);
|
||||
}
|
||||
|
||||
if pinning {
|
||||
log::info!("pinned {} image(s) for offline use", images.len());
|
||||
start_pin_fetch(window, ctl);
|
||||
} else {
|
||||
// The bytes stay until the budget needs the room, so there is nothing
|
||||
// to run here — unpinning withdraws a guarantee rather than deleting.
|
||||
log::info!("released the pin on {} image(s)", images.len());
|
||||
window.set_library_pin_total(0);
|
||||
window.set_library_pin_done(0);
|
||||
refresh_local_count(window, 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.
|
||||
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);
|
||||
match cache.release(catalog.connection(), &images) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
window.set_library_error(format!("removing local copies: {e}").into());
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3392,13 +3653,95 @@ pub fn wire<F>(
|
||||
}
|
||||
|
||||
// TRACES: FR-NC-6a
|
||||
// Pin or unpin the collection the grid is scoped to.
|
||||
// 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 };
|
||||
toggle_pin_scope(&w, &ctl);
|
||||
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 };
|
||||
*ctl.row_hold_timer.borrow_mut() = None;
|
||||
open_offline_prompt(&w, &ctl, dr_types::CollectionId(id as u64));
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-NC-6a | FR-UI-2 | FR-UI-4
|
||||
// And the touch way in: hold the collection's name.
|
||||
//
|
||||
// The release that ends the hold still reaches the row's `clicked` and
|
||||
// scopes the grid to that collection. Left deliberately: the user is now
|
||||
// looking at the photographs they are being asked about, which is context
|
||||
// rather than a side effect — and suppressing it would mean a second
|
||||
// "ignore the next click" flag threaded through the sidebar for no gain.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_collection_row_press(move |id, down| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if !down {
|
||||
*ctl.row_hold_timer.borrow_mut() = None;
|
||||
return;
|
||||
}
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
let weak = w.as_weak();
|
||||
let ctl_cb = ctl.clone();
|
||||
timer.start(
|
||||
slint::TimerMode::SingleShot,
|
||||
std::time::Duration::from_millis(crate::collections_ui::HOLD_DELAY_MS),
|
||||
move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
open_offline_prompt(&w, &ctl_cb, dr_types::CollectionId(id as u64));
|
||||
},
|
||||
);
|
||||
*ctl.row_hold_timer.borrow_mut() = Some(timer);
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user