mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
added photos thumbnail caching + bigger photos
This commit is contained in:
1 parent
ae0d374ec0
commit
cc744d56c7
8 files changed
+1616
-144
No files matched your search
@@ -213,15 +213,34 @@ pub struct PhotoItem {
|
||||
/// `None` here means "not fetched yet", not "has no thumbnail" — ask for it
|
||||
/// with [`Request::PhotoThumbs`].
|
||||
pub thumb_path: Option<String>,
|
||||
/// File name, when the daemon knows it.
|
||||
pub name: Option<String>,
|
||||
/// Aspect ratio (w/h), remembered by the daemon from the last time this
|
||||
/// photo's thumbnail was decoded. Lets the gallery justify its rows correctly
|
||||
/// on the first frame instead of guessing and reflowing as images land.
|
||||
pub ratio: Option<f64>,
|
||||
/// True when this photo can never be given a thumbnail — the server has none
|
||||
/// and its bytes could not be decoded locally. The tile shows a placeholder
|
||||
/// rather than waiting for an image that will never come.
|
||||
pub no_thumb: bool,
|
||||
}
|
||||
|
||||
/// One thumbnail in a [`Response::Thumbs`] batch.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PhotoThumb {
|
||||
pub uid: String,
|
||||
/// On-disk path, or `None` when the photo genuinely has no thumbnail (or the
|
||||
/// fetch failed) — a front-end can then stop asking for it.
|
||||
/// On-disk path, or `None` when there is no thumbnail to serve *yet*.
|
||||
pub path: Option<String>,
|
||||
/// True when the daemon is making this thumbnail itself, because the server
|
||||
/// has none: the photo's full file is downloading and will be scaled when it
|
||||
/// lands. A `None` path with `pending` set means "ask again shortly"; a `None`
|
||||
/// path *without* it means the photo can never have a thumbnail, and a
|
||||
/// front-end should stop asking.
|
||||
///
|
||||
/// Generation is not made to block the reply: one 20 MB camera photo takes
|
||||
/// far longer to fetch than the whole rest of a batch, and holding the batch
|
||||
/// for it would leave a screenful of ready thumbnails unpainted.
|
||||
pub pending: bool,
|
||||
}
|
||||
|
||||
/// The daemon's reply to a [`Request`].
|
||||
|
||||
+356
-1
@@ -10,6 +10,7 @@
|
||||
//! applies the forward-only schema migrations. Write-through of nodes, the
|
||||
//! event cursor, FTS, and the cache index land in later phases on this schema.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -21,7 +22,16 @@ use crate::Result;
|
||||
use crate::localindex::LocalEntry;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
const SCHEMA_VERSION: i64 = 6;
|
||||
const SCHEMA_VERSION: i64 = 7;
|
||||
|
||||
/// A photo whose thumbnail state is not known yet: it has never been asked for.
|
||||
pub const THUMB_UNKNOWN: i64 = 0;
|
||||
/// A thumbnail exists for this photo — served by the server, or generated locally
|
||||
/// from the full file when the server had none.
|
||||
pub const THUMB_HAVE: i64 = 1;
|
||||
/// This photo can never be given a thumbnail: the server has none and the bytes
|
||||
/// could not be decoded locally either. Never retried.
|
||||
pub const THUMB_NONE: i64 = 2;
|
||||
|
||||
/// Below this length the trigram tokenizer indexes nothing (it needs 3-char
|
||||
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
|
||||
@@ -42,6 +52,31 @@ pub struct SearchHit {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// One photo of the persisted timeline. The timeline itself is server-ordered
|
||||
/// (newest first) and stored with that order in `seq`; `ratio` and `thumb_state`
|
||||
/// are locally learned and survive a refresh of the timeline.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoredPhoto {
|
||||
pub uid: String,
|
||||
pub capture_time: i64,
|
||||
pub name: Option<String>,
|
||||
/// Aspect ratio (w/h), known once a thumbnail has been decoded.
|
||||
pub ratio: Option<f64>,
|
||||
/// One of [`THUMB_UNKNOWN`] / [`THUMB_HAVE`] / [`THUMB_NONE`].
|
||||
pub thumb_state: i64,
|
||||
}
|
||||
|
||||
/// One trashed node, as persisted for the Trash page. Trashed nodes live outside
|
||||
/// the mounted tree, so a uid is their only handle.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoredTrash {
|
||||
pub uid: String,
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub size: i64,
|
||||
pub mtime: i64,
|
||||
}
|
||||
|
||||
/// Handle to the unified metadata database.
|
||||
///
|
||||
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
|
||||
@@ -123,6 +158,9 @@ impl Db {
|
||||
if current < 6 {
|
||||
tx.execute_batch(MIGRATION_V6)?;
|
||||
}
|
||||
if current < 7 {
|
||||
tx.execute_batch(MIGRATION_V7)?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -299,6 +337,189 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a `sync_state` value as an integer (the freshness stamps of the
|
||||
/// photos timeline and the trash listing are kept there).
|
||||
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let v: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = ?1",
|
||||
params![key],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(v.and_then(|v| v.parse().ok()))
|
||||
}
|
||||
|
||||
/// Write a `sync_state` integer value.
|
||||
pub fn set_state_i64(&self, key: &str, value: i64) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop a `sync_state` key, so whatever it stamped counts as never fetched.
|
||||
pub fn clear_state(&self, key: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace the persisted photos timeline with `items` (newest first), keeping
|
||||
/// what was locally learned about the photos that are still there: a ratio or
|
||||
/// a thumbnail verdict costs a download to rediscover, while the server order
|
||||
/// and capture times are cheap and authoritative.
|
||||
///
|
||||
/// Photos no longer in the timeline are dropped, so a deletion on another
|
||||
/// client doesn't leave a ghost tile behind.
|
||||
pub fn photos_replace(&self, items: &[(String, i64, Option<String>)]) -> Result<()> {
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
let learned: HashMap<String, (Option<f64>, i64)> = {
|
||||
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state FROM photos")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?)))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
|
||||
tx.execute("DELETE FROM photos", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO photos (uid, capture_time, name, ratio, thumb_state, seq)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
)?;
|
||||
for (seq, (uid, capture_time, name)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state) = learned.get(uid).copied().unwrap_or((None, THUMB_UNKNOWN));
|
||||
stmt.execute(params![uid, capture_time, name, ratio, thumb_state, seq as i64])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One page of the persisted timeline, newest first.
|
||||
pub fn photos_page(&self, offset: usize, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state FROM photos
|
||||
ORDER BY seq LIMIT ?1 OFFSET ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![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)?,
|
||||
})
|
||||
})?;
|
||||
let mut photos = Vec::new();
|
||||
for row in rows {
|
||||
photos.push(row?);
|
||||
}
|
||||
Ok(photos)
|
||||
}
|
||||
|
||||
/// The stored photos for `uids`, in no particular order. Used by the thumbnail
|
||||
/// path, which needs each photo's capture time (the cache validity tag) and
|
||||
/// its thumbnail verdict.
|
||||
pub fn photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state FROM photos
|
||||
WHERE uid IN ({placeholders})"
|
||||
))?;
|
||||
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)?,
|
||||
})
|
||||
})?;
|
||||
let mut photos = Vec::new();
|
||||
for row in rows {
|
||||
photos.push(row?);
|
||||
}
|
||||
Ok(photos)
|
||||
}
|
||||
|
||||
/// Record what a thumbnail attempt learned: whether the photo now has one
|
||||
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
|
||||
/// seen. A `None` ratio leaves any previously learned one alone.
|
||||
pub fn photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
|
||||
params![uid, state, ratio],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of photos in the persisted timeline.
|
||||
pub fn photos_count(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
|
||||
/// Replace the persisted trash listing.
|
||||
pub fn trash_replace(&self, items: &[StoredTrash]) -> Result<()> {
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("DELETE FROM trash", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO trash (uid, name, is_dir, size, mtime) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
)?;
|
||||
for item in items {
|
||||
stmt.execute(params![
|
||||
item.uid,
|
||||
item.name,
|
||||
item.is_dir as i64,
|
||||
item.size,
|
||||
item.mtime
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The persisted trash listing, folders first then by name — the order the
|
||||
/// Trash page shows it in.
|
||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, name, is_dir, size, mtime FROM trash
|
||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(StoredTrash {
|
||||
uid: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
is_dir: r.get::<_, i64>(2)? != 0,
|
||||
size: r.get(3)?,
|
||||
mtime: r.get(4)?,
|
||||
})
|
||||
})?;
|
||||
let mut items = Vec::new();
|
||||
for row in rows {
|
||||
items.push(row?);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Children of `parent`, for the offline `ensure_children` fast path when a
|
||||
/// listed folder's `children` map entry was trimmed mid-run. Returns `None`
|
||||
/// when the folder is not marked `listed` (listing unknown, must re-fetch).
|
||||
@@ -849,6 +1070,35 @@ CREATE VIRTUAL TABLE local_fts USING fts5(
|
||||
);
|
||||
";
|
||||
|
||||
/// Schema v7: the photos timeline and the trash listing become persistent, so
|
||||
/// opening the app paints them from disk instead of re-fetching the world (both
|
||||
/// were memory-only: the timeline behind a 60 s TTL, the trash not cached at all).
|
||||
///
|
||||
/// `photos.seq` preserves the server's newest-first order, which is the only order
|
||||
/// the timeline has — `capture_time` ties are common (a burst of shots) and would
|
||||
/// otherwise shuffle between refreshes. `ratio` and `thumb_state` are *locally
|
||||
/// learned*: they cost a download to rediscover, so `photos_replace` carries them
|
||||
/// across a refresh while capture times and order come from the server.
|
||||
const MIGRATION_V7: &str = "
|
||||
CREATE TABLE photos (
|
||||
uid TEXT PRIMARY KEY,
|
||||
capture_time INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
ratio REAL,
|
||||
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_photos_seq ON photos(seq);
|
||||
|
||||
CREATE TABLE trash (
|
||||
uid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
mtime INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -879,6 +1129,111 @@ mod tests {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
("p1".into(), 300, None),
|
||||
("p2".into(), 200, None),
|
||||
("p3".into(), 100, None),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
// A thumbnail attempt teaches us p1's ratio and that p2 can never have one.
|
||||
db.photo_set_thumb("p1", THUMB_HAVE, Some(1.5)).unwrap();
|
||||
db.photo_set_thumb("p2", THUMB_NONE, None).unwrap();
|
||||
|
||||
// The next refresh brings a new photo, keeps p1 and p2, and loses p3.
|
||||
db.photos_replace(&[
|
||||
("p0".into(), 400, Some("new.jpg".into())),
|
||||
("p1".into(), 300, None),
|
||||
("p2".into(), 200, None),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let page = db.photos_page(0, 10).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p0", "p1", "p2"],
|
||||
"server order is preserved, and the dropped photo is gone"
|
||||
);
|
||||
assert_eq!(page[0].name.as_deref(), Some("new.jpg"));
|
||||
// Ratios and verdicts cost a download to rediscover: they survive a refresh.
|
||||
assert_eq!(page[1].ratio, Some(1.5));
|
||||
assert_eq!(page[1].thumb_state, THUMB_HAVE);
|
||||
assert_eq!(page[2].thumb_state, THUMB_NONE);
|
||||
// A photo we know nothing about yet starts blank.
|
||||
assert_eq!(page[0].ratio, None);
|
||||
assert_eq!(page[0].thumb_state, THUMB_UNKNOWN);
|
||||
|
||||
assert_eq!(db.photos_count().unwrap(), 3);
|
||||
let by_uid = db.photos_by_uid(&["p2".into()]).unwrap();
|
||||
assert_eq!(by_uid.len(), 1);
|
||||
assert_eq!(by_uid[0].capture_time, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_page_slices_the_timeline_in_order() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let items: Vec<_> = (0..5)
|
||||
.map(|i| (format!("p{i}"), 500 - i as i64, None))
|
||||
.collect();
|
||||
db.photos_replace(&items).unwrap();
|
||||
|
||||
let page = db.photos_page(2, 2).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p2", "p3"]
|
||||
);
|
||||
assert!(db.photos_page(9, 2).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trash_replace_lists_folders_first() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.trash_replace(&[
|
||||
StoredTrash {
|
||||
uid: "t1".into(),
|
||||
name: "zeta.txt".into(),
|
||||
is_dir: false,
|
||||
size: 10,
|
||||
mtime: 1,
|
||||
},
|
||||
StoredTrash {
|
||||
uid: "t2".into(),
|
||||
name: "Alpha".into(),
|
||||
is_dir: true,
|
||||
size: 0,
|
||||
mtime: 2,
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let items = db.trash_list().unwrap();
|
||||
assert_eq!(
|
||||
items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(),
|
||||
["Alpha", "zeta.txt"]
|
||||
);
|
||||
|
||||
// A replace is a replace: emptying the trash on the server empties it here.
|
||||
db.trash_replace(&[]).unwrap();
|
||||
assert!(db.trash_list().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_stamps_round_trip_and_clear() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
assert_eq!(db.state_i64("photos_synced_ms").unwrap(), None);
|
||||
db.set_state_i64("photos_synced_ms", 1234).unwrap();
|
||||
assert_eq!(db.state_i64("photos_synced_ms").unwrap(), Some(1234));
|
||||
db.clear_state("photos_synced_ms").unwrap();
|
||||
assert_eq!(
|
||||
db.state_i64("photos_synced_ms").unwrap(),
|
||||
None,
|
||||
"a cleared stamp reads as never fetched, so the next request blocks on a refresh"
|
||||
);
|
||||
}
|
||||
|
||||
fn folder(link: &str, parent: Option<&str>, name: &str) -> Node {
|
||||
let parent = match parent {
|
||||
Some(p) => serde_json::json!({"volume_id": "vol", "link_id": p}),
|
||||
|
||||
@@ -15,3 +15,4 @@ tracing.workspace = true
|
||||
fuser.workspace = true
|
||||
libc.workspace = true
|
||||
serde_json.workspace = true
|
||||
image.workspace = true
|
||||
+525
-124
@@ -31,7 +31,7 @@
|
||||
//! set, so a multi-GiB write never buffers in RAM and only the untouched
|
||||
//! remainder of the file is pulled from the remote — lazily, at commit.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Write as _};
|
||||
@@ -55,13 +55,13 @@ use pdfs_core::control::{
|
||||
DirEntry, LocalHit, PhotoItem, PhotoThumb, Request as CtlRequest, Response as CtlResponse,
|
||||
SearchHit, TransferDirection,
|
||||
};
|
||||
use pdfs_core::db::{Db, StoredNode};
|
||||
use pdfs_core::db::{self, Db, StoredNode, StoredPhoto, StoredTrash};
|
||||
use pdfs_core::localindex;
|
||||
use proton_drive_rs::proton_sdk::error::ProtonError;
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
|
||||
use proton_drive_rs::{
|
||||
DriveEvent, DriveEventScopeId, Node, NodeKind, PhotosTimelineItem, ProtonDriveClient,
|
||||
ProtonPhotosClient, ThumbnailType,
|
||||
DriveEvent, DriveEventScopeId, Node, NodeKind, ProtonDriveClient, ProtonPhotosClient,
|
||||
ThumbnailType,
|
||||
};
|
||||
|
||||
mod transfers;
|
||||
@@ -75,10 +75,33 @@ const TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How often the background task polls the remote event cursor.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
/// How long a fetched photos timeline stays good before the next page request
|
||||
/// re-fetches it. The SDK hands back the whole timeline at once, so we cache it
|
||||
/// in [`Core`] and serve every page from memory until it goes stale.
|
||||
const TIMELINE_TTL: Duration = Duration::from_secs(60);
|
||||
/// How long the persisted photos timeline stays good before a page request
|
||||
/// revalidates it. The SDK hands back the whole timeline at once, so it is stored
|
||||
/// in the DB and every page is sliced from there; a stale one is still served
|
||||
/// immediately and refreshed in the background.
|
||||
const TIMELINE_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
/// The same, for the persisted trash listing. Shorter, because the trash is the
|
||||
/// one listing a user changes and then immediately looks at — though our own
|
||||
/// mutations also invalidate it outright, so this only covers other clients.
|
||||
const TRASH_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// `sync_state` keys for the freshness stamps of the two persisted listings, and
|
||||
/// for whether the account has a photos volume at all (so an account without one
|
||||
/// doesn't re-ask the server on every page request).
|
||||
const PHOTOS_SYNCED_MS: &str = "photos_synced_ms";
|
||||
const PHOTOS_AVAILABLE: &str = "photos_available";
|
||||
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
|
||||
|
||||
/// Longest edge, in px, of a thumbnail generated locally for a photo the server
|
||||
/// has none for. Matches the server's own thumbnail scale closely enough that a
|
||||
/// tile can't tell them apart.
|
||||
const THUMB_EDGE: u32 = 512;
|
||||
/// JPEG quality of a locally generated thumbnail.
|
||||
const THUMB_QUALITY: u8 = 82;
|
||||
/// How many photos may be downloaded at once to generate their missing
|
||||
/// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise
|
||||
/// saturate the link and starve the rest of the daemon.
|
||||
const THUMB_GEN_CONCURRENCY: usize = 4;
|
||||
|
||||
/// How stale the local-file index may get before the background scanner rebuilds
|
||||
/// it. A rescan is a full walk of `$HOME`, so this trades index freshness against
|
||||
@@ -310,10 +333,16 @@ struct Core {
|
||||
/// `State` maps. Every mutation writes through here, and the maps rehydrate
|
||||
/// from it on mount (plan.md P1).
|
||||
db: Arc<Db>,
|
||||
/// Cached full photos timeline (newest first), so paged `PhotosTimeline`
|
||||
/// requests slice memory instead of re-fetching the whole timeline per page.
|
||||
/// `None` until first fetched; refreshed once older than [`TIMELINE_TTL`].
|
||||
timeline: Arc<Mutex<Option<TimelineCache>>>,
|
||||
/// True while a background refresh of the photos timeline (resp. the trash) is
|
||||
/// already running, so a burst of page requests against a stale listing kicks
|
||||
/// off one refresh rather than one per request.
|
||||
timeline_refreshing: Arc<AtomicBool>,
|
||||
trash_refreshing: Arc<AtomicBool>,
|
||||
/// Photos whose missing thumbnail is being generated right now. A tile that is
|
||||
/// still on screen asks for its thumbnail again every few seconds, and each of
|
||||
/// those downloads is a full-size photo — so an in-flight uid is never started
|
||||
/// twice.
|
||||
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
|
||||
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared
|
||||
/// across the FUSE session and the control-socket task.
|
||||
transfers: Arc<TransferRegistry>,
|
||||
@@ -322,12 +351,6 @@ struct Core {
|
||||
indexing: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// The whole photos timeline plus when it was fetched, for TTL freshness.
|
||||
struct TimelineCache {
|
||||
items: Vec<PhotosTimelineItem>,
|
||||
fetched: Instant,
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Rehydrate the in-memory `State` maps from the DB on mount, so a cold
|
||||
/// start serves previously-seen metadata (stable inodes, instant listings)
|
||||
@@ -1047,132 +1070,314 @@ impl Core {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// A page of the photos timeline (newest first). Metadata only: a thumbnail
|
||||
/// path is attached for photos whose thumbnail is *already* cached, and no
|
||||
/// thumbnail is downloaded here — the page comes back as fast as the cached
|
||||
/// timeline can be sliced, and the front-end pulls the thumbnails it actually
|
||||
/// paints via [`Core::photo_thumbs`]. `Ok(None)` when the account has no
|
||||
/// photos volume.
|
||||
/// A page of the photos timeline (newest first), sliced out of the DB.
|
||||
/// `Ok(None)` when the account has no photos volume.
|
||||
///
|
||||
/// The SDK's `enumerate_timeline` returns the *whole* timeline, so the first
|
||||
/// request (or the first after [`TIMELINE_TTL`] elapses) fetches and caches
|
||||
/// it in [`Core::timeline`]; every page then slices that cached vec, so
|
||||
/// "load more" scrolling doesn't re-hit the network per page.
|
||||
/// Stale-while-revalidate: a persisted timeline is served *immediately*, and
|
||||
/// refreshed on the runtime if it is older than [`TIMELINE_TTL`] — so opening
|
||||
/// the app paints from disk rather than waiting on a full `enumerate_timeline`
|
||||
/// (which returns the whole timeline, not a page). Only an empty DB blocks on
|
||||
/// the network, i.e. the very first run.
|
||||
///
|
||||
/// Metadata only: a thumbnail path is attached for photos whose thumbnail is
|
||||
/// already cached, and nothing is downloaded here — the front-end pulls the
|
||||
/// thumbnails it actually paints via [`Core::photo_thumbs`].
|
||||
fn photos_timeline(
|
||||
&self,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<Option<Vec<PhotoItem>>, String> {
|
||||
let page: Vec<PhotosTimelineItem> = {
|
||||
// Refresh the cached timeline if absent or stale. The lock is only
|
||||
// held for the freshness check and the store, never across the fetch.
|
||||
let stale = {
|
||||
let guard = self.timeline.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.is_none_or(|c| c.fetched.elapsed() >= TIMELINE_TTL)
|
||||
};
|
||||
if stale {
|
||||
let photos = self.photos();
|
||||
if self
|
||||
.rt
|
||||
.block_on(photos.get_photos_root())
|
||||
.map_err(|e| format!("photos root: {e}"))?
|
||||
.is_none()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let items = self
|
||||
.rt
|
||||
.block_on(photos.enumerate_timeline())
|
||||
.map_err(|e| format!("timeline: {e}"))?;
|
||||
*self.timeline.lock().unwrap() = Some(TimelineCache {
|
||||
items,
|
||||
fetched: Instant::now(),
|
||||
});
|
||||
let count = self.db.photos_count().map_err(|e| e.to_string())?;
|
||||
if count == 0 {
|
||||
// Nothing to serve, so this one request has to wait for the fetch —
|
||||
// unless we already know the account has no photos volume and the
|
||||
// answer is a fresh "no".
|
||||
let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0);
|
||||
if known_empty && !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
|
||||
return Ok(None);
|
||||
}
|
||||
let guard = self.timeline.lock().unwrap();
|
||||
match guard.as_ref() {
|
||||
Some(c) => c.items.iter().skip(offset).take(limit).cloned().collect(),
|
||||
None => return Ok(None),
|
||||
if !self.rt.block_on(self.refresh_timeline())? {
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let ttype = ThumbnailType::Thumbnail.as_i32();
|
||||
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
|
||||
self.spawn_timeline_refresh();
|
||||
}
|
||||
|
||||
let page = self
|
||||
.db
|
||||
.photos_page(offset, limit)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Some(
|
||||
page.into_iter()
|
||||
.map(|it| {
|
||||
let thumb_path = self
|
||||
.cache
|
||||
.cached_thumbnail_path(&it.uid, ttype, it.capture_time)
|
||||
.map(|p| p.display().to_string());
|
||||
PhotoItem {
|
||||
uid: it.uid.to_string(),
|
||||
capture_time: it.capture_time,
|
||||
thumb_path,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
page.into_iter().map(|p| self.photo_item(p)).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Thumbnails for `uids`, served from the cache and downloaded (in one
|
||||
/// batched SDK call) for whatever is missing. Requested on demand as tiles
|
||||
/// scroll into view, so a cold timeline paints immediately and only the
|
||||
/// photos actually on screen cost a round-trip.
|
||||
/// Project a persisted photo into the wire item the front-end paints: its
|
||||
/// learned aspect ratio, its thumbnail verdict, and the on-disk path of its
|
||||
/// thumbnail when one is cached (tagged with the capture time, which is the
|
||||
/// only revision handle a photo carries).
|
||||
fn photo_item(&self, photo: StoredPhoto) -> PhotoItem {
|
||||
let thumb_path = parse_uid(&photo.uid).and_then(|uid| {
|
||||
self.cache
|
||||
.cached_thumbnail_path(&uid, ThumbnailType::Thumbnail.as_i32(), photo.capture_time)
|
||||
.map(|p| p.display().to_string())
|
||||
});
|
||||
PhotoItem {
|
||||
uid: photo.uid,
|
||||
capture_time: photo.capture_time,
|
||||
thumb_path,
|
||||
name: photo.name,
|
||||
ratio: photo.ratio,
|
||||
no_thumb: photo.thumb_state == db::THUMB_NONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Thumbnails for `uids`, served from the cache, fetched from the server for
|
||||
/// whatever is missing, and — for the photos the server has no thumbnail for
|
||||
/// at all — *generated locally* from the full file (see
|
||||
/// [`Core::generate_thumbs`]). Requested on demand as tiles scroll into view,
|
||||
/// so a cold timeline paints immediately and only the photos actually on
|
||||
/// screen cost a round-trip.
|
||||
///
|
||||
/// The cache tags a thumbnail with its photo's capture time, which is what
|
||||
/// the timeline carries — a uid absent from the cached timeline therefore
|
||||
/// can't be validated and is skipped rather than guessed at.
|
||||
/// A photo absent from the persisted timeline is skipped: its capture time is
|
||||
/// the cache's validity tag, and guessing that would poison the cache.
|
||||
fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> {
|
||||
let ttype = ThumbnailType::Thumbnail.as_i32();
|
||||
let tags: HashMap<NodeUid, i64> = {
|
||||
let guard = self.timeline.lock().unwrap();
|
||||
match guard.as_ref() {
|
||||
Some(c) => c
|
||||
.items
|
||||
.iter()
|
||||
.map(|it| (it.uid.clone(), it.capture_time))
|
||||
.collect(),
|
||||
None => HashMap::new(),
|
||||
}
|
||||
};
|
||||
let keys: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
|
||||
let stored = self.db.photos_by_uid(&keys).unwrap_or_default();
|
||||
let tags: HashMap<String, i64> = stored
|
||||
.iter()
|
||||
.map(|p| (p.uid.clone(), p.capture_time))
|
||||
.collect();
|
||||
|
||||
// Ask the server only for photos that are missing a cached thumbnail and
|
||||
// haven't already been written off as un-thumbnailable.
|
||||
let want: Vec<NodeUid> = uids
|
||||
.iter()
|
||||
.filter(|uid| {
|
||||
tags.get(*uid)
|
||||
.is_some_and(|&tag| self.cache.cached_thumbnail_path(uid, ttype, tag).is_none())
|
||||
let key = uid.to_string();
|
||||
stored
|
||||
.iter()
|
||||
.find(|p| p.uid == key)
|
||||
.is_some_and(|p| p.thumb_state != db::THUMB_NONE)
|
||||
&& tags.get(&key).is_some_and(|&tag| {
|
||||
self.cache.cached_thumbnail_path(uid, ttype, tag).is_none()
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if !want.is_empty() {
|
||||
let mut missing = want.clone();
|
||||
match self.rt.block_on(
|
||||
self.photos()
|
||||
.enumerate_thumbnails(&want, ThumbnailType::Thumbnail),
|
||||
) {
|
||||
Ok(thumbs) => {
|
||||
for ft in thumbs {
|
||||
if let (Ok(bytes), Some(&tag)) = (ft.result, tags.get(&ft.file_uid)) {
|
||||
let _ = self.cache.store_thumbnail(&ft.file_uid, ttype, tag, &bytes);
|
||||
let Some(&tag) = tags.get(&ft.file_uid.to_string()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(bytes) = ft.result else { continue };
|
||||
if bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.cache
|
||||
.store_thumbnail(&ft.file_uid, ttype, tag, &bytes)
|
||||
.is_ok()
|
||||
{
|
||||
missing.retain(|uid| uid != &ft.file_uid);
|
||||
self.record_thumb(&ft.file_uid, db::THUMB_HAVE, ratio_of(&bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A failed batch is not a verdict: leave every uid in `missing` so
|
||||
// the local fallback still gives those tiles an image.
|
||||
Err(e) => warn!(error = %e, "batch photo thumbnails failed"),
|
||||
}
|
||||
|
||||
// Whatever the server had nothing for gets a thumbnail made from its
|
||||
// own bytes — this is what fills in camera photos uploaded by clients
|
||||
// that never generated one. Off the request path: a full-size photo
|
||||
// takes far longer to fetch than the whole rest of the batch, and the
|
||||
// thumbnails that *are* ready must not wait behind it.
|
||||
if !missing.is_empty() {
|
||||
self.spawn_generate_thumbs(missing, &tags);
|
||||
}
|
||||
}
|
||||
|
||||
let pending = self.thumb_gen.lock().unwrap();
|
||||
uids.iter()
|
||||
.map(|uid| PhotoThumb {
|
||||
uid: uid.to_string(),
|
||||
path: tags.get(uid).and_then(|&tag| {
|
||||
path: tags.get(&uid.to_string()).and_then(|&tag| {
|
||||
self.cache
|
||||
.cached_thumbnail_path(uid, ttype, tag)
|
||||
.map(|p| p.display().to_string())
|
||||
}),
|
||||
pending: pending.contains(uid),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate the missing thumbnails on the runtime, skipping any photo already
|
||||
/// being generated. The uids are marked in-flight before the task starts, so
|
||||
/// the reply this call is about to send already reports them as pending.
|
||||
fn spawn_generate_thumbs(&self, uids: Vec<NodeUid>, tags: &HashMap<String, i64>) {
|
||||
let fresh: Vec<NodeUid> = {
|
||||
let mut inflight = self.thumb_gen.lock().unwrap();
|
||||
uids.into_iter()
|
||||
.filter(|uid| inflight.insert(uid.clone()))
|
||||
.collect()
|
||||
};
|
||||
if fresh.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let core = self.clone();
|
||||
let tags = tags.clone();
|
||||
// `generate_thumbs` blocks on the runtime itself, so it belongs on the
|
||||
// blocking pool rather than on an async worker.
|
||||
self.rt.spawn_blocking(move || {
|
||||
core.generate_thumbs(&fresh, &tags);
|
||||
let mut inflight = core.thumb_gen.lock().unwrap();
|
||||
for uid in &fresh {
|
||||
inflight.remove(uid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Make thumbnails for photos the server has none for: download each full
|
||||
/// file once, scale it to [`THUMB_EDGE`], and store the result in the thumbnail
|
||||
/// cache exactly as if the server had served it.
|
||||
///
|
||||
/// Bounded by [`THUMB_GEN_CONCURRENCY`] — these are full-size originals, and a
|
||||
/// screenful of them at once would saturate the link. A photo whose bytes
|
||||
/// can't be decoded (a codec `image` doesn't speak) is marked
|
||||
/// [`db::THUMB_NONE`] and never attempted again.
|
||||
fn generate_thumbs(&self, uids: &[NodeUid], tags: &HashMap<String, i64>) {
|
||||
info!(
|
||||
count = uids.len(),
|
||||
"generating thumbnails the server has none for"
|
||||
);
|
||||
let results: Vec<(NodeUid, ThumbAttempt)> = self.rt.block_on(async {
|
||||
let mut out = Vec::with_capacity(uids.len());
|
||||
for chunk in uids.chunks(THUMB_GEN_CONCURRENCY) {
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for uid in chunk {
|
||||
let client = self.client.clone();
|
||||
let uid = uid.clone();
|
||||
set.spawn(async move {
|
||||
let bytes = match client.download_file(&uid).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
warn!(%uid, error = %e, "photo download for thumbnail failed");
|
||||
return (uid, ThumbAttempt::Unavailable);
|
||||
}
|
||||
};
|
||||
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
|
||||
// stall the runtime's worker; hand it to the blocking pool.
|
||||
let made = tokio::task::spawn_blocking(move || scale_thumbnail(&bytes))
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
match made {
|
||||
Some(thumb) => (uid, ThumbAttempt::Made(thumb)),
|
||||
None => (uid, ThumbAttempt::Undecodable),
|
||||
}
|
||||
});
|
||||
}
|
||||
while let Some(joined) = set.join_next().await {
|
||||
if let Ok(result) = joined {
|
||||
out.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
});
|
||||
|
||||
let ttype = ThumbnailType::Thumbnail.as_i32();
|
||||
for (uid, attempt) in results {
|
||||
match attempt {
|
||||
ThumbAttempt::Made(thumb) => {
|
||||
let Some(&tag) = tags.get(&uid.to_string()) else {
|
||||
continue;
|
||||
};
|
||||
match self.cache.store_thumbnail(&uid, ttype, tag, &thumb.bytes) {
|
||||
Ok(()) => self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio)),
|
||||
Err(e) => warn!(%uid, error = %e, "storing generated thumbnail failed"),
|
||||
}
|
||||
}
|
||||
// The photo's own bytes aren't an image we can read: no thumbnail
|
||||
// will ever exist for it, so stop trying.
|
||||
ThumbAttempt::Undecodable => self.record_thumb(&uid, db::THUMB_NONE, None),
|
||||
// The download failed — a dropped connection, an expired link. That
|
||||
// is not a verdict on the photo: leave its state alone so the next
|
||||
// scroll past it tries again.
|
||||
ThumbAttempt::Unavailable => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist what a thumbnail attempt learned about a photo.
|
||||
fn record_thumb(&self, uid: &NodeUid, state: i64, ratio: Option<f64>) {
|
||||
if let Err(e) = self.db.photo_set_thumb(&uid.to_string(), state, ratio) {
|
||||
warn!(%uid, error = %e, "recording thumbnail state failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the listing stamped under `key` is older than `ttl` (or was never
|
||||
/// fetched).
|
||||
fn listing_stale(&self, key: &str, ttl: Duration) -> bool {
|
||||
match self.db.state_i64(key).ok().flatten() {
|
||||
Some(ms) => now_ms().saturating_sub(ms) >= ttl.as_millis() as i64,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-fetch the whole photos timeline and persist it. Returns whether the
|
||||
/// account has a photos volume at all.
|
||||
async fn refresh_timeline(&self) -> Result<bool, String> {
|
||||
let photos = self.photos();
|
||||
if photos
|
||||
.get_photos_root()
|
||||
.await
|
||||
.map_err(|e| format!("photos root: {e}"))?
|
||||
.is_none()
|
||||
{
|
||||
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0);
|
||||
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
|
||||
return Ok(false);
|
||||
}
|
||||
let items = photos
|
||||
.enumerate_timeline()
|
||||
.await
|
||||
.map_err(|e| format!("timeline: {e}"))?;
|
||||
let rows: Vec<(String, i64, Option<String>)> = items
|
||||
.iter()
|
||||
.map(|it| (it.uid.to_string(), it.capture_time, None))
|
||||
.collect();
|
||||
self.db.photos_replace(&rows).map_err(|e| e.to_string())?;
|
||||
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
|
||||
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Refresh the timeline off the request path, so a stale page is still served
|
||||
/// at DB speed. At most one refresh runs at a time.
|
||||
fn spawn_timeline_refresh(&self) {
|
||||
if self.timeline_refreshing.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let core = self.clone();
|
||||
self.rt.spawn(async move {
|
||||
if let Err(e) = core.refresh_timeline().await {
|
||||
warn!(error = %e, "background timeline refresh failed");
|
||||
}
|
||||
core.timeline_refreshing.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
/// Download a photo's full content into the content cache, returning its
|
||||
/// on-disk path (served from cache when a fresh blob already exists).
|
||||
fn open_photo(&self, uid: &NodeUid) -> Result<PathBuf, String> {
|
||||
@@ -1300,42 +1505,101 @@ impl Core {
|
||||
.unwrap_or_default();
|
||||
self.cache.evict(&uid);
|
||||
self.invalidate_parent_listing(rel);
|
||||
self.invalidate_trash();
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// List the account's trash. Trashed nodes are outside the mounted tree — the
|
||||
/// FUSE side forgot them when they were trashed — so this always goes to the
|
||||
/// server rather than to `State` or the DB, and each entry is identified by
|
||||
/// its uid (its only remaining handle) with an empty path.
|
||||
/// List the account's trash, from the DB. Trashed nodes are outside the
|
||||
/// mounted tree — the FUSE side forgot them when they were trashed — so the
|
||||
/// listing is persisted in its own table rather than derived from `State`, and
|
||||
/// each entry is identified by its uid (its only remaining handle) with an
|
||||
/// empty path.
|
||||
///
|
||||
/// Stale-while-revalidate, like the photos timeline: a persisted listing comes
|
||||
/// back at DB speed and is refreshed in the background past [`TRASH_TTL`].
|
||||
/// Our own trash mutations invalidate it outright (see
|
||||
/// [`Core::invalidate_trash`]), so the TTL only covers changes made elsewhere.
|
||||
fn list_trash(&self) -> Result<Vec<DirEntry>, String> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_trash_node_uids())
|
||||
.map_err(|e| format!("enumerate trash: {e}"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
let stale = self.listing_stale(TRASH_SYNCED_MS, TRASH_TTL);
|
||||
// Never fetched: this request has to wait for it.
|
||||
if self.db.state_i64(TRASH_SYNCED_MS).ok().flatten().is_none() {
|
||||
self.rt.block_on(self.refresh_trash())?;
|
||||
} else if stale {
|
||||
self.spawn_trash_refresh();
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.map_err(|e| format!("enumerate nodes: {e}"))?;
|
||||
Ok(nodes
|
||||
|
||||
Ok(self
|
||||
.db
|
||||
.trash_list()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|node| DirEntry {
|
||||
name: node.name.clone(),
|
||||
is_dir: node.is_folder(),
|
||||
size: node_size(&node),
|
||||
modified: node.modification_time,
|
||||
.map(|item| DirEntry {
|
||||
name: item.name,
|
||||
is_dir: item.is_dir,
|
||||
size: item.size.max(0) as u64,
|
||||
modified: item.mtime,
|
||||
// A trashed node can't be pinned or served from the mount, so its
|
||||
// content is never current cache: report neither.
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: node.uid.to_string(),
|
||||
uid: item.uid,
|
||||
path: String::new(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Re-fetch the trash listing from the server and persist it.
|
||||
async fn refresh_trash(&self) -> Result<(), String> {
|
||||
let uids = self
|
||||
.client
|
||||
.enumerate_trash_node_uids()
|
||||
.await
|
||||
.map_err(|e| format!("enumerate trash: {e}"))?;
|
||||
let nodes = if uids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.client
|
||||
.enumerate_nodes(&uids)
|
||||
.await
|
||||
.map_err(|e| format!("enumerate nodes: {e}"))?
|
||||
};
|
||||
let items: Vec<StoredTrash> = nodes
|
||||
.into_iter()
|
||||
.map(|node| StoredTrash {
|
||||
uid: node.uid.to_string(),
|
||||
name: node.name.clone(),
|
||||
is_dir: node.is_folder(),
|
||||
size: node_size(&node) as i64,
|
||||
mtime: node.modification_time,
|
||||
})
|
||||
.collect();
|
||||
self.db.trash_replace(&items).map_err(|e| e.to_string())?;
|
||||
let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh the trash off the request path. At most one refresh at a time.
|
||||
fn spawn_trash_refresh(&self) {
|
||||
if self.trash_refreshing.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let core = self.clone();
|
||||
self.rt.spawn(async move {
|
||||
if let Err(e) = core.refresh_trash().await {
|
||||
warn!(error = %e, "background trash refresh failed");
|
||||
}
|
||||
core.trash_refreshing.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
/// Drop the persisted trash listing's freshness stamp after we changed the
|
||||
/// trash ourselves. The next Trash page then *waits* for a fresh listing
|
||||
/// rather than painting one from before the mutation — the user just made the
|
||||
/// change and is about to look straight at it.
|
||||
fn invalidate_trash(&self) {
|
||||
let _ = self.db.clear_state(TRASH_SYNCED_MS);
|
||||
}
|
||||
|
||||
/// Parse wire uids (`volume~link`) into [`NodeUid`]s, rejecting the whole
|
||||
/// batch if any is malformed — a partial trash mutation is worse than none.
|
||||
fn parse_uids(uids: &[String]) -> Result<Vec<NodeUid>, String> {
|
||||
@@ -1363,12 +1627,15 @@ impl Core {
|
||||
self.rt
|
||||
.block_on(self.client.restore_nodes(&parsed))
|
||||
.map_err(|e| format!("restore: {e}"))?;
|
||||
let mut st = self.state.lock().unwrap();
|
||||
for parent in parents {
|
||||
if let Some(&ino) = st.by_uid.get(&parent) {
|
||||
st.invalidate_listing(ino);
|
||||
{
|
||||
let mut st = self.state.lock().unwrap();
|
||||
for parent in parents {
|
||||
if let Some(&ino) = st.by_uid.get(&parent) {
|
||||
st.invalidate_listing(ino);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.invalidate_trash();
|
||||
Ok(parsed.len())
|
||||
}
|
||||
|
||||
@@ -1380,6 +1647,7 @@ impl Core {
|
||||
.block_on(self.client.delete_nodes(&parsed))
|
||||
.map_err(|e| format!("delete: {e}"))?;
|
||||
self.drop_local(&parsed);
|
||||
self.invalidate_trash();
|
||||
Ok(parsed.len())
|
||||
}
|
||||
|
||||
@@ -1395,6 +1663,7 @@ impl Core {
|
||||
.block_on(self.client.empty_trash())
|
||||
.map_err(|e| format!("empty trash: {e}"))?;
|
||||
self.drop_local(&uids);
|
||||
self.invalidate_trash();
|
||||
Ok(uids.len())
|
||||
}
|
||||
|
||||
@@ -1573,6 +1842,7 @@ impl ProtonFs {
|
||||
}
|
||||
self.core.state.lock().unwrap().forget(&uid);
|
||||
self.core.cache.evict(&uid);
|
||||
self.core.invalidate_trash();
|
||||
reply.ok();
|
||||
}
|
||||
}
|
||||
@@ -1613,6 +1883,72 @@ fn node_size(node: &Node) -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wall clock in milliseconds since the epoch — the unit the persisted listings
|
||||
/// stamp their freshness in.
|
||||
fn now_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no
|
||||
/// pixels are decoded. `None` when the format is unknown or the header is torn.
|
||||
fn ratio_of(bytes: &[u8]) -> Option<f64> {
|
||||
let (width, height) = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()?
|
||||
.into_dimensions()
|
||||
.ok()?;
|
||||
(height > 0).then(|| f64::from(width) / f64::from(height))
|
||||
}
|
||||
|
||||
/// A thumbnail the daemon made itself, and the aspect ratio of the photo it was
|
||||
/// made from (free at that point — the full image had to be decoded anyway).
|
||||
struct GeneratedThumb {
|
||||
bytes: Vec<u8>,
|
||||
ratio: f64,
|
||||
}
|
||||
|
||||
/// How one attempt at generating a missing thumbnail ended. The distinction that
|
||||
/// matters is *permanent* versus *transient*: only bytes we cannot decode prove
|
||||
/// the photo will never have a thumbnail, and only that verdict is persisted.
|
||||
enum ThumbAttempt {
|
||||
Made(GeneratedThumb),
|
||||
/// Decoded nothing — a format this build has no decoder for. Permanent.
|
||||
Undecodable,
|
||||
/// The photo couldn't be downloaded. Transient: try again next time.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Scale a full-size photo down to a thumbnail: at most [`THUMB_EDGE`] on its
|
||||
/// longest side, JPEG, aspect ratio preserved. `None` when the bytes aren't an
|
||||
/// image this build can decode — the caller then writes the photo off as
|
||||
/// un-thumbnailable.
|
||||
///
|
||||
/// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool.
|
||||
fn scale_thumbnail(bytes: &[u8]) -> Option<GeneratedThumb> {
|
||||
let image = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()?
|
||||
.decode()
|
||||
.ok()?;
|
||||
let (width, height) = (image.width(), image.height());
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
let ratio = f64::from(width) / f64::from(height);
|
||||
|
||||
// `thumbnail` fits the image *inside* the box, so the longest edge lands on
|
||||
// THUMB_EDGE and the ratio is untouched.
|
||||
let thumb = image.thumbnail(THUMB_EDGE, THUMB_EDGE).to_rgb8();
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, THUMB_QUALITY)
|
||||
.encode_image(&thumb)
|
||||
.ok()?;
|
||||
Some(GeneratedThumb { bytes, ratio })
|
||||
}
|
||||
|
||||
/// Convert a Unix timestamp (seconds, possibly negative) to a `SystemTime`.
|
||||
fn unix_secs(secs: i64) -> SystemTime {
|
||||
if secs >= 0 {
|
||||
@@ -2670,7 +3006,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
false,
|
||||
)) {
|
||||
Ok(uid) => {
|
||||
*core.timeline.lock().unwrap() = None;
|
||||
// The photo we just uploaded belongs at the head of the
|
||||
// timeline, and the GUI reloads the gallery the moment this
|
||||
// reply lands — so refresh now rather than leaving it to a
|
||||
// background pass that would land just after that reload.
|
||||
if let Err(e) = core.rt.block_on(core.refresh_timeline()) {
|
||||
warn!(error = %e, "timeline refresh after upload failed");
|
||||
}
|
||||
CtlResponse::Ok {
|
||||
message: format!("uploaded photo with uid {uid}"),
|
||||
}
|
||||
@@ -2929,7 +3271,9 @@ pub fn mount(
|
||||
})),
|
||||
cache: Arc::new(cache),
|
||||
db,
|
||||
timeline: Arc::new(Mutex::new(None)),
|
||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||
transfers: TransferRegistry::new(),
|
||||
indexing: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
@@ -3047,6 +3391,63 @@ pub fn mount(
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod thumb_tests {
|
||||
use super::{THUMB_EDGE, ratio_of, scale_thumbnail};
|
||||
|
||||
/// A `width`×`height` JPEG, standing in for a camera photo the server never
|
||||
/// generated a thumbnail for.
|
||||
fn jpeg(width: u32, height: u32) -> Vec<u8> {
|
||||
let image = image::RgbImage::from_fn(width, height, |x, y| {
|
||||
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
|
||||
});
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new(&mut bytes)
|
||||
.encode_image(&image)
|
||||
.unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_fits_the_long_edge_and_keeps_the_aspect_ratio() {
|
||||
let photo = jpeg(4000, 3000);
|
||||
let thumb = scale_thumbnail(&photo).expect("a JPEG scales");
|
||||
|
||||
let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes))
|
||||
.with_guessed_format()
|
||||
.unwrap()
|
||||
.into_dimensions()
|
||||
.unwrap();
|
||||
assert_eq!(width, THUMB_EDGE, "the long edge lands on the target");
|
||||
assert_eq!(height, THUMB_EDGE * 3 / 4, "and nothing is cropped");
|
||||
assert!((thumb.ratio - 4.0 / 3.0).abs() < 1e-6);
|
||||
assert!(
|
||||
thumb.bytes.len() < photo.len(),
|
||||
"a thumbnail that isn't smaller than its photo is no thumbnail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_portrait_photo_fits_its_long_edge_too() {
|
||||
let thumb = scale_thumbnail(&jpeg(1000, 2000)).expect("a JPEG scales");
|
||||
assert!(thumb.ratio < 1.0, "portrait stays portrait");
|
||||
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecodable_bytes_are_not_a_thumbnail() {
|
||||
// What a photo in a format this build has no decoder for looks like: the
|
||||
// caller writes it off as un-thumbnailable rather than retrying forever.
|
||||
assert!(scale_thumbnail(b"not an image at all").is_none());
|
||||
assert!(ratio_of(b"not an image at all").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ratio_is_read_from_the_header_alone() {
|
||||
assert_eq!(ratio_of(&jpeg(300, 200)), Some(1.5));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Intervals;
|
||||
|
||||
+104
-16
@@ -60,11 +60,11 @@ const SEARCH_LIMIT: usize = 200;
|
||||
/// content width, so this is the *target* height a row lands near, not an exact
|
||||
/// tile size (see [`justify_rows`]).
|
||||
const TILE_MIN: i32 = 90;
|
||||
const TILE_MAX: i32 = 300;
|
||||
const TILE_DEFAULT: i32 = 180;
|
||||
const TILE_MAX: i32 = 340;
|
||||
const TILE_DEFAULT: i32 = 220;
|
||||
const TILE_STEP: i32 = 30;
|
||||
/// Gap between tiles, horizontally and vertically.
|
||||
const TILE_GAP: i32 = 4;
|
||||
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
|
||||
@@ -82,6 +82,10 @@ const THUMB_BATCH: usize = 16;
|
||||
/// Idle pause before a thumbnail batch is sent, so a fast scroll coalesces into
|
||||
/// one request per settle instead of one per row that flickers past.
|
||||
const THUMB_DEBOUNCE: Duration = Duration::from_millis(60);
|
||||
/// How long to wait before asking again for a thumbnail the daemon is generating
|
||||
/// itself. That means downloading the photo's full file, so the wait is measured
|
||||
/// in seconds, not milliseconds.
|
||||
const THUMB_RETRY: Duration = Duration::from_secs(4);
|
||||
/// Decoded thumbnails held in memory. Each is a few hundred KiB of GPU texture;
|
||||
/// this caps the gallery's footprint while covering several screens of scroll.
|
||||
const TEXTURE_CACHE_MAX: usize = 600;
|
||||
@@ -457,11 +461,13 @@ 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: 4px 2px; }}\n\
|
||||
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; background: alpha(currentColor, 0.08); transition: filter 150ms ease; }}\n\
|
||||
.photo-tile:hover {{ filter: brightness(1.08); }}\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\
|
||||
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
|
||||
.photo-missing {{ background: alpha(currentColor, 0.12); }}\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-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\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\
|
||||
@@ -4134,9 +4140,14 @@ fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// One photo tile: a fixed-size button wrapping the thumbnail. A button (rather
|
||||
/// than a bare picture) so the tile is focusable, keyboard-activatable and gets
|
||||
/// hover feedback for free.
|
||||
/// One photo tile: a fixed-size button wrapping the thumbnail, with the capture
|
||||
/// time revealed on hover over a bottom scrim. A button (rather than a bare
|
||||
/// picture) so the tile is focusable, keyboard-activatable and gets hover feedback
|
||||
/// for free.
|
||||
///
|
||||
/// The picture sits in an overlay over a placeholder, so a tile is never a hole:
|
||||
/// until the thumbnail lands it shows a dim card, and a photo that can never have
|
||||
/// one keeps an image glyph instead of an empty rectangle.
|
||||
fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||
let picture = gtk4::Picture::builder()
|
||||
// The tile is exactly the thumbnail's own aspect ratio, so Cover scales
|
||||
@@ -4146,8 +4157,34 @@ fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||
.can_shrink(true)
|
||||
.build();
|
||||
|
||||
let placeholder = gtk4::Image::builder()
|
||||
.icon_name("image-x-generic-symbolic")
|
||||
.pixel_size(24)
|
||||
.halign(gtk4::Align::Center)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
placeholder.add_css_class("photo-placeholder");
|
||||
|
||||
// The capture time, on a gradient that only exists while the pointer is over
|
||||
// the tile — legible over any photo, invisible the rest of the time.
|
||||
let caption = gtk4::Label::builder()
|
||||
// Fill horizontally so the scrim spans the tile; the text itself stays
|
||||
// left-aligned inside it.
|
||||
.halign(gtk4::Align::Fill)
|
||||
.valign(gtk4::Align::End)
|
||||
.xalign(0.0)
|
||||
.label(short_capture_time(tile.photo.capture_time))
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
caption.add_css_class("photo-caption");
|
||||
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.set_child(Some(&placeholder));
|
||||
overlay.add_overlay(&picture);
|
||||
overlay.add_overlay(&caption);
|
||||
|
||||
let button = gtk4::Button::builder()
|
||||
.child(&picture)
|
||||
.child(&overlay)
|
||||
.width_request(tile.width)
|
||||
.height_request(tile.height)
|
||||
.tooltip_text(format_capture_time(tile.photo.capture_time))
|
||||
@@ -4176,10 +4213,10 @@ fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
||||
picture.set_paintable(Some(texture));
|
||||
return;
|
||||
}
|
||||
// No thumbnail will ever come for this one: leave the tile as its placeholder
|
||||
// (still clickable — the full photo may open fine).
|
||||
// No thumbnail will ever come for this one — not from the server, and not
|
||||
// from the daemon's own scaling of the file. The tile keeps its placeholder
|
||||
// glyph, and stays clickable: the full photo may still open fine.
|
||||
if ui.photo_nothumb.borrow().contains(&photo.uid) {
|
||||
picture.add_css_class("photo-missing");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4203,6 +4240,25 @@ fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Come back for thumbnails the daemon is still generating — it is downloading
|
||||
/// each photo's full file to scale it, which takes far longer than a batch. The
|
||||
/// tiles keep their placeholder until then, and a tile that has scrolled away is
|
||||
/// dropped by [`flush_thumbs`] like any other queued uid.
|
||||
fn retry_pending_thumbs(ui: &Rc<Ui>, uids: Vec<String>) {
|
||||
let ui = ui.clone();
|
||||
glib::timeout_add_local_once(THUMB_RETRY, move || {
|
||||
{
|
||||
let mut queue = ui.thumb_queue.borrow_mut();
|
||||
for uid in uids {
|
||||
if !queue.contains(&uid) {
|
||||
queue.push_back(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
schedule_thumbs(&ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask the daemon for the queued thumbnails after a short pause, so a fast scroll
|
||||
/// coalesces into one batch per settle rather than one per row it flew past.
|
||||
fn schedule_thumbs(ui: &Rc<Ui>) {
|
||||
@@ -4253,11 +4309,15 @@ fn flush_thumbs(ui: &Rc<Ui>) {
|
||||
Ok(Ok(Response::Thumbs { items })) => {
|
||||
let mut decode = ui.decode_queue.borrow_mut();
|
||||
let mut nothumb = ui.photo_nothumb.borrow_mut();
|
||||
let mut pending = Vec::new();
|
||||
for item in items {
|
||||
match item.path {
|
||||
Some(path) => decode.push_back((item.uid, path)),
|
||||
// The photo has no thumbnail at all (or its fetch failed):
|
||||
// remember that, so scrolling past it doesn't re-ask.
|
||||
// The daemon is making this one itself, from the photo's
|
||||
// full file — that takes a download, so come back for it.
|
||||
None if item.pending => pending.push(item.uid),
|
||||
// No thumbnail exists and none can be made: remember that,
|
||||
// so scrolling past the tile doesn't re-ask forever.
|
||||
None => {
|
||||
nothumb.insert(item.uid);
|
||||
}
|
||||
@@ -4265,6 +4325,9 @@ fn flush_thumbs(ui: &Rc<Ui>) {
|
||||
}
|
||||
drop((decode, nothumb));
|
||||
schedule_decode(&ui);
|
||||
if !pending.is_empty() {
|
||||
retry_pending_thumbs(&ui, pending);
|
||||
}
|
||||
}
|
||||
// A thumbnail that doesn't arrive is not worth a toast — the tile just
|
||||
// stays a placeholder, and the next scroll past it tries again.
|
||||
@@ -4491,6 +4554,15 @@ fn format_capture_time(secs: i64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The capture time as a tile caption: the clock time alone, since the day is
|
||||
/// already the section heading right above it.
|
||||
fn short_capture_time(secs: i64) -> String {
|
||||
glib::DateTime::from_unix_local(secs)
|
||||
.and_then(|d| d.format("%H:%M"))
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The lightbox's mutable parts, shared by [`load_photo`], [`navigate_photo`] and
|
||||
/// the button/key handlers so each one takes a single handle instead of a dozen
|
||||
/// widget arguments.
|
||||
@@ -5200,6 +5272,22 @@ fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
{
|
||||
let mut ratios = ui.photo_ratio.borrow_mut();
|
||||
let mut nothumb = ui.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 {
|
||||
ui.gallery_model.append(&BoxedAnyObject::new(item.clone()));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user