mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 17:32:00 +01:00
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:
20 files changed
+5294
-4986
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: >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 +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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
+572
-571
File diff suppressed because it is too large.
Load diff
+549
-548
File diff suppressed because it is too large.
Load diff
+549
-548
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user