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:
@@ -72,6 +72,13 @@ pub enum ThumbSize {
|
||||
/// Zoomed cells, the loupe, and the filmstrip. ~45 KB each, fetched only
|
||||
/// where something actually asks for that detail.
|
||||
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 {
|
||||
@@ -80,6 +87,18 @@ impl ThumbSize {
|
||||
match self {
|
||||
ThumbSize::Grid => 256,
|
||||
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 {
|
||||
0 => Some(ThumbSize::Grid),
|
||||
1 => Some(ThumbSize::Large),
|
||||
2 => Some(ThumbSize::Wide),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_i64(v: i64) -> Self {
|
||||
match v {
|
||||
1 => ThumbSize::Large,
|
||||
_ => ThumbSize::Grid,
|
||||
}
|
||||
Self::from_stored(v).unwrap_or(ThumbSize::Grid)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ pub(super) fn uncollapsed(image: &str) -> String {
|
||||
}
|
||||
|
||||
/// One grid cell's data, read from the catalog.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LibraryCell {
|
||||
pub image_id: i64,
|
||||
pub name: String,
|
||||
@@ -53,6 +53,10 @@ pub struct LibraryCell {
|
||||
pub metadata_state: u8,
|
||||
/// UTC seconds, once EXIF has been read.
|
||||
pub captured_at: Option<i64>,
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Width over height, where the catalog knows both: what decides whether
|
||||
/// the cell spans columns. Read with the window, never per cell.
|
||||
pub aspect: Option<f32>,
|
||||
}
|
||||
|
||||
/// Read a window of cells out of the catalog.
|
||||
@@ -346,6 +350,94 @@ pub fn ordinal_of_path(
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Which rows of the catalog are wide enough to span columns, spelled as
|
||||
/// [`crate::library_ui`]'s layout counts them: `w / h >= 1.9`, in integers
|
||||
/// so the index below can be matched term for term. Change one, change the
|
||||
/// other — `the_wide_predicate_is_the_layouts` holds them together.
|
||||
pub(super) const WIDE: &str = "w * 10 >= h * 19";
|
||||
|
||||
/// Make sure the partial index of wide photographs exists.
|
||||
///
|
||||
/// Created on first use rather than by a migration: it holds only the
|
||||
/// panoramas, it changes nothing an older build reads, and a schema bump
|
||||
/// would make every older peer refuse this catalog's snapshot. Keyed on the
|
||||
/// grid's ordering, so the planner can answer "is there any?" from it alone.
|
||||
fn ensure_wide_index(catalog: &Catalog) -> Result<(), dr_catalog::CatalogError> {
|
||||
catalog.connection().execute_batch(&format!(
|
||||
"CREATE INDEX IF NOT EXISTS images_wide
|
||||
ON images(captured_at, source_ref) WHERE {WIDE}"
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// The photographs in the grid's current list that are wide enough to span
|
||||
/// columns: each one's ordinal and aspect, in order.
|
||||
///
|
||||
/// What the layout places them from. Answered from the small side first: a
|
||||
/// library with no panorama in it — the reference library, and every one
|
||||
/// until a merge writes a composite — is told so by one probe of the
|
||||
/// partial index, and nothing else runs. Otherwise one pass over the list,
|
||||
/// numbering it through the ordering the cells are read with, exactly as
|
||||
/// [`ordinal_of_path`] does and for its reason; it runs when what the grid
|
||||
/// lists changes, not per scroll.
|
||||
pub fn wide_ordinals(
|
||||
catalog: &Catalog,
|
||||
scope: Option<Scope>,
|
||||
filter: &RatingFilter,
|
||||
trash: bool,
|
||||
) -> Result<Vec<(usize, f32)>, dr_catalog::CatalogError> {
|
||||
ensure_wide_index(catalog)?;
|
||||
let any: bool = catalog.connection().query_row(
|
||||
&format!("SELECT EXISTS(SELECT 1 FROM images WHERE {WIDE})"),
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if !any {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (inner, params) = if trash {
|
||||
(
|
||||
format!(
|
||||
"SELECT i.w AS w, i.h AS h,
|
||||
row_number() OVER ({TRASH_ORDER}) - 1 AS ord
|
||||
FROM images i
|
||||
WHERE {TRASHED}"
|
||||
),
|
||||
Vec::new(),
|
||||
)
|
||||
} else {
|
||||
let (clause, scope_params) = scope_clause(catalog, scope)?;
|
||||
let rated = filter.sql();
|
||||
let folded = uncollapsed("i");
|
||||
let (order, mut params) = grid_order_for(catalog, scope);
|
||||
// The window's parameters first — see `ordinal_of_path`.
|
||||
params.extend(scope_params);
|
||||
(
|
||||
format!(
|
||||
"SELECT i.w AS w, i.h AS h,
|
||||
row_number() OVER ({order}) - 1 AS ord
|
||||
FROM images i
|
||||
WHERE {VISIBLE}{rated}{folded}{clause}"
|
||||
),
|
||||
params,
|
||||
)
|
||||
};
|
||||
let mut stmt = catalog.connection().prepare(&format!(
|
||||
"SELECT ord, w, h FROM ({inner}) WHERE {WIDE} ORDER BY ord"
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?.max(0) as usize,
|
||||
r.get::<_, i64>(1)? as f32 / r.get::<_, i64>(2)?.max(1) as f32,
|
||||
))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The columns every windowed read selects, in the order [`row_to_cell`] reads
|
||||
/// them.
|
||||
///
|
||||
@@ -353,7 +445,7 @@ pub fn ordinal_of_path(
|
||||
/// the one column that is *not* here stays conspicuous. See
|
||||
/// [`attach_file_ids`] for why the server's file id is fetched separately.
|
||||
pub(super) const CELL_COLUMNS: &str =
|
||||
"i.id, i.source_ref, i.file_size, i.metadata_state, i.captured_at";
|
||||
"i.id, i.source_ref, i.file_size, i.metadata_state, i.captured_at, i.w, i.h";
|
||||
|
||||
/// Shared row mapping, so the scoped and unscoped queries cannot drift.
|
||||
///
|
||||
@@ -368,6 +460,10 @@ pub(super) fn row_to_cell(r: &rusqlite::Row) -> rusqlite::Result<LibraryCell> {
|
||||
size: r.get::<_, Option<i64>>(2)?.unwrap_or(0) as u64,
|
||||
metadata_state: r.get::<_, i64>(3)? as u8,
|
||||
captured_at: r.get(4)?,
|
||||
aspect: match (r.get::<_, Option<i64>>(5)?, r.get::<_, Option<i64>>(6)?) {
|
||||
(Some(w), Some(h)) if w > 0 && h > 0 => Some(w as f32 / h as f32),
|
||||
_ => None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -944,6 +1040,93 @@ mod tests {
|
||||
// --- the trash view (FR-CAT-15) ---------------------------------------
|
||||
|
||||
/// Mark one image trashed, at a given instant.
|
||||
fn shaped(catalog: &Catalog, path: &str, w: i64, h: i64) {
|
||||
let n = catalog
|
||||
.connection()
|
||||
.execute(
|
||||
"UPDATE images SET w = ?2, h = ?3 WHERE source_ref = ?1",
|
||||
rusqlite::params![path, w, h],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(n, 1, "fixture should have shaped exactly {path}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wide_photographs_are_found_at_their_ordinals() {
|
||||
let catalog = with_images(20);
|
||||
assert!(
|
||||
wide_ordinals(&catalog, None, &RatingFilter::default(), false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
shaped(&catalog, "PhotosRaw/img005.CR2", 4000, 1000);
|
||||
shaped(&catalog, "PhotosRaw/img012.CR2", 3000, 1400);
|
||||
shaped(&catalog, "PhotosRaw/img013.CR2", 3000, 2000);
|
||||
let wide = wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap();
|
||||
assert_eq!(
|
||||
wide.iter().map(|(o, _)| *o).collect::<Vec<_>>(),
|
||||
vec![5, 12],
|
||||
"{wide:?}"
|
||||
);
|
||||
assert!((wide[0].1 - 4.0).abs() < 1e-6);
|
||||
|
||||
// The ordinals are the grid's: a frame trashed before them moves
|
||||
// them up, and in the trash only the trash counts.
|
||||
trash_at(&catalog, "PhotosRaw/img003.CR2", 1);
|
||||
trash_at(&catalog, "PhotosRaw/img012.CR2", 2);
|
||||
let wide = wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap();
|
||||
assert_eq!(wide.iter().map(|(o, _)| *o).collect::<Vec<_>>(), vec![4]);
|
||||
let trashed = wide_ordinals(&catalog, None, &RatingFilter::default(), true).unwrap();
|
||||
// Newest deletion first: img012 then img003.
|
||||
assert_eq!(trashed.iter().map(|(o, _)| *o).collect::<Vec<_>>(), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_with_no_panorama_is_told_so_by_the_index() {
|
||||
let catalog = with_images(20);
|
||||
wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap();
|
||||
let plan: Vec<String> = catalog
|
||||
.connection()
|
||||
.prepare(&format!(
|
||||
"EXPLAIN QUERY PLAN SELECT EXISTS(SELECT 1 FROM images WHERE {WIDE})"
|
||||
))
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get::<_, String>(3))
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.collect();
|
||||
let plan = plan.join(" | ");
|
||||
assert!(plan.contains("images_wide"), "{plan}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wide_predicate_is_the_layouts() {
|
||||
use crate::library_ui::layout::natural_span;
|
||||
let catalog = with_images(1);
|
||||
for (w, h) in [
|
||||
(19, 10),
|
||||
(189, 100),
|
||||
(190, 100),
|
||||
(191, 100),
|
||||
(6000, 4000),
|
||||
(4000, 6000),
|
||||
(22_993, 5_980),
|
||||
(1_899, 1_000),
|
||||
(1_900, 1_000),
|
||||
] {
|
||||
let sql: bool = catalog
|
||||
.connection()
|
||||
.query_row(
|
||||
&format!("SELECT {}", WIDE.replace('w', "?1").replace('h', "?2")),
|
||||
rusqlite::params![w, h],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let rust = natural_span(Some(w as f32 / h as f32)) > 1;
|
||||
assert_eq!(sql, rust, "{w}×{h}");
|
||||
}
|
||||
}
|
||||
|
||||
fn trash_at(catalog: &Catalog, path: &str, when: i64) {
|
||||
let n = catalog
|
||||
.connection()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@
|
||||
mod controller;
|
||||
mod filter_bar;
|
||||
mod grid;
|
||||
pub(crate) mod layout;
|
||||
mod offline;
|
||||
mod open;
|
||||
mod ratings_keywords;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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| {
|
||||
cells.iter().enumerate().map(|(i, c)| {
|
||||
(
|
||||
c.captured_at.map(|t| {
|
||||
let (y, m, _, _) = civil_from_unix(t);
|
||||
(y, m)
|
||||
})
|
||||
}),
|
||||
offset,
|
||||
layout.slot_of(offset + i),
|
||||
)
|
||||
}),
|
||||
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
|
||||
|
||||
@@ -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> {
|
||||
let _ = (width, height);
|
||||
vec![ThumbSize::Grid, ThumbSize::Large]
|
||||
let aspect = width as f32 / height.max(1) as f32;
|
||||
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
|
||||
@@ -190,6 +196,12 @@ pub fn render(
|
||||
mod tests {
|
||||
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]
|
||||
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
|
||||
@@ -286,7 +298,8 @@ mod tests {
|
||||
eprintln!("no GPU; skipping");
|
||||
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 profile = profile(white);
|
||||
let header = dr_export::SourceMetadata {
|
||||
@@ -298,8 +311,8 @@ mod tests {
|
||||
let crop = dr_export::Rect {
|
||||
x: 40,
|
||||
y: 30,
|
||||
width: 2900,
|
||||
height: 700,
|
||||
width: 4500,
|
||||
height: 1080,
|
||||
};
|
||||
|
||||
// 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();
|
||||
|
||||
// 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) {
|
||||
let rows = rows_per_strip.min(height - first);
|
||||
let from = (first * width * 3) as usize;
|
||||
@@ -345,7 +359,7 @@ mod tests {
|
||||
&header,
|
||||
Some(crop),
|
||||
decoder,
|
||||
&[ThumbSize::Grid, ThumbSize::Large],
|
||||
&[ThumbSize::Grid, ThumbSize::Large, ThumbSize::Wide],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -1463,6 +1463,7 @@ in property <bool> panel-visible: true;
|
||||
expanded: root.expanded;
|
||||
cells: Library.library-cells;
|
||||
total: Library.library-total;
|
||||
total-slots: Library.library-total-slots;
|
||||
scanning: Library.library-scanning;
|
||||
opening: Library.library-opening;
|
||||
scan-status: Library.library-status;
|
||||
@@ -1584,9 +1585,13 @@ in property <bool> panel-visible: true;
|
||||
assign-keyword(word) => { Library.library-assign-keyword(word); }
|
||||
unassign-keyword(word) => { Library.library-unassign-keyword(word); }
|
||||
cursor: Library.library-cursor;
|
||||
cursor-slot: Library.library-cursor-slot;
|
||||
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(); }
|
||||
drag-image: Library.library-drag-image;
|
||||
drag-payload() => { return Library.library-drag-payload(); }
|
||||
|
||||
+52
-17
@@ -483,6 +483,13 @@ export struct LibraryCell {
|
||||
// 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.
|
||||
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
|
||||
@@ -535,8 +542,14 @@ export global Library {
|
||||
callback library-pinch-cells(float);
|
||||
/// A pinch on the grid has begun, so the press that opened it was not one.
|
||||
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-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
|
||||
/// 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
|
||||
@@ -559,6 +572,7 @@ export global Library {
|
||||
/// what the roll happens to hold.
|
||||
callback library-roll-step(int);
|
||||
callback library-columns-changed();
|
||||
/// The first visible row's first slot.
|
||||
callback library-scrolled(int);
|
||||
/// How many cells the grid's viewport shows at once. Rust sizes the
|
||||
/// 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
|
||||
// moving it may have to swap the loaded window underneath.
|
||||
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);
|
||||
/// 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();
|
||||
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||||
/// 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 <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;
|
||||
/// The catalog on this device is being opened and checked before the scan
|
||||
/// 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
|
||||
// teleporting to image zero.
|
||||
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
|
||||
/// 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
|
||||
/// neither how many images there are nor where the loaded window starts.
|
||||
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
|
||||
/// are worth having: a cull is walk, judge, open, back, without the hand
|
||||
/// 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
|
||||
// its first cell fell, mid-row included.
|
||||
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 -----------------------
|
||||
//
|
||||
@@ -2276,7 +2308,8 @@ export component LibraryGrid inherits Rectangle {
|
||||
property <int> viewport-cells: root.columns * (root.visible-rows + 1);
|
||||
changed viewport-cells => { root.viewport-cells-changed(root.viewport-cells); }
|
||||
/// 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;
|
||||
|
||||
@@ -3437,21 +3470,19 @@ export component LibraryGrid inherits Rectangle {
|
||||
return accept;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (Keys.chord(event) == "PageUp" || Keys.chord(event) == "Shift+PageUp") {
|
||||
root.move-cursor(-root.columns * root.visible-rows,
|
||||
event.modifiers.shift);
|
||||
root.move-cursor-rows(-root.visible-rows, event.modifiers.shift);
|
||||
return accept;
|
||||
}
|
||||
if (Keys.chord(event) == "PageDown" || Keys.chord(event) == "Shift+PageDown") {
|
||||
root.move-cursor(root.columns * root.visible-rows,
|
||||
event.modifiers.shift);
|
||||
root.move-cursor-rows(root.visible-rows, event.modifiers.shift);
|
||||
return accept;
|
||||
}
|
||||
// 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
|
||||
// brings in exactly its own row.
|
||||
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(); }
|
||||
|
||||
function reveal() {
|
||||
@@ -3722,7 +3753,7 @@ export component LibraryGrid inherits Rectangle {
|
||||
// Sits in the gap above its row, so it labels the row
|
||||
// rather than displacing it.
|
||||
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;
|
||||
width: parent.width - 2 * Theme.gap;
|
||||
text: cell.period-heading;
|
||||
@@ -3756,10 +3787,12 @@ export component LibraryGrid inherits Rectangle {
|
||||
// Cells are positioned at their **absolute** place in the
|
||||
// library, not their index in the loaded window: the window
|
||||
// starts at `offset`, so a cell drawn at window-index 0 belongs
|
||||
// wherever `offset` sits in the full grid.
|
||||
x: Theme.gap + mod(i + root.offset, root.columns) * (root.cell-size + Theme.gap);
|
||||
y: Theme.gap + floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap);
|
||||
width: root.cell-size;
|
||||
// wherever `offset` sits in the full grid. That place is its
|
||||
// slot, which Rust computes (FR-MRG-6): a panorama before it
|
||||
// pushes it along, and a panorama itself spans `span` slots.
|
||||
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;
|
||||
|
||||
// Copy, not move: dropping into a collection files the
|
||||
@@ -4433,12 +4466,14 @@ export component LibraryGrid inherits Rectangle {
|
||||
|
||||
visible: root.held-row >= 0;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
animate x, y, width, height { duration: 120ms; easing: ease-out; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user