From 2336ad9e87abb4a75561a4f6191dec9ee4ccf40b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 31 Jul 2026 20:31:25 +0200 Subject: [PATCH] added album to photos, renamed gallery view --- Cargo.lock | 16 +- Cargo.toml | 6 +- README.md | 1 + crates/pdfs-cli/src/main.rs | 87 ++++- crates/pdfs-core/src/control.rs | 44 +++ crates/pdfs-core/src/db/albums.rs | 238 ++++++++++++ crates/pdfs-core/src/db/migrations.rs | 41 ++- crates/pdfs-core/src/db/mod.rs | 2 + crates/pdfs-core/src/db/state.rs | 22 ++ crates/pdfs-core/src/db/tests.rs | 145 +++++++- crates/pdfs-fuse/src/albums.rs | 245 +++++++++++++ crates/pdfs-fuse/src/background.rs | 2 + crates/pdfs-fuse/src/control.rs | 28 ++ crates/pdfs-fuse/src/drain.rs | 4 + crates/pdfs-fuse/src/lib.rs | 15 + crates/pdfs-fuse/src/mount.rs | 1 + crates/pdfs-fuse/src/photos.rs | 27 +- crates/pdfs-fuse/src/state.rs | 2 + crates/pdfs-fuse/src/sweep.rs | 2 + crates/pdfs-fuse/src/virtual.rs | 3 + crates/pdfs-gui/src/app/main.rs | 232 +++++------- crates/pdfs-gui/src/app/pages/albums.rs | 265 ++++++++++++++ crates/pdfs-gui/src/app/pages/mod.rs | 1 + crates/pdfs-gui/src/app/pages/photos.rs | 457 ++++++++++++++---------- packaging/PKGBUILD | 2 +- 25 files changed, 1510 insertions(+), 378 deletions(-) create mode 100644 crates/pdfs-core/src/db/albums.rs create mode 100644 crates/pdfs-fuse/src/albums.rs create mode 100644 crates/pdfs-gui/src/app/pages/albums.rs diff --git a/Cargo.lock b/Cargo.lock index f8704e0..f3371c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 26f3a33..b98a4ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 "] 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" } diff --git a/README.md b/README.md index ada227f..eebc825 100644 --- a/README.md +++ b/README.md @@ -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 `. 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. diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index ff1d6b7..70a1cb3 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -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,24 +1500,52 @@ 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. - let uids: Vec = items.iter().map(|p| p.uid.clone()).collect(); - let thumbs: HashMap> = - match control_request(CtlRequest::PhotoThumbs { uids })? { - CtlResponse::Thumbs { items } => { - items.into_iter().map(|t| (t.uid, t.path)).collect() - } - _ => HashMap::new(), + 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) -> Result<()> { + let uids: Vec = items.iter().map(|p| p.uid.clone()).collect(); + let thumbs: HashMap> = + match control_request(CtlRequest::PhotoThumbs { uids })? { + CtlResponse::Thumbs { items } => items.into_iter().map(|t| (t.uid, t.path)).collect(), + _ => HashMap::new(), + }; + for p in items { + let thumb = thumbs + .get(&p.uid) + .and_then(|p| p.as_deref()) + .or(p.thumb_path.as_deref()) + .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 { + "" }; - for p in items { - let thumb = thumbs - .get(&p.uid) - .and_then(|p| p.as_deref()) - .or(p.thumb_path.as_deref()) - .unwrap_or("(no thumbnail)"); - println!("{} {} {thumb}", p.capture_time, p.uid); + println!( + "{:>6} {} {}{shared}", + album.photo_count, album.uid, album.name + ); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), @@ -1511,6 +1554,16 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> { 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:?}"), + } + Ok(()) +} + fn cmd_open_photo(uid: String) -> Result<()> { match control_request(CtlRequest::OpenPhoto { uid })? { CtlResponse::FilePath { path } => println!("{path}"), diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index a3a28fd..522f030 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -71,6 +71,20 @@ pub enum Request { #[serde(default)] kind: Option, }, + /// 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, + /// 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, + /// 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, + }, /// The months the timeline spans (reply to [`Request::PhotoMonths`]), /// newest first. PhotoMonths { months: Vec }, diff --git a/crates/pdfs-core/src/db/albums.rs b/crates/pdfs-core/src/db/albums.rs new file mode 100644 index 0000000..d34ea48 --- /dev/null +++ b/crates/pdfs-core/src/db/albums.rs @@ -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, + /// Epoch seconds of the last change to the album's contents. + pub last_activity: Option, + /// 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 = { + 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> { + 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::>()?; + Ok(rows) + } + + /// Number of albums in the persisted listing. + pub fn albums_count(&self) -> Result { + 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, Option)], + ) -> Result<()> { + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + let learned: HashMap, i64, Option)> = { + 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::>()? + }; + + 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> { + 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::>()?; + Ok(rows) + } + + /// How many photos of `album_uid` are persisted. + pub fn album_photos_count(&self, album_uid: &str) -> Result { + 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> { + 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::>()?; + 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) -> 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(()) + } +} diff --git a/crates/pdfs-core/src/db/migrations.rs b/crates/pdfs-core/src/db/migrations.rs index e7241ac..b9c8801 100644 --- a/crates/pdfs-core/src/db/migrations.rs +++ b/crates/pdfs-core/src/db/migrations.rs @@ -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); +"; diff --git a/crates/pdfs-core/src/db/mod.rs b/crates/pdfs-core/src/db/mod.rs index ed50884..f74b78f 100644 --- a/crates/pdfs-core/src/db/mod.rs +++ b/crates/pdfs-core/src/db/mod.rs @@ -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; diff --git a/crates/pdfs-core/src/db/state.rs b/crates/pdfs-core/src/db/state.rs index d0583ce..d2a39fd 100644 --- a/crates/pdfs-core/src/db/state.rs +++ b/crates/pdfs-core/src/db/state.rs @@ -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(()) + } } diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 050a89d..50df23d 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -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| 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::>(), + ["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" + ); +} diff --git a/crates/pdfs-fuse/src/albums.rs b/crates/pdfs-fuse/src/albums.rs new file mode 100644 index 0000000..3845e99 --- /dev/null +++ b/crates/pdfs-fuse/src/albums.rs @@ -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>> { + 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 { + 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 = 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 = 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> { + 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 = items.iter().map(|it| it.uid.clone()).collect(); + let mut meta: HashMap, Option)> = 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, Option)> = 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, + } +} diff --git a/crates/pdfs-fuse/src/background.rs b/crates/pdfs-fuse/src/background.rs index b484060..126a9fc 100644 --- a/crates/pdfs-fuse/src/background.rs +++ b/crates/pdfs-fuse/src/background.rs @@ -545,6 +545,8 @@ mod tests { is_shared_publicly: false, signature_email: None, membership: None, + photo: None, + album: None, verification: Default::default(), } } diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 1de9170..b7564cc 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -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 diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 8b794e2..d5bef46 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -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(), } } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 7dfe906..cd9ae2d 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -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, + /// The same, for the album listing. + albums_refreshing: Arc, trash_refreshing: Arc, /// 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(), } } diff --git a/crates/pdfs-fuse/src/mount.rs b/crates/pdfs-fuse/src/mount.rs index a97451e..f576588 100644 --- a/crates/pdfs-fuse/src/mount.rs +++ b/crates/pdfs-fuse/src/mount.rs @@ -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())), diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index f70fe17..d7142c5 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -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 { let ttype = ThumbnailType::Thumbnail.as_i32(); let keys: Vec = 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 = stored.iter().map(|p| p.uid.clone()).collect(); + let missing: Vec = 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 = 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) { - 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 diff --git a/crates/pdfs-fuse/src/state.rs b/crates/pdfs-fuse/src/state.rs index ddbcb4a..869c3b3 100644 --- a/crates/pdfs-fuse/src/state.rs +++ b/crates/pdfs-fuse/src/state.rs @@ -966,6 +966,8 @@ mod tests { is_shared_publicly: false, signature_email: None, membership: None, + photo: None, + album: None, verification: Default::default(), } } diff --git a/crates/pdfs-fuse/src/sweep.rs b/crates/pdfs-fuse/src/sweep.rs index 538ee00..350c99c 100644 --- a/crates/pdfs-fuse/src/sweep.rs +++ b/crates/pdfs-fuse/src/sweep.rs @@ -354,6 +354,8 @@ mod tests { is_shared_publicly: false, signature_email: None, membership: None, + photo: None, + album: None, verification: Default::default(), } } diff --git a/crates/pdfs-fuse/src/virtual.rs b/crates/pdfs-fuse/src/virtual.rs index ce6b657..7cb00a0 100644 --- a/crates/pdfs-fuse/src/virtual.rs +++ b/crates/pdfs-fuse/src/virtual.rs @@ -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(), } } diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 893f1a0..533e9e0 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -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::>(&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) { 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::() + 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); - - assert!( - rows.len() > 1, - "12 photos at 180px should need several rows" - ); - // Every row but the trailing one is justified. - for row in &rows[..rows.len() - 1] { - assert_eq!(row_width(row), 1000); + 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!( + used <= width && width - used < columns as i32, + "{columns} x {edge}px = {used}px does not span {width}px" + ); } } #[test] - fn every_photo_lands_in_exactly_one_row() { - let ratios: Vec = (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); + } } diff --git a/crates/pdfs-gui/src/app/pages/albums.rs b/crates/pdfs-gui/src/app/pages/albums.rs new file mode 100644 index 0000000..46c9644 --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/albums.rs @@ -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) { + 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) { + 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, 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, 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, uid: String, picture: >k4::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, 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) { + 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, 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) { + 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); + }); +} diff --git a/crates/pdfs-gui/src/app/pages/mod.rs b/crates/pdfs-gui/src/app/pages/mod.rs index 8b13b7a..edcccd3 100644 --- a/crates/pdfs-gui/src/app/pages/mod.rs +++ b/crates/pdfs-gui/src/app/pages/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod activity; +pub(crate) mod albums; pub(crate) mod browser; pub(crate) mod devices; pub(crate) mod locations; diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 616936a..212051f 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -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, - /// 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, + /// 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>, + /// 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>, /// 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>, /// 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, - /// 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, /// 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>, pub(crate) photo_tex_order: RefCell>, - /// 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>, - /// Ratios learned since the last save, so the ratio file is only rewritten - /// when it actually changed. - pub(crate) photo_ratio_dirty: Cell, /// 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>, @@ -75,46 +97,33 @@ pub(crate) struct GalleryState { pub(crate) decode_queue: RefCell>, pub(crate) decode_idle: Cell, /// 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>, pub(crate) relayout_source: RefCell>, /// 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>, } /// 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) { /// 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, 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, list: >k4::ListView, scroll: >k4::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, list: >k4::ListView, scroll: >k4::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, photos: &[PhotoItem], width: i32) -> Vec> { - let ratios: Vec = 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, photos: &[PhotoItem], width: i32) -> Vec> { + 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 { - photo: photo.clone(), - width, - height, - }) + row.iter() + .map(|photo| Tile { + photo: photo.clone(), + 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> { - 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::new(); - let mut row: Vec = 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, section: >k4::Box, photos: &[PhotoItem]) { let Some(heading) = section.first_child() else { return; @@ -791,8 +841,11 @@ pub(crate) fn fill_section(ui: &Rc, section: >k4::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, section: >k4::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) -> i32 { match ui.gallery.width.get() { @@ -822,12 +875,17 @@ pub(crate) fn gallery_width(ui: &Rc) -> i32 { /// one keeps an image glyph instead of an empty rectangle. pub(crate) fn photo_tile(ui: &Rc, 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, 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) { 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) { 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) { if let Some(id) = ui.gallery.relayout_source.borrow_mut().take() { id.remove(); @@ -1097,9 +1149,8 @@ pub(crate) fn schedule_relayout(ui: &Rc) { } /// 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) { let bound: Vec<(u32, gtk4::Box)> = ui .gallery @@ -1120,12 +1171,12 @@ pub(crate) fn relayout_gallery(ui: &Rc) { } } -/// 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, 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, 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) { 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, 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, 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, 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,20 +1407,13 @@ pub(crate) fn load_gallery(ui: &Rc, 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 { - nothumb.insert(item.uid.clone()); - } + for item in items.iter().filter(|item| item.no_thumb) { + nothumb.insert(item.uid.clone()); } } for item in &items { @@ -1350,13 +1421,15 @@ pub(crate) fn load_gallery(ui: &Rc, append: bool) { } repaint_gallery(&ui); if ui.gallery.model.n_items() == 0 { - gallery_status( - &ui, - "image-x-generic-symbolic", - "No photos yet", - "Photos you upload to Proton Drive appear here.", - false, - ); + 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.", + ) + }; + gallery_status(&ui, "image-x-generic-symbolic", title, description, false); return; } ui.gallery.content.set_visible_child_name("timeline"); diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index ee51661..edd432b 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -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')