diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs new file mode 100644 index 0000000000..bf8af589a6 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI; +using Microsoft.UI.Windowing; +using Windows.Graphics; +using Windows.Win32; +using Windows.Win32.Graphics.Gdi; +using Windows.Win32.UI.HiDpi; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal static class WindowPositionHelper +{ + private const int DefaultWidth = 800; + private const int DefaultHeight = 480; + private const int MinimumVisibleSize = 100; + private const int DefaultDpi = 96; + + public static PointInt32? CalculateCenteredPosition(DisplayArea? displayArea, SizeInt32 windowSize, int windowDpi) + { + if (displayArea is null) + { + return null; + } + + var workArea = displayArea.WorkArea; + if (workArea.Width <= 0 || workArea.Height <= 0) + { + return null; + } + + var targetDpi = GetDpiForDisplay(displayArea); + var predictedSize = ScaleSize(windowSize, windowDpi, targetDpi); + + // Clamp to work area + var width = Math.Min(predictedSize.Width, workArea.Width); + var height = Math.Min(predictedSize.Height, workArea.Height); + + return new PointInt32( + workArea.X + ((workArea.Width - width) / 2), + workArea.Y + ((workArea.Height - height) / 2)); + } + + /// + /// Adjusts a saved window rect to ensure it's visible on the nearest display, + /// accounting for DPI changes and work area differences. + /// + /// + public static RectInt32 AdjustRectForVisibility(RectInt32 savedRect, SizeInt32 savedScreenSize, int savedDpi) + { + var displayArea = DisplayArea.GetFromRect(savedRect, DisplayAreaFallback.Nearest); + if (displayArea is null) + { + return savedRect; + } + + var workArea = displayArea.WorkArea; + if (workArea.Width <= 0 || workArea.Height <= 0) + { + return savedRect; + } + + var targetDpi = GetDpiForDisplay(displayArea); + if (savedDpi <= 0) + { + savedDpi = targetDpi; + } + + var hasInvalidSize = savedRect.Width <= 0 || savedRect.Height <= 0; + if (hasInvalidSize) + { + savedRect = savedRect with { Width = DefaultWidth, Height = DefaultHeight }; + } + + if (targetDpi != savedDpi) + { + savedRect = ScaleRect(savedRect, savedDpi, targetDpi); + } + + var clampedSize = ClampSize(savedRect.Width, savedRect.Height, workArea); + + var shouldRecenter = hasInvalidSize || + IsOffscreen(savedRect, workArea) || + savedScreenSize.Width != workArea.Width || + savedScreenSize.Height != workArea.Height; + + if (shouldRecenter) + { + return CenterRectInWorkArea(clampedSize, workArea); + } + + return new RectInt32(savedRect.X, savedRect.Y, clampedSize.Width, clampedSize.Height); + } + + private static int GetDpiForDisplay(DisplayArea displayArea) + { + var hMonitor = Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId); + if (hMonitor == IntPtr.Zero) + { + return DefaultDpi; + } + + var hr = PInvoke.GetDpiForMonitor( + new HMONITOR(hMonitor), + MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, + out var dpiX, + out _); + + return hr.Succeeded && dpiX > 0 ? (int)dpiX : DefaultDpi; + } + + private static SizeInt32 ScaleSize(SizeInt32 size, int fromDpi, int toDpi) + { + if (fromDpi <= 0 || toDpi <= 0 || fromDpi == toDpi) + { + return size; + } + + var scale = (double)toDpi / fromDpi; + return new SizeInt32( + (int)Math.Round(size.Width * scale), + (int)Math.Round(size.Height * scale)); + } + + private static RectInt32 ScaleRect(RectInt32 rect, int fromDpi, int toDpi) + { + var scale = (double)toDpi / fromDpi; + return new RectInt32( + (int)Math.Round(rect.X * scale), + (int)Math.Round(rect.Y * scale), + (int)Math.Round(rect.Width * scale), + (int)Math.Round(rect.Height * scale)); + } + + private static SizeInt32 ClampSize(int width, int height, RectInt32 workArea) => + new(Math.Min(width, workArea.Width), Math.Min(height, workArea.Height)); + + private static RectInt32 CenterRectInWorkArea(SizeInt32 size, RectInt32 workArea) => + new( + workArea.X + ((workArea.Width - size.Width) / 2), + workArea.Y + ((workArea.Height - size.Height) / 2), + size.Width, + size.Height); + + private static bool IsOffscreen(RectInt32 rect, RectInt32 workArea) => + rect.X + MinimumVisibleSize > workArea.X + workArea.Width || + rect.X + rect.Width - MinimumVisibleSize < workArea.X || + rect.Y + MinimumVisibleSize > workArea.Y + workArea.Height || + rect.Y + rect.Height - MinimumVisibleSize < workArea.Y; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index abaf102a9e..bc083cd1bd 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -21,7 +21,6 @@ using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; -using Microsoft.UI; using Microsoft.UI.Composition; using Microsoft.UI.Composition.SystemBackdrops; using Microsoft.UI.Input; @@ -32,13 +31,9 @@ using Windows.ApplicationModel.Activation; using Windows.Foundation; using Windows.Graphics; using Windows.System; -using Windows.UI; -using Windows.UI.WindowManagement; using Windows.Win32; using Windows.Win32.Foundation; using Windows.Win32.Graphics.Dwm; -using Windows.Win32.Graphics.Gdi; -using Windows.Win32.UI.HiDpi; using Windows.Win32.UI.Input.KeyboardAndMouse; using Windows.Win32.UI.WindowsAndMessaging; using WinRT; @@ -60,9 +55,6 @@ public sealed partial class MainWindow : WindowEx, IRecipient, IDisposable { - private const int DefaultWidth = 800; - private const int DefaultHeight = 480; - [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_")] [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_")] private readonly uint WM_TASKBAR_RESTART; @@ -226,39 +218,40 @@ public sealed partial class MainWindow : WindowEx, PositionCentered(displayArea); } + private void PositionCentered(DisplayArea displayArea) + { + var position = WindowPositionHelper.CalculateCenteredPosition( + displayArea, + AppWindow.Size, + (int)this.GetDpiForWindow()); + + if (position is not null) + { + // Use Move(), not MoveAndResize(). Windows auto-resizes on DPI change via WM_DPICHANGED; + // the helper already accounts for this when calculating the centered position. + AppWindow.Move((PointInt32)position); + } + } + private void RestoreWindowPosition() { var settings = App.Current.Services.GetService(); - if (settings?.LastWindowPosition is not WindowPosition savedPosition) + if (settings?.LastWindowPosition is not { Width: > 0, Height: > 0 } savedPosition) { PositionCentered(); return; } - if (savedPosition.Width <= 0 || savedPosition.Height <= 0) - { - PositionCentered(); - return; - } + // MoveAndResize is safe here—we're restoring a saved state at startup, + // not moving a live window between displays. + var newRect = WindowPositionHelper.AdjustRectForVisibility( + savedPosition.ToPhysicalWindowRectangle(), + new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight), + savedPosition.Dpi); - var newRect = EnsureWindowIsVisible(savedPosition.ToPhysicalWindowRectangle(), new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight), savedPosition.Dpi); AppWindow.MoveAndResize(newRect); } - private void PositionCentered(DisplayArea displayArea) - { - if (displayArea is not null) - { - var centeredPosition = AppWindow.Position; - centeredPosition.X = (displayArea.WorkArea.Width - AppWindow.Size.Width) / 2; - centeredPosition.Y = (displayArea.WorkArea.Height - AppWindow.Size.Height) / 2; - - centeredPosition.X += displayArea.WorkArea.X; - centeredPosition.Y += displayArea.WorkArea.Y; - AppWindow.Move(centeredPosition); - } - } - private void UpdateWindowPositionInMemory() { var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest) ?? DisplayArea.Primary; @@ -352,7 +345,8 @@ public sealed partial class MainWindow : WindowEx, if (target == MonitorBehavior.ToLast) { - var newRect = EnsureWindowIsVisible(_currentWindowPosition.ToPhysicalWindowRectangle(), new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight), _currentWindowPosition.Dpi); + var originalScreen = new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight); + var newRect = WindowPositionHelper.AdjustRectForVisibility(_currentWindowPosition.ToPhysicalWindowRectangle(), originalScreen, _currentWindowPosition.Dpi); AppWindow.MoveAndResize(newRect); } else @@ -382,115 +376,7 @@ public sealed partial class MainWindow : WindowEx, PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE); } - /// - /// Ensures that the window rectangle is visible on-screen. - /// - /// The window rectangle in physical pixels. - /// The desktop area the window was positioned on. - /// The window's original DPI. - /// - /// A window rectangle in physical pixels, moved to the nearest display and resized - /// if the DPI has changed. - /// - private static RectInt32 EnsureWindowIsVisible(RectInt32 windowRect, SizeInt32 originalScreen, int originalDpi) - { - var displayArea = DisplayArea.GetFromRect(windowRect, DisplayAreaFallback.Nearest); - if (displayArea is null) - { - return windowRect; - } - - var workArea = displayArea.WorkArea; - if (workArea.Width <= 0 || workArea.Height <= 0) - { - // Fallback, nothing reasonable to do - return windowRect; - } - - var effectiveDpi = GetEffectiveDpiFromDisplayId(displayArea); - if (originalDpi <= 0) - { - originalDpi = effectiveDpi; // use current DPI as baseline (no scaling adjustment needed) - } - - var hasInvalidSize = windowRect.Width <= 0 || windowRect.Height <= 0; - if (hasInvalidSize) - { - windowRect = new RectInt32(windowRect.X, windowRect.Y, DefaultWidth, DefaultHeight); - } - - // If we have a DPI change, scale the window rectangle accordingly - if (effectiveDpi != originalDpi) - { - var scalingFactor = effectiveDpi / (double)originalDpi; - windowRect = new RectInt32( - (int)Math.Round(windowRect.X * scalingFactor), - (int)Math.Round(windowRect.Y * scalingFactor), - (int)Math.Round(windowRect.Width * scalingFactor), - (int)Math.Round(windowRect.Height * scalingFactor)); - } - - var targetWidth = Math.Min(windowRect.Width, workArea.Width); - var targetHeight = Math.Min(windowRect.Height, workArea.Height); - - // Ensure at least some minimum visible area (e.g., 100 pixels) - // This helps prevent the window from being entirely offscreen, regardless of display scaling. - const int minimumVisibleSize = 100; - var isOffscreen = - windowRect.X + minimumVisibleSize > workArea.X + workArea.Width || - windowRect.X + windowRect.Width - minimumVisibleSize < workArea.X || - windowRect.Y + minimumVisibleSize > workArea.Y + workArea.Height || - windowRect.Y + windowRect.Height - minimumVisibleSize < workArea.Y; - - // if the work area size has changed, re-center the window - var workAreaSizeChanged = - originalScreen.Width != workArea.Width || - originalScreen.Height != workArea.Height; - - int targetX; - int targetY; - var recenter = isOffscreen || workAreaSizeChanged || hasInvalidSize; - if (recenter) - { - targetX = workArea.X + ((workArea.Width - targetWidth) / 2); - targetY = workArea.Y + ((workArea.Height - targetHeight) / 2); - } - else - { - targetX = windowRect.X; - targetY = windowRect.Y; - } - - return new RectInt32(targetX, targetY, targetWidth, targetHeight); - } - - private static int GetEffectiveDpiFromDisplayId(DisplayArea displayArea) - { - var effectiveDpi = 96; - - var hMonitor = (HMONITOR)Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId); - if (!hMonitor.IsNull) - { - var hr = PInvoke.GetDpiForMonitor(hMonitor, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out var dpiX, out _); - if (hr == 0) - { - effectiveDpi = (int)dpiX; - } - else - { - Logger.LogWarning($"GetDpiForMonitor failed with HRESULT: 0x{hr.Value:X8} on display {displayArea.DisplayId}"); - } - } - - if (effectiveDpi <= 0) - { - effectiveDpi = 96; - } - - return effectiveDpi; - } - - private DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target) + private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target) { // Leaving a note here, in case we ever need it: // https://github.com/microsoft/microsoft-ui-xaml/issues/6454