diff --git a/core/dr-catalog/src/cache.rs b/core/dr-catalog/src/cache.rs index 2e7c1ab..71a3151 100644 --- a/core/dr-catalog/src/cache.rs +++ b/core/dr-catalog/src/cache.rs @@ -41,7 +41,7 @@ use std::path::{Path, PathBuf}; use dr_types::{ImageId, Tier}; -use rusqlite::Connection; +use rusqlite::{Connection, OptionalExtension as _}; use crate::error::CatalogError; @@ -299,6 +299,71 @@ impl Cache { self.set_pinned(conn, images, false) } + /// Release the pin *and* delete the bytes it was holding. + /// + /// The destructive half of the pair [`unpin`](Self::unpin) deliberately is + /// not. Unpinning answers "stop promising"; this answers "give me the disk + /// back", which is the question actually being asked when a trip is over + /// and the device is full. Leaving those gigabytes to sit until some future + /// eviction happens to want the room is not an answer to it. + /// + /// Nothing is lost that cannot be fetched again: the original lives on the + /// server, and the catalog row, the ratings and the edit graph are all + /// untouched here — they are authoritative and small (FR-NC-6b). + /// + /// Returns how many images were released and how many bytes that freed. + /// A file that has already vanished frees nothing and is still counted as + /// released, because the row describing it goes either way. + pub fn release( + &self, + conn: &Connection, + images: &[ImageId], + ) -> Result<(usize, u64), CatalogError> { + if images.is_empty() { + return Ok((0, 0)); + } + + // Read the paths before the rows are rewritten: `forget` clears `path`, + // and a file whose name has been forgotten cannot be deleted. + let mut held = Vec::new(); + { + let mut stmt = conn.prepare( + "SELECT bytes, path FROM image_cache + WHERE image_id = ?1 AND path IS NOT NULL", + )?; + for image in images { + if let Some(row) = stmt + .query_row(rusqlite::params![image.0 as i64], |r| { + Ok((r.get::<_, i64>(0)? as u64, r.get::<_, String>(1)?)) + }) + .optional()? + { + held.push(row); + } + } + } + + let mut freed = 0u64; + for (bytes, rel) in &held { + let abs = self.dir.join(rel); + match std::fs::remove_file(&abs) { + Ok(()) => freed += bytes, + // Already gone is the ordinary case after a crash mid-write, + // not a failure: the row still has to go, or the cache accounts + // for space nothing occupies. + Err(e) => log::debug!("releasing {}: {e}", abs.display()), + } + } + + // Unpin first, then forget. The other order would leave a pinned row + // claiming an original it no longer has, which `pending_pins` would + // then dutifully download again — the exact opposite of what was asked. + self.set_pinned(conn, images, false)?; + self.forget(conn, images)?; + + Ok((images.len(), freed)) + } + fn set_pinned( &self, conn: &Connection, @@ -609,6 +674,66 @@ mod tests { ); } + #[test] + fn releasing_a_pin_frees_the_disk_it_was_holding() { + // What "remove the local copies" has to mean. Unpinning alone leaves + // the bytes for a future eviction to notice, which is no answer at all + // to a device that is full now. + let (cat, cache, dir, ids) = fixture(2); + let bytes = vec![0u8; 700]; + cache + .store(cat.connection(), ids[0], "a.CR2", &bytes, true, 10) + .unwrap(); + cache + .store(cat.connection(), ids[1], "b.CR2", &bytes, true, 20) + .unwrap(); + + let (released, freed) = cache.release(cat.connection(), &ids).unwrap(); + assert_eq!(released, 2); + assert_eq!(freed, 1400); + + assert!(!cache.holds_original(cat.connection(), ids[0])); + assert_eq!(cache.usage(cat.connection()).unwrap().pinned_bytes, 0); + + // The files themselves, not just the bookkeeping: a row cleared over a + // file still on disk is how a cache comes to hold gigabytes it does not + // know about. + let left: Vec<_> = walk_files(&dir); + assert!(left.is_empty(), "files remain on disk: {left:?}"); + } + + #[test] + fn a_released_pin_is_not_downloaded_all_over_again() { + // The failure mode of releasing in the wrong order: bytes deleted while + // the row still says an original is wanted, so the next pin fetch pulls + // the whole trip back down. + let (cat, cache, _dir, ids) = fixture(1); + cache + .store(cat.connection(), ids[0], "a.CR2", &vec![0u8; 100], true, 10) + .unwrap(); + + cache.release(cat.connection(), &ids).unwrap(); + + assert!(cache.pending_pins(cat.connection()).unwrap().is_empty()); + } + + /// Every file under `dir`, for asserting that a release left nothing. + fn walk_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return out; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + out.extend(walk_files(&path)); + } else { + out.push(path); + } + } + out + } + #[test] fn pinned_bytes_do_not_count_against_the_budget() { // Otherwise a large pin starves the passive cache into evicting diff --git a/ui/dr-ui/src/collections_ui.rs b/ui/dr-ui/src/collections_ui.rs index f65b47d..72b2b5f 100644 --- a/ui/dr-ui/src/collections_ui.rs +++ b/ui/dr-ui/src/collections_ui.rs @@ -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>, + /// 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>, /// 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, + /// 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, + /// 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>, /// Collection ids parallel to the sidebar's rows, so a hovered row index /// resolves to an id without another query. row_ids: RefCell>, @@ -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, + anchor: &mut Option, + previous: &mut Option, + 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, + anchor: &mut Option, + previous: Option, + 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, 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, 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, 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, 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 { + let mut counts: std::collections::HashMap = + 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::, _>>() + }) { + 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) { + 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( // 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, + anchor: &mut Option, + previous: &mut Option, + 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![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) { + 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(); diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index df6c895..95f9284 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -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>, + /// 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>, + /// 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>, /// 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) { - 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::>() + .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 = 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, + 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) { } }; 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) { + 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, + coll_ctl: &Rc, +) { + 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, + coll_ctl: &Rc, +) { + 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, + coll_ctl: &Rc, +) { + 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( } // 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); }); } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 0880045..5e2934c 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -3,7 +3,7 @@ import { AdjustPanel, GeometryPanel, ParamRow, TransferPanel } from "adjust.slin import { LaunchScreen } from "launch.slint"; import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint"; import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint"; -import { CollectionsPanel, CollectionRow } from "collections.slint"; +import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint"; import { HistogramPanel, HistogramView } from "histogram.slint"; import { SettingsPage } from "settings.slint"; @@ -395,6 +395,20 @@ export component AppWindow inherits Window { in property library-pin-total: 0; callback library-toggle-pin-scope(); + // The question a held collection asks: keep these on the device, or give + // the disk back. Driven entirely from Rust — the counts and the byte sizes + // in the labels are catalog reads, and an empty `title` is what closes it. + in property offline-prompt-title: ""; + in property offline-prompt-detail: ""; + in property offline-prompt-keep-label: ""; + in property offline-prompt-release-label: ""; + in property offline-prompt-can-keep: false; + in property offline-prompt-can-release: false; + in property offline-prompt-busy: false; + callback offline-prompt-keep(); + callback offline-prompt-release(); + callback offline-prompt-dismiss(); + in property library-root-label: ""; in-out property <[TimelineBar]> library-timeline; in property library-timeline-label: ""; @@ -451,6 +465,27 @@ export component AppWindow inherits Window { callback collection-toggle(int); callback collection-new(); callback collection-menu(int); + /// TRACES: FR-NC-6a + /// Ask about a collection's local copies: the tray on a row, or a long + /// press anywhere on it. + callback collection-offline-menu(int); + /// A row is held or released — the long press is timed in Rust. + callback collection-row-press(int, bool); + /// TRACES: FR-CAT-7 | FR-UI-4 + /// File the grid's selection in a collection without dragging it there: + /// the target's id, and whether to take the images out of the collection + /// currently being shown. + callback library-file-in-collection(int, bool); + /// TRACES: FR-UI-2 + /// Whether a tap in the grid selects rather than opens, and the button + /// that turns it on. The long press does the same thing without it. + in property library-select-mode: false; + callback library-toggle-select-mode(); + /// A press on a cell ended, so the long-press timer can be cancelled. + callback library-cell-press-ended(); + /// TRACES: FR-UI-2 | FR-UI-4 + /// Two taps on a cell: in selection mode, the far end of a range. + callback library-cell-double-clicked(int); /// Renaming started on a row, by id — a double-click, or `F2`. callback collection-rename-start(int); /// A rename was committed: the collection's id and the new name. @@ -753,6 +788,15 @@ in property panel-visible: true; // the inner scopes that use it for something more specific — a // rename in the sidebar — see it first and accept it there. if (event.text == Key.Back || event.text == Key.Escape) { + // A dialogue takes "go up one" first, and it is the *only* + // thing that goes up. Without this, Android's Back button + // would leave the library from behind an open question — the + // view changing underneath a modal, which reads as the app + // having lost its place. + if (root.offline-prompt-title != "") { + root.offline-prompt-dismiss(); + return accept; + } return root.back-requested() ? accept : reject; } return reject; @@ -910,6 +954,8 @@ in property panel-visible: true; toggle(id) => { root.collection-toggle(id); } new-collection() => { root.collection-new(); } row-menu(id) => { root.collection-menu(id); } + offline-menu(id) => { root.collection-offline-menu(id); } + row-press(id, down) => { root.collection-row-press(id, down); } renaming-id: root.collection-renaming; rename-start(id) => { root.collection-rename-start(id); } @@ -1009,6 +1055,16 @@ in property panel-visible: true; cell-pressed(i, ctrl, shift) => { root.library-cell-pressed(i, ctrl, shift); } + cell-press-ended() => { root.library-cell-press-ended(); } + cell-double-clicked(i) => { root.library-cell-double-clicked(i); } + select-mode: root.library-select-mode; + toggle-select-mode() => { root.library-toggle-select-mode(); } + // The sidebar's rows, not a second model: the sheet files + // into the same tree the sidebar draws. + collections: root.collection-rows; + file-in-collection(id, moves) => { + root.library-file-in-collection(id, moves); + } cursor: root.library-cursor; move-cursor(delta, extend) => { root.library-move-cursor(delta, extend); @@ -1633,5 +1689,30 @@ in property panel-visible: true; indeterminate: !root.activity-determinate; fraction: root.activity-fraction; } + + // TRACES: FR-NC-6a + // The offline question, over the shell rather than inside the sidebar + // that raises it. The sidebar is 232px wide and closes entirely in the + // compact layout, so a dialogue living in there would be unreadable on + // the device the gesture exists for — and the answer commits gigabytes + // either way, which deserves the width to say so. + // + // Below the load bar, above everything else: a download started from + // here shows its progress in that bar, and the bar must not be the + // thing the dialogue covers. + OfflinePrompt { + width: 100%; + height: 100%; + title: root.offline-prompt-title; + detail: root.offline-prompt-detail; + keep-label: root.offline-prompt-keep-label; + release-label: root.offline-prompt-release-label; + can-keep: root.offline-prompt-can-keep; + can-release: root.offline-prompt-can-release; + busy: root.offline-prompt-busy; + keep() => { root.offline-prompt-keep(); } + release() => { root.offline-prompt-release(); } + dismiss() => { root.offline-prompt-dismiss(); } + } } } diff --git a/ui/dr-ui/ui/collections.slint b/ui/dr-ui/ui/collections.slint index 599eff4..8064b12 100644 --- a/ui/dr-ui/ui/collections.slint +++ b/ui/dr-ui/ui/collections.slint @@ -49,6 +49,17 @@ export struct CollectionRow { expanded: bool, // A saved filter. Cannot be dropped onto — its membership is its selector. smart: bool, + // TRACES: FR-NC-6a + // Whether every photograph in here — and in its children — is pinned for + // offline use. A collection holding nothing is never `pinned`: "all zero of + // them are kept" is true and useless, and it would draw a full tray beside + // an empty collection. + pinned: bool, + // Pinned in part: some of what is in here is kept, some is not. Distinct + // from `pinned` because the two say different things to a user about to + // board an aeroplane, and one bool could only say the more optimistic of + // them. + partly-pinned: bool, } // A collection row: disclosure arrow, name, count, and a drop highlight. @@ -70,6 +81,17 @@ component TreeRow inherits Rectangle { /// Images were dropped on this row. callback dropped(); callback context-menu(); + /// TRACES: FR-NC-6a + /// Ask what should happen to this collection's local copies — keep them, or + /// give the disk back. The question is asked rather than a toggle silently + /// flipped, because both answers are expensive: one starts a download of + /// gigabytes, the other deletes them. + callback offline-menu(); + /// The row is being held, or has been let go. A long press is the touch + /// route to [`offline-menu`], and the timer that decides how long is long + /// lives in Rust — Slint has no long-press gesture, and a hand-rolled one + /// here would need a `Timer` per row in the tree. + callback press-changed(bool); /// Start renaming this row — a double-click on the name. callback rename-requested(); /// The new name, committed with Enter or by clicking away. @@ -224,6 +246,54 @@ component TreeRow inherits Rectangle { } } + // TRACES: FR-NC-6a | FR-NC-6c + // Keep this collection on the device. Here rather than only in the + // grid header because the header's version acts on whatever the grid + // is *scoped* to: reaching it means selecting the collection, closing + // the sidebar, opening the action row and finding the button — four + // steps on a tablet to say "take this trip with me". This is one. + // + // Drawn only where there is something to keep, and on a row that is + // already keeping something: an outline tray beside every collection + // in the tree is chrome the eye has to filter past to read the names, + // which is what the sidebar is actually for. A smart collection is + // excluded for the same reason it refuses a drop — its membership is a + // rule that has not been evaluated here. + if !root.entry.smart + && (root.entry.deep-count > 0 || root.entry.direct-count > 0) + && (root.selected || root.entry.pinned || root.entry.partly-pinned + || touch.has-hover): Rectangle { + width: 18px; + y: (parent.height - self.height) / 2; + height: 18px; + + Icon { + name: root.entry.pinned ? "offline-held" : "offline"; + // Held is the state worth colouring: it is a promise the app + // has made about a gigabyte of transfer, and it should be + // legible from across the room. A partial hold is dimmer than + // a full one and brighter than an offer, which is the order + // the three actually stand in. + ink: root.entry.pinned ? Theme.active + : (root.entry.partly-pinned ? Theme.active-dim : Theme.ink-faint); + size: 12px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + + // Wider than it is drawn, and no taller. A 44px square here — the + // touch minimum — would be 18px taller than the row it sits in and + // would take presses aimed at the collections above and below it, + // which is a worse failure than a small target: the user hits a + // row and a different collection's download starts. + TouchArea { + width: 30px; + height: parent.height; + x: (parent.width - self.width) / 2; + clicked => { root.offline-menu(); } + } + } + // The count. A parent shows its deep total, since its own direct // membership is usually zero and "0" beside a full subtree reads as // broken. The distinction is spelled out in the tooltip-less way @@ -253,6 +323,21 @@ component TreeRow inherits Rectangle { if (ev.kind == PointerEventKind.down && ev.button == PointerEventButton.right) { root.context-menu(); + return; + } + // The hold that opens the offline question. Reported both ways: + // Rust starts a timer on the press and cancels it on the release, + // so a tap that happens to be slow is still a tap. + // + // `cancel` matters as much as `up` here — the sidebar's Flickable + // takes the pointer the moment the finger travels, and a press + // that became a scroll must not fire a dialogue over the list the + // user is scrolling. + if (ev.kind == PointerEventKind.down) { + root.press-changed(true); + } + if (ev.kind == PointerEventKind.up || ev.kind == PointerEventKind.cancel) { + root.press-changed(false); } } } @@ -277,6 +362,13 @@ export component CollectionsPanel inherits Rectangle { callback new-collection(); // Right-click on a row: rename, delete, new child. callback row-menu(int); + /// TRACES: FR-NC-6a + /// Ask about a collection's local copies, by id. Reached by tapping the + /// tray on a row, or by holding the row itself. + callback offline-menu(int); + /// A row is being held, or was let go: its id, and which. The long-press + /// timer is in Rust; see `TreeRow.press-changed`. + callback row-press(int, bool); /// Which collection is being renamed, by id. 0 is none. /// @@ -423,6 +515,8 @@ export component CollectionsPanel inherits Rectangle { clicked => { root.select(row.id); } toggle => { root.toggle(row.id); } context-menu => { root.row-menu(row.id); } + offline-menu => { root.offline-menu(row.id); } + press-changed(down) => { root.row-press(row.id, down); } dropped => { root.dropped-on(row.id); } drag-over(over) => { root.drag-over(row.id, over); } rename-requested => { root.rename-start(row.id); } @@ -564,3 +658,121 @@ export component CollectionsPanel inherits Rectangle { background: Theme.rule; } } + +// TRACES: FR-NC-6a | FR-NC-6c | FR-UI-3 +// What a held collection asks: keep these photographs on this device, or give +// the disk back. +// +// A question rather than a toggle, because the two answers are not symmetrical +// conveniences — one commits the device to downloading gigabytes over whatever +// connection it has, and the other deletes gigabytes it already holds. Neither +// is something to discover by having done it. +// +// The sizes are in the labels rather than in a second confirmation, the same +// way the grid's "Export 40" puts its count in the button: a number read before +// the tap is worth more than a dialogue asking the same question after it. +// +// Drawn over the whole window rather than inside the sidebar. The sidebar is +// 232px wide and collapses entirely in the compact layout — a dialogue that +// lived in there would be unreachable on the device this gesture exists for. +export component OfflinePrompt inherits Rectangle { + /// The collection being asked about. Empty closes the prompt — a single + /// source for "is this open", rather than a bool that can disagree with the + /// name beside it. + in property title; + /// What is in there and how much of it is already here, in one line. + in property detail; + /// The affirmative label, with the transfer it commits to: Rust knows the + /// count and the bytes, and this file should not be doing arithmetic on + /// them. + in property keep-label; + /// The destructive label, with the disk it frees. + in property release-label; + /// Whether there is anything left to download, and anything held to delete. + /// Both can be false — an empty collection — and the prompt then says only + /// what it holds and offers the way out. + in property can-keep: false; + in property can-release: false; + /// Whether a download is already running for this collection, so the + /// affirmative reads as "already going" rather than inviting a second one. + in property busy: false; + + callback keep(); + callback release(); + callback dismiss(); + + visible: root.title != ""; + background: #000000CC; + + // Swallows everything that misses the card, and dismisses. Declared before + // the card so the card's own controls sit above it. + TouchArea { + clicked => { root.dismiss(); } + } + + Rectangle { + width: min(420px, parent.width - 2 * Theme.gap-lg); + height: card.preferred-height; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + background: Theme.surface; + border-radius: Theme.radius; + border-width: 1px; + border-color: Theme.rule; + + // A press on the card must not reach the scrim behind it and close the + // thing being read. + TouchArea { } + + card := VerticalLayout { + padding: Theme.gap-lg; + spacing: Theme.gap; + + Text { + text: "Keep offline"; + color: Theme.ink-faint; + font-size: Theme.text-sm; + font-weight: 700; + letter-spacing: 1.2px; + } + + Text { + text: root.title; + color: Theme.ink; + font-size: Theme.text-lg; + font-weight: 600; + wrap: word-wrap; + } + + Text { + text: root.detail; + color: Theme.ink-dim; + font-size: Theme.text; + wrap: word-wrap; + } + + Rectangle { height: 1px; background: Theme.rule; } + + // Stacked rather than a row of three. At 420px on a tablet the + // labels carry counts and byte sizes, and three of those side by + // side elide to "Keep 128 pho…" — which loses exactly the number + // the label exists to show. + Button { + text: root.busy ? "Downloading…" : root.keep-label; + primary: true; + enabled: root.can-keep && !root.busy; + clicked => { root.keep(); } + } + + if root.can-release: Button { + text: root.release-label; + clicked => { root.release(); } + } + + Button { + text: "Cancel"; + clicked => { root.dismiss(); } + } + } + } +} diff --git a/ui/dr-ui/ui/icons.slint b/ui/dr-ui/ui/icons.slint index 52614ab..cf4d946 100644 --- a/ui/dr-ui/ui/icons.slint +++ b/ui/dr-ui/ui/icons.slint @@ -58,6 +58,7 @@ component Ink inherits Path { // chevron-right chevron-down chevron-left arrow-up // rotate-ccw rotate-cw flip-h flip-v // collection collection-smart +// offline offline-held export component Icon inherits Rectangle { in property name; /// The single colour the whole drawing takes. Named `ink` rather than @@ -253,4 +254,35 @@ export component Icon inherits Rectangle { stroke-width: root.weight; commands: "M 12 9 L 15 12 L 12 15 L 9 12 Z"; } + + // --- availability ------------------------------------------------------ + // + // TRACES: FR-NC-6a | FR-NC-6c + // "Kept on this device": an arrow coming down into a tray. The download + // shape rather than a pin, because what the gesture does is *fetch* — a + // pin reads as "stick this to the top of the list", which is what it means + // in every other sidebar the user has seen. + // + // Outline and solid are the same tray with the same arrow, so a collection + // that finishes downloading does not shift under the finger that started + // it. Solid fills the tray only: an arrow filled as well loses the shaft + // at 12px and reads as a blob. + + if root.name == "offline": Ink { + stroke: root.ink; + stroke-width: root.weight; + commands: "M 12 3.6 L 12 13.8 M 7.4 9.4 L 12 14 L 16.6 9.4 " + + "M 4.2 15.4 L 4.2 19.8 L 19.8 19.8 L 19.8 15.4"; + } + + if root.name == "offline-held": Ink { + stroke: root.ink; + stroke-width: root.weight; + commands: "M 12 3.6 L 12 13.8 M 7.4 9.4 L 12 14 L 16.6 9.4"; + } + if root.name == "offline-held": Ink { + fill: root.ink; + stroke-width: root.weight; + commands: "M 4.2 15.4 L 19.8 15.4 L 19.8 19.8 L 4.2 19.8 Z"; + } } diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 08d5b70..bb748bb 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -10,6 +10,10 @@ import { Theme } from "theme.slint"; import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon } from "widgets.slint"; +// The filing sheet lists the same rows the sidebar draws, from the same model: +// two lists of collections that could disagree about what exists is one list +// too many. +import { CollectionRow } from "collections.slint"; // One bar of the capture-time histogram. export struct TimelineBar { @@ -449,6 +453,11 @@ export component FlagMark inherits Rectangle { component HeaderActions inherits HorizontalLayout { in property scope-label; in property selected-count: 0; + /// TRACES: FR-UI-2 | FR-UI-4 + /// Whether taps are selecting rather than opening. The non-gesture half of + /// touch multi-selection: the long press is the quick way in, and this is + /// the way that can be *found*. + in property select-mode: false; in property scanning: false; in property syncing: false; in property scope-pinned: false; @@ -469,6 +478,9 @@ component HeaderActions inherits HorizontalLayout { callback cancel-export(); callback paste-settings-to-selection(); callback remove-from-collection(); + /// Open the sheet that files the selection in a collection. + callback add-to-collection(); + callback toggle-select-mode(); callback change-library(); callback toggle-pin-scope(); callback sync-now(); @@ -477,6 +489,35 @@ component HeaderActions inherits HorizontalLayout { spacing: Theme.gap; + // TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7 + // Selection mode, first in the row because it is what the rest of the row + // acts on: every button after this one appears once something is selected. + // + // It exists at all because the desktop's way in — ctrl-click, shift-click — + // has no touch equivalent, and the drag that files a photograph in a + // collection needs a *selection* before it can carry more than one. On a + // tablet the long press does this faster; a gesture with no visible + // counterpart is a feature only its author knows about (FR-UI-4). + // + // "Done" rather than "Selecting": the label should say what pressing it + // does, and the inverted fill already says which state we are in. + Button { + text: root.select-mode ? "Done" : "Select"; + active: root.select-mode; + y: root.centred ? (root.row-height - self.height) / 2 : 0; + clicked => { root.toggle-select-mode(); } + } + + // TRACES: FR-CAT-7 | FR-UI-4 + // File the selection in a collection without dragging it there. The drag + // is the faster gesture with a pointer and impossible with one finger on a + // grid that scrolls, which is the whole reason this button exists. + if root.selected-count > 0: Button { + text: "Add to collection"; + y: root.centred ? (root.row-height - self.height) / 2 : 0; + clicked => { root.add-to-collection(); } + } + // TRACES: FR-DEV-6 // Batch-apply the copied settings. Shown only with both a selection and a // clipboard, because it is meaningless without either — and because a @@ -544,9 +585,13 @@ component HeaderActions inherits HorizontalLayout { // request, and a button that meant either depending on invisible state // would be a trap. if root.scope-label != "": Button { - // No tick on the pinned label: `active` already inverts the button, + // No tick on the kept label: `active` already inverts the button, // which says the same thing without a symbol inside a string. - text: root.scope-pinned ? "Pinned" : "Pin offline"; + // + // It opens the same question the sidebar's tray and a held row open, + // rather than pinning outright — see the note on the callback in + // `library_ui::wire`. + text: root.scope-pinned ? "Kept offline" : "Keep offline"; active: root.scope-pinned; enabled: !root.scanning; y: root.centred ? (root.row-height - self.height) / 2 : 0; @@ -718,6 +763,29 @@ export component LibraryGrid inherits Rectangle { /// Modifier state at press time, so Rust can decide replace / add / extend /// without the .slint file encoding the selection policy. callback cell-pressed(int, bool, bool); + /// TRACES: FR-UI-2 | FR-UI-4 + /// The press on a cell ended — lifted, or taken away by the Flickable when + /// the finger travelled. Cancels the long-press timer that would otherwise + /// turn a scroll into a selection. Rust owns that timer: Slint has no + /// long-press gesture, and a hand-rolled one here would need a `Timer` per + /// visible cell. + callback cell-press-ended(); + /// TRACES: FR-UI-2 | FR-UI-4 + /// Two taps on the same cell. In selection mode this is the touch form of + /// shift-click: everything from where the selection started to here. Rust + /// decides — outside that mode a double tap is two ordinary clicks and the + /// first has already opened the image. + callback cell-double-clicked(int); + /// TRACES: FR-UI-2 | FR-UI-4 + /// Whether a tap selects rather than opens. + /// + /// Held in Rust beside the selection it modifies, so the long press and the + /// header's button are two doors into one state rather than two states that + /// can disagree. In this mode a plain tap toggles a cell — exactly what + /// ctrl-click does with a pointer, which is why the press below passes it + /// as the ctrl flag rather than as a third selection policy. + in property select-mode: false; + callback toggle-select-mode(); /// The drag payload: the selected image ids, wrapped by Rust. Called when a /// drag starts, so it always reflects the selection as it is at that moment. pure callback drag-payload() -> data-transfer; @@ -828,6 +896,31 @@ export component LibraryGrid inherits Rectangle { /// thing this button does. callback remove-from-collection(); + // --- filing the selection (FR-CAT-7, FR-UI-4) --------------------------- + // + // The drag onto the sidebar is the fast way to file photographs, and it is + // a pointer gesture: a one-finger drag beginning inside the grid belongs to + // the Flickable that scrolls it, which is the arbitration described at the + // top of `collections_ui.rs` working exactly as it should. So touch needs a + // way in that is not a drag, and that is this sheet. + // + // The rows are the sidebar's own model, passed through rather than queried + // again: two lists of collections is one list too many, and the one that + // goes stale is always the one nobody is looking at. + in property <[CollectionRow]> collections; + /// Whether the sheet is up. Local, because it is a disclosure rather than a + /// preference — nothing outside this file needs to know it is open, and + /// what closes it is choosing a collection or dismissing it. + property filing: false; + /// Whether the images should *leave* the collection being shown, rather + /// than being filed in a second one as well. Only meaningful while scoped, + /// and reset every time the sheet opens: a destructive default that + /// remembered itself between uses is how photographs go missing. + property filing-moves: false; + /// File the selection: the target collection's id, and whether to take it + /// out of the one currently being shown. + callback file-in-collection(int, bool); + // Cell geometry. Columns are derived from the available width so the grid // reflows with the window rather than fixing a count (FR-UI-1). // Zoomable, so the grid serves both jobs: fewer, larger images for @@ -943,6 +1036,17 @@ export component LibraryGrid inherits Rectangle { cancel-export => { root.cancel-export(); } paste-settings-to-selection => { root.paste-settings-to-selection(); } remove-from-collection => { root.remove-from-collection(); } + select-mode: root.select-mode; + toggle-select-mode => { root.toggle-select-mode(); } + add-to-collection => { + // Reset every time it opens: see `filing-moves`. + root.filing-moves = false; + root.filing = true; + // The disclosure has served its purpose and would + // otherwise sit behind the sheet, so the row it belongs + // to is still there when the sheet closes. + root.actions-open = false; + } change-library => { root.change-library(); } toggle-pin-scope => { root.toggle-pin-scope(); } sync-now => { root.sync-now(); } @@ -994,6 +1098,17 @@ export component LibraryGrid inherits Rectangle { cancel-export => { root.cancel-export(); } paste-settings-to-selection => { root.paste-settings-to-selection(); } remove-from-collection => { root.remove-from-collection(); } + select-mode: root.select-mode; + toggle-select-mode => { root.toggle-select-mode(); } + add-to-collection => { + // Reset every time it opens: see `filing-moves`. + root.filing-moves = false; + root.filing = true; + // The disclosure has served its purpose and would + // otherwise sit behind the sheet, so the row it belongs + // to is still there when the sheet closes. + root.actions-open = false; + } change-library => { root.change-library(); } toggle-pin-scope => { root.toggle-pin-scope(); } sync-now => { root.sync-now(); } @@ -1300,6 +1415,23 @@ export component LibraryGrid inherits Rectangle { init => { self.focus(); } key-pressed(event) => { + // TRACES: FR-UI-2 | FR-UI-4 + // Back and Escape close what is open here, innermost + // first, before the shell above gets to read them as + // "leave the library". On Android that is the system Back + // button, and a sheet it walked straight past would leave + // the user out of the grid with their selection gone. + if (event.text == Key.Back || event.text == Key.Escape) { + if (root.filing) { + root.filing = false; + return accept; + } + if (root.select-mode) { + root.toggle-select-mode(); + return accept; + } + return reject; + } if (event.text == "0") { root.judged(0, -1); return accept; } if (event.text == "1") { root.judged(1, -1); return accept; } if (event.text == "2") { root.judged(2, -1); return accept; } @@ -1678,14 +1810,30 @@ export component LibraryGrid inherits Rectangle { // Selected on *press*, not on release: the drag that may // follow reads the selection to build its payload, and by // release the pointer is over the sidebar. + // + // In selection mode the press is reported as though ctrl + // were held. That is not a shortcut: toggling one cell + // while keeping the rest *is* what ctrl-click means, and + // giving touch its own policy would be a second copy of + // the rules in `collections_ui::apply_press` to keep in + // step with the first. pointer-event(ev) => { if (ev.kind == PointerEventKind.down) { root.cell-pressed( i, - ev.modifiers.control, + ev.modifiers.control || root.select-mode, ev.modifiers.shift, ); } + // Both endings, and `cancel` is the important one: + // the Flickable takes the pointer as soon as the + // finger travels, so without this a scroll that + // began on a cell would come to rest as a long + // press and select it. + if (ev.kind == PointerEventKind.up + || ev.kind == PointerEventKind.cancel) { + root.cell-press-ended(); + } } // A *plain* click opens the image; a modified one is purely @@ -1695,6 +1843,11 @@ export component LibraryGrid inherits Rectangle { // records it and Rust decides — `cell-clicked` is only // honoured when the press was unmodified. clicked => { root.cell-clicked(i); } + // The far end of a range, in selection mode. Slint + // delivers `clicked` for the first tap as well, which + // is why the toggling is idempotent-by-union in Rust + // rather than this file trying to swallow one of them. + double-clicked => { root.cell-double-clicked(i); } } // --- the rating strip, ABOVE the cell's own hit area --- @@ -1735,4 +1888,149 @@ export component LibraryGrid inherits Rectangle { } } + + // --- the filing sheet (FR-CAT-7, FR-UI-4) ------------------------------- + // + // "Put these in…", for the times a drag is not available: one finger on a + // scrolling grid, or a selection made across a scrub where the sidebar has + // long since been closed to give the photographs the width. + // + // Last in the file, so it draws over the grid — and outside the + // VerticalLayout above, so appearing does not reflow the header and the + // cells underneath it. + if root.filing: Rectangle { + background: #000000CC; + + // Swallows the taps that miss the card, and closes. First, so the + // card's own controls sit above it. + TouchArea { + clicked => { root.filing = false; } + } + + Rectangle { + width: min(420px, parent.width - 2 * Theme.gap-lg); + // Tall enough for the list, but never taller than the window: a + // library with forty collections must still leave the buttons on + // screen, which is what the Flickable inside is for. + height: min(sheet.preferred-height, parent.height - 2 * Theme.gap-lg); + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + background: Theme.surface; + border-radius: Theme.radius; + border-width: 1px; + border-color: Theme.rule; + + // Stops a press on the card reaching the scrim behind it. + TouchArea { } + + sheet := VerticalLayout { + padding: Theme.gap-lg; + spacing: Theme.gap; + + Text { + text: root.selected-count == 1 + ? "File 1 photograph in…" + : "File " + root.selected-count + " photographs in…"; + color: Theme.ink; + font-size: Theme.text-lg; + font-weight: 600; + wrap: word-wrap; + } + + // Filing is a *copy*: a photograph can be in as many + // collections as it belongs in, which is what a join table + // means and what the drag has always done. Moving is the + // exception and has to be asked for, because it is the one + // that takes something away. + if root.scope-label != "": Button { + text: root.filing-moves + ? "Moving out of " + root.scope-label + : "Also keep in " + root.scope-label; + active: root.filing-moves; + clicked => { root.filing-moves = !root.filing-moves; } + } + + Rectangle { height: 1px; background: Theme.rule; } + + Flickable { + vertical-stretch: 1; + // A floor, so the list is not squeezed out of existence by + // the buttons around it on a short window. + min-height: 120px; + viewport-height: root.collections.length * (Theme.touch-target + 2px); + + for row[i] in root.collections: Rectangle { + y: i * (Theme.touch-target + 2px); + width: parent.width; + // A full touch target per row, where the sidebar's + // equivalent is 26px. The sidebar is a place to look; + // this is a place to hit once, with a thumb, holding a + // selection that took a minute to build (FR-UI-3). + height: Theme.touch-target; + background: row-touch.pressed ? Theme.pressed + : (row-touch.has-hover ? Theme.hover : transparent); + border-radius: Theme.radius-sm; + // A saved filter's membership is its selector, so it + // cannot be filed into — the same refusal the sidebar + // makes on a drag, made here before the tap rather + // than after it. + opacity: row.smart ? 0.4 : 1.0; + + HorizontalLayout { + padding-left: Theme.gap-sm + row.depth * Theme.indent; + padding-right: Theme.gap-sm; + spacing: Theme.gap-sm; + + Icon { + name: row.smart ? "collection-smart" : "collection"; + ink: Theme.ink-faint; + size: 14px; + y: (parent.height - self.height) / 2; + } + + Text { + text: row.name; + color: Theme.ink; + font-size: Theme.text; + vertical-alignment: center; + overflow: elide; + horizontal-stretch: 1; + } + + Text { + text: row.smart ? "computed" + : (row.deep-count > 0 ? row.deep-count + "" : ""); + color: Theme.ink-faint; + font-size: Theme.text-sm; + vertical-alignment: center; + } + } + + row-touch := TouchArea { + enabled: !row.smart; + clicked => { + root.file-in-collection(row.id, root.filing-moves); + root.filing = false; + } + } + } + + if root.collections.length == 0: Text { + text: "No collections yet. Make one with + in the sidebar."; + color: Theme.ink-faint; + font-size: Theme.text-sm; + wrap: word-wrap; + width: parent.width; + } + } + + Rectangle { height: 1px; background: Theme.rule; } + + Button { + text: "Cancel"; + clicked => { root.filing = false; } + } + } + } + } }