From b0f95139155d564f5191561046610c9ad6accd3c Mon Sep 17 00:00:00 2001 From: emptyname Date: Tue, 18 Aug 2026 13:06:36 +0100 Subject: [PATCH] feat(gui): show file thumbnails and browser status bar --- crates/pdfs-gui/build.rs | 3 + .../pdfs-build-thumbnails-symbolic.svg | 5 + crates/pdfs-gui/resources/pdfs.gresource.xml | 1 + crates/pdfs-gui/src/app/main.rs | 34 +- crates/pdfs-gui/src/app/pages/browser.rs | 543 ++++++++++++++++-- crates/pdfs-gui/src/app/pages/shared.rs | 7 +- crates/pdfs-gui/src/app/pages/shared_by_me.rs | 11 +- crates/pdfs-gui/src/app/pages/status.rs | 123 +++- crates/pdfs-gui/src/app/pages/trash.rs | 52 +- crates/pdfs-gui/src/app/widgets/mod.rs | 1 + crates/pdfs-gui/src/app/widgets/thumbnails.rs | 387 +++++++++++++ 11 files changed, 1053 insertions(+), 114 deletions(-) create mode 100644 crates/pdfs-gui/resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg create mode 100644 crates/pdfs-gui/src/app/widgets/thumbnails.rs diff --git a/crates/pdfs-gui/build.rs b/crates/pdfs-gui/build.rs index 6953c08..58a5198 100644 --- a/crates/pdfs-gui/build.rs +++ b/crates/pdfs-gui/build.rs @@ -22,6 +22,9 @@ fn main() { } println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml"); + println!( + "cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg" + ); println!( "cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg" ); diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg new file mode 100644 index 0000000..65ccdeb --- /dev/null +++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg @@ -0,0 +1,5 @@ + + + + diff --git a/crates/pdfs-gui/resources/pdfs.gresource.xml b/crates/pdfs-gui/resources/pdfs.gresource.xml index a84f6ea..cc21020 100644 --- a/crates/pdfs-gui/resources/pdfs.gresource.xml +++ b/crates/pdfs-gui/resources/pdfs.gresource.xml @@ -4,6 +4,7 @@ icon-theme resource path at startup, so they resolve by name (and recolour with the theme) exactly like a stock symbolic icon. --> + scalable/actions/pdfs-build-thumbnails-symbolic.svg scalable/actions/pdfs-folder-upload-symbolic.svg diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index b2545a9..97c942d 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -19,6 +19,7 @@ use pages::trash::*; use pages::verify::*; use widgets::details::*; use widgets::share_dialog::*; +use widgets::thumbnails::*; use widgets::versions_dialog::*; use std::cell::{Cell, RefCell}; @@ -49,8 +50,8 @@ use pdfs_core::control::{ ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, - SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, - pending_summary, send, + SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, + TransferItem, pending_summary, send, }; use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; @@ -107,6 +108,10 @@ struct Ui { /// The sidebar/content split. Collapsed while signed out, so the login page /// owns the whole window and no destination is reachable without a session. nav: adw::NavigationSplitView, + /// Shared thumbnails for ordinary image files outside the Photos gallery. + /// One cache and request queue serves Files, search, Shared and Trash, so + /// the same image is downloaded and decoded only once. + pub(crate) file_thumbs: FileThumbnailState, // Per-page state. Each page module owns its own struct; `Ui` keeps only // what more than one page genuinely shares. @@ -207,10 +212,15 @@ fn load_proton_theme() { .file-grid {{ padding: 6px; }}\n\ .file-tile {{ padding: 8px; border-radius: 10px; }}\n\ .file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n\ + .file-thumbnail {{ border-radius: 7px; }}\n\ .file-badge {{ -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }}\n\ .badge-pinned {{ color: #f5c211; }}\n\ .badge-cached {{ color: #2ec27e; }}\n\ .badge-cloud {{ color: #9aa0a6; }}\n\ + .browser-statusbar {{ background-color: alpha(currentColor, 0.025); }}\n\ + scale.browser-status-meter trough, progressbar.browser-status-meter trough {{ min-width: 104px; }}\n\ + scale.browser-status-meter trough {{ min-height: 6px; }}\n\ + progressbar.browser-status-meter trough, progressbar.browser-status-meter progress {{ min-height: 6px; }}\n\ .photo-viewer-window {{ background-color: #111014; }}\n\ .viewer-top-bar {{ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }}\n\ .viewer-title {{ font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\ @@ -345,6 +355,7 @@ fn build_window(app: &adw::Application) { mounted: RefCell::new(false), sidebar: sidebar_list.clone(), nav: split.clone(), + file_thumbs: FileThumbnailState::new(), login: LoginState { email: login_widgets.0, password: login_widgets.1, @@ -390,7 +401,14 @@ fn build_window(app: &adw::Application) { new_folder: browser_widgets.new_folder.clone(), upload: browser_widgets.upload.clone(), upload_folder: browser_widgets.upload_folder.clone(), + build_thumbnails: browser_widgets.build_thumbnails.clone(), + thumbnail_build_row: browser_widgets.thumbnail_build_row.clone(), + thumbnail_progress: browser_widgets.thumbnail_progress.clone(), + thumbnail_status: browser_widgets.thumbnail_status.clone(), + thumbnail_poll: RefCell::new(None), + thumbnail_build_running: Cell::new(false), search_source: RefCell::new(None), + load_generation: Cell::new(0), views: browser_widgets.views.clone(), bulk: browser_widgets.bulk.clone(), bulk_label: browser_widgets.bulk_label.clone(), @@ -398,6 +416,13 @@ fn build_window(app: &adw::Application) { bulk_pin: browser_widgets.bulk_pin.clone(), bulk_unpin: browser_widgets.bulk_unpin.clone(), empty_actions: browser_widgets.empty_actions.clone(), + summary: browser_widgets.summary.clone(), + zoom: browser_widgets.zoom.clone(), + grid_thumbnail_size: Cell::new(GRID_THUMB_DEFAULT), + grid_tiles: RefCell::new(Vec::new()), + quota_box: browser_widgets.quota_box.clone(), + quota: browser_widgets.quota.clone(), + quota_text: browser_widgets.quota_text.clone(), }, details: DetailsState { details: browser_widgets.details, @@ -552,6 +577,7 @@ fn build_window(app: &adw::Application) { &browser_widgets.new_folder, &browser_widgets.upload, &browser_widgets.upload_folder, + &browser_widgets.build_thumbnails, ); wire_details(&ui); wire_search(&ui); @@ -588,6 +614,10 @@ fn build_window(app: &adw::Application) { // network round-trip only happens on demand rather than on every refresh. let ui_nav = ui.clone(); stack.connect_visible_child_name_notify(move |st| { + // Rows from the page being left must not keep full-size image downloads + // alive in the daemon. The page being entered establishes a fresh + // thumbnail generation as it paints. + cancel_file_thumbnails(&ui_nav); sync_sidebar(&ui_nav); match st.visible_child_name().as_deref() { Some("browser") => load_browser(&ui_nav), diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index e01cecc..03a675f 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -28,9 +28,20 @@ pub(crate) struct BrowserState { pub(crate) new_folder: gtk4::Button, pub(crate) upload: gtk4::Button, pub(crate) upload_folder: gtk4::Button, + /// Starts a daemon-side recursive local-thumbnail build for [`Self::path`]. + pub(crate) build_thumbnails: gtk4::Button, + pub(crate) thumbnail_build_row: gtk4::Box, + pub(crate) thumbnail_progress: gtk4::ProgressBar, + pub(crate) thumbnail_status: gtk4::Label, + pub(crate) thumbnail_poll: RefCell>, + pub(crate) thumbnail_build_running: Cell, /// Pending debounce timer for the search box; replaced on every keystroke so /// only the last pause actually fires a [`Request::Search`]. pub(crate) search_source: RefCell>, + /// Identity of the newest folder/search request. Paths and queries can be + /// requested repeatedly, so comparing their text alone cannot reject an + /// older response that finishes after a manual refresh. + pub(crate) load_generation: Cell, /// The grid/list view stack, read to find out which of the two selections is /// the one the user is actually working in. pub(crate) views: gtk4::Stack, @@ -46,6 +57,17 @@ pub(crate) struct BrowserState { /// state is a place to act rather than a dead end. Hidden on every other /// status (a load error is not the moment to offer an upload). pub(crate) empty_actions: gtk4::Box, + /// Dolphin-style bottom status bar: current listing counts, the icon-grid + /// zoom value, and Proton account storage usage. + pub(crate) summary: gtk4::Label, + pub(crate) zoom: gtk4::Scale, + pub(crate) grid_thumbnail_size: Cell, + /// Weak references to realised grid cells. Zoom resizes only these visible, + /// recycled surfaces instead of invalidating the whole list model. + pub(crate) grid_tiles: RefCell, glib::WeakRef)>>, + pub(crate) quota_box: gtk4::Box, + pub(crate) quota: gtk4::ProgressBar, + pub(crate) quota_text: gtk4::Label, } /// Idle pause after the last keystroke before a search query is sent, so typing @@ -55,6 +77,13 @@ pub(crate) const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250); /// Cap on search hits requested from the daemon. pub(crate) const SEARCH_LIMIT: usize = 200; +/// Browser grid thumbnail size controlled by the bottom zoom slider. The +/// default preserves the former fixed 72 px presentation. +pub(crate) const GRID_THUMB_MIN: i32 = 48; +pub(crate) const GRID_THUMB_MAX: i32 = 144; +pub(crate) const GRID_THUMB_DEFAULT: i32 = 72; +pub(crate) const GRID_THUMB_STEP: i32 = 8; + /// The Files page: a Nautilus-style file manager. A back/breadcrumb header with /// a grid/list view toggle sits over a [`gtk4::Stack`] that swaps between an /// **icon grid** ([`gtk4::GridView`]) and a **column list** ([`gtk4::ColumnView`] @@ -86,6 +115,15 @@ pub(crate) struct BrowserWidgets { pub(crate) new_folder: gtk4::Button, pub(crate) upload: gtk4::Button, pub(crate) upload_folder: gtk4::Button, + pub(crate) build_thumbnails: gtk4::Button, + pub(crate) thumbnail_build_row: gtk4::Box, + pub(crate) thumbnail_progress: gtk4::ProgressBar, + pub(crate) thumbnail_status: gtk4::Label, + pub(crate) summary: gtk4::Label, + pub(crate) zoom: gtk4::Scale, + pub(crate) quota_box: gtk4::Box, + pub(crate) quota: gtk4::ProgressBar, + pub(crate) quota_text: gtk4::Label, pub(crate) refresh: gtk4::Button, /// Wraps the views + the details pane; the pane slides in on selection. pub(crate) split: adw::OverlaySplitView, @@ -149,6 +187,12 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { .valign(gtk4::Align::Center) .build(); upload_folder.add_css_class("flat"); + let build_thumbnails = gtk4::Button::builder() + .icon_name("pdfs-build-thumbnails-symbolic") + .tooltip_text("Build thumbnails in this folder and its subfolders") + .valign(gtk4::Align::Center) + .build(); + build_thumbnails.add_css_class("flat"); // Linked grid/list toggle, top-right, Nautilus-style. let grid_toggle = gtk4::ToggleButton::builder() @@ -178,12 +222,24 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { header.append(&back); header.append(&crumb_scroll); header.append(&refresh); + header.append(&build_thumbnails); header.append(&new_folder); header.append(&upload); header.append(&upload_folder); header.append(&search); header.append(&toggles); + let thumbnail_status = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + thumbnail_status.add_css_class("caption"); + let thumbnail_progress = gtk4::ProgressBar::builder().hexpand(true).build(); + let thumbnail_build_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + thumbnail_build_row.append(&thumbnail_status); + thumbnail_build_row.append(&thumbnail_progress); + thumbnail_build_row.set_visible(false); + // Empty / loading / error surface, shown in place of the views. let retry = gtk4::Button::builder() .label("Retry") @@ -314,17 +370,94 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { .child(&bulk_box) .build(); - let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); - inner.append(&bulk); - inner.append(&split); + // Mirror DolphinStatusBar's full-width order: contextual text (stretch 1), + // "Zoom:", its slider, then capacity information. Both visible troughs use + // the same CSS dimensions. + let summary = gtk4::Label::builder() + .label("Loading…") + .halign(gtk4::Align::Start) + .valign(gtk4::Align::Center) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .hexpand(true) + .build(); + let zoom_label = gtk4::Label::new(Some("Zoom:")); + zoom_label.set_valign(gtk4::Align::Center); + let zoom = gtk4::Scale::with_range( + gtk4::Orientation::Horizontal, + f64::from(GRID_THUMB_MIN), + f64::from(GRID_THUMB_MAX), + f64::from(GRID_THUMB_STEP), + ); + zoom.set_value(f64::from(GRID_THUMB_DEFAULT)); + zoom.set_draw_value(false); + zoom.set_valign(gtk4::Align::Center); + zoom.set_tooltip_text(Some("Size: 72 pixels")); + zoom.add_css_class("browser-status-meter"); + + let quota = gtk4::ProgressBar::new(); + quota.set_hexpand(false); + quota.set_valign(gtk4::Align::Center); + quota.set_tooltip_text(Some("Proton account storage")); + quota.add_css_class("browser-status-meter"); + let quota_text = gtk4::Label::builder() + .label("Loading…") + .halign(gtk4::Align::Start) + .valign(gtk4::Align::Center) + .margin_end(6) + .tooltip_text("Proton account storage") + .build(); + let quota_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + quota_box.set_valign(gtk4::Align::Center); + quota_box.append("a); + quota_box.append("a_text); + quota_box.set_visible(false); + + let status_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + status_bar.add_css_class("browser-statusbar"); + status_bar.set_margin_start(6); + status_bar.set_margin_end(2); + status_bar.append(&summary); + status_bar.append(&zoom_label); + status_bar.append(&zoom); + status_bar.append("a_box); + + let zoom_label_for_view = zoom_label.clone(); + let zoom_for_view = zoom.clone(); + let content_for_view = content.clone(); + view_stack.connect_visible_child_name_notify(move |stack| { + let visible = content_for_view.visible_child_name().as_deref() == Some("views") + && stack.visible_child_name().as_deref() == Some("grid"); + zoom_label_for_view.set_visible(visible); + zoom_for_view.set_visible(visible); + }); + let zoom_label_for_content = zoom_label.clone(); + let zoom_for_content = zoom.clone(); + let view_for_content = view_stack.clone(); + content.connect_visible_child_name_notify(move |stack| { + let visible = stack.visible_child_name().as_deref() == Some("views") + && view_for_content.visible_child_name().as_deref() == Some("grid"); + zoom_label_for_content.set_visible(visible); + zoom_for_content.set_visible(visible); + }); + + let body = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + body.set_vexpand(true); + body.set_margin_top(12); + body.set_margin_bottom(6); + body.set_margin_start(12); + body.set_margin_end(12); + body.append(&header); + body.append(&thumbnail_build_row); + body.append(&bulk); + body.append(&split); + + let page = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + page.append(&body); + page.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + page.append(&status_bar); ( - inner.upcast(), + page.upcast(), BrowserWidgets { model, back, @@ -338,6 +471,15 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { new_folder, upload, upload_folder, + build_thumbnails, + thumbnail_build_row, + thumbnail_progress, + thumbnail_status, + summary, + zoom, + quota_box, + quota, + quota_text, refresh, split, details, @@ -653,23 +795,51 @@ pub(crate) fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k load_browser(&ui_back); }); - // Grid tiles: a big icon over an ellipsized name, with a right-click menu. + // Resize only realised grid cells. Rebuilding the whole model for every + // slider step would repeatedly tear down selection state while the pointer + // is still moving. + let zoom = ui.browser.zoom.clone(); + let ui_zoom = ui.clone(); + let grid_zoom = grid.clone(); + zoom.connect_value_changed(move |scale| { + let size = (scale.value().round() as i32).clamp(GRID_THUMB_MIN, GRID_THUMB_MAX); + scale.set_tooltip_text(Some(&format!("Size: {size} pixels"))); + if ui_zoom.browser.grid_thumbnail_size.replace(size) == size { + return; + } + ui_zoom + .browser + .grid_tiles + .borrow_mut() + .retain(|(thumbnail_ref, label_ref)| { + let (Some(thumbnail), Some(label)) = (thumbnail_ref.upgrade(), label_ref.upgrade()) + else { + return false; + }; + resize_grid_tile(&thumbnail, &label, size); + true + }); + grid_zoom.queue_resize(); + }); + + // Grid tiles: a thumbnail over an ellipsized name, with a right-click menu. let factory = gtk4::SignalListItemFactory::new(); factory.connect_setup({ let ui = ui.clone(); move |_, item| { let item = item.downcast_ref::().unwrap(); - let icon = gtk4::Image::builder().pixel_size(64).build(); - // Corner sync-state badge, overlaid on the big icon. + let size = ui.browser.grid_thumbnail_size.get(); + let thumbnail = file_thumbnail_widget(size, grid_fallback_size(size)); + // Keep the sync-state badge inside the thumbnail surface. let badge = gtk4::Image::builder() .pixel_size(18) .halign(gtk4::Align::End) - .valign(gtk4::Align::End) + .valign(gtk4::Align::Start) + .margin_top(2) + .margin_end(2) .build(); badge.add_css_class("file-badge"); - let overlay = gtk4::Overlay::new(); - overlay.set_child(Some(&icon)); - overlay.add_overlay(&badge); + thumbnail.add_overlay(&badge); // `WordChar` rather than the default `Word`: a name with no spaces // offers no word-break opportunity, so word wrapping cannot break it // at all and the label asks for its full natural width instead — @@ -686,9 +856,13 @@ pub(crate) fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k .wrap_mode(gtk4::pango::WrapMode::WordChar) .lines(2) .build(); + ui.browser + .grid_tiles + .borrow_mut() + .push((thumbnail.downgrade(), label.downgrade())); let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4); tile.add_css_class("file-tile"); - tile.append(&overlay); + tile.append(&thumbnail); tile.append(&label); attach_context_menu(&ui, item, &tile); attach_drag(&ui, item, &tile); @@ -696,21 +870,29 @@ pub(crate) fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k item.set_child(Some(&tile)); } }); - factory.connect_bind(|_, item| { - let item = item.downcast_ref::().unwrap(); - let tile = item.child().and_downcast::().unwrap(); - let overlay = tile.first_child().and_downcast::().unwrap(); - let icon = overlay.first_child().and_downcast::().unwrap(); - let badge = overlay.last_child().and_downcast::().unwrap(); - let label = tile.last_child().and_downcast::().unwrap(); - let obj = item.item().and_downcast::().unwrap(); - let entry = obj.borrow::(); - icon.set_icon_name(Some(icon_base_for(&entry))); - label.set_label(&entry.name); - // The tile shows at most two lines of it, so the full name has to be - // reachable somehow. - label.set_tooltip_text(Some(&entry.name)); - apply_badge(&badge, &entry); + factory.connect_bind({ + let ui = ui.clone(); + move |_, item| { + let item = item.downcast_ref::().unwrap(); + let tile = item.child().and_downcast::().unwrap(); + let thumbnail = tile.first_child().and_downcast::().unwrap(); + let badge = thumbnail + .last_child() + .and_downcast::() + .unwrap(); + let label = thumbnail + .next_sibling() + .and_downcast::() + .unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let entry = obj.borrow::(); + let size = ui.browser.grid_thumbnail_size.get(); + resize_grid_tile(&thumbnail, &label, size); + bind_file_thumbnail(&ui, &thumbnail, &entry, false); + label.set_label(&entry.name); + label.set_tooltip_text(Some(&entry.name)); + apply_badge(&badge, &entry); + } }); grid.set_factory(Some(&factory)); @@ -740,15 +922,33 @@ pub(crate) fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k }); } -/// Build the Name column: a small icon plus the (star-prefixed when pinned) name, -/// with the same right-click menu the grid tiles carry. +fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::Label, size: i32) { + resize_file_thumbnail(thumbnail, size, grid_fallback_size(size)); + let name_width = (size / 6 + 1).clamp(8, 24); + label.set_width_chars(name_width); + label.set_max_width_chars(name_width); +} + +fn grid_fallback_size(thumbnail_size: i32) -> i32 { + (thumbnail_size * 8 / 9).clamp(24, thumbnail_size) +} + +/// Build the Name column: a small thumbnail with its local-state badge overlaid, +/// followed by the name and the same right-click menu the grid tiles carry. pub(crate) fn name_column(ui: &Rc) -> gtk4::ColumnViewColumn { let factory = gtk4::SignalListItemFactory::new(); factory.connect_setup({ let ui = ui.clone(); move |_, item| { let item = item.downcast_ref::().unwrap(); - let icon = gtk4::Image::builder().pixel_size(16).build(); + let thumbnail = file_thumbnail_widget(28, 16); + let badge = gtk4::Image::builder() + .pixel_size(14) + .halign(gtk4::Align::End) + .valign(gtk4::Align::Start) + .build(); + badge.add_css_class("file-badge"); + thumbnail.add_overlay(&badge); // Ellipsized so the Name column can be *narrower* than its longest // name. Without it the label's minimum width is the whole string, // the column inherits that minimum, and one long name pushes Size @@ -757,30 +957,36 @@ pub(crate) fn name_column(ui: &Rc) -> gtk4::ColumnViewColumn { .halign(gtk4::Align::Start) .ellipsize(gtk4::pango::EllipsizeMode::End) .build(); - let badge = gtk4::Image::builder().pixel_size(14).build(); - badge.add_css_class("file-badge"); let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - cell.append(&icon); + cell.append(&thumbnail); cell.append(&label); - cell.append(&badge); attach_context_menu(&ui, item, &cell); attach_drag(&ui, item, &cell); attach_drop(&ui, item, &cell); item.set_child(Some(&cell)); } }); - factory.connect_bind(|_, item| { - let item = item.downcast_ref::().unwrap(); - let cell = item.child().and_downcast::().unwrap(); - let icon = cell.first_child().and_downcast::().unwrap(); - let badge = cell.last_child().and_downcast::().unwrap(); - let label = icon.next_sibling().and_downcast::().unwrap(); - let obj = item.item().and_downcast::().unwrap(); - let entry = obj.borrow::(); - icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry)))); - label.set_label(&entry.name); - label.set_tooltip_text(Some(&entry.name)); - apply_badge(&badge, &entry); + factory.connect_bind({ + let ui = ui.clone(); + move |_, item| { + let item = item.downcast_ref::().unwrap(); + let cell = item.child().and_downcast::().unwrap(); + let thumbnail = cell.first_child().and_downcast::().unwrap(); + let badge = thumbnail + .last_child() + .and_downcast::() + .unwrap(); + let label = thumbnail + .next_sibling() + .and_downcast::() + .unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let entry = obj.borrow::(); + bind_file_thumbnail(&ui, &thumbnail, &entry, true); + label.set_label(&entry.name); + label.set_tooltip_text(Some(&entry.name)); + apply_badge(&badge, &entry); + } }); let column = gtk4::ColumnViewColumn::new(Some("Name"), Some(factory)); column.set_expand(true); @@ -1254,6 +1460,7 @@ pub(crate) fn wire_browser_actions( new_folder: >k4::Button, upload: >k4::Button, upload_folder: >k4::Button, + build_thumbnails: >k4::Button, ) { let ui_nf = ui.clone(); new_folder.connect_clicked(move |_| prompt_new_folder(&ui_nf)); @@ -1261,6 +1468,159 @@ pub(crate) fn wire_browser_actions( upload.connect_clicked(move |_| prompt_upload(&ui_up)); let ui_uf = ui.clone(); upload_folder.connect_clicked(move |_| prompt_upload_folder(&ui_uf)); + let ui_thumbs = ui.clone(); + build_thumbnails.connect_clicked(move |_| start_thumbnail_build(&ui_thumbs)); +} + +const THUMBNAIL_BUILD_POLL: Duration = Duration::from_millis(500); + +/// Start the daemon's deliberate recursive job. Opportunistic requests for +/// visible rows are cancelled first; the explicit job has its own lifetime and +/// continues if the user navigates while watching its progress. +fn start_thumbnail_build(ui: &Rc) { + if !*ui.mounted.borrow() { + toast_error( + ui, + "Couldn't build thumbnails", + "Proton Drive isn't connected.", + ); + return; + } + cancel_file_thumbnails(ui); + ui.browser.thumbnail_build_running.set(true); + ui.browser.build_thumbnails.set_sensitive(false); + ui.browser.thumbnail_build_row.set_visible(true); + ui.browser.thumbnail_progress.set_fraction(0.0); + ui.browser + .thumbnail_status + .set_label("Starting thumbnail build…"); + + let path = ui.browser.path.borrow().clone(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::StartThumbnailBuild { path }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::ThumbnailBuild { status })) => { + repaint_thumbnail_build(&ui, &status); + } + Ok(Ok(Response::Error { message, kind })) => { + thumbnail_build_failed(&ui, &message); + toast_failure(&ui, "Couldn't build thumbnails", &message, kind); + } + _ => { + let message = "The mount service didn't respond."; + thumbnail_build_failed(&ui, message); + toast_error(&ui, "Couldn't build thumbnails", message); + } + } + }); +} + +fn schedule_thumbnail_build_poll(ui: &Rc) { + if let Some(source) = ui.browser.thumbnail_poll.borrow_mut().take() { + source.remove(); + } + let ui_poll = ui.clone(); + let source = glib::timeout_add_local_once(THUMBNAIL_BUILD_POLL, move || { + ui_poll.browser.thumbnail_poll.borrow_mut().take(); + let rx = spawn_request(ui_poll.dirs.control_socket(), Request::ThumbnailBuildStatus); + let ui_result = ui_poll.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::ThumbnailBuild { status })) => { + repaint_thumbnail_build(&ui_result, &status) + } + Ok(Ok(Response::Error { message, .. })) => { + thumbnail_build_failed(&ui_result, &message) + } + _ => thumbnail_build_failed( + &ui_result, + "The mount service stopped reporting thumbnail progress.", + ), + } + }); + }); + *ui.browser.thumbnail_poll.borrow_mut() = Some(source); +} + +fn repaint_thumbnail_build(ui: &Rc, status: &ThumbnailBuildStatus) { + ui.browser + .thumbnail_build_row + .set_visible(show_thumbnail_build_progress(status)); + let root = if status.path.is_empty() { + "Proton Drive" + } else { + &status.path + }; + let text = if status.scanning { + ui.browser.thumbnail_progress.pulse(); + format!( + "Scanning {root}… {} folders, {} images", + status.folders_scanned, status.images_found + ) + } else if status.running { + let fraction = if status.images_found == 0 { + 0.0 + } else { + status.completed as f64 / status.images_found as f64 + }; + ui.browser + .thumbnail_progress + .set_fraction(fraction.clamp(0.0, 1.0)); + format!( + "Building thumbnails in {root}… {} of {}", + status.completed, status.images_found + ) + } else { + ui.browser.thumbnail_progress.set_fraction(1.0); + let available = status.completed.saturating_sub(status.failed); + match status.failed { + 0 => format!("Thumbnails ready for {available} images in {root}"), + failed => { + format!("Thumbnails ready for {available} images in {root}; {failed} unavailable") + } + } + }; + let text = match status.message.as_deref() { + Some(message) => format!("{text}. {message}"), + None => text, + }; + ui.browser.thumbnail_status.set_label(&text); + ui.browser.thumbnail_status.set_tooltip_text(Some(&text)); + + if status.running { + ui.browser.thumbnail_build_running.set(true); + ui.browser.build_thumbnails.set_sensitive(false); + schedule_thumbnail_build_poll(ui); + } else { + ui.browser.thumbnail_build_running.set(false); + ui.browser + .build_thumbnails + .set_sensitive(*ui.mounted.borrow()); + if ui.stack.visible_child_name().as_deref() == Some("browser") { + reload_listing(ui); + } + } +} + +/// The progress row is transient: once the daemon reports completion, both its +/// label and bar leave the browser instead of retaining a stale success state. +fn show_thumbnail_build_progress(status: &ThumbnailBuildStatus) -> bool { + status.running +} + +fn thumbnail_build_failed(ui: &Rc, message: &str) { + ui.browser.thumbnail_build_running.set(false); + ui.browser + .build_thumbnails + .set_sensitive(*ui.mounted.borrow()); + ui.browser.thumbnail_build_row.set_visible(true); + ui.browser.thumbnail_progress.set_fraction(0.0); + ui.browser.thumbnail_status.set_label(message); + ui.browser.thumbnail_status.set_tooltip_text(Some(message)); } /// Send a mutating request (rename / move / delete / mkdir / upload, or a trash @@ -1666,9 +2026,13 @@ pub(crate) fn format_modified(secs: i64) -> String { /// Request the current browser directory from the daemon and repaint both views. pub(crate) fn load_browser(ui: &Rc) { + cancel_file_thumbnails(ui); + let generation = ui.browser.load_generation.get().wrapping_add(1); + ui.browser.load_generation.set(generation); let path = ui.browser.path.borrow().clone(); repaint_crumb(ui, &path); ui.browser.back.set_sensitive(!path.is_empty()); + ui.browser.summary.set_label("Loading…"); // Drop the previous folder's rows up front: a slow reply must not leave stale // entries visible, where clicking one would open with a wrong relative path. @@ -1693,7 +2057,7 @@ pub(crate) fn load_browser(ui: &Rc) { // The user may have navigated on while this folder was loading. A stale // out-of-order reply must not repaint rows for a folder we've left, or // the breadcrumb and the grid would disagree. - if *ui.browser.path.borrow() != path { + if ui.browser.load_generation.get() != generation || *ui.browser.path.borrow() != path { return; } match result { @@ -1714,6 +2078,7 @@ pub(crate) fn load_browser(ui: &Rc) { /// (which restarts the service) wouldn't help and isn't offered. pub(crate) fn browser_failed(ui: &Rc, message: &str, kind: ErrorKind) { ui.browser.model.remove_all(); + ui.browser.summary.set_label("Folder unavailable"); browser_status( ui, "dialog-warning-symbolic", @@ -1730,6 +2095,7 @@ pub(crate) fn browser_failed(ui: &Rc, message: &str, kind: ErrorKind) { /// once the systemd mount comes up but a real failure stays visible. pub(crate) fn browser_unreachable(ui: &Rc) { if service::is_failed() || !service::is_active() { + ui.browser.summary.set_label("Not connected"); browser_status( ui, "network-offline-symbolic", @@ -1739,6 +2105,7 @@ pub(crate) fn browser_unreachable(ui: &Rc) { ); return; } + ui.browser.summary.set_label("Connecting…"); browser_status( ui, "folder-remote-symbolic", @@ -1759,6 +2126,15 @@ pub(crate) fn browser_unreachable(ui: &Rc) { /// which refreshes both the grid and the column list. pub(crate) fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { ui.browser.model.remove_all(); + ui.browser.summary.set_label(&listing_summary( + entries.iter().filter(|entry| !entry.is_dir).count(), + entries.iter().filter(|entry| entry.is_dir).count(), + entries + .iter() + .filter(|entry| !entry.is_dir) + .map(|entry| entry.size) + .sum(), + )); if entries.is_empty() { browser_status( ui, @@ -1810,7 +2186,11 @@ pub(crate) fn wire_search(ui: &Rc) { /// views, reusing the same row model so click-to-open and pin work unchanged /// (each hit carries its full path; see [`entry_rel`]). pub(crate) fn run_search(ui: &Rc, query: &str) { + cancel_file_thumbnails(ui); + let generation = ui.browser.load_generation.get().wrapping_add(1); + ui.browser.load_generation.set(generation); ui.browser.model.remove_all(); + ui.browser.summary.set_label("Searching…"); browser_status( ui, "system-search-symbolic", @@ -1835,7 +2215,9 @@ pub(crate) fn run_search(ui: &Rc, query: &str) { // The box may have been cleared or typed past while the reply was in // flight; if the query no longer matches, a fresher load/search already // owns the model — drop this stale, possibly out-of-order result. - if ui.browser.search.text().trim() != query { + if ui.browser.load_generation.get() != generation + || ui.browser.search.text().trim() != query + { return; } match result { @@ -1856,6 +2238,17 @@ pub(crate) fn run_search(ui: &Rc, query: &str) { /// handlers already understand. pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) { ui.browser.model.remove_all(); + let counts = listing_summary( + hits.iter().filter(|hit| !hit.is_dir).count(), + hits.iter().filter(|hit| hit.is_dir).count(), + hits.iter() + .filter(|hit| !hit.is_dir) + .map(|hit| hit.size) + .sum(), + ); + ui.browser + .summary + .set_label(&format!("{counts} — search results")); if hits.is_empty() { browser_status( ui, @@ -1892,3 +2285,47 @@ pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) { ui.browser.model.append(&BoxedAnyObject::new(entry)); } } + +fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String { + let file_word = if files == 1 { "file" } else { "files" }; + let folder_word = if folders == 1 { "folder" } else { "folders" }; + match (folders, files) { + (0, 0) => "0 folders, 0 files".to_string(), + (_, 0) => format!("{folders} {folder_word}"), + (0, _) => format!("{files} {file_word} ({})", human_bytes(file_bytes)), + _ => format!( + "{folders} {folder_word}, {files} {file_word} ({})", + human_bytes(file_bytes) + ), + } +} + +#[cfg(test)] +mod tests { + use super::{listing_summary, show_thumbnail_build_progress}; + use pdfs_core::control::ThumbnailBuildStatus; + + #[test] + fn listing_summary_matches_dolphin_order_and_wording() { + assert_eq!(listing_summary(0, 0, 0), "0 folders, 0 files"); + assert_eq!(listing_summary(0, 2, 0), "2 folders"); + assert_eq!(listing_summary(1, 0, 1024), "1 file (1.0 KiB)"); + assert_eq!(listing_summary(3, 1, 3072), "1 folder, 3 files (3.0 KiB)"); + } + + #[test] + fn thumbnail_progress_disappears_when_build_finishes() { + let mut status = ThumbnailBuildStatus { + running: true, + scanning: true, + ..Default::default() + }; + assert!(show_thumbnail_build_progress(&status)); + + status.scanning = false; + assert!(show_thumbnail_build_progress(&status)); + + status.running = false; + assert!(!show_thumbnail_build_progress(&status)); + } +} diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index 622d087..7f13d65 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -151,6 +151,7 @@ pub(crate) fn load_shared(ui: &Rc) { if ui.shared.inflight.get() { return; } + cancel_file_thumbnails(ui); let current = ui.shared.nav.borrow().last().cloned(); if let Some((uid, _)) = current { load_shared_folder(ui, uid); @@ -308,11 +309,7 @@ fn shared_entry_row(ui: &Rc, entry: &DirEntry) -> adw::ActionRow { if let Some(badge) = role_badge(&entry.role) { row.add_suffix(&badge); } - row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir { - "folder-symbolic" - } else { - "text-x-generic-symbolic" - })); + row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true)); let ui_act = ui.clone(); let uid = entry.uid.clone(); let name = entry.name.clone(); diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs index a465450..317010e 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -113,6 +113,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc) { if ui.shared_by_me.inflight.get() { return; } + cancel_file_thumbnails(ui); ui.shared_by_me.inflight.set(true); shared_by_me_status( ui, @@ -200,15 +201,12 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc, items: &[SharedItem]) { ui.shared_by_me.content.set_visible_child_name("list"); let mut rows: Vec = Vec::new(); for item in items { + let entry = shared_item_as_entry(item); let row = adw::ActionRow::builder() .title(&item.name) .subtitle(shared_item_summary(item)) .build(); - row.add_prefix(>k4::Image::from_icon_name(if item.is_dir { - "folder-symbolic" - } else { - "text-x-generic-symbolic" - })); + row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true)); // Copy / open the public link, when there is one with a recovered URL. if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) { @@ -245,7 +243,6 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc, items: &[SharedItem]) { .build(); manage.add_css_class("flat"); let ui_manage = ui.clone(); - let entry = shared_item_as_entry(item); manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry)); row.add_suffix(&manage); @@ -289,7 +286,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry { name: item.name.clone(), is_dir: item.is_dir, size: 0, - modified: 0, + modified: item.modified, pinned: false, cached: false, uid: item.uid.clone(), diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index be0b788..8f7c1a4 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -572,7 +572,7 @@ pub(crate) fn refresh(ui: &Rc) { refresh_takeout(ui); } match ui.stack.visible_child_name().as_deref() { - Some("main") => refresh_quota(ui), + Some("main" | "browser") => refresh_quota(ui), Some("locations") => refresh_locations(ui), Some("activity") => refresh_activity(ui), _ => {} @@ -580,13 +580,11 @@ pub(crate) fn refresh(ui: &Rc) { } /// How long a quota reading stays fresh. Account storage barely moves, so the -/// Settings tick refetches it only this often rather than every 2s. +/// active-page tick refetches it only this often rather than every 2s. const QUOTA_TTL: Duration = Duration::from_secs(60); -/// Fetch the account quota (if the last reading is stale) and paint the Account -/// storage group. Runs only while Settings is on screen. The group stays hidden -/// until a reading lands, so an account the API can't report — or a still-starting -/// daemon — shows nothing rather than an empty bar. A failed fetch leaves the last +/// Fetch the account quota (if the last reading is stale) and paint both the +/// Settings storage group and Files status bar. A failed fetch leaves the last /// good reading in place. pub(crate) fn refresh_quota(ui: &Rc) { if ui.status.quota_inflight.get() { @@ -609,28 +607,71 @@ pub(crate) fn refresh_quota(ui: &Rc) { })) = result { ui.status.quota_checked_at.set(Some(Instant::now())); - let used = used_space.max(0) as u64; - if max_space > 0 { - let total = max_space as u64; - let fraction = (used as f64 / total as f64).min(1.0); - ui.status.quota_bar.set_fraction(fraction); - let pct = (fraction * 100.0).round() as u64; - ui.status.quota_label.set_text(&format!( - "{} of {} used ({pct}%)", - human_bytes(used), - human_bytes(total) - )); - } else { - ui.status.quota_bar.set_fraction(0.0); - ui.status - .quota_label - .set_text(&format!("{} used", human_bytes(used))); - } + paint_account_quota(&ui, max_space, used_space); ui.status.quota_group.set_visible(true); + } else if ui.status.quota_checked_at.get().is_none() { + // Match Dolphin: capacity information does not occupy the bar until + // the backing observer has real figures. + ui.browser.quota_box.set_visible(false); } }); } +fn paint_account_quota(ui: &Rc, max_space: i64, used_space: i64) { + let (fraction, text) = quota_display(max_space, used_space); + ui.status.quota_bar.set_fraction(fraction); + ui.status.quota_label.set_text(&text); + if let Some((fraction, free_text, tooltip)) = quota_status_display(max_space, used_space) { + ui.browser.quota.set_fraction(fraction); + ui.browser.quota.set_tooltip_text(Some(&tooltip)); + ui.browser.quota_text.set_label(&free_text); + ui.browser.quota_text.set_tooltip_text(Some(&tooltip)); + ui.browser.quota_box.set_visible(true); + } else { + ui.browser.quota_box.set_visible(false); + } +} + +fn quota_display(max_space: i64, used_space: i64) -> (f64, String) { + let used = used_space.max(0) as u64; + if max_space <= 0 { + return (0.0, format!("{} used", human_bytes(used))); + } + let total = max_space as u64; + let fraction = (used as f64 / total as f64).clamp(0.0, 1.0); + let pct = (fraction * 100.0).round() as u64; + ( + fraction, + format!( + "{} of {} used ({pct}%)", + human_bytes(used), + human_bytes(total) + ), + ) +} + +/// Dolphin's status bar shows a bare capacity bar followed by “X free”; the +/// full free/total/percentage sentence is a tooltip rather than inline bar text. +fn quota_status_display(max_space: i64, used_space: i64) -> Option<(f64, String, String)> { + if max_space <= 0 { + return None; + } + let total = max_space as u64; + let used = (used_space.max(0) as u64).min(total); + let free = total.saturating_sub(used); + let fraction = used as f64 / total as f64; + let pct = (fraction * 100.0).round() as u64; + Some(( + fraction, + format!("{} free", human_bytes(free)), + format!( + "{} free out of {} ({pct}% used)", + human_bytes(free), + human_bytes(total) + ), + )) +} + /// Record the mount state seen by the last status poll: gate every control that /// needs a live daemon, and notify the desktop when the state actually flips. /// @@ -642,6 +683,9 @@ pub(crate) fn set_mounted(ui: &Rc, mounted: bool) { ui.browser.new_folder.set_sensitive(mounted); ui.browser.upload.set_sensitive(mounted); ui.browser.upload_folder.set_sensitive(mounted); + ui.browser + .build_thumbnails + .set_sensitive(mounted && !ui.browser.thumbnail_build_running.get()); ui.gallery.upload.set_sensitive(mounted); ui.details.details.pin_row.set_sensitive(mounted); ui.details.details.rename_button.set_sensitive(mounted); @@ -1010,3 +1054,36 @@ pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: .push(PinRow { row, unpin: None }); } } + +#[cfg(test)] +mod tests { + use super::{quota_display, quota_status_display}; + + #[test] + fn quota_display_reports_used_total_and_percentage() { + let gib = 1024_i64.pow(3); + let (fraction, text) = quota_display(4 * gib, gib); + assert!((fraction - 0.25).abs() < f64::EPSILON); + assert_eq!(text, "1.0 GiB of 4.0 GiB used (25%)"); + } + + #[test] + fn quota_display_clamps_bad_api_values() { + assert_eq!(quota_display(0, -1), (0.0, "0 B used".to_string())); + assert_eq!(quota_display(100, 150).0, 1.0); + } + + #[test] + fn quota_status_display_matches_dolphin_wording() { + let gib = 1024_i64.pow(3); + assert_eq!( + quota_status_display(4 * gib, gib), + Some(( + 0.25, + "3.0 GiB free".to_string(), + "3.0 GiB free out of 4.0 GiB (25% used)".to_string() + )) + ); + assert_eq!(quota_status_display(0, 0), None); + } +} diff --git a/crates/pdfs-gui/src/app/pages/trash.rs b/crates/pdfs-gui/src/app/pages/trash.rs index 0bf9cb9..2fb53ce 100644 --- a/crates/pdfs-gui/src/app/pages/trash.rs +++ b/crates/pdfs-gui/src/app/pages/trash.rs @@ -122,7 +122,7 @@ pub(crate) fn wire_trash(ui: &Rc, list: >k4::ListView, empty: >k4::Butto factory.connect_setup(move |_, item| { let item = item.downcast_ref::().unwrap(); - let icon = gtk4::Image::builder().pixel_size(32).build(); + let thumbnail = file_thumbnail_widget(40, 32); let name = gtk4::Label::builder() .halign(gtk4::Align::Start) .ellipsize(gtk4::pango::EllipsizeMode::Middle) @@ -169,35 +169,38 @@ pub(crate) fn wire_trash(ui: &Rc, list: >k4::ListView, empty: >k4::Butto row.set_margin_bottom(6); row.set_margin_start(6); row.set_margin_end(6); - row.append(&icon); + row.append(&thumbnail); row.append(&text); row.append(&restore); row.append(&purge); item.set_child(Some(&row)); }); - factory.connect_bind(|_, item| { - let item = item.downcast_ref::().unwrap(); - let Some(entry) = bound_entry(item) else { - return; - }; - let row = item.child().and_downcast::().unwrap(); - let Some(icon) = row.first_child().and_downcast::() else { - return; - }; - icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry)))); - let Some(text) = icon.next_sibling().and_downcast::() else { - return; - }; - if let Some(name) = text.first_child().and_downcast::() { - name.set_label(&entry.name); - } - if let Some(meta) = text.last_child().and_downcast::() { - let kind = if entry.is_dir { - "Folder".to_string() - } else { - human_bytes(entry.size) + factory.connect_bind({ + let ui = ui.clone(); + move |_, item| { + let item = item.downcast_ref::().unwrap(); + let Some(entry) = bound_entry(item) else { + return; }; - meta.set_label(&format!("{kind} · {}", format_modified(entry.modified))); + let row = item.child().and_downcast::().unwrap(); + let Some(thumbnail) = row.first_child().and_downcast::() else { + return; + }; + bind_file_thumbnail(&ui, &thumbnail, &entry, true); + let Some(text) = thumbnail.next_sibling().and_downcast::() else { + return; + }; + if let Some(name) = text.first_child().and_downcast::() { + name.set_label(&entry.name); + } + if let Some(meta) = text.last_child().and_downcast::() { + let kind = if entry.is_dir { + "Folder".to_string() + } else { + human_bytes(entry.size) + }; + meta.set_label(&format!("{kind} · {}", format_modified(entry.modified))); + } } }); list.set_factory(Some(&factory)); @@ -216,6 +219,7 @@ pub(crate) fn bound_entry(item: >k4::ListItem) -> Option { /// Fetch the trash listing and repaint the page. pub(crate) fn load_trash(ui: &Rc) { + cancel_file_thumbnails(ui); // Drop the old rows first: a stale row here would offer Restore on something // that may already be gone. ui.trash.model.remove_all(); diff --git a/crates/pdfs-gui/src/app/widgets/mod.rs b/crates/pdfs-gui/src/app/widgets/mod.rs index 5584e1f..ce1e722 100644 --- a/crates/pdfs-gui/src/app/widgets/mod.rs +++ b/crates/pdfs-gui/src/app/widgets/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod details; pub(crate) mod share_dialog; +pub(crate) mod thumbnails; pub(crate) mod versions_dialog; diff --git a/crates/pdfs-gui/src/app/widgets/thumbnails.rs b/crates/pdfs-gui/src/app/widgets/thumbnails.rs new file mode 100644 index 0000000..f69614b --- /dev/null +++ b/crates/pdfs-gui/src/app/widgets/thumbnails.rs @@ -0,0 +1,387 @@ +//! Shared thumbnails for ordinary Drive image files. +//! +//! The Photos gallery has timeline-specific batching and aspect-ratio state. +//! File listings need a smaller, reusable surface: one loader backs My files, +//! search, Shared and Trash, with modification-time cache keys +//! so a replaced image never flashes its previous contents. + +use crate::*; +use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const THUMB_BATCH: usize = 32; +const THUMB_CACHE_MAX: usize = 256; +const THUMB_MISSING_MAX: usize = 2048; +const THUMB_DEBOUNCE: Duration = Duration::from_millis(40); +const THUMB_RETRY: Duration = Duration::from_secs(2); + +pub(crate) struct FileThumbnailState { + textures: RefCell>, + texture_order: RefCell>, + missing: RefCell>, + missing_order: RefCell>, + wanted: RefCell>>, + queue: RefCell>, + inflight: Cell, + source: RefCell>, + generation: Cell, +} + +impl FileThumbnailState { + pub(crate) fn new() -> Self { + Self { + textures: RefCell::new(HashMap::new()), + texture_order: RefCell::new(VecDeque::new()), + missing: RefCell::new(HashSet::new()), + missing_order: RefCell::new(VecDeque::new()), + wanted: RefCell::new(HashMap::new()), + queue: RefCell::new(VecDeque::new()), + inflight: Cell::new(false), + source: RefCell::new(None), + generation: Cell::new( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .min(u128::from(u64::MAX)) as u64, + ), + } + } + + fn store_texture(&self, key: &str, texture: gtk4::gdk::Texture) { + let mut textures = self.textures.borrow_mut(); + let mut order = self.texture_order.borrow_mut(); + if textures.insert(key.to_string(), texture).is_none() { + order.push_back(key.to_string()); + } + while order.len() > THUMB_CACHE_MAX { + if let Some(old) = order.pop_front() { + textures.remove(&old); + } + } + } + + fn store_missing(&self, key: String) { + let mut missing = self.missing.borrow_mut(); + let mut order = self.missing_order.borrow_mut(); + if missing.insert(key.clone()) { + order.push_back(key); + } + while order.len() > THUMB_MISSING_MAX { + if let Some(old) = order.pop_front() { + missing.remove(&old); + } + } + } +} + +/// Abandon every opportunistic ordinary-file thumbnail associated with the +/// listing being left. Advancing a wire generation lets the daemon cancel work +/// even when an older request reaches it after this one. +pub(crate) fn cancel_file_thumbnails(ui: &Rc) { + if let Some(source) = ui.file_thumbs.source.borrow_mut().take() { + source.remove(); + } + ui.file_thumbs.queue.borrow_mut().clear(); + ui.file_thumbs.wanted.borrow_mut().clear(); + let generation = ui.file_thumbs.generation.get().saturating_add(1); + ui.file_thumbs.generation.set(generation); + // The request is useful even when its reply is not. `spawn_request` owns the + // socket round-trip on a worker thread, so dropping the receiver is safe. + drop(spawn_request( + ui.dirs.control_socket(), + Request::CancelFileThumbs { generation }, + )); +} + +/// A square thumbnail surface with a generic file icon underneath the picture. +/// Call [`bind_file_thumbnail`] whenever a recycled row is bound to an entry. +pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Overlay { + let fallback = gtk4::Image::builder().pixel_size(fallback_size).build(); + let picture = gtk4::Picture::builder() + .width_request(size) + .height_request(size) + .content_fit(gtk4::ContentFit::Cover) + .can_shrink(true) + .visible(false) + .build(); + picture.add_css_class("file-thumbnail"); + + let overlay = gtk4::Overlay::new(); + overlay.set_width_request(size); + overlay.set_height_request(size); + overlay.set_halign(gtk4::Align::Center); + overlay.set_valign(gtk4::Align::Center); + overlay.set_overflow(gtk4::Overflow::Hidden); + overlay.set_child(Some(&fallback)); + overlay.add_overlay(&picture); + overlay +} + +/// Resize an existing thumbnail surface. Grid rows are recycled, so changing +/// these requests in place and rebinding the model is enough to implement the +/// status-bar zoom without downloading or decoding an image again. +pub(crate) fn resize_file_thumbnail(widget: >k4::Overlay, size: i32, fallback_size: i32) { + widget.set_width_request(size); + widget.set_height_request(size); + let Some(fallback) = widget.child().and_downcast::() else { + return; + }; + fallback.set_pixel_size(fallback_size); + if let Some(picture) = fallback.next_sibling().and_downcast::() { + picture.set_width_request(size); + picture.set_height_request(size); + } +} + +/// Bind a thumbnail surface to one entry, painting a cached texture immediately +/// or queueing an on-demand daemon request. Non-images retain the generic icon. +pub(crate) fn bind_file_thumbnail( + ui: &Rc, + widget: >k4::Overlay, + entry: &DirEntry, + symbolic_fallback: bool, +) { + let Some(fallback) = widget.child().and_downcast::() else { + return; + }; + // The browser may add a status badge after the picture. Locate the picture + // immediately after the base fallback instead of assuming it is the last + // overlay child. + let Some(picture) = fallback.next_sibling().and_downcast::() else { + return; + }; + + let fallback_name = if symbolic_fallback { + format!("{}-symbolic", icon_base_for(entry)) + } else { + icon_base_for(entry).to_string() + }; + fallback.set_icon_name(Some(&fallback_name)); + fallback.set_visible(true); + picture.set_visible(false); + picture.set_paintable(gtk4::gdk::Paintable::NONE); + + // The widget name is a cheap binding token. List factories recycle their + // children; a reply for the previous entry must not paint into the new one. + let key = thumbnail_key(&entry.uid, entry.modified); + picture.set_widget_name(&key); + remove_waiter(ui, &picture); + + if entry.is_dir || !is_image_name(&entry.name) || entry.uid.is_empty() { + return; + } + if let Some(texture) = ui.file_thumbs.textures.borrow().get(&key) { + paint(&picture, &key, texture); + return; + } + if ui.file_thumbs.missing.borrow().contains(&key) { + return; + } + + ui.file_thumbs + .wanted + .borrow_mut() + .entry(key) + .or_default() + .push(picture); + let mut queue = ui.file_thumbs.queue.borrow_mut(); + queue.retain(|item| item.uid != entry.uid); + queue.push_back(FileThumbRequest { + uid: entry.uid.clone(), + modified: entry.modified, + name: entry.name.clone(), + }); + drop(queue); + schedule_file_thumbs(ui); +} + +/// Create and immediately bind a thumbnail for a non-recycled row. +pub(crate) fn file_thumbnail( + ui: &Rc, + entry: &DirEntry, + size: i32, + fallback_size: i32, + symbolic_fallback: bool, +) -> gtk4::Overlay { + let widget = file_thumbnail_widget(size, fallback_size); + bind_file_thumbnail(ui, &widget, entry, symbolic_fallback); + widget +} + +/// Whether the file name denotes an image format for which Proton or the local +/// decoder can reasonably provide a thumbnail. +pub(crate) fn is_image_name(name: &str) -> bool { + is_thumbnail_image_name(name) +} + +fn thumbnail_key(uid: &str, modified: i64) -> String { + format!("{uid}:{modified}") +} + +fn remove_waiter(ui: &Rc, picture: >k4::Picture) { + let mut wanted = ui.file_thumbs.wanted.borrow_mut(); + wanted.retain(|_, pictures| { + pictures.retain(|candidate| candidate != picture); + !pictures.is_empty() + }); +} + +fn paint(picture: >k4::Picture, key: &str, texture: >k4::gdk::Texture) { + if picture.widget_name() != key { + return; + } + picture.set_paintable(Some(texture)); + picture.set_visible(true); + if let Some(overlay) = picture.parent().and_downcast::() + && let Some(fallback) = overlay.child().and_downcast::() + { + fallback.set_visible(false); + } +} + +fn schedule_file_thumbs(ui: &Rc) { + if ui.file_thumbs.queue.borrow().is_empty() || ui.file_thumbs.inflight.get() { + return; + } + if let Some(source) = ui.file_thumbs.source.borrow_mut().take() { + source.remove(); + } + let ui_flush = ui.clone(); + let source = glib::timeout_add_local_once(THUMB_DEBOUNCE, move || { + ui_flush.file_thumbs.source.borrow_mut().take(); + flush_file_thumbs(&ui_flush); + }); + *ui.file_thumbs.source.borrow_mut() = Some(source); +} + +fn flush_file_thumbs(ui: &Rc) { + if ui.file_thumbs.inflight.get() { + return; + } + let items: Vec = { + let mut queue = ui.file_thumbs.queue.borrow_mut(); + let wanted = ui.file_thumbs.wanted.borrow(); + let mut batch = Vec::new(); + while batch.len() < THUMB_BATCH { + let Some(item) = queue.pop_front() else { + break; + }; + if wanted.contains_key(&thumbnail_key(&item.uid, item.modified)) { + batch.push(item); + } + } + batch + }; + if items.is_empty() { + return; + } + + ui.file_thumbs.inflight.set(true); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::FileThumbs { + items: items.clone(), + generation: ui.file_thumbs.generation.get(), + }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.file_thumbs.inflight.set(false); + match result { + Ok(Ok(Response::Thumbs { items: replies })) => { + let requested: HashMap = items + .iter() + .cloned() + .map(|item| (item.uid.clone(), item)) + .collect(); + let mut pending = Vec::new(); + for reply in replies { + let Some(request) = requested.get(&reply.uid) else { + continue; + }; + let key = thumbnail_key(&request.uid, request.modified); + match reply.path { + Some(path) => match gtk4::gdk::Texture::from_filename(&path) { + Ok(texture) => { + ui.file_thumbs.store_texture(&key, texture.clone()); + if let Some(pictures) = + ui.file_thumbs.wanted.borrow_mut().remove(&key) + { + for picture in pictures { + paint(&picture, &key, &texture); + } + } + } + Err(e) => { + tracing::debug!("cannot decode file thumbnail {path}: {e}"); + ui.file_thumbs.store_missing(key.clone()); + ui.file_thumbs.wanted.borrow_mut().remove(&key); + } + }, + None if reply.pending => pending.push(request.clone()), + None => { + ui.file_thumbs.store_missing(key.clone()); + ui.file_thumbs.wanted.borrow_mut().remove(&key); + } + } + } + if !pending.is_empty() { + retry_file_thumbs(&ui, pending); + } + } + Ok(Ok(Response::Error { message, .. })) => { + tracing::debug!("file thumbnails failed: {message}"); + } + Ok(Ok(_)) | Ok(Err(_)) | Err(_) => { + tracing::debug!("file thumbnails: no reply"); + } + } + schedule_file_thumbs(&ui); + }); +} + +fn retry_file_thumbs(ui: &Rc, items: Vec) { + let ui = ui.clone(); + glib::timeout_add_local_once(THUMB_RETRY, move || { + let wanted = ui.file_thumbs.wanted.borrow(); + let retry: Vec = items + .into_iter() + .filter(|item| wanted.contains_key(&thumbnail_key(&item.uid, item.modified))) + .collect(); + drop(wanted); + ui.file_thumbs.queue.borrow_mut().extend(retry); + schedule_file_thumbs(&ui); + }); +} + +#[cfg(test)] +mod tests { + use super::is_image_name; + + #[test] + fn image_extensions_are_case_insensitive_and_specific() { + for name in [ + "photo.jpg", + "SCAN.PNG", + "animation.Gif", + "archive.tiff", + "camera.nef", + "negative.CR3", + ] { + assert!(is_image_name(name), "{name}"); + } + for name in [ + "notes.txt", + "movie.mp4", + "image.jpg.zip", + "jpg", + "camera.heic", + "frame.avif", + "graphic.svg", + ] { + assert!(!is_image_name(name), "{name}"); + } + } +}