Give a panorama a wide cell in the grid

A 4:1 composite drawn in one square cell is a strip a few pixels high.
A photograph about twice as wide as it is tall (1.9 and up) now spans
two columns, three from 2.9, with a thumbnail class of its own whose
long edge is sized for that width; on the tablet, or where the columns
are too few to put it beside anything, it takes the whole row.

Rows are computed in one place, library_ui::layout. The grid is a
lattice of slots: each cell is drawn at the slot Rust gives it, and a
wide cell that would not fit in what is left of a row starts the next
one, leaving the gap empty so the grid still reads in capture order.
The scrollbar spans the slots, a scroll reports a slot that the layout
turns back into an ordinal, and scrubs, restores and the cursor go
through the same conversion. Up and down step by rows through the
layout rather than by a row's worth of ordinals; left and right, a
shift-click's run, burst folding and the timeline are ordinal-based
and unchanged.

The window's own read carries each photograph's w and h, so the cells
know their shape with no query per cell. Where the wide ones sit in
the whole list is one query, run when what the grid lists changes or
when a window finds the layout out of date, and a library with no
panorama answers it from a partial index created on first use
(images_wide), not a schema bump. The merge makes the wide thumbnail
for a wide composite along with the others.
This commit is contained in:
2026-09-28 19:57:37 -04:00
parent ce5b7d72e3
commit ae4e1a0f07
13 changed files with 878 additions and 89 deletions
+185 -2
View File
@@ -39,7 +39,7 @@ pub(super) fn uncollapsed(image: &str) -> String {
}
/// One grid cell's data, read from the catalog.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct LibraryCell {
pub image_id: i64,
pub name: String,
@@ -53,6 +53,10 @@ pub struct LibraryCell {
pub metadata_state: u8,
/// UTC seconds, once EXIF has been read.
pub captured_at: Option<i64>,
/// TRACES: FR-MRG-6
/// Width over height, where the catalog knows both: what decides whether
/// the cell spans columns. Read with the window, never per cell.
pub aspect: Option<f32>,
}
/// Read a window of cells out of the catalog.
@@ -346,6 +350,94 @@ pub fn ordinal_of_path(
}
}
/// TRACES: FR-MRG-6
/// Which rows of the catalog are wide enough to span columns, spelled as
/// [`crate::library_ui`]'s layout counts them: `w / h >= 1.9`, in integers
/// so the index below can be matched term for term. Change one, change the
/// other — `the_wide_predicate_is_the_layouts` holds them together.
pub(super) const WIDE: &str = "w * 10 >= h * 19";
/// Make sure the partial index of wide photographs exists.
///
/// Created on first use rather than by a migration: it holds only the
/// panoramas, it changes nothing an older build reads, and a schema bump
/// would make every older peer refuse this catalog's snapshot. Keyed on the
/// grid's ordering, so the planner can answer "is there any?" from it alone.
fn ensure_wide_index(catalog: &Catalog) -> Result<(), dr_catalog::CatalogError> {
catalog.connection().execute_batch(&format!(
"CREATE INDEX IF NOT EXISTS images_wide
ON images(captured_at, source_ref) WHERE {WIDE}"
))?;
Ok(())
}
/// TRACES: FR-MRG-6
/// The photographs in the grid's current list that are wide enough to span
/// columns: each one's ordinal and aspect, in order.
///
/// What the layout places them from. Answered from the small side first: a
/// library with no panorama in it — the reference library, and every one
/// until a merge writes a composite — is told so by one probe of the
/// partial index, and nothing else runs. Otherwise one pass over the list,
/// numbering it through the ordering the cells are read with, exactly as
/// [`ordinal_of_path`] does and for its reason; it runs when what the grid
/// lists changes, not per scroll.
pub fn wide_ordinals(
catalog: &Catalog,
scope: Option<Scope>,
filter: &RatingFilter,
trash: bool,
) -> Result<Vec<(usize, f32)>, dr_catalog::CatalogError> {
ensure_wide_index(catalog)?;
let any: bool = catalog.connection().query_row(
&format!("SELECT EXISTS(SELECT 1 FROM images WHERE {WIDE})"),
[],
|r| r.get(0),
)?;
if !any {
return Ok(Vec::new());
}
let (inner, params) = if trash {
(
format!(
"SELECT i.w AS w, i.h AS h,
row_number() OVER ({TRASH_ORDER}) - 1 AS ord
FROM images i
WHERE {TRASHED}"
),
Vec::new(),
)
} else {
let (clause, scope_params) = scope_clause(catalog, scope)?;
let rated = filter.sql();
let folded = uncollapsed("i");
let (order, mut params) = grid_order_for(catalog, scope);
// The window's parameters first — see `ordinal_of_path`.
params.extend(scope_params);
(
format!(
"SELECT i.w AS w, i.h AS h,
row_number() OVER ({order}) - 1 AS ord
FROM images i
WHERE {VISIBLE}{rated}{folded}{clause}"
),
params,
)
};
let mut stmt = catalog.connection().prepare(&format!(
"SELECT ord, w, h FROM ({inner}) WHERE {WIDE} ORDER BY ord"
))?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((
r.get::<_, i64>(0)?.max(0) as usize,
r.get::<_, i64>(1)? as f32 / r.get::<_, i64>(2)?.max(1) as f32,
))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// The columns every windowed read selects, in the order [`row_to_cell`] reads
/// them.
///
@@ -353,7 +445,7 @@ pub fn ordinal_of_path(
/// the one column that is *not* here stays conspicuous. See
/// [`attach_file_ids`] for why the server's file id is fetched separately.
pub(super) const CELL_COLUMNS: &str =
"i.id, i.source_ref, i.file_size, i.metadata_state, i.captured_at";
"i.id, i.source_ref, i.file_size, i.metadata_state, i.captured_at, i.w, i.h";
/// Shared row mapping, so the scoped and unscoped queries cannot drift.
///
@@ -368,6 +460,10 @@ pub(super) fn row_to_cell(r: &rusqlite::Row) -> rusqlite::Result<LibraryCell> {
size: r.get::<_, Option<i64>>(2)?.unwrap_or(0) as u64,
metadata_state: r.get::<_, i64>(3)? as u8,
captured_at: r.get(4)?,
aspect: match (r.get::<_, Option<i64>>(5)?, r.get::<_, Option<i64>>(6)?) {
(Some(w), Some(h)) if w > 0 && h > 0 => Some(w as f32 / h as f32),
_ => None,
},
})
}
@@ -944,6 +1040,93 @@ mod tests {
// --- the trash view (FR-CAT-15) ---------------------------------------
/// Mark one image trashed, at a given instant.
fn shaped(catalog: &Catalog, path: &str, w: i64, h: i64) {
let n = catalog
.connection()
.execute(
"UPDATE images SET w = ?2, h = ?3 WHERE source_ref = ?1",
rusqlite::params![path, w, h],
)
.unwrap();
assert_eq!(n, 1, "fixture should have shaped exactly {path}");
}
#[test]
fn the_wide_photographs_are_found_at_their_ordinals() {
let catalog = with_images(20);
assert!(
wide_ordinals(&catalog, None, &RatingFilter::default(), false)
.unwrap()
.is_empty()
);
shaped(&catalog, "PhotosRaw/img005.CR2", 4000, 1000);
shaped(&catalog, "PhotosRaw/img012.CR2", 3000, 1400);
shaped(&catalog, "PhotosRaw/img013.CR2", 3000, 2000);
let wide = wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap();
assert_eq!(
wide.iter().map(|(o, _)| *o).collect::<Vec<_>>(),
vec![5, 12],
"{wide:?}"
);
assert!((wide[0].1 - 4.0).abs() < 1e-6);
// The ordinals are the grid's: a frame trashed before them moves
// them up, and in the trash only the trash counts.
trash_at(&catalog, "PhotosRaw/img003.CR2", 1);
trash_at(&catalog, "PhotosRaw/img012.CR2", 2);
let wide = wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap();
assert_eq!(wide.iter().map(|(o, _)| *o).collect::<Vec<_>>(), vec![4]);
let trashed = wide_ordinals(&catalog, None, &RatingFilter::default(), true).unwrap();
// Newest deletion first: img012 then img003.
assert_eq!(trashed.iter().map(|(o, _)| *o).collect::<Vec<_>>(), vec![0]);
}
#[test]
fn a_library_with_no_panorama_is_told_so_by_the_index() {
let catalog = with_images(20);
wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap();
let plan: Vec<String> = catalog
.connection()
.prepare(&format!(
"EXPLAIN QUERY PLAN SELECT EXISTS(SELECT 1 FROM images WHERE {WIDE})"
))
.unwrap()
.query_map([], |r| r.get::<_, String>(3))
.unwrap()
.flatten()
.collect();
let plan = plan.join(" | ");
assert!(plan.contains("images_wide"), "{plan}");
}
#[test]
fn the_wide_predicate_is_the_layouts() {
use crate::library_ui::layout::natural_span;
let catalog = with_images(1);
for (w, h) in [
(19, 10),
(189, 100),
(190, 100),
(191, 100),
(6000, 4000),
(4000, 6000),
(22_993, 5_980),
(1_899, 1_000),
(1_900, 1_000),
] {
let sql: bool = catalog
.connection()
.query_row(
&format!("SELECT {}", WIDE.replace('w', "?1").replace('h', "?2")),
rusqlite::params![w, h],
|r| r.get(0),
)
.unwrap();
let rust = natural_span(Some(w as f32 / h as f32)) > 1;
assert_eq!(sql, rust, "{w}×{h}");
}
}
fn trash_at(catalog: &Catalog, path: &str, when: i64) {
let n = catalog
.connection()