diff --git a/ui/dr-ui/src/bursts.rs b/ui/dr-ui/src/bursts.rs new file mode 100644 index 0000000..5ec1083 --- /dev/null +++ b/ui/dr-ui/src/bursts.rs @@ -0,0 +1,535 @@ +//! TRACES: FR-CULL-5 +//! Burst grouping, as the library screen uses it. +//! +//! [`dr_catalog::bursts`] holds the grouping itself and knows nothing about +//! pixels. This is the other half: where the similarity signal comes from, and +//! how a group reaches a grid cell. +//! +//! # The signal comes out of the thumbnail store +//! +//! A perceptual signature needs pixels, and the cheapest pixels in the app are +//! the ones already sitting in `dr-thumbs`: a 256px JPEG per photograph, built +//! for the grid, shared between devices, and vastly more resolution than a 9×8 +//! reduction can use. So this pass decodes thumbnails, never originals. A +//! library that has been browsed — or that has synced somebody else's shards — +//! has already paid for every signature it is about to get. +//! +//! The consequence, stated rather than hidden: **an image with no thumbnail +//! gets no signature, and a frame with no signature never joins a burst.** That +//! is self-correcting rather than permanent — the next pass finds the thumbnail +//! the sweep has since built — and it is the reason this runs when the +//! thumbnail sweep finishes rather than on a timer. +//! +//! # Why a pass and not a job +//! +//! Hashing is per-image and would make a perfectly good job kind. Grouping is +//! not: a burst is a property of a *run* of frames, so a per-image job would +//! regroup the library once per photograph. Since the two have to happen in that +//! order and the second cannot be split, both live in one pass — the same +//! argument docs/catalog.md §10.2 makes for face clustering. +//! +//! # It is never on the UI thread +//! +//! Decoding tens of thousands of thumbnails is bounded only by library size, and +//! the one thing that must not grow with library size is how long the window +//! stops answering (NFR-P9). Cancellation is dropping the receiver; a pass +//! abandoned half way leaves the signatures it did compute — they are permanent +//! and correct — and the previous grouping intact. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::mpsc::Receiver; + +use dr_catalog::bursts::{self, Rules, Signature}; +use dr_catalog::Catalog; +use dr_thumbs::{ThumbSize, ThumbStore}; +use dr_types::ImageId; +use slint::Model as _; + +use crate::AppWindow; + +/// How many signatures are written per transaction. +/// +/// One transaction per image costs a WAL commit per thumbnail and turns a pass +/// over a real library into minutes of fsync; one transaction for the whole pass +/// holds a write lock for the duration and loses everything if the app closes. +/// A few hundred is the usual answer to that trade. +const WRITE_BATCH: usize = 256; + +/// Progress from a grouping pass. +#[derive(Debug, Clone, PartialEq)] +pub enum BurstMessage { + /// How many images still need a signature. Sent once, before any decoding. + Started { to_hash: usize }, + /// Cumulative signatures written. + Progress { hashed: usize }, + /// The pass finished, and this is what the library now looks like. + Finished { + hashed: usize, + bursts: usize, + frames: usize, + }, + /// It did not. + Failed(String), +} + +/// Hash whatever is missing a signature, then rebuild the grouping. +/// +/// Both halves run on a worker thread. The catalog is opened here rather than +/// shared with the UI's connection: SQLite connections are not `Send`, and WAL +/// is what makes a second one safe while the grid reads (NFR-R1). +pub fn spawn_grouping(catalog_path: PathBuf, thumbs_dir: PathBuf) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let catalog = match Catalog::open(&catalog_path) { + Ok(c) => c, + Err(e) => { + let _ = tx.send(BurstMessage::Failed(format!("cannot open catalog: {e}"))); + return; + } + }; + let conn = catalog.connection(); + + let outstanding = match bursts::images_without_signature(conn) { + Ok(v) => v, + Err(e) => { + let _ = tx.send(BurstMessage::Failed(e.to_string())); + return; + } + }; + if tx + .send(BurstMessage::Started { + to_hash: outstanding.len(), + }) + .is_err() + { + return; + } + + // A broken or absent store costs signatures, never correctness: the + // grouping still runs over whatever is already hashed, and the images + // that missed out are picked up by the next pass. + let store = match ThumbStore::open(&thumbs_dir) { + Ok(s) => Some(s), + Err(e) => { + log::warn!("thumbnail store unavailable, not hashing: {e}"); + None + } + }; + + let hashed = match store { + Some(store) => hash_all(conn, &store, &outstanding, &tx), + None => 0, + }; + + match bursts::regroup(conn, Rules::default()) { + Ok(report) => { + log::info!( + "bursts: {hashed} signature(s) added, {} group(s) over {} frame(s), \ + largest {}", + report.bursts, + report.frames, + report.largest + ); + let _ = tx.send(BurstMessage::Finished { + hashed, + bursts: report.bursts, + frames: report.frames, + }); + } + Err(e) => { + let _ = tx.send(BurstMessage::Failed(e.to_string())); + } + } + }); + + rx +} + +thread_local! { + /// The running pass's drain timer, and whether one is running. + /// + /// Module-local rather than a pair of fields on the library controller, so + /// that everything this feature needs to run lives in this file and the + /// screen that starts it is left holding nothing. Safe as a thread local + /// because Slint's event loop is single-threaded (NFR-P9) and this is only + /// ever touched from it. + /// + /// The flag is separate because stopping a timer does not drop it: a slot + /// tested for emptiness would refuse every pass after the first. + static DRAIN: RefCell> = const { RefCell::new(None) }; + static RUNNING: Cell = const { Cell::new(false) }; +} + +/// Hash what has become hashable, then rebuild the library's burst grouping. +/// +/// Fired when the thumbnail sweep finishes, because that is the moment the +/// signatures can all be computed: the pass reads thumbnails, and until the +/// sweep has run most images have none. Not on a timer, and not after every +/// scan — a regroup is cheap but not free, and nothing is waiting on it. +/// +/// `grouped` is called once, with the number of bursts the library now has, if +/// the pass finishes. It is where the caller reloads the grid: the cells hold +/// the same photographs they held before — a new burst arrives open — but every +/// run of frames now carries a mark it did not have a moment ago, and only a +/// reload carries it. +/// +/// Deliberately silent otherwise. The sweeps around it report progress because +/// they run for tens of minutes; this is seconds, and a status line for it would +/// be a line the user must read in order to learn nothing. +pub fn start_pass( + catalog_path: PathBuf, + thumbs_dir: PathBuf, + grouped: impl Fn(usize) + 'static, +) { + // A second pass would read the same rows and write the same answer over the + // first one's transactions. + if RUNNING.get() { + return; + } + RUNNING.set(true); + + let rx = spawn_grouping(catalog_path, thumbs_dir); + let timer = slint::Timer::default(); + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(400), + move || loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + finish(); + return; + } + }; + + match msg { + BurstMessage::Started { to_hash } => { + log::info!("burst grouping: {to_hash} image(s) still to hash"); + } + // Nothing on screen is showing this. Drained rather than + // ignored, because an unread channel is a worker that stalls. + BurstMessage::Progress { hashed } => { + log::debug!("burst grouping: {hashed} hashed so far"); + } + BurstMessage::Finished { + hashed, + bursts, + frames, + } => { + log::info!( + "burst grouping: {hashed} signature(s) added, \ + {bursts} group(s) over {frames} frame(s)" + ); + finish(); + grouped(bursts); + return; + } + BurstMessage::Failed(e) => { + log::warn!("burst grouping: {e}"); + finish(); + return; + } + } + }, + ); + + DRAIN.with(|slot| *slot.borrow_mut() = Some(timer)); +} + +/// Stop draining, and let another pass start. +/// +/// Stops the timer without dropping it — dropping one from inside its own +/// callback is not something to rely on — which is why the flag beside it is +/// what actually says whether a pass is running. +fn finish() { + RUNNING.set(false); + DRAIN.with(|slot| { + if let Some(timer) = slot.borrow().as_ref() { + timer.stop(); + } + }); +} + +/// Decode each image's stored thumbnail and record its signature. +/// +/// Returns how many were written. Anything without a stored thumbnail, or whose +/// blob will not decode, is simply skipped — it keeps its NULL and comes back +/// next time, which is the same treatment `spawn_thumbnails` gives a corrupt +/// blob. +fn hash_all( + conn: &rusqlite::Connection, + store: &ThumbStore, + outstanding: &[ImageId], + tx: &std::sync::mpsc::Sender, +) -> usize { + let keys = thumbnail_keys(conn); + let mut pending: Vec<(ImageId, Signature)> = Vec::with_capacity(WRITE_BATCH); + let mut written = 0usize; + + for image in outstanding { + let Some(file_id) = keys.get(image).copied() else { + continue; + }; + // The grid class, not the large one: 256px is already thirty times the + // detail the reduction keeps, and asking for `Large` would miss most of + // the store, which is filled at `Grid`. + let Ok(Some(thumb)) = store.get(file_id, ThumbSize::Grid) else { + continue; + }; + let Ok((w, h, rgba)) = dr_thumbs::decode_rgba(&thumb.bytes) else { + continue; + }; + let Some(signature) = bursts::signature_of_rgba(&rgba, w, h) else { + continue; + }; + pending.push((*image, signature)); + + if pending.len() >= WRITE_BATCH { + written += flush(conn, &mut pending); + if tx.send(BurstMessage::Progress { hashed: written }).is_err() { + // The receiver is gone: the screen has moved on, and finishing + // the pass would be work nobody is waiting for. + return written; + } + } + } + written += flush(conn, &mut pending); + written +} + +/// Write one batch of signatures, emptying `pending`. +fn flush(conn: &rusqlite::Connection, pending: &mut Vec<(ImageId, Signature)>) -> usize { + if pending.is_empty() { + return 0; + } + let n = pending.len(); + let tx = match conn.unchecked_transaction() { + Ok(t) => t, + Err(e) => { + log::warn!("recording signatures: {e}"); + pending.clear(); + return 0; + } + }; + for (image, signature) in pending.drain(..) { + if let Err(e) = bursts::set_signature(&tx, image, signature) { + log::debug!("recording signature for {}: {e}", image.0); + } + } + match tx.commit() { + Ok(()) => n, + Err(e) => { + log::warn!("committing signatures: {e}"); + 0 + } + } +} + +/// Every image's thumbnail-store key, in one query. +/// +/// Read whole rather than asked per image: the table is one small row per +/// photograph, and a query per image would be tens of thousands of statements +/// to save a megabyte. +fn thumbnail_keys(conn: &rusqlite::Connection) -> HashMap { + let mut out = HashMap::new(); + let Ok(mut stmt) = conn.prepare("SELECT image_id, file_id FROM remote") else { + return out; + }; + let Ok(rows) = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))) else { + return out; + }; + for (image, file) in rows.flatten() { + out.insert(ImageId(image as u64), file as u64); + } + out +} + +/// Fill in the burst badge on every cell of the loaded window. +/// +/// One query for the window, in the same style as the collection badges and the +/// rating counts beside it — a grid that asks the catalog a question per cell is +/// a grid that stutters under a finger. +/// +/// `ids` is the window in grid order, so the row index into the model is the +/// index into it. +pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) { + if ids.is_empty() { + return; + } + let found = match bursts::memberships(catalog.connection(), ids) { + Ok(m) => m, + Err(e) => { + log::debug!("reading burst membership: {e}"); + return; + } + }; + + let model = window.get_library_cells(); + for (row, id) in ids.iter().enumerate() { + let (count, expanded) = match found.get(id) { + Some(m) => (m.size as i32, m.expanded), + // Not in a burst at all, which is most of a library. + None => (0, false), + }; + if let Some(mut cell) = model.row_data(row) { + if cell.burst_count != count || cell.burst_expanded != expanded { + cell.burst_count = count; + cell.burst_expanded = expanded; + model.set_row_data(row, cell); + } + } + } +} + +/// Open or close the burst one cell belongs to. +/// +/// Which direction is decided from what the catalog says the group is currently +/// doing, rather than from the cell's own `burst-expanded`. The cell is a copy +/// of that state and can be one reload behind; the table cannot. +/// +/// The caller reloads the grid rather than repainting it, because collapsing +/// changes what the grid's *query* returns: the row count, the scrollbar and +/// the ordinal a scrub resolves all move together, and they can only stay in +/// step by being read again together. +/// +/// Returns whether anything changed, so a click on a cell that is in no burst — +/// an entirely normal thing to happen — costs no round trip through the grid. +pub fn toggle(catalog: &Catalog, image: ImageId) -> bool { + let conn = catalog.connection(); + let Ok(found) = bursts::memberships(conn, &[image]) else { + return false; + }; + let Some(membership) = found.get(&image) else { + return false; + }; + match bursts::set_expanded(conn, membership.burst_id, !membership.expanded) { + Ok(()) => true, + Err(e) => { + log::warn!("toggling burst {}: {e}", membership.burst_id.0); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A catalog with two frames of one burst, and a thumbnail store holding a + /// picture for each. + /// + /// `name` keeps two tests from sharing a directory, since they run in + /// parallel. Same shape as the store tests in `library`, which is also why + /// this reaches for `temp_dir` rather than a crate: nothing else here needs + /// one. + fn library(name: &str) -> (Catalog, PathBuf) { + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", + [], + ) + .unwrap(); + for (id, at) in [(1i64, 1000i64), (2, 1001)] { + c.execute( + "INSERT INTO images(id, root_id, source_ref, captured_at, camera, added_at) + VALUES (?1, 1, ?2, ?3, 'Canon EOS R5', 0)", + rusqlite::params![id, format!("IMG_{id}.CR3"), at], + ) + .unwrap(); + c.execute( + "INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)", + rusqlite::params![id, 100 + id], + ) + .unwrap(); + } + + let dir = std::env::temp_dir().join(format!("dr-ui-bursts-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + { + let mut store = ThumbStore::open(&dir).unwrap(); + for (id, shift) in [(1u64, 0usize), (2, 1)] { + let rgba = picture(shift); + let bytes = dr_thumbs::encode_rgba(64, 64, &rgba).unwrap(); + store + .put( + 100 + id, + ThumbSize::Grid, + &dr_thumbs::Thumbnail { + width: 64, + height: 64, + bytes, + }, + ) + .unwrap(); + } + } + (cat, dir) + } + + /// A blocky scene, moved sideways by `shift` pixels — one frame of a burst + /// and then the next. + fn picture(shift: usize) -> Vec { + let mut out = vec![0u8; 64 * 64 * 4]; + for y in 0..64 { + for x in 0..64 { + let v = (((x + shift) / 8) * 37 + (y / 8) * 91) as u8; + let p = (y * 64 + x) * 4; + out[p] = v; + out[p + 1] = v; + out[p + 2] = v; + out[p + 3] = 255; + } + } + out + } + + #[test] + fn the_pass_hashes_from_thumbnails_and_groups_what_it_hashed() { + let (cat, dir) = library("hashes"); + let conn = cat.connection(); + let store = ThumbStore::open(&dir).unwrap(); + let outstanding = bursts::images_without_signature(conn).unwrap(); + assert_eq!(outstanding.len(), 2); + + let (tx, _rx) = std::sync::mpsc::channel(); + let hashed = hash_all(conn, &store, &outstanding, &tx); + assert_eq!(hashed, 2, "both thumbnails should have yielded a signature"); + + let report = bursts::regroup(conn, Rules::default()).unwrap(); + assert_eq!(report.bursts, 1, "the two frames were not grouped"); + assert_eq!(report.frames, 2); + } + + #[test] + fn an_image_with_no_thumbnail_keeps_its_null() { + let (cat, dir) = library("no-thumb"); + let conn = cat.connection(); + conn.execute( + "INSERT INTO images(id, root_id, source_ref, captured_at, added_at) + VALUES (9, 1, 'IMG_9.CR3', 1002, 0)", + [], + ) + .unwrap(); + + let store = ThumbStore::open(&dir).unwrap(); + let outstanding = bursts::images_without_signature(conn).unwrap(); + let (tx, _rx) = std::sync::mpsc::channel(); + assert_eq!(hash_all(conn, &store, &outstanding, &tx), 2); + // And it is still offered next time, rather than being written off. + assert_eq!( + bursts::images_without_signature(conn).unwrap(), + vec![ImageId(9)] + ); + } + + #[test] + fn toggling_a_cell_that_is_in_no_burst_changes_nothing() { + let (cat, _dir) = library("no-burst"); + assert!(!toggle(&cat, ImageId(1))); + } +} diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 0305bbd..432ab9a 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -20,6 +20,7 @@ //! in `ui/` names an operation or knows a shader exists (FR-DEV-3a). mod activity; +mod bursts; mod collections_ui; mod derived_sync; mod develop; diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index 25e4473..d55ac11 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -186,6 +186,26 @@ const VISIBLE_UNALIASED: &str = "shadowed_by IS NULL AND trashed_at IS NULL"; /// restore the same frame twice. const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL"; +/// TRACES: FR-CULL-5 +/// The clause that hides the frames a collapsed burst is standing in for. +/// +/// Subject to exactly the discipline [`VISIBLE`] is under, and for the same +/// reason: the header's count, the scrollbar's size, the run a shift-click +/// resolves and the ordinal a scrub lands on are four answers about one list. +/// A burst folded away in the cells but still counted in the total would leave +/// the grid ending in rows that draw nothing, with no clue why. +/// +/// The predicate itself is `dr_catalog::bursts`'s, not this file's, so the +/// interface and the pass that writes the table cannot come to disagree about +/// what collapsed means. +/// +/// A function rather than a constant because it has to name the image table, +/// and the grid aliases it as `i` where the timeline's queries do not. `image` +/// is a table name from this file and never anything a user supplied. +fn uncollapsed(image: &str) -> String { + format!(" AND {}", dr_catalog::bursts::not_collapsed_away(image)) +} + /// TRACES: FR-CAT-4 /// The order the grid lists photographs in: when they were taken. /// @@ -3815,11 +3835,12 @@ pub fn read_cells_scoped( .collect::>() .join(","); let rated = filter.sql(); + let folded = uncollapsed("i"); let (order, order_params) = grid_order_for(catalog, Some(scope)); let sql = format!( "SELECT {CELL_COLUMNS} FROM images i - WHERE {VISIBLE}{rated} + WHERE {VISIBLE}{rated}{folded} AND i.id IN (SELECT image_id FROM collection_members WHERE collection_id IN ({placeholders})) {order} @@ -3854,11 +3875,12 @@ fn read_cells_all( limit: usize, ) -> Result, dr_catalog::CatalogError> { let rated = filter.sql(); + let folded = uncollapsed("i"); let mut rows = { let mut stmt = catalog.connection().prepare(&format!( "SELECT {CELL_COLUMNS} FROM images i - WHERE {VISIBLE}{rated} + WHERE {VISIBLE}{rated}{folded} {GRID_ORDER} LIMIT ?1 OFFSET ?2" ))?; @@ -3959,6 +3981,7 @@ pub fn read_ids_span( } else { let (clause, mut params) = scope_clause(catalog, scope)?; let rated = filter.sql(); + let folded = uncollapsed("i"); // The same ordering the cells were drawn with, from the same place. // A range is a pair of ordinals, and an ordinal read through a // different ORDER BY names a different photograph. @@ -3967,7 +3990,7 @@ pub fn read_ids_span( ( format!( "SELECT i.id FROM images i - WHERE {VISIBLE}{rated}{clause} + WHERE {VISIBLE}{rated}{folded}{clause} {order} LIMIT ? OFFSET ?" ), @@ -4096,9 +4119,10 @@ pub fn total_images_scoped( // Counted through `images` rather than over `collection_members` alone, so // `VISIBLE` applies — a trashed photograph is still a member row, and // counting it made the header claim images the grid would not draw. + let folded = uncollapsed("i"); let sql = format!( "SELECT count(DISTINCT i.id) FROM images i - WHERE {VISIBLE}{rated} + WHERE {VISIBLE}{rated}{folded} AND i.id IN (SELECT image_id FROM collection_members WHERE collection_id IN ({placeholders}))" ); @@ -4409,8 +4433,9 @@ fn total_images_filtered( filter: &RatingFilter, ) -> Result { let rated = filter.sql(); + let folded = uncollapsed("i"); let n: i64 = catalog.connection().query_row( - &format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}"), + &format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}{folded}"), [], |r| r.get(0), )?; @@ -4956,12 +4981,16 @@ mod tests { #[test] fn the_window_read_walks_the_ordering_index() { let catalog = with_images(20); + // Including the burst clause, because the grid includes it: a + // predicate that quietly cost the ordering index would put the sort + // back and this is the only place that would notice. + let folded = uncollapsed("i"); let plan: Vec = catalog .connection() .prepare(&format!( "EXPLAIN QUERY PLAN SELECT {CELL_COLUMNS} FROM images i - WHERE {VISIBLE} + WHERE {VISIBLE}{folded} {GRID_ORDER} LIMIT 10 OFFSET 5" )) @@ -5014,6 +5043,62 @@ mod tests { catalog } + /// TRACES: FR-CULL-5 + /// A folded burst takes rows out of the cells, the count and the range a + /// shift-click resolves — all three, together. + /// + /// This is the test that would fail if the clause were added to four of + /// the five queries that need it. That failure has no other symptom: the + /// header claims images the grid will not draw, the scrollbar sizes itself + /// for rows that are not there, and neither number looks wrong on its own. + #[test] + fn folding_a_burst_takes_the_same_rows_out_of_every_answer() { + use dr_catalog::bursts::{self, Rules, Signature}; + + let catalog = with_images(4); + // Three of the four are one burst: a second apart, one signature. + let ids = image_ids(&catalog); + for (n, id) in ids.iter().enumerate() { + let hash = if n < 3 { 0xFF00 } else { 0x00FF }; + catalog + .connection() + .execute( + "UPDATE images SET captured_at = ?2, camera = 'Canon EOS R5', + perceptual_hash = ?3 + WHERE id = ?1", + rusqlite::params![ + id.0 as i64, + 1_000 + n as i64, + Signature(hash).to_stored() + ], + ) + .unwrap(); + } + bursts::regroup(catalog.connection(), Rules::default()).unwrap(); + + let filter = RatingFilter::default(); + // Open, as a new burst is: nothing has been taken away yet. + assert_eq!(read_cells(&catalog, 0, 50).unwrap().len(), 4); + assert_eq!(total_images_filtered(&catalog, &filter).unwrap(), 4); + + bursts::set_expanded(catalog.connection(), ids[0], false).unwrap(); + + let cells = read_cells(&catalog, 0, 50).unwrap(); + assert_eq!(cells.len(), 2, "the folded frames are still in the cells"); + assert_eq!( + total_images_filtered(&catalog, &filter).unwrap(), + cells.len(), + "the header's count and the cells disagree" + ); + assert_eq!( + read_ids_span(&catalog, None, &filter, false, 0, 49) + .unwrap() + .len(), + cells.len(), + "a shift-click over the whole grid would select frames it cannot show" + ); + } + fn image_ids(catalog: &Catalog) -> Vec { let mut stmt = catalog .connection() diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 348273c..c338196 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -2214,6 +2214,11 @@ fn load_window(window: &AppWindow, ctl: &Rc) { // window, not one per cell. rating: 0, flag: 0, + // And by `bursts::sync_badges`, in one more query for the + // window. Zero is "not in a burst", which is what almost every + // photograph in a library is. + burst_count: 0, + burst_expanded: false, } }) .collect(); @@ -2246,6 +2251,9 @@ fn load_window(window: &AppWindow, ctl: &Rc) { .collect(); crate::collections_ui::sync_badges(window, catalog, &ids); sync_ratings(window, catalog, &ids); + // How many frames each cell stands for, where it stands for several + // (FR-CULL-5). + crate::bursts::sync_badges(window, catalog, &ids); // The rebuilt cells all carry `selected: false`, but the selection itself // is a set of image ids and survives untouched. Without this the ticks // vanished on every scroll — the selection was still there and still acted @@ -3769,6 +3777,26 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc) { if !offline { start_derived_sync(&w, &ctl_cb); } + // And now every photograph in the library has a + // thumbnail, which is the only moment all of its burst + // signatures can be computed. See `bursts::start_pass`, + // which owns the pass and everything it needs to drain + // itself; what it wants from here is the paths and a + // way to say the grid has something new to draw. + if let Some((conn, _)) = ctl_cb.session.borrow().clone() { + let weak_after = w.as_weak(); + let ctl_after = ctl_cb.clone(); + crate::bursts::start_pass( + library::catalog_path(&conn.account), + library::thumbs_dir(&conn.account), + move |bursts| { + let Some(w) = weak_after.upgrade() else { return }; + if bursts > 0 && w.get_show_library() { + schedule_reload(&w, &ctl_after); + } + }, + ); + } return; } } @@ -4119,10 +4147,14 @@ fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option { catalog .connection() .query_row( - "SELECT captured_at FROM images - WHERE shadowed_by IS NULL AND captured_at IS NOT NULL - ORDER BY captured_at - LIMIT 1 OFFSET ?1", + &format!( + "SELECT captured_at FROM images + WHERE shadowed_by IS NULL AND captured_at IS NOT NULL + AND {} + ORDER BY captured_at + LIMIT 1 OFFSET ?1", + dr_catalog::bursts::not_collapsed_away("images") + ), [ordinal as i64], |r| r.get::<_, i64>(0), ) @@ -4153,13 +4185,21 @@ fn scrub_to(window: &AppWindow, ctl: &Rc, when: i64) { // BY), so they never precede a dated one and the predicate below stays // a simple `<`. Shadowed rows are excluded here exactly as the grid // excludes them. + // + // A burst folded up occupies one row of the grid, so it must occupy one + // row of this count as well: an ordinal taken over the unfolded library + // would overshoot by every frame hidden earlier in it. catalog .connection() .query_row( - "SELECT count(*) FROM images - WHERE shadowed_by IS NULL - AND captured_at IS NOT NULL - AND captured_at < ?1", + &format!( + "SELECT count(*) FROM images + WHERE shadowed_by IS NULL + AND captured_at IS NOT NULL + AND captured_at < ?1 + AND {}", + dr_catalog::bursts::not_collapsed_away("images") + ), [when], |r| r.get::<_, i64>(0), ) @@ -4953,6 +4993,34 @@ pub fn wire( }); } + // Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because + // it changes what the grid's query returns — see [`crate::bursts::toggle`]. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_burst_toggled(move |row| { + let Some(w) = weak.upgrade() else { return }; + let id = ctl + .image_ids + .borrow() + .get(row as usize) + .map(|id| dr_types::ImageId(*id as u64)); + let Some(id) = id else { return }; + + let changed = { + let borrow = ctl.catalog.borrow(); + match borrow.as_ref() { + Some(catalog) => crate::bursts::toggle(catalog, id), + None => false, + } + }; + if changed { + ctl.requested.borrow_mut().clear(); + load_window(&w, &ctl); + } + }); + } + // A rating or flag key. Applies to the whole selection, which is what // makes judging a run of frames one keystroke rather than forty. { diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 6a4d828..6053a57 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -603,6 +603,8 @@ export component AppWindow inherits Window { callback library-cell-rated(int, int); /// The trash target was clicked on one cell, by row. callback library-cell-trashed(int); + /// The burst mark was clicked on one cell, by row (FR-CULL-5). + callback library-burst-toggled(int); /// Move the grid selection to the trash — the `Delete` key. callback library-trash-selection(); /// A judgement key was pressed, applying to the whole selection. One of @@ -1596,6 +1598,7 @@ in property panel-visible: true; cell-rated(i, n) => { root.library-cell-rated(i, n); } cell-trashed(i) => { root.library-cell-trashed(i); } + burst-toggled(i) => { root.library-burst-toggled(i); } trash-selection() => { root.library-trash-selection(); } // Derived from the sidebar's own selection rather than // mirrored in a second property: `-1` is already the sentinel diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 0589f16..d3c08f8 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -468,6 +468,16 @@ export struct LibraryCell { // 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a // four-star frame is a normal thing to do mid-cull. flag: int, + // Frames in the burst this cell belongs to (FR-CULL-5), itself included; 0 where it + // belongs to none, which is most of a library. A burst that is collapsed + // draws only its representative, so on that cell this is the count of what + // is hidden behind it — the reason it is shown at all. + burst-count: int, + // Whether the group is currently open. Drawn differently rather than + // hidden: a burst the user has expanded is the one thing on screen that + // needs a way back, and a control that disappears once used is a control + // nobody finds twice. + burst-expanded: bool, } // The photo roll: the grid's loaded window along the foot of the develop view. @@ -1165,6 +1175,11 @@ export component LibraryGrid inherits Rectangle { callback cell-clicked(int); /// A star was clicked on a cell: row, and the rating 0..5. callback cell-rated(int, int); + /// The burst mark on a cell was clicked (FR-CULL-5): open the group, or fold it back + /// up. Which of the two is decided in Rust, from what the catalog says the + /// group is currently doing, so the mark cannot get out of step with the + /// query that actually hides the frames. + callback burst-toggled(int); /// Whether the grid is currently listing the trash rather than the /// library. Suppresses the per-cell trash target, which would be inert /// there — `plan_trash` skips an already-trashed image — and offering a @@ -3247,6 +3262,75 @@ export component LibraryGrid inherits Rectangle { rate(n) => { root.cell-rated(i, n); } trash() => { root.cell-trashed(i); } } + + // The burst mark (FR-CULL-5): how many frames this + // moment holds, + // and the way in and out of them. + // + // A child of `cell-touch` for exactly the reason the + // stars above are: a click here must not also reach + // `cell-clicked` and throw the user into develop, and + // children are hit-tested before the element they sit + // in. Unlike the stars it is never hidden — a collapsed + // burst is standing in for frames that are not on + // screen, and there has to be something visible saying + // so whether or not a pointer is anywhere near. + // + // Bottom left, clear of the centred star strip and of + // both top corners, which the flag and the collection + // badge already have. + if cell.burst-count > 1: Rectangle { + x: 6px; + y: parent.height - self.height - 26px; + width: 30px; + height: 18px; + + // The pile behind the top card, drawn only while the + // group is folded up. It is the whole of the "there + // is more than one of these" cue; once the burst is + // open the frames themselves say it. + Rectangle { + x: 3px; + y: -3px; + width: parent.width - 3px; + height: parent.height; + visible: !cell.burst-expanded; + background: Theme.surface; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.rule; + } + + Rectangle { + width: 100%; + height: 100%; + background: cell.burst-expanded ? Theme.selected + : Theme.surface; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: cell.burst-expanded ? Theme.selected-ring + : Theme.rule; + + Text { + width: 100%; + height: 100%; + horizontal-alignment: center; + vertical-alignment: center; + // No "of": the number is the size of the + // group, and a cell this small cannot + // afford a word to say so. + text: cell.burst-count; + color: Theme.ink; + font-size: 10px; + font-weight: 700; + } + } + + TouchArea { + mouse-cursor: pointer; + clicked => { root.burst-toggled(i); } + } + } } } // Where the run would land. A bar in the gutter beside the