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
+12148
-10596
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) {
|
||||
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") {
|
||||
/// 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 {
|
||||
if topics.contains(&Topic::Locations) {
|
||||
refresh_locations(ui);
|
||||
}
|
||||
if topics.contains(&Topic::Queue) {
|
||||
refresh_queue(ui);
|
||||
refresh_conflicts(ui, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -23,13 +23,11 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
/// Spins under the timeline while the next page is loading.
|
||||
pub(crate) pager: gtk4::Spinner,
|
||||
/// The month scrubber on the timeline's right edge, the months it spans
|
||||
/// (newest first), and whether the pointer is on it — while it is, the
|
||||
/// scroll position must not move the knob out from under the drag.
|
||||
pub(crate) scrubber: gtk4::Scale,
|
||||
pub(crate) pager: Spinner,
|
||||
/// The month scrubber over the timeline's right edge, and the months it
|
||||
/// spans (newest first).
|
||||
pub(crate) scrubber: Scrubber,
|
||||
pub(crate) months: RefCell<Vec<PhotoMonth>>,
|
||||
pub(crate) scrubbing: Cell<bool>,
|
||||
/// The pending jump the scrubber debounces to.
|
||||
pub(crate) scrub_source: RefCell<Option<glib::SourceId>>,
|
||||
/// A scrubber jump in progress: the end of the month it is headed for.
|
||||
@@ -39,7 +37,7 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) burst: Cell<bool>,
|
||||
/// Says a Google Photos import is running, with a way to its page.
|
||||
pub(crate) import_banner: adw::Banner,
|
||||
pub(crate) upload: gtk4::Button,
|
||||
pub(crate) upload: adw::SplitButton,
|
||||
/// Upload / Import offered on the *empty timeline* status page, so a fresh
|
||||
/// account is a place to start rather than a dead end. Hidden on every other
|
||||
/// status: a load error is not the moment to offer an upload.
|
||||
@@ -51,20 +49,17 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) albums: gtk4::FlowBox,
|
||||
pub(crate) albums_stack: gtk4::Stack,
|
||||
pub(crate) albums_status: adw::StatusPage,
|
||||
/// The Photos/Albums switcher, and the box holding it — hidden while an
|
||||
/// album is open, where the back button leads instead.
|
||||
pub(crate) photos_btn: gtk4::ToggleButton,
|
||||
pub(crate) albums_btn: gtk4::ToggleButton,
|
||||
pub(crate) view_switch: gtk4::Box,
|
||||
/// The Timeline/Albums/Places switcher (see [`VIEW_TIMELINE`] and its
|
||||
/// siblings) — hidden while an album is open, where the back button leads
|
||||
/// instead.
|
||||
pub(crate) view_switch: ToggleGroup,
|
||||
/// True while the album listing is in flight, so a re-entry into the Albums
|
||||
/// view can't stack requests.
|
||||
pub(crate) albums_loading: Cell<bool>,
|
||||
/// The Places grid and its own status page, like the album grid's, and the
|
||||
/// switcher's Places toggle.
|
||||
/// The Places grid and its own status page, like the album grid's.
|
||||
pub(crate) places: gtk4::FlowBox,
|
||||
pub(crate) places_stack: gtk4::Stack,
|
||||
pub(crate) places_status: adw::StatusPage,
|
||||
pub(crate) places_btn: gtk4::ToggleButton,
|
||||
/// True while the place listing is in flight.
|
||||
pub(crate) places_loading: Cell<bool>,
|
||||
/// The row above the place grid saying how far the daemon has got reading
|
||||
@@ -74,9 +69,9 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) places_mapping_label: gtk4::Label,
|
||||
pub(crate) places_mapping_bar: gtk4::ProgressBar,
|
||||
pub(crate) places_poll: RefCell<Option<glib::SourceId>>,
|
||||
/// The place map, and the Grid/Map toggle's Map half.
|
||||
/// The place map, and the Grid/Map toggle (see [`PLACES_MAP`]).
|
||||
pub(crate) places_map: Rc<PlacesMap>,
|
||||
pub(crate) places_map_btn: gtk4::ToggleButton,
|
||||
pub(crate) places_layout: ToggleGroup,
|
||||
/// How many places the grid shows, for the subtitle when back returns there.
|
||||
pub(crate) place_count: Cell<usize>,
|
||||
/// The place currently open, paged by [`load_gallery`] like an album.
|
||||
@@ -102,7 +97,7 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) kind: Cell<Option<PhotoKind>>,
|
||||
/// The filter toggles, index-aligned with [`kind_for_tab`], kept so their
|
||||
/// labels can carry live per-kind counts.
|
||||
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
||||
pub(crate) tabs: ToggleGroup,
|
||||
/// Whole-timeline `(photos, videos, raw)` counts from the last page that
|
||||
/// carried them, so the subtitle can say how big the library *is* rather
|
||||
/// than how much of it has been paged in.
|
||||
@@ -192,6 +187,9 @@ pub(crate) struct GalleryState {
|
||||
/// back where they were. The ListView realises rows beyond both edges of
|
||||
/// the viewport, so the widget is what tells which of them is on screen.
|
||||
pub(crate) bound: RefCell<BTreeMap<u32, (Option<String>, gtk4::Box)>>,
|
||||
/// The tile widgets each row box has built, rebound to new photos as the
|
||||
/// ListView recycles the row rather than built afresh on every bind.
|
||||
pub(crate) tile_pools: RefCell<HashMap<gtk4::Box, Vec<TileWidget>>>,
|
||||
/// The ListView itself, so a rebuild can scroll back to the row the user was
|
||||
/// looking at.
|
||||
pub(crate) list: gtk4::ListView,
|
||||
@@ -391,18 +389,17 @@ pub(crate) struct GalleryWidgets {
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) title: adw::WindowTitle,
|
||||
pub(crate) pager: gtk4::Spinner,
|
||||
pub(crate) scrubber: gtk4::Scale,
|
||||
pub(crate) pager: Spinner,
|
||||
pub(crate) scrubber: Scrubber,
|
||||
/// Says a Google Photos import is running, with a way to its page.
|
||||
pub(crate) import_banner: adw::Banner,
|
||||
pub(crate) list: gtk4::ListView,
|
||||
pub(crate) scroll: gtk4::ScrolledWindow,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) upload: gtk4::Button,
|
||||
pub(crate) upload: adw::SplitButton,
|
||||
pub(crate) empty_actions: gtk4::Box,
|
||||
pub(crate) empty_upload: gtk4::Button,
|
||||
pub(crate) empty_import: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
/// The Select toggle and the bar it reveals.
|
||||
pub(crate) select_btn: gtk4::ToggleButton,
|
||||
pub(crate) select_bar: gtk4::Revealer,
|
||||
@@ -415,24 +412,21 @@ pub(crate) struct GalleryWidgets {
|
||||
pub(crate) albums: gtk4::FlowBox,
|
||||
pub(crate) albums_stack: gtk4::Stack,
|
||||
pub(crate) albums_status: adw::StatusPage,
|
||||
pub(crate) photos_btn: gtk4::ToggleButton,
|
||||
pub(crate) albums_btn: gtk4::ToggleButton,
|
||||
pub(crate) view_switch: gtk4::Box,
|
||||
pub(crate) view_switch: ToggleGroup,
|
||||
pub(crate) places: gtk4::FlowBox,
|
||||
pub(crate) places_stack: gtk4::Stack,
|
||||
pub(crate) places_status: adw::StatusPage,
|
||||
pub(crate) places_btn: gtk4::ToggleButton,
|
||||
pub(crate) places_mapping: gtk4::Box,
|
||||
pub(crate) places_mapping_label: gtk4::Label,
|
||||
pub(crate) places_mapping_bar: gtk4::ProgressBar,
|
||||
pub(crate) places_map: Rc<PlacesMap>,
|
||||
pub(crate) places_map_btn: gtk4::ToggleButton,
|
||||
pub(crate) places_layout: ToggleGroup,
|
||||
pub(crate) back: gtk4::Button,
|
||||
/// The kind toggles and date jump, as one box so an album view can hide them.
|
||||
pub(crate) filters: gtk4::Box,
|
||||
/// The All / Photos / Videos / Raw filter toggles, in that order (index maps
|
||||
/// to [`kind_for_tab`]).
|
||||
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
||||
pub(crate) tabs: ToggleGroup,
|
||||
pub(crate) favorites_row: adw::SwitchRow,
|
||||
pub(crate) not_in_album_row: adw::SwitchRow,
|
||||
/// The "On this day" strip and the box its cards go into.
|
||||
@@ -505,28 +499,10 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
|
||||
// The timeline pages itself in as the scroll nears the bottom (see
|
||||
// [`wire_gallery`]); this only says a page is on its way.
|
||||
let pager = gtk4::Spinner::builder()
|
||||
.halign(gtk4::Align::Center)
|
||||
.margin_bottom(6)
|
||||
.visible(false)
|
||||
.build();
|
||||
|
||||
// Month scrubber: newest at the top, a mark per year. Dragging it jumps
|
||||
// the timeline to the month under the knob (see [`wire_gallery`]).
|
||||
let scrubber = gtk4::Scale::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.adjustment(>k4::Adjustment::new(0.0, 0.0, 1.0, 1.0, 1.0, 0.0))
|
||||
.draw_value(false)
|
||||
.value_pos(gtk4::PositionType::Left)
|
||||
.round_digits(0)
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(6)
|
||||
.tooltip_text(gettext("Jump to a month"))
|
||||
.visible(false)
|
||||
.build();
|
||||
scrubber.add_css_class("photo-scrubber");
|
||||
let pager = spinner();
|
||||
pager.set_halign(gtk4::Align::Center);
|
||||
pager.set_margin_bottom(6);
|
||||
pager.set_visible(false);
|
||||
|
||||
// Horizontal scrolling is never wanted: the grid is sized to the viewport
|
||||
// width. External rather than Never, which would make the widest row the
|
||||
@@ -549,30 +525,32 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
back.add_css_class("flat");
|
||||
back.add_css_class("circular");
|
||||
|
||||
// `label` and `icon_name` both replace a button's child, so the pair needs
|
||||
// an `adw::ButtonContent` to show together.
|
||||
let upload = gtk4::Button::builder()
|
||||
// Importing a Google Photos export is another way of adding photos, so it
|
||||
// sits in the Upload button's own menu. `label` and `icon_name` both
|
||||
// replace a button's child, so the pair needs an `adw::ButtonContent` to
|
||||
// show together.
|
||||
let upload_menu = gio::Menu::new();
|
||||
upload_menu.append(
|
||||
Some(&gettext("Import from Google Photos…")),
|
||||
Some("win.show-import"),
|
||||
);
|
||||
let upload = adw::SplitButton::builder()
|
||||
.child(
|
||||
&adw::ButtonContent::builder()
|
||||
.label(pgettext("verb", "Upload"))
|
||||
.icon_name("list-add-symbolic")
|
||||
.build(),
|
||||
)
|
||||
.menu_model(&upload_menu)
|
||||
.tooltip_text(gettext("Upload photos"))
|
||||
.dropdown_tooltip(gettext("More ways to add photos"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
upload.add_css_class("suggested-action");
|
||||
|
||||
let refresh = refresh_button();
|
||||
|
||||
// Importing an export and hunting for duplicates are rarer, heavier jobs
|
||||
// than adding one photo, so they wait in the page menu rather than taking
|
||||
// a header button each.
|
||||
// Hunting for duplicates is a rarer, heavier job than adding one photo, so
|
||||
// it waits in the page menu rather than taking a header button.
|
||||
let page_menu = gio::Menu::new();
|
||||
page_menu.append(
|
||||
Some(&gettext("Import from Google Photos…")),
|
||||
Some("win.show-import"),
|
||||
);
|
||||
page_menu.append(
|
||||
Some(&gettext("Find Duplicates…")),
|
||||
Some("win.find-duplicates"),
|
||||
@@ -635,23 +613,9 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
// control: exactly one is active, and flipping it reloads the timeline
|
||||
// filtered to that kind (wired in [`wire_gallery`]). Labels gain live counts
|
||||
// once a page lands.
|
||||
let tabs: [gtk4::ToggleButton; 4] = std::array::from_fn(|i| {
|
||||
gtk4::ToggleButton::builder()
|
||||
.label(tab_label(i))
|
||||
.active(i == 0)
|
||||
.build()
|
||||
});
|
||||
// Group the toggles so they behave as a radio set: chaining each to the first
|
||||
// is what GTK turns into mutual exclusion.
|
||||
for btn in &tabs[1..] {
|
||||
btn.set_group(Some(&tabs[0]));
|
||||
}
|
||||
let tab_group = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
tab_group.add_css_class("linked");
|
||||
for btn in &tabs {
|
||||
btn.add_css_class("pill");
|
||||
tab_group.append(btn);
|
||||
}
|
||||
let labels: [String; 4] = std::array::from_fn(tab_label);
|
||||
let tabs = ToggleGroup::new(&labels.each_ref().map(|label| (label.as_str(), None)));
|
||||
tabs.set_round();
|
||||
|
||||
// Favorites, "not in an album" and the month: the filters that cut across
|
||||
// the kind tabs, together in one popover so the row stays short. The tabs
|
||||
@@ -704,7 +668,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
// timeline, and neither applies to the album grid or to an open album.
|
||||
let filters = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
filters.set_hexpand(true);
|
||||
filters.append(&tab_group);
|
||||
filters.append(&tabs.widget());
|
||||
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
spacer.set_hexpand(true);
|
||||
filters.append(&spacer);
|
||||
@@ -714,26 +678,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
// timeline, and the albums. This is navigation, not a filter — which is why
|
||||
// it sits above the filter row rather than beside the kind toggles.
|
||||
// "Timeline", not "Photos": the kind filter below has a Photos tab too.
|
||||
let photos_btn = gtk4::ToggleButton::builder()
|
||||
.label(gettext("Timeline"))
|
||||
.active(true)
|
||||
.build();
|
||||
let albums_btn = gtk4::ToggleButton::builder()
|
||||
.label(gettext("Albums"))
|
||||
.build();
|
||||
albums_btn.set_group(Some(&photos_btn));
|
||||
let places_btn = gtk4::ToggleButton::builder()
|
||||
.label(gettext("Places"))
|
||||
.build();
|
||||
places_btn.set_group(Some(&photos_btn));
|
||||
let view_switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
view_switch.add_css_class("linked");
|
||||
view_switch.add_css_class("view-switch");
|
||||
view_switch.set_halign(gtk4::Align::Start);
|
||||
for btn in [&photos_btn, &albums_btn, &places_btn] {
|
||||
btn.add_css_class("pill");
|
||||
view_switch.append(btn);
|
||||
}
|
||||
let view_switch = ToggleGroup::new(&[
|
||||
(&gettext("Timeline"), None),
|
||||
(&gettext("Albums"), None),
|
||||
(&gettext("Places"), None),
|
||||
]);
|
||||
view_switch.set_round();
|
||||
let view_switch_widget = view_switch.widget();
|
||||
view_switch_widget.add_css_class("view-switch");
|
||||
view_switch_widget.set_halign(gtk4::Align::Start);
|
||||
|
||||
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
filter_bar.append(&filters);
|
||||
@@ -762,13 +715,12 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
// The timeline (plus its pager) or the status page, never both.
|
||||
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
timeline.append(&select_bar);
|
||||
// The scrubber sits beside the list rather than over it, so the grid is
|
||||
// laid out to the width it really has and no tile hides under a year mark.
|
||||
let timeline_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
scroll.set_hexpand(true);
|
||||
timeline_row.append(&scroll);
|
||||
timeline_row.append(&scrubber);
|
||||
timeline.append(&timeline_row);
|
||||
// The scrubber lies over the grid's right edge and only shows when wanted,
|
||||
// so the grid keeps its full width.
|
||||
let timeline_view = gtk4::Overlay::new();
|
||||
timeline_view.set_child(Some(&scroll));
|
||||
let scrubber = Scrubber::new(&timeline_view);
|
||||
timeline.append(&timeline_view);
|
||||
timeline.append(&pager);
|
||||
|
||||
// The album grid: cover-first cards that flow to the width they are given.
|
||||
@@ -837,18 +789,10 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
let places_map = PlacesMap::new();
|
||||
places_stack.add_named(places_map.widget(), Some("map"));
|
||||
// The places as cards or on the map.
|
||||
let places_grid_btn = gtk4::ToggleButton::builder()
|
||||
.label(pgettext("layout", "Grid"))
|
||||
.active(true)
|
||||
.build();
|
||||
let places_map_btn = gtk4::ToggleButton::builder()
|
||||
.label(pgettext("layout", "Map"))
|
||||
.group(&places_grid_btn)
|
||||
.build();
|
||||
let places_switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
places_switch.add_css_class("linked");
|
||||
places_switch.append(&places_grid_btn);
|
||||
places_switch.append(&places_map_btn);
|
||||
let places_layout = ToggleGroup::new(&[
|
||||
(&pgettext("layout", "Grid"), None),
|
||||
(&pgettext("layout", "Map"), None),
|
||||
]);
|
||||
// While a refresh reads the library's locations (the first one after an
|
||||
// update reads all of them), a bar says how far it has got.
|
||||
let places_mapping_label = gtk4::Label::new(None);
|
||||
@@ -863,7 +807,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
places_mapping.append(&places_mapping_bar);
|
||||
places_mapping.set_visible(false);
|
||||
let places_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||
places_bar.append(&places_switch);
|
||||
places_bar.append(&places_layout.widget());
|
||||
places_bar.append(&places_mapping);
|
||||
let places_page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
places_page.append(&places_bar);
|
||||
@@ -888,7 +832,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&view_switch);
|
||||
inner.append(&view_switch_widget);
|
||||
inner.append(&filter_bar);
|
||||
inner.append(&content);
|
||||
|
||||
@@ -899,7 +843,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
header.pack_start(&back);
|
||||
header.pack_start(&upload);
|
||||
header.pack_end(&more);
|
||||
header.pack_end(&refresh);
|
||||
header.pack_end(&select_btn);
|
||||
|
||||
(
|
||||
@@ -920,7 +863,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
empty_actions,
|
||||
empty_upload,
|
||||
empty_import,
|
||||
refresh,
|
||||
select_btn,
|
||||
select_bar,
|
||||
select_label,
|
||||
@@ -939,18 +881,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
albums,
|
||||
albums_stack,
|
||||
albums_status,
|
||||
photos_btn,
|
||||
albums_btn,
|
||||
view_switch,
|
||||
places,
|
||||
places_stack,
|
||||
places_status,
|
||||
places_btn,
|
||||
places_mapping,
|
||||
places_mapping_label,
|
||||
places_mapping_bar,
|
||||
places_map,
|
||||
places_map_btn,
|
||||
places_layout,
|
||||
back,
|
||||
filters,
|
||||
},
|
||||
@@ -1066,13 +1005,26 @@ pub(crate) fn update_gallery_tabs(ui: &Rc<Ui>, counts: (usize, usize, usize)) {
|
||||
ui.gallery.counts.set(Some(counts));
|
||||
let (photos, videos, raw) = counts;
|
||||
let totals = [photos + videos + raw, photos, videos, raw];
|
||||
for (index, tab) in ui.gallery.tabs.iter().enumerate() {
|
||||
let n = totals[index];
|
||||
tab.set_label(&format!("{} {}", tab_label(index), thousands(n)));
|
||||
tab.set_sensitive(n > 0 || tab.is_active());
|
||||
let active = ui.gallery.tabs.active() as usize;
|
||||
for (index, n) in totals.into_iter().enumerate() {
|
||||
let tabs = &ui.gallery.tabs;
|
||||
tabs.set_label(
|
||||
index as u32,
|
||||
&format!("{} {}", tab_label(index), thousands(n)),
|
||||
);
|
||||
tabs.set_enabled(index as u32, n > 0 || index == active);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Photos page's views, as indexes into its view switcher.
|
||||
pub(crate) const VIEW_TIMELINE: u32 = 0;
|
||||
pub(crate) const VIEW_ALBUMS: u32 = 1;
|
||||
pub(crate) const VIEW_PLACES: u32 = 2;
|
||||
|
||||
/// The Places view's Map layout, as an index into its Grid/Map toggle; the
|
||||
/// grid is the other one.
|
||||
pub(crate) const PLACES_MAP: u32 = 1;
|
||||
|
||||
/// The name of filter tab `index`: All, Photos, Videos or Raw.
|
||||
fn tab_label(index: usize) -> String {
|
||||
match index {
|
||||
@@ -1121,9 +1073,8 @@ pub(crate) fn wire_gallery(
|
||||
factory.connect_setup(|_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
// One container for both row kinds: the bind fills it with either a
|
||||
// heading label or a strip of tiles. The alternative — two factories, or
|
||||
// a Stack per row — buys nothing, since a row's children are rebuilt on
|
||||
// bind either way.
|
||||
// heading label or a strip of tiles from the row's pool (see
|
||||
// [`TileWidget`]).
|
||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP);
|
||||
row.set_halign(gtk4::Align::Start);
|
||||
item.set_child(Some(&row));
|
||||
@@ -1168,9 +1119,19 @@ pub(crate) fn wire_gallery(
|
||||
row_box.set_margin_top(0);
|
||||
// The last row of a day carries the gap to the next heading.
|
||||
row_box.set_margin_bottom(if *last { 4 } else { TILE_GAP });
|
||||
for tile in tiles {
|
||||
row_box.append(&photo_tile(&ui_bind, tile.clone()));
|
||||
let mut pools = ui_bind.gallery.tile_pools.borrow_mut();
|
||||
let pool = pools.entry(row_box.clone()).or_default();
|
||||
for widget in pool.iter() {
|
||||
widget.release(&ui_bind);
|
||||
}
|
||||
while pool.len() < tiles.len() {
|
||||
pool.push(TileWidget::new(&ui_bind));
|
||||
}
|
||||
for (widget, tile) in pool.iter().zip(tiles) {
|
||||
widget.bind(&ui_bind, tile);
|
||||
row_box.append(&widget.button);
|
||||
}
|
||||
drop(pools);
|
||||
schedule_thumbs(&ui_bind);
|
||||
}
|
||||
}
|
||||
@@ -1215,6 +1176,13 @@ pub(crate) fn wire_gallery(
|
||||
}
|
||||
}
|
||||
});
|
||||
let ui_teardown = ui.clone();
|
||||
factory.connect_teardown(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
if let Some(row_box) = item.child().and_downcast::<gtk4::Box>() {
|
||||
ui_teardown.gallery.tile_pools.borrow_mut().remove(&row_box);
|
||||
}
|
||||
});
|
||||
list.set_factory(Some(&factory));
|
||||
|
||||
// The grid divides the content width, so a resize re-flows whatever is on
|
||||
@@ -1246,6 +1214,7 @@ pub(crate) fn wire_gallery(
|
||||
load_gallery(&ui_scroll, true);
|
||||
}
|
||||
sync_scrubber_position(&ui_scroll);
|
||||
ui_scroll.gallery.scrubber.flash();
|
||||
});
|
||||
wire_scrubber(ui);
|
||||
wire_drag_select(ui);
|
||||
@@ -1320,16 +1289,10 @@ pub(crate) fn wire_gallery(
|
||||
.connect_clicked(move |_| set_selection_mode(&ui_done, false));
|
||||
|
||||
// Filter toggles: flipping to a tab reloads the timeline filtered to that
|
||||
// kind. Only the button being switched *on* acts — the group also fires a
|
||||
// `toggled` for the one switching off, which this skips — and a redundant
|
||||
// toggle to the already-current kind is a no-op.
|
||||
for (index, tab) in ui.gallery.tabs.iter().enumerate() {
|
||||
// kind. A redundant toggle to the already-current kind is a no-op.
|
||||
let ui_tab = ui.clone();
|
||||
tab.connect_toggled(move |btn| {
|
||||
if !btn.is_active() {
|
||||
return;
|
||||
}
|
||||
let kind = kind_for_tab(index);
|
||||
ui.gallery.tabs.connect_changed(move |index| {
|
||||
let kind = kind_for_tab(index as usize);
|
||||
if ui_tab.gallery.kind.get() == kind {
|
||||
return;
|
||||
}
|
||||
@@ -1339,36 +1302,6 @@ pub(crate) fn wire_gallery(
|
||||
ui_tab.gallery.range.set(None);
|
||||
load_gallery(&ui_tab, false);
|
||||
});
|
||||
}
|
||||
|
||||
// Favorites: reload the timeline restricted to favorites (or back to all).
|
||||
// Independent of the kind tabs and the date jump, both of which keep their
|
||||
// current value across the toggle.
|
||||
let ui_fav = ui.clone();
|
||||
ui.gallery.favorites_row.connect_active_notify(move |row| {
|
||||
let on = row.is_active();
|
||||
if ui_fav.gallery.favorites.get() == on {
|
||||
return;
|
||||
}
|
||||
ui_fav.gallery.favorites.set(on);
|
||||
load_gallery(&ui_fav, false);
|
||||
});
|
||||
|
||||
// Not in an album: the same kind of filter, and just as independent.
|
||||
let ui_unfiled = ui.clone();
|
||||
ui.gallery
|
||||
.not_in_album_row
|
||||
.connect_active_notify(move |row| {
|
||||
let on = row.is_active();
|
||||
if ui_unfiled.gallery.not_in_album.get() == on {
|
||||
return;
|
||||
}
|
||||
ui_unfiled.gallery.not_in_album.set(on);
|
||||
load_gallery(&ui_unfiled, false);
|
||||
});
|
||||
|
||||
// Clear filters: back to the whole timeline in one reload. The state goes
|
||||
// first, so each widget's handler finds nothing to change and stays quiet.
|
||||
let ui_clear = ui.clone();
|
||||
ui.gallery.clear_filters.connect_clicked(move |_| {
|
||||
let gallery = &ui_clear.gallery;
|
||||
@@ -1376,7 +1309,7 @@ pub(crate) fn wire_gallery(
|
||||
gallery.favorites.set(false);
|
||||
gallery.not_in_album.set(false);
|
||||
gallery.range.set(None);
|
||||
gallery.tabs[0].set_active(true);
|
||||
gallery.tabs.set_active(0);
|
||||
gallery.favorites_row.set_active(false);
|
||||
gallery.not_in_album_row.set_active(false);
|
||||
gallery.date_suppress.set(true);
|
||||
@@ -1570,11 +1503,27 @@ pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
||||
/// The picture sits in an overlay over a placeholder, so a tile is never a hole:
|
||||
/// until the thumbnail lands it shows a dim card, and a photo that can never have
|
||||
/// one keeps an image glyph instead of an empty rectangle.
|
||||
pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||
///
|
||||
/// A fast scroll binds hundreds of rows, so a tile is built once and then
|
||||
/// rebound to whichever photo its row shows next: [`TileWidget::bind`] only
|
||||
/// swaps what it shows, and its handlers read the photo it shows now.
|
||||
pub(crate) struct TileWidget {
|
||||
pub(crate) button: gtk4::Button,
|
||||
picture: gtk4::Picture,
|
||||
caption: gtk4::Label,
|
||||
video: gtk4::Image,
|
||||
group: gtk4::Label,
|
||||
check: gtk4::Image,
|
||||
photo: Rc<RefCell<Option<PhotoItem>>>,
|
||||
}
|
||||
|
||||
impl TileWidget {
|
||||
pub(crate) fn new(ui: &Rc<Ui>) -> Self {
|
||||
let picture = gtk4::Picture::builder()
|
||||
// Every tile is square, so Cover crops the photo to fill it. Contain
|
||||
// would letterbox it, and the bars would read as gaps in the sheet.
|
||||
// The expands are what make the picture take the whole overlay.
|
||||
// Every tile is square, so Cover crops the photo to fill it.
|
||||
// Contain would letterbox it, and the bars would read as gaps in
|
||||
// the sheet. The expands are what make the picture take the whole
|
||||
// overlay.
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.can_shrink(true)
|
||||
.hexpand(true)
|
||||
@@ -1591,80 +1540,38 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||
.build();
|
||||
placeholder.add_css_class("photo-placeholder");
|
||||
|
||||
// The capture time, on a gradient that only exists while the pointer is over
|
||||
// the tile — legible over any photo, invisible the rest of the time. Under a
|
||||
// day heading the clock time is enough; under a month or a year the day is
|
||||
// what the heading no longer says.
|
||||
let caption_text = match tile.grouping {
|
||||
Grouping::Day => short_capture_time(tile.photo.capture_time),
|
||||
Grouping::Month | Grouping::Year => short_capture_date(tile.photo.capture_time),
|
||||
};
|
||||
// The capture time, on a gradient that only exists while the pointer
|
||||
// is over the tile — legible over any photo, invisible the rest of the
|
||||
// time.
|
||||
let caption = gtk4::Label::builder()
|
||||
// Fill horizontally so the scrim spans the tile; the text itself stays
|
||||
// left-aligned inside it.
|
||||
// Fill horizontally so the scrim spans the tile; the text itself
|
||||
// stays left-aligned inside it.
|
||||
.halign(gtk4::Align::Fill)
|
||||
.valign(gtk4::Align::End)
|
||||
.xalign(0.0)
|
||||
.label(caption_text)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
caption.add_css_class("photo-caption");
|
||||
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.set_child(Some(&placeholder));
|
||||
overlay.add_overlay(&picture);
|
||||
overlay.add_overlay(&caption);
|
||||
|
||||
// A video reads as a video at a glance: a play glyph centred over the poster
|
||||
// thumbnail. Kept above the caption scrim so it stays legible on hover.
|
||||
// Zoomed out, the glyph shrinks with the tile so it marks the video without
|
||||
// covering it.
|
||||
let small = tile.grouping != Grouping::Day;
|
||||
let is_video = tile.photo.kind == PhotoKind::Video;
|
||||
if is_video {
|
||||
let badge = gtk4::Image::builder()
|
||||
// A video reads as a video at a glance: a play glyph centred over the
|
||||
// poster thumbnail. Kept above the caption scrim so it stays legible
|
||||
// on hover.
|
||||
let video = gtk4::Image::builder()
|
||||
.icon_name("media-playback-start-symbolic")
|
||||
.pixel_size(if small { 12 } else { 28 })
|
||||
.halign(gtk4::Align::Center)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
badge.add_css_class("photo-video-badge");
|
||||
if small {
|
||||
badge.add_css_class("photo-video-badge-small");
|
||||
}
|
||||
overlay.add_overlay(&badge);
|
||||
}
|
||||
video.add_css_class("photo-video-badge");
|
||||
|
||||
// A shot stored as more than one file says so, in the corner the caption
|
||||
// does not use. "RAW" is the useful word when one of the members is a raw
|
||||
// file — that is what the person wants to find — and a plain count covers
|
||||
// the rest (a live photo, a burst). Only the Day zoom shows it: on the
|
||||
// smaller Month and Year tiles a badge on every raw+JPEG shot turns the
|
||||
// grid into noise.
|
||||
if (tile.photo.has_raw || tile.photo.group_size > 1) && tile.grouping == Grouping::Day {
|
||||
let badge = gtk4::Label::builder()
|
||||
.label(if tile.photo.has_raw {
|
||||
// Translators: badge on a photo tile whose shot includes a raw camera file.
|
||||
gettext("RAW")
|
||||
} else {
|
||||
format!("{}", tile.photo.group_size)
|
||||
})
|
||||
// A shot stored as more than one file says so, in the corner the
|
||||
// caption does not use.
|
||||
let group = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Start)
|
||||
.build();
|
||||
badge.add_css_class("photo-group-badge");
|
||||
overlay.add_overlay(&badge);
|
||||
}
|
||||
group.add_css_class("photo-group-badge");
|
||||
|
||||
// Selection mode marks every tile, picked or not: a check that only appears
|
||||
// once a photo is chosen leaves the user guessing what else is clickable.
|
||||
if tile.selecting {
|
||||
let check = gtk4::Image::builder()
|
||||
.icon_name(if tile.selected {
|
||||
"checkbox-checked-symbolic"
|
||||
} else {
|
||||
"checkbox-symbolic"
|
||||
})
|
||||
.pixel_size(16)
|
||||
.halign(gtk4::Align::Start)
|
||||
.valign(gtk4::Align::Start)
|
||||
@@ -1672,60 +1579,66 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||
.margin_top(6)
|
||||
.build();
|
||||
check.add_css_class("photo-check");
|
||||
if tile.selected {
|
||||
check.add_css_class("photo-check-on");
|
||||
}
|
||||
overlay.add_overlay(&check);
|
||||
}
|
||||
|
||||
let button = gtk4::Button::builder()
|
||||
.child(&overlay)
|
||||
.width_request(tile.width)
|
||||
.height_request(tile.height)
|
||||
.tooltip_text(format_capture_time(tile.photo.capture_time))
|
||||
.build();
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.set_child(Some(&placeholder));
|
||||
overlay.add_overlay(&picture);
|
||||
overlay.add_overlay(&caption);
|
||||
overlay.add_overlay(&video);
|
||||
overlay.add_overlay(&group);
|
||||
overlay.add_overlay(&check);
|
||||
|
||||
let button = gtk4::Button::builder().child(&overlay).build();
|
||||
button.add_css_class("photo-tile");
|
||||
button.add_css_class("flat");
|
||||
if tile.selected {
|
||||
button.add_css_class("photo-tile-selected");
|
||||
}
|
||||
// Clip the thumbnail to the tile's rounded corners.
|
||||
button.set_overflow(gtk4::Overflow::Hidden);
|
||||
|
||||
want_thumb(ui, &tile.photo, &picture);
|
||||
let photo: Rc<RefCell<Option<PhotoItem>>> = Rc::default();
|
||||
let uid = {
|
||||
let photo = photo.clone();
|
||||
move || photo.borrow().as_ref().map(|p| p.uid.clone())
|
||||
};
|
||||
|
||||
// Ctrl or Shift on a plain click starts a selection — the gesture people
|
||||
// already use for picking things, without having to find the Select button
|
||||
// first. Ctrl picks one photo; Shift picks everything from the last photo
|
||||
// clicked to this one, as in a file manager. Claimed in the capture phase so
|
||||
// the click never also opens the photo it was picking.
|
||||
// Ctrl or Shift on a plain click starts a selection — the gesture
|
||||
// people already use for picking things, without having to find the
|
||||
// Select button first. Ctrl picks one photo; Shift picks everything
|
||||
// from the last photo clicked to this one, as in a file manager.
|
||||
// Claimed in the capture phase so the click never also opens the photo
|
||||
// it was picking.
|
||||
let modifier = gtk4::GestureClick::new();
|
||||
modifier.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||
let ui_modifier = ui.clone();
|
||||
let modifier_uid = tile.photo.uid.clone();
|
||||
let modifier_uid = uid.clone();
|
||||
modifier.connect_pressed(move |gesture, _, _, _| {
|
||||
let state = gesture.current_event_state();
|
||||
let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
|
||||
if !shift && !state.contains(gtk4::gdk::ModifierType::CONTROL_MASK) {
|
||||
return;
|
||||
}
|
||||
let Some(uid) = modifier_uid() else {
|
||||
return;
|
||||
};
|
||||
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
||||
set_selection_mode(&ui_modifier, true);
|
||||
let anchor = ui_modifier.gallery.select_anchor.borrow().clone();
|
||||
match anchor {
|
||||
Some(anchor) if shift => {
|
||||
let base = ui_modifier.gallery.selected.borrow().clone();
|
||||
select_range(&ui_modifier, &anchor, &modifier_uid, &base);
|
||||
select_range(&ui_modifier, &anchor, &uid, &base);
|
||||
}
|
||||
_ => toggle_selected(&ui_modifier, &modifier_uid),
|
||||
_ => toggle_selected(&ui_modifier, &uid),
|
||||
}
|
||||
});
|
||||
button.add_controller(modifier);
|
||||
|
||||
let context = gtk4::GestureClick::builder().button(3).build();
|
||||
let ui_context = ui.clone();
|
||||
let photo = tile.photo.clone();
|
||||
let context_photo = photo.clone();
|
||||
context.connect_pressed(move |gesture, _, x, y| {
|
||||
let Some(photo) = context_photo.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
||||
if let Some(anchor) = gesture.widget().and_downcast::<gtk4::Button>() {
|
||||
show_photo_menu(&ui_context, &photo, &anchor, x, y);
|
||||
@@ -1733,19 +1646,103 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||
});
|
||||
button.add_controller(context);
|
||||
|
||||
// A tile opens in the in-app lightbox, which plays a video in place. In
|
||||
// selection mode a tile picks instead of opening: the lightbox is one
|
||||
// Escape away.
|
||||
// A tile opens in the in-app lightbox, which plays a video in place.
|
||||
// In selection mode a tile picks instead of opening: the lightbox is
|
||||
// one Escape away.
|
||||
let ui_open = ui.clone();
|
||||
let uid = tile.photo.uid.clone();
|
||||
button.connect_clicked(move |_| {
|
||||
let Some(uid) = uid() else {
|
||||
return;
|
||||
};
|
||||
if ui_open.gallery.selecting.get() {
|
||||
toggle_selected(&ui_open, &uid);
|
||||
} else {
|
||||
open_photo_viewer(&ui_open, uid.clone());
|
||||
open_photo_viewer(&ui_open, uid);
|
||||
}
|
||||
});
|
||||
button
|
||||
|
||||
Self {
|
||||
button,
|
||||
picture,
|
||||
caption,
|
||||
video,
|
||||
group,
|
||||
check,
|
||||
photo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `tile` in place of whatever the widget showed before.
|
||||
pub(crate) fn bind(&self, ui: &Rc<Ui>, tile: &Tile) {
|
||||
self.button.set_size_request(tile.width, tile.height);
|
||||
self.picture.set_paintable(None::<>k4::gdk::Paintable>);
|
||||
|
||||
// Under a day heading the clock time is enough; under a month or a
|
||||
// year the day is what the heading no longer says.
|
||||
self.caption.set_label(&match tile.grouping {
|
||||
Grouping::Day => short_capture_time(tile.photo.capture_time),
|
||||
Grouping::Month | Grouping::Year => short_capture_date(tile.photo.capture_time),
|
||||
});
|
||||
|
||||
// Zoomed out, the play glyph shrinks with the tile so it marks the
|
||||
// video without covering it.
|
||||
let small = tile.grouping != Grouping::Day;
|
||||
self.video.set_visible(tile.photo.kind == PhotoKind::Video);
|
||||
self.video.set_pixel_size(if small { 12 } else { 28 });
|
||||
set_css_class(&self.video, "photo-video-badge-small", small);
|
||||
|
||||
// "RAW" is the useful word when one of the shot's files is a raw file
|
||||
// — that is what the person wants to find — and a plain count covers
|
||||
// the rest (a live photo, a burst). Only the Day zoom shows it: on the
|
||||
// smaller Month and Year tiles a badge on every raw+JPEG shot turns
|
||||
// the grid into noise.
|
||||
let grouped = tile.photo.has_raw || tile.photo.group_size > 1;
|
||||
self.group
|
||||
.set_visible(grouped && tile.grouping == Grouping::Day);
|
||||
if tile.photo.has_raw {
|
||||
// Translators: badge on a photo tile whose shot includes a raw camera file.
|
||||
self.group.set_label(&gettext("RAW"));
|
||||
} else {
|
||||
self.group.set_label(&tile.photo.group_size.to_string());
|
||||
}
|
||||
|
||||
// Selection mode marks every tile, picked or not: a check that only
|
||||
// appears once a photo is chosen leaves the user guessing what else is
|
||||
// clickable.
|
||||
self.check.set_visible(tile.selecting);
|
||||
self.check.set_icon_name(Some(if tile.selected {
|
||||
"checkbox-checked-symbolic"
|
||||
} else {
|
||||
"checkbox-symbolic"
|
||||
}));
|
||||
set_css_class(&self.check, "photo-check-on", tile.selected);
|
||||
set_css_class(&self.button, "photo-tile-selected", tile.selected);
|
||||
|
||||
*self.photo.borrow_mut() = Some(tile.photo.clone());
|
||||
want_thumb(ui, &tile.photo, &self.picture);
|
||||
}
|
||||
|
||||
/// Give up the claim on the thumbnail of the photo shown last, so it
|
||||
/// cannot land in this widget once it shows another one.
|
||||
pub(crate) fn release(&self, ui: &Rc<Ui>) {
|
||||
let Some(photo) = self.photo.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
let mut wanted = ui.gallery.thumb_wanted.borrow_mut();
|
||||
if wanted.get(&photo.uid) == Some(&self.picture) {
|
||||
wanted.remove(&photo.uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or remove one CSS class, for widgets that are rebound rather than
|
||||
/// rebuilt.
|
||||
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
|
||||
if on {
|
||||
widget.add_css_class(class);
|
||||
} else {
|
||||
widget.remove_css_class(class);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter or leave selection mode, repainting the tiles so their checkboxes
|
||||
@@ -2851,20 +2848,14 @@ pub(crate) fn empty_timeline_text(
|
||||
/// mark and a year label where each year starts. Shown only for the whole,
|
||||
/// unfiltered timeline with more than one month to move between.
|
||||
fn fill_scrubber(ui: &Rc<Ui>, months: &[PhotoMonth]) {
|
||||
let scrubber = &ui.gallery.scrubber;
|
||||
scrubber.clear_marks();
|
||||
*ui.gallery.months.borrow_mut() = months.to_vec();
|
||||
scrubber.set_range(0.0, months.len().saturating_sub(1).max(1) as f64);
|
||||
scrubber.set_value(0.0);
|
||||
for (index, pair) in months.windows(2).enumerate() {
|
||||
if pair[0].year != pair[1].year {
|
||||
scrubber.add_mark(
|
||||
(index + 1) as f64,
|
||||
gtk4::PositionType::Left,
|
||||
Some(&pair[1].year.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
let years = months
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(index, month)| index == 0 || months[index - 1].year != month.year)
|
||||
.map(|(index, month)| (index, month.year.to_string()))
|
||||
.collect();
|
||||
ui.gallery.scrubber.set_months(months.len(), years);
|
||||
sync_scrubber(ui);
|
||||
}
|
||||
|
||||
@@ -2877,7 +2868,7 @@ pub(crate) fn sync_scrubber(ui: &Rc<Ui>) {
|
||||
&& !ui.gallery.not_in_album.get();
|
||||
ui.gallery
|
||||
.scrubber
|
||||
.set_visible(whole && ui.gallery.months.borrow().len() > 1);
|
||||
.set_available(whole && ui.gallery.months.borrow().len() > 1);
|
||||
}
|
||||
|
||||
/// How many photos the "On this day" strip asks for. Enough for a card per year
|
||||
@@ -3036,10 +3027,10 @@ fn scrubber_label(months: &[PhotoMonth], index: usize) -> String {
|
||||
.map_or_else(String::new, |m| month_label(m.year, m.month))
|
||||
}
|
||||
|
||||
/// Move the knob to the month at the top of the timeline, unless the user is
|
||||
/// holding it.
|
||||
/// Move the knob to the month at the top of the timeline. The scrubber itself
|
||||
/// ignores this while the user has it in hand.
|
||||
fn sync_scrubber_position(ui: &Rc<Ui>) {
|
||||
if ui.gallery.scrubbing.get() || !ui.gallery.scrubber.is_visible() {
|
||||
if !ui.gallery.scrubber.is_available() {
|
||||
return;
|
||||
}
|
||||
let Some((uid, _)) = top_anchor(ui) else {
|
||||
@@ -3054,7 +3045,7 @@ fn sync_scrubber_position(ui: &Rc<Ui>) {
|
||||
};
|
||||
let months = ui.gallery.months.borrow();
|
||||
if let Some(index) = month_index(&months, capture_time) {
|
||||
ui.gallery.scrubber.set_value(index as f64);
|
||||
ui.gallery.scrubber.set_value(index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3070,43 +3061,24 @@ pub(crate) fn month_index(months: &[PhotoMonth], capture_time: i64) -> Option<us
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire the scrubber: its value label while the pointer is on it, and the
|
||||
/// jump once it rests on a month.
|
||||
/// Wire the scrubber: the month names in its bubble, and the jump once it
|
||||
/// rests on a month.
|
||||
fn wire_scrubber(ui: &Rc<Ui>) {
|
||||
let scrubber = ui.gallery.scrubber.clone();
|
||||
let ui_format = ui.clone();
|
||||
scrubber.set_format_value_func(move |_, value| {
|
||||
scrubber_label(&ui_format.gallery.months.borrow(), value.round() as usize)
|
||||
});
|
||||
|
||||
let hover = gtk4::EventControllerMotion::new();
|
||||
let ui_enter = ui.clone();
|
||||
hover.connect_enter(move |_, _, _| {
|
||||
ui_enter.gallery.scrubbing.set(true);
|
||||
ui_enter.gallery.scrubber.set_draw_value(true);
|
||||
});
|
||||
let ui_leave = ui.clone();
|
||||
hover.connect_leave(move |_| {
|
||||
ui_leave.gallery.scrubbing.set(false);
|
||||
ui_leave.gallery.scrubber.set_draw_value(false);
|
||||
});
|
||||
scrubber.add_controller(hover);
|
||||
|
||||
// `change-value` fires for the user's own moves only, not for the knob
|
||||
// following the scroll.
|
||||
let ui_change = ui.clone();
|
||||
scrubber.connect_change_value(move |_, _, value| {
|
||||
if let Some(source) = ui_change.gallery.scrub_source.borrow_mut().take() {
|
||||
let ui_label = ui.clone();
|
||||
ui.gallery
|
||||
.scrubber
|
||||
.set_label_func(move |index| scrubber_label(&ui_label.gallery.months.borrow(), index));
|
||||
let ui_pick = ui.clone();
|
||||
ui.gallery.scrubber.connect_pick(move |index| {
|
||||
if let Some(source) = ui_pick.gallery.scrub_source.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
let index = value.round().max(0.0) as usize;
|
||||
let ui_jump = ui_change.clone();
|
||||
let ui_jump = ui_pick.clone();
|
||||
let source = glib::timeout_add_local_once(SCRUB_DEBOUNCE, move || {
|
||||
ui_jump.gallery.scrub_source.borrow_mut().take();
|
||||
jump_to_month(&ui_jump, index);
|
||||
});
|
||||
*ui_change.gallery.scrub_source.borrow_mut() = Some(source);
|
||||
glib::Propagation::Proceed
|
||||
*ui_pick.gallery.scrub_source.borrow_mut() = Some(source);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3332,7 +3304,6 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
};
|
||||
ui.gallery.loading.set(true);
|
||||
ui.gallery.pager.set_visible(append);
|
||||
ui.gallery.pager.set_spinning(append);
|
||||
|
||||
ui.busy_begin();
|
||||
let request = match (album, place) {
|
||||
@@ -3354,7 +3325,6 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
ui.busy_end();
|
||||
ui.gallery.loading.set(false);
|
||||
ui.gallery.pager.set_visible(false);
|
||||
ui.gallery.pager.set_spinning(false);
|
||||
if ui.gallery.reload_pending.take() {
|
||||
load_gallery(&ui, false);
|
||||
return;
|
||||
|
||||
@@ -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,7 +1180,20 @@ 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 {
|
||||
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 feed asks again when it reconnects.
|
||||
Ok(Err(e)) if e.is_timeout() => paint_sync_status(&ui, SyncState::NotResponding),
|
||||
_ => 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,
|
||||
@@ -1212,8 +1206,11 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
paused,
|
||||
paused_until,
|
||||
..
|
||||
})) => {
|
||||
set_mounted(&ui, true);
|
||||
} = 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.
|
||||
@@ -1235,9 +1232,9 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
} else {
|
||||
SyncState::UpToDate { mountpoint }
|
||||
};
|
||||
paint_sync_status(&ui, state);
|
||||
paint_sync_status(ui, state);
|
||||
ui.locations.failing.set(failing_ops);
|
||||
paint_sync_banner(&ui);
|
||||
paint_sync_banner(ui);
|
||||
let fraction = if budget == 0 {
|
||||
0.0
|
||||
} else {
|
||||
@@ -1263,27 +1260,21 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
],
|
||||
)
|
||||
});
|
||||
repaint_pins(&ui, &pins, true);
|
||||
repaint_pins(ui, &pins, true);
|
||||
}
|
||||
// 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.
|
||||
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);
|
||||
|
||||
/// 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 {
|
||||
@@ -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,14 +1519,13 @@ 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 {
|
||||
let title = if expanded {
|
||||
gettext("Show fewer")
|
||||
} else {
|
||||
ngettext_f(
|
||||
@@ -1545,16 +1534,14 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
pins.len() as u64,
|
||||
&[],
|
||||
)
|
||||
})
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.add_suffix(>k4::Image::from_icon_name(if expanded {
|
||||
};
|
||||
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