CmdPal: Fixes and improve main window positioning (#45585)

## Summary of the Pull Request

This PR improves main window positioning:

- Fixes cases where an invalid window size or position was saved.  
- `UpdateWindowPositionInMemory` failed to capture correct values when
the window was minimized or maximized (for example, a minimized window
reports coordinates like `(-32000, -32000)`).
- Improves repositioning logic to use relative anchors (corners and
center). When switching displays, the window should reappear in the
expected position. This also reduces cases that trigger the failsafe
recentering.
- Fixes the dragging rectangle size after switching DPIs - the rectangle
was not adapting, so it when switching from 100 % to 200 % it covered
only left half of the window and had teeny-tiny height.
- Suppresses system DPI handling during summon to prevent double
scaling.
- Makes `WindowPosition` class immutable.
- Adds light-weight failsafe preventing overwriting position with
invalid data.
- Hotfixes a min/max state conflict with the WinUIEx window manager.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #45576
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
This commit is contained in:
Jiří Polášek
2026-02-19 19:43:32 +01:00
committed by GitHub
parent dcf4c4d16d
commit 39bfa86335
4 changed files with 185 additions and 68 deletions

View File

@@ -11,37 +11,42 @@ public sealed class WindowPosition
/// <summary>
/// Gets or sets left position in device pixels.
/// </summary>
public int X { get; set; }
public int X { get; init; }
/// <summary>
/// Gets or sets top position in device pixels.
/// </summary>
public int Y { get; set; }
public int Y { get; init; }
/// <summary>
/// Gets or sets width in device pixels.
/// </summary>
public int Width { get; set; }
public int Width { get; init; }
/// <summary>
/// Gets or sets height in device pixels.
/// </summary>
public int Height { get; set; }
public int Height { get; init; }
/// <summary>
/// Gets or sets width of the screen in device pixels where the window is located.
/// </summary>
public int ScreenWidth { get; set; }
public int ScreenWidth { get; init; }
/// <summary>
/// Gets or sets height of the screen in device pixels where the window is located.
/// </summary>
public int ScreenHeight { get; set; }
public int ScreenHeight { get; init; }
/// <summary>
/// Gets or sets DPI (dots per inch) of the display where the window is located.
/// </summary>
public int Dpi { get; set; }
public int Dpi { get; init; }
/// <summary>
/// Gets a value indicating whether the width and height of the window are valid (greater than 0).
/// </summary>
public bool IsSizeValid => Width > 0 && Height > 0;
/// <summary>
/// Converts the window position properties to a <see cref="RectInt32"/> structure representing the physical window rectangle.

View File

@@ -18,7 +18,7 @@ internal static class WindowPositionHelper
private const int MinimumVisibleSize = 100;
private const int DefaultDpi = 96;
public static PointInt32? CalculateCenteredPosition(DisplayArea? displayArea, SizeInt32 windowSize, int windowDpi)
public static RectInt32? CenterOnDisplay(DisplayArea? displayArea, SizeInt32 windowSize, int windowDpi)
{
if (displayArea is null)
{
@@ -32,15 +32,9 @@ internal static class WindowPositionHelper
}
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));
var scaledSize = ScaleSize(windowSize, windowDpi, targetDpi);
var clampedSize = ClampSize(scaledSize.Width, scaledSize.Height, workArea);
return CenterRectInWorkArea(clampedSize, workArea);
}
/// <summary>
@@ -74,6 +68,10 @@ internal static class WindowPositionHelper
savedRect = savedRect with { Width = DefaultWidth, Height = DefaultHeight };
}
// Remember the original size before DPI scaling - needed to compute
// gaps relative to the old screen when repositioning across displays.
var originalSize = new SizeInt32(savedRect.Width, savedRect.Height);
if (targetDpi != savedDpi)
{
savedRect = ScaleRect(savedRect, savedDpi, targetDpi);
@@ -81,12 +79,17 @@ internal static class WindowPositionHelper
var clampedSize = ClampSize(savedRect.Width, savedRect.Height, workArea);
var shouldRecenter = hasInvalidSize ||
IsOffscreen(savedRect, workArea) ||
savedScreenSize.Width != workArea.Width ||
savedScreenSize.Height != workArea.Height;
if (hasInvalidSize)
{
return CenterRectInWorkArea(clampedSize, workArea);
}
if (shouldRecenter)
if (savedScreenSize.Width != workArea.Width || savedScreenSize.Height != workArea.Height)
{
return RepositionRelativeToWorkArea(savedRect, savedScreenSize, originalSize, clampedSize, workArea);
}
if (IsOffscreen(savedRect, workArea))
{
return CenterRectInWorkArea(clampedSize, workArea);
}
@@ -126,27 +129,92 @@ internal static class WindowPositionHelper
private static RectInt32 ScaleRect(RectInt32 rect, int fromDpi, int toDpi)
{
if (fromDpi <= 0 || toDpi <= 0 || fromDpi == toDpi)
{
return rect;
}
// Don't scale position, that's absolute coordinates in virtual screen space
var scale = (double)toDpi / fromDpi;
return new RectInt32(
(int)Math.Round(rect.X * scale),
(int)Math.Round(rect.Y * scale),
rect.X,
rect.Y,
(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 SizeInt32 ClampSize(int width, int height, RectInt32 workArea)
{
return new SizeInt32(Math.Min(width, workArea.Width), Math.Min(height, workArea.Height));
}
private static RectInt32 CenterRectInWorkArea(SizeInt32 size, RectInt32 workArea) =>
new(
private static RectInt32 RepositionRelativeToWorkArea(RectInt32 savedRect, SizeInt32 savedScreenSize, SizeInt32 originalSize, SizeInt32 clampedSize, RectInt32 workArea)
{
// Treat each axis as a 3-zone grid (start / center / end) so that
// edge-snapped windows stay snapped and centered windows stay centered.
// We don't store the old work area origin, so we use the current one as a
// best estimate (correct when the same physical display changed resolution/DPI/taskbar).
var newX = ScaleAxisByZone(savedRect.X, originalSize.Width, clampedSize.Width, workArea.X, savedScreenSize.Width, workArea.Width);
var newY = ScaleAxisByZone(savedRect.Y, originalSize.Height, clampedSize.Height, workArea.Y, savedScreenSize.Height, workArea.Height);
newX = Math.Clamp(newX, workArea.X, Math.Max(workArea.X, workArea.X + workArea.Width - clampedSize.Width));
newY = Math.Clamp(newY, workArea.Y, Math.Max(workArea.Y, workArea.Y + workArea.Height - clampedSize.Height));
return new RectInt32(newX, newY, clampedSize.Width, clampedSize.Height);
}
/// <summary>
/// Repositions a window along one axis using a 3-zone model (start / center / end).
/// The zone is determined by which third of the old screen the window center falls in.
/// Uses <paramref name="oldWindowSize"/> (pre-DPI-scaling) for gap calculations against
/// the old screen, and <paramref name="newWindowSize"/> (post-scaling) for placement on the new screen.
/// </summary>
private static int ScaleAxisByZone(int savedPos, int oldWindowSize, int newWindowSize, int workAreaOrigin, int oldScreenSize, int newScreenSize)
{
if (oldScreenSize <= 0 || newScreenSize <= 0)
{
return savedPos;
}
var gapFromStart = savedPos - workAreaOrigin;
var windowCenter = gapFromStart + (oldWindowSize / 2);
if (windowCenter >= oldScreenSize / 3 && windowCenter <= oldScreenSize * 2 / 3)
{
// Center zone - keep centered
return workAreaOrigin + ((newScreenSize - newWindowSize) / 2);
}
var gapFromEnd = oldScreenSize - gapFromStart - oldWindowSize;
if (gapFromStart <= gapFromEnd)
{
// Start zone - preserve proportional distance from start edge
var rel = (double)gapFromStart / oldScreenSize;
return workAreaOrigin + (int)Math.Round(rel * newScreenSize);
}
else
{
// End zone - preserve proportional distance from end edge
var rel = (double)gapFromEnd / oldScreenSize;
return workAreaOrigin + newScreenSize - newWindowSize - (int)Math.Round(rel * newScreenSize);
}
}
private static RectInt32 CenterRectInWorkArea(SizeInt32 size, RectInt32 workArea)
{
return new RectInt32(
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 ||
private static bool IsOffscreen(RectInt32 rect, RectInt32 workArea)
{
return 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;
}
}

View File

@@ -72,6 +72,7 @@ public sealed partial class MainWindow : WindowEx,
private readonly IThemeService _themeService;
private readonly WindowThemeSynchronizer _windowThemeSynchronizer;
private bool _ignoreHotKeyWhenFullScreen = true;
private bool _suppressDpiChange;
private bool _themeServiceInitialized;
// Session tracking for telemetry
@@ -127,6 +128,16 @@ public sealed partial class MainWindow : WindowEx,
_keyboardListener.SetProcessCommand(new CmdPalKeyboardService.ProcessCommand(HandleSummon));
WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated");
// LOAD BEARING: If you don't stick the pointer to HotKeyPrc into a
// member (and instead like, use a local), then the pointer we marshal
// into the WindowLongPtr will be useless after we leave this function,
// and our **WindProc will explode**.
_hotkeyWndProc = HotKeyPrc;
var hotKeyPrcPointer = Marshal.GetFunctionPointerForDelegate(_hotkeyWndProc);
_originalWndProc = Marshal.GetDelegateForFunctionPointer<WNDPROC>(PInvoke.SetWindowLongPtr(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_WNDPROC, hotKeyPrcPointer));
this.SetIcon();
AppWindow.Title = RS_.GetString("AppName");
RestoreWindowPosition();
@@ -153,16 +164,6 @@ public sealed partial class MainWindow : WindowEx,
SizeChanged += WindowSizeChanged;
RootElement.Loaded += RootElementLoaded;
WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated");
// LOAD BEARING: If you don't stick the pointer to HotKeyPrc into a
// member (and instead like, use a local), then the pointer we marshal
// into the WindowLongPtr will be useless after we leave this function,
// and our **WindProc will explode**.
_hotkeyWndProc = HotKeyPrc;
var hotKeyPrcPointer = Marshal.GetFunctionPointerForDelegate(_hotkeyWndProc);
_originalWndProc = Marshal.GetDelegateForFunctionPointer<WNDPROC>(PInvoke.SetWindowLongPtr(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_WNDPROC, hotKeyPrcPointer));
// Load our settings, and then also wire up a settings changed handler
HotReloadSettings();
App.Current.Services.GetService<SettingsModel>()!.SettingsChanged += SettingsChangedHandler;
@@ -213,6 +214,11 @@ public sealed partial class MainWindow : WindowEx,
// Now that our content has loaded, we can update our draggable regions
UpdateRegionsForCustomTitleBar();
// Also update regions when DPI changes. SizeChanged only fires when the logical
// (DIP) size changes — a DPI change that scales the physical size while preserving
// the DIP size won't trigger it, leaving drag regions at the old physical coordinates.
RootElement.XamlRoot.Changed += XamlRoot_Changed;
// Add dev ribbon if enabled
if (!BuildInfo.IsCiBuild)
{
@@ -221,6 +227,8 @@ public sealed partial class MainWindow : WindowEx,
}
}
private void XamlRoot_Changed(XamlRoot sender, XamlRootChangedEventArgs args) => UpdateRegionsForCustomTitleBar();
private void WindowSizeChanged(object sender, WindowSizeChangedEventArgs args) => UpdateRegionsForCustomTitleBar();
private void PositionCentered()
@@ -231,16 +239,14 @@ public sealed partial class MainWindow : WindowEx,
private void PositionCentered(DisplayArea displayArea)
{
var position = WindowPositionHelper.CalculateCenteredPosition(
var rect = WindowPositionHelper.CenterOnDisplay(
displayArea,
AppWindow.Size,
(int)this.GetDpiForWindow());
if (position is not null)
if (rect 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);
MoveAndResizeDpiAware(rect.Value);
}
}
@@ -249,29 +255,62 @@ public sealed partial class MainWindow : WindowEx,
var settings = App.Current.Services.GetService<SettingsModel>();
if (settings?.LastWindowPosition is not { Width: > 0, Height: > 0 } savedPosition)
{
// don't try to restore if the saved position is invalid, just recenter
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);
AppWindow.MoveAndResize(newRect);
MoveAndResizeDpiAware(newRect);
}
/// <summary>
/// Moves and resizes the window while suppressing WM_DPICHANGED.
/// The caller is expected to provide a rect already scaled for the target display's DPI.
/// Without suppression, the framework would apply its own DPI scaling on top, double-scaling the window.
/// </summary>
private void MoveAndResizeDpiAware(RectInt32 rect)
{
var originalMinHeight = MinHeight;
var originalMinWidth = MinWidth;
_suppressDpiChange = true;
try
{
// WindowEx is uses current DPI to calculate the minimum window size
MinHeight = 0;
MinWidth = 0;
AppWindow.MoveAndResize(rect);
}
finally
{
MinHeight = originalMinHeight;
MinWidth = originalMinWidth;
_suppressDpiChange = false;
}
}
private void UpdateWindowPositionInMemory()
{
var placement = new WINDOWPLACEMENT { length = (uint)Marshal.SizeOf<WINDOWPLACEMENT>() };
if (!PInvoke.GetWindowPlacement(_hwnd, ref placement))
{
return;
}
var rect = placement.rcNormalPosition;
var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest) ?? DisplayArea.Primary;
_currentWindowPosition = new WindowPosition
{
X = AppWindow.Position.X,
Y = AppWindow.Position.Y,
Width = AppWindow.Size.Width,
Height = AppWindow.Size.Height,
X = rect.X,
Y = rect.Y,
Width = rect.Width,
Height = rect.Height,
Dpi = (int)this.GetDpiForWindow(),
ScreenWidth = displayArea.WorkArea.Width,
ScreenHeight = displayArea.WorkArea.Height,
@@ -480,7 +519,7 @@ public sealed partial class MainWindow : WindowEx,
{
var originalScreen = new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight);
var newRect = WindowPositionHelper.AdjustRectForVisibility(_currentWindowPosition.ToPhysicalWindowRectangle(), originalScreen, _currentWindowPosition.Dpi);
AppWindow.MoveAndResize(newRect);
MoveAndResizeDpiAware(newRect);
}
else
{
@@ -737,19 +776,13 @@ public sealed partial class MainWindow : WindowEx,
var settings = serviceProvider.GetService<SettingsModel>();
if (settings is not null)
{
settings.LastWindowPosition = new WindowPosition
// a quick sanity check, so we don't overwrite correct values
if (_currentWindowPosition.IsSizeValid)
{
X = _currentWindowPosition.X,
Y = _currentWindowPosition.Y,
Width = _currentWindowPosition.Width,
Height = _currentWindowPosition.Height,
Dpi = _currentWindowPosition.Dpi,
ScreenWidth = _currentWindowPosition.ScreenWidth,
ScreenHeight = _currentWindowPosition.ScreenHeight,
};
settings.LastWindowPosition = _currentWindowPosition;
SettingsModel.SaveSettings(settings);
}
}
var extensionService = serviceProvider.GetService<IExtensionService>()!;
extensionService.SignalStopExtensionsAsync();
@@ -1108,6 +1141,13 @@ public sealed partial class MainWindow : WindowEx,
// Prevent the window from maximizing when double-clicking the title bar area
case PInvoke.WM_NCLBUTTONDBLCLK:
return (LRESULT)IntPtr.Zero;
// When restoring a saved position across monitors with different DPIs,
// MoveAndResize already sets the correctly-scaled size. Suppress the
// framework's automatic DPI resize to avoid double-scaling.
case PInvoke.WM_DPICHANGED when _suppressDpiChange:
return (LRESULT)IntPtr.Zero;
case PInvoke.WM_HOTKEY:
{
var hotkeyIndex = (int)wParam.Value;

View File

@@ -67,3 +67,7 @@ GetModuleHandle
GetWindowThreadProcessId
AttachThreadInput
GetWindowPlacement
WINDOWPLACEMENT
WM_DPICHANGED