diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 7a0bb20432..260cb835c7 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -377,6 +377,7 @@ fn app_creator<'a>( if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) { app.restore(&json); } + app.window_state = pdfcraft_ui_egui::window_state::WindowState::started_with(cc.storage); app.integrated_titlebar = integrated; app.window_controls = window_controls; app.system_text_scale = text_scale::system(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79464624c6..17f277c474 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -142,6 +142,8 @@ pub mod updates; mod wheel_pager; mod widgets; pub mod window_controls; +#[cfg(not(target_arch = "wasm32"))] +pub mod window_state; #[cfg(all(test, not(target_arch = "wasm32")))] mod open_worker_tests; @@ -744,6 +746,9 @@ pub struct PdfCraftApp { dialog_epoch: u64, /// The egui context, for commands that change window or theme state. ctx: Option, + /// The window's size and place kept across a minimized close (desktop). + #[cfg(not(target_arch = "wasm32"))] + pub window_state: window_state::WindowState, styled: bool, fonts_ready: bool, /// The CJK fallback order the installed fonts follow (see `theme::installed_font_definitions`). @@ -1033,6 +1038,8 @@ impl PdfCraftApp { dialog_seen: None, dialog_epoch: 0, ctx: None, + #[cfg(not(target_arch = "wasm32"))] + window_state: Default::default(), styled: false, fonts_ready: false, fonts_cjk: theme::CjkPreference::Default, @@ -2910,11 +2917,19 @@ impl eframe::App for PdfCraftApp { } fn save(&mut self, storage: &mut dyn eframe::Storage) { + // eframe has just stored the window as it is now: not while it is minimized. + #[cfg(not(target_arch = "wasm32"))] + { + let minimized = self.ctx.as_ref().is_some_and(|c| c.input(|i| i.viewport().minimized == Some(true))); + self.window_state.keep(storage, minimized); + } storage.set_string("pdfcraft", self.persist()); } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.ctx = Some(ctx.clone()); + #[cfg(not(target_arch = "wasm32"))] + self.window_state.repair(ctx); self.dialog_epoch(); // Notices raised outside `ui` (opened files, OS events, the control channel) translate too. let lang = i18n::Lang::from_pref(&self.language); diff --git a/crates/ui-egui/src/window_state.rs b/crates/ui-egui/src/window_state.rs new file mode 100644 index 0000000000..0475604a48 --- /dev/null +++ b/crates/ui-egui/src/window_state.rs @@ -0,0 +1,137 @@ +//! The window's saved size and place across starts. eframe stores them just before +//! `App::save`, from the window as it is at that moment. A minimized window has neither: Windows +//! reports it as 0 × 0 at (-32000, -32000), and the next start then opened at egui's 64-point +//! floor in the screen's corner. So while the window is minimized the last settings it was shown +//! with are put back, and a window that opens at that floor is given a usable size. + +/// eframe's storage key for the window settings. +const WINDOW_KEY: &str = "window"; +/// The size egui opens a stored size below (its floor for "ridiculously small" windows), in points. +const EGUI_FLOOR: f32 = 64.0; +/// The size a window that opened at [`EGUI_FLOOR`] is given (apps/pdfcraft opens a first start at +/// this size too), at most nine tenths of the monitor. +const USABLE: egui::Vec2 = egui::vec2(1440.0, 920.0); + +#[derive(Debug, Default)] +pub struct WindowState { + /// The settings eframe last stored for a shown (not minimized) window. + shown: Option, + /// The size at start has been checked ([`Self::repair`]). + checked: bool, +} + +impl WindowState { + /// The settings the window started with: what to put back if it is minimized before eframe + /// stores a shown one. + pub fn started_with(storage: Option<&dyn eframe::Storage>) -> Self { + Self { shown: storage.and_then(|s| s.get_string(WINDOW_KEY)), checked: false } + } + + /// After eframe has stored the window: remember it while the window is shown, and while it + /// is minimized put the last shown settings back (or none, so the next start opens at the + /// default size). + pub fn keep(&mut self, storage: &mut dyn eframe::Storage, minimized: bool) { + if !minimized { + self.shown = storage.get_string(WINDOW_KEY); + } else if let Some(shown) = &self.shown { + storage.set_string(WINDOW_KEY, shown.clone()); + } else { + storage.remove_string(WINDOW_KEY); + } + } + + /// Once the window's size is known: a shown window at egui's floor in both directions came + /// from settings stored while it was minimized (before this fix), so give it a usable size. + pub fn repair(&mut self, ctx: &egui::Context) { + if self.checked { + return; + } + let (rect, minimized, monitor) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().minimized, i.viewport().monitor_size)); + let Some(rect) = rect else { return }; + if minimized == Some(true) { + return; + } + self.checked = true; + if rect.width() <= EGUI_FLOOR + 0.5 && rect.height() <= EGUI_FLOOR + 0.5 { + // The stored settings are the broken ones: never put them back. + self.shown = None; + let size = monitor.map_or(USABLE, |m| USABLE.min(m * 0.9)); + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use eframe::Storage as _; + + #[derive(Default)] + struct Store(std::collections::HashMap); + + impl eframe::Storage for Store { + fn get_string(&self, key: &str) -> Option { + self.0.get(key).cloned() + } + fn set_string(&mut self, key: &str, value: String) { + self.0.insert(key.to_string(), value); + } + fn remove_string(&mut self, key: &str) { + self.0.remove(key); + } + fn flush(&mut self) {} + } + + const SHOWN: &str = "(outer_position_pixels:Some((x:200.0,y:200.0)),inner_size_points:Some((x:1320.0,y:940.0)))"; + const MINIMIZED: &str = "(outer_position_pixels:Some((x:-32000.0,y:-32000.0)),inner_size_points:Some((x:0.0,y:0.0)))"; + + /// Closing PdfCraft while it was minimized stored a 0 × 0 window off screen. + #[test] + fn a_minimized_window_keeps_the_last_shown_settings() { + let mut store = Store::default(); + store.set_string(WINDOW_KEY, SHOWN.into()); + let mut state = WindowState::started_with(Some(&store)); + // eframe stores the minimized window, then the app saves. + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY).as_deref(), Some(SHOWN)); + // Shown again and resized: that is what is kept from then on. + let moved = SHOWN.replace("200.0", "300.0"); + store.set_string(WINDOW_KEY, moved.clone()); + state.keep(&mut store, false); + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY), Some(moved)); + } + + #[test] + fn with_nothing_shown_yet_a_minimized_save_stores_no_window() { + let mut store = Store::default(); + let mut state = WindowState::started_with(Some(&store)); + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY), None, "the next start opens at the default size"); + } + + fn repaired(size: egui::Vec2, minimized: Option) -> Vec { + let ctx = egui::Context::default(); + let mut input = egui::RawInput::default(); + let info = input.viewports.entry(egui::ViewportId::ROOT).or_default(); + info.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)); + info.minimized = minimized; + info.monitor_size = Some(egui::vec2(2674.0, 1504.0)); + let mut state = WindowState { shown: Some(MINIMIZED.into()), checked: false }; + let out = ctx.run_logic(&input, |ctx| state.repair(ctx)); + let commands = out.viewport_commands.get(&egui::ViewportId::ROOT).cloned().unwrap_or_default(); + // Repaired: the settings it started with are not put back by a minimized close. + assert_eq!(state.shown.is_none(), !commands.is_empty()); + commands + } + + #[test] + fn a_window_at_eguis_floor_gets_a_usable_size() { + assert_eq!(repaired(egui::vec2(64.0, 64.0), Some(false)), [egui::ViewportCommand::InnerSize(USABLE)]); + assert!(repaired(egui::vec2(820.0, 520.0), Some(false)).is_empty(), "a small window someone chose stays"); + assert!(repaired(egui::vec2(64.0, 64.0), Some(true)).is_empty(), "not while minimized"); + } +}