mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Show only sections with content, invitations first, and an empty state when nothing is shared. Rows drop the mount path from the subtitle, show access as a tinted pill (Can view / Can edit / Can manage) and move Open, Show in My Files and Leave into a menu. The invitations banner is gone now that invitations lead the page.
1800 lines
72 KiB
Rust
1800 lines
72 KiB
Rust
#[path = "../activation.rs"]
|
|
pub(crate) mod activation;
|
|
pub(crate) mod pages;
|
|
pub(crate) mod widgets;
|
|
|
|
use pages::activity::*;
|
|
use pages::albums::*;
|
|
use pages::browser::*;
|
|
use pages::devices::*;
|
|
use pages::locations::*;
|
|
use pages::login::*;
|
|
use pages::photo_viewer::*;
|
|
use pages::photos::*;
|
|
use pages::shared::*;
|
|
use pages::shared_by_me::*;
|
|
use pages::status::*;
|
|
use pages::takeout::*;
|
|
use pages::trash::*;
|
|
use pages::verify::*;
|
|
use widgets::details::*;
|
|
use widgets::menu::*;
|
|
use widgets::share_dialog::*;
|
|
use widgets::thumbnails::*;
|
|
use widgets::versions_dialog::*;
|
|
|
|
use std::cell::{Cell, RefCell};
|
|
|
|
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use std::process::Command;
|
|
|
|
use std::rc::Rc;
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
use adw::prelude::*;
|
|
|
|
use gtk4::gio;
|
|
|
|
use gtk4::glib;
|
|
|
|
use gtk4::glib::BoxedAnyObject;
|
|
|
|
use pdfs_core::auth;
|
|
|
|
use pdfs_core::config::{AppDirs, FileSort, FilesView};
|
|
|
|
use pdfs_core::control::{
|
|
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
|
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem,
|
|
PhotoKind, PhotoMonth, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder,
|
|
RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo,
|
|
SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem,
|
|
pending_summary, send,
|
|
};
|
|
|
|
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
|
|
|
use pdfs_core::service;
|
|
|
|
const APP_ID: &str = "io.narl.proton-drive-linux";
|
|
|
|
/// Proton brand purple, applied as the libadwaita accent when the user opts in
|
|
/// (Preferences → Appearance), so switches, buttons and links all pick it up.
|
|
const PROTON_PURPLE: &str = "#6d4aff";
|
|
|
|
/// How often the window re-reads mount status, cache usage and the pin list.
|
|
const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
|
|
|
|
/// 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);
|
|
|
|
/// How long the gallery keeps following one timeline refresh. A library of tens
|
|
/// of thousands of photos is re-read in batches over several minutes; past this
|
|
/// the daemon carries on and the page catches up on its next load.
|
|
const PHOTOS_REFRESH_FOLLOW_LIMIT: Duration = Duration::from_secs(15 * 60);
|
|
|
|
/// All widgets the periodic refresh and the action handlers mutate, plus the
|
|
/// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout
|
|
/// closure share one instance.
|
|
struct Ui {
|
|
dirs: AppDirs,
|
|
stack: adw::ViewStack,
|
|
/// Wraps the whole window content; every non-blocking outcome (a completed
|
|
/// rename, a failed upload, a purge) is reported here rather than in a modal,
|
|
/// so an action never interrupts what the user is doing next.
|
|
toasts: adw::ToastOverlay,
|
|
/// How many open/load round-trips are in flight. While any is, every page
|
|
/// header shows its spinner (see [`page_frame`]); ref-counted so concurrent
|
|
/// operations don't hide it early.
|
|
busy: Cell<u32>,
|
|
/// Keys (relative path / photo uid) of open requests currently in flight, so
|
|
/// a double-click on the same entry is a no-op instead of a second download.
|
|
opening: RefCell<HashSet<String>>,
|
|
/// Resolved login identity, cached so the periodic [`refresh`] never hits the
|
|
/// keyring (a DBus round-trip). Populated at startup and updated only on
|
|
/// login / logout. `None` = signed out.
|
|
session: RefCell<Option<auth::StoredSession>>,
|
|
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
|
|
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
|
mounted: RefCell<bool>,
|
|
/// Sidebar destination list (My files, Photos, …, Sync). Selecting a row swaps
|
|
/// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere.
|
|
sidebar: gtk4::ListBox,
|
|
/// The sidebar/content split. Collapsed while signed out, so the login page
|
|
/// owns the whole window and no destination is reachable without a session.
|
|
nav: adw::NavigationSplitView,
|
|
/// Shared thumbnails for ordinary image files outside the Photos gallery.
|
|
/// One cache and request queue serves Files, search, Shared and Trash, so
|
|
/// the same image is downloaded and decoded only once.
|
|
pub(crate) file_thumbs: FileThumbnailState,
|
|
|
|
// Per-page state. Each page module owns its own struct; `Ui` keeps only
|
|
// what more than one page genuinely shares.
|
|
pub(crate) login: LoginState,
|
|
pub(crate) status: StatusState,
|
|
pub(crate) browser: BrowserState,
|
|
pub(crate) details: DetailsState,
|
|
pub(crate) trash: TrashState,
|
|
pub(crate) gallery: GalleryState,
|
|
pub(crate) shared: SharedState,
|
|
pub(crate) shared_by_me: SharedByMeState,
|
|
pub(crate) devices: DevicesState,
|
|
pub(crate) locations: LocationsState,
|
|
pub(crate) activity: ActivityState,
|
|
pub(crate) takeout: TakeoutState,
|
|
}
|
|
|
|
impl Ui {
|
|
/// Begin a unit of background work: show + spin the header spinner.
|
|
fn busy_begin(&self) {
|
|
self.busy.set(self.busy.get() + 1);
|
|
set_busy_spinners(true);
|
|
}
|
|
|
|
/// End a unit of background work: stop the spinner once the last one is done.
|
|
fn busy_end(&self) {
|
|
let remaining = self.busy.get().saturating_sub(1);
|
|
self.busy.set(remaining);
|
|
if remaining == 0 {
|
|
set_busy_spinners(false);
|
|
}
|
|
}
|
|
|
|
/// Remember a decoded thumbnail, evicting the oldest once the cache is full.
|
|
/// Scrolling back over a day then repaints from memory rather than decoding
|
|
/// the same JPEGs off disk again.
|
|
fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) {
|
|
let mut cache = self.gallery.photo_tex.borrow_mut();
|
|
let mut order = self.gallery.photo_tex_order.borrow_mut();
|
|
if cache.insert(uid.to_string(), texture).is_none() {
|
|
order.push_back(uid.to_string());
|
|
}
|
|
while order.len() > TEXTURE_CACHE_MAX {
|
|
if let Some(old) = order.pop_front() {
|
|
cache.remove(&old);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mark a cached thumbnail as freshly used, so eviction takes the tiles
|
|
/// nobody has looked at rather than the oldest ones.
|
|
///
|
|
/// Insertion order alone evicts the top of the timeline first — exactly what
|
|
/// a scroll back up then asks for again.
|
|
fn touch_texture(&self, uid: &str) {
|
|
let mut order = self.gallery.photo_tex_order.borrow_mut();
|
|
if order.back().is_some_and(|back| back == uid) {
|
|
return;
|
|
}
|
|
if let Some(at) = order.iter().position(|held| held == uid) {
|
|
order.remove(at);
|
|
order.push_back(uid.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main() -> glib::ExitCode {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
|
)
|
|
.init();
|
|
|
|
// The tray launches the app with arguments ("--page locations",
|
|
// "--confirm-stop"), which a running instance must receive too.
|
|
let app = adw::Application::builder()
|
|
.application_id(APP_ID)
|
|
.flags(gio::ApplicationFlags::HANDLES_COMMAND_LINE)
|
|
.build();
|
|
app.connect_startup(|_| {
|
|
load_proton_theme();
|
|
if let Ok(dirs) = AppDirs::new() {
|
|
set_proton_accent(dirs.load_config().proton_accent.unwrap_or(false));
|
|
}
|
|
// Refresh the file manager's right-click pin/unpin scripts, so they always
|
|
// match the installed `pdfs`.
|
|
pdfs_core::shell::install_file_manager_scripts();
|
|
if !AppDirs::new().is_ok_and(|dirs| dirs.load_config().tray_hidden) {
|
|
spawn_tray();
|
|
}
|
|
});
|
|
app.connect_activate(present_window);
|
|
app.connect_command_line(|app, command| {
|
|
let args: Vec<String> = command
|
|
.arguments()
|
|
.iter()
|
|
.skip(1)
|
|
.map(|arg| arg.to_string_lossy().into_owned())
|
|
.collect();
|
|
app.activate();
|
|
match parse_launch(&args) {
|
|
Launch::Window => {}
|
|
Launch::Page(page) => app.activate_action("show-page", Some(&page.to_variant())),
|
|
Launch::ConfirmStop => app.activate_action("confirm-stop", None),
|
|
}
|
|
glib::ExitCode::SUCCESS
|
|
});
|
|
app.run()
|
|
}
|
|
|
|
/// What a launch of `pdfs-app` asks for, beyond showing the window.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum Launch {
|
|
Window,
|
|
/// `--page <name>`: open on a stack page, such as "locations" for Sync.
|
|
Page(String),
|
|
/// `--confirm-stop`: ask whether to stop Proton Drive (from the tray).
|
|
ConfirmStop,
|
|
}
|
|
|
|
fn parse_launch(args: &[String]) -> Launch {
|
|
let mut args = args.iter();
|
|
while let Some(arg) = args.next() {
|
|
match arg.as_str() {
|
|
"--confirm-stop" => return Launch::ConfirmStop,
|
|
"--page" => {
|
|
if let Some(page) = args.next() {
|
|
return Launch::Page(page.clone());
|
|
}
|
|
}
|
|
other => {
|
|
if let Some(page) = other.strip_prefix("--page=") {
|
|
return Launch::Page(page.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Launch::Window
|
|
}
|
|
|
|
/// Raise the window if there is one, else build it. A second launch reaches
|
|
/// the running instance, which must not open a second window.
|
|
fn present_window(app: &adw::Application) {
|
|
match app.windows().first() {
|
|
Some(window) => window.present(),
|
|
None => build_window(app),
|
|
}
|
|
}
|
|
|
|
/// Spawn the tray icon process in the background.
|
|
pub(crate) fn spawn_tray() {
|
|
match Command::new("pdfs-tray").spawn() {
|
|
Ok(_) => tracing::info!("spawned `pdfs-tray`"),
|
|
Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"),
|
|
}
|
|
}
|
|
|
|
/// Register the bundled GResources (custom icons, the stylesheet) and load the
|
|
/// app stylesheet. The Proton accent is a separate provider, applied by
|
|
/// [`set_proton_accent`] from the saved preference.
|
|
fn load_proton_theme() {
|
|
let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource"));
|
|
let resource_data = glib::Bytes::from_static(bytes);
|
|
if let Ok(resource) = gio::Resource::from_data(&resource_data) {
|
|
gio::resources_register(&resource);
|
|
} else {
|
|
tracing::error!("failed to load gresource bundle");
|
|
}
|
|
|
|
let Some(display) = gtk4::gdk::Display::default() else {
|
|
return;
|
|
};
|
|
let provider = gtk4::CssProvider::new();
|
|
provider.load_from_resource("/de/nils/protondrivelinux/style.css");
|
|
gtk4::style_context_add_provider_for_display(
|
|
&display,
|
|
&provider,
|
|
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
|
);
|
|
gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons");
|
|
}
|
|
|
|
thread_local! {
|
|
/// The Proton purple accent provider while it is installed. Kept so turning
|
|
/// the preference off can remove exactly what turning it on added.
|
|
static PROTON_ACCENT: RefCell<Option<gtk4::CssProvider>> = const { RefCell::new(None) };
|
|
}
|
|
|
|
/// Paint the app in Proton purple (`on`) or follow the system accent colour.
|
|
/// Every accent-coloured widget reads `--accent-bg-color`, so overriding that
|
|
/// one variable recolours the whole app.
|
|
fn set_proton_accent(on: bool) {
|
|
let Some(display) = gtk4::gdk::Display::default() else {
|
|
return;
|
|
};
|
|
PROTON_ACCENT.with(|slot| {
|
|
let mut slot = slot.borrow_mut();
|
|
match (on, slot.as_ref()) {
|
|
(true, None) => {
|
|
let provider = gtk4::CssProvider::new();
|
|
provider
|
|
.load_from_string(&format!(":root {{ --accent-bg-color: {PROTON_PURPLE}; }}"));
|
|
gtk4::style_context_add_provider_for_display(
|
|
&display,
|
|
&provider,
|
|
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
|
|
);
|
|
*slot = Some(provider);
|
|
}
|
|
(false, Some(provider)) => {
|
|
gtk4::style_context_remove_provider_for_display(&display, provider);
|
|
*slot = None;
|
|
}
|
|
_ => {}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Build the application window, wire the two pages, kick off the refresh loop,
|
|
/// and present it.
|
|
fn build_window(app: &adw::Application) {
|
|
let dirs = match AppDirs::new() {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
tracing::error!("cannot resolve app dirs: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let stack = adw::ViewStack::new();
|
|
let (login_page, login_widgets) = build_login_page();
|
|
let main_widgets = build_main_page();
|
|
let (browser_page, browser_widgets) = build_browser_page();
|
|
let (gallery_page, gallery_widgets) = build_gallery_page();
|
|
let (shared_page, shared_widgets) = build_shared_page();
|
|
let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page();
|
|
let (devices_page, devices_widgets) = build_devices_page();
|
|
let (locations_page, locations_widgets) = build_locations_page();
|
|
let (activity_page, activity_widgets) = build_activity_page();
|
|
let (trash_page, trash_widgets) = build_trash_page();
|
|
let (takeout_page, takeout_widgets) = build_takeout_page();
|
|
// The login page has no title and no actions, but it still needs a header
|
|
// bar: it is the only thing carrying the window controls.
|
|
let login_frame = adw::ToolbarView::new();
|
|
login_frame.add_top_bar(&{
|
|
let header = adw::HeaderBar::new();
|
|
header.set_show_title(false);
|
|
header
|
|
});
|
|
login_frame.set_content(Some(&login_page));
|
|
stack.add_named(&login_frame, Some("login"));
|
|
stack.add_named(&browser_page, Some("browser"));
|
|
stack.add_named(&gallery_page, Some("gallery"));
|
|
stack.add_named(&shared_by_me_page, Some("sharedbyme"));
|
|
stack.add_named(&shared_page, Some("shared"));
|
|
stack.add_named(&devices_page, Some("devices"));
|
|
stack.add_named(&locations_page, Some("locations"));
|
|
stack.add_named(&activity_page, Some("activity"));
|
|
stack.add_named(&trash_page, Some("trash"));
|
|
stack.add_named(&takeout_page, Some("takeout"));
|
|
|
|
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
|
|
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
|
|
// login lands on Files).
|
|
let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer);
|
|
|
|
// Every page brings its own header bar (see `page_frame`), so the content
|
|
// side is the bare stack.
|
|
let content_page = adw::NavigationPage::builder()
|
|
.title("Proton Drive")
|
|
.child(&stack)
|
|
.build();
|
|
let split = adw::NavigationSplitView::builder()
|
|
.sidebar(&sidebar_page)
|
|
.content(&content_page)
|
|
.min_sidebar_width(200.0)
|
|
.max_sidebar_width(240.0)
|
|
.build();
|
|
|
|
// Toasts float over everything, so a report from a background action reaches
|
|
// the user whichever page they're on.
|
|
let toasts = adw::ToastOverlay::new();
|
|
toasts.set_child(Some(&split));
|
|
|
|
let ui = Rc::new(Ui {
|
|
dirs,
|
|
stack: stack.clone(),
|
|
toasts: toasts.clone(),
|
|
busy: Cell::new(0),
|
|
opening: RefCell::new(HashSet::new()),
|
|
session: RefCell::new(auth::load().ok()),
|
|
mounted: RefCell::new(false),
|
|
sidebar: sidebar_list.clone(),
|
|
nav: split.clone(),
|
|
file_thumbs: FileThumbnailState::new(),
|
|
login: login_widgets,
|
|
status: StatusState {
|
|
status_inflight: Cell::new(false),
|
|
transfers_group: locations_widgets.transfers_group.clone(),
|
|
prefs: main_widgets.prefs.clone(),
|
|
account_name: main_widgets.account_name.clone(),
|
|
avatar: main_widgets.avatar.clone(),
|
|
status_icon: main_widgets.status_icon.clone(),
|
|
status_title: main_widgets.status_title.clone(),
|
|
status_detail: main_widgets.status_detail.clone(),
|
|
transfer_rows: RefCell::new(Vec::new()),
|
|
transfers_inflight: Cell::new(false),
|
|
cache_bar: main_widgets.cache_bar.clone(),
|
|
cache_label: main_widgets.cache_label.clone(),
|
|
quota_box: main_widgets.quota_box.clone(),
|
|
quota_bar: main_widgets.quota_bar.clone(),
|
|
quota_label: main_widgets.quota_label.clone(),
|
|
quota_inflight: Cell::new(false),
|
|
quota_checked_at: Cell::new(None),
|
|
autostart_row: main_widgets.autostart_row.clone(),
|
|
budget_row: main_widgets.budget_row.clone(),
|
|
upload_limit_row: main_widgets.upload_limit_row.clone(),
|
|
download_limit_row: main_widgets.download_limit_row.clone(),
|
|
mountpoint_row: main_widgets.mountpoint_row.clone(),
|
|
accent_row: main_widgets.accent_row.clone(),
|
|
tray_row: main_widgets.tray_row.clone(),
|
|
settings_suppress: Cell::new(false),
|
|
budget_source: RefCell::new(None),
|
|
limit_source: RefCell::new(None),
|
|
pins_expanded: Cell::new(false),
|
|
pins_group: main_widgets.pins_group.clone(),
|
|
pin_rows: RefCell::new(Vec::new()),
|
|
pins_state: RefCell::new(None),
|
|
notified_mounted: Cell::new(None),
|
|
active_transfers: Cell::new(0),
|
|
},
|
|
browser: BrowserState {
|
|
model: browser_widgets.model.clone(),
|
|
history: RefCell::new(Vec::new()),
|
|
future: RefCell::new(Vec::new()),
|
|
crumb: browser_widgets.crumb.clone(),
|
|
content: browser_widgets.content.clone(),
|
|
status: browser_widgets.status.clone(),
|
|
retry: browser_widgets.retry.clone(),
|
|
split: browser_widgets.split.clone(),
|
|
path: RefCell::new(String::new()),
|
|
search: browser_widgets.search.clone(),
|
|
search_scope: browser_widgets.search_scope.clone(),
|
|
actions: browser_widgets.actions.clone(),
|
|
view_button: browser_widgets.view_button.clone(),
|
|
view: Cell::new(FilesView::default()),
|
|
listing: RefCell::new(Vec::new()),
|
|
build_thumbnails: browser_widgets.build_thumbnails.clone(),
|
|
thumbnail_build_row: browser_widgets.thumbnail_build_row.clone(),
|
|
thumbnail_progress: browser_widgets.thumbnail_progress.clone(),
|
|
thumbnail_status: browser_widgets.thumbnail_status.clone(),
|
|
thumbnail_poll: RefCell::new(None),
|
|
thumbnail_build_running: Cell::new(false),
|
|
thumbnail_cancel_pending: Cell::new(false),
|
|
search_source: RefCell::new(None),
|
|
load_generation: Cell::new(0),
|
|
views: browser_widgets.views.clone(),
|
|
column_view: browser_widgets.column_view.clone(),
|
|
bulk: browser_widgets.bulk.clone(),
|
|
bulk_label: browser_widgets.bulk_label.clone(),
|
|
bulk_trash: browser_widgets.bulk_trash.clone(),
|
|
bulk_pin: browser_widgets.bulk_pin.clone(),
|
|
bulk_unpin: browser_widgets.bulk_unpin.clone(),
|
|
bulk_move: browser_widgets.bulk_move.clone(),
|
|
empty_actions: browser_widgets.empty_actions.clone(),
|
|
summary: browser_widgets.summary.clone(),
|
|
zoom: browser_widgets.zoom.clone(),
|
|
grid_thumbnail_size: Cell::new(GRID_THUMB_DEFAULT),
|
|
grid_tiles: RefCell::new(Vec::new()),
|
|
quota_box: browser_widgets.quota_box.clone(),
|
|
quota: browser_widgets.quota.clone(),
|
|
quota_text: browser_widgets.quota_text.clone(),
|
|
},
|
|
details: DetailsState {
|
|
details: browser_widgets.details,
|
|
details_entry: RefCell::new(None),
|
|
details_suppress: Cell::new(false),
|
|
selection: browser_widgets.selection.clone(),
|
|
},
|
|
trash: TrashState {
|
|
model: trash_widgets.model.clone(),
|
|
content: trash_widgets.content.clone(),
|
|
status: trash_widgets.status.clone(),
|
|
retry: trash_widgets.retry.clone(),
|
|
empty: trash_widgets.empty.clone(),
|
|
subtitle: trash_widgets.subtitle.clone(),
|
|
selection: trash_widgets.selection.clone(),
|
|
selection_bar: trash_widgets.selection_bar.clone(),
|
|
selection_label: trash_widgets.selection_label.clone(),
|
|
},
|
|
gallery: GalleryState {
|
|
model: gallery_widgets.model.clone(),
|
|
groups: gallery_widgets.groups.clone(),
|
|
row_height: Cell::new(ROW_DEFAULT),
|
|
learned_ratios: RefCell::new(HashMap::new()),
|
|
scrolling_down: Cell::new(true),
|
|
scroll_offset: Cell::new(0.0),
|
|
assumed_ratios: RefCell::new(HashSet::new()),
|
|
content: gallery_widgets.content.clone(),
|
|
status: gallery_widgets.status.clone(),
|
|
retry: gallery_widgets.retry.clone(),
|
|
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),
|
|
import_banner: gallery_widgets.import_banner.clone(),
|
|
upload: gallery_widgets.upload.clone(),
|
|
import: gallery_widgets.import.clone(),
|
|
empty_actions: gallery_widgets.empty_actions.clone(),
|
|
title: gallery_widgets.title.clone(),
|
|
albums: gallery_widgets.albums.clone(),
|
|
albums_stack: gallery_widgets.albums_stack.clone(),
|
|
albums_status: gallery_widgets.albums_status.clone(),
|
|
photos_btn: gallery_widgets.photos_btn.clone(),
|
|
albums_btn: gallery_widgets.albums_btn.clone(),
|
|
view_switch: gallery_widgets.view_switch.clone(),
|
|
albums_loading: Cell::new(false),
|
|
album: RefCell::new(None),
|
|
timeline_stale: Cell::new(false),
|
|
album_list: RefCell::new(Vec::new()),
|
|
back: gallery_widgets.back.clone(),
|
|
filters: gallery_widgets.filters.clone(),
|
|
kind: Cell::new(None),
|
|
tabs: gallery_widgets.tabs.clone(),
|
|
counts: Cell::new(None),
|
|
dates: gallery_widgets.dates.clone(),
|
|
date_ranges: RefCell::new(vec![None]),
|
|
range: Cell::new(None),
|
|
favorites_btn: gallery_widgets.favorites_btn.clone(),
|
|
favorites: Cell::new(false),
|
|
date_suppress: Cell::new(false),
|
|
loading: Cell::new(false),
|
|
has_more: Cell::new(false),
|
|
page_waiters: RefCell::new(Vec::new()),
|
|
width: Cell::new(0),
|
|
photo_tex: RefCell::new(HashMap::new()),
|
|
photo_tex_order: RefCell::new(VecDeque::new()),
|
|
photo_nothumb: RefCell::new(HashSet::new()),
|
|
thumb_wanted: RefCell::new(HashMap::new()),
|
|
thumb_queue: RefCell::new(VecDeque::new()),
|
|
thumb_inflight: Cell::new(false),
|
|
decode_queue: RefCell::new(VecDeque::new()),
|
|
decode_idle: Cell::new(false),
|
|
thumb_source: RefCell::new(None),
|
|
relayout_source: RefCell::new(None),
|
|
bound: RefCell::new(BTreeMap::new()),
|
|
list: gallery_widgets.list.clone(),
|
|
selecting: Cell::new(false),
|
|
selected: RefCell::new(HashSet::new()),
|
|
select_btn: gallery_widgets.select_btn.clone(),
|
|
select_bar: gallery_widgets.select_bar.clone(),
|
|
select_label: gallery_widgets.select_label.clone(),
|
|
select_trash: gallery_widgets.select_trash.clone(),
|
|
select_album: gallery_widgets.select_album.clone(),
|
|
},
|
|
shared: SharedState {
|
|
content: shared_widgets.content.clone(),
|
|
status: shared_widgets.status.clone(),
|
|
retry: shared_widgets.retry.clone(),
|
|
with_me_group: shared_widgets.shared_with_me.clone(),
|
|
invitations_group: shared_widgets.invitations.clone(),
|
|
bookmarks_group: shared_widgets.bookmarks.clone(),
|
|
title: shared_widgets.title.clone(),
|
|
back: shared_widgets.back.clone(),
|
|
add_bookmark: shared_widgets.add_bookmark.clone(),
|
|
nav: RefCell::new(Vec::new()),
|
|
rows: RefCell::new(Vec::new()),
|
|
inflight: Cell::new(false),
|
|
loaded_at: Cell::new(None),
|
|
},
|
|
shared_by_me: SharedByMeState {
|
|
content: shared_by_me_widgets.content.clone(),
|
|
status: shared_by_me_widgets.status.clone(),
|
|
retry: shared_by_me_widgets.retry.clone(),
|
|
group: shared_by_me_widgets.group.clone(),
|
|
rows: RefCell::new(Vec::new()),
|
|
inflight: Cell::new(false),
|
|
loaded_at: Cell::new(None),
|
|
},
|
|
devices: DevicesState {
|
|
content: devices_widgets.content.clone(),
|
|
status: devices_widgets.status.clone(),
|
|
retry: devices_widgets.retry.clone(),
|
|
group: devices_widgets.group.clone(),
|
|
rows: RefCell::new(Vec::new()),
|
|
sync_group: devices_widgets.sync_group.clone(),
|
|
sync_rows: RefCell::new(Vec::new()),
|
|
rename_this: devices_widgets.rename_this.clone(),
|
|
this_device: RefCell::new(None),
|
|
inflight: Cell::new(false),
|
|
loaded_at: Cell::new(None),
|
|
},
|
|
locations: LocationsState {
|
|
content: locations_widgets.content.clone(),
|
|
status: locations_widgets.status.clone(),
|
|
retry: locations_widgets.retry.clone(),
|
|
group: locations_widgets.group.clone(),
|
|
rows: RefCell::new(Vec::new()),
|
|
inflight: Cell::new(false),
|
|
loaded_at: Cell::new(None),
|
|
card: SyncCard {
|
|
icon: locations_widgets.card_icon.clone(),
|
|
row: locations_widgets.card_row.clone(),
|
|
pause: locations_widgets.pause.clone(),
|
|
paused: Cell::new(false),
|
|
},
|
|
queue: QueueState {
|
|
group: locations_widgets.queue_group.clone(),
|
|
retry_all: locations_widgets.retry_all.clone(),
|
|
rows: RefCell::new(Vec::new()),
|
|
painted: RefCell::new(Vec::new()),
|
|
inflight: Cell::new(false),
|
|
},
|
|
conflicts: ConflictsState {
|
|
group: locations_widgets.conflicts_group.clone(),
|
|
rows: RefCell::new(Vec::new()),
|
|
painted: RefCell::new(Vec::new()),
|
|
inflight: Cell::new(false),
|
|
fetched_at: Cell::new(None),
|
|
},
|
|
},
|
|
activity: ActivityState {
|
|
content: activity_widgets.content.clone(),
|
|
status: activity_widgets.status.clone(),
|
|
retry: activity_widgets.retry.clone(),
|
|
attention: activity_widgets.attention.clone(),
|
|
attention_rows: RefCell::new(Vec::new()),
|
|
days: activity_widgets.days.clone(),
|
|
inflight: Cell::new(false),
|
|
key: RefCell::new(None),
|
|
items: RefCell::new(Vec::new()),
|
|
filter: Cell::new(ActivityFilter::All),
|
|
conflicts: RefCell::new(None),
|
|
conflicts_inflight: Cell::new(false),
|
|
conflicts_at: Cell::new(None),
|
|
},
|
|
takeout: TakeoutState {
|
|
archives: RefCell::new(Vec::new()),
|
|
list_group: takeout_widgets.list_group.clone(),
|
|
rows: RefCell::new(Vec::new()),
|
|
dropzone: takeout_widgets.dropzone.clone(),
|
|
scan_button: takeout_widgets.scan_button.clone(),
|
|
import_button: takeout_widgets.import_button.clone(),
|
|
cancel_button: takeout_widgets.cancel_button.clone(),
|
|
clear_button: takeout_widgets.clear_button.clone(),
|
|
progress_group: takeout_widgets.progress_group.clone(),
|
|
progress_label: takeout_widgets.progress_label.clone(),
|
|
progress_bar: takeout_widgets.progress_bar.clone(),
|
|
summary_group: takeout_widgets.summary_group.clone(),
|
|
summary_rows: RefCell::new(Vec::new()),
|
|
inflight: Cell::new(false),
|
|
running: Cell::new(false),
|
|
dry_run: Cell::new(false),
|
|
},
|
|
});
|
|
wire_login(&ui);
|
|
let ui_status = ui.clone();
|
|
main_widgets
|
|
.status_button
|
|
.connect_clicked(move |_| ui_status.stack.set_visible_child_name("locations"));
|
|
wire_settings(
|
|
&ui,
|
|
&main_widgets.purge_button,
|
|
&main_widgets.mountpoint_button,
|
|
);
|
|
wire_sidebar(&ui);
|
|
wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view);
|
|
wire_bulk(
|
|
&ui,
|
|
&browser_widgets.bulk_clear,
|
|
&browser_widgets.empty_upload,
|
|
&browser_widgets.empty_new_folder,
|
|
);
|
|
wire_browser_actions(&ui, &browser_widgets.build_thumbnails);
|
|
wire_details(&ui);
|
|
wire_search(&ui);
|
|
wire_gallery(
|
|
&ui,
|
|
&gallery_widgets.list,
|
|
&gallery_widgets.scroll,
|
|
&gallery_widgets.select_done,
|
|
);
|
|
wire_gallery_empty(
|
|
&ui,
|
|
&gallery_widgets.empty_upload,
|
|
&gallery_widgets.empty_import,
|
|
);
|
|
wire_albums(&ui);
|
|
wire_trash(&ui, &trash_widgets);
|
|
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
|
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
|
wire_devices(&ui, &devices_widgets.retry);
|
|
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
|
wire_activity(&ui, &activity_widgets.retry, &activity_widgets.filters);
|
|
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,
|
|
&activity_widgets.refresh,
|
|
],
|
|
);
|
|
wire_retry(&ui);
|
|
|
|
// Lazily load the Files / Photos pages the first time they're shown, so the
|
|
// network round-trip only happens on demand rather than on every refresh.
|
|
let ui_nav = ui.clone();
|
|
stack.connect_visible_child_name_notify(move |st| {
|
|
// Rows from the page being left must not keep full-size image downloads
|
|
// alive in the daemon. The page being entered establishes a fresh
|
|
// thumbnail generation as it paints.
|
|
cancel_file_thumbnails(&ui_nav);
|
|
sync_sidebar(&ui_nav);
|
|
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) => {}
|
|
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") => {
|
|
refresh_conflicts(&ui_nav, true);
|
|
if !page_fresh(&ui_nav.locations.loaded_at) {
|
|
load_locations(&ui_nav);
|
|
}
|
|
}
|
|
// Activity is intentionally not TTL-cached: it changes out from under
|
|
// the page as background uploads and edits complete, so it reloads on
|
|
// every visit to stay live.
|
|
Some("activity") => load_activity(&ui_nav),
|
|
Some("trash") => load_trash(&ui_nav),
|
|
// The import runs in the daemon and outlives this page, so arriving
|
|
// here asks straight away whether one is in flight.
|
|
Some("takeout") => refresh_takeout(&ui_nav),
|
|
_ => {}
|
|
}
|
|
});
|
|
|
|
let window = adw::ApplicationWindow::builder()
|
|
.application(app)
|
|
.title("Proton Drive")
|
|
.default_width(980)
|
|
.default_height(680)
|
|
.content(&toasts)
|
|
.build();
|
|
// A wide window docks the details pane beside the files instead of laying it
|
|
// over them.
|
|
let wide = adw::Breakpoint::new(
|
|
adw::BreakpointCondition::parse("min-width: 1000sp").expect("static breakpoint"),
|
|
);
|
|
wide.add_setter(&ui.browser.split, "collapsed", Some(&false.to_value()));
|
|
window.add_breakpoint(wide);
|
|
install_shortcuts(&ui, &window);
|
|
install_window_actions(&ui, &window);
|
|
install_launch_actions(&ui, app, &window);
|
|
app.set_accels_for_action("win.preferences", &["<Control>comma"]);
|
|
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.
|
|
let ui_tick = ui.clone();
|
|
let source = glib::timeout_add_local(REFRESH_INTERVAL, move || {
|
|
refresh(&ui_tick);
|
|
glib::ControlFlow::Continue
|
|
});
|
|
let cell = RefCell::new(Some(source));
|
|
window.connect_close_request(move |_| {
|
|
if let Some(id) = cell.borrow_mut().take() {
|
|
id.remove();
|
|
}
|
|
glib::Propagation::Proceed
|
|
});
|
|
|
|
window.present();
|
|
}
|
|
|
|
/// The sidebar destinations, in order: the row index is the index into this table,
|
|
/// and each entry is `(stack page name, label, icon)`. The places in the account
|
|
/// come first; the rows from [`SIDEBAR_SYNC_SECTION`] on are about this computer's
|
|
/// sync, set off by a separator.
|
|
const DESTINATIONS: [(&str, &str, &str); 8] = [
|
|
("browser", "My files", "folder-symbolic"),
|
|
("gallery", "Photos", "image-x-generic-symbolic"),
|
|
("shared", "Shared with me", "system-users-symbolic"),
|
|
("sharedbyme", "Shared by me", "emblem-shared-symbolic"),
|
|
("devices", "Computers", "computer-symbolic"),
|
|
("trash", "Trash", "user-trash-symbolic"),
|
|
("locations", "Sync", "emblem-synchronizing-symbolic"),
|
|
("activity", "Activity", "document-open-recent-symbolic"),
|
|
];
|
|
|
|
/// Index of the first row in the sidebar's sync section.
|
|
const SIDEBAR_SYNC_SECTION: i32 = 6;
|
|
|
|
/// The navigation sidebar: the destinations over a footer carrying the sync
|
|
/// status, the account quota and the account menu. Returns the page (for the
|
|
/// split view) and the list (to drive + reflect the current page).
|
|
fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
|
let list = gtk4::ListBox::new();
|
|
list.set_selection_mode(gtk4::SelectionMode::Single);
|
|
list.add_css_class("navigation-sidebar");
|
|
for (_, label, icon) in DESTINATIONS {
|
|
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
|
row_box.append(>k4::Image::from_icon_name(icon));
|
|
row_box.append(>k4::Label::new(Some(label)));
|
|
let row = gtk4::ListBoxRow::builder().child(&row_box).build();
|
|
list.append(&row);
|
|
}
|
|
list.set_header_func(|row, _| {
|
|
if row.index() == SIDEBAR_SYNC_SECTION {
|
|
let separator = gtk4::Separator::new(gtk4::Orientation::Horizontal);
|
|
separator.set_margin_top(6);
|
|
separator.set_margin_bottom(6);
|
|
row.set_header(Some(&separator));
|
|
} else {
|
|
row.set_header(gtk4::Widget::NONE);
|
|
}
|
|
});
|
|
|
|
let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
let icon = gtk4::Image::from_icon_name("folder-remote-symbolic");
|
|
icon.add_css_class("brand-icon");
|
|
brand.append(&icon);
|
|
let name = gtk4::Label::new(Some("Proton Drive"));
|
|
name.add_css_class("heading");
|
|
brand.append(&name);
|
|
|
|
let header = adw::HeaderBar::new();
|
|
header.set_title_widget(Some(&brand));
|
|
header.pack_end(&build_primary_menu());
|
|
|
|
let scroll = gtk4::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
|
.vexpand(true)
|
|
.child(&list)
|
|
.build();
|
|
let toolbar = adw::ToolbarView::new();
|
|
toolbar.add_top_bar(&header);
|
|
toolbar.set_content(Some(&scroll));
|
|
toolbar.add_bottom_bar(footer);
|
|
|
|
let page = adw::NavigationPage::builder()
|
|
.title("Proton Drive")
|
|
.child(&toolbar)
|
|
.build();
|
|
(page, list)
|
|
}
|
|
|
|
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()) };
|
|
}
|
|
|
|
fn set_busy_spinners(visible: bool) {
|
|
BUSY_SPINNERS.with(|spinners| {
|
|
for spinner in spinners.borrow().iter() {
|
|
spinner.set_visible(visible);
|
|
spinner.set_spinning(visible);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// A destination page's frame: its own header bar over the page content, the
|
|
/// way GNOME apps lay a split view out. The title sits in the middle with the
|
|
/// busy spinner beside it; the page packs its actions into the returned header
|
|
/// bar and may give the returned title a subtitle.
|
|
fn page_frame(
|
|
title: &str,
|
|
content: &impl IsA<gtk4::Widget>,
|
|
) -> (adw::ToolbarView, adw::HeaderBar, adw::WindowTitle) {
|
|
let window_title = adw::WindowTitle::new(title, "");
|
|
let spinner = gtk4::Spinner::new();
|
|
spinner.set_visible(false);
|
|
BUSY_SPINNERS.with(|spinners| spinners.borrow_mut().push(spinner.clone()));
|
|
let title_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
|
title_box.append(&window_title);
|
|
title_box.append(&spinner);
|
|
|
|
let header = adw::HeaderBar::new();
|
|
header.set_title_widget(Some(&title_box));
|
|
let toolbar = adw::ToolbarView::new();
|
|
toolbar.add_top_bar(&header);
|
|
toolbar.set_content(Some(content));
|
|
(toolbar, header, window_title)
|
|
}
|
|
|
|
/// The sidebar destination a page belongs under. Most pages are their own
|
|
/// destination; a sub-page reached from one (Import, off Photos) has no row of
|
|
/// its own and answers with its parent, so the sidebar highlights where the user
|
|
/// came from rather than clearing — which would read as "nowhere".
|
|
fn destination_of(page: &str) -> &str {
|
|
match page {
|
|
"takeout" => "gallery",
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
/// Selecting a sidebar row navigates the page stack. The reverse direction (stack
|
|
/// → sidebar highlight) is [`sync_sidebar`], so the two can't fight: this handler
|
|
/// only ever writes the stack.
|
|
fn wire_sidebar(ui: &Rc<Ui>) {
|
|
let ui_row = ui.clone();
|
|
ui.sidebar.connect_row_selected(move |_, row| {
|
|
let Some(row) = row else { return };
|
|
let Some((page, _, _)) = DESTINATIONS.get(row.index() as usize) else {
|
|
return;
|
|
};
|
|
// Compared by *destination*, not by page: while a sub-page is open its
|
|
// parent's row is the selected one, and re-asserting it here would kick
|
|
// the user off the sub-page the moment they arrived.
|
|
let current = ui_row.stack.visible_child_name();
|
|
if current.as_deref().map(destination_of) != Some(*page) {
|
|
ui_row.stack.set_visible_child_name(page);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 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("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.
|
|
fn reload_current_page(ui: &Rc<Ui>) {
|
|
match ui.stack.visible_child_name().as_deref() {
|
|
Some("browser") => {
|
|
let path = ui.browser.path.borrow().clone();
|
|
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
|
}
|
|
// One scope covers the whole photos view, so Refresh reloads whichever of
|
|
// the two — album grid or timeline/album — is actually on screen.
|
|
Some("gallery") => refresh_then(ui, RefreshScope::Photos { full: false }, |ui| {
|
|
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
|
|
load_albums(ui);
|
|
} else {
|
|
load_gallery(ui, false);
|
|
}
|
|
}),
|
|
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
|
|
Some("shared") => {
|
|
ui.shared.loaded_at.set(None);
|
|
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);
|
|
load_locations(ui);
|
|
}
|
|
Some("activity") => load_activity(ui),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Drop a daemon-side cached listing, then run the page's loader to re-fetch it.
|
|
///
|
|
/// The loader runs even when the invalidation failed: it is the loader that knows
|
|
/// how to report an unreachable daemon on its own page, and a refresh that fails
|
|
/// silently would read as a dead button.
|
|
fn refresh_then(ui: &Rc<Ui>, scope: RefreshScope, load: fn(&Rc<Ui>)) {
|
|
// A photos refresh answers as soon as it has started, because re-reading a
|
|
// large library takes minutes. The page is therefore loaded twice: once from
|
|
// what the daemon already holds, and again when the refresh has landed.
|
|
let follow = matches!(scope, RefreshScope::Photos { .. });
|
|
let rx = spawn_request(ui.dirs.control_socket(), Request::Refresh { scope });
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let _ = rx.recv().await;
|
|
load(&ui);
|
|
if follow {
|
|
follow_photos_refresh(&ui, load);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Watch the timeline refresh the daemon just started, and load the page again
|
|
/// when it finishes.
|
|
///
|
|
/// Giving up after [`PHOTOS_REFRESH_FOLLOW_LIMIT`] is not a failure: the refresh
|
|
/// keeps running in the daemon and the next visit to the page shows its result.
|
|
/// It only stops this front-end from polling a daemon forever.
|
|
fn follow_photos_refresh(ui: &Rc<Ui>, load: fn(&Rc<Ui>)) {
|
|
let ui = ui.clone();
|
|
let give_up_at = Instant::now() + PHOTOS_REFRESH_FOLLOW_LIMIT;
|
|
glib::spawn_future_local(async move {
|
|
loop {
|
|
glib::timeout_future(PHOTOS_REFRESH_POLL).await;
|
|
// The person may have left the gallery in the meantime; reloading a
|
|
// page nobody is looking at would only cost the daemon work.
|
|
if ui.stack.visible_child_name().as_deref() != Some("gallery") {
|
|
return;
|
|
}
|
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotosRefreshStatus);
|
|
match rx.recv().await {
|
|
Ok(Ok(Response::PhotosRefresh { running: true })) => {
|
|
if Instant::now() >= give_up_at {
|
|
return;
|
|
}
|
|
}
|
|
Ok(Ok(Response::PhotosRefresh { running: false })) => {
|
|
load(&ui);
|
|
return;
|
|
}
|
|
// An older daemon does not know the request, and anything else
|
|
// means we cannot tell — either way, stop asking.
|
|
_ => return,
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Highlight the sidebar row for whichever page the stack is showing, so
|
|
/// navigation that doesn't start in the sidebar (login landing on Files, the tray
|
|
/// raising the window) still moves the selection.
|
|
fn sync_sidebar(ui: &Rc<Ui>) {
|
|
let Some(current) = ui.stack.visible_child_name() else {
|
|
return;
|
|
};
|
|
let current = destination_of(¤t);
|
|
let index = DESTINATIONS
|
|
.iter()
|
|
.position(|(page, _, _)| *page == current);
|
|
match index.and_then(|i| ui.sidebar.row_at_index(i as i32)) {
|
|
Some(row) => {
|
|
if ui.sidebar.selected_row().as_ref() != Some(&row) {
|
|
ui.sidebar.select_row(Some(&row));
|
|
}
|
|
}
|
|
// The login page has no destination row.
|
|
None => ui.sidebar.unselect_all(),
|
|
}
|
|
}
|
|
|
|
/// "1 item" / "5 items": a count with the noun form that agrees with it, so
|
|
/// no user-facing string has to fall back to "item(s)".
|
|
fn count_noun(n: usize, one: &str, many: &str) -> String {
|
|
format!("{n} {}", if n == 1 { one } else { many })
|
|
}
|
|
|
|
/// Show a transient toast. Non-blocking by design: an action's outcome is
|
|
/// reported without stealing focus or forcing a click, so the user can keep
|
|
/// working while a slow upload lands.
|
|
fn toast(ui: &Rc<Ui>, message: &str) {
|
|
ui.toasts.add_toast(adw::Toast::new(message));
|
|
}
|
|
|
|
/// Show a toast that offers one follow-up action — an Undo, or a way to the page
|
|
/// the outcome landed on.
|
|
///
|
|
/// Given a longer timeout than a plain toast: an action the user has to notice,
|
|
/// read and reach is not the same ask as a line of confirmation they can ignore.
|
|
fn toast_action(ui: &Rc<Ui>, message: &str, label: &str, action: impl Fn(&Rc<Ui>) + 'static) {
|
|
let toast = adw::Toast::builder()
|
|
.title(message)
|
|
.button_label(label)
|
|
.timeout(8)
|
|
.build();
|
|
let ui_action = ui.clone();
|
|
toast.connect_button_clicked(move |_| action(&ui_action));
|
|
ui.toasts.add_toast(toast);
|
|
}
|
|
|
|
/// Show a toast for a failure. Same surface as [`toast`], but the message is
|
|
/// prefixed with what was being attempted, since a bare daemon error ("no such
|
|
/// file") reads as noise without it.
|
|
fn toast_error(ui: &Rc<Ui>, what: &str, detail: &str) {
|
|
let detail = detail.trim();
|
|
let message = if detail.is_empty() {
|
|
what.to_string()
|
|
} else {
|
|
format!("{what}: {detail}")
|
|
};
|
|
tracing::warn!("{message}");
|
|
let toast = adw::Toast::builder().title(&message).timeout(6).build();
|
|
ui.toasts.add_toast(toast);
|
|
}
|
|
|
|
/// Headline for a failed request, chosen from its [`ErrorKind`] rather than from
|
|
/// the text the daemon happened to assemble.
|
|
///
|
|
/// The daemon's prose names the layer that failed (`"enumerate nodes: …"`), which
|
|
/// is right for a log and wrong for a user. `kind` is the part that says what the
|
|
/// person in front of the screen should understand, so the headline comes from it
|
|
/// and the prose is demoted to the detail line.
|
|
///
|
|
/// `fallback` is the caller's own description of the action ("Couldn't rename"),
|
|
/// used where the class carries no better wording than the caller already has.
|
|
fn error_headline(kind: ErrorKind, fallback: &str) -> &str {
|
|
match kind {
|
|
ErrorKind::Offline => "You're offline",
|
|
ErrorKind::NotFound => "That's not there any more",
|
|
ErrorKind::Denied => "You don't have access to that",
|
|
ErrorKind::Conflict => "Something changed this first",
|
|
ErrorKind::Quota => "Your Proton Drive is full",
|
|
ErrorKind::Invalid | ErrorKind::Remote | ErrorKind::Internal => fallback,
|
|
}
|
|
}
|
|
|
|
/// Report a failed request, letting its class pick the wording.
|
|
///
|
|
/// Prefer this to [`toast_error`] anywhere a [`Response::Error`] is being shown:
|
|
/// being offline is the common case and deserves to read as a state of the
|
|
/// network rather than as a fault in whatever the user just did.
|
|
fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
|
|
match kind {
|
|
// The detail here is always some inner layer's EIO. Nothing in it helps.
|
|
ErrorKind::Offline => toast_error(ui, error_headline(kind, what), ""),
|
|
_ => toast_error(ui, error_headline(kind, what), message),
|
|
}
|
|
}
|
|
|
|
/// The sidebar's primary (hamburger) menu: the app-level entries that don't
|
|
/// belong on any one page.
|
|
fn build_primary_menu() -> gtk4::MenuButton {
|
|
let menu = gio::Menu::new();
|
|
menu.append(Some("Preferences"), Some("win.preferences"));
|
|
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
|
|
menu.append(Some("About Proton Drive for Linux"), Some("win.about"));
|
|
gtk4::MenuButton::builder()
|
|
.icon_name("open-menu-symbolic")
|
|
.tooltip_text("Main menu")
|
|
.primary(true)
|
|
.menu_model(&menu)
|
|
.build()
|
|
}
|
|
|
|
/// Back the primary and account menus' entries with window actions.
|
|
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|
let preferences = gio::SimpleAction::new("preferences", None);
|
|
let ui_prefs = ui.clone();
|
|
let win = window.clone();
|
|
preferences.connect_activate(move |_, _| {
|
|
// Signed out there is nothing to configure: every page needs a session.
|
|
if ui_prefs.session.borrow().is_some() {
|
|
ui_prefs.status.prefs.present(Some(&win));
|
|
}
|
|
});
|
|
window.add_action(&preferences);
|
|
|
|
let sign_out_action = gio::SimpleAction::new("sign-out", None);
|
|
let ui_out = ui.clone();
|
|
sign_out_action.connect_activate(move |_, _| sign_out(&ui_out));
|
|
window.add_action(&sign_out_action);
|
|
|
|
// The Photos page's import banner leads here.
|
|
let show_import = gio::SimpleAction::new("show-import", None);
|
|
let ui_import = ui.clone();
|
|
show_import.connect_activate(move |_, _| ui_import.stack.set_visible_child_name("takeout"));
|
|
window.add_action(&show_import);
|
|
|
|
// The Albums view's New Album button.
|
|
let new_album = gio::SimpleAction::new("new-album", None);
|
|
let ui_album = ui.clone();
|
|
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new()));
|
|
window.add_action(&new_album);
|
|
|
|
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
|
let win = window.clone();
|
|
shortcuts.connect_activate(move |_, _| show_shortcuts(&win));
|
|
window.add_action(&shortcuts);
|
|
|
|
let about = gio::SimpleAction::new("about", None);
|
|
let win = window.clone();
|
|
let ui_about = ui.clone();
|
|
about.connect_activate(move |_, _| {
|
|
let dialog = adw::AboutDialog::builder()
|
|
.application_name("Proton Drive for Linux")
|
|
.application_icon("io.narl.proton-drive-linux")
|
|
.version(pdfs_core::config::APP_VERSION)
|
|
.developer_name("Nils Pukropp")
|
|
.website("https://github.com/narrrl/proton-drive-linux")
|
|
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
|
|
.license_type(gtk4::License::MitX11)
|
|
.comments(
|
|
"Files-on-demand Proton Drive for the Linux desktop.\n\n\
|
|
Unofficial client — not affiliated with, endorsed by, or supported by Proton AG.",
|
|
)
|
|
.debug_info(debug_info(&ui_about))
|
|
.debug_info_filename("proton-drive-linux-debug.txt")
|
|
.build();
|
|
dialog.present(Some(&win));
|
|
});
|
|
window.add_action(&about);
|
|
}
|
|
|
|
/// Back the launch arguments (see [`Launch`]) with app actions, so a running
|
|
/// instance can act on them.
|
|
fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::ApplicationWindow) {
|
|
let show_page = gio::SimpleAction::new("show-page", Some(glib::VariantTy::STRING));
|
|
let ui_page = ui.clone();
|
|
show_page.connect_activate(move |_, param| {
|
|
let Some(page) = param.and_then(|p| p.str()) else {
|
|
return;
|
|
};
|
|
// Signed out, the login page owns the window; unknown names are ignored.
|
|
if ui_page.session.borrow().is_some() && ui_page.stack.child_by_name(page).is_some() {
|
|
ui_page.stack.set_visible_child_name(page);
|
|
}
|
|
});
|
|
app.add_action(&show_page);
|
|
|
|
let confirm_stop = gio::SimpleAction::new("confirm-stop", None);
|
|
let win = window.clone();
|
|
let app_stop = app.clone();
|
|
let ui_stop = ui.clone();
|
|
confirm_stop.connect_activate(move |_, _| {
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading("Stop Proton Drive?")
|
|
.body(
|
|
"The drive unmounts and nothing syncs until you connect again. \
|
|
Proton Drive starts again at your next login.",
|
|
)
|
|
.build();
|
|
dialog.add_response("cancel", "Cancel");
|
|
dialog.add_response("stop", "Stop");
|
|
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
|
|
dialog.set_default_response(Some("cancel"));
|
|
dialog.set_close_response("cancel");
|
|
let app = app_stop.clone();
|
|
let ui = ui_stop.clone();
|
|
dialog.connect_response(Some("stop"), move |_, _| {
|
|
let app = app.clone();
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let stopped = gio::spawn_blocking(service::stop).await.unwrap_or(false);
|
|
if stopped {
|
|
pdfs_core::tray::quit(&ui.dirs);
|
|
app.quit();
|
|
} else {
|
|
toast_error(
|
|
&ui,
|
|
"Couldn't stop Proton Drive",
|
|
"The mount service did not stop.",
|
|
);
|
|
}
|
|
});
|
|
});
|
|
dialog.present(Some(&win));
|
|
});
|
|
app.add_action(&confirm_stop);
|
|
}
|
|
|
|
/// The About dialog's Troubleshooting text: what a bug report needs and what
|
|
/// used to sit in a "Developer" group on the Settings page.
|
|
fn debug_info(ui: &Rc<Ui>) -> String {
|
|
let config = ui.dirs.load_config();
|
|
format!(
|
|
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
|
|
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
|
|
pdfs_core::config::APP_VERSION,
|
|
pdfs_core::config::USER_AGENT,
|
|
adw::major_version(),
|
|
adw::minor_version(),
|
|
adw::micro_version(),
|
|
gtk4::major_version(),
|
|
gtk4::minor_version(),
|
|
gtk4::micro_version(),
|
|
ui.dirs.resolved_mountpoint(&config).display(),
|
|
ui.dirs.control_socket().display(),
|
|
if *ui.mounted.borrow() { "yes" } else { "no" },
|
|
)
|
|
}
|
|
|
|
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
|
|
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
|
|
fn show_shortcuts(window: &adw::ApplicationWindow) {
|
|
const GROUPS: [(&str, &[(&str, &str)]); 4] = [
|
|
(
|
|
"General",
|
|
&[
|
|
("<Control>f", "Search Drive"),
|
|
("F5", "Refresh"),
|
|
("<Control>comma", "Preferences"),
|
|
("<Control>question", "Keyboard shortcuts"),
|
|
],
|
|
),
|
|
(
|
|
"Files",
|
|
&[
|
|
("<Control>n", "New folder"),
|
|
("<Control>u", "Upload files"),
|
|
("<Alt>Left <Alt>Right", "Back / forward"),
|
|
("<Alt>Up", "Parent folder"),
|
|
("<Control>1 <Control>2", "Grid / list"),
|
|
("F2", "Rename"),
|
|
("Delete", "Move to Trash"),
|
|
("Escape", "Clear the selection"),
|
|
],
|
|
),
|
|
(
|
|
"Photos",
|
|
&[
|
|
("<Control>plus", "Larger thumbnails"),
|
|
("<Control>minus", "Smaller thumbnails"),
|
|
("<Control>0", "Reset thumbnail size"),
|
|
("<Control>a", "Select all"),
|
|
],
|
|
),
|
|
(
|
|
"Photo Viewer",
|
|
&[
|
|
("Left Right", "Previous / next photo"),
|
|
("Home End", "First / last photo"),
|
|
("i", "Show details"),
|
|
("f", "Fullscreen"),
|
|
("Delete", "Move to Trash"),
|
|
("Escape", "Close"),
|
|
],
|
|
),
|
|
];
|
|
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);
|
|
}
|
|
|
|
let dialog = adw::Dialog::builder()
|
|
.title("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();
|
|
dialog.present(Some(window));
|
|
}
|
|
|
|
/// Window-level keyboard shortcuts, so the browser is usable without the mouse:
|
|
/// Ctrl+F focuses search, Ctrl+N makes a folder, Ctrl+U uploads, F2 renames and
|
|
/// Delete trashes the selected entry, Escape closes the details pane.
|
|
fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|
let controller = gtk4::EventControllerKey::new();
|
|
let ui = ui.clone();
|
|
controller.connect_key_pressed(move |_, key, _, state| {
|
|
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
|
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
|
let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
|
|
match key {
|
|
// Refresh works on every page, so it is matched before the
|
|
// browser-only bindings.
|
|
gtk4::gdk::Key::F5 => reload_current_page(&ui),
|
|
gtk4::gdk::Key::r | gtk4::gdk::Key::R if ctrl => reload_current_page(&ui),
|
|
// Search lives on Files, but the shortcut works from anywhere:
|
|
// wanting to find a file is not a reason to first have to remember
|
|
// which page owns the search box.
|
|
gtk4::gdk::Key::f | gtk4::gdk::Key::F if ctrl => {
|
|
if !on_browser {
|
|
ui.stack.set_visible_child_name("browser");
|
|
}
|
|
ui.browser.search.grab_focus();
|
|
}
|
|
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
|
|
gtk4::gdk::Key::_1 if ctrl && on_browser => set_files_view(
|
|
&ui,
|
|
FilesView {
|
|
list: false,
|
|
..ui.browser.view.get()
|
|
},
|
|
),
|
|
gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view(
|
|
&ui,
|
|
FilesView {
|
|
list: true,
|
|
..ui.browser.view.get()
|
|
},
|
|
),
|
|
gtk4::gdk::Key::Left if alt && on_browser => {
|
|
ui.browser.actions.activate_action("back", None)
|
|
}
|
|
gtk4::gdk::Key::Right if alt && on_browser => {
|
|
ui.browser.actions.activate_action("forward", None)
|
|
}
|
|
gtk4::gdk::Key::Up if alt && on_browser => {
|
|
ui.browser.actions.activate_action("up", None)
|
|
}
|
|
// A focused text field takes Backspace first, so this only fires
|
|
// with the focus on the files, as in a file manager.
|
|
gtk4::gdk::Key::BackSpace if on_browser => {
|
|
ui.browser.actions.activate_action("back", None)
|
|
}
|
|
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
|
|
gtk4::gdk::Key::Menu if on_browser => popup_keyboard_context_menu(&ui),
|
|
gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter if alt && on_browser => {
|
|
toggle_details(&ui)
|
|
}
|
|
gtk4::gdk::Key::F10
|
|
if on_browser && state.contains(gtk4::gdk::ModifierType::SHIFT_MASK) =>
|
|
{
|
|
popup_keyboard_context_menu(&ui)
|
|
}
|
|
gtk4::gdk::Key::F2 if on_browser => {
|
|
// Renaming is one name at a time; saying so beats a key that
|
|
// silently does nothing with several items highlighted.
|
|
if selected_entries(&ui).len() > 1 {
|
|
toast(&ui, "Select a single item to rename it");
|
|
} else if let Some(entry) = selected_entry(&ui) {
|
|
prompt_rename(&ui, &entry);
|
|
}
|
|
}
|
|
// Acts on the whole selection: Delete with five files highlighted
|
|
// means those five, not whichever one the details pane happens to
|
|
// be describing.
|
|
gtk4::gdk::Key::Delete if on_browser => {
|
|
let entries = selected_entries(&ui);
|
|
if !entries.is_empty() {
|
|
trash_entries(&ui, entries);
|
|
}
|
|
}
|
|
gtk4::gdk::Key::Escape
|
|
if on_browser
|
|
&& (ui.browser.split.shows_sidebar() || ui.browser.bulk.reveals_child()) =>
|
|
{
|
|
clear_selection(&ui);
|
|
sync_bulk_bar(&ui);
|
|
clear_details(&ui);
|
|
ui.details.details.toggle.set_active(false);
|
|
}
|
|
_ => return glib::Propagation::Proceed,
|
|
}
|
|
glib::Propagation::Stop
|
|
});
|
|
window.add_controller(controller);
|
|
}
|
|
|
|
/// Send a desktop notification through the app's GIO channel. `id` replaces any
|
|
/// earlier notification with the same id, so a flapping mount updates one
|
|
/// notification instead of stacking a column of them.
|
|
fn notify(id: &str, title: &str, body: &str) {
|
|
let Some(app) = gio::Application::default() else {
|
|
return;
|
|
};
|
|
let notification = gio::Notification::new(title);
|
|
notification.set_body(Some(body));
|
|
notification.set_priority(gio::NotificationPriority::Low);
|
|
app.send_notification(Some(id), ¬ification);
|
|
}
|
|
|
|
/// Run one blocking control-socket round-trip on a worker thread, returning a
|
|
/// channel that yields the [`Response`] once. Browser/gallery requests reach the
|
|
/// network through the daemon, so they must not block the GTK main loop the way
|
|
/// the cheap [`Request::Status`] poll in [`refresh`] can.
|
|
fn spawn_request(
|
|
socket: PathBuf,
|
|
req: Request,
|
|
) -> async_channel::Receiver<Result<Response, String>> {
|
|
let (tx, rx) = async_channel::bounded(1);
|
|
std::thread::spawn(move || {
|
|
let result = send(&socket, &req).map_err(|e| e.to_string());
|
|
let _ = tx.send_blocking(result);
|
|
});
|
|
rx
|
|
}
|
|
|
|
/// The top-level window, for parenting dialogs.
|
|
fn ui_window(ui: &Rc<Ui>) -> Option<gtk4::Window> {
|
|
ui.stack.root().and_downcast::<gtk4::Window>()
|
|
}
|
|
|
|
/// Ask before an action that has no Undo. `on_confirm` runs only when the user
|
|
/// picks `action`; Cancel is the default, so Enter never destroys anything.
|
|
fn confirm_destructive(
|
|
parent: &impl IsA<gtk4::Widget>,
|
|
heading: &str,
|
|
body: &str,
|
|
action: &str,
|
|
on_confirm: impl Fn() + 'static,
|
|
) {
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading(heading)
|
|
.body(body)
|
|
.build();
|
|
dialog.add_response("cancel", "Cancel");
|
|
dialog.add_response("confirm", action);
|
|
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Destructive);
|
|
dialog.set_default_response(Some("cancel"));
|
|
dialog.set_close_response("cancel");
|
|
dialog.connect_response(Some("confirm"), move |_, _| on_confirm());
|
|
dialog.present(Some(parent));
|
|
}
|
|
|
|
/// A dim, non-interactive placeholder row for an empty section.
|
|
fn dim_row(text: &str) -> adw::ActionRow {
|
|
let row = adw::ActionRow::builder().title(text).build();
|
|
row.add_css_class("dim-label");
|
|
row.set_activatable(false);
|
|
row
|
|
}
|
|
|
|
/// Open a URL in the user's default browser.
|
|
fn open_uri(url: &str) {
|
|
if let Err(e) = gio::AppInfo::launch_default_for_uri(url, None::<&gio::AppLaunchContext>) {
|
|
tracing::warn!("open uri {url}: {e}");
|
|
}
|
|
}
|
|
|
|
/// Uppercase the first character of a role word for read-only display.
|
|
fn capitalize(s: &str) -> String {
|
|
let mut chars = s.chars();
|
|
match chars.next() {
|
|
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
/// Open a local path with the handler the user configured — `xdg-open` unless
|
|
/// `open_with` in `config.json` overrides it for this kind of file.
|
|
fn open_path(path: &str) {
|
|
let path = Path::new(path);
|
|
pdfs_core::opener::open_default(path, path.is_dir());
|
|
}
|
|
|
|
/// [`open_path`] for a file the daemon materialised into the content cache,
|
|
/// where the on-disk name is a content hash and carries no extension for the
|
|
/// rules (or `xdg-open`) to key off. `name` is the Drive name it was opened as.
|
|
pub(crate) fn open_named_path(path: &str, name: &str) {
|
|
pdfs_core::opener::open_default_named(Path::new(path), name, false);
|
|
}
|
|
|
|
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`).
|
|
fn human_bytes(bytes: u64) -> String {
|
|
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
if bytes == 0 {
|
|
return "0 B".into();
|
|
}
|
|
let mut value = bytes as f64;
|
|
let mut unit = 0;
|
|
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
|
value /= 1024.0;
|
|
unit += 1;
|
|
}
|
|
if unit == 0 {
|
|
format!("{bytes} B")
|
|
} else {
|
|
format!("{value:.1} {}", UNITS[unit])
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_tray_can_launch_the_app_on_a_page_or_at_the_stop_prompt() {
|
|
let args = |list: &[&str]| list.iter().map(|a| a.to_string()).collect::<Vec<_>>();
|
|
assert_eq!(parse_launch(&args(&[])), Launch::Window);
|
|
assert_eq!(
|
|
parse_launch(&args(&["--page", "locations"])),
|
|
Launch::Page("locations".into())
|
|
);
|
|
assert_eq!(
|
|
parse_launch(&args(&["--page=activity"])),
|
|
Launch::Page("activity".into())
|
|
);
|
|
assert_eq!(
|
|
parse_launch(&args(&["--confirm-stop"])),
|
|
Launch::ConfirmStop
|
|
);
|
|
// A trailing --page with no name is no request at all.
|
|
assert_eq!(parse_launch(&args(&["--page"])), Launch::Window);
|
|
}
|
|
|
|
/// The full width a row occupies, gaps included.
|
|
fn row_width(widths: &[i32]) -> i32 {
|
|
widths.iter().sum::<i32>() + TILE_GAP * (widths.len() as i32 - 1)
|
|
}
|
|
|
|
/// Ratios of a typical mixed day: landscape phone shots, a portrait, a
|
|
/// square crop.
|
|
fn mixed() -> Vec<f64> {
|
|
vec![
|
|
1.5, 1.5, 0.75, 1.0, 1.33, 1.5, 0.75, 1.78, 1.0, 1.5, 1.5, 1.33,
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn every_full_row_spans_the_content_width() {
|
|
// The point of justifying: no ragged right margin at any width, at any
|
|
// zoom, for any mix of shapes.
|
|
for width in [640, 900, 1000, 1440, 1920, 2560] {
|
|
let rows = plan_rows(&mixed(), width, ROW_DEFAULT);
|
|
// The last row is deliberately short, so it is not part of this.
|
|
for (_, widths) in rows.iter().take(rows.len() - 1) {
|
|
assert_eq!(row_width(widths), width, "row does not span {width}px");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_tile_keeps_its_photos_shape() {
|
|
// Each tile is its photo's ratio at the row's height, which is what
|
|
// makes cropping unnecessary.
|
|
let ratios = mixed();
|
|
let rows = plan_rows(&ratios, 1440, ROW_DEFAULT);
|
|
let (height, widths) = &rows[0];
|
|
for (width, ratio) in widths.iter().zip(&ratios).take(widths.len() - 1) {
|
|
let laid_out = *width as f64 / *height as f64;
|
|
assert!((laid_out - ratio).abs() < 0.02, "{laid_out} is not {ratio}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rows_land_near_the_target_height() {
|
|
// A row is closed as soon as it no longer fits at the target, so it is
|
|
// never taller than the target and never far below it.
|
|
for target in [ROW_MIN, ROW_DEFAULT, ROW_MAX] {
|
|
let rows = plan_rows(&mixed(), 1600, target);
|
|
for (height, _) in rows.iter().take(rows.len() - 1) {
|
|
assert!(
|
|
*height <= target,
|
|
"{height}px exceeds the {target}px target"
|
|
);
|
|
assert!(*height > target / 2, "{height}px is far under {target}px");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_short_day_is_not_stretched_across_the_window() {
|
|
// Two photos are two photos, not two half-window tiles.
|
|
let rows = plan_rows(&[1.5, 1.5], 1920, ROW_DEFAULT);
|
|
assert_eq!(rows.len(), 1);
|
|
let (height, widths) = &rows[0];
|
|
assert_eq!(*height, ROW_DEFAULT);
|
|
assert!(row_width(widths) < 1920 / 2);
|
|
}
|
|
|
|
#[test]
|
|
fn zooming_in_puts_fewer_photos_in_a_row() {
|
|
let small = plan_rows(&mixed(), 1200, ROW_MIN);
|
|
let big = plan_rows(&mixed(), 1200, ROW_MAX);
|
|
assert!(small[0].1.len() > big[0].1.len());
|
|
}
|
|
|
|
#[test]
|
|
fn every_photo_is_laid_out_exactly_once() {
|
|
let ratios = mixed();
|
|
let placed: usize = plan_rows(&ratios, 1000, ROW_DEFAULT)
|
|
.iter()
|
|
.map(|(_, widths)| widths.len())
|
|
.sum();
|
|
assert_eq!(placed, ratios.len());
|
|
assert!(plan_rows(&[], 1000, ROW_DEFAULT).is_empty());
|
|
}
|
|
|
|
/// One file of a shot, for the group-switch tests.
|
|
fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem {
|
|
PhotoItem {
|
|
uid: uid.into(),
|
|
capture_time: 0,
|
|
thumb_path: None,
|
|
name: name.map(str::to_string),
|
|
ratio: None,
|
|
no_thumb: false,
|
|
kind,
|
|
favorite: false,
|
|
group_size: 2,
|
|
has_raw: kind == PhotoKind::Raw,
|
|
}
|
|
}
|
|
|
|
/// The lightbox steps through a shot's files and comes back round, so a
|
|
/// burst of three is as reachable as a RAW+JPEG pair.
|
|
#[test]
|
|
fn the_group_switch_cycles_through_every_file() {
|
|
let members = [
|
|
member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo),
|
|
member("raw", Some("IMG_1.CR2"), PhotoKind::Raw),
|
|
member("clip", None, PhotoKind::Video),
|
|
];
|
|
let next = |uid| next_group_member(&members, uid).map(|item| item.uid.as_str());
|
|
assert_eq!(next("jpeg"), Some("raw"));
|
|
assert_eq!(next("raw"), Some("clip"));
|
|
assert_eq!(next("clip"), Some("jpeg"));
|
|
// A photo that is not in the group leaves the button alone rather than
|
|
// jumping somewhere arbitrary.
|
|
assert_eq!(next("elsewhere"), None);
|
|
assert!(next_group_member(&[], "jpeg").is_none());
|
|
}
|
|
|
|
/// The switch names what it will show; a file whose name the daemon has not
|
|
/// resolved still gets a sentence rather than a blank.
|
|
#[test]
|
|
fn a_group_member_without_a_name_is_still_described() {
|
|
assert_eq!(
|
|
member_label(&member("raw", Some("IMG_1.CR2"), PhotoKind::Raw)),
|
|
"IMG_1.CR2"
|
|
);
|
|
assert_eq!(
|
|
member_label(&member("raw", None, PhotoKind::Raw)),
|
|
"the raw file"
|
|
);
|
|
assert_eq!(
|
|
member_label(&member("clip", Some(""), PhotoKind::Video)),
|
|
"the video"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_window_narrower_than_one_photo_still_lays_it_out() {
|
|
// A tile of at least one px, rather than a zero-width widget or a
|
|
// division by zero.
|
|
for width in [0, 40] {
|
|
let rows = plan_rows(&[1.5, 1.5, 1.5], width, ROW_DEFAULT);
|
|
assert!(rows.iter().all(|(h, w)| *h > 0 && w.iter().all(|w| *w > 0)));
|
|
}
|
|
}
|
|
}
|