mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(core,gui): phase 1 shell — per-page headers, sidebar footer, Preferences dialog
Restructure the app shell per docs/UI_UX_PLAN.md phase 1: - Every destination page gets its own AdwHeaderBar via page_frame(), with page actions packed into it and a busy spinner beside the title. - The sidebar is regrouped (files, then a Sync section) with a primary menu (Preferences, Keyboard Shortcuts, About) and a footer showing sync state, storage quota and the account with Sign Out. - The Settings page is gone: startup, folder location, appearance and cache options move into an AdwPreferencesDialog (Ctrl+,); transfers move to the Sync page; developer details move into About's debug info. - Inline CSS moves to a gresource stylesheet using libadwaita colour variables. The system accent is the default; the Proton purple accent is an opt-in preference (new `proton_accent` config key, defaults off). - Add a keyboard shortcuts dialog (Ctrl+?). libadwaita requirement stays at 1.5, so Ubuntu 24.04 remains supported.
This commit is contained in:
1 parent
cc308ac25a
commit
cf9e1665ca
17 files changed
+861
-579
No files matched your search
@@ -119,6 +119,11 @@ pub struct AppConfig {
|
||||
/// field.
|
||||
#[serde(default)]
|
||||
pub prompt: Option<PromptConfig>,
|
||||
/// Paint the desktop app in Proton purple instead of the system accent
|
||||
/// colour. `None` means "follow the system". Defaulted for configs
|
||||
/// predating the field.
|
||||
#[serde(default)]
|
||||
pub proton_accent: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -10,4 +10,10 @@
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
|
||||
</gresource>
|
||||
<!-- The app stylesheet, loaded once at startup. Colours come from the
|
||||
libadwaita palette so the app follows the system accent, light/dark and
|
||||
high contrast; the Proton purple accent is a separate, opt-in provider. -->
|
||||
<gresource prefix="/de/nils/protondrivelinux">
|
||||
<file>style.css</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
@@ -0,0 +1,79 @@
|
||||
/* Proton Drive for Linux — app stylesheet.
|
||||
*
|
||||
* Colours come from the libadwaita palette (var(--accent-bg-color),
|
||||
* var(--success-color), ...) so the app follows the system accent, light/dark
|
||||
* and high contrast. The one exception is the photo viewer, which always sits
|
||||
* on a near-black backdrop and so uses fixed light-on-dark colours. The Proton
|
||||
* purple accent is opt-in and lives in its own provider (see main.rs).
|
||||
*/
|
||||
|
||||
/* Brand */
|
||||
.brand-title { font-size: 1.6rem; font-weight: 800; }
|
||||
.brand-icon { color: #6d4aff; }
|
||||
|
||||
/* Sidebar footer */
|
||||
.sidebar-footer { padding: 6px 12px 12px 12px; }
|
||||
.sidebar-status { padding: 6px 8px; border-radius: 8px; }
|
||||
.sidebar-status image.success { color: var(--success-color); }
|
||||
.sidebar-status image.warning { color: var(--warning-color); }
|
||||
.sidebar-status image.error { color: var(--error-color); }
|
||||
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
||||
.sidebar-account { padding: 4px 4px 4px 8px; }
|
||||
|
||||
/* File browser */
|
||||
.file-grid { padding: 6px; }
|
||||
.file-tile { padding: 8px; border-radius: 10px; }
|
||||
.file-tile:hover { background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
|
||||
.file-tile:selected, .file-tile:hover:selected { background: color-mix(in srgb, var(--accent-bg-color) 20%, transparent); }
|
||||
.file-thumbnail { border-radius: 7px; }
|
||||
.file-badge { -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }
|
||||
.badge-pinned { color: var(--yellow-3); }
|
||||
.badge-cached { color: var(--success-color); }
|
||||
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
||||
scale.browser-status-meter trough, progressbar.browser-status-meter trough { min-width: 104px; }
|
||||
scale.browser-status-meter trough { min-height: 6px; }
|
||||
progressbar.browser-status-meter trough, progressbar.browser-status-meter progress { min-height: 6px; }
|
||||
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
||||
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
||||
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
|
||||
|
||||
/* Photo viewer: always light-on-dark, whatever the system style */
|
||||
.photo-viewer-window { background-color: #111014; }
|
||||
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }
|
||||
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
|
||||
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
|
||||
.viewer-action-btn { color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }
|
||||
.viewer-action-btn:hover { background-color: rgba(255, 255, 255, 0.28); color: white; }
|
||||
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||
.viewer-close-btn { background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }
|
||||
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
|
||||
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
|
||||
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
|
||||
.viewer-nav-btn:disabled { opacity: 0; }
|
||||
.viewer-spinner { color: white; }
|
||||
.viewer-status { color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }
|
||||
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
|
||||
|
||||
/* Photos grid */
|
||||
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 4px 2px 8px 2px; }
|
||||
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }
|
||||
.photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }
|
||||
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
|
||||
.photo-thumb { transition: transform 220ms ease; }
|
||||
.photo-tile:hover .photo-thumb { transform: scale(1.06); }
|
||||
.photo-check { color: white; background: rgba(0, 0, 0, 0.5); border-radius: 999px; padding: 3px; }
|
||||
.photo-check-on { background: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||
.photo-tile-selected { outline: 3px solid var(--accent-bg-color); outline-offset: -3px; }
|
||||
.photo-placeholder { color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }
|
||||
.photo-caption { font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }
|
||||
.photo-tile:hover .photo-caption { opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }
|
||||
.photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }
|
||||
.photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); }
|
||||
.photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }
|
||||
.album-card { padding: 0; border-radius: 16px; transition: background 180ms ease; }
|
||||
.album-card:hover { background: alpha(currentColor, 0.07); }
|
||||
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
|
||||
.album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }
|
||||
.album-card:hover .photo-thumb { transform: scale(1.05); }
|
||||
.view-switch button { padding-left: 18px; padding-right: 18px; }
|
||||
+266
-151
@@ -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<u32>,
|
||||
/// Keys (relative path / photo uid) of open requests currently in flight, so
|
||||
/// a double-click on the same entry is a no-op instead of a second download.
|
||||
@@ -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<bool>,
|
||||
/// Sidebar destination list (Files / Photos / Settings). Selecting a row swaps
|
||||
/// Sidebar destination list (My files, Photos, …, Sync). Selecting a row swaps
|
||||
/// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere.
|
||||
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<Option<gtk4::CssProvider>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Paint the app in Proton purple (`on`) or follow the system accent colour.
|
||||
/// Every accent-coloured widget reads `--accent-bg-color`, so overriding that
|
||||
/// one variable recolours the whole app.
|
||||
fn set_proton_accent(on: bool) {
|
||||
let Some(display) = gtk4::gdk::Display::default() else {
|
||||
return;
|
||||
};
|
||||
PROTON_ACCENT.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
match (on, slot.as_ref()) {
|
||||
(true, None) => {
|
||||
let provider = gtk4::CssProvider::new();
|
||||
provider
|
||||
.load_from_string(&format!(":root {{ --accent-bg-color: {PROTON_PURPLE}; }}"));
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
|
||||
);
|
||||
*slot = Some(provider);
|
||||
}
|
||||
(false, Some(provider)) => {
|
||||
gtk4::style_context_remove_provider_for_display(&display, provider);
|
||||
*slot = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the application window, wire the two pages, kick off the refresh loop,
|
||||
@@ -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", &["<Control>comma"]);
|
||||
app.set_accels_for_action("win.shortcuts", &["<Control>question"]);
|
||||
|
||||
refresh(&ui);
|
||||
// Periodic refresh while the window lives. The closure holds a strong `Rc`;
|
||||
@@ -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<Vec<gtk4::Spinner>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
fn set_busy_spinners(visible: bool) {
|
||||
BUSY_SPINNERS.with(|spinners| {
|
||||
for spinner in spinners.borrow().iter() {
|
||||
spinner.set_visible(visible);
|
||||
spinner.set_spinning(visible);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A destination page's frame: its own header bar over the page content, the
|
||||
/// way GNOME apps lay a split view out. The title sits in the middle with the
|
||||
/// busy spinner beside it; the page packs its actions into the returned header
|
||||
/// bar and may give the returned title a subtitle.
|
||||
fn page_frame(
|
||||
title: &str,
|
||||
content: &impl IsA<gtk4::Widget>,
|
||||
) -> (adw::ToolbarView, adw::HeaderBar, adw::WindowTitle) {
|
||||
let window_title = adw::WindowTitle::new(title, "");
|
||||
let spinner = gtk4::Spinner::new();
|
||||
spinner.set_visible(false);
|
||||
BUSY_SPINNERS.with(|spinners| spinners.borrow_mut().push(spinner.clone()));
|
||||
let title_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||
title_box.append(&window_title);
|
||||
title_box.append(&spinner);
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_title_widget(Some(&title_box));
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(content));
|
||||
(toolbar, header, window_title)
|
||||
}
|
||||
|
||||
/// The sidebar destination a page belongs under. Most pages are their own
|
||||
/// destination; a sub-page reached from one (Import, off Photos) has no row of
|
||||
/// its own and answers with its parent, so the sidebar highlights where the user
|
||||
@@ -1033,21 +1072,39 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The header's primary (hamburger) menu: the app-level entries that don't belong
|
||||
/// 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<Ui>, window: &adw::ApplicationWindow) {
|
||||
let preferences = gio::SimpleAction::new("preferences", None);
|
||||
let ui_prefs = ui.clone();
|
||||
let win = window.clone();
|
||||
preferences.connect_activate(move |_, _| {
|
||||
// Signed out there is nothing to configure: every page needs a session.
|
||||
if ui_prefs.session.borrow().is_some() {
|
||||
ui_prefs.status.prefs.present(Some(&win));
|
||||
}
|
||||
});
|
||||
window.add_action(&preferences);
|
||||
|
||||
let sign_out_action = gio::SimpleAction::new("sign-out", None);
|
||||
let ui_out = ui.clone();
|
||||
sign_out_action.connect_activate(move |_, _| sign_out(&ui_out));
|
||||
window.add_action(&sign_out_action);
|
||||
|
||||
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
||||
let 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<Ui>) -> String {
|
||||
let config = ui.dirs.load_config();
|
||||
format!(
|
||||
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
|
||||
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
|
||||
pdfs_core::config::APP_VERSION,
|
||||
pdfs_core::config::USER_AGENT,
|
||||
adw::major_version(),
|
||||
adw::minor_version(),
|
||||
adw::micro_version(),
|
||||
gtk4::major_version(),
|
||||
gtk4::minor_version(),
|
||||
gtk4::micro_version(),
|
||||
ui.dirs.resolved_mountpoint(&config).display(),
|
||||
ui.dirs.control_socket().display(),
|
||||
if *ui.mounted.borrow() { "yes" } else { "no" },
|
||||
)
|
||||
}
|
||||
|
||||
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
|
||||
/// [`install_shortcuts`] 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",
|
||||
&[
|
||||
("<Control>f", "Search Drive"),
|
||||
("F5", "Refresh"),
|
||||
("<Control>comma", "Preferences"),
|
||||
("<Control>question", "Keyboard shortcuts"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Files",
|
||||
&[
|
||||
("<Control>n", "New folder"),
|
||||
("<Control>u", "Upload files"),
|
||||
("F2", "Rename"),
|
||||
("Delete", "Move to Trash"),
|
||||
("Escape", "Clear the selection"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Photos",
|
||||
&[
|
||||
("<Control>plus", "Larger thumbnails"),
|
||||
("<Control>minus", "Smaller thumbnails"),
|
||||
("<Control>0", "Reset thumbnail size"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Photo Viewer",
|
||||
&[
|
||||
("Left Right", "Previous / next photo"),
|
||||
("Home End", "First / last photo"),
|
||||
("i", "Show details"),
|
||||
("f", "Fullscreen"),
|
||||
("Delete", "Move to Trash"),
|
||||
("Escape", "Close"),
|
||||
],
|
||||
),
|
||||
];
|
||||
let group = adw::PreferencesGroup::builder().title("Files").build();
|
||||
for (keys, action) in KEYS {
|
||||
let row = adw::ActionRow::builder().title(action).build();
|
||||
let label = gtk4::Label::builder()
|
||||
.label(keys)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
label.add_css_class("dim-label");
|
||||
label.add_css_class("monospace");
|
||||
row.add_suffix(&label);
|
||||
group.add(&row);
|
||||
}
|
||||
let page = adw::PreferencesPage::new();
|
||||
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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -192,9 +192,8 @@ fn want_cover(ui: &Rc<Ui>, 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<Ui>, 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<Ui>) {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Ui>, retry: >k4::Button, add_folder: >k4::Button) {
|
||||
|
||||
@@ -268,27 +268,25 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
|
||||
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<Ui>, 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<Ui>) {
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<Ui>) {
|
||||
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<Ui>) {
|
||||
},
|
||||
_ => 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)),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<bool>,
|
||||
/// Activity group + its current rows, hidden when no transfer is in flight.
|
||||
/// Transfers group (on the Sync page) + its current rows, hidden when no
|
||||
/// transfer is in flight.
|
||||
pub(crate) transfers_group: adw::PreferencesGroup,
|
||||
pub(crate) transfer_rows: RefCell<Vec<TransferRow>>,
|
||||
// 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<bool>,
|
||||
@@ -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<bool>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
|
||||
/// Widgets the settings page hands back for the refresh loop and action wiring.
|
||||
/// Widgets the Preferences dialog and the sidebar footer hand back for the
|
||||
/// refresh loop and action wiring.
|
||||
pub(crate) struct MainWidgets {
|
||||
pub(crate) 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>) {
|
||||
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<Ui>) {
|
||||
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<Ui>) {
|
||||
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<Ui>) {
|
||||
if ui.status.quota_inflight.get() {
|
||||
@@ -583,7 +650,7 @@ pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
||||
{
|
||||
ui.status.quota_checked_at.set(Some(Instant::now()));
|
||||
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<Ui>) {
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
failing_ops,
|
||||
failing_error,
|
||||
..
|
||||
})) => {
|
||||
set_mounted(&ui, true);
|
||||
@@ -878,14 +947,19 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
// in it: it is why a file that looks saved is not on the remote
|
||||
// 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<Ui>) {
|
||||
// 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<Ui>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// What the sidebar's status strip reports, most urgent first.
|
||||
pub(crate) enum SyncState {
|
||||
/// Operations keep failing; the user may have to act.
|
||||
Attention {
|
||||
count: u64,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// No connection to Proton: cached files only, changes wait.
|
||||
Offline {
|
||||
queued: Option<String>,
|
||||
},
|
||||
/// Local changes are on their way up.
|
||||
Syncing {
|
||||
queued: String,
|
||||
},
|
||||
UpToDate {
|
||||
mountpoint: String,
|
||||
},
|
||||
/// No mount daemon answered: still starting, or the service is down.
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/// Paint the sidebar status strip: icon, one-line state, and a detail line.
|
||||
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
let (icon, class, title, detail) = match state {
|
||||
SyncState::Attention { count, error } => (
|
||||
"dialog-warning-symbolic",
|
||||
Some("error"),
|
||||
format!(
|
||||
"{} need{} attention",
|
||||
count_noun(count as usize, "change", "changes"),
|
||||
if count == 1 { "s" } else { "" }
|
||||
),
|
||||
error,
|
||||
),
|
||||
SyncState::Offline { queued } => (
|
||||
"network-offline-symbolic",
|
||||
Some("warning"),
|
||||
"Offline".to_string(),
|
||||
Some(queued.unwrap_or_else(|| "Cached files only".to_string())),
|
||||
),
|
||||
SyncState::Syncing { queued } => (
|
||||
"emblem-synchronizing-symbolic",
|
||||
None,
|
||||
"Syncing".to_string(),
|
||||
Some(queued),
|
||||
),
|
||||
SyncState::UpToDate { mountpoint } => (
|
||||
"emblem-ok-symbolic",
|
||||
Some("success"),
|
||||
"Up to date".to_string(),
|
||||
Some(mountpoint),
|
||||
),
|
||||
SyncState::Disconnected => (
|
||||
"network-offline-symbolic",
|
||||
Some("warning"),
|
||||
"Not connected".to_string(),
|
||||
Some("Proton Drive isn't running".to_string()),
|
||||
),
|
||||
};
|
||||
let image = &ui.status.status_icon;
|
||||
image.set_icon_name(Some(icon));
|
||||
for c in ["success", "warning", "error"] {
|
||||
image.remove_css_class(c);
|
||||
}
|
||||
if let Some(class) = class {
|
||||
image.add_css_class(class);
|
||||
}
|
||||
ui.status.status_title.set_label(&title);
|
||||
ui.status.status_detail.set_visible(detail.is_some());
|
||||
ui.status
|
||||
.status_detail
|
||||
.set_label(detail.as_deref().unwrap_or_default());
|
||||
ui.status.status_detail.set_tooltip_text(detail.as_deref());
|
||||
}
|
||||
|
||||
/// Render the pins group from `pins`, with the unpin buttons enabled only while a
|
||||
/// 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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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();
|
||||
|
||||
@@ -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<Ui>, widgets: &TakeoutWidgets) {
|
||||
@@ -755,7 +744,7 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
||||
ui.takeout.summary_group.set_visible(true);
|
||||
}
|
||||
|
||||
/// Paint the import's progress from the transfer snapshot the 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<Ui>, jobs: &[JobItem]) {
|
||||
|
||||
@@ -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::<BoxedAnyObject>();
|
||||
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Trash")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let subtitle = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
subtitle.add_css_class("dim-label");
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
titles.append(&subtitle);
|
||||
|
||||
let empty = gtk4::Button::builder()
|
||||
.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<Ui>, 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<Ui>, 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"),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user