diff --git a/crates/pdfs-gui/resources/style.css b/crates/pdfs-gui/resources/style.css index 4cb7ad7..db938e7 100644 --- a/crates/pdfs-gui/resources/style.css +++ b/crates/pdfs-gui/resources/style.css @@ -58,8 +58,8 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre .viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); } /* Photos grid */ -.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 4px 2px 8px 2px; } -.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; background: none; box-shadow: none; transition: box-shadow 180ms ease; } +.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 2px 2px 6px 2px; } +.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; transition: box-shadow 180ms ease; } .photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); } .photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; } .photo-thumb { transition: transform 220ms ease; } @@ -71,6 +71,7 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre .photo-caption { font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; } .photo-tile:hover .photo-caption { opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); } .photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; } +.photo-video-badge-small { padding: 3px; min-width: 0; min-height: 0; } .photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); } .photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); } .album-card { padding: 0; border-radius: 16px; transition: background 180ms ease; } diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 7090b41..6f1c80b 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -301,10 +301,14 @@ fn load_proton_theme() { }; let provider = gtk4::CssProvider::new(); provider.load_from_resource("/de/nils/protondrivelinux/style.css"); + // Above the user's own gtk.css, which often imports a whole theme: its + // `button { padding: 6px 10px; min-width: 16px }` would otherwise pad every + // photo tile and push the grid past the window. Every rule in the sheet is + // scoped to the app's own classes, so nothing else the user styled changes. gtk4::style_context_add_provider_for_display( &display, &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + gtk4::STYLE_PROVIDER_PRIORITY_USER + 1, ); gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons"); } @@ -522,10 +526,8 @@ fn build_window(app: &adw::Application) { model: gallery_widgets.model.clone(), groups: gallery_widgets.groups.clone(), row_height: Cell::new(ROW_DEFAULT), - learned_ratios: RefCell::new(HashMap::new()), scrolling_down: Cell::new(true), scroll_offset: Cell::new(0.0), - assumed_ratios: RefCell::new(HashSet::new()), content: gallery_widgets.content.clone(), status: gallery_widgets.status.clone(), retry: gallery_widgets.retry.clone(), @@ -1722,88 +1724,6 @@ mod tests { } /// The full width a row occupies, gaps included. - fn row_width(widths: &[i32]) -> i32 { - widths.iter().sum::() + TILE_GAP * (widths.len() as i32 - 1) - } - - /// Ratios of a typical mixed day: landscape phone shots, a portrait, a - /// square crop. - fn mixed() -> Vec { - vec![ - 1.5, 1.5, 0.75, 1.0, 1.33, 1.5, 0.75, 1.78, 1.0, 1.5, 1.5, 1.33, - ] - } - - #[test] - fn every_full_row_spans_the_content_width() { - // The point of justifying: no ragged right margin at any width, at any - // zoom, for any mix of shapes. - for width in [640, 900, 1000, 1440, 1920, 2560] { - let rows = plan_rows(&mixed(), width, ROW_DEFAULT); - // The last row is deliberately short, so it is not part of this. - for (_, widths) in rows.iter().take(rows.len() - 1) { - assert_eq!(row_width(widths), width, "row does not span {width}px"); - } - } - } - - #[test] - fn a_tile_keeps_its_photos_shape() { - // Each tile is its photo's ratio at the row's height, which is what - // makes cropping unnecessary. - let ratios = mixed(); - let rows = plan_rows(&ratios, 1440, ROW_DEFAULT); - let (height, widths) = &rows[0]; - for (width, ratio) in widths.iter().zip(&ratios).take(widths.len() - 1) { - let laid_out = *width as f64 / *height as f64; - assert!((laid_out - ratio).abs() < 0.02, "{laid_out} is not {ratio}"); - } - } - - #[test] - fn rows_land_near_the_target_height() { - // A row is closed as soon as it no longer fits at the target, so it is - // never taller than the target and never far below it. - for target in [ROW_MIN, ROW_DEFAULT, ROW_MAX] { - let rows = plan_rows(&mixed(), 1600, target); - for (height, _) in rows.iter().take(rows.len() - 1) { - assert!( - *height <= target, - "{height}px exceeds the {target}px target" - ); - assert!(*height > target / 2, "{height}px is far under {target}px"); - } - } - } - - #[test] - fn a_short_day_is_not_stretched_across_the_window() { - // Two photos are two photos, not two half-window tiles. - let rows = plan_rows(&[1.5, 1.5], 1920, ROW_DEFAULT); - assert_eq!(rows.len(), 1); - let (height, widths) = &rows[0]; - assert_eq!(*height, ROW_DEFAULT); - assert!(row_width(widths) < 1920 / 2); - } - - #[test] - fn zooming_in_puts_fewer_photos_in_a_row() { - let small = plan_rows(&mixed(), 1200, ROW_MIN); - let big = plan_rows(&mixed(), 1200, ROW_MAX); - assert!(small[0].1.len() > big[0].1.len()); - } - - #[test] - fn every_photo_is_laid_out_exactly_once() { - let ratios = mixed(); - let placed: usize = plan_rows(&ratios, 1000, ROW_DEFAULT) - .iter() - .map(|(_, widths)| widths.len()) - .sum(); - assert_eq!(placed, ratios.len()); - assert!(plan_rows(&[], 1000, ROW_DEFAULT).is_empty()); - } - /// One file of a shot, for the group-switch tests. fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem { PhotoItem { @@ -1856,14 +1776,4 @@ mod tests { "the video" ); } - - #[test] - fn a_window_narrower_than_one_photo_still_lays_it_out() { - // A tile of at least one px, rather than a zero-width widget or a - // division by zero. - for width in [0, 40] { - let rows = plan_rows(&[1.5, 1.5, 1.5], width, ROW_DEFAULT); - assert!(rows.iter().all(|(h, w)| *h > 0 && w.iter().all(|w| *w > 0))); - } - } } diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 4176319..985e51f 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -17,15 +17,6 @@ pub(crate) struct GalleryState { /// ratios and then scaled to the content width, so this is the height a row /// lands near rather than the height it gets (see [`justify_rows`]). pub(crate) row_height: Cell, - /// Aspect ratios learned by decoding a thumbnail, for photos the daemon had - /// not recorded one for. Consulted by the layout ahead of - /// [`PhotoItem::ratio`]: a row laid out at the assumed 1.0 re-flows once the - /// real shape is known. - pub(crate) learned_ratios: RefCell>, - /// Photos the layout had to guess a ratio for. Decoding one of these is what - /// makes a re-flow worth doing; decoding a photo whose ratio was already - /// known would re-flow the timeline for no visible change. - pub(crate) assumed_ratios: RefCell>, /// Swaps the Photos content area between the timeline, its status page, and /// the Albums grid. pub(crate) content: gtk4::Stack, @@ -196,10 +187,10 @@ pub(crate) const JUMP_PAGE: usize = 1000; /// How long the scrubber has to rest on a month before the timeline jumps. const SCRUB_DEBOUNCE: Duration = Duration::from_millis(150); -/// Gallery row height in px: the zoom range, its default, and the step one -/// Ctrl+scroll notch (or Ctrl+±) moves it by. A justified row is scaled to the -/// content width once it is full, so this is the *target* a row lands near -/// rather than the height it ends up with (see [`justify_rows`]). +/// Gallery tile size in px: the zoom range, its default, and the step one +/// Ctrl+scroll notch (or Ctrl+±) moves it by. The columns are stretched to +/// span the content width, so this is the *target* a tile lands near rather +/// than the size it ends up with (see [`plan_grid`]). pub(crate) const ROW_MIN: i32 = 60; pub(crate) const ROW_MAX: i32 = 340; @@ -208,10 +199,11 @@ pub(crate) const ROW_DEFAULT: i32 = 180; pub(crate) const ROW_STEP: i32 = 30; -/// Row heights at which the timeline stops heading each day and heads each -/// month instead, then each year. Zoomed out, a library is a wall of small -/// tiles, and a heading per day would take more room than the photos under it. -pub(crate) const GROUP_BY_MONTH_BELOW: i32 = 150; +/// Tile sizes at which the timeline stops heading each day and heads each +/// month instead, then each year. Only big tiles get a heading per day: a day +/// rarely holds a row of photos, and a heading plus a short row per day would +/// leave most of a wide window empty. +pub(crate) const GROUP_BY_MONTH_BELOW: i32 = 240; pub(crate) const GROUP_BY_YEAR_BELOW: i32 = 90; @@ -224,7 +216,7 @@ pub(crate) enum Grouping { Year, } -/// The grouping that suits a target row height. +/// The grouping that suits a target tile size. pub(crate) fn grouping_for(row_height: i32) -> Grouping { if row_height < GROUP_BY_YEAR_BELOW { Grouping::Year @@ -239,19 +231,6 @@ pub(crate) fn grouping_for(row_height: i32) -> Grouping { /// should read as a sheet of photographs, not as a deck of cards. pub(crate) const TILE_GAP: i32 = 2; -/// The aspect ratios the layout will lay out. A 12:1 panorama laid out honestly -/// is a row of one photo two hundred px tall, and a scan of a strip of film is -/// worse; clamping keeps one odd frame from deciding what a whole row looks -/// like, at the cost of cropping that frame's extremes in its tile. -pub(crate) const RATIO_MIN: f64 = 0.4; - -pub(crate) const RATIO_MAX: f64 = 3.0; - -/// The ratio a photo gets before anything has seen its pixels. Square is the -/// least wrong guess: it is between the two orientations, so the re-flow when -/// the real ratio lands moves the row as little as possible. -pub(crate) const RATIO_UNKNOWN: f64 = 1.0; - /// How many thumbnails one on-demand [`Request::PhotoThumbs`] batch asks for. /// Small, so the first tiles on screen fill in quickly rather than the whole /// page landing at once. @@ -481,18 +460,19 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { .halign(gtk4::Align::End) .margin_top(12) .margin_bottom(12) - // Clear of the overlay scrollbar, which widens under the pointer. - .margin_end(18) + .margin_start(6) .tooltip_text(gettext("Jump to a month")) .visible(false) .build(); scrubber.add_css_class("photo-scrubber"); // Horizontal scrolling is never wanted: the grid is sized to the viewport - // width, and a stray hscrollbar would fight the layout. + // width. External rather than Never, which would make the widest row the + // window's minimum width, so a row laid out a pixel too wide would push the + // window wider, and the next layout wider still. let scroll = gtk4::ScrolledWindow::builder() .vexpand(true) - .hscrollbar_policy(gtk4::PolicyType::Never) + .hscrollbar_policy(gtk4::PolicyType::External) .child(&list) .build(); @@ -662,9 +642,13 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { // The timeline (plus its pager) or the status page, never both. let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12); timeline.append(&select_bar); - let timeline_overlay = gtk4::Overlay::builder().child(&scroll).build(); - timeline_overlay.add_overlay(&scrubber); - timeline.append(&timeline_overlay); + // The scrubber sits beside the list rather than over it, so the grid is + // laid out to the width it really has and no tile hides under a year mark. + let timeline_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + scroll.set_hexpand(true); + timeline_row.append(&scroll); + timeline_row.append(&scrubber); + timeline.append(&timeline_row); timeline.append(&pager); // The album grid: cover-first cards that flow to the width they are given. @@ -933,8 +917,8 @@ pub(crate) fn wire_gallery( } match &*row { GalleryRow::Heading(heading) => { - row_box.set_margin_top(10); - row_box.set_margin_bottom(4); + row_box.set_margin_top(8); + row_box.set_margin_bottom(0); let label = gtk4::Label::builder() .label(heading) .halign(gtk4::Align::Start) @@ -962,36 +946,55 @@ pub(crate) fn wire_gallery( // ListView recycles row widgets, so a scrolled-away row must give up its // claim on them — otherwise a thumbnail landing late would paint into a tile - // that now shows a different photo. + // that now shows a different photo. Only claims this row widget still + // holds are given up: after a re-flow, the same photo (and the same + // position) is often bound again in another row before this one unbinds. let ui_unbind = ui.clone(); factory.connect_unbind(move |_, item| { let item = item.downcast_ref::().unwrap(); - ui_unbind - .gallery - .bound - .borrow_mut() - .remove(&item.position()); + let Some(row_box) = item.child() else { + return; + }; + { + let mut bound = ui_unbind.gallery.bound.borrow_mut(); + if bound + .get(&item.position()) + .is_some_and(|(_, row)| row.upcast_ref::() == &row_box) + { + bound.remove(&item.position()); + } + } if let Some(obj) = item.item().and_downcast::() && let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::() { let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut(); for tile in tiles { - wanted.remove(&tile.photo.uid); + if wanted + .get(&tile.photo.uid) + .is_some_and(|picture| picture.is_ancestor(&row_box)) + { + wanted.remove(&tile.photo.uid); + } } } }); list.set_factory(Some(&factory)); // The grid divides the content width, so a resize re-flows whatever is on - // screen (offscreen sections pick the new width up when they bind). + // screen (offscreen sections pick the new width up when they bind). The + // width is the viewport's, read from the horizontal adjustment's page + // size: the list's own width follows its widest row, so laying rows out to + // it would feed each layout's rounding into the next, wider one. let ui_width = ui.clone(); - list.connect_notify_local(Some("width"), move |list, _| { - let width = list.width(); - if width > 0 && width != ui_width.gallery.width.get() { - ui_width.gallery.width.set(width); - schedule_relayout(&ui_width); - } - }); + scroll + .hadjustment() + .connect_page_size_notify(move |adjustment| { + let width = adjustment.page_size() as i32; + if width > 0 && width != ui_width.gallery.width.get() { + ui_width.gallery.width.set(width); + schedule_relayout(&ui_width); + } + }); // Page the timeline in while the user is still a screen and a half from the // end, so scrolling at a normal pace never reaches it. @@ -1256,119 +1259,50 @@ pub(crate) struct Tile { pub(crate) grouping: Grouping, } -/// The aspect ratio to lay `photo` out at: what a decode has learned, else what -/// the daemon remembered, else square. -fn tile_ratio(ui: &Rc, photo: &PhotoItem) -> f64 { - let known = ui - .gallery - .learned_ratios - .borrow() - .get(&photo.uid) - .copied() - .or(photo.ratio) - .filter(|ratio| ratio.is_finite() && *ratio > 0.0); - match known { - Some(ratio) => ratio.clamp(RATIO_MIN, RATIO_MAX), - None => { - ui.gallery - .assumed_ratios - .borrow_mut() - .insert(photo.uid.clone()); - RATIO_UNKNOWN - } - } -} - -/// Break one day's photos into justified rows spanning `width` — each photo at -/// its own aspect ratio, each row scaled so it ends exactly on the right margin. +/// Break one section's photos into rows of square tiles spanning `width`. /// -/// This is what removes the black bars: a square tile had to either crop the -/// photo or letterbox it, and with a box of the right shape there is nothing to -/// do either way. +/// Squares, because a wide window and a day of a few photos do not mix with +/// rows justified to each photo's shape: a short row stays short, and portrait +/// phone shots come out as thin slivers. Every tile of a square grid is the +/// same size whatever it shows, and the thumbnail is cropped to fill it. pub(crate) fn justify_rows(ui: &Rc, photos: &[PhotoItem], width: i32) -> Vec> { - let ratios: Vec = photos.iter().map(|photo| tile_ratio(ui, photo)).collect(); let selecting = ui.gallery.selecting.get(); let selected = ui.gallery.selected.borrow(); - let grouping = grouping_for(ui.gallery.row_height.get()); - let mut photos = photos.iter(); - plan_rows(&ratios, width, ui.gallery.row_height.get()) - .into_iter() - .map(|(height, widths)| { - widths - .into_iter() - .filter_map(|width| { - photos.next().map(|photo| Tile { - selecting, - selected: selecting && selected.contains(&photo.uid), - grouping, - photo: photo.clone(), - width, - height, - }) + let target = ui.gallery.row_height.get(); + let grouping = grouping_for(target); + let (columns, size) = plan_grid(width, target); + photos + .chunks(columns) + .map(|row| { + row.iter() + .map(|photo| Tile { + selecting, + selected: selecting && selected.contains(&photo.uid), + grouping, + photo: photo.clone(), + width: size, + height: size, }) .collect() }) .collect() } -/// The layout math, over aspect ratios alone: each row's height, and the width -/// of every tile in it. +/// The layout math: how many square tiles of about `target` px fit across +/// `width`, and the size that makes them span it exactly, gaps included. /// -/// Photos are taken in order until their summed ratio no longer leaves them -/// `target` px tall, and that row is then scaled to land on `width` exactly. The -/// *last* row of a day is left at the target height instead of being stretched: -/// a day holding two photos would otherwise be two enormous tiles, which reads -/// as a layout bug rather than as a short day. -pub(crate) fn plan_rows(ratios: &[f64], width: i32, target: i32) -> Vec<(i32, Vec)> { - let width = width.max(ROW_MIN); +/// The count is rounded to the nearest whole column, so a tile never ends up +/// more than half a column off the zoom level asked for. +pub(crate) fn plan_grid(width: i32, target: i32) -> (usize, i32) { + let width = width.max(1); let target = target.clamp(ROW_MIN, ROW_MAX); - let mut rows = Vec::new(); - let mut start = 0; - let mut sum = 0.0; - - for (index, ratio) in ratios.iter().enumerate() { - sum += ratio; - let count = (index + 1 - start) as i32; - // The gaps are fixed, so only the pixels left over from them scale. - let usable = (width - TILE_GAP * (count - 1)).max(1) as f64; - let height = usable / sum; - if height <= target as f64 { - let height = (height.round() as i32).max(1); - rows.push((height, fit_row(&ratios[start..=index], width, height))); - start = index + 1; - sum = 0.0; - } - } - if start < ratios.len() { - let tail = &ratios[start..]; - let widths = tail - .iter() - .map(|ratio| ((ratio * target as f64).round() as i32).max(1)) - .collect(); - rows.push((target, widths)); - } - rows + let columns = (f64::from(width + TILE_GAP) / f64::from(target + TILE_GAP)).round() as i32; + let columns = columns.max(1); + let size = ((width - TILE_GAP * (columns - 1)) / columns).max(1); + (columns as usize, size) } -/// The tile widths of one full row at `height`, adjusted so the row spans -/// `width` to the pixel. -/// -/// Rounding each tile independently leaves a few px of slack, which at a tight -/// gap is visible as a ragged right margin; the last tile absorbs it. -pub(crate) fn fit_row(ratios: &[f64], width: i32, height: i32) -> Vec { - let mut widths: Vec = ratios - .iter() - .map(|ratio| ((ratio * height as f64).round() as i32).max(1)) - .collect(); - let gaps = TILE_GAP * (widths.len() as i32 - 1); - let used: i32 = widths.iter().sum::() + gaps; - if let Some(last) = widths.last_mut() { - *last = (*last + (width - used)).max(1); - } - widths -} - -/// The width the grid is laid out to: the ListView's own width, less a couple of +/// The width the grid is laid out to: the viewport's width, less a couple of /// px so a rounding error can't push a row into a horizontal overflow. /// Falls back to a sane guess before the first allocation. pub(crate) fn gallery_width(ui: &Rc) -> i32 { @@ -1388,12 +1322,10 @@ pub(crate) fn gallery_width(ui: &Rc) -> i32 { /// one keeps an image glyph instead of an empty rectangle. pub(crate) fn photo_tile(ui: &Rc, tile: Tile) -> gtk4::Button { let picture = gtk4::Picture::builder() - // The tile is already the photo's shape, so Contain shows the whole - // frame with nothing cropped and nothing letterboxed. Cover would still - // crop here: a thumbnail's ratio is not exactly the ratio the row was - // laid out at, and the rounding is enough to shave an edge. The expands - // are what make the picture take the whole overlay. - .content_fit(gtk4::ContentFit::Contain) + // Every tile is square, so Cover crops the photo to fill it. Contain + // would letterbox it, and the bars would read as gaps in the sheet. + // The expands are what make the picture take the whole overlay. + .content_fit(gtk4::ContentFit::Cover) .can_shrink(true) .hexpand(true) .vexpand(true) @@ -1435,23 +1367,30 @@ pub(crate) fn photo_tile(ui: &Rc, tile: Tile) -> gtk4::Button { // A video reads as a video at a glance: a play glyph centred over the poster // thumbnail. Kept above the caption scrim so it stays legible on hover. + // Zoomed out, the glyph shrinks with the tile so it marks the video without + // covering it. + let small = tile.grouping != Grouping::Day; let is_video = tile.photo.kind == PhotoKind::Video; if is_video { let badge = gtk4::Image::builder() .icon_name("media-playback-start-symbolic") - .pixel_size(28) + .pixel_size(if small { 12 } else { 28 }) .halign(gtk4::Align::Center) .valign(gtk4::Align::Center) .build(); badge.add_css_class("photo-video-badge"); + if small { + badge.add_css_class("photo-video-badge-small"); + } overlay.add_overlay(&badge); } // A shot stored as more than one file says so, in the corner the caption // does not use. "RAW" is the useful word when one of the members is a raw // file — that is what the person wants to find — and a plain count covers - // the rest (a live photo, a burst). - if tile.photo.has_raw || tile.photo.group_size > 1 { + // the rest (a live photo, a burst). Zoomed out to years the badge would + // cover most of a tile, so it is left off there. + if (tile.photo.has_raw || tile.photo.group_size > 1) && tile.grouping != Grouping::Year { let badge = gtk4::Label::builder() .label(if tile.photo.has_raw { // Translators: badge on a photo tile whose shot includes a raw camera file. @@ -2234,11 +2173,6 @@ pub(crate) fn schedule_decode(ui: &Rc) { continue; } }; - // A thumbnail is the first look anyone gets at the photo's shape. - // If the row was laid out without it, remember it and re-flow — the - // debounce means a batch of decodes costs one re-flow, not one - // each. - learn_ratio(&ui, &uid, &texture); ui.store_texture(&uid, texture.clone()); if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) { picture.set_paintable(Some(&texture)); @@ -2253,25 +2187,6 @@ pub(crate) fn schedule_decode(ui: &Rc) { }); } -/// Record the aspect ratio a decoded thumbnail proves, and re-flow if the layout -/// had been guessing. -/// -/// Only a photo the layout had to guess about is worth a re-flow: re-recording -/// what the daemon already told us would re-flow the timeline on every scroll. -fn learn_ratio(ui: &Rc, uid: &str, texture: >k4::gdk::Texture) { - let (width, height) = (texture.width(), texture.height()); - if width <= 0 || height <= 0 { - return; - } - ui.gallery - .learned_ratios - .borrow_mut() - .insert(uid.to_string(), width as f64 / height as f64); - if ui.gallery.assumed_ratios.borrow_mut().remove(uid) { - schedule_relayout(ui); - } -} - /// Re-flow the sections on screen shortly. Debounced, because the triggers (a /// window resize, a zoom step) arrive in floods and only the final state /// matters. @@ -2292,40 +2207,78 @@ pub(crate) fn schedule_relayout(ui: &Rc) { /// A different column count means different rows, so unlike the old /// section-per-day model there is nothing to patch in place — the row model is /// rebuilt. What that would cost the user is their scroll position, so the -/// topmost realised row's first photo is remembered and scrolled back to. +/// topmost visible row's first photo is remembered, and the row that holds it +/// afterwards is put back at the same height in the viewport. pub(crate) fn relayout_gallery(ui: &Rc) { let anchor = top_anchor(ui); repaint_gallery(ui); - let Some(anchor) = anchor else { return }; + let Some((anchor, offset)) = anchor else { + return; + }; let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else { return; }; + // Scrolling to a row only brings it into view, anywhere in it; the rows + // are laid out over the next frames, so the fine alignment follows them. ui.gallery .list .scroll_to(row, gtk4::ListScrollFlags::empty(), None); + align_row(ui, row, offset); } -/// The first photo of the topmost photo row that reaches into the viewport. +/// How many frames [`align_row`] keeps correcting for: rows above the anchor +/// that the list only estimated get measured as they are realised. +const ALIGN_FRAMES: u32 = 4; + +/// Scroll so the top of row `position` sits `offset` px below the top of the +/// viewport, re-checking for a few frames while the rows around it settle. +fn align_row(ui: &Rc, position: u32, offset: f32) { + let ui = ui.clone(); + let frames = Cell::new(0); + ui.gallery.list.clone().add_tick_callback(move |list, _| { + frames.set(frames.get() + 1); + let top = ui + .gallery + .bound + .borrow() + .get(&position) + .filter(|(_, row)| row.is_mapped()) + .and_then(|(_, row)| row.compute_bounds(list)) + .map(|rect| rect.y()); + if let (Some(top), Some(adjustment)) = (top, list.vadjustment()) { + let delta = f64::from(top - offset); + if delta.abs() >= 1.0 { + adjustment.set_value(adjustment.value() + delta); + } + } + if frames.get() >= ALIGN_FRAMES { + glib::ControlFlow::Break + } else { + glib::ControlFlow::Continue + } + }); +} + +/// The first photo of the topmost photo row that reaches into the viewport, +/// and how far below the top of the viewport that row starts (negative when it +/// is partly scrolled off). /// /// Not simply the first bound row: the ListView keeps rows bound well outside /// the viewport (unmapped, with their last allocation), and scrolling one of /// those "back" to the top is a jump. Falls back to the first bound photo row /// while the list isn't on screen. -fn top_anchor(ui: &Rc) -> Option { +fn top_anchor(ui: &Rc) -> Option<(String, f32)> { let list = &ui.gallery.list; let bound = ui.gallery.bound.borrow(); let mut photo_rows = bound .values() .filter_map(|(uid, row)| uid.as_ref().map(|uid| (uid, row))); - let first = photo_rows.clone().next().map(|(uid, _)| uid.clone()); + let first = photo_rows.clone().next().map(|(uid, _)| (uid.clone(), 0.0)); photo_rows - .find(|(_, row)| { - row.is_mapped() - && row - .compute_bounds(list) - .is_some_and(|rect| rect.y() + rect.height() > 0.0) + .find_map(|(uid, row)| { + let rect = row.compute_bounds(list)?; + (row.is_mapped() && rect.y() + rect.height() > 0.0).then(|| (uid.clone(), rect.y())) }) - .map(|(uid, _)| uid.clone()) .or(first) } @@ -2672,7 +2625,7 @@ fn sync_scrubber_position(ui: &Rc) { if ui.gallery.scrubbing.get() || !ui.gallery.scrubber.is_visible() { return; } - let Some(uid) = top_anchor(ui) else { + let Some((uid, _)) = top_anchor(ui) else { return; }; let Some(capture_time) = find_photo_index(&ui.gallery.model, &uid) @@ -3143,8 +3096,8 @@ pub(crate) fn play_external(path: &str) { #[cfg(test)] mod tests { use super::{ - Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, empty_timeline_text, grouping_for, - month_index, month_range, section_heading, + Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, empty_timeline_text, + grouping_for, month_index, month_range, plan_grid, section_heading, }; use pdfs_core::control::{PhotoKind, PhotoMonth}; @@ -3164,11 +3117,38 @@ mod tests { } #[test] - fn zooming_out_groups_by_month_then_year() { - assert_eq!(grouping_for(ROW_DEFAULT), Grouping::Day); + fn square_tiles_span_the_content_width() { + for width in [0, 40, 640, 900, 1440, 1920, 2560] { + for target in [ROW_MIN, ROW_DEFAULT, ROW_MAX] { + let (columns, size) = plan_grid(width, target); + let columns = columns as i32; + let row = columns * size + TILE_GAP * (columns - 1); + assert!(size > 0 && columns > 0); + assert!( + width < target || row <= width, + "{row}px overflows {width}px" + ); + assert!( + width < target || width - row < columns, + "{row}px leaves a margin in {width}px" + ); + } + } + } + + #[test] + fn zooming_in_puts_fewer_tiles_in_a_row() { + assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0); + assert!(plan_grid(1920, ROW_DEFAULT).0 > plan_grid(1920, ROW_MAX).0); + let (_, size) = plan_grid(1920, ROW_DEFAULT); + assert!((size - ROW_DEFAULT).abs() < ROW_DEFAULT / 2); + } + + #[test] + fn only_big_tiles_are_headed_by_day() { assert_eq!(grouping_for(ROW_MAX), Grouping::Day); - assert_eq!(grouping_for(ROW_DEFAULT - ROW_STEP), Grouping::Day); - assert_eq!(grouping_for(ROW_DEFAULT - 2 * ROW_STEP), Grouping::Month); + assert_eq!(grouping_for(ROW_DEFAULT + 2 * ROW_STEP), Grouping::Day); + assert_eq!(grouping_for(ROW_DEFAULT), Grouping::Month); assert_eq!(grouping_for(ROW_DEFAULT - 3 * ROW_STEP), Grouping::Month); assert_eq!(grouping_for(ROW_MIN), Grouping::Year); }