fix(gui): keep the user's place when navigating

A folder opened in My files, Shared with me or Computers kept the scroll
offset of the folder before it, because the rows are swapped in place to
keep a refresh steady. Another folder, a new search and a fresh Photos
load (filter, album, place) now start at the top; a refresh still keeps
its place.

Closing a photo opened from the Places map returns to the map instead of
leaving the town's timeline behind, and the pending open is dropped when
the user leaves the town before it loads.

load_gallery no longer swaps Albums or Places for the timeline when a
page lands late (viewer paging, upload, trash Undo); the timeline is
marked stale and read when the user switches back. Coming back to Photos
reuses the timeline unless it is stale, and the timeline returns
filtered the way the kind tabs and date show after an album or place.

A search in My files is no longer replaced by the folder listing on file
changes, F5 or returning to the page. A favorite set in the viewer shows
on its tile at once.
This commit is contained in:
nvrl committed 2026-11-14 12:53:04 +01:00
1 parent 57acb0b849
commit 6be8a8f9bc
11 files changed
+178 -21

No files matched your search

+12 -2
View File
@@ -128,8 +128,18 @@ fn follow_changes(ui: &Rc<Ui>, 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")
+13 -4
View File
@@ -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<Ui>) {
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.
+17 -4
View File
@@ -664,12 +664,25 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
}
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);
+22 -1
View File
@@ -2921,6 +2921,18 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
});
}
/// 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<Ui>) {
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<Ui>) {
pub(crate) fn repaint_browser(ui: &Rc<Ui>, 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<Ui>, 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<Ui>, 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 {
+5
View File
@@ -297,6 +297,7 @@ fn load_device_folder(ui: &Rc<Ui>, 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<Ui>, 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();
}
}
});
}
@@ -1001,7 +1001,10 @@ fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, 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<Ui>, 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<Ui>, initial_uid: String) -> gtk4::Window {
let parent = ui.stack.root().and_downcast::<gtk4::Window>().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<Ui>, 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<Ui>, initial_uid: String) {
window.add_controller(key_controller);
window.present();
window
}
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
+36 -1
View File
@@ -3952,7 +3952,9 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: &gtk4::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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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()
+31 -7
View File
@@ -392,21 +392,45 @@ fn load_place_locations(ui: &Rc<Ui>, 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<Ui>, 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<Ui>, uid: String) {
/// The open place's id, if a place is open.
fn open_place_id(ui: &Ui) -> Option<u32> {
ui.gallery.place.borrow().as_ref().map(|place| place.id)
}
fn view_when_loaded(ui: &Rc<Ui>, 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);
}
+5 -1
View File
@@ -443,6 +443,7 @@ fn repaint_shared_files(ui: &Rc<Ui>, 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<Ui>, 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
@@ -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")
+16
View File
@@ -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.