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:
+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