feat(gui): photos infinite scroll and month scrubber

The timeline pages itself in 1.5 screens before the end instead of offering a Load more button, and a year-marked month scrubber jumps to any month, loading long pages until it is reached.
This commit is contained in:
nvrl committed 2026-09-23 03:37:08 +02:00
1 parent a5d29969dc
commit be8464f4a0
4 files changed
+294 -44

No files matched your search

+2
View File
@@ -52,6 +52,8 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; } .viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; } .viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
.viewer-nav-btn:disabled { opacity: 0; } .viewer-nav-btn:disabled { opacity: 0; }
.photo-scrubber { min-width: 20px; padding: 0 2px; }
.photo-scrubber value { font-weight: 600; background-color: var(--popover-bg-color); border-radius: 6px; padding: 2px 8px; }
.viewer-spinner { color: white; } .viewer-spinner { color: white; }
.viewer-status { color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; } .viewer-status { color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); } .viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
+11 -4
View File
@@ -50,9 +50,10 @@ use pdfs_core::config::{AppDirs, FileSort, FilesView};
use pdfs_core::control::{ use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo, ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem, DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem,
PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder, RestoreItem, PhotoKind, PhotoMonth, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder,
RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo,
SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem, pending_summary, send, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem,
pending_summary, send,
}; };
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
@@ -461,7 +462,13 @@ fn build_window(app: &adw::Application) {
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(),
more: gallery_widgets.more.clone(), pager: gallery_widgets.pager.clone(),
scrubber: gallery_widgets.scrubber.clone(),
months: RefCell::new(Vec::new()),
scrubbing: Cell::new(false),
scrub_source: RefCell::new(None),
jump: Cell::new(None),
burst: Cell::new(false),
import_banner: gallery_widgets.import_banner.clone(), import_banner: gallery_widgets.import_banner.clone(),
upload: gallery_widgets.upload.clone(), upload: gallery_widgets.upload.clone(),
import: gallery_widgets.import.clone(), import: gallery_widgets.import.clone(),
+277 -40
View File
@@ -31,7 +31,21 @@ pub(crate) struct GalleryState {
pub(crate) content: gtk4::Stack, pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage, pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button, pub(crate) retry: gtk4::Button,
pub(crate) more: gtk4::Button, /// Spins under the timeline while the next page is loading.
pub(crate) pager: gtk4::Spinner,
/// The month scrubber on the timeline's right edge, the months it spans
/// (newest first), and whether the pointer is on it — while it is, the
/// scroll position must not move the knob out from under the drag.
pub(crate) scrubber: gtk4::Scale,
pub(crate) months: RefCell<Vec<PhotoMonth>>,
pub(crate) scrubbing: Cell<bool>,
/// The pending jump the scrubber debounces to.
pub(crate) scrub_source: RefCell<Option<glib::SourceId>>,
/// A scrubber jump in progress: the end of the month it is headed for.
/// Pages keep loading until a photo older than this is in the model.
pub(crate) jump: Cell<Option<i64>>,
/// Makes the next page [`JUMP_PAGE`] long, for a jump that has far to go.
pub(crate) burst: Cell<bool>,
/// Says a Google Photos import is running, with a way to its page. /// Says a Google Photos import is running, with a way to its page.
pub(crate) import_banner: adw::Banner, pub(crate) import_banner: adw::Banner,
pub(crate) upload: gtk4::Button, pub(crate) upload: gtk4::Button,
@@ -166,6 +180,13 @@ 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 = 200; pub(crate) const PHOTOS_PAGE: usize = 200;
/// Page length while a scrubber jump is loading its way to a month — the
/// daemon's cap on one reply.
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 /// 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 /// 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 /// content width once it is full, so this is the *target* a row lands near
@@ -297,7 +318,8 @@ pub(crate) struct GalleryWidgets {
pub(crate) content: gtk4::Stack, pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage, pub(crate) status: adw::StatusPage,
pub(crate) title: adw::WindowTitle, pub(crate) title: adw::WindowTitle,
pub(crate) more: gtk4::Button, pub(crate) pager: gtk4::Spinner,
pub(crate) scrubber: gtk4::Scale,
/// Says a Google Photos import is running, with a way to its page. /// Says a Google Photos import is running, with a way to its page.
pub(crate) import_banner: adw::Banner, pub(crate) import_banner: adw::Banner,
pub(crate) list: gtk4::ListView, pub(crate) list: gtk4::ListView,
@@ -391,15 +413,30 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.build(); .build();
status.add_css_class("compact"); status.add_css_class("compact");
// Kept as an explicit fallback: the timeline also pages itself in as the // The timeline pages itself in as the scroll nears the bottom (see
// scroll nears the bottom (see [`wire_gallery`]), so reaching this button at // [`wire_gallery`]); this only says a page is on its way.
// all is unusual. let pager = gtk4::Spinner::builder()
let more = gtk4::Button::builder()
.label("Load more")
.halign(gtk4::Align::Center) .halign(gtk4::Align::Center)
.margin_bottom(6)
.visible(false)
.build(); .build();
more.add_css_class("pill");
more.set_visible(false); // Month scrubber: newest at the top, a mark per year. Dragging it jumps
// the timeline to the month under the knob (see [`wire_gallery`]).
let scrubber = gtk4::Scale::builder()
.orientation(gtk4::Orientation::Vertical)
.adjustment(&gtk4::Adjustment::new(0.0, 0.0, 1.0, 1.0, 1.0, 0.0))
.draw_value(false)
.value_pos(gtk4::PositionType::Left)
.round_digits(0)
.halign(gtk4::Align::End)
.margin_top(12)
.margin_bottom(12)
.margin_end(4)
.tooltip_text("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 // Horizontal scrolling is never wanted: the grid is sized to the viewport
// width, and a stray hscrollbar would fight the layout. // width, and a stray hscrollbar would fight the layout.
@@ -566,8 +603,10 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
// The timeline (plus its pager) or the status page, never both. // The timeline (plus its pager) or the status page, never both.
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12); let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
timeline.append(&select_bar); timeline.append(&select_bar);
timeline.append(&scroll); let timeline_overlay = gtk4::Overlay::builder().child(&scroll).build();
timeline.append(&more); timeline_overlay.add_overlay(&scrubber);
timeline.append(&timeline_overlay);
timeline.append(&pager);
// The album grid: cover-first cards that flow to the width they are given. // The album grid: cover-first cards that flow to the width they are given.
let albums = gtk4::FlowBox::builder() let albums = gtk4::FlowBox::builder()
@@ -644,7 +683,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
content, content,
status, status,
title, title,
more, pager,
scrubber,
import_banner, import_banner,
list, list,
scroll, scroll,
@@ -731,6 +771,7 @@ pub(crate) fn refresh_photo_months(ui: &Rc<Ui>) {
}; };
let mut labels = vec!["All dates".to_string()]; let mut labels = vec!["All dates".to_string()];
let mut ranges: Vec<Option<(i64, i64)>> = vec![None]; let mut ranges: Vec<Option<(i64, i64)>> = vec![None];
fill_scrubber(&ui, &months);
for m in months { for m in months {
let name = MONTH_NAMES let name = MONTH_NAMES
.get((m.month - 1) as usize) .get((m.month - 1) as usize)
@@ -886,20 +927,21 @@ pub(crate) fn wire_gallery(
} }
}); });
// Page the timeline in as the scroll nears the end, so "load more" is a // Page the timeline in while the user is still a screen and a half from the
// fallback button rather than something the user has to hunt for. // end, so scrolling at a normal pace never reaches it.
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()); let last = ui_scroll.gallery.scroll_offset.replace(adj.value());
if (adj.value() - last).abs() > 1.0 { if (adj.value() - last).abs() > 1.0 {
ui_scroll.gallery.scrolling_down.set(adj.value() > last); 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() * 1.5;
if near_end && ui_scroll.gallery.more.is_visible() && ui_scroll.gallery.more.is_sensitive() if near_end && ui_scroll.gallery.has_more.get() {
{
load_gallery(&ui_scroll, true); load_gallery(&ui_scroll, true);
} }
sync_scrubber_position(&ui_scroll);
}); });
wire_scrubber(ui);
// Ctrl+scroll zoom. Capture phase so the ScrolledWindow doesn't eat the event // Ctrl+scroll zoom. Capture phase so the ScrolledWindow doesn't eat the event
// and scroll the page out from under the gesture. // and scroll the page out from under the gesture.
@@ -952,11 +994,6 @@ pub(crate) fn wire_gallery(
}); });
list.add_controller(zoom_keys); list.add_controller(zoom_keys);
let ui_more = ui.clone();
ui.gallery.more.clone().connect_clicked(move |_| {
load_gallery(&ui_more, true);
});
let ui_select = ui.clone(); let ui_select = ui.clone();
ui.gallery.select_btn.clone().connect_toggled(move |btn| { ui.gallery.select_btn.clone().connect_toggled(move |btn| {
set_selection_mode(&ui_select, btn.is_active()); set_selection_mode(&ui_select, btn.is_active());
@@ -2186,6 +2223,192 @@ pub(crate) fn empty_timeline_text(
} }
} }
/// Point the scrubber at `months` (newest first): one step per month, with a
/// mark and a year label where each year starts. Shown only for the whole,
/// unfiltered timeline with more than one month to move between.
fn fill_scrubber(ui: &Rc<Ui>, months: &[PhotoMonth]) {
let scrubber = &ui.gallery.scrubber;
scrubber.clear_marks();
*ui.gallery.months.borrow_mut() = months.to_vec();
scrubber.set_range(0.0, months.len().saturating_sub(1).max(1) as f64);
scrubber.set_value(0.0);
for (index, pair) in months.windows(2).enumerate() {
if pair[0].year != pair[1].year {
scrubber.add_mark(
(index + 1) as f64,
gtk4::PositionType::Left,
Some(&pair[1].year.to_string()),
);
}
}
sync_scrubber(ui);
}
/// Show the scrubber only where a month jump makes sense: the whole timeline,
/// not an album, a date window or the favorites.
pub(crate) fn sync_scrubber(ui: &Rc<Ui>) {
let whole = ui.gallery.album.borrow().is_none()
&& ui.gallery.range.get().is_none()
&& !ui.gallery.favorites.get();
ui.gallery
.scrubber
.set_visible(whole && ui.gallery.months.borrow().len() > 1);
}
/// "March 2024" for the scrubber's month `index`.
fn scrubber_label(months: &[PhotoMonth], index: usize) -> String {
months.get(index).map_or_else(String::new, |m| {
let name = MONTH_NAMES
.get((m.month - 1) as usize)
.copied()
.unwrap_or("?");
format!("{name} {}", m.year)
})
}
/// Move the knob to the month at the top of the timeline, unless the user is
/// holding it.
fn sync_scrubber_position(ui: &Rc<Ui>) {
if ui.gallery.scrubbing.get() || !ui.gallery.scrubber.is_visible() {
return;
}
let Some(uid) = ui.gallery.bound.borrow().values().flatten().next().cloned() else {
return;
};
let Some(capture_time) = find_photo_index(&ui.gallery.model, &uid)
.and_then(|idx| ui.gallery.model.item(idx))
.and_downcast::<BoxedAnyObject>()
.map(|boxed| boxed.borrow::<PhotoItem>().capture_time)
else {
return;
};
let months = ui.gallery.months.borrow();
if let Some(index) = month_index(&months, capture_time) {
ui.gallery.scrubber.set_value(index as f64);
}
}
/// Which of `months` (newest first) `capture_time` falls in; the nearest one
/// when it falls between them.
pub(crate) fn month_index(months: &[PhotoMonth], capture_time: i64) -> Option<usize> {
let position = months
.iter()
.position(|m| month_range(m.year, m.month).is_some_and(|(from, _)| capture_time >= from));
match position {
Some(index) => Some(index),
None => months.len().checked_sub(1),
}
}
/// Wire the scrubber: its value label while the pointer is on it, and the
/// jump once it rests on a month.
fn wire_scrubber(ui: &Rc<Ui>) {
let scrubber = ui.gallery.scrubber.clone();
let ui_format = ui.clone();
scrubber.set_format_value_func(move |_, value| {
scrubber_label(&ui_format.gallery.months.borrow(), value.round() as usize)
});
let hover = gtk4::EventControllerMotion::new();
let ui_enter = ui.clone();
hover.connect_enter(move |_, _, _| {
ui_enter.gallery.scrubbing.set(true);
ui_enter.gallery.scrubber.set_draw_value(true);
});
let ui_leave = ui.clone();
hover.connect_leave(move |_| {
ui_leave.gallery.scrubbing.set(false);
ui_leave.gallery.scrubber.set_draw_value(false);
});
scrubber.add_controller(hover);
// `change-value` fires for the user's own moves only, not for the knob
// following the scroll.
let ui_change = ui.clone();
scrubber.connect_change_value(move |_, _, value| {
if let Some(source) = ui_change.gallery.scrub_source.borrow_mut().take() {
source.remove();
}
let index = value.round().max(0.0) as usize;
let ui_jump = ui_change.clone();
let source = glib::timeout_add_local_once(SCRUB_DEBOUNCE, move || {
ui_jump.gallery.scrub_source.borrow_mut().take();
jump_to_month(&ui_jump, index);
});
*ui_change.gallery.scrub_source.borrow_mut() = Some(source);
glib::Propagation::Proceed
});
}
/// Scroll the timeline to the scrubber's month `index`, loading pages until
/// its first photo is in.
fn jump_to_month(ui: &Rc<Ui>, index: usize) {
let end = {
let months = ui.gallery.months.borrow();
months
.get(index)
.and_then(|m| month_range(m.year, m.month))
.map(|(_, to)| to)
};
ui.gallery.jump.set(end);
continue_jump(ui);
}
/// One step of a scrubber jump: scroll there when the month is loaded, or load
/// a long page and come back when it lands.
fn continue_jump(ui: &Rc<Ui>) {
let Some(end) = ui.gallery.jump.get() else {
return;
};
let model = &ui.gallery.model;
// Newest first, so the month's first photo is the first one older than
// its end.
let landing = (0..model.n_items()).find(|idx| {
model
.item(*idx)
.and_downcast::<BoxedAnyObject>()
.is_some_and(|boxed| boxed.borrow::<PhotoItem>().capture_time < end)
});
if landing.is_none() && ui.gallery.has_more.get() {
let ui_page = ui.clone();
ui.gallery
.page_waiters
.borrow_mut()
.push(Box::new(move || continue_jump(&ui_page)));
ui.gallery.burst.set(true);
load_gallery(ui, true);
return;
}
ui.gallery.jump.set(None);
let target = landing.or_else(|| model.n_items().checked_sub(1));
let Some(uid) = target
.and_then(|idx| model.item(idx))
.and_downcast::<BoxedAnyObject>()
.map(|boxed| boxed.borrow::<PhotoItem>().uid.clone())
else {
return;
};
let Some(row) = row_of_photo(&ui.gallery.groups, &uid) else {
return;
};
// Land on the day heading above the photo when it opens a day.
let heading = row
.checked_sub(1)
.filter(|above| {
ui.gallery
.groups
.item(*above)
.and_downcast::<BoxedAnyObject>()
.is_some_and(|boxed| {
matches!(*boxed.borrow::<GalleryRow>(), GalleryRow::Heading(_))
})
})
.unwrap_or(row);
ui.gallery
.list
.scroll_to(heading, gtk4::ListScrollFlags::empty(), None);
}
/// Load the next page when the timeline does not fill the window yet: with no /// Load the next page when the timeline does not fill the window yet: with no
/// scrollbar there is no scrolling to trigger it. /// scrollbar there is no scrolling to trigger it.
fn fill_viewport(ui: &Rc<Ui>) { fn fill_viewport(ui: &Rc<Ui>) {
@@ -2194,10 +2417,7 @@ fn fill_viewport(ui: &Rc<Ui>) {
let Some(adj) = ui.gallery.list.vadjustment() else { let Some(adj) = ui.gallery.list.vadjustment() else {
return; return;
}; };
if ui.gallery.more.is_visible() if ui.gallery.has_more.get() && adj.upper() <= adj.page_size() + 1.0 {
&& ui.gallery.more.is_sensitive()
&& adj.upper() <= adj.page_size() + 1.0
{
load_gallery(&ui, true); load_gallery(&ui, true);
} }
}); });
@@ -2317,21 +2537,24 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
if album.is_none() && ui.gallery.range.get().is_none() { if album.is_none() && ui.gallery.range.get().is_none() {
refresh_photo_months(ui); refresh_photo_months(ui);
} }
sync_scrubber(ui);
} }
let offset = ui.gallery.model.n_items() as usize; let offset = ui.gallery.model.n_items() as usize;
let limit = if ui.gallery.burst.replace(false) {
JUMP_PAGE
} else {
PHOTOS_PAGE
};
ui.gallery.loading.set(true); ui.gallery.loading.set(true);
ui.gallery.more.set_sensitive(false); ui.gallery.pager.set_visible(append);
ui.gallery.pager.set_spinning(append);
ui.busy_begin(); ui.busy_begin();
let request = match album { let request = match album {
Some(uid) => Request::AlbumPhotos { Some(uid) => Request::AlbumPhotos { uid, offset, limit },
uid,
offset,
limit: PHOTOS_PAGE,
},
None => Request::PhotosTimeline { None => Request::PhotosTimeline {
offset, offset,
limit: PHOTOS_PAGE, limit,
kind: ui.gallery.kind.get(), kind: ui.gallery.kind.get(),
range: ui.gallery.range.get(), range: ui.gallery.range.get(),
favorites: ui.gallery.favorites.get(), favorites: ui.gallery.favorites.get(),
@@ -2343,7 +2566,8 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
let result = rx.recv().await; let result = rx.recv().await;
ui.busy_end(); ui.busy_end();
ui.gallery.loading.set(false); ui.gallery.loading.set(false);
ui.gallery.more.set_sensitive(true); ui.gallery.pager.set_visible(false);
ui.gallery.pager.set_spinning(false);
// Whoever waited on this page runs once the reply below has put it in // Whoever waited on this page runs once the reply below has put it in
// the model, whichever way that goes. // the model, whichever way that goes.
let waiters = std::mem::take(&mut *ui.gallery.page_waiters.borrow_mut()); let waiters = std::mem::take(&mut *ui.gallery.page_waiters.borrow_mut());
@@ -2366,7 +2590,7 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
); );
return; return;
} }
ui.gallery.has_more.set(items.len() == PHOTOS_PAGE); ui.gallery.has_more.set(items.len() == limit);
// Label the filter tabs with live per-kind counts. // Label the filter tabs with live per-kind counts.
if let Some(counts) = counts { if let Some(counts) = counts {
update_gallery_tabs(&ui, counts); update_gallery_tabs(&ui, counts);
@@ -2427,8 +2651,6 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
return; return;
} }
ui.gallery.content.set_visible_child_name("timeline"); ui.gallery.content.set_visible_child_name("timeline");
// Offer "Load more" only when the page came back full.
ui.gallery.more.set_visible(items.len() == PHOTOS_PAGE);
fill_viewport(&ui); fill_viewport(&ui);
} }
// A failed *next* page keeps the photos already on screen — the failure // A failed *next* page keeps the photos already on screen — the failure
@@ -2470,7 +2692,7 @@ pub(crate) fn gallery_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
// Only the empty timeline offers a way to fill it; every other status turns // Only the empty timeline offers a way to fill it; every other status turns
// those buttons back off. // those buttons back off.
ui.gallery.empty_actions.set_visible(false); ui.gallery.empty_actions.set_visible(false);
ui.gallery.more.set_visible(false); ui.gallery.has_more.set(false);
ui.gallery.content.set_visible_child_name("status"); ui.gallery.content.set_visible_child_name("status");
} }
@@ -2515,8 +2737,23 @@ pub(crate) fn play_external(path: &str) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::empty_timeline_text; use super::{empty_timeline_text, month_index, month_range};
use pdfs_core::control::PhotoKind; use pdfs_core::control::{PhotoKind, PhotoMonth};
#[test]
fn the_scrubber_finds_the_month_a_photo_was_taken_in() {
let months = [(2026, 3), (2025, 12), (2025, 11)].map(|(year, month)| PhotoMonth {
year,
month,
count: 1,
});
let mid = |year, month| month_range(year, month).unwrap().0 + 86_400 * 10;
assert_eq!(month_index(&months, mid(2026, 3)), Some(0));
assert_eq!(month_index(&months, mid(2025, 12)), Some(1));
// A month with no photos of its own lands on the next older one.
assert_eq!(month_index(&months, mid(2026, 1)), Some(1));
assert_eq!(month_index(&months, mid(2020, 1)), Some(2));
}
#[test] #[test]
fn an_empty_filter_names_what_it_filtered() { fn an_empty_filter_names_what_it_filtered() {
+4
View File
@@ -99,6 +99,10 @@ Schema: **31** (unchanged).
covering it. Stepping past the last loaded photo loads the next page, and the counter shows the covering it. Stepping past the last loaded photo loads the next page, and the counter shows the
library's total rather than the loaded count. A video plays inline. Save a Copy uses the photo's library's total rather than the loaded count. A video plays inline. Save a Copy uses the photo's
real name and reports failures, and Open With… offers the app chooser. real name and reports failures, and Open With… offers the app chooser.
- **Photos scroll without a Load More button.** The timeline loads the next page while you are
still a screen and a half from the end, with a spinner under it while a page is on its way. A
month scrubber on the right edge of the whole timeline, marked by year, jumps to the month you
drag it to and follows the scroll otherwise.
- **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the - **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
`pdfs favorite` command. `pdfs favorite` command.