diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index e3dc3df..26138fc 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -157,7 +157,7 @@ Turn learning off to keep verdicts and notes for review only. **Likely cause** is a heuristic from trigger kind and changed file paths (e.g. Cursor/agent tooling folders vs source edits). **Your note** is free text — use it to record what you were doing (e.g. “Cursor ask mode chat”) when marking unexpected rebuilds. Status panel **AI working?** appears in the **header** during a rebuild countdown (extends the wait) or while a build is running (marks that trigger **Unexpected**). The countdown auto-extends when Cursor/agent tooling is active (`useAgentTranscriptActivity`) and resets when meaningful source files (e.g. `.cs`) are saved. -Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layout.json` (Settings, build log, diagnostics — including trigger grid column widths — and status panel width — height auto-fits content up to 460 px). +Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layout.json` (Settings, build log, diagnostics — including trigger grid column widths — and status panel width — height auto-fits content up to 460 px). If a saved position is no longer sufficiently visible on any monitor (for example after RDP from a single-monitor client, or after unplugging a display), BuildMonitor clamps the window into the tray/primary work area when it opens and again when Windows fires a display-settings change. ## Watch / file-watcher excludes diff --git a/src/BuildMonitor.Tests/WindowScreenVisibilityTests.cs b/src/BuildMonitor.Tests/WindowScreenVisibilityTests.cs new file mode 100644 index 0000000..343feae --- /dev/null +++ b/src/BuildMonitor.Tests/WindowScreenVisibilityTests.cs @@ -0,0 +1,99 @@ +using BuildMonitor.Core.Rules; + +namespace BuildMonitor.Tests; + +public sealed class WindowScreenVisibilityTests +{ + private static WindowScreenVisibility.Rect Primary => new(0, 0, 1920, 1080); + private static WindowScreenVisibility.Rect Secondary => new(1920, 0, 1920, 1080); + + [Fact] + public void IsSufficientlyVisible_true_when_center_on_primary() + { + var window = new WindowScreenVisibility.Rect(100, 100, 800, 600); + Assert.True(WindowScreenVisibility.IsSufficientlyVisible(window, [Primary])); + } + + [Fact] + public void IsSufficientlyVisible_false_when_fully_on_missing_secondary() + { + var window = new WindowScreenVisibility.Rect(2200, 100, 800, 600); + Assert.False(WindowScreenVisibility.IsSufficientlyVisible(window, [Primary])); + } + + [Fact] + public void IsSufficientlyVisible_true_on_secondary_when_present() + { + var window = new WindowScreenVisibility.Rect(2200, 100, 800, 600); + Assert.True(WindowScreenVisibility.IsSufficientlyVisible(window, [Primary, Secondary])); + } + + [Fact] + public void IsSufficientlyVisible_false_for_one_pixel_edge_overlap() + { + // Almost entirely off to the right of a single 1920-wide primary; only 1px still intersects. + var window = new WindowScreenVisibility.Rect(1919, 100, 800, 600); + Assert.False(WindowScreenVisibility.IsSufficientlyVisible(window, [Primary])); + } + + [Fact] + public void IsSufficientlyVisible_true_when_majority_overlaps_even_if_center_off() + { + // Center sits on the right edge (not inside primary); half the width still overlaps. + var window = new WindowScreenVisibility.Rect(960, 100, 1920, 600); + Assert.Equal(1920, window.CenterX); + Assert.True(WindowScreenVisibility.IsSufficientlyVisible(window, [Primary])); + } + + [Fact] + public void ClampToWorkArea_moves_offscreen_window_fully_inside() + { + var window = new WindowScreenVisibility.Rect(2500, 100, 800, 600); + var clamped = WindowScreenVisibility.ClampToWorkArea(window, Primary); + Assert.Equal(1120, clamped.X); // 1920 - 800 + Assert.Equal(100, clamped.Y); + Assert.Equal(800, clamped.Width); + Assert.Equal(600, clamped.Height); + Assert.True(WindowScreenVisibility.IsSufficientlyVisible(clamped, [Primary])); + } + + [Fact] + public void ClampToWorkArea_shrinks_when_larger_than_work_area() + { + var window = new WindowScreenVisibility.Rect(0, 0, 3000, 2000); + var clamped = WindowScreenVisibility.ClampToWorkArea(window, Primary); + Assert.Equal(0, clamped.X); + Assert.Equal(0, clamped.Y); + Assert.Equal(1920, clamped.Width); + Assert.Equal(1080, clamped.Height); + } + + [Fact] + public void EnsureVisible_leaves_on_screen_window_unchanged() + { + var window = new WindowScreenVisibility.Rect(100, 100, 800, 600); + var result = WindowScreenVisibility.EnsureVisible(window, [Primary]); + Assert.Equal(window, result); + } + + [Fact] + public void EnsureVisible_uses_preferred_work_area_when_clamping() + { + // Secondary coords with only primary still present (RDP / unplug). + var window = new WindowScreenVisibility.Rect(2500, 100, 400, 300); + var preferred = new WindowScreenVisibility.Rect(100, 100, 800, 600); + var result = WindowScreenVisibility.EnsureVisible(window, [Primary], preferred); + Assert.True(result.X >= preferred.X); + Assert.True(result.Right <= preferred.Right); + Assert.True(result.Y >= preferred.Y); + Assert.True(result.Bottom <= preferred.Bottom); + } + + [Fact] + public void ResolveTargetWorkArea_picks_nearest_when_no_preferred() + { + var window = new WindowScreenVisibility.Rect(2500, 100, 400, 300); + var target = WindowScreenVisibility.ResolveTargetWorkArea(window, [Primary, Secondary]); + Assert.Equal(Secondary, target); + } +} diff --git a/src/Core/Rules/WindowScreenVisibility.cs b/src/Core/Rules/WindowScreenVisibility.cs new file mode 100644 index 0000000..77402e0 --- /dev/null +++ b/src/Core/Rules/WindowScreenVisibility.cs @@ -0,0 +1,142 @@ +namespace BuildMonitor.Core.Rules; + +/// +/// Pure geometry for deciding whether a window is sufficiently on-screen and clamping it into a work area. +/// +public static class WindowScreenVisibility +{ + /// Minimum fraction of window area that must intersect work areas to count as visible. + public const double MinVisibleAreaFraction = 0.5; + + public readonly record struct Rect(double X, double Y, double Width, double Height) + { + public double Right => X + Width; + public double Bottom => Y + Height; + public double CenterX => X + (Width / 2.0); + public double CenterY => Y + (Height / 2.0); + public double Area => Math.Max(0, Width) * Math.Max(0, Height); + } + + /// + /// True when the window center lies in any work area, or at least + /// of the window area intersects the union of work areas. + /// + public static bool IsSufficientlyVisible(Rect window, IReadOnlyList workAreas) + { + if (workAreas.Count == 0 || window.Width <= 0 || window.Height <= 0) + { + return false; + } + + if (workAreas.Any(area => ContainsPoint(area, window.CenterX, window.CenterY))) + { + return true; + } + + var windowArea = window.Area; + if (windowArea <= 0) + { + return false; + } + + var visible = 0.0; + foreach (var area in workAreas) + { + visible += IntersectionArea(window, area); + } + + return visible / windowArea >= MinVisibleAreaFraction; + } + + /// + /// Places fully inside , shrinking only when larger than the area. + /// + public static Rect ClampToWorkArea(Rect window, Rect workArea) + { + if (workArea.Width <= 0 || workArea.Height <= 0) + { + return window; + } + + var width = Math.Min(Math.Max(1, window.Width), workArea.Width); + var height = Math.Min(Math.Max(1, window.Height), workArea.Height); + var maxX = workArea.Right - width; + var maxY = workArea.Bottom - height; + var x = double.IsFinite(window.X) ? Math.Clamp(window.X, workArea.X, Math.Max(workArea.X, maxX)) : workArea.X; + var y = double.IsFinite(window.Y) ? Math.Clamp(window.Y, workArea.Y, Math.Max(workArea.Y, maxY)) : workArea.Y; + return new Rect(x, y, width, height); + } + + /// + /// Prefers when provided; otherwise the work area whose center is nearest the window center. + /// + public static Rect ResolveTargetWorkArea( + Rect window, + IReadOnlyList workAreas, + Rect? preferredWorkArea = null) + { + if (preferredWorkArea is { Width: > 0, Height: > 0 } preferred) + { + return preferred; + } + + if (workAreas.Count == 0) + { + return new Rect(0, 0, 1920, 1080); + } + + if (workAreas.Count == 1) + { + return workAreas[0]; + } + + Rect best = workAreas[0]; + var bestDistance = double.PositiveInfinity; + foreach (var area in workAreas) + { + var dx = area.CenterX - window.CenterX; + var dy = area.CenterY - window.CenterY; + var distance = (dx * dx) + (dy * dy); + if (distance < bestDistance) + { + bestDistance = distance; + best = area; + } + } + + return best; + } + + /// + /// If the window is not sufficiently visible, clamp it into the resolved target work area; otherwise return unchanged. + /// + public static Rect EnsureVisible(Rect window, IReadOnlyList workAreas, Rect? preferredWorkArea = null) + { + if (IsSufficientlyVisible(window, workAreas)) + { + return window; + } + + var target = ResolveTargetWorkArea(window, workAreas, preferredWorkArea); + return ClampToWorkArea(window, target); + } + + private static bool ContainsPoint(Rect area, double x, double y) => + x >= area.X && x < area.Right && y >= area.Y && y < area.Bottom; + + private static double IntersectionArea(Rect a, Rect b) + { + var left = Math.Max(a.X, b.X); + var top = Math.Max(a.Y, b.Y); + var right = Math.Min(a.Right, b.Right); + var bottom = Math.Min(a.Bottom, b.Bottom); + var width = right - left; + var height = bottom - top; + if (width <= 0 || height <= 0) + { + return 0; + } + + return width * height; + } +} diff --git a/src/TrayApp/App.xaml.cs b/src/TrayApp/App.xaml.cs index 3652d65..6bb7141 100644 --- a/src/TrayApp/App.xaml.cs +++ b/src/TrayApp/App.xaml.cs @@ -48,6 +48,7 @@ public partial class App : System.Windows.Application private DateTimeOffset trayHoverStatusPanelSuppressedUntil = DateTimeOffset.MinValue; private readonly Dictionary previousEditGatingActive = new(StringComparer.OrdinalIgnoreCase); + private WindowDisplayChangeWatcher? displayChangeWatcher; private readonly BuildLifecycleToastNotifier buildLifecycleToastNotifier = new(); private readonly TrayContextMenuBuilder trayMenuBuilder = new(); private int settingsApplyVersion; @@ -73,7 +74,7 @@ protected override async void OnStartup(StartupEventArgs e) appDataDirectory = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BuildMonitor"); - MigrateLegacyAppDataIfNeeded(appDataDirectory); + AppLaunchPolicy.MigrateLegacyAppDataIfNeeded(appDataDirectory); Directory.CreateDirectory(appDataDirectory); var settingsPath = Path.Combine(appDataDirectory, "settings.json"); @@ -125,8 +126,38 @@ protected override async void OnStartup(StartupEventArgs e) ApplyThemeToUi(); notifyIcon = BuildNotifyIcon(); notifyIcon.Visible = true; + displayChangeWatcher = new WindowDisplayChangeWatcher( + Dispatcher, + () => Volatile.Read(ref exitRequested) != 0, + () => + { + var windows = new List(openLogViewers.Count + 2); + windows.AddRange(openLogViewers.Values); + if (diagnosticsWindow is not null) + { + windows.Add(diagnosticsWindow); + } + + if (buildMonitorHealthWindow is not null) + { + windows.Add(buildMonitorHealthWindow); + } + + return windows; + }, + () => hoverPanel?.InvalidateTrayPlacementCache(), + () => + { + if (hoverPanel is not { IsVisible: true }) + { + return; + } + + GetTrayPlacementContext(out var trayIconBounds, out var trayWindowHandle); + hoverPanel.FollowTray(trayIconBounds, trayWindowHandle); + }); - if (ShouldAutoOpenBuildMonitorHealth()) + if (AppLaunchPolicy.ShouldAutoOpenBuildMonitorHealth(currentSettings)) { await OpenBuildMonitorHealthWhenReadyAsync(); await WaitForUiIdleAsync(); @@ -170,7 +201,7 @@ private async Task ApplySettingsAndStartAsync() orchestrator.ApplySettings(currentSettings); ApplyControlPlaneHost(); - if (ShouldAutoStartAnyProjectsOnLaunch()) + if (AppLaunchPolicy.ShouldAutoStartAnyProjectsOnLaunch(currentSettings)) { await orchestrator.StartActiveProjectsAsync(CancellationToken.None).ConfigureAwait(false); } @@ -707,6 +738,8 @@ private void ApplyControlPlaneHost() protected override void OnExit(ExitEventArgs e) { + displayChangeWatcher?.Dispose(); + displayChangeWatcher = null; buildIconAnimationTimer?.Stop(); buildIconAnimationTimer = null; dispatcherHealthProbe?.Dispose(); @@ -1867,66 +1900,4 @@ private void ApplyTrayIconFrame() RefreshStatusPanelIfVisible(); } } - - private static void MigrateLegacyAppDataIfNeeded(string newAppDataDirectory) - { - if (Directory.Exists(newAppDataDirectory)) - { - return; - } - - var legacyDirectory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "AzureBuildMonitor"); - if (!Directory.Exists(legacyDirectory)) - { - return; - } - - try - { - Directory.Move(legacyDirectory, newAppDataDirectory); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Legacy app data migration failed: {ex.Message}"); - } - } - - private static bool ShouldSkipProjectStart() - { - var value = Environment.GetEnvironmentVariable("BUILDMONITOR_SKIP_PROJECT_START"); - return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) - || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); - } - - private bool ShouldAutoStartAnyProjectsOnLaunch() => - !ShouldSkipProjectStart() - && currentSettings.Projects.Any(p => p.IsActiveInSession && p.StartOnLaunch); - - private bool ShouldAutoOpenBuildMonitorHealth() - { - var value = Environment.GetEnvironmentVariable("BUILDMONITOR_AUTO_BUILD_MONITOR_HEALTH"); - if (string.IsNullOrWhiteSpace(value)) - { - value = Environment.GetEnvironmentVariable("BUILDMONITOR_AUTO_THREAD_HEALTH"); - } - - if (!string.IsNullOrWhiteSpace(value)) - { - if (string.Equals(value, "0", StringComparison.OrdinalIgnoreCase) - || string.Equals(value, "false", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) - || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return currentSettings.Monitor.AutoOpenBuildMonitorHealthOnStartup; - } } diff --git a/src/TrayApp/HoverStatusPanel.xaml.cs b/src/TrayApp/HoverStatusPanel.xaml.cs index 76cb9d6..596ddea 100644 --- a/src/TrayApp/HoverStatusPanel.xaml.cs +++ b/src/TrayApp/HoverStatusPanel.xaml.cs @@ -547,12 +547,12 @@ private void ScheduleFitPanelToContent(bool repositionAfter = false) { fitLayoutPending = false; var sizeChanged = FitPanelToContent(); - if (sizeChanged || repositionAfterFit) + var shouldReposition = sizeChanged || repositionAfterFit; + repositionAfterFit = false; + if (shouldReposition) { - ApplyTrayPlacementIfNeeded(force: sizeChanged); + ApplyTrayPlacementIfNeeded(force: true); } - - repositionAfterFit = false; }); } @@ -594,6 +594,18 @@ public void ApplyLayout(WindowLayoutState layout) public void CaptureLayout(WindowLayoutState layout) => WindowLayoutService.Capture(this, layout, sizeOnly: true); + /// + /// Clears cached tray placement so the next show/follow recomputes against the current display layout. + /// + public void InvalidateTrayPlacementCache() + { + lastTrayIconBounds = null; + lastPlacementBounds = null; + lastTrayIconWindowHandle = IntPtr.Zero; + lastPlacedLeft = double.NaN; + lastPlacedTop = double.NaN; + } + public void ShowNearTray(Rectangle? trayIconBounds = null, IntPtr trayIconWindowHandle = default) { if (trayIconBounds is { Width: > 0, Height: > 0 } bounds) diff --git a/src/TrayApp/Services/AppLaunchPolicy.cs b/src/TrayApp/Services/AppLaunchPolicy.cs new file mode 100644 index 0000000..2bc3616 --- /dev/null +++ b/src/TrayApp/Services/AppLaunchPolicy.cs @@ -0,0 +1,70 @@ +using System.IO; +using BuildMonitor.Core.Settings; + +namespace BuildMonitor.TrayApp.Services; + +/// Startup migration and launch-gate helpers extracted from App. +internal static class AppLaunchPolicy +{ + public static void MigrateLegacyAppDataIfNeeded(string newAppDataDirectory) + { + if (Directory.Exists(newAppDataDirectory)) + { + return; + } + + var legacyDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "AzureBuildMonitor"); + if (!Directory.Exists(legacyDirectory)) + { + return; + } + + try + { + Directory.Move(legacyDirectory, newAppDataDirectory); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Legacy app data migration failed: {ex.Message}"); + } + } + + public static bool ShouldSkipProjectStart() + { + var value = Environment.GetEnvironmentVariable("BUILDMONITOR_SKIP_PROJECT_START"); + return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + + public static bool ShouldAutoStartAnyProjectsOnLaunch(AppSettings settings) => + !ShouldSkipProjectStart() + && settings.Projects.Any(p => p.IsActiveInSession && p.StartOnLaunch); + + public static bool ShouldAutoOpenBuildMonitorHealth(AppSettings settings) + { + var value = Environment.GetEnvironmentVariable("BUILDMONITOR_AUTO_BUILD_MONITOR_HEALTH"); + if (string.IsNullOrWhiteSpace(value)) + { + value = Environment.GetEnvironmentVariable("BUILDMONITOR_AUTO_THREAD_HEALTH"); + } + + if (!string.IsNullOrWhiteSpace(value)) + { + if (string.Equals(value, "0", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "false", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return settings.Monitor.AutoOpenBuildMonitorHealthOnStartup; + } +} diff --git a/src/TrayApp/Services/WindowDisplayChangeWatcher.cs b/src/TrayApp/Services/WindowDisplayChangeWatcher.cs new file mode 100644 index 0000000..f6ad239 --- /dev/null +++ b/src/TrayApp/Services/WindowDisplayChangeWatcher.cs @@ -0,0 +1,97 @@ +using System.Windows; +using System.Windows.Threading; +using Microsoft.Win32; + +namespace BuildMonitor.TrayApp.Services; + +/// +/// Re-anchors tray and persisted windows when the display topology changes (RDP, unplug monitor). +/// +internal sealed class WindowDisplayChangeWatcher : IDisposable +{ + private readonly Dispatcher dispatcher; + private readonly Func> windows; + private readonly Action? invalidateHoverPlacementCache; + private readonly Action? refreshHoverTrayPlacement; + private readonly Func isExiting; + private bool disposed; + + public WindowDisplayChangeWatcher( + Dispatcher dispatcher, + Func isExiting, + Func> windows, + Action? invalidateHoverPlacementCache, + Action? refreshHoverTrayPlacement) + { + this.dispatcher = dispatcher; + this.isExiting = isExiting; + this.windows = windows; + this.invalidateHoverPlacementCache = invalidateHoverPlacementCache; + this.refreshHoverTrayPlacement = refreshHoverTrayPlacement; + SystemEvents.DisplaySettingsChanged += OnSystemDisplaySettingsChanged; + } + + public static void RecoverVisibleWindows( + Action? invalidateHoverPlacementCache, + Action? refreshHoverTrayPlacement, + IEnumerable windows) + { + try + { + TrayScreenPlacement.CaptureFromCursor(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Tray work-area capture failed after display change: {ex.Message}"); + } + + invalidateHoverPlacementCache?.Invoke(); + refreshHoverTrayPlacement?.Invoke(); + + foreach (var window in windows) + { + try + { + if (window.IsLoaded) + { + WindowLayoutService.EnsureVisible(window); + } + } + catch (InvalidOperationException) + { + // Window may have been closed between enumeration and ensure. + } + } + } + + private void OnSystemDisplaySettingsChanged(object? sender, EventArgs e) + { + if (disposed) + { + return; + } + + _ = dispatcher.BeginInvoke(DispatcherPriority.Normal, Recover); + } + + private void Recover() + { + if (disposed || isExiting()) + { + return; + } + + RecoverVisibleWindows(invalidateHoverPlacementCache, refreshHoverTrayPlacement, windows()); + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + SystemEvents.DisplaySettingsChanged -= OnSystemDisplaySettingsChanged; + } +} diff --git a/src/TrayApp/Services/WindowLayoutService.cs b/src/TrayApp/Services/WindowLayoutService.cs index 20d0a6b..603bd14 100644 --- a/src/TrayApp/Services/WindowLayoutService.cs +++ b/src/TrayApp/Services/WindowLayoutService.cs @@ -1,5 +1,7 @@ using System.Windows; +using BuildMonitor.Core.Rules; using FormsScreen = System.Windows.Forms.Screen; +using ScreenRect = BuildMonitor.Core.Rules.WindowScreenVisibility.Rect; namespace BuildMonitor.TrayApp.Services; @@ -25,12 +27,10 @@ public static void Apply(Window window, WindowLayoutState state, double defaultW window.Height = defaultHeight; } - if (double.IsFinite(state.Left) && double.IsFinite(state.Top) - && IsOnScreen(state.Left, state.Top, window.Width, window.Height)) + if (double.IsFinite(state.Left) && double.IsFinite(state.Top)) { window.WindowStartupLocation = WindowStartupLocation.Manual; - window.Left = state.Left; - window.Top = state.Top; + ApplyVisiblePosition(window, state.Left, state.Top); } if (state.WindowState is (int)WindowState.Maximized or (int)WindowState.Minimized) @@ -39,6 +39,26 @@ public static void Apply(Window window, WindowLayoutState state, double defaultW } } + /// + /// Repositions an already-created window when it is no longer sufficiently on a work area + /// (e.g. after RDP or a monitor was removed). + /// + public static void EnsureVisible(Window window) + { + if (window.WindowState == WindowState.Maximized) + { + return; + } + + if (!double.IsFinite(window.Left) || !double.IsFinite(window.Top) + || window.Width <= 0 || window.Height <= 0) + { + return; + } + + ApplyVisiblePosition(window, window.Left, window.Top); + } + public static void Capture(Window window, WindowLayoutState state, bool sizeOnly = false) { var bounds = window.WindowState == WindowState.Normal @@ -56,22 +76,45 @@ public static void Capture(Window window, WindowLayoutState state, bool sizeOnly state.WindowState = (int)window.WindowState; } - private static void AssignIfFinite(double value, Action assign) + private static void ApplyVisiblePosition(Window window, double left, double top) { - if (double.IsFinite(value)) + var workAreas = GetWorkAreas(); + var preferred = ToScreenRect(TrayScreenPlacement.GetWorkArea()); + var candidate = new ScreenRect(left, top, window.Width, window.Height); + var visible = WindowScreenVisibility.EnsureVisible(candidate, workAreas, preferred); + + window.Left = visible.X; + window.Top = visible.Y; + if (Math.Abs(window.Width - visible.Width) > 0.5) { - assign(value); + window.Width = visible.Width; + } + + if (Math.Abs(window.Height - visible.Height) > 0.5) + { + window.Height = visible.Height; } } - private static bool IsOnScreen(double left, double top, double width, double height) + private static IReadOnlyList GetWorkAreas() { - var rect = new System.Drawing.Rectangle( - (int)Math.Round(left), - (int)Math.Round(top), - (int)Math.Max(1, Math.Round(width)), - (int)Math.Max(1, Math.Round(height))); + var screens = FormsScreen.AllScreens; + if (screens.Length == 0) + { + return [new ScreenRect(0, 0, 1920, 1080)]; + } + + return screens.Select(s => ToScreenRect(s.WorkingArea)).ToArray(); + } + + private static ScreenRect ToScreenRect(System.Drawing.Rectangle area) => + new(area.X, area.Y, area.Width, area.Height); - return FormsScreen.AllScreens.Any(screen => screen.WorkingArea.IntersectsWith(rect)); + private static void AssignIfFinite(double value, Action assign) + { + if (double.IsFinite(value)) + { + assign(value); + } } }