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
+134 -22
View File
@@ -230,6 +230,31 @@ pub(super) fn bring_window_to(
.filter(|row| *row < ctl.paths.borrow().len())
}
/// TRACES: FR-MRG-6
/// Ask the grid to show ordinal `at` in its first row.
///
/// The grid scrolls by slots, which a panorama's cell makes different from
/// ordinals; this is the one conversion, so no caller spells `scroll-to`
/// in the wrong unit.
pub(super) fn seek_to(window: &AppWindow, ctl: &LibraryController, at: usize) {
let slot = ctl.layout.borrow().slot_of(at);
window
.global::<Library>()
.set_library_scroll_to(slot as i32);
window
.global::<Library>()
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
}
/// Put the keyboard cursor on ordinal `at`, telling the grid where its cell
/// is so its row can be kept in view.
pub(super) fn show_cursor(window: &AppWindow, ctl: &LibraryController, at: usize) {
window.global::<Library>().set_library_cursor(at as i32);
window
.global::<Library>()
.set_library_cursor_slot(ctl.layout.borrow().slot_of(at) as i32);
}
/// Record that the photograph at `row` of the loaded window is the one now
/// open in develop, and mark it on the roll.
pub(super) fn mark_open(window: &AppWindow, ctl: &LibraryController, row: usize) {
@@ -327,6 +352,10 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
};
let describes_something_new = ctl.library_facts.borrow().as_ref() != Some(&facts);
*ctl.library_facts.borrow_mut() = Some(facts);
// A different list has its panoramas in different places.
if describes_something_new {
*ctl.wide.borrow_mut() = None;
}
// TRACES: FR-NC-6a
// Whether the newly scoped collection is already pinned. Read here rather
@@ -444,18 +473,37 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
.into(),
);
// TRACES: FR-MRG-6
// Where each cell goes: the layout of the whole list, checked against
// what this window says about its own photographs' shapes and read again
// if they disagree — a reorder, a merge, a composite's size learned.
let columns = window.global::<Library>().get_library_columns().max(1) as usize;
let layout = place_cells(
catalog, ctl, &cells, offset, total, columns, scope, &filter, trash,
);
window
.global::<Library>()
.set_library_total_slots(layout.total_slots() as i32);
let cursor = window.global::<Library>().get_library_cursor();
if cursor >= 0 {
window
.global::<Library>()
.set_library_cursor_slot(layout.slot_of(cursor as usize) as i32);
}
// Month headings. The grid is ordered by capture time, so without these a
// wall of thumbnails gives no sense of *when* you are looking — the
// sidebar says it, but only if you consult it.
let columns = window.global::<Library>().get_library_columns().max(1) as usize;
let headings = period_headings(
cells.iter().map(|c| {
c.captured_at.map(|t| {
let (y, m, _, _) = civil_from_unix(t);
(y, m)
})
cells.iter().enumerate().map(|(i, c)| {
(
c.captured_at.map(|t| {
let (y, m, _, _) = civil_from_unix(t);
(y, m)
}),
layout.slot_of(offset + i),
)
}),
offset,
columns,
);
@@ -470,16 +518,15 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// A composite a merge has just catalogued is drawn from the merge's own
// thumbnails until the store can serve it (FR-MRG-6), and counts as
// served at that class so no fetch is spent on a file still uploading.
let wanted = dr_thumbs::ThumbSize::for_cell(
window.global::<Library>().get_library_cell_size().max(1.0) as u32,
);
let cell_pixels = window.global::<Library>().get_library_cell_size().max(1.0) as u32;
let mut held = held;
{
let merged = ctl.merged_thumbs.borrow();
for c in &cells {
for (i, c) in cells.iter().enumerate() {
let Some(made) = merged.get(&c.image_id) else {
continue;
};
let wanted = dr_thumbs::ThumbSize::for_span(layout.span_of(offset + i), cell_pixels);
if held
.get(&c.image_id)
.is_some_and(|h| h.class == Some(wanted))
@@ -507,9 +554,12 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
let rows: Vec<LibraryCell> = cells
.iter()
.zip(headings)
.map(|(c, heading)| {
.enumerate()
.map(|(i, (c, heading))| {
let carried = held.get(&c.image_id);
LibraryCell {
slot: layout.slot_of(offset + i) as i32,
span: layout.span_of(offset + i) as i32,
period_heading: heading.into(),
// A freshly loaded window has no drag in flight.
lifted: false,
@@ -611,6 +661,55 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
request_thumbnails(window, ctl);
}
/// TRACES: FR-MRG-6
/// The layout of the current list, made to agree with the window just read.
///
/// The panoramas' ordinals are kept between loads — a scroll does not move
/// them — and read again only when they are unknown or when this window
/// finds a photograph wide that the layout does not, or the other way round.
/// That check is over the window's own rows, in memory; the read it can
/// trigger is one query ([`library::wide_ordinals`]).
#[allow(clippy::too_many_arguments)]
fn place_cells(
catalog: &dr_catalog::Catalog,
ctl: &LibraryController,
cells: &[library::LibraryCell],
offset: usize,
total: usize,
columns: usize,
scope: Option<library::Scope>,
filter: &library::RatingFilter,
trash: bool,
) -> super::layout::Layout {
use super::layout::{natural_span, Layout};
let full_width = dr_plat::is_touch_first();
let natural: Vec<usize> = cells.iter().map(|c| natural_span(c.aspect)).collect();
let read = || -> Vec<(usize, usize)> {
match library::wide_ordinals(catalog, scope, filter, trash) {
Ok(wide) => wide
.into_iter()
.map(|(ordinal, aspect)| (ordinal, natural_span(Some(aspect))))
.collect(),
Err(e) => {
log::warn!("reading the grid's panoramas: {e}");
Vec::new()
}
}
};
if ctl.wide.borrow().is_none() {
*ctl.wide.borrow_mut() = Some(read());
}
let pack = |wide: &[(usize, usize)]| Layout::pack(columns, total, full_width, wide);
let mut layout = pack(ctl.wide.borrow().as_deref().unwrap_or_default());
if !layout.agrees_with(offset, &natural, full_width) {
let wide = read();
layout = pack(&wide);
*ctl.wide.borrow_mut() = Some(wide);
}
*ctl.layout.borrow_mut() = layout.clone();
layout
}
/// Where a row of the loaded window sits in the fetch queue.
///
/// On screen first, top to bottom; then the rows below the view, nearest
@@ -648,11 +747,17 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
return;
};
// The drawn cell size decides which class to ask for. Chosen once for the
// batch rather than per row, and carried through to the drain so a cell it
// fills can record what it is now showing.
// The drawn cell size decides which class to ask for, and a panorama's
// cell asks for the wide one (FR-MRG-6). Carried through to the drain per
// row, so a cell it fills can record what it is now showing.
let cell_pixels = window.global::<Library>().get_library_cell_size().max(1.0) as u32;
let class = dr_thumbs::ThumbSize::for_cell(cell_pixels);
let offset = *ctl.offset.borrow();
let classes: Vec<dr_thumbs::ThumbSize> = {
let layout = ctl.layout.borrow();
(0..ctl.paths.borrow().len())
.map(|i| dr_thumbs::ThumbSize::for_span(layout.span_of(offset + i), cell_pixels))
.collect()
};
let mut wanted: Vec<library::ThumbnailRequest> = {
let paths = ctl.paths.borrow();
@@ -668,7 +773,10 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
let image_id = *image_ids.get(i)?;
// A zoomed grid asks for detail a 256px thumbnail cannot give,
// and a wall of small cells does not pay for it.
let thumb_size = class;
let thumb_size = classes
.get(i)
.copied()
.unwrap_or(dr_thumbs::ThumbSize::Grid);
// Keyed on the photograph, so scrolling back over a cell that
// has already been served does not ask for it again.
if !requested.insert((image_id, thumb_size)) {
@@ -697,7 +805,7 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
// the model they were built against; only the order they are asked for in
// changes.
{
let first_on_screen = ctl.resume_at.get().saturating_sub(*ctl.offset.borrow());
let first_on_screen = ctl.resume_at.get().saturating_sub(offset);
let on_screen = ctl.viewport_cells.get().max(1);
wanted.sort_by_key(|r| fetch_rank(r.row, first_on_screen, on_screen));
}
@@ -709,7 +817,7 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
library::thumbs_dir(&conn.account),
library::catalog_path(&conn.account),
);
drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class);
drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, classes);
}
/// TRACES: FR-CAT-9 | FR-DEV-6
@@ -835,7 +943,7 @@ fn drain_thumbnails(
ctl: Rc<LibraryController>,
rx: Receiver<ThumbnailMessage>,
requested: usize,
class: dr_thumbs::ThumbSize,
classes: Vec<dr_thumbs::ThumbSize>,
) {
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
@@ -977,7 +1085,9 @@ fn drain_thumbnails(
model.set_row_data(t.row, row);
// What this cell is now showing, so the next reload
// can carry it over and know not to ask again.
record_class(&ctl_cb, t.row, class);
if let Some(class) = classes.get(t.row) {
record_class(&ctl_cb, t.row, *class);
}
}
}
ThumbnailMessage::Unavailable { row, reason } => {
@@ -989,7 +1099,9 @@ fn drain_thumbnails(
// A verdict is worth carrying too: "no preview" is
// an answer about the file, and re-asking it on
// every scroll is a fetch that will fail again.
record_class(&ctl_cb, row, class);
if let Some(class) = classes.get(row) {
record_class(&ctl_cb, row, *class);
}
}
}
// TRACES: FR-CAT-9