diff --git a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Windows.cs b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Windows.cs index 0d30bca6ac05..601302064872 100644 --- a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Windows.cs +++ b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.Windows.cs @@ -154,6 +154,110 @@ await CreateHandlerAndAddToWindow(mainPage, async (handler) => }); } + [Fact] + public async Task WindowsBoundsWhenMaximized() + { + SetupBuilder(); + var mainPage = new NavigationPage(new ContentPage()); + + await CreateHandlerAndAddToWindow(mainPage, async (handler) => + { + var appWindowPlatform = handler.PlatformView.GetAppWindow(); + Assert.NotNull(appWindowPlatform?.Presenter); + var presenter = Assert.IsType(appWindowPlatform.Presenter); + + // maximize window + presenter.Maximize(); + var appWindow = handler.PlatformView.GetWindow(); + Assert.NotNull(appWindow); + + // Compute work-area reference values before polling so the same values + // are used for both the wait predicate and the final assertions. + // Compare against the monitor's work area. This correctly handles negative + // coordinates when the window is on a monitor positioned left of or above + // the primary display, and catches regressions beyond a simple > 0 check. + var displayArea = DisplayArea.GetFromWindowId(appWindowPlatform.Id, DisplayAreaFallback.Nearest); + var workArea = displayArea.WorkArea; + var density = handler.PlatformView.GetDisplayDensity(); + + // Wait until the MAUI frame reflects the maximized work-area bounds. + // Waiting only for Height > 0 is insufficient: that condition is already true + // before Maximize() is called, so on slow machines the assertions below would + // execute against the pre-maximized frame and become flaky. + await AssertEventually(() => + Math.Abs(appWindow.Width - workArea.Width / density) < 2 && + Math.Abs(appWindow.Height - workArea.Height / density) < 2); + + Assert.True(Math.Abs(appWindow.X - workArea.X / density) < 2, + $"X should be near work area X ({workArea.X / density:F2}) but was {appWindow.X}"); + Assert.True(Math.Abs(appWindow.Y - workArea.Y / density) < 2, + $"Y should be near work area Y ({workArea.Y / density:F2}) but was {appWindow.Y}"); + Assert.True(Math.Abs(appWindow.Width - workArea.Width / density) < 2, + $"Width should match work area width ({workArea.Width / density:F2}) but was {appWindow.Width}"); + Assert.True(Math.Abs(appWindow.Height - workArea.Height / density) < 2, + $"Height should match work area height ({workArea.Height / density:F2}) but was {appWindow.Height}"); + }); + } + + [Fact] + public async Task WindowsYAndHeightCorrectWhenClosingMaximizedWindow() + { + SetupBuilder(); + var mainPage = new NavigationPage(new ContentPage()); + + double destroyingY = double.NaN; + double destroyingHeight = double.NaN; + double expectedY = double.NaN; + double expectedHeight = double.NaN; + + await CreateHandlerAndAddToWindow(mainPage, async (handler) => + { + var window = handler.VirtualView as Window; + Assert.NotNull(window); + + var appWindowPlatform = handler.PlatformView.GetAppWindow(); + Assert.NotNull(appWindowPlatform?.Presenter); + var presenter = Assert.IsType(appWindowPlatform.Presenter); + + // Capture the frame values at destroy time so we can verify them after cleanup + window.Destroying += (s, e) => + { + destroyingY = window.Y; + destroyingHeight = window.Height; + }; + + // Capture the expected work-area bounds before waiting for the maximized frame, + // so the same reference values are used for both the wait predicate and the + // post-cleanup assertions. + var displayArea = DisplayArea.GetFromWindowId(appWindowPlatform.Id, DisplayAreaFallback.Nearest); + var workArea = displayArea.WorkArea; + var density = handler.PlatformView.GetDisplayDensity(); + expectedY = workArea.Y / density; + expectedHeight = workArea.Height / density; + + // Maximize the window and wait until the MAUI frame reflects the maximized bounds. + // Waiting only for Height > 0 is insufficient: that condition is already true + // before Maximize() is called, so on slow machines the captured values at + // Destroying time could come from the pre-maximized frame. + // Do not assert window.Y == 0: on monitors positioned above the primary display + // Y is legitimately negative. + presenter.Maximize(); + await AssertEventually(() => + Math.Abs(window.Height - expectedHeight) < 2 && + Math.Abs(window.Y - expectedY) < 2); + }); + + // The window is destroyed during CreateHandlerAndAddToWindow cleanup. + // Assert that the bounds reported at Destroying time match the monitor work area, + // using the same < 2 tolerance as WindowsBoundsWhenMaximized. This catches + // regressions where Y or Height is off by ~8 pixels when closing a maximized window. + Assert.False(double.IsNaN(destroyingHeight), "Window.Destroying event was not raised"); + Assert.True(Math.Abs(destroyingY - expectedY) < 2, + $"Y should be near work area Y ({expectedY:F2}) when closing a maximized window, but was {destroyingY}"); + Assert.True(Math.Abs(destroyingHeight - expectedHeight) < 2, + $"Height should match work area height ({expectedHeight:F2}) when closing a maximized window, but was {destroyingHeight}"); + } + [Fact] public async Task ToggleFullscreenTitleBarWorks() { diff --git a/src/Core/src/Handlers/Window/WindowHandler.Windows.cs b/src/Core/src/Handlers/Window/WindowHandler.Windows.cs index 2d457bdc50f5..c27c9efd8c7a 100644 --- a/src/Core/src/Handlers/Window/WindowHandler.Windows.cs +++ b/src/Core/src/Handlers/Window/WindowHandler.Windows.cs @@ -4,11 +4,16 @@ using Microsoft.UI.Windowing; using Microsoft.UI.Xaml.Controls; using Windows.Graphics; +using WinRT.Interop; namespace Microsoft.Maui.Handlers { public partial class WindowHandler : ElementHandler { + // The HWND never changes after the window is created; cache it once to avoid + // a COM/marshalling round-trip on every OnWindowChanged (move/resize) event. + IntPtr _hwnd = IntPtr.Zero; + protected override void ConnectHandler(UI.Xaml.Window platformView) { base.ConnectHandler(platformView); @@ -20,6 +25,8 @@ protected override void ConnectHandler(UI.Xaml.Window platformView) platformView.UpdatePosition(VirtualView); platformView.UpdateSize(VirtualView); + _hwnd = platformView.GetWindowHandle(); + var appWindow = platformView.GetAppWindow(); if (appWindow is not null) { @@ -68,6 +75,8 @@ protected override void DisconnectHandler(UI.Xaml.Window platformView) appWindow.Changed -= OnWindowChanged; } + _hwnd = IntPtr.Zero; + base.DisconnectHandler(platformView); } @@ -202,9 +211,24 @@ void OnWindowChanged(AppWindow sender, AppWindowChangedEventArgs args) void UpdateVirtualViewFrame(AppWindow appWindow) { + // Use the HWND cached at ConnectHandler — it never changes and this method + // runs on every move/resize event, so avoid the COM round-trip each time. + if (_hwnd == IntPtr.Zero) + { + return; + } + var size = appWindow.Size; var pos = appWindow.Position; + if (appWindow.Presenter is OverlappedPresenter presenter && + presenter.State == OverlappedPresenterState.Maximized && + PlatformMethods.TryGetExtendedFrameBounds(_hwnd, out var dwmRect)) + { + size = new SizeInt32(dwmRect.Right - dwmRect.Left, dwmRect.Bottom - dwmRect.Top); + pos = new PointInt32(dwmRect.Left, dwmRect.Top); + } + var density = PlatformView.GetDisplayDensity(); VirtualView.FrameChanged(new Rect( diff --git a/src/Essentials/src/Platform/PlatformMethods.windows.cs b/src/Essentials/src/Platform/PlatformMethods.windows.cs index 2eb120101bfc..4217ea2e5d21 100644 --- a/src/Essentials/src/Platform/PlatformMethods.windows.cs +++ b/src/Essentials/src/Platform/PlatformMethods.windows.cs @@ -74,6 +74,19 @@ public static long GetWindowLongPtr(IntPtr hWnd, WindowLongFlags nIndex) static extern long GetWindowLongPtr64(IntPtr hWnd, WindowLongFlags nIndex); } + internal static bool TryGetExtendedFrameBounds(IntPtr hWnd, out RECT bounds) + { + bounds = default; + if (hWnd == IntPtr.Zero) + { + return false; + } + + int hr = DwmGetWindowAttribute(hWnd, DwmWindowAttribute.DWMWA_EXTENDED_FRAME_BOUNDS, + out bounds, Marshal.SizeOf()); + return hr == 0 && (bounds.Right - bounds.Left) > 0 && (bounds.Bottom - bounds.Top) > 0; + } + [DllImport("user32.dll")] public static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam);