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
+11
View File
@@ -123,6 +123,15 @@ pub struct LibraryController {
/// blank in the meantime; after, the store holds the same ones.
pub(super) merged_thumbs:
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.
pub(super) offset: RefCell<usize>,
/// The first visible ordinal, kept so returning from the develop view lands
@@ -460,6 +469,8 @@ impl LibraryController {
captured_at: RefCell::new(Vec::new()),
thumb_class: RefCell::new(Vec::new()),
merged_thumbs: RefCell::new(Default::default()),
layout: RefCell::new(super::layout::Layout::uniform(1, 0)),
wide: RefCell::new(None),
offset: RefCell::new(0),
resume_at: std::cell::Cell::new(0),
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
/// the next month began.
pub(super) fn period_headings(
months: impl Iterator<Item = Option<(i64, i64)>>,
offset: usize,
months: impl Iterator<Item = (Option<(i64, i64)>, usize)>,
columns: usize,
) -> Vec<String> {
let columns = columns.max(1);
let mut announced: Option<(i64, i64)> = None;
months
.enumerate()
.map(|(i, month)| {
.map(|(i, (month, slot))| {
let Some((y, m)) = month else {
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))) {
announced = Some((y, m));
format!("{} {y}", month_name(m))
@@ -863,6 +864,29 @@ fn show_range(window: &AppWindow, ctl: &Rc<LibraryController>) {
mod tests {
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]
fn a_month_that_begins_mid_row_is_still_announced() {
// Four columns; August starts in the second column of the second
@@ -877,7 +901,7 @@ mod tests {
aug, aug, 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[4], "", "still March at the top of row two");
assert_eq!(h[5], "", "never mid-row");
@@ -892,12 +916,12 @@ mod tests {
let feb = Some((2026, 2));
// 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.
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[2], "February 2026", "cell 2 begins a row at offset 2");
assert_eq!(h[3], "");
// 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[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,
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.
///
@@ -109,7 +109,7 @@ fn place_cursor(
}
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
@@ -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 ctl = ctl.clone();
@@ -635,9 +662,18 @@ fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc<LibraryController>) {
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_scrolled(move |first_visible| {
.on_library_scrolled(move |first_slot| {
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.**
//
@@ -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`
// was last *set* to — a stale scrub, or zero — and landed there.
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
// 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 filter_bar;
mod grid;
pub(crate) mod layout;
mod offline;
mod open;
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::sync::{start_derived_sync, start_sweep};
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.
///
@@ -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;
*ctl_cb.offset.borrow_mut() = window_start(anchor, window_size, total);
load_window(&w, &ctl_cb);
w.global::<Library>().set_library_scroll_to(anchor as i32);
w.global::<Library>()
.set_library_scroll_token(w.global::<Library>().get_library_scroll_token() + 1);
seek_to(&w, &ctl_cb, anchor);
});
// 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::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.
///
@@ -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
// 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.
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);
// 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
@@ -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,
// which is the thing the photographer is actually looking at.
let anchor = ctl.resume_at.get().min(total - 1);
window
.global::<Library>()
.set_library_scroll_to(anchor as i32);
window
.global::<Library>()
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
seek_to(window, ctl, anchor);
}
/// 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
// the rebuilt Flickable reads `scroll-to` in its `init`. Setting these
// afterwards would leave that init to run against the previous position.
window
.global::<Library>()
.set_library_scroll_to(anchor as i32);
window
.global::<Library>()
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
seek_to(window, ctl, anchor);
if let Some(at) = focus {
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.
+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