diff --git a/crates/pdfs-gui/src/app/events.rs b/crates/pdfs-gui/src/app/events.rs index e951b1d..6302390 100644 --- a/crates/pdfs-gui/src/app/events.rs +++ b/crates/pdfs-gui/src/app/events.rs @@ -128,8 +128,18 @@ fn follow_changes(ui: &Rc, state: &FeedState, topics: &[Topic]) { if changed(Topic::Shares) { refresh_invitations_badge(ui, Duration::ZERO); } - match ui.stack.visible_child_name().as_deref() { - Some("browser") if changed(Topic::Files) => load_browser(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). + if changed(Topic::Photos) && (page.as_deref() != Some("gallery") || !photo_grid_shown(ui)) { + ui.gallery.timeline_stale.set(true); + } + match page.as_deref() { + // A search on screen is left alone: reloading would swap its hits for + // the folder's listing while the query stays in the box. + Some("browser") if changed(Topic::Files) && ui.browser.search.text().trim().is_empty() => { + load_browser(ui) + } // The timeline is left alone: reloading it would throw away the place // the user scrolled to. F5 reloads it on request. Some("gallery") diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 1678368..ba41ea3 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -726,17 +726,26 @@ fn build_window(app: &adw::Application) { cancel_file_thumbnails(&ui_nav); sync_sidebar(&ui_nav); match st.visible_child_name().as_deref() { - Some("browser") => load_browser(&ui_nav), + Some("browser") => reload_browser_view(&ui_nav), // The Albums and Places listings are re-read in place. Loading the // timeline instead would swap it in under the Albums or Places toggle. + // The timeline, an album or a place comes back as it was left, + // scrolled where it was, unless photos changed while it was out of + // sight (see `follow_changes`) or nothing is loaded yet. Some("gallery") if !ui_nav.gallery.in_collection() => { match ui_nav.gallery.view_switch.active() { VIEW_ALBUMS => load_albums(&ui_nav), VIEW_PLACES => load_places(&ui_nav), - _ => load_gallery(&ui_nav, false), + _ if ui_nav.gallery.timeline_stale.replace(false) + || ui_nav.gallery.model.n_items() == 0 => + { + load_gallery(&ui_nav, false) + } + _ => {} } } - Some("gallery") => load_gallery(&ui_nav, false), + Some("gallery") if ui_nav.gallery.model.n_items() == 0 => load_gallery(&ui_nav, false), + Some("gallery") => {} // Every visit reads the page again. The rows already on screen stay // up while it does, so arriving costs no "Loading…" flash. Some("sharedbyme") => load_shared_by_me(&ui_nav), @@ -1066,7 +1075,7 @@ fn reload_current_page(ui: &Rc) { match ui.stack.visible_child_name().as_deref() { Some("browser") => { let path = ui.browser.path.borrow().clone(); - refresh_then(ui, RefreshScope::Dir { path }, load_browser); + refresh_then(ui, RefreshScope::Dir { path }, reload_browser_view); } // One scope covers the whole photos view, so Refresh reloads whichever of // the views — a grid, or the timeline, an album or a place — is on screen. diff --git a/crates/pdfs-gui/src/app/pages/albums.rs b/crates/pdfs-gui/src/app/pages/albums.rs index 6af6ffd..96511d6 100644 --- a/crates/pdfs-gui/src/app/pages/albums.rs +++ b/crates/pdfs-gui/src/app/pages/albums.rs @@ -664,12 +664,25 @@ pub(crate) fn wire_albums(ui: &Rc) { } close_album(&ui_photos); close_place(&ui_photos); - ui_photos.gallery.filters.set_visible(true); - ui_photos.gallery.content.set_visible_child_name("timeline"); + let gallery = &ui_photos.gallery; + gallery.filters.set_visible(true); + gallery.content.set_visible_child_name("timeline"); + // An album or a place cleared the kind and the month it does not use; + // the filter controls still show them, so the timeline comes back + // filtered the way they say. + let kind = kind_for_tab(gallery.tabs.active() as usize); + let range = gallery + .date_ranges + .borrow() + .get(gallery.dates.selected() as usize) + .copied() + .flatten(); + let kind_changed = gallery.kind.replace(kind) != kind; + let range_changed = gallery.range.replace(range) != range; + let refiltered = kind_changed || range_changed; // The timeline comes back as it was left unless an album took over the // model since; then it has to be reloaded. - if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.gallery.model.n_items() == 0 - { + if gallery.timeline_stale.replace(false) || refiltered || gallery.model.n_items() == 0 { load_gallery(&ui_photos, false); } else { update_gallery_subtitle(&ui_photos); diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index a3d46a0..b6fb218 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -2921,6 +2921,18 @@ pub(crate) fn load_browser(ui: &Rc) { }); } +/// Read again what My files shows: the search hits while there is a query, +/// the folder otherwise. [`load_browser`] alone would swap a search's hits +/// for the folder while the query stays in the box. +pub(crate) fn reload_browser_view(ui: &Rc) { + let query = ui.browser.search.text().trim().to_string(); + if query.is_empty() { + load_browser(ui); + } else { + run_search(ui, &query); + } +} + /// Clear the model and show the daemon's error on the status page. Used for /// in-band failures (a bad path, a permission error) — the mount is up, so Retry /// (which restarts the service) wouldn't help and isn't offered. @@ -2959,7 +2971,10 @@ pub(crate) fn browser_unreachable(ui: &Rc) { pub(crate) fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { show_location_column(ui, false); *ui.browser.listing.borrow_mut() = entries.to_vec(); - *ui.browser.listed.borrow_mut() = Some(ui.browser.path.borrow().clone()); + // `listed` still names the folder the rows belong to only on a refresh; + // any other load forgot it (see `load_browser`). + let path = ui.browser.path.borrow().clone(); + let another_folder = ui.browser.listed.replace(Some(path.clone())).as_ref() != Some(&path); ui.browser.summary.set_label(&listing_summary( entries.iter().filter(|entry| !entry.is_dir).count(), entries.iter().filter(|entry| entry.is_dir).count(), @@ -2988,6 +3003,9 @@ pub(crate) fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { // Only the rows that changed are swapped, so a refresh keeps the // selection, the scroll position and the thumbnails already drawn. replace_items(&ui.browser.files.model, &sorted); + if another_folder { + ui.browser.files.scroll_to_top(); + } } /// Forget which folder the rows belong to and drop the selection, before the @@ -3166,6 +3184,9 @@ pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) { .collect(); sort_entries(&mut entries, ui.browser.view.get()); replace_items(&ui.browser.files.model, &entries); + // Every search starts over (`run_search` forgets the listing), so its hits + // read from the first one. + ui.browser.files.scroll_to_top(); } fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String { diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index 4956266..cbf4286 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -297,6 +297,7 @@ fn load_device_folder(ui: &Rc, nav: &[(String, String)]) { }; ui.devices.content.set_visible_child_name("browse"); repaint_device_crumb(&ui, &nav); + let another_folder = ui.devices.listed.borrow().as_ref() != Some(&nav); *ui.devices.listed.borrow_mut() = Some(nav); if entries.is_empty() { ui.devices.files.model.remove_all(); @@ -304,6 +305,10 @@ fn load_device_folder(ui: &Rc, nav: &[(String, String)]) { } else { ui.devices.files_content.set_visible_child_name("list"); replace_items(&ui.devices.files.model, &entries); + // A refresh keeps its place; another folder starts at the top. + if another_folder { + ui.devices.files.scroll_to_top(); + } } }); } diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs index 7866633..c4d507d 100644 --- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -1001,7 +1001,10 @@ fn show_photo_position(ui: &Rc, viewer: &Rc, index: u32, capture_tim /// /// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close /// button, or a click on the backdrop beside the photo all dismiss it. -pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { +/// +/// Returns the lightbox's window, for a caller that has to put the page +/// behind it back once it closes. +pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) -> gtk4::Window { let parent = ui.stack.root().and_downcast::().unwrap(); // An `adw::Window` for its breakpoints: the details panel lies over the @@ -1510,6 +1513,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { let failed = match rx.recv().await { Ok(Ok(Response::Ok { .. })) => { set_gallery_favorite(&ui_result, &uid, favorite); + // The tiles hold their own copy of the photo, so the star + // shows on the grid only once it is laid out again. + repaint_gallery(&ui_result); None } Ok(Ok(Response::Error { message, .. })) => Some(message), @@ -1718,6 +1724,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { window.add_controller(key_controller); window.present(); + window } /// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 09c74b5..dd3c76a 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -3952,7 +3952,9 @@ fn show_photo_menu(ui: &Rc, photo: &PhotoItem, anchor: >k4::Button, x: f64 } else { pgettext("verb", "Open") }; - menu.item(&label, move || open_photo_viewer(&ui_c, uid.clone())); + menu.item(&label, move || { + open_photo_viewer(&ui_c, uid.clone()); + }); let (ui_c, uid) = (ui.clone(), photo.uid.clone()); // Translators: a check item in a photo's menu, on while the photo is a favorite. let favorite_label = pgettext("state", "Favorite"); @@ -4020,8 +4022,21 @@ fn set_photo_favorite(ui: &Rc, uid: String, favorite: bool) { }); } +/// Whether the photo grid is the view the Photos page shows: an album or a +/// place, or the timeline itself. Under Albums or Places it is out of sight. +pub(crate) fn photo_grid_shown(ui: &Ui) -> bool { + ui.gallery.in_collection() || ui.gallery.view_switch.active() == VIEW_TIMELINE +} + pub(crate) fn load_gallery(ui: &Rc, append: bool) { sync_filter_badge(ui); + // A load would put the grid, or its status page, on screen in place of + // the Albums or Places view the user is looking at. Leave the timeline to + // be read again when they switch back to it. + if !photo_grid_shown(ui) { + ui.gallery.timeline_stale.set(true); + return; + } if ui.gallery.loading.get() { // A filter flipped while a page is on its way: that page answers the // old question, so fetch again once it lands. More pages can wait. @@ -4098,6 +4113,12 @@ pub(crate) fn load_gallery(ui: &Rc, append: bool) { if !waiters.is_empty() { glib::idle_add_local_once(move || waiters.into_iter().for_each(|waiter| waiter())); } + // The user left for Albums or Places while the page was on its way; + // painting it now would switch the view under them. + if !photo_grid_shown(&ui) { + ui.gallery.timeline_stale.set(true); + return; + } match result { Ok(Ok(Response::Photos { available, @@ -4138,6 +4159,20 @@ pub(crate) fn load_gallery(ui: &Rc, append: bool) { ui.gallery.model.append(&BoxedAnyObject::new(item.clone())); } repaint_gallery(&ui); + // The rows are swapped in place, which keeps the old scroll + // offset; a fresh load (another kind, month, album or place) + // reads from its newest photo. + if !append + && ui + .gallery + .list + .model() + .is_some_and(|rows| rows.n_items() > 0) + { + ui.gallery + .list + .scroll_to(0, gtk4::ListScrollFlags::NONE, None); + } if ui.gallery.model.n_items() == 0 { let filtered = ui.gallery.kind.get().is_some() || ui.gallery.favorites.get() diff --git a/crates/pdfs-gui/src/app/pages/places.rs b/crates/pdfs-gui/src/app/pages/places.rs index e2bb9ed..e05505f 100644 --- a/crates/pdfs-gui/src/app/pages/places.rs +++ b/crates/pdfs-gui/src/app/pages/places.rs @@ -392,21 +392,45 @@ fn load_place_locations(ui: &Rc, id: u32) { }); } -/// Open `place` and then photo `uid` in it, paging through the town until -/// the photo has loaded. +/// Open photo `uid` of `place` from the map or a spot's list, paging through +/// the town until the photo has loaded. +/// +/// The lightbox steps through the gallery's model, so the town is opened +/// behind it. Closing the lightbox goes back to the map rather than leaving +/// the user in a town timeline they never asked for. fn open_place_photo(ui: &Rc, place: PlaceInfo, uid: String) { + let id = place.id; open_place(ui, place); - view_when_loaded(ui, uid); + view_when_loaded(ui, id, uid); } -fn view_when_loaded(ui: &Rc, uid: String) { +/// The open place's id, if a place is open. +fn open_place_id(ui: &Ui) -> Option { + ui.gallery.place.borrow().as_ref().map(|place| place.id) +} + +fn view_when_loaded(ui: &Rc, id: u32, uid: String) { let ui_page = ui.clone(); ui.gallery.page_waiters.borrow_mut().push(Box::new(move || { let ui = ui_page; + // The user went elsewhere while the town loaded: whatever page lands + // now belongs to that, and must not pop the lightbox up over it. + if open_place_id(&ui) != Some(id) { + return; + } if find_photo_index(&ui.gallery.model, &uid).is_some() { - open_photo_viewer(&ui, uid); - } else if ui.gallery.has_more.get() && ui.gallery.place.borrow().is_some() { - view_when_loaded(&ui, uid); + let window = open_photo_viewer(&ui, uid); + let ui_close = ui.clone(); + window.connect_close_request(move |_| { + // Only if the town is still what the lightbox was showing: "Show + // on map" in the lightbox has already moved on by itself. + if open_place_id(&ui_close) == Some(id) { + show_place_grid(&ui_close); + } + glib::Propagation::Proceed + }); + } else if ui.gallery.has_more.get() { + view_when_loaded(&ui, id, uid); ui.gallery.burst.set(true); load_gallery(&ui, true); } diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index 2bfd3d5..9fd3038 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -443,6 +443,7 @@ fn repaint_shared_files(ui: &Rc, entries: &[DirEntry]) { let nav = ui.shared.nav.borrow().clone(); repaint_shared_crumb(ui, &nav); let top = nav.is_empty(); + let another_folder = ui.shared.listed.borrow().as_ref() != Some(&nav); *ui.shared.listed.borrow_mut() = Some(nav); if entries.is_empty() { ui.shared.files.model.remove_all(); @@ -467,8 +468,11 @@ fn repaint_shared_files(ui: &Rc, entries: &[DirEntry]) { } ui.shared.files_content.set_visible_child_name("list"); // Only the rows that changed are swapped, so a refresh keeps the selection - // and the scroll position. + // and the scroll position. Another folder starts from its first entry. replace_items(&ui.shared.files.model, entries); + if another_folder { + ui.shared.files.scroll_to_top(); + } } /// Rebuild the breadcrumb trail for `nav`: "Shared with me", then each folder diff --git a/crates/pdfs-gui/src/app/widgets/file_list.rs b/crates/pdfs-gui/src/app/widgets/file_list.rs index 06d2d46..94b5455 100644 --- a/crates/pdfs-gui/src/app/widgets/file_list.rs +++ b/crates/pdfs-gui/src/app/widgets/file_list.rs @@ -224,6 +224,19 @@ impl FileList { .set_visible_child_name(if list { "list" } else { "grid" }); } + /// Bring both views back to the first entry. For a listing of something + /// else: [`replace_items`] keeps the scroll position, which is right for a + /// refresh, but another folder would open where the last one was left — + /// often at its bottom. + pub(crate) fn scroll_to_top(&self) { + if self.model.n_items() == 0 { + return; + } + self.grid.scroll_to(0, gtk4::ListScrollFlags::NONE, None); + self.column_view + .scroll_to(0, None, gtk4::ListScrollFlags::NONE, None); + } + /// Whether the list, rather than the grid, is on screen. pub(crate) fn shows_list(&self) -> bool { self.views.visible_child_name().as_deref() == Some("list") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 68c4887..51e5579 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -115,6 +115,22 @@ Existing folders stay device backups. Older builds cannot open the upgraded data - **Videos over 256 MiB get a thumbnail** (B283). They never got one, and a smaller video was downloaded whole into memory for one frame. +- **A folder opens at the top.** A folder opened in My files, Shared with me or Computers could + open scrolled to where its parent was left, often at the bottom. Another folder, a new search + and a new Photos filter, album or place now start at the top, and a refresh keeps its place. + +- **Closing a photo opened from the Places map goes back to the map**, not to the town's + timeline. + +- **Albums and Places stay on screen.** They could switch to the timeline by themselves when a + timeline page arrived late, after an upload or after a trash Undo. Coming back to Photos no + longer reloads the timeline and loses its scroll position, unless photos changed while it was + out of sight. After an album or a place, the timeline came back unfiltered while the kind tab + and the date still showed the filter. + +- **A search in My files stays.** It was replaced by the folder's listing whenever files + changed, on refresh and on coming back to the page, while the query stayed in the box. + ## [3.7.0] — 2026-11-02 Schema: **44**, unchanged.