diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 35e34df..bf9ccf4 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -679,8 +679,88 @@ pub enum Request { /// passes, …), so a front-end can show a running "what happened" feed /// without re-deriving it from anywhere. ListActivity { limit: usize }, + + // ---- push ------------------------------------------------------------- + /// Keep the connection open and stream [`Event`] lines instead of one + /// [`Response`]. See [`subscribe`] for the client side. + Subscribe, } +/// Something a front-end shows that the daemon can say has changed, so the +/// front-end reads it again. Carried by [`Event::Changed`]. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[serde(rename_all = "snake_case")] +pub enum Topic { + /// Folder listings in the mount: something was created, renamed, moved, + /// trashed or pinned, here or on another device. + Files, + /// The photos timeline or albums. + Photos, + Trash, + /// Shares, links, invitations and saved links, in either direction. + Shares, + /// The account's computers. + Devices, + /// Synced folders, their mode and their live progress. + Locations, + /// The pending-op queue ([`Request::ListPendingOps`]). + Queue, + Conflicts, + /// The activity log gained entries. + Activity, + /// A topic from a newer daemon, which this front-end cannot act on. + #[serde(other)] + Unknown, +} + +impl Topic { + /// Every topic a front-end can act on. A new subscription is told all of + /// them have changed, so it catches up on whatever happened while it was + /// not connected. + pub const ALL: [Topic; 9] = [ + Topic::Files, + Topic::Photos, + Topic::Trash, + Topic::Shares, + Topic::Devices, + Topic::Locations, + Topic::Queue, + Topic::Conflicts, + Topic::Activity, + ]; +} + +/// One line on a [`Request::Subscribe`] connection. +/// +/// The two things front ends show continuously, status and transfers, come +/// with their contents, so following them costs no requests. Everything else is +/// a [`Topic`] to re-read. +#[derive(Serialize, Deserialize, Debug)] +pub enum Event { + /// A fresh [`Response::Status`]. Sent when a subscription starts and + /// whenever any of its fields changes. + Status(Box), + /// A fresh [`Response::Transfers`] snapshot. Sent when a subscription + /// starts and whenever the snapshot changes, which is continuously while + /// something moves. + Transfers { + items: Vec, + jobs: Vec, + }, + /// These topics changed. Changes close together arrive as one event. + Changed { topics: Vec }, + /// Nothing happened. Sent every [`HEARTBEAT`] so a client can tell a quiet + /// daemon from a stuck one. + Heartbeat, +} + +/// How often a quiet subscription sends [`Event::Heartbeat`]. +pub const HEARTBEAT: Duration = Duration::from_secs(10); + +/// How long [`Subscription::next_event`] waits for a line before it gives the +/// daemon up as stuck: a few missed heartbeats. +const SUBSCRIBE_READ_TIMEOUT: Duration = Duration::from_secs(25); + /// Which kind of share entry a [`ShareEntry`] is, and which collection an /// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`] targets. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] @@ -2116,6 +2196,145 @@ pub fn send(socket: &Path, req: &Request) -> Result { Ok(serde_json::from_str(resp.trim())?) } +/// An open [`Request::Subscribe`] connection. See [`subscribe`]. +pub struct Subscription { + reader: BufReader, + line: String, +} + +/// Start following the daemon listening on `socket`. Fails if no daemon is +/// listening. A daemon too old to know [`Request::Subscribe`] answers it with an +/// error, which the first [`Subscription::next_event`] returns as +/// [`crate::Error::Other`]. +pub fn subscribe(socket: &Path) -> Result { + let mut stream = UnixStream::connect(socket)?; + stream.set_read_timeout(Some(SUBSCRIBE_READ_TIMEOUT))?; + stream.set_write_timeout(Some(WRITE_TIMEOUT))?; + let mut line = serde_json::to_vec(&Request::Subscribe)?; + line.push(b'\n'); + stream.write_all(&line)?; + stream.flush()?; + Ok(Subscription { + reader: BufReader::new(stream), + line: String::new(), + }) +} + +impl Subscription { + /// Wait for the next event. `Ok(None)` means the daemon closed the + /// connection; a timeout means it went [`SUBSCRIBE_READ_TIMEOUT`] without + /// even a heartbeat. A line from a newer daemon that this client cannot + /// read is skipped rather than ending the subscription. + pub fn next_event(&mut self) -> Result> { + loop { + self.line.clear(); + if self.reader.read_line(&mut self.line)? == 0 { + return Ok(None); + } + let line = self.line.trim(); + if let Ok(event) = serde_json::from_str::(line) { + return Ok(Some(event)); + } + if let Ok(Response::Error { message, .. }) = serde_json::from_str::(line) { + return Err(crate::Error::Other(message)); + } + } + } +} + +/// What [`follow`] reports. +#[derive(Debug)] +pub enum Feed { + /// A subscription started. The events after it describe the daemon's + /// current state, so a front-end catches up from them. + Connected, + Event(Event), + /// The subscription ended: the daemon stopped, or stopped answering. + Disconnected, +} + +/// How often [`follow`] polls a daemon that cannot push events. +const LEGACY_POLL: Duration = Duration::from_secs(3); + +/// The longest [`follow`] waits between attempts to reach a daemon. +const RECONNECT_MAX: Duration = Duration::from_secs(5); + +/// Follow the daemon on `socket` for as long as `on` returns true, across +/// daemon restarts. Blocks, so run it on a thread of its own. +/// +/// A daemon that predates [`Request::Subscribe`] (still running after an +/// upgrade, until its service restarts) is polled for its status and transfers +/// instead, so a front-end sees the same events either way, only later. +pub fn follow(socket: &Path, mut on: impl FnMut(Feed) -> bool) { + let mut connected = false; + let mut backoff = Duration::from_secs(1); + loop { + let ended = match subscribe(socket) { + Ok(mut subscription) => { + let mut first = true; + loop { + match subscription.next_event() { + Ok(Some(event)) => { + if first { + first = false; + connected = true; + backoff = Duration::from_secs(1); + if !on(Feed::Connected) { + return; + } + } + if !on(Feed::Event(event)) { + return; + } + } + Ok(None) => break Ok(()), + Err(e) => break Err(e), + } + } + } + Err(e) => Err(e), + }; + if let Err(crate::Error::Other(_)) = ended { + if !connected { + connected = true; + if !on(Feed::Connected) { + return; + } + } + if !poll_legacy(socket, &mut on) { + return; + } + std::thread::sleep(LEGACY_POLL); + continue; + } + if connected { + connected = false; + if !on(Feed::Disconnected) { + return; + } + } + std::thread::sleep(backoff); + backoff = (backoff * 2).min(RECONNECT_MAX); + } +} + +/// One round of [`follow`]'s fallback: the status and transfers a subscription +/// would have pushed. Returns what `on` returned. +fn poll_legacy(socket: &Path, on: &mut impl FnMut(Feed) -> bool) -> bool { + match send(socket, &Request::Status) { + Ok(status @ Response::Status { .. }) => { + if !on(Feed::Event(Event::Status(Box::new(status)))) { + return false; + } + } + _ => return true, + } + if let Ok(Response::Transfers { items, jobs }) = send(socket, &Request::GetQueueStatus) { + return on(Feed::Event(Event::Transfers { items, jobs })); + } + true +} + #[cfg(test)] mod tests { use super::*; @@ -2137,6 +2356,59 @@ mod tests { ); } + /// A daemon on a fresh socket that reads the subscription request and + /// answers with `lines`, then hangs up. + fn fake_daemon(name: &str, lines: &'static str) -> std::path::PathBuf { + let socket = std::env::temp_dir().join(format!("pdfs-{name}-{}.sock", std::process::id())); + let _ = std::fs::remove_file(&socket); + let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream); + let mut request = String::new(); + reader.read_line(&mut request).unwrap(); + assert!(matches!( + serde_json::from_str::(request.trim()), + Ok(Request::Subscribe) + )); + reader.get_mut().write_all(lines.as_bytes()).unwrap(); + }); + socket + } + + #[test] + fn a_subscription_skips_what_it_cannot_read() { + let socket = fake_daemon( + "subscribe", + "not json\n\ + {\"Changed\":{\"topics\":[\"files\",\"from_a_newer_daemon\"]}}\n\ + {\"NewEvent\":{}}\n\ + \"Heartbeat\"\n", + ); + let mut subscription = subscribe(&socket).unwrap(); + let first = subscription.next_event().unwrap(); + let second = subscription.next_event().unwrap(); + let end = subscription.next_event().unwrap(); + let _ = std::fs::remove_file(&socket); + match first { + Some(Event::Changed { topics }) => { + assert_eq!(topics, vec![Topic::Files, Topic::Unknown]); + } + other => panic!("expected a change, got {other:?}"), + } + assert!(matches!(second, Some(Event::Heartbeat)), "{second:?}"); + assert!(end.is_none(), "{end:?}"); + } + + #[test] + fn a_daemon_without_subscriptions_is_told_apart_from_a_dead_one() { + let socket = fake_daemon("legacy", "{\"Error\":{\"message\":\"unknown request\"}}\n"); + let mut subscription = subscribe(&socket).unwrap(); + let answer = subscription.next_event(); + let _ = std::fs::remove_file(&socket); + assert!(matches!(answer, Err(crate::Error::Other(_))), "{answer:?}"); + } + #[test] fn a_daemon_that_never_answers_times_out_as_not_responding() { let socket = std::env::temp_dir().join(format!("pdfs-silent-{}.sock", std::process::id())); diff --git a/crates/pdfs-fuse/src/albums.rs b/crates/pdfs-fuse/src/albums.rs index 5b8f43a..b5c7b1b 100644 --- a/crates/pdfs-fuse/src/albums.rs +++ b/crates/pdfs-fuse/src/albums.rs @@ -15,7 +15,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; -use pdfs_core::control::{AlbumInfo, PhotoItem}; +use pdfs_core::control::{AlbumInfo, PhotoItem, Topic}; use pdfs_core::db::StoredAlbum; use pdfs_core::{CoreError, CoreResult}; @@ -137,10 +137,12 @@ impl Core { } let core = self.clone(); self.rt.spawn(async move { - if let Err(e) = core.refresh_albums().await { - warn!(error = %e, "background album refresh failed"); - } + let refreshed = core.refresh_albums().await; core.albums_refreshing.store(false, Ordering::SeqCst); + match refreshed { + Ok(_) => core.events.publish(&[Topic::Photos]), + Err(e) => warn!(error = %e, "background album refresh failed"), + } }); } diff --git a/crates/pdfs-fuse/src/background.rs b/crates/pdfs-fuse/src/background.rs index d6463f5..d89a9c8 100644 --- a/crates/pdfs-fuse/src/background.rs +++ b/crates/pdfs-fuse/src/background.rs @@ -422,6 +422,10 @@ pub(super) async fn run_event_sync( cursor = Some(applied); } flush_dirty_parents(&core, &dirty); + // Another device changed the tree: whatever a front-end lists may have + // moved, including what is shared and what is in the trash. + core.events + .publish(&[Topic::Files, Topic::Trash, Topic::Shares, Topic::Conflicts]); } } @@ -479,6 +483,7 @@ pub(super) async fn run_photos_event_sync(core: Core) { continue; }; apply_photo_events(&core, &events); + core.events.publish(&[Topic::Photos]); // One cursor write per batch, after the batch is applied: a crash in // between replays events whose effects are idempotent. if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) { diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 3feea09..966cf67 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -17,8 +17,8 @@ use std::time::{Duration, Instant}; use pdfs_core::config::AppDirs; use pdfs_core::control::{ - ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse, - TransferDirection, TrashFailure, + ActivityKind, Event, HEARTBEAT, PhotoMonth, RefreshScope, Request as CtlRequest, + Response as CtlResponse, Topic, TransferDirection, TrashFailure, }; use pdfs_core::{CoreError, CoreResult}; use proton_drive_rs::proton_sdk::ids::NodeUid; @@ -286,6 +286,155 @@ impl Drop for RefreshGuard { } } +/// The reply to [`CtlRequest::Status`], which subscriptions are also sent +/// whenever it changes. +pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) -> CtlResponse { + let pins = core.cache.list_pins(); + let queued = core.db.pending_op_counts().unwrap_or_default(); + let staging = core.cache.staging_usage(); + CtlResponse::Status { + parked_uploads: queued.parked.max(0) as u64, + failing_ops: queued.failing.max(0) as u64, + failing_error: queued.last_error.clone(), + staged_bytes: staging.bytes, + staged_oldest_secs: staging.oldest_secs, + paused: core.sync_pause().is_some(), + paused_until: core.sync_pause().flatten(), + username: username.to_string(), + mountpoint: mountpoint.display().to_string(), + pinned: pins.len(), + used: core.cache.usage(), + budget: core.cache.budget(), + pins, + online: core.online.load(Ordering::Relaxed), + pending_uploads: queued.uploads.max(0) as u64, + pending_changes: queued.changes.max(0) as u64, + } +} + +/// What a front-end shows that `request` changes when it succeeds. Requests +/// from the CLI publish the same way as the app's, so an open window follows +/// a change made in a terminal. +fn touched_topics(request: &CtlRequest) -> &'static [Topic] { + use CtlRequest as R; + match request { + R::Pin { .. } | R::Unpin { .. } => &[Topic::Files], + R::CreateAlbum { .. } + | R::RenameAlbum { .. } + | R::DeleteAlbum { .. } + | R::AddToAlbum { .. } + | R::RemoveFromAlbum { .. } + | R::SetPhotoFavorite { .. } + | R::UploadPhoto { .. } => &[Topic::Photos], + R::TrashNodes { .. } => &[Topic::Files, Topic::Photos, Topic::Trash, Topic::Conflicts], + R::Rename { .. } | R::Move { .. } | R::CreateFolder { .. } | R::UploadPaths { .. } => { + &[Topic::Files, Topic::Conflicts] + } + R::Delete { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts], + R::Restore { .. } | R::DeleteForever { .. } | R::EmptyTrash => { + &[Topic::Files, Topic::Photos, Topic::Trash, Topic::Conflicts] + } + R::Refresh { scope } => match scope { + RefreshScope::Dir { .. } => &[Topic::Files], + RefreshScope::Trash => &[Topic::Trash], + // A photos refresh answers once it has started; the timeline + // publishes again when it lands. + RefreshScope::Photos { .. } => &[], + }, + R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices], + R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations], + R::AddSyncFolder { .. } + | R::RemoveSyncFolder { .. } + | R::SetSyncFolderMode { .. } + | R::SetSyncFolderPaused { .. } + | R::RestoreSyncFolders { .. } + | R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices], + R::RetryPendingOp { .. } => &[Topic::Queue], + R::ResolveConflict { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts], + R::ShareNode { .. } + | R::ShareNodeByUid { .. } + | R::UpdateShareRole { .. } + | R::UpdateShareRoleByUid { .. } + | R::RemoveShareEntry { .. } + | R::RemoveShareEntryByUid { .. } + | R::CreatePublicLink { .. } + | R::CreatePublicLinkByUid { .. } + | R::RemovePublicLink { .. } + | R::RemovePublicLinkByUid { .. } + | R::StopSharing { .. } + | R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files], + R::LeaveShared { .. } + | R::AcceptInvitation { .. } + | R::RejectInvitation { .. } + | R::CreateBookmark { .. } + | R::DeleteBookmark { .. } => &[Topic::Shares], + R::RestoreRevision { .. } + | R::RestoreRevisionByUid { .. } + | R::SaveRevisionAs { .. } + | R::SaveRevisionAsByUid { .. } => &[Topic::Files], + _ => &[], + } +} + +/// How long a subscription's writes may block. A client that stops reading +/// is dropped after this rather than holding its handler forever. +const SUBSCRIPTION_WRITE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Serve a [`CtlRequest::Subscribe`]: send the current status and transfers +/// and say every topic changed, so the client catches up, then send whatever +/// the [`EventHub`](super::events::EventHub) collects, with a heartbeat when +/// nothing does. Ends when the client goes away or the daemon stops. +fn serve_subscription(core: &Core, username: &str, mountpoint: &Path, mut stream: UnixStream) { + let Some(mailbox) = core.events.subscribe() else { + warn!("control: refusing a subscription at the limit"); + if let Ok(mut out) = serde_json::to_vec(&CtlResponse::error(CoreError::remote( + "too many subscriptions", + ))) { + out.push(b'\n'); + let _ = stream.write_all(&out); + } + return; + }; + struct Unsubscribe<'a>(&'a Core, std::sync::Arc); + impl Drop for Unsubscribe<'_> { + fn drop(&mut self) { + self.0.events.unsubscribe(&self.1); + } + } + let _unsubscribe = Unsubscribe(core, mailbox.clone()); + if let Err(e) = stream.set_write_timeout(Some(SUBSCRIPTION_WRITE_TIMEOUT)) { + warn!(error = %e, "control: could not set subscription timeout"); + return; + } + let heartbeat = super::events::event_line(&Event::Heartbeat); + let mut lines: Vec<_> = [ + super::events::event_line(&Event::Status(Box::new(status_response( + core, username, mountpoint, + )))), + super::events::transfers_line(core.transfers.snapshot(), core.jobs_snapshot()), + super::events::event_line(&Event::Changed { + topics: Topic::ALL.to_vec(), + }), + ] + .into_iter() + .flatten() + .collect(); + loop { + for line in &lines { + if stream.write_all(line.as_bytes()).is_err() { + return; + } + } + if core.shutdown.is_stopping() { + return; + } + lines = mailbox.take(HEARTBEAT); + if lines.is_empty() { + lines.extend(heartbeat.clone()); + } + } +} + fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) { if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) { warn!(error = %e, "control: could not set request timeout"); @@ -310,32 +459,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U return; } }; + let request = serde_json::from_str::(line.trim()); + // A subscription lasts as long as its client, so it is not a request in + // flight and stays out of the diagnostics' list of them. + if let Ok(CtlRequest::Subscribe) = request { + serve_subscription(core, username, mountpoint, stream); + return; + } + let touched = request.as_ref().map_or(&[][..], touched_topics); // Registered before the request is even parsed, so a handler that wedges // inside the daemon shows up by name in `pdfs diagnostics`. let _inflight = super::diagnostics::InflightGuard::new(super::diagnostics::request_kind(&line)); - let response = match serde_json::from_str::(line.trim()) { - Ok(CtlRequest::Status) => { - let pins = core.cache.list_pins(); - let queued = core.db.pending_op_counts().unwrap_or_default(); - let staging = core.cache.staging_usage(); - CtlResponse::Status { - parked_uploads: queued.parked.max(0) as u64, - failing_ops: queued.failing.max(0) as u64, - failing_error: queued.last_error.clone(), - staged_bytes: staging.bytes, - staged_oldest_secs: staging.oldest_secs, - paused: core.sync_pause().is_some(), - paused_until: core.sync_pause().flatten(), - username: username.to_string(), - mountpoint: mountpoint.display().to_string(), - pinned: pins.len(), - used: core.cache.usage(), - budget: core.cache.budget(), - pins, - online: core.online.load(Ordering::Relaxed), - pending_uploads: queued.uploads.max(0) as u64, - pending_changes: queued.changes.max(0) as u64, - } + let response = match request { + Ok(CtlRequest::Status) => status_response(core, username, mountpoint), + Ok(CtlRequest::Subscribe) => { + CtlResponse::error(CoreError::invalid("a subscription is served on its own")) } Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.pin(&rel) { @@ -1584,6 +1722,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U // The request did not parse: the caller sent something malformed. Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))), }; + if !touched.is_empty() && !matches!(response, CtlResponse::Error { .. }) { + core.events.publish(touched); + } let mut out = match serde_json::to_vec(&response) { Ok(v) => v, Err(e) => { diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 66f51e3..d06fc71 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -1184,6 +1184,10 @@ impl Core { format!("{reason}; local changes uploaded as {alt}"), false, ); + self.events.publish(&[ + pdfs_core::control::Topic::Files, + pdfs_core::control::Topic::Conflicts, + ]); info!(%uid, name, alt, "queued write landed as a conflict copy"); Ok(()) } diff --git a/crates/pdfs-fuse/src/events.rs b/crates/pdfs-fuse/src/events.rs new file mode 100644 index 0000000..dd32c2c --- /dev/null +++ b/crates/pdfs-fuse/src/events.rs @@ -0,0 +1,321 @@ +//! Push notifications for front-ends: the daemon's side of +//! [`Request::Subscribe`](pdfs_core::control::Request::Subscribe). +//! +//! Front-ends used to poll. The app asked for the status every two seconds, the +//! tray every three, and pages re-read themselves on a timer, whether or not +//! anything had changed. Now each subscription owns a [`Mailbox`] in the +//! [`EventHub`]. Code that changes something publishes a [`Topic`], and +//! [`run_event_sampler`] watches the few things that change without passing +//! through one place (the status, transfers, sync progress) and publishes them +//! when they differ. + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::{Condvar, Mutex, MutexGuard}; +use pdfs_core::control::{Event, JobItem, Response as CtlResponse, Topic, TransferItem}; +use tracing::warn; + +use super::Core; + +/// Most subscriptions served at once. Each holds a control handler for as long +/// as it lasts; the app and the tray need one apiece, and the cap keeps a +/// misbehaving client from spending the handlers that requests need. +const MAX_SUBSCRIBERS: usize = 8; + +/// How long a subscription waits after a change before it sends, so a burst +/// (a folder of files trashed at once) goes out as one event. +const COALESCE: Duration = Duration::from_millis(150); + +/// How often [`run_event_sampler`] looks at transfers. Status and sync +/// progress are looked at every other time. +const SAMPLE_INTERVAL: Duration = Duration::from_millis(500); + +/// Every open subscription's mailbox. Shared by every clone of [`Core`], +/// on-demand forks included, so a change in any mount reaches every client. +#[derive(Default)] +pub(crate) struct EventHub { + mailboxes: Mutex>>, +} + +/// What one subscription has yet to send. +#[derive(Default)] +pub(crate) struct Mailbox { + inbox: Mutex, + ready: Condvar, +} + +#[derive(Default)] +struct Inbox { + topics: BTreeSet, + /// The newest [`Event::Status`] line. A newer one replaces it unsent: a + /// client wants the current status, not every status in between. + status: Option>, + /// The newest [`Event::Transfers`] line, replaced the same way. + transfers: Option>, +} + +impl Inbox { + fn is_empty(&self) -> bool { + self.topics.is_empty() && self.status.is_none() && self.transfers.is_none() + } +} + +/// `event` as one line of the wire format, newline included. +pub(crate) fn event_line(event: &Event) -> Option> { + match serde_json::to_string(event) { + Ok(mut line) => { + line.push('\n'); + Some(line.into()) + } + Err(e) => { + warn!(error = %e, "events: serialize event failed"); + None + } + } +} + +impl EventHub { + /// Tell every subscriber that `topics` changed. + pub(crate) fn publish(&self, topics: &[Topic]) { + self.deliver(|inbox| inbox.topics.extend(topics.iter().copied())); + } + + /// Hand every subscriber a fresh status. + fn publish_status(&self, line: Arc) { + self.deliver(|inbox| inbox.status = Some(line.clone())); + } + + /// Hand every subscriber a fresh transfers snapshot. + fn publish_transfers(&self, line: Arc) { + self.deliver(|inbox| inbox.transfers = Some(line.clone())); + } + + fn deliver(&self, put: impl Fn(&mut Inbox)) { + for mailbox in self.mailboxes.lock().iter() { + put(&mut mailbox.inbox.lock()); + mailbox.ready.notify_one(); + } + } + + /// Open a mailbox, or `None` at [`MAX_SUBSCRIBERS`]. + pub(crate) fn subscribe(&self) -> Option> { + let mut mailboxes = self.mailboxes.lock(); + if mailboxes.len() >= MAX_SUBSCRIBERS { + return None; + } + let mailbox = Arc::new(Mailbox::default()); + mailboxes.push(mailbox.clone()); + Some(mailbox) + } + + pub(crate) fn unsubscribe(&self, mailbox: &Arc) { + self.mailboxes + .lock() + .retain(|other| !Arc::ptr_eq(other, mailbox)); + } + + fn has_subscribers(&self) -> bool { + !self.mailboxes.lock().is_empty() + } +} + +impl Mailbox { + /// Wait up to `timeout` for something to send, then take it as wire lines: + /// the status first, then transfers, then the changed topics. Empty when + /// nothing arrived in time. + pub(crate) fn take(&self, timeout: Duration) -> Vec> { + let mut inbox = self.inbox.lock(); + if inbox.is_empty() { + self.ready.wait_for(&mut inbox, timeout); + if inbox.is_empty() { + return Vec::new(); + } + // Let the rest of a burst arrive before sending the first of it. + MutexGuard::unlocked(&mut inbox, || std::thread::sleep(COALESCE)); + } + let mut lines = Vec::new(); + lines.extend(inbox.status.take()); + lines.extend(inbox.transfers.take()); + if !inbox.topics.is_empty() { + let topics = std::mem::take(&mut inbox.topics).into_iter().collect(); + lines.extend(event_line(&Event::Changed { topics })); + } + lines + } +} + +/// The [`Event::Transfers`] line for a snapshot. +pub(crate) fn transfers_line(items: Vec, jobs: Vec) -> Option> { + event_line(&Event::Transfers { items, jobs }) +} + +/// Watch what changes without a single place to publish it from, and publish it +/// when it differs from the last look: the status (queue counts, cache use, +/// online, pause), the transfers snapshot, and the synced folders with their +/// live progress. Looks only while someone is subscribed. +pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::path::PathBuf) { + let mut transfers: Option> = None; + let mut status: Option = None; + let mut queue: Option = None; + let mut locations: Option = None; + let mut tick = 0u32; + while core.shutdown.sleep(SAMPLE_INTERVAL) { + if !core.events.has_subscribers() { + // A new subscription starts from a snapshot of its own, so there is + // nothing to compare against until then. + transfers = None; + status = None; + queue = None; + locations = None; + continue; + } + let line = transfers_line(core.transfers.snapshot(), core.jobs_snapshot()); + if line.is_some() && line != transfers { + transfers = line.clone(); + if let Some(line) = line { + core.events.publish_transfers(line); + } + } + + tick = tick.wrapping_add(1); + if !tick.is_multiple_of(2) { + continue; + } + let now = super::control::status_response(&core, &username, &mountpoint); + let (key, queue_key) = status_keys(&now); + if status.as_ref().is_some_and(|last| *last != key) + && let Some(line) = event_line(&Event::Status(Box::new(now))) + { + core.events.publish_status(line); + } + status = Some(key); + if queue.as_ref().is_some_and(|last| *last != queue_key) { + core.events.publish(&[Topic::Queue]); + } + queue = Some(queue_key); + + if let Ok(now) = core.list_locations() { + let key = serde_json::to_string(&now).unwrap_or_default(); + if locations.as_ref().is_some_and(|last| *last != key) { + core.events.publish(&[Topic::Locations]); + } + locations = Some(key); + } + } +} + +/// What makes two statuses different enough to send, and what makes their +/// queues different. The age of the oldest staged write is left out of both: +/// it grows every second while anything is staged, which is not news. +fn status_keys(status: &CtlResponse) -> (String, String) { + let CtlResponse::Status { + parked_uploads, + failing_ops, + failing_error, + staged_bytes, + paused, + paused_until, + username, + mountpoint, + pinned, + used, + budget, + online, + pending_uploads, + pending_changes, + .. + } = status + else { + return (String::new(), String::new()); + }; + let queue = format!( + "{pending_uploads}/{pending_changes}/{parked_uploads}/{failing_ops}/{failing_error:?}" + ); + let key = format!( + "{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\ + {used}/{budget}/{online}" + ); + (key, queue) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn changed(line: &str) -> Vec { + match serde_json::from_str::(line.trim()) { + Ok(Event::Changed { topics }) => topics, + other => panic!("not a change event: {other:?}"), + } + } + + #[test] + fn a_burst_of_changes_arrives_as_one_event() { + let hub = EventHub::default(); + let mailbox = hub.subscribe().expect("room for a subscriber"); + hub.publish(&[Topic::Trash, Topic::Files]); + hub.publish(&[Topic::Files]); + let lines = mailbox.take(Duration::from_secs(1)); + assert_eq!(lines.len(), 1); + assert_eq!(changed(&lines[0]), vec![Topic::Files, Topic::Trash]); + assert!(mailbox.take(Duration::from_millis(10)).is_empty()); + } + + #[test] + fn a_newer_status_replaces_one_not_yet_sent() { + let hub = EventHub::default(); + let mailbox = hub.subscribe().expect("room for a subscriber"); + hub.publish_status("first\n".into()); + hub.publish_status("second\n".into()); + assert_eq!( + mailbox.take(Duration::from_secs(1)), + vec![Arc::::from("second\n")] + ); + } + + #[test] + fn a_closed_subscription_gets_nothing_more() { + let hub = EventHub::default(); + let mailbox = hub.subscribe().expect("room for a subscriber"); + hub.unsubscribe(&mailbox); + hub.publish(&[Topic::Files]); + assert!(mailbox.take(Duration::from_millis(10)).is_empty()); + assert!(!hub.has_subscribers()); + } + + #[test] + fn subscriptions_are_capped() { + let hub = EventHub::default(); + let open: Vec<_> = (0..MAX_SUBSCRIBERS) + .map(|_| hub.subscribe().expect("room for a subscriber")) + .collect(); + assert!(hub.subscribe().is_none()); + hub.unsubscribe(&open[0]); + assert!(hub.subscribe().is_some()); + } + + #[test] + fn the_age_of_a_staged_write_is_not_a_change() { + let status = |staged_oldest_secs| CtlResponse::Status { + parked_uploads: 0, + failing_ops: 0, + failing_error: None, + staged_bytes: 10, + staged_oldest_secs, + paused: false, + paused_until: None, + username: "u".into(), + mountpoint: "/m".into(), + pinned: 0, + used: 0, + budget: 0, + pins: Vec::new(), + online: true, + pending_uploads: 1, + pending_changes: 0, + }; + assert_eq!(status_keys(&status(5)), status_keys(&status(6))); + } +} diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index ae62d0b..c966366 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode}; use pdfs_core::control::{ ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo, SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress, - ThumbnailBuildStatus, TransferDirection, + ThumbnailBuildStatus, Topic, TransferDirection, }; use pdfs_core::db::{ Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp, @@ -86,6 +86,7 @@ use control::run_control_socket; mod devices; mod diagnostics; mod drain; +mod events; mod filesystem; pub use filesystem::ProtonFs; mod mount; @@ -665,6 +666,9 @@ struct Core { /// In-flight upload/download progress, served to `GetQueueStatus`. Shared /// across the FUSE session and the control-socket task. transfers: Arc, + /// Open [`Request::Subscribe`](pdfs_core::control::Request::Subscribe) + /// connections, which every change a front-end shows is published to. + events: Arc, /// True while the background scanner is rebuilding the local-file index, so /// `SearchLocal` can tell a front-end "still indexing" apart from "no match". indexing: Arc, @@ -4533,10 +4537,12 @@ impl Core { } let core = self.clone(); self.rt.spawn(async move { - if let Err(e) = core.refresh_trash().await { - warn!(error = %e, "background trash refresh failed"); - } + let refreshed = core.refresh_trash().await; core.trash_refreshing.store(false, Ordering::SeqCst); + match refreshed { + Ok(_) => core.events.publish(&[Topic::Trash]), + Err(e) => warn!(error = %e, "background trash refresh failed"), + } // After the flag, so a waiter that wakes on this sees it cleared. core.trash_progress.notify_waiters(); }); @@ -4827,6 +4833,14 @@ impl Core { }; if let Err(e) = self.db.activity_add(&entry) { warn!(error = ?e, "could not record activity"); + return; + } + // A sync pass is how a conflict copy reaches the listing, and the sweep + // reports the ones it finds as conflicts. + if matches!(entry.kind, ActivityKind::Conflict | ActivityKind::Sync) { + self.events.publish(&[Topic::Activity, Topic::Conflicts]); + } else { + self.events.publish(&[Topic::Activity]); } } diff --git a/crates/pdfs-fuse/src/mount.rs b/crates/pdfs-fuse/src/mount.rs index bfe11f9..5b159f5 100644 --- a/crates/pdfs-fuse/src/mount.rs +++ b/crates/pdfs-fuse/src/mount.rs @@ -416,6 +416,7 @@ pub fn mount( notifier: Arc::new(OnceLock::new()), session_live: Arc::new(AtomicBool::new(false)), transfers: TransferRegistry::new(), + events: Arc::new(Default::default()), indexing: Arc::new(AtomicBool::new(false)), sync_progress: Arc::new(Mutex::new(HashMap::new())), sync_tx, @@ -568,6 +569,22 @@ pub fn mount( } } } + // Publishes what changes without one place to publish it from (status, + // transfers, sync progress) to the front-ends subscribed on that socket. + { + let sampler_core = core.clone(); + let username = username.clone(); + let mountpoint = mountpoint.to_path_buf(); + match std::thread::Builder::new() + .name("pdfs-events".into()) + .spawn(move || events::run_event_sampler(sampler_core, username, mountpoint)) + { + Ok(handle) => workers.push(handle), + // Not fatal: front-ends still get every published change, only + // not the sampled ones. + Err(error) => warn!(error = %error, "could not start the event sampler"), + } + } // The supervisor watches the pool and answers the systemd watchdog. Started // after the listener, because its liveness probe is a round trip over that // socket. diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index f4cb472..a9486c3 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -19,7 +19,7 @@ use std::time::{Duration, Instant}; use base64::Engine as _; use pdfs_core::control::{ FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo, - ThumbnailBuildStatus, is_raw_image_name, is_thumbnail_image_name, + ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name, }; use pdfs_core::db::{self, StoredPhoto}; use pdfs_core::{CoreError, CoreResult}; @@ -2041,10 +2041,14 @@ impl Core { } let core = self.clone(); self.rt.spawn(async move { - if let Err(e) = core.refresh_timeline().await { - warn!(error = %e, "background timeline refresh failed"); - } + let refreshed = core.refresh_timeline().await; core.timeline_refreshing.store(false, Ordering::SeqCst); + // Only a refresh that landed: a front-end re-reading after a failed + // one would find the timeline still stale and start another. + match refreshed { + Ok(_) => core.events.publish(&[Topic::Photos]), + Err(e) => warn!(error = %e, "background timeline refresh failed"), + } }); } diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-link-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-link-symbolic.svg index b4848b0..c48808d 100644 --- a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-link-symbolic.svg +++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-link-symbolic.svg @@ -1,9 +1,8 @@ - - - - - + on the diagonal. Written out in plain coordinates: GTK's symbolic icon + renderer drops a transformed group. --> + + + diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-sync-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-sync-symbolic.svg index 97bd3d3..43a9fab 100644 --- a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-sync-symbolic.svg +++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-sync-symbolic.svg @@ -1,10 +1,10 @@ + Adwaita 50 no longer ships emblem-synchronizing-symbolic. The lower arrow + is the upper one turned half a circle, written out in plain coordinates: + GTK's symbolic icon renderer drops a transformed group. --> - - - - + + diff --git a/crates/pdfs-gui/resources/style.css b/crates/pdfs-gui/resources/style.css index 7c18274..20b42cd 100644 --- a/crates/pdfs-gui/resources/style.css +++ b/crates/pdfs-gui/resources/style.css @@ -66,19 +66,19 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; } .viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; } .viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; } .viewer-nav-btn:disabled { opacity: 0; } -.photo-scrubber { min-width: 20px; padding: 0 2px; } -.photo-scrubber value { font-weight: 600; background-color: var(--popover-bg-color); border-radius: 6px; padding: 2px 8px; } +.photo-scrubber { background: linear-gradient(to left, alpha(var(--window-bg-color), 0.92) 40%, alpha(var(--window-bg-color), 0)); } +.photo-scrubber-knob { background-color: var(--accent-bg-color); border-radius: 2px; } +.photo-scrubber-bubble { font-weight: 600; background-color: var(--popover-bg-color); color: var(--popover-fg-color); border-radius: 999px; padding: 4px 12px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); } .viewer-spinner { color: white; } .viewer-status { color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; } .viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); } /* Photos grid */ .gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 2px 2px 6px 2px; } -.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; transition: box-shadow 180ms ease; } -.photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); } +.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; } .photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; } .photo-thumb { transition: transform 220ms ease; } -.photo-tile:hover .photo-thumb { transform: scale(1.06); } +.photo-tile:hover .photo-thumb { transform: scale(1.02); } .photo-check { color: white; background: rgba(0, 0, 0, 0.5); border-radius: 999px; padding: 3px; } .photo-check-on { background: var(--accent-bg-color); color: var(--accent-fg-color); } .photo-tile-selected { outline: 3px solid var(--accent-bg-color); outline-offset: -3px; } @@ -97,7 +97,7 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; } .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; } .album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); } .album-card:hover .photo-thumb { transform: scale(1.05); } -.view-switch button { padding-left: 18px; padding-right: 18px; } +.view-switch button, .view-switch toggle { padding-left: 18px; padding-right: 18px; } /* Shared with Me: what a share lets me do, as a tinted pill beside the name. */ .role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); } diff --git a/crates/pdfs-gui/src/app/events.rs b/crates/pdfs-gui/src/app/events.rs new file mode 100644 index 0000000..e951b1d --- /dev/null +++ b/crates/pdfs-gui/src/app/events.rs @@ -0,0 +1,179 @@ +//! The window's side of the daemon's event feed ([`Request::Subscribe`]). +//! +//! The window used to ask the daemon for its status every two seconds and let +//! pages re-read themselves on timers. Now one thread follows the feed and hands +//! each event to the main loop: a status or transfers snapshot is painted as it +//! comes, and a change reloads the page on screen if the page shows it. A +//! window nobody can see keeps its changes for later and catches up when it is +//! focused again. + +use std::collections::BTreeSet; + +use pdfs_core::control::{Event, Feed, follow}; + +use crate::*; + +/// How long the conflict list waits after a change before it is read again. +/// Listing walks every node the daemon knows, and a sync pass can report +/// changes many times a second. +const CONFLICTS_DEBOUNCE: Duration = Duration::from_secs(3); + +/// How long the import's state waits after its progress moved before it is +/// read again. +const TAKEOUT_DEBOUNCE: Duration = Duration::from_secs(2); + +/// How old the invitations may be when the window is focused. Another person +/// inviting this account sends the daemon no event, so focus is when they are +/// looked for. +const INVITATIONS_ON_FOCUS: Duration = Duration::from_secs(60); + +/// What the feed keeps between events. +#[derive(Default)] +struct FeedState { + /// What changed while the window was out of sight. + deferred: RefCell>, + /// Whether a conflict read is already waiting out [`CONFLICTS_DEBOUNCE`]. + conflicts_pending: Rc>, + /// Whether an import read is already waiting out [`TAKEOUT_DEBOUNCE`]. + takeout_pending: Rc>, +} + +/// Follow the daemon for as long as `window` is open. +pub(crate) fn follow_daemon(ui: &Rc, window: &adw::ApplicationWindow) { + let (tx, rx) = async_channel::bounded(64); + let socket = ui.dirs.control_socket(); + let spawned = std::thread::Builder::new() + .name("pdfs-feed".into()) + .spawn(move || follow(&socket, |feed| tx.send_blocking(feed).is_ok())); + if let Err(e) = spawned { + tracing::error!("cannot start the event feed: {e}"); + return; + } + + let state = Rc::new(FeedState::default()); + let ui_feed = ui.clone(); + let state_feed = state.clone(); + let window_feed = window.downgrade(); + let rx_feed = rx.clone(); + glib::spawn_future_local(async move { + while let Ok(feed) = rx_feed.recv().await { + let hidden = window_feed.upgrade().is_none_or(|w| window_hidden(&w)); + on_feed(&ui_feed, &state_feed, feed, hidden); + } + }); + + // Catch up as soon as the user comes back to the window. + let ui_focus = ui.clone(); + window.connect_is_active_notify(move |window| { + if !window.is_active() { + return; + } + let topics: Vec = state.deferred.take().into_iter().collect(); + if !topics.is_empty() { + follow_changes(&ui_focus, &state, &topics); + } + refresh_quota(&ui_focus); + refresh_invitations_badge(&ui_focus, INVITATIONS_ON_FOCUS); + }); + // Closing the channel ends the loop above, and the thread with the next + // event it tries to hand over. + window.connect_close_request(move |_| { + rx.close(); + glib::Propagation::Proceed + }); +} + +fn on_feed(ui: &Rc, state: &Rc, feed: Feed, hidden: bool) { + match feed { + // The daemon follows up with its whole state, so there is nothing to + // ask for here. + Feed::Connected | Feed::Event(Event::Heartbeat) => {} + Feed::Event(Event::Status(status)) => { + paint_status(ui, *status); + // Uploads are what move the quota, and they move the status too. + refresh_quota(ui); + } + // Painted even out of sight: the end of a batch is what sends the + // "Sync complete" notification. + Feed::Event(Event::Transfers { items, jobs }) => { + repaint_transfers(ui, &items, &jobs); + follow_takeout(ui, state, &jobs); + } + Feed::Event(Event::Changed { topics }) if hidden => { + state.deferred.borrow_mut().extend(topics); + } + Feed::Event(Event::Changed { topics }) => follow_changes(ui, state, &topics), + // Ask once, which paints "not connected" or "not responding", and clear + // progress that can no longer move. + Feed::Disconnected => { + refresh_status(ui); + repaint_transfers(ui, &[], &[]); + } + } +} + +/// Reload what `topics` says changed: the sidebar badges wherever the user +/// is, and the page on screen if it shows one of them. +fn follow_changes(ui: &Rc, state: &FeedState, topics: &[Topic]) { + let changed = |topic| topics.contains(&topic); + if changed(Topic::Conflicts) { + let ui = ui.clone(); + debounce(&state.conflicts_pending, CONFLICTS_DEBOUNCE, move || { + refresh_conflicts(&ui); + if activity_visible(&ui) { + refresh_activity_conflicts(&ui, true); + } + }); + } + if changed(Topic::Shares) { + refresh_invitations_badge(ui, Duration::ZERO); + } + match ui.stack.visible_child_name().as_deref() { + Some("browser") if changed(Topic::Files) => load_browser(ui), + // The timeline is left alone: reloading it would throw away the place + // the user scrolled to. F5 reloads it on request. + Some("gallery") + if changed(Topic::Photos) + && ui.gallery.content.visible_child_name().as_deref() == Some("albums") => + { + load_albums(ui) + } + Some("trash") if changed(Topic::Trash) => load_trash(ui), + Some("shared") if changed(Topic::Shares) => load_shared(ui), + Some("sharedbyme") if changed(Topic::Shares) || changed(Topic::Files) => { + load_shared_by_me(ui) + } + Some("devices") if changed(Topic::Devices) => load_devices(ui), + Some("locations") => follow_sync_view(ui, topics), + _ => {} + } +} + +/// Read the import's state again while its progress moves, and once more after +/// it stops, which is how a finished import is noticed. Nothing is read while +/// no import runs and the page is elsewhere. +fn follow_takeout(ui: &Rc, state: &FeedState, jobs: &[JobItem]) { + let importing = jobs.iter().any(|job| job.title == IMPORT_JOB); + if importing + || ui.takeout.running.get() + || ui.stack.visible_child_name().as_deref() == Some("takeout") + { + let ui = ui.clone(); + debounce(&state.takeout_pending, TAKEOUT_DEBOUNCE, move || { + refresh_takeout(&ui) + }); + } +} + +/// Run `f` once `delay` has passed, unless a run is already waiting. The run +/// sees everything that happened up to it, so nothing is missed by skipping. +fn debounce(pending: &Rc>, delay: Duration, f: impl FnOnce() + 'static) { + if pending.replace(true) { + return; + } + let pending = pending.clone(); + glib::timeout_add_local_once(delay, move || { + pending.set(false); + f(); + }); +} diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 3e43194..643366f 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -1,12 +1,17 @@ #[path = "../activation.rs"] pub(crate) mod activation; +#[path = "../compat.rs"] +pub(crate) mod compat; pub(crate) mod dates; +pub(crate) mod events; #[path = "../i18n.rs"] pub(crate) mod i18n; pub(crate) mod pages; pub(crate) mod reload; pub(crate) mod widgets; +use compat::*; +use events::*; use pages::activity::*; use pages::albums::*; use pages::browser::*; @@ -25,9 +30,11 @@ use pages::takeout::*; use pages::trash::*; use pages::verify::*; use reload::{LoadTicket, Loader, replace_items}; +use widgets::compat::*; use widgets::details::*; use widgets::file_list::*; use widgets::menu::*; +use widgets::scrubber::*; use widgets::share_dialog::*; use widgets::thumbnails::*; use widgets::versions_dialog::*; @@ -65,8 +72,8 @@ use pdfs_core::control::{ DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, - SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem, - send, + SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic, TransferDirection, + TransferItem, send, }; use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; @@ -75,25 +82,10 @@ use pdfs_core::service; const APP_ID: &str = "io.narl.proton-drive-linux"; -/// How often the window re-reads mount status, cache usage and the pin list. -const REFRESH_INTERVAL: Duration = Duration::from_secs(2); - -/// While the window is open but not focused, only every this-many ticks -/// refreshes (every 10 s). A minimised or hidden window skips them all, and -/// focusing the window refreshes at once. -const BACKGROUND_REFRESH_EVERY: u32 = 5; - /// Backoff between auto-retries of a Files/Photos load while the mount service /// is still coming up (see [`load_browser`] / [`load_gallery`]). const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2); -/// How long a network-backed page (Shared, Shared-by-me, Devices, Activity) is -/// considered fresh. Re-navigating to it within this window reuses the rows -/// already on screen instead of re-fetching and flashing the "Loading…" -/// placeholder. The Retry button and every mutation still force an immediate -/// reload by clearing the page's timestamp. -const PAGE_TTL: Duration = Duration::from_secs(30); - /// How often the gallery asks whether the timeline refresh it started has /// finished. const PHOTOS_REFRESH_POLL: Duration = Duration::from_secs(2); @@ -627,7 +619,6 @@ fn build_window(app: &adw::Application) { pager: gallery_widgets.pager.clone(), scrubber: gallery_widgets.scrubber.clone(), months: RefCell::new(Vec::new()), - scrubbing: Cell::new(false), scrub_source: RefCell::new(None), jump: Cell::new(None), burst: Cell::new(false), @@ -638,21 +629,18 @@ fn build_window(app: &adw::Application) { 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), places: gallery_widgets.places.clone(), places_stack: gallery_widgets.places_stack.clone(), places_status: gallery_widgets.places_status.clone(), - places_btn: gallery_widgets.places_btn.clone(), places_loading: Cell::new(false), places_mapping: gallery_widgets.places_mapping.clone(), places_mapping_label: gallery_widgets.places_mapping_label.clone(), places_mapping_bar: gallery_widgets.places_mapping_bar.clone(), places_poll: RefCell::new(None), places_map: gallery_widgets.places_map.clone(), - places_map_btn: gallery_widgets.places_map_btn.clone(), + places_layout: gallery_widgets.places_layout.clone(), place_count: Cell::new(0), place: RefCell::new(None), album: RefCell::new(None), @@ -693,6 +681,7 @@ fn build_window(app: &adw::Application) { thumb_source: RefCell::new(None), relayout_source: RefCell::new(None), bound: RefCell::new(BTreeMap::new()), + tile_pools: RefCell::new(HashMap::new()), list: gallery_widgets.list.clone(), selecting: Cell::new(false), selected: RefCell::new(HashSet::new()), @@ -714,7 +703,6 @@ fn build_window(app: &adw::Application) { rows: RefCell::new(Vec::new()), inflight: Cell::new(false), loader: Loader::new(&locations_widgets.content), - loaded_at: Cell::new(None), card: SyncCard { icon: locations_widgets.card_icon.clone(), title: locations_widgets.card_title.clone(), @@ -734,7 +722,6 @@ fn build_window(app: &adw::Application) { rows: RefCell::new(Vec::new()), painted: RefCell::new(Vec::new()), inflight: Cell::new(false), - fetched_at: Cell::new(None), }, views: locations_widgets.views.clone(), banner: locations_widgets.banner.clone(), @@ -816,18 +803,6 @@ fn build_window(app: &adw::Application) { wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder); wire_activity(&ui, &activity_widgets); wire_takeout(&ui, &takeout_widgets); - wire_refresh( - &ui, - &[ - &browser_widgets.refresh, - &gallery_widgets.refresh, - &trash_widgets.refresh, - &shared_widgets.refresh, - &shared_by_me_widgets.refresh, - &devices_widgets.refresh, - &locations_widgets.refresh, - ], - ); wire_retry(&ui); // Lazily load the Files / Photos pages the first time they're shown, so the @@ -842,14 +817,10 @@ fn build_window(app: &adw::Application) { match st.visible_child_name().as_deref() { Some("browser") => load_browser(&ui_nav), Some("gallery") => load_gallery(&ui_nav, false), - // Network-backed pages skip the fetch (and the "Loading…" flash) when - // the rows on screen are still fresh; the Retry button and mutations - // invalidate the timestamp to force a reload. - Some("sharedbyme") if page_fresh(&ui_nav.shared_by_me.loaded_at) => {} + // Every visit reads the page again. The rows already on screen stay + // up while it does, so arriving costs no "Loading…" flash. Some("sharedbyme") => load_shared_by_me(&ui_nav), - Some("shared") if page_fresh(&ui_nav.shared.loaded_at) => {} Some("shared") => load_shared(&ui_nav), - Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {} Some("devices") => load_devices(&ui_nav), Some("locations") => load_sync_view(&ui_nav), Some("trash") => load_trash(&ui_nav), @@ -881,41 +852,8 @@ fn build_window(app: &adw::Application) { app.set_accels_for_action("win.shortcuts", &["question"]); refresh(&ui); - // Periodic refresh while the window lives. The closure holds a strong `Rc`; - // it is dropped when the source is removed on window close. A window in the - // background refreshes less often, and one nobody can see not at all. - let ui_tick = ui.clone(); - let window_tick = window.downgrade(); - let skipped = Cell::new(0u32); - let source = glib::timeout_add_local(REFRESH_INTERVAL, move || { - let Some(window) = window_tick.upgrade() else { - return glib::ControlFlow::Continue; - }; - if window_hidden(&window) { - return glib::ControlFlow::Continue; - } - if !window.is_active() && skipped.get() + 1 < BACKGROUND_REFRESH_EVERY { - skipped.set(skipped.get() + 1); - return glib::ControlFlow::Continue; - } - skipped.set(0); - refresh(&ui_tick); - glib::ControlFlow::Continue - }); - // Catch up as soon as the user comes back to the window. - let ui_focus = ui.clone(); - window.connect_is_active_notify(move |window| { - if window.is_active() { - refresh(&ui_focus); - } - }); - let cell = RefCell::new(Some(source)); - window.connect_close_request(move |_| { - if let Some(id) = cell.borrow_mut().take() { - id.remove(); - } - glib::Propagation::Proceed - }); + // From here on the daemon says when something changed; nothing polls. + follow_daemon(&ui, &window); window.present(); } @@ -1037,14 +975,13 @@ thread_local! { /// Every page header's busy spinner. There is one per page because each /// page has its own header bar; [`Ui::busy_begin`] shows them all, so the one /// on screen is always the right one. - static BUSY_SPINNERS: RefCell> = const { RefCell::new(Vec::new()) }; + static BUSY_SPINNERS: RefCell> = const { RefCell::new(Vec::new()) }; } fn set_busy_spinners(visible: bool) { BUSY_SPINNERS.with(|spinners| { for spinner in spinners.borrow().iter() { spinner.set_visible(visible); - spinner.set_spinning(visible); } }); } @@ -1068,7 +1005,7 @@ fn page_frame_with( title: &impl IsA, content: &impl IsA, ) -> (adw::ToolbarView, adw::HeaderBar) { - let spinner = gtk4::Spinner::new(); + let spinner = spinner(); spinner.set_visible(false); BUSY_SPINNERS.with(|spinners| spinners.borrow_mut().push(spinner.clone())); let title_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); @@ -1114,43 +1051,15 @@ fn wire_sidebar(ui: &Rc) { }); } -/// Whether a network-backed page painted good data within [`PAGE_TTL`] and so -/// can be reused without re-fetching. A `None` timestamp (never loaded, or -/// invalidated by a mutation) is always stale. -fn page_fresh(loaded_at: &Cell>) -> bool { - loaded_at.get().is_some_and(|t| t.elapsed() < PAGE_TTL) -} - -/// A page header's Refresh button. Every page that can show stale rows carries -/// one, so the user never has to guess whether what they're looking at is -/// current or wait out a TTL they can't see. -fn refresh_button() -> gtk4::Button { - let button = gtk4::Button::builder() - .icon_name("view-refresh-symbolic") - .tooltip_text(gettext("Refresh (F5)")) - .valign(gtk4::Align::Center) - .build(); - button.add_css_class("flat"); - button -} - -/// Point every page's Refresh button at the current page. One handler for all of -/// them: the button acts on whatever is on screen, so it can't refresh a page the -/// user has since navigated away from. -fn wire_refresh(ui: &Rc, buttons: &[>k4::Button]) { - for button in buttons { - let ui = ui.clone(); - button.connect_clicked(move |_| reload_current_page(&ui)); - } -} - /// Re-fetch the visible page from the server, bypassing every layer of cache /// between it and the account. /// -/// The two layers are separate: the daemon's own persisted listings (folders, -/// trash, photos) are dropped with [`Request::Refresh`] before re-asking, while -/// the pages the daemon always fetches live (sharing, devices, activity) only -/// need this front-end's [`PAGE_TTL`] stamp cleared. +/// The daemon keeps persisted listings (folders, trash, photos), and those are +/// dropped with [`Request::Refresh`] before re-asking. The pages the daemon +/// always fetches live (sharing, devices, activity) are simply loaded again. +/// +/// The daemon's events keep every page current on their own. This is for F5, +/// when the account changed somewhere the daemon has not heard about yet. fn reload_current_page(ui: &Rc) { match ui.stack.visible_child_name().as_deref() { Some("browser") => { @@ -1167,22 +1076,10 @@ fn reload_current_page(ui: &Rc) { } }), Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash), - Some("shared") => { - ui.shared.loaded_at.set(None); - load_shared(ui); - } - Some("sharedbyme") => { - ui.shared_by_me.loaded_at.set(None); - load_shared_by_me(ui); - } - Some("devices") => { - ui.devices.loaded_at.set(None); - load_devices(ui); - } - Some("locations") => { - ui.locations.loaded_at.set(None); - reload_sync_view(ui); - } + Some("shared") => load_shared(ui), + Some("sharedbyme") => load_shared_by_me(ui), + Some("devices") => load_devices(ui), + Some("locations") => reload_sync_view(ui), _ => {} } } @@ -1649,36 +1546,17 @@ fn show_shortcuts(window: &adw::ApplicationWindow) { ], ), ]; - let page = adw::PreferencesPage::new(); - for (title, keys) in GROUPS { - let group = adw::PreferencesGroup::builder() - .title(gettext(title)) - .build(); - for (accel, action) in keys { - let row = adw::ActionRow::builder().title(gettext(action)).build(); - row.add_suffix( - >k4::ShortcutLabel::builder() - .accelerator(*accel) - .valign(gtk4::Align::Center) - .build(), - ); - group.add(&row); - } - page.add(&group); - } - - let dialog = adw::Dialog::builder() - .title(gettext("Keyboard Shortcuts")) - .content_width(460) - .content_height(620) - .child(&{ - let toolbar = adw::ToolbarView::new(); - toolbar.add_top_bar(&adw::HeaderBar::new()); - toolbar.set_content(Some(&page)); - toolbar + let groups: Vec<_> = GROUPS + .iter() + .map(|(title, keys)| { + let keys = keys + .iter() + .map(|(accel, action)| (*accel, gettext(action))) + .collect(); + (gettext(title), keys) }) - .build(); - dialog.present(Some(window)); + .collect(); + present_shortcuts(window, &groups); } /// Window-level keyboard shortcuts, so the browser is usable without the mouse: diff --git a/crates/pdfs-gui/src/app/pages/activity.rs b/crates/pdfs-gui/src/app/pages/activity.rs index f1d3f1d..065b019 100644 --- a/crates/pdfs-gui/src/app/pages/activity.rs +++ b/crates/pdfs-gui/src/app/pages/activity.rs @@ -56,7 +56,8 @@ pub(crate) struct ActivityWidgets { pub(crate) model: gio::ListStore, pub(crate) list: gtk4::ListView, pub(crate) feed: gtk4::Stack, - pub(crate) filters: Vec<(ActivityFilter, gtk4::ToggleButton)>, + /// One option per [`ActivityFilter::ALL`], in that order. + pub(crate) filters: ToggleGroup, /// Holds the ⋮ menu, which needs the [`Ui`] and so is built when wiring. pub(crate) options: gtk4::Box, pub(crate) retry: gtk4::Button, @@ -115,20 +116,11 @@ impl ActivityFilter { pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { // Filter chips: a linked row of radio toggles. They sit above the list, // not in it, so they stay in reach however far the feed is scrolled. - let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - bar.add_css_class("linked"); + let labels = ActivityFilter::ALL.map(|filter| filter.label()); + let options: Vec<_> = labels.iter().map(|label| (label.as_str(), None)).collect(); + let filters = ToggleGroup::new(&options); + let bar = filters.widget(); bar.set_halign(gtk4::Align::Start); - let mut filters: Vec<(ActivityFilter, gtk4::ToggleButton)> = Vec::new(); - for filter in ActivityFilter::ALL { - let button = gtk4::ToggleButton::with_label(&filter.label()); - if let Some((_, first)) = filters.first() { - button.set_group(Some(first)); - } else { - button.set_active(true); - } - bar.append(&button); - filters.push((filter, button)); - } bar.set_hexpand(true); let options = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); @@ -282,16 +274,13 @@ pub(crate) fn wire_activity(ui: &Rc, widgets: &ActivityWidgets) { widgets .retry .connect_clicked(move |_| restart_service_then(&ui_retry, load_activity)); - for (filter, button) in &widgets.filters { - let ui = ui.clone(); - let filter = *filter; - button.connect_toggled(move |button| { - if button.is_active() { - ui.activity.filter.set(filter); - paint_activity(&ui); - } - }); - } + let ui_filter = ui.clone(); + widgets.filters.connect_changed(move |index| { + if let Some(&filter) = ActivityFilter::ALL.get(index as usize) { + ui_filter.activity.filter.set(filter); + paint_activity(&ui_filter); + } + }); let mut menu = ActionMenu::new(); menu.toggle(&gettext("Show Temporary Files"), false, { let ui = ui.clone(); diff --git a/crates/pdfs-gui/src/app/pages/albums.rs b/crates/pdfs-gui/src/app/pages/albums.rs index 60a86e7..1ee2f34 100644 --- a/crates/pdfs-gui/src/app/pages/albums.rs +++ b/crates/pdfs-gui/src/app/pages/albums.rs @@ -618,7 +618,7 @@ pub(crate) fn open_album(ui: &Rc, album: AlbumInfo) { // 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.view_switch.widget().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. @@ -636,7 +636,7 @@ pub(crate) fn close_album(ui: &Rc) { return; } ui.gallery.title.set_title(&gettext("Photos")); - ui.gallery.view_switch.set_visible(true); + ui.gallery.view_switch.widget().set_visible(true); ui.gallery.upload.set_visible(true); ui.gallery.back.set_visible(false); } @@ -653,17 +653,15 @@ fn albums_status(ui: &Rc, icon: &str, title: &str, description: &str) { /// 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() { + ui.gallery.view_switch.connect_changed(move |view| { + if view == VIEW_ALBUMS { 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() { + ui.gallery.view_switch.connect_changed(move |view| { + if view != VIEW_TIMELINE { return; } close_album(&ui_photos); diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index be5009d..5c93895 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -124,7 +124,6 @@ pub(crate) struct BrowserWidgets { pub(crate) thumbnail_status: gtk4::Label, pub(crate) summary: gtk4::Label, pub(crate) zoom: gtk4::Scale, - pub(crate) refresh: gtk4::Button, /// Wraps the views + the details pane; the pane shows the selection while its header toggle is on. pub(crate) split: adw::OverlaySplitView, pub(crate) details: DetailsWidgets, @@ -259,8 +258,6 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { .build(); search_scope.add_css_class("flat"); - let refresh = refresh_button(); - // The path bar stays in the page, under the header bar: the header's title // slot carries the page name and the busy spinner. let path_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); @@ -457,7 +454,6 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { let (frame, header, _) = page_frame(&gettext("My files"), &page); header.pack_start(&new_button); header.pack_end(&folder_button); - header.pack_end(&refresh); header.pack_end(&details_toggle); header.pack_end(&view_button); header.pack_end(&search); @@ -483,7 +479,6 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { thumbnail_status, summary, zoom, - refresh, split, details, bulk, diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index d2fc040..d12d56d 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -28,7 +28,6 @@ pub(crate) struct DevicesState { pub(crate) listed: RefCell>>, /// Runs the loads; the rows stay up while one runs. pub(crate) loader: Rc, - pub(crate) loaded_at: Cell>, } impl DevicesState { @@ -46,7 +45,6 @@ impl DevicesState { nav: RefCell::new(Vec::new()), listed: RefCell::new(None), loader: Loader::new(&widgets.content), - loaded_at: Cell::new(None), } } } @@ -64,15 +62,12 @@ pub(crate) struct DevicesWidgets { pub(crate) crumb: gtk4::Box, pub(crate) layout: gtk4::Button, pub(crate) retry: gtk4::Button, - pub(crate) refresh: gtk4::Button, } /// The Computers page: every computer backing up to the account, with this one /// first. This computer's row leads to its folders on the Sync page; another /// computer's row opens its backup read-only, in the same list as My files. pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { - let refresh = refresh_button(); - let group = adw::PreferencesGroup::new(); let clamp = adw::Clamp::builder().child(&group).build(); let scroll = gtk4::ScrolledWindow::builder() @@ -143,7 +138,6 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { inner.set_margin_end(18); inner.append(&content); let (frame, header, _) = page_frame(&gettext("Computers"), &inner); - header.pack_end(&refresh); header.pack_end(&layout); ( @@ -158,7 +152,6 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { crumb, layout, retry, - refresh, }, ) } @@ -247,7 +240,6 @@ pub(crate) fn load_devices(ui: &Rc) { // on to the devices list rather than failing the whole page. Ok(Ok(_)) => Vec::new(), Ok(Err(_)) | Err(_) => { - ui.devices.loaded_at.set(None); devices_unreachable(&ui); return; } @@ -260,7 +252,6 @@ pub(crate) fn load_devices(ui: &Rc) { repaint_devices(&ui, &devices, &folders); repaint_device_crumb(&ui, &[]); *ui.devices.listed.borrow_mut() = Some(Vec::new()); - ui.devices.loaded_at.set(Some(Instant::now())); }); } @@ -298,7 +289,6 @@ fn load_device_folder(ui: &Rc, nav: &[(String, String)]) { return; } _ => { - ui.devices.loaded_at.set(None); devices_unreachable(&ui); return; } @@ -313,7 +303,6 @@ fn load_device_folder(ui: &Rc, nav: &[(String, String)]) { ui.devices.files_content.set_visible_child_name("list"); replace_items(&ui.devices.files.model, &entries); } - ui.devices.loaded_at.set(Some(Instant::now())); }); } diff --git a/crates/pdfs-gui/src/app/pages/duplicates.rs b/crates/pdfs-gui/src/app/pages/duplicates.rs index 49c63c3..2ebc2ed 100644 --- a/crates/pdfs-gui/src/app/pages/duplicates.rs +++ b/crates/pdfs-gui/src/app/pages/duplicates.rs @@ -75,22 +75,19 @@ pub(crate) fn show_duplicates(ui: &Rc) { bottom.set_halign(gtk4::Align::End); bottom.append(&trash); - let exact = gtk4::ToggleButton::builder() - .label(pgettext("duplicates", "Exact")) - .tooltip_text(gettext("Photos stored more than once")) - .active(true) - .build(); - let similar = gtk4::ToggleButton::builder() - .label(pgettext("duplicates", "Similar")) - .tooltip_text(gettext("Photos that look alike")) - .group(&exact) - .build(); - let switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - switch.add_css_class("linked"); - switch.append(&exact); - switch.append(&similar); + // Exact first, then Similar: the index is the finder. + let switch = ToggleGroup::new(&[ + ( + &pgettext("duplicates", "Exact"), + Some(&gettext("Photos stored more than once")), + ), + ( + &pgettext("duplicates", "Similar"), + Some(&gettext("Photos that look alike")), + ), + ]); let header = adw::HeaderBar::new(); - header.set_title_widget(Some(&switch)); + header.set_title_widget(Some(&switch.widget())); let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header); @@ -124,8 +121,8 @@ pub(crate) fn show_duplicates(ui: &Rc) { .set(view_closed.generation.get().wrapping_add(1)); }); let view_switch = view.clone(); - similar.connect_toggled(move |similar| { - let finder = if similar.is_active() { + switch.connect_changed(move |index| { + let finder = if index == 1 { Finder::Similar } else { Finder::Exact diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs index 3585ba5..a789c71 100644 --- a/crates/pdfs-gui/src/app/pages/locations.rs +++ b/crates/pdfs-gui/src/app/pages/locations.rs @@ -26,7 +26,6 @@ pub(crate) struct LocationsState { /// Shows the loading state if a full load is slow; the rows stay up /// until then. pub(crate) loader: Rc, - pub(crate) loaded_at: Cell>, pub(crate) card: SyncCard, pub(crate) queue: QueueState, pub(crate) conflicts: ConflictsState, @@ -94,9 +93,6 @@ pub(crate) struct ConflictsState { pub(crate) rows: RefCell>, pub(crate) painted: RefCell>, pub(crate) inflight: Cell, - /// When the list was last fetched. Listing walks every node the daemon - /// knows, so the tick asks far less often than it does for the queue. - pub(crate) fetched_at: Cell>, } /// Widgets the Locations page's load/repaint touch. @@ -105,7 +101,6 @@ pub(crate) struct LocationsWidgets { pub(crate) status: adw::StatusPage, pub(crate) group: adw::PreferencesGroup, pub(crate) retry: gtk4::Button, - pub(crate) refresh: gtk4::Button, pub(crate) add_folder: gtk4::Button, /// Live transfers, on the Overview; painted by the refresh loop. pub(crate) transfers_group: adw::PreferencesGroup, @@ -137,7 +132,6 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc .valign(gtk4::Align::Center) .build(); add_folder.add_css_class("flat"); - let refresh = refresh_button(); let transfers_group = build_transfers_group(); let (card, card_icon, card_title, card_detail) = build_sync_card(); let pause = build_pause_button(); @@ -247,14 +241,12 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc .build(); let (frame, header) = page_frame_with(&switcher, &column); header.pack_start(&pause); - header.pack_end(&refresh); let widgets = LocationsWidgets { content: content.clone(), status: status.clone(), group: group.clone(), retry: retry.clone(), - refresh, add_folder, transfers_group, card_icon, @@ -290,7 +282,6 @@ fn scrolled_column(child: &impl IsA) -> gtk4::ScrolledWindow { pub(crate) fn wire_locations(ui: &Rc, retry: >k4::Button, add_folder: >k4::Button) { let ui_retry = ui.clone(); retry.connect_clicked(move |_| { - ui_retry.locations.loaded_at.set(None); load_locations(&ui_retry); }); let ui_add = ui.clone(); @@ -323,32 +314,37 @@ pub(crate) fn load_sync_view(ui: &Rc) { // edits complete, so it reloads on every visit. Some("history") => load_activity(ui), _ => { - refresh_conflicts(ui, true); - if !page_fresh(&ui.locations.loaded_at) { - load_locations(ui); - } + refresh_conflicts(ui); + load_locations(ui); } } } -/// Reload the Sync page's current view, for Refresh. +/// Reload the Sync page's current view, for F5. pub(crate) fn reload_sync_view(ui: &Rc) { if ui.locations.views.visible_child_name().as_deref() == Some("history") { load_activity(ui); } else { - refresh_conflicts(ui, true); + refresh_conflicts(ui); load_locations(ui); } } -/// Follow the refresh tick on the Sync page's current view. -pub(crate) fn tick_sync_view(ui: &Rc) { - if ui.locations.views.visible_child_name().as_deref() == Some("history") { - refresh_activity(ui); +/// Bring the Sync page's current view up to date with what the daemon says +/// changed. Conflicts are left to the caller: they badge the sidebar, so they +/// are followed on every page. +pub(crate) fn follow_sync_view(ui: &Rc, topics: &[Topic]) { + if activity_visible(ui) { + if topics.contains(&Topic::Activity) { + refresh_activity(ui); + } } else { - refresh_locations(ui); - refresh_queue(ui); - refresh_conflicts(ui, false); + if topics.contains(&Topic::Locations) { + refresh_locations(ui); + } + if topics.contains(&Topic::Queue) { + refresh_queue(ui); + } } } @@ -715,33 +711,16 @@ fn retry_queued(ui: &Rc, id: Option) { }); } -/// How long a conflict listing stays fresh on the refresh tick. +/// How long the activity feed's conflict listing stays fresh while the feed +/// follows new activity. Conflicts come with their own event, so this only +/// saves reading them twice. pub(crate) const CONFLICTS_TTL: Duration = Duration::from_secs(30); -/// How long a conflict listing stays fresh for the sidebar badge while the -/// Sync page is not on screen. -const CONFLICTS_BADGE_TTL: Duration = Duration::from_secs(300); - -/// Poll the conflict list while the Sync page is on screen. `force` skips the -/// TTL, for navigation and right after a resolution. -pub(crate) fn refresh_conflicts(ui: &Rc, force: bool) { - fetch_conflicts(ui, if force { Duration::ZERO } else { CONFLICTS_TTL }); -} - -/// Poll the conflict list now and then from anywhere, for the sidebar badge. -pub(crate) fn refresh_conflicts_badge(ui: &Rc) { - fetch_conflicts(ui, CONFLICTS_BADGE_TTL); -} - -/// Ask for the conflict list unless the last one is younger than `ttl`. -fn fetch_conflicts(ui: &Rc, ttl: Duration) { +/// Read the conflict list again, for the Sync page and the sidebar badge: on +/// navigation, after a resolution, and when the daemon says it changed. +pub(crate) fn refresh_conflicts(ui: &Rc) { let conflicts = &ui.locations.conflicts; - if conflicts.inflight.get() - || conflicts - .fetched_at - .get() - .is_some_and(|at| !ttl.is_zero() && at.elapsed() < ttl) - { + if conflicts.inflight.get() { return; } conflicts.inflight.set(true); @@ -751,7 +730,6 @@ fn fetch_conflicts(ui: &Rc, ttl: Duration) { let result = rx.recv().await; let conflicts = &ui.locations.conflicts; conflicts.inflight.set(false); - conflicts.fetched_at.set(Some(Instant::now())); match result { Ok(Ok(Response::Conflicts { items })) => repaint_conflicts(&ui, &items), // Same as the queue: an older daemon, or none, shows nothing. @@ -934,7 +912,7 @@ fn resolve_conflict(ui: &Rc, path: String, keep: ConflictKeep) { match result { Ok(Ok(Response::Ok { message })) => { toast(&ui, &capitalize(&message)); - refresh_conflicts(&ui, true); + refresh_conflicts(&ui); refresh_activity_conflicts(&ui, true); } Ok(Ok(Response::Error { message, kind })) => toast_failure( @@ -1004,10 +982,8 @@ pub(crate) fn load_locations(ui: &Rc) { Ok(Ok(Response::Locations { items })) => { ui.locations.content.set_visible_child_name("list"); repaint_locations(&ui, &items); - ui.locations.loaded_at.set(Some(Instant::now())); } Ok(Ok(Response::Error { message, .. })) => { - ui.locations.loaded_at.set(None); locations_status( &ui, "dialog-warning-symbolic", @@ -1017,7 +993,6 @@ pub(crate) fn load_locations(ui: &Rc) { ); } _ => { - ui.locations.loaded_at.set(None); locations_unreachable(&ui); } } diff --git a/crates/pdfs-gui/src/app/pages/login.rs b/crates/pdfs-gui/src/app/pages/login.rs index cad1bc0..7cddf30 100644 --- a/crates/pdfs-gui/src/app/pages/login.rs +++ b/crates/pdfs-gui/src/app/pages/login.rs @@ -7,7 +7,7 @@ pub(crate) struct LoginState { pub(crate) password: adw::PasswordEntryRow, pub(crate) login_button: gtk4::Button, /// Spins inside the Sign in button while a sign-in is running. - pub(crate) login_spinner: gtk4::Spinner, + pub(crate) login_spinner: Spinner, pub(crate) login_status: gtk4::Label, } @@ -34,7 +34,8 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) { group.add(&email); group.add(&password); - let login_spinner = gtk4::Spinner::builder().visible(false).build(); + let login_spinner = spinner(); + login_spinner.set_visible(false); let button_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); button_content.append(&login_spinner); button_content.append(>k4::Label::new(Some(&gettext("Sign in")))); @@ -185,7 +186,6 @@ fn set_signing_in(ui: &Rc, running: bool) { ui.login.email.set_sensitive(!running); ui.login.password.set_sensitive(!running); ui.login.login_spinner.set_visible(running); - ui.login.login_spinner.set_spinning(running); } /// What to tell the person when sign-in fails. Proton's own API messages are diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs index 29cc49d..f394649 100644 --- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -31,7 +31,7 @@ pub(crate) struct Viewer { pub(crate) scroller: gtk4::ScrolledWindow, pub(crate) picture: gtk4::Picture, pub(crate) video: gtk4::Video, - pub(crate) spinner: gtk4::Spinner, + pub(crate) spinner: Spinner, pub(crate) status: gtk4::Label, pub(crate) title: gtk4::Label, pub(crate) counter: gtk4::Label, @@ -224,7 +224,6 @@ fn viewer_item(ui: &Rc, viewer: &Rc, uid: &str) -> Option /// plays in place once its file is down. pub(crate) fn load_photo(ui: &Rc, viewer: &Rc, uid: String) { viewer.spinner.set_visible(true); - viewer.spinner.start(); viewer.status.set_visible(false); viewer.loading.set(true); *viewer.path.borrow_mut() = None; @@ -266,7 +265,6 @@ pub(crate) fn load_photo(ui: &Rc, viewer: &Rc, uid: String) { if *viewer.uid.borrow() != uid { return; } - viewer.spinner.stop(); viewer.spinner.set_visible(false); viewer.loading.set(false); // Armed whether or not the photo rendered: a slideshow steps past a @@ -1192,12 +1190,10 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { .child(&info_panel) .build(); - let spinner = gtk4::Spinner::builder() - .halign(gtk4::Align::Center) - .valign(gtk4::Align::Center) - .width_request(48) - .height_request(48) - .build(); + let spinner = spinner(); + spinner.set_halign(gtk4::Align::Center); + spinner.set_valign(gtk4::Align::Center); + spinner.set_size_request(48, 48); spinner.add_css_class("viewer-spinner"); overlay.add_overlay(&spinner); diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 5bcc686..bfd4b33 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -23,13 +23,11 @@ pub(crate) struct GalleryState { pub(crate) status: adw::StatusPage, pub(crate) retry: gtk4::Button, /// Spins under the timeline while the next page is loading. - pub(crate) pager: gtk4::Spinner, - /// The month scrubber on the timeline's right edge, the months it spans - /// (newest first), and whether the pointer is on it — while it is, the - /// scroll position must not move the knob out from under the drag. - pub(crate) scrubber: gtk4::Scale, + pub(crate) pager: Spinner, + /// The month scrubber over the timeline's right edge, and the months it + /// spans (newest first). + pub(crate) scrubber: Scrubber, pub(crate) months: RefCell>, - pub(crate) scrubbing: Cell, /// The pending jump the scrubber debounces to. pub(crate) scrub_source: RefCell>, /// A scrubber jump in progress: the end of the month it is headed for. @@ -39,7 +37,7 @@ pub(crate) struct GalleryState { pub(crate) burst: Cell, /// Says a Google Photos import is running, with a way to its page. pub(crate) import_banner: adw::Banner, - pub(crate) upload: gtk4::Button, + pub(crate) upload: adw::SplitButton, /// Upload / Import offered on the *empty timeline* status page, so a fresh /// account is a place to start rather than a dead end. Hidden on every other /// status: a load error is not the moment to offer an upload. @@ -51,20 +49,17 @@ pub(crate) struct GalleryState { 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, + /// The Timeline/Albums/Places switcher (see [`VIEW_TIMELINE`] and its + /// siblings) — hidden while an album is open, where the back button leads + /// instead. + pub(crate) view_switch: ToggleGroup, /// 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 Places grid and its own status page, like the album grid's, and the - /// switcher's Places toggle. + /// The Places grid and its own status page, like the album grid's. pub(crate) places: gtk4::FlowBox, pub(crate) places_stack: gtk4::Stack, pub(crate) places_status: adw::StatusPage, - pub(crate) places_btn: gtk4::ToggleButton, /// True while the place listing is in flight. pub(crate) places_loading: Cell, /// The row above the place grid saying how far the daemon has got reading @@ -74,9 +69,9 @@ pub(crate) struct GalleryState { pub(crate) places_mapping_label: gtk4::Label, pub(crate) places_mapping_bar: gtk4::ProgressBar, pub(crate) places_poll: RefCell>, - /// The place map, and the Grid/Map toggle's Map half. + /// The place map, and the Grid/Map toggle (see [`PLACES_MAP`]). pub(crate) places_map: Rc, - pub(crate) places_map_btn: gtk4::ToggleButton, + pub(crate) places_layout: ToggleGroup, /// How many places the grid shows, for the subtitle when back returns there. pub(crate) place_count: Cell, /// The place currently open, paged by [`load_gallery`] like an album. @@ -102,7 +97,7 @@ pub(crate) struct GalleryState { 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], + pub(crate) tabs: ToggleGroup, /// 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. @@ -192,6 +187,9 @@ pub(crate) struct GalleryState { /// back where they were. The ListView realises rows beyond both edges of /// the viewport, so the widget is what tells which of them is on screen. pub(crate) bound: RefCell, gtk4::Box)>>, + /// The tile widgets each row box has built, rebound to new photos as the + /// ListView recycles the row rather than built afresh on every bind. + pub(crate) tile_pools: RefCell>>, /// The ListView itself, so a rebuild can scroll back to the row the user was /// looking at. pub(crate) list: gtk4::ListView, @@ -391,18 +389,17 @@ pub(crate) struct GalleryWidgets { pub(crate) content: gtk4::Stack, pub(crate) status: adw::StatusPage, pub(crate) title: adw::WindowTitle, - pub(crate) pager: gtk4::Spinner, - pub(crate) scrubber: gtk4::Scale, + pub(crate) pager: Spinner, + pub(crate) scrubber: Scrubber, /// Says a Google Photos import is running, with a way to its page. pub(crate) import_banner: adw::Banner, pub(crate) list: gtk4::ListView, pub(crate) scroll: gtk4::ScrolledWindow, pub(crate) retry: gtk4::Button, - pub(crate) upload: gtk4::Button, + pub(crate) upload: adw::SplitButton, pub(crate) empty_actions: gtk4::Box, pub(crate) empty_upload: gtk4::Button, pub(crate) empty_import: gtk4::Button, - pub(crate) refresh: gtk4::Button, /// The Select toggle and the bar it reveals. pub(crate) select_btn: gtk4::ToggleButton, pub(crate) select_bar: gtk4::Revealer, @@ -415,24 +412,21 @@ pub(crate) struct GalleryWidgets { 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) view_switch: ToggleGroup, pub(crate) places: gtk4::FlowBox, pub(crate) places_stack: gtk4::Stack, pub(crate) places_status: adw::StatusPage, - pub(crate) places_btn: gtk4::ToggleButton, pub(crate) places_mapping: gtk4::Box, pub(crate) places_mapping_label: gtk4::Label, pub(crate) places_mapping_bar: gtk4::ProgressBar, pub(crate) places_map: Rc, - pub(crate) places_map_btn: gtk4::ToggleButton, + pub(crate) places_layout: ToggleGroup, 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], + pub(crate) tabs: ToggleGroup, pub(crate) favorites_row: adw::SwitchRow, pub(crate) not_in_album_row: adw::SwitchRow, /// The "On this day" strip and the box its cards go into. @@ -505,28 +499,10 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { // The timeline pages itself in as the scroll nears the bottom (see // [`wire_gallery`]); this only says a page is on its way. - let pager = gtk4::Spinner::builder() - .halign(gtk4::Align::Center) - .margin_bottom(6) - .visible(false) - .build(); - - // Month scrubber: newest at the top, a mark per year. Dragging it jumps - // the timeline to the month under the knob (see [`wire_gallery`]). - let scrubber = gtk4::Scale::builder() - .orientation(gtk4::Orientation::Vertical) - .adjustment(>k4::Adjustment::new(0.0, 0.0, 1.0, 1.0, 1.0, 0.0)) - .draw_value(false) - .value_pos(gtk4::PositionType::Left) - .round_digits(0) - .halign(gtk4::Align::End) - .margin_top(12) - .margin_bottom(12) - .margin_start(6) - .tooltip_text(gettext("Jump to a month")) - .visible(false) - .build(); - scrubber.add_css_class("photo-scrubber"); + let pager = spinner(); + pager.set_halign(gtk4::Align::Center); + pager.set_margin_bottom(6); + pager.set_visible(false); // Horizontal scrolling is never wanted: the grid is sized to the viewport // width. External rather than Never, which would make the widest row the @@ -549,30 +525,32 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { back.add_css_class("flat"); back.add_css_class("circular"); - // `label` and `icon_name` both replace a button's child, so the pair needs - // an `adw::ButtonContent` to show together. - let upload = gtk4::Button::builder() + // Importing a Google Photos export is another way of adding photos, so it + // sits in the Upload button's own menu. `label` and `icon_name` both + // replace a button's child, so the pair needs an `adw::ButtonContent` to + // show together. + let upload_menu = gio::Menu::new(); + upload_menu.append( + Some(&gettext("Import from Google Photos…")), + Some("win.show-import"), + ); + let upload = adw::SplitButton::builder() .child( &adw::ButtonContent::builder() .label(pgettext("verb", "Upload")) .icon_name("list-add-symbolic") .build(), ) + .menu_model(&upload_menu) .tooltip_text(gettext("Upload photos")) + .dropdown_tooltip(gettext("More ways to add photos")) .valign(gtk4::Align::Center) .build(); upload.add_css_class("suggested-action"); - let refresh = refresh_button(); - - // Importing an export and hunting for duplicates are rarer, heavier jobs - // than adding one photo, so they wait in the page menu rather than taking - // a header button each. + // Hunting for duplicates is a rarer, heavier job than adding one photo, so + // it waits in the page menu rather than taking a header button. let page_menu = gio::Menu::new(); - page_menu.append( - Some(&gettext("Import from Google Photos…")), - Some("win.show-import"), - ); page_menu.append( Some(&gettext("Find Duplicates…")), Some("win.find-duplicates"), @@ -635,23 +613,9 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { // control: exactly one is active, and flipping it reloads the timeline // filtered to that kind (wired in [`wire_gallery`]). Labels gain live counts // once a page lands. - let tabs: [gtk4::ToggleButton; 4] = std::array::from_fn(|i| { - gtk4::ToggleButton::builder() - .label(tab_label(i)) - .active(i == 0) - .build() - }); - // Group the toggles so they behave as a radio set: chaining each to the first - // is what GTK turns into mutual exclusion. - for btn in &tabs[1..] { - btn.set_group(Some(&tabs[0])); - } - let tab_group = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - tab_group.add_css_class("linked"); - for btn in &tabs { - btn.add_css_class("pill"); - tab_group.append(btn); - } + let labels: [String; 4] = std::array::from_fn(tab_label); + let tabs = ToggleGroup::new(&labels.each_ref().map(|label| (label.as_str(), None))); + tabs.set_round(); // Favorites, "not in an album" and the month: the filters that cut across // the kind tabs, together in one popover so the row stays short. The tabs @@ -704,7 +668,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { // 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); + filters.append(&tabs.widget()); let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); spacer.set_hexpand(true); filters.append(&spacer); @@ -714,26 +678,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { // 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. // "Timeline", not "Photos": the kind filter below has a Photos tab too. - let photos_btn = gtk4::ToggleButton::builder() - .label(gettext("Timeline")) - .active(true) - .build(); - let albums_btn = gtk4::ToggleButton::builder() - .label(gettext("Albums")) - .build(); - albums_btn.set_group(Some(&photos_btn)); - let places_btn = gtk4::ToggleButton::builder() - .label(gettext("Places")) - .build(); - places_btn.set_group(Some(&photos_btn)); - let view_switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - view_switch.add_css_class("linked"); - view_switch.add_css_class("view-switch"); - view_switch.set_halign(gtk4::Align::Start); - for btn in [&photos_btn, &albums_btn, &places_btn] { - btn.add_css_class("pill"); - view_switch.append(btn); - } + let view_switch = ToggleGroup::new(&[ + (&gettext("Timeline"), None), + (&gettext("Albums"), None), + (&gettext("Places"), None), + ]); + view_switch.set_round(); + let view_switch_widget = view_switch.widget(); + view_switch_widget.add_css_class("view-switch"); + view_switch_widget.set_halign(gtk4::Align::Start); let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); filter_bar.append(&filters); @@ -762,13 +715,12 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { // The timeline (plus its pager) or the status page, never both. let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12); timeline.append(&select_bar); - // The scrubber sits beside the list rather than over it, so the grid is - // laid out to the width it really has and no tile hides under a year mark. - let timeline_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - scroll.set_hexpand(true); - timeline_row.append(&scroll); - timeline_row.append(&scrubber); - timeline.append(&timeline_row); + // The scrubber lies over the grid's right edge and only shows when wanted, + // so the grid keeps its full width. + let timeline_view = gtk4::Overlay::new(); + timeline_view.set_child(Some(&scroll)); + let scrubber = Scrubber::new(&timeline_view); + timeline.append(&timeline_view); timeline.append(&pager); // The album grid: cover-first cards that flow to the width they are given. @@ -837,18 +789,10 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { let places_map = PlacesMap::new(); places_stack.add_named(places_map.widget(), Some("map")); // The places as cards or on the map. - let places_grid_btn = gtk4::ToggleButton::builder() - .label(pgettext("layout", "Grid")) - .active(true) - .build(); - let places_map_btn = gtk4::ToggleButton::builder() - .label(pgettext("layout", "Map")) - .group(&places_grid_btn) - .build(); - let places_switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - places_switch.add_css_class("linked"); - places_switch.append(&places_grid_btn); - places_switch.append(&places_map_btn); + let places_layout = ToggleGroup::new(&[ + (&pgettext("layout", "Grid"), None), + (&pgettext("layout", "Map"), None), + ]); // While a refresh reads the library's locations (the first one after an // update reads all of them), a bar says how far it has got. let places_mapping_label = gtk4::Label::new(None); @@ -863,7 +807,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { places_mapping.append(&places_mapping_bar); places_mapping.set_visible(false); let places_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - places_bar.append(&places_switch); + places_bar.append(&places_layout.widget()); places_bar.append(&places_mapping); let places_page = gtk4::Box::new(gtk4::Orientation::Vertical, 12); places_page.append(&places_bar); @@ -888,7 +832,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { inner.set_margin_bottom(12); inner.set_margin_start(12); inner.set_margin_end(12); - inner.append(&view_switch); + inner.append(&view_switch_widget); inner.append(&filter_bar); inner.append(&content); @@ -899,7 +843,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { header.pack_start(&back); header.pack_start(&upload); header.pack_end(&more); - header.pack_end(&refresh); header.pack_end(&select_btn); ( @@ -920,7 +863,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { empty_actions, empty_upload, empty_import, - refresh, select_btn, select_bar, select_label, @@ -939,18 +881,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { albums, albums_stack, albums_status, - photos_btn, - albums_btn, view_switch, places, places_stack, places_status, - places_btn, places_mapping, places_mapping_label, places_mapping_bar, places_map, - places_map_btn, + places_layout, back, filters, }, @@ -1066,13 +1005,26 @@ 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 n = totals[index]; - tab.set_label(&format!("{} {}", tab_label(index), thousands(n))); - tab.set_sensitive(n > 0 || tab.is_active()); + let active = ui.gallery.tabs.active() as usize; + for (index, n) in totals.into_iter().enumerate() { + let tabs = &ui.gallery.tabs; + tabs.set_label( + index as u32, + &format!("{} {}", tab_label(index), thousands(n)), + ); + tabs.set_enabled(index as u32, n > 0 || index == active); } } +/// The Photos page's views, as indexes into its view switcher. +pub(crate) const VIEW_TIMELINE: u32 = 0; +pub(crate) const VIEW_ALBUMS: u32 = 1; +pub(crate) const VIEW_PLACES: u32 = 2; + +/// The Places view's Map layout, as an index into its Grid/Map toggle; the +/// grid is the other one. +pub(crate) const PLACES_MAP: u32 = 1; + /// The name of filter tab `index`: All, Photos, Videos or Raw. fn tab_label(index: usize) -> String { match index { @@ -1121,9 +1073,8 @@ pub(crate) fn wire_gallery( factory.connect_setup(|_, item| { let item = item.downcast_ref::().unwrap(); // One container for both row kinds: the bind fills it with either a - // heading label or a strip of tiles. The alternative — two factories, or - // a Stack per row — buys nothing, since a row's children are rebuilt on - // bind either way. + // heading label or a strip of tiles from the row's pool (see + // [`TileWidget`]). let row = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP); row.set_halign(gtk4::Align::Start); item.set_child(Some(&row)); @@ -1168,9 +1119,19 @@ pub(crate) fn wire_gallery( row_box.set_margin_top(0); // The last row of a day carries the gap to the next heading. row_box.set_margin_bottom(if *last { 4 } else { TILE_GAP }); - for tile in tiles { - row_box.append(&photo_tile(&ui_bind, tile.clone())); + let mut pools = ui_bind.gallery.tile_pools.borrow_mut(); + let pool = pools.entry(row_box.clone()).or_default(); + for widget in pool.iter() { + widget.release(&ui_bind); } + while pool.len() < tiles.len() { + pool.push(TileWidget::new(&ui_bind)); + } + for (widget, tile) in pool.iter().zip(tiles) { + widget.bind(&ui_bind, tile); + row_box.append(&widget.button); + } + drop(pools); schedule_thumbs(&ui_bind); } } @@ -1215,6 +1176,13 @@ pub(crate) fn wire_gallery( } } }); + let ui_teardown = ui.clone(); + factory.connect_teardown(move |_, item| { + let item = item.downcast_ref::().unwrap(); + if let Some(row_box) = item.child().and_downcast::() { + ui_teardown.gallery.tile_pools.borrow_mut().remove(&row_box); + } + }); list.set_factory(Some(&factory)); // The grid divides the content width, so a resize re-flows whatever is on @@ -1246,6 +1214,7 @@ pub(crate) fn wire_gallery( load_gallery(&ui_scroll, true); } sync_scrubber_position(&ui_scroll); + ui_scroll.gallery.scrubber.flash(); }); wire_scrubber(ui); wire_drag_select(ui); @@ -1320,55 +1289,19 @@ pub(crate) fn wire_gallery( .connect_clicked(move |_| set_selection_mode(&ui_done, false)); // Filter toggles: flipping to a tab reloads the timeline filtered to that - // kind. Only the button being switched *on* acts — the group also fires a - // `toggled` for the one switching off, which this skips — and a redundant - // toggle to the already-current kind is a no-op. - for (index, tab) in ui.gallery.tabs.iter().enumerate() { - let ui_tab = ui.clone(); - tab.connect_toggled(move |btn| { - if !btn.is_active() { - return; - } - let kind = kind_for_tab(index); - if ui_tab.gallery.kind.get() == kind { - return; - } - ui_tab.gallery.kind.set(kind); - // A different kind has a different set of months; clearing the active - // window makes the reload rebuild the date jump for the new kind. - ui_tab.gallery.range.set(None); - load_gallery(&ui_tab, false); - }); - } - - // Favorites: reload the timeline restricted to favorites (or back to all). - // Independent of the kind tabs and the date jump, both of which keep their - // current value across the toggle. - let ui_fav = ui.clone(); - ui.gallery.favorites_row.connect_active_notify(move |row| { - let on = row.is_active(); - if ui_fav.gallery.favorites.get() == on { + // kind. A redundant toggle to the already-current kind is a no-op. + let ui_tab = ui.clone(); + ui.gallery.tabs.connect_changed(move |index| { + let kind = kind_for_tab(index as usize); + if ui_tab.gallery.kind.get() == kind { return; } - ui_fav.gallery.favorites.set(on); - load_gallery(&ui_fav, false); + ui_tab.gallery.kind.set(kind); + // A different kind has a different set of months; clearing the active + // window makes the reload rebuild the date jump for the new kind. + ui_tab.gallery.range.set(None); + load_gallery(&ui_tab, false); }); - - // Not in an album: the same kind of filter, and just as independent. - let ui_unfiled = ui.clone(); - ui.gallery - .not_in_album_row - .connect_active_notify(move |row| { - let on = row.is_active(); - if ui_unfiled.gallery.not_in_album.get() == on { - return; - } - ui_unfiled.gallery.not_in_album.set(on); - load_gallery(&ui_unfiled, false); - }); - - // Clear filters: back to the whole timeline in one reload. The state goes - // first, so each widget's handler finds nothing to change and stays quiet. let ui_clear = ui.clone(); ui.gallery.clear_filters.connect_clicked(move |_| { let gallery = &ui_clear.gallery; @@ -1376,7 +1309,7 @@ pub(crate) fn wire_gallery( gallery.favorites.set(false); gallery.not_in_album.set(false); gallery.range.set(None); - gallery.tabs[0].set_active(true); + gallery.tabs.set_active(0); gallery.favorites_row.set_active(false); gallery.not_in_album_row.set_active(false); gallery.date_suppress.set(true); @@ -1570,101 +1503,75 @@ pub(crate) fn gallery_width(ui: &Rc) -> i32 { /// 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. -pub(crate) fn photo_tile(ui: &Rc, tile: Tile) -> gtk4::Button { - let picture = gtk4::Picture::builder() - // Every tile is square, so Cover crops the photo to fill it. Contain - // would letterbox it, and the bars would read as gaps in the sheet. - // The expands are what make the picture take the whole overlay. - .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"); +/// +/// A fast scroll binds hundreds of rows, so a tile is built once and then +/// rebound to whichever photo its row shows next: [`TileWidget::bind`] only +/// swaps what it shows, and its handlers read the photo it shows now. +pub(crate) struct TileWidget { + pub(crate) button: gtk4::Button, + picture: gtk4::Picture, + caption: gtk4::Label, + video: gtk4::Image, + group: gtk4::Label, + check: gtk4::Image, + photo: Rc>>, +} - 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"); +impl TileWidget { + pub(crate) fn new(ui: &Rc) -> Self { + let picture = gtk4::Picture::builder() + // Every tile is square, so Cover crops the photo to fill it. + // Contain would letterbox it, and the bars would read as gaps in + // the sheet. The expands are what make the picture take the whole + // overlay. + .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"); - // 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. Under a - // day heading the clock time is enough; under a month or a year the day is - // what the heading no longer says. - let caption_text = match tile.grouping { - Grouping::Day => short_capture_time(tile.photo.capture_time), - Grouping::Month | Grouping::Year => short_capture_date(tile.photo.capture_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(caption_text) - .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); - - // A video reads as a video at a glance: a play glyph centred over the poster - // thumbnail. Kept above the caption scrim so it stays legible on hover. - // Zoomed out, the glyph shrinks with the tile so it marks the video without - // covering it. - let small = tile.grouping != Grouping::Day; - let is_video = tile.photo.kind == PhotoKind::Video; - if is_video { - let badge = gtk4::Image::builder() - .icon_name("media-playback-start-symbolic") - .pixel_size(if small { 12 } else { 28 }) + let placeholder = gtk4::Image::builder() + .icon_name("image-x-generic-symbolic") + .pixel_size(24) .halign(gtk4::Align::Center) .valign(gtk4::Align::Center) .build(); - badge.add_css_class("photo-video-badge"); - if small { - badge.add_css_class("photo-video-badge-small"); - } - overlay.add_overlay(&badge); - } + placeholder.add_css_class("photo-placeholder"); - // A shot stored as more than one file says so, in the corner the caption - // does not use. "RAW" is the useful word when one of the members is a raw - // file — that is what the person wants to find — and a plain count covers - // the rest (a live photo, a burst). Only the Day zoom shows it: on the - // smaller Month and Year tiles a badge on every raw+JPEG shot turns the - // grid into noise. - if (tile.photo.has_raw || tile.photo.group_size > 1) && tile.grouping == Grouping::Day { - let badge = gtk4::Label::builder() - .label(if tile.photo.has_raw { - // Translators: badge on a photo tile whose shot includes a raw camera file. - gettext("RAW") - } else { - format!("{}", tile.photo.group_size) - }) + // 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) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + caption.add_css_class("photo-caption"); + + // A video reads as a video at a glance: a play glyph centred over the + // poster thumbnail. Kept above the caption scrim so it stays legible + // on hover. + let video = gtk4::Image::builder() + .icon_name("media-playback-start-symbolic") + .halign(gtk4::Align::Center) + .valign(gtk4::Align::Center) + .build(); + video.add_css_class("photo-video-badge"); + + // A shot stored as more than one file says so, in the corner the + // caption does not use. + let group = gtk4::Label::builder() .halign(gtk4::Align::End) .valign(gtk4::Align::Start) .build(); - badge.add_css_class("photo-group-badge"); - overlay.add_overlay(&badge); - } + group.add_css_class("photo-group-badge"); - // Selection mode marks every tile, picked or not: a check that only appears - // once a photo is chosen leaves the user guessing what else is clickable. - if tile.selecting { let check = gtk4::Image::builder() - .icon_name(if tile.selected { - "checkbox-checked-symbolic" - } else { - "checkbox-symbolic" - }) .pixel_size(16) .halign(gtk4::Align::Start) .valign(gtk4::Align::Start) @@ -1672,80 +1579,170 @@ pub(crate) fn photo_tile(ui: &Rc, tile: Tile) -> gtk4::Button { .margin_top(6) .build(); check.add_css_class("photo-check"); - if tile.selected { - check.add_css_class("photo-check-on"); - } + + let overlay = gtk4::Overlay::new(); + overlay.set_child(Some(&placeholder)); + overlay.add_overlay(&picture); + overlay.add_overlay(&caption); + overlay.add_overlay(&video); + overlay.add_overlay(&group); overlay.add_overlay(&check); - } - let button = gtk4::Button::builder() - .child(&overlay) - .width_request(tile.width) - .height_request(tile.height) - .tooltip_text(format_capture_time(tile.photo.capture_time)) - .build(); - button.add_css_class("photo-tile"); - button.add_css_class("flat"); - if tile.selected { - button.add_css_class("photo-tile-selected"); - } - // Clip the thumbnail to the tile's rounded corners. - button.set_overflow(gtk4::Overflow::Hidden); + let button = gtk4::Button::builder().child(&overlay).build(); + button.add_css_class("photo-tile"); + button.add_css_class("flat"); + // Clip the thumbnail to the tile's rounded corners. + button.set_overflow(gtk4::Overflow::Hidden); - want_thumb(ui, &tile.photo, &picture); + let photo: Rc>> = Rc::default(); + let uid = { + let photo = photo.clone(); + move || photo.borrow().as_ref().map(|p| p.uid.clone()) + }; - // Ctrl or Shift on a plain click starts a selection — the gesture people - // already use for picking things, without having to find the Select button - // first. Ctrl picks one photo; Shift picks everything from the last photo - // clicked to this one, as in a file manager. Claimed in the capture phase so - // the click never also opens the photo it was picking. - let modifier = gtk4::GestureClick::new(); - modifier.set_propagation_phase(gtk4::PropagationPhase::Capture); - let ui_modifier = ui.clone(); - let modifier_uid = tile.photo.uid.clone(); - modifier.connect_pressed(move |gesture, _, _, _| { - let state = gesture.current_event_state(); - let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK); - if !shift && !state.contains(gtk4::gdk::ModifierType::CONTROL_MASK) { - return; - } - gesture.set_state(gtk4::EventSequenceState::Claimed); - set_selection_mode(&ui_modifier, true); - let anchor = ui_modifier.gallery.select_anchor.borrow().clone(); - match anchor { - Some(anchor) if shift => { - let base = ui_modifier.gallery.selected.borrow().clone(); - select_range(&ui_modifier, &anchor, &modifier_uid, &base); + // Ctrl or Shift on a plain click starts a selection — the gesture + // people already use for picking things, without having to find the + // Select button first. Ctrl picks one photo; Shift picks everything + // from the last photo clicked to this one, as in a file manager. + // Claimed in the capture phase so the click never also opens the photo + // it was picking. + let modifier = gtk4::GestureClick::new(); + modifier.set_propagation_phase(gtk4::PropagationPhase::Capture); + let ui_modifier = ui.clone(); + let modifier_uid = uid.clone(); + modifier.connect_pressed(move |gesture, _, _, _| { + let state = gesture.current_event_state(); + let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK); + if !shift && !state.contains(gtk4::gdk::ModifierType::CONTROL_MASK) { + return; } - _ => toggle_selected(&ui_modifier, &modifier_uid), - } - }); - button.add_controller(modifier); + let Some(uid) = modifier_uid() else { + return; + }; + gesture.set_state(gtk4::EventSequenceState::Claimed); + set_selection_mode(&ui_modifier, true); + let anchor = ui_modifier.gallery.select_anchor.borrow().clone(); + match anchor { + Some(anchor) if shift => { + let base = ui_modifier.gallery.selected.borrow().clone(); + select_range(&ui_modifier, &anchor, &uid, &base); + } + _ => toggle_selected(&ui_modifier, &uid), + } + }); + button.add_controller(modifier); - let context = gtk4::GestureClick::builder().button(3).build(); - let ui_context = ui.clone(); - let photo = tile.photo.clone(); - context.connect_pressed(move |gesture, _, x, y| { - gesture.set_state(gtk4::EventSequenceState::Claimed); - if let Some(anchor) = gesture.widget().and_downcast::() { - show_photo_menu(&ui_context, &photo, &anchor, x, y); - } - }); - button.add_controller(context); + let context = gtk4::GestureClick::builder().button(3).build(); + let ui_context = ui.clone(); + let context_photo = photo.clone(); + context.connect_pressed(move |gesture, _, x, y| { + let Some(photo) = context_photo.borrow().clone() else { + return; + }; + gesture.set_state(gtk4::EventSequenceState::Claimed); + if let Some(anchor) = gesture.widget().and_downcast::() { + show_photo_menu(&ui_context, &photo, &anchor, x, y); + } + }); + button.add_controller(context); - // A tile opens in the in-app lightbox, which plays a video in place. In - // selection mode a tile picks instead of opening: the lightbox is one - // Escape away. - let ui_open = ui.clone(); - let uid = tile.photo.uid.clone(); - button.connect_clicked(move |_| { - if ui_open.gallery.selecting.get() { - toggle_selected(&ui_open, &uid); + // A tile opens in the in-app lightbox, which plays a video in place. + // In selection mode a tile picks instead of opening: the lightbox is + // one Escape away. + let ui_open = ui.clone(); + button.connect_clicked(move |_| { + let Some(uid) = uid() else { + return; + }; + if ui_open.gallery.selecting.get() { + toggle_selected(&ui_open, &uid); + } else { + open_photo_viewer(&ui_open, uid); + } + }); + + Self { + button, + picture, + caption, + video, + group, + check, + photo, + } + } + + /// Show `tile` in place of whatever the widget showed before. + pub(crate) fn bind(&self, ui: &Rc, tile: &Tile) { + self.button.set_size_request(tile.width, tile.height); + self.picture.set_paintable(None::<>k4::gdk::Paintable>); + + // Under a day heading the clock time is enough; under a month or a + // year the day is what the heading no longer says. + self.caption.set_label(&match tile.grouping { + Grouping::Day => short_capture_time(tile.photo.capture_time), + Grouping::Month | Grouping::Year => short_capture_date(tile.photo.capture_time), + }); + + // Zoomed out, the play glyph shrinks with the tile so it marks the + // video without covering it. + let small = tile.grouping != Grouping::Day; + self.video.set_visible(tile.photo.kind == PhotoKind::Video); + self.video.set_pixel_size(if small { 12 } else { 28 }); + set_css_class(&self.video, "photo-video-badge-small", small); + + // "RAW" is the useful word when one of the shot's files is a raw file + // — that is what the person wants to find — and a plain count covers + // the rest (a live photo, a burst). Only the Day zoom shows it: on the + // smaller Month and Year tiles a badge on every raw+JPEG shot turns + // the grid into noise. + let grouped = tile.photo.has_raw || tile.photo.group_size > 1; + self.group + .set_visible(grouped && tile.grouping == Grouping::Day); + if tile.photo.has_raw { + // Translators: badge on a photo tile whose shot includes a raw camera file. + self.group.set_label(&gettext("RAW")); } else { - open_photo_viewer(&ui_open, uid.clone()); + self.group.set_label(&tile.photo.group_size.to_string()); } - }); - button + + // Selection mode marks every tile, picked or not: a check that only + // appears once a photo is chosen leaves the user guessing what else is + // clickable. + self.check.set_visible(tile.selecting); + self.check.set_icon_name(Some(if tile.selected { + "checkbox-checked-symbolic" + } else { + "checkbox-symbolic" + })); + set_css_class(&self.check, "photo-check-on", tile.selected); + set_css_class(&self.button, "photo-tile-selected", tile.selected); + + *self.photo.borrow_mut() = Some(tile.photo.clone()); + want_thumb(ui, &tile.photo, &self.picture); + } + + /// Give up the claim on the thumbnail of the photo shown last, so it + /// cannot land in this widget once it shows another one. + pub(crate) fn release(&self, ui: &Rc) { + let Some(photo) = self.photo.borrow_mut().take() else { + return; + }; + let mut wanted = ui.gallery.thumb_wanted.borrow_mut(); + if wanted.get(&photo.uid) == Some(&self.picture) { + wanted.remove(&photo.uid); + } + } +} + +/// Add or remove one CSS class, for widgets that are rebound rather than +/// rebuilt. +fn set_css_class(widget: &impl IsA, class: &str, on: bool) { + if on { + widget.add_css_class(class); + } else { + widget.remove_css_class(class); + } } /// Enter or leave selection mode, repainting the tiles so their checkboxes @@ -2851,20 +2848,14 @@ pub(crate) fn empty_timeline_text( /// mark and a year label where each year starts. Shown only for the whole, /// unfiltered timeline with more than one month to move between. fn fill_scrubber(ui: &Rc, months: &[PhotoMonth]) { - let scrubber = &ui.gallery.scrubber; - scrubber.clear_marks(); *ui.gallery.months.borrow_mut() = months.to_vec(); - scrubber.set_range(0.0, months.len().saturating_sub(1).max(1) as f64); - scrubber.set_value(0.0); - for (index, pair) in months.windows(2).enumerate() { - if pair[0].year != pair[1].year { - scrubber.add_mark( - (index + 1) as f64, - gtk4::PositionType::Left, - Some(&pair[1].year.to_string()), - ); - } - } + let years = months + .iter() + .enumerate() + .filter(|&(index, month)| index == 0 || months[index - 1].year != month.year) + .map(|(index, month)| (index, month.year.to_string())) + .collect(); + ui.gallery.scrubber.set_months(months.len(), years); sync_scrubber(ui); } @@ -2877,7 +2868,7 @@ pub(crate) fn sync_scrubber(ui: &Rc) { && !ui.gallery.not_in_album.get(); ui.gallery .scrubber - .set_visible(whole && ui.gallery.months.borrow().len() > 1); + .set_available(whole && ui.gallery.months.borrow().len() > 1); } /// How many photos the "On this day" strip asks for. Enough for a card per year @@ -3036,10 +3027,10 @@ fn scrubber_label(months: &[PhotoMonth], index: usize) -> String { .map_or_else(String::new, |m| month_label(m.year, m.month)) } -/// Move the knob to the month at the top of the timeline, unless the user is -/// holding it. +/// Move the knob to the month at the top of the timeline. The scrubber itself +/// ignores this while the user has it in hand. fn sync_scrubber_position(ui: &Rc) { - if ui.gallery.scrubbing.get() || !ui.gallery.scrubber.is_visible() { + if !ui.gallery.scrubber.is_available() { return; } let Some((uid, _)) = top_anchor(ui) else { @@ -3054,7 +3045,7 @@ fn sync_scrubber_position(ui: &Rc) { }; let months = ui.gallery.months.borrow(); if let Some(index) = month_index(&months, capture_time) { - ui.gallery.scrubber.set_value(index as f64); + ui.gallery.scrubber.set_value(index); } } @@ -3070,43 +3061,24 @@ pub(crate) fn month_index(months: &[PhotoMonth], capture_time: i64) -> Option) { - let scrubber = ui.gallery.scrubber.clone(); - let ui_format = ui.clone(); - scrubber.set_format_value_func(move |_, value| { - scrubber_label(&ui_format.gallery.months.borrow(), value.round() as usize) - }); - - let hover = gtk4::EventControllerMotion::new(); - let ui_enter = ui.clone(); - hover.connect_enter(move |_, _, _| { - ui_enter.gallery.scrubbing.set(true); - ui_enter.gallery.scrubber.set_draw_value(true); - }); - let ui_leave = ui.clone(); - hover.connect_leave(move |_| { - ui_leave.gallery.scrubbing.set(false); - ui_leave.gallery.scrubber.set_draw_value(false); - }); - scrubber.add_controller(hover); - - // `change-value` fires for the user's own moves only, not for the knob - // following the scroll. - let ui_change = ui.clone(); - scrubber.connect_change_value(move |_, _, value| { - if let Some(source) = ui_change.gallery.scrub_source.borrow_mut().take() { + let ui_label = ui.clone(); + ui.gallery + .scrubber + .set_label_func(move |index| scrubber_label(&ui_label.gallery.months.borrow(), index)); + let ui_pick = ui.clone(); + ui.gallery.scrubber.connect_pick(move |index| { + if let Some(source) = ui_pick.gallery.scrub_source.borrow_mut().take() { source.remove(); } - let index = value.round().max(0.0) as usize; - let ui_jump = ui_change.clone(); + let ui_jump = ui_pick.clone(); let source = glib::timeout_add_local_once(SCRUB_DEBOUNCE, move || { ui_jump.gallery.scrub_source.borrow_mut().take(); jump_to_month(&ui_jump, index); }); - *ui_change.gallery.scrub_source.borrow_mut() = Some(source); - glib::Propagation::Proceed + *ui_pick.gallery.scrub_source.borrow_mut() = Some(source); }); } @@ -3332,7 +3304,6 @@ pub(crate) fn load_gallery(ui: &Rc, append: bool) { }; ui.gallery.loading.set(true); ui.gallery.pager.set_visible(append); - ui.gallery.pager.set_spinning(append); ui.busy_begin(); let request = match (album, place) { @@ -3354,7 +3325,6 @@ pub(crate) fn load_gallery(ui: &Rc, append: bool) { ui.busy_end(); ui.gallery.loading.set(false); ui.gallery.pager.set_visible(false); - ui.gallery.pager.set_spinning(false); if ui.gallery.reload_pending.take() { load_gallery(&ui, false); return; diff --git a/crates/pdfs-gui/src/app/pages/places.rs b/crates/pdfs-gui/src/app/pages/places.rs index 89b64fe..ea0364b 100644 --- a/crates/pdfs-gui/src/app/pages/places.rs +++ b/crates/pdfs-gui/src/app/pages/places.rs @@ -422,7 +422,7 @@ fn open_place(ui: &Rc, place: PlaceInfo) { // As in an album: no filters, no Upload, no view switch — back leads out. ui.gallery.filters.set_visible(false); - ui.gallery.view_switch.set_visible(false); + ui.gallery.view_switch.widget().set_visible(false); ui.gallery.upload.set_visible(false); ui.gallery.back.set_visible(true); ui.gallery.kind.set(None); @@ -439,7 +439,7 @@ pub(crate) fn close_place(ui: &Rc) { return; } ui.gallery.title.set_title(&gettext("Photos")); - ui.gallery.view_switch.set_visible(true); + ui.gallery.view_switch.widget().set_visible(true); ui.gallery.upload.set_visible(true); ui.gallery.back.set_visible(false); } @@ -455,14 +455,14 @@ fn places_status(ui: &Rc, icon: &str, title: &str, description: &str) { /// Wire the Places toggle of the view switcher. pub(crate) fn wire_places(ui: &Rc) { let ui_places = ui.clone(); - ui.gallery.places_btn.clone().connect_toggled(move |btn| { - if btn.is_active() { + ui.gallery.view_switch.connect_changed(move |view| { + if view == VIEW_PLACES { show_places(&ui_places); } }); let ui_layout = ui.clone(); - ui.gallery.places_map_btn.connect_toggled(move |_| { + ui.gallery.places_layout.connect_changed(move |_| { // A status page (loading, empty, an error) stays until a listing // replaces it; only the grid and the map swap. if ui_layout @@ -493,19 +493,19 @@ pub(crate) fn wire_places(ui: &Rc) { /// Switch to the Places map and zoom in on `(latitude, longitude)`, where a /// photo in the viewer was taken. pub(crate) fn show_on_map(ui: &Rc, latitude: f64, longitude: f64) { - ui.gallery.places_map_btn.set_active(true); - if ui.gallery.places_btn.is_active() { + ui.gallery.places_layout.set_active(PLACES_MAP); + if ui.gallery.view_switch.active() == VIEW_PLACES { // Already in Places, perhaps inside a town: back out to the listing. show_places(ui); } else { - ui.gallery.places_btn.set_active(true); + ui.gallery.view_switch.set_active(VIEW_PLACES); } ui.gallery.places_map.show_spot(latitude, longitude); } /// Show the places as cards or on the map, as the Grid/Map toggle says. fn show_places_layout(ui: &Rc) { - let layout = if ui.gallery.places_map_btn.is_active() { + let layout = if ui.gallery.places_layout.active() == PLACES_MAP { "map" } else { "grid" diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index 969b59e..197d96b 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -42,9 +42,6 @@ pub(crate) struct SharedState { /// The [`SharedState::nav`] the files on screen were painted for, or `None` /// before the first paint. pub(crate) listed: RefCell>>, - /// When the Shared page last painted good data. `None` = never / invalidated, - /// forcing a fetch on next visit. See [`PAGE_TTL`]. - pub(crate) loaded_at: Cell>, } impl SharedState { @@ -69,7 +66,6 @@ impl SharedState { nav: RefCell::new(Vec::new()), loader: Loader::new(&widgets.content), listed: RefCell::new(None), - loaded_at: Cell::new(None), } } } @@ -88,7 +84,6 @@ pub(crate) struct SharedWidgets { pub(crate) invitations_banner: adw::Banner, pub(crate) links_content: gtk4::Stack, pub(crate) links_group: adw::PreferencesGroup, - pub(crate) refresh: gtk4::Button, pub(crate) layout: gtk4::Button, pub(crate) add_link: gtk4::Button, pub(crate) add_link_empty: gtk4::Button, @@ -98,7 +93,6 @@ pub(crate) struct SharedWidgets { /// in the same list and grid as My files, and a second view of the public /// links I saved. Pending invitations wait in a banner above both. pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { - let refresh = refresh_button(); let add_link = gtk4::Button::builder() .label(gettext("Save Link…")) .tooltip_text(gettext("Save a public link to open it from here")) @@ -220,7 +214,6 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { // Under the header, above both views: an invitation is waiting for a // decision whichever view is open. frame.add_top_bar(&invitations_banner); - header.pack_end(&refresh); header.pack_end(&layout); header.pack_end(&add_link); @@ -239,7 +232,6 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { invitations_banner, links_content, links_group, - refresh, layout, add_link, add_link_empty, @@ -362,7 +354,6 @@ pub(crate) fn load_shared(ui: &Rc) { || matches!(invites, Ok(Err(_)) | Err(_)) || matches!(bookmarks, Ok(Err(_)) | Err(_)) { - ui.shared.loaded_at.set(None); shared_unreachable(&ui); return; } @@ -382,7 +373,6 @@ pub(crate) fn load_shared(ui: &Rc) { repaint_invitations(&ui, invitations); repaint_links(&ui, &bookmark_items); repaint_shared_files(&ui, &shared_items); - ui.shared.loaded_at.set(Some(Instant::now())); }); } @@ -407,7 +397,6 @@ fn load_shared_folder(ui: &Rc, uid: String) { match result { Ok(Ok(Response::Entries { entries })) => { repaint_shared_files(&ui, &entries); - ui.shared.loaded_at.set(Some(Instant::now())); } Ok(Ok(Response::Error { message, kind })) => { // The folder is gone or access was revoked: fall back to the top @@ -417,7 +406,6 @@ fn load_shared_folder(ui: &Rc, uid: String) { load_shared(&ui); } _ => { - ui.shared.loaded_at.set(None); shared_unreachable(&ui); } } @@ -721,18 +709,15 @@ pub(crate) fn open_shared_file(ui: &Rc, uid: &str, name: &str) { }); } -/// How often the sidebar badge asks for invitations away from the page. -const INVITATIONS_TTL: Duration = Duration::from_secs(300); - -/// Poll the invitations now and then from anywhere, so the sidebar's Shared -/// with me row can say that some wait. -pub(crate) fn refresh_invitations_badge(ui: &Rc) { +/// Read the invitations from anywhere unless the last reading is younger than +/// `max_age`, so the sidebar's Shared with me row can say that some wait. +pub(crate) fn refresh_invitations_badge(ui: &Rc, max_age: Duration) { let state = &ui.shared; if state.invitations_inflight.get() || state .invitations_at .get() - .is_some_and(|at| at.elapsed() < INVITATIONS_TTL) + .is_some_and(|at| at.elapsed() < max_age) { return; } @@ -744,7 +729,7 @@ pub(crate) fn refresh_invitations_badge(ui: &Rc) { ui.shared.invitations_inflight.set(false); match result { Ok(Ok(Response::Invitations { items })) => repaint_invitations(&ui, items), - // Try again at the next interval rather than on every tick. + // Wait out `max_age` before asking a failing daemon again. _ => ui.shared.invitations_at.set(Some(Instant::now())), } }); diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs index 1f3b42b..cc399de 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -17,9 +17,6 @@ pub(crate) struct SharedByMeState { pub(crate) items: Rc>>, /// Runs the loads; the rows stay up while one runs. pub(crate) loader: Rc, - /// When the page last painted good data. `None` = never / invalidated, - /// forcing a fetch on next visit. See [`PAGE_TTL`]. - pub(crate) loaded_at: Cell>, } impl SharedByMeState { @@ -33,7 +30,6 @@ impl SharedByMeState { filter: widgets.filter.clone(), items: Rc::new(RefCell::new(Vec::new())), loader: Loader::new(&widgets.content), - loaded_at: Cell::new(None), } } } @@ -44,7 +40,6 @@ pub(crate) struct SharedByMeWidgets { pub(crate) content: gtk4::Stack, pub(crate) status: adw::StatusPage, pub(crate) retry: gtk4::Button, - pub(crate) refresh: gtk4::Button, pub(crate) subtitle: adw::WindowTitle, pub(crate) filter: gtk4::DropDown, } @@ -82,7 +77,6 @@ fn share_filter_label(filter: ShareFilter) -> String { /// Activating one opens its Share dialog, since managing access is what this /// page is for; opening the item itself is in the menu. pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) { - let refresh = refresh_button(); let files = FileList::new(); files.show_list(true); let layout = layout_button(&files); @@ -125,7 +119,6 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) { let (frame, header, subtitle) = page_frame(&gettext("Shared by me"), &inner); header.pack_start(&filter); - header.pack_end(&refresh); header.pack_end(&layout); ( @@ -135,7 +128,6 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) { content, status, retry, - refresh, subtitle, filter, }, @@ -375,7 +367,6 @@ pub(crate) fn load_shared_by_me(ui: &Rc) { *ui.shared_by_me.items.borrow_mut() = items.clone(); repaint_shared_by_me(&ui, &items); rebind_rows(&ui, &changed); - ui.shared_by_me.loaded_at.set(Some(Instant::now())); } Ok(Ok(Response::Error { message, .. })) => shared_by_me_status( &ui, @@ -392,7 +383,6 @@ pub(crate) fn load_shared_by_me(ui: &Rc) { false, ), Ok(Err(_)) | Err(_) => { - ui.shared_by_me.loaded_at.set(None); shared_by_me_unreachable(&ui); } } @@ -582,7 +572,6 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc, entries: &[DirEntry]) { .collect(), gettext_noop("Couldn't stop sharing"), move |ui| { - ui.shared_by_me.loaded_at.set(None); reload_listing(ui); toast(ui, &done); }, diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index a3bee65..b2c1245 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -20,7 +20,7 @@ pub(crate) struct StatusState { /// systemd user service, not by the user — so this only reports. pub(crate) status_icon: gtk4::Image, /// Stands in for [`Self::status_icon`] until the first status arrives. - pub(crate) status_spinner: gtk4::Spinner, + pub(crate) status_spinner: Spinner, pub(crate) status_title: gtk4::Label, pub(crate) status_detail: gtk4::Label, pub(crate) cache_bar: gtk4::ProgressBar, @@ -106,7 +106,7 @@ const SYNC_NOTIFY_MIN_DURATION: Duration = Duration::from_secs(30); /// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's /// `sensitive` in place (when the pin set is unchanged) instead of rebuilding. pub(crate) struct PinRow { - pub(crate) row: adw::ActionRow, + pub(crate) row: gtk4::ListBoxRow, /// The unpin button, absent on the placeholder row. pub(crate) unpin: Option, } @@ -140,7 +140,7 @@ pub(crate) struct MainWidgets { pub(crate) account_name: gtk4::Label, pub(crate) avatar: adw::Avatar, pub(crate) status_icon: gtk4::Image, - pub(crate) status_spinner: gtk4::Spinner, + pub(crate) status_spinner: Spinner, pub(crate) status_title: gtk4::Label, pub(crate) status_detail: gtk4::Label, pub(crate) quota_box: gtk4::Box, @@ -331,7 +331,7 @@ pub(crate) fn build_main_page() -> MainWidgets { // Until the first status arrives there is no state to show, only that the // app is still asking. let status_icon = gtk4::Image::builder().visible(false).build(); - let status_spinner = gtk4::Spinner::builder().spinning(true).build(); + let status_spinner = spinner(); let status_title = gtk4::Label::builder() .label(gettext("Connecting…")) .halign(gtk4::Align::Start) @@ -774,9 +774,8 @@ pub(crate) fn prompt_mountpoint(ui: &Rc) { return; } ui.status.mountpoint_row.set_subtitle(&path_str); - // The Locations page shows the same path as a row title; its cached rows - // are now stale whether or not it is the visible page. - ui.locations.loaded_at.set(None); + // The Locations page shows the same path as a row title. Any other page + // reads it again on the next visit. if ui.stack.visible_child_name().as_deref() == Some("locations") { load_locations(&ui); } @@ -826,10 +825,11 @@ pub(crate) fn wire_retry(ui: &Rc) { .connect_clicked(move |_| restart_service_then(&ui_trash, load_trash)); } -/// Repaint the window from the cached login identity, then kick an async mount- -/// status fetch. Runs on the 2s tick: the identity check is instant (no keyring), -/// and the status round-trip is offloaded to a worker so the main loop never -/// blocks on a slow or wedged daemon. +/// Repaint the window from the cached login identity, then ask the daemon for +/// its status and transfers. Runs at startup, after a login and after a service +/// restart; the daemon's event feed keeps both current in between. The identity +/// check is instant (no keyring), and the round-trips run on workers so the +/// main loop never blocks on a slow or wedged daemon. pub(crate) fn refresh(ui: &Rc) { // Login identity decides which page is shown. Read the cached session — set // at startup and on login/logout — never the keyring. @@ -862,32 +862,10 @@ pub(crate) fn refresh(ui: &Rc) { refresh_status(ui); refresh_transfers(ui); - // Both of these pages show work as it happens, so they follow the tick while - // they are on screen. Every other page loads on navigation only. - // A Takeout import outlives the page that started it, so it is polled - // wherever the user has navigated to — otherwise leaving the page means - // never being told it finished. - ui.gallery - .import_banner - .set_revealed(ui.takeout.running.get()); - if ui.stack.visible_child_name().as_deref() == Some("takeout") || ui.takeout.running.get() { - refresh_takeout(ui); - } - // The quota sits in the sidebar footer, visible on every page; its TTL - // keeps this from asking more than once a minute. - refresh_quota(ui); - if ui.stack.visible_child_name().as_deref() == Some("locations") { - tick_sync_view(ui); - } else { - // The sidebar badges count conflicts and invitations wherever the - // user is; both lists are costly enough to ask for rarely. - refresh_conflicts_badge(ui); - } - refresh_invitations_badge(ui); } -/// How long a quota reading stays fresh. Account storage barely moves, so the -/// active-page tick refetches it only this often rather than every 2s. +/// How long a quota reading stays fresh. Account storage barely moves, so a +/// status change reads it again only this often. const QUOTA_TTL: Duration = Duration::from_secs(60); /// Fetch the account quota (if the last reading is stale) and paint the @@ -992,9 +970,9 @@ pub(crate) fn set_mounted(ui: &Rc, mounted: bool) { } } -/// Poll the daemon's in-flight transfers on a worker thread and repaint the +/// Ask the daemon for its in-flight transfers on a worker thread and repaint the /// Activity group. Independently inflight-guarded from [`refresh_status`] so the -/// two cheap polls on the 2s tick don't gate each other. The group hides itself +/// two requests don't gate each other; the event feed pushes later snapshots. The group hides itself /// when nothing is moving, so an idle account shows no Activity section. pub(crate) fn refresh_transfers(ui: &Rc) { if ui.status.transfers_inflight.get() { @@ -1187,7 +1165,10 @@ pub(crate) fn transfer_line(t: &TransferItem) -> ActivityLine { /// the mount line, cache bar and pin list on the reply. The daemon owns the cache /// stats now (`used`/`budget`/`pins` ride along on [`Response::Status`]), so the /// GUI never opens the on-disk cache itself. Skipped while a fetch is in flight so -/// the tick can't stack threads on a stalled daemon. +/// a stalled daemon can't pile up threads. +/// +/// The event feed pushes every later change through [`paint_status`]; this asks +/// only when the feed (re)connects or loses the daemon. pub(crate) fn refresh_status(ui: &Rc) { if ui.status.status_inflight.get() { return; @@ -1199,92 +1180,102 @@ pub(crate) fn refresh_status(ui: &Rc) { let result = rx.recv().await; ui.status.status_inflight.set(false); match result { - Ok(Ok(Response::Status { - mountpoint, - used, - budget, - pins, - online, - pending_uploads, - pending_changes, - failing_ops, - failing_error, - paused, - paused_until, - .. - })) => { - set_mounted(&ui, true); - // The queue is the more useful thing to say when it has anything - // in it: it is why a file that looks saved is not on the remote - // yet, and offline is usually the reason it is still queued. - let queued = i18n::pending_summary(pending_uploads, pending_changes); - let state = if paused { - SyncState::Paused { - until: paused_until, - queued, - } - } else if failing_ops > 0 { - SyncState::Attention { - count: failing_ops, - error: failing_error, - } - } else if !online { - SyncState::Offline { queued } - } else if let Some(queued) = queued { - SyncState::Syncing { queued } - } else { - SyncState::UpToDate { mountpoint } - }; - paint_sync_status(&ui, state); - ui.locations.failing.set(failing_ops); - paint_sync_banner(&ui); - let fraction = if budget == 0 { - 0.0 - } else { - (used as f64 / budget as f64).min(1.0) - }; - ui.status.cache_bar.set_fraction(fraction); - // A 0 budget means *unlimited*, not a zero-byte cap — "of 0 B - // used" reads as a broken read-out, and there is no fraction to - // draw against no limit. - ui.status.cache_label.set_text(&if budget == 0 { - // Translators: {used} is a size such as "1.2 GiB". - gettext_f( - "{used} cached — no limit set", - &[("used", &human_bytes(used))], - ) - } else { - // Translators: {used} and {limit} are sizes such as "1.2 GiB". - gettext_f( - "{used} of {limit} used", - &[ - ("used", &human_bytes(used)), - ("limit", &human_bytes(budget)), - ], - ) - }); - repaint_pins(&ui, &pins, true); - } + Ok(Ok(status @ Response::Status { .. })) => paint_status(&ui, status), // The daemon is up but stuck. Say so, and keep the mount state: // announcing "disconnected" for a slow reply would be wrong, and - // the next tick asks again. + // the feed asks again when it reconnects. Ok(Err(e)) if e.is_timeout() => paint_sync_status(&ui, SyncState::NotResponding), - // Daemon unreachable (still starting, or down): report not-mounted and - // grey out the unpin buttons in place, but leave the last-known pin - // rows and cache read-out so the page doesn't flicker on a blip. - _ => { - set_mounted(&ui, false); - paint_sync_status(&ui, SyncState::Disconnected); - for r in ui.status.pin_rows.borrow().iter() { - if let Some(b) = &r.unpin { - b.set_sensitive(false); - } - } - } + _ => paint_disconnected(&ui), } }); } +/// Paint a [`Response::Status`], fetched or pushed: the mount state, the sync +/// status, the cache bar and the pin list. +pub(crate) fn paint_status(ui: &Rc, status: Response) { + let Response::Status { + mountpoint, + used, + budget, + pins, + online, + pending_uploads, + pending_changes, + failing_ops, + failing_error, + paused, + paused_until, + .. + } = status + else { + return; + }; + set_mounted(ui, true); + // The queue is the more useful thing to say when it has anything + // in it: it is why a file that looks saved is not on the remote + // yet, and offline is usually the reason it is still queued. + let queued = i18n::pending_summary(pending_uploads, pending_changes); + let state = if paused { + SyncState::Paused { + until: paused_until, + queued, + } + } else if failing_ops > 0 { + SyncState::Attention { + count: failing_ops, + error: failing_error, + } + } else if !online { + SyncState::Offline { queued } + } else if let Some(queued) = queued { + SyncState::Syncing { queued } + } else { + SyncState::UpToDate { mountpoint } + }; + paint_sync_status(ui, state); + ui.locations.failing.set(failing_ops); + paint_sync_banner(ui); + let fraction = if budget == 0 { + 0.0 + } else { + (used as f64 / budget as f64).min(1.0) + }; + ui.status.cache_bar.set_fraction(fraction); + // A 0 budget means *unlimited*, not a zero-byte cap — "of 0 B + // used" reads as a broken read-out, and there is no fraction to + // draw against no limit. + ui.status.cache_label.set_text(&if budget == 0 { + // Translators: {used} is a size such as "1.2 GiB". + gettext_f( + "{used} cached — no limit set", + &[("used", &human_bytes(used))], + ) + } else { + // Translators: {used} and {limit} are sizes such as "1.2 GiB". + gettext_f( + "{used} of {limit} used", + &[ + ("used", &human_bytes(used)), + ("limit", &human_bytes(budget)), + ], + ) + }); + repaint_pins(ui, &pins, true); +} + +/// Daemon unreachable (still starting, or down): report not-mounted and grey +/// out the unpin buttons in place, but leave the last-known pin rows and cache +/// read-out so the page doesn't flicker on a blip. +pub(crate) fn paint_disconnected(ui: &Rc) { + set_mounted(ui, false); + paint_sync_status(ui, SyncState::Disconnected); + for r in ui.status.pin_rows.borrow().iter() { + if let Some(b) = &r.unpin { + b.set_sensitive(false); + } + } +} + /// What the sidebar's status strip reports, most urgent first. pub(crate) enum SyncState { /// The user paused syncing; nothing goes up until it resumes. Outranks @@ -1386,7 +1377,6 @@ fn paint_sync_status(ui: &Rc, state: SyncState) { image.set_icon_name(Some(icon)); image.set_visible(true); ui.status.status_spinner.set_visible(false); - ui.status.status_spinner.set_spinning(false); for c in ["success", "warning", "error"] { image.remove_css_class(c); } @@ -1465,10 +1455,10 @@ pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: )) .build(); ui.status.pins_group.add(&row); - ui.status - .pin_rows - .borrow_mut() - .push(PinRow { row, unpin: None }); + ui.status.pin_rows.borrow_mut().push(PinRow { + row: row.upcast(), + unpin: None, + }); return; } @@ -1529,32 +1519,29 @@ pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: ui.status.pins_group.add(&row); ui.status.pin_rows.borrow_mut().push(PinRow { - row, + row: row.upcast(), unpin: Some(unpin), }); } if pins.len() > PINS_COLLAPSED { - let row = adw::ActionRow::builder() - .title(if expanded { - gettext("Show fewer") - } else { - ngettext_f( - "Show all {n} file", - "Show all {n} files", - pins.len() as u64, - &[], - ) - }) - .activatable(true) - .build(); - row.add_suffix(>k4::Image::from_icon_name(if expanded { + let title = if expanded { + gettext("Show fewer") + } else { + ngettext_f( + "Show all {n} file", + "Show all {n} files", + pins.len() as u64, + &[], + ) + }; + let icon = if expanded { "go-up-symbolic" } else { "go-down-symbolic" - })); + }; let ui_more = ui.clone(); - row.connect_activated(move |_| { + let row = button_row(&title, icon, move || { ui_more .status .pins_expanded diff --git a/crates/pdfs-gui/src/app/pages/takeout.rs b/crates/pdfs-gui/src/app/pages/takeout.rs index dc6ec14..bbbf64c 100644 --- a/crates/pdfs-gui/src/app/pages/takeout.rs +++ b/crates/pdfs-gui/src/app/pages/takeout.rs @@ -15,16 +15,16 @@ //! Import — staging is local, so a mis-drop costs nothing. //! //! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only -//! stages paths, launches, polls [`Request::ImportStatus`] for the final report, +//! stages paths, launches, asks [`Request::ImportStatus`] for the final report, //! and reads live progress off the import's job in the transfer snapshot the -//! status tick already fetches (see [`takeout_progress`]). +//! daemon already pushes (see [`takeout_progress`]). use crate::*; /// The title the daemon gives the import's job in [`Request::GetQueueStatus`]. -/// Matching on it is how this page borrows the progress the status tick is -/// already polling instead of adding a second poll of its own. -const IMPORT_JOB: &str = "Importing Google Photos"; +/// Matching on it is how this page borrows the progress the daemon already +/// pushes instead of asking for it separately. +pub(crate) const IMPORT_JOB: &str = "Importing Google Photos"; /// What the archive list says before anything is staged. `repaint_archives` /// swaps in the count and total once there is something to report, and swaps @@ -589,6 +589,7 @@ fn start_import(ui: &Rc, dry_run: bool) { match result { Ok(Ok(Response::Ok { .. })) => { ui.takeout.running.set(true); + ui.gallery.import_banner.set_revealed(true); ui.takeout.summary_group.set_visible(false); ui.takeout.progress_group.set_visible(true); ui.takeout.progress_bar.set_fraction(0.0); @@ -619,9 +620,9 @@ fn start_import(ui: &Rc, dry_run: bool) { }); } -/// Poll the daemon for the import's state while the page is on screen, and paint -/// the report once a run has finished. Cheap and inflight-guarded, like the other -/// tick polls. +/// Ask the daemon for the import's state, and paint the report once a run has +/// finished. Asked on arrival and while import progress keeps arriving. Cheap +/// and inflight-guarded. pub(crate) fn refresh_takeout(ui: &Rc) { if ui.takeout.inflight.get() { return; @@ -636,6 +637,7 @@ pub(crate) fn refresh_takeout(ui: &Rc) { return; }; let was_running = ui.takeout.running.replace(running); + ui.gallery.import_banner.set_revealed(running); if running { ui.takeout.progress_group.set_visible(true); } else { @@ -776,8 +778,8 @@ fn repaint_summary(ui: &Rc, summary: &ImportSummary) { ui.takeout.summary_group.set_visible(true); } -/// Paint the import's progress from the transfer snapshot the status tick -/// already fetched, so this page costs no extra poll. A job with no known total +/// Paint the import's progress from the transfer snapshot the daemon pushed, +/// so this page costs no extra request. A job with no known total /// pulses instead of showing a fraction, the same as the Activity list. pub(crate) fn takeout_progress(ui: &Rc, jobs: &[JobItem]) { let Some(job) = jobs.iter().find(|job| job.title == IMPORT_JOB) else { diff --git a/crates/pdfs-gui/src/app/pages/trash.rs b/crates/pdfs-gui/src/app/pages/trash.rs index f83bdaa..591fd23 100644 --- a/crates/pdfs-gui/src/app/pages/trash.rs +++ b/crates/pdfs-gui/src/app/pages/trash.rs @@ -25,7 +25,6 @@ pub(crate) struct TrashWidgets { pub(crate) status: adw::StatusPage, pub(crate) retry: gtk4::Button, pub(crate) empty: gtk4::Button, - pub(crate) refresh: gtk4::Button, pub(crate) subtitle: adw::WindowTitle, pub(crate) selection_bar: gtk4::Revealer, pub(crate) selection_label: gtk4::Label, @@ -49,7 +48,6 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { .tooltip_text(gettext("Permanently delete everything in the Trash")) .visible(false) .build(); - let refresh = refresh_button(); let retry = gtk4::Button::builder() .label(gettext("Retry")) @@ -102,7 +100,6 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { let (frame, header, subtitle) = page_frame(&gettext("Trash"), &inner); header.pack_start(&empty); - header.pack_end(&refresh); header.pack_end(&layout); ( @@ -113,7 +110,6 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { status, retry, empty, - refresh, subtitle, selection_bar, selection_label, diff --git a/crates/pdfs-gui/src/app/widgets/compat.rs b/crates/pdfs-gui/src/app/widgets/compat.rs new file mode 100644 index 0000000..f746660 --- /dev/null +++ b/crates/pdfs-gui/src/app/widgets/compat.rs @@ -0,0 +1,311 @@ +//! The app's side of `src/compat.rs`: widgets libadwaita 1.6–1.8 brought, with +//! a stand-in for 1.5. [`has_adw_1_8`] picks the real ones at run time. + +use crate::*; + +/// A segmented control: a row of options of which exactly one is on, told +/// apart by index in the order they were given. `AdwToggleGroup` on +/// libadwaita 1.8, linked toggle buttons before. +#[derive(Clone)] +pub(crate) struct ToggleGroup { + widget: gtk4::Widget, + options: Options, +} + +#[derive(Clone)] +enum Options { + /// The group's `AdwToggle`s, reached by property name. + Toggles(Rc<[glib::Object]>), + Buttons(Rc<[gtk4::ToggleButton]>), +} + +impl ToggleGroup { + /// A group of `(label, tooltip)` options, with the first one on. + pub(crate) fn new(options: &[(&str, Option<&str>)]) -> Self { + if has_adw_1_8() { + let children: String = (0..options.len()) + .map(|i| format!(r#""#)) + .collect(); + let builder = build_ui(&format!( + r#"{children}"# + )); + let toggles = options + .iter() + .enumerate() + .map(|(i, (label, tooltip))| { + let toggle: glib::Object = built(&builder, &format!("toggle{i}")); + toggle.set_property("label", *label); + if let Some(tooltip) = tooltip { + toggle.set_property("tooltip", *tooltip); + } + toggle + }) + .collect(); + let widget: gtk4::Widget = built(&builder, "group"); + // A toggle group starts with nothing on. + widget.set_property("active", 0u32); + Self { + widget, + options: Options::Toggles(toggles), + } + } else { + let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + bar.add_css_class("linked"); + let buttons: Rc<[gtk4::ToggleButton]> = options + .iter() + .map(|(label, tooltip)| { + let button = gtk4::ToggleButton::with_label(label); + button.set_tooltip_text(*tooltip); + bar.append(&button); + button + }) + .collect(); + if let Some((first, rest)) = buttons.split_first() { + first.set_active(true); + // Chaining each to the first is what GTK turns into mutual + // exclusion. + for button in rest { + button.set_group(Some(first)); + } + } + Self { + widget: bar.upcast(), + options: Options::Buttons(buttons), + } + } + } + + /// The widget to pack. + pub(crate) fn widget(&self) -> gtk4::Widget { + self.widget.clone() + } + + /// Give the options pill-shaped ends. + pub(crate) fn set_round(&self) { + match &self.options { + Options::Toggles(_) => self.widget.add_css_class("round"), + Options::Buttons(buttons) => { + for button in buttons.iter() { + button.add_css_class("pill"); + } + } + } + } + + /// The option that is on. + pub(crate) fn active(&self) -> u32 { + match &self.options { + Options::Toggles(_) => self.widget.property("active"), + Options::Buttons(buttons) => { + buttons.iter().position(|b| b.is_active()).unwrap_or(0) as u32 + } + } + } + + /// Turn option `index` on, which turns the one that was on off. + pub(crate) fn set_active(&self, index: u32) { + match &self.options { + Options::Toggles(_) => self.widget.set_property("active", index), + Options::Buttons(buttons) => { + if let Some(button) = buttons.get(index as usize) { + button.set_active(true); + } + } + } + } + + pub(crate) fn set_label(&self, index: u32, label: &str) { + match &self.options { + Options::Toggles(toggles) => { + if let Some(toggle) = toggles.get(index as usize) { + toggle.set_property("label", label); + } + } + Options::Buttons(buttons) => { + if let Some(button) = buttons.get(index as usize) { + button.set_label(label); + } + } + } + } + + /// Let option `index` be picked, or not. + pub(crate) fn set_enabled(&self, index: u32, enabled: bool) { + match &self.options { + Options::Toggles(toggles) => { + if let Some(toggle) = toggles.get(index as usize) { + toggle.set_property("enabled", enabled); + } + } + Options::Buttons(buttons) => { + if let Some(button) = buttons.get(index as usize) { + button.set_sensitive(enabled); + } + } + } + } + + /// Call `f` with the option that has just come on, whether the user or + /// [`Self::set_active`] turned it on. + pub(crate) fn connect_changed(&self, f: impl Fn(u32) + 'static) { + match &self.options { + Options::Toggles(_) => { + self.widget + .connect_notify_local(Some("active"), move |group, _| { + f(group.property("active")) + }); + } + Options::Buttons(buttons) => { + let f = Rc::new(f); + for (index, button) in buttons.iter().enumerate() { + let f = f.clone(); + // The group also fires `toggled` for the button going off; + // only the one coming on counts. + button.connect_toggled(move |button| { + if button.is_active() { + f(index as u32); + } + }); + } + } + } + } +} + +/// A list row that is a button: a centred title and an icon after it. +/// `AdwButtonRow` on libadwaita 1.8, an activatable `adw::ActionRow` before. +pub(crate) fn button_row( + title: &str, + end_icon: &str, + on_activated: impl Fn() + 'static, +) -> gtk4::ListBoxRow { + if has_adw_1_8() { + let builder = build_ui(r#""#); + let row: adw::PreferencesRow = built(&builder, "row"); + row.set_title(title); + row.set_property("end-icon-name", end_icon); + row.connect_local("activated", false, move |_| { + on_activated(); + None + }); + row.upcast() + } else { + let row = adw::ActionRow::builder() + .title(title) + .activatable(true) + .build(); + row.add_suffix(>k4::Image::from_icon_name(end_icon)); + row.connect_activated(move |_| on_activated()); + row.upcast() + } +} + +/// Show the keyboard shortcuts window over `window`. See [`shortcuts_dialog`]. +pub(crate) fn present_shortcuts( + window: &impl IsA, + groups: &[(String, Vec<(&str, String)>)], +) { + shortcuts_dialog(groups).present(Some(window)); +} + +/// The keyboard shortcuts window: `groups` of `(accelerator, what it does)` +/// under a heading each, all already translated. `AdwShortcutsDialog` on +/// libadwaita 1.8, a preferences page of rows before. +fn shortcuts_dialog(groups: &[(String, Vec<(&str, String)>)]) -> adw::Dialog { + if has_adw_1_8() { + let sections: String = groups + .iter() + .enumerate() + .map(|(s, (_, keys))| { + let items: String = (0..keys.len()) + .map(|k| { + format!(r#""#) + }) + .collect(); + format!( + r#"{items}"# + ) + }) + .collect(); + let builder = build_ui(&format!( + r#"{sections}"# + )); + for (s, (title, keys)) in groups.iter().enumerate() { + let section: glib::Object = built(&builder, &format!("section{s}")); + section.set_property("title", title.as_str()); + for (k, (accel, action)) in keys.iter().enumerate() { + let item: glib::Object = built(&builder, &format!("item{s}_{k}")); + item.set_property("title", action.as_str()); + item.set_property("accelerator", *accel); + } + } + return built(&builder, "dialog"); + } + + let page = adw::PreferencesPage::new(); + for (title, keys) in groups { + let group = adw::PreferencesGroup::builder().title(title).build(); + for (accel, action) in keys { + let row = adw::ActionRow::builder().title(action).build(); + row.add_suffix( + >k4::ShortcutLabel::builder() + .accelerator(*accel) + .valign(gtk4::Align::Center) + .build(), + ); + group.add(&row); + } + page.add(&group); + } + + adw::Dialog::builder() + .title(gettext("Keyboard Shortcuts")) + .content_width(460) + .content_height(620) + .child(&{ + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&adw::HeaderBar::new()); + toolbar.set_content(Some(&page)); + toolbar + }) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The newer widgets are made by class name and set up by property and + /// signal name, so a typo in any of those only shows when they are built. + /// Needs a display and libadwaita 1.8; passes trivially without either. + #[test] + fn newer_widgets_build_by_class_name() { + if adw::init().is_err() || !has_adw_1_8() { + return; + } + + let group = ToggleGroup::new(&[("One", None), ("Two", Some("The second"))]); + group.set_round(); + group.set_label(1, "Deux"); + group.set_enabled(1, true); + assert_eq!(group.widget().type_().name(), "AdwToggleGroup"); + assert_eq!(group.active(), 0); + let changed = Rc::new(Cell::new(None)); + group.connect_changed({ + let changed = changed.clone(); + move |index| changed.set(Some(index)) + }); + group.set_active(1); + assert_eq!((group.active(), changed.get()), (1, Some(1))); + + let row = button_row("Row", "go-down-symbolic", || {}); + assert_eq!(row.type_().name(), "AdwButtonRow"); + assert_eq!(spinner().type_().name(), "AdwSpinner"); + + let dialog = shortcuts_dialog(&[( + "General".to_owned(), + vec![("q", "Quit".to_owned())], + )]); + assert_eq!(dialog.type_().name(), "AdwShortcutsDialog"); + } +} diff --git a/crates/pdfs-gui/src/app/widgets/mod.rs b/crates/pdfs-gui/src/app/widgets/mod.rs index 4d78e5e..3e4d1a4 100644 --- a/crates/pdfs-gui/src/app/widgets/mod.rs +++ b/crates/pdfs-gui/src/app/widgets/mod.rs @@ -1,6 +1,8 @@ +pub(crate) mod compat; pub(crate) mod details; pub(crate) mod file_list; pub(crate) mod menu; +pub(crate) mod scrubber; pub(crate) mod share_dialog; pub(crate) mod thumbnails; pub(crate) mod versions_dialog; diff --git a/crates/pdfs-gui/src/app/widgets/scrubber.rs b/crates/pdfs-gui/src/app/widgets/scrubber.rs new file mode 100644 index 0000000..4af9d11 --- /dev/null +++ b/crates/pdfs-gui/src/app/widgets/scrubber.rs @@ -0,0 +1,403 @@ +//! The Photos timeline's month scrubber, laid over the grid's right edge as in +//! Google Photos: a thin tick where each year starts, a knob at the month on +//! screen, and a bubble naming the month under the pointer. It stays out of +//! the way until the pointer nears the edge or the timeline scrolls. + +use crate::*; +use gtk4::cairo; + +/// Width of the strip the scrubber draws in, in px. +const STRIP_WIDTH: i32 = 72; +/// How close to the right edge the pointer has to come to show the scrubber. +const EDGE: f64 = 96.0; +/// Space kept free above the first and below the last month, in px. +const PAD: f64 = 16.0; +/// How long the scrubber stays up after the timeline stops scrolling. +const LINGER: Duration = Duration::from_millis(1500); + +#[derive(Default)] +struct State { + /// How many months the strip spans, newest at the top. + months: usize, + /// Where each year starts, as `(month index, label)`. + years: Vec<(usize, String)>, + /// The month the knob marks. + value: usize, + /// Whether the timeline has months to move between at all. + available: bool, + /// The pointer is near the right edge. + near: bool, + /// The month under the pointer while it is on the strip. + hover: Option, + /// Where the running drag started, in the strip's coordinates. + drag_from: Option, + /// Keeps the scrubber up for a moment after a scroll. + linger: Option, + label: Option String>>, + on_pick: Option>, +} + +/// The scrubber's widgets and state. Cheap to clone: every clone is the same +/// scrubber. +#[derive(Clone)] +pub(crate) struct Scrubber { + revealer: gtk4::Revealer, + area: gtk4::DrawingArea, + knob: gtk4::Box, + bubble: gtk4::Label, + /// The timeline the scrubber lies over. + scroll: Option, + state: Rc>, +} + +impl Scrubber { + /// Build the scrubber over `host`, whose child is the scrolled timeline. + pub(crate) fn new(host: >k4::Overlay) -> Self { + let scroll = host.child().and_downcast::(); + let area = gtk4::DrawingArea::builder() + .width_request(STRIP_WIDTH) + .vexpand(true) + .build(); + area.add_css_class("photo-scrubber"); + let knob = gtk4::Box::builder() + .width_request(24) + .height_request(4) + .halign(gtk4::Align::End) + .valign(gtk4::Align::Start) + .margin_end(4) + .can_target(false) + .build(); + knob.add_css_class("photo-scrubber-knob"); + let strip = gtk4::Overlay::new(); + strip.set_child(Some(&area)); + strip.add_overlay(&knob); + let revealer = gtk4::Revealer::builder() + .transition_type(gtk4::RevealerTransitionType::Crossfade) + .transition_duration(150) + .halign(gtk4::Align::End) + .valign(gtk4::Align::Fill) + .can_target(false) + .child(&strip) + .build(); + let bubble = gtk4::Label::builder() + .halign(gtk4::Align::End) + .valign(gtk4::Align::Start) + .margin_end(STRIP_WIDTH) + .can_target(false) + .visible(false) + .build(); + bubble.add_css_class("photo-scrubber-bubble"); + host.add_overlay(&revealer); + host.add_overlay(&bubble); + + let scrubber = Self { + revealer, + area, + knob, + bubble, + scroll, + state: Rc::default(), + }; + scrubber.wire(host); + scrubber + } + + fn wire(&self, host: >k4::Overlay) { + let this = self.clone(); + self.area.set_draw_func(move |area, cr, width, height| { + this.draw(area, cr, width, height); + }); + let this = self.clone(); + self.area.connect_resize(move |_, _, _| this.place_knob()); + + // Near the edge is judged on the whole timeline, not the strip: the + // strip takes no input while it is hidden. + let edge = gtk4::EventControllerMotion::new(); + let this = self.clone(); + let host_motion = host.clone(); + edge.connect_motion(move |_, x, _| { + let near = x >= f64::from(host_motion.width()) - EDGE; + if this.state.borrow().near != near { + this.state.borrow_mut().near = near; + this.sync_reveal(); + } + }); + let this = self.clone(); + edge.connect_leave(move |_| { + this.state.borrow_mut().near = false; + this.sync_reveal(); + }); + host.add_controller(edge); + + let hover = gtk4::EventControllerMotion::new(); + let this = self.clone(); + hover.connect_motion(move |_, _, y| { + let index = this.index_at(y); + this.state.borrow_mut().hover = index; + this.sync_bubble(); + }); + let this = self.clone(); + hover.connect_leave(move |_| { + this.state.borrow_mut().hover = None; + this.sync_bubble(); + }); + self.area.add_controller(hover); + + // A click is a drag that goes nowhere, so this covers both. + let drag = gtk4::GestureDrag::new(); + let this = self.clone(); + drag.connect_drag_begin(move |_, _, y| { + this.state.borrow_mut().drag_from = Some(y); + this.pick(y, true); + }); + let this = self.clone(); + drag.connect_drag_update(move |_, _, dy| { + let from = this.state.borrow().drag_from; + if let Some(from) = from { + this.pick(from + dy, false); + } + }); + let this = self.clone(); + drag.connect_drag_end(move |_, _, _| { + this.state.borrow_mut().drag_from = None; + this.sync_reveal(); + this.sync_bubble(); + }); + self.area.add_controller(drag); + } + + /// Span `months` months, newest first, with a tick and a label where each + /// of `years` starts. Moves the knob back to the top. + pub(crate) fn set_months(&self, months: usize, years: Vec<(usize, String)>) { + { + let mut state = self.state.borrow_mut(); + state.months = months; + state.years = years; + state.value = 0; + } + self.area.queue_draw(); + self.place_knob(); + } + + /// Whether there is anything to scrub through. The scrubber never shows + /// while there is not. + pub(crate) fn set_available(&self, available: bool) { + self.state.borrow_mut().available = available; + // The scrubber takes the scrollbar's place at the edge; both at once + // would fight over the same strip. + if let Some(scroll) = &self.scroll { + scroll.set_vscrollbar_policy(if available { + gtk4::PolicyType::External + } else { + gtk4::PolicyType::Automatic + }); + } + self.sync_reveal(); + } + + pub(crate) fn is_available(&self) -> bool { + self.state.borrow().available + } + + /// Mark month `index`, unless the user has the scrubber in hand: the + /// timeline's scroll position must not move the knob out from under them. + pub(crate) fn set_value(&self, index: usize) { + { + let mut state = self.state.borrow_mut(); + if state.drag_from.is_some() || state.hover.is_some() || state.value == index { + return; + } + state.value = index; + } + self.place_knob(); + } + + /// How a month index reads in the bubble, such as "March 2024". + pub(crate) fn set_label_func(&self, label: impl Fn(usize) -> String + 'static) { + self.state.borrow_mut().label = Some(Rc::new(label)); + } + + /// Call `f` with the month the user points the scrubber at. + pub(crate) fn connect_pick(&self, f: impl Fn(usize) + 'static) { + self.state.borrow_mut().on_pick = Some(Rc::new(f)); + } + + /// Show the scrubber for a moment, as the timeline scrolls, so it can be + /// found without hunting for the edge — and on a touch screen at all. + pub(crate) fn flash(&self) { + if !self.is_available() { + return; + } + if let Some(source) = self.state.borrow_mut().linger.take() { + source.remove(); + } + let this = self.clone(); + let source = glib::timeout_add_local_once(LINGER, move || { + this.state.borrow_mut().linger = None; + this.sync_reveal(); + }); + self.state.borrow_mut().linger = Some(source); + self.sync_reveal(); + } + + fn sync_reveal(&self) { + let show = { + let state = self.state.borrow(); + state.available && (state.near || state.drag_from.is_some() || state.linger.is_some()) + }; + self.revealer.set_reveal_child(show); + // A hidden revealer still claims its strip of the grid; let the clicks + // through to the tiles underneath. + self.revealer.set_can_target(show); + if !show { + self.state.borrow_mut().hover = None; + self.bubble.set_visible(false); + } + } + + /// The bubble names the month being dragged to, or else the one under + /// the pointer. + fn sync_bubble(&self) { + let (index, label) = { + let state = self.state.borrow(); + let index = if state.drag_from.is_some() { + Some(state.value) + } else { + state.hover + }; + (index, state.label.clone()) + }; + let (Some(index), Some(label)) = (index, label) else { + self.bubble.set_visible(false); + return; + }; + self.bubble.set_label(&label(index)); + let (_, height, _, _) = self.bubble.measure(gtk4::Orientation::Vertical, -1); + let y = self.y_of(index) - f64::from(height) / 2.0; + self.bubble.set_margin_top(y.max(0.0) as i32); + self.bubble.set_visible(true); + } + + /// Move the knob to month `y` points at, and tell the page when that is a + /// different month — or when a new drag starts, so a click jumps even to + /// the month already marked. + fn pick(&self, y: f64, starting: bool) { + let Some(index) = self.index_at(y) else { + return; + }; + let (changed, on_pick) = { + let mut state = self.state.borrow_mut(); + let changed = state.value != index; + state.value = index; + (changed, state.on_pick.clone()) + }; + self.place_knob(); + self.sync_bubble(); + if let (true, Some(on_pick)) = (changed || starting, on_pick) { + on_pick(index); + } + } + + fn place_knob(&self) { + let value = self.state.borrow().value; + let y = self.y_of(value) - 2.0; + self.knob.set_margin_top(y.max(0.0) as i32); + } + + /// Where month `index` sits on the strip. + fn y_of(&self, index: usize) -> f64 { + let months = self.state.borrow().months; + month_y(index, months, f64::from(self.area.height())) + } + + /// The month at height `y` on the strip, if it spans any. + fn index_at(&self, y: f64) -> Option { + let months = self.state.borrow().months; + month_at(y, months, f64::from(self.area.height())) + } + + fn draw(&self, area: >k4::DrawingArea, cr: &cairo::Context, width: i32, height: i32) { + let state = self.state.borrow(); + let color = area.color(); + let (width, height) = (f64::from(width), f64::from(height)); + let attrs = gtk4::pango::AttrList::new(); + attrs.insert(gtk4::pango::AttrFloat::new_scale(0.8)); + attrs.insert(gtk4::pango::AttrInt::new_weight(gtk4::pango::Weight::Bold)); + // The bottom of the last label drawn: a year crowded against the one + // above it keeps its tick but not its label. + let mut clear_below = f64::NEG_INFINITY; + for (index, year) in &state.years { + let y = month_y(*index, state.months, height); + cr.set_source_rgba( + f64::from(color.red()), + f64::from(color.green()), + f64::from(color.blue()), + 0.5, + ); + cr.rectangle(width - 12.0, y.round() - 0.5, 8.0, 1.0); + let _ = cr.fill(); + + let layout = area.create_pango_layout(Some(year)); + layout.set_attributes(Some(&attrs)); + let (_, logical) = layout.pixel_extents(); + let (w, h) = (f64::from(logical.width()), f64::from(logical.height())); + let top = y - h / 2.0; + if top < clear_below { + continue; + } + clear_below = top + h + 2.0; + cr.set_source_rgba( + f64::from(color.red()), + f64::from(color.green()), + f64::from(color.blue()), + 0.8, + ); + cr.move_to(width - 16.0 - w, top); + pangocairo::functions::show_layout(cr, &layout); + } + } +} + +/// Where month `index` of `months` sits on a strip `height` px tall: evenly +/// spaced, newest at the top. +fn month_y(index: usize, months: usize, height: f64) -> f64 { + let span = (height - 2.0 * PAD).max(0.0); + if months < 2 { + return PAD; + } + PAD + span * index as f64 / (months - 1) as f64 +} + +/// The month nearest height `y` on a strip `height` px tall. +fn month_at(y: f64, months: usize, height: f64) -> Option { + let last = months.checked_sub(1)?; + let span = height - 2.0 * PAD; + if span <= 0.0 { + return Some(0); + } + let fraction = ((y - PAD) / span).clamp(0.0, 1.0); + Some((fraction * last as f64).round() as usize) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_scrubber_maps_heights_back_to_their_months() { + let height = 432.0; + for index in 0..12 { + assert_eq!( + month_at(month_y(index, 12, height), 12, height), + Some(index) + ); + } + } + + #[test] + fn the_scrubber_clamps_past_either_end() { + assert_eq!(month_at(-50.0, 12, 432.0), Some(0)); + assert_eq!(month_at(900.0, 12, 432.0), Some(11)); + assert_eq!(month_at(10.0, 0, 432.0), None); + } +} diff --git a/crates/pdfs-gui/src/app/widgets/share_dialog.rs b/crates/pdfs-gui/src/app/widgets/share_dialog.rs index 5903b4e..9d3fdc5 100644 --- a/crates/pdfs-gui/src/app/widgets/share_dialog.rs +++ b/crates/pdfs-gui/src/app/widgets/share_dialog.rs @@ -97,7 +97,7 @@ impl Busy { pub(crate) fn start(button: >k4::Button) -> Self { let label = button.label(); let icon = button.icon_name(); - let spinner = gtk4::Spinner::builder().spinning(true).build(); + let spinner = spinner(); match &label { Some(text) => { let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); @@ -408,10 +408,9 @@ pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { }); // Shared by me lists what this dialog changes: reload it when it is the - // page underneath, and have the next visit fetch otherwise. + // page underneath. Any other page reads it again on the next visit. let ui_closed = ui.clone(); dialog.connect_closed(move |_| { - ui_closed.shared_by_me.loaded_at.set(None); if ui_closed.stack.visible_child_name().as_deref() == Some("sharedbyme") { load_shared_by_me(&ui_closed); } diff --git a/crates/pdfs-gui/src/compat.rs b/crates/pdfs-gui/src/compat.rs new file mode 100644 index 0000000..cb9d501 --- /dev/null +++ b/crates/pdfs-gui/src/compat.rs @@ -0,0 +1,55 @@ +//! Widgets that newer libadwaita releases bring, with a stand-in built from +//! what the oldest supported release (1.5, as on Ubuntu 24.04) has. +//! +//! The app builds against 1.5 and picks at run time: when the libadwaita it +//! runs with is 1.8 or newer, the real widgets are made by class name through +//! a `gtk4::Builder`, since the 1.5 bindings have no types for them. Their +//! properties and signals are then reached by name. Call sites never care +//! which one they got. + +use std::sync::OnceLock; + +use gtk4::prelude::*; + +/// Set to anything to use the stand-ins even on a new libadwaita, to see the +/// app as it looks on the oldest supported release. +const STAND_INS_ENV: &str = "PDFS_STAND_IN_WIDGETS"; + +/// Whether the libadwaita the app runs with has the 1.8 widgets. +pub(crate) fn has_adw_1_8() -> bool { + static HAS: OnceLock = OnceLock::new(); + *HAS.get_or_init(|| { + std::env::var_os(STAND_INS_ENV).is_none() + && (adw::major_version(), adw::minor_version()) >= (1, 8) + }) +} + +/// Build `xml`, a GtkBuilder interface naming classes the bindings lack. It +/// is only ever one this module wrote, so a failure is a bug here. +pub(crate) fn build_ui(xml: &str) -> gtk4::Builder { + gtk4::Builder::from_string(&format!("{xml}")) +} + +/// The object `id` from a builder [`build_ui`] made. +pub(crate) fn built>(builder: >k4::Builder, id: &str) -> T { + builder + .object(id) + .unwrap_or_else(|| panic!("the compat UI has no object {id}")) +} + +/// A busy spinner. It spins whenever it is shown: show and hide it with +/// `set_visible` rather than starting and stopping it. +pub(crate) type Spinner = gtk4::Widget; + +/// A new [`Spinner`], shown: `AdwSpinner` on libadwaita 1.8, a spinning +/// `gtk4::Spinner` before. +pub(crate) fn spinner() -> Spinner { + if has_adw_1_8() { + let builder = build_ui(r#""#); + built(&builder, "spinner") + } else { + // Spinning costs nothing while hidden: an unmapped widget draws no + // frames. + gtk4::Spinner::builder().spinning(true).build().upcast() + } +} diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs index 1cd0737..546185e 100644 --- a/crates/pdfs-gui/src/main.rs +++ b/crates/pdfs-gui/src/main.rs @@ -13,7 +13,7 @@ use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use ksni::blocking::TrayMethods; @@ -21,14 +21,17 @@ use ksni::menu::StandardItem; use ksni::{MenuItem, Status, ToolTip, Tray}; use pdfs_core::auth; use pdfs_core::config::AppDirs; -use pdfs_core::control::{JobItem, Request, Response, TransferDirection, TransferItem, send}; +use pdfs_core::control::{ + Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, follow, send, +}; use pdfs_core::service; mod i18n; use i18n::{gettext, gettext_f, ngettext_f}; -/// How often the tray re-polls the daemon to refresh its menu. -const POLL_INTERVAL: Duration = Duration::from_secs(3); +/// How long the tray lets a burst of events arrive before it repaints. Progress +/// moves several times a second, which is more than a tray menu can show. +const COALESCE: Duration = Duration::from_secs(1); /// The one word the icon says about the drive, derived from each poll. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -139,79 +142,16 @@ struct DriveTray { /// menu can still say "Logged in …" / "Not logged in" when no mount is running. fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState { match send(socket, &Request::Status) { - Ok(Response::Status { - mountpoint, - pinned, - online, - pending_uploads, - pending_changes, - failing_ops, - paused, - .. - }) => { - // Same daemon is up, so a cheap follow-up poll gives the sync line. + Ok(status @ Response::Status { .. }) => { + // Same daemon is up, so a cheap follow-up request gives the sync line. let sync = match send(socket, &Request::GetQueueStatus) { Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs), _ => String::new(), }; - let queued = i18n::pending_summary(pending_uploads, pending_changes); - let phase = phase_of( - paused, - failing_ops, - online, - queued.is_some() || !sync.is_empty(), - ); - DriveState { - line: match (online, queued) { - _ if paused => gettext("Sync paused"), - _ if failing_ops > 0 => ngettext_f( - "{n} change needs attention", - "{n} changes need attention", - failing_ops, - &[], - ), - // Translators: {mountpoint} is a folder path; {n} is the number of pinned items. - (true, None) => ngettext_f( - "Up to date in {mountpoint} ({n} pinned)", - "Up to date in {mountpoint} ({n} pinned)", - pinned as u64, - &[("mountpoint", &mountpoint)], - ), - // Translators: {queued} is a queue summary such as "3 uploads queued"; {n} is the number of pinned items. - (true, Some(q)) => ngettext_f( - "Syncing — {queued} ({n} pinned)", - "Syncing — {queued} ({n} pinned)", - pinned as u64, - &[("queued", &q)], - ), - // Translators: {n} is the number of pinned items. - (false, None) => ngettext_f( - "Offline — cached files only ({n} pinned)", - "Offline — cached files only ({n} pinned)", - pinned as u64, - &[], - ), - // Translators: {queued} is a queue summary such as "3 uploads queued". - (false, Some(q)) => gettext_f("Offline — {queued}", &[("queued", &q)]), - }, - phase, - mounted: true, - paused, - mountpoint: PathBuf::from(mountpoint), - sync, - failing: failing_ops, - } + status_state(&status, sync, default_mountpoint) } // Socket answered but with something unexpected — treat as up but odd. - Ok(_) => DriveState { - line: gettext("Proton Drive sent an unexpected reply"), - phase: Phase::Attention, - mounted: true, - mountpoint: default_mountpoint.to_path_buf(), - sync: String::new(), - paused: false, - failing: 0, - }, + Ok(_) => unexpected_reply(default_mountpoint), // The daemon is up but stuck: say so rather than "not connected", and // keep the mount actions, which still work on the folder itself. Err(e) if e.is_timeout() => DriveState { @@ -254,6 +194,86 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState { } } +/// What the tray says when the daemon answers with something it did not ask +/// for: up, but odd. +fn unexpected_reply(default_mountpoint: &Path) -> DriveState { + DriveState { + line: gettext("Proton Drive sent an unexpected reply"), + phase: Phase::Attention, + mounted: true, + mountpoint: default_mountpoint.to_path_buf(), + sync: String::new(), + paused: false, + failing: 0, + } +} + +/// The state a running daemon's [`Response::Status`] describes, with `sync` +/// saying what it is moving right now. +fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState { + let Response::Status { + mountpoint, + pinned, + online, + pending_uploads, + pending_changes, + failing_ops, + paused, + .. + } = status + else { + return unexpected_reply(default_mountpoint); + }; + let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused); + let queued = i18n::pending_summary(*pending_uploads, *pending_changes); + let phase = phase_of( + paused, + failing_ops, + online, + queued.is_some() || !sync.is_empty(), + ); + DriveState { + line: match (online, queued) { + _ if paused => gettext("Sync paused"), + _ if failing_ops > 0 => ngettext_f( + "{n} change needs attention", + "{n} changes need attention", + failing_ops, + &[], + ), + // Translators: {mountpoint} is a folder path; {n} is the number of pinned items. + (true, None) => ngettext_f( + "Up to date in {mountpoint} ({n} pinned)", + "Up to date in {mountpoint} ({n} pinned)", + pinned as u64, + &[("mountpoint", mountpoint)], + ), + // Translators: {queued} is a queue summary such as "3 uploads queued"; {n} is the number of pinned items. + (true, Some(q)) => ngettext_f( + "Syncing — {queued} ({n} pinned)", + "Syncing — {queued} ({n} pinned)", + pinned as u64, + &[("queued", &q)], + ), + // Translators: {n} is the number of pinned items. + (false, None) => ngettext_f( + "Offline — cached files only ({n} pinned)", + "Offline — cached files only ({n} pinned)", + pinned as u64, + &[], + ), + // Translators: {queued} is a queue summary such as "3 uploads queued". + (false, Some(q)) => gettext_f("Offline — {queued}", &[("queued", &q)]), + }, + phase, + mounted: true, + paused, + mountpoint: PathBuf::from(mountpoint), + sync, + failing: failing_ops, + } +} + /// The phase a running mount is in. A pause is the user's own doing and says /// so first; failures outrank everything else the daemon reports. fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase { @@ -501,13 +521,53 @@ fn main() -> Result<()> { .spawn() .context("start tray service")?; - // Poll the daemon forever, pushing each fresh snapshot into the tray so the + // Follow the daemon forever, pushing each fresh state into the tray so the // menu reflects mount/login changes made elsewhere (e.g. via the CLI). - loop { - std::thread::sleep(POLL_INTERVAL); - let st = poll_state(&socket, &default_mountpoint); + let (tx, rx) = std::sync::mpsc::channel(); + let feed_socket = socket.clone(); + std::thread::spawn(move || follow(&feed_socket, |feed| tx.send(feed).is_ok())); + let mut heard = Heard::default(); + while let Ok(feed) = rx.recv() { + heard.take(feed); + let until = Instant::now() + COALESCE; + while let Ok(feed) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) { + heard.take(feed); + } + let st = match &heard.status { + Some(status) => status_state( + status, + sync_line(&heard.items, &heard.jobs), + &default_mountpoint, + ), + // Lost the daemon: ask once, which describes the sign-in instead. + None => poll_state(&socket, &default_mountpoint), + }; handle.update(move |t: &mut DriveTray| t.state = st); } + Ok(()) +} + +/// What the tray has heard from the daemon's event feed. +#[derive(Default)] +struct Heard { + /// The last status, or `None` once the daemon has gone. + status: Option, + items: Vec, + jobs: Vec, +} + +impl Heard { + fn take(&mut self, feed: Feed) { + match feed { + Feed::Event(Event::Status(status)) => self.status = Some(*status), + Feed::Event(Event::Transfers { items, jobs }) => { + self.items = items; + self.jobs = jobs; + } + Feed::Disconnected => *self = Self::default(), + Feed::Connected | Feed::Event(Event::Changed { .. } | Event::Heartbeat) => {} + } + } } #[cfg(test)] diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs index b7af8cc..69746c7 100644 --- a/crates/pdfs-gui/src/prompt.rs +++ b/crates/pdfs-gui/src/prompt.rs @@ -23,7 +23,9 @@ use pdfs_core::menu::PromptMode; use pdfs_core::opener::{self, OpenWith}; mod activation; +mod compat; mod i18n; +use compat::{Spinner, spinner}; use i18n::{gettext, gettext_f, gettext_noop, human_bytes, ngettext_f, pgettext}; mod dmenu; mod fzf; @@ -235,7 +237,7 @@ struct Ui { opener: OpenWith, entry: gtk4::Entry, - spinner: gtk4::Spinner, + spinner: Spinner, stack: gtk4::Stack, scroller: gtk4::ScrolledWindow, results: gtk4::Box, @@ -542,7 +544,7 @@ fn build_window(app: &adw::Application) -> Option> { entry.add_css_class("search-entry"); search_bar.append(&entry); - let spinner = gtk4::Spinner::new(); + let spinner = spinner(); spinner.set_visible(false); search_bar.append(&spinner); @@ -824,7 +826,6 @@ impl Ui { self.query_id.set(self.query_id.get() + 1); self.pending.set(0); self.open_pending.set(false); - self.spinner.stop(); self.spinner.set_visible(false); self.suppress_entry_change.set(true); self.entry.set_text(""); @@ -887,7 +888,6 @@ impl Ui { /// the offline page and lock the entry; the retry button re-runs `connect`. fn go_offline(self: &Rc) { self.pending.set(0); - self.spinner.stop(); self.spinner.set_visible(false); self.entry.set_sensitive(false); self.hint.set_label(&gettext("Proton Drive isn't running")); @@ -990,7 +990,6 @@ impl Ui { self.query_id.set(id); self.pending.set(requests); self.spinner.set_visible(true); - self.spinner.start(); id } @@ -1003,7 +1002,6 @@ impl Ui { let left = self.pending.get().saturating_sub(1); self.pending.set(left); if left == 0 { - self.spinner.stop(); self.spinner.set_visible(false); } true @@ -1292,7 +1290,6 @@ impl Ui { self.opening.set(true); self.spinner.set_visible(true); - self.spinner.start(); // Translators: {name} is a file name. let opening = gettext_f("Opening {name}…", &[("name", &drive.name)]); self.hint.set_label(&opening); @@ -1307,7 +1304,6 @@ impl Ui { let ui = self.clone(); glib::spawn_future_local(async move { let reply = rx.recv().await; - ui.spinner.stop(); ui.spinner.set_visible(false); ui.opening.set(false); match reply { diff --git a/po/POTFILES.in b/po/POTFILES.in index a916e3f..cba07d1 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -19,6 +19,7 @@ crates/pdfs-gui/src/app/pages/status.rs crates/pdfs-gui/src/app/pages/takeout.rs crates/pdfs-gui/src/app/pages/trash.rs crates/pdfs-gui/src/app/pages/verify.rs +crates/pdfs-gui/src/app/widgets/compat.rs crates/pdfs-gui/src/app/widgets/details.rs crates/pdfs-gui/src/app/widgets/file_list.rs crates/pdfs-gui/src/app/widgets/menu.rs diff --git a/po/de.po b/po/de.po index cceb7df..5e3405d 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-09-28 15:06+0200\n" +"POT-Creation-Date: 2026-09-28 16:17+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -30,8 +30,8 @@ msgstr[1] "vor {n} Min." #. Translators: strftime format for the time of day in the activity feed, such as "14:05". #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 -#: crates/pdfs-gui/src/app/pages/activity.rs:849 -#: crates/pdfs-gui/src/app/pages/photos.rs:2774 +#: crates/pdfs-gui/src/app/pages/activity.rs:838 +#: crates/pdfs-gui/src/app/pages/photos.rs:2771 msgid "%H:%M" msgstr "%H:%M" @@ -57,105 +57,102 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:944 +#: crates/pdfs-gui/src/app/main.rs:882 #: crates/pdfs-gui/src/app/pages/activity_text.rs:466 -#: crates/pdfs-gui/src/app/pages/browser.rs:457 -#: crates/pdfs-gui/src/app/pages/browser.rs:824 -#: crates/pdfs-gui/src/app/pages/browser.rs:1222 -#: crates/pdfs-gui/src/app/pages/locations.rs:1494 -#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:260 -#: crates/pdfs-gui/src/app/pages/trash.rs:195 crates/pdfs-gui/src/prompt.rs:169 +#: crates/pdfs-gui/src/app/pages/browser.rs:454 +#: crates/pdfs-gui/src/app/pages/browser.rs:819 +#: crates/pdfs-gui/src/app/pages/browser.rs:1217 +#: crates/pdfs-gui/src/app/pages/locations.rs:1469 +#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 +#: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:947 crates/pdfs-gui/src/app/main.rs:1632 +#: crates/pdfs-gui/src/app/main.rs:885 crates/pdfs-gui/src/app/main.rs:1529 #: crates/pdfs-gui/src/app/pages/albums.rs:638 -#: crates/pdfs-gui/src/app/pages/photos.rs:898 +#: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:952 -#: crates/pdfs-gui/src/app/pages/shared.rs:174 -#: crates/pdfs-gui/src/app/pages/shared.rs:494 +#: crates/pdfs-gui/src/app/main.rs:890 +#: crates/pdfs-gui/src/app/pages/shared.rs:168 +#: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:957 -#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:126 +#: crates/pdfs-gui/src/app/main.rs:895 +#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:960 -#: crates/pdfs-gui/src/app/pages/devices.rs:145 -#: crates/pdfs-gui/src/app/pages/devices.rs:492 +#: crates/pdfs-gui/src/app/main.rs:898 +#: crates/pdfs-gui/src/app/pages/devices.rs:140 +#: crates/pdfs-gui/src/app/pages/devices.rs:481 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:961 -#: crates/pdfs-gui/src/app/pages/trash.rs:103 +#: crates/pdfs-gui/src/app/main.rs:899 +#: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:963 +#: crates/pdfs-gui/src/app/main.rs:901 msgid "Sync" msgstr "Synchronisierung" -#: crates/pdfs-gui/src/app/main.rs:1130 -msgid "Refresh (F5)" -msgstr "Aktualisieren (F5)" - #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1321 +#: crates/pdfs-gui/src/app/main.rs:1218 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1340 +#: crates/pdfs-gui/src/app/main.rs:1237 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1341 +#: crates/pdfs-gui/src/app/main.rs:1238 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1342 +#: crates/pdfs-gui/src/app/main.rs:1239 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1343 +#: crates/pdfs-gui/src/app/main.rs:1240 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1344 +#: crates/pdfs-gui/src/app/main.rs:1241 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1385 crates/pdfs-gui/src/app/main.rs:1614 +#: crates/pdfs-gui/src/app/main.rs:1282 crates/pdfs-gui/src/app/main.rs:1511 #: crates/pdfs-gui/src/app/pages/status.rs:385 msgid "Preferences" msgstr "Einstellungen" -#: crates/pdfs-gui/src/app/main.rs:1386 crates/pdfs-gui/src/app/main.rs:1671 +#: crates/pdfs-gui/src/app/main.rs:1283 +#: crates/pdfs-gui/src/app/widgets/compat.rs:262 msgid "Keyboard Shortcuts" msgstr "Tastenkürzel" -#: crates/pdfs-gui/src/app/main.rs:1388 +#: crates/pdfs-gui/src/app/main.rs:1285 msgid "About Proton Drive for Linux" msgstr "Info zu Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1290 msgid "Main menu" msgstr "Hauptmenü" -#: crates/pdfs-gui/src/app/main.rs:1449 +#: crates/pdfs-gui/src/app/main.rs:1346 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1457 +#: crates/pdfs-gui/src/app/main.rs:1354 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -167,23 +164,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1462 +#: crates/pdfs-gui/src/app/main.rs:1359 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1466 +#: crates/pdfs-gui/src/app/main.rs:1363 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1470 +#: crates/pdfs-gui/src/app/main.rs:1367 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1475 +#: crates/pdfs-gui/src/app/main.rs:1372 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1479 +#: crates/pdfs-gui/src/app/main.rs:1376 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -208,7 +205,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1487 +#: crates/pdfs-gui/src/app/main.rs:1384 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1495 +#: crates/pdfs-gui/src/app/main.rs:1392 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -228,7 +225,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1400 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1495 +#: crates/pdfs-gui/src/app/main.rs:1392 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -225,7 +222,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1400 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1495 +#: crates/pdfs-gui/src/app/main.rs:1392 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -226,7 +223,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1400 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1495 +#: crates/pdfs-gui/src/app/main.rs:1392 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -227,7 +224,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1400 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1495 +#: crates/pdfs-gui/src/app/main.rs:1392 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -225,7 +222,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1400 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1495 +#: crates/pdfs-gui/src/app/main.rs:1392 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -226,7 +223,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1400 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -33,8 +33,8 @@ msgstr[1] "" #. Translators: strftime format for the time of day in the activity feed, such as "14:05". #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 -#: crates/pdfs-gui/src/app/pages/activity.rs:849 -#: crates/pdfs-gui/src/app/pages/photos.rs:2774 +#: crates/pdfs-gui/src/app/pages/activity.rs:838 +#: crates/pdfs-gui/src/app/pages/photos.rs:2771 msgid "%H:%M" msgstr "" @@ -60,105 +60,102 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:944 +#: crates/pdfs-gui/src/app/main.rs:882 #: crates/pdfs-gui/src/app/pages/activity_text.rs:466 -#: crates/pdfs-gui/src/app/pages/browser.rs:457 -#: crates/pdfs-gui/src/app/pages/browser.rs:824 -#: crates/pdfs-gui/src/app/pages/browser.rs:1222 -#: crates/pdfs-gui/src/app/pages/locations.rs:1494 -#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:260 -#: crates/pdfs-gui/src/app/pages/trash.rs:195 crates/pdfs-gui/src/prompt.rs:169 +#: crates/pdfs-gui/src/app/pages/browser.rs:454 +#: crates/pdfs-gui/src/app/pages/browser.rs:819 +#: crates/pdfs-gui/src/app/pages/browser.rs:1217 +#: crates/pdfs-gui/src/app/pages/locations.rs:1469 +#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 +#: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:947 crates/pdfs-gui/src/app/main.rs:1632 +#: crates/pdfs-gui/src/app/main.rs:885 crates/pdfs-gui/src/app/main.rs:1529 #: crates/pdfs-gui/src/app/pages/albums.rs:638 -#: crates/pdfs-gui/src/app/pages/photos.rs:898 +#: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:952 -#: crates/pdfs-gui/src/app/pages/shared.rs:174 -#: crates/pdfs-gui/src/app/pages/shared.rs:494 +#: crates/pdfs-gui/src/app/main.rs:890 +#: crates/pdfs-gui/src/app/pages/shared.rs:168 +#: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:957 -#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:126 +#: crates/pdfs-gui/src/app/main.rs:895 +#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:960 -#: crates/pdfs-gui/src/app/pages/devices.rs:145 -#: crates/pdfs-gui/src/app/pages/devices.rs:492 +#: crates/pdfs-gui/src/app/main.rs:898 +#: crates/pdfs-gui/src/app/pages/devices.rs:140 +#: crates/pdfs-gui/src/app/pages/devices.rs:481 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:961 -#: crates/pdfs-gui/src/app/pages/trash.rs:103 +#: crates/pdfs-gui/src/app/main.rs:899 +#: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:963 +#: crates/pdfs-gui/src/app/main.rs:901 msgid "Sync" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1130 -msgid "Refresh (F5)" -msgstr "" - #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1321 +#: crates/pdfs-gui/src/app/main.rs:1218 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1340 +#: crates/pdfs-gui/src/app/main.rs:1237 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1341 +#: crates/pdfs-gui/src/app/main.rs:1238 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1342 +#: crates/pdfs-gui/src/app/main.rs:1239 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1343 +#: crates/pdfs-gui/src/app/main.rs:1240 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1344 +#: crates/pdfs-gui/src/app/main.rs:1241 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1385 crates/pdfs-gui/src/app/main.rs:1614 +#: crates/pdfs-gui/src/app/main.rs:1282 crates/pdfs-gui/src/app/main.rs:1511 #: crates/pdfs-gui/src/app/pages/status.rs:385 msgid "Preferences" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1386 crates/pdfs-gui/src/app/main.rs:1671 +#: crates/pdfs-gui/src/app/main.rs:1283 +#: crates/pdfs-gui/src/app/widgets/compat.rs:262 msgid "Keyboard Shortcuts" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1388 +#: crates/pdfs-gui/src/app/main.rs:1285 msgid "About Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1290 msgid "Main menu" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1449 +#: crates/pdfs-gui/src/app/main.rs:1346 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1457 +#: crates/pdfs-gui/src/app/main.rs:1354 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -166,23 +163,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1462 +#: crates/pdfs-gui/src/app/main.rs:1359 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1466 +#: crates/pdfs-gui/src/app/main.rs:1363 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1470 +#: crates/pdfs-gui/src/app/main.rs:1367 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1475 +#: crates/pdfs-gui/src/app/main.rs:1372 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1479 +#: crates/pdfs-gui/src/app/main.rs:1376 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -195,21 +192,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1487 +#: crates/pdfs-gui/src/app/main.rs:1384 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1495 +#: crates/pdfs-gui/src/app/main.rs:1392 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1400 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1495 +#: crates/pdfs-gui/src/app/main.rs:1392 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -229,7 +226,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1400 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1495 +#: crates/pdfs-gui/src/app/main.rs:1392 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -225,7 +222,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1400 msgid "" "The street map, when turned on, comes from OpenFreeMap in the