From b4c815a8cd10d7bca284ad05c45ea0e62134e4e5 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Fri, 24 Jul 2026 19:53:33 -0400 Subject: [PATCH 1/4] fix(window): persist and restore window size and position across restarts Only the window position was saved (and only at exit); the constructor always reset to 1080x720, so every restart lost the user's size and shifted the window (issue #11). - Add LastWindowWidth/LastWindowHeight config keys, round-tripped with the existing position keys. - Restore saved size and position at startup, but only when the saved caption strip still intersects an attached display's working area; a changed monitor layout falls back to default placement instead of resurrecting the window off-screen. - Save normal-mode bounds at exit: from minimal UI use the pre-minimal restore bounds, from maximized use RestoreBounds, otherwise live geometry. First-run minimal closes keep any previously saved size. - Exiting minimal UI with no in-session restore bounds (started directly into minimal mode) now also restores persisted bounds. Regression tests cover config round-trip, missing-key defaults, and on/off-screen visibility validation. Fixes #11 --- src/App/PageSwitcher.xaml.cs | 98 +++++++++++++++++-- src/App/RatConfig.cs | 6 ++ .../WindowBoundsPersistenceTests.cs | 95 ++++++++++++++++++ 3 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs diff --git a/src/App/PageSwitcher.xaml.cs b/src/App/PageSwitcher.xaml.cs index 6a6d4f68..a4645025 100644 --- a/src/App/PageSwitcher.xaml.cs +++ b/src/App/PageSwitcher.xaml.cs @@ -80,11 +80,7 @@ public PageSwitcher() AddJumpList(); AddTrayIcon(); - if (RatConfig.LastWindowPositionX != int.MinValue && RatConfig.LastWindowPositionY != int.MinValue) - { - Left = RatConfig.LastWindowPositionX; - Top = RatConfig.LastWindowPositionY; - } + RestoreWindowBounds(); Topmost = RatConfig.AlwaysOnTop; if (RatConfig.LastWindowMode == RatConfig.WindowMode.Minimal) ShowMinimalUI(); @@ -111,6 +107,58 @@ internal void ResetWindowSize() Height = DefaultHeight; } + /// + /// Restores the persisted normal-mode window size and position from the + /// previous session. Saved bounds are validated against the currently + /// attached displays: a monitor layout change since the last run must not + /// resurrect the window on a screen that no longer exists. + /// + private void RestoreWindowBounds() + { + if (RatConfig.LastWindowWidth >= MinimumWidth && RatConfig.LastWindowHeight >= MinimumHeight) + { + Width = RatConfig.LastWindowWidth; + Height = RatConfig.LastWindowHeight; + } + + if (RatConfig.LastWindowPositionX == int.MinValue || RatConfig.LastWindowPositionY == int.MinValue) + return; + + double left = RatConfig.LastWindowPositionX; + double top = RatConfig.LastWindowPositionY; + if (IsVisibleOnAnyScreen(left, top, Width, Height)) + { + Left = left; + Top = top; + } + // Otherwise leave the default position: the saved location refers to a + // display that is not currently attached. + } + + /// + /// True when enough of the window (roughly the caption strip) intersects + /// the working area of at least one attached display for the user to grab it. + /// + internal static bool IsVisibleOnAnyScreen(double left, double top, double width, double height) + { + // The title bar is the reliable grab strip; require most of it plus a + // minimal slice of the window body to land on some screen. + double grabLeft = left + Math.Min(40, width / 4); + double grabRight = left + width - Math.Min(40, width / 4); + double grabTop = top; + double grabBottom = top + Math.Min(48, height); + + foreach (System.Windows.Forms.Screen screen in System.Windows.Forms.Screen.AllScreens) + { + System.Drawing.Rectangle area = screen.WorkingArea; + bool intersects = + grabRight > area.Left && grabLeft < area.Right && grabBottom > area.Top && grabTop < area.Bottom; + if (intersects) + return true; + } + return false; + } + internal void Navigate(UserControl nextControl, object? state = null) { if (!(nextControl is ISwitchable)) @@ -400,6 +448,14 @@ internal void ShowUI() Top = minimalTop - chromeMargin; } } + else + { + // Entered minimal UI straight from startup (LastWindowMode = + // Minimal), so no in-session restore bounds exist. Fall back + // to the persisted normal-mode bounds. + WindowState = WindowState.Normal; + RestoreWindowBounds(); + } // The offset is only valid for one exit; clear it so a subsequent // tray-menu exit doesn't use a stale value. @@ -513,12 +569,40 @@ private void AnchorNearTopRight(double rightEdge, double topEdge) internal void ExitApplication() { - RatConfig.LastWindowPositionX = (int)Left; - RatConfig.LastWindowPositionY = (int)Top; + PersistWindowBounds(); RatConfig.SaveConfig(); Application.Current.Shutdown(); } + /// + /// Persists the normal-mode window bounds so the next launch restores the + /// user's size and position. When closing from minimal UI or a maximized + /// window, the pre-minimal / pre-maximize restore bounds are what the user + /// actually arranged, so those are saved instead of the live geometry. + /// + private void PersistWindowBounds() + { + Rect bounds = + _isMinimalUi ? _restoreBounds + : WindowState == WindowState.Maximized ? RestoreBounds + : new Rect(Left, Top, Width, Height); + + if (bounds.IsEmpty || bounds.Width < MinimumWidth || bounds.Height < MinimumHeight) + { + // No trustworthy normal-mode geometry (e.g. first run closed while + // still in minimal UI): keep the previous saved size, only track + // the position as before. + RatConfig.LastWindowPositionX = (int)Left; + RatConfig.LastWindowPositionY = (int)Top; + return; + } + + RatConfig.LastWindowPositionX = (int)bounds.X; + RatConfig.LastWindowPositionY = (int)bounds.Y; + RatConfig.LastWindowWidth = (int)bounds.Width; + RatConfig.LastWindowHeight = (int)bounds.Height; + } + private void OnToggleSidebar(object? sender, RoutedEventArgs e) { _appStateService?.ToggleSidebar(); diff --git a/src/App/RatConfig.cs b/src/App/RatConfig.cs index 07c6c328..d79f54c4 100644 --- a/src/App/RatConfig.cs +++ b/src/App/RatConfig.cs @@ -208,6 +208,8 @@ internal static bool LogDebug internal static event Action? SettingsChanged; internal static int LastWindowPositionX = int.MinValue; internal static int LastWindowPositionY = int.MinValue; + internal static int LastWindowWidth = 0; + internal static int LastWindowHeight = 0; internal static WindowMode LastWindowMode = WindowMode.Normal; internal static float GameScale => RatScannerMain.Instance.RatEyeEngine.Config.ProcessingConfig.Scale; @@ -390,6 +392,8 @@ private static void LoadConfig(string configPath, bool showMigrationMessage) LastWindowPositionX = config.ReadInt(nameof(LastWindowPositionX), LastWindowPositionX); LastWindowPositionY = config.ReadInt(nameof(LastWindowPositionY), LastWindowPositionY); + LastWindowWidth = config.ReadInt(nameof(LastWindowWidth), LastWindowWidth); + LastWindowHeight = config.ReadInt(nameof(LastWindowHeight), LastWindowHeight); LastWindowMode = (WindowMode)config.ReadInt(nameof(LastWindowMode), (int)LastWindowMode); if (GameDisplayPreferencesStore.TryRead(config, ScreenWidth, ScreenHeight, ScreenScale, out var preferences)) @@ -521,6 +525,8 @@ internal static void SaveConfig(string configPath) config.WriteInt(nameof(ConfigVersion), ConfigVersion); config.WriteInt(nameof(LastWindowPositionX), LastWindowPositionX); config.WriteInt(nameof(LastWindowPositionY), LastWindowPositionY); + config.WriteInt(nameof(LastWindowWidth), LastWindowWidth); + config.WriteInt(nameof(LastWindowHeight), LastWindowHeight); config.WriteInt(nameof(LastWindowMode), (int)LastWindowMode); GameDisplayPreferencesStore.Write(config, GetGameDisplayPreferences()); diff --git a/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs b/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs new file mode 100644 index 00000000..41dc9c1e --- /dev/null +++ b/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs @@ -0,0 +1,95 @@ +using System.IO; +using Xunit; + +namespace RatScanner.Tests; + +[Collection(RatConfigCollection.Name)] +public sealed class WindowBoundsPersistenceTests +{ + [Fact] + public void Window_bounds_round_trip_through_config() + { + string root = CreateTemporaryDirectory(); + string configPath = Path.Combine(root, "config.cfg"); + try + { + RatConfig.LastWindowPositionX = 120; + RatConfig.LastWindowPositionY = 80; + RatConfig.LastWindowWidth = 900; + RatConfig.LastWindowHeight = 640; + RatConfig.SaveConfig(configPath); + + RatConfig.LastWindowPositionX = int.MinValue; + RatConfig.LastWindowPositionY = int.MinValue; + RatConfig.LastWindowWidth = 0; + RatConfig.LastWindowHeight = 0; + RatConfig.LoadConfig(configPath); + + Assert.Equal(120, RatConfig.LastWindowPositionX); + Assert.Equal(80, RatConfig.LastWindowPositionY); + Assert.Equal(900, RatConfig.LastWindowWidth); + Assert.Equal(640, RatConfig.LastWindowHeight); + } + finally + { + RatConfig.LastWindowPositionX = int.MinValue; + RatConfig.LastWindowPositionY = int.MinValue; + RatConfig.LastWindowWidth = 0; + RatConfig.LastWindowHeight = 0; + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Missing_size_keys_default_to_unset() + { + // Config reads pass the current static as the default, so a config + // without size keys must leave the unset (0) defaults untouched. + string root = CreateTemporaryDirectory(); + string configPath = Path.Combine(root, "config.cfg"); + try + { + File.WriteAllText(configPath, "[Other]\r\nconfigversion=3\r\n"); + + RatConfig.LastWindowWidth = 0; + RatConfig.LastWindowHeight = 0; + RatConfig.LoadConfig(configPath); + + Assert.Equal(0, RatConfig.LastWindowWidth); + Assert.Equal(0, RatConfig.LastWindowHeight); + } + finally + { + RatConfig.LastWindowWidth = 0; + RatConfig.LastWindowHeight = 0; + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Off_screen_saved_position_is_rejected() + { + // A position far outside any possible monitor arrangement must not be + // considered visible (monitor unplugged since last run). + Assert.False(PageSwitcher.IsVisibleOnAnyScreen(-100000, -100000, 1080, 720)); + } + + [Fact] + public void On_screen_position_is_accepted() + { + // Center of the primary working area must be restorable on any machine + // that can run the test suite (Windows desktop session). + System.Drawing.Rectangle area = System.Windows.Forms.Screen.PrimaryScreen!.WorkingArea; + double left = area.Left + (area.Width - 1080) / 2.0; + double top = area.Top + (area.Height - 720) / 2.0; + + Assert.True(PageSwitcher.IsVisibleOnAnyScreen(left, top, 1080, 720)); + } + + private static string CreateTemporaryDirectory() + { + string path = Path.Combine(Path.GetTempPath(), "RatScannerTests", Path.GetRandomFileName()); + Directory.CreateDirectory(path); + return path; + } +} From a32b7f34a4ffa235153961d2d8ad324e7ccb0ed2 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Mon, 27 Jul 2026 04:52:06 -0400 Subject: [PATCH 2/4] fix(window): address bounds persistence review findings --- src/App/Display/WindowsGameDisplayService.cs | 2 +- src/App/PageSwitcher.xaml.cs | 155 ++++++++++++++---- .../WindowBoundsPersistenceTests.cs | 113 ++++++++++++- 3 files changed, 229 insertions(+), 41 deletions(-) diff --git a/src/App/Display/WindowsGameDisplayService.cs b/src/App/Display/WindowsGameDisplayService.cs index d2e3d59b..90c4eab7 100644 --- a/src/App/Display/WindowsGameDisplayService.cs +++ b/src/App/Display/WindowsGameDisplayService.cs @@ -78,7 +78,7 @@ private static GameDisplayInfo CreateDisplayInfo(Screen screen, int fallbackNumb ); } - private static (double Scale, bool IsReliable) GetDpiScale(Rectangle bounds) + internal static (double Scale, bool IsReliable) GetDpiScale(Rectangle bounds) { try { diff --git a/src/App/PageSwitcher.xaml.cs b/src/App/PageSwitcher.xaml.cs index a4645025..488d7950 100644 --- a/src/App/PageSwitcher.xaml.cs +++ b/src/App/PageSwitcher.xaml.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Windows; using System.Windows.Automation; @@ -8,6 +9,7 @@ using System.Windows.Shell; using Microsoft.Extensions.DependencyInjection; using Microsoft.Win32; +using RatScanner.Display; using RatScanner.View; using ContextMenuStrip = System.Windows.Forms.ContextMenuStrip; using NotifyIcon = System.Windows.Forms.NotifyIcon; @@ -42,6 +44,7 @@ public partial class PageSwitcher : Window private UserControl? activeControl; private bool _isMinimalUi; + private bool _isExiting; private WindowState _restoreWindowState = WindowState.Normal; private Rect _restoreBounds; @@ -115,24 +118,24 @@ internal void ResetWindowSize() /// private void RestoreWindowBounds() { - if (RatConfig.LastWindowWidth >= MinimumWidth && RatConfig.LastWindowHeight >= MinimumHeight) - { - Width = RatConfig.LastWindowWidth; - Height = RatConfig.LastWindowHeight; - } - - if (RatConfig.LastWindowPositionX == int.MinValue || RatConfig.LastWindowPositionY == int.MinValue) + if ( + !TryGetRestorableBounds( + RatConfig.LastWindowPositionX, + RatConfig.LastWindowPositionY, + RatConfig.LastWindowWidth, + RatConfig.LastWindowHeight, + Width, + Height, + GetLogicalWorkingAreas(), + out Rect bounds + ) + ) return; - double left = RatConfig.LastWindowPositionX; - double top = RatConfig.LastWindowPositionY; - if (IsVisibleOnAnyScreen(left, top, Width, Height)) - { - Left = left; - Top = top; - } - // Otherwise leave the default position: the saved location refers to a - // display that is not currently attached. + Width = bounds.Width; + Height = bounds.Height; + Left = bounds.Left; + Top = bounds.Top; } /// @@ -140,6 +143,17 @@ private void RestoreWindowBounds() /// the working area of at least one attached display for the user to grab it. /// internal static bool IsVisibleOnAnyScreen(double left, double top, double width, double height) + { + return IsVisibleOnAnyScreen(left, top, width, height, GetLogicalWorkingAreas()); + } + + internal static bool IsVisibleOnAnyScreen( + double left, + double top, + double width, + double height, + IReadOnlyList workingAreas + ) { // The title bar is the reliable grab strip; require most of it plus a // minimal slice of the window body to land on some screen. @@ -148,9 +162,8 @@ internal static bool IsVisibleOnAnyScreen(double left, double top, double width, double grabTop = top; double grabBottom = top + Math.Min(48, height); - foreach (System.Windows.Forms.Screen screen in System.Windows.Forms.Screen.AllScreens) + foreach (LogicalWorkingArea area in workingAreas) { - System.Drawing.Rectangle area = screen.WorkingArea; bool intersects = grabRight > area.Left && grabLeft < area.Right && grabBottom > area.Top && grabTop < area.Bottom; if (intersects) @@ -159,6 +172,68 @@ internal static bool IsVisibleOnAnyScreen(double left, double top, double width, return false; } + internal static bool TryGetRestorableBounds( + int savedLeft, + int savedTop, + int savedWidth, + int savedHeight, + double defaultWidth, + double defaultHeight, + IReadOnlyList workingAreas, + out Rect bounds + ) + { + bounds = Rect.Empty; + if (savedLeft == int.MinValue || savedTop == int.MinValue) + return false; + + bool hasValidSavedSize = savedWidth >= MinimumWidth && savedHeight >= MinimumHeight; + double width = hasValidSavedSize ? savedWidth : defaultWidth; + double height = hasValidSavedSize ? savedHeight : defaultHeight; + if ( + !double.IsFinite(width) + || !double.IsFinite(height) + || width < MinimumWidth + || height < MinimumHeight + || !IsVisibleOnAnyScreen(savedLeft, savedTop, width, height, workingAreas) + ) + return false; + + bounds = new Rect(savedLeft, savedTop, width, height); + return true; + } + + internal static LogicalWorkingArea PhysicalToLogicalWorkingArea( + System.Drawing.Rectangle physicalArea, + double dpiScale + ) + { + double scale = double.IsFinite(dpiScale) && dpiScale > 0 ? dpiScale : 1; + return new LogicalWorkingArea( + physicalArea.Left / scale, + physicalArea.Top / scale, + physicalArea.Right / scale, + physicalArea.Bottom / scale + ); + } + + private static IReadOnlyList GetLogicalWorkingAreas() + { + System.Windows.Forms.Screen[] screens = System.Windows.Forms.Screen.AllScreens; + LogicalWorkingArea[] workingAreas = new LogicalWorkingArea[screens.Length]; + for (int index = 0; index < screens.Length; index++) + { + System.Windows.Forms.Screen screen = screens[index]; + (double dpiScale, _) = WindowsGameDisplayService.GetDpiScale(screen.Bounds); + // WinForms exposes physical pixels while WPF persists window + // coordinates in device-independent units. + workingAreas[index] = PhysicalToLogicalWorkingArea(screen.WorkingArea, dpiScale); + } + return workingAreas; + } + + internal readonly record struct LogicalWorkingArea(double Left, double Top, double Right, double Bottom); + internal void Navigate(UserControl nextControl, object? state = null) { if (!(nextControl is ISwitchable)) @@ -569,8 +644,12 @@ private void AnchorNearTopRight(double rightEdge, double topEdge) internal void ExitApplication() { + if (_isExiting) + return; + PersistWindowBounds(); RatConfig.SaveConfig(); + _isExiting = true; Application.Current.Shutdown(); } @@ -582,20 +661,16 @@ internal void ExitApplication() /// private void PersistWindowBounds() { - Rect bounds = - _isMinimalUi ? _restoreBounds - : WindowState == WindowState.Maximized ? RestoreBounds - : new Rect(Left, Top, Width, Height); - - if (bounds.IsEmpty || bounds.Width < MinimumWidth || bounds.Height < MinimumHeight) - { - // No trustworthy normal-mode geometry (e.g. first run closed while - // still in minimal UI): keep the previous saved size, only track - // the position as before. - RatConfig.LastWindowPositionX = (int)Left; - RatConfig.LastWindowPositionY = (int)Top; + if ( + !TryGetPersistableBounds( + _isMinimalUi, + WindowState, + _restoreBounds, + new Rect(Left, Top, Width, Height), + out Rect bounds + ) + ) return; - } RatConfig.LastWindowPositionX = (int)bounds.X; RatConfig.LastWindowPositionY = (int)bounds.Y; @@ -603,6 +678,24 @@ private void PersistWindowBounds() RatConfig.LastWindowHeight = (int)bounds.Height; } + internal static bool TryGetPersistableBounds( + bool isMinimalUi, + WindowState windowState, + Rect restoreBounds, + Rect liveBounds, + out Rect bounds + ) + { + bounds = isMinimalUi || windowState != WindowState.Normal ? restoreBounds : liveBounds; + return !bounds.IsEmpty + && double.IsFinite(bounds.X) + && double.IsFinite(bounds.Y) + && double.IsFinite(bounds.Width) + && double.IsFinite(bounds.Height) + && bounds.Width >= MinimumWidth + && bounds.Height >= MinimumHeight; + } + private void OnToggleSidebar(object? sender, RoutedEventArgs e) { _appStateService?.ToggleSidebar(); diff --git a/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs b/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs index 41dc9c1e..77d43d1a 100644 --- a/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs +++ b/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Windows; using Xunit; namespace RatScanner.Tests; @@ -69,21 +70,115 @@ public void Missing_size_keys_default_to_unset() [Fact] public void Off_screen_saved_position_is_rejected() { - // A position far outside any possible monitor arrangement must not be - // considered visible (monitor unplugged since last run). - Assert.False(PageSwitcher.IsVisibleOnAnyScreen(-100000, -100000, 1080, 720)); + PageSwitcher.LogicalWorkingArea[] workingAreas = [new(0, 0, 1920, 1080)]; + + Assert.False(PageSwitcher.IsVisibleOnAnyScreen(-100000, -100000, 1080, 720, workingAreas)); } [Fact] public void On_screen_position_is_accepted() { - // Center of the primary working area must be restorable on any machine - // that can run the test suite (Windows desktop session). - System.Drawing.Rectangle area = System.Windows.Forms.Screen.PrimaryScreen!.WorkingArea; - double left = area.Left + (area.Width - 1080) / 2.0; - double top = area.Top + (area.Height - 720) / 2.0; + PageSwitcher.LogicalWorkingArea[] workingAreas = [new(-1280, 0, 1920, 1080)]; + + Assert.True(PageSwitcher.IsVisibleOnAnyScreen(-1100, 120, 900, 640, workingAreas)); + } + + [Fact] + public void Physical_working_area_is_converted_to_wpf_logical_units() + { + PageSwitcher.LogicalWorkingArea area = PageSwitcher.PhysicalToLogicalWorkingArea( + new System.Drawing.Rectangle(3840, 0, 2560, 1440), + 1.5 + ); + + Assert.Equal(2560, area.Left); + Assert.Equal(0, area.Top); + Assert.Equal(4266.666666666667, area.Right, 10); + Assert.Equal(960, area.Bottom); + } + + [Fact] + public void High_dpi_physical_extent_does_not_accept_off_screen_logical_position() + { + PageSwitcher.LogicalWorkingArea[] workingAreas = + [ + PageSwitcher.PhysicalToLogicalWorkingArea(new System.Drawing.Rectangle(0, 0, 3840, 2160), 2), + ]; + + Assert.False(PageSwitcher.IsVisibleOnAnyScreen(1900, 100, 1080, 720, workingAreas)); + } + + [Fact] + public void Off_screen_saved_position_rejects_saved_size_as_one_placement() + { + PageSwitcher.LogicalWorkingArea[] workingAreas = [new(0, 0, 1920, 1080)]; + + bool restored = PageSwitcher.TryGetRestorableBounds( + 5000, + 100, + 3200, + 1800, + PageSwitcher.DefaultWidth, + PageSwitcher.DefaultHeight, + workingAreas, + out Rect bounds + ); + + Assert.False(restored); + Assert.True(bounds.IsEmpty); + } + + [Fact] + public void Missing_saved_size_uses_default_size_with_valid_saved_position() + { + PageSwitcher.LogicalWorkingArea[] workingAreas = [new(0, 0, 1920, 1080)]; + + bool restored = PageSwitcher.TryGetRestorableBounds( + 120, + 80, + 0, + 0, + PageSwitcher.DefaultWidth, + PageSwitcher.DefaultHeight, + workingAreas, + out Rect bounds + ); + + Assert.True(restored); + Assert.Equal(new Rect(120, 80, PageSwitcher.DefaultWidth, PageSwitcher.DefaultHeight), bounds); + } + + [Fact] + public void Minimized_window_persists_restore_bounds() + { + Rect restoreBounds = new(120, 80, 900, 640); + Rect iconicBounds = new(-32000, -32000, 160, 28); + + bool persisted = PageSwitcher.TryGetPersistableBounds( + isMinimalUi: false, + WindowState.Minimized, + restoreBounds, + iconicBounds, + out Rect bounds + ); + + Assert.True(persisted); + Assert.Equal(restoreBounds, bounds); + } + + [Fact] + public void Minimal_ui_without_restore_bounds_preserves_previous_normal_bounds() + { + bool persisted = PageSwitcher.TryGetPersistableBounds( + isMinimalUi: true, + WindowState.Normal, + Rect.Empty, + new Rect(1700, 20, 260, 90), + out Rect bounds + ); - Assert.True(PageSwitcher.IsVisibleOnAnyScreen(left, top, 1080, 720)); + Assert.False(persisted); + Assert.True(bounds.IsEmpty); } private static string CreateTemporaryDirectory() From eb4f444137e8dcebda374227e1ac951cf7fa61ae Mon Sep 17 00:00:00 2001 From: DysektAI Date: Mon, 27 Jul 2026 04:57:04 -0400 Subject: [PATCH 3/4] fix(window): use WPF restore bounds for window states --- src/App/PageSwitcher.xaml.cs | 9 +++++++-- tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/App/PageSwitcher.xaml.cs b/src/App/PageSwitcher.xaml.cs index 488d7950..f559e5cd 100644 --- a/src/App/PageSwitcher.xaml.cs +++ b/src/App/PageSwitcher.xaml.cs @@ -666,6 +666,7 @@ private void PersistWindowBounds() _isMinimalUi, WindowState, _restoreBounds, + RestoreBounds, new Rect(Left, Top, Width, Height), out Rect bounds ) @@ -681,12 +682,16 @@ out Rect bounds internal static bool TryGetPersistableBounds( bool isMinimalUi, WindowState windowState, - Rect restoreBounds, + Rect minimalRestoreBounds, + Rect stateRestoreBounds, Rect liveBounds, out Rect bounds ) { - bounds = isMinimalUi || windowState != WindowState.Normal ? restoreBounds : liveBounds; + bounds = + isMinimalUi ? minimalRestoreBounds + : windowState != WindowState.Normal ? stateRestoreBounds + : liveBounds; return !bounds.IsEmpty && double.IsFinite(bounds.X) && double.IsFinite(bounds.Y) diff --git a/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs b/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs index 77d43d1a..9de4fb51 100644 --- a/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs +++ b/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs @@ -157,6 +157,7 @@ public void Minimized_window_persists_restore_bounds() bool persisted = PageSwitcher.TryGetPersistableBounds( isMinimalUi: false, WindowState.Minimized, + Rect.Empty, restoreBounds, iconicBounds, out Rect bounds @@ -173,6 +174,7 @@ public void Minimal_ui_without_restore_bounds_preserves_previous_normal_bounds() isMinimalUi: true, WindowState.Normal, Rect.Empty, + new Rect(120, 80, 900, 640), new Rect(1700, 20, 260, 90), out Rect bounds ); From 84993d6169e1fda80575c32b6ed805c4a4343a7a Mon Sep 17 00:00:00 2001 From: DysektAI Date: Mon, 27 Jul 2026 05:11:42 -0400 Subject: [PATCH 4/4] fix(window): harden close and DPI restore paths --- src/App/PageSwitcher.xaml.cs | 61 ++++++++++++++----- .../WindowBoundsPersistenceTests.cs | 34 +++++++++-- 2 files changed, 74 insertions(+), 21 deletions(-) diff --git a/src/App/PageSwitcher.xaml.cs b/src/App/PageSwitcher.xaml.cs index f559e5cd..77ba2457 100644 --- a/src/App/PageSwitcher.xaml.cs +++ b/src/App/PageSwitcher.xaml.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Windows; using System.Windows.Automation; @@ -45,6 +46,7 @@ public partial class PageSwitcher : Window private UserControl? activeControl; private bool _isMinimalUi; private bool _isExiting; + private bool _hasPersistedWindowBounds; private WindowState _restoreWindowState = WindowState.Normal; private Rect _restoreBounds; @@ -203,31 +205,44 @@ out Rect bounds return true; } - internal static LogicalWorkingArea PhysicalToLogicalWorkingArea( + internal static bool TryPhysicalToLogicalWorkingArea( System.Drawing.Rectangle physicalArea, - double dpiScale + double dpiScale, + bool isDpiReliable, + out LogicalWorkingArea workingArea ) { - double scale = double.IsFinite(dpiScale) && dpiScale > 0 ? dpiScale : 1; - return new LogicalWorkingArea( - physicalArea.Left / scale, - physicalArea.Top / scale, - physicalArea.Right / scale, - physicalArea.Bottom / scale + workingArea = default; + if (!isDpiReliable || !double.IsFinite(dpiScale) || dpiScale <= 0) + return false; + + workingArea = new LogicalWorkingArea( + physicalArea.Left / dpiScale, + physicalArea.Top / dpiScale, + physicalArea.Right / dpiScale, + physicalArea.Bottom / dpiScale ); + return true; } private static IReadOnlyList GetLogicalWorkingAreas() { System.Windows.Forms.Screen[] screens = System.Windows.Forms.Screen.AllScreens; - LogicalWorkingArea[] workingAreas = new LogicalWorkingArea[screens.Length]; - for (int index = 0; index < screens.Length; index++) + List workingAreas = new(screens.Length); + foreach (System.Windows.Forms.Screen screen in screens) { - System.Windows.Forms.Screen screen = screens[index]; - (double dpiScale, _) = WindowsGameDisplayService.GetDpiScale(screen.Bounds); + (double dpiScale, bool isDpiReliable) = WindowsGameDisplayService.GetDpiScale(screen.Bounds); // WinForms exposes physical pixels while WPF persists window // coordinates in device-independent units. - workingAreas[index] = PhysicalToLogicalWorkingArea(screen.WorkingArea, dpiScale); + if ( + TryPhysicalToLogicalWorkingArea( + screen.WorkingArea, + dpiScale, + isDpiReliable, + out LogicalWorkingArea workingArea + ) + ) + workingAreas.Add(workingArea); } return workingAreas; } @@ -263,6 +278,13 @@ protected override void OnStateChanged(EventArgs e) base.OnStateChanged(e); } + protected override void OnClosing(CancelEventArgs e) + { + base.OnClosing(e); + if (!e.Cancel) + PersistWindowBoundsOnce(); + } + protected override void OnClosed(EventArgs e) { SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; @@ -647,12 +669,21 @@ internal void ExitApplication() if (_isExiting) return; - PersistWindowBounds(); - RatConfig.SaveConfig(); _isExiting = true; + PersistWindowBoundsOnce(); Application.Current.Shutdown(); } + private void PersistWindowBoundsOnce() + { + if (_hasPersistedWindowBounds) + return; + + PersistWindowBounds(); + RatConfig.SaveConfig(); + _hasPersistedWindowBounds = true; + } + /// /// Persists the normal-mode window bounds so the next launch restores the /// user's size and position. When closing from minimal UI or a maximized diff --git a/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs b/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs index 9de4fb51..45e58418 100644 --- a/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs +++ b/tests/RatScanner.Tests/WindowBoundsPersistenceTests.cs @@ -86,24 +86,46 @@ public void On_screen_position_is_accepted() [Fact] public void Physical_working_area_is_converted_to_wpf_logical_units() { - PageSwitcher.LogicalWorkingArea area = PageSwitcher.PhysicalToLogicalWorkingArea( + bool converted = PageSwitcher.TryPhysicalToLogicalWorkingArea( new System.Drawing.Rectangle(3840, 0, 2560, 1440), - 1.5 + 1.5, + isDpiReliable: true, + out PageSwitcher.LogicalWorkingArea area ); + Assert.True(converted); Assert.Equal(2560, area.Left); Assert.Equal(0, area.Top); Assert.Equal(4266.666666666667, area.Right, 10); Assert.Equal(960, area.Bottom); } + [Fact] + public void Unreliable_dpi_query_is_excluded_from_restore_validation() + { + bool converted = PageSwitcher.TryPhysicalToLogicalWorkingArea( + new System.Drawing.Rectangle(0, 0, 3840, 2160), + dpiScale: 1, + isDpiReliable: false, + out PageSwitcher.LogicalWorkingArea area + ); + + Assert.False(converted); + Assert.Equal(default, area); + } + [Fact] public void High_dpi_physical_extent_does_not_accept_off_screen_logical_position() { - PageSwitcher.LogicalWorkingArea[] workingAreas = - [ - PageSwitcher.PhysicalToLogicalWorkingArea(new System.Drawing.Rectangle(0, 0, 3840, 2160), 2), - ]; + Assert.True( + PageSwitcher.TryPhysicalToLogicalWorkingArea( + new System.Drawing.Rectangle(0, 0, 3840, 2160), + 2, + isDpiReliable: true, + out PageSwitcher.LogicalWorkingArea workingArea + ) + ); + PageSwitcher.LogicalWorkingArea[] workingAreas = [workingArea]; Assert.False(PageSwitcher.IsVisibleOnAnyScreen(1900, 100, 1080, 720, workingAreas)); }