diff --git a/crates/pdfs-gui/src/app/events.rs b/crates/pdfs-gui/src/app/events.rs index e951b1d..6302390 100644 --- a/crates/pdfs-gui/src/app/events.rs +++ b/crates/pdfs-gui/src/app/events.rs @@ -128,8 +128,18 @@ fn follow_changes(ui: &Rc, state: &FeedState, topics: &[Topic]) { if changed(Topic::Shares) { refresh_invitations_badge(ui, Duration::ZERO); } - match ui.stack.visible_child_name().as_deref() { - Some("browser") if changed(Topic::Files) => load_browser(ui), + let page = ui.stack.visible_child_name(); + // A timeline out of sight is read again when the user comes back to it; + // one on screen is left alone (below). + if changed(Topic::Photos) && (page.as_deref() != Some("gallery") || !photo_grid_shown(ui)) { + ui.gallery.timeline_stale.set(true); + } + match page.as_deref() { + // A search on screen is left alone: reloading would swap its hits for + // the folder's listing while the query stays in the box. + Some("browser") if changed(Topic::Files) && ui.browser.search.text().trim().is_empty() => { + load_browser(ui) + } // The timeline is left alone: reloading it would throw away the place // the user scrolled to. F5 reloads it on request. Some("gallery") diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 1678368..242a798 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -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) { 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) { ("minus", gettext_noop("Smaller thumbnails")), ("0", gettext_noop("Reset thumbnail size")), ("a", gettext_noop("Select all")), + ("Escape Left", gettext_noop("Back")), ], ), ( @@ -1575,8 +1587,17 @@ fn install_shortcuts(ui: &Rc, 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, 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) { diff --git a/crates/pdfs-gui/src/app/pages/albums.rs b/crates/pdfs-gui/src/app/pages/albums.rs index 6af6ffd..96511d6 100644 --- a/crates/pdfs-gui/src/app/pages/albums.rs +++ b/crates/pdfs-gui/src/app/pages/albums.rs @@ -664,12 +664,25 @@ pub(crate) fn wire_albums(ui: &Rc) { } close_album(&ui_photos); close_place(&ui_photos); - ui_photos.gallery.filters.set_visible(true); - ui_photos.gallery.content.set_visible_child_name("timeline"); + let gallery = &ui_photos.gallery; + gallery.filters.set_visible(true); + gallery.content.set_visible_child_name("timeline"); + // An album or a place cleared the kind and the month it does not use; + // the filter controls still show them, so the timeline comes back + // filtered the way they say. + let kind = kind_for_tab(gallery.tabs.active() as usize); + let range = gallery + .date_ranges + .borrow() + .get(gallery.dates.selected() as usize) + .copied() + .flatten(); + let kind_changed = gallery.kind.replace(kind) != kind; + let range_changed = gallery.range.replace(range) != range; + let refiltered = kind_changed || range_changed; // The timeline comes back as it was left unless an album took over the // model since; then it has to be reloaded. - if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.gallery.model.n_items() == 0 - { + if gallery.timeline_stale.replace(false) || refiltered || gallery.model.n_items() == 0 { load_gallery(&ui_photos, false); } else { update_gallery_subtitle(&ui_photos); diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index a3d46a0..4977ecb 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -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, +} + 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>, pub(crate) future: RefCell>, + /// 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>, + /// The folder whose [`Self::places`] entry the next listing restores, set + /// by Back, Forward and Up. + pub(crate) restore: RefCell>, /// 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) { + 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, 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) { 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) { 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.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) { }); } +/// Read again what My files shows: the search hits while there is a query, +/// the folder otherwise. [`load_browser`] alone would swap a search's hits +/// for the folder while the query stays in the box. +pub(crate) fn reload_browser_view(ui: &Rc) { + let query = ui.browser.search.text().trim().to_string(); + if query.is_empty() { + load_browser(ui); + } else { + run_search(ui, &query); + } +} + /// Clear the model and show the daemon's error on the status page. Used for /// in-band failures (a bad path, a permission error) — the mount is up, so Retry /// (which restarts the service) wouldn't help and isn't offered. @@ -2959,7 +3012,10 @@ pub(crate) fn browser_unreachable(ui: &Rc) { pub(crate) fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { show_location_column(ui, false); *ui.browser.listing.borrow_mut() = entries.to_vec(); - *ui.browser.listed.borrow_mut() = Some(ui.browser.path.borrow().clone()); + // `listed` still names the folder the rows belong to only on a refresh; + // any other load forgot it (see `load_browser`). + let path = ui.browser.path.borrow().clone(); + let another_folder = ui.browser.listed.replace(Some(path.clone())).as_ref() != Some(&path); ui.browser.summary.set_label(&listing_summary( entries.iter().filter(|entry| !entry.is_dir).count(), entries.iter().filter(|entry| entry.is_dir).count(), @@ -2988,6 +3044,16 @@ pub(crate) fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { // Only the rows that changed are swapped, so a refresh keeps the // selection, the scroll position and the thumbnails already drawn. replace_items(&ui.browser.files.model, &sorted); + // 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, hits: &[SearchHit]) { .collect(); sort_entries(&mut entries, ui.browser.view.get()); replace_items(&ui.browser.files.model, &entries); + // Every search starts over (`run_search` forgets the listing), so its hits + // read from the first one. + ui.browser.files.scroll_to_top(); } fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String { diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index 4956266..4628f21 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -190,10 +190,9 @@ pub(crate) fn wire_devices(ui: &Rc, 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, 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) { + 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) { @@ -297,6 +304,7 @@ fn load_device_folder(ui: &Rc, nav: &[(String, String)]) { }; ui.devices.content.set_visible_child_name("browse"); repaint_device_crumb(&ui, &nav); + let another_folder = ui.devices.listed.borrow().as_ref() != Some(&nav); *ui.devices.listed.borrow_mut() = Some(nav); if entries.is_empty() { ui.devices.files.model.remove_all(); @@ -304,6 +312,10 @@ fn load_device_folder(ui: &Rc, nav: &[(String, String)]) { } else { ui.devices.files_content.set_visible_child_name("list"); replace_items(&ui.devices.files.model, &entries); + // A refresh keeps its place; another folder starts at the top. + if another_folder { + ui.devices.files.scroll_to_top(); + } } }); } diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs index 7866633..e67cb01 100644 --- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -1001,7 +1001,10 @@ fn show_photo_position(ui: &Rc, viewer: &Rc, index: u32, capture_tim /// /// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close /// button, or a click on the backdrop beside the photo all dismiss it. -pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { +/// +/// Returns the lightbox's window, for a caller that has to put the page +/// behind it back once it closes. +pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) -> gtk4::Window { let parent = ui.stack.root().and_downcast::().unwrap(); // An `adw::Window` for its breakpoints: the details panel lies over the @@ -1425,8 +1428,10 @@ pub(crate) fn open_photo_viewer(ui: &Rc, 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, 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, initial_uid: String) { window.add_controller(key_controller); window.present(); + window } /// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 09c74b5..9070db5 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -3288,6 +3288,16 @@ fn top_anchor(ui: &Rc) -> 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, 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 { (0..store.n_items()).find(|i| { @@ -3952,7 +3962,9 @@ fn show_photo_menu(ui: &Rc, photo: &PhotoItem, anchor: >k4::Button, x: f64 } else { pgettext("verb", "Open") }; - menu.item(&label, move || open_photo_viewer(&ui_c, uid.clone())); + menu.item(&label, move || { + open_photo_viewer(&ui_c, uid.clone()); + }); let (ui_c, uid) = (ui.clone(), photo.uid.clone()); // Translators: a check item in a photo's menu, on while the photo is a favorite. let favorite_label = pgettext("state", "Favorite"); @@ -4020,8 +4032,23 @@ fn set_photo_favorite(ui: &Rc, uid: String, favorite: bool) { }); } +/// Whether the photo grid is the view the Photos page shows: an album or a +/// place, or the timeline itself. Under Albums or Places it is out of sight. +pub(crate) fn photo_grid_shown(ui: &Ui) -> bool { + ui.gallery.in_collection() || ui.gallery.view_switch.active() == VIEW_TIMELINE +} + pub(crate) fn load_gallery(ui: &Rc, append: bool) { sync_filter_badge(ui); + // A load would put the grid, or its status page, on screen in place of + // the Albums or Places view the user is looking at. Leave the timeline to + // be read again when they switch back to it. + if !photo_grid_shown(ui) { + ui.gallery.timeline_stale.set(true); + // 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, 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, 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, append: bool) { ui.gallery.model.append(&BoxedAnyObject::new(item.clone())); } repaint_gallery(&ui); + // The rows are swapped in place, which keeps the old scroll + // offset; a fresh load (another kind, month, album or place) + // reads from its newest photo. + if !append + && ui + .gallery + .list + .model() + .is_some_and(|rows| rows.n_items() > 0) + { + ui.gallery + .list + .scroll_to(0, gtk4::ListScrollFlags::NONE, None); + } if ui.gallery.model.n_items() == 0 { let filtered = ui.gallery.kind.get().is_some() || ui.gallery.favorites.get() diff --git a/crates/pdfs-gui/src/app/pages/places.rs b/crates/pdfs-gui/src/app/pages/places.rs index e2bb9ed..e05505f 100644 --- a/crates/pdfs-gui/src/app/pages/places.rs +++ b/crates/pdfs-gui/src/app/pages/places.rs @@ -392,21 +392,45 @@ fn load_place_locations(ui: &Rc, id: u32) { }); } -/// Open `place` and then photo `uid` in it, paging through the town until -/// the photo has loaded. +/// Open photo `uid` of `place` from the map or a spot's list, paging through +/// the town until the photo has loaded. +/// +/// The lightbox steps through the gallery's model, so the town is opened +/// behind it. Closing the lightbox goes back to the map rather than leaving +/// the user in a town timeline they never asked for. fn open_place_photo(ui: &Rc, place: PlaceInfo, uid: String) { + let id = place.id; open_place(ui, place); - view_when_loaded(ui, uid); + view_when_loaded(ui, id, uid); } -fn view_when_loaded(ui: &Rc, uid: String) { +/// The open place's id, if a place is open. +fn open_place_id(ui: &Ui) -> Option { + ui.gallery.place.borrow().as_ref().map(|place| place.id) +} + +fn view_when_loaded(ui: &Rc, id: u32, uid: String) { let ui_page = ui.clone(); ui.gallery.page_waiters.borrow_mut().push(Box::new(move || { let ui = ui_page; + // The user went elsewhere while the town loaded: whatever page lands + // now belongs to that, and must not pop the lightbox up over it. + if open_place_id(&ui) != Some(id) { + return; + } if find_photo_index(&ui.gallery.model, &uid).is_some() { - open_photo_viewer(&ui, uid); - } else if ui.gallery.has_more.get() && ui.gallery.place.borrow().is_some() { - view_when_loaded(&ui, uid); + let window = open_photo_viewer(&ui, uid); + let ui_close = ui.clone(); + window.connect_close_request(move |_| { + // Only if the town is still what the lightbox was showing: "Show + // on map" in the lightbox has already moved on by itself. + if open_place_id(&ui_close) == Some(id) { + show_place_grid(&ui_close); + } + glib::Propagation::Proceed + }); + } else if ui.gallery.has_more.get() { + view_when_loaded(&ui, id, uid); ui.gallery.burst.set(true); load_gallery(&ui, true); } diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index 2bfd3d5..24065c9 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -279,10 +279,7 @@ pub(crate) fn wire_shared(ui: &Rc, 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, 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) { + 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, 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, entries: &[DirEntry]) { } ui.shared.files_content.set_visible_child_name("list"); // Only the rows that changed are swapped, so a refresh keeps the selection - // and the scroll position. + // and the scroll position. Another folder starts from its first entry. replace_items(&ui.shared.files.model, entries); + if another_folder { + ui.shared.files.scroll_to_top(); + } } /// Rebuild the breadcrumb trail for `nav`: "Shared with me", then each folder diff --git a/crates/pdfs-gui/src/app/widgets/file_list.rs b/crates/pdfs-gui/src/app/widgets/file_list.rs index 06d2d46..97f6d8e 100644 --- a/crates/pdfs-gui/src/app/widgets/file_list.rs +++ b/crates/pdfs-gui/src/app/widgets/file_list.rs @@ -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::() + .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::() + 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") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 68c4887..fa46e1a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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. diff --git a/po/de.po b/po/de.po index 57e8372..a656e2d 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-11-14 02:49+0100\n" +"POT-Creation-Date: 2026-11-14 12:53+0100\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -31,7 +31,7 @@ msgstr[1] "vor {n} Min." #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 #: crates/pdfs-gui/src/app/pages/activity.rs:840 -#: crates/pdfs-gui/src/app/pages/photos.rs:3534 +#: crates/pdfs-gui/src/app/pages/photos.rs:3544 msgid "%H:%M" msgstr "%H:%M" @@ -57,87 +57,87 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:865 +#: crates/pdfs-gui/src/app/main.rs:876 #: crates/pdfs-gui/src/app/pages/activity_text.rs:543 -#: crates/pdfs-gui/src/app/pages/browser.rs:458 -#: crates/pdfs-gui/src/app/pages/browser.rs:865 -#: crates/pdfs-gui/src/app/pages/browser.rs:1281 -#: crates/pdfs-gui/src/app/pages/browser.rs:2240 -#: crates/pdfs-gui/src/app/pages/browser.rs:2319 +#: crates/pdfs-gui/src/app/pages/browser.rs:473 +#: crates/pdfs-gui/src/app/pages/browser.rs:880 +#: crates/pdfs-gui/src/app/pages/browser.rs:1296 +#: crates/pdfs-gui/src/app/pages/browser.rs:2281 +#: crates/pdfs-gui/src/app/pages/browser.rs:2360 #: crates/pdfs-gui/src/app/pages/locations.rs:1721 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:866 crates/pdfs-gui/src/app/main.rs:1536 +#: crates/pdfs-gui/src/app/main.rs:877 crates/pdfs-gui/src/app/main.rs:1547 #: crates/pdfs-gui/src/app/pages/albums.rs:636 #: crates/pdfs-gui/src/app/pages/photos.rs:898 -#: crates/pdfs-gui/src/app/pages/places.rs:439 +#: crates/pdfs-gui/src/app/pages/places.rs:463 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:869 +#: crates/pdfs-gui/src/app/main.rs:880 #: crates/pdfs-gui/src/app/pages/shared.rs:168 -#: crates/pdfs-gui/src/app/pages/shared.rs:484 +#: crates/pdfs-gui/src/app/pages/shared.rs:493 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:874 +#: crates/pdfs-gui/src/app/main.rs:885 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:877 +#: crates/pdfs-gui/src/app/main.rs:888 #: crates/pdfs-gui/src/app/pages/devices.rs:140 -#: crates/pdfs-gui/src/app/pages/devices.rs:493 +#: crates/pdfs-gui/src/app/pages/devices.rs:505 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:878 +#: crates/pdfs-gui/src/app/main.rs:889 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:880 +#: crates/pdfs-gui/src/app/main.rs:891 msgid "Sync" msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1230 +#: crates/pdfs-gui/src/app/main.rs:1241 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1264 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1254 +#: crates/pdfs-gui/src/app/main.rs:1265 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1255 +#: crates/pdfs-gui/src/app/main.rs:1266 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1256 +#: crates/pdfs-gui/src/app/main.rs:1267 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1257 +#: crates/pdfs-gui/src/app/main.rs:1268 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1344 +#: crates/pdfs-gui/src/app/main.rs:1355 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1352 +#: crates/pdfs-gui/src/app/main.rs:1363 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -149,23 +149,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1357 +#: crates/pdfs-gui/src/app/main.rs:1368 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1361 +#: crates/pdfs-gui/src/app/main.rs:1372 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1365 +#: crates/pdfs-gui/src/app/main.rs:1376 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1370 +#: crates/pdfs-gui/src/app/main.rs:1381 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1374 +#: crates/pdfs-gui/src/app/main.rs:1385 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -190,7 +190,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1382 +#: crates/pdfs-gui/src/app/main.rs:1393 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1390 +#: crates/pdfs-gui/src/app/main.rs:1401 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -210,7 +210,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1398 +#: crates/pdfs-gui/src/app/main.rs:1409 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1390 +#: crates/pdfs-gui/src/app/main.rs:1401 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1398 +#: crates/pdfs-gui/src/app/main.rs:1409 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1390 +#: crates/pdfs-gui/src/app/main.rs:1401 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1398 +#: crates/pdfs-gui/src/app/main.rs:1409 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1390 +#: crates/pdfs-gui/src/app/main.rs:1401 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1398 +#: crates/pdfs-gui/src/app/main.rs:1409 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1390 +#: crates/pdfs-gui/src/app/main.rs:1401 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1398 +#: crates/pdfs-gui/src/app/main.rs:1409 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1390 +#: crates/pdfs-gui/src/app/main.rs:1401 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1398 +#: crates/pdfs-gui/src/app/main.rs:1409 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -34,7 +34,7 @@ msgstr[1] "" #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 #: crates/pdfs-gui/src/app/pages/activity.rs:840 -#: crates/pdfs-gui/src/app/pages/photos.rs:3534 +#: crates/pdfs-gui/src/app/pages/photos.rs:3544 msgid "%H:%M" msgstr "" @@ -60,87 +60,87 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:865 +#: crates/pdfs-gui/src/app/main.rs:876 #: crates/pdfs-gui/src/app/pages/activity_text.rs:543 -#: crates/pdfs-gui/src/app/pages/browser.rs:458 -#: crates/pdfs-gui/src/app/pages/browser.rs:865 -#: crates/pdfs-gui/src/app/pages/browser.rs:1281 -#: crates/pdfs-gui/src/app/pages/browser.rs:2240 -#: crates/pdfs-gui/src/app/pages/browser.rs:2319 +#: crates/pdfs-gui/src/app/pages/browser.rs:473 +#: crates/pdfs-gui/src/app/pages/browser.rs:880 +#: crates/pdfs-gui/src/app/pages/browser.rs:1296 +#: crates/pdfs-gui/src/app/pages/browser.rs:2281 +#: crates/pdfs-gui/src/app/pages/browser.rs:2360 #: crates/pdfs-gui/src/app/pages/locations.rs:1721 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:866 crates/pdfs-gui/src/app/main.rs:1536 +#: crates/pdfs-gui/src/app/main.rs:877 crates/pdfs-gui/src/app/main.rs:1547 #: crates/pdfs-gui/src/app/pages/albums.rs:636 #: crates/pdfs-gui/src/app/pages/photos.rs:898 -#: crates/pdfs-gui/src/app/pages/places.rs:439 +#: crates/pdfs-gui/src/app/pages/places.rs:463 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:869 +#: crates/pdfs-gui/src/app/main.rs:880 #: crates/pdfs-gui/src/app/pages/shared.rs:168 -#: crates/pdfs-gui/src/app/pages/shared.rs:484 +#: crates/pdfs-gui/src/app/pages/shared.rs:493 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:874 +#: crates/pdfs-gui/src/app/main.rs:885 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:877 +#: crates/pdfs-gui/src/app/main.rs:888 #: crates/pdfs-gui/src/app/pages/devices.rs:140 -#: crates/pdfs-gui/src/app/pages/devices.rs:493 +#: crates/pdfs-gui/src/app/pages/devices.rs:505 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:878 +#: crates/pdfs-gui/src/app/main.rs:889 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:880 +#: crates/pdfs-gui/src/app/main.rs:891 msgid "Sync" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1230 +#: crates/pdfs-gui/src/app/main.rs:1241 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1264 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1254 +#: crates/pdfs-gui/src/app/main.rs:1265 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1255 +#: crates/pdfs-gui/src/app/main.rs:1266 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1256 +#: crates/pdfs-gui/src/app/main.rs:1267 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1257 +#: crates/pdfs-gui/src/app/main.rs:1268 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1344 +#: crates/pdfs-gui/src/app/main.rs:1355 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1352 +#: crates/pdfs-gui/src/app/main.rs:1363 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -148,23 +148,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1357 +#: crates/pdfs-gui/src/app/main.rs:1368 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1361 +#: crates/pdfs-gui/src/app/main.rs:1372 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1365 +#: crates/pdfs-gui/src/app/main.rs:1376 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1370 +#: crates/pdfs-gui/src/app/main.rs:1381 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1374 +#: crates/pdfs-gui/src/app/main.rs:1385 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -177,21 +177,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1382 +#: crates/pdfs-gui/src/app/main.rs:1393 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1390 +#: crates/pdfs-gui/src/app/main.rs:1401 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1398 +#: crates/pdfs-gui/src/app/main.rs:1409 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1390 +#: crates/pdfs-gui/src/app/main.rs:1401 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -211,7 +211,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1398 +#: crates/pdfs-gui/src/app/main.rs:1409 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1390 +#: crates/pdfs-gui/src/app/main.rs:1401 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1398 +#: crates/pdfs-gui/src/app/main.rs:1409 msgid "" "The street map, when turned on, comes from OpenFreeMap in the