From 770d4fda38c6298957150af815c98bb579e1ded6 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 17:21:46 +0200 Subject: [PATCH] feat(core,gui): remember the main window's size The app always opened at 980x680. It now keeps the unmaximized size and whether the window was maximized in config.json under `window`, written when the window closes and when the app quits with it still open. A missing field, or a saved size below 480x400, opens at the old default. --- crates/pdfs-core/src/config.rs | 80 +++++++++++++++++++++++++++++++++ crates/pdfs-gui/src/app/main.rs | 47 +++++++++++++++++-- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/crates/pdfs-core/src/config.rs b/crates/pdfs-core/src/config.rs index a48006e..4206655 100644 --- a/crates/pdfs-core/src/config.rs +++ b/crates/pdfs-core/src/config.rs @@ -163,6 +163,10 @@ pub struct AppConfig { /// Defaulted for configs predating the field. #[serde(default)] pub files_view: FilesView, + /// The desktop app's main window size, remembered between runs. Defaulted + /// for configs predating the field. + #[serde(default)] + pub window: WindowState, /// The user hid the tray icon from its own menu. The tray exits at start /// while this is set, and the app does not spawn it; Preferences turns it /// back on. Defaulted for configs predating the field. @@ -211,6 +215,44 @@ impl Default for FilesView { } } +/// The desktop app's main window, as it was when it last closed. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(default)] +pub struct WindowState { + /// The unmaximised size, in logical pixels. + pub width: i32, + pub height: i32, + pub maximized: bool, +} + +impl WindowState { + /// Below this the saved size is taken for damage and the default is used. + pub const MIN_WIDTH: i32 = 480; + pub const MIN_HEIGHT: i32 = 400; + + /// This state, with a size too small to use replaced by the default. + pub fn usable(self) -> Self { + if self.width < Self::MIN_WIDTH || self.height < Self::MIN_HEIGHT { + Self { + maximized: self.maximized, + ..Self::default() + } + } else { + self + } + } +} + +impl Default for WindowState { + fn default() -> Self { + Self { + width: 980, + height: 680, + maximized: false, + } + } +} + /// The key a My Files listing is ordered by. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] @@ -702,6 +744,11 @@ mod tests { descending: true, folders_first: false, }, + window: WindowState { + width: 1200, + height: 800, + maximized: true, + }, tray_hidden: true, language: Some("de".to_string()), online_map: true, @@ -721,6 +768,39 @@ mod tests { assert_eq!(decoded.upload_limit, Some(500_000)); assert_eq!(decoded.download_limit, None); assert_eq!(decoded.files_view, config.files_view); + assert_eq!(decoded.window, config.window); + } + + #[test] + fn a_config_without_a_window_opens_at_the_default_size() { + let decoded: AppConfig = + serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap(); + assert_eq!(decoded.window, WindowState::default()); + let partial: WindowState = serde_json::from_str(r#"{"maximized":true}"#).unwrap(); + assert_eq!((partial.width, partial.height), (980, 680)); + assert!(partial.maximized); + } + + #[test] + fn a_window_saved_too_small_opens_at_the_default_size() { + let tiny = WindowState { + width: 0, + height: 500, + maximized: true, + }; + assert_eq!( + tiny.usable(), + WindowState { + maximized: true, + ..WindowState::default() + } + ); + let fine = WindowState { + width: 640, + height: 480, + maximized: false, + }; + assert_eq!(fine.usable(), fine); } #[test] diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index ba820ae..e854e18 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -69,7 +69,7 @@ use gtk4::glib::BoxedAnyObject; use pdfs_core::auth; -use pdfs_core::config::{AppDirs, FileSort, FilesView}; +use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState}; use pdfs_core::control::{ ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo, @@ -716,13 +716,18 @@ fn build_window(app: &adw::Application) { } }); + let saved = ui.dirs.load_config().window.usable(); let window = adw::ApplicationWindow::builder() .application(app) .title("Proton Drive") - .default_width(980) - .default_height(680) + .default_width(saved.width) + .default_height(saved.height) + .maximized(saved.maximized) + .width_request(WindowState::MIN_WIDTH) + .height_request(WindowState::MIN_HEIGHT) .content(&toasts) .build(); + remember_window_state(&ui, app, &window); // A wide window docks the details pane beside the files instead of laying it // over them. let wide = adw::Breakpoint::new( @@ -743,6 +748,42 @@ fn build_window(app: &adw::Application) { window.present(); } +/// Save the window's size when it closes, and when the app quits with the +/// window still open (Ctrl+Q, the tray's Quit), which never asks it to close. +fn remember_window_state(ui: &Rc, app: &adw::Application, window: &adw::ApplicationWindow) { + let ui_close = ui.clone(); + window.connect_close_request(move |window| { + save_window_state(&ui_close, window); + glib::Propagation::Proceed + }); + let ui_quit = ui.clone(); + let window_quit = window.downgrade(); + app.connect_shutdown(move |_| { + if let Some(window) = window_quit.upgrade() { + save_window_state(&ui_quit, &window); + } + }); +} + +/// Write the window's unmaximised size and maximised state to the config, if +/// either changed. Runs as the window goes away, so a failure is only logged. +fn save_window_state(ui: &Ui, window: &adw::ApplicationWindow) { + let (width, height) = window.default_size(); + let state = WindowState { + width, + height, + maximized: window.is_maximized(), + }; + let mut config = ui.dirs.load_config(); + if config.window == state { + return; + } + config.window = state; + if let Err(e) = ui.dirs.save_config(&config) { + tracing::warn!("cannot save the window size: {e}"); + } +} + /// Whether `window` is minimised or otherwise out of sight (another workspace, /// fully covered), as far as the compositor tells us. fn window_hidden(window: &adw::ApplicationWindow) -> bool {