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) { if changed(Topic::Shares) {
refresh_invitations_badge(ui, Duration::ZERO); refresh_invitations_badge(ui, Duration::ZERO);
} }
match ui.stack.visible_child_name().as_deref() { let page = ui.stack.visible_child_name();
Some("browser") if changed(Topic::Files) => load_browser(ui), // 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 timeline is left alone: reloading it would throw away the place
// the user scrolled to. F5 reloads it on request. // the user scrolled to. F5 reloads it on request.
Some("gallery") Some("gallery")
+13 -4
View File
@@ -726,17 +726,26 @@ fn build_window(app: &adw::Application) {
cancel_file_thumbnails(&ui_nav); cancel_file_thumbnails(&ui_nav);
sync_sidebar(&ui_nav); sync_sidebar(&ui_nav);
match st.visible_child_name().as_deref() { 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 // The Albums and Places listings are re-read in place. Loading the
// timeline instead would swap it in under the Albums or Places toggle. // 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() => { Some("gallery") if !ui_nav.gallery.in_collection() => {
match ui_nav.gallery.view_switch.active() { match ui_nav.gallery.view_switch.active() {
VIEW_ALBUMS => load_albums(&ui_nav), VIEW_ALBUMS => load_albums(&ui_nav),
VIEW_PLACES => load_places(&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 // Every visit reads the page again. The rows already on screen stay
// up while it does, so arriving costs no "Loading…" flash. // up while it does, so arriving costs no "Loading…" flash.
Some("sharedbyme") => load_shared_by_me(&ui_nav), 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() { match ui.stack.visible_child_name().as_deref() {
Some("browser") => { Some("browser") => {
let path = ui.browser.path.borrow().clone(); 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 // 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. // 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_album(&ui_photos);
close_place(&ui_photos); close_place(&ui_photos);
ui_photos.gallery.filters.set_visible(true); let gallery = &ui_photos.gallery;
ui_photos.gallery.content.set_visible_child_name("timeline"); 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 // The timeline comes back as it was left unless an album took over the
// model since; then it has to be reloaded. // 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); load_gallery(&ui_photos, false);
} else { } else {
update_gallery_subtitle(&ui_photos); 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 /// 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 /// 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. /// (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]) { pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
show_location_column(ui, false); show_location_column(ui, false);
*ui.browser.listing.borrow_mut() = entries.to_vec(); *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( 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).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 // Only the rows that changed are swapped, so a refresh keeps the
// selection, the scroll position and the thumbnails already drawn. // selection, the scroll position and the thumbnails already drawn.
replace_items(&ui.browser.files.model, &sorted); 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 /// 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(); .collect();
sort_entries(&mut entries, ui.browser.view.get()); sort_entries(&mut entries, ui.browser.view.get());
replace_items(&ui.browser.files.model, &entries); 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 { 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"); ui.devices.content.set_visible_child_name("browse");
repaint_device_crumb(&ui, &nav); repaint_device_crumb(&ui, &nav);
let another_folder = ui.devices.listed.borrow().as_ref() != Some(&nav);
*ui.devices.listed.borrow_mut() = Some(nav); *ui.devices.listed.borrow_mut() = Some(nav);
if entries.is_empty() { if entries.is_empty() {
ui.devices.files.model.remove_all(); ui.devices.files.model.remove_all();
@@ -304,6 +305,10 @@ fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
} else { } else {
ui.devices.files_content.set_visible_child_name("list"); ui.devices.files_content.set_visible_child_name("list");
replace_items(&ui.devices.files.model, &entries); 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 /// 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. /// 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(); let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
// An `adw::Window` for its breakpoints: the details panel lies over the // 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 { let failed = match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => { Ok(Ok(Response::Ok { .. })) => {
set_gallery_favorite(&ui_result, &uid, favorite); 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 None
} }
Ok(Ok(Response::Error { message, .. })) => Some(message), 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.add_controller(key_controller);
window.present(); window.present();
window
} }
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself /// 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 { } else {
pgettext("verb", "Open") 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()); 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. // Translators: a check item in a photo's menu, on while the photo is a favorite.
let favorite_label = pgettext("state", "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) { pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
sync_filter_badge(ui); 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() { if ui.gallery.loading.get() {
// A filter flipped while a page is on its way: that page answers the // 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. // 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() { if !waiters.is_empty() {
glib::idle_add_local_once(move || waiters.into_iter().for_each(|waiter| waiter())); 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 { match result {
Ok(Ok(Response::Photos { Ok(Ok(Response::Photos {
available, available,
@@ -4138,6 +4159,20 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
ui.gallery.model.append(&BoxedAnyObject::new(item.clone())); ui.gallery.model.append(&BoxedAnyObject::new(item.clone()));
} }
repaint_gallery(&ui); 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 { if ui.gallery.model.n_items() == 0 {
let filtered = ui.gallery.kind.get().is_some() let filtered = ui.gallery.kind.get().is_some()
|| ui.gallery.favorites.get() || 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 /// Open photo `uid` of `place` from the map or a spot's list, paging through
/// the photo has loaded. /// 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) { fn open_place_photo(ui: &Rc<Ui>, place: PlaceInfo, uid: String) {
let id = place.id;
open_place(ui, place); 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(); let ui_page = ui.clone();
ui.gallery.page_waiters.borrow_mut().push(Box::new(move || { ui.gallery.page_waiters.borrow_mut().push(Box::new(move || {
let ui = ui_page; 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() { if find_photo_index(&ui.gallery.model, &uid).is_some() {
open_photo_viewer(&ui, uid); let window = open_photo_viewer(&ui, uid);
} else if ui.gallery.has_more.get() && ui.gallery.place.borrow().is_some() { let ui_close = ui.clone();
view_when_loaded(&ui, uid); 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); ui.gallery.burst.set(true);
load_gallery(&ui, 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(); let nav = ui.shared.nav.borrow().clone();
repaint_shared_crumb(ui, &nav); repaint_shared_crumb(ui, &nav);
let top = nav.is_empty(); let top = nav.is_empty();
let another_folder = ui.shared.listed.borrow().as_ref() != Some(&nav);
*ui.shared.listed.borrow_mut() = Some(nav); *ui.shared.listed.borrow_mut() = Some(nav);
if entries.is_empty() { if entries.is_empty() {
ui.shared.files.model.remove_all(); 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"); ui.shared.files_content.set_visible_child_name("list");
// Only the rows that changed are swapped, so a refresh keeps the selection // 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); 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 /// 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" }); .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. /// Whether the list, rather than the grid, is on screen.
pub(crate) fn shows_list(&self) -> bool { pub(crate) fn shows_list(&self) -> bool {
self.views.visible_child_name().as_deref() == Some("list") 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 - **Videos over 256 MiB get a thumbnail** (B283). They never got one, and a smaller video was
downloaded whole into memory for one frame. 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 ## [3.7.0] — 2026-11-02
Schema: **44**, unchanged. Schema: **44**, unchanged.