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