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;