mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(gui): show file thumbnails and browser status bar
This commit is contained in:
1 parent
91b0864fce
commit
b0f9513915
11 files changed
+1053
-114
No files matched your search
@@ -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>
|
||||
@@ -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),
|
||||
|
||||
@@ -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("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<Ui>, 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::<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: >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<Ui>, grid: >k4::GridView, column_view: >k
|
||||
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: >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<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: >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<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));
|
||||
}
|
||||
}
|
||||
@@ -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(>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();
|
||||
|
||||
@@ -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(>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<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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::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: >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::<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: >k4::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,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: >k4::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: >k4::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: >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::<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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user