From 4b669168ee0ca5aee7af03e0f4e16fd549df6e40 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 14 Nov 2026 15:25:26 +0100 Subject: [PATCH 1/2] feat(gui): drag photos out of the timeline, upload progress bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A drag that starts on a selected tile carries the selection out as a text/uri-list. PhotoDrag, a gdk::ContentProvider, downloads the photos only when the receiver reads the list, and hands over hard links (or copies) under their Drive names in the user cache; copies older than a day are pruned. Drag-select steps aside for such a drag, and the timeline's own drop target ignores drags from inside the app. - Uploading several photos shows "Uploading 3 of 12 items…" with a progress bar under the Photos header, replacing the start toast. Photo uploads report no byte progress from the daemon, so the bar counts files; per-tile rings would need that first. --- crates/pdfs-gui/src/app/main.rs | 9 + crates/pdfs-gui/src/app/pages/photos.rs | 160 ++++++- crates/pdfs-gui/src/app/widgets/mod.rs | 1 + crates/pdfs-gui/src/app/widgets/photo_drag.rs | 237 ++++++++++ docs/CHANGELOG.md | 7 + po/de.po | 440 ++++++++--------- po/en_GB.po | 440 ++++++++--------- po/es.po | 440 ++++++++--------- po/fr.po | 440 ++++++++--------- po/it.po | 440 ++++++++--------- po/nl.po | 440 ++++++++--------- po/pdfs.pot | 440 ++++++++--------- po/pl.po | 441 +++++++++--------- po/pt_BR.po | 440 ++++++++--------- 14 files changed, 2410 insertions(+), 1965 deletions(-) create mode 100644 crates/pdfs-gui/src/app/widgets/photo_drag.rs diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index ca889b9..894c778 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -40,6 +40,7 @@ use widgets::crumb::*; use widgets::details::*; use widgets::file_list::*; use widgets::menu::*; +use widgets::photo_drag::*; use widgets::scrubber::*; use widgets::share_dialog::*; use widgets::thumbnails::*; @@ -525,6 +526,7 @@ fn build_window(app: &adw::Application) { jump: Cell::new(None), burst: Cell::new(false), import_banner: gallery_widgets.import_banner.clone(), + upload_bar: gallery_widgets.upload_bar.clone(), upload: gallery_widgets.upload.clone(), empty_actions: gallery_widgets.empty_actions.clone(), title: gallery_widgets.title.clone(), @@ -2036,6 +2038,13 @@ mod tests { assert!(next_group_member(&[], "jpeg").is_none()); } + /// The upload bar counts the upload under way, not the ones finished. + #[test] + fn the_upload_bar_counts_the_upload_under_way() { + assert_eq!(upload_progress_text(0, 12), "Uploading 1 of 12 items…"); + assert_eq!(upload_progress_text(11, 12), "Uploading 12 of 12 items…"); + } + /// The pill names what kind of shot it switches through, and lights up /// on the file that is not the shot's main one. #[test] diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index e41e1ff..39f0777 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -37,6 +37,9 @@ pub(crate) struct GalleryState { pub(crate) burst: Cell, /// Says a Google Photos import is running, with a way to its page. pub(crate) import_banner: adw::Banner, + /// The bar that counts uploads from this computer through, under the + /// header while any run. + pub(crate) upload_bar: UploadBar, pub(crate) upload: adw::SplitButton, /// Upload / Import offered on the *empty timeline* status page, so a fresh /// account is a place to start rather than a dead end. Hidden on every other @@ -478,6 +481,9 @@ pub(crate) struct GalleryWidgets { pub(crate) scrubber: Scrubber, /// Says a Google Photos import is running, with a way to its page. pub(crate) import_banner: adw::Banner, + /// The bar that counts uploads from this computer through, under the + /// header while any run. + pub(crate) upload_bar: UploadBar, pub(crate) list: gtk4::ListView, pub(crate) scroll: gtk4::ScrolledWindow, pub(crate) retry: gtk4::Button, @@ -903,6 +909,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { .action_name("win.show-import") .build(); + let upload_bar = UploadBar::new(); + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); inner.set_margin_top(12); inner.set_margin_bottom(12); @@ -913,6 +921,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0); body.append(&import_banner); + body.append(&upload_bar.revealer); body.append(&inner); // The title slot holds the view switch on the page itself, the album's or // place's name inside one, and the count in selection mode (see @@ -943,6 +952,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { pager, scrubber, import_banner, + upload_bar, list, scroll, retry, @@ -1513,9 +1523,11 @@ pub(crate) fn wire_gallery( gtk4::gdk::DragAction::COPY, ); let ui_drop = ui.clone(); - drop.connect_accept(move |_, _| { + drop.connect_accept(move |_, drop| { let gallery = &ui_drop.gallery; - !gallery.in_collection() + // Photos dragged out of the timeline are already in it. + drop.drag().is_none() + && !gallery.in_collection() && gallery.view_switch.active() == VIEW_TIMELINE && gallery.library.get() && *ui_drop.mounted.borrow() @@ -1913,6 +1925,86 @@ pub(crate) fn copy_without_overwrite( unreachable!("some numbered name is free") } +/// How far the uploads from this computer have come: a count and a bar, so a +/// drop of a few hundred photos shows it is moving. Batches started while one +/// runs join its count. +#[derive(Clone)] +pub(crate) struct UploadBar { + pub(crate) revealer: gtk4::Revealer, + label: gtk4::Label, + progress: gtk4::ProgressBar, + /// Uploads finished, and uploads asked for, since the bar last emptied. + count: Rc>, +} + +impl UploadBar { + fn new() -> Self { + let label = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + let progress = gtk4::ProgressBar::builder().hexpand(true).build(); + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + content.set_margin_top(10); + content.set_margin_bottom(4); + content.set_margin_start(16); + content.set_margin_end(16); + content.append(&label); + content.append(&progress); + let revealer = gtk4::Revealer::builder() + .transition_type(gtk4::RevealerTransitionType::SlideDown) + .child(&content) + .build(); + Self { + revealer, + label, + progress, + count: Rc::default(), + } + } + + /// Count `n` more uploads in. + pub(crate) fn add(&self, n: usize) { + let (done, total) = self.count.get(); + self.count.set((done, total + n)); + self.paint(); + } + + /// Count one upload as finished, whether or not it went through. + pub(crate) fn finish(&self) { + let (done, total) = self.count.get(); + let done = (done + 1).min(total); + if done == total { + self.count.set((0, 0)); + self.revealer.set_reveal_child(false); + return; + } + self.count.set((done, total)); + self.paint(); + } + + fn paint(&self) { + let (done, total) = self.count.get(); + if total == 0 { + return; + } + self.label.set_label(&upload_progress_text(done, total)); + self.progress.set_fraction(done as f64 / total as f64); + self.revealer.set_reveal_child(true); + } +} + +/// "Uploading 3 of 12 items…": the upload under way is number `done + 1`. +pub(crate) fn upload_progress_text(done: usize, total: usize) -> String { + // Translators: progress above the photo timeline while photos upload; {position} is the upload under way, {n} how many there are. + ngettext_f( + "Uploading {position} of {n} item…", + "Uploading {position} of {n} items…", + total as u64, + &[("position", &thousands(done + 1))], + ) +} + /// Upload photos and videos from this computer into the timeline, one after /// the other, then reload it once. pub(crate) fn upload_media(ui: &Rc, paths: Vec) { @@ -1927,15 +2019,7 @@ pub(crate) fn upload_media(ui: &Rc, paths: Vec) { let total = uploads.len(); ui.busy_begin(); if total > 1 { - toast( - ui, - &ngettext_f( - "Uploading {n} item…", - "Uploading {n} items…", - total as u64, - &[], - ), - ); + ui.gallery.upload_bar.add(total); } let ui = ui.clone(); glib::spawn_future_local(async move { @@ -1973,6 +2057,9 @@ pub(crate) fn upload_media(ui: &Rc, paths: Vec) { )) } } + if total > 1 { + ui.gallery.upload_bar.finish(); + } } ui.busy_end(); if uploaded > 0 { @@ -2381,6 +2468,23 @@ impl TileWidget { }); button.add_controller(context); + // A picked photo drags out, with the rest of the selection, into a + // file manager or any app that takes files. A drag from anywhere else + // selects instead (see `wire_drag_select`). + let drag_out = gtk4::DragSource::new(); + drag_out.set_actions(gtk4::gdk::DragAction::COPY); + let ui_drag = ui.clone(); + let drag_uid = uid.clone(); + drag_out.connect_prepare(move |source, x, y| { + let photos = dragged_photos(&ui_drag, &drag_uid()?)?; + if let Some(tile) = source.widget() { + let icon = gtk4::WidgetPaintable::new(Some(&tile)); + source.set_icon(Some(&icon), x as i32, y as i32); + } + Some(PhotoDrag::new(ui_drag.dirs.control_socket(), photos).upcast()) + }); + button.add_controller(drag_out); + // A tile opens in the in-app lightbox, which plays a video in place. // In selection mode a tile picks instead of opening: the lightbox is // one Escape away. @@ -2478,6 +2582,23 @@ impl TileWidget { } } +/// The photos a drag that starts on `uid` carries out: the whole selection, +/// in timeline order, as uid and name. `None` unless `uid` is picked. +fn dragged_photos(ui: &Rc, uid: &str) -> Option)>> { + let selected = ui.gallery.selected.borrow(); + if !ui.gallery.selecting.get() || !selected.contains(uid) { + return None; + } + let model = &ui.gallery.model; + let photos: Vec<(String, Option)> = (0..model.n_items()) + .filter_map(|index| model.item(index).and_downcast::()) + .map(|boxed| boxed.borrow::().clone()) + .filter(|photo| selected.contains(&photo.uid)) + .map(|photo| (photo.uid, photo.name)) + .collect(); + (!photos.is_empty()).then_some(photos) +} + /// Add or remove one CSS class, for widgets that are rebound rather than /// rebuilt. fn set_css_class(widget: &impl IsA, class: &str, on: bool) { @@ -2556,6 +2677,9 @@ struct DragSelect { pointer: (f64, f64), /// The edge autoscroll, while the pointer is near the top or bottom. scroll: Option, + /// The drag started on a picked photo, so it carries the selection out + /// of the app rather than selecting. + out: bool, } /// Dragging across the timeline selects every photo from where the drag @@ -2572,21 +2696,33 @@ fn wire_drag_select(ui: &Rc) { let state = Rc::new(RefCell::new(DragSelect::default())); let state_begin = state.clone(); - drag.connect_drag_begin(move |_, x, y| { + let ui_begin = ui.clone(); + drag.connect_drag_begin(move |gesture, x, y| { let mut drag = state_begin.borrow_mut(); if let Some(source) = drag.scroll.take() { source.remove(); } + let out = ui_begin.gallery.selecting.get() + && tile_at(&ui_begin, x, y) + .is_some_and(|uid| ui_begin.gallery.selected.borrow().contains(&uid)); *drag = DragSelect { start: (x, y), pointer: (x, y), + out, ..DragSelect::default() }; + // Left to the tile's own drag source. + if out { + gesture.set_state(gtk4::EventSequenceState::Denied); + } }); let ui_update = ui.clone(); let state_update = state.clone(); drag.connect_drag_update(move |gesture, dx, dy| { + if state_update.borrow().out { + return; + } let (sx, sy) = state_update.borrow().start; state_update.borrow_mut().pointer = (sx + dx, sy + dy); if !state_update.borrow().active { diff --git a/crates/pdfs-gui/src/app/widgets/mod.rs b/crates/pdfs-gui/src/app/widgets/mod.rs index e0f5620..464b276 100644 --- a/crates/pdfs-gui/src/app/widgets/mod.rs +++ b/crates/pdfs-gui/src/app/widgets/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod crumb; pub(crate) mod details; pub(crate) mod file_list; pub(crate) mod menu; +pub(crate) mod photo_drag; pub(crate) mod scrubber; pub(crate) mod share_dialog; pub(crate) mod thumbnails; diff --git a/crates/pdfs-gui/src/app/widgets/photo_drag.rs b/crates/pdfs-gui/src/app/widgets/photo_drag.rs new file mode 100644 index 0000000..955a77f --- /dev/null +++ b/crates/pdfs-gui/src/app/widgets/photo_drag.rs @@ -0,0 +1,237 @@ +//! Dragging photos out of the timeline into a file manager or another app. +//! +//! A photo is not a file anywhere the receiver can read until it has been +//! downloaded, and a drag has to start the moment the pointer moves. So the +//! drag offers a list of files without having them: [`PhotoDrag`] downloads +//! the photos only when the receiver asks for the list, which it does once the +//! photos are dropped on it, and then hands over copies under their own names. + +use std::future::Future; +use std::pin::Pin; +use std::time::{Duration, SystemTime}; + +use gtk4::gdk; +use gtk4::gdk::subclass::prelude::*; + +use crate::*; + +/// The one format a drag of photos offers. GTK turns it into a file list for +/// a receiver in this app, and every file manager reads it. +const URI_LIST: &str = "text/uri-list"; + +/// How long the copies handed to a receiver are kept. The receiver copies +/// them on drop, so a day is far more than it needs. +const KEEP_DRAGGED: Duration = Duration::from_secs(24 * 60 * 60); + +mod imp { + use super::*; + + #[derive(Default)] + pub struct PhotoDrag { + /// The photos dragged, as uid and Drive name. + pub(super) photos: RefCell)>>, + pub(super) socket: RefCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for PhotoDrag { + const NAME: &'static str = "PdfsPhotoDrag"; + type Type = super::PhotoDrag; + type ParentType = gdk::ContentProvider; + } + + impl ObjectImpl for PhotoDrag {} + + impl ContentProviderImpl for PhotoDrag { + fn formats(&self) -> gdk::ContentFormats { + gdk::ContentFormats::new(&[URI_LIST]) + } + + fn write_mime_type_future( + &self, + mime_type: &str, + stream: &gio::OutputStream, + io_priority: glib::Priority, + ) -> Pin> + 'static>> { + let photos = self.photos.borrow().clone(); + let socket = self.socket.borrow().clone(); + let stream = stream.clone(); + let wanted = mime_type == URI_LIST; + Box::pin(async move { + if !wanted { + return Err(glib::Error::new( + gio::IOErrorEnum::NotSupported, + "photos are offered as a list of files only", + )); + } + let files = fetch_dragged(socket, photos).await?; + let list: String = files + .iter() + .map(|path| format!("{}\r\n", gio::File::for_path(path).uri())) + .collect(); + stream + .write_all_future(list.into_bytes(), io_priority) + .await + .map_err(|(_, e)| e)?; + Ok(()) + }) + } + } +} + +glib::wrapper! { + /// The content of a drag of photos: their files, fetched when dropped. + pub struct PhotoDrag(ObjectSubclass) + @extends gdk::ContentProvider; +} + +impl PhotoDrag { + pub(crate) fn new(socket: PathBuf, photos: Vec<(String, Option)>) -> Self { + let drag: Self = glib::Object::new(); + *drag.imp().photos.borrow_mut() = photos; + *drag.imp().socket.borrow_mut() = socket; + drag + } +} + +/// Download each dragged photo and put a copy under its Drive name where the +/// receiver can read it. A photo that fails is left out; only when every one +/// fails does the drop fail. +async fn fetch_dragged( + socket: PathBuf, + photos: Vec<(String, Option)>, +) -> Result, glib::Error> { + let base = glib::user_cache_dir().join("pdfs-drag"); + let mut files = Vec::new(); + let mut last_error = None; + for (uid, name) in photos { + let rx = spawn_request(socket.clone(), Request::OpenPhoto { uid: uid.clone() }); + let source = match rx.recv().await { + Ok(Ok(Response::FilePath { path })) => path, + Ok(Ok(Response::Error { message, .. })) => { + last_error = Some(message); + continue; + } + _ => { + last_error = Some("the Proton Drive service didn't respond".to_string()); + continue; + } + }; + let base = base.clone(); + let copied = gio::spawn_blocking(move || { + prune_dragged(&base, SystemTime::now()); + let name = name.unwrap_or_else(|| "photo".to_string()); + hand_over(&base, Path::new(&source), &uid, &name) + }) + .await; + match copied { + Ok(Ok(path)) => files.push(path), + Ok(Err(e)) => last_error = Some(e.to_string()), + Err(_) => last_error = Some("the copy was interrupted".to_string()), + } + } + if files.is_empty() { + let message = last_error.unwrap_or_else(|| "nothing to drop".to_string()); + tracing::warn!("dragging photos out failed: {message}"); + return Err(glib::Error::new(gio::IOErrorEnum::Failed, &message)); + } + Ok(files) +} + +/// Put the cached photo `source` at `base//`, where a receiver +/// copies it from. +/// +/// A hard link when the cache is on the same file system, so a large video is +/// not copied twice; a copy otherwise. Not a symlink: a file manager copies a +/// symlink as a link, which would leave the user a link into the cache. +pub(crate) fn hand_over( + base: &Path, + source: &Path, + uid: &str, + name: &str, +) -> std::io::Result { + let file_name = Path::new(name) + .file_name() + .ok_or_else(|| std::io::Error::other("the photo has no usable name"))?; + let dir = base.join(uid.replace('/', "_")); + std::fs::create_dir_all(&dir)?; + let dest = dir.join(file_name); + let _ = std::fs::remove_file(&dest); + if std::fs::hard_link(source, &dest).is_err() { + std::fs::copy(source, &dest)?; + } + Ok(dest) +} + +/// Remove the copies handed over more than [`KEEP_DRAGGED`] before `now`. +pub(crate) fn prune_dragged(base: &Path, now: SystemTime) { + let Ok(entries) = std::fs::read_dir(base) else { + return; + }; + for entry in entries.flatten() { + let old = entry + .metadata() + .and_then(|meta| meta.modified()) + .is_ok_and(|modified| { + now.duration_since(modified) + .is_ok_and(|age| age > KEEP_DRAGGED) + }); + if old { + let _ = std::fs::remove_dir_all(entry.path()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("pdfs-drag-test-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn a_dragged_photo_is_handed_over_under_its_own_name() { + let dir = scratch("name"); + let source = dir.join("0f3a9c"); + std::fs::write(&source, b"jpeg bytes").unwrap(); + let base = dir.join("out"); + + let path = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap(); + assert_eq!(path, base.join("vol~link").join("IMG_0001.JPG")); + assert_eq!(std::fs::read(&path).unwrap(), b"jpeg bytes"); + assert!(!path.is_symlink()); + + // Dragged again, it replaces the copy rather than failing on it. + std::fs::write(&source, b"new bytes").unwrap(); + let again = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap(); + assert_eq!(std::fs::read(again).unwrap(), b"new bytes"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_name_cannot_climb_out_of_its_folder() { + let dir = scratch("climb"); + let source = dir.join("blob"); + std::fs::write(&source, b"x").unwrap(); + let base = dir.join("out"); + let path = hand_over(&base, &source, "u", "../../escape.jpg").unwrap(); + assert_eq!(path, base.join("u").join("escape.jpg")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn only_old_handovers_are_pruned() { + let dir = scratch("prune"); + std::fs::create_dir_all(dir.join("fresh")).unwrap(); + prune_dragged(&dir, SystemTime::now()); + assert!(dir.join("fresh").exists()); + prune_dragged(&dir, SystemTime::now() + KEEP_DRAGGED * 2); + assert!(!dir.join("fresh").exists()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2d832f9..0c0d40c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -88,6 +88,13 @@ Existing folders stay device backups. Older builds cannot open the upgraded data slideshow. - The line under the date names the town the photo was taken in, when it is known. +- **Drag photos out of Photos.** Select photos, then drag one of them into a file manager, a chat + or any app that takes files. The photos download when dropped and arrive under their own + names. A drag that starts on a photo that is not selected still selects, as before. + +- **Upload progress in Photos.** Uploading several photos at once shows "Uploading 3 of 12 + items…" with a progress bar above the timeline, instead of a single toast at the start. + ### Changed - **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter diff --git a/po/de.po b/po/de.po index 725588f..288bc29 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-11-14 13:53+0100\n" +"POT-Creation-Date: 2026-11-14 15:22+0100\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -31,7 +31,7 @@ msgstr[1] "vor {n} Min." #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 #: crates/pdfs-gui/src/app/pages/activity.rs:840 -#: crates/pdfs-gui/src/app/pages/photos.rs:3571 +#: crates/pdfs-gui/src/app/pages/photos.rs:3707 msgid "%H:%M" msgstr "%H:%M" @@ -57,7 +57,7 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:876 +#: crates/pdfs-gui/src/app/main.rs:878 #: crates/pdfs-gui/src/app/pages/activity_text.rs:543 #: crates/pdfs-gui/src/app/pages/browser.rs:473 #: crates/pdfs-gui/src/app/pages/browser.rs:880 @@ -70,75 +70,75 @@ msgstr "%A, %-d. %B %Y, %H:%M" msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:877 crates/pdfs-gui/src/app/main.rs:1547 +#: crates/pdfs-gui/src/app/main.rs:879 crates/pdfs-gui/src/app/main.rs:1549 #: crates/pdfs-gui/src/app/pages/albums.rs:636 -#: crates/pdfs-gui/src/app/pages/photos.rs:920 +#: crates/pdfs-gui/src/app/pages/photos.rs:929 #: crates/pdfs-gui/src/app/pages/places.rs:473 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:880 +#: crates/pdfs-gui/src/app/main.rs:882 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:493 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:885 +#: crates/pdfs-gui/src/app/main.rs:887 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:888 +#: crates/pdfs-gui/src/app/main.rs:890 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:505 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:889 +#: crates/pdfs-gui/src/app/main.rs:891 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:891 +#: crates/pdfs-gui/src/app/main.rs:893 msgid "Sync" msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1241 +#: crates/pdfs-gui/src/app/main.rs:1243 #: crates/pdfs-gui/src/app/pages/photo_viewer.rs:915 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1264 +#: crates/pdfs-gui/src/app/main.rs:1266 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1265 +#: crates/pdfs-gui/src/app/main.rs:1267 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1266 +#: crates/pdfs-gui/src/app/main.rs:1268 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1267 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1268 +#: crates/pdfs-gui/src/app/main.rs:1270 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1355 +#: crates/pdfs-gui/src/app/main.rs:1357 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1363 +#: crates/pdfs-gui/src/app/main.rs:1365 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -150,23 +150,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1368 +#: crates/pdfs-gui/src/app/main.rs:1370 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1372 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1376 +#: crates/pdfs-gui/src/app/main.rs:1378 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1381 +#: crates/pdfs-gui/src/app/main.rs:1383 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1385 +#: crates/pdfs-gui/src/app/main.rs:1387 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -191,7 +191,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1395 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -211,7 +211,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -210,7 +210,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -34,7 +34,7 @@ msgstr[1] "" #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 #: crates/pdfs-gui/src/app/pages/activity.rs:840 -#: crates/pdfs-gui/src/app/pages/photos.rs:3571 +#: crates/pdfs-gui/src/app/pages/photos.rs:3707 msgid "%H:%M" msgstr "" @@ -60,7 +60,7 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:876 +#: crates/pdfs-gui/src/app/main.rs:878 #: crates/pdfs-gui/src/app/pages/activity_text.rs:543 #: crates/pdfs-gui/src/app/pages/browser.rs:473 #: crates/pdfs-gui/src/app/pages/browser.rs:880 @@ -73,75 +73,75 @@ msgstr "" msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:877 crates/pdfs-gui/src/app/main.rs:1547 +#: crates/pdfs-gui/src/app/main.rs:879 crates/pdfs-gui/src/app/main.rs:1549 #: crates/pdfs-gui/src/app/pages/albums.rs:636 -#: crates/pdfs-gui/src/app/pages/photos.rs:920 +#: crates/pdfs-gui/src/app/pages/photos.rs:929 #: crates/pdfs-gui/src/app/pages/places.rs:473 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:880 +#: crates/pdfs-gui/src/app/main.rs:882 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:493 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:885 +#: crates/pdfs-gui/src/app/main.rs:887 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:888 +#: crates/pdfs-gui/src/app/main.rs:890 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:505 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:889 +#: crates/pdfs-gui/src/app/main.rs:891 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:891 +#: crates/pdfs-gui/src/app/main.rs:893 msgid "Sync" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1241 +#: crates/pdfs-gui/src/app/main.rs:1243 #: crates/pdfs-gui/src/app/pages/photo_viewer.rs:915 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1264 +#: crates/pdfs-gui/src/app/main.rs:1266 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1265 +#: crates/pdfs-gui/src/app/main.rs:1267 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1266 +#: crates/pdfs-gui/src/app/main.rs:1268 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1267 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1268 +#: crates/pdfs-gui/src/app/main.rs:1270 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1355 +#: crates/pdfs-gui/src/app/main.rs:1357 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1363 +#: crates/pdfs-gui/src/app/main.rs:1365 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -149,23 +149,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1368 +#: crates/pdfs-gui/src/app/main.rs:1370 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1372 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1376 +#: crates/pdfs-gui/src/app/main.rs:1378 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1381 +#: crates/pdfs-gui/src/app/main.rs:1383 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1385 +#: crates/pdfs-gui/src/app/main.rs:1387 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -178,21 +178,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1395 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -212,7 +212,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the Date: Sat, 14 Nov 2026 15:33:41 +0100 Subject: [PATCH 2/2] fix(gui): keep paging the timeline when zoomed out onto one screen Zoomed all the way out, a 200-photo page fits on one screen. With nothing to scroll, the scroll handler never asked for the next page and the timeline stopped after the first rows. Ask for more whenever the content no longer fills a screen and a half, also after a zoom step or a resize, not only when a page lands. --- crates/pdfs-gui/src/app/pages/photos.rs | 40 +++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 39f0777..659b6a5 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -1394,6 +1394,13 @@ pub(crate) fn wire_gallery( sync_float_heading(&ui_scroll); ui_scroll.gallery.scrubber.flash(); }); + // Zooming out or making the window taller can leave every loaded photo on + // one screen. Nothing scrolls then, so the scrolling above never asks for + // the next page: ask whenever the content shrinks under the viewport. + let ui_fill = ui.clone(); + scroll + .vadjustment() + .connect_changed(move |_| fill_viewport(&ui_fill)); wire_scrubber(ui); wire_drag_select(ui); @@ -4090,17 +4097,32 @@ fn continue_jump(ui: &Rc) { /// 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) { + if !ui.gallery.has_more.get() || ui.gallery.loading.get() { + return; + } let ui = ui.clone(); glib::idle_add_local_once(move || { let Some(adj) = ui.gallery.list.vadjustment() else { return; }; - if ui.gallery.has_more.get() && adj.upper() <= adj.page_size() + 1.0 { + let timeline = ui.gallery.content.visible_child_name().as_deref() == Some("timeline"); + if timeline + && ui.gallery.has_more.get() + && adj.page_size() > 0.0 + && viewport_unfilled(adj.upper(), adj.page_size()) + { load_gallery(&ui, true); } }); } +/// Whether content `upper` px tall leaves a viewport `page_size` px tall short +/// of a screen and a half to scroll through, the margin the scrolling pager +/// keeps. Below it the pager would never fire. +pub(crate) fn viewport_unfilled(upper: f64, page_size: f64) -> bool { + upper <= page_size * 2.5 + 1.0 +} + /// Select every photo loaded so far (Ctrl+A). pub(crate) fn select_all_photos(ui: &Rc) { set_selection_mode(ui, true); @@ -4494,10 +4516,10 @@ pub(crate) fn play_external(path: &str) { #[cfg(test)] mod tests { use super::{ - Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX, + Grouping, PHOTOS_PAGE, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX, TILE_RATIO_MIN, active_filters, copy_without_overwrite, empty_timeline_text, grouping_for, memory_years, month_index, month_range, plan_grid, plan_justified, section_heading, - tile_ratio, + tile_ratio, viewport_unfilled, }; use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth}; @@ -4620,6 +4642,18 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// A first page zoomed all the way out fits on one screen, with nothing + /// to scroll: the next page has to load without a scroll asking for it. + #[test] + fn a_page_zoomed_out_onto_one_screen_asks_for_more() { + let (columns, size) = plan_grid(1580, ROW_MIN); + let rows = PHOTOS_PAGE.div_ceil(columns) as i32; + let height = f64::from(rows * (size + TILE_GAP) + 40); + assert!(viewport_unfilled(height, 880.0)); + assert!(viewport_unfilled(880.0, 880.0)); + assert!(!viewport_unfilled(880.0 * 3.0, 880.0)); + } + #[test] fn zooming_in_puts_fewer_tiles_in_a_row() { assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0);