feat(core,fuse,gui): Proton theme, photos on the map and an opt-in street map

- The Proton accent preference becomes a whole Proton theme that also
  holds against a GTK theme imported from gtk.css; folders get Proton's
  purple folder icon while it is on.
- Places map markers show one of each town's photos with a count, and
  zoomed in on a town every photo sits where it was taken
  (Request::PlaceLocations). Photos taken on one spot are listed in a
  popover. "Show on map" in the photo viewer goes to the Places map.
- The built-in map draws country borders, sea and land in their own
  colours.
- An opt-in street map from OpenFreeMap (vector tiles drawn with cairo,
  cached on disk up to 256 MB), with the OpenStreetMap credit on the map
  and in About.
- Translations for all catalogs; changelog folded into 2.3.0.
This commit is contained in:
nvrl committed 2026-09-28 03:12:57 +02:00
1 parent 4fd14eb745
commit fde9c2d271
37 files changed
+5846 -2214

No files matched your search

+26 -8
View File
@@ -128,11 +128,12 @@ pub struct AppConfig {
/// field.
#[serde(default)]
pub prompt: Option<PromptConfig>,
/// Paint the desktop app in Proton purple instead of the system accent
/// colour. `None` means "follow the system". Defaulted for configs
/// predating the field.
#[serde(default)]
pub proton_accent: Option<bool>,
/// Paint the desktop app in Proton's colours instead of the system theme
/// and accent colour. `None` means "follow the system". Defaulted for
/// configs predating the field; also read from `proton_accent`, its name
/// while it only changed the accent.
#[serde(default, alias = "proton_accent")]
pub proton_theme: Option<bool>,
/// How the desktop app's My Files page lays out and orders a folder.
/// Defaulted for configs predating the field.
#[serde(default)]
@@ -147,6 +148,12 @@ pub struct AppConfig {
/// Defaulted for configs predating the field.
#[serde(default)]
pub language: Option<String>,
/// Draw the Places map from OpenFreeMap's street map instead of the
/// built-in outline. Off by default: the tiles come from a third party,
/// which then learns which parts of the world are looked at. Defaulted for
/// configs predating the field.
#[serde(default)]
pub online_map: bool,
}
/// The My Files page's view choices, remembered between runs.
@@ -216,10 +223,11 @@ impl Default for AppConfig {
conflict_sweep: None,
open_with: None,
prompt: None,
proton_accent: None,
proton_theme: None,
files_view: FilesView::default(),
tray_hidden: false,
language: None,
online_map: false,
}
}
}
@@ -566,7 +574,7 @@ mod tests {
conflict_sweep: Some(SweepMode::Enforce),
open_with: None,
prompt: None,
proton_accent: Some(true),
proton_theme: Some(true),
files_view: FilesView {
list: true,
sort: FileSort::Modified,
@@ -575,13 +583,15 @@ mod tests {
},
tray_hidden: true,
language: Some("de".to_string()),
online_map: true,
};
let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
assert_eq!(decoded.user_agent, "test-agent/1.0");
assert_eq!(decoded.proton_accent, Some(true));
assert_eq!(decoded.proton_theme, Some(true));
assert!(decoded.tray_hidden);
assert!(decoded.online_map);
assert_eq!(decoded.language.as_deref(), Some("de"));
assert_eq!(decoded.upload_limit, Some(500_000));
assert_eq!(decoded.download_limit, None);
@@ -598,6 +608,14 @@ mod tests {
assert!(partial.folders_first);
}
#[test]
fn the_old_proton_accent_setting_turns_on_the_proton_theme() {
let decoded: AppConfig =
serde_json::from_str(r#"{"app_version":"a","user_agent":"b","proton_accent":true}"#)
.unwrap();
assert_eq!(decoded.proton_theme, Some(true));
}
#[test]
fn test_default_app_config() {
let default_config = AppConfig::default();
+16
View File
@@ -103,6 +103,9 @@ pub enum Request {
offset: usize,
limit: usize,
},
/// Every photo taken in one town with where exactly it was taken, for the
/// map's close-up. Replies with [`Response::PhotoLocations`].
PlaceLocations { id: u32 },
/// List the account's photo albums, newest activity first, including the
/// albums other people share with us. Metadata only — an album's cover
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
@@ -1468,6 +1471,15 @@ pub struct PlaceInfo {
pub cover: PhotoItem,
}
/// One photo and where it was taken, in decimal degrees. Part of
/// [`Response::PhotoLocations`].
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoLocation {
pub photo: PhotoItem,
pub latitude: f64,
pub longitude: f64,
}
/// How many of the timeline's photos a running refresh has read the metadata
/// (and so the location) of, out of how many. Part of [`Response::Places`].
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
@@ -1845,6 +1857,9 @@ pub enum Response {
#[serde(default)]
mapping: Option<MappingProgress>,
},
/// The photos of one town, each with its location (reply to
/// [`Request::PlaceLocations`]).
PhotoLocations { items: Vec<PhotoLocation> },
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> },
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
@@ -2549,6 +2564,7 @@ mod tests {
offset: 60,
limit: 30,
},
Request::PlaceLocations { id: 2950159 },
] {
let line = serde_json::to_string(&req).unwrap();
let back: Request = serde_json::from_str(&line).unwrap();
+23
View File
@@ -697,6 +697,29 @@ impl Db {
Ok(rows)
}
/// Every photo taken in a town, one per group as in [`Self::photos_at_place`],
/// with where exactly it was taken. A group's own location is that of the
/// photo standing for it.
pub fn photo_locations_at_place(&self, id: u32) -> Result<Vec<(StoredPhoto, (f64, f64))>> {
let photos = self.photos_at_place(id, 0, usize::MAX >> 1)?;
let located: HashMap<String, (f64, f64)> = {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT uid, latitude, longitude FROM photos \
WHERE place_id = ?1 AND latitude IS NOT NULL AND longitude IS NOT NULL",
)?;
stmt.query_map(params![id], |r| Ok((r.get(0)?, (r.get(1)?, r.get(2)?))))?
.collect::<rusqlite::Result<_>>()?
};
Ok(photos
.into_iter()
.filter_map(|photo| {
let at = *located.get(&photo.uid)?;
Some((photo, at))
})
.collect())
}
/// Sets of photos with the same content hash, each set ordered with the
/// copy worth keeping first, and the sets newest first.
///
+24
View File
@@ -339,6 +339,30 @@ fn places_group_photos_by_the_nearest_town() {
assert_eq!(berlin, ["b2", "b1-jpeg"]);
}
/// A town's photos come with where each was taken, one per RAW+JPEG shot.
#[test]
fn place_photos_carry_their_own_location() {
let db = Db::open_in_memory().unwrap();
let kreuzberg = (52.497, 13.411);
db.photos_replace(&[
located("b2", 500, "IMG_5.JPG", BERLIN),
located("b1-jpeg", 300, "IMG_3.JPG", kreuzberg),
located("b1-raw", 300, "IMG_3.CR2", kreuzberg),
])
.unwrap();
let id = db.photo_places().unwrap()[0].id;
let located: Vec<(String, (f64, f64))> = db
.photo_locations_at_place(id)
.unwrap()
.into_iter()
.map(|(photo, at)| (photo.uid, at))
.collect();
assert_eq!(
located,
[("b2".to_owned(), BERLIN), ("b1-jpeg".to_owned(), kreuzberg)]
);
}
/// A refresh that skips an already-resolved photo keeps where it was taken; one
/// that reads the node again takes what the node now says.
#[test]