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);
+ }
}
}