mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +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
+266
-151
@@ -60,8 +60,8 @@ use pdfs_core::service;
|
||||
|
||||
const APP_ID: &str = "io.narl.proton-drive-linux";
|
||||
|
||||
/// Proton brand purple, applied as the libadwaita accent so switches, buttons,
|
||||
/// links and the storage bar all pick it up.
|
||||
/// Proton brand purple, applied as the libadwaita accent when the user opts in
|
||||
/// (Preferences → Appearance), so switches, buttons and links all pick it up.
|
||||
const PROTON_PURPLE: &str = "#6d4aff";
|
||||
|
||||
/// How often the window re-reads mount status, cache usage and the pin list.
|
||||
@@ -97,9 +97,9 @@ struct Ui {
|
||||
/// rename, a failed upload, a purge) is reported here rather than in a modal,
|
||||
/// so an action never interrupts what the user is doing next.
|
||||
toasts: adw::ToastOverlay,
|
||||
/// Header spinner shown while any open/load round-trip is in flight; ref-
|
||||
/// counted via [`Self::busy`] so concurrent operations don't stop it early.
|
||||
spinner: gtk4::Spinner,
|
||||
/// How many open/load round-trips are in flight. While any is, every page
|
||||
/// header shows its spinner (see [`page_frame`]); ref-counted so concurrent
|
||||
/// operations don't hide it early.
|
||||
busy: Cell<u32>,
|
||||
/// Keys (relative path / photo uid) of open requests currently in flight, so
|
||||
/// a double-click on the same entry is a no-op instead of a second download.
|
||||
@@ -111,7 +111,7 @@ struct Ui {
|
||||
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
|
||||
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
||||
mounted: RefCell<bool>,
|
||||
/// Sidebar destination list (Files / Photos / Settings). Selecting a row swaps
|
||||
/// Sidebar destination list (My files, Photos, …, Sync). Selecting a row swaps
|
||||
/// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere.
|
||||
sidebar: gtk4::ListBox,
|
||||
/// The sidebar/content split. Collapsed while signed out, so the login page
|
||||
@@ -142,8 +142,7 @@ impl Ui {
|
||||
/// Begin a unit of background work: show + spin the header spinner.
|
||||
fn busy_begin(&self) {
|
||||
self.busy.set(self.busy.get() + 1);
|
||||
self.spinner.set_visible(true);
|
||||
self.spinner.start();
|
||||
set_busy_spinners(true);
|
||||
}
|
||||
|
||||
/// End a unit of background work: stop the spinner once the last one is done.
|
||||
@@ -151,8 +150,7 @@ impl Ui {
|
||||
let remaining = self.busy.get().saturating_sub(1);
|
||||
self.busy.set(remaining);
|
||||
if remaining == 0 {
|
||||
self.spinner.stop();
|
||||
self.spinner.set_visible(false);
|
||||
set_busy_spinners(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +197,9 @@ fn main() -> glib::ExitCode {
|
||||
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||
app.connect_startup(|_| {
|
||||
load_proton_theme();
|
||||
if let Ok(dirs) = AppDirs::new() {
|
||||
set_proton_accent(dirs.load_config().proton_accent.unwrap_or(false));
|
||||
}
|
||||
// Refresh the file manager's right-click pin/unpin scripts, so they always
|
||||
// match the installed `pdfs`.
|
||||
pdfs_core::shell::install_file_manager_scripts();
|
||||
@@ -216,11 +217,10 @@ fn spawn_tray() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a CSS provider that overrides libadwaita's accent colour with Proton
|
||||
/// purple, app-wide. Named-colour overrides recolour the stock widgets (switch,
|
||||
/// buttons, progress fill) without per-widget styling.
|
||||
/// Register the bundled GResources (custom icons, the stylesheet) and load the
|
||||
/// app stylesheet. The Proton accent is a separate provider, applied by
|
||||
/// [`set_proton_accent`] from the saved preference.
|
||||
fn load_proton_theme() {
|
||||
// Compile-in and register our custom GResources (e.g. custom icons)
|
||||
let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource"));
|
||||
let resource_data = glib::Bytes::from_static(bytes);
|
||||
if let Ok(resource) = gio::Resource::from_data(&resource_data) {
|
||||
@@ -229,82 +229,53 @@ fn load_proton_theme() {
|
||||
tracing::error!("failed to load gresource bundle");
|
||||
}
|
||||
|
||||
let css = format!(
|
||||
"@define-color accent_bg_color {PROTON_PURPLE};\n\
|
||||
@define-color accent_color {PROTON_PURPLE};\n\
|
||||
.brand-title {{ font-size: 1.6rem; font-weight: 800; }}\n\
|
||||
.brand-icon {{ color: {PROTON_PURPLE}; }}\n\
|
||||
.file-grid {{ padding: 6px; }}\n\
|
||||
.file-tile {{ padding: 8px; border-radius: 10px; }}\n\
|
||||
.file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n\
|
||||
.file-thumbnail {{ border-radius: 7px; }}\n\
|
||||
.file-badge {{ -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }}\n\
|
||||
.badge-pinned {{ color: #f5c211; }}\n\
|
||||
.badge-cached {{ color: #2ec27e; }}\n\
|
||||
.badge-cloud {{ color: #9aa0a6; }}\n\
|
||||
.browser-statusbar {{ background-color: alpha(currentColor, 0.025); }}\n\
|
||||
scale.browser-status-meter trough, progressbar.browser-status-meter trough {{ min-width: 104px; }}\n\
|
||||
scale.browser-status-meter trough {{ min-height: 6px; }}\n\
|
||||
progressbar.browser-status-meter trough, progressbar.browser-status-meter progress {{ min-height: 6px; }}\n\
|
||||
.photo-viewer-window {{ background-color: #111014; }}\n\
|
||||
.viewer-top-bar {{ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }}\n\
|
||||
.viewer-title {{ font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\
|
||||
.viewer-counter {{ font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }}\n\
|
||||
.viewer-action-btn {{ color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }}\n\
|
||||
.viewer-action-btn:hover {{ background-color: rgba(255, 255, 255, 0.28); color: white; }}\n\
|
||||
.viewer-action-btn:checked {{ background-color: {PROTON_PURPLE}; color: white; }}\n\
|
||||
.viewer-close-btn {{ background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }}\n\
|
||||
.viewer-close-btn:hover {{ background-color: #e01b24; color: white; }}\n\
|
||||
.viewer-nav-btn {{ background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }}\n\
|
||||
.viewer-nav-btn:hover {{ background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }}\n\
|
||||
.viewer-nav-btn:disabled {{ opacity: 0; }}\n\
|
||||
.viewer-spinner {{ color: white; }}\n\
|
||||
.viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\
|
||||
.viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\
|
||||
.gallery-day {{ font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 4px 2px 8px 2px; }}\n\
|
||||
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }}\n\
|
||||
.photo-tile:hover {{ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }}\n\
|
||||
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
|
||||
.photo-thumb {{ transition: transform 220ms ease; }}\n\
|
||||
.photo-tile:hover .photo-thumb {{ transform: scale(1.06); }}\n\
|
||||
.photo-check {{ color: white; background: rgba(0, 0, 0, 0.5); border-radius: 999px; padding: 3px; }}\n\
|
||||
.photo-check-on {{ background: {PROTON_PURPLE}; }}\n\
|
||||
.photo-tile-selected {{ outline: 3px solid {PROTON_PURPLE}; outline-offset: -3px; }}\n\
|
||||
.photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\
|
||||
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }}\n\
|
||||
.photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\
|
||||
.photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\
|
||||
.photo-group-badge {{ font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }}\n\
|
||||
.photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\
|
||||
.album-card {{ padding: 0; border-radius: 16px; transition: background 180ms ease; }}\n\
|
||||
.album-card:hover {{ background: alpha(currentColor, 0.07); }}\n\
|
||||
.album-cover {{ border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }}\n\
|
||||
.album-card:hover .album-cover {{ box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }}\n\
|
||||
.album-card:hover .photo-thumb {{ transform: scale(1.05); }}\n\
|
||||
.view-switch button {{ padding-left: 18px; padding-right: 18px; }}\n\
|
||||
.card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\
|
||||
.card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\
|
||||
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
|
||||
.navigation-sidebar row:selected {{ background: alpha({PROTON_PURPLE}, 0.16); color: {PROTON_PURPLE}; font-weight: 600; }}\n\
|
||||
.navigation-sidebar row:selected image {{ color: {PROTON_PURPLE}; }}\n\
|
||||
.file-tile:selected, .file-tile:hover:selected {{ background: alpha({PROTON_PURPLE}, 0.20); }}\n\
|
||||
.bulk-bar {{ padding: 6px 8px; border-radius: 12px; background: alpha({PROTON_PURPLE}, 0.12); }}\n\
|
||||
.dropzone {{ border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }}\n\
|
||||
.dropzone-active {{ border-color: {PROTON_PURPLE}; background: alpha({PROTON_PURPLE}, 0.10); }}\n"
|
||||
);
|
||||
let Some(display) = gtk4::gdk::Display::default() else {
|
||||
return;
|
||||
};
|
||||
let provider = gtk4::CssProvider::new();
|
||||
provider.load_from_string(&css);
|
||||
if let Some(display) = gtk4::gdk::Display::default() {
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
provider.load_from_resource("/de/nils/protondrivelinux/style.css");
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons");
|
||||
}
|
||||
|
||||
// Register custom icons directory from our GResource with the icon theme
|
||||
let icon_theme = gtk4::IconTheme::for_display(&display);
|
||||
icon_theme.add_resource_path("/de/nils/protondrivelinux/icons");
|
||||
}
|
||||
thread_local! {
|
||||
/// The Proton purple accent provider while it is installed. Kept so turning
|
||||
/// the preference off can remove exactly what turning it on added.
|
||||
static PROTON_ACCENT: RefCell<Option<gtk4::CssProvider>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Paint the app in Proton purple (`on`) or follow the system accent colour.
|
||||
/// Every accent-coloured widget reads `--accent-bg-color`, so overriding that
|
||||
/// one variable recolours the whole app.
|
||||
fn set_proton_accent(on: bool) {
|
||||
let Some(display) = gtk4::gdk::Display::default() else {
|
||||
return;
|
||||
};
|
||||
PROTON_ACCENT.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
match (on, slot.as_ref()) {
|
||||
(true, None) => {
|
||||
let provider = gtk4::CssProvider::new();
|
||||
provider
|
||||
.load_from_string(&format!(":root {{ --accent-bg-color: {PROTON_PURPLE}; }}"));
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
|
||||
);
|
||||
*slot = Some(provider);
|
||||
}
|
||||
(false, Some(provider)) => {
|
||||
gtk4::style_context_remove_provider_for_display(&display, provider);
|
||||
*slot = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the application window, wire the two pages, kick off the refresh loop,
|
||||
@@ -320,7 +291,7 @@ fn build_window(app: &adw::Application) {
|
||||
|
||||
let stack = adw::ViewStack::new();
|
||||
let (login_page, login_widgets) = build_login_page();
|
||||
let (main_page, main_widgets) = build_main_page();
|
||||
let main_widgets = build_main_page();
|
||||
let (browser_page, browser_widgets) = build_browser_page();
|
||||
let (gallery_page, gallery_widgets) = build_gallery_page();
|
||||
let (shared_page, shared_widgets) = build_shared_page();
|
||||
@@ -330,8 +301,16 @@ fn build_window(app: &adw::Application) {
|
||||
let (activity_page, activity_widgets) = build_activity_page();
|
||||
let (trash_page, trash_widgets) = build_trash_page();
|
||||
let (takeout_page, takeout_widgets) = build_takeout_page();
|
||||
stack.add_named(&login_page, Some("login"));
|
||||
stack.add_named(&main_page, Some("main"));
|
||||
// The login page has no title and no actions, but it still needs a header
|
||||
// bar: it is the only thing carrying the window controls.
|
||||
let login_frame = adw::ToolbarView::new();
|
||||
login_frame.add_top_bar(&{
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_show_title(false);
|
||||
header
|
||||
});
|
||||
login_frame.set_content(Some(&login_page));
|
||||
stack.add_named(&login_frame, Some("login"));
|
||||
stack.add_named(&browser_page, Some("browser"));
|
||||
stack.add_named(&gallery_page, Some("gallery"));
|
||||
stack.add_named(&shared_by_me_page, Some("sharedbyme"));
|
||||
@@ -345,21 +324,13 @@ fn build_window(app: &adw::Application) {
|
||||
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
|
||||
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
|
||||
// login lands on Files).
|
||||
let (sidebar_page, sidebar_list) = build_sidebar();
|
||||
|
||||
// Header spinner, hidden until a background open/load is in flight.
|
||||
let spinner = gtk4::Spinner::new();
|
||||
spinner.set_visible(false);
|
||||
let header = adw::HeaderBar::new();
|
||||
header.pack_end(&build_primary_menu());
|
||||
header.pack_end(&spinner);
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&stack));
|
||||
let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer);
|
||||
|
||||
// Every page brings its own header bar (see `page_frame`), so the content
|
||||
// side is the bare stack.
|
||||
let content_page = adw::NavigationPage::builder()
|
||||
.title("Proton Drive")
|
||||
.child(&toolbar)
|
||||
.child(&stack)
|
||||
.build();
|
||||
let split = adw::NavigationSplitView::builder()
|
||||
.sidebar(&sidebar_page)
|
||||
@@ -377,7 +348,6 @@ fn build_window(app: &adw::Application) {
|
||||
dirs,
|
||||
stack: stack.clone(),
|
||||
toasts: toasts.clone(),
|
||||
spinner: spinner.clone(),
|
||||
busy: Cell::new(0),
|
||||
opening: RefCell::new(HashSet::new()),
|
||||
session: RefCell::new(auth::load().ok()),
|
||||
@@ -393,14 +363,18 @@ fn build_window(app: &adw::Application) {
|
||||
},
|
||||
status: StatusState {
|
||||
status_inflight: Cell::new(false),
|
||||
account_row: main_widgets.account_row.clone(),
|
||||
mount_row: main_widgets.mount_row.clone(),
|
||||
transfers_group: main_widgets.transfers_group.clone(),
|
||||
transfers_group: locations_widgets.transfers_group.clone(),
|
||||
prefs: main_widgets.prefs.clone(),
|
||||
account_name: main_widgets.account_name.clone(),
|
||||
avatar: main_widgets.avatar.clone(),
|
||||
status_icon: main_widgets.status_icon.clone(),
|
||||
status_title: main_widgets.status_title.clone(),
|
||||
status_detail: main_widgets.status_detail.clone(),
|
||||
transfer_rows: RefCell::new(Vec::new()),
|
||||
transfers_inflight: Cell::new(false),
|
||||
cache_bar: main_widgets.cache_bar.clone(),
|
||||
cache_label: main_widgets.cache_label.clone(),
|
||||
quota_group: main_widgets.quota_group.clone(),
|
||||
quota_box: main_widgets.quota_box.clone(),
|
||||
quota_bar: main_widgets.quota_bar.clone(),
|
||||
quota_label: main_widgets.quota_label.clone(),
|
||||
quota_inflight: Cell::new(false),
|
||||
@@ -408,6 +382,7 @@ fn build_window(app: &adw::Application) {
|
||||
autostart_row: main_widgets.autostart_row.clone(),
|
||||
budget_row: main_widgets.budget_row.clone(),
|
||||
mountpoint_row: main_widgets.mountpoint_row.clone(),
|
||||
accent_row: main_widgets.accent_row.clone(),
|
||||
settings_suppress: Cell::new(false),
|
||||
budget_source: RefCell::new(None),
|
||||
pins_expanded: Cell::new(false),
|
||||
@@ -485,7 +460,6 @@ fn build_window(app: &adw::Application) {
|
||||
import: gallery_widgets.import.clone(),
|
||||
empty_actions: gallery_widgets.empty_actions.clone(),
|
||||
title: gallery_widgets.title.clone(),
|
||||
subtitle: gallery_widgets.subtitle.clone(),
|
||||
albums: gallery_widgets.albums.clone(),
|
||||
albums_stack: gallery_widgets.albums_stack.clone(),
|
||||
albums_status: gallery_widgets.albums_status.clone(),
|
||||
@@ -598,7 +572,10 @@ fn build_window(app: &adw::Application) {
|
||||
},
|
||||
});
|
||||
wire_login(&ui);
|
||||
wire_logout(&ui, &main_widgets.logout_button);
|
||||
let ui_status = ui.clone();
|
||||
main_widgets
|
||||
.status_button
|
||||
.connect_clicked(move |_| ui_status.stack.set_visible_child_name("locations"));
|
||||
wire_settings(
|
||||
&ui,
|
||||
&main_widgets.purge_button,
|
||||
@@ -698,7 +675,9 @@ fn build_window(app: &adw::Application) {
|
||||
.content(&toasts)
|
||||
.build();
|
||||
install_shortcuts(&ui, &window);
|
||||
install_window_actions(&window);
|
||||
install_window_actions(&ui, &window);
|
||||
app.set_accels_for_action("win.preferences", &["<Control>comma"]);
|
||||
app.set_accels_for_action("win.shortcuts", &["<Control>question"]);
|
||||
|
||||
refresh(&ui);
|
||||
// Periodic refresh while the window lives. The closure holds a strong `Rc`;
|
||||
@@ -720,40 +699,59 @@ fn build_window(app: &adw::Application) {
|
||||
}
|
||||
|
||||
/// The sidebar destinations, in order: the row index is the index into this table,
|
||||
/// and each entry is `(stack page name, label, icon)`.
|
||||
const DESTINATIONS: [(&str, &str, &str); 9] = [
|
||||
/// and each entry is `(stack page name, label, icon)`. The places in the account
|
||||
/// come first; the rows from [`SIDEBAR_SYNC_SECTION`] on are about this computer's
|
||||
/// sync, set off by a separator.
|
||||
const DESTINATIONS: [(&str, &str, &str); 8] = [
|
||||
("browser", "My files", "folder-symbolic"),
|
||||
("sharedbyme", "Shared", "emblem-shared-symbolic"),
|
||||
("gallery", "Photos", "image-x-generic-symbolic"),
|
||||
("shared", "Shared with me", "system-users-symbolic"),
|
||||
("locations", "Locations", "drive-harddisk-symbolic"),
|
||||
("sharedbyme", "Shared by me", "emblem-shared-symbolic"),
|
||||
("devices", "Computers", "computer-symbolic"),
|
||||
("gallery", "Gallery", "image-x-generic-symbolic"),
|
||||
("activity", "Activity", "document-open-recent-symbolic"),
|
||||
("trash", "Trash", "user-trash-symbolic"),
|
||||
("main", "Settings", "emblem-system-symbolic"),
|
||||
("locations", "Sync", "emblem-synchronizing-symbolic"),
|
||||
("activity", "Activity", "document-open-recent-symbolic"),
|
||||
];
|
||||
|
||||
/// The navigation sidebar: a Proton-branded header over one row per destination.
|
||||
/// Returns the page (for the split view) and the list (to drive + reflect the
|
||||
/// current page).
|
||||
fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
/// Index of the first row in the sidebar's sync section.
|
||||
const SIDEBAR_SYNC_SECTION: i32 = 6;
|
||||
|
||||
/// The navigation sidebar: the destinations over a footer carrying the sync
|
||||
/// status, the account quota and the account menu. Returns the page (for the
|
||||
/// split view) and the list (to drive + reflect the current page).
|
||||
fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
let list = gtk4::ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::Single);
|
||||
list.add_css_class("navigation-sidebar");
|
||||
for (_, label, icon) in DESTINATIONS {
|
||||
let row = adw::ActionRow::builder().title(label).build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(icon));
|
||||
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||
row_box.append(>k4::Image::from_icon_name(icon));
|
||||
row_box.append(>k4::Label::new(Some(label)));
|
||||
let row = gtk4::ListBoxRow::builder().child(&row_box).build();
|
||||
list.append(&row);
|
||||
}
|
||||
list.set_header_func(|row, _| {
|
||||
if row.index() == SIDEBAR_SYNC_SECTION {
|
||||
let separator = gtk4::Separator::new(gtk4::Orientation::Horizontal);
|
||||
separator.set_margin_top(6);
|
||||
separator.set_margin_bottom(6);
|
||||
row.set_header(Some(&separator));
|
||||
} else {
|
||||
row.set_header(gtk4::Widget::NONE);
|
||||
}
|
||||
});
|
||||
|
||||
let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
let icon = gtk4::Image::from_icon_name("folder-remote-symbolic");
|
||||
icon.add_css_class("brand-icon");
|
||||
brand.append(&icon);
|
||||
brand.append(>k4::Label::new(Some("Proton Drive")));
|
||||
let name = gtk4::Label::new(Some("Proton Drive"));
|
||||
name.add_css_class("heading");
|
||||
brand.append(&name);
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_title_widget(Some(&brand));
|
||||
header.pack_end(&build_primary_menu());
|
||||
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
@@ -763,6 +761,7 @@ fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&scroll));
|
||||
toolbar.add_bottom_bar(footer);
|
||||
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Proton Drive")
|
||||
@@ -771,6 +770,46 @@ fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
(page, list)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Every page header's busy spinner. There is one per page because each
|
||||
/// page has its own header bar; [`Ui::busy_begin`] shows them all, so the one
|
||||
/// on screen is always the right one.
|
||||
static BUSY_SPINNERS: RefCell<Vec<gtk4::Spinner>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
fn set_busy_spinners(visible: bool) {
|
||||
BUSY_SPINNERS.with(|spinners| {
|
||||
for spinner in spinners.borrow().iter() {
|
||||
spinner.set_visible(visible);
|
||||
spinner.set_spinning(visible);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A destination page's frame: its own header bar over the page content, the
|
||||
/// way GNOME apps lay a split view out. The title sits in the middle with the
|
||||
/// busy spinner beside it; the page packs its actions into the returned header
|
||||
/// bar and may give the returned title a subtitle.
|
||||
fn page_frame(
|
||||
title: &str,
|
||||
content: &impl IsA<gtk4::Widget>,
|
||||
) -> (adw::ToolbarView, adw::HeaderBar, adw::WindowTitle) {
|
||||
let window_title = adw::WindowTitle::new(title, "");
|
||||
let spinner = gtk4::Spinner::new();
|
||||
spinner.set_visible(false);
|
||||
BUSY_SPINNERS.with(|spinners| spinners.borrow_mut().push(spinner.clone()));
|
||||
let title_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||
title_box.append(&window_title);
|
||||
title_box.append(&spinner);
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_title_widget(Some(&title_box));
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(content));
|
||||
(toolbar, header, window_title)
|
||||
}
|
||||
|
||||
/// The sidebar destination a page belongs under. Most pages are their own
|
||||
/// destination; a sub-page reached from one (Import, off Photos) has no row of
|
||||
/// its own and answers with its parent, so the sidebar highlights where the user
|
||||
@@ -1033,21 +1072,39 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The header's primary (hamburger) menu: the app-level entries that don't belong
|
||||
/// on any one page.
|
||||
/// The sidebar's primary (hamburger) menu: the app-level entries that don't
|
||||
/// belong on any one page.
|
||||
fn build_primary_menu() -> gtk4::MenuButton {
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Preferences"), Some("win.preferences"));
|
||||
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
|
||||
menu.append(Some("About Proton Drive for Linux"), Some("win.about"));
|
||||
gtk4::MenuButton::builder()
|
||||
.icon_name("open-menu-symbolic")
|
||||
.tooltip_text("Main menu")
|
||||
.primary(true)
|
||||
.menu_model(&menu)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Back the primary menu's entries with window actions.
|
||||
fn install_window_actions(window: &adw::ApplicationWindow) {
|
||||
/// Back the primary and account menus' entries with window actions.
|
||||
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
let preferences = gio::SimpleAction::new("preferences", None);
|
||||
let ui_prefs = ui.clone();
|
||||
let win = window.clone();
|
||||
preferences.connect_activate(move |_, _| {
|
||||
// Signed out there is nothing to configure: every page needs a session.
|
||||
if ui_prefs.session.borrow().is_some() {
|
||||
ui_prefs.status.prefs.present(Some(&win));
|
||||
}
|
||||
});
|
||||
window.add_action(&preferences);
|
||||
|
||||
let sign_out_action = gio::SimpleAction::new("sign-out", None);
|
||||
let ui_out = ui.clone();
|
||||
sign_out_action.connect_activate(move |_, _| sign_out(&ui_out));
|
||||
window.add_action(&sign_out_action);
|
||||
|
||||
let shortcuts = gio::SimpleAction::new("shortcuts", None);
|
||||
let win = window.clone();
|
||||
shortcuts.connect_activate(move |_, _| show_shortcuts(&win));
|
||||
@@ -1055,6 +1112,7 @@ fn install_window_actions(window: &adw::ApplicationWindow) {
|
||||
|
||||
let about = gio::SimpleAction::new("about", None);
|
||||
let win = window.clone();
|
||||
let ui_about = ui.clone();
|
||||
about.connect_activate(move |_, _| {
|
||||
let dialog = adw::AboutDialog::builder()
|
||||
.application_name("Proton Drive for Linux")
|
||||
@@ -1068,41 +1126,98 @@ fn install_window_actions(window: &adw::ApplicationWindow) {
|
||||
"Files-on-demand Proton Drive for the Linux desktop.\n\n\
|
||||
Unofficial client — not affiliated with, endorsed by, or supported by Proton AG.",
|
||||
)
|
||||
.debug_info(debug_info(&ui_about))
|
||||
.debug_info_filename("proton-drive-linux-debug.txt")
|
||||
.build();
|
||||
dialog.present(Some(&win));
|
||||
});
|
||||
window.add_action(&about);
|
||||
}
|
||||
|
||||
/// The About dialog's Troubleshooting text: what a bug report needs and what
|
||||
/// used to sit in a "Developer" group on the Settings page.
|
||||
fn debug_info(ui: &Rc<Ui>) -> String {
|
||||
let config = ui.dirs.load_config();
|
||||
format!(
|
||||
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
|
||||
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
|
||||
pdfs_core::config::APP_VERSION,
|
||||
pdfs_core::config::USER_AGENT,
|
||||
adw::major_version(),
|
||||
adw::minor_version(),
|
||||
adw::micro_version(),
|
||||
gtk4::major_version(),
|
||||
gtk4::minor_version(),
|
||||
gtk4::micro_version(),
|
||||
ui.dirs.resolved_mountpoint(&config).display(),
|
||||
ui.dirs.control_socket().display(),
|
||||
if *ui.mounted.borrow() { "yes" } else { "no" },
|
||||
)
|
||||
}
|
||||
|
||||
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
|
||||
/// [`install_shortcuts`] binds.
|
||||
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
|
||||
fn show_shortcuts(window: &adw::ApplicationWindow) {
|
||||
const KEYS: [(&str, &str); 6] = [
|
||||
("Ctrl+F", "Search Drive"),
|
||||
("Ctrl+N", "New folder"),
|
||||
("Ctrl+U", "Upload file"),
|
||||
("F2", "Rename selection"),
|
||||
("Delete", "Move selection to Trash"),
|
||||
("Escape", "Close the details pane"),
|
||||
const GROUPS: [(&str, &[(&str, &str)]); 4] = [
|
||||
(
|
||||
"General",
|
||||
&[
|
||||
("<Control>f", "Search Drive"),
|
||||
("F5", "Refresh"),
|
||||
("<Control>comma", "Preferences"),
|
||||
("<Control>question", "Keyboard shortcuts"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Files",
|
||||
&[
|
||||
("<Control>n", "New folder"),
|
||||
("<Control>u", "Upload files"),
|
||||
("F2", "Rename"),
|
||||
("Delete", "Move to Trash"),
|
||||
("Escape", "Clear the selection"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Photos",
|
||||
&[
|
||||
("<Control>plus", "Larger thumbnails"),
|
||||
("<Control>minus", "Smaller thumbnails"),
|
||||
("<Control>0", "Reset thumbnail size"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Photo Viewer",
|
||||
&[
|
||||
("Left Right", "Previous / next photo"),
|
||||
("Home End", "First / last photo"),
|
||||
("i", "Show details"),
|
||||
("f", "Fullscreen"),
|
||||
("Delete", "Move to Trash"),
|
||||
("Escape", "Close"),
|
||||
],
|
||||
),
|
||||
];
|
||||
let group = adw::PreferencesGroup::builder().title("Files").build();
|
||||
for (keys, action) in KEYS {
|
||||
let row = adw::ActionRow::builder().title(action).build();
|
||||
let label = gtk4::Label::builder()
|
||||
.label(keys)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
label.add_css_class("dim-label");
|
||||
label.add_css_class("monospace");
|
||||
row.add_suffix(&label);
|
||||
group.add(&row);
|
||||
}
|
||||
let page = adw::PreferencesPage::new();
|
||||
page.add(&group);
|
||||
for (title, keys) in GROUPS {
|
||||
let group = adw::PreferencesGroup::builder().title(title).build();
|
||||
for (accel, action) in keys {
|
||||
let row = adw::ActionRow::builder().title(*action).build();
|
||||
row.add_suffix(
|
||||
>k4::ShortcutLabel::builder()
|
||||
.accelerator(*accel)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build(),
|
||||
);
|
||||
group.add(&row);
|
||||
}
|
||||
page.add(&group);
|
||||
}
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Keyboard Shortcuts")
|
||||
.content_width(420)
|
||||
.content_width(460)
|
||||
.content_height(620)
|
||||
.child(&{
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
|
||||
Reference in new issue
Block a user