From cf9e1665cadb1c68f78beca2c8eab80899e80671 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 23 Sep 2026 01:50:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(core,gui):=20phase=201=20shell=20=E2=80=94?= =?UTF-8?q?=20per-page=20headers,=20sidebar=20footer,=20Preferences=20dial?= =?UTF-8?q?og?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/pdfs-core/src/config.rs | 8 + crates/pdfs-gui/build.rs | 3 + crates/pdfs-gui/resources/pdfs.gresource.xml | 6 + crates/pdfs-gui/resources/style.css | 79 +++ crates/pdfs-gui/src/app/main.rs | 417 ++++++++----- crates/pdfs-gui/src/app/pages/activity.rs | 23 +- crates/pdfs-gui/src/app/pages/albums.rs | 7 +- crates/pdfs-gui/src/app/pages/browser.rs | 28 +- crates/pdfs-gui/src/app/pages/devices.rs | 36 +- crates/pdfs-gui/src/app/pages/locations.rs | 40 +- crates/pdfs-gui/src/app/pages/login.rs | 42 +- crates/pdfs-gui/src/app/pages/photos.rs | 57 +- crates/pdfs-gui/src/app/pages/shared.rs | 38 +- crates/pdfs-gui/src/app/pages/shared_by_me.rs | 23 +- crates/pdfs-gui/src/app/pages/status.rs | 560 +++++++++++------- crates/pdfs-gui/src/app/pages/takeout.rs | 29 +- crates/pdfs-gui/src/app/pages/trash.rs | 44 +- 17 files changed, 861 insertions(+), 579 deletions(-) create mode 100644 crates/pdfs-gui/resources/style.css diff --git a/crates/pdfs-core/src/config.rs b/crates/pdfs-core/src/config.rs index bdbf8d5..12854a4 100644 --- a/crates/pdfs-core/src/config.rs +++ b/crates/pdfs-core/src/config.rs @@ -119,6 +119,11 @@ pub struct AppConfig { /// field. #[serde(default)] pub prompt: Option, + /// 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, } impl Default for AppConfig { @@ -133,6 +138,7 @@ impl Default for AppConfig { conflict_sweep: None, open_with: None, prompt: None, + proton_accent: None, } } } @@ -477,11 +483,13 @@ mod tests { conflict_sweep: Some(SweepMode::Enforce), open_with: None, prompt: None, + proton_accent: Some(true), }; let json = serde_json::to_string(&config).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.user_agent, "test-agent/1.0"); + assert_eq!(decoded.proton_accent, Some(true)); } #[test] diff --git a/crates/pdfs-gui/build.rs b/crates/pdfs-gui/build.rs index 59e8691..7bb028f 100644 --- a/crates/pdfs-gui/build.rs +++ b/crates/pdfs-gui/build.rs @@ -9,6 +9,8 @@ fn main() { &format!("{}/pdfs.gresource", out_dir), "--sourcedir", "resources/icons", + "--sourcedir", + "resources", "resources/pdfs.gresource.xml", ]) .status() @@ -23,4 +25,5 @@ fn main() { println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml"); println!("cargo:rerun-if-changed=resources/icons"); + println!("cargo:rerun-if-changed=resources/style.css"); } diff --git a/crates/pdfs-gui/resources/pdfs.gresource.xml b/crates/pdfs-gui/resources/pdfs.gresource.xml index e893c8a..79929c6 100644 --- a/crates/pdfs-gui/resources/pdfs.gresource.xml +++ b/crates/pdfs-gui/resources/pdfs.gresource.xml @@ -10,4 +10,10 @@ scalable/actions/pdfs-offline-symbolic.svg scalable/actions/pdfs-online-only-symbolic.svg + + + style.css + diff --git a/crates/pdfs-gui/resources/style.css b/crates/pdfs-gui/resources/style.css new file mode 100644 index 0000000..633348d --- /dev/null +++ b/crates/pdfs-gui/resources/style.css @@ -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; } diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 3f029f2..4af4c6c 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -60,8 +60,8 @@ use pdfs_core::service; const APP_ID: &str = "io.narl.proton-drive-linux"; -/// Proton brand purple, applied as the libadwaita accent so switches, buttons, -/// links and the storage bar all pick it up. +/// Proton brand purple, applied as the libadwaita accent when the user opts in +/// (Preferences → Appearance), so switches, buttons and links all pick it up. const PROTON_PURPLE: &str = "#6d4aff"; /// How often the window re-reads mount status, cache usage and the pin list. @@ -97,9 +97,9 @@ struct Ui { /// rename, a failed upload, a purge) is reported here rather than in a modal, /// so an action never interrupts what the user is doing next. toasts: adw::ToastOverlay, - /// Header spinner shown while any open/load round-trip is in flight; ref- - /// counted via [`Self::busy`] so concurrent operations don't stop it early. - spinner: gtk4::Spinner, + /// How many open/load round-trips are in flight. While any is, every page + /// header shows its spinner (see [`page_frame`]); ref-counted so concurrent + /// operations don't hide it early. busy: Cell, /// 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. @@ -111,7 +111,7 @@ struct Ui { /// Whether the last refresh saw a live mount daemon. Gates the unpin buttons /// (which need the daemon to evict + re-hydrate) and every mutating action. mounted: RefCell, - /// 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. sidebar: gtk4::ListBox, /// 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. fn busy_begin(&self) { self.busy.set(self.busy.get() + 1); - self.spinner.set_visible(true); - self.spinner.start(); + set_busy_spinners(true); } /// 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); self.busy.set(remaining); if remaining == 0 { - self.spinner.stop(); - self.spinner.set_visible(false); + set_busy_spinners(false); } } @@ -199,6 +197,9 @@ fn main() -> glib::ExitCode { let app = adw::Application::builder().application_id(APP_ID).build(); app.connect_startup(|_| { load_proton_theme(); + if let Ok(dirs) = AppDirs::new() { + set_proton_accent(dirs.load_config().proton_accent.unwrap_or(false)); + } // Refresh the file manager's right-click pin/unpin scripts, so they always // match the installed `pdfs`. pdfs_core::shell::install_file_manager_scripts(); @@ -216,11 +217,10 @@ fn spawn_tray() { } } -/// Install a CSS provider that overrides libadwaita's accent colour with Proton -/// purple, app-wide. Named-colour overrides recolour the stock widgets (switch, -/// buttons, progress fill) without per-widget styling. +/// Register the bundled GResources (custom icons, the stylesheet) and load the +/// app stylesheet. The Proton accent is a separate provider, applied by +/// [`set_proton_accent`] from the saved preference. fn load_proton_theme() { - // Compile-in and register our custom GResources (e.g. custom icons) let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource")); let resource_data = glib::Bytes::from_static(bytes); if let Ok(resource) = gio::Resource::from_data(&resource_data) { @@ -229,82 +229,53 @@ fn load_proton_theme() { tracing::error!("failed to load gresource bundle"); } - let css = format!( - "@define-color accent_bg_color {PROTON_PURPLE};\n\ - @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 Some(display) = gtk4::gdk::Display::default() else { + return; + }; let provider = gtk4::CssProvider::new(); - provider.load_from_string(&css); - if let Some(display) = gtk4::gdk::Display::default() { - gtk4::style_context_add_provider_for_display( - &display, - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); + provider.load_from_resource("/de/nils/protondrivelinux/style.css"); + gtk4::style_context_add_provider_for_display( + &display, + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons"); +} - // Register custom icons directory from our GResource with the icon theme - let icon_theme = gtk4::IconTheme::for_display(&display); - icon_theme.add_resource_path("/de/nils/protondrivelinux/icons"); - } +thread_local! { + /// The Proton purple accent provider while it is installed. Kept so turning + /// the preference off can remove exactly what turning it on added. + static PROTON_ACCENT: RefCell> = 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, @@ -320,7 +291,7 @@ fn build_window(app: &adw::Application) { let stack = adw::ViewStack::new(); 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 (gallery_page, gallery_widgets) = build_gallery_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 (trash_page, trash_widgets) = build_trash_page(); let (takeout_page, takeout_widgets) = build_takeout_page(); - stack.add_named(&login_page, Some("login")); - stack.add_named(&main_page, Some("main")); + // The login page has no title and no actions, but it still needs a header + // bar: it is the only thing carrying the window controls. + let login_frame = adw::ToolbarView::new(); + login_frame.add_top_bar(&{ + let header = adw::HeaderBar::new(); + header.set_show_title(false); + header + }); + login_frame.set_content(Some(&login_page)); + stack.add_named(&login_frame, Some("login")); stack.add_named(&browser_page, Some("browser")); stack.add_named(&gallery_page, Some("gallery")); stack.add_named(&shared_by_me_page, Some("sharedbyme")); @@ -345,21 +324,13 @@ fn build_window(app: &adw::Application) { // Sidebar: the signed-in destinations. Selecting a row swaps the page stack; // `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g. // login lands on Files). - let (sidebar_page, sidebar_list) = build_sidebar(); - - // 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)); + let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer); + // Every page brings its own header bar (see `page_frame`), so the content + // side is the bare stack. let content_page = adw::NavigationPage::builder() .title("Proton Drive") - .child(&toolbar) + .child(&stack) .build(); let split = adw::NavigationSplitView::builder() .sidebar(&sidebar_page) @@ -377,7 +348,6 @@ fn build_window(app: &adw::Application) { dirs, stack: stack.clone(), toasts: toasts.clone(), - spinner: spinner.clone(), busy: Cell::new(0), opening: RefCell::new(HashSet::new()), session: RefCell::new(auth::load().ok()), @@ -393,14 +363,18 @@ fn build_window(app: &adw::Application) { }, status: StatusState { status_inflight: Cell::new(false), - account_row: main_widgets.account_row.clone(), - mount_row: main_widgets.mount_row.clone(), - transfers_group: main_widgets.transfers_group.clone(), + transfers_group: locations_widgets.transfers_group.clone(), + prefs: main_widgets.prefs.clone(), + account_name: main_widgets.account_name.clone(), + avatar: main_widgets.avatar.clone(), + status_icon: main_widgets.status_icon.clone(), + status_title: main_widgets.status_title.clone(), + status_detail: main_widgets.status_detail.clone(), transfer_rows: RefCell::new(Vec::new()), transfers_inflight: Cell::new(false), cache_bar: main_widgets.cache_bar.clone(), cache_label: main_widgets.cache_label.clone(), - quota_group: main_widgets.quota_group.clone(), + quota_box: main_widgets.quota_box.clone(), quota_bar: main_widgets.quota_bar.clone(), quota_label: main_widgets.quota_label.clone(), quota_inflight: Cell::new(false), @@ -408,6 +382,7 @@ fn build_window(app: &adw::Application) { autostart_row: main_widgets.autostart_row.clone(), budget_row: main_widgets.budget_row.clone(), mountpoint_row: main_widgets.mountpoint_row.clone(), + accent_row: main_widgets.accent_row.clone(), settings_suppress: Cell::new(false), budget_source: RefCell::new(None), pins_expanded: Cell::new(false), @@ -485,7 +460,6 @@ fn build_window(app: &adw::Application) { import: gallery_widgets.import.clone(), empty_actions: gallery_widgets.empty_actions.clone(), title: gallery_widgets.title.clone(), - subtitle: gallery_widgets.subtitle.clone(), albums: gallery_widgets.albums.clone(), albums_stack: gallery_widgets.albums_stack.clone(), albums_status: gallery_widgets.albums_status.clone(), @@ -598,7 +572,10 @@ fn build_window(app: &adw::Application) { }, }); 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( &ui, &main_widgets.purge_button, @@ -698,7 +675,9 @@ fn build_window(app: &adw::Application) { .content(&toasts) .build(); install_shortcuts(&ui, &window); - install_window_actions(&window); + install_window_actions(&ui, &window); + app.set_accels_for_action("win.preferences", &["comma"]); + app.set_accels_for_action("win.shortcuts", &["question"]); refresh(&ui); // 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, -/// and each entry is `(stack page name, label, icon)`. -const DESTINATIONS: [(&str, &str, &str); 9] = [ +/// and each entry is `(stack page name, label, icon)`. The places in the account +/// come first; the rows from [`SIDEBAR_SYNC_SECTION`] on are about this computer's +/// sync, set off by a separator. +const DESTINATIONS: [(&str, &str, &str); 8] = [ ("browser", "My files", "folder-symbolic"), - ("sharedbyme", "Shared", "emblem-shared-symbolic"), + ("gallery", "Photos", "image-x-generic-symbolic"), ("shared", "Shared with me", "system-users-symbolic"), - ("locations", "Locations", "drive-harddisk-symbolic"), + ("sharedbyme", "Shared by me", "emblem-shared-symbolic"), ("devices", "Computers", "computer-symbolic"), - ("gallery", "Gallery", "image-x-generic-symbolic"), - ("activity", "Activity", "document-open-recent-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. -/// Returns the page (for the split view) and the list (to drive + reflect the -/// current page). -fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) { +/// Index of the first row in the sidebar's sync section. +const SIDEBAR_SYNC_SECTION: i32 = 6; + +/// The navigation sidebar: the destinations over a footer carrying the sync +/// status, the account quota and the account menu. Returns the page (for the +/// split view) and the list (to drive + reflect the current page). +fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) { let list = gtk4::ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::Single); list.add_css_class("navigation-sidebar"); for (_, label, icon) in DESTINATIONS { - let row = adw::ActionRow::builder().title(label).build(); - row.add_prefix(>k4::Image::from_icon_name(icon)); + let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + row_box.append(>k4::Image::from_icon_name(icon)); + row_box.append(>k4::Label::new(Some(label))); + let row = gtk4::ListBoxRow::builder().child(&row_box).build(); list.append(&row); } + list.set_header_func(|row, _| { + if row.index() == SIDEBAR_SYNC_SECTION { + let separator = gtk4::Separator::new(gtk4::Orientation::Horizontal); + separator.set_margin_top(6); + separator.set_margin_bottom(6); + row.set_header(Some(&separator)); + } else { + row.set_header(gtk4::Widget::NONE); + } + }); let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let icon = gtk4::Image::from_icon_name("folder-remote-symbolic"); icon.add_css_class("brand-icon"); brand.append(&icon); - brand.append(>k4::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(); header.set_title_widget(Some(&brand)); + header.pack_end(&build_primary_menu()); let scroll = gtk4::ScrolledWindow::builder() .hscrollbar_policy(gtk4::PolicyType::Never) @@ -763,6 +761,7 @@ fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) { let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header); toolbar.set_content(Some(&scroll)); + toolbar.add_bottom_bar(footer); let page = adw::NavigationPage::builder() .title("Proton Drive") @@ -771,6 +770,46 @@ fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) { (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> = 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, +) -> (adw::ToolbarView, adw::HeaderBar, adw::WindowTitle) { + let window_title = adw::WindowTitle::new(title, ""); + let spinner = gtk4::Spinner::new(); + spinner.set_visible(false); + BUSY_SPINNERS.with(|spinners| spinners.borrow_mut().push(spinner.clone())); + let title_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + title_box.append(&window_title); + title_box.append(&spinner); + + let header = adw::HeaderBar::new(); + header.set_title_widget(Some(&title_box)); + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(content)); + (toolbar, header, window_title) +} + /// The sidebar destination a page belongs under. Most pages are their own /// destination; a sub-page reached from one (Import, off Photos) has no row of /// its own and answers with its parent, so the sidebar highlights where the user @@ -1033,21 +1072,39 @@ fn toast_failure(ui: &Rc, what: &str, message: &str, kind: ErrorKind) { } } -/// The header's primary (hamburger) menu: the app-level entries that don't belong -/// on any one page. +/// The sidebar's primary (hamburger) menu: the app-level entries that don't +/// belong on any one page. fn build_primary_menu() -> gtk4::MenuButton { let menu = gio::Menu::new(); + menu.append(Some("Preferences"), Some("win.preferences")); menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts")); menu.append(Some("About Proton Drive for Linux"), Some("win.about")); gtk4::MenuButton::builder() .icon_name("open-menu-symbolic") .tooltip_text("Main menu") + .primary(true) .menu_model(&menu) .build() } -/// Back the primary menu's entries with window actions. -fn install_window_actions(window: &adw::ApplicationWindow) { +/// Back the primary and account menus' entries with window actions. +fn install_window_actions(ui: &Rc, 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 win = window.clone(); 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 win = window.clone(); + let ui_about = ui.clone(); about.connect_activate(move |_, _| { let dialog = adw::AboutDialog::builder() .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\ Unofficial client — not affiliated with, endorsed by, or supported by Proton AG.", ) + .debug_info(debug_info(&ui_about)) + .debug_info_filename("proton-drive-linux-debug.txt") .build(); dialog.present(Some(&win)); }); window.add_action(&about); } +/// 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) -> String { + let config = ui.dirs.load_config(); + format!( + "App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\ + Mountpoint: {}\nControl socket: {}\nMount service running: {}\n", + pdfs_core::config::APP_VERSION, + pdfs_core::config::USER_AGENT, + adw::major_version(), + adw::minor_version(), + adw::micro_version(), + gtk4::major_version(), + gtk4::minor_version(), + gtk4::micro_version(), + ui.dirs.resolved_mountpoint(&config).display(), + ui.dirs.control_socket().display(), + if *ui.mounted.borrow() { "yes" } else { "no" }, + ) +} + /// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what -/// [`install_shortcuts`] binds. +/// [`install_shortcuts`], the Photos page and the photo viewer bind. fn show_shortcuts(window: &adw::ApplicationWindow) { - const KEYS: [(&str, &str); 6] = [ - ("Ctrl+F", "Search Drive"), - ("Ctrl+N", "New folder"), - ("Ctrl+U", "Upload file"), - ("F2", "Rename selection"), - ("Delete", "Move selection to Trash"), - ("Escape", "Close the details pane"), + const GROUPS: [(&str, &[(&str, &str)]); 4] = [ + ( + "General", + &[ + ("f", "Search Drive"), + ("F5", "Refresh"), + ("comma", "Preferences"), + ("question", "Keyboard shortcuts"), + ], + ), + ( + "Files", + &[ + ("n", "New folder"), + ("u", "Upload files"), + ("F2", "Rename"), + ("Delete", "Move to Trash"), + ("Escape", "Clear the selection"), + ], + ), + ( + "Photos", + &[ + ("plus", "Larger thumbnails"), + ("minus", "Smaller thumbnails"), + ("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(); - 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( + >k4::ShortcutLabel::builder() + .accelerator(*accel) + .valign(gtk4::Align::Center) + .build(), + ); + group.add(&row); + } + page.add(&group); + } let dialog = adw::Dialog::builder() .title("Keyboard Shortcuts") - .content_width(420) + .content_width(460) + .content_height(620) .child(&{ let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&adw::HeaderBar::new()); diff --git a/crates/pdfs-gui/src/app/pages/activity.rs b/crates/pdfs-gui/src/app/pages/activity.rs index d04c31a..6355ac2 100644 --- a/crates/pdfs-gui/src/app/pages/activity.rs +++ b/crates/pdfs-gui/src/app/pages/activity.rs @@ -27,17 +27,7 @@ pub(crate) struct ActivityWidgets { /// The Activity page: a newest-first feed of the mutations and transfers the /// daemon performed this session (uploads, deletes, shares, …). 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 header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - header.set_hexpand(true); - header.append(&title); - header.append(&refresh); let group = adw::PreferencesGroup::new(); 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")); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); + inner.set_margin_top(18); + inner.set_margin_bottom(18); + inner.set_margin_start(18); + inner.set_margin_end(18); inner.append(&content); + let (frame, header, _) = page_frame("Activity", &inner); + header.pack_end(&refresh); ( - inner.upcast(), + frame.upcast(), ActivityWidgets { content, status, diff --git a/crates/pdfs-gui/src/app/pages/albums.rs b/crates/pdfs-gui/src/app/pages/albums.rs index 46c9644..94df434 100644 --- a/crates/pdfs-gui/src/app/pages/albums.rs +++ b/crates/pdfs-gui/src/app/pages/albums.rs @@ -192,9 +192,8 @@ fn want_cover(ui: &Rc, uid: String, picture: >k4::Picture) { /// 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. pub(crate) fn open_album(ui: &Rc, album: AlbumInfo) { - ui.gallery.title.set_label(&album.name); - ui.gallery.subtitle.set_label(&album_subtitle(&album)); - ui.gallery.subtitle.set_visible(true); + ui.gallery.title.set_title(&album.name); + ui.gallery.title.set_subtitle(&album_subtitle(&album)); *ui.gallery.album.borrow_mut() = Some(album); // An album page carries no kind or date filter, and Upload targets the @@ -219,7 +218,7 @@ pub(crate) fn close_album(ui: &Rc) { if ui.gallery.album.borrow_mut().take().is_none() { return; } - ui.gallery.title.set_label("Gallery"); + ui.gallery.title.set_title("Photos"); ui.gallery.filters.set_visible(true); ui.gallery.view_switch.set_visible(true); ui.gallery.upload.set_visible(true); diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index 98a8b46..05b6668 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -219,16 +219,11 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { let refresh = refresh_button(); - let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - header.append(&back); - header.append(&crumb_scroll); - header.append(&refresh); - header.append(&build_thumbnails); - header.append(&new_folder); - header.append(&upload); - header.append(&upload_folder); - header.append(&search); - header.append(&toggles); + // The path bar stays in the page, under the header bar: the header's title + // slot carries the page name and the busy spinner. + let path_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + path_bar.append(&back); + path_bar.append(&crumb_scroll); let thumbnail_status = gtk4::Label::builder() .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_start(12); body.set_margin_end(12); - body.append(&header); + body.append(&path_bar); body.append(&thumbnail_build_row); body.append(&bulk); body.append(&split); @@ -457,8 +452,17 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { page.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); 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 { model, back, diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index d5aea76..95f0eb3 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -46,33 +46,17 @@ pub(crate) struct DevicesWidgets { /// The Computers page: a "This computer" section naming the device this machine /// backs up to (renamable from its header), plus an "Other computers" section /// 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) { - 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() - .label("Restore Folders") + .label("Restore Folders…") .tooltip_text("Sync this computer's Drive folders back to local directories") - .valign(gtk4::Align::Center) .build(); - restore.add_css_class("flat"); 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 // 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. let sync_group = adw::PreferencesGroup::builder() .title("This computer") @@ -124,15 +108,17 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { content.add_named(&status, Some("status")); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); + inner.set_margin_top(18); + inner.set_margin_bottom(18); + inner.set_margin_start(18); + inner.set_margin_end(18); inner.append(&content); + let (frame, header, _) = page_frame("Computers", &inner); + header.pack_start(&restore); + header.pack_end(&refresh); ( - inner.upcast(), + frame.upcast(), DevicesWidgets { content, status, diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs index e47844f..0cd97b8 100644 --- a/crates/pdfs-gui/src/app/pages/locations.rs +++ b/crates/pdfs-gui/src/app/pages/locations.rs @@ -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 //! `~/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) refresh: 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) { - let title = gtk4::Label::builder() - .label("Locations") - .halign(gtk4::Align::Start) - .build(); - title.add_css_class("title-2"); - 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") - .valign(gtk4::Align::Center) .build(); - add_folder.add_css_class("flat"); let refresh = refresh_button(); - - 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(); + let transfers_group = build_transfers_group(); // 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 @@ -72,6 +62,7 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) { .build(); let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18); + groups.append(&transfers_group); groups.append(&group); let clamp = adw::Clamp::builder().child(&groups).build(); // 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_start(18); page.set_margin_end(18); - page.append(&header_clamp); page.append(&content); + let (frame, header, _) = page_frame("Sync", &page); + header.pack_start(&add_folder); + header.pack_end(&refresh); let widgets = LocationsWidgets { content: content.clone(), @@ -124,8 +117,9 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) { retry: retry.clone(), refresh, add_folder, + transfers_group, }; - (page.upcast(), widgets) + (frame.upcast(), widgets) } pub(crate) fn wire_locations(ui: &Rc, retry: >k4::Button, add_folder: >k4::Button) { diff --git a/crates/pdfs-gui/src/app/pages/login.rs b/crates/pdfs-gui/src/app/pages/login.rs index 2221af7..516909c 100644 --- a/crates/pdfs-gui/src/app/pages/login.rs +++ b/crates/pdfs-gui/src/app/pages/login.rs @@ -268,27 +268,25 @@ pub(crate) fn prompt_2fa(ui: &Rc, code_tx: std::sync::mpsc::Sender) dialog.present(parent.as_ref()); } -/// Connect the sign-out button: after a confirmation, disable+stop the mount -/// service (so the daemon isn't left running without credentials), forget the -/// stored session, and drop back to the login page. -pub(crate) fn wire_logout(ui: &Rc, button: >k4::Button) { +/// Sign out, after a confirmation: disable+stop the mount service (so the +/// daemon isn't left running without credentials), forget the stored session, +/// and drop back to the login page. +pub(crate) fn sign_out(ui: &Rc) { + let Some(window) = ui_window(ui) else { return }; let ui = ui.clone(); - button.connect_clicked(move |btn| { - let ui = ui.clone(); - confirm_destructive( - btn, - "Sign Out?", - "Proton Drive will disconnect and stop syncing until you sign in again. \ - Files already on this computer stay where they are.", - "Sign Out", - move || { - service::disable_stop(); - if let Err(e) = auth::logout() { - tracing::error!("logout failed: {e}"); - } - *ui.session.borrow_mut() = None; - refresh(&ui); - }, - ); - }); + confirm_destructive( + &window, + "Sign Out?", + "Proton Drive will disconnect and stop syncing until you sign in again. \ + Files already on this computer stay where they are.", + "Sign Out", + move || { + service::disable_stop(); + if let Err(e) = auth::logout() { + tracing::error!("logout failed: {e}"); + } + *ui.session.borrow_mut() = None; + refresh(&ui); + }, + ); } diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 875627e..b8bd46e 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -40,9 +40,8 @@ pub(crate) struct GalleryState { /// status: a load error is not the moment to offer an upload. pub(crate) empty_actions: gtk4::Box, /// "Photos", or the album's name while one is open. - pub(crate) title: gtk4::Label, - /// "1,204 photos" under the page title. - pub(crate) subtitle: gtk4::Label, + /// "1,204 photos" as its subtitle. + pub(crate) title: adw::WindowTitle, /// The album grid, and the stack that swaps it for its own status page. pub(crate) albums: gtk4::FlowBox, pub(crate) albums_stack: gtk4::Stack, @@ -283,8 +282,7 @@ pub(crate) struct GalleryWidgets { /// Albums grid. pub(crate) content: gtk4::Stack, pub(crate) status: adw::StatusPage, - pub(crate) title: gtk4::Label, - pub(crate) subtitle: gtk4::Label, + pub(crate) title: adw::WindowTitle, pub(crate) more: gtk4::Button, pub(crate) list: gtk4::ListView, pub(crate) scroll: gtk4::ScrolledWindow, @@ -394,24 +392,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { .child(&list) .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 // it — the Albums toggle is what leaves the grid itself. let back = gtk4::Button::builder() @@ -435,7 +415,6 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { .tooltip_text("Upload photos") .valign(gtk4::Align::Center) .build(); - upload.add_css_class("pill"); upload.add_css_class("suggested-action"); // 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) .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 // control: exactly one is active, and flipping it reloads the timeline // 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_start(12); inner.set_margin_end(12); - inner.append(&header_box); inner.append(&view_switch); inner.append(&filter_bar); 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 { model, groups, content, status, - title: title_label, - subtitle, + title, more, list, scroll, @@ -1995,7 +1971,9 @@ fn clone_row(row: &GalleryRow) -> GalleryRow { /// "1,204 photos" under the page title. fn update_gallery_subtitle(ui: &Rc) { 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 // paged in — the subtitle would otherwise climb as the user scrolls. if ui.gallery.album.borrow().is_some() { @@ -2020,7 +1998,10 @@ fn update_gallery_subtitle(ui: &Rc) { }, _ => loaded, }; - ui.gallery.subtitle.set_label(&match total { + if loaded == 0 { + return; + } + ui.gallery.title.set_subtitle(&match total { 1 => format!("1 {one}"), n => format!("{} {many}", thousands(n)), }); diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index e21ce1c..c637357 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -39,27 +39,17 @@ pub(crate) struct SharedWidgets { /// 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. 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() - .label("Add Bookmark") - .valign(gtk4::Align::Center) + .child( + &adw::ButtonContent::builder() + .label("Add Bookmark") + .icon_name("list-add-symbolic") + .build(), + ) + .tooltip_text("Save a shared link to open it from here") .build(); - add_bookmark.add_css_class("flat"); 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() .title("Shared with me") .build(); @@ -99,15 +89,17 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { content.add_named(&status, Some("status")); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); + inner.set_margin_top(18); + inner.set_margin_bottom(18); + inner.set_margin_start(18); + inner.set_margin_end(18); 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 { content, status, diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs index 28efd37..b2cec6b 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -25,17 +25,7 @@ pub(crate) struct SharedByMeWidgets { /// each row summarizing who has access and carrying its public link (copy/open) /// and a Manage shortcut into the per-node Share dialog. 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 header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - header.set_hexpand(true); - header.append(&title); - header.append(&refresh); let group = adw::PreferencesGroup::new(); 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")); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); + inner.set_margin_top(18); + inner.set_margin_bottom(18); + inner.set_margin_start(18); + inner.set_margin_end(18); inner.append(&content); + let (frame, header, _) = page_frame("Shared by Me", &inner); + header.pack_end(&refresh); ( - inner.upcast(), + frame.upcast(), SharedByMeWidgets { content, status, diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index 231e8f5..1971aa1 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -7,18 +7,24 @@ pub(crate) struct StatusState { /// 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. pub(crate) transfers_inflight: Cell, - /// 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) transfer_rows: RefCell>, - // Main page. - pub(crate) account_row: adw::ActionRow, - /// Read-only mount status line. The mount is driven by the systemd user - /// service (enabled on login), not by the user — so this only reports. - pub(crate) mount_row: adw::ActionRow, + /// The Preferences dialog, built once and presented on demand. + pub(crate) prefs: adw::PreferencesDialog, + /// Sidebar footer: signed-in identity. + pub(crate) account_name: gtk4::Label, + 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_label: gtk4::Label, - /// Account-quota group + its bar/label. Hidden until the first reading. - pub(crate) quota_group: adw::PreferencesGroup, + /// Sidebar footer: account quota bar/label. Hidden until the first reading. + pub(crate) quota_box: gtk4::Box, pub(crate) quota_bar: gtk4::ProgressBar, pub(crate) quota_label: gtk4::Label, /// 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 /// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`]. pub(crate) budget_row: adw::SpinRow, - /// Shows where the primary mount lives; the folder itself is managed on the - /// Locations page, which owns every local path. + /// Shows where the primary mount lives; its Change button picks a new one. 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 /// change handler skips the IPC/systemd side effect. pub(crate) settings_suppress: Cell, @@ -45,7 +52,7 @@ pub(crate) struct StatusState { pub(crate) pins_group: adw::PreferencesGroup, /// Whether the pin list is showing every pin or only the first /// [`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, /// Rows currently shown under [`Self::pins_group`], retained so a refresh can /// diff against them and only rebuild when the pin set actually changes. @@ -89,100 +96,94 @@ pub(crate) struct ActivityLine { pub(crate) fraction: Option, } -/// 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) account_row: adw::ActionRow, - pub(crate) mount_row: adw::ActionRow, - /// Live upload/download progress, populated by the refresh loop. - pub(crate) transfers_group: adw::PreferencesGroup, - /// Account-quota group; hidden until `refresh_quota` gets a reading. - pub(crate) quota_group: adw::PreferencesGroup, + pub(crate) prefs: adw::PreferencesDialog, + /// The sidebar footer: sync status, quota, account. + pub(crate) footer: gtk4::Box, + /// Opens the Sync page; the footer's status strip is this button. + pub(crate) status_button: gtk4::Button, + 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_label: gtk4::Label, pub(crate) cache_bar: gtk4::ProgressBar, pub(crate) cache_label: gtk4::Label, pub(crate) pins_group: adw::PreferencesGroup, - pub(crate) logout_button: gtk4::Button, /// "Start on login" toggle, reflecting the systemd unit's enabled state. pub(crate) autostart_row: adw::SwitchRow, /// Cache soft-cap editor, in GiB; `0` = unlimited. pub(crate) budget_row: adw::SpinRow, /// Purges all unpinned cached content. pub(crate) purge_button: gtk4::Button, - /// Shows the active mountpoint; its suffix button opens the Locations page, - /// which is where the folder is changed. + /// Shows the active mountpoint; its suffix button picks a new one. pub(crate) mountpoint_row: adw::ActionRow, pub(crate) mountpoint_button: gtk4::Button, + pub(crate) accent_row: adw::SwitchRow, } -/// The main (logged-in) page: a libadwaita settings surface — account header, -/// mount status, storage controls (cache budget + purge), system integration -/// (start-on-login, mountpoint), the pin list, and developer overrides. Returns -/// the widgets the refresh loop updates plus the controls to wire. -pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) { - // Account group: identity + sign-out. - let account_group = adw::PreferencesGroup::new(); - let account_row = adw::ActionRow::builder().title("Not signed in").build(); - let avatar = adw::Avatar::new(40, None, true); - account_row.add_prefix(&avatar); - let logout_button = gtk4::Button::builder() - .label("Sign out") +/// Build the two surfaces that replaced the old Settings page: +/// +/// - the Preferences dialog (General: start on login, mount location, +/// appearance; Storage: cache usage, budget, clear, offline files), and +/// - the sidebar footer (sync status strip, account quota, account menu). +/// +/// Live state (transfers, the folder list) lives on the Sync page instead, and +/// the app version and user agent in About → Troubleshooting. +pub(crate) fn build_main_page() -> MainWidgets { + // ---- Preferences: General + let startup_group = adw::PreferencesGroup::builder().title("Startup").build(); + 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) .build(); - logout_button.add_css_class("flat"); - account_row.add_suffix(&logout_button); - account_group.add(&account_row); + mountpoint_button.add_css_class("flat"); + mountpoint_row.add_suffix(&mountpoint_button); + location_group.add(&mountpoint_row); - // Account storage: the Proton account quota (used of total), distinct from - // the local content cache below. A progress bar + "X of Y used" line, painted - // by `refresh_quota`. Hidden until the first successful read so a cold start - // (or an account the API can't report) shows nothing rather than an empty bar. - let quota_group = adw::PreferencesGroup::builder() - .title("Account storage") - .description("Your Proton storage across all products.") - .visible(false) + let appearance_group = adw::PreferencesGroup::builder().title("Appearance").build(); + let accent_row = adw::SwitchRow::builder() + .title("Proton purple accent") + .subtitle("Use the Proton brand colour instead of the system accent colour") .build(); - let quota_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - 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("a_bar); - quota_box.append("a_label); - let quota_row = adw::PreferencesRow::builder() - .activatable(false) - .child("a_box) - .build(); - quota_group.add("a_row); + appearance_group.add(&accent_row); - // Mount group: a read-only status line. The mount is managed automatically - // by the systemd user service; there is no toggle to fiddle with. - let mount_group = adw::PreferencesGroup::builder().title("Drive").build(); - let mount_row = adw::ActionRow::builder() - .title("Proton Drive") - .subtitle("Not mounted") + let general = adw::PreferencesPage::builder() + .title("General") + .icon_name("preferences-system-symbolic") .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 - // 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. + // ---- Preferences: Storage let storage_group = adw::PreferencesGroup::builder() - .title("Storage") - .description("Local cache for pinned and recently opened files.") + .title("Cache") + .description("Pinned and recently opened files, kept on this computer.") .build(); let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - storage_box.set_margin_top(6); - storage_box.set_margin_bottom(6); + storage_box.set_margin_top(12); + 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_label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); 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. let budget_adj = gtk4::Adjustment::new(0.0, 0.0, 1024.0, 0.5, 1.0, 0.0); let budget_row = adw::SpinRow::builder() - .title("Cache budget (GiB)") - .subtitle("Soft cap for cached content; 0 = unlimited.") + .title("Cache size limit (GiB)") + .subtitle("Older files are removed past this size; 0 means no limit") .adjustment(&budget_adj) .digits(1) .build(); storage_group.add(&budget_row); let purge_row = adw::ActionRow::builder() - .title("Purge cache") - .subtitle("Delete cached content. Pinned files are kept.") + .title("Clear cache") + .subtitle("Remove cached copies. Files kept offline stay.") .build(); let purge_button = gtk4::Button::builder() - .label("Purge") + .label("Clear…") .valign(gtk4::Align::Center) .build(); - purge_button.add_css_class("destructive-action"); + purge_button.add_css_class("flat"); purge_row.add_suffix(&purge_button); 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. let pins_group = adw::PreferencesGroup::builder() - .title("Pinned files") - .description("Kept available offline on this device.") + .title("Available offline") + .description("Files kept on this computer, even without a connection.") .build(); - // Developer overrides: read-only client identity, for support/debugging. - let dev_group = adw::PreferencesGroup::builder().title("Developer").build(); - let version_row = adw::ActionRow::builder() - .title("App version") - .subtitle(pdfs_core::config::APP_VERSION) + let storage = adw::PreferencesPage::builder() + .title("Storage") + .icon_name("drive-harddisk-symbolic") .build(); - version_row.add_css_class("property"); - let agent_row = adw::ActionRow::builder() - .title("User agent") - .subtitle(pdfs_core::config::USER_AGENT) + storage.add(&storage_group); + storage.add(&pins_group); + + let prefs = adw::PreferencesDialog::builder() + .search_enabled(false) .build(); - agent_row.add_css_class("property"); - dev_group.add(&version_row); - dev_group.add(&agent_row); + prefs.add(&general); + prefs.add(&storage); - let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 18); - inner.set_margin_top(18); - inner.set_margin_bottom(18); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&account_group); - inner.append("a_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) + // ---- Sidebar footer + let status_icon = gtk4::Image::from_icon_name("content-loading-symbolic"); + let status_title = gtk4::Label::builder() + .label("Connecting…") + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) .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"); - ( - scroll.upcast(), - MainWidgets { - account_row, - mount_row, - transfers_group, - quota_group, - quota_bar, - quota_label, - cache_bar, - cache_label, - pins_group, - logout_button, - autostart_row, - budget_row, - purge_button, - mountpoint_row, - mountpoint_button, - }, - ) + let quota_bar = gtk4::ProgressBar::new(); + let quota_label = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + quota_label.add_css_class("caption"); + quota_label.add_css_class("dim-label"); + let quota_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + quota_box.add_css_class("sidebar-quota"); + quota_box.set_margin_start(8); + quota_box.set_margin_end(8); + quota_box.set_visible(false); + quota_box.append("a_bar); + quota_box.append("a_label); + + let avatar = adw::Avatar::new(28, None, true); + let account_name = gtk4::Label::builder() + .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("a_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 /// the groups below it stay reachable. 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. pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0; -/// Wire the Settings-page controls: the cache-budget editor, the purge button, -/// the start-on-login switch and the mountpoint chooser. Initial widget state is +/// Wire the Preferences controls: the cache-budget editor, the purge button, the +/// 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 /// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the /// programmatic populate so the change handlers don't fire on it. @@ -338,6 +377,9 @@ pub(crate) fn wire_settings( .mountpoint_row .set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string()); 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); // 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 |_| { let ui = ui_purge.clone(); let dialog = adw::AlertDialog::builder() - .heading("Purge cache") - .body("Delete all cached content that isn't pinned? Pinned files stay offline.") + .heading("Clear Cache?") + .body( + "Cached copies are removed from this computer and download again when \ + you open them. Files kept available offline stay.", + ) .build(); 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_default_response(Some("cancel")); dialog.set_close_response("cancel"); @@ -383,12 +428,12 @@ pub(crate) fn wire_settings( settings_request( &ui, Request::PurgeCache, - "Cache purged", - "Couldn't purge cache", + "Cache cleared", + "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 @@ -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(); - 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, @@ -522,8 +584,11 @@ pub(crate) fn refresh(ui: &Rc) { ui.stack.set_visible_child_name("browser"); } ui.nav.set_collapsed(false); - ui.status.account_row.set_title(&s.username); - ui.status.account_row.set_subtitle("Proton account"); + if ui.status.account_name.label() != s.username { + 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 => { ui.stack.set_visible_child_name("login"); @@ -546,8 +611,10 @@ pub(crate) fn refresh(ui: &Rc) { if ui.stack.visible_child_name().as_deref() == Some("takeout") || ui.takeout.running.get() { refresh_takeout(ui); } + // The quota sits in the sidebar footer, visible on every page; its TTL + // keeps this from asking more than once a minute. + refresh_quota(ui); match ui.stack.visible_child_name().as_deref() { - Some("main" | "browser") => refresh_quota(ui), Some("locations") => refresh_locations(ui), Some("activity") => refresh_activity(ui), _ => {} @@ -559,7 +626,7 @@ pub(crate) fn refresh(ui: &Rc) { const QUOTA_TTL: Duration = Duration::from_secs(60); /// 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. pub(crate) fn refresh_quota(ui: &Rc) { if ui.status.quota_inflight.get() { @@ -583,7 +650,7 @@ pub(crate) fn refresh_quota(ui: &Rc) { { ui.status.quota_checked_at.set(Some(Instant::now())); 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() { // Match Dolphin: capacity information does not occupy the bar until // the backing observer has real figures. @@ -871,6 +938,8 @@ pub(crate) fn refresh_status(ui: &Rc) { online, pending_uploads, pending_changes, + failing_ops, + failing_error, .. })) => { set_mounted(&ui, true); @@ -878,14 +947,19 @@ pub(crate) fn refresh_status(ui: &Rc) { // in it: it is why a file that looks saved is not on the remote // yet, and offline is usually the reason it is still queued. let queued = pending_summary(pending_uploads, pending_changes); - ui.status.mount_row.set_subtitle(&match (online, queued) { - (true, None) => format!("Mounted at {mountpoint}"), - (true, Some(q)) => format!("Mounted at {mountpoint} — {q}"), - (false, None) => { - format!("Mounted at {mountpoint} — offline, cached files only") + let state = if failing_ops > 0 { + SyncState::Attention { + count: failing_ops, + error: failing_error, } - (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 { 0.0 } else { @@ -907,7 +981,7 @@ pub(crate) fn refresh_status(ui: &Rc) { // rows and cache read-out so the page doesn't flicker on a blip. _ => { 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() { if let Some(b) = &r.unpin { b.set_sensitive(false); @@ -918,6 +992,82 @@ pub(crate) fn refresh_status(ui: &Rc) { }); } +/// 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, + }, + /// No connection to Proton: cached files only, changes wait. + Offline { + queued: Option, + }, + /// 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, 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 /// 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 @@ -941,8 +1091,8 @@ pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: if pins.is_empty() { let row = adw::ActionRow::builder() - .title("No pinned files") - .subtitle("Right-click a file in the mount to keep it offline.") + .title("No files kept offline") + .subtitle("Right-click a file and choose “Make available offline”.") .build(); ui.status.pins_group.add(&row); ui.status @@ -970,11 +1120,11 @@ pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: .title(&name) .subtitle(&pin.path) .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); let unpin = gtk4::Button::builder() - .icon_name("user-trash-symbolic") + .icon_name("pdfs-online-only-symbolic") .valign(gtk4::Align::Center) .tooltip_text("Make online only") .sensitive(mounted) @@ -983,12 +1133,24 @@ pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: let ui_btn = ui.clone(); let path = pin.path.clone(); unpin.connect_clicked(move |_| { - let socket = ui_btn.dirs.control_socket(); - match send(&socket, &Request::Unpin { path: path.clone() }) { - Ok(Response::Error { message, .. }) => tracing::error!("unpin failed: {message}"), - Ok(_) => refresh(&ui_btn), - Err(e) => tracing::error!("unpin request failed: {e}"), - } + let rx = spawn_request( + ui_btn.dirs.control_socket(), + Request::Unpin { path: path.clone() }, + ); + 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); @@ -1004,7 +1166,7 @@ pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: .title(if expanded { "Show fewer".to_string() } else { - format!("Show all {} pinned files", pins.len()) + format!("Show all {}", count_noun(pins.len(), "file", "files")) }) .activatable(true) .build(); diff --git a/crates/pdfs-gui/src/app/pages/takeout.rs b/crates/pdfs-gui/src/app/pages/takeout.rs index 24b6353..784c609 100644 --- a/crates/pdfs-gui/src/app/pages/takeout.rs +++ b/crates/pdfs-gui/src/app/pages/takeout.rs @@ -17,12 +17,12 @@ //! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only //! stages paths, launches, polls [`Request::ImportStatus`] for the final report, //! 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::*; /// 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. const IMPORT_JOB: &str = "Importing Google Photos"; @@ -135,11 +135,6 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) { .build(); 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() .label("Add a Google Takeout export to your Proton Photos timeline.") .halign(gtk4::Align::Start) @@ -148,15 +143,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) { .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 header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - header.append(&back_button); - header.append(&titles); - let header_clamp = adw::Clamp::builder().child(&header).build(); + let subtitle_clamp = adw::Clamp::builder().child(&subtitle).build(); // 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 @@ -235,7 +222,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) { actions.append(&import_button); // 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() .title("Import in progress") .visible(false) @@ -284,8 +271,10 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) { page.set_margin_bottom(18); page.set_margin_start(18); page.set_margin_end(18); - page.append(&header_clamp); + page.append(&subtitle_clamp); page.append(&scroll); + let (frame, header, _) = page_frame("Import from Google Photos", &page); + header.pack_start(&back_button); let widgets = TakeoutWidgets { list_group: list_group.clone(), @@ -302,7 +291,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) { progress_bar: progress_bar.clone(), summary_group: summary_group.clone(), }; - (page.upcast(), widgets) + (frame.upcast(), widgets) } pub(crate) fn wire_takeout(ui: &Rc, widgets: &TakeoutWidgets) { @@ -755,7 +744,7 @@ fn repaint_summary(ui: &Rc, summary: &ImportSummary) { 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 /// pulses instead of showing a fraction, the same as the Activity list. pub(crate) fn takeout_progress(ui: &Rc, jobs: &[JobItem]) { diff --git a/crates/pdfs-gui/src/app/pages/trash.rs b/crates/pdfs-gui/src/app/pages/trash.rs index 5fa74d8..6bfdfca 100644 --- a/crates/pdfs-gui/src/app/pages/trash.rs +++ b/crates/pdfs-gui/src/app/pages/trash.rs @@ -10,7 +10,7 @@ pub(crate) struct TrashState { /// Empties the trash; insensitive while it is empty (or unread). pub(crate) empty: gtk4::Button, /// "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. @@ -22,7 +22,7 @@ pub(crate) struct TrashWidgets { pub(crate) retry: gtk4::Button, pub(crate) empty: 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 @@ -35,31 +35,13 @@ pub(crate) struct TrashWidgets { pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { let model = gio::ListStore::new::(); - 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() - .label("Empty Trash") - .valign(gtk4::Align::Center) + .label("Empty…") + .tooltip_text("Permanently delete everything in the Trash") .sensitive(false) .build(); - empty.add_css_class("destructive-action"); 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() .label("Retry") .halign(gtk4::Align::Center) @@ -91,15 +73,17 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { content.add_named(&status, Some("status")); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); + inner.set_margin_top(18); + inner.set_margin_bottom(18); + inner.set_margin_start(18); + inner.set_margin_end(18); inner.append(&content); + let (frame, header, subtitle) = page_frame("Trash", &inner); + header.pack_start(&empty); + header.pack_end(&refresh); ( - inner.upcast(), + frame.upcast(), TrashWidgets { model, list, @@ -294,7 +278,7 @@ pub(crate) fn trash_status(ui: &Rc, icon: &str, title: &str, description: &s ui.trash.status.set_description(Some(description)); ui.trash.retry.set_visible(retry); 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 @@ -313,7 +297,7 @@ pub(crate) fn repaint_trash(ui: &Rc, entries: &[DirEntry]) { return; } 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(), n => format!("{n} items"), });