feat(gui): drag photos out of the timeline, upload progress bar

- 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.
This commit is contained in:
nvrl committed 2026-11-14 15:25:26 +01:00
1 parent 41212efdf3
commit 4b669168ee
14 files changed
+2410 -1965

No files matched your search

+9
View File
@@ -40,6 +40,7 @@ use widgets::crumb::*;
use widgets::details::*; use widgets::details::*;
use widgets::file_list::*; use widgets::file_list::*;
use widgets::menu::*; use widgets::menu::*;
use widgets::photo_drag::*;
use widgets::scrubber::*; use widgets::scrubber::*;
use widgets::share_dialog::*; use widgets::share_dialog::*;
use widgets::thumbnails::*; use widgets::thumbnails::*;
@@ -525,6 +526,7 @@ fn build_window(app: &adw::Application) {
jump: Cell::new(None), jump: Cell::new(None),
burst: Cell::new(false), burst: Cell::new(false),
import_banner: gallery_widgets.import_banner.clone(), import_banner: gallery_widgets.import_banner.clone(),
upload_bar: gallery_widgets.upload_bar.clone(),
upload: gallery_widgets.upload.clone(), upload: gallery_widgets.upload.clone(),
empty_actions: gallery_widgets.empty_actions.clone(), empty_actions: gallery_widgets.empty_actions.clone(),
title: gallery_widgets.title.clone(), title: gallery_widgets.title.clone(),
@@ -2036,6 +2038,13 @@ mod tests {
assert!(next_group_member(&[], "jpeg").is_none()); 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 /// The pill names what kind of shot it switches through, and lights up
/// on the file that is not the shot's main one. /// on the file that is not the shot's main one.
#[test] #[test]
+148 -12
View File
@@ -37,6 +37,9 @@ pub(crate) struct GalleryState {
pub(crate) burst: Cell<bool>, 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,
/// 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, pub(crate) upload: adw::SplitButton,
/// Upload / Import offered on the *empty timeline* status page, so a fresh /// 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 /// 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, pub(crate) scrubber: Scrubber,
/// 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,
/// 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) list: gtk4::ListView,
pub(crate) scroll: gtk4::ScrolledWindow, pub(crate) scroll: gtk4::ScrolledWindow,
pub(crate) retry: gtk4::Button, pub(crate) retry: gtk4::Button,
@@ -903,6 +909,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.action_name("win.show-import") .action_name("win.show-import")
.build(); .build();
let upload_bar = UploadBar::new();
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12); inner.set_margin_top(12);
inner.set_margin_bottom(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); let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
body.append(&import_banner); body.append(&import_banner);
body.append(&upload_bar.revealer);
body.append(&inner); body.append(&inner);
// The title slot holds the view switch on the page itself, the album's or // 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 // 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, pager,
scrubber, scrubber,
import_banner, import_banner,
upload_bar,
list, list,
scroll, scroll,
retry, retry,
@@ -1513,9 +1523,11 @@ pub(crate) fn wire_gallery(
gtk4::gdk::DragAction::COPY, gtk4::gdk::DragAction::COPY,
); );
let ui_drop = ui.clone(); let ui_drop = ui.clone();
drop.connect_accept(move |_, _| { drop.connect_accept(move |_, drop| {
let gallery = &ui_drop.gallery; 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.view_switch.active() == VIEW_TIMELINE
&& gallery.library.get() && gallery.library.get()
&& *ui_drop.mounted.borrow() && *ui_drop.mounted.borrow()
@@ -1913,6 +1925,86 @@ pub(crate) fn copy_without_overwrite(
unreachable!("some numbered name is free") 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<Cell<(usize, usize)>>,
}
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 /// Upload photos and videos from this computer into the timeline, one after
/// the other, then reload it once. /// the other, then reload it once.
pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) { pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
@@ -1927,15 +2019,7 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
let total = uploads.len(); let total = uploads.len();
ui.busy_begin(); ui.busy_begin();
if total > 1 { if total > 1 {
toast( ui.gallery.upload_bar.add(total);
ui,
&ngettext_f(
"Uploading {n} item…",
"Uploading {n} items…",
total as u64,
&[],
),
);
} }
let ui = ui.clone(); let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
@@ -1973,6 +2057,9 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
)) ))
} }
} }
if total > 1 {
ui.gallery.upload_bar.finish();
}
} }
ui.busy_end(); ui.busy_end();
if uploaded > 0 { if uploaded > 0 {
@@ -2381,6 +2468,23 @@ impl TileWidget {
}); });
button.add_controller(context); 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. // 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 // In selection mode a tile picks instead of opening: the lightbox is
// one Escape away. // 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<Ui>, uid: &str) -> Option<Vec<(String, Option<String>)>> {
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<String>)> = (0..model.n_items())
.filter_map(|index| model.item(index).and_downcast::<BoxedAnyObject>())
.map(|boxed| boxed.borrow::<PhotoItem>().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 /// Add or remove one CSS class, for widgets that are rebound rather than
/// rebuilt. /// rebuilt.
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) { fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
@@ -2556,6 +2677,9 @@ struct DragSelect {
pointer: (f64, f64), pointer: (f64, f64),
/// The edge autoscroll, while the pointer is near the top or bottom. /// The edge autoscroll, while the pointer is near the top or bottom.
scroll: Option<glib::SourceId>, scroll: Option<glib::SourceId>,
/// 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 /// Dragging across the timeline selects every photo from where the drag
@@ -2572,21 +2696,33 @@ fn wire_drag_select(ui: &Rc<Ui>) {
let state = Rc::new(RefCell::new(DragSelect::default())); let state = Rc::new(RefCell::new(DragSelect::default()));
let state_begin = state.clone(); 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(); let mut drag = state_begin.borrow_mut();
if let Some(source) = drag.scroll.take() { if let Some(source) = drag.scroll.take() {
source.remove(); 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 { *drag = DragSelect {
start: (x, y), start: (x, y),
pointer: (x, y), pointer: (x, y),
out,
..DragSelect::default() ..DragSelect::default()
}; };
// Left to the tile's own drag source.
if out {
gesture.set_state(gtk4::EventSequenceState::Denied);
}
}); });
let ui_update = ui.clone(); let ui_update = ui.clone();
let state_update = state.clone(); let state_update = state.clone();
drag.connect_drag_update(move |gesture, dx, dy| { drag.connect_drag_update(move |gesture, dx, dy| {
if state_update.borrow().out {
return;
}
let (sx, sy) = state_update.borrow().start; let (sx, sy) = state_update.borrow().start;
state_update.borrow_mut().pointer = (sx + dx, sy + dy); state_update.borrow_mut().pointer = (sx + dx, sy + dy);
if !state_update.borrow().active { if !state_update.borrow().active {
+1
View File
@@ -3,6 +3,7 @@ pub(crate) mod crumb;
pub(crate) mod details; pub(crate) mod details;
pub(crate) mod file_list; pub(crate) mod file_list;
pub(crate) mod menu; pub(crate) mod menu;
pub(crate) mod photo_drag;
pub(crate) mod scrubber; pub(crate) mod scrubber;
pub(crate) mod share_dialog; pub(crate) mod share_dialog;
pub(crate) mod thumbnails; pub(crate) mod thumbnails;
@@ -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<Vec<(String, Option<String>)>>,
pub(super) socket: RefCell<PathBuf>,
}
#[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<Box<dyn Future<Output = Result<(), glib::Error>> + '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<imp::PhotoDrag>)
@extends gdk::ContentProvider;
}
impl PhotoDrag {
pub(crate) fn new(socket: PathBuf, photos: Vec<(String, Option<String>)>) -> 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<String>)>,
) -> Result<Vec<PathBuf>, 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/<uid>/<name>`, 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<PathBuf> {
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);
}
}
+7
View File
@@ -88,6 +88,13 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
slideshow. slideshow.
- The line under the date names the town the photo was taken in, when it is known. - 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 ### Changed
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter - **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
+223 -217
View File
File diff suppressed because it is too large. Load diff
+223 -217
View File
File diff suppressed because it is too large. Load diff
+223 -217
View File
File diff suppressed because it is too large. Load diff
+223 -217
View File
File diff suppressed because it is too large. Load diff
+223 -217
View File
File diff suppressed because it is too large. Load diff
+223 -217
View File
File diff suppressed because it is too large. Load diff
+223 -217
View File
File diff suppressed because it is too large. Load diff
+224 -217
View File
File diff suppressed because it is too large. Load diff
+223 -217
View File
File diff suppressed because it is too large. Load diff