added album to photos, renamed gallery view

This commit is contained in:
nvrl committed 2026-07-31 20:31:25 +02:00
1 parent d988236bfa
commit 2336ad9e87
25 files changed
+1493 -361

No files matched your search

Generated
+8 -8
View File
@@ -3518,7 +3518,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pdfs-cli"
version = "1.2.1"
version = "1.3.0"
dependencies = [
"anyhow",
"clap 4.6.4",
@@ -3533,7 +3533,7 @@ dependencies = [
[[package]]
name = "pdfs-core"
version = "1.2.1"
version = "1.3.0"
dependencies = [
"directories",
"ignore",
@@ -3553,7 +3553,7 @@ dependencies = [
[[package]]
name = "pdfs-fuse"
version = "1.2.1"
version = "1.3.0"
dependencies = [
"fuser",
"image",
@@ -3569,7 +3569,7 @@ dependencies = [
[[package]]
name = "pdfs-gui"
version = "1.2.1"
version = "1.3.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3830,9 +3830,9 @@ dependencies = [
[[package]]
name = "proton-drive-rs"
version = "0.3.1"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a02afd5a53ecbf0e1ea18f3102e7b99e5ad61977ee0709bd214279b2376e0077"
checksum = "06f6b2b3aaaf90cd3eab823e3c13a4ff784693f183f7fde97f865378c4d3e0bd"
dependencies = [
"async-trait",
"base64",
@@ -3857,9 +3857,9 @@ dependencies = [
[[package]]
name = "proton-sdk"
version = "0.3.1"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46b5f84f0c33ac7a19b0131e971e7a17f10f12d6dc6df64c3b9dda7f37aa35bb"
checksum = "1037a17f8951cf3ade40a8d2a8230720caf6136383eecc7d61e89636e229793e"
dependencies = [
"aes-gcm 0.11.0",
"async-trait",
+3 -3
View File
@@ -3,15 +3,15 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "1.2.1"
version = "1.3.0"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
rust-version = "1.96"
[workspace.dependencies]
proton-sdk = { version = "0.3.1" }
proton-drive-rs = { version = "0.3.1" }
proton-sdk = { version = "0.3.2" }
proton-drive-rs = { version = "0.3.2" }
# Internal crates.
pdfs-core = { path = "crates/pdfs-core" }
+1
View File
@@ -14,6 +14,7 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey.
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
+59 -6
View File
@@ -145,6 +145,19 @@ enum Command {
#[arg(long, default_value_t = 0)]
offset: usize,
},
/// List the photo albums (including ones shared with this account).
Albums,
/// List the photos in one album (newest capture first).
Album {
/// Album node uid in `volume~link` form (from `pdfs albums`).
uid: String,
/// Max photos to list.
#[arg(long, default_value_t = 50)]
limit: usize,
/// Skip this many photos from the start of the album.
#[arg(long, default_value_t = 0)]
offset: usize,
},
/// Download a photo by uid (`volume~link`) and print its cached path.
OpenPhoto {
/// Photo node uid in `volume~link` form (from `pdfs photos`).
@@ -517,6 +530,8 @@ fn main() -> Result<()> {
Command::Pins => cmd_pins(),
Command::Ls { path } => cmd_ls(path),
Command::Photos { limit, offset } => cmd_photos(limit, offset),
Command::Albums => cmd_albums(),
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
Command::OpenPhoto { uid } => cmd_open_photo(uid),
Command::Search { query, limit } => cmd_search(query, limit),
Command::Rename { path, new_name } => cmd_rename(path, new_name),
@@ -1485,15 +1500,22 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
println!("This account has no photos volume.")
}
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
CtlResponse::Photos { items, .. } => {
// The timeline reply only carries thumbnails already in the cache, so
// pull the rest of the page's in one batch to print a path per photo.
CtlResponse::Photos { items, .. } => print_photo_page(items)?,
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
/// Print one page of photos as `capture-time uid thumbnail-path`.
///
/// A timeline or album reply only carries thumbnails already in the cache, so
/// the rest of the page's are pulled in one batch rather than left blank.
fn print_photo_page(items: Vec<pdfs_core::control::PhotoItem>) -> Result<()> {
let uids: Vec<String> = items.iter().map(|p| p.uid.clone()).collect();
let thumbs: HashMap<String, Option<String>> =
match control_request(CtlRequest::PhotoThumbs { uids })? {
CtlResponse::Thumbs { items } => {
items.into_iter().map(|t| (t.uid, t.path)).collect()
}
CtlResponse::Thumbs { items } => items.into_iter().map(|t| (t.uid, t.path)).collect(),
_ => HashMap::new(),
};
for p in items {
@@ -1504,7 +1526,38 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
.unwrap_or("(no thumbnail)");
println!("{} {} {thumb}", p.capture_time, p.uid);
}
Ok(())
}
fn cmd_albums() -> Result<()> {
match control_request(CtlRequest::PhotoAlbums)? {
CtlResponse::Albums {
available: false, ..
} => println!("This account has no photos volume."),
CtlResponse::Albums { items, .. } if items.is_empty() => println!("No albums."),
CtlResponse::Albums { items, .. } => {
for album in items {
let shared = if album.shared {
" (shared with me)"
} else {
""
};
println!(
"{:>6} {} {}{shared}",
album.photo_count, album.uid, album.name
);
}
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_album(uid: String, limit: usize, offset: usize) -> Result<()> {
match control_request(CtlRequest::AlbumPhotos { uid, offset, limit })? {
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
CtlResponse::Photos { items, .. } => print_photo_page(items)?,
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
+44
View File
@@ -71,6 +71,20 @@ pub enum Request {
#[serde(default)]
kind: Option<PhotoKind>,
},
/// 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.
/// Replies with [`Response::Albums`].
PhotoAlbums,
/// Fetch a page of one album's photos, newest capture first. Same reply
/// shape as [`Request::PhotosTimeline`] ([`Response::Photos`]), so a
/// front-end paints an album with the gallery it already has. `uid` is the
/// album node's uid, as reported by [`Request::PhotoAlbums`].
AlbumPhotos {
uid: String,
offset: usize,
limit: usize,
},
/// Fetch thumbnails for the given photo uids, downloading the ones not
/// already cached (one batched round-trip) and replying with their on-disk
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
@@ -952,6 +966,29 @@ pub struct PhotoMonth {
pub count: usize,
}
/// One album in a [`Response::Albums`] listing.
///
/// An album is a folder node on the photos volume carrying album properties, so
/// it has an ordinary name; everything else here comes from those properties.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AlbumInfo {
/// Album node uid in `volume~link` form — what [`Request::AlbumPhotos`]
/// takes.
pub uid: String,
pub name: String,
/// How many photos the server says the album holds.
pub photo_count: usize,
/// The photo shown as the album's cover, when it has one. Its thumbnail is
/// fetched with [`Request::PhotoThumbs`] like any other photo.
pub cover_uid: Option<String>,
/// Epoch seconds of the last change to the album's contents, when the server
/// reports one. The listing is ordered by it, newest first.
pub last_activity: Option<i64>,
/// True when this album lives on someone else's photos volume — it is shared
/// with us rather than ours.
pub shared: bool,
}
/// One photo in a [`Request::PhotosTimeline`] page.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoItem {
@@ -1107,6 +1144,13 @@ pub enum Response {
#[serde(default)]
counts: Option<(usize, usize, usize)>,
},
/// The account's photo albums (reply to [`Request::PhotoAlbums`]).
/// `available` is false when the account has no photos volume, matching
/// [`Response::Photos`].
Albums {
available: bool,
items: Vec<AlbumInfo>,
},
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
/// newest first.
PhotoMonths { months: Vec<PhotoMonth> },
+238
View File
@@ -0,0 +1,238 @@
//! Photo albums: the album listing and each album's contents, persisted like
//! the timeline so the Albums view opens from disk instead of re-enumerating the
//! photos volume on every launch.
//!
//! An album's photos are stored per album rather than as a join against
//! `photos`: an album shared with us lives on the sharer's volume, so its photos
//! never appear in our own timeline and have nowhere else to record what a
//! thumbnail attempt learned about them.
use rusqlite::params;
use std::collections::HashMap;
use super::{Db, StoredPhoto, THUMB_UNKNOWN};
use crate::Result;
/// One album of the persisted listing, ordered newest-activity-first in `seq`.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredAlbum {
pub uid: String,
pub name: String,
pub photo_count: usize,
/// The photo shown as the album's cover, when the server named one.
pub cover_uid: Option<String>,
/// Epoch seconds of the last change to the album's contents.
pub last_activity: Option<i64>,
/// True when the album lives on someone else's photos volume.
pub shared: bool,
}
impl Db {
/// Replace the album listing wholesale, in the given order.
pub fn albums_replace(&self, albums: &[StoredAlbum]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
// Contents of an album that no longer exists (unshared, deleted) go with
// it — otherwise they would linger forever, keyed by an album uid no
// listing mentions again.
{
let kept: std::collections::HashSet<&str> =
albums.iter().map(|a| a.uid.as_str()).collect();
let stale: Vec<String> = {
let mut stmt = tx.prepare("SELECT DISTINCT album_uid FROM album_photos")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
rows.filter_map(|r| r.ok())
.filter(|uid| !kept.contains(uid.as_str()))
.collect()
};
let mut stmt = tx.prepare("DELETE FROM album_photos WHERE album_uid = ?1")?;
for uid in stale {
stmt.execute([uid])?;
}
}
tx.execute("DELETE FROM albums", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO albums
(uid, name, photo_count, cover_uid, last_activity, shared, seq)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?;
for (seq, album) in albums.iter().enumerate() {
stmt.execute(params![
album.uid,
album.name,
album.photo_count as i64,
album.cover_uid,
album.last_activity,
album.shared as i64,
seq as i64,
])?;
}
}
tx.commit()?;
Ok(())
}
/// The persisted album listing, in stored order.
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
FROM albums ORDER BY seq",
)?;
let rows = stmt
.query_map([], |r| {
Ok(StoredAlbum {
uid: r.get(0)?,
name: r.get(1)?,
photo_count: r.get::<_, i64>(2)?.max(0) as usize,
cover_uid: r.get(3)?,
last_activity: r.get(4)?,
shared: r.get::<_, i64>(5)? != 0,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Number of albums in the persisted listing.
pub fn albums_count(&self) -> Result<usize> {
let conn = self.conn.lock();
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
Ok(n.max(0) as usize)
}
/// Replace one album's contents, in the given (newest-capture-first) order.
/// Learned aspect ratios and thumbnail verdicts survive the replacement, the
/// same way [`Db::photos_replace`] keeps them for the timeline.
pub fn album_photos_replace(
&self,
album_uid: &str,
items: &[(String, i64, Option<String>, Option<String>)],
) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
let mut stmt = tx.prepare(
"SELECT uid, ratio, thumb_state, media_type FROM album_photos
WHERE album_uid = ?1",
)?;
let rows = stmt.query_map([album_uid], |r| {
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
})?;
rows.collect::<rusqlite::Result<_>>()?
};
tx.execute("DELETE FROM album_photos WHERE album_uid = ?1", [album_uid])?;
{
let mut stmt = tx.prepare(
"INSERT INTO album_photos
(album_uid, uid, capture_time, name, media_type, kind, ratio, thumb_state, seq)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)?;
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
let (ratio, thumb_state, learned_media) =
learned
.get(uid)
.cloned()
.unwrap_or((None, THUMB_UNKNOWN, None));
let media_type = media_type.clone().or(learned_media);
let kind =
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
stmt.execute(params![
album_uid,
uid,
capture_time,
name,
media_type,
kind.as_i64(),
ratio,
thumb_state,
seq as i64,
])?;
}
}
tx.commit()?;
Ok(())
}
/// One page of an album's photos, in stored order.
pub fn album_photos_page(
&self,
album_uid: &str,
offset: usize,
limit: usize,
) -> Result<Vec<StoredPhoto>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
)?;
let rows = stmt
.query_map(params![album_uid, limit as i64, offset as i64], |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// How many photos of `album_uid` are persisted.
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
let conn = self.conn.lock();
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
[album_uid],
|r| r.get(0),
)?;
Ok(n.max(0) as usize)
}
/// The album-held photos among `uids`, one row per uid (a photo in two albums
/// is returned once). Backs the thumbnail path for photos that are in an
/// album but not in our own timeline — everything in a shared album, and an
/// album cover whose photo the current timeline page hasn't reached.
pub fn album_photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
if uids.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock();
let placeholders = vec!["?"; uids.len()].join(",");
let mut stmt = conn.prepare(&format!(
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
WHERE uid IN ({placeholders}) GROUP BY uid"
))?;
let rows = stmt
.query_map(rusqlite::params_from_iter(uids), |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Record a thumbnail verdict against every album row for `uid`, so what one
/// attempt learned is not re-learned per album the photo sits in. The
/// timeline's own copy is updated separately by [`Db::photo_set_thumb`].
pub fn album_photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE album_photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
params![uid, state, ratio],
)?;
Ok(())
}
}
+40 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 18;
pub(super) const SCHEMA_VERSION: i64 = 19;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -95,6 +95,9 @@ impl Db {
if current < 18 {
tx.execute_batch(MIGRATION_V18)?;
}
if current < 19 {
tx.execute_batch(MIGRATION_V19)?;
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -472,3 +475,39 @@ BEGIN
VALUES ('device', NEW.id);
END;
";
/// Schema v19: photo albums and their contents. Both are projections of the
/// server's listings — like `photos`, they are replaced wholesale on refresh —
/// so the Albums view opens from disk instead of re-enumerating every launch.
///
/// `album_photos` carries its own `ratio` / `thumb_state` rather than joining
/// `photos`: an album shared with us lives on the sharer's volume, so its
/// photos are not in our timeline at all and would otherwise have nowhere to
/// remember what a thumbnail attempt learned.
const MIGRATION_V19: &str = "
CREATE TABLE albums (
uid TEXT PRIMARY KEY,
name TEXT NOT NULL,
photo_count INTEGER NOT NULL DEFAULT 0,
cover_uid TEXT,
last_activity INTEGER,
shared INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL
);
CREATE TABLE album_photos (
album_uid TEXT NOT NULL,
uid TEXT NOT NULL,
capture_time INTEGER NOT NULL,
name TEXT,
media_type TEXT,
kind INTEGER NOT NULL DEFAULT 0,
ratio REAL,
thumb_state INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL,
PRIMARY KEY (album_uid, uid)
);
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
";
+2
View File
@@ -22,6 +22,7 @@ use crate::Result;
use std::path::Path;
mod activity;
mod albums;
mod cache;
mod devices;
mod local;
@@ -38,6 +39,7 @@ mod sync;
mod trash;
mod utils;
pub use albums::StoredAlbum;
pub use cache::CacheEntryInput;
pub use devices::StoredDevice;
pub use local::LocalFileHit;
+22
View File
@@ -94,4 +94,26 @@ impl Db {
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
Ok(())
}
/// Drop every `sync_state` key starting with `prefix` — the per-entity
/// stamps (one album, one shared folder) that are keyed by uid and so have
/// no fixed name to clear individually.
pub fn clear_state_prefix(&self, prefix: &str) -> Result<()> {
let conn = self.conn.lock();
// `\` escapes the LIKE wildcards, so a prefix containing `%` or `_`
// (a uid never does today, but this is not the place to rely on that)
// matches literally instead of sweeping unrelated keys.
let pattern = format!(
"{}%",
prefix
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
);
conn.execute(
"DELETE FROM sync_state WHERE key LIKE ?1 ESCAPE '\\'",
params![pattern],
)?;
Ok(())
}
}
+143 -2
View File
@@ -1334,6 +1334,8 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
"DROP TRIGGER mount_sync_folder_insert;
DROP TABLE mount;
DROP TABLE share_access;
DROP TABLE album_photos;
DROP TABLE albums;
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
)
.unwrap();
@@ -1347,9 +1349,10 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
let root = uid("offline-share");
db.set_share_access(&root, crate::Access::Viewer).unwrap();
assert_eq!(db.share_access(&root).unwrap(), Some(crate::Access::Viewer));
// Migrating an old fixture always lands on head, whatever head is today.
assert_eq!(
db.state_str("schema_version").unwrap().as_deref(),
Some("18")
Some(SCHEMA_VERSION.to_string().as_str())
);
drop(db);
@@ -1438,9 +1441,10 @@ fn migration_v18_projects_v17_sync_folders_and_cascades_deletes() {
1,
"the V18 foreign key must remove the device projection"
);
// Migrating an old fixture always lands on head, whatever head is today.
assert_eq!(
db.state_str("schema_version").unwrap().as_deref(),
Some("18")
Some(SCHEMA_VERSION.to_string().as_str())
);
drop(db);
@@ -2842,6 +2846,8 @@ fn test_db_has_children_and_trashed_filtering() {
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
};
db.upsert_node(&parent_node).unwrap();
@@ -2866,6 +2872,8 @@ fn test_db_has_children_and_trashed_filtering() {
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
};
db.upsert_node(&child_node).unwrap();
@@ -2909,3 +2917,136 @@ fn test_db_has_create_op() {
db.delete_ops_for_uid(local_uid).unwrap();
assert!(!db.has_create_op(local_uid).unwrap(), "op deleted");
}
/// The album listing is a wholesale replacement, and an album that leaves the
/// listing must take its contents with it — nothing else ever deletes those rows.
#[test]
fn albums_replace_drops_the_contents_of_an_album_that_left() {
let db = Db::open_in_memory().unwrap();
let album = |uid: &str, name: &str, activity: Option<i64>| StoredAlbum {
uid: uid.into(),
name: name.into(),
photo_count: 2,
cover_uid: Some("cover".into()),
last_activity: activity,
shared: false,
};
db.albums_replace(&[
album("a1", "Trip", Some(500)),
album("a2", "Cats", Some(400)),
])
.unwrap();
db.album_photos_replace("a1", &[("p1".into(), 300, None, None)])
.unwrap();
db.album_photos_replace("a2", &[("p2".into(), 200, None, None)])
.unwrap();
// The next refresh no longer has a2 — it was deleted, or unshared.
db.albums_replace(&[album("a1", "Trip", Some(600))])
.unwrap();
assert_eq!(db.albums_count().unwrap(), 1);
assert_eq!(db.album_photos_count("a1").unwrap(), 1);
assert_eq!(
db.album_photos_count("a2").unwrap(),
0,
"a departed album's photos go with it"
);
}
/// An album's contents are replaced like the timeline: server order is kept, and
/// what a thumbnail attempt cost a download to learn survives the refresh.
#[test]
fn album_photos_replace_keeps_what_was_learned() {
let db = Db::open_in_memory().unwrap();
db.albums_replace(&[StoredAlbum {
uid: "a1".into(),
name: "Trip".into(),
photo_count: 3,
cover_uid: None,
last_activity: None,
shared: true,
}])
.unwrap();
db.album_photos_replace(
"a1",
&[
("p1".into(), 300, None, Some("video/mp4".into())),
("p2".into(), 200, None, None),
],
)
.unwrap();
db.album_photo_set_thumb("p1", THUMB_HAVE, Some(1.5))
.unwrap();
db.album_photo_set_thumb("p2", THUMB_NONE, None).unwrap();
db.album_photos_replace(
"a1",
&[
("p0".into(), 400, Some("new.jpg".into()), None),
("p1".into(), 300, None, None),
("p2".into(), 200, None, None),
],
)
.unwrap();
let page = db.album_photos_page("a1", 0, 10).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p0", "p1", "p2"],
"the album's own order is preserved"
);
assert_eq!(page[1].ratio, Some(1.5));
assert_eq!(page[1].thumb_state, THUMB_HAVE);
assert_eq!(page[2].thumb_state, THUMB_NONE);
// The media type is learned-and-kept, so p1 stays classified as a video even
// though the second refresh carried none.
assert_eq!(page[1].kind, crate::control::PhotoKind::Video);
assert_eq!(page[0].thumb_state, THUMB_UNKNOWN);
// Paging is relative to the album, and the shared flag round-trips.
let second = db.album_photos_page("a1", 1, 1).unwrap();
assert_eq!(second.len(), 1);
assert_eq!(second[0].uid, "p1");
assert!(db.albums_list().unwrap()[0].shared);
}
/// A photo held only by an album — everything in an album shared with us — is
/// still resolvable for its capture time, which is what tags its cached
/// thumbnail. One row per uid, even when the photo is in several albums.
#[test]
fn album_photos_by_uid_resolves_a_photo_outside_the_timeline() {
let db = Db::open_in_memory().unwrap();
db.album_photos_replace("a1", &[("p1".into(), 300, None, None)])
.unwrap();
db.album_photos_replace("a2", &[("p1".into(), 300, None, None)])
.unwrap();
let found = db
.album_photos_by_uid(&["p1".into(), "absent".into()])
.unwrap();
assert_eq!(found.len(), 1, "a photo in two albums resolves once");
assert_eq!(found[0].capture_time, 300);
assert!(db.album_photos_by_uid(&[]).unwrap().is_empty());
}
/// Per-album freshness stamps are keyed by uid, so invalidating them all is a
/// prefix sweep — and it must not take unrelated keys with it.
#[test]
fn clear_state_prefix_takes_only_the_prefixed_keys() {
let db = Db::open_in_memory().unwrap();
db.set_state_i64("album_synced_ms:vol~a1", 1).unwrap();
db.set_state_i64("album_synced_ms:vol~a2", 2).unwrap();
db.set_state_i64("albums_synced_ms", 3).unwrap();
db.clear_state_prefix("album_synced_ms:").unwrap();
assert_eq!(db.state_i64("album_synced_ms:vol~a1").unwrap(), None);
assert_eq!(db.state_i64("album_synced_ms:vol~a2").unwrap(), None);
assert_eq!(
db.state_i64("albums_synced_ms").unwrap(),
Some(3),
"the listing's own stamp is not a per-album one"
);
}
+245
View File
@@ -0,0 +1,245 @@
//! Photo albums: the album listing, and one album's contents.
//!
//! Both are persisted projections of what the photos volume reports, refreshed
//! on the same stale-while-serving rule as the timeline
//! ([`crate::photos`]): a listing already on disk is served immediately and
//! refreshed behind the caller, and only an empty one waits on the network.
//!
//! An album is a folder node carrying album properties, so it has a name like
//! any other node; the properties supply the photo count and the cover. Albums
//! other people share with us come from a second listing and live on *their*
//! photos volume — which is why an album's photos are persisted per album rather
//! than looked up in our own timeline, where a shared album's photos never
//! appear.
use std::collections::{HashMap, HashSet};
use std::sync::atomic::Ordering;
use pdfs_core::control::{AlbumInfo, PhotoItem};
use pdfs_core::db::StoredAlbum;
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::warn;
use super::{
ALBUM_SYNCED_PREFIX, ALBUMS_SYNCED_MS, Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS,
TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, now_ms, parse_uid,
};
impl Core {
/// The album listing, served from disk and refreshed behind the caller when
/// stale. `None` means the account has no photos volume — the same signal
/// [`Core::photos_timeline`] gives.
pub(crate) fn albums(&self) -> CoreResult<Option<Vec<AlbumInfo>>> {
let count = self.db.albums_count().map_err(CoreError::from)?;
if count == 0 {
// Nothing on disk, so this one request waits for the fetch — unless
// the account is already known to have no photos volume.
let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0);
if known_empty && !self.listing_stale(ALBUMS_SYNCED_MS, TIMELINE_TTL) {
return Ok(None);
}
if !self.rt.block_on(self.refresh_albums())? {
return Ok(None);
}
} else if self.listing_stale(ALBUMS_SYNCED_MS, TIMELINE_TTL) {
self.spawn_albums_refresh();
}
let albums = self.db.albums_list().map_err(CoreError::from)?;
Ok(Some(albums.into_iter().map(album_info).collect()))
}
/// Re-enumerate the albums — ours and the ones shared with us — and replace
/// the persisted listing. Returns false when the account has no photos
/// volume.
pub(crate) async fn refresh_albums(&self) -> CoreResult<bool> {
let photos = self.photos();
if photos
.get_photos_root()
.await
.map_err(|e| CoreError::from_api(&e, "photos root"))?
.is_none()
{
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0);
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
let _ = self.db.set_state_i64(ALBUMS_SYNCED_MS, now_ms());
return Ok(false);
}
let mut uids = photos
.enumerate_album_node_uids()
.await
.map_err(|e| CoreError::from_api(&e, "albums"))?;
// Shared-with-me albums are a separate listing on the sharer's volume.
// Best-effort: an account whose sharing listing fails still gets its own
// albums rather than an error page.
let shared: HashSet<String> = match photos.enumerate_shared_with_me_album_uids().await {
Ok(shared) => {
let keys = shared.iter().map(|u| u.to_string()).collect();
uids.extend(shared);
keys
}
Err(e) => {
warn!(error = %e, "enumerating shared-with-me albums failed");
HashSet::new()
}
};
// The listings carry uids only; the name, photo count and cover come from
// the album nodes themselves. A chunk that fails to resolve drops those
// albums from this refresh rather than failing the whole listing.
let mut albums: Vec<StoredAlbum> = Vec::with_capacity(uids.len());
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
match photos.enumerate_nodes(chunk).await {
Ok(nodes) => {
for node in nodes {
let key = node.uid.to_string();
let props = node.album.as_ref();
albums.push(StoredAlbum {
shared: shared.contains(&key),
uid: key,
name: node.name,
photo_count: props.map_or(0, |a| a.photo_count.max(0) as usize),
cover_uid: props
.and_then(|a| a.cover_photo_uid.as_ref())
.map(|u| u.to_string()),
last_activity: props.and_then(|a| a.last_activity_time),
});
}
}
Err(e) => warn!(error = %e, "resolving an album chunk failed"),
}
}
// Newest activity first, and albums the server gave no activity time for
// last — an album nobody has touched belongs below the ones in use. Ties
// fall back to the name so the order is stable between refreshes.
albums.sort_by(|a, b| {
b.last_activity
.cmp(&a.last_activity)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
self.db.albums_replace(&albums).map_err(CoreError::from)?;
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
let _ = self.db.set_state_i64(ALBUMS_SYNCED_MS, now_ms());
Ok(true)
}
/// Refresh the album listing off the request path. At most one runs at a time.
pub(crate) fn spawn_albums_refresh(&self) {
if self.albums_refreshing.swap(true, Ordering::SeqCst) {
return;
}
let core = self.clone();
self.rt.spawn(async move {
if let Err(e) = core.refresh_albums().await {
warn!(error = %e, "background album refresh failed");
}
core.albums_refreshing.store(false, Ordering::SeqCst);
});
}
/// A page of one album's photos, newest capture first. The album's contents
/// are fetched on first open and refreshed behind the caller afterwards, so
/// paging never waits on the network twice.
pub(crate) fn album_photos(
&self,
album: &NodeUid,
offset: usize,
limit: usize,
) -> CoreResult<Vec<PhotoItem>> {
let key = album.to_string();
let stamp = format!("{ALBUM_SYNCED_PREFIX}{key}");
let count = self.db.album_photos_count(&key).map_err(CoreError::from)?;
if count == 0 {
self.rt.block_on(self.refresh_album(album))?;
} else if self.listing_stale(&stamp, TIMELINE_TTL) {
let core = self.clone();
let album = album.clone();
self.rt.spawn(async move {
if let Err(e) = core.refresh_album(&album).await {
warn!(%album, error = %e, "background album refresh failed");
}
});
}
let page = self
.db
.album_photos_page(&key, offset, limit)
.map_err(CoreError::from)?;
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
}
/// Re-enumerate one album's photos and replace its persisted contents.
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
let photos = self.photos();
let items = photos
.enumerate_album(album)
.await
.map_err(|e| CoreError::from_api(&e, "album"))?;
// Same enrichment as the timeline: the listing carries a uid and a
// capture time, and the gallery needs a name and media type to classify
// each entry. Best-effort per chunk.
let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect();
let mut meta: HashMap<String, (Option<String>, Option<String>)> = HashMap::new();
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
match photos.enumerate_nodes(chunk).await {
Ok(nodes) => {
for node in nodes {
let media_type = match &node.kind {
NodeKind::File { media_type, .. } => Some(media_type.clone()),
NodeKind::Folder => None,
};
meta.insert(node.uid.to_string(), (Some(node.name), media_type));
}
}
Err(e) => warn!(error = %e, "resolving album photo metadata failed"),
}
}
let rows: Vec<(String, i64, Option<String>, Option<String>)> = items
.iter()
.map(|it| {
let key = it.uid.to_string();
let (name, media_type) = meta.get(&key).cloned().unwrap_or((None, None));
(key, it.capture_time, name, media_type)
})
.collect();
let key = album.to_string();
self.db
.album_photos_replace(&key, &rows)
.map_err(CoreError::from)?;
let _ = self
.db
.set_state_i64(&format!("{ALBUM_SYNCED_PREFIX}{key}"), now_ms());
Ok(())
}
/// Drop the album listing's and every album's freshness stamp, so the next
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
/// to the timeline can just as easily be a change to an album.
pub(crate) fn invalidate_albums(&self) {
let _ = self.db.clear_state(ALBUMS_SYNCED_MS);
let _ = self.db.clear_state_prefix(ALBUM_SYNCED_PREFIX);
}
}
/// Project a persisted album into the wire item a front-end paints.
fn album_info(album: StoredAlbum) -> AlbumInfo {
AlbumInfo {
uid: album.uid,
name: album.name,
photo_count: album.photo_count,
// A cover uid the daemon cannot parse is no cover: the front-end would
// only ask for a thumbnail that can never be resolved.
cover_uid: album.cover_uid.filter(|uid| parse_uid(uid).is_some()),
last_activity: album.last_activity,
shared: album.shared,
}
}
+2
View File
@@ -545,6 +545,8 @@ mod tests {
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
}
}
+28
View File
@@ -185,6 +185,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}
RefreshScope::Photos => {
core.invalidate_photos();
// Whatever changed the timeline can just as easily have
// changed an album; one scope covers the whole photos view.
core.invalidate_albums();
Ok(())
}
};
@@ -213,6 +216,31 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PhotoAlbums) => match core.albums() {
Ok(Some(items)) => CtlResponse::Albums {
available: true,
items,
},
Ok(None) => CtlResponse::Albums {
available: false,
items: Vec::new(),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::AlbumPhotos { uid, offset, limit }) => match parse_uid(&uid) {
// An album page is a timeline page as far as the front-end is
// concerned, so it comes back in the same reply — with `counts`
// unset, which describe the whole timeline rather than this album.
Some(album) => match core.album_photos(&album, offset, limit) {
Ok(items) => CtlResponse::Photos {
available: true,
items,
counts: None,
},
Err(e) => CtlResponse::error(e),
},
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
},
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
Ok(months) => CtlResponse::PhotoMonths {
months: months
+4
View File
@@ -1143,6 +1143,8 @@ mod tests {
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
}
}
@@ -1291,6 +1293,8 @@ mod tests {
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
}
}
+15
View File
@@ -76,6 +76,7 @@ use proton_drive_rs::{
ProtonPhotosClient, RevisionReader, SharedWithMeItem, ThumbnailType,
};
mod albums;
mod background;
mod control;
use control::run_control_socket;
@@ -184,6 +185,11 @@ const TRASH_FIRST_WAIT: Duration = Duration::from_secs(20);
/// doesn't re-ask the server on every page request).
const PHOTOS_SYNCED_MS: &str = "photos_synced_ms";
const PHOTOS_AVAILABLE: &str = "photos_available";
/// Freshness stamp of the album listing, and of one album's contents (suffixed
/// with the album uid). Separate from the timeline's: albums are enumerated by
/// their own endpoints, and an album is only fetched when it is opened.
const ALBUMS_SYNCED_MS: &str = "albums_synced_ms";
const ALBUM_SYNCED_PREFIX: &str = "album_synced_ms:";
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
const SHARED_WITH_ME_NAME: &str = "shared_with_me_name";
const SHARED_WITH_ME_SYNCED_MS: &str = "shared_with_me_synced_ms";
@@ -343,6 +349,8 @@ struct Core {
/// already running, so a burst of page requests against a stale listing kicks
/// off one refresh rather than one per request.
timeline_refreshing: Arc<AtomicBool>,
/// The same, for the album listing.
albums_refreshing: Arc<AtomicBool>,
trash_refreshing: Arc<AtomicBool>,
/// Fires whenever a trash refresh persists a batch or finishes, so a
/// `ListTrash` request waiting on a first-ever refresh wakes on progress
@@ -4040,6 +4048,9 @@ fn local_node(uid: NodeUid, parent_uid: NodeUid, name: String, is_dir: bool) ->
is_shared_publicly: false,
signature_email: None,
membership: None,
// A locally fabricated node is on the main volume, never a photo/album.
photo: None,
album: None,
// Nothing signed it: it has never been near the crypto layer.
verification: Default::default(),
}
@@ -4628,6 +4639,8 @@ mod pending_size_tests {
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
}
}
@@ -6170,6 +6183,8 @@ mod tests {
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
}
}
+1
View File
@@ -376,6 +376,7 @@ pub fn mount(
hidden: Arc::new(Mutex::new(HashSet::new())),
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
timeline_refreshing: Arc::new(AtomicBool::new(false)),
albums_refreshing: Arc::new(AtomicBool::new(false)),
trash_refreshing: Arc::new(AtomicBool::new(false)),
trash_progress: Arc::new(tokio::sync::Notify::new()),
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
+23 -4
View File
@@ -5,7 +5,7 @@
//! user. Thumbnails the server has none for (anything a camera wrote rather than
//! a phone) are generated locally and stored as if the server had served them.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb};
@@ -154,7 +154,20 @@ impl Core {
pub(crate) fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> {
let ttype = ThumbnailType::Thumbnail.as_i32();
let keys: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
let stored = self.db.photos_by_uid(&keys).unwrap_or_default();
let mut stored = self.db.photos_by_uid(&keys).unwrap_or_default();
// Album covers and the contents of an album shared with us are not in our
// own timeline, so their capture time — the cache's validity tag — only
// exists on the album rows. Fill in whatever the timeline didn't cover.
{
let have: HashSet<String> = stored.iter().map(|p| p.uid.clone()).collect();
let missing: Vec<String> = keys
.iter()
.filter(|k| !have.contains(*k))
.cloned()
.collect();
stored.extend(self.db.album_photos_by_uid(&missing).unwrap_or_default());
}
let stored = stored;
let tags: HashMap<String, i64> = stored
.iter()
.map(|p| (p.uid.clone(), p.capture_time))
@@ -329,11 +342,17 @@ impl Core {
}
}
/// Persist what a thumbnail attempt learned about a photo.
/// Persist what a thumbnail attempt learned about a photo, against the
/// timeline and against every album the photo sits in — one attempt, one
/// verdict, wherever the photo is painted.
pub(crate) fn record_thumb(&self, uid: &NodeUid, state: i64, ratio: Option<f64>) {
if let Err(e) = self.db.photo_set_thumb(&uid.to_string(), state, ratio) {
let key = uid.to_string();
if let Err(e) = self.db.photo_set_thumb(&key, state, ratio) {
warn!(%uid, error = %e, "recording thumbnail state failed");
}
if let Err(e) = self.db.album_photo_set_thumb(&key, state, ratio) {
warn!(%uid, error = %e, "recording album thumbnail state failed");
}
}
/// Whether the listing stamped under `key` is older than `ttl` (or was never
+2
View File
@@ -966,6 +966,8 @@ mod tests {
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
}
}
+2
View File
@@ -354,6 +354,8 @@ mod tests {
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
}
}
+3
View File
@@ -47,6 +47,9 @@ pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) ->
is_shared_publicly: false,
signature_email: None,
membership: None,
// A synthetic folder is neither a photo nor an album.
photo: None,
album: None,
verification: Default::default(),
}
}
+80 -148
View File
@@ -4,6 +4,7 @@ pub(crate) mod pages;
pub(crate) mod widgets;
use pages::activity::*;
use pages::albums::*;
use pages::browser::*;
use pages::devices::*;
use pages::locations::*;
@@ -43,8 +44,8 @@ use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind, InvitationInfo,
JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response,
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind,
InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response,
RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, pending_summary,
send,
@@ -138,23 +139,10 @@ impl Ui {
}
}
/// The aspect ratio (w/h) to lay `uid`'s tile out at: the real one once its
/// thumbnail has been seen, otherwise [`RATIO_UNKNOWN`].
fn ratio(&self, uid: &str) -> f64 {
self.gallery
.photo_ratio
.borrow()
.get(uid)
.copied()
.unwrap_or(RATIO_UNKNOWN)
}
/// Remember a decoded thumbnail and the aspect ratio it revealed. Returns
/// true when the ratio is *new information* — the caller re-justifies the
/// affected rows, since they were laid out against a guess.
fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) -> bool {
let (w, h) = (texture.width(), texture.height());
/// Remember a decoded thumbnail, evicting the oldest once the cache is full.
/// Scrolling back over a day then repaints from memory rather than decoding
/// the same JPEGs off disk again.
fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) {
let mut cache = self.gallery.photo_tex.borrow_mut();
let mut order = self.gallery.photo_tex_order.borrow_mut();
if cache.insert(uid.to_string(), texture).is_none() {
@@ -165,58 +153,6 @@ impl Ui {
cache.remove(&old);
}
}
drop((cache, order));
if h <= 0 {
return false;
}
let ratio = f64::from(w) / f64::from(h);
let mut ratios = self.gallery.photo_ratio.borrow_mut();
match ratios.insert(uid.to_string(), ratio) {
// Only a *changed* ratio invalidates a layout; re-decoding a photo we
// already sized correctly must not trigger another pass.
Some(prev) if (prev - ratio).abs() < f64::EPSILON => false,
_ => {
self.gallery.photo_ratio_dirty.set(true);
true
}
}
}
/// Where the learned aspect ratios are persisted between runs.
fn ratio_path(&self) -> PathBuf {
self.dirs.cache_dir().join("photo-ratios.json")
}
/// Load the persisted aspect ratios. A missing or corrupt file just means the
/// first paint uses [`RATIO_UNKNOWN`] and re-justifies as thumbnails land.
fn load_ratios(&self) {
let path = self.ratio_path();
let Ok(text) = std::fs::read_to_string(&path) else {
return;
};
match serde_json::from_str::<HashMap<String, f64>>(&text) {
Ok(map) => *self.gallery.photo_ratio.borrow_mut() = map,
Err(e) => tracing::debug!("ignoring {}: {e}", path.display()),
}
}
/// Persist the learned aspect ratios, if any were learned since the last save.
fn save_ratios(&self) {
if !self.gallery.photo_ratio_dirty.replace(false) {
return;
}
let path = self.ratio_path();
let ratios = self.gallery.photo_ratio.borrow();
let write = serde_json::to_vec(&*ratios)
.map_err(std::io::Error::other)
.and_then(|bytes| {
std::fs::create_dir_all(path.parent().unwrap_or(Path::new("/")))?;
std::fs::write(&path, bytes)
});
if let Err(e) = write {
tracing::debug!("cannot save {}: {e}", path.display());
}
}
}
@@ -287,15 +223,23 @@ fn load_proton_theme() {
.viewer-spinner {{ color: white; }}\n\
.viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\
.viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\
.gallery-day {{ font-size: 1.05rem; font-weight: 700; padding: 2px 2px 6px 2px; }}\n\
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 10px; background: alpha(currentColor, 0.06); box-shadow: none; transition: filter 160ms ease, box-shadow 160ms ease; }}\n\
.photo-tile:hover {{ filter: brightness(1.06); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); }}\n\
.gallery-day {{ font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 4px 2px 8px 2px; }}\n\
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 14px; background: alpha(currentColor, 0.06); box-shadow: none; transition: box-shadow 180ms ease, background 180ms ease; }}\n\
.photo-tile:hover {{ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }}\n\
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
.photo-thumb {{ transition: transform 220ms ease; }}\n\
.photo-tile:hover .photo-thumb {{ transform: scale(1.06); }}\n\
.photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 160ms ease; }}\n\
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }}\n\
.photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\
.photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\
.photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\
.album-card {{ padding: 0; border-radius: 16px; transition: background 180ms ease; }}\n\
.album-card:hover {{ background: alpha(currentColor, 0.07); }}\n\
.album-cover {{ border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }}\n\
.album-card:hover .album-cover {{ box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }}\n\
.album-card:hover .photo-thumb {{ transform: scale(1.05); }}\n\
.view-switch button {{ padding-left: 18px; padding-right: 18px; }}\n\
.card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\
.card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
@@ -462,9 +406,21 @@ fn build_window(app: &adw::Application) {
retry: gallery_widgets.retry.clone(),
more: gallery_widgets.more.clone(),
upload: gallery_widgets.upload.clone(),
title: gallery_widgets.title.clone(),
subtitle: gallery_widgets.subtitle.clone(),
albums: gallery_widgets.albums.clone(),
albums_stack: gallery_widgets.albums_stack.clone(),
albums_status: gallery_widgets.albums_status.clone(),
photos_btn: gallery_widgets.photos_btn.clone(),
albums_btn: gallery_widgets.albums_btn.clone(),
view_switch: gallery_widgets.view_switch.clone(),
albums_loading: Cell::new(false),
album: RefCell::new(None),
back: gallery_widgets.back.clone(),
filters: gallery_widgets.filters.clone(),
kind: Cell::new(None),
tabs: gallery_widgets.tabs.clone(),
counts: Cell::new(None),
dates: gallery_widgets.dates.clone(),
date_ranges: RefCell::new(vec![None]),
range: Cell::new(None),
@@ -473,8 +429,6 @@ fn build_window(app: &adw::Application) {
width: Cell::new(0),
photo_tex: RefCell::new(HashMap::new()),
photo_tex_order: RefCell::new(VecDeque::new()),
photo_ratio: RefCell::new(HashMap::new()),
photo_ratio_dirty: Cell::new(false),
photo_nothumb: RefCell::new(HashSet::new()),
thumb_wanted: RefCell::new(HashMap::new()),
thumb_queue: RefCell::new(VecDeque::new()),
@@ -538,8 +492,6 @@ fn build_window(app: &adw::Application) {
key: RefCell::new(None),
},
});
ui.load_ratios();
wire_login(&ui);
wire_logout(&ui, &main_widgets.logout_button);
wire_settings(
@@ -558,6 +510,7 @@ fn build_window(app: &adw::Application) {
wire_details(&ui);
wire_search(&ui);
wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll);
wire_albums(&ui);
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
@@ -626,12 +579,10 @@ fn build_window(app: &adw::Application) {
glib::ControlFlow::Continue
});
let cell = RefCell::new(Some(source));
let ui_close = ui.clone();
window.connect_close_request(move |_| {
if let Some(id) = cell.borrow_mut().take() {
id.remove();
}
ui_close.save_ratios();
glib::Propagation::Proceed
});
@@ -646,7 +597,7 @@ const DESTINATIONS: [(&str, &str, &str); 9] = [
("shared", "Shared with me", "system-users-symbolic"),
("locations", "Locations", "drive-harddisk-symbolic"),
("devices", "Computers", "computer-symbolic"),
("gallery", "Photos", "image-x-generic-symbolic"),
("gallery", "Gallery", "image-x-generic-symbolic"),
("activity", "Activity", "document-open-recent-symbolic"),
("trash", "Trash", "user-trash-symbolic"),
("main", "Settings", "emblem-system-symbolic"),
@@ -749,7 +700,15 @@ fn reload_current_page(ui: &Rc<Ui>) {
let path = ui.browser.path.borrow().clone();
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
}
Some("gallery") => refresh_then(ui, RefreshScope::Photos, |ui| load_gallery(ui, false)),
// One scope covers the whole photos view, so Refresh reloads whichever of
// the two — album grid or timeline/album — is actually on screen.
Some("gallery") => refresh_then(ui, RefreshScope::Photos, |ui| {
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
load_albums(ui);
} else {
load_gallery(ui, false);
}
}),
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
Some("shared") => {
ui.shared.loaded_at.set(None);
@@ -1064,81 +1023,54 @@ fn human_bytes(bytes: u64) -> String {
mod tests {
use super::*;
/// A justified row must fill the content width exactly: tile widths plus the
/// gaps between them add up to the width, with nothing left ragged.
fn row_width(row: &[(i32, i32)]) -> i32 {
let gaps = TILE_GAP * (row.len().saturating_sub(1)) as i32;
row.iter().map(|(width, _)| *width).sum::<i32>() + gaps
/// The full width one row of `columns` tiles occupies, gaps included.
fn row_width(columns: usize, edge: i32) -> i32 {
edge * columns as i32 + TILE_GAP * (columns as i32 - 1)
}
#[test]
fn justified_rows_fill_the_width_exactly() {
let ratios = [
1.5, 0.75, 1.5, 1.33, 0.66, 1.5, 1.0, 2.2, 1.5, 0.8, 1.5, 1.6,
];
let rows = plan_rows(&ratios, 180.0, 1000.0);
fn the_grid_spans_the_content_width() {
// Whatever the width, the columns plus their gaps land on it (bar the
// few px integer division cannot divide), so no ragged right margin.
for width in [640, 900, 1000, 1440, 1920, 2560] {
let (columns, edge) = plan_grid(TILE_DEFAULT, width);
let used = row_width(columns, edge);
assert!(
rows.len() > 1,
"12 photos at 180px should need several rows"
used <= width && width - used < columns as i32,
"{columns} x {edge}px = {used}px does not span {width}px"
);
// Every row but the trailing one is justified.
for row in &rows[..rows.len() - 1] {
assert_eq!(row_width(row), 1000);
}
}
#[test]
fn every_photo_lands_in_exactly_one_row() {
let ratios: Vec<f64> = (0..37).map(|i| 0.5 + f64::from(i % 5) * 0.4).collect();
let rows = plan_rows(&ratios, 180.0, 1000.0);
let tiles: usize = rows.iter().map(Vec::len).sum();
assert_eq!(tiles, ratios.len());
}
#[test]
fn tiles_keep_their_aspect_ratio() {
let rows = plan_rows(&[1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5], 180.0, 1000.0);
// All but the last tile of a justified row are sized straight from the
// ratio, so w/h is the photo's own — no cropping needed to place it.
for row in &rows {
for (width, height) in &row[..row.len() - 1] {
let ratio = f64::from(*width) / f64::from(*height);
assert!((ratio - 1.5).abs() < 0.02, "{width}x{height} is not 3:2");
}
}
}
#[test]
fn trailing_row_is_not_stretched() {
// Two photos can't fill a 1000px row at 180px tall, so they must keep the
// target height rather than being blown up to a screen-wide row.
let rows = plan_rows(&[1.5, 1.5], 180.0, 1000.0);
assert_eq!(rows.len(), 1);
assert!(rows[0].iter().all(|(_, height)| *height == 180));
assert!(row_width(&rows[0]) < 1000);
}
#[test]
fn an_extreme_panorama_cannot_squash_its_row() {
// Unclamped, a 20:1 panorama would drag its whole row down to ~40px tall.
// Clamped at RATIO_MAX it stays a wide tile in a row of normal height.
let rows = plan_rows(&[20.0, 1.5, 1.5], 180.0, 1000.0);
let (width, height) = rows[0][0];
assert!(height > 120, "row squashed to {height}px");
assert!(width <= 1000, "tile overflows the row at {width}px");
assert_eq!(row_width(&rows[0]), 1000);
}
#[test]
fn narrow_widths_and_no_photos_are_survivable() {
assert!(plan_rows(&[], 180.0, 1000.0).is_empty());
// Width below the floor is clamped rather than producing zero-px tiles.
let rows = plan_rows(&[1.5, 1.5], 180.0, 0.0);
fn tiles_stay_near_the_target_size() {
// The column count rounds and the edge absorbs the remainder, so a tile
// is never more than one gap-and-a-bit away from what was asked for.
let (columns, edge) = plan_grid(TILE_DEFAULT, 1920);
assert!(columns >= 7, "1920px should hold several 220px tiles");
assert!(
rows.iter()
.flatten()
.all(|(width, height)| *width > 0 && *height > 0)
(edge - TILE_DEFAULT).abs() < TILE_DEFAULT / 3,
"{edge}px is not near the {TILE_DEFAULT}px target"
);
}
#[test]
fn zooming_in_widens_the_tiles_and_drops_columns() {
let (wide_columns, small_edge) = plan_grid(TILE_MIN, 1200);
let (few_columns, big_edge) = plan_grid(TILE_MAX, 1200);
assert!(wide_columns > few_columns);
assert!(big_edge > small_edge);
}
#[test]
fn a_window_narrower_than_one_tile_still_gets_a_column() {
// A single column at whatever fits, rather than zero columns (which would
// divide by zero) or a zero-px tile.
let (columns, edge) = plan_grid(TILE_DEFAULT, 40);
assert_eq!(columns, 1);
assert!(edge > 0);
let (columns, edge) = plan_grid(TILE_DEFAULT, 0);
assert_eq!(columns, 1);
assert!(edge > 0);
}
}
+265
View File
@@ -0,0 +1,265 @@
//! The Albums view of the Photos page: a grid of album covers, and the album
//! that opens when one is clicked.
//!
//! An open album reuses the gallery wholesale — same square grid, same
//! on-demand thumbnails, same lightbox — by parking the album in
//! [`GalleryState::album`] and letting [`load_gallery`] page
//! [`Request::AlbumPhotos`] instead of the timeline. Only the header and the
//! filter bar differ, because an album is served whole rather than filtered.
use crate::*;
/// Edge length in px of an album cover in the grid. Bigger than a timeline tile:
/// there are far fewer albums than photos, and the cover is the only thing
/// identifying one at a glance.
const COVER_EDGE: i32 = 200;
/// Show the Albums grid and (re)load it. Called when the Albums toggle goes on.
pub(crate) fn show_albums(ui: &Rc<Ui>) {
close_album(ui);
ui.gallery.content.set_visible_child_name("albums");
load_albums(ui);
}
/// Ask the daemon for the album listing and rebuild the grid.
pub(crate) fn load_albums(ui: &Rc<Ui>) {
if ui.gallery.albums_loading.get() {
return;
}
ui.gallery.albums_loading.set(true);
albums_status(
ui,
"view-grid-symbolic",
"Loading albums…",
"Reading your Proton Drive albums.",
);
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.gallery.albums_loading.set(false);
match result {
Ok(Ok(Response::Albums {
available: false, ..
})) => albums_status(
&ui,
"image-missing-symbolic",
"No photo library",
"This Proton account doesn't have Photos enabled.",
),
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
&ui,
"view-grid-symbolic",
"No albums",
"Albums you create in Proton Photos appear here.",
),
Ok(Ok(Response::Albums { items, .. })) => {
fill_albums(&ui, &items);
ui.gallery.albums_stack.set_visible_child_name("grid");
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't load albums", &message, kind);
albums_status(
&ui,
"dialog-warning-symbolic",
"Couldn't load albums",
&message,
);
}
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => albums_status(
&ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service didn't respond.",
),
}
});
}
/// Replace the grid's cards with `albums`, in the order the daemon gave them
/// (newest activity first).
fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
while let Some(child) = ui.gallery.albums.first_child() {
ui.gallery.albums.remove(&child);
}
for album in albums {
ui.gallery.albums.append(&album_card(ui, album));
}
// The covers are ordinary photos as far as the daemon is concerned, so the
// batch that fills them is the gallery's own.
schedule_thumbs(ui);
}
/// One album as a clickable card: its cover, its name, and how many photos it
/// holds. An album shared with us says so — it lives on someone else's volume,
/// and that is worth knowing before opening it.
fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
let picture = gtk4::Picture::builder()
// Fills and crops the cover square, like a timeline tile.
.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("view-grid-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(&album.name)
.halign(gtk4::Align::Start)
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
name.add_css_class("heading");
let count = gtk4::Label::builder()
.label(album_subtitle(album))
.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(&album.name));
if let Some(uid) = album.cover_uid.clone() {
want_cover(ui, uid, &picture);
}
let ui_open = ui.clone();
let album = album.clone();
button.connect_clicked(move |_| open_album(&ui_open, album.clone()));
button
}
/// "12 photos", plus where the album came from when it isn't ours.
fn album_subtitle(album: &AlbumInfo) -> String {
let photos = match album.photo_count {
1 => "1 photo".to_string(),
n => format!("{n} photos"),
};
if album.shared {
format!("{photos} · shared with me")
} else {
photos
}
}
/// 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: &gtk4::Picture) {
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
picture.set_paintable(Some(texture));
return;
}
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
return;
}
ui.gallery
.thumb_wanted
.borrow_mut()
.insert(uid.clone(), picture.clone());
let mut queue = ui.gallery.thumb_queue.borrow_mut();
if !queue.contains(&uid) {
queue.push_back(uid);
}
}
/// Open one album in the gallery: the timeline view, paged from the album
/// instead, with the filters that don't apply to it out of the way.
pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
ui.gallery.title.set_label(&album.name);
ui.gallery.subtitle.set_label(&album_subtitle(&album));
ui.gallery.subtitle.set_visible(true);
*ui.gallery.album.borrow_mut() = Some(album);
// An album page carries no kind or date filter, and Upload targets the
// timeline rather than an album — hide those rather than offer controls that
// would silently do something else. The Photos/Albums switcher goes too: an
// album is a level below it, and back is the way 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);
// A stale kind/date filter would otherwise be sent with the album request.
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 album, restoring the timeline's header and filters. A no-op
/// when no album is open, so callers can use it as "make sure we're not in one".
pub(crate) fn close_album(ui: &Rc<Ui>) {
if ui.gallery.album.borrow_mut().take().is_none() {
return;
}
ui.gallery.title.set_label("Gallery");
ui.gallery.filters.set_visible(true);
ui.gallery.view_switch.set_visible(true);
ui.gallery.upload.set_visible(true);
ui.gallery.back.set_visible(false);
}
/// Show the album grid's own status page (loading, empty, or an error).
fn albums_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
ui.gallery.albums_status.set_icon_name(Some(icon));
ui.gallery.albums_status.set_title(title);
ui.gallery.albums_status.set_description(Some(description));
ui.gallery.albums_stack.set_visible_child_name("status");
}
/// Wire the Photos/Albums switcher and the back button. The switcher swaps the
/// whole content area; back leaves an open album for the grid it came from.
pub(crate) fn wire_albums(ui: &Rc<Ui>) {
let ui_albums = ui.clone();
ui.gallery.albums_btn.clone().connect_toggled(move |btn| {
if btn.is_active() {
show_albums(&ui_albums);
}
});
// Only the button being switched *on* acts: the group fires `toggled` for
// the one going off too, and acting on both would load twice.
let ui_photos = ui.clone();
ui.gallery.photos_btn.clone().connect_toggled(move |btn| {
if !btn.is_active() {
return;
}
// The timeline is reloaded rather than restored, because an open album
// left the model holding its own photos.
close_album(&ui_photos);
ui_photos.gallery.content.set_visible_child_name("timeline");
load_gallery(&ui_photos, false);
});
let ui_back = ui.clone();
ui.gallery.back.clone().connect_clicked(move |_| {
show_albums(&ui_back);
});
}
+1
View File
@@ -1,4 +1,5 @@
pub(crate) mod activity;
pub(crate) mod albums;
pub(crate) mod browser;
pub(crate) mod devices;
pub(crate) mod locations;
+261 -188
View File
@@ -8,24 +8,53 @@ pub(crate) struct GalleryState {
/// The day sections rendered by the Photos ListView, rebuilt from
/// [`Self::model`] by [`repaint_gallery`].
pub(crate) groups: gio::ListStore,
/// Target row height in px, retuned by Ctrl+scroll / Ctrl+± (see
/// [`zoom_gallery`]). Each tile keeps its own aspect ratio; rows are
/// justified to the content width around this height.
/// Target tile edge in px, retuned by Ctrl+scroll / Ctrl+± (see
/// [`zoom_gallery`]). The grid fits as many square tiles of about this size
/// as the content width holds.
pub(crate) tile: Cell<i32>,
/// Swaps the Photos content area between the timeline and its status page.
/// Swaps the Photos content area between the timeline, its status page, and
/// the Albums grid.
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) more: gtk4::Button,
pub(crate) upload: gtk4::Button,
/// "Photos", or the album's name while one is open.
pub(crate) title: gtk4::Label,
/// "1,204 photos" under the page title.
pub(crate) subtitle: gtk4::Label,
/// The album grid, and the stack that swaps it for its own status page.
pub(crate) albums: gtk4::FlowBox,
pub(crate) albums_stack: gtk4::Stack,
pub(crate) albums_status: adw::StatusPage,
/// The Photos/Albums switcher, and the box holding it — hidden while an
/// album is open, where the back button leads instead.
pub(crate) photos_btn: gtk4::ToggleButton,
pub(crate) albums_btn: gtk4::ToggleButton,
pub(crate) view_switch: gtk4::Box,
/// 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 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.
pub(crate) album: RefCell<Option<AlbumInfo>>,
/// Leaves an open album for the grid it was opened from. Visible only while
/// an album is open.
pub(crate) back: gtk4::Button,
/// The kind toggles and the date jump, hidden while an album is open — an
/// album page is served whole, not filtered.
pub(crate) filters: gtk4::Box,
/// Which tab (Photos / Videos / Raw) the timeline is filtered to, or `None`
/// for All. Read by [`load_gallery`] and set by the filter toggles.
pub(crate) kind: Cell<Option<PhotoKind>>,
/// The filter toggles, index-aligned with [`kind_for_tab`], kept so their
/// labels can carry live per-kind counts.
pub(crate) tabs: [gtk4::ToggleButton; 4],
/// Whole-timeline `(photos, videos, raw)` counts from the last page that
/// carried them, so the subtitle can say how big the library *is* rather
/// than how much of it has been paged in.
pub(crate) counts: Cell<Option<(usize, usize, usize)>>,
/// The date-jump dropdown ("All dates" then a month per timeline entry), and
/// the `[from, to)` window each of its rows selects (index-aligned; `None` is
/// "All dates"). Selecting a row loads that month via [`load_gallery`].
@@ -40,21 +69,14 @@ pub(crate) struct GalleryState {
/// True while a timeline page is in flight, so the scroll-to-the-end paging
/// can't fire a second request for the page already coming.
pub(crate) loading: Cell<bool>,
/// Content width the sections are currently justified to. Updated when the
/// ListView is resized, which re-justifies the visible sections.
/// Content width the grid is currently laid out to. Updated when the
/// ListView is resized, which re-flows the visible sections.
pub(crate) width: Cell<i32>,
/// Decoded thumbnails by photo uid, with the insertion order that evicts the
/// oldest past [`TEXTURE_CACHE_MAX`]. Scrolling back over a day therefore
/// repaints from memory instead of re-decoding from disk.
pub(crate) photo_tex: RefCell<HashMap<String, gtk4::gdk::Texture>>,
pub(crate) photo_tex_order: RefCell<VecDeque<String>>,
/// Aspect ratio (w/h) per uid, learned when a thumbnail is decoded and
/// persisted to disk, so a relaunch justifies its rows correctly on the first
/// frame instead of reflowing as thumbnails arrive.
pub(crate) photo_ratio: RefCell<HashMap<String, f64>>,
/// Ratios learned since the last save, so the ratio file is only rewritten
/// when it actually changed.
pub(crate) photo_ratio_dirty: Cell<bool>,
/// Photos the daemon reported as having no thumbnail at all, so a tile that
/// can never be filled isn't requested again on every scroll past it.
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
@@ -75,46 +97,33 @@ pub(crate) struct GalleryState {
pub(crate) decode_queue: RefCell<VecDeque<(String, String)>>,
pub(crate) decode_idle: Cell<bool>,
/// Pending debounce timers for the thumbnail queue flush and the section
/// re-justify, replaced on each new trigger so only the last one fires.
/// re-flow, replaced on each new trigger so only the last one fires.
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
/// The day sections currently realised by the ListView, by their index in
/// [`Self::groups`]. A learned ratio or a resize re-justifies these
/// in place — rebuilding the ListStore instead would reset the scroll
/// position out from under the user.
/// [`Self::groups`]. A resize or a zoom step re-flows these in place —
/// rebuilding the ListStore instead would reset the scroll position out from
/// under the user.
pub(crate) bound: RefCell<HashMap<u32, gtk4::Box>>,
}
/// How many photos to pull per [`Request::PhotosTimeline`] page.
pub(crate) const PHOTOS_PAGE: usize = 60;
/// Gallery row height in px: the zoom range, its default, and the step one
/// Ctrl+scroll notch (or Ctrl+±) moves it by. Rows are justified to the full
/// content width, so this is the *target* height a row lands near, not an exact
/// tile size (see [`justify_rows`]).
/// Gallery tile edge in px: the zoom range, its default, and the step one
/// Ctrl+scroll notch (or Ctrl+±) moves it by. The grid divides the content width
/// evenly, so this is the *target* a tile lands near rather than its exact size
/// (see [`plan_grid`]).
pub(crate) const TILE_MIN: i32 = 90;
pub(crate) const TILE_MAX: i32 = 340;
pub(crate) const TILE_DEFAULT: i32 = 220;
pub(crate) const TILE_DEFAULT: i32 = 180;
pub(crate) const TILE_STEP: i32 = 30;
/// Gap between tiles, horizontally and vertically.
pub(crate) const TILE_GAP: i32 = 6;
/// Aspect ratio (w/h) assumed for a photo whose thumbnail hasn't been decoded
/// yet, so a tile can be laid out before its image exists. Landscape 3:2, the
/// commonest camera/phone ratio; once the thumbnail lands the real ratio
/// replaces it and the section re-justifies in place.
pub(crate) const RATIO_UNKNOWN: f64 = 1.5;
/// Aspect ratios are clamped to this range before a row is packed: one absurd
/// panorama, or a sliver of a portrait, must not be able to squash the row it
/// lands in down to nothing. The tile still shows the whole image.
pub(crate) const RATIO_MIN: f64 = 0.4;
pub(crate) const RATIO_MAX: f64 = 3.5;
pub(crate) const TILE_GAP: i32 = 8;
/// How many thumbnails one on-demand [`Request::PhotoThumbs`] batch asks for.
/// Small, so the first tiles on screen fill in quickly rather than the whole
@@ -134,7 +143,7 @@ pub(crate) const THUMB_RETRY: Duration = Duration::from_secs(4);
/// this caps the gallery's footprint while covering several screens of scroll.
pub(crate) const TEXTURE_CACHE_MAX: usize = 600;
/// Pause after a resize/zoom before the visible sections are re-justified.
/// Pause after a resize/zoom before the visible sections are re-flowed.
pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80);
/// One day-section of the photos timeline: a heading plus the photos captured
@@ -153,9 +162,11 @@ pub(crate) struct GalleryWidgets {
pub(crate) model: gio::ListStore,
/// Day sections rendered by the ListView, derived from `model`.
pub(crate) groups: gio::ListStore,
/// Swaps between the timeline and the empty/loading/error status page.
/// Swaps between the timeline, the empty/loading/error status page, and the
/// Albums grid.
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) title: gtk4::Label,
pub(crate) subtitle: gtk4::Label,
pub(crate) more: gtk4::Button,
pub(crate) list: gtk4::ListView,
@@ -163,6 +174,17 @@ pub(crate) struct GalleryWidgets {
pub(crate) retry: gtk4::Button,
pub(crate) upload: gtk4::Button,
pub(crate) refresh: gtk4::Button,
/// The Albums grid, its own status page and the stack between them, plus the
/// Photos/Albums switcher and the back button out of an album.
pub(crate) albums: gtk4::FlowBox,
pub(crate) albums_stack: gtk4::Stack,
pub(crate) albums_status: adw::StatusPage,
pub(crate) photos_btn: gtk4::ToggleButton,
pub(crate) albums_btn: gtk4::ToggleButton,
pub(crate) view_switch: gtk4::Box,
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,
/// The All / Photos / Videos / Raw filter toggles, in that order (index maps
/// to [`kind_for_tab`]).
pub(crate) tabs: [gtk4::ToggleButton; 4],
@@ -217,7 +239,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
more.add_css_class("pill");
more.set_visible(false);
// Horizontal scrolling is never wanted: rows are justified to the viewport
// Horizontal scrolling is never wanted: the grid is sized to the viewport
// width, and a stray hscrollbar would fight the layout.
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
@@ -226,7 +248,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.build();
let title_label = gtk4::Label::builder()
.label("Photos")
.label("Gallery")
.halign(gtk4::Align::Start)
.build();
title_label.add_css_class("title-2");
@@ -243,6 +265,17 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
titles.append(&title_label);
titles.append(&subtitle);
// Leaves an open album for the grid it came from. Only an open album shows
// it — the Albums toggle is what leaves the grid itself.
let back = gtk4::Button::builder()
.icon_name("go-previous-symbolic")
.tooltip_text("Back to albums")
.valign(gtk4::Align::Center)
.visible(false)
.build();
back.add_css_class("flat");
back.add_css_class("circular");
let upload = gtk4::Button::builder()
.label("Upload")
.icon_name("list-add-symbolic")
@@ -253,6 +286,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
let refresh = refresh_button();
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header_box.append(&back);
header_box.append(&titles);
header_box.append(&refresh);
header_box.append(&upload);
@@ -287,23 +321,73 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
dates.add_css_class("pill");
dates.set_tooltip_text(Some("Jump to a month"));
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
filter_bar.append(&tab_group);
// The kind toggles and the date jump travel together: they filter the
// timeline, and neither applies to the album grid or to an open album.
let filters = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
filters.set_hexpand(true);
filters.append(&tab_group);
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
spacer.set_hexpand(true);
filter_bar.append(&spacer);
filter_bar.append(&dates);
filters.append(&spacer);
filters.append(&dates);
// The page's two views, as one segmented control under the title: the
// timeline, and the albums. This is navigation, not a filter — which is why
// it sits above the filter row rather than beside the kind toggles.
let photos_btn = gtk4::ToggleButton::builder()
.label("Photos")
.active(true)
.build();
let albums_btn = gtk4::ToggleButton::builder().label("Albums").build();
albums_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] {
btn.add_css_class("pill");
view_switch.append(btn);
}
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
filter_bar.append(&filters);
// The timeline (plus its pager) or the status page, never both.
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
timeline.append(&scroll);
timeline.append(&more);
// The album grid: cover-first cards that flow to the width they are given.
let albums = 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 albums_scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&albums)
.build();
let albums_status = adw::StatusPage::builder()
.icon_name("view-grid-symbolic")
.vexpand(true)
.build();
albums_status.add_css_class("compact");
let albums_stack = gtk4::Stack::new();
albums_stack.set_vexpand(true);
albums_stack.add_named(&albums_scroll, Some("grid"));
albums_stack.add_named(&albums_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_stack, Some("albums"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
@@ -311,6 +395,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header_box);
inner.append(&view_switch);
inner.append(&filter_bar);
inner.append(&content);
@@ -321,6 +406,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
groups,
content,
status,
title: title_label,
subtitle,
more,
list,
@@ -330,6 +416,14 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
refresh,
tabs,
dates,
albums,
albums_stack,
albums_status,
photos_btn,
albums_btn,
view_switch,
back,
filters,
},
)
}
@@ -416,16 +510,31 @@ pub(crate) fn refresh_photo_months(ui: &Rc<Ui>) {
/// behind it is disabled — you can't filter to an empty set — but the currently
/// selected one stays clickable so you can always switch back off it.
pub(crate) fn update_gallery_tabs(ui: &Rc<Ui>, counts: (usize, usize, usize)) {
ui.gallery.counts.set(Some(counts));
let (photos, videos, raw) = counts;
let totals = [photos + videos + raw, photos, videos, raw];
for (index, tab) in ui.gallery.tabs.iter().enumerate() {
let name = ["All", "Photos", "Videos", "Raw"][index];
let n = totals[index];
tab.set_label(&format!("{name} {n}"));
tab.set_label(&format!("{name} {}", thousands(n)));
tab.set_sensitive(n > 0 || tab.is_active());
}
}
/// `1422` as `1,422`. Six-figure libraries are ordinary, and an unseparated run
/// of digits in a tab label is unreadable at a glance.
pub(crate) fn thousands(n: usize) -> String {
let digits = n.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
/// Wire the gallery: install the section factory, the zoom gestures, the pager
/// and the upload button. Activating a thumbnail downloads the photo and opens it
/// in the in-app lightbox.
@@ -485,8 +594,8 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::Sc
});
list.set_factory(Some(&factory));
// Rows are justified to the content width, so a resize re-justifies whatever
// is on screen (offscreen sections pick the new width up when they bind).
// The grid divides the content width, so a resize re-flows whatever is on
// screen (offscreen sections pick the new width up when they bind).
let ui_width = ui.clone();
list.connect_notify_local(Some("width"), move |list, _| {
let width = list.width();
@@ -673,114 +782,55 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::Sc
});
}
/// One tile in a justified row: the photo, and the pixel size it was justified to.
/// One tile of the grid: the photo, and the square edge it was sized to.
pub(crate) struct Tile {
pub(crate) photo: PhotoItem,
pub(crate) width: i32,
pub(crate) height: i32,
pub(crate) edge: i32,
}
/// Break one day's photos into rows that each fill `width` exactly, at heights
/// near [`Ui::gallery_tile`] — the layout every modern photo gallery uses, and
/// the reason nothing here has to be cropped: each tile keeps its own aspect
/// ratio, and it is the row *height* that flexes to make the widths add up.
/// Break one day's photos into rows of equal square tiles that span `width` —
/// the layout a phone gallery uses, and the reason a day holding two photos
/// looks like every other day rather than like a mistake.
///
/// Greedy, one pass: keep adding photos to the row: the more photos share it, the
/// shorter it has to be to fit. The moment that height drops to the target, the
/// row is as full as it should be and is emitted. The trailing row keeps the
/// target height instead of being stretched, so a day with three photos doesn't
/// blow them up to fill a screen-wide row.
///
/// Photos whose thumbnail hasn't been decoded yet are laid out at
/// [`RATIO_UNKNOWN`]; [`Ui::store_texture`] reports the real ratio when it lands
/// and the section is re-justified in place.
pub(crate) fn justify_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
let ratios: Vec<f64> = photos.iter().map(|photo| ui.ratio(&photo.uid)).collect();
let target = f64::from(ui.gallery.tile.get());
let plan = plan_rows(&ratios, target, f64::from(width));
let mut photos = photos.iter();
plan.into_iter()
/// Each tile is centre-cropped to its square (the full frame is one click away
/// in the lightbox), so nothing here depends on knowing a photo's aspect ratio
/// and a row never has to be re-flowed when a thumbnail finally lands.
pub(crate) fn grid_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
let (columns, edge) = plan_grid(ui.gallery.tile.get(), width);
photos
.chunks(columns)
.map(|row| {
row.into_iter()
.filter_map(|(width, height)| {
photos.next().map(|photo| Tile {
row.iter()
.map(|photo| Tile {
photo: photo.clone(),
width,
height,
})
edge,
})
.collect()
})
.collect()
}
/// The row-packing math behind [`justify_rows`], over nothing but aspect ratios:
/// takes each photo's ratio (w/h) in order and returns the pixel `(width,
/// height)` of every tile, grouped into rows.
pub(crate) fn plan_rows(ratios: &[f64], target: f64, width: f64) -> Vec<Vec<(i32, i32)>> {
let avail = width.max(f64::from(TILE_MIN));
let gap = f64::from(TILE_GAP);
// The height a row of `n` photos with `sum_ratio` total ratio must take for
// its widths (plus gaps) to add up to exactly `avail`.
let row_height = |sum_ratio: f64, n: usize| {
let gaps = gap * (n.saturating_sub(1)) as f64;
(avail - gaps) / sum_ratio
};
let mut rows: Vec<Vec<(i32, i32)>> = Vec::new();
let mut row: Vec<f64> = Vec::new();
let mut sum_ratio = 0.0;
for ratio in ratios {
let ratio = ratio.clamp(RATIO_MIN, RATIO_MAX);
row.push(ratio);
sum_ratio += ratio;
let height = row_height(sum_ratio, row.len());
if height <= target {
rows.push(size_row(&row, height, avail, true));
row.clear();
sum_ratio = 0.0;
}
}
if !row.is_empty() {
let height = row_height(sum_ratio, row.len()).min(target);
rows.push(size_row(&row, height, avail, false));
}
rows
/// The grid math: how many columns fit in `width` at roughly `target` px per
/// tile, and the exact square edge that divides the width between them.
///
/// The column count is what rounds — the edge then absorbs the remainder, so the
/// grid spans the full width at every window size instead of leaving a ragged
/// margin. Never fewer than one column, however narrow the window gets.
pub(crate) fn plan_grid(target: i32, width: i32) -> (usize, i32) {
let avail = width.max(TILE_MIN);
let target = target.clamp(TILE_MIN, TILE_MAX);
// A row of n tiles occupies n*edge + (n-1)*gap, so n tiles of the target size
// fit while n*(target + gap) - gap <= avail.
let columns = ((avail + TILE_GAP) / (target + TILE_GAP)).max(1) as usize;
let gaps = TILE_GAP * (columns as i32 - 1);
let edge = ((avail - gaps) / columns as i32).max(1);
(columns, edge)
}
/// Size one row's tiles at `height`. A `justified` row is nudged so its widths
/// plus gaps hit `avail` exactly — rounding each width independently leaves a
/// few px of ragged right edge, so the last tile absorbs the remainder.
pub(crate) fn size_row(
ratios: &[f64],
height: f64,
avail: f64,
justified: bool,
) -> Vec<(i32, i32)> {
let height = height.round().max(1.0);
let mut sizes: Vec<(i32, i32)> = ratios
.iter()
.map(|ratio| ((ratio * height).round().max(1.0) as i32, height as i32))
.collect();
if justified {
let gaps = TILE_GAP * (sizes.len().saturating_sub(1)) as i32;
let used: i32 = sizes.iter().rev().skip(1).map(|(width, _)| *width).sum();
if let Some(last) = sizes.last_mut() {
last.0 = (avail as i32 - gaps - used).max(1);
}
}
sizes
}
/// (Re)build a bound day-section's tiles: justify this day's photos to the
/// (Re)build a bound day-section's tiles: lay this day's photos out to the
/// current content width and hand each tile whatever thumbnail is already in
/// memory, queueing the rest. Replaces the section's rows in place, leaving the
/// heading — so a re-justify never touches the ListView's model or scroll.
/// heading — so a re-flow never touches the ListView's model or scroll.
pub(crate) fn fill_section(ui: &Rc<Ui>, section: &gtk4::Box, photos: &[PhotoItem]) {
let Some(heading) = section.first_child() else {
return;
@@ -791,8 +841,11 @@ pub(crate) fn fill_section(ui: &Rc<Ui>, section: &gtk4::Box, photos: &[PhotoItem
let width = gallery_width(ui);
let rows = gtk4::Box::new(gtk4::Orientation::Vertical, TILE_GAP);
for row in justify_rows(ui, photos, width) {
for row in grid_rows(ui, photos, width) {
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP);
// A short last row stays left-aligned at the same tile size rather than
// stretching: an even grid is the whole point of the square layout.
row_box.set_halign(gtk4::Align::Start);
for tile in row {
row_box.append(&photo_tile(ui, tile));
}
@@ -802,8 +855,8 @@ pub(crate) fn fill_section(ui: &Rc<Ui>, section: &gtk4::Box, photos: &[PhotoItem
schedule_thumbs(ui);
}
/// The width justified rows are laid out to: the ListView's own width, less a
/// couple of px so a rounding error can't push a row into a horizontal overflow.
/// The width the grid is laid out to: the ListView's own width, less a couple of
/// px so a rounding error can't push a row into a horizontal overflow.
/// Falls back to a sane guess before the first allocation.
pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
match ui.gallery.width.get() {
@@ -822,12 +875,17 @@ pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
/// one keeps an image glyph instead of an empty rectangle.
pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
let picture = gtk4::Picture::builder()
// The tile is exactly the thumbnail's own aspect ratio, so Cover scales
// it and crops nothing; it only bites during the brief window where the
// ratio is still a guess, and the re-justify then fixes the tile.
// Cover fills the square and crops the overflow, so a portrait photo
// sits flush in its tile instead of floating in letterbox bars. The
// expands are what make the picture take the whole overlay: without
// them it is allocated its natural size and the crop never happens.
.content_fit(gtk4::ContentFit::Cover)
.can_shrink(true)
.hexpand(true)
.vexpand(true)
.build();
// Zoomed on hover by the stylesheet; the tile clips the overflow.
picture.add_css_class("photo-thumb");
let placeholder = gtk4::Image::builder()
.icon_name("image-x-generic-symbolic")
@@ -871,8 +929,8 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
let button = gtk4::Button::builder()
.child(&overlay)
.width_request(tile.width)
.height_request(tile.height)
.width_request(tile.edge)
.height_request(tile.edge)
.tooltip_text(format_capture_time(tile.photo.capture_time))
.build();
button.add_css_class("photo-tile");
@@ -1052,7 +1110,6 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
let mut queue = ui.gallery.decode_queue.borrow_mut();
(0..4).filter_map(|_| queue.pop_front()).collect()
};
let mut relayout = false;
for (uid, path) in batch {
let texture = match gtk4::gdk::Texture::from_filename(&path) {
Ok(texture) => texture,
@@ -1062,28 +1119,23 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
continue;
}
};
// A ratio we hadn't seen means the tile was sized against a guess.
relayout |= ui.store_texture(&uid, texture.clone());
ui.store_texture(&uid, texture.clone());
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
picture.set_paintable(Some(&texture));
}
}
if relayout {
schedule_relayout(&ui);
}
if ui.gallery.decode_queue.borrow().is_empty() {
ui.gallery.decode_idle.set(false);
ui.save_ratios();
return glib::ControlFlow::Break;
}
glib::ControlFlow::Continue
});
}
/// Re-justify the sections on screen shortly. Debounced, because the triggers
/// (a window resize, a zoom step, a burst of decoded thumbnails) all arrive in
/// floods and only the final state matters.
/// Re-flow the sections on screen shortly. Debounced, because the triggers (a
/// window resize, a zoom step) arrive in floods and only the final state
/// matters.
pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
if let Some(id) = ui.gallery.relayout_source.borrow_mut().take() {
id.remove();
@@ -1097,9 +1149,8 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
}
/// Rebuild the tiles of the day sections currently on screen, at the current
/// width, zoom and set of known aspect ratios. Sections that are *not* realised
/// need no work: they justify themselves against the current state when the
/// ListView binds them.
/// width and zoom. Sections that are *not* realised need no work: they lay
/// themselves out against the current state when the ListView binds them.
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
let bound: Vec<(u32, gtk4::Box)> = ui
.gallery
@@ -1120,12 +1171,12 @@ pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
}
}
/// Step the row height by `delta` px and re-justify, clamped to the zoom range.
/// Step the tile size by `delta` px and re-flow, clamped to the zoom range.
pub(crate) fn zoom_gallery(ui: &Rc<Ui>, delta: i32) {
set_gallery_tile(ui, ui.gallery.tile.get() + delta);
}
/// Set the row height (clamped) and re-justify the visible sections at it.
/// Set the tile size (clamped) and re-flow the visible sections at it.
pub(crate) fn set_gallery_tile(ui: &Rc<Ui>, tile: i32) {
let tile = tile.clamp(TILE_MIN, TILE_MAX);
if tile == ui.gallery.tile.get() {
@@ -1175,17 +1226,35 @@ pub(crate) fn repaint_gallery(ui: &Rc<Ui>) {
store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]);
}
let count = ui.gallery.model.n_items();
ui.gallery.subtitle.set_visible(count > 0);
let loaded = ui.gallery.model.n_items() as usize;
ui.gallery.subtitle.set_visible(loaded > 0);
// An album counts what the server says 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() {
return;
}
// The noun tracks the active filter, so a Videos tab doesn't count "photos".
let (one, many) = match ui.gallery.kind.get() {
let kind = ui.gallery.kind.get();
let (one, many) = match kind {
Some(PhotoKind::Video) => ("video", "videos"),
Some(PhotoKind::Raw) => ("raw photo", "raw photos"),
_ => ("photo", "photos"),
};
ui.gallery.subtitle.set_label(&match count {
// The whole library for this filter, not the page count — the subtitle sits
// next to tabs carrying the same totals, and the two disagreeing reads as a
// bug. A date jump is the exception: there the window is the subject.
let total = match (ui.gallery.range.get(), ui.gallery.counts.get()) {
(None, Some((photos, videos, raw))) => match kind {
Some(PhotoKind::Photo) => photos,
Some(PhotoKind::Video) => videos,
Some(PhotoKind::Raw) => raw,
None => photos + videos + raw,
},
_ => loaded,
};
ui.gallery.subtitle.set_label(&match total {
1 => format!("1 {one}"),
n => format!("{n} {many}"),
n => format!("{} {many}", thousands(n)),
});
}
@@ -1272,6 +1341,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
if ui.gallery.loading.get() {
return;
}
// An open album 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());
if !append {
// Fresh load: clear the timeline and show Loading until the first page lands.
ui.gallery.model.remove_all();
@@ -1284,8 +1356,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
);
// Rebuild the date jump for the current kind, but only for a full-span
// load — a jump *to* a month sets a range and reloads, and refreshing the
// dropdown then would fight the selection the user just made.
if ui.gallery.range.get().is_none() {
// 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() {
refresh_photo_months(ui);
}
}
@@ -1294,15 +1367,20 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
ui.gallery.more.set_sensitive(false);
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::PhotosTimeline {
let request = match album {
Some(uid) => Request::AlbumPhotos {
uid,
offset,
limit: PHOTOS_PAGE,
},
None => Request::PhotosTimeline {
offset,
limit: PHOTOS_PAGE,
kind: ui.gallery.kind.get(),
range: ui.gallery.range.get(),
},
);
};
let rx = spawn_request(ui.dirs.control_socket(), request);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
@@ -1329,34 +1407,29 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
if let Some(counts) = counts {
update_gallery_tabs(&ui, counts);
}
// Take the daemon's word on ratios and thumbnail verdicts before
// anything is laid out: it persists both, so the very first frame
// justifies its rows correctly instead of guessing RATIO_UNKNOWN
// and reflowing as the images land.
// Take the daemon's word on which photos can never have a
// thumbnail, so their tiles show a placeholder from the first
// frame instead of queueing a request that can only fail.
{
let mut ratios = ui.gallery.photo_ratio.borrow_mut();
let mut nothumb = ui.gallery.photo_nothumb.borrow_mut();
for item in &items {
if let Some(ratio) = item.ratio {
ratios.insert(item.uid.clone(), ratio);
}
if item.no_thumb {
for item in items.iter().filter(|item| item.no_thumb) {
nothumb.insert(item.uid.clone());
}
}
}
for item in &items {
ui.gallery.model.append(&BoxedAnyObject::new(item.clone()));
}
repaint_gallery(&ui);
if ui.gallery.model.n_items() == 0 {
gallery_status(
&ui,
"image-x-generic-symbolic",
let (title, description) = if ui.gallery.album.borrow().is_some() {
("Empty album", "This album has no photos in it.")
} else {
(
"No photos yet",
"Photos you upload to Proton Drive appear here.",
false,
);
)
};
gallery_status(&ui, "image-x-generic-symbolic", title, description, false);
return;
}
ui.gallery.content.set_visible_child_name("timeline");
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux
pkgver=1.2.1
pkgver=1.3.0
pkgrel=1
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
arch=('x86_64')