//! Rebuilding the sidebar from the catalog, and pushing catalog-derived //! state into the grid: the selection flags, the lifted cells, the //! per-cell collection badges, and the membership sheet's own rows. //! //! `offline_state` and `direct_holdings` live here rather than beside the //! menu or the tray that reads them, because both are catalog reads over //! the tree — the same shape as `refresh_tree` itself, just narrower. use std::rc::Rc; use dr_catalog::collections::{self as coll, CollectionKind}; use dr_catalog::Catalog; use dr_types::{CollectionId, ImageId}; use slint::{ComponentHandle as _, Model as _}; use crate::{AppWindow, CollectionRow, Collections, Library}; use super::controller::CollectionsController; /// Rebuild the sidebar from the catalog. /// /// Called after every edit. The whole tree rather than a patch: a rename can /// reorder siblings, a delete promotes children, and a drop changes counts on /// every ancestor — diffing that against the model would be more code than the /// query costs, and the tree is tens of rows, not thousands. pub fn refresh_tree(window: &AppWindow, ctl: &Rc, catalog: &Catalog) { // The albums share the sidebar, and every caller of this is a moment // they may have changed too — a merge brings both. if let Some(albums) = ctl.albums.borrow().as_ref() { crate::albums_ui::refresh_from(window, albums, catalog); } let rows = match coll::tree(catalog.connection()) { Ok(r) => r, Err(e) => { window .global::() .set_collection_error(format!("reading collections: {e}").into()); return; } }; 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 — // no ancestor lookup per row. let mut hide_below: Option = None; let mut ids = Vec::new(); let mut smart = Vec::new(); let mut has_kids = Vec::new(); let mut nested = Vec::new(); let mut out = Vec::new(); for row in &rows { if let Some(depth) = hide_below { if row.depth > depth { continue; } hide_below = None; } let id = row.collection.id; let expanded = !collapsed.contains(&id); if row.has_children && !expanded { hide_below = Some(row.depth); } // Deep counts are one query per row. That is fine at sidebar scale and // wrong at grid scale, which is why the grid does not do this. 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); nested.push(row.collection.parent.is_some()); out.push(CollectionRow { id: id.0 as i32, name: row.collection.name.as_str().into(), depth: row.depth as i32, direct_count: row.collection.direct_count as i32, deep_count: deep as i32, 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, }); } *ctl.row_ids.borrow_mut() = ids; *ctl.row_smart.borrow_mut() = smart; *ctl.row_has_children.borrow_mut() = has_kids; *ctl.row_nested.borrow_mut() = nested; window .global::() .set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out))); // The rows are what `sync_reorderable` reads, so it has to be told they // changed: a collection that gains a child, or one that is deleted out from // under the scope, changes whether the grid can be reordered without the // scope itself moving. sync_reorderable(window); } /// 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 } /// Mark which cells are currently lifted out by a drag. /// /// Separate from [`sync_selection`] because the two differ: the selection /// persists after the drag, the lift lasts only while it is in flight. pub fn sync_lifted(window: &AppWindow, lifted: &[ImageId], ids: &[ImageId]) { let model = window.global::().get_library_cells(); for (row, id) in ids.iter().enumerate() { let want = lifted.contains(id); if let Some(mut cell) = model.row_data(row) { if cell.lifted != want { cell.lifted = want; model.set_row_data(row, cell); } } } } /// Push the current selection into the grid model's `selected` flags. /// /// The model carries the flag per cell so Slint can style without a lookup; /// this is what keeps the two in step after a scrub, a drop, or a click. pub fn sync_selection(window: &AppWindow, ctl: &Rc, ids: &[ImageId]) { let selection = ctl.selection.borrow(); let model = window.global::().get_library_cells(); // The anchor is deliberately **not** pushed to the grid. // // It used to be, and it was drawn as a thin ring inside the cell. Two // things were wrong with that. It was the clearest mark on the cell, so it // read as *the* selection to anyone who had not written it; and an anchor // outlives a deselection, so the ring sat around the last photograph // touched while nothing was selected at all — indistinguishable from a cell // that had stayed behind. // // The ordinal still lives in the controller and still decides where a range // extends from. What is gone is the claim that the user needs to see it: // the selection bar says "Tap the last photograph" while a range is armed, // which answers the question the ring was there to answer. for (row, id) in ids.iter().enumerate() { let want = selection.contains(id); if let Some(mut cell) = model.row_data(row) { if cell.selected != want { cell.selected = want; model.set_row_data(row, cell); } } } window .global::() .set_library_selected_count(selection.len() as i32); } /// TRACES: FR-CAT-7 /// Whether what the grid is showing has an order the user can change. /// /// A single manual collection: not the whole library, not a saved filter whose /// membership is a rule, and not a set — a set draws its descendants' images /// too, and two children's `position` columns are unrelated integers that would /// interleave arbitrarily. /// /// Read off the tree rows the sidebar already draws rather than asked of the /// catalog. `smart` and `has_children` are the only two facts it needs and both /// are in the model already, so this cannot disagree with what the user is /// looking at, and a scope change costs no extra query. pub(super) fn sync_reorderable(window: &AppWindow) { let id = window.get_collection_selected(); let rows = window.global::().get_collection_rows(); let manual = id > 0 && (0..rows.row_count()) .filter_map(|i| rows.row_data(i)) .find(|r| r.id == id) .is_some_and(|r| !r.smart && !r.has_children); window.global::().set_library_reorderable(manual); } /// Refresh the per-cell "in this many collections" badges. /// /// One query for the whole window rather than one per cell: 120 cells is 120 /// round trips otherwise, on every drop. pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) { if ids.is_empty() { return; } let placeholders = std::iter::repeat_n("?", ids.len()) .collect::>() .join(","); let sql = format!( "SELECT m.image_id, count(*) FROM collection_members m JOIN collections c ON c.id = m.collection_id WHERE m.image_id IN ({placeholders}) AND c.deleted = 0 GROUP BY m.image_id" ); let params: Vec = ids .iter() .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) .collect(); let mut counts = std::collections::HashMap::new(); if let Ok(mut stmt) = catalog.connection().prepare(&sql) { if let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)) }) { for (id, n) in rows.flatten() { counts.insert(id, n as i32); } } } let model = window.global::().get_library_cells(); for (row, id) in ids.iter().enumerate() { let want = counts.get(&(id.0 as i64)).copied().unwrap_or(0); if let Some(mut cell) = model.row_data(row) { if cell.collection_count != want { cell.collection_count = want; model.set_row_data(row, cell); } } } } /// TRACES: FR-CAT-7 /// What a collection directly holds: its own member rows, and its own children. /// /// One query each rather than reusing `deep_count`, which counts descendants' /// photographs — see [`menu_detail`] for why the menu needs the direct numbers. pub(super) fn direct_holdings( conn: &rusqlite::Connection, id: CollectionId, ) -> Result<(usize, usize), dr_catalog::CatalogError> { let holds: i64 = conn.query_row( "SELECT count(*) FROM collection_members WHERE collection_id = ?1", [id.0 as i64], |r| r.get(0), )?; let children: i64 = conn.query_row( "SELECT count(*) FROM collections WHERE parent_id = ?1 AND deleted = 0", [id.0 as i64], |r| r.get(0), )?; Ok((holds as usize, children as usize)) } /// TRACES: FR-CAT-7 | FR-UI-4 /// Rebuild the membership sheet from the selection as it stands. /// /// Called on open and after every removal rather than patched: a removal can /// take the last selected photograph out of a collection, and that row then has /// to go — a patched model would leave a "Remove" that removes nothing. pub(super) fn refresh_membership( window: &AppWindow, ctl: &Rc, catalog: &Catalog, ) { let chosen = ctl.selected(); let total = chosen.len(); let rows = match coll::membership_of(catalog.connection(), &chosen) { Ok(r) => r, Err(e) => { window .global::() .set_collection_error(format!("reading collections: {e}").into()); Vec::new() } }; let out: Vec = rows .into_iter() .map(|m| crate::MembershipRow { id: m.id.0 as i32, name: m.name.as_str().into(), holding: m.holding as i32, // "1 of 1" is arithmetic nobody asked for; with one photograph // selected the name already says everything true about it. detail: if total <= 1 { slint::SharedString::new() } else { format!("{} of {total}", m.holding).into() }, }) .collect(); window .global::() .set_membership_rows(slint::ModelRc::new(slint::VecModel::from(out))); } #[cfg(test)] mod tests { use super::*; /// 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); } }