mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
feat(core,fuse,gui): places view from photo locations
Photos now know where they were taken. proton-drive-rs 0.7.0 reads the Location from a photo's extended attributes, and the timeline refresh stores it with the photo (schema v33). The migration marks every photo unresolved once, so the next refresh backfills locations from node metadata without downloading anything. Towns are looked up offline: pdfs-core bundles GeoNames' cities15000 table, trimmed by scripts/geonames-cities.py, and files each photo under the nearest town within 50 km. The new PhotoPlaces and PlacePhotos requests list the towns and page one town's photos. The Photos page gets a third view, Places, with a card per town (newest photo, town, country, count). A place opens in the gallery like an album. Country names come translated from the iso-codes catalogs, now an optional dependency. The About dialog credits GeoNames (CC BY 4.0).
This commit is contained in:
1 parent
2757977fd6
commit
753056418c
40 files changed
+35708
-2360
No files matched your search
@@ -14,6 +14,7 @@ use pages::locations::*;
|
||||
use pages::login::*;
|
||||
use pages::photo_viewer::*;
|
||||
use pages::photos::*;
|
||||
use pages::places::*;
|
||||
use pages::shared::*;
|
||||
use pages::shared_by_me::*;
|
||||
use pages::status::*;
|
||||
@@ -26,7 +27,7 @@ use widgets::share_dialog::*;
|
||||
use widgets::thumbnails::*;
|
||||
use widgets::versions_dialog::*;
|
||||
|
||||
pub(crate) use i18n::{gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
|
||||
pub(crate) use i18n::{country_name, gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
@@ -55,9 +56,10 @@ use pdfs_core::config::{AppDirs, FileSort, FilesView};
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem,
|
||||
PhotoKind, PhotoMonth, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder,
|
||||
RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo,
|
||||
SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem, send,
|
||||
PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
||||
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||
SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem,
|
||||
send,
|
||||
};
|
||||
|
||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||
@@ -551,6 +553,13 @@ fn build_window(app: &adw::Application) {
|
||||
albums_btn: gallery_widgets.albums_btn.clone(),
|
||||
view_switch: gallery_widgets.view_switch.clone(),
|
||||
albums_loading: Cell::new(false),
|
||||
places: gallery_widgets.places.clone(),
|
||||
places_stack: gallery_widgets.places_stack.clone(),
|
||||
places_status: gallery_widgets.places_status.clone(),
|
||||
places_btn: gallery_widgets.places_btn.clone(),
|
||||
places_loading: Cell::new(false),
|
||||
place_count: Cell::new(0),
|
||||
place: RefCell::new(None),
|
||||
album: RefCell::new(None),
|
||||
timeline_stale: Cell::new(false),
|
||||
album_list: RefCell::new(Vec::new()),
|
||||
@@ -736,6 +745,7 @@ fn build_window(app: &adw::Application) {
|
||||
.duplicates
|
||||
.connect_clicked(move |_| show_duplicates(&ui_duplicates));
|
||||
wire_albums(&ui);
|
||||
wire_places(&ui);
|
||||
wire_trash(&ui, &trash_widgets);
|
||||
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
||||
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
||||
@@ -1044,12 +1054,12 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
||||
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
||||
}
|
||||
// One scope covers the whole photos view, so Refresh reloads whichever of
|
||||
// the two — album grid or timeline/album — is actually on screen.
|
||||
// the views — a grid, or the timeline, an album or a place — is on screen.
|
||||
Some("gallery") => refresh_then(ui, RefreshScope::Photos { full: false }, |ui| {
|
||||
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
|
||||
load_albums(ui);
|
||||
} else {
|
||||
load_gallery(ui, false);
|
||||
match ui.gallery.content.visible_child_name().as_deref() {
|
||||
Some("albums") => load_albums(ui),
|
||||
Some("places") => load_places(ui),
|
||||
_ => load_gallery(ui, false),
|
||||
}
|
||||
}),
|
||||
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
|
||||
@@ -1338,6 +1348,14 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
"This is an unofficial, community-built client. It is not affiliated with, endorsed by, or supported by Proton AG.\n\n“Proton” and “Proton Drive” are trademarks of Proton AG, used here only to describe the service this software connects to. Your use of Proton Drive is governed by Proton's own terms of service and privacy policy.\n\nThis software is provided “as is”, without warranty of any kind. Keep a copy of any data you cannot afford to lose.",
|
||||
)),
|
||||
);
|
||||
dialog.add_legal_section(
|
||||
"GeoNames",
|
||||
None,
|
||||
gtk4::License::Custom,
|
||||
Some(&gettext(
|
||||
"Places are named with data from <a href=\"https://www.geonames.org/\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/licenses/by/4.0/\">CC BY 4.0</a>.",
|
||||
)),
|
||||
);
|
||||
dialog.add_legal_section(
|
||||
"proton-sdk-rs",
|
||||
Some("© 2026 Nils Pukropp (contact@narl.io)"),
|
||||
|
||||
@@ -24,6 +24,7 @@ pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
||||
/// position. The timeline's filters do not apply to albums, so they go.
|
||||
fn show_album_grid(ui: &Rc<Ui>) {
|
||||
close_album(ui);
|
||||
close_place(ui);
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.content.set_visible_child_name("albums");
|
||||
}
|
||||
@@ -582,7 +583,7 @@ fn album_subtitle(album: &AlbumInfo) -> String {
|
||||
|
||||
/// Queue an album cover for the gallery's own thumbnail machinery: same batching,
|
||||
/// same texture cache, same decode pacing as a timeline tile.
|
||||
fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||
pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
|
||||
picture.set_paintable(Some(texture));
|
||||
return;
|
||||
@@ -662,6 +663,7 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||
return;
|
||||
}
|
||||
close_album(&ui_photos);
|
||||
close_place(&ui_photos);
|
||||
ui_photos.gallery.filters.set_visible(true);
|
||||
ui_photos.gallery.content.set_visible_child_name("timeline");
|
||||
// The timeline comes back as it was left unless an album took over the
|
||||
@@ -674,10 +676,14 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||
}
|
||||
});
|
||||
|
||||
// Back returns to the grid the album was opened from, scrolled where it
|
||||
// was, instead of reloading it.
|
||||
// Back returns to the grid the album or place was opened from, scrolled
|
||||
// where it was, instead of reloading it.
|
||||
let ui_back = ui.clone();
|
||||
ui.gallery.back.clone().connect_clicked(move |_| {
|
||||
if ui_back.gallery.place.borrow().is_some() {
|
||||
show_place_grid(&ui_back);
|
||||
return;
|
||||
}
|
||||
show_album_grid(&ui_back);
|
||||
let count = ui_back.gallery.album_list.borrow().len();
|
||||
ui_back.gallery.title.set_subtitle(&ngettext_f(
|
||||
|
||||
@@ -7,6 +7,7 @@ pub(crate) mod locations;
|
||||
pub(crate) mod login;
|
||||
pub(crate) mod photo_viewer;
|
||||
pub(crate) mod photos;
|
||||
pub(crate) mod places;
|
||||
pub(crate) mod shared;
|
||||
pub(crate) mod shared_by_me;
|
||||
pub(crate) mod status;
|
||||
|
||||
@@ -917,6 +917,9 @@ fn gallery_total(ui: &Rc<Ui>) -> Option<usize> {
|
||||
if let Some(album) = ui.gallery.album.borrow().as_ref() {
|
||||
return Some(album.photo_count);
|
||||
}
|
||||
if let Some(place) = ui.gallery.place.borrow().as_ref() {
|
||||
return Some(place.photo_count);
|
||||
}
|
||||
if ui.gallery.range.get().is_some()
|
||||
|| ui.gallery.favorites.get()
|
||||
|| ui.gallery.not_in_album.get()
|
||||
|
||||
@@ -61,6 +61,18 @@ pub(crate) struct GalleryState {
|
||||
/// True while the album listing is in flight, so a re-entry into the Albums
|
||||
/// view can't stack requests.
|
||||
pub(crate) albums_loading: Cell<bool>,
|
||||
/// The Places grid and its own status page, like the album grid's, and the
|
||||
/// switcher's Places toggle.
|
||||
pub(crate) places: gtk4::FlowBox,
|
||||
pub(crate) places_stack: gtk4::Stack,
|
||||
pub(crate) places_status: adw::StatusPage,
|
||||
pub(crate) places_btn: gtk4::ToggleButton,
|
||||
/// True while the place listing is in flight.
|
||||
pub(crate) places_loading: Cell<bool>,
|
||||
/// How many places the grid shows, for the subtitle when back returns there.
|
||||
pub(crate) place_count: Cell<usize>,
|
||||
/// The place currently open, paged by [`load_gallery`] like an album.
|
||||
pub(crate) place: RefCell<Option<PlaceInfo>>,
|
||||
/// The album currently open, or `None` when the timeline is showing. Set by
|
||||
/// [`open_album`]; read by [`load_gallery`], which pages that album instead
|
||||
/// of the timeline while it is set.
|
||||
@@ -190,6 +202,14 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) select_album: gtk4::Button,
|
||||
}
|
||||
|
||||
impl GalleryState {
|
||||
/// Whether the gallery shows an album or a place rather than the timeline.
|
||||
/// Neither is filtered or dated, so the timeline's extras stay away.
|
||||
pub(crate) fn in_collection(&self) -> bool {
|
||||
self.album.borrow().is_some() || self.place.borrow().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||
pub(crate) const PHOTOS_PAGE: usize = 200;
|
||||
|
||||
@@ -385,6 +405,10 @@ pub(crate) struct GalleryWidgets {
|
||||
pub(crate) photos_btn: gtk4::ToggleButton,
|
||||
pub(crate) albums_btn: gtk4::ToggleButton,
|
||||
pub(crate) view_switch: gtk4::Box,
|
||||
pub(crate) places: gtk4::FlowBox,
|
||||
pub(crate) places_stack: gtk4::Stack,
|
||||
pub(crate) places_status: adw::StatusPage,
|
||||
pub(crate) places_btn: gtk4::ToggleButton,
|
||||
pub(crate) back: gtk4::Button,
|
||||
/// The kind toggles and date jump, as one box so an album view can hide them.
|
||||
pub(crate) filters: gtk4::Box,
|
||||
@@ -678,11 +702,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
.label(gettext("Albums"))
|
||||
.build();
|
||||
albums_btn.set_group(Some(&photos_btn));
|
||||
let places_btn = gtk4::ToggleButton::builder()
|
||||
.label(gettext("Places"))
|
||||
.build();
|
||||
places_btn.set_group(Some(&photos_btn));
|
||||
let view_switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
view_switch.add_css_class("linked");
|
||||
view_switch.add_css_class("view-switch");
|
||||
view_switch.set_halign(gtk4::Align::Start);
|
||||
for btn in [&photos_btn, &albums_btn] {
|
||||
for btn in [&photos_btn, &albums_btn, &places_btn] {
|
||||
btn.add_css_class("pill");
|
||||
view_switch.append(btn);
|
||||
}
|
||||
@@ -761,12 +789,38 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
albums_page.append(&new_album);
|
||||
albums_page.append(&albums_stack);
|
||||
|
||||
// The place grid: the same cards, one per town the photos were taken in.
|
||||
let places = gtk4::FlowBox::builder()
|
||||
.selection_mode(gtk4::SelectionMode::None)
|
||||
.homogeneous(true)
|
||||
.row_spacing(TILE_GAP as u32 * 2)
|
||||
.column_spacing(TILE_GAP as u32 * 2)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(8)
|
||||
.valign(gtk4::Align::Start)
|
||||
.build();
|
||||
let places_scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&places)
|
||||
.build();
|
||||
let places_status = adw::StatusPage::builder()
|
||||
.icon_name("mark-location-symbolic")
|
||||
.vexpand(true)
|
||||
.build();
|
||||
places_status.add_css_class("compact");
|
||||
let places_stack = gtk4::Stack::new();
|
||||
places_stack.set_vexpand(true);
|
||||
places_stack.add_named(&places_scroll, Some("grid"));
|
||||
places_stack.add_named(&places_status, Some("status"));
|
||||
|
||||
let content = gtk4::Stack::new();
|
||||
content.set_vexpand(true);
|
||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||
content.add_named(&timeline, Some("timeline"));
|
||||
content.add_named(&status, Some("status"));
|
||||
content.add_named(&albums_page, Some("albums"));
|
||||
content.add_named(&places_stack, Some("places"));
|
||||
|
||||
let import_banner = adw::Banner::builder()
|
||||
.title(gettext("Importing from Google Photos…"))
|
||||
@@ -836,6 +890,10 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
photos_btn,
|
||||
albums_btn,
|
||||
view_switch,
|
||||
places,
|
||||
places_stack,
|
||||
places_status,
|
||||
places_btn,
|
||||
back,
|
||||
filters,
|
||||
},
|
||||
@@ -2527,9 +2585,9 @@ pub(crate) fn update_gallery_subtitle(ui: &Rc<Ui>) {
|
||||
if loaded == 0 {
|
||||
ui.gallery.title.set_subtitle("");
|
||||
}
|
||||
// An album counts what the server says it holds, not how much of it has been
|
||||
// An album or a place counts what it holds, not how much of it has been
|
||||
// paged in — the subtitle would otherwise climb as the user scrolls.
|
||||
if ui.gallery.album.borrow().is_some() {
|
||||
if ui.gallery.in_collection() {
|
||||
return;
|
||||
}
|
||||
// The noun tracks the active filter, so a Videos tab doesn't count "photos".
|
||||
@@ -2748,7 +2806,7 @@ fn fill_scrubber(ui: &Rc<Ui>, months: &[PhotoMonth]) {
|
||||
/// Show the scrubber only where a month jump makes sense: the whole timeline,
|
||||
/// not an album, a date window, the favorites or the unfiled photos.
|
||||
pub(crate) fn sync_scrubber(ui: &Rc<Ui>) {
|
||||
let whole = ui.gallery.album.borrow().is_none()
|
||||
let whole = !ui.gallery.in_collection()
|
||||
&& ui.gallery.range.get().is_none()
|
||||
&& !ui.gallery.favorites.get()
|
||||
&& !ui.gallery.not_in_album.get();
|
||||
@@ -2888,7 +2946,7 @@ fn sync_memories(ui: &Rc<Ui>) {
|
||||
.list
|
||||
.vadjustment()
|
||||
.is_none_or(|adj| adj.value() < 1.0);
|
||||
let whole = ui.gallery.album.borrow().is_none()
|
||||
let whole = !ui.gallery.in_collection()
|
||||
&& ui.gallery.kind.get().is_none()
|
||||
&& ui.gallery.range.get().is_none()
|
||||
&& !ui.gallery.favorites.get()
|
||||
@@ -3164,9 +3222,12 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// An open album pages itself instead of the timeline; everything downstream —
|
||||
// the model, the sections, the thumbnails, the lightbox — is the same.
|
||||
// An open album or place pages itself instead of the timeline; everything
|
||||
// downstream — the model, the sections, the thumbnails, the lightbox — is
|
||||
// the same.
|
||||
let album = ui.gallery.album.borrow().as_ref().map(|a| a.uid.clone());
|
||||
let place = ui.gallery.place.borrow().as_ref().map(|p| p.id);
|
||||
let collection = album.is_some() || place.is_some();
|
||||
if !append {
|
||||
// Fresh load: clear the timeline and show Loading until the first page lands.
|
||||
ui.gallery.model.remove_all();
|
||||
@@ -3181,11 +3242,11 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
// load — a jump *to* a month sets a range and reloads, and refreshing the
|
||||
// dropdown then would fight the selection the user just made. An album
|
||||
// has no date jump at all.
|
||||
if album.is_none() && ui.gallery.range.get().is_none() {
|
||||
if !collection && ui.gallery.range.get().is_none() {
|
||||
refresh_photo_months(ui);
|
||||
}
|
||||
sync_scrubber(ui);
|
||||
if album.is_none() {
|
||||
if !collection {
|
||||
refresh_memories(ui);
|
||||
}
|
||||
sync_memories(ui);
|
||||
@@ -3201,9 +3262,10 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
ui.gallery.pager.set_spinning(append);
|
||||
|
||||
ui.busy_begin();
|
||||
let request = match album {
|
||||
Some(uid) => Request::AlbumPhotos { uid, offset, limit },
|
||||
None => Request::PhotosTimeline {
|
||||
let request = match (album, place) {
|
||||
(Some(uid), _) => Request::AlbumPhotos { uid, offset, limit },
|
||||
(None, Some(id)) => Request::PlacePhotos { id, offset, limit },
|
||||
(None, None) => Request::PhotosTimeline {
|
||||
offset,
|
||||
limit,
|
||||
kind: ui.gallery.kind.get(),
|
||||
@@ -3305,7 +3367,7 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
// and an empty filter is not a library that needs photos.
|
||||
ui.gallery
|
||||
.empty_actions
|
||||
.set_visible(ui.gallery.album.borrow().is_none() && !filtered);
|
||||
.set_visible(!ui.gallery.in_collection() && !filtered);
|
||||
return;
|
||||
}
|
||||
ui.gallery.content.set_visible_child_name("timeline");
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! The Places view of the Photos page: a grid of the towns the photos were
|
||||
//! taken in, and the place that opens when one is clicked.
|
||||
//!
|
||||
//! An open place works like an open album: it is parked in
|
||||
//! [`GalleryState::place`], and [`load_gallery`] pages
|
||||
//! [`Request::PlacePhotos`] instead of the timeline.
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Edge length in px of a place's cover, the same as an album's.
|
||||
const COVER_EDGE: i32 = 200;
|
||||
|
||||
/// Show the Places grid and (re)load it. Called when the Places toggle goes on.
|
||||
pub(crate) fn show_places(ui: &Rc<Ui>) {
|
||||
show_place_grid(ui);
|
||||
load_places(ui);
|
||||
}
|
||||
|
||||
/// Switch to the place grid as it was last filled, keeping its scroll
|
||||
/// position. The timeline's filters do not apply to places, so they go.
|
||||
pub(crate) fn show_place_grid(ui: &Rc<Ui>) {
|
||||
close_place(ui);
|
||||
close_album(ui);
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.content.set_visible_child_name("places");
|
||||
ui.gallery
|
||||
.title
|
||||
.set_subtitle(&places_subtitle(ui.gallery.place_count.get()));
|
||||
}
|
||||
|
||||
/// Ask the daemon for the place listing and rebuild the grid.
|
||||
pub(crate) fn load_places(ui: &Rc<Ui>) {
|
||||
if ui.gallery.places_loading.get() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.places_loading.set(true);
|
||||
// A grid already on screen stays there while it refreshes.
|
||||
if ui.gallery.places_stack.visible_child_name().as_deref() != Some("grid") {
|
||||
places_status(
|
||||
ui,
|
||||
"mark-location-symbolic",
|
||||
&gettext("Loading places…"),
|
||||
&gettext("Finding where your photos were taken."),
|
||||
);
|
||||
}
|
||||
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoPlaces);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.gallery.places_loading.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::Places { items })) if items.is_empty() => {
|
||||
ui.gallery.place_count.set(0);
|
||||
if ui.gallery.place.borrow().is_none() {
|
||||
ui.gallery.title.set_subtitle("");
|
||||
}
|
||||
places_status(
|
||||
&ui,
|
||||
"mark-location-symbolic",
|
||||
&gettext("No places"),
|
||||
&gettext(
|
||||
"Photos that carry the location they were taken at show up here, grouped by town.",
|
||||
),
|
||||
)
|
||||
}
|
||||
Ok(Ok(Response::Places { items })) => {
|
||||
fill_places(&ui, &items);
|
||||
ui.gallery.places_stack.set_visible_child_name("grid");
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &gettext("Couldn't load places"), &message, kind);
|
||||
places_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
&gettext("Couldn't load places"),
|
||||
&message,
|
||||
);
|
||||
}
|
||||
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => places_status(
|
||||
&ui,
|
||||
"network-offline-symbolic",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Replace the grid's cards with `places`, the one with the most photos first.
|
||||
fn fill_places(ui: &Rc<Ui>, places: &[PlaceInfo]) {
|
||||
let scroll = ui
|
||||
.gallery
|
||||
.places
|
||||
.ancestor(gtk4::ScrolledWindow::static_type())
|
||||
.and_downcast::<gtk4::ScrolledWindow>();
|
||||
let position = scroll.as_ref().map(|s| s.vadjustment().value());
|
||||
while let Some(child) = ui.gallery.places.first_child() {
|
||||
ui.gallery.places.remove(&child);
|
||||
}
|
||||
ui.gallery.place_count.set(places.len());
|
||||
if ui.gallery.place.borrow().is_none() {
|
||||
ui.gallery
|
||||
.title
|
||||
.set_subtitle(&places_subtitle(places.len()));
|
||||
}
|
||||
for place in places {
|
||||
ui.gallery.places.append(&place_card(ui, place));
|
||||
}
|
||||
schedule_thumbs(ui);
|
||||
if let (Some(scroll), Some(position)) = (scroll, position) {
|
||||
glib::idle_add_local_once(move || scroll.vadjustment().set_value(position));
|
||||
}
|
||||
}
|
||||
|
||||
fn places_subtitle(count: usize) -> String {
|
||||
ngettext_f("{n} place", "{n} places", count as u64, &[])
|
||||
}
|
||||
|
||||
/// "Germany · 12 photos": where the town is, and how much was taken there.
|
||||
fn place_subtitle(place: &PlaceInfo) -> String {
|
||||
ngettext_f(
|
||||
// Translators: {country} is a country name, {n} a number of photos.
|
||||
"{country} · {n} photo",
|
||||
"{country} · {n} photos",
|
||||
place.photo_count as u64,
|
||||
&[("country", &country_name(&place.country))],
|
||||
)
|
||||
}
|
||||
|
||||
/// One place as a clickable card: its newest photo, the town, and the country
|
||||
/// with the number of photos taken there.
|
||||
fn place_card(ui: &Rc<Ui>, place: &PlaceInfo) -> gtk4::Button {
|
||||
let picture = gtk4::Picture::builder()
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.can_shrink(true)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
picture.add_css_class("photo-thumb");
|
||||
|
||||
let placeholder = gtk4::Image::builder()
|
||||
.icon_name("mark-location-symbolic")
|
||||
.pixel_size(32)
|
||||
.halign(gtk4::Align::Center)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
placeholder.add_css_class("photo-placeholder");
|
||||
|
||||
let cover = gtk4::Overlay::new();
|
||||
cover.set_child(Some(&placeholder));
|
||||
cover.add_overlay(&picture);
|
||||
cover.set_size_request(COVER_EDGE, COVER_EDGE);
|
||||
cover.set_overflow(gtk4::Overflow::Hidden);
|
||||
cover.add_css_class("album-cover");
|
||||
|
||||
let name = gtk4::Label::builder()
|
||||
.label(&place.name)
|
||||
.halign(gtk4::Align::Start)
|
||||
.xalign(0.0)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
name.add_css_class("heading");
|
||||
|
||||
let subtitle = place_subtitle(place);
|
||||
let count = gtk4::Label::builder()
|
||||
.label(&subtitle)
|
||||
.halign(gtk4::Align::Start)
|
||||
.xalign(0.0)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
count.add_css_class("dim-label");
|
||||
count.add_css_class("caption");
|
||||
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
card.append(&cover);
|
||||
card.append(&name);
|
||||
card.append(&count);
|
||||
|
||||
let button = gtk4::Button::builder().child(&card).build();
|
||||
button.add_css_class("flat");
|
||||
button.add_css_class("album-card");
|
||||
button.set_tooltip_text(Some(&format!("{}\n{subtitle}", place.name)));
|
||||
|
||||
want_cover(ui, place.cover.uid.clone(), &picture);
|
||||
|
||||
let ui_open = ui.clone();
|
||||
let place = place.clone();
|
||||
button.connect_clicked(move |_| open_place(&ui_open, place.clone()));
|
||||
button
|
||||
}
|
||||
|
||||
/// Open one place in the gallery: the timeline view, paged from that town.
|
||||
fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
|
||||
ui.gallery.title.set_title(&place.name);
|
||||
ui.gallery.title.set_subtitle(&place_subtitle(&place));
|
||||
*ui.gallery.place.borrow_mut() = Some(place);
|
||||
ui.gallery.timeline_stale.set(true);
|
||||
|
||||
// As in an album: no filters, no Upload, no view switch — back leads out.
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.view_switch.set_visible(false);
|
||||
ui.gallery.upload.set_visible(false);
|
||||
ui.gallery.back.set_visible(true);
|
||||
ui.gallery.kind.set(None);
|
||||
ui.gallery.range.set(None);
|
||||
|
||||
ui.gallery.content.set_visible_child_name("timeline");
|
||||
load_gallery(ui, false);
|
||||
}
|
||||
|
||||
/// Leave an open place, restoring the timeline's header. A no-op when no place
|
||||
/// is open.
|
||||
pub(crate) fn close_place(ui: &Rc<Ui>) {
|
||||
if ui.gallery.place.borrow_mut().take().is_none() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.title.set_title(&gettext("Photos"));
|
||||
ui.gallery.view_switch.set_visible(true);
|
||||
ui.gallery.upload.set_visible(true);
|
||||
ui.gallery.back.set_visible(false);
|
||||
}
|
||||
|
||||
/// Show the place grid's own status page (loading, empty, or an error).
|
||||
fn places_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
||||
ui.gallery.places_status.set_icon_name(Some(icon));
|
||||
ui.gallery.places_status.set_title(title);
|
||||
ui.gallery.places_status.set_description(Some(description));
|
||||
ui.gallery.places_stack.set_visible_child_name("status");
|
||||
}
|
||||
|
||||
/// Wire the Places toggle of the view switcher.
|
||||
pub(crate) fn wire_places(ui: &Rc<Ui>) {
|
||||
let ui_places = ui.clone();
|
||||
ui.gallery.places_btn.clone().connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
show_places(&ui_places);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -64,11 +64,31 @@ pub(crate) fn init() {
|
||||
tracing::warn!("bindtextdomain failed: {e}");
|
||||
}
|
||||
let _ = gettextrs::bind_textdomain_codeset(DOMAIN, "UTF-8");
|
||||
// Country names come translated from the iso-codes catalogs, which live in
|
||||
// the system's own locale directory.
|
||||
let _ = gettextrs::bind_textdomain_codeset(ISO_3166_DOMAIN, "UTF-8");
|
||||
if let Err(e) = gettextrs::textdomain(DOMAIN) {
|
||||
tracing::warn!("textdomain failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The iso-codes text domain that translates ISO 3166-1 country names.
|
||||
const ISO_3166_DOMAIN: &str = "iso_3166-1";
|
||||
|
||||
/// The name of the country with ISO 3166-1 code `code`, in the user's language.
|
||||
/// The English names are the iso-codes project's own, so that project's
|
||||
/// catalogs translate them; Kosovo has no ISO code of its own and is
|
||||
/// translated here. An unknown code is shown as it is.
|
||||
pub(crate) fn country_name(code: &str) -> String {
|
||||
if code == "XK" {
|
||||
return gettext("Kosovo");
|
||||
}
|
||||
match pdfs_core::places::country_name(code) {
|
||||
Some(name) => gettextrs::dgettext(ISO_3166_DOMAIN, name),
|
||||
None => code.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `code` the message language. gettext reads `LANGUAGE` first, but
|
||||
/// ignores it while the message locale is "C" or "C.UTF-8", so a system without
|
||||
/// a configured locale also gets its message locale moved to a real one. Both
|
||||
|
||||
Reference in new issue
Block a user