Merge pull request #86 from narrrl/fix/keep-place-when-navigating

fix(gui): keep the user's place when navigating
This commit is contained in:
nvrl authored and GitHub committed 2026-11-14 12:57:35 +01:00
commit 033c8d3d71
20 files changed
+5294 -4986

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")
+42 -6
View File
@@ -457,6 +457,8 @@ fn build_window(app: &adw::Application) {
files: browser_widgets.files.clone(),
history: RefCell::new(Vec::new()),
future: RefCell::new(Vec::new()),
places: RefCell::new(HashMap::new()),
restore: RefCell::new(None),
crumb: browser_widgets.crumb.clone(),
content: browser_widgets.content.clone(),
status: browser_widgets.status.clone(),
@@ -726,17 +728,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 +1077,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.
@@ -1539,6 +1550,7 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
("<Control>minus", gettext_noop("Smaller thumbnails")),
("<Control>0", gettext_noop("Reset thumbnail size")),
("<Control>a", gettext_noop("Select all")),
("Escape <Alt>Left", gettext_noop("Back")),
],
),
(
@@ -1575,8 +1587,17 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
controller.connect_key_pressed(move |_, key, _, state| {
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
let on_trash = ui.stack.visible_child_name().as_deref() == Some("trash");
let page = ui.stack.visible_child_name();
let on_browser = page.as_deref() == Some("browser");
let on_trash = page.as_deref() == Some("trash");
let on_gallery = page.as_deref() == Some("gallery");
// Pages with a level to go back to, besides My files' own history.
let in_collection = on_gallery && ui.gallery.in_collection();
let in_shared_folder =
page.as_deref() == Some("shared") && !ui.shared.nav.borrow().is_empty();
let in_device_folder =
page.as_deref() == Some("devices") && !ui.devices.nav.borrow().is_empty();
let back_key = (key == gtk4::gdk::Key::Left && alt) || key == gtk4::gdk::Key::BackSpace;
match key {
// Refresh works on every page, so it is matched before the
// browser-only bindings.
@@ -1633,6 +1654,21 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
gtk4::gdk::Key::BackSpace if on_browser => {
ui.browser.actions.activate_action("back", None)
}
// Selection mode ends before Escape leaves anything; the grid
// handles this itself while it has the focus.
gtk4::gdk::Key::Escape if on_gallery && ui.gallery.selecting.get() => {
set_selection_mode(&ui, false)
}
// An album or a place is a level below Photos, as a folder is
// below its parent: Back leaves it, and so does Escape.
_ if in_collection
&& !ui.gallery.selecting.get()
&& (back_key || key == gtk4::gdk::Key::Escape) =>
{
ui.gallery.back.emit_clicked()
}
_ if in_shared_folder && back_key => shared_back(&ui),
_ if in_device_folder && back_key => devices_back(&ui),
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
gtk4::gdk::Key::Menu if visible_file_list(&ui).is_some() => {
if let Some(list) = visible_file_list(&ui) {
+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);
+70 -1
View File
@@ -1,6 +1,14 @@
use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within};
use crate::*;
/// How a folder in My files was left: scrolled `offset` px down, with the
/// entries named `selected` highlighted.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct FolderPlace {
pub(crate) offset: f64,
pub(crate) selected: Vec<String>,
}
pub(crate) struct BrowserState {
// Files (browser) page.
/// The grid and list; the model is repopulated per directory.
@@ -10,6 +18,13 @@ pub(crate) struct BrowserState {
/// breadcrumb.
pub(crate) history: RefCell<Vec<String>>,
pub(crate) future: RefCell<Vec<String>>,
/// Where each folder left by Back, Forward, Up or a descent was scrolled
/// and what was selected in it, by path. Back, Forward and Up put the
/// folder back that way; opening it afresh starts at the top.
pub(crate) places: RefCell<HashMap<String, FolderPlace>>,
/// The folder whose [`Self::places`] entry the next listing restores, set
/// by Back, Forward and Up.
pub(crate) restore: RefCell<Option<String>>,
/// Clickable breadcrumb trail (a button per path segment); rebuilt per load
/// by [`repaint_crumb`] so each ancestor folder navigates on click.
pub(crate) crumb: gtk4::Box,
@@ -1556,8 +1571,29 @@ pub(crate) fn sort_entries(entries: &mut [DirEntry], view: FilesView) {
});
}
/// Remember how the folder on screen is scrolled and what is selected in it,
/// for Back, Forward or Up to bring it back that way. Only a listing of the
/// folder itself counts; search hits are not its rows.
fn remember_place(ui: &Rc<Ui>) {
let path = ui.browser.path.borrow().clone();
if ui.browser.listed.borrow().as_deref() != Some(path.as_str()) {
return;
}
let files = &ui.browser.files;
let place = FolderPlace {
offset: files.scroll_offset(),
selected: files
.selected()
.into_iter()
.map(|entry| entry.name)
.collect(),
};
ui.browser.places.borrow_mut().insert(path, place);
}
/// Open the folder on screen in a new place, remembering where we were.
pub(crate) fn browse_to(ui: &Rc<Ui>, target: String) {
remember_place(ui);
let left = ui.browser.path.replace(target.clone());
if left != target {
ui.browser.history.borrow_mut().push(left);
@@ -1582,6 +1618,8 @@ fn browse_back(ui: &Rc<Ui>) {
let Some(previous) = ui.browser.history.borrow_mut().pop() else {
return;
};
remember_place(ui);
*ui.browser.restore.borrow_mut() = Some(previous.clone());
let left = ui.browser.path.replace(previous);
ui.browser.future.borrow_mut().push(left);
load_browser(ui);
@@ -1592,6 +1630,8 @@ fn browse_forward(ui: &Rc<Ui>) {
return;
};
ui.browser.search.set_text("");
remember_place(ui);
*ui.browser.restore.borrow_mut() = Some(next.clone());
let left = ui.browser.path.replace(next);
ui.browser.history.borrow_mut().push(left);
load_browser(ui);
@@ -1604,6 +1644,7 @@ fn browse_up(ui: &Rc<Ui>) {
}
ui.browser.search.set_text("");
let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or_default();
*ui.browser.restore.borrow_mut() = Some(parent.to_string());
browse_to(ui, parent.to_string());
}
@@ -2921,6 +2962,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 +3012,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 +3044,16 @@ 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);
// Back, Forward and Up return to a folder as it was left; any other way
// in starts at the top.
let restore = ui.browser.restore.take().filter(|target| *target == path);
if another_folder {
let place = restore.and_then(|path| ui.browser.places.borrow().get(&path).cloned());
match place {
Some(place) => ui.browser.files.restore(place.offset, &place.selected),
None => ui.browser.files.scroll_to_top(),
}
}
}
/// Forget which folder the rows belong to and drop the selection, before the
@@ -3166,6 +3232,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 {
+16 -4
View File
@@ -190,10 +190,9 @@ pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
}));
let ui_back = ui.clone();
widgets.back.connect_clicked(move |_| {
ui_back.devices.nav.borrow_mut().pop();
load_devices(&ui_back);
});
widgets
.back
.connect_clicked(move |_| devices_back(&ui_back));
let ui_retry = ui.clone();
widgets
.retry
@@ -211,6 +210,14 @@ pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
ui.devices.content.set_visible_child_name("status");
}
/// Back out of a computer's folder to the level above: the page's Back button, and
/// Alt+Left or Backspace. A no-op at the top.
pub(crate) fn devices_back(ui: &Rc<Ui>) {
if ui.devices.nav.borrow_mut().pop().is_some() {
load_devices(ui);
}
}
/// Load the view [`DevicesState::nav`] points at: the device list, or a folder
/// of another computer's backup.
pub(crate) fn load_devices(ui: &Rc<Ui>) {
@@ -297,6 +304,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 +312,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();
}
}
});
}
+10 -1
View File
@@ -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
@@ -1425,8 +1428,10 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
// kept every closed lightbox, its window and its full-size photo in memory
// for the life of the app.
let keep = RefCell::new(Some(viewer.clone()));
let ui_close = ui.clone();
window.connect_close_request(move |_| {
if let Some(viewer) = keep.borrow_mut().take() {
reveal_photo(&ui_close, &viewer.uid.borrow());
stop_video(&viewer);
disarm_slide(&viewer);
if let Some(source) = viewer.chrome_source.borrow_mut().take() {
@@ -1510,6 +1515,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 +1726,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
+51 -1
View File
@@ -3288,6 +3288,16 @@ fn top_anchor(ui: &Rc<Ui>) -> Option<(String, f32)> {
.or(first)
}
/// Scroll the grid just far enough to show photo `uid`, as the lightbox
/// closes: paging with the arrow keys may have left the grid far behind.
pub(crate) fn reveal_photo(ui: &Rc<Ui>, uid: &str) {
if let Some(row) = row_of_photo(&ui.gallery.groups, uid) {
ui.gallery
.list
.scroll_to(row, gtk4::ListScrollFlags::NONE, None);
}
}
/// Which row of the rendered model holds `uid`, if any.
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
(0..store.n_items()).find(|i| {
@@ -3952,7 +3962,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 +4032,23 @@ 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);
// A scrubber jump was paging the grid the user has just left.
ui.gallery.jump.set(None);
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.
@@ -4037,6 +4064,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
let place = ui.gallery.place.borrow().as_ref().map(|p| p.id);
let collection = album.is_some() || place.is_some();
if !append {
// A scrubber jump looks for a month of the listing being replaced:
// another filter, album or place has its own months, if any.
ui.gallery.jump.set(None);
// Fresh load: clear the timeline and show Loading until the first page lands.
ui.gallery.model.remove_all();
gallery_status(
@@ -4098,6 +4128,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 +4174,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);
}
+14 -5
View File
@@ -279,10 +279,7 @@ pub(crate) fn wire_shared(ui: &Rc<Ui>, widgets: &SharedWidgets) {
}));
let ui_back = ui.clone();
widgets.back.connect_clicked(move |_| {
ui_back.shared.nav.borrow_mut().pop();
load_shared(&ui_back);
});
widgets.back.connect_clicked(move |_| shared_back(&ui_back));
let ui_retry = ui.clone();
widgets
.retry
@@ -318,6 +315,14 @@ pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &
ui.shared.content.set_visible_child_name("status");
}
/// Back out of a shared folder to the level above: the page's Back button, and
/// Alt+Left or Backspace. A no-op at the top.
pub(crate) fn shared_back(ui: &Rc<Ui>) {
if ui.shared.nav.borrow_mut().pop().is_some() {
load_shared(ui);
}
}
/// Fetch the top level (shared-with-me, invitations, saved links) in parallel
/// and repaint the page once all three land.
///
@@ -443,6 +448,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 +473,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,49 @@ 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);
}
/// How far the view on screen is scrolled, in px from the top.
pub(crate) fn scroll_offset(&self) -> f64 {
self.views
.visible_child()
.and_downcast::<gtk4::ScrolledWindow>()
.map_or(0.0, |scroll| scroll.vadjustment().value())
}
/// Select the entries named `selected` and scroll the view on screen to
/// `offset`, as they were when the folder was left. The scroll waits for
/// an idle, when the views have measured the rows just put in.
pub(crate) fn restore(&self, offset: f64, selected: &[String]) {
self.selection.unselect_all();
for i in 0..self.selection.n_items() {
if entry_at(Some(&self.selection), i)
.is_some_and(|entry| selected.contains(&entry.name))
{
self.selection.select_item(i, false);
}
}
let Some(scroll) = self
.views
.visible_child()
.and_downcast::<gtk4::ScrolledWindow>()
else {
return;
};
glib::idle_add_local_once(move || scroll.vadjustment().set_value(offset));
}
/// 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")
+24
View File
@@ -74,6 +74,11 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
tell photos apart by their thumbnail. Ctrl+scroll, Ctrl+plus, Ctrl+minus and Ctrl+0
change it too, and the size is remembered between runs.
- **Back takes you to where you were.** Back, Forward and Up in My files return to a folder
scrolled where it was left, with the same items selected, and closing the photo viewer scrolls
the grid to the photo it was showing. Alt+Left and Backspace go back a level in Shared with me
and Computers and leave an open album or place in Photos, as Escape does too.
### Changed
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
@@ -115,6 +120,25 @@ 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.
- **A date jump in Photos stops when you look elsewhere.** A jump still loading the timeline
kept going after another filter, album or place was opened.
## [3.7.0] — 2026-11-02
Schema: **44**, unchanged.
+549 -548
View File
File diff suppressed because it is too large. Load diff
+572 -571
View File
File diff suppressed because it is too large. Load diff
+549 -548
View File
File diff suppressed because it is too large. Load diff
+549 -548
View File
File diff suppressed because it is too large. Load diff
+549 -548
View File
File diff suppressed because it is too large. Load diff
+549 -548
View File
File diff suppressed because it is too large. Load diff
+549 -548
View File
File diff suppressed because it is too large. Load diff
+549 -548
View File
File diff suppressed because it is too large. Load diff
+549 -548
View File
File diff suppressed because it is too large. Load diff