diff --git a/CHANGELOG.md b/CHANGELOG.md index 848cb96..67940f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Major Changes - decreased the decimal count of most number to reduce used space (can be changed to be like before in the settings) +### Other Changes +- the main window now opens with the size it had when it was last closed, and comes back maximized if it was closed that way +- the main window contents now follow the window while it is being resized, instead of trailing behind the drag +- the main window can no longer be shrunk to a size where its controls no longer fit + ### Fixes - fixed auto refresh stopping to work when changing the logs path diff --git a/src/app/mod.rs b/src/app/mod.rs index ff801d5..715c19f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use eframe::egui::*; use rfd::FileDialog; @@ -33,6 +33,25 @@ pub struct App { upload: Upload, records: Records, state: AppState, + window_geometry: WindowGeometry, + window_geometry_dirty: bool, + last_geometry_change: f64, +} + +/// How long the window size has to stay unchanged before it is written to the +/// settings file, so that dragging a window edge does not cause a write per +/// frame. +const GEOMETRY_SETTLE_TIME: f64 = 2.0; + +/// How long after the last size change the window still counts as being +/// dragged, and is therefore redrawn every frame. +const ACTIVE_RESIZE_TIME: f64 = 0.5; + +/// The window size and maximized state to start with, read before the window +/// exists (see main.rs). +pub fn saved_window_geometry() -> (Option, bool) { + let window = Settings::load_or_default().window; + (window.size.map(|[w, h]| vec2(w, h)), window.maximized) } impl App { @@ -52,7 +71,10 @@ impl App { summary_copy: Default::default(), upload: Default::default(), records: Default::default(), + window_geometry: state.settings.window, state, + window_geometry_dirty: false, + last_geometry_change: 0.0, } } } @@ -60,6 +82,7 @@ impl App { impl eframe::App for App { fn ui(&mut self, ui: &mut eframe::egui::Ui, frame: &mut eframe::Frame) { self.handle_analysis_infos(); + self.track_window_geometry(ui.ctx()); CentralPanel::default().show_inside(ui, |ui| { ui.vertical(|ui| { ui.horizontal(|ui| { @@ -160,9 +183,89 @@ impl eframe::App for App { fn clear_color(&self, _visuals: &eframe::egui::Visuals) -> [f32; 4] { _visuals.window_fill().to_normalized_gamma_f32() } + + fn on_exit(&mut self) { + // Catches a size change that has not settled yet when the window is + // closed (see track_window_geometry). + if self.window_geometry_dirty { + self.save_window_geometry(); + } + } } impl App { + /// Remembers the window size and maximized state so that the next launch + /// can restore them (see `saved_window_geometry`). + /// + /// The size comes from the egui viewport rect instead of + /// `ViewportInfo::inner_rect`, because the latter is `None` on Wayland, + /// where a client is not told where its window is. That rect is in points, + /// so it is scaled back by the zoom factor to the logical pixels that + /// `ViewportBuilder::with_inner_size` expects — otherwise a "ui scale" + /// other than 1 would shrink or grow the window on every launch. + /// + /// A size change also means the window is being resized right now, which is + /// used to keep redrawing while that lasts. + fn track_window_geometry(&mut self, ctx: &Context) { + let now = ctx.input(|i| i.time); + let maximized = ctx.input(|i| i.viewport().maximized); + + // Only remember the size the window has while not maximized, so that + // un-maximizing it after a restart gives back a usable window. + if maximized != Some(true) { + let size = (ctx.viewport_rect().size() * ctx.zoom_factor()).round(); + self.set_window_geometry( + now, + WindowGeometry { + size: Some([size.x, size.y]), + ..self.window_geometry + }, + ); + } + if let Some(maximized) = maximized { + self.set_window_geometry( + now, + WindowGeometry { + maximized, + ..self.window_geometry + }, + ); + } + + if self.window_geometry_dirty { + let idle = now - self.last_geometry_change; + if idle >= GEOMETRY_SETTLE_TIME { + self.save_window_geometry(); + } else if idle < ACTIVE_RESIZE_TIME { + // The edge is still being dragged. Redraw every frame so the + // contents follow the window instead of trailing behind it. + ctx.request_repaint(); + } else { + // Dragging has stopped and no further frame is guaranteed, so + // ask for the one that writes the settled size. + ctx.request_repaint_after(Duration::from_secs_f64(GEOMETRY_SETTLE_TIME - idle)); + } + } + } + + fn set_window_geometry(&mut self, now: f64, geometry: WindowGeometry) { + if self.window_geometry != geometry { + self.window_geometry = geometry; + self.window_geometry_dirty = true; + self.last_geometry_change = now; + } + } + + /// Writes the tracked geometry into the settings file. The geometry is held + /// in a field rather than in `state.settings` because the settings dialog + /// replaces the whole settings object when it is applied, which would drop + /// a resize made while the dialog was open. + fn save_window_geometry(&mut self) { + self.state.settings.window = self.window_geometry; + self.state.settings.save(); + self.window_geometry_dirty = false; + } + fn handle_analysis_infos(&mut self) { let combatlog_file = &self.state.settings.analysis.combatlog_file; for info in self.state.analysis_handler.check_for_info() { diff --git a/src/app/settings/app_settings.rs b/src/app/settings/app_settings.rs index a89547d..25f05d3 100644 --- a/src/app/settings/app_settings.rs +++ b/src/app/settings/app_settings.rs @@ -14,6 +14,20 @@ pub struct Settings { pub debug: DebugSettings, #[serde(default)] pub upload: UploadSettings, + #[serde(default)] + pub window: WindowGeometry, +} + +/// Size and maximized state of the main window, remembered between runs. +/// +/// Kept out of [`General`] on purpose: the settings dialog compares that +/// section to decide whether the log has to be analyzed again, and resizing a +/// window is no reason to redo the analysis. +#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub struct WindowGeometry { + /// Inner size in logical pixels, as last seen while not maximized. + pub size: Option<[f32; 2]>, + pub maximized: bool, } #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] @@ -136,3 +150,29 @@ impl Default for UploadSettings { Settings::default().upload.clone() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_file_without_window_section_still_loads() { + // Settings files written by older versions have no window section. + let settings: Settings = serde_json::from_str(DEFAULT_SETTINGS).unwrap(); + assert_eq!(WindowGeometry::default(), settings.window); + } + + #[test] + fn window_geometry_survives_a_save_and_load() { + let mut settings = Settings::default(); + settings.window = WindowGeometry { + size: Some([1024.0, 768.0]), + maximized: true, + }; + + let json = serde_json::to_string(&settings).unwrap(); + let loaded: Settings = serde_json::from_str(&json).unwrap(); + + assert_eq!(settings.window, loaded.window); + } +} diff --git a/src/app/settings/mod.rs b/src/app/settings/mod.rs index 0e0fc3b..3712c86 100644 --- a/src/app/settings/mod.rs +++ b/src/app/settings/mod.rs @@ -1,6 +1,6 @@ use std::ffi::OsStr; -pub use app_settings::Settings; +pub use app_settings::{Settings, WindowGeometry}; use eframe::{Frame, egui::*}; use crate::analyzer::Combat; diff --git a/src/main.rs b/src/main.rs index 5f1afed..f12862b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,10 +26,16 @@ fn main() { })); logging::initialize(); + + // Restore the window size of the previous run. eframe clamps the requested + // size to the largest monitor on its own, so a window that was last used on + // a bigger screen still comes up usable here. + let (size, maximized) = app::saved_window_geometry(); let native_options = eframe::NativeOptions { viewport: ViewportBuilder::default() - .with_inner_size(vec2(1280.0, 720.0)) - .with_min_inner_size(vec2(480.0, 270.0)) + .with_inner_size(size.unwrap_or(vec2(1280.0, 720.0))) + .with_min_inner_size(vec2(800.0, 600.0)) + .with_maximized(maximized) .with_icon(icon_data()), ..Default::default() };