mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
feat(gui): justify the gallery rows and keep the thumbnails on screen
The Photos grid laid every photo out as a centre-cropped square. That is why portraits showed black bars and why a panorama was unrecognisable at tile size: the tile's shape had nothing to do with the photo's. Rows are now justified. Photos are taken in capture order until they no longer fit the target height, and the row is then scaled to land exactly on the right margin, so each tile is its own photo's shape and nothing is cropped or letterboxed. The last row of a day stays at the target height rather than being stretched — a day holding two photos should look like a short day, not like a layout bug. Ratios come from the daemon where it has one; where it has not, the tile is laid out square and the day re-flows once a decoded thumbnail proves the real shape, on the existing debounce so a screenful of decodes costs one re-flow. Extreme ratios are clamped, so one strip of film cannot decide what a row looks like. Ctrl+scroll and Ctrl+± now move the target row height, and the grid is tighter to match: 2 px gaps, smaller heading margins, a 6 px radius and no card behind each tile, so the page reads as photographs rather than as a deck of cards. Thumbnails also stopped disappearing on scroll. The texture cache was evicting in insertion order — which is precisely what a scroll back up asks for again — so it is now least-recently-used and holds 1500 rather than 600, which at the new density is several screens instead of under two. A batch tops itself up with the photos just past the rows on screen, in the direction of travel, and once the queue is short the near-miss tiles are fetched rather than dropped.
This commit is contained in:
1 parent
cf0d495f8c
commit
5a8e73657c
2 files changed
+405
-149
No files matched your search
@@ -162,6 +162,22 @@ impl Ui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark a cached thumbnail as freshly used, so eviction takes the tiles
|
||||||
|
/// nobody has looked at rather than the oldest ones.
|
||||||
|
///
|
||||||
|
/// Insertion order alone evicts the top of the timeline first — exactly what
|
||||||
|
/// a scroll back up then asks for again.
|
||||||
|
fn touch_texture(&self, uid: &str) {
|
||||||
|
let mut order = self.gallery.photo_tex_order.borrow_mut();
|
||||||
|
if order.back().is_some_and(|back| back == uid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(at) = order.iter().position(|held| held == uid) {
|
||||||
|
order.remove(at);
|
||||||
|
order.push_back(uid.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> glib::ExitCode {
|
fn main() -> glib::ExitCode {
|
||||||
@@ -237,7 +253,7 @@ fn load_proton_theme() {
|
|||||||
.viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\
|
.viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\
|
||||||
.viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\
|
.viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\
|
||||||
.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; }}\n\
|
.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; }}\n\
|
||||||
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 14px; background: alpha(currentColor, 0.06); box-shadow: none; transition: box-shadow 180ms ease, background 180ms ease; }}\n\
|
.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; }}\n\
|
||||||
.photo-tile:hover {{ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }}\n\
|
.photo-tile:hover {{ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }}\n\
|
||||||
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
|
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
|
||||||
.photo-thumb {{ transition: transform 220ms ease; }}\n\
|
.photo-thumb {{ transition: transform 220ms ease; }}\n\
|
||||||
@@ -443,7 +459,11 @@ fn build_window(app: &adw::Application) {
|
|||||||
gallery: GalleryState {
|
gallery: GalleryState {
|
||||||
model: gallery_widgets.model.clone(),
|
model: gallery_widgets.model.clone(),
|
||||||
groups: gallery_widgets.groups.clone(),
|
groups: gallery_widgets.groups.clone(),
|
||||||
tile: Cell::new(TILE_DEFAULT),
|
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(),
|
content: gallery_widgets.content.clone(),
|
||||||
status: gallery_widgets.status.clone(),
|
status: gallery_widgets.status.clone(),
|
||||||
retry: gallery_widgets.retry.clone(),
|
retry: gallery_widgets.retry.clone(),
|
||||||
@@ -1170,54 +1190,96 @@ fn human_bytes(bytes: u64) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// The full width one row of `columns` tiles occupies, gaps included.
|
/// The full width a row occupies, gaps included.
|
||||||
fn row_width(columns: usize, edge: i32) -> i32 {
|
fn row_width(widths: &[i32]) -> i32 {
|
||||||
edge * columns as i32 + TILE_GAP * (columns as i32 - 1)
|
widths.iter().sum::<i32>() + TILE_GAP * (widths.len() as i32 - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ratios of a typical mixed day: landscape phone shots, a portrait, a
|
||||||
|
/// square crop.
|
||||||
|
fn mixed() -> Vec<f64> {
|
||||||
|
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]
|
#[test]
|
||||||
fn the_grid_spans_the_content_width() {
|
fn every_full_row_spans_the_content_width() {
|
||||||
// Whatever the width, the columns plus their gaps land on it (bar the
|
// The point of justifying: no ragged right margin at any width, at any
|
||||||
// few px integer division cannot divide), so no ragged right margin.
|
// zoom, for any mix of shapes.
|
||||||
for width in [640, 900, 1000, 1440, 1920, 2560] {
|
for width in [640, 900, 1000, 1440, 1920, 2560] {
|
||||||
let (columns, edge) = plan_grid(TILE_DEFAULT, width);
|
let rows = plan_rows(&mixed(), width, ROW_DEFAULT);
|
||||||
let used = row_width(columns, edge);
|
// The last row is deliberately short, so it is not part of this.
|
||||||
assert!(
|
for (_, widths) in rows.iter().take(rows.len() - 1) {
|
||||||
used <= width && width - used < columns as i32,
|
assert_eq!(row_width(widths), width, "row does not span {width}px");
|
||||||
"{columns} x {edge}px = {used}px does not span {width}px"
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tiles_stay_near_the_target_size() {
|
fn a_tile_keeps_its_photos_shape() {
|
||||||
// The column count rounds and the edge absorbs the remainder, so a tile
|
// Each tile is its photo's ratio at the row's height, which is what
|
||||||
// is never more than one gap-and-a-bit away from what was asked for.
|
// makes cropping unnecessary.
|
||||||
let (columns, edge) = plan_grid(TILE_DEFAULT, 1920);
|
let ratios = mixed();
|
||||||
assert!(columns >= 7, "1920px should hold several 220px tiles");
|
let rows = plan_rows(&ratios, 1440, ROW_DEFAULT);
|
||||||
assert!(
|
let (height, widths) = &rows[0];
|
||||||
(edge - TILE_DEFAULT).abs() < TILE_DEFAULT / 3,
|
for (width, ratio) in widths.iter().zip(&ratios).take(widths.len() - 1) {
|
||||||
"{edge}px is not near the {TILE_DEFAULT}px target"
|
let laid_out = *width as f64 / *height as f64;
|
||||||
);
|
assert!((laid_out - ratio).abs() < 0.02, "{laid_out} is not {ratio}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zooming_in_widens_the_tiles_and_drops_columns() {
|
fn rows_land_near_the_target_height() {
|
||||||
let (wide_columns, small_edge) = plan_grid(TILE_MIN, 1200);
|
// A row is closed as soon as it no longer fits at the target, so it is
|
||||||
let (few_columns, big_edge) = plan_grid(TILE_MAX, 1200);
|
// never taller than the target and never far below it.
|
||||||
assert!(wide_columns > few_columns);
|
for target in [ROW_MIN, ROW_DEFAULT, ROW_MAX] {
|
||||||
assert!(big_edge > small_edge);
|
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]
|
#[test]
|
||||||
fn a_window_narrower_than_one_tile_still_gets_a_column() {
|
fn a_short_day_is_not_stretched_across_the_window() {
|
||||||
// A single column at whatever fits, rather than zero columns (which would
|
// Two photos are two photos, not two half-window tiles.
|
||||||
// divide by zero) or a zero-px tile.
|
let rows = plan_rows(&[1.5, 1.5], 1920, ROW_DEFAULT);
|
||||||
let (columns, edge) = plan_grid(TILE_DEFAULT, 40);
|
assert_eq!(rows.len(), 1);
|
||||||
assert_eq!(columns, 1);
|
let (height, widths) = &rows[0];
|
||||||
assert!(edge > 0);
|
assert_eq!(*height, ROW_DEFAULT);
|
||||||
let (columns, edge) = plan_grid(TILE_DEFAULT, 0);
|
assert!(row_width(widths) < 1920 / 2);
|
||||||
assert_eq!(columns, 1);
|
}
|
||||||
assert!(edge > 0);
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,10 +12,20 @@ pub(crate) struct GalleryState {
|
|||||||
/// 1,600 photos (a Takeout import lands one) would otherwise be 1,600
|
/// 1,600 photos (a Takeout import lands one) would otherwise be 1,600
|
||||||
/// widgets built in a single bind.
|
/// widgets built in a single bind.
|
||||||
pub(crate) groups: gio::ListStore,
|
pub(crate) groups: gio::ListStore,
|
||||||
/// Target tile edge in px, retuned by Ctrl+scroll / Ctrl+± (see
|
/// Target row height in px, retuned by Ctrl+scroll / Ctrl+± (see
|
||||||
/// [`zoom_gallery`]). The grid fits as many square tiles of about this size
|
/// [`zoom_gallery`]). Each row is filled with photos at their own aspect
|
||||||
/// as the content width holds.
|
/// ratios and then scaled to the content width, so this is the height a row
|
||||||
pub(crate) tile: Cell<i32>,
|
/// lands near rather than the height it gets (see [`justify_rows`]).
|
||||||
|
pub(crate) row_height: Cell<i32>,
|
||||||
|
/// 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<HashMap<String, f64>>,
|
||||||
|
/// 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<HashSet<String>>,
|
||||||
/// Swaps the Photos content area between the timeline, its status page, and
|
/// Swaps the Photos content area between the timeline, its status page, and
|
||||||
/// the Albums grid.
|
/// the Albums grid.
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
@@ -102,6 +112,12 @@ pub(crate) struct GalleryState {
|
|||||||
/// batch is already in flight (only one at a time, so a long scroll can't
|
/// batch is already in flight (only one at a time, so a long scroll can't
|
||||||
/// stack requests on the daemon).
|
/// stack requests on the daemon).
|
||||||
pub(crate) thumb_queue: RefCell<VecDeque<String>>,
|
pub(crate) thumb_queue: RefCell<VecDeque<String>>,
|
||||||
|
/// Which way the timeline was last scrolled: `true` for downwards, the
|
||||||
|
/// direction [`prefetch_thumbs`] warms tiles in. Photos are read newest
|
||||||
|
/// first, so downwards is the common case and the default.
|
||||||
|
pub(crate) scrolling_down: Cell<bool>,
|
||||||
|
/// The scroll offset the direction was last decided at.
|
||||||
|
pub(crate) scroll_offset: Cell<f64>,
|
||||||
pub(crate) thumb_inflight: Cell<bool>,
|
pub(crate) thumb_inflight: Cell<bool>,
|
||||||
/// Thumbnails on disk waiting to be turned into textures, as `(uid, path)`.
|
/// Thumbnails on disk waiting to be turned into textures, as `(uid, path)`.
|
||||||
/// Decoding happens on the GTK thread (textures are not `Send`), so it is fed
|
/// Decoding happens on the GTK thread (textures are not `Send`), so it is fed
|
||||||
@@ -127,20 +143,34 @@ pub(crate) struct GalleryState {
|
|||||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||||
pub(crate) const PHOTOS_PAGE: usize = 60;
|
pub(crate) const PHOTOS_PAGE: usize = 60;
|
||||||
|
|
||||||
/// Gallery tile edge in px: the zoom range, its default, and the step one
|
/// Gallery row height in px: the zoom range, its default, and the step one
|
||||||
/// Ctrl+scroll notch (or Ctrl+±) moves it by. The grid divides the content width
|
/// Ctrl+scroll notch (or Ctrl+±) moves it by. A justified row is scaled to the
|
||||||
/// evenly, so this is the *target* a tile lands near rather than its exact size
|
/// content width once it is full, so this is the *target* a row lands near
|
||||||
/// (see [`plan_grid`]).
|
/// rather than the height it ends up with (see [`justify_rows`]).
|
||||||
pub(crate) const TILE_MIN: i32 = 90;
|
pub(crate) const ROW_MIN: i32 = 90;
|
||||||
|
|
||||||
pub(crate) const TILE_MAX: i32 = 340;
|
pub(crate) const ROW_MAX: i32 = 340;
|
||||||
|
|
||||||
pub(crate) const TILE_DEFAULT: i32 = 180;
|
pub(crate) const ROW_DEFAULT: i32 = 180;
|
||||||
|
|
||||||
pub(crate) const TILE_STEP: i32 = 30;
|
pub(crate) const ROW_STEP: i32 = 30;
|
||||||
|
|
||||||
/// Gap between tiles, horizontally and vertically.
|
/// Gap between tiles, horizontally and vertically. Tight on purpose: the grid
|
||||||
pub(crate) const TILE_GAP: i32 = 8;
|
/// 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.
|
/// 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
|
/// Small, so the first tiles on screen fill in quickly rather than the whole
|
||||||
@@ -156,9 +186,15 @@ pub(crate) const THUMB_DEBOUNCE: Duration = Duration::from_millis(60);
|
|||||||
/// in seconds, not milliseconds.
|
/// in seconds, not milliseconds.
|
||||||
pub(crate) const THUMB_RETRY: Duration = Duration::from_secs(4);
|
pub(crate) const THUMB_RETRY: Duration = Duration::from_secs(4);
|
||||||
|
|
||||||
/// Decoded thumbnails held in memory. Each is a few hundred KiB of GPU texture;
|
/// Decoded thumbnails held in memory, evicted least-recently-*used* first. Each
|
||||||
/// this caps the gallery's footprint while covering several screens of scroll.
|
/// is a few hundred KiB of GPU texture, and at the justified row density 600 was
|
||||||
pub(crate) const TEXTURE_CACHE_MAX: usize = 600;
|
/// under two screens' worth — a scroll down and back up then had to re-fetch and
|
||||||
|
/// re-decode everything it had just shown.
|
||||||
|
pub(crate) const TEXTURE_CACHE_MAX: usize = 1500;
|
||||||
|
|
||||||
|
/// How many tiles beyond the realised rows [`prefetch_thumbs`] warms, so a tile
|
||||||
|
/// is decoded before it is scrolled onto rather than after.
|
||||||
|
pub(crate) const THUMB_PREFETCH: usize = 32;
|
||||||
|
|
||||||
/// Pause after a resize/zoom before the visible sections are re-flowed.
|
/// Pause after a resize/zoom before the visible sections are re-flowed.
|
||||||
pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80);
|
pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80);
|
||||||
@@ -182,10 +218,9 @@ pub(crate) struct PhotoGroup {
|
|||||||
pub(crate) enum GalleryRow {
|
pub(crate) enum GalleryRow {
|
||||||
/// A day heading: "Today", "3 June 2026".
|
/// A day heading: "Today", "3 June 2026".
|
||||||
Heading(String),
|
Heading(String),
|
||||||
/// One row of a day's grid, already laid out to the current width and zoom.
|
/// One row of a day's grid, already justified to the current width and zoom.
|
||||||
Tiles {
|
Tiles {
|
||||||
photos: Vec<PhotoItem>,
|
tiles: Vec<Tile>,
|
||||||
edge: i32,
|
|
||||||
/// True for the last row of its day, which carries the section's bottom
|
/// True for the last row of its day, which carries the section's bottom
|
||||||
/// margin so days stay visually separated.
|
/// margin so days stay visually separated.
|
||||||
last: bool,
|
last: bool,
|
||||||
@@ -198,7 +233,7 @@ impl GalleryRow {
|
|||||||
fn anchor(&self) -> Option<String> {
|
fn anchor(&self) -> Option<String> {
|
||||||
match self {
|
match self {
|
||||||
GalleryRow::Heading(_) => None,
|
GalleryRow::Heading(_) => None,
|
||||||
GalleryRow::Tiles { photos, .. } => photos.first().map(|p| p.uid.clone()),
|
GalleryRow::Tiles { tiles, .. } => tiles.first().map(|t| t.photo.uid.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,21 +244,14 @@ impl GalleryRow {
|
|||||||
match (self, other) {
|
match (self, other) {
|
||||||
(GalleryRow::Heading(a), GalleryRow::Heading(b)) => a == b,
|
(GalleryRow::Heading(a), GalleryRow::Heading(b)) => a == b,
|
||||||
(
|
(
|
||||||
GalleryRow::Tiles {
|
GalleryRow::Tiles { tiles: a, last: al },
|
||||||
photos: a,
|
GalleryRow::Tiles { tiles: b, last: bl },
|
||||||
edge: ae,
|
|
||||||
last: al,
|
|
||||||
},
|
|
||||||
GalleryRow::Tiles {
|
|
||||||
photos: b,
|
|
||||||
edge: be,
|
|
||||||
last: bl,
|
|
||||||
},
|
|
||||||
) => {
|
) => {
|
||||||
ae == be
|
al == bl
|
||||||
&& al == bl
|
|
||||||
&& a.len() == b.len()
|
&& a.len() == b.len()
|
||||||
&& a.iter().zip(b).all(|(x, y)| x.uid == y.uid)
|
&& a.iter().zip(b).all(|(x, y)| {
|
||||||
|
x.photo.uid == y.photo.uid && x.width == y.width && x.height == y.height
|
||||||
|
})
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
@@ -698,8 +726,8 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
}
|
}
|
||||||
match &*row {
|
match &*row {
|
||||||
GalleryRow::Heading(heading) => {
|
GalleryRow::Heading(heading) => {
|
||||||
row_box.set_margin_top(16);
|
row_box.set_margin_top(10);
|
||||||
row_box.set_margin_bottom(8);
|
row_box.set_margin_bottom(4);
|
||||||
let label = gtk4::Label::builder()
|
let label = gtk4::Label::builder()
|
||||||
.label(heading)
|
.label(heading)
|
||||||
.halign(gtk4::Align::Start)
|
.halign(gtk4::Align::Start)
|
||||||
@@ -708,18 +736,12 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
label.add_css_class("gallery-day");
|
label.add_css_class("gallery-day");
|
||||||
row_box.append(&label);
|
row_box.append(&label);
|
||||||
}
|
}
|
||||||
GalleryRow::Tiles { photos, edge, last } => {
|
GalleryRow::Tiles { tiles, last } => {
|
||||||
row_box.set_margin_top(0);
|
row_box.set_margin_top(0);
|
||||||
// The last row of a day carries the gap to the next heading.
|
// The last row of a day carries the gap to the next heading.
|
||||||
row_box.set_margin_bottom(if *last { 8 } else { TILE_GAP });
|
row_box.set_margin_bottom(if *last { 4 } else { TILE_GAP });
|
||||||
for photo in photos {
|
for tile in tiles {
|
||||||
row_box.append(&photo_tile(
|
row_box.append(&photo_tile(&ui_bind, tile.clone()));
|
||||||
&ui_bind,
|
|
||||||
Tile {
|
|
||||||
photo: photo.clone(),
|
|
||||||
edge: *edge,
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
schedule_thumbs(&ui_bind);
|
schedule_thumbs(&ui_bind);
|
||||||
}
|
}
|
||||||
@@ -743,11 +765,11 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.remove(&item.position());
|
.remove(&item.position());
|
||||||
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
|
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
|
||||||
&& let GalleryRow::Tiles { photos, .. } = &*obj.borrow::<GalleryRow>()
|
&& let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::<GalleryRow>()
|
||||||
{
|
{
|
||||||
let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut();
|
let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut();
|
||||||
for photo in photos {
|
for tile in tiles {
|
||||||
wanted.remove(&photo.uid);
|
wanted.remove(&tile.photo.uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -768,6 +790,10 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
// fallback button rather than something the user has to hunt for.
|
// fallback button rather than something the user has to hunt for.
|
||||||
let ui_scroll = ui.clone();
|
let ui_scroll = ui.clone();
|
||||||
scroll.vadjustment().connect_value_changed(move |adj| {
|
scroll.vadjustment().connect_value_changed(move |adj| {
|
||||||
|
let last = ui_scroll.gallery.scroll_offset.replace(adj.value());
|
||||||
|
if (adj.value() - last).abs() > 1.0 {
|
||||||
|
ui_scroll.gallery.scrolling_down.set(adj.value() > last);
|
||||||
|
}
|
||||||
let near_end = adj.value() + adj.page_size() >= adj.upper() - adj.page_size() * 0.5;
|
let near_end = adj.value() + adj.page_size() >= adj.upper() - adj.page_size() * 0.5;
|
||||||
if near_end && ui_scroll.gallery.more.is_visible() && ui_scroll.gallery.more.is_sensitive()
|
if near_end && ui_scroll.gallery.more.is_visible() && ui_scroll.gallery.more.is_sensitive()
|
||||||
{
|
{
|
||||||
@@ -789,7 +815,7 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
return glib::Propagation::Proceed;
|
return glib::Propagation::Proceed;
|
||||||
}
|
}
|
||||||
// Scroll up (negative dy) zooms in, i.e. bigger tiles.
|
// Scroll up (negative dy) zooms in, i.e. bigger tiles.
|
||||||
zoom_gallery(&ui_zoom, if dy < 0.0 { TILE_STEP } else { -TILE_STEP });
|
zoom_gallery(&ui_zoom, if dy < 0.0 { ROW_STEP } else { -ROW_STEP });
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
});
|
});
|
||||||
scroll.add_controller(zoom_scroll);
|
scroll.add_controller(zoom_scroll);
|
||||||
@@ -802,9 +828,9 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
return glib::Propagation::Proceed;
|
return glib::Propagation::Proceed;
|
||||||
}
|
}
|
||||||
match key.name().as_deref() {
|
match key.name().as_deref() {
|
||||||
Some("plus" | "equal" | "KP_Add") => zoom_gallery(&ui_keys, TILE_STEP),
|
Some("plus" | "equal" | "KP_Add") => zoom_gallery(&ui_keys, ROW_STEP),
|
||||||
Some("minus" | "KP_Subtract") => zoom_gallery(&ui_keys, -TILE_STEP),
|
Some("minus" | "KP_Subtract") => zoom_gallery(&ui_keys, -ROW_STEP),
|
||||||
Some("0" | "KP_0") => set_gallery_tile(&ui_keys, TILE_DEFAULT),
|
Some("0" | "KP_0") => set_gallery_tile(&ui_keys, ROW_DEFAULT),
|
||||||
_ => return glib::Propagation::Proceed,
|
_ => return glib::Propagation::Proceed,
|
||||||
}
|
}
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
@@ -962,49 +988,118 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One tile of the grid: the photo, and the square edge it was sized to.
|
/// One tile of the grid: the photo, and the box [`justify_rows`] sized it to.
|
||||||
|
#[derive(Clone)]
|
||||||
pub(crate) struct Tile {
|
pub(crate) struct Tile {
|
||||||
pub(crate) photo: PhotoItem,
|
pub(crate) photo: PhotoItem,
|
||||||
pub(crate) edge: i32,
|
pub(crate) width: i32,
|
||||||
|
pub(crate) height: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Break one day's photos into rows of equal square tiles that span `width` —
|
/// The aspect ratio to lay `photo` out at: what a decode has learned, else what
|
||||||
/// the layout a phone gallery uses, and the reason a day holding two photos
|
/// the daemon remembered, else square.
|
||||||
/// looks like every other day rather than like a mistake.
|
fn tile_ratio(ui: &Rc<Ui>, 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.
|
||||||
///
|
///
|
||||||
/// Each tile is centre-cropped to its square (the full frame is one click away
|
/// This is what removes the black bars: a square tile had to either crop the
|
||||||
/// in the lightbox), so nothing here depends on knowing a photo's aspect ratio
|
/// photo or letterbox it, and with a box of the right shape there is nothing to
|
||||||
/// and a row never has to be re-flowed when a thumbnail finally lands.
|
/// do either way.
|
||||||
pub(crate) fn grid_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
|
pub(crate) fn justify_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
|
||||||
let (columns, edge) = plan_grid(ui.gallery.tile.get(), width);
|
let ratios: Vec<f64> = photos.iter().map(|photo| tile_ratio(ui, photo)).collect();
|
||||||
photos
|
let mut photos = photos.iter();
|
||||||
.chunks(columns)
|
plan_rows(&ratios, width, ui.gallery.row_height.get())
|
||||||
.map(|row| {
|
.into_iter()
|
||||||
row.iter()
|
.map(|(height, widths)| {
|
||||||
.map(|photo| Tile {
|
widths
|
||||||
photo: photo.clone(),
|
.into_iter()
|
||||||
edge,
|
.filter_map(|width| {
|
||||||
|
photos.next().map(|photo| Tile {
|
||||||
|
photo: photo.clone(),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The grid math: how many columns fit in `width` at roughly `target` px per
|
/// The layout math, over aspect ratios alone: each row's height, and the width
|
||||||
/// tile, and the exact square edge that divides the width between them.
|
/// of every tile in it.
|
||||||
///
|
///
|
||||||
/// The column count is what rounds — the edge then absorbs the remainder, so the
|
/// Photos are taken in order until their summed ratio no longer leaves them
|
||||||
/// grid spans the full width at every window size instead of leaving a ragged
|
/// `target` px tall, and that row is then scaled to land on `width` exactly. The
|
||||||
/// margin. Never fewer than one column, however narrow the window gets.
|
/// *last* row of a day is left at the target height instead of being stretched:
|
||||||
pub(crate) fn plan_grid(target: i32, width: i32) -> (usize, i32) {
|
/// a day holding two photos would otherwise be two enormous tiles, which reads
|
||||||
let avail = width.max(TILE_MIN);
|
/// as a layout bug rather than as a short day.
|
||||||
let target = target.clamp(TILE_MIN, TILE_MAX);
|
pub(crate) fn plan_rows(ratios: &[f64], width: i32, target: i32) -> Vec<(i32, Vec<i32>)> {
|
||||||
// A row of n tiles occupies n*edge + (n-1)*gap, so n tiles of the target size
|
let width = width.max(ROW_MIN);
|
||||||
// fit while n*(target + gap) - gap <= avail.
|
let target = target.clamp(ROW_MIN, ROW_MAX);
|
||||||
let columns = ((avail + TILE_GAP) / (target + TILE_GAP)).max(1) as usize;
|
let mut rows = Vec::new();
|
||||||
let gaps = TILE_GAP * (columns as i32 - 1);
|
let mut start = 0;
|
||||||
let edge = ((avail - gaps) / columns as i32).max(1);
|
let mut sum = 0.0;
|
||||||
(columns, edge)
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<i32> {
|
||||||
|
let mut widths: Vec<i32> = 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::<i32>() + 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 ListView's own width, less a couple of
|
||||||
@@ -1013,7 +1108,7 @@ pub(crate) fn plan_grid(target: i32, width: i32) -> (usize, i32) {
|
|||||||
pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
||||||
match ui.gallery.width.get() {
|
match ui.gallery.width.get() {
|
||||||
0 => 900,
|
0 => 900,
|
||||||
w => (w - 2).max(TILE_MIN),
|
w => (w - 2).max(ROW_MIN),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1027,11 +1122,12 @@ pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
|||||||
/// one keeps an image glyph instead of an empty rectangle.
|
/// one keeps an image glyph instead of an empty rectangle.
|
||||||
pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||||
let picture = gtk4::Picture::builder()
|
let picture = gtk4::Picture::builder()
|
||||||
// Cover fills the square and crops the overflow, so a portrait photo
|
// The tile is already the photo's shape, so Contain shows the whole
|
||||||
// sits flush in its tile instead of floating in letterbox bars. The
|
// frame with nothing cropped and nothing letterboxed. Cover would still
|
||||||
// expands are what make the picture take the whole overlay: without
|
// crop here: a thumbnail's ratio is not exactly the ratio the row was
|
||||||
// them it is allocated its natural size and the crop never happens.
|
// laid out at, and the rounding is enough to shave an edge. The expands
|
||||||
.content_fit(gtk4::ContentFit::Cover)
|
// are what make the picture take the whole overlay.
|
||||||
|
.content_fit(gtk4::ContentFit::Contain)
|
||||||
.can_shrink(true)
|
.can_shrink(true)
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
@@ -1081,8 +1177,8 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
|||||||
|
|
||||||
let button = gtk4::Button::builder()
|
let button = gtk4::Button::builder()
|
||||||
.child(&overlay)
|
.child(&overlay)
|
||||||
.width_request(tile.edge)
|
.width_request(tile.width)
|
||||||
.height_request(tile.edge)
|
.height_request(tile.height)
|
||||||
.tooltip_text(format_capture_time(tile.photo.capture_time))
|
.tooltip_text(format_capture_time(tile.photo.capture_time))
|
||||||
.build();
|
.build();
|
||||||
button.add_css_class("photo-tile");
|
button.add_css_class("photo-tile");
|
||||||
@@ -1113,8 +1209,10 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
|||||||
/// This is what makes the gallery on-demand: only tiles the ListView actually
|
/// This is what makes the gallery on-demand: only tiles the ListView actually
|
||||||
/// realises ever ask for an image.
|
/// realises ever ask for an image.
|
||||||
pub(crate) fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
pub(crate) fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
||||||
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&photo.uid) {
|
let cached = ui.gallery.photo_tex.borrow().get(&photo.uid).cloned();
|
||||||
picture.set_paintable(Some(texture));
|
if let Some(texture) = cached {
|
||||||
|
picture.set_paintable(Some(&texture));
|
||||||
|
ui.touch_texture(&photo.uid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// No thumbnail will ever come for this one — not from the server, and not
|
// No thumbnail will ever come for this one — not from the server, and not
|
||||||
@@ -1182,9 +1280,15 @@ pub(crate) fn schedule_thumbs(ui: &Rc<Ui>) {
|
|||||||
*ui.gallery.thumb_source.borrow_mut() = Some(source);
|
*ui.gallery.thumb_source.borrow_mut() = Some(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send one [`Request::PhotoThumbs`] batch for the tiles still on screen. Queued
|
/// Send one [`Request::PhotoThumbs`] batch for the tiles on screen, topped up
|
||||||
/// uids whose tile has scrolled away are dropped rather than fetched: the point
|
/// with the tiles just beyond them.
|
||||||
/// of the batch is what the user is looking at *now*.
|
///
|
||||||
|
/// A queued uid whose tile has scrolled away is dropped while the queue is long,
|
||||||
|
/// because during a fast scroll what the user is looking at *now* is the only
|
||||||
|
/// thing worth the round trip. Once the queue is short the scroll has settled,
|
||||||
|
/// and those near misses are the tiles one flick away — fetching them costs one
|
||||||
|
/// batch and saves a blank tile, and the reply lands in the texture cache
|
||||||
|
/// whether or not a widget still wants it.
|
||||||
pub(crate) fn flush_thumbs(ui: &Rc<Ui>) {
|
pub(crate) fn flush_thumbs(ui: &Rc<Ui>) {
|
||||||
if ui.gallery.thumb_inflight.get() {
|
if ui.gallery.thumb_inflight.get() {
|
||||||
return;
|
return;
|
||||||
@@ -1192,15 +1296,25 @@ pub(crate) fn flush_thumbs(ui: &Rc<Ui>) {
|
|||||||
let uids: Vec<String> = {
|
let uids: Vec<String> = {
|
||||||
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
||||||
let wanted = ui.gallery.thumb_wanted.borrow();
|
let wanted = ui.gallery.thumb_wanted.borrow();
|
||||||
|
let settled = queue.len() <= THUMB_BATCH * 2;
|
||||||
let mut batch = Vec::new();
|
let mut batch = Vec::new();
|
||||||
|
let mut skipped = VecDeque::new();
|
||||||
while batch.len() < THUMB_BATCH {
|
while batch.len() < THUMB_BATCH {
|
||||||
let Some(uid) = queue.pop_front() else { break };
|
let Some(uid) = queue.pop_front() else { break };
|
||||||
if wanted.contains_key(&uid) {
|
if wanted.contains_key(&uid) || settled {
|
||||||
batch.push(uid);
|
batch.push(uid);
|
||||||
|
} else {
|
||||||
|
skipped.push_back(uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Anything skipped over stays queued behind what was taken: the scroll
|
||||||
|
// may still come back to it.
|
||||||
|
for uid in skipped.into_iter().rev() {
|
||||||
|
queue.push_front(uid);
|
||||||
|
}
|
||||||
batch
|
batch
|
||||||
};
|
};
|
||||||
|
let uids = prefetch_thumbs(ui, uids);
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1247,6 +1361,64 @@ pub(crate) fn flush_thumbs(ui: &Rc<Ui>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Top a batch up with the photos just past the rows on screen, in the direction
|
||||||
|
/// the timeline is being scrolled.
|
||||||
|
///
|
||||||
|
/// A tile that starts its fetch when it is bound is a tile that shows a
|
||||||
|
/// placeholder for as long as the round trip takes. Warming the next screen is
|
||||||
|
/// what makes a steady scroll look like it already has the photos.
|
||||||
|
fn prefetch_thumbs(ui: &Rc<Ui>, mut batch: Vec<String>) -> Vec<String> {
|
||||||
|
if batch.is_empty() || batch.len() >= THUMB_BATCH {
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
let bound = ui.gallery.bound.borrow();
|
||||||
|
let (Some(first), Some(last)) = (bound.keys().next(), bound.keys().next_back()) else {
|
||||||
|
return batch;
|
||||||
|
};
|
||||||
|
let rows = &ui.gallery.groups;
|
||||||
|
let ahead: Box<dyn Iterator<Item = u32>> = if ui.gallery.scrolling_down.get() {
|
||||||
|
Box::new(last.saturating_add(1)..rows.n_items())
|
||||||
|
} else {
|
||||||
|
Box::new((0..*first).rev())
|
||||||
|
};
|
||||||
|
drop(bound);
|
||||||
|
|
||||||
|
let cached = ui.gallery.photo_tex.borrow();
|
||||||
|
let nothumb = ui.gallery.photo_nothumb.borrow();
|
||||||
|
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
||||||
|
let mut warmed = 0;
|
||||||
|
for index in ahead {
|
||||||
|
if warmed >= THUMB_PREFETCH || batch.len() >= THUMB_BATCH {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some(obj) = rows.item(index).and_downcast::<BoxedAnyObject>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::<GalleryRow>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for tile in tiles {
|
||||||
|
let uid = &tile.photo.uid;
|
||||||
|
// A photo the daemon already handed us a path for is decoded, not
|
||||||
|
// fetched, and one that can never have a thumbnail is neither.
|
||||||
|
if tile.photo.thumb_path.is_some() || cached.contains_key(uid) || nothumb.contains(uid)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if batch.contains(uid) || queue.contains(uid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
warmed += 1;
|
||||||
|
if batch.len() < THUMB_BATCH {
|
||||||
|
batch.push(uid.clone());
|
||||||
|
} else {
|
||||||
|
queue.push_back(uid.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batch
|
||||||
|
}
|
||||||
|
|
||||||
/// Decode queued thumbnails into textures on an idle callback, a few per pass, so
|
/// Decode queued thumbnails into textures on an idle callback, a few per pass, so
|
||||||
/// a big batch fills in progressively instead of freezing the scroll for the
|
/// a big batch fills in progressively instead of freezing the scroll for the
|
||||||
/// length of the whole decode.
|
/// length of the whole decode.
|
||||||
@@ -1271,6 +1443,11 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
|||||||
continue;
|
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());
|
ui.store_texture(&uid, texture.clone());
|
||||||
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
|
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
|
||||||
picture.set_paintable(Some(&texture));
|
picture.set_paintable(Some(&texture));
|
||||||
@@ -1285,6 +1462,25 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Ui>, 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
|
/// 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
|
/// window resize, a zoom step) arrive in floods and only the final state
|
||||||
/// matters.
|
/// matters.
|
||||||
@@ -1326,23 +1522,24 @@ fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
|
|||||||
.and_downcast::<BoxedAnyObject>()
|
.and_downcast::<BoxedAnyObject>()
|
||||||
.is_some_and(|obj| match &*obj.borrow::<GalleryRow>() {
|
.is_some_and(|obj| match &*obj.borrow::<GalleryRow>() {
|
||||||
GalleryRow::Heading(_) => false,
|
GalleryRow::Heading(_) => false,
|
||||||
GalleryRow::Tiles { photos, .. } => photos.iter().any(|p| p.uid == uid),
|
GalleryRow::Tiles { tiles, .. } => tiles.iter().any(|t| t.photo.uid == uid),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step the tile size by `delta` px and re-flow, clamped to the zoom range.
|
/// Step the target row height by `delta` px and re-flow, clamped to the zoom
|
||||||
|
/// range.
|
||||||
pub(crate) fn zoom_gallery(ui: &Rc<Ui>, delta: i32) {
|
pub(crate) fn zoom_gallery(ui: &Rc<Ui>, delta: i32) {
|
||||||
set_gallery_tile(ui, ui.gallery.tile.get() + delta);
|
set_gallery_tile(ui, ui.gallery.row_height.get() + delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the tile size (clamped) and re-flow the visible sections at it.
|
/// Set the target row height (clamped) and re-flow the visible sections at it.
|
||||||
pub(crate) fn set_gallery_tile(ui: &Rc<Ui>, tile: i32) {
|
pub(crate) fn set_gallery_tile(ui: &Rc<Ui>, tile: i32) {
|
||||||
let tile = tile.clamp(TILE_MIN, TILE_MAX);
|
let tile = tile.clamp(ROW_MIN, ROW_MAX);
|
||||||
if tile == ui.gallery.tile.get() {
|
if tile == ui.gallery.row_height.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.tile.set(tile);
|
ui.gallery.row_height.set(tile);
|
||||||
schedule_relayout(ui);
|
schedule_relayout(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1388,13 +1585,11 @@ fn build_rows(ui: &Rc<Ui>) -> Vec<GalleryRow> {
|
|||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
for group in group_photos(&ui.gallery.model) {
|
for group in group_photos(&ui.gallery.model) {
|
||||||
rows.push(GalleryRow::Heading(group.heading));
|
rows.push(GalleryRow::Heading(group.heading));
|
||||||
let grid = grid_rows(ui, &group.photos, width);
|
let grid = justify_rows(ui, &group.photos, width);
|
||||||
let last_index = grid.len().saturating_sub(1);
|
let last_index = grid.len().saturating_sub(1);
|
||||||
for (index, tiles) in grid.into_iter().enumerate() {
|
for (index, tiles) in grid.into_iter().enumerate() {
|
||||||
let edge = tiles.first().map(|t| t.edge).unwrap_or(TILE_DEFAULT);
|
|
||||||
rows.push(GalleryRow::Tiles {
|
rows.push(GalleryRow::Tiles {
|
||||||
photos: tiles.into_iter().map(|t| t.photo).collect(),
|
tiles,
|
||||||
edge,
|
|
||||||
last: index == last_index,
|
last: index == last_index,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1405,9 +1600,8 @@ fn build_rows(ui: &Rc<Ui>) -> Vec<GalleryRow> {
|
|||||||
fn clone_row(row: &GalleryRow) -> GalleryRow {
|
fn clone_row(row: &GalleryRow) -> GalleryRow {
|
||||||
match row {
|
match row {
|
||||||
GalleryRow::Heading(heading) => GalleryRow::Heading(heading.clone()),
|
GalleryRow::Heading(heading) => GalleryRow::Heading(heading.clone()),
|
||||||
GalleryRow::Tiles { photos, edge, last } => GalleryRow::Tiles {
|
GalleryRow::Tiles { tiles, last } => GalleryRow::Tiles {
|
||||||
photos: photos.clone(),
|
tiles: tiles.clone(),
|
||||||
edge: *edge,
|
|
||||||
last: *last,
|
last: *last,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user