mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(core,fuse,gui): "On this day" strip above the photos timeline
Add a PhotosOnThisDay control request, served from the stored timeline:
photos whose local capture date matches today's in earlier years, one
row per group. The GUI shows a card per year ("3 years ago") with the
year's newest photo as its cover; clicking a card scrolls the timeline
to that day. The strip is revealed only at the top of the whole,
unfiltered timeline, so it gives the grid its room back once you
scroll or filter.
This commit is contained in:
1 parent
c3ec82392a
commit
7dbf818d83
17 files changed
+2017
-1551
No files matched your search
@@ -84,6 +84,10 @@ pub enum Request {
|
||||
#[serde(default)]
|
||||
kind: Option<PhotoKind>,
|
||||
},
|
||||
/// Photos taken on today's date in earlier years, newest first, at most
|
||||
/// `limit` of them — the "On this day" strip above the timeline. Replies
|
||||
/// with [`Response::Photos`]; a front-end groups them by year itself.
|
||||
PhotosOnThisDay { limit: usize },
|
||||
/// List the account's photo albums, newest activity first, including the
|
||||
/// albums other people share with us. Metadata only — an album's cover
|
||||
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
|
||||
|
||||
@@ -588,6 +588,27 @@ impl Db {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Photos taken on `today`'s calendar day in earlier years, newest first —
|
||||
/// the "On this day" strip. `today` is epoch seconds: its local month and day
|
||||
/// pick the date, and its own year is left out, because today's photos are
|
||||
/// what the timeline already opens on. One row per group, like the timeline.
|
||||
pub fn photos_on_this_day(&self, today: i64, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let sql = format!(
|
||||
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) \
|
||||
AND strftime('%m-%d', p.capture_time, 'unixepoch', 'localtime') \
|
||||
= strftime('%m-%d', ?1, 'unixepoch', 'localtime') \
|
||||
AND strftime('%Y', p.capture_time, 'unixepoch', 'localtime') \
|
||||
< strftime('%Y', ?1, 'unixepoch', 'localtime') \
|
||||
ORDER BY p.seq LIMIT ?2"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(params![today, limit as i64], stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||
use crate::control::PhotoKind;
|
||||
|
||||
@@ -253,6 +253,38 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
||||
assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite);
|
||||
}
|
||||
|
||||
/// The strip shows the same calendar day in earlier years only: not today's
|
||||
/// own photos, not the day before, and one tile per RAW+JPEG shot.
|
||||
#[test]
|
||||
fn on_this_day_finds_the_same_date_in_earlier_years() {
|
||||
const DAY: i64 = 86_400;
|
||||
// Noon UTC, so the local date is the same in every common time zone.
|
||||
let today = 1_781_524_800; // 2026-06-15 12:00 UTC
|
||||
let year_ago = today - 365 * DAY; // 2025-06-15
|
||||
let three_years_ago = today - (365 * 3 + 1) * DAY; // 2023-06-15, past a 29 February
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
TimelineRow::new("today", today),
|
||||
TimelineRow {
|
||||
name: Some("IMG_1.JPG".into()),
|
||||
..TimelineRow::new("jpeg", year_ago)
|
||||
},
|
||||
TimelineRow {
|
||||
name: Some("IMG_1.CR2".into()),
|
||||
..TimelineRow::new("raw", year_ago)
|
||||
},
|
||||
TimelineRow::new("day-before", year_ago - DAY),
|
||||
TimelineRow::new("older", three_years_ago),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let found = db.photos_on_this_day(today, 10).unwrap();
|
||||
assert_eq!(
|
||||
found.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["jpeg", "older"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_in_album_hides_photos_any_album_holds() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
|
||||
@@ -461,6 +461,16 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::PhotosOnThisDay { limit }) => {
|
||||
match core.photos_on_this_day(clamp_limit(limit)) {
|
||||
Ok(items) => CtlResponse::Photos {
|
||||
available: true,
|
||||
items,
|
||||
counts: None,
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::PhotoGroup { uid }) => match core.photo_group(&uid) {
|
||||
Ok(items) => CtlResponse::Photos {
|
||||
available: true,
|
||||
|
||||
@@ -729,6 +729,20 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Photos taken on today's date in earlier years, as wire items. Served
|
||||
/// from the stored timeline only: the strip is a nicety, not worth a wait
|
||||
/// on the network, and the timeline's own load keeps that store fresh.
|
||||
pub(crate) fn photos_on_this_day(&self, limit: usize) -> CoreResult<Vec<PhotoItem>> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64);
|
||||
let photos = self
|
||||
.db
|
||||
.photos_on_this_day(now, limit)
|
||||
.map_err(CoreError::from)?;
|
||||
Ok(photos.into_iter().map(|p| self.photo_item(p)).collect())
|
||||
}
|
||||
|
||||
/// Every file of one photo's group, in server order, as wire items. A photo
|
||||
/// the timeline does not hold comes back empty, which the lightbox reads as
|
||||
/// "nothing to switch between".
|
||||
|
||||
@@ -74,6 +74,9 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
|
||||
.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); }
|
||||
.memory-card { padding: 0; border-radius: 12px; }
|
||||
.memory-cover { border-radius: 12px; background: alpha(currentColor, 0.06); }
|
||||
.memory-label { font-weight: 700; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); }
|
||||
.album-card { padding: 0; border-radius: 16px; transition: background 180ms ease; }
|
||||
.album-card:hover { background: alpha(currentColor, 0.07); }
|
||||
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
|
||||
|
||||
@@ -565,6 +565,9 @@ fn build_window(app: &adw::Application) {
|
||||
favorites: Cell::new(false),
|
||||
not_in_album_btn: gallery_widgets.not_in_album_btn.clone(),
|
||||
not_in_album: Cell::new(false),
|
||||
memories: gallery_widgets.memories.clone(),
|
||||
memory_cards: gallery_widgets.memory_cards.clone(),
|
||||
has_memories: Cell::new(false),
|
||||
date_suppress: Cell::new(false),
|
||||
loading: Cell::new(false),
|
||||
has_more: Cell::new(false),
|
||||
@@ -1723,7 +1726,6 @@ mod tests {
|
||||
assert_eq!(parse_launch(&args(&["--page"])), Launch::Window);
|
||||
}
|
||||
|
||||
/// The full width a row occupies, gaps included.
|
||||
/// One file of a shot, for the group-switch tests.
|
||||
fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem {
|
||||
PhotoItem {
|
||||
|
||||
@@ -103,6 +103,11 @@ pub(crate) struct GalleryState {
|
||||
/// restricted to photos no album of ours holds yet.
|
||||
pub(crate) not_in_album_btn: gtk4::ToggleButton,
|
||||
pub(crate) not_in_album: Cell<bool>,
|
||||
/// The "On this day" strip above the timeline, the box holding its cards,
|
||||
/// and whether there is anything to show in it.
|
||||
pub(crate) memories: gtk4::Revealer,
|
||||
pub(crate) memory_cards: gtk4::Box,
|
||||
pub(crate) has_memories: Cell<bool>,
|
||||
/// Set while the date dropdown is being repopulated, so resetting its model
|
||||
/// doesn't fire the selection handler and kick off a spurious reload.
|
||||
pub(crate) date_suppress: Cell<bool>,
|
||||
@@ -379,6 +384,9 @@ pub(crate) struct GalleryWidgets {
|
||||
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
||||
pub(crate) favorites_btn: gtk4::ToggleButton,
|
||||
pub(crate) not_in_album_btn: gtk4::ToggleButton,
|
||||
/// The "On this day" strip and the box its cards go into.
|
||||
pub(crate) memories: gtk4::Revealer,
|
||||
pub(crate) memory_cards: gtk4::Box,
|
||||
/// The date-jump dropdown, populated with the timeline's months.
|
||||
pub(crate) dates: gtk4::DropDown,
|
||||
}
|
||||
@@ -639,9 +647,33 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
filter_bar.append(&filters);
|
||||
|
||||
// "On this day": a card per earlier year that has photos from today's
|
||||
// date. Revealed only at the top of the unfiltered timeline (see
|
||||
// [`sync_memories`]), so it gives the grid its room back once you scroll.
|
||||
let memories_title = gtk4::Label::builder()
|
||||
.label(gettext("On this day"))
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
memories_title.add_css_class("heading");
|
||||
let memory_cards = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
let memory_scroll = gtk4::ScrolledWindow::builder()
|
||||
.vscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||
.propagate_natural_height(true)
|
||||
.child(&memory_cards)
|
||||
.build();
|
||||
let memories_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
memories_box.append(&memories_title);
|
||||
memories_box.append(&memory_scroll);
|
||||
let memories = gtk4::Revealer::builder()
|
||||
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
||||
.child(&memories_box)
|
||||
.build();
|
||||
|
||||
// The timeline (plus its pager) or the status page, never both.
|
||||
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
timeline.append(&select_bar);
|
||||
timeline.append(&memories);
|
||||
// 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);
|
||||
@@ -747,6 +779,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
tabs,
|
||||
favorites_btn,
|
||||
not_in_album_btn,
|
||||
memories,
|
||||
memory_cards,
|
||||
dates,
|
||||
albums,
|
||||
albums_stack,
|
||||
@@ -1009,6 +1043,7 @@ pub(crate) fn wire_gallery(
|
||||
load_gallery(&ui_scroll, true);
|
||||
}
|
||||
sync_scrubber_position(&ui_scroll);
|
||||
sync_memories(&ui_scroll);
|
||||
});
|
||||
wire_scrubber(ui);
|
||||
wire_drag_select(ui);
|
||||
@@ -2619,6 +2654,147 @@ pub(crate) fn sync_scrubber(ui: &Rc<Ui>) {
|
||||
.set_visible(whole && ui.gallery.months.borrow().len() > 1);
|
||||
}
|
||||
|
||||
/// How many photos the "On this day" strip asks for. Enough for a card per year
|
||||
/// of a big library; the cards only need one cover each.
|
||||
const MEMORIES_LIMIT: usize = 200;
|
||||
|
||||
/// Size of an "On this day" card's cover, in px.
|
||||
const MEMORY_CARD_WIDTH: i32 = 180;
|
||||
const MEMORY_CARD_HEIGHT: i32 = 120;
|
||||
|
||||
/// Ask the daemon for photos taken on today's date in earlier years, and fill
|
||||
/// the "On this day" strip with a card per year. A failure leaves the strip
|
||||
/// hidden: it is a nicety, not worth a toast.
|
||||
fn refresh_memories(ui: &Rc<Ui>) {
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::PhotosOnThisDay {
|
||||
limit: MEMORIES_LIMIT,
|
||||
},
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let items = match rx.recv().await {
|
||||
Ok(Ok(Response::Photos { items, .. })) => items,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let this_year = glib::DateTime::now_local().map_or(0, |now| now.year());
|
||||
let cards = &ui.gallery.memory_cards;
|
||||
while let Some(child) = cards.first_child() {
|
||||
cards.remove(&child);
|
||||
}
|
||||
let years = memory_years(&items, this_year);
|
||||
ui.gallery.has_memories.set(!years.is_empty());
|
||||
for (years_ago, photos) in years {
|
||||
cards.append(&memory_card(&ui, years_ago, photos));
|
||||
}
|
||||
schedule_thumbs(&ui);
|
||||
sync_memories(&ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// Group "On this day" photos (newest first) by how many years ago they were
|
||||
/// taken, nearest year first. Each group keeps the newest-first order, so its
|
||||
/// first photo is the card's cover.
|
||||
pub(crate) fn memory_years(items: &[PhotoItem], this_year: i32) -> Vec<(u32, Vec<PhotoItem>)> {
|
||||
let mut years: Vec<(u32, Vec<PhotoItem>)> = Vec::new();
|
||||
for item in items {
|
||||
let Ok(date) = glib::DateTime::from_unix_local(item.capture_time) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(years_ago) = u32::try_from(this_year - date.year()) else {
|
||||
continue;
|
||||
};
|
||||
if years_ago == 0 {
|
||||
continue;
|
||||
}
|
||||
match years.iter_mut().find(|(y, _)| *y == years_ago) {
|
||||
Some((_, photos)) => photos.push(item.clone()),
|
||||
None => years.push((years_ago, vec![item.clone()])),
|
||||
}
|
||||
}
|
||||
years.sort_by_key(|(years_ago, _)| *years_ago);
|
||||
years
|
||||
}
|
||||
|
||||
/// One "On this day" card: the year's newest photo as the cover, labelled with
|
||||
/// how long ago it was. Clicking it scrolls the timeline to that day.
|
||||
fn memory_card(ui: &Rc<Ui>, years_ago: u32, photos: Vec<PhotoItem>) -> gtk4::Button {
|
||||
let cover = photos[0].clone();
|
||||
let picture = gtk4::Picture::builder()
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.can_shrink(true)
|
||||
.build();
|
||||
let placeholder = gtk4::Image::builder()
|
||||
.icon_name("image-x-generic-symbolic")
|
||||
.pixel_size(24)
|
||||
.build();
|
||||
placeholder.add_css_class("photo-placeholder");
|
||||
let label = gtk4::Label::builder()
|
||||
.label(ngettext_f(
|
||||
"{n} year ago",
|
||||
"{n} years ago",
|
||||
u64::from(years_ago),
|
||||
&[],
|
||||
))
|
||||
.halign(gtk4::Align::Fill)
|
||||
.valign(gtk4::Align::End)
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
label.add_css_class("memory-label");
|
||||
|
||||
// Sized here rather than on the picture: an overlay takes its size from
|
||||
// its main child, which is the placeholder glyph.
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.set_size_request(MEMORY_CARD_WIDTH, MEMORY_CARD_HEIGHT);
|
||||
overlay.set_child(Some(&placeholder));
|
||||
overlay.add_overlay(&picture);
|
||||
overlay.add_overlay(&label);
|
||||
overlay.set_overflow(gtk4::Overflow::Hidden);
|
||||
overlay.add_css_class("memory-cover");
|
||||
|
||||
let day = section_heading(cover.capture_time, Grouping::Day);
|
||||
let card = gtk4::Button::builder()
|
||||
.child(&overlay)
|
||||
.tooltip_text(ngettext_f(
|
||||
"{date}, {n} photo",
|
||||
"{date}, {n} photos",
|
||||
photos.len() as u64,
|
||||
&[("date", &day)],
|
||||
))
|
||||
.build();
|
||||
card.add_css_class("flat");
|
||||
card.add_css_class("memory-card");
|
||||
|
||||
want_thumb(ui, &cover, &picture);
|
||||
let ui_click = ui.clone();
|
||||
card.connect_clicked(move |_| {
|
||||
// The timeline is newest first, so the day's newest photo is the first
|
||||
// one older than a second past it.
|
||||
ui_click.gallery.jump.set(Some(cover.capture_time + 1));
|
||||
continue_jump(&ui_click);
|
||||
});
|
||||
card
|
||||
}
|
||||
|
||||
/// Show the "On this day" strip only where it belongs: at the top of the whole,
|
||||
/// unfiltered timeline, and only when it has cards.
|
||||
fn sync_memories(ui: &Rc<Ui>) {
|
||||
let at_top = ui
|
||||
.gallery
|
||||
.list
|
||||
.vadjustment()
|
||||
.is_none_or(|adj| adj.value() < 1.0);
|
||||
let whole = ui.gallery.album.borrow().is_none()
|
||||
&& ui.gallery.kind.get().is_none()
|
||||
&& ui.gallery.range.get().is_none()
|
||||
&& !ui.gallery.favorites.get()
|
||||
&& !ui.gallery.not_in_album.get();
|
||||
ui.gallery
|
||||
.memories
|
||||
.set_reveal_child(whole && at_top && ui.gallery.has_memories.get());
|
||||
}
|
||||
|
||||
/// "March 2024" for the scrubber's month `index`.
|
||||
fn scrubber_label(months: &[PhotoMonth], index: usize) -> String {
|
||||
months
|
||||
@@ -2900,6 +3076,10 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
refresh_photo_months(ui);
|
||||
}
|
||||
sync_scrubber(ui);
|
||||
if album.is_none() {
|
||||
refresh_memories(ui);
|
||||
}
|
||||
sync_memories(ui);
|
||||
}
|
||||
let offset = ui.gallery.model.n_items() as usize;
|
||||
let limit = if ui.gallery.burst.replace(false) {
|
||||
@@ -3104,9 +3284,9 @@ pub(crate) fn play_external(path: &str) {
|
||||
mod tests {
|
||||
use super::{
|
||||
Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, empty_timeline_text,
|
||||
grouping_for, month_index, month_range, plan_grid, section_heading,
|
||||
grouping_for, memory_years, month_index, month_range, plan_grid, section_heading,
|
||||
};
|
||||
use pdfs_core::control::{PhotoKind, PhotoMonth};
|
||||
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth};
|
||||
|
||||
#[test]
|
||||
fn the_scrubber_finds_the_month_a_photo_was_taken_in() {
|
||||
@@ -3178,6 +3358,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The strip shows the nearest year first, with each year's newest photo
|
||||
/// as its cover, and never this year's own photos.
|
||||
#[test]
|
||||
fn on_this_day_cards_go_nearest_year_first() {
|
||||
let photo = |uid: &str, year: i32, hour: i32| {
|
||||
let Some((from, _)) = month_range(year, 6) else {
|
||||
panic!("June {year} is a month");
|
||||
};
|
||||
PhotoItem {
|
||||
uid: uid.into(),
|
||||
capture_time: from + 14 * 86_400 + i64::from(hour) * 3_600,
|
||||
thumb_path: None,
|
||||
name: None,
|
||||
ratio: None,
|
||||
no_thumb: false,
|
||||
kind: PhotoKind::Photo,
|
||||
favorite: false,
|
||||
group_size: 1,
|
||||
has_raw: false,
|
||||
}
|
||||
};
|
||||
// Newest first, the way the daemon sends them.
|
||||
let items = [
|
||||
photo("today", 2026, 9),
|
||||
photo("late", 2025, 18),
|
||||
photo("early", 2025, 8),
|
||||
photo("old", 2021, 12),
|
||||
];
|
||||
let years = memory_years(&items, 2026);
|
||||
let summary: Vec<(u32, Vec<&str>)> = years
|
||||
.iter()
|
||||
.map(|(ago, photos)| (*ago, photos.iter().map(|p| p.uid.as_str()).collect()))
|
||||
.collect();
|
||||
assert_eq!(summary, [(1, vec!["late", "early"]), (5, vec!["old"])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_filter_names_what_it_filtered() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in new issue
Block a user