From 8f28f5fd22620a78392078eee8bc34a79701bc10 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 13 Jul 2026 02:15:08 +0200 Subject: [PATCH] improvements to the photos gallery --- Cargo.lock | 28 +- Cargo.toml | 5 +- crates/pdfs-cli/src/main.rs | 44 +- crates/pdfs-core/src/auth.rs | 39 ++ crates/pdfs-gui/Cargo.toml | 1 + crates/pdfs-gui/src/app.rs | 765 ++++++++++++++++++++++++---------- crates/pdfs-gui/src/prompt.rs | 35 +- packaging/PKGBUILD | 2 +- 8 files changed, 641 insertions(+), 278 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b58ab84..5c4ca83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2278,6 +2278,15 @@ dependencies = [ "signature", ] +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + [[package]] name = "keccak" version = "0.1.6" @@ -2502,6 +2511,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + [[package]] name = "nix" version = "0.29.0" @@ -2825,7 +2840,7 @@ dependencies = [ [[package]] name = "pdfs-cli" -version = "0.2.4" +version = "0.2.5" dependencies = [ "anyhow", "clap 4.6.1", @@ -2840,7 +2855,7 @@ dependencies = [ [[package]] name = "pdfs-core" -version = "0.2.4" +version = "0.2.5" dependencies = [ "directories", "keyring", @@ -2857,7 +2872,7 @@ dependencies = [ [[package]] name = "pdfs-fuse" -version = "0.2.4" +version = "0.2.5" dependencies = [ "fuser", "libc", @@ -2870,11 +2885,12 @@ dependencies = [ [[package]] name = "pdfs-gui" -version = "0.2.4" +version = "0.2.5" dependencies = [ "anyhow", "async-channel", "gtk4", + "kamadak-exif", "ksni", "libadwaita", "pdfs-core", @@ -3086,8 +3102,6 @@ dependencies = [ [[package]] name = "proton-drive-rs" version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2233c256d048c94bc6e59d5332b908e69852ee16690840d02d44e8b3c708aece" dependencies = [ "async-trait", "base64", @@ -3111,8 +3125,6 @@ dependencies = [ [[package]] name = "proton-sdk" version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a415594c96d3ca2d9c0db9fbd63b645447ed3565b0d683b5a403b42f54a64818" dependencies = [ "aes-gcm", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 50bfab8..ea4bd19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] [workspace.package] -version = "0.2.4" +version = "0.2.5" edition = "2024" license = "MIT" authors = ["Nils Pukropp "] @@ -33,3 +33,6 @@ rpassword = "7" fuser = "0.17" libc = "0.2" +[patch.crates-io] +proton-sdk = { path = "../proton-sdk-rs/crates/proton-sdk" } +proton-drive-rs = { path = "../proton-sdk-rs/crates/proton-drive-rs" } diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 3488513..c60fef0 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -292,39 +292,11 @@ fn mount_once(mountpoint: Option) -> Result { .block_on(auth::resume_client()) .context("resume session (run `pdfs login` first)")?; - // Persist rotated tokens for the daemon's lifetime. Proton refresh tokens are - // single-use: the 401-refresh path swaps in a new access+refresh pair, and if - // that pair is never written back the keyring keeps a now-dead refresh token, - // so the next `pdfs mount` fails with `InvalidRefreshToken`. The session - // shares its token store with the Drive client (one `Arc`-backed token store), - // so both the poll below and the shutdown flush see every rotation. - // - // `ProtonApiSession` is `Clone` over that shared store, so the polling task - // gets its own handle and we keep `session` here for a final flush. The poll - // is a backstop for long-lived mounts; the flush after `mount` returns is what - // makes the common case correct — a refresh shortly before a clean unmount - // would otherwise be lost in the poll gap, leaving a dead token behind. - let poll_session = session.clone(); - rt.spawn(async move { - let mut last = poll_session.current_tokens().await; - loop { - tokio::time::sleep(std::time::Duration::from_secs(10)).await; - let current = poll_session.current_tokens().await; - if current.access_token == last.access_token - && current.refresh_token == last.refresh_token - { - continue; - } - match auth::persist(&poll_session).await { - Ok(()) => { - tracing::info!("persisted refreshed session tokens"); - last = current; - } - Err(e) => tracing::warn!(error = %e, "failed to persist refreshed tokens"), - } - } - }); - + // Token updates are persisted automatically in real-time. The SDK session has + // a token refresh callback registered during `resume_client` that writes updated + // credentials back to the OS keyring immediately upon rotation. This prevents + // the system keyring from holding stale refresh tokens even if the daemon crashes, + // the system is suspended, or the network changes. let handle = rt.handle().clone(); let result = pdfs_fuse::mount( client, @@ -336,11 +308,9 @@ fn mount_once(mountpoint: Option) -> Result { username, ); - // Clean unmount: flush whatever tokens the store holds now so the keyring - // always ends a session with the live refresh token, not a stale one. Runs - // even if `mount` errored — a rotation may still have happened first. + // Clean unmount check (as a best-effort final backup). if let Err(e) = rt.block_on(auth::persist(&session)) { - tracing::warn!(error = %e, "failed to persist tokens on shutdown"); + tracing::debug!(error = %e, "no new tokens to persist on shutdown"); } let outcome = result.context("mount failed")?; diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index 966bf29..b75e2e0 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -84,6 +84,8 @@ pub async fn login( session.apply_second_factor_code(code.trim()).await?; } + register_refresh_handler(&session, password.to_owned()); + save(&session, password).await?; Ok(()) } @@ -137,6 +139,9 @@ pub fn logout() -> Result<()> { pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> { let stored = load()?; let session = ProtonApiSession::resume(client_config(), stored.to_params())?; + + register_refresh_handler(&session, stored.mailbox_password.clone()); + let client = ProtonDriveClient::new(&session, stored.mailbox_password.clone().into_bytes()); Ok((client, session)) } @@ -151,3 +156,37 @@ pub async fn persist(session: &ProtonApiSession) -> Result<()> { let mailbox_password = load()?.mailbox_password; save(session, &mailbox_password).await } + +fn register_refresh_handler(session: &ProtonApiSession, mailbox_password: String) { + let session_id = session.session_id().as_str().to_owned(); + let username = session.username().to_owned(); + let user_id = session.user_id().as_str().to_owned(); + let scopes = session.scopes().to_vec(); + let password_mode = match session.password_mode() { + PasswordMode::Single => 1, + PasswordMode::Dual => 2, + }; + + session.http().set_on_tokens_refreshed(move |tokens| { + let stored = StoredSession { + session_id: session_id.clone(), + username: username.clone(), + user_id: user_id.clone(), + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + scopes: scopes.clone(), + password_mode, + mailbox_password: mailbox_password.clone(), + }; + if let Ok(json) = serde_json::to_string(&stored) { + if let Ok(entry) = keyring_entry() { + if let Err(e) = entry.set_password(&json) { + tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring"); + } else { + tracing::info!("successfully auto-persisted refreshed tokens in keyring"); + } + } + } + }); +} + diff --git a/crates/pdfs-gui/Cargo.toml b/crates/pdfs-gui/Cargo.toml index aa1c3df..712f235 100644 --- a/crates/pdfs-gui/Cargo.toml +++ b/crates/pdfs-gui/Cargo.toml @@ -28,3 +28,4 @@ ksni = "0.2" gtk4 = { version = "0.9", features = ["v4_12"] } adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] } async-channel = "2" +exif = { version = "0.6", package = "kamadak-exif" } diff --git a/crates/pdfs-gui/src/app.rs b/crates/pdfs-gui/src/app.rs index 03e62b1..fc8a84b 100644 --- a/crates/pdfs-gui/src/app.rs +++ b/crates/pdfs-gui/src/app.rs @@ -55,6 +55,13 @@ const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250); /// Cap on search hits requested from the daemon. const SEARCH_LIMIT: usize = 200; +/// Gallery thumbnail height in px: the zoom range, its default, and the step one +/// Ctrl+scroll notch (or Ctrl+±) moves it by. +const TILE_MIN: i32 = 90; +const TILE_MAX: i32 = 300; +const TILE_DEFAULT: i32 = 150; +const TILE_STEP: i32 = 30; + /// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's /// `sensitive` in place (when the pin set is unchanged) instead of rebuilding. struct PinRow { @@ -154,7 +161,15 @@ struct Ui { /// only the last pause actually fires a [`Request::Search`]. search_source: RefCell>, // Photos (gallery) page. + /// Every photo loaded so far, newest first — the order the lightbox's + /// prev/next walks. The visible day sections are derived from this. gallery_model: gio::ListStore, + /// The day sections rendered by the Photos ListView, rebuilt from + /// [`Self::gallery_model`] by [`repaint_gallery`]. + gallery_groups: gio::ListStore, + /// Thumbnail height in px, retuned by Ctrl+scroll / Ctrl+± (see + /// [`zoom_gallery`]). Widths follow each photo's own aspect ratio. + gallery_tile: Cell, gallery_status: gtk4::Label, gallery_retry: gtk4::Button, gallery_more: gtk4::Button, @@ -228,6 +243,8 @@ fn load_proton_theme() { .viewer-nav-btn {{ background-color: rgba(0, 0, 0, 0.5); color: white; margin: 24px; padding: 16px; border-radius: 50%; }}\n\ .viewer-nav-btn:hover {{ background-color: rgba(0, 0, 0, 0.8); color: white; }}\n\ .viewer-status {{ color: #ff5555; font-size: 1.1rem; background-color: rgba(0, 0, 0, 0.8); padding: 12px 24px; border-radius: 8px; text-shadow: 0 1px 2px rgba(0,0,0,0.5); }}\n\ + .viewer-info-panel {{ background-color: rgba(0, 0, 0, 0.75); color: white; }}\n\ + .viewer-info-panel label {{ color: white; }}\n\ .card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\ .card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n" ); @@ -317,11 +334,13 @@ fn build_window(app: &adw::Application) { browser_path: RefCell::new(String::new()), browser_search: browser_widgets.7, search_source: RefCell::new(None), - gallery_model: gallery_widgets.0, - gallery_status: gallery_widgets.1, - gallery_retry: gallery_widgets.4, - gallery_more: gallery_widgets.2, - gallery_upload: gallery_widgets.5, + gallery_model: gallery_widgets.model.clone(), + gallery_groups: gallery_widgets.groups.clone(), + gallery_tile: Cell::new(TILE_DEFAULT), + gallery_status: gallery_widgets.status.clone(), + gallery_retry: gallery_widgets.retry.clone(), + gallery_more: gallery_widgets.more.clone(), + gallery_upload: gallery_widgets.upload.clone(), }); wire_login(&ui); @@ -334,7 +353,7 @@ fn build_window(app: &adw::Application) { wire_browser(&ui, &browser_widgets.4, &browser_widgets.5); wire_browser_actions(&ui, &browser_widgets.8, &browser_widgets.9); wire_search(&ui); - wire_gallery(&ui, &gallery_widgets.3); + wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll); wire_retry(&ui); // Lazily load the Files / Photos pages the first time they're shown, so the @@ -2295,54 +2314,48 @@ fn repaint_search(ui: &Rc, hits: &[SearchHit]) { } } -/// The Photos page: a [`gtk4::GridView`] of thumbnails backed by a -/// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`PhotoItem`]s, plus a -/// "Load more" button and a status label. -fn build_gallery_page() -> ( - gtk4::Widget, - ( - gio::ListStore, - gtk4::Label, - gtk4::Button, - gtk4::GridView, - gtk4::Button, - gtk4::Button, // Upload Photo - ), -) { +/// One day-section of the photos timeline: a heading plus the photos captured +/// that day, in timeline order. Built from the flat [`Ui::gallery_model`] by +/// [`group_photos`] and rendered as one [`gtk4::ListView`] row. +struct PhotoGroup { + /// "Today", "Yesterday", or e.g. "3 June 2026". + heading: String, + photos: Vec, +} + +/// The widgets [`build_gallery_page`] hands back to [`build_window`]. +struct GalleryWidgets { + /// Flat, newest-first list of every loaded photo. Backs the lightbox's + /// prev/next navigation; the visible sections are derived from it. + model: gio::ListStore, + /// Day sections rendered by the ListView, derived from `model`. + groups: gio::ListStore, + status: gtk4::Label, + more: gtk4::Button, + list: gtk4::ListView, + scroll: gtk4::ScrolledWindow, + retry: gtk4::Button, + upload: gtk4::Button, +} + +/// The Photos page: a [`gtk4::ListView`] of day sections, each a heading over a +/// [`gtk4::FlowBox`] collage of that day's thumbnails at their original aspect +/// ratios, plus a "Load more" button and a status label. +/// +/// A ListView of sections rather than one flat GridView because GTK's grid has no +/// row headers and forces square cells: the collage and the date headings both +/// need per-row structure. The factory is installed by [`wire_gallery`], which has +/// the [`Ui`] the tiles need (zoom level, click-to-open). +fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { let model = gio::ListStore::new::(); + let groups = gio::ListStore::new::(); - let factory = gtk4::SignalListItemFactory::new(); - factory.connect_setup(|_, item| { - let item = item.downcast_ref::().unwrap(); - let picture = gtk4::Picture::builder() - .content_fit(gtk4::ContentFit::Cover) - .width_request(150) - .height_request(150) - .build(); - picture.add_css_class("card"); - item.set_child(Some(&picture)); - }); - factory.connect_bind(|_, item| { - let item = item.downcast_ref::().unwrap(); - let picture = item.child().and_downcast::().unwrap(); - let obj = item.item().and_downcast::().unwrap(); - let photo = obj.borrow::(); - match photo.thumb_path.as_deref() { - Some(p) => match gtk4::gdk::Texture::from_filename(p) { - Ok(texture) => picture.set_paintable(Some(&texture)), - Err(_) => picture.set_paintable(gtk4::gdk::Paintable::NONE), - }, - None => picture.set_paintable(gtk4::gdk::Paintable::NONE), - } - }); - - let selection = gtk4::NoSelection::new(Some(model.clone())); - let grid = gtk4::GridView::builder() + let selection = gtk4::NoSelection::new(Some(groups.clone())); + let list = gtk4::ListView::builder() .model(&selection) - .factory(&factory) - .min_columns(2) - .max_columns(6) + .single_click_activate(false) .build(); + list.add_css_class("gallery-sections"); let status = gtk4::Label::builder().wrap(true).build(); status.add_css_class("dim-label"); @@ -2365,7 +2378,7 @@ fn build_gallery_page() -> ( let scroll = gtk4::ScrolledWindow::builder() .vexpand(true) - .child(&grid) + .child(&list) .build(); // Modern header: "Photos" title + "Upload Photo" action button @@ -2398,24 +2411,93 @@ fn build_gallery_page() -> ( inner.append(&scroll); inner.append(&more); - (inner.upcast(), (model, status, more, grid, retry, upload)) + ( + inner.upcast(), + GalleryWidgets { + model, + groups, + status, + more, + list, + scroll, + retry, + upload, + }, + ) } -/// Wire the gallery: activating a tile downloads the photo and opens it in our -/// in-app lightbox viewer; the "Load more" button appends the next page. -fn wire_gallery(ui: &Rc, grid: >k4::GridView) { - let ui_open = ui.clone(); - grid.connect_activate(move |grid, pos| { - let Some(obj) = grid.model().and_then(|m| m.item(pos)) else { - return; - }; - let uid = obj - .downcast_ref::() - .map(|o| o.borrow::().uid.clone()); - let Some(uid) = uid else { return }; - open_photo_viewer(&ui_open, uid); +/// Wire the gallery: install the section factory, the zoom gestures, the pager +/// and the upload button. Activating a thumbnail downloads the photo and opens it +/// in the in-app lightbox. +fn wire_gallery(ui: &Rc, list: >k4::ListView, scroll: >k4::ScrolledWindow) { + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let item = item.downcast_ref::().unwrap(); + let section = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + section.set_margin_bottom(12); + let heading = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + heading.add_css_class("heading"); + section.append(&heading); + item.set_child(Some(§ion)); + item.set_activatable(false); }); + let ui_bind = ui.clone(); + factory.connect_bind(move |_, item| { + let item = item.downcast_ref::().unwrap(); + let section = item.child().and_downcast::().unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let group = obj.borrow::(); + + let heading = section.first_child().and_downcast::().unwrap(); + heading.set_label(&group.heading); + + // Drop the previous day's collage and build this one's. The FlowBox is + // rebuilt (not repopulated) so its child-activated handler closes over the + // photos it actually shows — ListView recycles the row widget itself. + while let Some(old) = heading.next_sibling() { + section.remove(&old); + } + section.append(&photo_collage(&ui_bind, &group.photos)); + }); + list.set_factory(Some(&factory)); + + // Ctrl+scroll zoom. Capture phase so the ScrolledWindow doesn't eat the event + // and scroll the page out from under the gesture. + let zoom_scroll = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL); + zoom_scroll.set_propagation_phase(gtk4::PropagationPhase::Capture); + let ui_zoom = ui.clone(); + zoom_scroll.connect_scroll(move |controller, _dx, dy| { + if !controller + .current_event_state() + .contains(gtk4::gdk::ModifierType::CONTROL_MASK) + || dy == 0.0 + { + return glib::Propagation::Proceed; + } + // Scroll up (negative dy) zooms in, i.e. bigger tiles. + zoom_gallery(&ui_zoom, if dy < 0.0 { TILE_STEP } else { -TILE_STEP }); + glib::Propagation::Stop + }); + scroll.add_controller(zoom_scroll); + + // Ctrl+plus / Ctrl+minus / Ctrl+0, the keyboard equivalents. + let zoom_keys = gtk4::EventControllerKey::new(); + let ui_keys = ui.clone(); + zoom_keys.connect_key_pressed(move |_, key, _code, state| { + if !state.contains(gtk4::gdk::ModifierType::CONTROL_MASK) { + return glib::Propagation::Proceed; + } + match key.name().as_deref() { + Some("plus" | "equal" | "KP_Add") => zoom_gallery(&ui_keys, TILE_STEP), + Some("minus" | "KP_Subtract") => zoom_gallery(&ui_keys, -TILE_STEP), + Some("0" | "KP_0") => set_gallery_tile(&ui_keys, TILE_DEFAULT), + _ => return glib::Propagation::Proceed, + } + glib::Propagation::Stop + }); + list.add_controller(zoom_keys); + let ui_more = ui.clone(); ui.gallery_more.clone().connect_clicked(move |_| { load_gallery(&ui_more, true); @@ -2507,6 +2589,125 @@ fn wire_gallery(ui: &Rc, grid: >k4::GridView) { }); } +/// One day's thumbnails as a masonry collage: a [`gtk4::FlowBox`] of pictures all +/// [`Ui::gallery_tile`] tall and as wide as their own aspect ratio makes them, so +/// rows justify like a phone gallery instead of square-cropping every photo. +fn photo_collage(ui: &Rc, photos: &[PhotoItem]) -> gtk4::FlowBox { + let tile = ui.gallery_tile.get(); + let flow = gtk4::FlowBox::builder() + .selection_mode(gtk4::SelectionMode::None) + .homogeneous(false) + .row_spacing(6) + .column_spacing(6) + .min_children_per_line(1) + .max_children_per_line(64) + .build(); + + for photo in photos { + let picture = gtk4::Picture::builder() + .content_fit(gtk4::ContentFit::Cover) + .build(); + picture.add_css_class("card"); + + // Width follows the thumbnail's own aspect ratio, clamped so a panorama + // can't take a whole row and a tall portrait still stays clickable. + let mut width = tile; + if let Some(path) = photo.thumb_path.as_deref() + && let Ok(texture) = gtk4::gdk::Texture::from_filename(path) + { + if texture.height() > 0 { + let ratio = f64::from(texture.width()) / f64::from(texture.height()); + width = (f64::from(tile) * ratio).round() as i32; + } + picture.set_paintable(Some(&texture)); + } + picture.set_height_request(tile); + picture.set_width_request(width.clamp(tile / 2, tile * 3)); + + flow.append(>k4::FlowBoxChild::builder().child(&picture).build()); + } + + let ui_activate = ui.clone(); + let photos: Vec = photos.to_vec(); + flow.connect_child_activated(move |_, child| { + if let Some(photo) = photos.get(child.index() as usize) { + open_photo_viewer(&ui_activate, photo.uid.clone()); + } + }); + + flow +} + +/// Step the thumbnail size by `delta` px and repaint, clamped to the zoom range. +fn zoom_gallery(ui: &Rc, delta: i32) { + set_gallery_tile(ui, ui.gallery_tile.get() + delta); +} + +/// Set the thumbnail size (clamped) and rebuild the sections at the new size. +fn set_gallery_tile(ui: &Rc, tile: i32) { + let tile = tile.clamp(TILE_MIN, TILE_MAX); + if tile == ui.gallery_tile.get() { + return; + } + ui.gallery_tile.set(tile); + repaint_gallery(ui); +} + +/// Rebuild the day sections from the flat photo model. The timeline arrives +/// newest-first, so photos of the same day are already contiguous — one pass +/// splits them. +fn repaint_gallery(ui: &Rc) { + ui.gallery_groups.remove_all(); + for group in group_photos(&ui.gallery_model) { + ui.gallery_groups.append(&BoxedAnyObject::new(group)); + } +} + +fn group_photos(model: &gio::ListStore) -> Vec { + let mut groups: Vec = Vec::new(); + for i in 0..model.n_items() { + let Some(obj) = model.item(i) else { continue }; + let Some(boxed) = obj.downcast_ref::() else { + continue; + }; + let photo = boxed.borrow::().clone(); + let heading = day_heading(photo.capture_time); + match groups.last_mut() { + Some(group) if group.heading == heading => group.photos.push(photo), + _ => groups.push(PhotoGroup { + heading, + photos: vec![photo], + }), + } + } + groups +} + +/// Section heading for a capture time: "Today", "Yesterday", or the local date. +fn day_heading(secs: i64) -> String { + let Ok(date) = glib::DateTime::from_unix_local(secs) else { + return "Unknown date".into(); + }; + let same_day = |other: &glib::DateTime| { + other.year() == date.year() + && other.month() == date.month() + && other.day_of_month() == date.day_of_month() + }; + if let Ok(now) = glib::DateTime::now_local() { + if same_day(&now) { + return "Today".into(); + } + if let Ok(yesterday) = glib::DateTime::from_unix_local(now.to_unix() - 86_400) + && same_day(&yesterday) + { + return "Yesterday".into(); + } + } + date.format("%-d %B %Y") + .map(|s| s.to_string()) + .unwrap_or_else(|_| "Unknown date".into()) +} + fn show_error_dialog(ui: &Rc, message: &str) { alert(ui, "Photo Operation", message); } @@ -2534,54 +2735,200 @@ fn format_capture_time(secs: i64) -> String { } } -fn load_photo( - ui: &Rc, - uid: String, - picture: >k4::Picture, - spinner: >k4::Spinner, - status_label: >k4::Label, - current_path: Rc>>, -) { - spinner.set_visible(true); - spinner.start(); - picture.set_paintable(gtk4::gdk::Paintable::NONE); - status_label.set_visible(false); - *current_path.borrow_mut() = None; +/// The lightbox's mutable parts, shared by [`load_photo`], [`navigate_photo`] and +/// the button/key handlers so each one takes a single handle instead of a dozen +/// widget arguments. +struct Viewer { + picture: gtk4::Picture, + spinner: gtk4::Spinner, + status: gtk4::Label, + title: gtk4::Label, + prev: gtk4::Button, + next: gtk4::Button, + /// EXIF drawer: the toggle that reveals it, the body it fills in, and the + /// "Show on map" button (hidden when the photo carries no GPS tags). + info_toggle: gtk4::ToggleButton, + info_revealer: gtk4::Revealer, + info_body: gtk4::Label, + info_map: gtk4::Button, + /// Coordinates behind `info_map`, once a photo with GPS tags is shown. + coords: RefCell>, + /// uid of the photo currently on screen. + uid: RefCell, + /// On-disk path of the full-size photo, once it has been downloaded. + path: RefCell>, +} + +/// Camera/exposure/location facts pulled from a photo's own EXIF tags. +struct ExifInfo { + /// Rendered "Camera: … / Exposure: …" lines, empty when the file has no EXIF. + lines: Vec, + /// Decimal degrees, if the photo is geotagged. + coords: Option<(f64, f64)>, +} + +/// Download the photo behind `uid` into the cache and show it, then fill the EXIF +/// drawer from the file that lands on disk. +fn load_photo(ui: &Rc, viewer: &Rc, uid: String) { + viewer.spinner.set_visible(true); + viewer.spinner.start(); + viewer.picture.set_paintable(gtk4::gdk::Paintable::NONE); + viewer.status.set_visible(false); + *viewer.path.borrow_mut() = None; + clear_exif(viewer); let rx = spawn_request( ui.dirs.control_socket(), Request::OpenPhoto { uid: uid.clone() }, ); - let picture_clone = picture.clone(); - let spinner_clone = spinner.clone(); - let status_clone = status_label.clone(); + let viewer = viewer.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; - spinner_clone.stop(); - spinner_clone.set_visible(false); + viewer.spinner.stop(); + viewer.spinner.set_visible(false); match result { Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) { Ok(texture) => { - picture_clone.set_paintable(Some(&texture)); - *current_path.borrow_mut() = Some(path); + viewer.picture.set_paintable(Some(&texture)); + *viewer.path.borrow_mut() = Some(path.clone()); + show_exif(&viewer, read_exif(&path)); } Err(e) => { - status_clone.set_label(&format!("Failed to load image: {e}")); - status_clone.set_visible(true); + tracing::error!("Failed to load texture for {path}: {e}"); + viewer.status.set_label("Couldn't render this photo."); + viewer.status.set_visible(true); } }, Ok(Ok(Response::Error { message })) => { - status_clone.set_label(&format!("Error downloading photo: {message}")); - status_clone.set_visible(true); + viewer.status.set_label(&message); + viewer.status.set_visible(true); } - _ => { - status_clone.set_label("Failed to connect to daemon"); - status_clone.set_visible(true); + Ok(Ok(_)) => { + viewer.status.set_label("Unexpected reply from daemon."); + viewer.status.set_visible(true); + } + Ok(Err(_)) | Err(_) => { + viewer.status.set_label("Couldn't reach Proton Drive."); + viewer.status.set_visible(true); } } }); } +/// Reset the EXIF drawer while the next photo is in flight, so it never shows the +/// previous photo's camera against the new image. +fn clear_exif(viewer: &Rc) { + *viewer.coords.borrow_mut() = None; + viewer.info_body.set_label("Reading photo details…"); + viewer.info_map.set_visible(false); +} + +fn show_exif(viewer: &Rc, info: ExifInfo) { + if info.lines.is_empty() { + viewer + .info_body + .set_label("No EXIF metadata in this photo."); + } else { + viewer.info_body.set_label(&info.lines.join("\n")); + } + viewer.info_map.set_visible(info.coords.is_some()); + *viewer.coords.borrow_mut() = info.coords; +} + +/// Read the EXIF tags a gallery viewer cares about out of a decrypted photo on +/// disk. Anything missing is simply left out — phone screenshots and re-encoded +/// images legitimately carry no EXIF at all. +fn read_exif(path: &str) -> ExifInfo { + let mut lines = Vec::new(); + let mut coords = None; + + let reader = match std::fs::File::open(path) { + Ok(file) => { + match exif::Reader::new().read_from_container(&mut std::io::BufReader::new(file)) { + Ok(reader) => reader, + Err(e) => { + tracing::debug!("no exif in {path}: {e}"); + return ExifInfo { lines, coords }; + } + } + } + Err(e) => { + tracing::warn!("cannot open {path} for exif: {e}"); + return ExifInfo { lines, coords }; + } + }; + + let field = |tag: exif::Tag| { + reader + .get_field(tag, exif::In::PRIMARY) + .map(|f| f.display_value().with_unit(&reader).to_string()) + }; + + let camera = [exif::Tag::Make, exif::Tag::Model] + .iter() + .filter_map(|tag| field(*tag)) + .collect::>() + .join(" "); + if !camera.is_empty() { + lines.push(format!("Camera: {camera}")); + } + if let Some(lens) = field(exif::Tag::LensModel) { + lines.push(format!("Lens: {lens}")); + } + + let exposure: Vec = [ + exif::Tag::FNumber, + exif::Tag::ExposureTime, + exif::Tag::PhotographicSensitivity, + exif::Tag::FocalLength, + ] + .iter() + .filter_map(|tag| field(*tag)) + .collect(); + if !exposure.is_empty() { + lines.push(format!("Exposure: {}", exposure.join(" · "))); + } + + if let Some(taken) = field(exif::Tag::DateTimeOriginal) { + lines.push(format!("Taken: {taken}")); + } + if let Some(size) = field(exif::Tag::PixelXDimension) + .zip(field(exif::Tag::PixelYDimension)) + .map(|(w, h)| format!("{w} × {h}")) + { + lines.push(format!("Dimensions: {size}")); + } + + if let Some(lat) = gps_degrees(&reader, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef) + && let Some(lon) = gps_degrees(&reader, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef) + { + lines.push(format!("Location: {lat:.5}, {lon:.5}")); + coords = Some((lat, lon)); + } + + ExifInfo { lines, coords } +} + +/// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to +/// decimal degrees, negating for the S/W hemispheres. +fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option { + let field = reader.get_field(tag, exif::In::PRIMARY)?; + let exif::Value::Rational(dms) = &field.value else { + return None; + }; + let [deg, min, sec] = dms.get(..3)? else { + return None; + }; + let degrees = deg.to_f64() + min.to_f64() / 60.0 + sec.to_f64() / 3600.0; + + let hemisphere = reader + .get_field(ref_tag, exif::In::PRIMARY) + .map(|f| f.display_value().to_string()) + .unwrap_or_default(); + let negative = hemisphere.starts_with('S') || hemisphere.starts_with('W'); + Some(if negative { -degrees } else { degrees }) +} + fn save_photo_to_disk(window: >k4::Window, source_path: &str, original_name: &str) { let dialog = gtk4::FileDialog::builder() .title("Save Photo") @@ -2601,24 +2948,14 @@ fn save_photo_to_disk(window: >k4::Window, source_path: &str, original_name: & }); } -fn navigate_photo( - ui: &Rc, - current_uid: &Rc>, - delta: i32, - picture: >k4::Picture, - spinner: >k4::Spinner, - status_label: >k4::Label, - title_label: >k4::Label, - prev_btn: >k4::Button, - next_btn: >k4::Button, - current_path: Rc>>, -) { +/// Step `delta` photos through the flat timeline model and load what lands there. +fn navigate_photo(ui: &Rc, viewer: &Rc, delta: i32) { let model = &ui.gallery_model; let n = model.n_items(); if n == 0 { return; } - let uid_val = current_uid.borrow().clone(); + let uid_val = viewer.uid.borrow().clone(); let current_idx = find_photo_index(model, &uid_val).unwrap_or(0); let next_idx = (current_idx as i32 + delta).clamp(0, n as i32 - 1) as u32; @@ -2630,22 +2967,17 @@ fn navigate_photo( }; let photo = boxed.borrow::().clone(); - *current_uid.borrow_mut() = photo.uid.clone(); + *viewer.uid.borrow_mut() = photo.uid.clone(); - prev_btn.set_sensitive(next_idx > 0); - next_btn.set_sensitive(next_idx < n - 1); + viewer.prev.set_sensitive(next_idx > 0); + viewer.next.set_sensitive(next_idx < n - 1); let date_str = format_capture_time(photo.capture_time); - title_label.set_label(&format!("Photo {} of {} • {}", next_idx + 1, n, date_str)); + viewer + .title + .set_label(&format!("Photo {} of {} • {}", next_idx + 1, n, date_str)); - load_photo( - ui, - photo.uid.clone(), - picture, - spinner, - status_label, - current_path, - ); + load_photo(ui, viewer, photo.uid.clone()); } fn open_photo_viewer(ui: &Rc, initial_uid: String) { @@ -2704,6 +3036,14 @@ fn open_photo_viewer(ui: &Rc, initial_uid: String) { title_label.add_css_class("viewer-title"); top_bar.append(&title_label); + let info_toggle = gtk4::ToggleButton::builder() + .icon_name("info-symbolic") + .tooltip_text("Photo Details") + .build(); + info_toggle.add_css_class("flat"); + info_toggle.add_css_class("viewer-action-btn"); + top_bar.append(&info_toggle); + let download_btn = gtk4::Button::builder() .icon_name("document-save-symbolic") .tooltip_text("Download to Disk") @@ -2730,6 +3070,45 @@ fn open_photo_viewer(ui: &Rc, initial_uid: String) { overlay.add_overlay(&top_bar); + // EXIF drawer: slides in from the right over the photo, filled from the file + // once it lands on disk. + let info_body = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .xalign(0.0) + .wrap(true) + .selectable(true) + .build(); + let info_map = gtk4::Button::builder() + .label("Show on map") + .halign(gtk4::Align::Start) + .build(); + info_map.add_css_class("pill"); + info_map.set_visible(false); + + let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + info_panel.add_css_class("viewer-info-panel"); + info_panel.set_margin_top(56); + info_panel.set_margin_bottom(12); + info_panel.set_margin_start(12); + info_panel.set_margin_end(12); + info_panel.set_width_request(260); + let info_title = gtk4::Label::builder() + .label("Details") + .halign(gtk4::Align::Start) + .build(); + info_title.add_css_class("heading"); + info_panel.append(&info_title); + info_panel.append(&info_body); + info_panel.append(&info_map); + + let info_revealer = gtk4::Revealer::builder() + .transition_type(gtk4::RevealerTransitionType::SlideLeft) + .halign(gtk4::Align::End) + .valign(gtk4::Align::Fill) + .child(&info_panel) + .build(); + overlay.add_overlay(&info_revealer); + let spinner = gtk4::Spinner::builder() .halign(gtk4::Align::Center) .valign(gtk4::Align::Center) @@ -2749,13 +3128,26 @@ fn open_photo_viewer(ui: &Rc, initial_uid: String) { window.set_child(Some(&overlay)); - let current_uid = Rc::new(RefCell::new(initial_uid.clone())); - let current_path = Rc::new(RefCell::new(None::)); + let viewer = Rc::new(Viewer { + picture, + spinner, + status: status_label, + title: title_label, + prev: prev_btn.clone(), + next: next_btn.clone(), + info_toggle: info_toggle.clone(), + info_revealer: info_revealer.clone(), + info_body, + info_map: info_map.clone(), + coords: RefCell::new(None), + uid: RefCell::new(initial_uid.clone()), + path: RefCell::new(None), + }); let n = ui.gallery_model.n_items(); let initial_idx = find_photo_index(&ui.gallery_model, &initial_uid).unwrap_or(0); - prev_btn.set_sensitive(initial_idx > 0); - next_btn.set_sensitive(initial_idx < n - 1); + viewer.prev.set_sensitive(initial_idx > 0); + viewer.next.set_sensitive(initial_idx < n - 1); let mut initial_capture_time = 0; if let Some(obj) = ui.gallery_model.item(initial_idx) @@ -2764,145 +3156,93 @@ fn open_photo_viewer(ui: &Rc, initial_uid: String) { initial_capture_time = boxed.borrow::().capture_time; } let date_str = format_capture_time(initial_capture_time); - title_label.set_label(&format!( + viewer.title.set_label(&format!( "Photo {} of {} • {}", initial_idx + 1, n, date_str )); - load_photo( - ui, - initial_uid, - &picture, - &spinner, - &status_label, - current_path.clone(), - ); + load_photo(ui, &viewer, initial_uid); let w_close = window.clone(); close_btn.connect_clicked(move |_| { w_close.close(); }); + let viewer_info = viewer.clone(); + info_toggle.connect_toggled(move |toggle| { + viewer_info + .info_revealer + .set_reveal_child(toggle.is_active()); + }); + + let viewer_map = viewer.clone(); + info_map.connect_clicked(move |_| { + if let Some((lat, lon)) = *viewer_map.coords.borrow() { + open_path(&format!( + "https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=16/{lat:.6}/{lon:.6}" + )); + } + }); + let w_download = window.clone(); - let path_download = current_path.clone(); - let uid_download = current_uid.clone(); + let viewer_download = viewer.clone(); download_btn.connect_clicked(move |_| { - if let Some(path) = path_download.borrow().as_deref() { - let uid_val = uid_download.borrow(); - let name = format!("{}.jpg", uid_val); + if let Some(path) = viewer_download.path.borrow().as_deref() { + let name = format!("{}.jpg", viewer_download.uid.borrow()); save_photo_to_disk(&w_download, path, &name); } }); - let path_ext = current_path.clone(); + let viewer_ext = viewer.clone(); open_ext_btn.connect_clicked(move |_| { - if let Some(path) = path_ext.borrow().as_deref() { + if let Some(path) = viewer_ext.path.borrow().as_deref() { open_path(path); } }); let ui_prev = ui.clone(); - let uid_prev = current_uid.clone(); - let pic_prev = picture.clone(); - let spin_prev = spinner.clone(); - let status_prev = status_label.clone(); - let title_prev = title_label.clone(); - let p_btn = prev_btn.clone(); - let n_btn = next_btn.clone(); - let path_prev = current_path.clone(); + let viewer_prev = viewer.clone(); prev_btn.connect_clicked(move |_| { - navigate_photo( - &ui_prev, - &uid_prev, - -1, - &pic_prev, - &spin_prev, - &status_prev, - &title_prev, - &p_btn, - &n_btn, - path_prev.clone(), - ); + navigate_photo(&ui_prev, &viewer_prev, -1); }); let ui_next = ui.clone(); - let uid_next = current_uid.clone(); - let pic_next = picture.clone(); - let spin_next = spinner.clone(); - let status_next = status_label.clone(); - let title_next = title_label.clone(); - let p_btn = prev_btn.clone(); - let n_btn = next_btn.clone(); - let path_next = current_path.clone(); + let viewer_next = viewer.clone(); next_btn.connect_clicked(move |_| { - navigate_photo( - &ui_next, - &uid_next, - 1, - &pic_next, - &spin_next, - &status_next, - &title_next, - &p_btn, - &n_btn, - path_next.clone(), - ); + navigate_photo(&ui_next, &viewer_next, 1); }); let key_controller = gtk4::EventControllerKey::new(); let ui_key = ui.clone(); - let uid_key = current_uid.clone(); - let pic_key = picture.clone(); - let spin_key = spinner.clone(); - let status_key = status_label.clone(); - let title_key = title_label.clone(); - let p_btn = prev_btn.clone(); - let n_btn = next_btn.clone(); - let path_key = current_path.clone(); + let viewer_key = viewer.clone(); let w_key = window.clone(); key_controller.connect_key_pressed(move |_, key, _keycode, _state| { match key.name().as_deref() { Some("Left") => { let current_idx = - find_photo_index(&ui_key.gallery_model, &uid_key.borrow()).unwrap_or(0); + find_photo_index(&ui_key.gallery_model, &viewer_key.uid.borrow()).unwrap_or(0); if current_idx > 0 { - navigate_photo( - &ui_key, - &uid_key, - -1, - &pic_key, - &spin_key, - &status_key, - &title_key, - &p_btn, - &n_btn, - path_key.clone(), - ); + navigate_photo(&ui_key, &viewer_key, -1); } glib::Propagation::Stop } Some("Right") => { let n = ui_key.gallery_model.n_items(); let current_idx = - find_photo_index(&ui_key.gallery_model, &uid_key.borrow()).unwrap_or(0); - if current_idx < n - 1 { - navigate_photo( - &ui_key, - &uid_key, - 1, - &pic_key, - &spin_key, - &status_key, - &title_key, - &p_btn, - &n_btn, - path_key.clone(), - ); + find_photo_index(&ui_key.gallery_model, &viewer_key.uid.borrow()).unwrap_or(0); + if current_idx + 1 < n { + navigate_photo(&ui_key, &viewer_key, 1); } glib::Propagation::Stop } + Some("i") => { + viewer_key + .info_toggle + .set_active(!viewer_key.info_toggle.is_active()); + glib::Propagation::Stop + } Some("Escape") => { w_key.close(); glib::Propagation::Stop @@ -2952,6 +3292,7 @@ fn load_gallery(ui: &Rc, append: bool) { for item in &items { ui.gallery_model.append(&BoxedAnyObject::new(item.clone())); } + repaint_gallery(&ui); if ui.gallery_model.n_items() == 0 { gallery_message(&ui, "No photos yet."); } else { diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs index 6297ec3..c7ebe52 100644 --- a/crates/pdfs-gui/src/prompt.rs +++ b/crates/pdfs-gui/src/prompt.rs @@ -392,10 +392,10 @@ fn build_window(app: &adw::Application) { let hits = hits.clone(); let raw_hits = raw_hits.clone(); let status_label = status_label.clone(); - + Rc::new(move |filter: FileFilter| { current_filter.set(filter); - + // Update active CSS class on buttons for (f, btn) in filter_buttons.borrow().iter() { if *f == filter { @@ -404,10 +404,10 @@ fn build_window(app: &adw::Application) { btn.remove_css_class("active"); } } - + // Re-apply filter apply_filter(&list_box, &hits, &raw_hits.borrow(), filter); - + // Update status text based on filter matches let filtered_count = hits.borrow().len(); let total_count = raw_hits.borrow().len(); @@ -433,20 +433,17 @@ fn build_window(app: &adw::Application) { ]; for (filter_type, label, icon) in filter_names { - let btn = gtk4::Button::builder() - .label(label) - .icon_name(icon) - .build(); + let btn = gtk4::Button::builder().label(label).icon_name(icon).build(); btn.add_css_class("filter-chip"); if filter_type == FileFilter::All { btn.add_css_class("active"); } - + let select_filter_clone = select_filter_rc.clone(); btn.connect_clicked(move |_| { select_filter_clone(filter_type); }); - + filter_box.append(&btn); filter_buttons.borrow_mut().push((filter_type, btn)); } @@ -882,7 +879,11 @@ fn repaint_prompt_results( let ext = hit.name.split('.').last().unwrap_or("").to_lowercase(); if ext == "pdf" { ("document-symbolic", "icon-pdf") - } else if ["docx", "doc", "txt", "md", "odt", "xlsx", "xls", "csv", "pptx", "ppt"].contains(&ext.as_str()) { + } else if [ + "docx", "doc", "txt", "md", "odt", "xlsx", "xls", "csv", "pptx", "ppt", + ] + .contains(&ext.as_str()) + { ("document-symbolic", "icon-doc") } else if ["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].contains(&ext.as_str()) { ("image-symbolic", "icon-image") @@ -927,18 +928,14 @@ fn repaint_prompt_results( if !hit.is_dir && hit.size > 0 { let size_str = format_size(hit.size); - let size_label = gtk4::Label::builder() - .label(&size_str) - .build(); + let size_label = gtk4::Label::builder().label(&size_str).build(); size_label.add_css_class("dim-label"); right_box.append(&size_label); } if hit.modified > 0 { let time_str = format_relative_time(hit.modified); - let time_label = gtk4::Label::builder() - .label(&time_str) - .build(); + let time_label = gtk4::Label::builder().label(&time_str).build(); time_label.add_css_class("dim-label"); right_box.append(&time_label); } @@ -1043,7 +1040,7 @@ fn format_relative_time(epoch_secs: i64) -> String { .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); - + let diff = now - epoch_secs; if diff <= 0 { return "Just now".to_string(); @@ -1063,7 +1060,7 @@ fn format_relative_time(epoch_secs: i64) -> String { if days < 7 { return format!("{days}d ago"); } - + if days < 30 { return format!("{days}d ago"); } diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 6365d91..60869c9 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -8,7 +8,7 @@ # Build + install: cd packaging && makepkg -fi pkgname=proton-drive-linux -pkgver=0.2.3 +pkgver=0.2.5 pkgrel=1 pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray" arch=('x86_64')