diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 190a5e6..8075e50 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -768,6 +768,9 @@ pub enum Topic { Conflicts, /// The activity log gained entries. Activity, + /// File thumbnails were made or fetched. A front-end asks again for the + /// tiles it still shows blank; the ones it already painted stay as they are. + Thumbnails, /// A topic from a newer daemon, which this front-end cannot act on. #[serde(other)] Unknown, @@ -777,7 +780,7 @@ impl Topic { /// Every topic a front-end can act on. A new subscription is told all of /// them have changed, so it catches up on whatever happened while it was /// not connected. - pub const ALL: [Topic; 9] = [ + pub const ALL: [Topic; 10] = [ Topic::Files, Topic::Photos, Topic::Trash, @@ -787,6 +790,7 @@ impl Topic { Topic::Queue, Topic::Conflicts, Topic::Activity, + Topic::Thumbnails, ]; } @@ -2663,6 +2667,16 @@ mod tests { assert!(end.is_none(), "{end:?}"); } + #[test] + fn a_thumbnail_change_travels_as_its_own_topic() { + let line = serde_json::to_string(&Event::Changed { + topics: vec![Topic::Thumbnails], + }) + .unwrap(); + assert_eq!(line, r#"{"Changed":{"topics":["thumbnails"]}}"#); + assert!(Topic::ALL.contains(&Topic::Thumbnails)); + } + #[test] fn a_daemon_without_subscriptions_is_told_apart_from_a_dead_one() { let socket = fake_daemon("legacy", "{\"Error\":{\"message\":\"unknown request\"}}\n"); diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index 0f4ff39..5639795 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -1910,6 +1910,11 @@ impl Core { ThumbAttempt::Cancelled => summary.cancelled += 1, } } + // A folder on screen whose tiles were given up on, as a recursive + // build's start does to them, fills in without being opened again. + if summary.made > 0 { + self.events.publish(&[Topic::Thumbnails]); + } summary } diff --git a/crates/pdfs-gui/src/app/events.rs b/crates/pdfs-gui/src/app/events.rs index 6302390..65e33f1 100644 --- a/crates/pdfs-gui/src/app/events.rs +++ b/crates/pdfs-gui/src/app/events.rs @@ -128,6 +128,10 @@ fn follow_changes(ui: &Rc, state: &FeedState, topics: &[Topic]) { if changed(Topic::Shares) { refresh_invitations_badge(ui, Duration::ZERO); } + // On any page: a grid, a list or a player may show a tile still blank. + if changed(Topic::Thumbnails) { + refresh_file_thumbnails(ui); + } let page = ui.stack.visible_child_name(); // A timeline out of sight is read again when the user comes back to it; // one on screen is left alone (below). diff --git a/crates/pdfs-gui/src/app/widgets/thumbnails.rs b/crates/pdfs-gui/src/app/widgets/thumbnails.rs index ad9abd4..d843b51 100644 --- a/crates/pdfs-gui/src/app/widgets/thumbnails.rs +++ b/crates/pdfs-gui/src/app/widgets/thumbnails.rs @@ -11,6 +11,7 @@ use pdfs_core::control::{FileThumbRequest, is_thumbnail_media_name, is_video_fil const THUMB_BATCH: usize = 32; const THUMB_CACHE_MAX: usize = 256; const THUMB_MISSING_MAX: usize = 2048; +const THUMB_BLANK_MAX: usize = 1024; const THUMB_DEBOUNCE: Duration = Duration::from_millis(40); const THUMB_RETRY: Duration = Duration::from_secs(2); @@ -20,6 +21,9 @@ pub(crate) struct FileThumbnailState { missing: RefCell>, missing_order: RefCell>, wanted: RefCell>>, + /// Tiles on screen still showing their icon, by key, kept across a + /// cancelled request so [`refresh_file_thumbnails`] can ask for them again. + blank: RefCell>, queue: RefCell>, inflight: Cell, source: RefCell>, @@ -27,6 +31,18 @@ pub(crate) struct FileThumbnailState { generation_loading: Cell, } +/// What a tile left blank needs to be asked for again. +struct BlankTile { + request: FileThumbRequest, + pictures: Vec>, +} + +/// Whether a picture registered as blank under `key` still is: bound to that +/// key, not painted, and in a window rather than in a list's spare pool. +fn tile_still_blank(key: &str, bound: &str, painted: bool, attached: bool) -> bool { + bound == key && !painted && attached +} + #[derive(Debug, PartialEq, Eq)] enum ReplyDisposition { Ignore, @@ -55,6 +71,7 @@ impl FileThumbnailState { missing: RefCell::new(HashSet::new()), missing_order: RefCell::new(VecDeque::new()), wanted: RefCell::new(HashMap::new()), + blank: RefCell::new(HashMap::new()), queue: RefCell::new(VecDeque::new()), inflight: Cell::new(false), source: RefCell::new(None), @@ -88,6 +105,85 @@ impl FileThumbnailState { } } } + + fn remember_blank(&self, key: &str, request: &FileThumbRequest, picture: >k4::Picture) { + let mut blank = self.blank.borrow_mut(); + if blank.len() >= THUMB_BLANK_MAX { + prune_blank(&mut blank); + } + let tile = blank.entry(key.to_string()).or_insert_with(|| BlankTile { + request: request.clone(), + pictures: Vec::new(), + }); + if !tile + .pictures + .iter() + .any(|weak| weak.upgrade().as_ref() == Some(picture)) + { + tile.pictures.push(picture.downgrade()); + } + } +} + +/// Drop the tiles that were painted, rebound to another file, or freed. +fn prune_blank(blank: &mut HashMap) { + blank.retain(|key, tile| { + tile.pictures.retain(|weak| { + weak.upgrade().is_some_and(|picture| { + tile_still_blank( + key, + &picture.widget_name(), + picture.is_visible(), + picture.root().is_some(), + ) + }) + }); + !tile.pictures.is_empty() + }); +} + +/// Ask again for every tile on screen still showing its icon. Sent when the +/// daemon says thumbnails landed, so a folder fills in while a build runs in +/// it, or after its own requests were cancelled, without being opened again. +pub(crate) fn refresh_file_thumbnails(ui: &Rc) { + let tiles: Vec<(String, FileThumbRequest, Vec)> = { + let mut blank = ui.file_thumbs.blank.borrow_mut(); + prune_blank(&mut blank); + blank + .iter() + .map(|(key, tile)| { + let pictures = tile.pictures.iter().filter_map(|w| w.upgrade()).collect(); + (key.clone(), tile.request.clone(), pictures) + }) + .collect() + }; + if tiles.is_empty() { + return; + } + for (key, request, pictures) in tiles { + let cached = ui.file_thumbs.textures.borrow().get(&key).cloned(); + if let Some(texture) = cached { + for picture in &pictures { + paint(picture, &key, &texture); + } + ui.file_thumbs.blank.borrow_mut().remove(&key); + continue; + } + // A miss from before the thumbnail landed is no verdict any more. + ui.file_thumbs.missing.borrow_mut().remove(&key); + let mut wanted = ui.file_thumbs.wanted.borrow_mut(); + let waiters = wanted.entry(key).or_default(); + for picture in pictures { + if !waiters.contains(&picture) { + waiters.push(picture); + } + } + drop(wanted); + let mut queue = ui.file_thumbs.queue.borrow_mut(); + queue.retain(|item| item.uid != request.uid); + queue.push_back(request); + } + schedule_file_thumbs(ui); } /// Abandon every opportunistic ordinary-file thumbnail associated with the @@ -230,6 +326,12 @@ pub(crate) fn bind_file_thumbnail( paint(&picture, &key, texture); return; } + let request = FileThumbRequest { + uid: entry.uid.clone(), + modified: entry.modified, + name: entry.name.clone(), + }; + ui.file_thumbs.remember_blank(&key, &request, &picture); if ui.file_thumbs.missing.borrow().contains(&key) { return; } @@ -242,11 +344,7 @@ pub(crate) fn bind_file_thumbnail( .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(), - }); + queue.push_back(request); drop(queue); schedule_file_thumbs(ui); } @@ -387,6 +485,7 @@ fn flush_file_thumbs(ui: &Rc) { match gtk4::gdk::Texture::from_filename(&path) { Ok(texture) => { ui.file_thumbs.store_texture(&key, texture.clone()); + ui.file_thumbs.blank.borrow_mut().remove(&key); if let Some(pictures) = ui.file_thumbs.wanted.borrow_mut().remove(&key) { @@ -447,7 +546,16 @@ fn retry_file_thumbs(ui: &Rc, items: Vec) { #[cfg(test)] mod tests { - use super::{ReplyDisposition, reply_disposition}; + use super::{ReplyDisposition, reply_disposition, tile_still_blank}; + + #[test] + fn only_a_tile_still_showing_its_icon_is_asked_for_again() { + assert!(tile_still_blank("a:1", "a:1", false, true)); + assert!(!tile_still_blank("a:1", "a:1", true, true), "painted"); + assert!(!tile_still_blank("a:1", "b:1", false, true), "rebound"); + assert!(!tile_still_blank("a:1", "a:2", false, true), "file changed"); + assert!(!tile_still_blank("a:1", "a:1", false, false), "spare row"); + } fn is_image_name(name: &str) -> bool { pdfs_core::control::is_thumbnail_image_name(name) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b76b258..8bb490f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -116,6 +116,11 @@ Existing folders stay device backups. Older builds cannot open the upgraded data screen there, and with nothing to scroll no more were loaded. More now load whenever the photos don't fill the window, also after a zoom or a resize. +- **Thumbnails appear in the folder you are looking at as soon as they are made.** During + **Build thumbnails**, the folder on screen kept its file icons until you left it and opened it + again. Its tiles now fill in while the build runs, and the same goes for any tile whose + thumbnail is made while it is on screen. + - **My files comes back after the service was killed for hanging** (B279). A killed service left its folders behind as dead mounts, and every restart then failed with `File exists` and retried forever without opening anything, until the mounts were cleared by hand. The service now clears