mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Photos now know where they were taken. proton-drive-rs 0.7.0 reads the Location from a photo's extended attributes, and the timeline refresh stores it with the photo (schema v33). The migration marks every photo unresolved once, so the next refresh backfills locations from node metadata without downloading anything. Towns are looked up offline: pdfs-core bundles GeoNames' cities15000 table, trimmed by scripts/geonames-cities.py, and files each photo under the nearest town within 50 km. The new PhotoPlaces and PlacePhotos requests list the towns and page one town's photos. The Photos page gets a third view, Places, with a card per town (newest photo, town, country, count). A place opens in the gallery like an album. Country names come translated from the iso-codes catalogs, now an optional dependency. The About dialog credits GeoNames (CC BY 4.0).
1809 lines
74 KiB
Rust
1809 lines
74 KiB
Rust
#[path = "../activation.rs"]
|
|
pub(crate) mod activation;
|
|
#[path = "../i18n.rs"]
|
|
pub(crate) mod i18n;
|
|
pub(crate) mod pages;
|
|
pub(crate) mod widgets;
|
|
|
|
use pages::activity::*;
|
|
use pages::albums::*;
|
|
use pages::browser::*;
|
|
use pages::devices::*;
|
|
use pages::duplicates::*;
|
|
use pages::locations::*;
|
|
use pages::login::*;
|
|
use pages::photo_viewer::*;
|
|
use pages::photos::*;
|
|
use pages::places::*;
|
|
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::*;
|
|
|
|
pub(crate) use i18n::{country_name, gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
|
|
|
|
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, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
|
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
|
SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem,
|
|
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();
|
|
i18n::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");
|
|
// Above the user's own gtk.css, which often imports a whole theme: its
|
|
// `button { padding: 6px 10px; min-width: 16px }` would otherwise pad every
|
|
// photo tile and push the grid past the window. Every rule in the sheet is
|
|
// scoped to the app's own classes, so nothing else the user styled changes.
|
|
gtk4::style_context_add_provider_for_display(
|
|
&display,
|
|
&provider,
|
|
gtk4::STYLE_PROVIDER_PRIORITY_USER + 1,
|
|
);
|
|
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(),
|
|
language_row: main_widgets.language_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),
|
|
scrolling_down: Cell::new(true),
|
|
scroll_offset: Cell::new(0.0),
|
|
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),
|
|
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),
|
|
place_count: Cell::new(0),
|
|
place: RefCell::new(None),
|
|
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(),
|
|
filter_btn: gallery_widgets.filter_btn.clone(),
|
|
filter_count: gallery_widgets.filter_count.clone(),
|
|
clear_filters: gallery_widgets.clear_filters.clone(),
|
|
date_ranges: RefCell::new(vec![None]),
|
|
range: Cell::new(None),
|
|
favorites_row: gallery_widgets.favorites_row.clone(),
|
|
favorites: Cell::new(false),
|
|
not_in_album_row: gallery_widgets.not_in_album_row.clone(),
|
|
not_in_album: Cell::new(false),
|
|
memories: gallery_widgets.memories.clone(),
|
|
memory_cards: gallery_widgets.memory_cards.clone(),
|
|
has_memories: Cell::new(false),
|
|
date_suppress: Cell::new(false),
|
|
reload_pending: 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_anchor: RefCell::new(None),
|
|
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,
|
|
);
|
|
let ui_duplicates = ui.clone();
|
|
gallery_widgets
|
|
.duplicates
|
|
.connect_clicked(move |_| show_duplicates(&ui_duplicates));
|
|
wire_albums(&ui);
|
|
wire_places(&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", gettext_noop("My files"), "folder-symbolic"),
|
|
(
|
|
"gallery",
|
|
gettext_noop("Photos"),
|
|
"image-x-generic-symbolic",
|
|
),
|
|
(
|
|
"shared",
|
|
gettext_noop("Shared with me"),
|
|
"system-users-symbolic",
|
|
),
|
|
(
|
|
"sharedbyme",
|
|
gettext_noop("Shared by me"),
|
|
"emblem-shared-symbolic",
|
|
),
|
|
("devices", gettext_noop("Computers"), "computer-symbolic"),
|
|
("trash", gettext_noop("Trash"), "user-trash-symbolic"),
|
|
// Translators: sidebar entry for the page listing this computer's synced folders.
|
|
(
|
|
"locations",
|
|
gettext_noop("Sync"),
|
|
"emblem-synchronizing-symbolic",
|
|
),
|
|
(
|
|
"activity",
|
|
gettext_noop("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(&gettext(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(APP_ID);
|
|
icon.set_pixel_size(24);
|
|
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(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.
|
|
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 views — a grid, or the timeline, an album or a place — is on screen.
|
|
Some("gallery") => refresh_then(ui, RefreshScope::Photos { full: false }, |ui| {
|
|
match ui.gallery.content.visible_child_name().as_deref() {
|
|
Some("albums") => load_albums(ui),
|
|
Some("places") => load_places(ui),
|
|
_ => 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(),
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
// Translators: a failed action's toast. {what} says what failed, such as
|
|
// "Couldn't rename"; {detail} is the reason, usually untranslated text
|
|
// from the mount service.
|
|
gettext_f("{what}: {detail}", &[("what", what), ("detail", 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 => static_gettext(gettext_noop("You're offline")),
|
|
ErrorKind::NotFound => static_gettext(gettext_noop("That's not there any more")),
|
|
ErrorKind::Denied => static_gettext(gettext_noop("You don't have access to that")),
|
|
ErrorKind::Conflict => static_gettext(gettext_noop("Something changed this first")),
|
|
ErrorKind::Quota => static_gettext(gettext_noop("Your Proton Drive is full")),
|
|
ErrorKind::Invalid | ErrorKind::Remote | ErrorKind::Internal => fallback,
|
|
}
|
|
}
|
|
|
|
thread_local! {
|
|
/// Translations handed out by [`static_gettext`], one per msgid.
|
|
static STATIC_TRANSLATIONS: RefCell<HashMap<&'static str, &'static str>> =
|
|
RefCell::new(HashMap::new());
|
|
}
|
|
|
|
/// [`gettext`] for a helper that has to return `&'static str`. The language is
|
|
/// fixed for the life of the process, so each msgid is translated once and the
|
|
/// result kept; the set of msgids is a handful of literals, so the kept strings
|
|
/// are bounded.
|
|
fn static_gettext(msgid: &'static str) -> &'static str {
|
|
STATIC_TRANSLATIONS.with(|cache| {
|
|
*cache
|
|
.borrow_mut()
|
|
.entry(msgid)
|
|
.or_insert_with(|| Box::leak(gettext(msgid).into_boxed_str()))
|
|
})
|
|
}
|
|
|
|
/// 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(&gettext("Preferences")), Some("win.preferences"));
|
|
menu.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
|
|
menu.append(
|
|
Some(&gettext("About Proton Drive for Linux")),
|
|
Some("win.about"),
|
|
);
|
|
gtk4::MenuButton::builder()
|
|
.icon_name("open-menu-symbolic")
|
|
.tooltip_text(gettext("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(gettext("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")
|
|
.copyright("© 2026 Nils Pukropp (contact@narl.io)")
|
|
.license_type(gtk4::License::MitX11)
|
|
.comments(gettext("Files-on-demand Proton Drive for the Linux desktop.\n\nUnofficial 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.add_link(
|
|
&gettext("Source Code"),
|
|
"https://github.com/narrrl/proton-drive-linux",
|
|
);
|
|
dialog.add_link(
|
|
&gettext("Proton Terms of Service"),
|
|
"https://proton.me/legal/terms",
|
|
);
|
|
dialog.add_link(
|
|
&gettext("Proton Privacy Policy"),
|
|
"https://proton.me/legal/privacy",
|
|
);
|
|
// The text is Pango markup: an ampersand must be written as "&".
|
|
dialog.add_legal_section(
|
|
&gettext("Disclaimer"),
|
|
None,
|
|
gtk4::License::Custom,
|
|
Some(&gettext(
|
|
"This is an unofficial, community-built client. It is not affiliated with, endorsed by, or supported by Proton AG.\n\n“Proton” and “Proton Drive” are trademarks of Proton AG, used here only to describe the service this software connects to. Your use of Proton Drive is governed by Proton's own terms of service and privacy policy.\n\nThis software is provided “as is”, without warranty of any kind. Keep a copy of any data you cannot afford to lose.",
|
|
)),
|
|
);
|
|
dialog.add_legal_section(
|
|
"GeoNames",
|
|
None,
|
|
gtk4::License::Custom,
|
|
Some(&gettext(
|
|
"Places are named with data from <a href=\"https://www.geonames.org/\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/licenses/by/4.0/\">CC BY 4.0</a>.",
|
|
)),
|
|
);
|
|
dialog.add_legal_section(
|
|
"proton-sdk-rs",
|
|
Some("© 2026 Nils Pukropp (contact@narl.io)"),
|
|
gtk4::License::MitX11,
|
|
None,
|
|
);
|
|
// Translators: replace with your name(s) and email address(es), one
|
|
// per line, to be credited in the About dialog.
|
|
let credits = gettext("translator-credits");
|
|
if credits != "translator-credits" {
|
|
dialog.set_translator_credits(&credits);
|
|
}
|
|
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(gettext("Stop Proton Drive?"))
|
|
.body(gettext("The drive unmounts and nothing syncs until you connect again. Proton Drive starts again at your next login."))
|
|
.build();
|
|
dialog.add_response("cancel", &gettext("Cancel"));
|
|
dialog.add_response("stop", &pgettext("verb", "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,
|
|
&gettext("Couldn't stop Proton Drive"),
|
|
&gettext("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] = [
|
|
(
|
|
gettext_noop("General"),
|
|
&[
|
|
("<Control>f", gettext_noop("Search Drive")),
|
|
("F5", gettext_noop("Refresh")),
|
|
("<Control>comma", gettext_noop("Preferences")),
|
|
("<Control>question", gettext_noop("Keyboard shortcuts")),
|
|
],
|
|
),
|
|
(
|
|
gettext_noop("Files"),
|
|
&[
|
|
("<Control>n", gettext_noop("New folder")),
|
|
("<Control>u", gettext_noop("Upload files")),
|
|
("<Alt>Left <Alt>Right", gettext_noop("Back / forward")),
|
|
("<Alt>Up", gettext_noop("Parent folder")),
|
|
("<Control>1 <Control>2", gettext_noop("Grid / list")),
|
|
("F2", gettext_noop("Rename")),
|
|
("Delete", gettext_noop("Move to Trash")),
|
|
("Escape", gettext_noop("Clear the selection")),
|
|
],
|
|
),
|
|
(
|
|
gettext_noop("Photos"),
|
|
&[
|
|
("<Control>plus", gettext_noop("Larger thumbnails")),
|
|
("<Control>minus", gettext_noop("Smaller thumbnails")),
|
|
("<Control>0", gettext_noop("Reset thumbnail size")),
|
|
("<Control>a", gettext_noop("Select all")),
|
|
],
|
|
),
|
|
(
|
|
gettext_noop("Photo Viewer"),
|
|
&[
|
|
("Left Right", gettext_noop("Previous / next photo")),
|
|
("Home End", gettext_noop("First / last photo")),
|
|
("i", gettext_noop("Show details")),
|
|
("f", gettext_noop("Fullscreen")),
|
|
("Delete", gettext_noop("Move to Trash")),
|
|
("Escape", gettext_noop("Close")),
|
|
],
|
|
),
|
|
];
|
|
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
|
|
})
|
|
.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, &gettext("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", &gettext("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`, or
|
|
/// `512 bytes` below one KiB).
|
|
///
|
|
/// GLib does the formatting, so the units, the plural of "bytes" and the
|
|
/// decimal separator follow the user's language from GLib's own catalog. GLib
|
|
/// separates number and unit with a no-break space; it is turned back into a
|
|
/// plain space so the text matches what the rest of the UI builds around it.
|
|
fn human_bytes(bytes: u64) -> String {
|
|
glib::format_size_full(bytes, glib::FormatSizeFlags::IEC_UNITS).replace('\u{a0}', " ")
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
}
|
|
}
|