mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(core,fuse,gui): push daemon events, polish Photos, adopt newer widgets
The app and the tray stop polling. pdfs-core adds Request::Subscribe: the connection stays open and carries Event lines (status, transfers, and Changed with a Topic), with a heartbeat while quiet. In pdfs-fuse, an EventHub gives each subscriber a mailbox, code that changes something publishes its topic, and a sampler publishes the status, transfers and sync progress when they differ. The app follows the feed on one thread, paints status and transfers as they arrive, and reloads the page on screen when its topic changes, including after changes made with the pdfs CLI. A hidden window keeps the changes and catches up when focused. Front-ends fall back to polling a daemon that predates subscriptions. Refresh buttons leave the page headers; F5 and Ctrl+R still reload from the server. Photos gets a scrubber overlaid on the right edge of the timeline in place of the slider and scrollbar. It shows the years, appears when the pointer comes near or the timeline scrolls, names the month under the pointer and jumps to it on drag. Upload becomes a split button whose menu holds the Google Photos import. Tiles are pooled per row and rebound instead of rebuilt, hover scales the thumbnail to 1.02 without a shadow, and tiles lose their tooltip. On libadwaita 1.8 or newer the app uses AdwSpinner, AdwToggleGroup, AdwButtonRow and AdwShortcutsDialog, and keeps look-alike stand-ins on older releases (src/compat.rs, widgets/compat.rs). It still builds against libadwaita 1.5 and picks at run time, creating the newer widgets by class name through GtkBuilder, so every package gets them. PDFS_STAND_IN_WIDGETS forces the stand-ins. The Sync and share-link icons draw in full again: GTK drops group transforms from symbolic icons, so their coordinates are baked into the paths. All catalogs are updated and complete.
This commit is contained in:
1 parent
da7ea0ec56
commit
d18655538e
46 files changed
+12288
-10736
No files matched your search
@@ -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<Response>),
|
||||
/// A fresh [`Response::Transfers`] snapshot. Sent when a subscription
|
||||
/// starts and whenever the snapshot changes, which is continuously while
|
||||
/// something moves.
|
||||
Transfers {
|
||||
items: Vec<TransferItem>,
|
||||
jobs: Vec<JobItem>,
|
||||
},
|
||||
/// These topics changed. Changes close together arrive as one event.
|
||||
Changed { topics: Vec<Topic> },
|
||||
/// 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<Response> {
|
||||
Ok(serde_json::from_str(resp.trim())?)
|
||||
}
|
||||
|
||||
/// An open [`Request::Subscribe`] connection. See [`subscribe`].
|
||||
pub struct Subscription {
|
||||
reader: BufReader<UnixStream>,
|
||||
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<Subscription> {
|
||||
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<Option<Event>> {
|
||||
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::<Event>(line) {
|
||||
return Ok(Some(event));
|
||||
}
|
||||
if let Ok(Response::Error { message, .. }) = serde_json::from_str::<Response>(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>(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()));
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
+166
-25
@@ -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<super::events::Mailbox>);
|
||||
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::<CtlRequest>(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::<CtlRequest>(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) => {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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<Vec<Arc<Mailbox>>>,
|
||||
}
|
||||
|
||||
/// What one subscription has yet to send.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Mailbox {
|
||||
inbox: Mutex<Inbox>,
|
||||
ready: Condvar,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inbox {
|
||||
topics: BTreeSet<Topic>,
|
||||
/// The newest [`Event::Status`] line. A newer one replaces it unsent: a
|
||||
/// client wants the current status, not every status in between.
|
||||
status: Option<Arc<str>>,
|
||||
/// The newest [`Event::Transfers`] line, replaced the same way.
|
||||
transfers: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
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<Arc<str>> {
|
||||
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<str>) {
|
||||
self.deliver(|inbox| inbox.status = Some(line.clone()));
|
||||
}
|
||||
|
||||
/// Hand every subscriber a fresh transfers snapshot.
|
||||
fn publish_transfers(&self, line: Arc<str>) {
|
||||
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<Arc<Mailbox>> {
|
||||
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<Mailbox>) {
|
||||
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<Arc<str>> {
|
||||
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<TransferItem>, jobs: Vec<JobItem>) -> Option<Arc<str>> {
|
||||
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<Arc<str>> = None;
|
||||
let mut status: Option<String> = None;
|
||||
let mut queue: Option<String> = None;
|
||||
let mut locations: Option<String> = 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<Topic> {
|
||||
match serde_json::from_str::<Event>(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::<str>::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)));
|
||||
}
|
||||
}
|
||||
@@ -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<TransferRegistry>,
|
||||
/// Open [`Request::Subscribe`](pdfs_core::control::Request::Subscribe)
|
||||
/// connections, which every change a front-end shows is published to.
|
||||
events: Arc<events::EventHub>,
|
||||
/// 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<AtomicBool>,
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- A share link: two open chain ends and the bar that joins them, laid
|
||||
on the diagonal. -->
|
||||
<g transform="rotate(-45 8 8)">
|
||||
<path fill="#222222" d="M 7,4.5 H 4.5 A 3.5,3.5 0 0 0 4.5,11.5 H 7 V 9.75 H 4.5 A 1.75,1.75 0 0 1 4.5,6.25 H 7 Z"/>
|
||||
<path fill="#222222" d="M 9,4.5 H 11.5 A 3.5,3.5 0 0 1 11.5,11.5 H 9 V 9.75 H 11.5 A 1.75,1.75 0 0 0 11.5,6.25 H 9 Z"/>
|
||||
<path fill="#222222" d="M 5,7.125 H 11 V 8.875 H 5 Z"/>
|
||||
</g>
|
||||
on the diagonal. Written out in plain coordinates: GTK's symbolic icon
|
||||
renderer drops a transformed group. -->
|
||||
<path fill="#222222" d="M 4.82,6.23 L 3.05,8 A 3.5,3.5 0 0 0 8,12.95 L 9.77,11.18 L 8.53,9.94 L 6.76,11.71 A 1.75,1.75 0 0 1 4.29,9.24 L 6.06,7.47 Z"/>
|
||||
<path fill="#222222" d="M 6.23,4.82 L 8,3.05 A 3.5,3.5 0 0 1 12.95,8 L 11.18,9.77 L 9.94,8.53 L 11.71,6.76 A 1.75,1.75 0 0 0 9.24,4.29 L 7.47,6.06 Z"/>
|
||||
<path fill="#222222" d="M 5.26,9.5 L 9.5,5.26 L 10.74,6.5 L 6.5,10.74 Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 533 B After Width: | Height: | Size: 676 B |
@@ -1,10 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Syncing: two arrows chasing each other round a circle. Bundled because
|
||||
Adwaita 50 no longer ships emblem-synchronizing-symbolic. -->
|
||||
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. -->
|
||||
<path fill="#222222" d="M 2,8 A 6,6 0 0 1 12.91,4.56 L 11.48,5.56 A 4.25,4.25 0 0 0 3.75,8 Z"/>
|
||||
<path fill="#222222" d="M 14.23,3.64 L 14.03,7.68 L 10.17,6.48 Z"/>
|
||||
<g transform="rotate(180 8 8)">
|
||||
<path fill="#222222" d="M 2,8 A 6,6 0 0 1 12.91,4.56 L 11.48,5.56 A 4.25,4.25 0 0 0 3.75,8 Z"/>
|
||||
<path fill="#222222" d="M 14.23,3.64 L 14.03,7.68 L 10.17,6.48 Z"/>
|
||||
</g>
|
||||
<path fill="#222222" d="M 14,8 A 6,6 0 0 1 3.09,11.44 L 4.52,10.44 A 4.25,4.25 0 0 0 12.25,8 Z"/>
|
||||
<path fill="#222222" d="M 1.77,12.36 L 1.97,8.32 L 5.83,9.52 Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 619 B After Width: | Height: | Size: 733 B |
@@ -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); }
|
||||
|
||||
@@ -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<BTreeSet<Topic>>,
|
||||
/// Whether a conflict read is already waiting out [`CONFLICTS_DEBOUNCE`].
|
||||
conflicts_pending: Rc<Cell<bool>>,
|
||||
/// Whether an import read is already waiting out [`TAKEOUT_DEBOUNCE`].
|
||||
takeout_pending: Rc<Cell<bool>>,
|
||||
}
|
||||
|
||||
/// Follow the daemon for as long as `window` is open.
|
||||
pub(crate) fn follow_daemon(ui: &Rc<Ui>, 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<Topic> = 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<Ui>, state: &Rc<FeedState>, 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<Ui>, 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<Ui>, 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<Cell<bool>>, 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();
|
||||
});
|
||||
}
|
||||
+37
-159
@@ -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", &["<Control>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<Vec<gtk4::Spinner>> = const { RefCell::new(Vec::new()) };
|
||||
static BUSY_SPINNERS: RefCell<Vec<Spinner>> = 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<gtk4::Widget>,
|
||||
content: &impl IsA<gtk4::Widget>,
|
||||
) -> (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<Ui>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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<Option<Instant>>) -> 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<Ui>, 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<Ui>) {
|
||||
match ui.stack.visible_child_name().as_deref() {
|
||||
Some("browser") => {
|
||||
@@ -1167,22 +1076,10 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
||||
}
|
||||
}),
|
||||
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:
|
||||
|
||||
@@ -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<Ui>, 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();
|
||||
|
||||
@@ -618,7 +618,7 @@ pub(crate) fn open_album(ui: &Rc<Ui>, 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<Ui>) {
|
||||
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<Ui>, 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<Ui>) {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,7 +28,6 @@ pub(crate) struct DevicesState {
|
||||
pub(crate) listed: RefCell<Option<Vec<(String, String)>>>,
|
||||
/// Runs the loads; the rows stay up while one runs.
|
||||
pub(crate) loader: Rc<Loader>,
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
}
|
||||
|
||||
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<Ui>) {
|
||||
// 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<Ui>) {
|
||||
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<Ui>, nav: &[(String, String)]) {
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
ui.devices.loaded_at.set(None);
|
||||
devices_unreachable(&ui);
|
||||
return;
|
||||
}
|
||||
@@ -313,7 +303,6 @@ fn load_device_folder(ui: &Rc<Ui>, 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()));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -75,22 +75,19 @@ pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
|
||||
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<Ui>) {
|
||||
.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
|
||||
|
||||
@@ -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<Loader>,
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
pub(crate) card: SyncCard,
|
||||
pub(crate) queue: QueueState,
|
||||
pub(crate) conflicts: ConflictsState,
|
||||
@@ -94,9 +93,6 @@ pub(crate) struct ConflictsState {
|
||||
pub(crate) rows: RefCell<Vec<adw::ActionRow>>,
|
||||
pub(crate) painted: RefCell<Vec<ConflictKey>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
/// 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<Option<Instant>>,
|
||||
}
|
||||
|
||||
/// 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::Widget>) -> gtk4::ScrolledWindow {
|
||||
pub(crate) fn wire_locations(ui: &Rc<Ui>, 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<Ui>) {
|
||||
// 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<Ui>) {
|
||||
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<Ui>) {
|
||||
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<Ui>, 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<Ui>, id: Option<i64>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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<Ui>, 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<Ui>) {
|
||||
fetch_conflicts(ui, CONFLICTS_BADGE_TTL);
|
||||
}
|
||||
|
||||
/// Ask for the conflict list unless the last one is younger than `ttl`.
|
||||
fn fetch_conflicts(ui: &Rc<Ui>, 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<Ui>) {
|
||||
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<Ui>, 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<Ui>, 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<Ui>) {
|
||||
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>) {
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
ui.locations.loaded_at.set(None);
|
||||
locations_unreachable(&ui);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Ui>, 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
|
||||
|
||||
@@ -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<Ui>, viewer: &Rc<Viewer>, uid: &str) -> Option<PhotoItem>
|
||||
/// plays in place once its file is down.
|
||||
pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, 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<Ui>, viewer: &Rc<Viewer>, 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<Ui>, 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);
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -422,7 +422,7 @@ fn open_place(ui: &Rc<Ui>, 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<Ui>) {
|
||||
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<Ui>, icon: &str, title: &str, description: &str) {
|
||||
/// Wire the Places toggle of the view switcher.
|
||||
pub(crate) fn wire_places(ui: &Rc<Ui>) {
|
||||
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<Ui>) {
|
||||
/// 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<Ui>, 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<Ui>) {
|
||||
let layout = if ui.gallery.places_map_btn.is_active() {
|
||||
let layout = if ui.gallery.places_layout.active() == PLACES_MAP {
|
||||
"map"
|
||||
} else {
|
||||
"grid"
|
||||
|
||||
@@ -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<Option<Vec<(String, String)>>>,
|
||||
/// 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<Option<Instant>>,
|
||||
}
|
||||
|
||||
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<Ui>) {
|
||||
|| 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<Ui>) {
|
||||
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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>) {
|
||||
/// 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<Ui>, 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>) {
|
||||
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())),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,9 +17,6 @@ pub(crate) struct SharedByMeState {
|
||||
pub(crate) items: Rc<RefCell<Vec<SharedItem>>>,
|
||||
/// Runs the loads; the rows stay up while one runs.
|
||||
pub(crate) loader: Rc<Loader>,
|
||||
/// When the page last painted good data. `None` = never / invalidated,
|
||||
/// forcing a fetch on next visit. See [`PAGE_TTL`].
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
}
|
||||
|
||||
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>) {
|
||||
*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<Ui>) {
|
||||
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<Ui>, 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);
|
||||
},
|
||||
|
||||
@@ -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<gtk4::Button>,
|
||||
}
|
||||
@@ -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<Ui>) {
|
||||
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<Ui>) {
|
||||
.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<Ui>) {
|
||||
// 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<Ui>) {
|
||||
|
||||
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<Ui>, 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<Ui>) {
|
||||
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<Ui>) {
|
||||
if ui.status.status_inflight.get() {
|
||||
return;
|
||||
@@ -1199,92 +1180,102 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
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<Ui>, 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<Ui>) {
|
||||
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<Ui>, 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<Ui>, 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<Ui>, 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
|
||||
|
||||
@@ -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<Ui>, 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<Ui>, 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<Ui>) {
|
||||
if ui.takeout.inflight.get() {
|
||||
return;
|
||||
@@ -636,6 +637,7 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
||||
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<Ui>, 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<Ui>, jobs: &[JobItem]) {
|
||||
let Some(job) = jobs.iter().find(|job| job.title == IMPORT_JOB) else {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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#"<child><object class="AdwToggle" id="toggle{i}"/></child>"#))
|
||||
.collect();
|
||||
let builder = build_ui(&format!(
|
||||
r#"<object class="AdwToggleGroup" id="group">{children}</object>"#
|
||||
));
|
||||
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#"<object class="AdwButtonRow" id="row"/>"#);
|
||||
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<gtk4::Widget>,
|
||||
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#"<child><object class="AdwShortcutsItem" id="item{s}_{k}"/></child>"#)
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
r#"<child><object class="AdwShortcutsSection" id="section{s}">{items}</object></child>"#
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let builder = build_ui(&format!(
|
||||
r#"<object class="AdwShortcutsDialog" id="dialog">{sections}</object>"#
|
||||
));
|
||||
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![("<Control>q", "Quit".to_owned())],
|
||||
)]);
|
||||
assert_eq!(dialog.type_().name(), "AdwShortcutsDialog");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<usize>,
|
||||
/// Where the running drag started, in the strip's coordinates.
|
||||
drag_from: Option<f64>,
|
||||
/// Keeps the scrubber up for a moment after a scroll.
|
||||
linger: Option<glib::SourceId>,
|
||||
label: Option<Rc<dyn Fn(usize) -> String>>,
|
||||
on_pick: Option<Rc<dyn Fn(usize)>>,
|
||||
}
|
||||
|
||||
/// 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<gtk4::ScrolledWindow>,
|
||||
state: Rc<RefCell<State>>,
|
||||
}
|
||||
|
||||
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::<gtk4::ScrolledWindow>();
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Ui>, 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);
|
||||
}
|
||||
|
||||
@@ -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<bool> = 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!("<interface>{xml}</interface>"))
|
||||
}
|
||||
|
||||
/// The object `id` from a builder [`build_ui`] made.
|
||||
pub(crate) fn built<T: IsA<gtk4::glib::Object>>(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#"<object class="AdwSpinner" id="spinner"/>"#);
|
||||
built(&builder, "spinner")
|
||||
} else {
|
||||
// Spinning costs nothing while hidden: an unmapped widget draws no
|
||||
// frames.
|
||||
gtk4::Spinner::builder().spinning(true).build().upcast()
|
||||
}
|
||||
}
|
||||
+135
-75
@@ -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<Response>,
|
||||
items: Vec<TransferItem>,
|
||||
jobs: Vec<JobItem>,
|
||||
}
|
||||
|
||||
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)]
|
||||
|
||||
@@ -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<Rc<Ui>> {
|
||||
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>) {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+1112
-1109
File diff suppressed because it is too large.
Load diff
+1090
-1093
File diff suppressed because it is too large.
Load diff
+1079
-1076
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user