diff --git a/src/modules/GrabAndMove/GrabAndMove/GrabAndMove.vcxproj b/src/modules/GrabAndMove/GrabAndMove/GrabAndMove.vcxproj
index 26db82fb6d6e..2a793c23a801 100644
--- a/src/modules/GrabAndMove/GrabAndMove/GrabAndMove.vcxproj
+++ b/src/modules/GrabAndMove/GrabAndMove/GrabAndMove.vcxproj
@@ -88,7 +88,7 @@
- WindowsApp.lib;%(AdditionalDependencies)
+ WindowsApp.lib;dwmapi.lib;%(AdditionalDependencies)
@@ -208,4 +208,4 @@
-
\ No newline at end of file
+
diff --git a/src/modules/GrabAndMove/GrabAndMove/main.cpp b/src/modules/GrabAndMove/GrabAndMove/main.cpp
index 505b8359dd17..50d0972bdc09 100644
--- a/src/modules/GrabAndMove/GrabAndMove/main.cpp
+++ b/src/modules/GrabAndMove/GrabAndMove/main.cpp
@@ -58,7 +58,9 @@ static DWORD g_absorbedFlags = 0; // flags for replay (extended key, etc.)
static bool g_showGeometry = false; // true if we want to draw the X, Y, W and H on the overlay on move and resize
static bool g_doNotActivateOnGameMode = true; // true if GrabAndMove is suppressed when Windows Game Mode is active
-static bool g_useAltResize = true; // This can be toggled from the settings. If false, Alt + right click does nothing.
+static bool g_useAltResize = true; // This can be toggled from the settings. If false, Alt + right click does nothing.
+static bool g_useScreenEdgeMaximize = true; // If true, dragging to the top screen edge maximizes/restores the window.
+static bool g_useScreenEdgeSnap = true; // If true, dragging to side/corner screen edges snaps the window.
// Count of non-modifier keys currently held. Used to suppress GrabAndMove when the
// modifier key is pressed while another key is already down (e.g. Q held, then modifier pressed).
@@ -83,15 +85,30 @@ enum ResizeHandle
RESIZE_LEFT
};
+enum class ScreenEdgeSnapTarget
+{
+ None,
+ Maximize,
+ LeftHalf,
+ RightHalf,
+ TopLeft,
+ TopRight,
+ BottomLeft,
+ BottomRight,
+};
+
static bool g_resizing = false;
static bool g_resizeFirstMove = false;
static HWND g_resizeTarget = nullptr;
static POINT g_resizeLast = {}; // cursor pos from previous frame
static RECT g_resizeWndRect = {}; // current window rect (updated each frame)
static ResizeHandle g_currentHandle = RESIZE_NONE;
+static ScreenEdgeSnapTarget g_pendingSnapTarget = ScreenEdgeSnapTarget::None;
+static RECT g_pendingSnapRect = {};
static const int MIN_WINDOW_WIDTH = 150;
static const int MIN_WINDOW_HEIGHT = 50;
+static constexpr int SCREEN_EDGE_SNAP_THRESHOLD_PX = 16;
// Minimum interval (ms) between move/resize updates. Lower = snappier but
// more CPU/GPU work. 0 = unlimited (every mouse event triggers an update).
@@ -241,6 +258,230 @@ static void TraceShortcutUse(bool successful, GrabAndMoveShortcutAction action,
TraceLoggingWideString(reason, "Reason"));
}
+static bool IsWindowResizable(HWND hwnd)
+{
+ return (GetWindowLongPtrW(hwnd, GWL_STYLE) & WS_THICKFRAME) != 0;
+}
+
+static bool IsWindowMaximizable(HWND hwnd)
+{
+ return (GetWindowLongPtrW(hwnd, GWL_STYLE) & WS_MAXIMIZEBOX) != 0;
+}
+
+static bool TryGetWorkAreaFromPoint(POINT pt, RECT& workArea)
+{
+ HMONITOR monitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
+ if (!monitor)
+ {
+ return false;
+ }
+
+ MONITORINFO monitorInfo = {};
+ monitorInfo.cbSize = sizeof(monitorInfo);
+ if (!GetMonitorInfoW(monitor, &monitorInfo))
+ {
+ return false;
+ }
+
+ workArea = monitorInfo.rcWork;
+ return true;
+}
+
+static bool TryGetVisibleFrameBounds(HWND hwnd, RECT& frameRect)
+{
+ return SUCCEEDED(DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &frameRect, sizeof(frameRect))) && !IsRectEmpty(&frameRect);
+}
+
+static RECT GetWindowRectForVisibleFrame(HWND hwnd, const RECT& desiredFrameRect)
+{
+ RECT windowRect = {};
+ RECT frameRect = {};
+ if (!GetWindowRect(hwnd, &windowRect) || !TryGetVisibleFrameBounds(hwnd, frameRect))
+ {
+ return desiredFrameRect;
+ }
+
+ return {
+ desiredFrameRect.left + (windowRect.left - frameRect.left),
+ desiredFrameRect.top + (windowRect.top - frameRect.top),
+ desiredFrameRect.right + (windowRect.right - frameRect.right),
+ desiredFrameRect.bottom + (windowRect.bottom - frameRect.bottom),
+ };
+}
+
+static bool SetWindowVisibleFrameToRect(HWND hwnd, const RECT& desiredFrameRect, UINT flags = 0)
+{
+ RECT adjustedRect = GetWindowRectForVisibleFrame(hwnd, desiredFrameRect);
+ const int w = adjustedRect.right - adjustedRect.left;
+ const int h = adjustedRect.bottom - adjustedRect.top;
+ if (w <= 0 || h <= 0)
+ {
+ return false;
+ }
+
+ return SetWindowPos(
+ hwnd,
+ nullptr,
+ adjustedRect.left,
+ adjustedRect.top,
+ w,
+ h,
+ SWP_NOZORDER | SWP_NOACTIVATE | flags) != FALSE;
+}
+
+static RECT GetScreenEdgeSnapRect(ScreenEdgeSnapTarget target, const RECT& workArea)
+{
+ const int midX = workArea.left + (workArea.right - workArea.left) / 2;
+ const int midY = workArea.top + (workArea.bottom - workArea.top) / 2;
+
+ switch (target)
+ {
+ case ScreenEdgeSnapTarget::Maximize:
+ return workArea;
+ case ScreenEdgeSnapTarget::LeftHalf:
+ return { workArea.left, workArea.top, midX, workArea.bottom };
+ case ScreenEdgeSnapTarget::RightHalf:
+ return { midX, workArea.top, workArea.right, workArea.bottom };
+ case ScreenEdgeSnapTarget::TopLeft:
+ return { workArea.left, workArea.top, midX, midY };
+ case ScreenEdgeSnapTarget::TopRight:
+ return { midX, workArea.top, workArea.right, midY };
+ case ScreenEdgeSnapTarget::BottomLeft:
+ return { workArea.left, midY, midX, workArea.bottom };
+ case ScreenEdgeSnapTarget::BottomRight:
+ return { midX, midY, workArea.right, workArea.bottom };
+ default:
+ return {};
+ }
+}
+
+static ScreenEdgeSnapTarget GetScreenEdgeSnapTarget(POINT pt, const RECT& workArea)
+{
+ const bool nearLeft = pt.x <= workArea.left + SCREEN_EDGE_SNAP_THRESHOLD_PX;
+ const bool nearRight = pt.x >= workArea.right - SCREEN_EDGE_SNAP_THRESHOLD_PX;
+ const bool nearTop = pt.y <= workArea.top + SCREEN_EDGE_SNAP_THRESHOLD_PX;
+ const bool nearBottom = pt.y >= workArea.bottom - SCREEN_EDGE_SNAP_THRESHOLD_PX;
+
+ if (nearTop && nearLeft)
+ {
+ return ScreenEdgeSnapTarget::TopLeft;
+ }
+
+ if (nearTop && nearRight)
+ {
+ return ScreenEdgeSnapTarget::TopRight;
+ }
+
+ if (nearBottom && nearLeft)
+ {
+ return ScreenEdgeSnapTarget::BottomLeft;
+ }
+
+ if (nearBottom && nearRight)
+ {
+ return ScreenEdgeSnapTarget::BottomRight;
+ }
+
+ if (nearTop)
+ {
+ return ScreenEdgeSnapTarget::Maximize;
+ }
+
+ if (nearLeft)
+ {
+ return ScreenEdgeSnapTarget::LeftHalf;
+ }
+
+ if (nearRight)
+ {
+ return ScreenEdgeSnapTarget::RightHalf;
+ }
+
+ return ScreenEdgeSnapTarget::None;
+}
+
+static bool IsScreenEdgeSnapTargetAllowed(HWND hwnd, ScreenEdgeSnapTarget target)
+{
+ if (target == ScreenEdgeSnapTarget::None)
+ {
+ return false;
+ }
+
+ if (target == ScreenEdgeSnapTarget::Maximize)
+ {
+ return IsWindowMaximizable(hwnd);
+ }
+
+ return IsWindowResizable(hwnd);
+}
+
+static bool IsScreenEdgeSnapTargetEnabled(ScreenEdgeSnapTarget target)
+{
+ if (target == ScreenEdgeSnapTarget::Maximize)
+ {
+ return g_useScreenEdgeMaximize;
+ }
+
+ return target != ScreenEdgeSnapTarget::None && g_useScreenEdgeSnap;
+}
+
+static void UpdatePendingScreenEdgeSnap(POINT pt)
+{
+ g_pendingSnapTarget = ScreenEdgeSnapTarget::None;
+ g_pendingSnapRect = {};
+
+ if (!g_dragTarget)
+ {
+ return;
+ }
+
+ RECT workArea = {};
+ if (!TryGetWorkAreaFromPoint(pt, workArea))
+ {
+ return;
+ }
+
+ ScreenEdgeSnapTarget target = GetScreenEdgeSnapTarget(pt, workArea);
+ if (!IsScreenEdgeSnapTargetEnabled(target) || !IsScreenEdgeSnapTargetAllowed(g_dragTarget, target))
+ {
+ return;
+ }
+
+ g_pendingSnapTarget = target;
+ g_pendingSnapRect = GetScreenEdgeSnapRect(target, workArea);
+}
+
+static bool ToggleWindowMaximized(HWND hwnd)
+{
+ if (!hwnd || !IsWindowMaximizable(hwnd))
+ {
+ return false;
+ }
+
+ ShowWindow(hwnd, IsZoomed(hwnd) ? SW_RESTORE : SW_MAXIMIZE);
+ return true;
+}
+
+static bool ApplyPendingScreenEdgeSnap()
+{
+ if (!g_dragTarget || !IsScreenEdgeSnapTargetAllowed(g_dragTarget, g_pendingSnapTarget))
+ {
+ return false;
+ }
+
+ if (g_pendingSnapTarget == ScreenEdgeSnapTarget::Maximize)
+ {
+ return ToggleWindowMaximized(g_dragTarget);
+ }
+
+ if (IsZoomed(g_dragTarget))
+ {
+ ShowWindow(g_dragTarget, SW_RESTORE);
+ }
+
+ return SetWindowVisibleFrameToRect(g_dragTarget, g_pendingSnapRect);
+}
+
// ---------------------------------------------------------------------------
// Settings file helpers
// ---------------------------------------------------------------------------
@@ -270,6 +511,16 @@ static void LoadSettingsFromFile()
g_useAltResize = *v;
}
+ if (auto v = values.get_bool_value(L"useScreenEdgeMaximize"))
+ {
+ g_useScreenEdgeMaximize = *v;
+ }
+
+ if (auto v = values.get_bool_value(L"useScreenEdgeSnap"))
+ {
+ g_useScreenEdgeSnap = *v;
+ }
+
if (auto v = values.get_int_value(L"modifierKey"))
{
g_modifierKey = (*v == 1) ? GrabAndMoveModifier::Win : GrabAndMoveModifier::Alt;
@@ -509,6 +760,8 @@ static void StopDragging()
g_dragging = false;
g_dragFirstMove = false;
g_dragTarget = nullptr;
+ g_pendingSnapTarget = ScreenEdgeSnapTarget::None;
+ g_pendingSnapRect = {};
HideOverlay();
}
@@ -1045,6 +1298,8 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
g_dragTarget = hwnd;
g_dragStart = pt;
GetWindowRect(hwnd, &g_dragWndRect);
+ g_pendingSnapTarget = ScreenEdgeSnapTarget::None;
+ g_pendingSnapRect = {};
// Show the semi-transparent overlay on top of the target (persistent window – fix #9)
ShowOverlay(g_dragWndRect, g_curSizeAll);
@@ -1078,6 +1333,13 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
int newY = g_dragWndRect.top + dy;
int w = g_dragWndRect.right - g_dragWndRect.left;
int h = g_dragWndRect.bottom - g_dragWndRect.top;
+ UpdatePendingScreenEdgeSnap(pt);
+ if (g_pendingSnapTarget != ScreenEdgeSnapTarget::None && ApplyPendingScreenEdgeSnap())
+ {
+ EndInteraction(true, false);
+ return 1;
+ }
+
SetWindowPos(g_dragTarget, nullptr, newX, newY, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);
EndInteraction(true, false);
return 1; // swallow the release
@@ -1193,10 +1455,22 @@ static void HandleDragMove(POINT pt)
int w = g_dragWndRect.right - g_dragWndRect.left;
int h = g_dragWndRect.bottom - g_dragWndRect.top;
+ UpdatePendingScreenEdgeSnap(pt);
+ if (g_pendingSnapTarget != ScreenEdgeSnapTarget::None)
+ {
+ RepositionOverlay(
+ g_pendingSnapRect.left,
+ g_pendingSnapRect.top,
+ g_pendingSnapRect.right - g_pendingSnapRect.left,
+ g_pendingSnapRect.bottom - g_pendingSnapRect.top);
+ return;
+ }
+
// Move target + overlay (separate SetWindowPos – DeferWindowPos doesn't
// work reliably for cross-process target windows)
SetWindowPos(g_dragTarget, nullptr, newX, newY, 0, 0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);
+
RepositionOverlay(newX, newY, w, h);
}
diff --git a/src/modules/GrabAndMove/GrabAndMove/pch.h b/src/modules/GrabAndMove/GrabAndMove/pch.h
index e3a8aa12ea96..06e64d4a8d19 100644
--- a/src/modules/GrabAndMove/GrabAndMove/pch.h
+++ b/src/modules/GrabAndMove/GrabAndMove/pch.h
@@ -3,6 +3,7 @@
#define WIN32_LEAN_AND_MEAN
#include
+#include
#include
#include
#include
diff --git a/src/settings-ui/Settings.UI.Library/GrabAndMoveProperties.cs b/src/settings-ui/Settings.UI.Library/GrabAndMoveProperties.cs
index f022c595d0c0..1bedb87524e6 100644
--- a/src/settings-ui/Settings.UI.Library/GrabAndMoveProperties.cs
+++ b/src/settings-ui/Settings.UI.Library/GrabAndMoveProperties.cs
@@ -14,6 +14,8 @@ public GrabAndMoveProperties()
DoNotActivateOnGameMode = new BoolProperty(true);
ShowGeometry = new BoolProperty(false);
UseAltResize = new BoolProperty(true);
+ UseScreenEdgeMaximize = new BoolProperty(true);
+ UseScreenEdgeSnap = new BoolProperty(true);
ExcludedApps = new StringProperty();
ModifierKey = new IntProperty(0); // 0 = Alt, 1 = Win
}
@@ -30,6 +32,12 @@ public GrabAndMoveProperties()
[JsonPropertyName("useAltResize")]
public BoolProperty UseAltResize { get; set; }
+ [JsonPropertyName("useScreenEdgeMaximize")]
+ public BoolProperty UseScreenEdgeMaximize { get; set; }
+
+ [JsonPropertyName("useScreenEdgeSnap")]
+ public BoolProperty UseScreenEdgeSnap { get; set; }
+
[JsonPropertyName("doNotActivateOnGameMode")]
public BoolProperty DoNotActivateOnGameMode { get; set; }
diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/GrabAndMove.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/GrabAndMove.cs
new file mode 100644
index 000000000000..9a9c7360d3a8
--- /dev/null
+++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/GrabAndMove.cs
@@ -0,0 +1,48 @@
+// 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 System.Text.Json;
+using Microsoft.PowerToys.Settings.UI.Library;
+using Microsoft.PowerToys.Settings.UI.UnitTests.Mocks;
+using Microsoft.PowerToys.Settings.UI.ViewModels;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace ViewModelTests
+{
+ [TestClass]
+ public class GrabAndMove
+ {
+ [TestMethod]
+ public void NewGrabActionsAreEnabledByDefault()
+ {
+ var settings = new GrabAndMoveSettings();
+
+ Assert.IsTrue(settings.Properties.UseScreenEdgeMaximize.Value);
+ Assert.IsTrue(settings.Properties.UseScreenEdgeSnap.Value);
+ }
+
+ [TestMethod]
+ public void UpdatingNewGrabActionsNotifiesRunner()
+ {
+ var moduleSettings = new GrabAndMoveSettings();
+ string serializedSettings = string.Empty;
+
+ using var viewModel = new GrabAndMoveViewModel(
+ SettingsRepository.GetInstance(ISettingsUtilsMocks.GetStubSettingsUtils().Object),
+ moduleSettings,
+ msg =>
+ {
+ serializedSettings = msg;
+ return 0;
+ });
+
+ viewModel.UseScreenEdgeMaximize = false;
+ viewModel.UseScreenEdgeSnap = false;
+
+ var outgoingSettings = JsonSerializer.Deserialize>(serializedSettings);
+ Assert.IsFalse(outgoingSettings!.PowertoysSetting.Settings.Properties.UseScreenEdgeMaximize.Value);
+ Assert.IsFalse(outgoingSettings!.PowertoysSetting.Settings.Properties.UseScreenEdgeSnap.Value);
+ }
+ }
+}
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/GrabAndMovePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/GrabAndMovePage.xaml
index 4e6ea76cf28d..044b67b24682 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/Views/GrabAndMovePage.xaml
+++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/GrabAndMovePage.xaml
@@ -59,6 +59,12 @@
+
+
+
+
+
+
diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw
index 1e4bc972bc11..32a5397d1832 100644
--- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw
+++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw
@@ -6162,6 +6162,20 @@ Text uses the current drawing color.
Hold the modifier key and drag with the right mouse button from the nearest edge or corner to resize the window
+
+ Maximize windows at the top screen edge while dragging
+ When enabled, holding the modifier key and dragging a window to the top screen edge maximizes it.
+
+
+ Hold the modifier key and drag to the top of a screen to maximize or restore the window
+
+
+ Snap windows at side and corner screen edges while dragging
+ When enabled, holding the modifier key and dragging a window to a side or corner screen edge snaps it to the matching screen area.
+
+
+ Hold the modifier key and drag to the left, right, or corner of a screen to half-snap or quarter-snap the window
+
Excluded apps
@@ -6172,4 +6186,4 @@ Text uses the current drawing color.
Example: outlook
{Locked="outlook"}
-
\ No newline at end of file
+
diff --git a/src/settings-ui/Settings.UI/ViewModels/GrabAndMoveViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/GrabAndMoveViewModel.cs
index 114114a99714..2e71aa6bb225 100644
--- a/src/settings-ui/Settings.UI/ViewModels/GrabAndMoveViewModel.cs
+++ b/src/settings-ui/Settings.UI/ViewModels/GrabAndMoveViewModel.cs
@@ -147,6 +147,36 @@ public bool UseAltResize
}
}
+ public bool UseScreenEdgeSnap
+ {
+ get => _moduleSettings.Properties.UseScreenEdgeSnap.Value;
+
+ set
+ {
+ if (_moduleSettings.Properties.UseScreenEdgeSnap.Value != value)
+ {
+ _moduleSettings.Properties.UseScreenEdgeSnap.Value = value;
+ NotifyModuleSettingsChanged();
+ OnPropertyChanged(nameof(UseScreenEdgeSnap));
+ }
+ }
+ }
+
+ public bool UseScreenEdgeMaximize
+ {
+ get => _moduleSettings.Properties.UseScreenEdgeMaximize.Value;
+
+ set
+ {
+ if (_moduleSettings.Properties.UseScreenEdgeMaximize.Value != value)
+ {
+ _moduleSettings.Properties.UseScreenEdgeMaximize.Value = value;
+ NotifyModuleSettingsChanged();
+ OnPropertyChanged(nameof(UseScreenEdgeMaximize));
+ }
+ }
+ }
+
public bool DoNotActivateOnGameMode
{
get => _moduleSettings.Properties.DoNotActivateOnGameMode.Value;