mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 09:46:54 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -67,3 +67,7 @@ GetModuleHandle
|
||||
|
||||
GetWindowThreadProcessId
|
||||
AttachThreadInput
|
||||
|
||||
GetWindowPlacement
|
||||
WINDOWPLACEMENT
|
||||
WM_DPICHANGED
|
||||
Reference in New Issue
Block a user