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
+21 -4
View File
@@ -72,6 +72,13 @@ pub enum ThumbSize {
/// Zoomed cells, the loupe, and the filmstrip. ~45 KB each, fetched only /// Zoomed cells, the loupe, and the filmstrip. ~45 KB each, fetched only
/// where something actually asks for that detail. /// where something actually asks for that detail.
Large = 1, Large = 1,
/// TRACES: FR-MRG-6
/// A panorama's cell, which spans two or three columns — or the whole
/// row on the tablet — at the height of one. Its long edge is sized for
/// that width rather than for a square: the grid class of a 4:1
/// panorama is 256×64, a smear across three cells. Made only for
/// photographs that wide, so it costs a library nothing else.
Wide = 2,
} }
impl ThumbSize { impl ThumbSize {
@@ -80,6 +87,18 @@ impl ThumbSize {
match self { match self {
ThumbSize::Grid => 256, ThumbSize::Grid => 256,
ThumbSize::Large => 1024, ThumbSize::Large => 1024,
ThumbSize::Wide => 2048,
}
}
/// The class for a cell `span` columns wide whose columns are drawn at
/// `pixels`: the wide class for any cell wider than one, whatever the
/// zoom, since its height is a column's and its width is not.
pub fn for_span(span: usize, pixels: u32) -> Self {
if span > 1 {
ThumbSize::Wide
} else {
Self::for_cell(pixels)
} }
} }
@@ -102,15 +121,13 @@ impl ThumbSize {
match v { match v {
0 => Some(ThumbSize::Grid), 0 => Some(ThumbSize::Grid),
1 => Some(ThumbSize::Large), 1 => Some(ThumbSize::Large),
2 => Some(ThumbSize::Wide),
_ => None, _ => None,
} }
} }
fn from_i64(v: i64) -> Self { fn from_i64(v: i64) -> Self {
match v { Self::from_stored(v).unwrap_or(ThumbSize::Grid)
1 => ThumbSize::Large,
_ => ThumbSize::Grid,
}
} }
} }
+185 -2
View File
@@ -39,7 +39,7 @@ pub(super) fn uncollapsed(image: &str) -> String {
} }
/// One grid cell's data, read from the catalog. /// One grid cell's data, read from the catalog.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq)]
pub struct LibraryCell { pub struct LibraryCell {
pub image_id: i64, pub image_id: i64,
pub name: String, pub name: String,
@@ -53,6 +53,10 @@ pub struct LibraryCell {
pub metadata_state: u8, pub metadata_state: u8,
/// UTC seconds, once EXIF has been read. /// UTC seconds, once EXIF has been read.
pub captured_at: Option<i64>, 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. /// 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 /// The columns every windowed read selects, in the order [`row_to_cell`] reads
/// them. /// them.
/// ///
@@ -353,7 +445,7 @@ pub fn ordinal_of_path(
/// the one column that is *not* here stays conspicuous. See /// the one column that is *not* here stays conspicuous. See
/// [`attach_file_ids`] for why the server's file id is fetched separately. /// [`attach_file_ids`] for why the server's file id is fetched separately.
pub(super) const CELL_COLUMNS: &str = 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. /// 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, size: r.get::<_, Option<i64>>(2)?.unwrap_or(0) as u64,
metadata_state: r.get::<_, i64>(3)? as u8, metadata_state: r.get::<_, i64>(3)? as u8,
captured_at: r.get(4)?, 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) --------------------------------------- // --- the trash view (FR-CAT-15) ---------------------------------------
/// Mark one image trashed, at a given instant. /// 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) { fn trash_at(catalog: &Catalog, path: &str, when: i64) {
let n = catalog let n = catalog
.connection() .connection()
+11
View File
@@ -123,6 +123,15 @@ pub struct LibraryController {
/// blank in the meantime; after, the store holds the same ones. /// blank in the meantime; after, the store holds the same ones.
pub(super) merged_thumbs: pub(super) merged_thumbs:
RefCell<std::collections::HashMap<i64, Vec<(dr_thumbs::ThumbSize, slint::Image)>>>, RefCell<std::collections::HashMap<i64, Vec<(dr_thumbs::ThumbSize, slint::Image)>>>,
/// TRACES: FR-MRG-6
/// Where each photograph of the current list sits in the grid — see
/// [`super::layout`]. Rebuilt on every window load from `wide` and the
/// column count, which is arithmetic over a handful of panoramas.
pub(super) layout: RefCell<super::layout::Layout>,
/// The wide photographs of the current list, `(ordinal, natural span)`,
/// or `None` when they have to be read again: what the list is changed,
/// or a window found them out of date.
pub(super) wide: RefCell<Option<Vec<(usize, usize)>>>,
/// Where in the catalog the current window starts. Scrubbing moves this. /// Where in the catalog the current window starts. Scrubbing moves this.
pub(super) offset: RefCell<usize>, pub(super) offset: RefCell<usize>,
/// The first visible ordinal, kept so returning from the develop view lands /// The first visible ordinal, kept so returning from the develop view lands
@@ -460,6 +469,8 @@ impl LibraryController {
captured_at: RefCell::new(Vec::new()), captured_at: RefCell::new(Vec::new()),
thumb_class: RefCell::new(Vec::new()), thumb_class: RefCell::new(Vec::new()),
merged_thumbs: RefCell::new(Default::default()), merged_thumbs: RefCell::new(Default::default()),
layout: RefCell::new(super::layout::Layout::uniform(1, 0)),
wide: RefCell::new(None),
offset: RefCell::new(0), offset: RefCell::new(0),
resume_at: std::cell::Cell::new(0), resume_at: std::cell::Cell::new(0),
roll_open: std::cell::Cell::new(None), roll_open: std::cell::Cell::new(None),
+31 -7
View File
@@ -814,19 +814,20 @@ pub(super) fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {
/// lands in: a scrolled window would otherwise show no date at all until /// lands in: a scrolled window would otherwise show no date at all until
/// the next month began. /// the next month began.
pub(super) fn period_headings( pub(super) fn period_headings(
months: impl Iterator<Item = Option<(i64, i64)>>, months: impl Iterator<Item = (Option<(i64, i64)>, usize)>,
offset: usize,
columns: usize, columns: usize,
) -> Vec<String> { ) -> Vec<String> {
let columns = columns.max(1); let columns = columns.max(1);
let mut announced: Option<(i64, i64)> = None; let mut announced: Option<(i64, i64)> = None;
months months
.enumerate() .enumerate()
.map(|(i, month)| { .map(|(i, (month, slot))| {
let Some((y, m)) = month else { let Some((y, m)) = month else {
return String::new(); return String::new();
}; };
let begins_row = (i + offset).is_multiple_of(columns); // By slot, not ordinal: a panorama's cell pushes what follows it
// along (FR-MRG-6).
let begins_row = slot.is_multiple_of(columns);
if i == 0 || (begins_row && announced != Some((y, m))) { if i == 0 || (begins_row && announced != Some((y, m))) {
announced = Some((y, m)); announced = Some((y, m));
format!("{} {y}", month_name(m)) format!("{} {y}", month_name(m))
@@ -863,6 +864,29 @@ fn show_range(window: &AppWindow, ctl: &Rc<LibraryController>) {
mod tests { mod tests {
use super::*; use super::*;
/// Every cell one slot, the window starting at `offset`.
fn at_slots<const N: usize>(
months: [Option<(i64, i64)>; N],
offset: usize,
) -> impl Iterator<Item = (Option<(i64, i64)>, usize)> {
months
.into_iter()
.enumerate()
.map(move |(i, m)| (m, offset + i))
}
#[test]
fn a_month_is_announced_on_the_row_a_panorama_pushed_it_to() {
// Four columns; a panorama spans the first three slots of row two,
// and the frame after it sits in the fourth. February's first frame
// is pushed to the start of row three, and is announced there.
let jan = Some((2026, 1));
let feb = Some((2026, 2));
let cells = [(jan, 0), (jan, 1), (jan, 2), (jan, 4), (jan, 7), (feb, 8)];
let h = period_headings(cells.into_iter(), 4);
assert_eq!(h, ["January 2026", "", "", "", "", "February 2026"]);
}
#[test] #[test]
fn a_month_that_begins_mid_row_is_still_announced() { fn a_month_that_begins_mid_row_is_still_announced() {
// Four columns; August starts in the second column of the second // Four columns; August starts in the second column of the second
@@ -877,7 +901,7 @@ mod tests {
aug, aug, oct, oct, // aug, aug, oct, oct, //
oct, oct,
]; ];
let h = period_headings(months.into_iter(), 0, 4); let h = period_headings(at_slots(months, 0), 4);
assert_eq!(h[0], "March 2024"); assert_eq!(h[0], "March 2024");
assert_eq!(h[4], "", "still March at the top of row two"); assert_eq!(h[4], "", "still March at the top of row two");
assert_eq!(h[5], "", "never mid-row"); assert_eq!(h[5], "", "never mid-row");
@@ -892,12 +916,12 @@ mod tests {
let feb = Some((2026, 2)); let feb = Some((2026, 2));
// Offset 2 into a 4-column grid: the window's first cell is in the // Offset 2 into a 4-column grid: the window's first cell is in the
// third column, and the row after it begins a new month. // third column, and the row after it begins a new month.
let h = period_headings([jan, jan, feb, feb, feb].into_iter(), 2, 4); let h = period_headings(at_slots([jan, jan, feb, feb, feb], 2), 4);
assert_eq!(h[0], "January 2026"); assert_eq!(h[0], "January 2026");
assert_eq!(h[2], "February 2026", "cell 2 begins a row at offset 2"); assert_eq!(h[2], "February 2026", "cell 2 begins a row at offset 2");
assert_eq!(h[3], ""); assert_eq!(h[3], "");
// Undated cells carry nothing and announce nothing. // Undated cells carry nothing and announce nothing.
let h = period_headings([None, jan, None, None, jan].into_iter(), 0, 4); let h = period_headings(at_slots([None, jan, None, None, jan], 0), 4);
assert_eq!(h[0], ""); assert_eq!(h[0], "");
assert_eq!(h[4], "January 2026"); assert_eq!(h[4], "January 2026");
} }
+41 -5
View File
@@ -27,7 +27,7 @@ use super::timeline::{
capture_time_at, catalog_span, note_place, resume_position, wire_timeline, write_place, capture_time_at, catalog_span, note_place, resume_position, wire_timeline, write_place,
zoomed_span, zoomed_span,
}; };
use super::window::{bring_window_to, load_window, mark_open, window_move}; use super::window::{bring_window_to, load_window, mark_open, show_cursor, window_move};
/// Walk the keyboard cursor through the library — the arrow keys. /// Walk the keyboard cursor through the library — the arrow keys.
/// ///
@@ -109,7 +109,7 @@ fn place_cursor(
} }
crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend); crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend);
window.global::<Library>().set_library_cursor(at as i32); show_cursor(window, ctl, at);
} }
/// Where a step of `delta` along the roll lands, as a library ordinal, from /// Where a step of `delta` along the roll lands, as a library ordinal, from
@@ -419,6 +419,33 @@ fn wire_grid_cursor_and_zoom(
}); });
} }
// TRACES: FR-MRG-6
// Up and down, by rows: with a panorama's cell in the grid a row is not
// a fixed number of photographs, so the layout says what is under the
// cursor that many rows on, and the step is the distance to it.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll = coll_ctl.clone();
window
.global::<Library>()
.on_library_move_cursor_rows(move |rows, extend| {
let Some(w) = weak.upgrade() else { return };
let delta = match coll.cursor() {
Some(from) => {
let to = ctl.layout.borrow().step_rows(from, rows as isize);
to as i32 - from as i32
}
// The first press takes hold of the grid, whichever key.
None => rows,
};
if delta == 0 && coll.cursor().is_some() {
return;
}
move_cursor(&w, &ctl, &coll, delta, extend);
});
}
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = ctl.clone(); let ctl = ctl.clone();
@@ -635,9 +662,18 @@ fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc<LibraryController>) {
let ctl = ctl.clone(); let ctl = ctl.clone();
window window
.global::<Library>() .global::<Library>()
.on_library_scrolled(move |first_visible| { .on_library_scrolled(move |first_slot| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
let first_visible = first_visible.max(0) as usize; // The grid reports the first slot of its first visible row;
// the photograph there is what everything below means
// (FR-MRG-6).
let first_slot = first_slot.max(0) as usize;
let total = w.global::<Library>().get_library_total().max(0) as usize;
let first_visible = ctl
.layout
.borrow()
.ordinal_at(first_slot)
.min(total.saturating_sub(1));
// **A report from a grid that is not on screen is not a scroll.** // **A report from a grid that is not on screen is not a scroll.**
// //
@@ -693,7 +729,7 @@ fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc<LibraryController>) {
// hand. Without this they each rebuilt against whatever `scroll-to` // hand. Without this they each rebuilt against whatever `scroll-to`
// was last *set* to — a stale scrub, or zero — and landed there. // was last *set* to — a stale scrub, or zero — and landed there.
w.global::<Library>() w.global::<Library>()
.set_library_scroll_to(first_visible as i32); .set_library_scroll_to(first_slot as i32);
// Move the timeline marker with the view. Scrolling the grid is a // Move the timeline marker with the view. Scrolling the grid is a
// way of moving through time just as scrubbing is, and a marker // way of moving through time just as scrubbing is, and a marker
+366
View File
@@ -0,0 +1,366 @@
//! TRACES: FR-MRG-6 | FR-CAT-4
//! Where each photograph sits in the grid: the one place rows are computed.
//!
//! # Slots
//!
//! The grid is a lattice of `columns` slots per row, and until panoramas had
//! cells of their own a photograph's slot was its ordinal — row
//! `ordinal / columns`, column `ordinal % columns`, spelled out in half a
//! dozen places in the markup and in Rust. A photograph at least about twice
//! as wide as it is tall now takes two slots side by side, three for the
//! widest, and a slot is no longer an ordinal: every cell after a wide one
//! is pushed along, and a wide cell that would not fit in what is left of a
//! row starts the next one and leaves the rest of its row empty. Reading
//! order is kept — nothing later is moved up into the gap — so the arrows,
//! a shift-click's run and a scrub all still mean what they meant.
//!
//! Everything that turns an ordinal into a place on screen, or a place on
//! screen into an ordinal, asks [`Layout`]; the markup draws each cell at
//! the slot Rust gives it and sizes the scrollbar from [`Layout::total_slots`].
//!
//! # What it costs
//!
//! Where a wide photograph sits depends on every wide photograph before it,
//! so the layout is built from the ordinals of all of them in the current
//! view, not from the loaded window. Those are few — a library has a handful
//! of panoramas among thousands of frames — and they are read in one query,
//! and only when what the grid lists changes (`LibraryFacts`), or when the
//! window finds them out of date. The window itself says how wide each of
//! its own photographs is: the aspect comes with the cells' own read.
//!
//! # On the tablet, and with few columns
//!
//! A wide photograph takes the whole row there: on a touch-first device
//! always, and anywhere the columns are too few to put it beside anything.
/// The aspect from which a photograph takes two slots: about 2:1, a little
/// under so a 2:1 crop that came out a pixel short still counts.
pub const WIDE_ASPECT: f32 = 1.9;
/// From which it takes three.
pub const EXTRA_WIDE_ASPECT: f32 = 2.9;
/// How many slots a photograph of this aspect would like, before the columns
/// have their say.
pub fn natural_span(aspect: Option<f32>) -> usize {
match aspect {
Some(a) if a >= EXTRA_WIDE_ASPECT => 3,
Some(a) if a >= WIDE_ASPECT => 2,
_ => 1,
}
}
/// How many slots it gets in a grid of `columns`: its own, or the whole row
/// where `full_width` says so or the columns are too few to put it beside
/// anything.
pub fn span_in(natural: usize, columns: usize, full_width: bool) -> usize {
let columns = columns.max(1);
if natural <= 1 {
1
} else if full_width || columns <= natural {
columns
} else {
natural
}
}
/// The grid's placement of the current view.
#[derive(Debug, Clone, PartialEq)]
pub struct Layout {
columns: usize,
total: usize,
/// Each wide photograph: its ordinal, the slot it starts at and how many
/// it takes. Ascending in both.
anchors: Vec<(usize, usize, usize)>,
}
impl Layout {
/// Every photograph one slot: the grid as it was.
pub fn uniform(columns: usize, total: usize) -> Self {
Layout {
columns: columns.max(1),
total,
anchors: Vec::new(),
}
}
/// Pack a view of `total` photographs whose wide ones are `wide` —
/// `(ordinal, natural span)`, in any order — into rows of `columns`.
pub fn pack(columns: usize, total: usize, full_width: bool, wide: &[(usize, usize)]) -> Self {
let columns = columns.max(1);
let mut wide: Vec<(usize, usize)> = wide
.iter()
.filter(|(ordinal, _)| *ordinal < total)
.map(|&(ordinal, natural)| (ordinal, span_in(natural, columns, full_width)))
.filter(|(_, span)| *span > 1)
.collect();
wide.sort_unstable();
wide.dedup_by_key(|(ordinal, _)| *ordinal);
let mut anchors = Vec::with_capacity(wide.len());
// The slot and ordinal just past the last wide cell placed.
let (mut next_slot, mut next_ordinal) = (0usize, 0usize);
for (ordinal, span) in wide {
let mut slot = next_slot + (ordinal - next_ordinal);
if slot % columns + span > columns {
slot = slot.div_ceil(columns) * columns;
}
anchors.push((ordinal, slot, span));
next_slot = slot + span;
next_ordinal = ordinal + 1;
}
Layout {
columns,
total,
anchors,
}
}
/// Whether any photograph takes more than one slot.
#[cfg(test)]
pub fn is_uniform(&self) -> bool {
self.anchors.is_empty()
}
/// The last wide cell at or before `ordinal`.
fn anchor_before(&self, ordinal: usize) -> Option<&(usize, usize, usize)> {
let at = self.anchors.partition_point(|(o, _, _)| *o <= ordinal);
at.checked_sub(1).map(|i| &self.anchors[i])
}
/// The slot `ordinal` starts at.
pub fn slot_of(&self, ordinal: usize) -> usize {
match self.anchor_before(ordinal) {
None => ordinal,
Some(&(o, slot, _)) if o == ordinal => slot,
Some(&(o, slot, span)) => slot + span + (ordinal - o - 1),
}
}
/// How many slots `ordinal` takes.
pub fn span_of(&self, ordinal: usize) -> usize {
match self.anchor_before(ordinal) {
Some(&(o, _, span)) if o == ordinal => span,
_ => 1,
}
}
/// The slot just past the last photograph: what the scrollbar spans.
pub fn total_slots(&self) -> usize {
self.slot_of(self.total)
}
/// The first photograph whose cell ends after `slot` — the one a view
/// whose first row begins at `slot` shows first. `total` past the end.
pub fn ordinal_at(&self, slot: usize) -> usize {
// `slot_of(n) + span_of(n)` rises with `n`, so the answer is where
// it first passes `slot`.
let (mut lo, mut hi) = (0usize, self.total);
while lo < hi {
let mid = lo + (hi - lo) / 2;
if self.slot_of(mid) + self.span_of(mid) > slot {
hi = mid;
} else {
lo = mid + 1;
}
}
lo
}
/// The photograph a step of `rows` rows from `ordinal` lands on: the
/// one under the same column, or where that falls in the gap a wide cell
/// left at the end of a row, the last one in that row. Clamped to the
/// view.
pub fn step_rows(&self, ordinal: usize, rows: isize) -> usize {
if self.total == 0 {
return 0;
}
let rows_total = self.total_slots().div_ceil(self.columns) as isize;
let from = self.slot_of(ordinal.min(self.total - 1));
let row = (from / self.columns) as isize + rows;
if row < 0 {
return 0;
}
if row >= rows_total {
return self.total - 1;
}
let target = row as usize * self.columns + from % self.columns;
// The last photograph starting at or before the target slot.
let after = self.ordinal_starting_after(target);
after.saturating_sub(1).min(self.total - 1)
}
/// The first photograph that starts after `slot`.
fn ordinal_starting_after(&self, slot: usize) -> usize {
let (mut lo, mut hi) = (0usize, self.total);
while lo < hi {
let mid = lo + (hi - lo) / 2;
if self.slot_of(mid) > slot {
hi = mid;
} else {
lo = mid + 1;
}
}
lo
}
/// Whether the window starting at `offset` agrees with this layout
/// about which of its photographs are wide — `natural[i]` being the
/// natural span of the window's row `i`, as its own read found it. A
/// layout built before a reorder, a merge or a change of aspect does
/// not, and is read again.
pub fn agrees_with(&self, offset: usize, natural: &[usize], full_width: bool) -> bool {
natural
.iter()
.enumerate()
.all(|(i, &n)| self.span_of(offset + i) == span_in(n, self.columns, full_width))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The grid drawn as text, one row per line: `.` a photograph, a letter
/// for each slot of a wide one, `_` an empty slot.
fn draw(layout: &Layout) -> Vec<String> {
let mut slots = vec!['_'; layout.total_slots()];
let mut wide = b'A';
for n in 0..layout.total {
let (s, span) = (layout.slot_of(n), layout.span_of(n));
for slot in &mut slots[s..s + span] {
assert_eq!(*slot, '_', "photograph {n} overlaps another at slot {s}");
*slot = if span > 1 { wide as char } else { '.' };
}
if span > 1 {
wide += 1;
}
}
slots
.chunks(layout.columns)
.map(|r| r.iter().collect())
.collect()
}
#[test]
fn a_grid_of_ordinary_photographs_is_the_grid_it_always_was() {
let l = Layout::pack(4, 10, false, &[]);
assert!(l.is_uniform());
assert_eq!(l, Layout::uniform(4, 10));
assert_eq!(draw(&l), ["....", "....", ".."]);
for n in 0..10 {
assert_eq!(l.slot_of(n), n);
}
assert_eq!(l.ordinal_at(8), 8);
}
#[test]
fn a_wide_photograph_takes_two_slots_and_the_rest_move_along() {
// Photograph 2 is a 2:1 panorama, 5 a 4:1.
let l = Layout::pack(5, 10, false, &[(2, 2), (5, 3)]);
assert_eq!(draw(&l), ["..AA.", ".BBB.", "..."]);
assert_eq!(l.slot_of(3), 4);
assert_eq!(l.slot_of(5), 6);
assert_eq!(l.span_of(5), 3);
assert_eq!(l.total_slots(), 13);
}
#[test]
fn a_wide_photograph_that_does_not_fit_starts_the_next_row() {
// Three ordinary frames fill three of four columns; the panorama
// after them needs two, so it opens the next row and the fourth
// column of the first stays empty. Nothing later is moved up into
// it: the grid still reads in capture order.
let l = Layout::pack(4, 8, false, &[(3, 3)]);
assert_eq!(draw(&l), ["..._", "AAA.", "..."]);
assert_eq!(l.slot_of(3), 4);
assert_eq!(l.slot_of(4), 7);
}
#[test]
fn with_too_few_columns_a_wide_photograph_takes_the_whole_row() {
let l = Layout::pack(3, 5, false, &[(1, 3)]);
assert_eq!(draw(&l), [".__", "AAA", "..."]);
let l = Layout::pack(2, 4, false, &[(1, 2)]);
assert_eq!(draw(&l), ["._", "AA", ".."]);
// One column: nothing to span.
let l = Layout::pack(1, 3, false, &[(1, 3)]);
assert!(l.is_uniform());
}
#[test]
fn on_the_tablet_a_wide_photograph_takes_the_whole_row() {
let l = Layout::pack(5, 8, true, &[(2, 2)]);
assert_eq!(draw(&l), ["..___", "AAAAA", "....."]);
}
#[test]
fn wide_photographs_side_by_side_and_back_to_back() {
let l = Layout::pack(5, 7, false, &[(0, 2), (1, 2), (2, 2), (3, 3)]);
assert_eq!(draw(&l), ["AABB_", "CCDDD", "..."]);
}
#[test]
fn a_first_visible_slot_names_the_photograph_there() {
let l = Layout::pack(4, 8, false, &[(3, 3)]);
// Row 1 begins at slot 4, the panorama.
assert_eq!(l.ordinal_at(4), 3);
// The empty slot at the end of row 0 belongs to nothing: the next
// photograph is the panorama.
assert_eq!(l.ordinal_at(3), 3);
// Slot 5 is inside the panorama.
assert_eq!(l.ordinal_at(5), 3);
assert_eq!(l.ordinal_at(8), 5);
// Past the end.
assert_eq!(l.ordinal_at(40), 8);
// And every ordinal is found again at its own slot.
for n in 0..8 {
assert_eq!(l.ordinal_at(l.slot_of(n)), n);
}
}
#[test]
fn up_and_down_move_by_rows_not_by_a_row_of_ordinals() {
// ..._
// AAA.
// ....
let l = Layout::pack(4, 8, false, &[(3, 3)]);
// Down from the second frame lands in the panorama under it.
assert_eq!(l.step_rows(1, 1), 3);
// Down from the panorama, to the frame under its first column.
assert_eq!(l.step_rows(3, 1), 5);
// Up from the frame beside the panorama: the gap above it is empty,
// so the last frame of that row.
assert_eq!(l.step_rows(4, -1), 2);
// Up from under the panorama's middle, into the panorama.
assert_eq!(l.step_rows(6, -1), 3);
// Clamped at both ends.
assert_eq!(l.step_rows(1, -3), 0);
assert_eq!(l.step_rows(1, 9), 7);
// A page is several rows at once.
assert_eq!(l.step_rows(0, 2), 5);
}
#[test]
fn a_window_that_disagrees_with_the_layout_is_caught() {
let l = Layout::pack(4, 8, false, &[(3, 3)]);
assert!(l.agrees_with(2, &[1, 3, 1], false));
// The panorama has moved to ordinal 4: the window sees it there.
assert!(!l.agrees_with(2, &[1, 1, 3], false));
// A photograph that became wide.
assert!(!l.agrees_with(0, &[2], false));
}
#[test]
fn spans_by_aspect() {
assert_eq!(natural_span(None), 1);
assert_eq!(natural_span(Some(1.5)), 1);
assert_eq!(natural_span(Some(1.95)), 2);
assert_eq!(natural_span(Some(2.5)), 2);
assert_eq!(natural_span(Some(3.8)), 3);
assert_eq!(span_in(2, 6, false), 2);
assert_eq!(span_in(3, 3, false), 3);
assert_eq!(span_in(3, 2, false), 2);
assert_eq!(span_in(2, 6, true), 6);
assert_eq!(span_in(1, 6, true), 1);
}
}
+1
View File
@@ -39,6 +39,7 @@
mod controller; mod controller;
mod filter_bar; mod filter_bar;
mod grid; mod grid;
pub(crate) mod layout;
mod offline; mod offline;
mod open; mod open;
mod ratings_keywords; mod ratings_keywords;
+2 -4
View File
@@ -26,7 +26,7 @@ use super::offline::refresh_offline;
use super::ratings_keywords::refresh_xmp_conflicts; use super::ratings_keywords::refresh_xmp_conflicts;
use super::sync::{start_derived_sync, start_sweep}; use super::sync::{start_derived_sync, start_sweep};
use super::timeline::{apply_place, note_place}; use super::timeline::{apply_place, note_place};
use super::window::{load_window, window_start}; use super::window::{load_window, seek_to, window_start};
/// Reload the grid for the current scope and offset. /// Reload the grid for the current scope and offset.
/// ///
@@ -776,9 +776,7 @@ pub(super) fn schedule_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
let total = w.global::<Library>().get_library_total().max(0) as usize; let total = w.global::<Library>().get_library_total().max(0) as usize;
*ctl_cb.offset.borrow_mut() = window_start(anchor, window_size, total); *ctl_cb.offset.borrow_mut() = window_start(anchor, window_size, total);
load_window(&w, &ctl_cb); load_window(&w, &ctl_cb);
w.global::<Library>().set_library_scroll_to(anchor as i32); seek_to(&w, &ctl_cb, anchor);
w.global::<Library>()
.set_library_scroll_token(w.global::<Library>().get_library_scroll_token() + 1);
}); });
// Replacing the slot drops the previous timer, which is what makes this // Replacing the slot drops the previous timer, which is what makes this
+7 -20
View File
@@ -18,7 +18,7 @@ use crate::{AppWindow, Library, TimelineBar, View};
use super::controller::LibraryController; use super::controller::LibraryController;
use super::filter_bar::{push_people_chips, refilter}; use super::filter_bar::{push_people_chips, refilter};
use super::window::{load_window, mark_open, window_start}; use super::window::{load_window, mark_open, seek_to, show_cursor, window_start};
/// Move the timeline's zoom by whole levels. /// Move the timeline's zoom by whole levels.
/// ///
@@ -501,13 +501,10 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
// Move the viewport as well as the window. Cells are drawn at their // Move the viewport as well as the window. Cells are drawn at their
// absolute place in the library, so loading rows around image 15,000 while // absolute place in the library, so loading rows around image 15,000 while
// the viewport sits at row 0 shows an empty grid until the user scrolls. // the viewport sits at row 0 shows an empty grid until the user scrolls.
window
.global::<Library>()
.set_library_scroll_to(position as i32);
window
.global::<Library>()
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
load_window(window, ctl); load_window(window, ctl);
// After the load, which is what places the cells: the grid scrolls by
// slot, and a panorama before `position` moves its slot along.
seek_to(window, ctl, position);
} }
/// TRACES: FR-CAT-7 /// TRACES: FR-CAT-7
@@ -543,12 +540,7 @@ pub fn restore_position(window: &AppWindow, ctl: &Rc<LibraryController>) {
// `resume_at` is what the grid last reported as its first visible image, // `resume_at` is what the grid last reported as its first visible image,
// which is the thing the photographer is actually looking at. // which is the thing the photographer is actually looking at.
let anchor = ctl.resume_at.get().min(total - 1); let anchor = ctl.resume_at.get().min(total - 1);
window seek_to(window, ctl, anchor);
.global::<Library>()
.set_library_scroll_to(anchor as i32);
window
.global::<Library>()
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
} }
/// Format a bucket start for the histogram's hover label. /// Format a bucket start for the histogram's hover label.
@@ -998,16 +990,11 @@ pub(super) fn resume_position(
// Before the grid is shown, not after: the markup gates it on an `if`, and // Before the grid is shown, not after: the markup gates it on an `if`, and
// the rebuilt Flickable reads `scroll-to` in its `init`. Setting these // the rebuilt Flickable reads `scroll-to` in its `init`. Setting these
// afterwards would leave that init to run against the previous position. // afterwards would leave that init to run against the previous position.
window seek_to(window, ctl, anchor);
.global::<Library>()
.set_library_scroll_to(anchor as i32);
window
.global::<Library>()
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
if let Some(at) = focus { if let Some(at) = focus {
coll.set_cursor(Some(at)); coll.set_cursor(Some(at));
window.global::<Library>().set_library_cursor(at as i32); show_cursor(window, ctl, at);
} }
// The view has moved, so the capture-time marker has to move with it. // The view has moved, so the capture-time marker has to move with it.
+134 -22
View File
@@ -230,6 +230,31 @@ pub(super) fn bring_window_to(
.filter(|row| *row < ctl.paths.borrow().len()) .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 /// Record that the photograph at `row` of the loaded window is the one now
/// open in develop, and mark it on the roll. /// open in develop, and mark it on the roll.
pub(super) fn mark_open(window: &AppWindow, ctl: &LibraryController, row: usize) { 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); let describes_something_new = ctl.library_facts.borrow().as_ref() != Some(&facts);
*ctl.library_facts.borrow_mut() = 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 // TRACES: FR-NC-6a
// Whether the newly scoped collection is already pinned. Read here rather // 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(), .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 // 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 // wall of thumbnails gives no sense of *when* you are looking — the
// sidebar says it, but only if you consult it. // 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( let headings = period_headings(
cells.iter().map(|c| { cells.iter().enumerate().map(|(i, c)| {
c.captured_at.map(|t| { (
let (y, m, _, _) = civil_from_unix(t); c.captured_at.map(|t| {
(y, m) let (y, m, _, _) = civil_from_unix(t);
}) (y, m)
}),
layout.slot_of(offset + i),
)
}), }),
offset,
columns, 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 // 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 // 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. // served at that class so no fetch is spent on a file still uploading.
let wanted = dr_thumbs::ThumbSize::for_cell( let cell_pixels = window.global::<Library>().get_library_cell_size().max(1.0) as u32;
window.global::<Library>().get_library_cell_size().max(1.0) as u32,
);
let mut held = held; let mut held = held;
{ {
let merged = ctl.merged_thumbs.borrow(); 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 { let Some(made) = merged.get(&c.image_id) else {
continue; continue;
}; };
let wanted = dr_thumbs::ThumbSize::for_span(layout.span_of(offset + i), cell_pixels);
if held if held
.get(&c.image_id) .get(&c.image_id)
.is_some_and(|h| h.class == Some(wanted)) .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 let rows: Vec<LibraryCell> = cells
.iter() .iter()
.zip(headings) .zip(headings)
.map(|(c, heading)| { .enumerate()
.map(|(i, (c, heading))| {
let carried = held.get(&c.image_id); let carried = held.get(&c.image_id);
LibraryCell { LibraryCell {
slot: layout.slot_of(offset + i) as i32,
span: layout.span_of(offset + i) as i32,
period_heading: heading.into(), period_heading: heading.into(),
// A freshly loaded window has no drag in flight. // A freshly loaded window has no drag in flight.
lifted: false, lifted: false,
@@ -611,6 +661,55 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
request_thumbnails(window, ctl); 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. /// 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 /// 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; return;
}; };
// The drawn cell size decides which class to ask for. Chosen once for the // The drawn cell size decides which class to ask for, and a panorama's
// batch rather than per row, and carried through to the drain so a cell it // cell asks for the wide one (FR-MRG-6). Carried through to the drain per
// fills can record what it is now showing. // 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 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 mut wanted: Vec<library::ThumbnailRequest> = {
let paths = ctl.paths.borrow(); let paths = ctl.paths.borrow();
@@ -668,7 +773,10 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
let image_id = *image_ids.get(i)?; let image_id = *image_ids.get(i)?;
// A zoomed grid asks for detail a 256px thumbnail cannot give, // A zoomed grid asks for detail a 256px thumbnail cannot give,
// and a wall of small cells does not pay for it. // 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 // Keyed on the photograph, so scrolling back over a cell that
// has already been served does not ask for it again. // has already been served does not ask for it again.
if !requested.insert((image_id, thumb_size)) { 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 // the model they were built against; only the order they are asked for in
// changes. // 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); let on_screen = ctl.viewport_cells.get().max(1);
wanted.sort_by_key(|r| fetch_rank(r.row, first_on_screen, on_screen)); 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::thumbs_dir(&conn.account),
library::catalog_path(&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 /// TRACES: FR-CAT-9 | FR-DEV-6
@@ -835,7 +943,7 @@ fn drain_thumbnails(
ctl: Rc<LibraryController>, ctl: Rc<LibraryController>,
rx: Receiver<ThumbnailMessage>, rx: Receiver<ThumbnailMessage>,
requested: usize, requested: usize,
class: dr_thumbs::ThumbSize, classes: Vec<dr_thumbs::ThumbSize>,
) { ) {
let timer = slint::Timer::default(); let timer = slint::Timer::default();
let ctl_cb = ctl.clone(); let ctl_cb = ctl.clone();
@@ -977,7 +1085,9 @@ fn drain_thumbnails(
model.set_row_data(t.row, row); model.set_row_data(t.row, row);
// What this cell is now showing, so the next reload // What this cell is now showing, so the next reload
// can carry it over and know not to ask again. // 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 } => { ThumbnailMessage::Unavailable { row, reason } => {
@@ -989,7 +1099,9 @@ fn drain_thumbnails(
// A verdict is worth carrying too: "no preview" is // A verdict is worth carrying too: "no preview" is
// an answer about the file, and re-asking it on // an answer about the file, and re-asking it on
// every scroll is a fetch that will fail again. // 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 // TRACES: FR-CAT-9
+22 -8
View File
@@ -115,10 +115,16 @@ impl Reduced {
} }
} }
/// Which size classes a composite of this shape is thumbnailed at. /// Which size classes a composite of this shape is thumbnailed at: the two
/// every photograph has, and the wide one where the grid will give it a
/// wide cell.
pub fn classes(width: u32, height: u32) -> Vec<ThumbSize> { pub fn classes(width: u32, height: u32) -> Vec<ThumbSize> {
let _ = (width, height); let aspect = width as f32 / height.max(1) as f32;
vec![ThumbSize::Grid, ThumbSize::Large] let mut out = vec![ThumbSize::Grid, ThumbSize::Large];
if crate::library_ui::layout::natural_span(Some(aspect)) > 1 {
out.push(ThumbSize::Wide);
}
out
} }
/// Develop the reduced composite as develop would open the file, and render /// Develop the reduced composite as develop would open the file, and render
@@ -190,6 +196,12 @@ pub fn render(
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_panorama_is_thumbnailed_for_its_wide_cell_and_a_squat_merge_is_not() {
assert_eq!(classes(22_000, 5_600).last(), Some(&ThumbSize::Wide));
assert!(!classes(9_000, 6_000).contains(&ThumbSize::Wide));
}
#[test] #[test]
fn a_reduction_averages_its_boxes_and_the_ragged_edge() { fn a_reduction_averages_its_boxes_and_the_ragged_edge() {
// 5 × 3 by 2: boxes of 2 × 2, then 1 × 2 at the right, 2 × 1 and // 5 × 3 by 2: boxes of 2 × 2, then 1 × 2 at the right, 2 × 1 and
@@ -286,7 +298,8 @@ mod tests {
eprintln!("no GPU; skipping"); eprintln!("no GPU; skipping");
return; return;
}; };
let (width, height, white) = (3000u32, 760u32, 13_023u32); // Past `SOURCE_EDGE`, so the thumbnails come from a reduced copy.
let (width, height, white) = (4600u32, 1160u32, 13_023u32);
let pixels = composite(width, height, white); let pixels = composite(width, height, white);
let profile = profile(white); let profile = profile(white);
let header = dr_export::SourceMetadata { let header = dr_export::SourceMetadata {
@@ -298,8 +311,8 @@ mod tests {
let crop = dr_export::Rect { let crop = dr_export::Rect {
x: 40, x: 40,
y: 30, y: 30,
width: 2900, width: 4500,
height: 700, height: 1080,
}; };
// Develop's side: the whole composite written as the merge writes // Develop's side: the whole composite written as the merge writes
@@ -328,7 +341,8 @@ mod tests {
let mut developed = crate::open_session(&ctx, decoder, &bytes, &meta).unwrap(); let mut developed = crate::open_session(&ctx, decoder, &bytes, &meta).unwrap();
// The merge's side: the same bands, reduced as they pass. // The merge's side: the same bands, reduced as they pass.
let mut reduced = Reduced::new(width, height, 1500); let mut reduced = Reduced::new(width, height, SOURCE_EDGE);
assert_eq!(reduced.factor(), 2);
for first in (0..height).step_by(rows_per_strip as usize) { for first in (0..height).step_by(rows_per_strip as usize) {
let rows = rows_per_strip.min(height - first); let rows = rows_per_strip.min(height - first);
let from = (first * width * 3) as usize; let from = (first * width * 3) as usize;
@@ -345,7 +359,7 @@ mod tests {
&header, &header,
Some(crop), Some(crop),
decoder, decoder,
&[ThumbSize::Grid, ThumbSize::Large], &[ThumbSize::Grid, ThumbSize::Large, ThumbSize::Wide],
) )
.unwrap(); .unwrap();
+5
View File
@@ -1463,6 +1463,7 @@ in property <bool> panel-visible: true;
expanded: root.expanded; expanded: root.expanded;
cells: Library.library-cells; cells: Library.library-cells;
total: Library.library-total; total: Library.library-total;
total-slots: Library.library-total-slots;
scanning: Library.library-scanning; scanning: Library.library-scanning;
opening: Library.library-opening; opening: Library.library-opening;
scan-status: Library.library-status; scan-status: Library.library-status;
@@ -1584,9 +1585,13 @@ in property <bool> panel-visible: true;
assign-keyword(word) => { Library.library-assign-keyword(word); } assign-keyword(word) => { Library.library-assign-keyword(word); }
unassign-keyword(word) => { Library.library-unassign-keyword(word); } unassign-keyword(word) => { Library.library-unassign-keyword(word); }
cursor: Library.library-cursor; cursor: Library.library-cursor;
cursor-slot: Library.library-cursor-slot;
move-cursor(delta, extend) => { move-cursor(delta, extend) => {
Library.library-move-cursor(delta, extend); Library.library-move-cursor(delta, extend);
} }
move-cursor-rows(rows, extend) => {
Library.library-move-cursor-rows(rows, extend);
}
open-cursor() => { Library.library-open-cursor(); } open-cursor() => { Library.library-open-cursor(); }
drag-image: Library.library-drag-image; drag-image: Library.library-drag-image;
drag-payload() => { return Library.library-drag-payload(); } drag-payload() => { return Library.library-drag-payload(); }
+52 -17
View File
@@ -483,6 +483,13 @@ export struct LibraryCell {
// the one cell on screen is the representative by construction, and a mark // the one cell on screen is the representative by construction, and a mark
// saying so would be telling the user what they can already see. // saying so would be telling the user what they can already see.
burst-representative: bool, burst-representative: bool,
// TRACES: FR-MRG-6
// Where the cell sits in the grid's lattice of slots — row
// `slot / columns`, column `slot % columns` — and how many slots it
// takes: one, or two or three for a panorama, or the whole row. Rust's
// layout (`library_ui::layout`) is the one place these are computed.
slot: int,
span: int,
} }
// TRACES: FR-CAT-3 | FR-CAT-4 | FR-CAT-5 | FR-CAT-6 | FR-CAT-7 | FR-CULL-4 | FR-CULL-5 // TRACES: FR-CAT-3 | FR-CAT-4 | FR-CAT-5 | FR-CAT-6 | FR-CAT-7 | FR-CULL-4 | FR-CULL-5
@@ -535,8 +542,14 @@ export global Library {
callback library-pinch-cells(float); callback library-pinch-cells(float);
/// A pinch on the grid has begun, so the press that opened it was not one. /// A pinch on the grid has begun, so the press that opened it was not one.
callback library-pinch-started(); callback library-pinch-started();
/// The slot the grid's first row should show — a slot, not an ordinal:
/// see `LibraryCell.slot`.
in property <int> library-scroll-to: 0; in property <int> library-scroll-to: 0;
in property <int> library-scroll-token: 0; in property <int> library-scroll-token: 0;
/// TRACES: FR-MRG-6
/// The slots the whole list takes, which a panorama's cell makes more
/// than its count of photographs: what the scrollbar spans.
in property <int> library-total-slots: 0;
/// Which row of the loaded window is the photograph currently open in /// Which row of the loaded window is the photograph currently open in
/// develop, so the roll can mark it. `-1` when it is not in the window. /// develop, so the roll can mark it. `-1` when it is not in the window.
/// Rust re-finds it on every reload, so it follows the photograph and not /// Rust re-finds it on every reload, so it follows the photograph and not
@@ -559,6 +572,7 @@ export global Library {
/// what the roll happens to hold. /// what the roll happens to hold.
callback library-roll-step(int); callback library-roll-step(int);
callback library-columns-changed(); callback library-columns-changed();
/// The first visible row's first slot.
callback library-scrolled(int); callback library-scrolled(int);
/// How many cells the grid's viewport shows at once. Rust sizes the /// How many cells the grid's viewport shows at once. Rust sizes the
/// loaded window from it — see `library_ui::window_move`. /// loaded window from it — see `library_ui::window_move`.
@@ -616,7 +630,15 @@ export global Library {
// ordinal. Rust owns it — clamping it needs the library's length, and // ordinal. Rust owns it — clamping it needs the library's length, and
// moving it may have to swap the loaded window underneath. // moving it may have to swap the loaded window underneath.
in property <int> library-cursor: -1; in property <int> library-cursor: -1;
/// The slot the cursor's photograph starts at, for keeping its row in
/// view.
in property <int> library-cursor-slot: -1;
callback library-move-cursor(int, bool); callback library-move-cursor(int, bool);
/// TRACES: FR-MRG-6
/// Move the cursor by rows, which with a panorama's cell in the way is
/// not a fixed number of photographs: Rust asks the layout what is under
/// the cursor that many rows on.
callback library-move-cursor-rows(int, bool);
callback library-open-cursor(); callback library-open-cursor();
/// TRACES: FR-CAT-5 | FR-CAT-6 /// TRACES: FR-CAT-5 | FR-CAT-6
/// Keywording the grid's selection. The catalog has been searchable by /// Keywording the grid's selection. The catalog has been searchable by
@@ -1461,6 +1483,10 @@ export component LibraryGrid inherits Rectangle {
in property <[LibraryCell]> cells; in property <[LibraryCell]> cells;
in property <int> total: 0; in property <int> total: 0;
/// TRACES: FR-MRG-6
/// The slots the whole list takes: `total` where every cell is one slot,
/// more where a panorama spans several or a row ends short of one.
in property <int> total-slots: 0;
in property <bool> scanning: false; in property <bool> scanning: false;
/// The catalog on this device is being opened and checked before the scan /// The catalog on this device is being opened and checked before the scan
/// starts. Distinct from `scanning` for the reason the empty state below /// starts. Distinct from `scanning` for the reason the empty state below
@@ -2036,6 +2062,9 @@ export component LibraryGrid inherits Rectangle {
// and the first arrow press picks up where the view already is rather than // and the first arrow press picks up where the view already is rather than
// teleporting to image zero. // teleporting to image zero.
in property <int> cursor: -1; in property <int> cursor: -1;
/// The slot the cursor's photograph starts at; its row is what is kept
/// in view.
in property <int> cursor-slot: -1;
/// Move the cursor by a number of images; the flag extends the selection /// Move the cursor by a number of images; the flag extends the selection
/// from the anchor instead of replacing it. /// from the anchor instead of replacing it.
/// ///
@@ -2043,6 +2072,10 @@ export component LibraryGrid inherits Rectangle {
/// longer than the library, which Rust clamps — so this file needs to know /// longer than the library, which Rust clamps — so this file needs to know
/// neither how many images there are nor where the loaded window starts. /// neither how many images there are nor where the loaded window starts.
callback move-cursor(int, bool); callback move-cursor(int, bool);
/// Move the cursor by rows. Up and down are not a row's worth of
/// photographs once a panorama spans columns, so Rust finds the one
/// under the cursor that many rows on.
callback move-cursor-rows(int, bool);
/// Open the image under the cursor. `Return`, and the reason the arrows /// Open the image under the cursor. `Return`, and the reason the arrows
/// are worth having: a cull is walk, judge, open, back, without the hand /// are worth having: a cull is walk, judge, open, back, without the hand
/// ever leaving the keyboard. /// ever leaving the keyboard.
@@ -2205,7 +2238,6 @@ export component LibraryGrid inherits Rectangle {
// start-up. Every month then began a row and was announced wherever // start-up. Every month then began a row and was announced wherever
// its first cell fell, mid-row included. // its first cell fell, mid-row included.
init => { root.columns-changed(root.columns); } init => { root.columns-changed(root.columns); }
property <int> row-count: ceil(root.cells.length / max(1, columns));
// --- while a pinch is happening, and just after ----------------------- // --- while a pinch is happening, and just after -----------------------
// //
@@ -2276,7 +2308,8 @@ export component LibraryGrid inherits Rectangle {
property <int> viewport-cells: root.columns * (root.visible-rows + 1); property <int> viewport-cells: root.columns * (root.visible-rows + 1);
changed viewport-cells => { root.viewport-cells-changed(root.viewport-cells); } changed viewport-cells => { root.viewport-cells-changed(root.viewport-cells); }
/// Rows the *whole library* occupies, which is what the scrollbar spans. /// Rows the *whole library* occupies, which is what the scrollbar spans.
property <int> total-rows: ceil(root.total / max(1, columns)); /// Counted in slots, since a panorama's cell takes several.
property <int> total-rows: ceil(max(root.total, root.total-slots) / max(1, columns));
background: Theme.ground; background: Theme.ground;
@@ -3437,21 +3470,19 @@ export component LibraryGrid inherits Rectangle {
return accept; return accept;
} }
if (Keys.chord(event) == "Up" || Keys.chord(event) == "Shift+Up") { if (Keys.chord(event) == "Up" || Keys.chord(event) == "Shift+Up") {
root.move-cursor(-root.columns, event.modifiers.shift); root.move-cursor-rows(-1, event.modifiers.shift);
return accept; return accept;
} }
if (Keys.chord(event) == "Down" || Keys.chord(event) == "Shift+Down") { if (Keys.chord(event) == "Down" || Keys.chord(event) == "Shift+Down") {
root.move-cursor(root.columns, event.modifiers.shift); root.move-cursor-rows(1, event.modifiers.shift);
return accept; return accept;
} }
if (Keys.chord(event) == "PageUp" || Keys.chord(event) == "Shift+PageUp") { if (Keys.chord(event) == "PageUp" || Keys.chord(event) == "Shift+PageUp") {
root.move-cursor(-root.columns * root.visible-rows, root.move-cursor-rows(-root.visible-rows, event.modifiers.shift);
event.modifiers.shift);
return accept; return accept;
} }
if (Keys.chord(event) == "PageDown" || Keys.chord(event) == "Shift+PageDown") { if (Keys.chord(event) == "PageDown" || Keys.chord(event) == "Shift+PageDown") {
root.move-cursor(root.columns * root.visible-rows, root.move-cursor-rows(root.visible-rows, event.modifiers.shift);
event.modifiers.shift);
return accept; return accept;
} }
// A step longer than the library, clamped at the far end. // A step longer than the library, clamped at the far end.
@@ -3603,7 +3634,7 @@ export component LibraryGrid inherits Rectangle {
// on screen moves nothing at all, and one that has just left // on screen moves nothing at all, and one that has just left
// brings in exactly its own row. // brings in exactly its own row.
property <length> pitch: root.cell-size + Theme.gap; property <length> pitch: root.cell-size + Theme.gap;
property <int> cursor-row: floor(root.cursor / max(1, root.columns)); property <int> cursor-row: floor(max(0, root.cursor-slot) / max(1, root.columns));
changed cursor-row => { self.reveal(); } changed cursor-row => { self.reveal(); }
function reveal() { function reveal() {
@@ -3722,7 +3753,7 @@ export component LibraryGrid inherits Rectangle {
// Sits in the gap above its row, so it labels the row // Sits in the gap above its row, so it labels the row
// rather than displacing it. // rather than displacing it.
y: Theme.gap y: Theme.gap
+ floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap) + floor(cell.slot / root.columns) * (root.cell-size + Theme.gap)
- 15px; - 15px;
width: parent.width - 2 * Theme.gap; width: parent.width - 2 * Theme.gap;
text: cell.period-heading; text: cell.period-heading;
@@ -3756,10 +3787,12 @@ export component LibraryGrid inherits Rectangle {
// Cells are positioned at their **absolute** place in the // Cells are positioned at their **absolute** place in the
// library, not their index in the loaded window: the window // library, not their index in the loaded window: the window
// starts at `offset`, so a cell drawn at window-index 0 belongs // starts at `offset`, so a cell drawn at window-index 0 belongs
// wherever `offset` sits in the full grid. // wherever `offset` sits in the full grid. That place is its
x: Theme.gap + mod(i + root.offset, root.columns) * (root.cell-size + Theme.gap); // slot, which Rust computes (FR-MRG-6): a panorama before it
y: Theme.gap + floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap); // pushes it along, and a panorama itself spans `span` slots.
width: root.cell-size; x: Theme.gap + mod(cell.slot, root.columns) * (root.cell-size + Theme.gap);
y: Theme.gap + floor(cell.slot / root.columns) * (root.cell-size + Theme.gap);
width: max(1, cell.span) * root.cell-size + (max(1, cell.span) - 1) * Theme.gap;
height: root.cell-size; height: root.cell-size;
// Copy, not move: dropping into a collection files the // Copy, not move: dropping into a collection files the
@@ -4433,12 +4466,14 @@ export component LibraryGrid inherits Rectangle {
visible: root.held-row >= 0; visible: root.held-row >= 0;
x: Theme.gap x: Theme.gap
+ mod(root.held-row + root.offset, root.columns) * self.pitch + mod(root.cells[root.held-row].slot, root.columns) * self.pitch
- self.reach; - self.reach;
y: Theme.gap y: Theme.gap
+ floor((root.held-row + root.offset) / root.columns) * self.pitch + floor(root.cells[root.held-row].slot / root.columns) * self.pitch
- self.reach; - self.reach;
width: root.cell-size + 2 * self.reach; property <int> span: max(1, root.cells[root.held-row].span);
width: self.span * root.cell-size + (self.span - 1) * Theme.gap
+ 2 * self.reach;
height: root.cell-size + 2 * self.reach; height: root.cell-size + 2 * self.reach;
animate x, y, width, height { duration: 120ms; easing: ease-out; } animate x, y, width, height { duration: 120ms; easing: ease-out; }