diff --git a/src/PeekDesktop.InteropHarness/Program.cs b/src/PeekDesktop.InteropHarness/Program.cs index 9c7603b..acef4ec 100644 --- a/src/PeekDesktop.InteropHarness/Program.cs +++ b/src/PeekDesktop.InteropHarness/Program.cs @@ -28,6 +28,9 @@ private static int Main(string[] args) RunTest("Notification state stress", options, failures, NotificationStateStress); RunTest("Malformed input contracts", options, failures, MalformedInputContracts); RunTest("Mouse click routing logic", options, failures, MouseClickRoutingLogic); + RunTest("Fly Away virtual-screen geometry", options, failures, FlyAwayVirtualScreenGeometry); + RunTest("Fly Away geometry fuzz", options, failures, FlyAwayGeometryFuzz); + RunTest("Fly Away geometry exhaustive", options, failures, FlyAwayGeometryExhaustive); RunTest($"Leak probe ({options.Iterations:N0} iterations)", options, failures, () => LeakProbe(options)); // Auto-updater tests @@ -351,6 +354,328 @@ private static void InvalidHandleMatrix() } } + private static void FlyAwayVirtualScreenGeometry() + { + NativeMethods.RECT[] rectangularLayout = + [ + new() { Left = -1920, Top = 0, Right = 0, Bottom = 1080 }, + new() { Left = 0, Top = 0, Right = 1920, Bottom = 1080 }, + new() { Left = 1920, Top = 0, Right = 3840, Bottom = 1080 } + ]; + + AssertFlyAwayTarget( + new NativeMethods.RECT { Left = -900, Top = 300, Right = -300, Bottom = 900 }, + rectangularLayout, + expectedDirection: "down"); + AssertFlyAwayTarget( + new NativeMethods.RECT { Left = 3400, Top = 100, Right = 3800, Bottom = 800 }, + rectangularLayout, + expectedDirection: "right"); + AssertFlyAwayTarget( + new NativeMethods.RECT { Left = -2500, Top = -400, Right = 2500, Bottom = 1400 }, + rectangularLayout, + expectedDirection: "up"); + + NativeMethods.RECT[] staggeredLayout = + [ + new() { Left = 0, Top = 0, Right = 1920, Bottom = 1080 }, + new() { Left = 1920, Top = 540, Right = 3840, Bottom = 1620 } + ]; + AssertFlyAwayTarget( + new NativeMethods.RECT { Left = 2400, Top = 715, Right = 2600, Bottom = 915 }, + staggeredLayout, + expectedDirection: "up"); + + NativeMethods.RECT[] staggeredRowWithGap = + [ + new() { Left = 0, Top = 0, Right = 1920, Bottom = 1080 }, + new() { Left = 1920, Top = 540, Right = 3840, Bottom = 1620 }, + new() { Left = 3840, Top = 0, Right = 5760, Bottom = 1080 } + ]; + AssertFlyAwayTarget( + new NativeMethods.RECT { Left = 1700, Top = 100, Right = 1900, Bottom = 400 }, + staggeredRowWithGap, + expectedDirection: "right"); + + NativeMethods.RECT[] portraitLayout = + [ + new() { Left = -1080, Top = -1920, Right = 0, Bottom = 0 } + ]; + AssertFlyAwayTarget( + new NativeMethods.RECT { Left = -1050, Top = -1200, Right = -750, Bottom = -500 }, + portraitLayout, + expectedDirection: "left"); + + NativeMethods.RECT[] lShapedLayout = + [ + new() { Left = 0, Top = 0, Right = 1920, Bottom = 1080 }, + new() { Left = 1920, Top = 1080, Right = 3840, Bottom = 2160 } + ]; + AssertFlyAwayTarget( + new NativeMethods.RECT { Left = 1600, Top = 1000, Right = 1700, Bottom = 1070 }, + lShapedLayout, + expectedDirection: "down"); + + var original = new NativeMethods.RECT { Left = 10, Top = 20, Right = 110, Bottom = 120 }; + NativeMethods.RECT unchanged = WindowTracker.ComputeFlyAwayTarget(original, []); + if (unchanged.Left != original.Left + || unchanged.Top != original.Top + || unchanged.Right != original.Right + || unchanged.Bottom != original.Bottom) + { + throw new InvalidOperationException("Invalid virtual-screen bounds changed the target."); + } + } + + private static void AssertFlyAwayTarget( + NativeMethods.RECT start, + IReadOnlyList monitorBounds, + string expectedDirection) + { + NativeMethods.RECT target = WindowTracker.ComputeFlyAwayTarget(start, monitorBounds); + if (!WindowTracker.IsOutsideAllMonitors(target, monitorBounds)) + throw new InvalidOperationException("Fly Away target did not exit the complete virtual desktop."); + + if ((target.Right - target.Left) != (start.Right - start.Left) + || (target.Bottom - target.Top) != (start.Bottom - start.Top)) + { + throw new InvalidOperationException("Fly Away target changed the window size."); + } + + string actualDirection = target.Left < start.Left + ? "left" + : target.Left > start.Left + ? "right" + : target.Top < start.Top + ? "up" + : "down"; + if (!string.Equals(actualDirection, expectedDirection, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Fly Away chose {actualDirection} instead of the nearest {expectedDirection} exit."); + } + + AssertShortestExit(start, target, monitorBounds); + } + + private static void FlyAwayGeometryFuzz() + { + var random = new Random(0x5045454B); + for (int iteration = 0; iteration < 2_000; iteration++) + { + int monitorCount = random.Next(1, 17); + var monitors = new List(monitorCount); + for (int i = 0; i < monitorCount; i++) + { + int left = random.Next(-30_000, 30_001); + int top = random.Next(-30_000, 30_001); + int width = random.Next(320, 7_681); + int height = random.Next(240, 4_321); + monitors.Add(new NativeMethods.RECT + { + Left = left, + Top = top, + Right = left + width, + Bottom = top + height + }); + } + + NativeMethods.RECT anchor = monitors[random.Next(monitors.Count)]; + int windowWidth = random.Next(1, 20_001); + int windowHeight = random.Next(1, 12_001); + int windowLeft = random.Next(anchor.Left - windowWidth + 1, anchor.Right); + int windowTop = random.Next(anchor.Top - windowHeight + 1, anchor.Bottom); + var start = new NativeMethods.RECT + { + Left = windowLeft, + Top = windowTop, + Right = windowLeft + windowWidth, + Bottom = windowTop + windowHeight + }; + + NativeMethods.RECT target = WindowTracker.ComputeFlyAwayTarget(start, monitors); + if (target.Left == start.Left && target.Top == start.Top) + throw new InvalidOperationException($"Geometry fuzz case {iteration} did not move an on-monitor window."); + + if (target.Left != start.Left && target.Top != start.Top) + throw new InvalidOperationException($"Geometry fuzz case {iteration} moved on both axes."); + + if ((target.Right - target.Left) != windowWidth + || (target.Bottom - target.Top) != windowHeight) + { + throw new InvalidOperationException($"Geometry fuzz case {iteration} changed the window size."); + } + + foreach (NativeMethods.RECT monitor in monitors) + { + if (RectanglesIntersect(target, monitor)) + { + throw new InvalidOperationException( + $"Geometry fuzz case {iteration} still intersected a monitor."); + } + } + + } + } + + private static void FlyAwayGeometryExhaustive() + { + var random = new Random(0x4D4F4E49); + for (int iteration = 0; iteration < 300; iteration++) + { + int monitorCount = random.Next(1, 7); + var monitors = new List(monitorCount); + for (int i = 0; i < monitorCount; i++) + { + int monitorLeft = random.Next(-400, 401); + int monitorTop = random.Next(-400, 401); + int width = random.Next(40, 501); + int height = random.Next(40, 501); + monitors.Add(new NativeMethods.RECT + { + Left = monitorLeft, + Top = monitorTop, + Right = monitorLeft + width, + Bottom = monitorTop + height + }); + } + + NativeMethods.RECT anchor = monitors[random.Next(monitors.Count)]; + int windowWidth = random.Next(1, 601); + int windowHeight = random.Next(1, 601); + int windowLeft = random.Next(anchor.Left - windowWidth + 1, anchor.Right); + int windowTop = random.Next(anchor.Top - windowHeight + 1, anchor.Bottom); + var start = new NativeMethods.RECT + { + Left = windowLeft, + Top = windowTop, + Right = windowLeft + windowWidth, + Bottom = windowTop + windowHeight + }; + + NativeMethods.RECT target = WindowTracker.ComputeFlyAwayTarget(start, monitors); + AssertShortestExit(start, target, monitors); + } + } + + private static void AssertShortestExit( + NativeMethods.RECT start, + NativeMethods.RECT actual, + IReadOnlyList monitors) + { + long expectedDistance = FindShortestExitDistanceByScan(start, monitors); + if (expectedDistance == long.MaxValue) + throw new InvalidOperationException("Independent scan found no safe Fly Away exit."); + + long actualDistance = Math.Abs((long)actual.Left - start.Left) + + Math.Abs((long)actual.Top - start.Top); + if (actualDistance != expectedDistance) + { + throw new InvalidOperationException( + $"Fly Away travelled {actualDistance} pixels; shortest safe exit is {expectedDistance}."); + } + } + + private static long FindShortestExitDistanceByScan( + NativeMethods.RECT start, + IReadOnlyList monitors) + { + int minLeft = int.MaxValue; + int minTop = int.MaxValue; + int maxRight = int.MinValue; + int maxBottom = int.MinValue; + foreach (NativeMethods.RECT monitor in monitors) + { + minLeft = Math.Min(minLeft, monitor.Left); + minTop = Math.Min(minTop, monitor.Top); + maxRight = Math.Max(maxRight, monitor.Right); + maxBottom = Math.Max(maxBottom, monitor.Bottom); + } + + long maximumDistance = Math.Max( + Math.Max( + (long)start.Right - minLeft, + (long)maxRight - start.Left), + Math.Max( + (long)start.Bottom - minTop, + (long)maxBottom - start.Top)) + + WindowTracker.OffscreenMargin + + 1; + + for (int pass = 0; pass < 2; pass++) + { + int clearance = pass == 0 ? WindowTracker.OffscreenMargin : 0; + for (long distance = 0; distance <= maximumDistance; distance++) + { + NativeMethods.RECT[] candidates = + [ + new NativeMethods.RECT + { + Left = (int)(start.Left - distance), + Top = start.Top, + Right = (int)(start.Right - distance), + Bottom = start.Bottom + }, + new NativeMethods.RECT + { + Left = (int)(start.Left + distance), + Top = start.Top, + Right = (int)(start.Right + distance), + Bottom = start.Bottom + }, + new NativeMethods.RECT + { + Left = start.Left, + Top = (int)(start.Top - distance), + Right = start.Right, + Bottom = (int)(start.Bottom - distance) + }, + new NativeMethods.RECT + { + Left = start.Left, + Top = (int)(start.Top + distance), + Right = start.Right, + Bottom = (int)(start.Bottom + distance) + } + ]; + + foreach (NativeMethods.RECT candidate in candidates) + { + if (IsClearOfAllMonitors(candidate, monitors, clearance)) + return distance; + } + } + } + + return long.MaxValue; + } + + private static bool IsClearOfAllMonitors( + NativeMethods.RECT candidate, + IReadOnlyList monitors, + int clearance) + { + foreach (NativeMethods.RECT monitor in monitors) + { + bool horizontalOverlap = (long)candidate.Left - clearance < monitor.Right + && (long)candidate.Right + clearance > monitor.Left; + bool verticalOverlap = (long)candidate.Top - clearance < monitor.Bottom + && (long)candidate.Bottom + clearance > monitor.Top; + if (horizontalOverlap && verticalOverlap) + return false; + } + + return true; + } + + private static bool RectanglesIntersect(NativeMethods.RECT first, NativeMethods.RECT second) + { + return first.Left < second.Right + && first.Right > second.Left + && first.Top < second.Bottom + && first.Bottom > second.Top; + } + private static void PointAndRoleFuzz() { NativeMethods.POINT[] points = diff --git a/src/PeekDesktop/NativeMethods.cs b/src/PeekDesktop/NativeMethods.cs index d70d5e1..ee67537 100644 --- a/src/PeekDesktop/NativeMethods.cs +++ b/src/PeekDesktop/NativeMethods.cs @@ -100,6 +100,11 @@ public delegate void WinEventDelegate( int idObject, int idChild, uint dwEventThread, uint dwmsEventTime); public delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam); + public delegate bool MonitorEnumProc( + IntPtr hMonitor, + IntPtr hdcMonitor, + ref RECT monitorRect, + IntPtr data); #endregion @@ -302,6 +307,14 @@ public static extern IntPtr FindWindowEx( [DllImport("user32.dll")] public static extern IntPtr MonitorFromRect(ref RECT lprc, uint dwFlags); + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool EnumDisplayMonitors( + IntPtr hdc, + IntPtr clipRect, + MonitorEnumProc callback, + IntPtr data); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetMonitorInfoW(IntPtr hMonitor, ref MONITORINFO lpmi); diff --git a/src/PeekDesktop/WindowTracker.cs b/src/PeekDesktop/WindowTracker.cs index c53ed72..b9d5161 100644 --- a/src/PeekDesktop/WindowTracker.cs +++ b/src/PeekDesktop/WindowTracker.cs @@ -14,7 +14,7 @@ public sealed class WindowTracker { private const int AnimationSteps = 12; private const int AnimationDurationMs = 160; - private const int OffscreenMargin = 64; + internal const int OffscreenMargin = 64; private readonly List _savedWindows = new(); @@ -103,6 +103,12 @@ public void FlyAwayAll() { var stopwatch = Stopwatch.StartNew(); var animationWindows = new List(_savedWindows.Count); + IReadOnlyList monitorBounds = GetMonitorBounds(); + if (monitorBounds.Count == 0) + { + AppDiagnostics.Log("Fly Away skipped because monitor geometry was unavailable"); + return; + } foreach (var window in _savedWindows) { @@ -121,7 +127,7 @@ public void FlyAwayAll() NativeMethods.ShowWindow(window.Handle, NativeMethods.SW_SHOWNOACTIVATE); NativeMethods.RECT startBounds = GetCurrentBounds(window); - NativeMethods.RECT targetBounds = ComputeFlyAwayTarget(startBounds); + NativeMethods.RECT targetBounds = ComputeFlyAwayTarget(startBounds, monitorBounds); animationWindows.Add(new AnimatedWindow(window.Handle, startBounds, targetBounds)); } @@ -249,39 +255,199 @@ private static NativeMethods.RECT GetRestoreBounds(WindowInfo window) return window.Bounds; } - private static NativeMethods.RECT ComputeFlyAwayTarget(NativeMethods.RECT startBounds) + internal static NativeMethods.RECT ComputeFlyAwayTarget( + NativeMethods.RECT startBounds, + IReadOnlyList monitorBounds) { - var monitorInfo = new NativeMethods.MONITORINFO { cbSize = (uint)Marshal.SizeOf() }; - IntPtr hMonitor = NativeMethods.MonitorFromRect(ref startBounds, NativeMethods.MONITOR_DEFAULTTONEAREST); - NativeMethods.GetMonitorInfoW(hMonitor, ref monitorInfo); - NativeMethods.RECT screenBounds = monitorInfo.rcWork; + if (monitorBounds.Count == 0) + return startBounds; - int width = Math.Max(1, startBounds.Right - startBounds.Left); - int height = Math.Max(1, startBounds.Bottom - startBounds.Top); - int centerX = startBounds.Left + (width / 2); - int centerY = startBounds.Top + (height / 2); + int width = startBounds.Right - startBounds.Left; + int height = startBounds.Bottom - startBounds.Top; + if (width <= 0 || height <= 0 || IsOutsideAllMonitors(startBounds, monitorBounds)) + return startBounds; - int screenWidth = screenBounds.Right - screenBounds.Left; - int screenHeight = screenBounds.Bottom - screenBounds.Top; + for (int pass = 0; pass < 2; pass++) + { + int clearance = pass == 0 ? OffscreenMargin : 0; + NativeMethods.RECT bestTarget = startBounds; + long bestDistance = long.MaxValue; - bool moveLeft = centerX < screenBounds.Left + (screenWidth / 2); - bool moveUp = centerY < screenBounds.Top + (screenHeight / 2); + foreach (NativeMethods.RECT monitor in monitorBounds) + { + if (RangesOverlap( + (long)startBounds.Top - clearance, + (long)startBounds.Bottom + clearance, + monitor.Top, + monitor.Bottom)) + { + int leftRight = ClampCoordinate((long)monitor.Left - clearance); + ConsiderTarget( + new NativeMethods.RECT + { + Left = ClampCoordinate((long)leftRight - width), + Top = startBounds.Top, + Right = leftRight, + Bottom = startBounds.Bottom + }, + startBounds, + monitorBounds, + clearance, + ref bestTarget, + ref bestDistance); + + int rightLeft = ClampCoordinate((long)monitor.Right + clearance); + ConsiderTarget( + new NativeMethods.RECT + { + Left = rightLeft, + Top = startBounds.Top, + Right = ClampCoordinate((long)rightLeft + width), + Bottom = startBounds.Bottom + }, + startBounds, + monitorBounds, + clearance, + ref bestTarget, + ref bestDistance); + } - int targetLeft = moveLeft - ? screenBounds.Left - width - OffscreenMargin - : screenBounds.Right + OffscreenMargin; + if (RangesOverlap( + (long)startBounds.Left - clearance, + (long)startBounds.Right + clearance, + monitor.Left, + monitor.Right)) + { + int topBottom = ClampCoordinate((long)monitor.Top - clearance); + ConsiderTarget( + new NativeMethods.RECT + { + Left = startBounds.Left, + Top = ClampCoordinate((long)topBottom - height), + Right = startBounds.Right, + Bottom = topBottom + }, + startBounds, + monitorBounds, + clearance, + ref bestTarget, + ref bestDistance); + + int bottomTop = ClampCoordinate((long)monitor.Bottom + clearance); + ConsiderTarget( + new NativeMethods.RECT + { + Left = startBounds.Left, + Top = bottomTop, + Right = startBounds.Right, + Bottom = ClampCoordinate((long)bottomTop + height) + }, + startBounds, + monitorBounds, + clearance, + ref bestTarget, + ref bestDistance); + } + } - int targetTop = moveUp - ? screenBounds.Top - height - OffscreenMargin - : screenBounds.Bottom + OffscreenMargin; + if (bestDistance != long.MaxValue) + return bestTarget; + } - return new NativeMethods.RECT + return startBounds; + } + + private static IReadOnlyList GetMonitorBounds() + { + var monitorBounds = new List(); + bool enumerated = NativeMethods.EnumDisplayMonitors( + IntPtr.Zero, + IntPtr.Zero, + (IntPtr _, IntPtr _, ref NativeMethods.RECT monitorRect, IntPtr _) => + { + if (monitorRect.Right > monitorRect.Left && monitorRect.Bottom > monitorRect.Top) + monitorBounds.Add(monitorRect); + return true; + }, + IntPtr.Zero); + + if (!enumerated) { - Left = targetLeft, - Top = targetTop, - Right = targetLeft + width, - Bottom = targetTop + height - }; + AppDiagnostics.Log("EnumDisplayMonitors failed while computing Fly Away targets"); + monitorBounds.Clear(); + } + + return monitorBounds; + } + + private static void ConsiderTarget( + NativeMethods.RECT candidate, + NativeMethods.RECT startBounds, + IReadOnlyList monitorBounds, + int clearance, + ref NativeMethods.RECT bestTarget, + ref long bestDistance) + { + long sourceWidth = (long)startBounds.Right - startBounds.Left; + long sourceHeight = (long)startBounds.Bottom - startBounds.Top; + if ((long)candidate.Right - candidate.Left != sourceWidth + || (long)candidate.Bottom - candidate.Top != sourceHeight) + { + return; + } + + if (!IsClearOfAllMonitors(candidate, monitorBounds, clearance)) + return; + + long distance = Math.Abs((long)candidate.Left - startBounds.Left) + + Math.Abs((long)candidate.Top - startBounds.Top); + if (distance < bestDistance) + { + bestTarget = candidate; + bestDistance = distance; + } + } + + internal static bool IsOutsideAllMonitors( + NativeMethods.RECT bounds, + IReadOnlyList monitorBounds) + { + return IsClearOfAllMonitors(bounds, monitorBounds, 0); + } + + internal static bool IsClearOfAllMonitors( + NativeMethods.RECT bounds, + IReadOnlyList monitorBounds, + int clearance) + { + foreach (NativeMethods.RECT monitor in monitorBounds) + { + if (RangesOverlap( + (long)bounds.Left - clearance, + (long)bounds.Right + clearance, + monitor.Left, + monitor.Right) + && RangesOverlap( + (long)bounds.Top - clearance, + (long)bounds.Bottom + clearance, + monitor.Top, + monitor.Bottom)) + { + return false; + } + } + + return true; + } + + private static bool RangesOverlap(long firstStart, long firstEnd, long secondStart, long secondEnd) + { + return firstStart < secondEnd && firstEnd > secondStart; + } + + private static int ClampCoordinate(long value) + { + return (int)Math.Clamp(value, int.MinValue, int.MaxValue); } private static void AnimateWindows(IReadOnlyList windows) @@ -342,7 +508,7 @@ private static NativeMethods.RECT LerpRect(NativeMethods.RECT from, NativeMethod private static int Lerp(int from, int to, double t) { - return (int)Math.Round(from + ((to - from) * t)); + return (int)Math.Round(from + (((long)to - from) * t)); } private record WindowInfo(IntPtr Handle, NativeMethods.WINDOWPLACEMENT Placement, NativeMethods.RECT Bounds);