From fdda35918ff8031d9a370945136741bd5e0d161b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 23 Sep 2026 02:26:42 +0200 Subject: [PATCH] feat(gui): photos context menu, filter empty states and import banner (phase 5) --- crates/pdfs-gui/src/app/main.rs | 8 + crates/pdfs-gui/src/app/pages/photo_viewer.rs | 2 +- crates/pdfs-gui/src/app/pages/photos.rs | 270 +++++++++++++++++- crates/pdfs-gui/src/app/pages/status.rs | 3 + docs/CHANGELOG.md | 5 + 5 files changed, 275 insertions(+), 13 deletions(-) diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 46b447f..9194ecf 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -459,6 +459,7 @@ fn build_window(app: &adw::Application) { status: gallery_widgets.status.clone(), retry: gallery_widgets.retry.clone(), more: gallery_widgets.more.clone(), + import_banner: gallery_widgets.import_banner.clone(), upload: gallery_widgets.upload.clone(), import: gallery_widgets.import.clone(), empty_actions: gallery_widgets.empty_actions.clone(), @@ -1126,6 +1127,12 @@ fn install_window_actions(ui: &Rc, window: &adw::ApplicationWindow) { sign_out_action.connect_activate(move |_, _| sign_out(&ui_out)); window.add_action(&sign_out_action); + // The Photos page's import banner leads here. + let show_import = gio::SimpleAction::new("show-import", None); + let ui_import = ui.clone(); + show_import.connect_activate(move |_, _| ui_import.stack.set_visible_child_name("takeout")); + window.add_action(&show_import); + let shortcuts = gio::SimpleAction::new("shortcuts", None); let win = window.clone(); shortcuts.connect_activate(move |_, _| show_shortcuts(&win)); @@ -1208,6 +1215,7 @@ fn show_shortcuts(window: &adw::ApplicationWindow) { ("plus", "Larger thumbnails"), ("minus", "Smaller thumbnails"), ("0", "Reset thumbnail size"), + ("a", "Select all"), ], ), ( diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs index 8ceca95..cefe42b 100644 --- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -70,7 +70,7 @@ fn set_favorite_icon(button: >k4::ToggleButton, favorite: bool) { /// Write a favourite change back into the loaded gallery page, so returning to /// the grid (or reopening the photo) shows what was just set without a reload. -fn set_gallery_favorite(ui: &Rc, uid: &str, favorite: bool) { +pub(crate) fn set_gallery_favorite(ui: &Rc, uid: &str, favorite: bool) { let Some(idx) = find_photo_index(&ui.gallery.model, uid) else { return; }; diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index b8bd46e..c30646e 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -32,6 +32,8 @@ pub(crate) struct GalleryState { pub(crate) status: adw::StatusPage, pub(crate) retry: gtk4::Button, pub(crate) more: gtk4::Button, + /// Says a Google Photos import is running, with a way to its page. + pub(crate) import_banner: adw::Banner, pub(crate) upload: gtk4::Button, /// Opens the Google Photos Takeout import chooser. pub(crate) import: gtk4::Button, @@ -150,7 +152,7 @@ pub(crate) struct GalleryState { } /// How many photos to pull per [`Request::PhotosTimeline`] page. -pub(crate) const PHOTOS_PAGE: usize = 60; +pub(crate) const PHOTOS_PAGE: usize = 200; /// 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 @@ -284,6 +286,8 @@ pub(crate) struct GalleryWidgets { pub(crate) status: adw::StatusPage, pub(crate) title: adw::WindowTitle, pub(crate) more: gtk4::Button, + /// Says a Google Photos import is running, with a way to its page. + pub(crate) import_banner: adw::Banner, pub(crate) list: gtk4::ListView, pub(crate) scroll: gtk4::ScrolledWindow, pub(crate) retry: gtk4::Button, @@ -521,8 +525,9 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { // The page's two views, as one segmented control under the title: the // timeline, and the albums. This is navigation, not a filter — which is why // it sits above the filter row rather than beside the kind toggles. + // "Timeline", not "Photos": the kind filter below has a Photos tab too. let photos_btn = gtk4::ToggleButton::builder() - .label("Photos") + .label("Timeline") .active(true) .build(); let albums_btn = gtk4::ToggleButton::builder().label("Albums").build(); @@ -577,6 +582,12 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { content.add_named(&status, Some("status")); content.add_named(&albums_stack, Some("albums")); + let import_banner = adw::Banner::builder() + .title("Importing from Google Photos…") + .button_label("View") + .action_name("win.show-import") + .build(); + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); inner.set_margin_top(12); inner.set_margin_bottom(12); @@ -586,7 +597,10 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { inner.append(&filter_bar); inner.append(&content); - let (frame, header, title) = page_frame("Photos", &inner); + let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + body.append(&import_banner); + body.append(&inner); + let (frame, header, title) = page_frame("Photos", &body); header.pack_start(&back); header.pack_start(&upload); header.pack_end(&refresh); @@ -602,6 +616,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { status, title, more, + import_banner, list, scroll, retry, @@ -900,6 +915,7 @@ pub(crate) fn wire_gallery( Some("plus" | "equal" | "KP_Add") => zoom_gallery(&ui_keys, ROW_STEP), Some("minus" | "KP_Subtract") => zoom_gallery(&ui_keys, -ROW_STEP), Some("0" | "KP_0") => set_gallery_tile(&ui_keys, ROW_DEFAULT), + Some("a" | "A") => select_all_photos(&ui_keys), _ => return glib::Propagation::Proceed, } glib::Propagation::Stop @@ -1343,6 +1359,17 @@ pub(crate) fn photo_tile(ui: &Rc, tile: Tile) -> gtk4::Button { }); button.add_controller(modifier); + let context = gtk4::GestureClick::builder().button(3).build(); + let ui_context = ui.clone(); + let photo = tile.photo.clone(); + context.connect_pressed(move |gesture, _, x, y| { + gesture.set_state(gtk4::EventSequenceState::Claimed); + if let Some(anchor) = gesture.widget().and_downcast::() { + show_photo_menu(&ui_context, &photo, &anchor, x, y); + } + }); + button.add_controller(context); + // A still opens in the in-app lightbox; a video can't render there, so it // downloads and hands off to an external player instead. In selection mode // a tile picks instead of opening: the lightbox is one Escape away. @@ -1424,6 +1451,11 @@ fn selected_file_count(ui: &Rc, uids: &[String]) -> usize { pub(crate) fn delete_selected(ui: &Rc) { let uids: Vec = ui.gallery.selected.borrow().iter().cloned().collect(); + confirm_trash_photos(ui, uids); +} + +/// Confirm, then move the photos behind `uids` to Proton trash. +pub(crate) fn confirm_trash_photos(ui: &Rc, uids: Vec) { if uids.is_empty() { return; } @@ -2086,6 +2118,175 @@ pub(crate) fn short_capture_time(secs: i64) -> String { /// Fetch a timeline page from the daemon. When `append` is false the model is /// cleared first (fresh load); otherwise the next page is tacked on. +/// The empty state for a timeline filtered to `kind`, favourites and/or a +/// `month`: what is missing, in the filter's own words. +pub(crate) fn empty_timeline_text( + kind: Option, + favorites: bool, + month: Option<&str>, +) -> (String, String) { + let what = match kind { + None => "photos", + Some(PhotoKind::Photo) => "photos", + Some(PhotoKind::Video) => "videos", + Some(PhotoKind::Raw) => "raw files", + }; + let when = month.map(|m| format!(" in {m}")).unwrap_or_default(); + match (favorites, kind, month) { + (false, None, None) => ( + "No photos yet".to_string(), + "Photos you upload to Proton Drive appear here.".to_string(), + ), + (true, None, None) => ( + "No favourites yet".to_string(), + "Star a photo in the viewer or from its menu to find it here.".to_string(), + ), + (true, _, _) => ( + format!("No favourite {what}{when}"), + "Turn off the favourites filter to see everything.".to_string(), + ), + (false, _, _) => ( + format!("No {what}{when}"), + "Try another filter or month.".to_string(), + ), + } +} + +/// Load the next page when the timeline does not fill the window yet: with no +/// scrollbar there is no scrolling to trigger it. +fn fill_viewport(ui: &Rc) { + let ui = ui.clone(); + glib::idle_add_local_once(move || { + let Some(adj) = ui.gallery.list.vadjustment() else { + return; + }; + if ui.gallery.more.is_visible() + && ui.gallery.more.is_sensitive() + && adj.upper() <= adj.page_size() + 1.0 + { + load_gallery(&ui, true); + } + }); +} + +/// Select every photo loaded so far (Ctrl+A). +pub(crate) fn select_all_photos(ui: &Rc) { + set_selection_mode(ui, true); + { + let mut selected = ui.gallery.selected.borrow_mut(); + for idx in 0..ui.gallery.model.n_items() { + if let Some(boxed) = ui.gallery.model.item(idx).and_downcast::() { + selected.insert(boxed.borrow::().uid.clone()); + } + } + } + sync_selection_bar(ui); + repaint_gallery(ui); +} + +/// A tile's right-click menu. +fn show_photo_menu(ui: &Rc, photo: &PhotoItem, anchor: >k4::Button, x: f64, y: f64) { + let menu = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + let popover = gtk4::Popover::builder() + .has_arrow(false) + .position(gtk4::PositionType::Bottom) + .pointing_to(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)) + .child(&menu) + .build(); + popover.set_parent(anchor); + popover.connect_closed(|p| p.unparent()); + let item = |label: &str, icon: &str, run: Box| { + let button = menu_item(label, icon); + let pop = popover.clone(); + button.connect_clicked(move |_| { + pop.popdown(); + run(); + }); + menu.append(&button); + }; + + let (ui_c, uid) = (ui.clone(), photo.uid.clone()); + if photo.kind == PhotoKind::Video { + item( + "Play", + "media-playback-start-symbolic", + Box::new(move || play_video(&ui_c, uid.clone())), + ); + } else { + item( + "Open", + "document-open-symbolic", + Box::new(move || open_photo_viewer(&ui_c, uid.clone())), + ); + } + let (ui_c, uid, favorite) = (ui.clone(), photo.uid.clone(), photo.favorite); + item( + if favorite { + "Remove from Favourites" + } else { + "Add to Favourites" + }, + if favorite { + "non-starred-symbolic" + } else { + "starred-symbolic" + }, + Box::new(move || set_photo_favorite(&ui_c, uid.clone(), !favorite)), + ); + let (ui_c, uid) = (ui.clone(), photo.uid.clone()); + item( + "Select", + "selection-mode-symbolic", + Box::new(move || { + set_selection_mode(&ui_c, true); + toggle_selected(&ui_c, &uid); + }), + ); + let (ui_c, uid) = (ui.clone(), photo.uid.clone()); + item( + "Move to Trash…", + "user-trash-symbolic", + Box::new(move || confirm_trash_photos(&ui_c, vec![uid.clone()])), + ); + popover.popup(); +} + +/// Star or unstar a photo from the grid, and show the new state. +fn set_photo_favorite(ui: &Rc, uid: String, favorite: bool) { + let rx = spawn_request( + ui.dirs.control_socket(), + Request::SetPhotoFavorite { + uid: uid.clone(), + favorite, + }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Ok { .. })) => { + set_gallery_favorite(&ui, &uid, favorite); + repaint_gallery(&ui); + toast( + &ui, + if favorite { + "Added to Favourites" + } else { + "Removed from Favourites" + }, + ); + } + Ok(Ok(Response::Error { message, .. })) => { + toast_error(&ui, "Couldn't change the favourite", &message) + } + _ => toast_error( + &ui, + "Couldn't change the favourite", + "The mount service didn't respond.", + ), + } + }); +} + pub(crate) fn load_gallery(ui: &Rc, append: bool) { if ui.gallery.loading.get() { return; @@ -2171,26 +2372,51 @@ pub(crate) fn load_gallery(ui: &Rc, append: bool) { } repaint_gallery(&ui); if ui.gallery.model.n_items() == 0 { + let filtered = ui.gallery.kind.get().is_some() + || ui.gallery.favorites.get() + || ui.gallery.range.get().is_some(); let (title, description) = if ui.gallery.album.borrow().is_some() { - ("Empty album", "This album has no photos in it.") - } else { ( - "No photos yet", - "Photos you upload to Proton Drive appear here.", + "Empty album".to_string(), + "This album has no photos in it.".to_string(), + ) + } else { + let month = ui + .gallery + .range + .get() + .and_then(|_| ui.gallery.dates.selected_item()) + .and_downcast::() + .map(|month| month.string().to_string()); + empty_timeline_text( + ui.gallery.kind.get(), + ui.gallery.favorites.get(), + month.as_deref(), ) }; - gallery_status(&ui, "image-x-generic-symbolic", title, description, false); - // Only the whole-timeline empty state: an empty *album* is - // filled by adding existing photos to it, not by uploading - // into it, so the buttons would point the wrong way. + gallery_status( + &ui, + if ui.gallery.favorites.get() { + "starred-symbolic" + } else { + "image-x-generic-symbolic" + }, + &title, + &description, + false, + ); + // Only the whole, unfiltered timeline offers Upload: an + // empty *album* is filled by adding existing photos to it, + // and an empty filter is not a library that needs photos. ui.gallery .empty_actions - .set_visible(ui.gallery.album.borrow().is_none()); + .set_visible(ui.gallery.album.borrow().is_none() && !filtered); return; } 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); } // A failed *next* page keeps the photos already on screen — the failure // goes to a toast rather than wiping the timeline for a status page. @@ -2305,3 +2531,23 @@ pub(crate) fn play_video(ui: &Rc, uid: String) { } }); } + +#[cfg(test)] +mod tests { + use super::empty_timeline_text; + use pdfs_core::control::PhotoKind; + + #[test] + fn an_empty_filter_names_what_it_filtered() { + assert_eq!(empty_timeline_text(None, false, None).0, "No photos yet"); + assert_eq!(empty_timeline_text(None, true, None).0, "No favourites yet"); + assert_eq!( + empty_timeline_text(Some(PhotoKind::Video), false, Some("June 2024")).0, + "No videos in June 2024" + ); + assert_eq!( + empty_timeline_text(Some(PhotoKind::Raw), true, None).0, + "No favourite raw files" + ); + } +} diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index 35ac6bc..c1f85ff 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -608,6 +608,9 @@ pub(crate) fn refresh(ui: &Rc) { // A Takeout import outlives the page that started it, so it is polled // wherever the user has navigated to — otherwise leaving the page means // never being told it finished. + ui.gallery + .import_banner + .set_revealed(ui.takeout.running.get()); if ui.stack.visible_child_name().as_deref() == Some("takeout") || ui.takeout.running.get() { refresh_takeout(ui); } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2cb3057..ac28f5c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -41,6 +41,11 @@ Schema: **30** (unchanged). whole selection. Rename selects the name without its extension. - **Drop to upload.** Files and folders dropped on My Files upload into the folder on screen. The upload toast links to the Sync page. +- **Photos.** The main view is called Timeline. Right-clicking a photo opens a menu to open it, + favourite it, select it or move it to Trash; Ctrl+A selects every loaded photo. An empty filter + says what it filtered ("No videos in June 2024") instead of the first-run hint. A banner shows + while a Takeout import runs and links to it. Pages load 200 photos at a time and keep loading + until the window is full. ## [1.11.1] — 2026-09-19