Mark a burst in the grid, and let it be folded away

The counterpart to the grouping: where the signatures come from, and how a
group reaches a cell.

Signatures are computed from the 256px thumbnails dr-thumbs already holds --
vastly more resolution than a 9x8 reduction can use -- so a library that has
been browsed, or that has synced somebody else's shards, has already paid for
them and no RAW is decoded for this. The consequence is stated rather than
hidden: an image with no thumbnail gets no signature and never joins a burst.
That is self-correcting, and it is why the pass runs when the thumbnail sweep
finishes rather than on a timer. Nothing happens at import and nothing happens
at query time.

The mark is drawn as a child of the cell's TouchArea, for the same reason the
star strip is: a click on it must not also reach `cell-clicked` and throw the
user into develop, and children are hit-tested before the element they sit in.
It is never hidden on hover the way the stars are -- a collapsed burst stands
in for frames that are not on screen, and something has to say so whether or
not a pointer is nearby.

Folding changes what the grid's *query* returns rather than what its cells
draw, because the grid is a window over an ordered query and the frames a fold
hides are mostly not loaded. So the predicate joins VISIBLE in every query
that lists or counts cells -- the window, the header's count, the run a
shift-click resolves, and the ordinal a scrub lands on -- under the discipline
VISIBLE's own comment sets out: present in four places of five is worse than
absent, because the counts disagree with the cells and neither looks wrong on
its own. There is a test for exactly that.

`the_window_read_walks_the_ordering_index` now includes the burst clause. It
asserts on the query plan while holding its own copy of the query, so left
alone it would have gone on reporting green against a query the grid no longer
runs. If the clause costs `images_grid_order` and puts the sort back, that
fails here rather than becoming jitter someone measures in six months.

The pass keeps its own drain timer in a thread-local instead of taking fields
on the library controller, so everything the feature needs to run lives in one
file and the screen that starts it holds nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 23:20:26 +02:00
co-authored by Claude Opus 5
parent 5226f7223b
commit e5db849f04
6 changed files with 790 additions and 14 deletions
+535
View File
@@ -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<BurstMessage> {
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<Option<slint::Timer>> = const { RefCell::new(None) };
static RUNNING: Cell<bool> = 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<BurstMessage>,
) -> 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<ImageId, u64> {
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<u8> {
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)));
}
}
+1
View File
@@ -20,6 +20,7 @@
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a). //! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
mod activity; mod activity;
mod bursts;
mod collections_ui; mod collections_ui;
mod derived_sync; mod derived_sync;
mod develop; mod develop;
+91 -6
View File
@@ -186,6 +186,26 @@ const VISIBLE_UNALIASED: &str = "shadowed_by IS NULL AND trashed_at IS NULL";
/// restore the same frame twice. /// restore the same frame twice.
const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL"; 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 /// TRACES: FR-CAT-4
/// The order the grid lists photographs in: when they were taken. /// The order the grid lists photographs in: when they were taken.
/// ///
@@ -3815,11 +3835,12 @@ pub fn read_cells_scoped(
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(","); .join(",");
let rated = filter.sql(); let rated = filter.sql();
let folded = uncollapsed("i");
let (order, order_params) = grid_order_for(catalog, Some(scope)); let (order, order_params) = grid_order_for(catalog, Some(scope));
let sql = format!( let sql = format!(
"SELECT {CELL_COLUMNS} "SELECT {CELL_COLUMNS}
FROM images i FROM images i
WHERE {VISIBLE}{rated} WHERE {VISIBLE}{rated}{folded}
AND i.id IN (SELECT image_id FROM collection_members AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders})) WHERE collection_id IN ({placeholders}))
{order} {order}
@@ -3854,11 +3875,12 @@ fn read_cells_all(
limit: usize, limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> { ) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
let rated = filter.sql(); let rated = filter.sql();
let folded = uncollapsed("i");
let mut rows = { let mut rows = {
let mut stmt = catalog.connection().prepare(&format!( let mut stmt = catalog.connection().prepare(&format!(
"SELECT {CELL_COLUMNS} "SELECT {CELL_COLUMNS}
FROM images i FROM images i
WHERE {VISIBLE}{rated} WHERE {VISIBLE}{rated}{folded}
{GRID_ORDER} {GRID_ORDER}
LIMIT ?1 OFFSET ?2" LIMIT ?1 OFFSET ?2"
))?; ))?;
@@ -3959,6 +3981,7 @@ pub fn read_ids_span(
} else { } else {
let (clause, mut params) = scope_clause(catalog, scope)?; let (clause, mut params) = scope_clause(catalog, scope)?;
let rated = filter.sql(); let rated = filter.sql();
let folded = uncollapsed("i");
// The same ordering the cells were drawn with, from the same place. // 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 // A range is a pair of ordinals, and an ordinal read through a
// different ORDER BY names a different photograph. // different ORDER BY names a different photograph.
@@ -3967,7 +3990,7 @@ pub fn read_ids_span(
( (
format!( format!(
"SELECT i.id FROM images i "SELECT i.id FROM images i
WHERE {VISIBLE}{rated}{clause} WHERE {VISIBLE}{rated}{folded}{clause}
{order} {order}
LIMIT ? OFFSET ?" LIMIT ? OFFSET ?"
), ),
@@ -4096,9 +4119,10 @@ pub fn total_images_scoped(
// Counted through `images` rather than over `collection_members` alone, so // Counted through `images` rather than over `collection_members` alone, so
// `VISIBLE` applies — a trashed photograph is still a member row, and // `VISIBLE` applies — a trashed photograph is still a member row, and
// counting it made the header claim images the grid would not draw. // counting it made the header claim images the grid would not draw.
let folded = uncollapsed("i");
let sql = format!( let sql = format!(
"SELECT count(DISTINCT i.id) FROM images i "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 AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))" WHERE collection_id IN ({placeholders}))"
); );
@@ -4409,8 +4433,9 @@ fn total_images_filtered(
filter: &RatingFilter, filter: &RatingFilter,
) -> Result<usize, dr_catalog::CatalogError> { ) -> Result<usize, dr_catalog::CatalogError> {
let rated = filter.sql(); let rated = filter.sql();
let folded = uncollapsed("i");
let n: i64 = catalog.connection().query_row( 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), |r| r.get(0),
)?; )?;
@@ -4956,12 +4981,16 @@ mod tests {
#[test] #[test]
fn the_window_read_walks_the_ordering_index() { fn the_window_read_walks_the_ordering_index() {
let catalog = with_images(20); 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<String> = catalog let plan: Vec<String> = catalog
.connection() .connection()
.prepare(&format!( .prepare(&format!(
"EXPLAIN QUERY PLAN "EXPLAIN QUERY PLAN
SELECT {CELL_COLUMNS} FROM images i SELECT {CELL_COLUMNS} FROM images i
WHERE {VISIBLE} WHERE {VISIBLE}{folded}
{GRID_ORDER} {GRID_ORDER}
LIMIT 10 OFFSET 5" LIMIT 10 OFFSET 5"
)) ))
@@ -5014,6 +5043,62 @@ mod tests {
catalog 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<dr_types::ImageId> { fn image_ids(catalog: &Catalog) -> Vec<dr_types::ImageId> {
let mut stmt = catalog let mut stmt = catalog
.connection() .connection()
+76 -8
View File
@@ -2214,6 +2214,11 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// window, not one per cell. // window, not one per cell.
rating: 0, rating: 0,
flag: 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(); .collect();
@@ -2246,6 +2251,9 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
.collect(); .collect();
crate::collections_ui::sync_badges(window, catalog, &ids); crate::collections_ui::sync_badges(window, catalog, &ids);
sync_ratings(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 // The rebuilt cells all carry `selected: false`, but the selection itself
// is a set of image ids and survives untouched. Without this the ticks // 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 // vanished on every scroll — the selection was still there and still acted
@@ -3769,6 +3777,26 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
if !offline { if !offline {
start_derived_sync(&w, &ctl_cb); 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; return;
} }
} }
@@ -4119,10 +4147,14 @@ fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option<i64> {
catalog catalog
.connection() .connection()
.query_row( .query_row(
"SELECT captured_at FROM images &format!(
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL "SELECT captured_at FROM images
ORDER BY captured_at WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
LIMIT 1 OFFSET ?1", AND {}
ORDER BY captured_at
LIMIT 1 OFFSET ?1",
dr_catalog::bursts::not_collapsed_away("images")
),
[ordinal as i64], [ordinal as i64],
|r| r.get::<_, i64>(0), |r| r.get::<_, i64>(0),
) )
@@ -4153,13 +4185,21 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
// BY), so they never precede a dated one and the predicate below stays // BY), so they never precede a dated one and the predicate below stays
// a simple `<`. Shadowed rows are excluded here exactly as the grid // a simple `<`. Shadowed rows are excluded here exactly as the grid
// excludes them. // 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 catalog
.connection() .connection()
.query_row( .query_row(
"SELECT count(*) FROM images &format!(
WHERE shadowed_by IS NULL "SELECT count(*) FROM images
AND captured_at IS NOT NULL WHERE shadowed_by IS NULL
AND captured_at < ?1", AND captured_at IS NOT NULL
AND captured_at < ?1
AND {}",
dr_catalog::bursts::not_collapsed_away("images")
),
[when], [when],
|r| r.get::<_, i64>(0), |r| r.get::<_, i64>(0),
) )
@@ -4953,6 +4993,34 @@ pub fn wire<F>(
}); });
} }
// 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 // A rating or flag key. Applies to the whole selection, which is what
// makes judging a run of frames one keystroke rather than forty. // makes judging a run of frames one keystroke rather than forty.
{ {
+3
View File
@@ -603,6 +603,8 @@ export component AppWindow inherits Window {
callback library-cell-rated(int, int); callback library-cell-rated(int, int);
/// The trash target was clicked on one cell, by row. /// The trash target was clicked on one cell, by row.
callback library-cell-trashed(int); 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. /// Move the grid selection to the trash — the `Delete` key.
callback library-trash-selection(); callback library-trash-selection();
/// A judgement key was pressed, applying to the whole selection. One of /// A judgement key was pressed, applying to the whole selection. One of
@@ -1596,6 +1598,7 @@ in property <bool> panel-visible: true;
cell-rated(i, n) => { root.library-cell-rated(i, n); } cell-rated(i, n) => { root.library-cell-rated(i, n); }
cell-trashed(i) => { root.library-cell-trashed(i); } cell-trashed(i) => { root.library-cell-trashed(i); }
burst-toggled(i) => { root.library-burst-toggled(i); }
trash-selection() => { root.library-trash-selection(); } trash-selection() => { root.library-trash-selection(); }
// Derived from the sidebar's own selection rather than // Derived from the sidebar's own selection rather than
// mirrored in a second property: `-1` is already the sentinel // mirrored in a second property: `-1` is already the sentinel
+84
View File
@@ -468,6 +468,16 @@ export struct LibraryCell {
// 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a // 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a
// four-star frame is a normal thing to do mid-cull. // four-star frame is a normal thing to do mid-cull.
flag: int, 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. // 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); callback cell-clicked(int);
/// A star was clicked on a cell: row, and the rating 0..5. /// A star was clicked on a cell: row, and the rating 0..5.
callback cell-rated(int, int); 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 /// Whether the grid is currently listing the trash rather than the
/// library. Suppresses the per-cell trash target, which would be inert /// library. Suppresses the per-cell trash target, which would be inert
/// there — `plan_trash` skips an already-trashed image — and offering a /// 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); } rate(n) => { root.cell-rated(i, n); }
trash() => { root.cell-trashed(i); } 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 // Where the run would land. A bar in the gutter beside the