feat(core,gui): phase 1 shell — per-page headers, sidebar footer, Preferences dialog

Restructure the app shell per docs/UI_UX_PLAN.md phase 1:

- Every destination page gets its own AdwHeaderBar via page_frame(), with
  page actions packed into it and a busy spinner beside the title.
- The sidebar is regrouped (files, then a Sync section) with a primary menu
  (Preferences, Keyboard Shortcuts, About) and a footer showing sync state,
  storage quota and the account with Sign Out.
- The Settings page is gone: startup, folder location, appearance and cache
  options move into an AdwPreferencesDialog (Ctrl+,); transfers move to the
  Sync page; developer details move into About's debug info.
- Inline CSS moves to a gresource stylesheet using libadwaita colour
  variables. The system accent is the default; the Proton purple accent is
  an opt-in preference (new `proton_accent` config key, defaults off).
- Add a keyboard shortcuts dialog (Ctrl+?).

libadwaita requirement stays at 1.5, so Ubuntu 24.04 remains supported.
This commit is contained in:
nvrl committed 2026-09-23 01:50:37 +02:00
1 parent cc308ac25a
commit cf9e1665ca
17 files changed
+861 -579

No files matched your search

+8
View File
@@ -119,6 +119,11 @@ pub struct AppConfig {
/// field. /// field.
#[serde(default)] #[serde(default)]
pub prompt: Option<PromptConfig>, pub prompt: Option<PromptConfig>,
/// Paint the desktop app in Proton purple instead of the system accent
/// colour. `None` means "follow the system". Defaulted for configs
/// predating the field.
#[serde(default)]
pub proton_accent: Option<bool>,
} }
impl Default for AppConfig { impl Default for AppConfig {
@@ -133,6 +138,7 @@ impl Default for AppConfig {
conflict_sweep: None, conflict_sweep: None,
open_with: None, open_with: None,
prompt: None, prompt: None,
proton_accent: None,
} }
} }
} }
@@ -477,11 +483,13 @@ mod tests {
conflict_sweep: Some(SweepMode::Enforce), conflict_sweep: Some(SweepMode::Enforce),
open_with: None, open_with: None,
prompt: None, prompt: None,
proton_accent: Some(true),
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap(); let decoded: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0"); assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
assert_eq!(decoded.user_agent, "test-agent/1.0"); assert_eq!(decoded.user_agent, "test-agent/1.0");
assert_eq!(decoded.proton_accent, Some(true));
} }
#[test] #[test]
+3
View File
@@ -9,6 +9,8 @@ fn main() {
&format!("{}/pdfs.gresource", out_dir), &format!("{}/pdfs.gresource", out_dir),
"--sourcedir", "--sourcedir",
"resources/icons", "resources/icons",
"--sourcedir",
"resources",
"resources/pdfs.gresource.xml", "resources/pdfs.gresource.xml",
]) ])
.status() .status()
@@ -23,4 +25,5 @@ fn main() {
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml"); println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
println!("cargo:rerun-if-changed=resources/icons"); println!("cargo:rerun-if-changed=resources/icons");
println!("cargo:rerun-if-changed=resources/style.css");
} }
@@ -10,4 +10,10 @@
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
</gresource> </gresource>
<!-- The app stylesheet, loaded once at startup. Colours come from the
libadwaita palette so the app follows the system accent, light/dark and
high contrast; the Proton purple accent is a separate, opt-in provider. -->
<gresource prefix="/de/nils/protondrivelinux">
<file>style.css</file>
</gresource>
</gresources> </gresources>
+79
View File
@@ -0,0 +1,79 @@
/* Proton Drive for Linux — app stylesheet.
*
* Colours come from the libadwaita palette (var(--accent-bg-color),
* var(--success-color), ...) so the app follows the system accent, light/dark
* and high contrast. The one exception is the photo viewer, which always sits
* on a near-black backdrop and so uses fixed light-on-dark colours. The Proton
* purple accent is opt-in and lives in its own provider (see main.rs).
*/
/* Brand */
.brand-title { font-size: 1.6rem; font-weight: 800; }
.brand-icon { color: #6d4aff; }
/* Sidebar footer */
.sidebar-footer { padding: 6px 12px 12px 12px; }
.sidebar-status { padding: 6px 8px; border-radius: 8px; }
.sidebar-status image.success { color: var(--success-color); }
.sidebar-status image.warning { color: var(--warning-color); }
.sidebar-status image.error { color: var(--error-color); }
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
.sidebar-account { padding: 4px 4px 4px 8px; }
/* File browser */
.file-grid { padding: 6px; }
.file-tile { padding: 8px; border-radius: 10px; }
.file-tile:hover { background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
.file-tile:selected, .file-tile:hover:selected { background: color-mix(in srgb, var(--accent-bg-color) 20%, transparent); }
.file-thumbnail { border-radius: 7px; }
.file-badge { -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }
.badge-pinned { color: var(--yellow-3); }
.badge-cached { color: var(--success-color); }
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
scale.browser-status-meter trough, progressbar.browser-status-meter trough { min-width: 104px; }
scale.browser-status-meter trough { min-height: 6px; }
progressbar.browser-status-meter trough, progressbar.browser-status-meter progress { min-height: 6px; }
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
/* Photo viewer: always light-on-dark, whatever the system style */
.photo-viewer-window { background-color: #111014; }
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
.viewer-action-btn { color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }
.viewer-action-btn:hover { background-color: rgba(255, 255, 255, 0.28); color: white; }
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
.viewer-close-btn { background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
.viewer-nav-btn:disabled { opacity: 0; }
.viewer-spinner { color: white; }
.viewer-status { color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
/* Photos grid */
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 4px 2px 8px 2px; }
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }
.photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
.photo-thumb { transition: transform 220ms ease; }
.photo-tile:hover .photo-thumb { transform: scale(1.06); }
.photo-check { color: white; background: rgba(0, 0, 0, 0.5); border-radius: 999px; padding: 3px; }
.photo-check-on { background: var(--accent-bg-color); color: var(--accent-fg-color); }
.photo-tile-selected { outline: 3px solid var(--accent-bg-color); outline-offset: -3px; }
.photo-placeholder { color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }
.photo-caption { font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }
.photo-tile:hover .photo-caption { opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }
.photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }
.photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); }
.photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }
.album-card { padding: 0; border-radius: 16px; transition: background 180ms ease; }
.album-card:hover { background: alpha(currentColor, 0.07); }
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
.album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }
.album-card:hover .photo-thumb { transform: scale(1.05); }
.view-switch button { padding-left: 18px; padding-right: 18px; }
+266 -151
View File
@@ -60,8 +60,8 @@ use pdfs_core::service;
const APP_ID: &str = "io.narl.proton-drive-linux"; const APP_ID: &str = "io.narl.proton-drive-linux";
/// Proton brand purple, applied as the libadwaita accent so switches, buttons, /// Proton brand purple, applied as the libadwaita accent when the user opts in
/// links and the storage bar all pick it up. /// (Preferences → Appearance), so switches, buttons and links all pick it up.
const PROTON_PURPLE: &str = "#6d4aff"; const PROTON_PURPLE: &str = "#6d4aff";
/// How often the window re-reads mount status, cache usage and the pin list. /// How often the window re-reads mount status, cache usage and the pin list.
@@ -97,9 +97,9 @@ struct Ui {
/// rename, a failed upload, a purge) is reported here rather than in a modal, /// 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. /// so an action never interrupts what the user is doing next.
toasts: adw::ToastOverlay, toasts: adw::ToastOverlay,
/// Header spinner shown while any open/load round-trip is in flight; ref- /// How many open/load round-trips are in flight. While any is, every page
/// counted via [`Self::busy`] so concurrent operations don't stop it early. /// header shows its spinner (see [`page_frame`]); ref-counted so concurrent
spinner: gtk4::Spinner, /// operations don't hide it early.
busy: Cell<u32>, busy: Cell<u32>,
/// Keys (relative path / photo uid) of open requests currently in flight, so /// 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. /// a double-click on the same entry is a no-op instead of a second download.
@@ -111,7 +111,7 @@ struct Ui {
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons /// 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. /// (which need the daemon to evict + re-hydrate) and every mutating action.
mounted: RefCell<bool>, mounted: RefCell<bool>,
/// Sidebar destination list (Files / Photos / Settings). Selecting a row swaps /// Sidebar destination list (My files, Photos, …, Sync). Selecting a row swaps
/// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere. /// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere.
sidebar: gtk4::ListBox, sidebar: gtk4::ListBox,
/// The sidebar/content split. Collapsed while signed out, so the login page /// The sidebar/content split. Collapsed while signed out, so the login page
@@ -142,8 +142,7 @@ impl Ui {
/// Begin a unit of background work: show + spin the header spinner. /// Begin a unit of background work: show + spin the header spinner.
fn busy_begin(&self) { fn busy_begin(&self) {
self.busy.set(self.busy.get() + 1); self.busy.set(self.busy.get() + 1);
self.spinner.set_visible(true); set_busy_spinners(true);
self.spinner.start();
} }
/// End a unit of background work: stop the spinner once the last one is done. /// End a unit of background work: stop the spinner once the last one is done.
@@ -151,8 +150,7 @@ impl Ui {
let remaining = self.busy.get().saturating_sub(1); let remaining = self.busy.get().saturating_sub(1);
self.busy.set(remaining); self.busy.set(remaining);
if remaining == 0 { if remaining == 0 {
self.spinner.stop(); set_busy_spinners(false);
self.spinner.set_visible(false);
} }
} }
@@ -199,6 +197,9 @@ fn main() -> glib::ExitCode {
let app = adw::Application::builder().application_id(APP_ID).build(); let app = adw::Application::builder().application_id(APP_ID).build();
app.connect_startup(|_| { app.connect_startup(|_| {
load_proton_theme(); 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 // Refresh the file manager's right-click pin/unpin scripts, so they always
// match the installed `pdfs`. // match the installed `pdfs`.
pdfs_core::shell::install_file_manager_scripts(); pdfs_core::shell::install_file_manager_scripts();
@@ -216,11 +217,10 @@ fn spawn_tray() {
} }
} }
/// Install a CSS provider that overrides libadwaita's accent colour with Proton /// Register the bundled GResources (custom icons, the stylesheet) and load the
/// purple, app-wide. Named-colour overrides recolour the stock widgets (switch, /// app stylesheet. The Proton accent is a separate provider, applied by
/// buttons, progress fill) without per-widget styling. /// [`set_proton_accent`] from the saved preference.
fn load_proton_theme() { fn load_proton_theme() {
// Compile-in and register our custom GResources (e.g. custom icons)
let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource")); let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource"));
let resource_data = glib::Bytes::from_static(bytes); let resource_data = glib::Bytes::from_static(bytes);
if let Ok(resource) = gio::Resource::from_data(&resource_data) { if let Ok(resource) = gio::Resource::from_data(&resource_data) {
@@ -229,82 +229,53 @@ fn load_proton_theme() {
tracing::error!("failed to load gresource bundle"); tracing::error!("failed to load gresource bundle");
} }
let css = format!( let Some(display) = gtk4::gdk::Display::default() else {
"@define-color accent_bg_color {PROTON_PURPLE};\n\ return;
@define-color accent_color {PROTON_PURPLE};\n\ };
.brand-title {{ font-size: 1.6rem; font-weight: 800; }}\n\
.brand-icon {{ color: {PROTON_PURPLE}; }}\n\
.file-grid {{ padding: 6px; }}\n\
.file-tile {{ padding: 8px; border-radius: 10px; }}\n\
.file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n\
.file-thumbnail {{ border-radius: 7px; }}\n\
.file-badge {{ -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }}\n\
.badge-pinned {{ color: #f5c211; }}\n\
.badge-cached {{ color: #2ec27e; }}\n\
.badge-cloud {{ color: #9aa0a6; }}\n\
.browser-statusbar {{ background-color: alpha(currentColor, 0.025); }}\n\
scale.browser-status-meter trough, progressbar.browser-status-meter trough {{ min-width: 104px; }}\n\
scale.browser-status-meter trough {{ min-height: 6px; }}\n\
progressbar.browser-status-meter trough, progressbar.browser-status-meter progress {{ min-height: 6px; }}\n\
.photo-viewer-window {{ background-color: #111014; }}\n\
.viewer-top-bar {{ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }}\n\
.viewer-title {{ font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\
.viewer-counter {{ font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }}\n\
.viewer-action-btn {{ color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }}\n\
.viewer-action-btn:hover {{ background-color: rgba(255, 255, 255, 0.28); color: white; }}\n\
.viewer-action-btn:checked {{ background-color: {PROTON_PURPLE}; color: white; }}\n\
.viewer-close-btn {{ background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }}\n\
.viewer-close-btn:hover {{ background-color: #e01b24; color: white; }}\n\
.viewer-nav-btn {{ background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }}\n\
.viewer-nav-btn:hover {{ background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }}\n\
.viewer-nav-btn:disabled {{ opacity: 0; }}\n\
.viewer-spinner {{ color: white; }}\n\
.viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\
.viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\
.gallery-day {{ font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 4px 2px 8px 2px; }}\n\
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }}\n\
.photo-tile:hover {{ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }}\n\
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
.photo-thumb {{ transition: transform 220ms ease; }}\n\
.photo-tile:hover .photo-thumb {{ transform: scale(1.06); }}\n\
.photo-check {{ color: white; background: rgba(0, 0, 0, 0.5); border-radius: 999px; padding: 3px; }}\n\
.photo-check-on {{ background: {PROTON_PURPLE}; }}\n\
.photo-tile-selected {{ outline: 3px solid {PROTON_PURPLE}; outline-offset: -3px; }}\n\
.photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }}\n\
.photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\
.photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\
.photo-group-badge {{ font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }}\n\
.photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\
.album-card {{ padding: 0; border-radius: 16px; transition: background 180ms ease; }}\n\
.album-card:hover {{ background: alpha(currentColor, 0.07); }}\n\
.album-cover {{ border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }}\n\
.album-card:hover .album-cover {{ box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }}\n\
.album-card:hover .photo-thumb {{ transform: scale(1.05); }}\n\
.view-switch button {{ padding-left: 18px; padding-right: 18px; }}\n\
.card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\
.card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
.navigation-sidebar row:selected {{ background: alpha({PROTON_PURPLE}, 0.16); color: {PROTON_PURPLE}; font-weight: 600; }}\n\
.navigation-sidebar row:selected image {{ color: {PROTON_PURPLE}; }}\n\
.file-tile:selected, .file-tile:hover:selected {{ background: alpha({PROTON_PURPLE}, 0.20); }}\n\
.bulk-bar {{ padding: 6px 8px; border-radius: 12px; background: alpha({PROTON_PURPLE}, 0.12); }}\n\
.dropzone {{ border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }}\n\
.dropzone-active {{ border-color: {PROTON_PURPLE}; background: alpha({PROTON_PURPLE}, 0.10); }}\n"
);
let provider = gtk4::CssProvider::new(); let provider = gtk4::CssProvider::new();
provider.load_from_string(&css); provider.load_from_resource("/de/nils/protondrivelinux/style.css");
if let Some(display) = gtk4::gdk::Display::default() { gtk4::style_context_add_provider_for_display(
gtk4::style_context_add_provider_for_display( &display,
&display, &provider,
&provider, gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, );
); gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons");
}
// Register custom icons directory from our GResource with the icon theme thread_local! {
let icon_theme = gtk4::IconTheme::for_display(&display); /// The Proton purple accent provider while it is installed. Kept so turning
icon_theme.add_resource_path("/de/nils/protondrivelinux/icons"); /// 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, /// Build the application window, wire the two pages, kick off the refresh loop,
@@ -320,7 +291,7 @@ fn build_window(app: &adw::Application) {
let stack = adw::ViewStack::new(); let stack = adw::ViewStack::new();
let (login_page, login_widgets) = build_login_page(); let (login_page, login_widgets) = build_login_page();
let (main_page, main_widgets) = build_main_page(); let main_widgets = build_main_page();
let (browser_page, browser_widgets) = build_browser_page(); let (browser_page, browser_widgets) = build_browser_page();
let (gallery_page, gallery_widgets) = build_gallery_page(); let (gallery_page, gallery_widgets) = build_gallery_page();
let (shared_page, shared_widgets) = build_shared_page(); let (shared_page, shared_widgets) = build_shared_page();
@@ -330,8 +301,16 @@ fn build_window(app: &adw::Application) {
let (activity_page, activity_widgets) = build_activity_page(); let (activity_page, activity_widgets) = build_activity_page();
let (trash_page, trash_widgets) = build_trash_page(); let (trash_page, trash_widgets) = build_trash_page();
let (takeout_page, takeout_widgets) = build_takeout_page(); let (takeout_page, takeout_widgets) = build_takeout_page();
stack.add_named(&login_page, Some("login")); // The login page has no title and no actions, but it still needs a header
stack.add_named(&main_page, Some("main")); // 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(&browser_page, Some("browser"));
stack.add_named(&gallery_page, Some("gallery")); stack.add_named(&gallery_page, Some("gallery"));
stack.add_named(&shared_by_me_page, Some("sharedbyme")); stack.add_named(&shared_by_me_page, Some("sharedbyme"));
@@ -345,21 +324,13 @@ fn build_window(app: &adw::Application) {
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack; // Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g. // `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
// login lands on Files). // login lands on Files).
let (sidebar_page, sidebar_list) = build_sidebar(); let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer);
// Header spinner, hidden until a background open/load is in flight.
let spinner = gtk4::Spinner::new();
spinner.set_visible(false);
let header = adw::HeaderBar::new();
header.pack_end(&build_primary_menu());
header.pack_end(&spinner);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&stack));
// Every page brings its own header bar (see `page_frame`), so the content
// side is the bare stack.
let content_page = adw::NavigationPage::builder() let content_page = adw::NavigationPage::builder()
.title("Proton Drive") .title("Proton Drive")
.child(&toolbar) .child(&stack)
.build(); .build();
let split = adw::NavigationSplitView::builder() let split = adw::NavigationSplitView::builder()
.sidebar(&sidebar_page) .sidebar(&sidebar_page)
@@ -377,7 +348,6 @@ fn build_window(app: &adw::Application) {
dirs, dirs,
stack: stack.clone(), stack: stack.clone(),
toasts: toasts.clone(), toasts: toasts.clone(),
spinner: spinner.clone(),
busy: Cell::new(0), busy: Cell::new(0),
opening: RefCell::new(HashSet::new()), opening: RefCell::new(HashSet::new()),
session: RefCell::new(auth::load().ok()), session: RefCell::new(auth::load().ok()),
@@ -393,14 +363,18 @@ fn build_window(app: &adw::Application) {
}, },
status: StatusState { status: StatusState {
status_inflight: Cell::new(false), status_inflight: Cell::new(false),
account_row: main_widgets.account_row.clone(), transfers_group: locations_widgets.transfers_group.clone(),
mount_row: main_widgets.mount_row.clone(), prefs: main_widgets.prefs.clone(),
transfers_group: main_widgets.transfers_group.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()), transfer_rows: RefCell::new(Vec::new()),
transfers_inflight: Cell::new(false), transfers_inflight: Cell::new(false),
cache_bar: main_widgets.cache_bar.clone(), cache_bar: main_widgets.cache_bar.clone(),
cache_label: main_widgets.cache_label.clone(), cache_label: main_widgets.cache_label.clone(),
quota_group: main_widgets.quota_group.clone(), quota_box: main_widgets.quota_box.clone(),
quota_bar: main_widgets.quota_bar.clone(), quota_bar: main_widgets.quota_bar.clone(),
quota_label: main_widgets.quota_label.clone(), quota_label: main_widgets.quota_label.clone(),
quota_inflight: Cell::new(false), quota_inflight: Cell::new(false),
@@ -408,6 +382,7 @@ fn build_window(app: &adw::Application) {
autostart_row: main_widgets.autostart_row.clone(), autostart_row: main_widgets.autostart_row.clone(),
budget_row: main_widgets.budget_row.clone(), budget_row: main_widgets.budget_row.clone(),
mountpoint_row: main_widgets.mountpoint_row.clone(), mountpoint_row: main_widgets.mountpoint_row.clone(),
accent_row: main_widgets.accent_row.clone(),
settings_suppress: Cell::new(false), settings_suppress: Cell::new(false),
budget_source: RefCell::new(None), budget_source: RefCell::new(None),
pins_expanded: Cell::new(false), pins_expanded: Cell::new(false),
@@ -485,7 +460,6 @@ fn build_window(app: &adw::Application) {
import: gallery_widgets.import.clone(), import: gallery_widgets.import.clone(),
empty_actions: gallery_widgets.empty_actions.clone(), empty_actions: gallery_widgets.empty_actions.clone(),
title: gallery_widgets.title.clone(), title: gallery_widgets.title.clone(),
subtitle: gallery_widgets.subtitle.clone(),
albums: gallery_widgets.albums.clone(), albums: gallery_widgets.albums.clone(),
albums_stack: gallery_widgets.albums_stack.clone(), albums_stack: gallery_widgets.albums_stack.clone(),
albums_status: gallery_widgets.albums_status.clone(), albums_status: gallery_widgets.albums_status.clone(),
@@ -598,7 +572,10 @@ fn build_window(app: &adw::Application) {
}, },
}); });
wire_login(&ui); wire_login(&ui);
wire_logout(&ui, &main_widgets.logout_button); let ui_status = ui.clone();
main_widgets
.status_button
.connect_clicked(move |_| ui_status.stack.set_visible_child_name("locations"));
wire_settings( wire_settings(
&ui, &ui,
&main_widgets.purge_button, &main_widgets.purge_button,
@@ -698,7 +675,9 @@ fn build_window(app: &adw::Application) {
.content(&toasts) .content(&toasts)
.build(); .build();
install_shortcuts(&ui, &window); install_shortcuts(&ui, &window);
install_window_actions(&window); install_window_actions(&ui, &window);
app.set_accels_for_action("win.preferences", &["<Control>comma"]);
app.set_accels_for_action("win.shortcuts", &["<Control>question"]);
refresh(&ui); refresh(&ui);
// Periodic refresh while the window lives. The closure holds a strong `Rc`; // Periodic refresh while the window lives. The closure holds a strong `Rc`;
@@ -720,40 +699,59 @@ fn build_window(app: &adw::Application) {
} }
/// The sidebar destinations, in order: the row index is the index into this table, /// The sidebar destinations, in order: the row index is the index into this table,
/// and each entry is `(stack page name, label, icon)`. /// and each entry is `(stack page name, label, icon)`. The places in the account
const DESTINATIONS: [(&str, &str, &str); 9] = [ /// 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"), ("browser", "My files", "folder-symbolic"),
("sharedbyme", "Shared", "emblem-shared-symbolic"), ("gallery", "Photos", "image-x-generic-symbolic"),
("shared", "Shared with me", "system-users-symbolic"), ("shared", "Shared with me", "system-users-symbolic"),
("locations", "Locations", "drive-harddisk-symbolic"), ("sharedbyme", "Shared by me", "emblem-shared-symbolic"),
("devices", "Computers", "computer-symbolic"), ("devices", "Computers", "computer-symbolic"),
("gallery", "Gallery", "image-x-generic-symbolic"),
("activity", "Activity", "document-open-recent-symbolic"),
("trash", "Trash", "user-trash-symbolic"), ("trash", "Trash", "user-trash-symbolic"),
("main", "Settings", "emblem-system-symbolic"), ("locations", "Sync", "emblem-synchronizing-symbolic"),
("activity", "Activity", "document-open-recent-symbolic"),
]; ];
/// The navigation sidebar: a Proton-branded header over one row per destination. /// Index of the first row in the sidebar's sync section.
/// Returns the page (for the split view) and the list (to drive + reflect the const SIDEBAR_SYNC_SECTION: i32 = 6;
/// current page).
fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) { /// 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: &gtk4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
let list = gtk4::ListBox::new(); let list = gtk4::ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::Single); list.set_selection_mode(gtk4::SelectionMode::Single);
list.add_css_class("navigation-sidebar"); list.add_css_class("navigation-sidebar");
for (_, label, icon) in DESTINATIONS { for (_, label, icon) in DESTINATIONS {
let row = adw::ActionRow::builder().title(label).build(); let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
row.add_prefix(&gtk4::Image::from_icon_name(icon)); row_box.append(&gtk4::Image::from_icon_name(icon));
row_box.append(&gtk4::Label::new(Some(label)));
let row = gtk4::ListBoxRow::builder().child(&row_box).build();
list.append(&row); 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 brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let icon = gtk4::Image::from_icon_name("folder-remote-symbolic"); let icon = gtk4::Image::from_icon_name("folder-remote-symbolic");
icon.add_css_class("brand-icon"); icon.add_css_class("brand-icon");
brand.append(&icon); brand.append(&icon);
brand.append(&gtk4::Label::new(Some("Proton Drive"))); let name = gtk4::Label::new(Some("Proton Drive"));
name.add_css_class("heading");
brand.append(&name);
let header = adw::HeaderBar::new(); let header = adw::HeaderBar::new();
header.set_title_widget(Some(&brand)); header.set_title_widget(Some(&brand));
header.pack_end(&build_primary_menu());
let scroll = gtk4::ScrolledWindow::builder() let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never) .hscrollbar_policy(gtk4::PolicyType::Never)
@@ -763,6 +761,7 @@ fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) {
let toolbar = adw::ToolbarView::new(); let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header); toolbar.add_top_bar(&header);
toolbar.set_content(Some(&scroll)); toolbar.set_content(Some(&scroll));
toolbar.add_bottom_bar(footer);
let page = adw::NavigationPage::builder() let page = adw::NavigationPage::builder()
.title("Proton Drive") .title("Proton Drive")
@@ -771,6 +770,46 @@ fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) {
(page, list) (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 /// 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 /// 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 /// its own and answers with its parent, so the sidebar highlights where the user
@@ -1033,21 +1072,39 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
} }
} }
/// The header's primary (hamburger) menu: the app-level entries that don't belong /// The sidebar's primary (hamburger) menu: the app-level entries that don't
/// on any one page. /// belong on any one page.
fn build_primary_menu() -> gtk4::MenuButton { fn build_primary_menu() -> gtk4::MenuButton {
let menu = gio::Menu::new(); let menu = gio::Menu::new();
menu.append(Some("Preferences"), Some("win.preferences"));
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts")); menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
menu.append(Some("About Proton Drive for Linux"), Some("win.about")); menu.append(Some("About Proton Drive for Linux"), Some("win.about"));
gtk4::MenuButton::builder() gtk4::MenuButton::builder()
.icon_name("open-menu-symbolic") .icon_name("open-menu-symbolic")
.tooltip_text("Main menu") .tooltip_text("Main menu")
.primary(true)
.menu_model(&menu) .menu_model(&menu)
.build() .build()
} }
/// Back the primary menu's entries with window actions. /// Back the primary and account menus' entries with window actions.
fn install_window_actions(window: &adw::ApplicationWindow) { 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);
let shortcuts = gio::SimpleAction::new("shortcuts", None); let shortcuts = gio::SimpleAction::new("shortcuts", None);
let win = window.clone(); let win = window.clone();
shortcuts.connect_activate(move |_, _| show_shortcuts(&win)); shortcuts.connect_activate(move |_, _| show_shortcuts(&win));
@@ -1055,6 +1112,7 @@ fn install_window_actions(window: &adw::ApplicationWindow) {
let about = gio::SimpleAction::new("about", None); let about = gio::SimpleAction::new("about", None);
let win = window.clone(); let win = window.clone();
let ui_about = ui.clone();
about.connect_activate(move |_, _| { about.connect_activate(move |_, _| {
let dialog = adw::AboutDialog::builder() let dialog = adw::AboutDialog::builder()
.application_name("Proton Drive for Linux") .application_name("Proton Drive for Linux")
@@ -1068,41 +1126,98 @@ fn install_window_actions(window: &adw::ApplicationWindow) {
"Files-on-demand Proton Drive for the Linux desktop.\n\n\ "Files-on-demand Proton Drive for the Linux desktop.\n\n\
Unofficial client — not affiliated with, endorsed by, or supported by Proton AG.", 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(); .build();
dialog.present(Some(&win)); dialog.present(Some(&win));
}); });
window.add_action(&about); window.add_action(&about);
} }
/// 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 /// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
/// [`install_shortcuts`] binds. /// [`install_shortcuts`], the Photos page and the photo viewer bind.
fn show_shortcuts(window: &adw::ApplicationWindow) { fn show_shortcuts(window: &adw::ApplicationWindow) {
const KEYS: [(&str, &str); 6] = [ const GROUPS: [(&str, &[(&str, &str)]); 4] = [
("Ctrl+F", "Search Drive"), (
("Ctrl+N", "New folder"), "General",
("Ctrl+U", "Upload file"), &[
("F2", "Rename selection"), ("<Control>f", "Search Drive"),
("Delete", "Move selection to Trash"), ("F5", "Refresh"),
("Escape", "Close the details pane"), ("<Control>comma", "Preferences"),
("<Control>question", "Keyboard shortcuts"),
],
),
(
"Files",
&[
("<Control>n", "New folder"),
("<Control>u", "Upload files"),
("F2", "Rename"),
("Delete", "Move to Trash"),
("Escape", "Clear the selection"),
],
),
(
"Photos",
&[
("<Control>plus", "Larger thumbnails"),
("<Control>minus", "Smaller thumbnails"),
("<Control>0", "Reset thumbnail size"),
],
),
(
"Photo Viewer",
&[
("Left Right", "Previous / next photo"),
("Home End", "First / last photo"),
("i", "Show details"),
("f", "Fullscreen"),
("Delete", "Move to Trash"),
("Escape", "Close"),
],
),
]; ];
let group = adw::PreferencesGroup::builder().title("Files").build();
for (keys, action) in KEYS {
let row = adw::ActionRow::builder().title(action).build();
let label = gtk4::Label::builder()
.label(keys)
.valign(gtk4::Align::Center)
.build();
label.add_css_class("dim-label");
label.add_css_class("monospace");
row.add_suffix(&label);
group.add(&row);
}
let page = adw::PreferencesPage::new(); let page = adw::PreferencesPage::new();
page.add(&group); 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(
&gtk4::ShortcutLabel::builder()
.accelerator(*accel)
.valign(gtk4::Align::Center)
.build(),
);
group.add(&row);
}
page.add(&group);
}
let dialog = adw::Dialog::builder() let dialog = adw::Dialog::builder()
.title("Keyboard Shortcuts") .title("Keyboard Shortcuts")
.content_width(420) .content_width(460)
.content_height(620)
.child(&{ .child(&{
let toolbar = adw::ToolbarView::new(); let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&adw::HeaderBar::new()); toolbar.add_top_bar(&adw::HeaderBar::new());
+7 -16
View File
@@ -27,17 +27,7 @@ pub(crate) struct ActivityWidgets {
/// The Activity page: a newest-first feed of the mutations and transfers the /// The Activity page: a newest-first feed of the mutations and transfers the
/// daemon performed this session (uploads, deletes, shares, …). /// daemon performed this session (uploads, deletes, shares, …).
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
let title = gtk4::Label::builder()
.label("Activity")
.halign(gtk4::Align::Start)
.hexpand(true)
.build();
title.add_css_class("title-2");
let refresh = refresh_button(); let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.set_hexpand(true);
header.append(&title);
header.append(&refresh);
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
let clamp = adw::Clamp::builder().child(&group).build(); let clamp = adw::Clamp::builder().child(&group).build();
@@ -67,15 +57,16 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
content.add_named(&status, Some("status")); content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12); inner.set_margin_top(18);
inner.set_margin_bottom(12); inner.set_margin_bottom(18);
inner.set_margin_start(12); inner.set_margin_start(18);
inner.set_margin_end(12); inner.set_margin_end(18);
inner.append(&header);
inner.append(&content); inner.append(&content);
let (frame, header, _) = page_frame("Activity", &inner);
header.pack_end(&refresh);
( (
inner.upcast(), frame.upcast(),
ActivityWidgets { ActivityWidgets {
content, content,
status, status,
+3 -4
View File
@@ -192,9 +192,8 @@ fn want_cover(ui: &Rc<Ui>, uid: String, picture: &gtk4::Picture) {
/// Open one album in the gallery: the timeline view, paged from the album /// Open one album in the gallery: the timeline view, paged from the album
/// instead, with the filters that don't apply to it out of the way. /// instead, with the filters that don't apply to it out of the way.
pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) { pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
ui.gallery.title.set_label(&album.name); ui.gallery.title.set_title(&album.name);
ui.gallery.subtitle.set_label(&album_subtitle(&album)); ui.gallery.title.set_subtitle(&album_subtitle(&album));
ui.gallery.subtitle.set_visible(true);
*ui.gallery.album.borrow_mut() = Some(album); *ui.gallery.album.borrow_mut() = Some(album);
// An album page carries no kind or date filter, and Upload targets the // An album page carries no kind or date filter, and Upload targets the
@@ -219,7 +218,7 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
if ui.gallery.album.borrow_mut().take().is_none() { if ui.gallery.album.borrow_mut().take().is_none() {
return; return;
} }
ui.gallery.title.set_label("Gallery"); ui.gallery.title.set_title("Photos");
ui.gallery.filters.set_visible(true); ui.gallery.filters.set_visible(true);
ui.gallery.view_switch.set_visible(true); ui.gallery.view_switch.set_visible(true);
ui.gallery.upload.set_visible(true); ui.gallery.upload.set_visible(true);
+16 -12
View File
@@ -219,16 +219,11 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
let refresh = refresh_button(); let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); // The path bar stays in the page, under the header bar: the header's title
header.append(&back); // slot carries the page name and the busy spinner.
header.append(&crumb_scroll); let path_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&refresh); path_bar.append(&back);
header.append(&build_thumbnails); path_bar.append(&crumb_scroll);
header.append(&new_folder);
header.append(&upload);
header.append(&upload_folder);
header.append(&search);
header.append(&toggles);
let thumbnail_status = gtk4::Label::builder() let thumbnail_status = gtk4::Label::builder()
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
@@ -447,7 +442,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
body.set_margin_bottom(6); body.set_margin_bottom(6);
body.set_margin_start(12); body.set_margin_start(12);
body.set_margin_end(12); body.set_margin_end(12);
body.append(&header); body.append(&path_bar);
body.append(&thumbnail_build_row); body.append(&thumbnail_build_row);
body.append(&bulk); body.append(&bulk);
body.append(&split); body.append(&split);
@@ -457,8 +452,17 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
page.append(&gtk4::Separator::new(gtk4::Orientation::Horizontal)); page.append(&gtk4::Separator::new(gtk4::Orientation::Horizontal));
page.append(&status_bar); page.append(&status_bar);
let (frame, header, _) = page_frame("My Files", &page);
header.pack_start(&upload);
header.pack_start(&upload_folder);
header.pack_start(&new_folder);
header.pack_end(&refresh);
header.pack_end(&toggles);
header.pack_end(&build_thumbnails);
header.pack_end(&search);
( (
page.upcast(), frame.upcast(),
BrowserWidgets { BrowserWidgets {
model, model,
back, back,
+11 -25
View File
@@ -46,33 +46,17 @@ pub(crate) struct DevicesWidgets {
/// The Computers page: a "This computer" section naming the device this machine /// The Computers page: a "This computer" section naming the device this machine
/// backs up to (renamable from its header), plus an "Other computers" section /// backs up to (renamable from its header), plus an "Other computers" section
/// listing the account's other registered devices. The synced folders /// listing the account's other registered devices. The synced folders
/// themselves live on the Locations page. /// themselves live on the Sync page.
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
let title = gtk4::Label::builder()
.label("Computers")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
let restore = gtk4::Button::builder() let restore = gtk4::Button::builder()
.label("Restore Folders") .label("Restore Folders…")
.tooltip_text("Sync this computer's Drive folders back to local directories") .tooltip_text("Sync this computer's Drive folders back to local directories")
.valign(gtk4::Align::Center)
.build(); .build();
restore.add_css_class("flat");
let refresh = refresh_button(); let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&titles);
header.append(&refresh);
header.append(&restore);
// This page is about device *identity* — which computer this is, which other // This page is about device *identity* — which computer this is, which other
// computers back up to the account, and how to adopt one. The folders // computers back up to the account, and how to adopt one. The folders
// themselves are local paths, so they live on the Locations page; keeping a // themselves are local paths, so they live on the Sync page; keeping a
// second copy of that list here would be two places to change the same mode. // second copy of that list here would be two places to change the same mode.
let sync_group = adw::PreferencesGroup::builder() let sync_group = adw::PreferencesGroup::builder()
.title("This computer") .title("This computer")
@@ -124,15 +108,17 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
content.add_named(&status, Some("status")); content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12); inner.set_margin_top(18);
inner.set_margin_bottom(12); inner.set_margin_bottom(18);
inner.set_margin_start(12); inner.set_margin_start(18);
inner.set_margin_end(12); inner.set_margin_end(18);
inner.append(&header);
inner.append(&content); inner.append(&content);
let (frame, header, _) = page_frame("Computers", &inner);
header.pack_start(&restore);
header.pack_end(&refresh);
( (
inner.upcast(), frame.upcast(),
DevicesWidgets { DevicesWidgets {
content, content,
status, status,
+17 -23
View File
@@ -1,4 +1,5 @@
//! The Locations page: every local place Proton Drive occupies on this machine. //! The Sync page (formerly Locations): what is moving right now, and every local
//! place Proton Drive occupies on this machine.
//! //!
//! One row per [`MountSpec`] from [`Request::ListLocations`] — the primary //! One row per [`MountSpec`] from [`Request::ListLocations`] — the primary
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is //! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
@@ -31,33 +32,22 @@ pub(crate) struct LocationsWidgets {
pub(crate) retry: gtk4::Button, pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button, pub(crate) refresh: gtk4::Button,
pub(crate) add_folder: gtk4::Button, pub(crate) add_folder: gtk4::Button,
/// Live transfers, above the folder list; painted by the refresh loop.
pub(crate) transfers_group: adw::PreferencesGroup,
} }
pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) { pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
let title = gtk4::Label::builder()
.label("Locations")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let add_folder = gtk4::Button::builder() let add_folder = gtk4::Button::builder()
.label("Add Folder") .child(
&adw::ButtonContent::builder()
.label("Add Folder")
.icon_name("list-add-symbolic")
.build(),
)
.tooltip_text("Back a local folder up to this computer's Proton Drive device") .tooltip_text("Back a local folder up to this computer's Proton Drive device")
.valign(gtk4::Align::Center)
.build(); .build();
add_folder.add_css_class("flat");
let refresh = refresh_button(); let refresh = refresh_button();
let transfers_group = build_transfers_group();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
header.append(&titles);
header.append(&refresh);
header.append(&add_folder);
// Clamped to the same width as the rows below, or the page title floats off
// to the left of the list it names.
let header_clamp = adw::Clamp::builder().child(&header).build();
// Same warning the Computers page carried, for the same reason: the // Same warning the Computers page carried, for the same reason: the
// on-demand switch removes the local copy, which is not a thing to discover // on-demand switch removes the local copy, which is not a thing to discover
@@ -72,6 +62,7 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
.build(); .build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18); let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
groups.append(&transfers_group);
groups.append(&group); groups.append(&group);
let clamp = adw::Clamp::builder().child(&groups).build(); let clamp = adw::Clamp::builder().child(&groups).build();
// Never scroll sideways: a location's title is a full path, and letting the // Never scroll sideways: a location's title is a full path, and letting the
@@ -114,8 +105,10 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
page.set_margin_bottom(18); page.set_margin_bottom(18);
page.set_margin_start(18); page.set_margin_start(18);
page.set_margin_end(18); page.set_margin_end(18);
page.append(&header_clamp);
page.append(&content); page.append(&content);
let (frame, header, _) = page_frame("Sync", &page);
header.pack_start(&add_folder);
header.pack_end(&refresh);
let widgets = LocationsWidgets { let widgets = LocationsWidgets {
content: content.clone(), content: content.clone(),
@@ -124,8 +117,9 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
retry: retry.clone(), retry: retry.clone(),
refresh, refresh,
add_folder, add_folder,
transfers_group,
}; };
(page.upcast(), widgets) (frame.upcast(), widgets)
} }
pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: &gtk4::Button, add_folder: &gtk4::Button) { pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: &gtk4::Button, add_folder: &gtk4::Button) {
+20 -22
View File
@@ -268,27 +268,25 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
dialog.present(parent.as_ref()); dialog.present(parent.as_ref());
} }
/// Connect the sign-out button: after a confirmation, disable+stop the mount /// Sign out, after a confirmation: disable+stop the mount service (so the
/// service (so the daemon isn't left running without credentials), forget the /// daemon isn't left running without credentials), forget the stored session,
/// stored session, and drop back to the login page. /// and drop back to the login page.
pub(crate) fn wire_logout(ui: &Rc<Ui>, button: &gtk4::Button) { pub(crate) fn sign_out(ui: &Rc<Ui>) {
let Some(window) = ui_window(ui) else { return };
let ui = ui.clone(); let ui = ui.clone();
button.connect_clicked(move |btn| { confirm_destructive(
let ui = ui.clone(); &window,
confirm_destructive( "Sign Out?",
btn, "Proton Drive will disconnect and stop syncing until you sign in again. \
"Sign Out?", Files already on this computer stay where they are.",
"Proton Drive will disconnect and stop syncing until you sign in again. \ "Sign Out",
Files already on this computer stay where they are.", move || {
"Sign Out", service::disable_stop();
move || { if let Err(e) = auth::logout() {
service::disable_stop(); tracing::error!("logout failed: {e}");
if let Err(e) = auth::logout() { }
tracing::error!("logout failed: {e}"); *ui.session.borrow_mut() = None;
} refresh(&ui);
*ui.session.borrow_mut() = None; },
refresh(&ui); );
},
);
});
} }
+19 -38
View File
@@ -40,9 +40,8 @@ pub(crate) struct GalleryState {
/// status: a load error is not the moment to offer an upload. /// status: a load error is not the moment to offer an upload.
pub(crate) empty_actions: gtk4::Box, pub(crate) empty_actions: gtk4::Box,
/// "Photos", or the album's name while one is open. /// "Photos", or the album's name while one is open.
pub(crate) title: gtk4::Label, /// "1,204 photos" as its subtitle.
/// "1,204 photos" under the page title. pub(crate) title: adw::WindowTitle,
pub(crate) subtitle: gtk4::Label,
/// The album grid, and the stack that swaps it for its own status page. /// The album grid, and the stack that swaps it for its own status page.
pub(crate) albums: gtk4::FlowBox, pub(crate) albums: gtk4::FlowBox,
pub(crate) albums_stack: gtk4::Stack, pub(crate) albums_stack: gtk4::Stack,
@@ -283,8 +282,7 @@ pub(crate) struct GalleryWidgets {
/// Albums grid. /// Albums grid.
pub(crate) content: gtk4::Stack, pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage, pub(crate) status: adw::StatusPage,
pub(crate) title: gtk4::Label, pub(crate) title: adw::WindowTitle,
pub(crate) subtitle: gtk4::Label,
pub(crate) more: gtk4::Button, pub(crate) more: gtk4::Button,
pub(crate) list: gtk4::ListView, pub(crate) list: gtk4::ListView,
pub(crate) scroll: gtk4::ScrolledWindow, pub(crate) scroll: gtk4::ScrolledWindow,
@@ -394,24 +392,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.child(&list) .child(&list)
.build(); .build();
let title_label = gtk4::Label::builder()
.label("Gallery")
.halign(gtk4::Align::Start)
.build();
title_label.add_css_class("title-2");
let subtitle = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.visible(false)
.build();
subtitle.add_css_class("dim-label");
subtitle.add_css_class("caption");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title_label);
titles.append(&subtitle);
// Leaves an open album for the grid it came from. Only an open album shows // Leaves an open album for the grid it came from. Only an open album shows
// it — the Albums toggle is what leaves the grid itself. // it — the Albums toggle is what leaves the grid itself.
let back = gtk4::Button::builder() let back = gtk4::Button::builder()
@@ -435,7 +415,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.tooltip_text("Upload photos") .tooltip_text("Upload photos")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
upload.add_css_class("pill");
upload.add_css_class("suggested-action"); upload.add_css_class("suggested-action");
// Importing an export is a rarer, heavier action than adding one photo, so // Importing an export is a rarer, heavier action than adding one photo, so
@@ -489,14 +468,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.child(&select_box) .child(&select_box)
.build(); .build();
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header_box.append(&back);
header_box.append(&titles);
header_box.append(&select_btn);
header_box.append(&refresh);
header_box.append(&import);
header_box.append(&upload);
// All / Photos / Videos / Raw filter. Linked toggles acting as one segmented // All / Photos / Videos / Raw filter. Linked toggles acting as one segmented
// control: exactly one is active, and flipping it reloads the timeline // control: exactly one is active, and flipping it reloads the timeline
// filtered to that kind (wired in [`wire_gallery`]). Labels gain live counts // filtered to that kind (wired in [`wire_gallery`]). Labels gain live counts
@@ -611,20 +582,25 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
inner.set_margin_bottom(12); inner.set_margin_bottom(12);
inner.set_margin_start(12); inner.set_margin_start(12);
inner.set_margin_end(12); inner.set_margin_end(12);
inner.append(&header_box);
inner.append(&view_switch); inner.append(&view_switch);
inner.append(&filter_bar); inner.append(&filter_bar);
inner.append(&content); inner.append(&content);
let (frame, header, title) = page_frame("Photos", &inner);
header.pack_start(&back);
header.pack_start(&upload);
header.pack_end(&refresh);
header.pack_end(&select_btn);
header.pack_end(&import);
( (
inner.upcast(), frame.upcast(),
GalleryWidgets { GalleryWidgets {
model, model,
groups, groups,
content, content,
status, status,
title: title_label, title,
subtitle,
more, more,
list, list,
scroll, scroll,
@@ -1995,7 +1971,9 @@ fn clone_row(row: &GalleryRow) -> GalleryRow {
/// "1,204 photos" under the page title. /// "1,204 photos" under the page title.
fn update_gallery_subtitle(ui: &Rc<Ui>) { fn update_gallery_subtitle(ui: &Rc<Ui>) {
let loaded = ui.gallery.model.n_items() as usize; let loaded = ui.gallery.model.n_items() as usize;
ui.gallery.subtitle.set_visible(loaded > 0); if loaded == 0 {
ui.gallery.title.set_subtitle("");
}
// An album counts what the server says it holds, not how much of it has been // An album counts what the server says it holds, not how much of it has been
// paged in — the subtitle would otherwise climb as the user scrolls. // paged in — the subtitle would otherwise climb as the user scrolls.
if ui.gallery.album.borrow().is_some() { if ui.gallery.album.borrow().is_some() {
@@ -2020,7 +1998,10 @@ fn update_gallery_subtitle(ui: &Rc<Ui>) {
}, },
_ => loaded, _ => loaded,
}; };
ui.gallery.subtitle.set_label(&match total { if loaded == 0 {
return;
}
ui.gallery.title.set_subtitle(&match total {
1 => format!("1 {one}"), 1 => format!("1 {one}"),
n => format!("{} {many}", thousands(n)), n => format!("{} {many}", thousands(n)),
}); });
+15 -23
View File
@@ -39,27 +39,17 @@ pub(crate) struct SharedWidgets {
/// links I've bookmarked (open / remove). All three live outside the mount tree, /// links I've bookmarked (open / remove). All three live outside the mount tree,
/// so the page addresses them by uid/id/token and always re-lists from the daemon. /// so the page addresses them by uid/id/token and always re-lists from the daemon.
pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
let title = gtk4::Label::builder()
.label("Shared with me")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
let add_bookmark = gtk4::Button::builder() let add_bookmark = gtk4::Button::builder()
.label("Add Bookmark") .child(
.valign(gtk4::Align::Center) &adw::ButtonContent::builder()
.label("Add Bookmark")
.icon_name("list-add-symbolic")
.build(),
)
.tooltip_text("Save a shared link to open it from here")
.build(); .build();
add_bookmark.add_css_class("flat");
let refresh = refresh_button(); let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&titles);
header.append(&refresh);
header.append(&add_bookmark);
let shared_with_me = adw::PreferencesGroup::builder() let shared_with_me = adw::PreferencesGroup::builder()
.title("Shared with me") .title("Shared with me")
.build(); .build();
@@ -99,15 +89,17 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
content.add_named(&status, Some("status")); content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12); inner.set_margin_top(18);
inner.set_margin_bottom(12); inner.set_margin_bottom(18);
inner.set_margin_start(12); inner.set_margin_start(18);
inner.set_margin_end(12); inner.set_margin_end(18);
inner.append(&header);
inner.append(&content); inner.append(&content);
let (frame, header, _) = page_frame("Shared with Me", &inner);
header.pack_start(&add_bookmark);
header.pack_end(&refresh);
( (
inner.upcast(), frame.upcast(),
SharedWidgets { SharedWidgets {
content, content,
status, status,
+7 -16
View File
@@ -25,17 +25,7 @@ pub(crate) struct SharedByMeWidgets {
/// each row summarizing who has access and carrying its public link (copy/open) /// each row summarizing who has access and carrying its public link (copy/open)
/// and a Manage shortcut into the per-node Share dialog. /// and a Manage shortcut into the per-node Share dialog.
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) { pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
let title = gtk4::Label::builder()
.label("Shared")
.halign(gtk4::Align::Start)
.hexpand(true)
.build();
title.add_css_class("title-2");
let refresh = refresh_button(); let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.set_hexpand(true);
header.append(&title);
header.append(&refresh);
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
let clamp = adw::Clamp::builder().child(&group).build(); let clamp = adw::Clamp::builder().child(&group).build();
@@ -65,15 +55,16 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
content.add_named(&status, Some("status")); content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12); inner.set_margin_top(18);
inner.set_margin_bottom(12); inner.set_margin_bottom(18);
inner.set_margin_start(12); inner.set_margin_start(18);
inner.set_margin_end(12); inner.set_margin_end(18);
inner.append(&header);
inner.append(&content); inner.append(&content);
let (frame, header, _) = page_frame("Shared by Me", &inner);
header.pack_end(&refresh);
( (
inner.upcast(), frame.upcast(),
SharedByMeWidgets { SharedByMeWidgets {
content, content,
status, status,
+361 -199
View File
@@ -7,18 +7,24 @@ pub(crate) struct StatusState {
/// Guards the [`Request::GetQueueStatus`] poll the same way: at most one /// Guards the [`Request::GetQueueStatus`] poll the same way: at most one
/// in-flight at a time so a wedged daemon can't stack worker threads. /// in-flight at a time so a wedged daemon can't stack worker threads.
pub(crate) transfers_inflight: Cell<bool>, pub(crate) transfers_inflight: Cell<bool>,
/// Activity group + its current rows, hidden when no transfer is in flight. /// Transfers group (on the Sync page) + its current rows, hidden when no
/// transfer is in flight.
pub(crate) transfers_group: adw::PreferencesGroup, pub(crate) transfers_group: adw::PreferencesGroup,
pub(crate) transfer_rows: RefCell<Vec<TransferRow>>, pub(crate) transfer_rows: RefCell<Vec<TransferRow>>,
// Main page. /// The Preferences dialog, built once and presented on demand.
pub(crate) account_row: adw::ActionRow, pub(crate) prefs: adw::PreferencesDialog,
/// Read-only mount status line. The mount is driven by the systemd user /// Sidebar footer: signed-in identity.
/// service (enabled on login), not by the user — so this only reports. pub(crate) account_name: gtk4::Label,
pub(crate) mount_row: adw::ActionRow, pub(crate) avatar: adw::Avatar,
/// Sidebar footer: the one-line sync state. The mount is driven by the
/// systemd user service, not by the user — so this only reports.
pub(crate) status_icon: gtk4::Image,
pub(crate) status_title: gtk4::Label,
pub(crate) status_detail: gtk4::Label,
pub(crate) cache_bar: gtk4::ProgressBar, pub(crate) cache_bar: gtk4::ProgressBar,
pub(crate) cache_label: gtk4::Label, pub(crate) cache_label: gtk4::Label,
/// Account-quota group + its bar/label. Hidden until the first reading. /// Sidebar footer: account quota bar/label. Hidden until the first reading.
pub(crate) quota_group: adw::PreferencesGroup, pub(crate) quota_box: gtk4::Box,
pub(crate) quota_bar: gtk4::ProgressBar, pub(crate) quota_bar: gtk4::ProgressBar,
pub(crate) quota_label: gtk4::Label, pub(crate) quota_label: gtk4::Label,
/// One `AccountQuota` in flight at a time, and when it last succeeded — quota /// One `AccountQuota` in flight at a time, and when it last succeeded — quota
@@ -31,9 +37,10 @@ pub(crate) struct StatusState {
/// Cache-budget editor (GiB). Populated once from config; user edits drive a /// Cache-budget editor (GiB). Populated once from config; user edits drive a
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`]. /// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
pub(crate) budget_row: adw::SpinRow, pub(crate) budget_row: adw::SpinRow,
/// Shows where the primary mount lives; the folder itself is managed on the /// Shows where the primary mount lives; its Change button picks a new one.
/// Locations page, which owns every local path.
pub(crate) mountpoint_row: adw::ActionRow, pub(crate) mountpoint_row: adw::ActionRow,
/// "Proton purple accent" toggle. Guarded by [`Self::settings_suppress`].
pub(crate) accent_row: adw::SwitchRow,
/// Set while a settings widget is being populated programmatically, so its /// Set while a settings widget is being populated programmatically, so its
/// change handler skips the IPC/systemd side effect. /// change handler skips the IPC/systemd side effect.
pub(crate) settings_suppress: Cell<bool>, pub(crate) settings_suppress: Cell<bool>,
@@ -45,7 +52,7 @@ pub(crate) struct StatusState {
pub(crate) pins_group: adw::PreferencesGroup, pub(crate) pins_group: adw::PreferencesGroup,
/// Whether the pin list is showing every pin or only the first /// Whether the pin list is showing every pin or only the first
/// [`PINS_COLLAPSED`]. A long pin list would otherwise push everything below /// [`PINS_COLLAPSED`]. A long pin list would otherwise push everything below
/// it — including the Developer group — off the end of the page. /// it off the end of the Storage page.
pub(crate) pins_expanded: Cell<bool>, pub(crate) pins_expanded: Cell<bool>,
/// Rows currently shown under [`Self::pins_group`], retained so a refresh can /// Rows currently shown under [`Self::pins_group`], retained so a refresh can
/// diff against them and only rebuild when the pin set actually changes. /// diff against them and only rebuild when the pin set actually changes.
@@ -89,100 +96,94 @@ pub(crate) struct ActivityLine {
pub(crate) fraction: Option<f64>, pub(crate) fraction: Option<f64>,
} }
/// Widgets the settings page hands back for the refresh loop and action wiring. /// Widgets the Preferences dialog and the sidebar footer hand back for the
/// refresh loop and action wiring.
pub(crate) struct MainWidgets { pub(crate) struct MainWidgets {
pub(crate) account_row: adw::ActionRow, pub(crate) prefs: adw::PreferencesDialog,
pub(crate) mount_row: adw::ActionRow, /// The sidebar footer: sync status, quota, account.
/// Live upload/download progress, populated by the refresh loop. pub(crate) footer: gtk4::Box,
pub(crate) transfers_group: adw::PreferencesGroup, /// Opens the Sync page; the footer's status strip is this button.
/// Account-quota group; hidden until `refresh_quota` gets a reading. pub(crate) status_button: gtk4::Button,
pub(crate) quota_group: adw::PreferencesGroup, pub(crate) account_name: gtk4::Label,
pub(crate) avatar: adw::Avatar,
pub(crate) status_icon: gtk4::Image,
pub(crate) status_title: gtk4::Label,
pub(crate) status_detail: gtk4::Label,
pub(crate) quota_box: gtk4::Box,
pub(crate) quota_bar: gtk4::ProgressBar, pub(crate) quota_bar: gtk4::ProgressBar,
pub(crate) quota_label: gtk4::Label, pub(crate) quota_label: gtk4::Label,
pub(crate) cache_bar: gtk4::ProgressBar, pub(crate) cache_bar: gtk4::ProgressBar,
pub(crate) cache_label: gtk4::Label, pub(crate) cache_label: gtk4::Label,
pub(crate) pins_group: adw::PreferencesGroup, pub(crate) pins_group: adw::PreferencesGroup,
pub(crate) logout_button: gtk4::Button,
/// "Start on login" toggle, reflecting the systemd unit's enabled state. /// "Start on login" toggle, reflecting the systemd unit's enabled state.
pub(crate) autostart_row: adw::SwitchRow, pub(crate) autostart_row: adw::SwitchRow,
/// Cache soft-cap editor, in GiB; `0` = unlimited. /// Cache soft-cap editor, in GiB; `0` = unlimited.
pub(crate) budget_row: adw::SpinRow, pub(crate) budget_row: adw::SpinRow,
/// Purges all unpinned cached content. /// Purges all unpinned cached content.
pub(crate) purge_button: gtk4::Button, pub(crate) purge_button: gtk4::Button,
/// Shows the active mountpoint; its suffix button opens the Locations page, /// Shows the active mountpoint; its suffix button picks a new one.
/// which is where the folder is changed.
pub(crate) mountpoint_row: adw::ActionRow, pub(crate) mountpoint_row: adw::ActionRow,
pub(crate) mountpoint_button: gtk4::Button, pub(crate) mountpoint_button: gtk4::Button,
pub(crate) accent_row: adw::SwitchRow,
} }
/// The main (logged-in) page: a libadwaita settings surface — account header, /// Build the two surfaces that replaced the old Settings page:
/// mount status, storage controls (cache budget + purge), system integration ///
/// (start-on-login, mountpoint), the pin list, and developer overrides. Returns /// - the Preferences dialog (General: start on login, mount location,
/// the widgets the refresh loop updates plus the controls to wire. /// appearance; Storage: cache usage, budget, clear, offline files), and
pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) { /// - the sidebar footer (sync status strip, account quota, account menu).
// Account group: identity + sign-out. ///
let account_group = adw::PreferencesGroup::new(); /// Live state (transfers, the folder list) lives on the Sync page instead, and
let account_row = adw::ActionRow::builder().title("Not signed in").build(); /// the app version and user agent in About → Troubleshooting.
let avatar = adw::Avatar::new(40, None, true); pub(crate) fn build_main_page() -> MainWidgets {
account_row.add_prefix(&avatar); // ---- Preferences: General
let logout_button = gtk4::Button::builder() let startup_group = adw::PreferencesGroup::builder().title("Startup").build();
.label("Sign out") let autostart_row = adw::SwitchRow::builder()
.title("Start on login")
.subtitle("Connect Proton Drive automatically when you log in")
.build();
startup_group.add(&autostart_row);
let location_group = adw::PreferencesGroup::builder().title("Location").build();
let mountpoint_row = adw::ActionRow::builder()
.title("Proton Drive folder")
.subtitle("—")
.build();
mountpoint_row.add_css_class("property");
let mountpoint_button = gtk4::Button::builder()
.label("Change…")
.tooltip_text("Choose a different folder for the Proton Drive mount")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
logout_button.add_css_class("flat"); mountpoint_button.add_css_class("flat");
account_row.add_suffix(&logout_button); mountpoint_row.add_suffix(&mountpoint_button);
account_group.add(&account_row); location_group.add(&mountpoint_row);
// Account storage: the Proton account quota (used of total), distinct from let appearance_group = adw::PreferencesGroup::builder().title("Appearance").build();
// the local content cache below. A progress bar + "X of Y used" line, painted let accent_row = adw::SwitchRow::builder()
// by `refresh_quota`. Hidden until the first successful read so a cold start .title("Proton purple accent")
// (or an account the API can't report) shows nothing rather than an empty bar. .subtitle("Use the Proton brand colour instead of the system accent colour")
let quota_group = adw::PreferencesGroup::builder()
.title("Account storage")
.description("Your Proton storage across all products.")
.visible(false)
.build(); .build();
let quota_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); appearance_group.add(&accent_row);
quota_box.set_margin_top(6);
quota_box.set_margin_bottom(6);
let quota_bar = gtk4::ProgressBar::new();
let quota_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
quota_label.add_css_class("dim-label");
quota_box.append(&quota_bar);
quota_box.append(&quota_label);
let quota_row = adw::PreferencesRow::builder()
.activatable(false)
.child(&quota_box)
.build();
quota_group.add(&quota_row);
// Mount group: a read-only status line. The mount is managed automatically let general = adw::PreferencesPage::builder()
// by the systemd user service; there is no toggle to fiddle with. .title("General")
let mount_group = adw::PreferencesGroup::builder().title("Drive").build(); .icon_name("preferences-system-symbolic")
let mount_row = adw::ActionRow::builder()
.title("Proton Drive")
.subtitle("Not mounted")
.build(); .build();
mount_group.add(&mount_row); general.add(&startup_group);
general.add(&location_group);
general.add(&appearance_group);
// Activity group: live upload/download progress. Hidden until the refresh // ---- Preferences: Storage
// loop sees an in-flight transfer from `Request::GetQueueStatus`.
let transfers_group = adw::PreferencesGroup::builder()
.title("Activity")
.description("Files moving to and from Proton Drive.")
.visible(false)
.build();
// Storage group: a progress bar + "X of Y used" label, plus the cache-budget
// editor and a purge button. `budget_row`/`purge_button` are wired in
// `wire_settings`; the bar + label are repainted by the refresh loop.
let storage_group = adw::PreferencesGroup::builder() let storage_group = adw::PreferencesGroup::builder()
.title("Storage") .title("Cache")
.description("Local cache for pinned and recently opened files.") .description("Pinned and recently opened files, kept on this computer.")
.build(); .build();
let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
storage_box.set_margin_top(6); storage_box.set_margin_top(12);
storage_box.set_margin_bottom(6); storage_box.set_margin_bottom(12);
storage_box.set_margin_start(12);
storage_box.set_margin_end(12);
let cache_bar = gtk4::ProgressBar::new(); let cache_bar = gtk4::ProgressBar::new();
let cache_label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); let cache_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
cache_label.add_css_class("dim-label"); cache_label.add_css_class("dim-label");
@@ -198,114 +199,152 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
// as "no eviction". Step in 0.5 GiB; the upper bound is generous. // as "no eviction". Step in 0.5 GiB; the upper bound is generous.
let budget_adj = gtk4::Adjustment::new(0.0, 0.0, 1024.0, 0.5, 1.0, 0.0); let budget_adj = gtk4::Adjustment::new(0.0, 0.0, 1024.0, 0.5, 1.0, 0.0);
let budget_row = adw::SpinRow::builder() let budget_row = adw::SpinRow::builder()
.title("Cache budget (GiB)") .title("Cache size limit (GiB)")
.subtitle("Soft cap for cached content; 0 = unlimited.") .subtitle("Older files are removed past this size; 0 means no limit")
.adjustment(&budget_adj) .adjustment(&budget_adj)
.digits(1) .digits(1)
.build(); .build();
storage_group.add(&budget_row); storage_group.add(&budget_row);
let purge_row = adw::ActionRow::builder() let purge_row = adw::ActionRow::builder()
.title("Purge cache") .title("Clear cache")
.subtitle("Delete cached content. Pinned files are kept.") .subtitle("Remove cached copies. Files kept offline stay.")
.build(); .build();
let purge_button = gtk4::Button::builder() let purge_button = gtk4::Button::builder()
.label("Purge") .label("Clear…")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
purge_button.add_css_class("destructive-action"); purge_button.add_css_class("flat");
purge_row.add_suffix(&purge_button); purge_row.add_suffix(&purge_button);
storage_group.add(&purge_row); storage_group.add(&purge_row);
// System integration: start-on-login + mountpoint chooser.
let system_group = adw::PreferencesGroup::builder()
.title("System integration")
.build();
let autostart_row = adw::SwitchRow::builder()
.title("Start on login")
.subtitle("Mount Proton Drive automatically when you log in.")
.build();
system_group.add(&autostart_row);
// The mountpoint is a *location*, and every other local path this client
// owns is managed on the Locations page. Keeping a second chooser here would
// be a second source of truth for the same setting, so this row reports the
// path and hands the change over.
let mountpoint_row = adw::ActionRow::builder()
.title("Mountpoint")
.subtitle("—")
.build();
let mountpoint_button = gtk4::Button::builder()
.label("Locations")
.tooltip_text("Manage this computer's Proton Drive locations")
.valign(gtk4::Align::Center)
.build();
mountpoint_button.add_css_class("flat");
mountpoint_row.add_suffix(&mountpoint_button);
system_group.add(&mountpoint_row);
// Pins group: filled in by refresh. // Pins group: filled in by refresh.
let pins_group = adw::PreferencesGroup::builder() let pins_group = adw::PreferencesGroup::builder()
.title("Pinned files") .title("Available offline")
.description("Kept available offline on this device.") .description("Files kept on this computer, even without a connection.")
.build(); .build();
// Developer overrides: read-only client identity, for support/debugging. let storage = adw::PreferencesPage::builder()
let dev_group = adw::PreferencesGroup::builder().title("Developer").build(); .title("Storage")
let version_row = adw::ActionRow::builder() .icon_name("drive-harddisk-symbolic")
.title("App version")
.subtitle(pdfs_core::config::APP_VERSION)
.build(); .build();
version_row.add_css_class("property"); storage.add(&storage_group);
let agent_row = adw::ActionRow::builder() storage.add(&pins_group);
.title("User agent")
.subtitle(pdfs_core::config::USER_AGENT) let prefs = adw::PreferencesDialog::builder()
.search_enabled(false)
.build(); .build();
agent_row.add_css_class("property"); prefs.add(&general);
dev_group.add(&version_row); prefs.add(&storage);
dev_group.add(&agent_row);
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 18); // ---- Sidebar footer
inner.set_margin_top(18); let status_icon = gtk4::Image::from_icon_name("content-loading-symbolic");
inner.set_margin_bottom(18); let status_title = gtk4::Label::builder()
inner.set_margin_start(12); .label("Connecting…")
inner.set_margin_end(12); .halign(gtk4::Align::Start)
inner.append(&account_group); .ellipsize(gtk4::pango::EllipsizeMode::End)
inner.append(&quota_group);
inner.append(&mount_group);
inner.append(&transfers_group);
inner.append(&storage_group);
inner.append(&system_group);
inner.append(&pins_group);
inner.append(&dev_group);
let clamp = adw::Clamp::builder()
.maximum_size(560)
.child(&inner)
.build(); .build();
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build(); status_title.add_css_class("heading");
let status_detail = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.visible(false)
.build();
status_detail.add_css_class("caption");
status_detail.add_css_class("dim-label");
let status_text = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
status_text.set_hexpand(true);
status_text.append(&status_title);
status_text.append(&status_detail);
let status_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
status_content.append(&status_icon);
status_content.append(&status_text);
let status_button = gtk4::Button::builder()
.child(&status_content)
.tooltip_text("Show sync status")
.build();
status_button.add_css_class("flat");
status_button.add_css_class("sidebar-status");
( let quota_bar = gtk4::ProgressBar::new();
scroll.upcast(), let quota_label = gtk4::Label::builder()
MainWidgets { .halign(gtk4::Align::Start)
account_row, .ellipsize(gtk4::pango::EllipsizeMode::End)
mount_row, .build();
transfers_group, quota_label.add_css_class("caption");
quota_group, quota_label.add_css_class("dim-label");
quota_bar, let quota_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
quota_label, quota_box.add_css_class("sidebar-quota");
cache_bar, quota_box.set_margin_start(8);
cache_label, quota_box.set_margin_end(8);
pins_group, quota_box.set_visible(false);
logout_button, quota_box.append(&quota_bar);
autostart_row, quota_box.append(&quota_label);
budget_row,
purge_button, let avatar = adw::Avatar::new(28, None, true);
mountpoint_row, let account_name = gtk4::Label::builder()
mountpoint_button, .halign(gtk4::Align::Start)
}, .hexpand(true)
) .ellipsize(gtk4::pango::EllipsizeMode::Middle)
.build();
let account_menu = gio::Menu::new();
account_menu.append(Some("Preferences"), Some("win.preferences"));
let sign_out = gio::Menu::new();
sign_out.append(Some("Sign Out…"), Some("win.sign-out"));
account_menu.append_section(None, &sign_out);
let account_button = gtk4::MenuButton::builder()
.icon_name("view-more-symbolic")
.tooltip_text("Account")
.menu_model(&account_menu)
.valign(gtk4::Align::Center)
.build();
account_button.add_css_class("flat");
let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
account.add_css_class("sidebar-account");
account.append(&avatar);
account.append(&account_name);
account.append(&account_button);
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
footer.add_css_class("sidebar-footer");
footer.append(&status_button);
footer.append(&quota_box);
footer.append(&account);
MainWidgets {
prefs,
footer,
status_button,
account_name,
avatar,
status_icon,
status_title,
status_detail,
quota_box,
quota_bar,
quota_label,
cache_bar,
cache_label,
pins_group,
autostart_row,
budget_row,
purge_button,
mountpoint_row,
mountpoint_button,
accent_row,
}
} }
/// How many pins the Settings list shows before collapsing the rest behind a /// The Sync page's live-transfers group, hidden until the refresh loop sees an
/// in-flight transfer from [`Request::GetQueueStatus`].
pub(crate) fn build_transfers_group() -> adw::PreferencesGroup {
adw::PreferencesGroup::builder()
.title("Transfers")
.description("Files moving to and from Proton Drive.")
.visible(false)
.build()
}
/// How many pins the Storage preferences list before collapsing the rest behind a
/// "Show all" row. Enough to recognise the list at a glance; short enough that /// "Show all" row. Enough to recognise the list at a glance; short enough that
/// the groups below it stay reachable. /// the groups below it stay reachable.
pub(crate) const PINS_COLLAPSED: usize = 6; pub(crate) const PINS_COLLAPSED: usize = 6;
@@ -317,8 +356,8 @@ pub(crate) const BUDGET_DEBOUNCE: Duration = Duration::from_millis(600);
/// Bytes per GiB, for the cache-budget editor's unit conversion. /// Bytes per GiB, for the cache-budget editor's unit conversion.
pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0; pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
/// Wire the Settings-page controls: the cache-budget editor, the purge button, /// Wire the Preferences controls: the cache-budget editor, the purge button, the
/// the start-on-login switch and the mountpoint chooser. Initial widget state is /// start-on-login switch, the mountpoint chooser and the accent toggle. Initial widget state is
/// read once from config / systemd here (the refresh loop owns only the live /// read once from config / systemd here (the refresh loop owns only the live
/// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the /// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the
/// programmatic populate so the change handlers don't fire on it. /// programmatic populate so the change handlers don't fire on it.
@@ -338,6 +377,9 @@ pub(crate) fn wire_settings(
.mountpoint_row .mountpoint_row
.set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string()); .set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string());
ui.status.autostart_row.set_active(service::is_enabled()); ui.status.autostart_row.set_active(service::is_enabled());
ui.status
.accent_row
.set_active(config.proton_accent.unwrap_or(false));
ui.status.settings_suppress.set(false); ui.status.settings_suppress.set(false);
// Cache budget: a user edit applies the new soft cap on the daemon (which // Cache budget: a user edit applies the new soft cap on the daemon (which
@@ -370,11 +412,14 @@ pub(crate) fn wire_settings(
purge_button.connect_clicked(move |_| { purge_button.connect_clicked(move |_| {
let ui = ui_purge.clone(); let ui = ui_purge.clone();
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Purge cache") .heading("Clear Cache?")
.body("Delete all cached content that isn't pinned? Pinned files stay offline.") .body(
"Cached copies are removed from this computer and download again when \
you open them. Files kept available offline stay.",
)
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", "Cancel");
dialog.add_response("purge", "Purge"); dialog.add_response("purge", "Clear Cache");
dialog.set_response_appearance("purge", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("purge", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -383,12 +428,12 @@ pub(crate) fn wire_settings(
settings_request( settings_request(
&ui, &ui,
Request::PurgeCache, Request::PurgeCache,
"Cache purged", "Cache cleared",
"Couldn't purge cache", "Couldn't clear the cache",
); );
} }
}); });
dialog.present(ui_window(&ui_purge).as_ref()); dialog.present(Some(&ui_purge.status.prefs));
}); });
// Start on login: enable/disable the systemd unit without stopping a live // Start on login: enable/disable the systemd unit without stopping a live
@@ -405,10 +450,27 @@ pub(crate) fn wire_settings(
} }
}); });
// Mountpoint: the chooser lives on the Locations page, next to every other
// local path; this button is the way there.
let ui_mp = ui.clone(); let ui_mp = ui.clone();
mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations")); mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
// Accent: saved with the rest of the config, applied right away.
let ui_accent = ui.clone();
ui.status.accent_row.connect_active_notify(move |row| {
if ui_accent.status.settings_suppress.get() {
return;
}
let on = row.is_active();
set_proton_accent(on);
let mut config = ui_accent.dirs.load_config();
config.proton_accent = Some(on);
if let Err(e) = ui_accent.dirs.save_config(&config) {
toast_error(
&ui_accent,
"Couldn't save the accent colour",
&e.to_string(),
);
}
});
} }
/// Run a settings control-socket round-trip (budget / purge) on a worker thread, /// Run a settings control-socket round-trip (budget / purge) on a worker thread,
@@ -522,8 +584,11 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
ui.stack.set_visible_child_name("browser"); ui.stack.set_visible_child_name("browser");
} }
ui.nav.set_collapsed(false); ui.nav.set_collapsed(false);
ui.status.account_row.set_title(&s.username); if ui.status.account_name.label() != s.username {
ui.status.account_row.set_subtitle("Proton account"); ui.status.account_name.set_label(&s.username);
ui.status.account_name.set_tooltip_text(Some(&s.username));
ui.status.avatar.set_text(Some(&s.username));
}
} }
None => { None => {
ui.stack.set_visible_child_name("login"); ui.stack.set_visible_child_name("login");
@@ -546,8 +611,10 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
if ui.stack.visible_child_name().as_deref() == Some("takeout") || ui.takeout.running.get() { if ui.stack.visible_child_name().as_deref() == Some("takeout") || ui.takeout.running.get() {
refresh_takeout(ui); refresh_takeout(ui);
} }
// The quota sits in the sidebar footer, visible on every page; its TTL
// keeps this from asking more than once a minute.
refresh_quota(ui);
match ui.stack.visible_child_name().as_deref() { match ui.stack.visible_child_name().as_deref() {
Some("main" | "browser") => refresh_quota(ui),
Some("locations") => refresh_locations(ui), Some("locations") => refresh_locations(ui),
Some("activity") => refresh_activity(ui), Some("activity") => refresh_activity(ui),
_ => {} _ => {}
@@ -559,7 +626,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
const QUOTA_TTL: Duration = Duration::from_secs(60); const QUOTA_TTL: Duration = Duration::from_secs(60);
/// Fetch the account quota (if the last reading is stale) and paint both the /// Fetch the account quota (if the last reading is stale) and paint both the
/// Settings storage group and Files status bar. A failed fetch leaves the last /// sidebar footer and Files status bar. A failed fetch leaves the last
/// good reading in place. /// good reading in place.
pub(crate) fn refresh_quota(ui: &Rc<Ui>) { pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
if ui.status.quota_inflight.get() { if ui.status.quota_inflight.get() {
@@ -583,7 +650,7 @@ pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
{ {
ui.status.quota_checked_at.set(Some(Instant::now())); ui.status.quota_checked_at.set(Some(Instant::now()));
paint_account_quota(&ui, max_space, used_space); paint_account_quota(&ui, max_space, used_space);
ui.status.quota_group.set_visible(true); ui.status.quota_box.set_visible(true);
} else if ui.status.quota_checked_at.get().is_none() { } else if ui.status.quota_checked_at.get().is_none() {
// Match Dolphin: capacity information does not occupy the bar until // Match Dolphin: capacity information does not occupy the bar until
// the backing observer has real figures. // the backing observer has real figures.
@@ -871,6 +938,8 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
online, online,
pending_uploads, pending_uploads,
pending_changes, pending_changes,
failing_ops,
failing_error,
.. ..
})) => { })) => {
set_mounted(&ui, true); set_mounted(&ui, true);
@@ -878,14 +947,19 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
// in it: it is why a file that looks saved is not on the remote // in it: it is why a file that looks saved is not on the remote
// yet, and offline is usually the reason it is still queued. // yet, and offline is usually the reason it is still queued.
let queued = pending_summary(pending_uploads, pending_changes); let queued = pending_summary(pending_uploads, pending_changes);
ui.status.mount_row.set_subtitle(&match (online, queued) { let state = if failing_ops > 0 {
(true, None) => format!("Mounted at {mountpoint}"), SyncState::Attention {
(true, Some(q)) => format!("Mounted at {mountpoint} — {q}"), count: failing_ops,
(false, None) => { error: failing_error,
format!("Mounted at {mountpoint} — offline, cached files only")
} }
(false, Some(q)) => format!("Mounted at {mountpoint} — offline, {q}"), } else if !online {
}); SyncState::Offline { queued }
} else if let Some(queued) = queued {
SyncState::Syncing { queued }
} else {
SyncState::UpToDate { mountpoint }
};
paint_sync_status(&ui, state);
let fraction = if budget == 0 { let fraction = if budget == 0 {
0.0 0.0
} else { } else {
@@ -907,7 +981,7 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
// rows and cache read-out so the page doesn't flicker on a blip. // rows and cache read-out so the page doesn't flicker on a blip.
_ => { _ => {
set_mounted(&ui, false); set_mounted(&ui, false);
ui.status.mount_row.set_subtitle("Not mounted"); paint_sync_status(&ui, SyncState::Disconnected);
for r in ui.status.pin_rows.borrow().iter() { for r in ui.status.pin_rows.borrow().iter() {
if let Some(b) = &r.unpin { if let Some(b) = &r.unpin {
b.set_sensitive(false); b.set_sensitive(false);
@@ -918,6 +992,82 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
}); });
} }
/// What the sidebar's status strip reports, most urgent first.
pub(crate) enum SyncState {
/// Operations keep failing; the user may have to act.
Attention {
count: u64,
error: Option<String>,
},
/// No connection to Proton: cached files only, changes wait.
Offline {
queued: Option<String>,
},
/// Local changes are on their way up.
Syncing {
queued: String,
},
UpToDate {
mountpoint: String,
},
/// No mount daemon answered: still starting, or the service is down.
Disconnected,
}
/// Paint the sidebar status strip: icon, one-line state, and a detail line.
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
let (icon, class, title, detail) = match state {
SyncState::Attention { count, error } => (
"dialog-warning-symbolic",
Some("error"),
format!(
"{} need{} attention",
count_noun(count as usize, "change", "changes"),
if count == 1 { "s" } else { "" }
),
error,
),
SyncState::Offline { queued } => (
"network-offline-symbolic",
Some("warning"),
"Offline".to_string(),
Some(queued.unwrap_or_else(|| "Cached files only".to_string())),
),
SyncState::Syncing { queued } => (
"emblem-synchronizing-symbolic",
None,
"Syncing".to_string(),
Some(queued),
),
SyncState::UpToDate { mountpoint } => (
"emblem-ok-symbolic",
Some("success"),
"Up to date".to_string(),
Some(mountpoint),
),
SyncState::Disconnected => (
"network-offline-symbolic",
Some("warning"),
"Not connected".to_string(),
Some("Proton Drive isn't running".to_string()),
),
};
let image = &ui.status.status_icon;
image.set_icon_name(Some(icon));
for c in ["success", "warning", "error"] {
image.remove_css_class(c);
}
if let Some(class) = class {
image.add_css_class(class);
}
ui.status.status_title.set_label(&title);
ui.status.status_detail.set_visible(detail.is_some());
ui.status
.status_detail
.set_label(detail.as_deref().unwrap_or_default());
ui.status.status_detail.set_tooltip_text(detail.as_deref());
}
/// Render the pins group from `pins`, with the unpin buttons enabled only while a /// Render the pins group from `pins`, with the unpin buttons enabled only while a
/// mount daemon is running (`mounted`). Diffs against the last batch by path: when /// mount daemon is running (`mounted`). Diffs against the last batch by path: when
/// the set is unchanged (the common case on the 2s tick) it only flips the unpin /// the set is unchanged (the common case on the 2s tick) it only flips the unpin
@@ -941,8 +1091,8 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
if pins.is_empty() { if pins.is_empty() {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("No pinned files") .title("No files kept offline")
.subtitle("Right-click a file in the mount to keep it offline.") .subtitle("Right-click a file and choose “Make available offline”.")
.build(); .build();
ui.status.pins_group.add(&row); ui.status.pins_group.add(&row);
ui.status ui.status
@@ -970,11 +1120,11 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
.title(&name) .title(&name)
.subtitle(&pin.path) .subtitle(&pin.path)
.build(); .build();
let icon = gtk4::Image::from_icon_name("emblem-documents-symbolic"); let icon = gtk4::Image::from_icon_name("pdfs-offline-symbolic");
row.add_prefix(&icon); row.add_prefix(&icon);
let unpin = gtk4::Button::builder() let unpin = gtk4::Button::builder()
.icon_name("user-trash-symbolic") .icon_name("pdfs-online-only-symbolic")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.tooltip_text("Make online only") .tooltip_text("Make online only")
.sensitive(mounted) .sensitive(mounted)
@@ -983,12 +1133,24 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
let ui_btn = ui.clone(); let ui_btn = ui.clone();
let path = pin.path.clone(); let path = pin.path.clone();
unpin.connect_clicked(move |_| { unpin.connect_clicked(move |_| {
let socket = ui_btn.dirs.control_socket(); let rx = spawn_request(
match send(&socket, &Request::Unpin { path: path.clone() }) { ui_btn.dirs.control_socket(),
Ok(Response::Error { message, .. }) => tracing::error!("unpin failed: {message}"), Request::Unpin { path: path.clone() },
Ok(_) => refresh(&ui_btn), );
Err(e) => tracing::error!("unpin request failed: {e}"), let ui = ui_btn.clone();
} glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't make it online only", &message, kind)
}
Ok(Ok(_)) => refresh(&ui),
_ => toast_error(
&ui,
"Couldn't make it online only",
"The mount service didn't respond.",
),
}
});
}); });
row.add_suffix(&unpin); row.add_suffix(&unpin);
@@ -1004,7 +1166,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
.title(if expanded { .title(if expanded {
"Show fewer".to_string() "Show fewer".to_string()
} else { } else {
format!("Show all {} pinned files", pins.len()) format!("Show all {}", count_noun(pins.len(), "file", "files"))
}) })
.activatable(true) .activatable(true)
.build(); .build();
+9 -20
View File
@@ -17,12 +17,12 @@
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only //! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
//! stages paths, launches, polls [`Request::ImportStatus`] for the final report, //! stages paths, launches, polls [`Request::ImportStatus`] for the final report,
//! and reads live progress off the import's job in the transfer snapshot the //! and reads live progress off the import's job in the transfer snapshot the
//! Settings tick already fetches (see [`takeout_progress`]). //! status tick already fetches (see [`takeout_progress`]).
use crate::*; use crate::*;
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`]. /// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
/// Matching on it is how this page borrows the progress the Settings tick is /// Matching on it is how this page borrows the progress the status tick is
/// already polling instead of adding a second poll of its own. /// already polling instead of adding a second poll of its own.
const IMPORT_JOB: &str = "Importing Google Photos"; const IMPORT_JOB: &str = "Importing Google Photos";
@@ -135,11 +135,6 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
.build(); .build();
back_button.add_css_class("flat"); back_button.add_css_class("flat");
let title = gtk4::Label::builder()
.label("Import from Google Photos")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let subtitle = gtk4::Label::builder() let subtitle = gtk4::Label::builder()
.label("Add a Google Takeout export to your Proton Photos timeline.") .label("Add a Google Takeout export to your Proton Photos timeline.")
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
@@ -148,15 +143,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
.build(); .build();
subtitle.add_css_class("dim-label"); subtitle.add_css_class("dim-label");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2); let subtitle_clamp = adw::Clamp::builder().child(&subtitle).build();
titles.set_hexpand(true);
titles.append(&title);
titles.append(&subtitle);
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&back_button);
header.append(&titles);
let header_clamp = adw::Clamp::builder().child(&header).build();
// Drop zone. It is a hint, not the only way in — the button under it does // Drop zone. It is a hint, not the only way in — the button under it does
// the same thing for anyone not dragging, and the drop target covers the // the same thing for anyone not dragging, and the drop target covers the
@@ -235,7 +222,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
actions.append(&import_button); actions.append(&import_button);
// Progress: the import's job from the transfer snapshot, so this page shows // Progress: the import's job from the transfer snapshot, so this page shows
// the same numbers the Settings Activity list does. // the same numbers the Sync page's Transfers list does.
let progress_group = adw::PreferencesGroup::builder() let progress_group = adw::PreferencesGroup::builder()
.title("Import in progress") .title("Import in progress")
.visible(false) .visible(false)
@@ -284,8 +271,10 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
page.set_margin_bottom(18); page.set_margin_bottom(18);
page.set_margin_start(18); page.set_margin_start(18);
page.set_margin_end(18); page.set_margin_end(18);
page.append(&header_clamp); page.append(&subtitle_clamp);
page.append(&scroll); page.append(&scroll);
let (frame, header, _) = page_frame("Import from Google Photos", &page);
header.pack_start(&back_button);
let widgets = TakeoutWidgets { let widgets = TakeoutWidgets {
list_group: list_group.clone(), list_group: list_group.clone(),
@@ -302,7 +291,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
progress_bar: progress_bar.clone(), progress_bar: progress_bar.clone(),
summary_group: summary_group.clone(), summary_group: summary_group.clone(),
}; };
(page.upcast(), widgets) (frame.upcast(), widgets)
} }
pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) { pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
@@ -755,7 +744,7 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
ui.takeout.summary_group.set_visible(true); ui.takeout.summary_group.set_visible(true);
} }
/// Paint the import's progress from the transfer snapshot the Settings tick /// Paint the import's progress from the transfer snapshot the status tick
/// already fetched, so this page costs no extra poll. A job with no known total /// already fetched, so this page costs no extra poll. A job with no known total
/// pulses instead of showing a fraction, the same as the Activity list. /// pulses instead of showing a fraction, the same as the Activity list.
pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) { pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
+14 -30
View File
@@ -10,7 +10,7 @@ pub(crate) struct TrashState {
/// Empties the trash; insensitive while it is empty (or unread). /// Empties the trash; insensitive while it is empty (or unread).
pub(crate) empty: gtk4::Button, pub(crate) empty: gtk4::Button,
/// "12 items" under the page title. /// "12 items" under the page title.
pub(crate) subtitle: gtk4::Label, pub(crate) subtitle: adw::WindowTitle,
} }
/// The widgets of the Trash page that a load repaints. /// The widgets of the Trash page that a load repaints.
@@ -22,7 +22,7 @@ pub(crate) struct TrashWidgets {
pub(crate) retry: gtk4::Button, pub(crate) retry: gtk4::Button,
pub(crate) empty: gtk4::Button, pub(crate) empty: gtk4::Button,
pub(crate) refresh: gtk4::Button, pub(crate) refresh: gtk4::Button,
pub(crate) subtitle: gtk4::Label, pub(crate) subtitle: adw::WindowTitle,
} }
/// The Trash page: a flat list of everything Drive is holding in the trash, each /// The Trash page: a flat list of everything Drive is holding in the trash, each
@@ -35,31 +35,13 @@ pub(crate) struct TrashWidgets {
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
let model = gio::ListStore::new::<BoxedAnyObject>(); let model = gio::ListStore::new::<BoxedAnyObject>();
let title = gtk4::Label::builder()
.label("Trash")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let subtitle = gtk4::Label::builder().halign(gtk4::Align::Start).build();
subtitle.add_css_class("dim-label");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
titles.append(&subtitle);
let empty = gtk4::Button::builder() let empty = gtk4::Button::builder()
.label("Empty Trash") .label("Empty…")
.valign(gtk4::Align::Center) .tooltip_text("Permanently delete everything in the Trash")
.sensitive(false) .sensitive(false)
.build(); .build();
empty.add_css_class("destructive-action");
let refresh = refresh_button(); let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&titles);
header.append(&refresh);
header.append(&empty);
let retry = gtk4::Button::builder() let retry = gtk4::Button::builder()
.label("Retry") .label("Retry")
.halign(gtk4::Align::Center) .halign(gtk4::Align::Center)
@@ -91,15 +73,17 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
content.add_named(&status, Some("status")); content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12); inner.set_margin_top(18);
inner.set_margin_bottom(12); inner.set_margin_bottom(18);
inner.set_margin_start(12); inner.set_margin_start(18);
inner.set_margin_end(12); inner.set_margin_end(18);
inner.append(&header);
inner.append(&content); inner.append(&content);
let (frame, header, subtitle) = page_frame("Trash", &inner);
header.pack_start(&empty);
header.pack_end(&refresh);
( (
inner.upcast(), frame.upcast(),
TrashWidgets { TrashWidgets {
model, model,
list, list,
@@ -294,7 +278,7 @@ pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &s
ui.trash.status.set_description(Some(description)); ui.trash.status.set_description(Some(description));
ui.trash.retry.set_visible(retry); ui.trash.retry.set_visible(retry);
ui.trash.content.set_visible_child_name("status"); ui.trash.content.set_visible_child_name("status");
ui.trash.subtitle.set_label(""); ui.trash.subtitle.set_subtitle("");
} }
/// Repopulate the trash list, most recently modified first — the order in which a /// Repopulate the trash list, most recently modified first — the order in which a
@@ -313,7 +297,7 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
return; return;
} }
ui.trash.content.set_visible_child_name("list"); ui.trash.content.set_visible_child_name("list");
ui.trash.subtitle.set_label(&match entries.len() { ui.trash.subtitle.set_subtitle(&match entries.len() {
1 => "1 item".to_string(), 1 => "1 item".to_string(),
n => format!("{n} items"), n => format!("{n} items"),
}); });