feat(gui): show file thumbnails and browser status bar

This commit is contained in:
emptyname committed 2026-08-18 13:06:36 +01:00
1 parent 91b0864fce
commit b0f9513915
11 files changed
+1053 -114

No files matched your search

+3
View File
@@ -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"
);
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Generate-thumbnails action: a picture frame with a sparkle. A single
symbolic fill lets GTK recolour it with the surrounding control. -->
<path fill="#222222" d="M 2,3 C 1.45,3 1,3.45 1,4 V 14 C 1,14.55 1.45,15 2,15 H 14 C 14.55,15 15,14.55 15,14 V 7 H 13 V 13 H 3 V 5 H 9 V 3 Z M 5,6 C 4.45,6 4,6.45 4,7 C 4,7.55 4.45,8 5,8 C 5.55,8 6,7.55 6,7 C 6,6.45 5.55,6 5,6 Z M 10,7 L 7.5,10 L 6,9 L 4,11.5 V 12 H 12 V 9 Z M 13,0 L 12.4,2.4 L 10,3 L 12.4,3.6 L 13,6 L 13.6,3.6 L 16,3 L 13.6,2.4 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 598 B

@@ -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. -->
<gresource prefix="/de/nils/protondrivelinux/icons">
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
</gresource>
</gresources>
+32 -2
View File
@@ -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),
+490 -53
View File
@@ -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<Option<glib::SourceId>>,
pub(crate) thumbnail_build_running: Cell<bool>,
/// 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<Option<glib::SourceId>>,
/// 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<u64>,
/// 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<i32>,
/// 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<Vec<(glib::WeakRef<gtk4::Overlay>, glib::WeakRef<gtk4::Label>)>>,
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(&quota);
quota_box.append(&quota_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(&quota_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(&gtk4::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<Ui>, grid: &gtk4::GridView, column_view: &gtk
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::<gtk4::ListItem>().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<Ui>, grid: &gtk4::GridView, column_view: &gtk
.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<Ui>, grid: &gtk4::GridView, column_view: &gtk
item.set_child(Some(&tile));
}
});
factory.connect_bind(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let tile = item.child().and_downcast::<gtk4::Box>().unwrap();
let overlay = tile.first_child().and_downcast::<gtk4::Overlay>().unwrap();
let icon = overlay.first_child().and_downcast::<gtk4::Image>().unwrap();
let badge = overlay.last_child().and_downcast::<gtk4::Image>().unwrap();
let label = tile.last_child().and_downcast::<gtk4::Label>().unwrap();
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
let entry = obj.borrow::<DirEntry>();
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::<gtk4::ListItem>().unwrap();
let tile = item.child().and_downcast::<gtk4::Box>().unwrap();
let thumbnail = tile.first_child().and_downcast::<gtk4::Overlay>().unwrap();
let badge = thumbnail
.last_child()
.and_downcast::<gtk4::Image>()
.unwrap();
let label = thumbnail
.next_sibling()
.and_downcast::<gtk4::Label>()
.unwrap();
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
let entry = obj.borrow::<DirEntry>();
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<Ui>, grid: &gtk4::GridView, column_view: &gtk
});
}
/// 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: &gtk4::Overlay, label: &gtk4::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<Ui>) -> gtk4::ColumnViewColumn {
let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup({
let ui = ui.clone();
move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().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<Ui>) -> 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::<gtk4::ListItem>().unwrap();
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
let icon = cell.first_child().and_downcast::<gtk4::Image>().unwrap();
let badge = cell.last_child().and_downcast::<gtk4::Image>().unwrap();
let label = icon.next_sibling().and_downcast::<gtk4::Label>().unwrap();
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
let entry = obj.borrow::<DirEntry>();
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::<gtk4::ListItem>().unwrap();
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
let thumbnail = cell.first_child().and_downcast::<gtk4::Overlay>().unwrap();
let badge = thumbnail
.last_child()
.and_downcast::<gtk4::Image>()
.unwrap();
let label = thumbnail
.next_sibling()
.and_downcast::<gtk4::Label>()
.unwrap();
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
let entry = obj.borrow::<DirEntry>();
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: &gtk4::Button,
upload: &gtk4::Button,
upload_folder: &gtk4::Button,
build_thumbnails: &gtk4::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<Ui>) {
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<Ui>) {
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<Ui>, 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<Ui>, 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<Ui>) {
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<Ui>) {
// 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<Ui>) {
/// (which restarts the service) wouldn't help and isn't offered.
pub(crate) fn browser_failed(ui: &Rc<Ui>, 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<Ui>, message: &str, kind: ErrorKind) {
/// once the systemd mount comes up but a real failure stays visible.
pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
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<Ui>) {
);
return;
}
ui.browser.summary.set_label("Connecting…");
browser_status(
ui,
"folder-remote-symbolic",
@@ -1759,6 +2126,15 @@ pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
/// which refreshes both the grid and the column list.
pub(crate) fn repaint_browser(ui: &Rc<Ui>, 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<Ui>) {
/// 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<Ui>, 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<Ui>, 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<Ui>, query: &str) {
/// handlers already understand.
pub(crate) fn repaint_search(ui: &Rc<Ui>, 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<Ui>, 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));
}
}
+2 -5
View File
@@ -151,6 +151,7 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
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<Ui>, entry: &DirEntry) -> adw::ActionRow {
if let Some(badge) = role_badge(&entry.role) {
row.add_suffix(&badge);
}
row.add_prefix(&gtk4::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();
@@ -113,6 +113,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
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<Ui>, items: &[SharedItem]) {
ui.shared_by_me.content.set_visible_child_name("list");
let mut rows: Vec<gtk4::Widget> = 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(&gtk4::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<Ui>, 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(),
+100 -23
View File
@@ -572,7 +572,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
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<Ui>) {
}
/// 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<Ui>) {
if ui.status.quota_inflight.get() {
@@ -609,28 +607,71 @@ pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
})) = 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<Ui>, 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<Ui>, 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<Ui>, 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);
}
}
+28 -24
View File
@@ -122,7 +122,7 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: &gtk4::ListView, empty: &gtk4::Butto
factory.connect_setup(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().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<Ui>, list: &gtk4::ListView, empty: &gtk4::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::<gtk4::ListItem>().unwrap();
let Some(entry) = bound_entry(item) else {
return;
};
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
let Some(icon) = row.first_child().and_downcast::<gtk4::Image>() else {
return;
};
icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry))));
let Some(text) = icon.next_sibling().and_downcast::<gtk4::Box>() else {
return;
};
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
name.set_label(&entry.name);
}
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
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::<gtk4::ListItem>().unwrap();
let Some(entry) = bound_entry(item) else {
return;
};
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
let Some(thumbnail) = row.first_child().and_downcast::<gtk4::Overlay>() else {
return;
};
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
let Some(text) = thumbnail.next_sibling().and_downcast::<gtk4::Box>() else {
return;
};
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
name.set_label(&entry.name);
}
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
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: &gtk4::ListItem) -> Option<DirEntry> {
/// Fetch the trash listing and repaint the page.
pub(crate) fn load_trash(ui: &Rc<Ui>) {
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();
+1
View File
@@ -1,3 +1,4 @@
pub(crate) mod details;
pub(crate) mod share_dialog;
pub(crate) mod thumbnails;
pub(crate) mod versions_dialog;
@@ -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<HashMap<String, gtk4::gdk::Texture>>,
texture_order: RefCell<VecDeque<String>>,
missing: RefCell<HashSet<String>>,
missing_order: RefCell<VecDeque<String>>,
wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
queue: RefCell<VecDeque<FileThumbRequest>>,
inflight: Cell<bool>,
source: RefCell<Option<glib::SourceId>>,
generation: Cell<u64>,
}
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<Ui>) {
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: &gtk4::Overlay, size: i32, fallback_size: i32) {
widget.set_width_request(size);
widget.set_height_request(size);
let Some(fallback) = widget.child().and_downcast::<gtk4::Image>() else {
return;
};
fallback.set_pixel_size(fallback_size);
if let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() {
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<Ui>,
widget: &gtk4::Overlay,
entry: &DirEntry,
symbolic_fallback: bool,
) {
let Some(fallback) = widget.child().and_downcast::<gtk4::Image>() 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::<gtk4::Picture>() 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<Ui>,
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<Ui>, picture: &gtk4::Picture) {
let mut wanted = ui.file_thumbs.wanted.borrow_mut();
wanted.retain(|_, pictures| {
pictures.retain(|candidate| candidate != picture);
!pictures.is_empty()
});
}
fn paint(picture: &gtk4::Picture, key: &str, texture: &gtk4::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::<gtk4::Overlay>()
&& let Some(fallback) = overlay.child().and_downcast::<gtk4::Image>()
{
fallback.set_visible(false);
}
}
fn schedule_file_thumbs(ui: &Rc<Ui>) {
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<Ui>) {
if ui.file_thumbs.inflight.get() {
return;
}
let items: Vec<FileThumbRequest> = {
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<String, FileThumbRequest> = 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<Ui>, items: Vec<FileThumbRequest>) {
let ui = ui.clone();
glib::timeout_add_local_once(THUMB_RETRY, move || {
let wanted = ui.file_thumbs.wanted.borrow();
let retry: Vec<FileThumbRequest> = 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}");
}
}
}