mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-08 19:40:01 +02:00
This lets Alt/Win + click/rclick (no drag) go through if no mouse movement is recorded. Now the target window goes either into the "all clicks go through" or "all clicks are considered resizes/moves" based on whether the first mouse action after the modifier is pressed is a click or the beginning of a mouse drag action. <!-- Enter a brief description/summary of your PR here. What does it fix/what does it change/how was it tested (even manually, if necessary)? --> ## Summary of the Pull Request <!-- Please review the items on the PR checklist before submitting--> ## PR Checklist - [ ] Closes: #xxx <!-- - [ ] 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
1925 lines
68 KiB
C++
1925 lines
68 KiB
C++
#include "pch.h"
|
||
|
||
#include <common/SettingsAPI/settings_objects.h>
|
||
#include <common/SettingsAPI/settings_helpers.h>
|
||
#include <common/Telemetry/ProjectTelemetry.h>
|
||
#include <common/utils/process_path.h>
|
||
#include <common/utils/ProcessWaiter.h>
|
||
#include <common/utils/excluded_apps.h>
|
||
#include <common/utils/game_mode.h>
|
||
|
||
#include "resource.h"
|
||
|
||
#include <dwmapi.h>
|
||
|
||
TRACELOGGING_DEFINE_PROVIDER(
|
||
g_hProvider,
|
||
"Microsoft.PowerToys",
|
||
// {38e8889b-9731-53f5-e901-e8a7c1753074}
|
||
(0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74),
|
||
TraceLoggingOptionProjectTelemetry());
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Globals
|
||
// ---------------------------------------------------------------------------
|
||
static HINSTANCE g_hInstance = nullptr;
|
||
static ULONG_PTR g_gdiplusToken = 0; // GDI+ token for overlay border rendering
|
||
static HHOOK g_hhkKeyboard = nullptr;
|
||
static HHOOK g_hhkMouse = nullptr;
|
||
static HWND g_hMsgWnd = nullptr;
|
||
|
||
// 0 = Alt (default), 1 = Win
|
||
enum class GrabAndMoveModifier
|
||
{
|
||
Alt = 0,
|
||
Win = 1,
|
||
};
|
||
static GrabAndMoveModifier g_modifierKey = GrabAndMoveModifier::Alt;
|
||
|
||
static bool g_altPressed = false;
|
||
static bool g_winPressed = false; // true while LWIN or RWIN is held (Win modifier mode)
|
||
static bool g_ignoreModifier = false; // true if the user pressed the modifier then clicked the mouse without dragging
|
||
static bool g_winAbsorbed = false; // true if we absorbed a Win keydown
|
||
static bool g_dragging = false;
|
||
static bool g_dragFirstMove = false; // true until first WM_MOUSEMOVE of a drag
|
||
static HWND g_dragTarget = nullptr;
|
||
static POINT g_dragStart = {}; // cursor pos at drag start
|
||
static RECT g_dragWndRect = {}; // window rect at drag start
|
||
static HWND g_hOverlay = nullptr; // semi-transparent overlay during drag
|
||
|
||
// Current target window rect for overlay info display
|
||
static int g_overlayInfoX = 0, g_overlayInfoY = 0;
|
||
static int g_overlayInfoW = 0, g_overlayInfoH = 0;
|
||
|
||
// Visible frame overlay metrics. Computed once per drag/resize (cold path) and
|
||
// reused while rendering - never recomputed in the mouse-move hot path.
|
||
// Margins are the difference between GetWindowRect and the DWM extended frame
|
||
// bounds (the invisible resize border), so the fill and border hug the visible
|
||
// window. The border is drawn just inside the visible edge; Always On Top draws
|
||
// its own border just outside that edge, so the two stack into a clean double
|
||
// layer without Grab and Move having to widen its stroke.
|
||
static int g_overlayMarginL = 0, g_overlayMarginT = 0, g_overlayMarginR = 0, g_overlayMarginB = 0;
|
||
static int g_overlayCornerRadius = 0; // physical px; 0 = square corners
|
||
static int g_overlayBorderThickness = 4; // physical px
|
||
|
||
// Fluent "warning" gold - copy of WinUI SystemFillColorCaution
|
||
// (used as a ThemeResource for warnings across the Settings UI). A Win32 layered
|
||
// window can't resolve a ThemeResource, so the literal is required here.
|
||
static constexpr COLORREF OVERLAY_BORDER_COLOR = RGB(255, 185, 0); // #FFB900
|
||
|
||
// Border thickness in DIPs (scaled by the target window DPI).
|
||
static constexpr int OVERLAY_BORDER_DIP = 4;
|
||
|
||
// Translucent white wash painted over the visible window during a drag/resize,
|
||
// matching the prior overlay. ~40% opacity (premultiplied white = 0x66666666).
|
||
static constexpr BYTE OVERLAY_FILL_ALPHA = 0x66;
|
||
|
||
static bool g_shouldAbsorbAlt =
|
||
true; // true if we want to absorb Alt on the next keydown (set when Alt is pressed without dragging, cleared on next non-Alt key or Alt keyup)
|
||
static bool g_altAbsorbed = false; // true if we absorbed an Alt keydown
|
||
static bool g_dragConsumedAlt = false; // true if a drag consumed the absorbed Alt
|
||
static DWORD g_absorbedVk = 0; // VK code of absorbed Alt key
|
||
static DWORD g_absorbedScanCode = 0; // scan code for replay
|
||
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.
|
||
|
||
// 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).
|
||
static int g_heldNonAltKeyCount = 0;
|
||
|
||
// Per-vkCode tracking for held keys. Only the initial press increments
|
||
// g_heldNonAltKeyCount; auto-repeat keydowns are ignored. This avoids relying
|
||
// on KF_REPEAT which is not available in KBDLLHOOKSTRUCT::flags.
|
||
static bool g_keyHeld[256] = {};
|
||
|
||
// Resize handle identifiers
|
||
enum ResizeHandle
|
||
{
|
||
RESIZE_NONE,
|
||
RESIZE_TOP_LEFT,
|
||
RESIZE_TOP,
|
||
RESIZE_TOP_RIGHT,
|
||
RESIZE_RIGHT,
|
||
RESIZE_BOTTOM_RIGHT,
|
||
RESIZE_BOTTOM,
|
||
RESIZE_BOTTOM_LEFT,
|
||
RESIZE_LEFT
|
||
};
|
||
|
||
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;
|
||
|
||
// Deferred activation state: a modifier+button press is held "pending" until the
|
||
// cursor moves past the drag threshold (promoting it to a real drag/resize) or the
|
||
// button is released first (replaying a normal modifier+click to the target).
|
||
static bool g_pendingDrag = false;
|
||
static bool g_pendingResize = false;
|
||
static HWND g_pendingTarget = nullptr;
|
||
static POINT g_pendingDownPt = {};
|
||
|
||
// Set when a pending press was replayed to the target on modifier release while the
|
||
// physical button was still down; the matching physical button-up is then swallowed.
|
||
static bool g_swallowNextLButtonUp = false;
|
||
static bool g_swallowNextRButtonUp = false;
|
||
|
||
// Set once ReplayPendingClick has replayed the absorbed modifier key-down for a pending
|
||
// click. The absorbed-modifier flags stay set so the keyboard hook still forwards the
|
||
// real modifier key-up; this guard stops the key-down from being replayed a second time.
|
||
static bool g_pendingModifierReplayed = false;
|
||
|
||
// Set once a drag or resize has actually started during the current modifier hold.
|
||
// While set, subsequent modifier+button presses skip the deferred/pending phase and
|
||
// begin the drag/resize immediately - the user has already committed to Grab and Move.
|
||
// Cleared when the modifier key is released.
|
||
static bool g_activatedDuringHold = false;
|
||
|
||
static const int MIN_WINDOW_WIDTH = 150;
|
||
static const int MIN_WINDOW_HEIGHT = 50;
|
||
|
||
// Minimum interval (ms) between move/resize updates. Lower = snappier but
|
||
// more CPU/GPU work. 0 = unlimited (every mouse event triggers an update).
|
||
// 4 ms ≈ 240 Hz, 8 ms ≈ 120 Hz, 16 ms ≈ 60 Hz.
|
||
static constexpr ULONGLONG THROTTLE_INTERVAL_MS = 16;
|
||
|
||
// QPC helpers for throttle
|
||
static ULONGLONG QpcMs()
|
||
{
|
||
static LARGE_INTEGER freq = {};
|
||
if (freq.QuadPart == 0)
|
||
QueryPerformanceFrequency(&freq);
|
||
LARGE_INTEGER now;
|
||
QueryPerformanceCounter(&now);
|
||
return static_cast<ULONGLONG>(now.QuadPart * 1000 / freq.QuadPart);
|
||
}
|
||
static ULONGLONG g_lastMoveTick = 0;
|
||
|
||
// Cached system cursors (loaded once at startup) – fix #6
|
||
static HCURSOR g_curSizeAll = nullptr;
|
||
static HCURSOR g_curSizeNWSE = nullptr;
|
||
static HCURSOR g_curSizeNESW = nullptr;
|
||
static HCURSOR g_curSizeNS = nullptr;
|
||
static HCURSOR g_curSizeWE = nullptr;
|
||
|
||
// IsExcluded result cache keyed by HWND (cleared on foreground change / settings reload) – fix #4
|
||
static std::unordered_map<HWND, bool> g_excludedCache;
|
||
|
||
// Custom message: posted from the settings thread to invalidate g_excludedCache
|
||
// on the main thread (where all other accesses occur), avoiding a data race.
|
||
static constexpr UINT WM_INVALIDATE_EXCLUDED_CACHE = WM_APP + 1;
|
||
|
||
static const wchar_t* const CLASS_NAME = L"GrabAndMove_MsgWnd";
|
||
static const wchar_t* const OVERLAY_CLASS_NAME = L"GrabAndMove_Overlay";
|
||
static const wchar_t* const APP_TITLE = L"GrabAndMove";
|
||
|
||
// Must match CommonSharedConstants::GRABANDMOVE_REFRESH_SETTINGS_EVENT in shared_constants.h
|
||
static const wchar_t* const GRABANDMOVE_REFRESH_SETTINGS_EVENT =
|
||
L"Local\\PowerToysGrabAndMove-RefreshSettingsEvent-a7b3c1d2-4e5f-6a7b-8c9d-0e1f2a3b4c5d";
|
||
|
||
// Must match CommonSharedConstants::GRABANDMOVE_EXIT_EVENT in shared_constants.h
|
||
static const wchar_t* const GRABANDMOVE_EXIT_EVENT =
|
||
L"Local\\PowerToysGrabAndMove-ExitEvent-b8c4d2e3-5f6a-7b8c-9d0e-1f2a3b4c5d6e";
|
||
|
||
// Lock-free excluded apps list – readers use .load(), writer uses .store() (fix #5)
|
||
static std::atomic<std::shared_ptr<const std::vector<std::wstring>>> g_excludedApps{
|
||
std::make_shared<const std::vector<std::wstring>>()};
|
||
|
||
static HANDLE g_hReloadSettingsEvent = nullptr;
|
||
static HANDLE g_hExitEvent = nullptr;
|
||
static std::thread g_settingsThread;
|
||
static bool g_running = true;
|
||
static HWINEVENTHOOK g_hWinEventHook = nullptr;
|
||
|
||
static void StopDragging();
|
||
static void StopResizing();
|
||
|
||
static void EndInteraction(bool endDrag, bool endResize)
|
||
{
|
||
if (endDrag && g_dragging)
|
||
{
|
||
StopDragging();
|
||
}
|
||
|
||
if (endResize && g_resizing)
|
||
{
|
||
StopResizing();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// WinEvent hook – detects foreground switch to elevated processes where
|
||
// low-level keyboard hooks stop delivering key-up events.
|
||
// ---------------------------------------------------------------------------
|
||
static void CALLBACK WinEventProc(HWINEVENTHOOK, DWORD, HWND hwnd, LONG, LONG, DWORD, DWORD)
|
||
{
|
||
// Ignore focus changes to our own windows – these are benign and fire constantly
|
||
// (overlay creation/destruction, repositioned drag targets, etc.).
|
||
if (hwnd == g_hOverlay || hwnd == g_hMsgWnd)
|
||
return;
|
||
|
||
// Any foreground switch to a non-own window can eat key-up events (e.g. Win+L eats
|
||
// the 'L' keyup before the session locks). Always reset the held-key counter so that
|
||
// the next Alt/Win press is never blocked by a stale non-zero count.
|
||
g_heldNonAltKeyCount = 0;
|
||
memset(g_keyHeld, 0, sizeof(g_keyHeld));
|
||
|
||
// Invalidate the IsExcluded cache on foreground change (fix #4)
|
||
g_excludedCache.clear();
|
||
|
||
// Only validate modifier state when there is actually something to reset.
|
||
// Skipping here when all flags are clear prevents spurious resets that would
|
||
// break continuous Alt-dragging between multiple drags.
|
||
if (!g_altPressed && !g_winPressed && !g_altAbsorbed && !g_winAbsorbed && !g_dragging && !g_resizing)
|
||
return;
|
||
|
||
bool modActuallyHeld = (g_modifierKey == GrabAndMoveModifier::Win)
|
||
? ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000) != 0
|
||
: ((GetAsyncKeyState(VK_LMENU) | GetAsyncKeyState(VK_RMENU)) & 0x8000) != 0;
|
||
|
||
if (!modActuallyHeld)
|
||
{
|
||
g_altPressed = false;
|
||
g_winPressed = false;
|
||
g_winAbsorbed = false;
|
||
g_altAbsorbed = false;
|
||
g_dragConsumedAlt = false;
|
||
g_ignoreModifier = false;
|
||
g_activatedDuringHold = false;
|
||
g_pendingDrag = false;
|
||
g_pendingResize = false;
|
||
g_pendingTarget = nullptr;
|
||
g_swallowNextLButtonUp = false;
|
||
g_swallowNextRButtonUp = false;
|
||
g_pendingModifierReplayed = false;
|
||
EndInteraction(true, true);
|
||
}
|
||
}
|
||
|
||
static bool IsSuppressedByGameMode()
|
||
{
|
||
// Remote sessions can report fullscreen notification states that are not actual games.
|
||
if (GetSystemMetrics(SM_REMOTESESSION))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return g_doNotActivateOnGameMode && detect_game_mode();
|
||
}
|
||
|
||
static bool IsActivationModifierPressed()
|
||
{
|
||
if (g_ignoreModifier)
|
||
{
|
||
return false;
|
||
}
|
||
if (g_modifierKey == GrabAndMoveModifier::Win)
|
||
return g_winPressed;
|
||
return g_altPressed;
|
||
}
|
||
|
||
enum class GrabAndMoveShortcutAction
|
||
{
|
||
Move,
|
||
Resize,
|
||
};
|
||
|
||
static void TraceShortcutUse(bool successful, GrabAndMoveShortcutAction action, const wchar_t* reason) noexcept
|
||
{
|
||
const wchar_t* actionName = action == GrabAndMoveShortcutAction::Move ? L"move" : L"resize";
|
||
|
||
TraceLoggingWrite(
|
||
g_hProvider,
|
||
"GrabAndMove_ShortcutUse",
|
||
ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance),
|
||
TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE),
|
||
TraceLoggingBoolean(successful, "Successful"),
|
||
TraceLoggingWideString(actionName, "Action"),
|
||
TraceLoggingWideString(reason, "Reason"));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Settings file helpers
|
||
// ---------------------------------------------------------------------------
|
||
static void LoadSettingsFromFile()
|
||
{
|
||
try
|
||
{
|
||
PowerToysSettings::PowerToyValues values = PowerToysSettings::PowerToyValues::load_from_settings_file(L"GrabAndMove");
|
||
|
||
if (auto v = values.get_bool_value(L"shouldAbsorbAlt"))
|
||
{
|
||
g_shouldAbsorbAlt = *v;
|
||
}
|
||
|
||
if (auto v = values.get_bool_value(L"showGeometry"))
|
||
{
|
||
g_showGeometry = *v;
|
||
}
|
||
|
||
if (auto v = values.get_bool_value(L"doNotActivateOnGameMode"))
|
||
{
|
||
g_doNotActivateOnGameMode = *v;
|
||
}
|
||
|
||
if (auto v = values.get_bool_value(L"useAltResize"))
|
||
{
|
||
g_useAltResize = *v;
|
||
}
|
||
|
||
if (auto v = values.get_int_value(L"modifierKey"))
|
||
{
|
||
g_modifierKey = (*v == 1) ? GrabAndMoveModifier::Win : GrabAndMoveModifier::Alt;
|
||
}
|
||
|
||
if (auto v = values.get_string_value(L"excluded_apps"))
|
||
{
|
||
std::vector<std::wstring> apps;
|
||
std::wstring upper = *v;
|
||
CharUpperBuffW(upper.data(), static_cast<DWORD>(upper.length()));
|
||
std::wstring_view view(upper);
|
||
|
||
while (!view.empty())
|
||
{
|
||
// skip leading whitespace / newlines
|
||
auto start = view.find_first_not_of(L" \t\r\n");
|
||
if (start == std::wstring_view::npos)
|
||
break;
|
||
view.remove_prefix(start);
|
||
|
||
auto pos = view.find_first_of(L"\r\n");
|
||
if (pos == std::wstring_view::npos)
|
||
pos = view.length();
|
||
|
||
apps.emplace_back(view.substr(0, pos));
|
||
view.remove_prefix(pos);
|
||
}
|
||
|
||
g_excludedApps.store(
|
||
std::make_shared<const std::vector<std::wstring>>(std::move(apps)));
|
||
// Invalidate the cache on the main thread to avoid a data race –
|
||
// g_excludedCache is an unordered_map accessed from the hook/main thread.
|
||
if (g_hMsgWnd)
|
||
PostMessage(g_hMsgWnd, WM_INVALIDATE_EXCLUDED_CACHE, 0, 0);
|
||
}
|
||
}
|
||
catch (...)
|
||
{
|
||
// Keep defaults on error
|
||
}
|
||
}
|
||
|
||
static void SettingsWatcherThread(DWORD mainThreadId)
|
||
{
|
||
HANDLE events[2] = { g_hReloadSettingsEvent, g_hExitEvent };
|
||
DWORD eventCount = g_hExitEvent ? 2 : 1;
|
||
|
||
while (g_running)
|
||
{
|
||
DWORD wait = WaitForMultipleObjects(eventCount, events, FALSE, 1000);
|
||
if (!g_running)
|
||
break;
|
||
if (wait == WAIT_OBJECT_0)
|
||
{
|
||
LoadSettingsFromFile();
|
||
}
|
||
else if (wait == WAIT_OBJECT_0 + 1)
|
||
{
|
||
// Exit event signaled by the module interface
|
||
PostThreadMessage(mainThreadId, WM_QUIT, 0, 0);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Overlay window helpers – persistent window, shown/hidden per interaction
|
||
// ---------------------------------------------------------------------------
|
||
// Tracks the last rendered overlay dimensions so we can skip re-rendering
|
||
// when only the position changed (move-only path).
|
||
static int g_overlayRenderedW = 0;
|
||
static int g_overlayRenderedH = 0;
|
||
|
||
// Maps the DWM window corner preference to a base radius in DIPs, matching
|
||
// Always On Top (WindowCornerUtils::CornersRadius).
|
||
static int CornerRadiusForWindow(HWND hwnd)
|
||
{
|
||
// Remote sessions draw square windows even on Win11, yet still report DWMWCP_DEFAULT. Match the
|
||
// window: a remote session gets square (radius 0) so the overlay border doesn't round off the corner.
|
||
if (GetSystemMetrics(SM_REMOTESESSION))
|
||
{
|
||
return 0;
|
||
}
|
||
|
||
int pref = 0; // DWMWCP_DEFAULT
|
||
if (DwmGetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &pref, sizeof(pref)) != S_OK)
|
||
{
|
||
return 0; // pre-Win11 / unsupported -> square corners
|
||
}
|
||
|
||
switch (pref)
|
||
{
|
||
case DWMWCP_ROUND:
|
||
return 8;
|
||
case DWMWCP_ROUNDSMALL:
|
||
return 4;
|
||
case DWMWCP_DEFAULT:
|
||
return 8;
|
||
default:
|
||
return 0; // DWMWCP_DONOTROUND
|
||
}
|
||
}
|
||
|
||
// Computes the overlay metrics (margins to the visible frame, corner radius, border
|
||
// thickness) for the target window. Cold path only: called at the start of a
|
||
// drag/resize and after un-maximize, never from the mouse-move hot path.
|
||
static void PrepareOverlayMetrics(HWND target)
|
||
{
|
||
g_overlayMarginL = g_overlayMarginT = g_overlayMarginR = g_overlayMarginB = 0;
|
||
g_overlayCornerRadius = 0;
|
||
g_overlayBorderThickness = OVERLAY_BORDER_DIP;
|
||
|
||
if (!target)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const UINT dpi = GetDpiForWindow(target);
|
||
const float scale = (dpi != 0) ? dpi / 96.0f : 1.0f;
|
||
|
||
RECT windowRect{};
|
||
RECT frameRect{};
|
||
if (GetWindowRect(target, &windowRect) &&
|
||
SUCCEEDED(DwmGetWindowAttribute(target, DWMWA_EXTENDED_FRAME_BOUNDS, &frameRect, sizeof(frameRect))))
|
||
{
|
||
g_overlayMarginL = max(0, static_cast<int>(frameRect.left - windowRect.left));
|
||
g_overlayMarginT = max(0, static_cast<int>(frameRect.top - windowRect.top));
|
||
g_overlayMarginR = max(0, static_cast<int>(windowRect.right - frameRect.right));
|
||
g_overlayMarginB = max(0, static_cast<int>(windowRect.bottom - frameRect.bottom));
|
||
}
|
||
|
||
g_overlayCornerRadius = static_cast<int>(CornerRadiusForWindow(target) * scale);
|
||
g_overlayBorderThickness = static_cast<int>(OVERLAY_BORDER_DIP * scale);
|
||
}
|
||
|
||
// Draws an antialiased (optionally rounded) border stroke fully inside `rect` using
|
||
// GDI+. The stroke hugs the inner edge of `rect` (the visible window frame).
|
||
static void DrawOverlayBorder(Gdiplus::Graphics& graphics, const RECT& rect, int thickness, int radius)
|
||
{
|
||
const int w = rect.right - rect.left;
|
||
const int h = rect.bottom - rect.top;
|
||
if (w <= 0 || h <= 0 || thickness <= 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Keep the whole stroke inside the visible frame on every side.
|
||
thickness = min(thickness, min(w, h) / 2);
|
||
if (thickness <= 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const float half = thickness / 2.0f;
|
||
const Gdiplus::RectF path(
|
||
rect.left + half,
|
||
rect.top + half,
|
||
static_cast<Gdiplus::REAL>(w) - thickness,
|
||
static_cast<Gdiplus::REAL>(h) - thickness);
|
||
|
||
graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
|
||
Gdiplus::Pen pen(
|
||
Gdiplus::Color(255, GetRValue(OVERLAY_BORDER_COLOR), GetGValue(OVERLAY_BORDER_COLOR), GetBValue(OVERLAY_BORDER_COLOR)),
|
||
static_cast<Gdiplus::REAL>(thickness));
|
||
|
||
if (radius <= 0)
|
||
{
|
||
graphics.DrawRectangle(&pen, path);
|
||
return;
|
||
}
|
||
|
||
// The stroke is centred, so the path corner radius is the window radius minus
|
||
// half the thickness; that keeps the outer edge aligned with the window corner.
|
||
const float pathRadius = max(0.0f, radius - half);
|
||
const float diameter = min(pathRadius * 2.0f, min(path.Width, path.Height));
|
||
if (diameter <= 0.0f)
|
||
{
|
||
graphics.DrawRectangle(&pen, path);
|
||
return;
|
||
}
|
||
|
||
Gdiplus::GraphicsPath border;
|
||
border.AddArc(path.X, path.Y, diameter, diameter, 180.0f, 90.0f);
|
||
border.AddArc(path.GetRight() - diameter, path.Y, diameter, diameter, 270.0f, 90.0f);
|
||
border.AddArc(path.GetRight() - diameter, path.GetBottom() - diameter, diameter, diameter, 0.0f, 90.0f);
|
||
border.AddArc(path.X, path.GetBottom() - diameter, diameter, diameter, 90.0f, 90.0f);
|
||
border.CloseFigure();
|
||
graphics.DrawPath(&pen, &border);
|
||
}
|
||
|
||
// Renders the overlay surface using per-pixel alpha via UpdateLayeredWindow.
|
||
// A translucent white wash covers the visible window (matching the prior overlay)
|
||
// with a tight warning-gold border on top, both hugging the visible window frame;
|
||
// the optional geometry label box is painted fully opaque so it remains legible
|
||
// regardless of what is beneath.
|
||
static void RenderOverlayContent(HWND hwnd, int cw, int ch)
|
||
{
|
||
if (!hwnd || cw <= 0 || ch <= 0)
|
||
return;
|
||
|
||
BITMAPINFO bmi = {};
|
||
bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||
bmi.bmiHeader.biWidth = cw;
|
||
bmi.bmiHeader.biHeight = -ch; // top-down so (0,0) is top-left
|
||
bmi.bmiHeader.biPlanes = 1;
|
||
bmi.bmiHeader.biBitCount = 32;
|
||
bmi.bmiHeader.biCompression = BI_RGB;
|
||
|
||
HDC screenDC = GetDC(nullptr);
|
||
DWORD* pBits = nullptr;
|
||
HBITMAP hDib = CreateDIBSection(screenDC, &bmi, DIB_RGB_COLORS,
|
||
reinterpret_cast<void**>(&pBits), nullptr, 0);
|
||
if (!hDib)
|
||
{
|
||
ReleaseDC(nullptr, screenDC);
|
||
return;
|
||
}
|
||
|
||
HDC memDC = CreateCompatibleDC(screenDC);
|
||
HBITMAP hOldBmp = static_cast<HBITMAP>(SelectObject(memDC, hDib));
|
||
|
||
// Start fully transparent.
|
||
memset(pBits, 0, static_cast<size_t>(cw) * ch * sizeof(DWORD));
|
||
|
||
// We apply a translucent white rect with a gold border.
|
||
// The overlay window spans GetWindowRect, so inset by
|
||
// the invisible-border margins so both hug the visible edge; Always On Top draws
|
||
// its own border just outside that edge, giving a clean double layer.
|
||
{
|
||
const RECT visible = {
|
||
g_overlayMarginL,
|
||
g_overlayMarginT,
|
||
cw - g_overlayMarginR,
|
||
ch - g_overlayMarginB
|
||
};
|
||
const int vw = visible.right - visible.left;
|
||
const int vh = visible.bottom - visible.top;
|
||
|
||
Gdiplus::Bitmap bitmap(cw, ch, cw * 4, PixelFormat32bppPARGB, reinterpret_cast<BYTE*>(pBits));
|
||
Gdiplus::Graphics graphics(&bitmap);
|
||
graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
|
||
|
||
if (vw > 0 && vh > 0)
|
||
{
|
||
Gdiplus::SolidBrush fillBrush(Gdiplus::Color(OVERLAY_FILL_ALPHA, 255, 255, 255));
|
||
if (g_overlayCornerRadius > 0)
|
||
{
|
||
// Round the wash to match the window corners (and the border).
|
||
const float d = min(static_cast<float>(g_overlayCornerRadius) * 2.0f,
|
||
static_cast<float>(min(vw, vh)));
|
||
Gdiplus::GraphicsPath fillPath;
|
||
fillPath.AddArc(static_cast<float>(visible.left), static_cast<float>(visible.top), d, d, 180.0f, 90.0f);
|
||
fillPath.AddArc(static_cast<float>(visible.right) - d, static_cast<float>(visible.top), d, d, 270.0f, 90.0f);
|
||
fillPath.AddArc(static_cast<float>(visible.right) - d, static_cast<float>(visible.bottom) - d, d, d, 0.0f, 90.0f);
|
||
fillPath.AddArc(static_cast<float>(visible.left), static_cast<float>(visible.bottom) - d, d, d, 90.0f, 90.0f);
|
||
fillPath.CloseFigure();
|
||
graphics.FillPath(&fillBrush, &fillPath);
|
||
}
|
||
else
|
||
{
|
||
graphics.FillRectangle(&fillBrush, visible.left, visible.top, vw, vh);
|
||
}
|
||
}
|
||
|
||
DrawOverlayBorder(graphics, visible, g_overlayBorderThickness, g_overlayCornerRadius);
|
||
graphics.Flush();
|
||
}
|
||
|
||
if (g_showGeometry)
|
||
{
|
||
wchar_t text[128];
|
||
swprintf_s(text, L"X: %d Y: %d\nW: %d H: %d",
|
||
g_overlayInfoX, g_overlayInfoY, g_overlayInfoW, g_overlayInfoH);
|
||
|
||
HFONT hFont = static_cast<HFONT>(GetStockObject(DEFAULT_GUI_FONT));
|
||
HFONT hOldFont = static_cast<HFONT>(SelectObject(memDC, hFont));
|
||
|
||
RECT textRect = {};
|
||
DrawTextW(memDC, text, -1, &textRect, DT_CALCRECT | DT_CENTER | DT_NOPREFIX);
|
||
|
||
const int pad = 10;
|
||
const int boxW = (textRect.right - textRect.left) + pad * 2;
|
||
const int boxH = (textRect.bottom - textRect.top) + pad * 2;
|
||
RECT boxRect = {cw / 2 - boxW / 2, ch / 2 - boxH / 2,
|
||
cw / 2 + boxW / 2, ch / 2 + boxH / 2};
|
||
|
||
HBRUSH hBlack = CreateSolidBrush(RGB(0, 0, 0));
|
||
FillRect(memDC, &boxRect, hBlack);
|
||
DeleteObject(hBlack);
|
||
|
||
RECT textDrawRect = {boxRect.left + pad, boxRect.top + pad,
|
||
boxRect.right - pad, boxRect.bottom - pad};
|
||
SetTextColor(memDC, RGB(255, 255, 255));
|
||
SetBkMode(memDC, TRANSPARENT);
|
||
DrawTextW(memDC, text, -1, &textDrawRect, DT_CENTER | DT_NOPREFIX);
|
||
SelectObject(memDC, hOldFont);
|
||
|
||
// GDI zeroes the alpha byte for every pixel it touches in a 32bpp DIB.
|
||
// Walk the box region and force A=255 so those pixels are fully opaque.
|
||
// With A=255 premultiplied alpha equals straight RGB, so the colours GDI
|
||
// wrote (black fill, white text, anti-aliased edges) are correct as-is.
|
||
const int x0 = max(0, boxRect.left);
|
||
const int y0 = max(0, boxRect.top);
|
||
const int x1 = min(cw, boxRect.right);
|
||
const int y1 = min(ch, boxRect.bottom);
|
||
for (int y = y0; y < y1; ++y)
|
||
for (int x = x0; x < x1; ++x)
|
||
pBits[y * cw + x] |= 0xFF000000u;
|
||
}
|
||
|
||
SIZE sz = {cw, ch};
|
||
POINT ptSrc = {0, 0};
|
||
BLENDFUNCTION blend = {AC_SRC_OVER, 0, 255, AC_SRC_ALPHA};
|
||
UpdateLayeredWindow(hwnd, screenDC, nullptr, &sz, memDC, &ptSrc, 0, &blend, ULW_ALPHA);
|
||
|
||
SelectObject(memDC, hOldBmp);
|
||
DeleteObject(hDib);
|
||
DeleteDC(memDC);
|
||
ReleaseDC(nullptr, screenDC);
|
||
|
||
g_overlayRenderedW = cw;
|
||
g_overlayRenderedH = ch;
|
||
}
|
||
|
||
// Ensures the persistent overlay window exists (created once, reused).
|
||
static void EnsureOverlayWindow()
|
||
{
|
||
if (g_hOverlay)
|
||
return;
|
||
|
||
g_hOverlay = CreateWindowExW(
|
||
WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
|
||
OVERLAY_CLASS_NAME,
|
||
nullptr,
|
||
WS_POPUP, // initially hidden (no WS_VISIBLE)
|
||
0, 0, 1, 1,
|
||
nullptr,
|
||
nullptr,
|
||
g_hInstance,
|
||
nullptr);
|
||
}
|
||
|
||
static void ShowOverlay(const RECT& rc, HCURSOR hCursor)
|
||
{
|
||
EnsureOverlayWindow();
|
||
if (!g_hOverlay)
|
||
return;
|
||
|
||
int w = rc.right - rc.left;
|
||
int h = rc.bottom - rc.top;
|
||
|
||
SetClassLongPtrW(g_hOverlay, GCLP_HCURSOR, reinterpret_cast<LONG_PTR>(hCursor));
|
||
|
||
g_overlayInfoX = rc.left;
|
||
g_overlayInfoY = rc.top;
|
||
g_overlayInfoW = w;
|
||
g_overlayInfoH = h;
|
||
g_overlayRenderedW = 0; // force re-render
|
||
g_overlayRenderedH = 0;
|
||
|
||
SetWindowPos(g_hOverlay, HWND_TOPMOST, rc.left, rc.top, w, h,
|
||
SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||
RenderOverlayContent(g_hOverlay, w, h);
|
||
}
|
||
|
||
// Repositions (and optionally re-renders) the overlay.
|
||
// For move-only (size unchanged), skips the expensive RenderOverlayContent.
|
||
// For resize (size changed), always re-renders so the layered surface matches.
|
||
static void RepositionOverlay(int x, int y, int w, int h)
|
||
{
|
||
if (!g_hOverlay)
|
||
return;
|
||
|
||
g_overlayInfoX = x;
|
||
g_overlayInfoY = y;
|
||
g_overlayInfoW = w;
|
||
g_overlayInfoH = h;
|
||
|
||
SetWindowPos(g_hOverlay, HWND_TOPMOST, x, y, w, h,
|
||
SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||
|
||
// Re-render only when the size changed or geometry text needs updating
|
||
bool sizeChanged = (w != g_overlayRenderedW || h != g_overlayRenderedH);
|
||
if (sizeChanged || g_showGeometry)
|
||
{
|
||
RenderOverlayContent(g_hOverlay, w, h);
|
||
}
|
||
}
|
||
|
||
static void HideOverlay()
|
||
{
|
||
if (g_hOverlay)
|
||
{
|
||
ShowWindow(g_hOverlay, SW_HIDE);
|
||
}
|
||
}
|
||
|
||
static void StopDragging()
|
||
{
|
||
g_dragging = false;
|
||
g_dragFirstMove = false;
|
||
g_dragTarget = nullptr;
|
||
HideOverlay();
|
||
}
|
||
|
||
static ResizeHandle GetClosestHandle(POINT pt, const RECT& rc)
|
||
{
|
||
int cx = (rc.left + rc.right) / 2;
|
||
int cy = (rc.top + rc.bottom) / 2;
|
||
|
||
struct
|
||
{
|
||
int x;
|
||
int y;
|
||
ResizeHandle h;
|
||
} handles[] = {
|
||
{rc.left, rc.top, RESIZE_TOP_LEFT},
|
||
{cx, rc.top, RESIZE_TOP},
|
||
{rc.right, rc.top, RESIZE_TOP_RIGHT},
|
||
{rc.right, cy, RESIZE_RIGHT},
|
||
{rc.right, rc.bottom, RESIZE_BOTTOM_RIGHT},
|
||
{cx, rc.bottom, RESIZE_BOTTOM},
|
||
{rc.left, rc.bottom, RESIZE_BOTTOM_LEFT},
|
||
{rc.left, cy, RESIZE_LEFT},
|
||
};
|
||
|
||
ResizeHandle closest = RESIZE_BOTTOM_RIGHT;
|
||
LONG minDist = (std::numeric_limits<LONG>::max)();
|
||
for (auto& e : handles)
|
||
{
|
||
LONG dx = pt.x - e.x;
|
||
LONG dy = pt.y - e.y;
|
||
LONG dist = dx * dx + dy * dy;
|
||
if (dist < minDist)
|
||
{
|
||
minDist = dist;
|
||
closest = e.h;
|
||
}
|
||
}
|
||
return closest;
|
||
}
|
||
|
||
static HCURSOR CursorForHandle(ResizeHandle handle)
|
||
{
|
||
switch (handle)
|
||
{
|
||
case RESIZE_TOP_LEFT:
|
||
case RESIZE_BOTTOM_RIGHT:
|
||
return g_curSizeNWSE;
|
||
case RESIZE_TOP_RIGHT:
|
||
case RESIZE_BOTTOM_LEFT:
|
||
return g_curSizeNESW;
|
||
case RESIZE_TOP:
|
||
case RESIZE_BOTTOM:
|
||
return g_curSizeNS;
|
||
case RESIZE_LEFT:
|
||
case RESIZE_RIGHT:
|
||
return g_curSizeWE;
|
||
default:
|
||
return g_curSizeAll;
|
||
}
|
||
}
|
||
|
||
static void StopResizing()
|
||
{
|
||
g_resizing = false;
|
||
g_resizeFirstMove = false;
|
||
g_resizeTarget = nullptr;
|
||
g_currentHandle = RESIZE_NONE;
|
||
HideOverlay();
|
||
}
|
||
|
||
static void ReplayAbsorbedModifier(bool alsoKeyUp)
|
||
{
|
||
INPUT inputs[2] = {};
|
||
inputs[0].type = INPUT_KEYBOARD;
|
||
inputs[0].ki.wVk = static_cast<WORD>(g_absorbedVk);
|
||
inputs[0].ki.wScan = static_cast<WORD>(g_absorbedScanCode);
|
||
inputs[0].ki.dwFlags = (g_absorbedFlags & LLKHF_EXTENDED) ? KEYEVENTF_EXTENDEDKEY : 0;
|
||
inputs[1] = inputs[0];
|
||
inputs[1].ki.dwFlags |= KEYEVENTF_KEYUP;
|
||
SendInput(alsoKeyUp ? 2 : 1, inputs, sizeof(INPUT));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Deferred activation helpers.
|
||
// A modifier+button press does not start an interaction immediately; it is held
|
||
// "pending" until the cursor moves past the drag threshold (then it becomes a
|
||
// real drag/resize) or the button is released first (then the absorbed input is
|
||
// replayed so the target application receives a normal modifier+click).
|
||
// ---------------------------------------------------------------------------
|
||
static void BeginDrag(HWND hwnd, POINT pt)
|
||
{
|
||
g_dragging = true;
|
||
g_dragFirstMove = true;
|
||
g_dragConsumedAlt = true;
|
||
g_activatedDuringHold = true;
|
||
g_dragTarget = hwnd;
|
||
g_dragStart = pt;
|
||
GetWindowRect(hwnd, &g_dragWndRect);
|
||
PrepareOverlayMetrics(hwnd);
|
||
ShowOverlay(g_dragWndRect, g_curSizeAll);
|
||
TraceShortcutUse(true, GrabAndMoveShortcutAction::Move, L"started");
|
||
}
|
||
|
||
static void BeginResize(HWND hwnd, POINT pt)
|
||
{
|
||
g_resizing = true;
|
||
g_resizeFirstMove = true;
|
||
g_dragConsumedAlt = true;
|
||
g_activatedDuringHold = true;
|
||
g_resizeTarget = hwnd;
|
||
g_resizeLast = pt;
|
||
GetWindowRect(hwnd, &g_resizeWndRect);
|
||
PrepareOverlayMetrics(hwnd);
|
||
g_currentHandle = GetClosestHandle(pt, g_resizeWndRect);
|
||
ShowOverlay(g_resizeWndRect, CursorForHandle(g_currentHandle));
|
||
TraceShortcutUse(true, GrabAndMoveShortcutAction::Resize, L"started");
|
||
}
|
||
|
||
// Replays a modifier+click to the target: first the absorbed modifier key-down
|
||
// (if it was swallowed), then the button click. The modifier key-up is not
|
||
// synthesized here - the real key-up still reaches the target when the user
|
||
// releases the modifier. The absorbed-modifier flags are intentionally left set so
|
||
// the keyboard hook forwards that real key-up; g_pendingModifierReplayed guards
|
||
// against replaying the key-down more than once (e.g. across repeated clicks).
|
||
static void ReplayPendingClick(bool isRight)
|
||
{
|
||
if ((g_altAbsorbed || g_winAbsorbed) && !g_pendingModifierReplayed)
|
||
{
|
||
g_pendingModifierReplayed = true;
|
||
ReplayAbsorbedModifier(false); // modifier down only
|
||
}
|
||
|
||
INPUT inputs[2] = {};
|
||
inputs[0].type = INPUT_MOUSE;
|
||
inputs[0].mi.dwFlags = isRight ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_LEFTDOWN;
|
||
inputs[1].type = INPUT_MOUSE;
|
||
inputs[1].mi.dwFlags = isRight ? MOUSEEVENTF_RIGHTUP : MOUSEEVENTF_LEFTUP;
|
||
SendInput(2, inputs, sizeof(INPUT));
|
||
}
|
||
|
||
// Called when the modifier is released while a press is still pending (button
|
||
// held, no move yet). Delivers the click to the target now and swallows the
|
||
// matching physical button-up that will arrive later.
|
||
static void FlushPendingClickOnModifierRelease()
|
||
{
|
||
if (g_pendingDrag)
|
||
{
|
||
g_pendingDrag = false;
|
||
g_pendingTarget = nullptr;
|
||
ReplayPendingClick(false);
|
||
g_swallowNextLButtonUp = true;
|
||
}
|
||
else if (g_pendingResize)
|
||
{
|
||
g_pendingResize = false;
|
||
g_pendingTarget = nullptr;
|
||
ReplayPendingClick(true);
|
||
g_swallowNextRButtonUp = true;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// System tray helpers
|
||
// ---------------------------------------------------------------------------
|
||
static NOTIFYICONDATAW g_nid = {};
|
||
|
||
static void AddTrayIcon(HWND hwnd)
|
||
{
|
||
g_nid.cbSize = sizeof(NOTIFYICONDATAW);
|
||
g_nid.hWnd = hwnd;
|
||
g_nid.uID = 1;
|
||
g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
||
g_nid.uCallbackMessage = WM_TRAY_ICON;
|
||
g_nid.hIcon = LoadIconW(g_hInstance, MAKEINTRESOURCEW(IDI_APP_ICON));
|
||
wcscpy_s(g_nid.szTip, APP_TITLE);
|
||
Shell_NotifyIconW(NIM_ADD, &g_nid);
|
||
}
|
||
|
||
static void RemoveTrayIcon()
|
||
{
|
||
Shell_NotifyIconW(NIM_DELETE, &g_nid);
|
||
}
|
||
|
||
static void ShowTrayMenu(HWND hwnd)
|
||
{
|
||
HMENU hMenu = CreatePopupMenu();
|
||
if (!hMenu)
|
||
return;
|
||
|
||
AppendMenuW(hMenu, MF_STRING, IDM_EXIT, L"Exit");
|
||
|
||
POINT pt;
|
||
GetCursorPos(&pt);
|
||
|
||
// Required so the menu dismisses when clicking outside it
|
||
SetForegroundWindow(hwnd);
|
||
TrackPopupMenu(hMenu, TPM_RIGHTBUTTON, pt.x, pt.y, 0, hwnd, nullptr);
|
||
PostMessageW(hwnd, WM_NULL, 0, 0);
|
||
|
||
DestroyMenu(hMenu);
|
||
}
|
||
|
||
static bool IsSystemClass(HWND hwnd)
|
||
{
|
||
wchar_t cls[256] = {};
|
||
GetClassNameW(hwnd, cls, ARRAYSIZE(cls));
|
||
|
||
// Desktop and primary/secondary taskbars
|
||
if (wcscmp(cls, L"Progman") == 0 ||
|
||
wcscmp(cls, L"Shell_TrayWnd") == 0 ||
|
||
wcscmp(cls, L"Shell_SecondaryTrayWnd") == 0)
|
||
return true;
|
||
|
||
// System tray / notification area popups and overflow
|
||
if (wcscmp(cls, L"NotifyIconOverflowWindow") == 0 ||
|
||
wcscmp(cls, L"TopLevelWindowForOverflowXamlIsland") == 0)
|
||
return true;
|
||
|
||
// Tooltips (e.g. "Show hidden icons" tooltip)
|
||
if (wcscmp(cls, L"tooltips_class32") == 0)
|
||
return true;
|
||
|
||
// Task View (Win+Tab)
|
||
if (wcscmp(cls, L"MultitaskingViewFrame") == 0 ||
|
||
wcscmp(cls, L"XamlExplorerHostIslandWindow") == 0)
|
||
return true;
|
||
|
||
// System tray flyouts (Quick Settings, calendar, input switcher)
|
||
if (wcscmp(cls, L"Windows.UI.Composition.DesktopWindowContentBridge") == 0 ||
|
||
wcscmp(cls, L"Shell_InputSwitchTopLevelWindow") == 0)
|
||
return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
|
||
std::wstring ToUpperInvariant(std::wstring_view input)
|
||
{
|
||
int required = LCMapStringEx(
|
||
LOCALE_NAME_INVARIANT,
|
||
LCMAP_UPPERCASE,
|
||
input.data(),
|
||
static_cast<int>(input.size()),
|
||
nullptr,
|
||
0,
|
||
nullptr,
|
||
nullptr,
|
||
0);
|
||
|
||
std::wstring result(required, L'\0');
|
||
|
||
LCMapStringEx(
|
||
LOCALE_NAME_INVARIANT,
|
||
LCMAP_UPPERCASE,
|
||
input.data(),
|
||
static_cast<int>(input.size()),
|
||
result.data(),
|
||
required,
|
||
nullptr,
|
||
nullptr,
|
||
0);
|
||
|
||
return result;
|
||
}
|
||
|
||
static bool IsExcluded(HWND hwnd)
|
||
{
|
||
if (IsSystemClass(hwnd))
|
||
return true;
|
||
|
||
// To identify these for adding a new exception:
|
||
// 1. Resolve the hwnd class name.
|
||
// 2. Resolve the process path.
|
||
// 3. Add OutputDebugStringW() for the class name and process path.
|
||
// 4. Build the executable.
|
||
// 5. Check with the debugger (or with Sysinternals DebugView) the outputs.
|
||
// 6. Delete the added code.
|
||
// 7. Add the exception below, according to the pattern there.
|
||
//
|
||
// Shell experience windows: Start menu, Notifications (Win+N), Search,
|
||
// Quick Settings (volume / network / battery).
|
||
// These use the generic Windows.UI.Core.CoreWindow class, so filter by process.
|
||
{
|
||
wchar_t cls[256] = {};
|
||
GetClassNameW(hwnd, cls, ARRAYSIZE(cls));
|
||
if (wcscmp(cls, L"Windows.UI.Core.CoreWindow") == 0)
|
||
{
|
||
std::wstring processPath = ToUpperInvariant(get_process_path(hwnd));
|
||
if (processPath.find(L"STARTMENUEXPERIENCEHOST.EXE") != std::wstring::npos ||
|
||
processPath.find(L"SHELLEXPERIENCEHOST.EXE") != std::wstring::npos ||
|
||
processPath.find(L"SEARCHHOST.EXE") != std::wstring::npos)
|
||
return true;
|
||
}
|
||
else if (wcscmp(cls, L"ControlCenterWindow") == 0)
|
||
{
|
||
// The Quick Settings flyout.
|
||
std::wstring processPath = ToUpperInvariant(get_process_path(hwnd));
|
||
if (processPath.find(L"SHELLHOST.EXE") != std::wstring::npos)
|
||
return true;
|
||
}
|
||
else if (wcscmp(cls, L"WindowsDashboard") == 0)
|
||
{
|
||
// The Windows 11 Widgets flyout.
|
||
std::wstring processPath = ToUpperInvariant(get_process_path(hwnd));
|
||
if (processPath.find(L"WIDGETBOARD.EXE") != std::wstring::npos)
|
||
return true;
|
||
}
|
||
}
|
||
|
||
auto apps = g_excludedApps.load();
|
||
if (!apps || apps->empty())
|
||
return false;
|
||
|
||
// Check process-path exclusion (cached per HWND – fix #4)
|
||
bool pathExcluded = false;
|
||
auto it = g_excludedCache.find(hwnd);
|
||
if (it != g_excludedCache.end())
|
||
{
|
||
pathExcluded = it->second;
|
||
}
|
||
else
|
||
{
|
||
std::wstring processPath = get_process_path(hwnd);
|
||
CharUpperBuffW(processPath.data(), static_cast<DWORD>(processPath.length()));
|
||
pathExcluded = find_app_name_in_path(processPath, *apps);
|
||
g_excludedCache[hwnd] = pathExcluded;
|
||
}
|
||
|
||
if (pathExcluded)
|
||
return true;
|
||
|
||
// Title-based exclusion is always evaluated live (titles can change)
|
||
if (check_excluded_app_with_title(hwnd, *apps))
|
||
return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Hook callbacks
|
||
// ---------------------------------------------------------------------------
|
||
static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||
{
|
||
if (nCode >= 0)
|
||
{
|
||
auto* kb = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam);
|
||
|
||
// Ignore injected events (includes our own replayed keys)
|
||
if (kb->flags & LLKHF_INJECTED)
|
||
goto forward;
|
||
|
||
// Skip processing if the foreground window is excluded
|
||
{
|
||
HWND fg = GetForegroundWindow();
|
||
if (fg && IsExcluded(fg))
|
||
goto forward;
|
||
}
|
||
|
||
bool isAltKey = (kb->vkCode == VK_MENU || kb->vkCode == VK_LMENU || kb->vkCode == VK_RMENU);
|
||
bool isWinKey = (kb->vkCode == VK_LWIN || kb->vkCode == VK_RWIN);
|
||
|
||
// ----- Win key modifier handling -----
|
||
if (isWinKey && g_modifierKey == GrabAndMoveModifier::Win)
|
||
{
|
||
if (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN)
|
||
{
|
||
if (IsSuppressedByGameMode())
|
||
goto forward;
|
||
// Suppress if Ctrl, Alt, Shift, or any regular key is already held
|
||
if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) ||
|
||
(GetAsyncKeyState(VK_MENU) & 0x8000) ||
|
||
(GetAsyncKeyState(VK_SHIFT) & 0x8000) ||
|
||
g_heldNonAltKeyCount > 0)
|
||
{
|
||
goto forward;
|
||
}
|
||
g_winPressed = true;
|
||
if (!g_winAbsorbed)
|
||
{
|
||
g_winAbsorbed = true;
|
||
g_dragConsumedAlt = false;
|
||
g_pendingModifierReplayed = false;
|
||
g_absorbedVk = kb->vkCode;
|
||
g_absorbedScanCode = kb->scanCode;
|
||
g_absorbedFlags = kb->flags;
|
||
}
|
||
return 1; // swallow Win keydown
|
||
}
|
||
else if (wParam == WM_KEYUP || wParam == WM_SYSKEYUP)
|
||
{
|
||
g_winPressed = false;
|
||
g_ignoreModifier = false;
|
||
g_activatedDuringHold = false;
|
||
// If a press was still pending (button held, no move), deliver it now.
|
||
FlushPendingClickOnModifierRelease();
|
||
bool wasDragging = g_dragging;
|
||
bool wasResizing = g_resizing;
|
||
EndInteraction(true, true);
|
||
if (g_winAbsorbed)
|
||
{
|
||
g_winAbsorbed = false;
|
||
if (wasDragging || wasResizing || g_dragConsumedAlt)
|
||
{
|
||
// Win was used for a drag/resize; swallow the keyup too
|
||
g_dragConsumedAlt = false;
|
||
return 1;
|
||
}
|
||
if (g_pendingModifierReplayed)
|
||
{
|
||
// The key-down was already replayed for a pending click; let the
|
||
// real key-up reach the target (falls through to goto forward).
|
||
g_pendingModifierReplayed = false;
|
||
}
|
||
else
|
||
{
|
||
// No drag happened; replay the keydown, THEN the keyup
|
||
ReplayAbsorbedModifier(true);
|
||
return 1; // swallow this keyup since the replay already sent one
|
||
}
|
||
}
|
||
}
|
||
goto forward; // let Win keyup pass through
|
||
}
|
||
|
||
if (g_modifierKey == GrabAndMoveModifier::Alt)
|
||
{
|
||
if (isAltKey)
|
||
{
|
||
if (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN)
|
||
{
|
||
if (IsSuppressedByGameMode())
|
||
{
|
||
goto forward;
|
||
}
|
||
// If any other modifier is already held (e.g. Ctrl in Ctrl+Alt+Del),
|
||
// or any regular key is currently held (e.g. Q held before Alt),
|
||
// don't intercept this Alt press – let it reach the target application.
|
||
if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) ||
|
||
(GetAsyncKeyState(VK_SHIFT) & 0x8000) ||
|
||
(GetAsyncKeyState(VK_LWIN) & 0x8000) ||
|
||
(GetAsyncKeyState(VK_RWIN) & 0x8000) ||
|
||
g_heldNonAltKeyCount > 0)
|
||
{
|
||
goto forward;
|
||
}
|
||
g_altPressed = true;
|
||
if (g_shouldAbsorbAlt)
|
||
{
|
||
if (!g_altAbsorbed)
|
||
{
|
||
g_altAbsorbed = true;
|
||
g_dragConsumedAlt = false;
|
||
g_pendingModifierReplayed = false;
|
||
g_absorbedVk = kb->vkCode;
|
||
g_absorbedScanCode = kb->scanCode;
|
||
g_absorbedFlags = kb->flags;
|
||
}
|
||
return 1; // swallow the Alt keydown
|
||
}
|
||
}
|
||
else if (wParam == WM_KEYUP || wParam == WM_SYSKEYUP)
|
||
{
|
||
g_altPressed = false;
|
||
g_ignoreModifier = false;
|
||
g_activatedDuringHold = false;
|
||
// If a press was still pending (button held, no move), deliver it now.
|
||
FlushPendingClickOnModifierRelease();
|
||
bool wasDragging = g_dragging;
|
||
bool wasResizing = g_resizing;
|
||
EndInteraction(true, true);
|
||
if (g_altAbsorbed)
|
||
{
|
||
g_altAbsorbed = false;
|
||
if (wasDragging || wasResizing || g_dragConsumedAlt)
|
||
{
|
||
// Alt was used for a drag/resize; swallow the keyup too
|
||
g_dragConsumedAlt = false;
|
||
return 1;
|
||
}
|
||
if (g_pendingModifierReplayed)
|
||
{
|
||
// The key-down was already replayed for a pending click; let
|
||
// the real key-up reach the target.
|
||
g_pendingModifierReplayed = false;
|
||
}
|
||
else
|
||
{
|
||
// No drag happened; replay the keydown, then let keyup through
|
||
ReplayAbsorbedModifier(false);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Non-Alt key while Alt was absorbed without a drag: replay Alt first
|
||
if (g_altAbsorbed && !g_dragConsumedAlt && !g_pendingModifierReplayed)
|
||
{
|
||
g_altAbsorbed = false;
|
||
g_altPressed = false;
|
||
ReplayAbsorbedModifier(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Non-Win key while Win was absorbed without a drag: replay Win first
|
||
if (g_winAbsorbed && !g_dragConsumedAlt && !isWinKey && !g_pendingModifierReplayed)
|
||
{
|
||
g_winAbsorbed = false;
|
||
g_winPressed = false;
|
||
ReplayAbsorbedModifier(false);
|
||
}
|
||
|
||
// Track held non-modifier keys (used to suppress GrabAndMove when the modifier
|
||
// is pressed while another key is already down). Applies to both Alt and Win modes.
|
||
// Use per-vkCode tracking to detect initial press vs auto-repeat, since
|
||
// KBDLLHOOKSTRUCT::flags does not carry the KF_REPEAT bit.
|
||
if (!isAltKey && !isWinKey)
|
||
{
|
||
BYTE vk = static_cast<BYTE>(kb->vkCode);
|
||
if (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN)
|
||
{
|
||
if (!g_keyHeld[vk])
|
||
{
|
||
g_keyHeld[vk] = true;
|
||
++g_heldNonAltKeyCount;
|
||
}
|
||
}
|
||
else if (wParam == WM_KEYUP || wParam == WM_SYSKEYUP)
|
||
{
|
||
if (g_keyHeld[vk])
|
||
{
|
||
g_keyHeld[vk] = false;
|
||
if (g_heldNonAltKeyCount > 0)
|
||
--g_heldNonAltKeyCount;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
forward:
|
||
return CallNextHookEx(g_hhkKeyboard, nCode, wParam, lParam);
|
||
}
|
||
|
||
static HWND ResolveTargetWindow(POINT pt)
|
||
{
|
||
auto normalizeTarget = [](HWND candidate) -> HWND {
|
||
if (!candidate)
|
||
{
|
||
return nullptr;
|
||
}
|
||
|
||
if (HWND root = GetAncestor(candidate, GA_ROOT))
|
||
{
|
||
candidate = root;
|
||
}
|
||
|
||
if (!candidate || candidate == g_hOverlay || candidate == g_hMsgWnd || IsSystemClass(candidate))
|
||
{
|
||
return nullptr;
|
||
}
|
||
|
||
return candidate;
|
||
};
|
||
|
||
// Remote sessions can produce unstable hit-testing for topmost windows.
|
||
// Prefer the actual foreground top-level window there.
|
||
if (GetSystemMetrics(SM_REMOTESESSION))
|
||
{
|
||
if (HWND foreground = normalizeTarget(GetForegroundWindow()))
|
||
{
|
||
return foreground;
|
||
}
|
||
}
|
||
|
||
HWND hwnd = WindowFromPoint(pt);
|
||
hwnd = normalizeTarget(hwnd);
|
||
|
||
if (!hwnd)
|
||
{
|
||
HWND foreground = GetForegroundWindow();
|
||
if (HWND normalizedForeground = normalizeTarget(foreground))
|
||
{
|
||
RECT foregroundRect{};
|
||
if (GetWindowRect(normalizedForeground, &foregroundRect) && PtInRect(&foregroundRect, pt))
|
||
{
|
||
hwnd = normalizedForeground;
|
||
}
|
||
}
|
||
}
|
||
|
||
return hwnd;
|
||
}
|
||
|
||
// Forward declarations for helpers called from MouseProc
|
||
static void HandleDragMove(POINT pt);
|
||
static void HandleDragResize(POINT pt);
|
||
|
||
static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||
{
|
||
if (nCode >= 0)
|
||
{
|
||
auto* ms = reinterpret_cast<MSLLHOOKSTRUCT*>(lParam);
|
||
|
||
// Ignore injected events to avoid feedback loops
|
||
if (ms->flags & LLMHF_INJECTED)
|
||
goto forward;
|
||
|
||
// Deliver-as-click bookkeeping: swallow the physical button-up that matches a
|
||
// press we already replayed to the target on modifier release.
|
||
if (wParam == WM_LBUTTONUP && g_swallowNextLButtonUp)
|
||
{
|
||
g_swallowNextLButtonUp = false;
|
||
return 1;
|
||
}
|
||
if (wParam == WM_RBUTTONUP && g_swallowNextRButtonUp)
|
||
{
|
||
g_swallowNextRButtonUp = false;
|
||
return 1;
|
||
}
|
||
|
||
// Recovery path: if a non-modifier click occurs while stale drag/resize/pending state exists, clear it.
|
||
if ((wParam == WM_LBUTTONDOWN || wParam == WM_RBUTTONDOWN) && !IsActivationModifierPressed())
|
||
{
|
||
g_pendingDrag = false;
|
||
g_pendingResize = false;
|
||
g_pendingTarget = nullptr;
|
||
g_pendingModifierReplayed = false;
|
||
EndInteraction(true, true);
|
||
}
|
||
|
||
if (!g_dragging && !g_resizing && g_hOverlay && IsWindowVisible(g_hOverlay))
|
||
{
|
||
HideOverlay();
|
||
}
|
||
|
||
// ----- Modifier + Left Button Down: arm a pending drag (deferred until move) -----
|
||
if (wParam == WM_LBUTTONDOWN && IsActivationModifierPressed())
|
||
{
|
||
if (IsSuppressedByGameMode())
|
||
{
|
||
TraceShortcutUse(false, GrabAndMoveShortcutAction::Move, L"game_mode");
|
||
goto forward;
|
||
}
|
||
POINT pt = ms->pt;
|
||
HWND hwnd = ResolveTargetWindow(pt);
|
||
if (hwnd)
|
||
{
|
||
// Don't drag excluded windows (desktop, taskbar, user-excluded apps)
|
||
if (IsExcluded(hwnd))
|
||
{
|
||
goto forward;
|
||
}
|
||
|
||
// Already committed to Grab and Move this hold: start dragging immediately.
|
||
if (g_activatedDuringHold)
|
||
{
|
||
BeginDrag(hwnd, pt);
|
||
return 1; // swallow the press; the drag is now active
|
||
}
|
||
|
||
// A press is already pending (the other button is still held): don't
|
||
// overwrite it. Forward this one so we never strand the already-swallowed
|
||
// button-down (which would leak an unmatched button-up to the target).
|
||
if (g_pendingDrag || g_pendingResize)
|
||
{
|
||
goto forward;
|
||
}
|
||
|
||
// Defer: absorb the button-down but do nothing until the cursor moves.
|
||
g_pendingDrag = true;
|
||
g_pendingResize = false;
|
||
g_pendingTarget = hwnd;
|
||
g_pendingDownPt = pt;
|
||
return 1; // swallow the press for now; replayed on release if no move
|
||
}
|
||
}
|
||
|
||
// ----- Promote a pending press to a real drag/resize once past the threshold -----
|
||
if (wParam == WM_MOUSEMOVE && (g_pendingDrag || g_pendingResize))
|
||
{
|
||
int ddx = ms->pt.x - g_pendingDownPt.x;
|
||
int ddy = ms->pt.y - g_pendingDownPt.y;
|
||
if (ddx < 0) ddx = -ddx;
|
||
if (ddy < 0) ddy = -ddy;
|
||
if (ddx >= GetSystemMetrics(SM_CXDRAG) || ddy >= GetSystemMetrics(SM_CYDRAG))
|
||
{
|
||
bool wasResize = g_pendingResize;
|
||
HWND target = g_pendingTarget;
|
||
POINT downPt = g_pendingDownPt;
|
||
g_pendingDrag = false;
|
||
g_pendingResize = false;
|
||
g_pendingTarget = nullptr;
|
||
|
||
if (wasResize)
|
||
{
|
||
BeginResize(target, downPt);
|
||
HandleDragResize(ms->pt);
|
||
}
|
||
else
|
||
{
|
||
BeginDrag(target, downPt);
|
||
HandleDragMove(ms->pt);
|
||
}
|
||
}
|
||
return 0; // keep the cursor moving while waiting for the threshold
|
||
}
|
||
|
||
// ----- Mouse Move while dragging -----
|
||
if (wParam == WM_MOUSEMOVE && g_dragging && g_dragTarget)
|
||
{
|
||
ULONGLONG now = QpcMs();
|
||
if (now - g_lastMoveTick >= THROTTLE_INTERVAL_MS)
|
||
{
|
||
g_lastMoveTick = now;
|
||
HandleDragMove(ms->pt);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// ----- Left Button Up while a drag is pending (no move): deliver the click -----
|
||
if (wParam == WM_LBUTTONUP && g_pendingDrag)
|
||
{
|
||
g_ignoreModifier = true;
|
||
g_pendingDrag = false;
|
||
g_pendingTarget = nullptr;
|
||
ReplayPendingClick(false);
|
||
return 1; // swallow the physical up; the injected click replaces it
|
||
}
|
||
|
||
// ----- Left Button Up: end drag -----
|
||
if (wParam == WM_LBUTTONUP && g_dragging)
|
||
{
|
||
// Flush final position (may have been throttled)
|
||
POINT pt = ms->pt;
|
||
int dx = pt.x - g_dragStart.x;
|
||
int dy = pt.y - g_dragStart.y;
|
||
int newX = g_dragWndRect.left + dx;
|
||
int newY = g_dragWndRect.top + dy;
|
||
int w = g_dragWndRect.right - g_dragWndRect.left;
|
||
int h = g_dragWndRect.bottom - g_dragWndRect.top;
|
||
SetWindowPos(g_dragTarget, nullptr, newX, newY, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);
|
||
EndInteraction(true, false);
|
||
return 1; // swallow the release
|
||
}
|
||
|
||
// ----- Modifier + Right Button Down: arm a pending resize (deferred until move) -----
|
||
if (wParam == WM_RBUTTONDOWN && g_useAltResize && IsActivationModifierPressed())
|
||
{
|
||
if (IsSuppressedByGameMode())
|
||
{
|
||
TraceShortcutUse(false, GrabAndMoveShortcutAction::Resize, L"game_mode");
|
||
goto forward;
|
||
}
|
||
POINT pt = ms->pt;
|
||
HWND hwnd = ResolveTargetWindow(pt);
|
||
if (hwnd)
|
||
{
|
||
if (IsExcluded(hwnd))
|
||
{
|
||
goto forward;
|
||
}
|
||
|
||
// Only allow resize if the window style permits resizing
|
||
if (!(GetWindowLongW(hwnd, GWL_STYLE) & WS_THICKFRAME))
|
||
{
|
||
goto forward;
|
||
}
|
||
|
||
// Already committed to Grab and Move this hold: start resizing immediately.
|
||
if (g_activatedDuringHold)
|
||
{
|
||
BeginResize(hwnd, pt);
|
||
return 1; // swallow the press; the resize is now active
|
||
}
|
||
|
||
// A press is already pending (the other button is still held): don't
|
||
// overwrite it. Forward this one so we never strand the already-swallowed
|
||
// button-down (which would leak an unmatched button-up to the target).
|
||
if (g_pendingDrag || g_pendingResize)
|
||
{
|
||
goto forward;
|
||
}
|
||
|
||
// Defer: absorb the button-down but do nothing until the cursor moves.
|
||
g_pendingResize = true;
|
||
g_pendingDrag = false;
|
||
g_pendingTarget = hwnd;
|
||
g_pendingDownPt = pt;
|
||
return 1; // swallow the press for now; replayed on release if no move
|
||
}
|
||
}
|
||
|
||
// ----- Mouse Move while resizing -----
|
||
if (wParam == WM_MOUSEMOVE && g_resizing && g_resizeTarget)
|
||
{
|
||
ULONGLONG now = QpcMs();
|
||
if (now - g_lastMoveTick >= THROTTLE_INTERVAL_MS)
|
||
{
|
||
g_lastMoveTick = now;
|
||
HandleDragResize(ms->pt);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// ----- Right Button Up while a resize is pending (no move): deliver the click -----
|
||
if (wParam == WM_RBUTTONUP && g_pendingResize)
|
||
{
|
||
g_ignoreModifier = true;
|
||
g_pendingResize = false;
|
||
g_pendingTarget = nullptr;
|
||
ReplayPendingClick(true);
|
||
return 1; // swallow the physical up; the injected click replaces it
|
||
}
|
||
|
||
// ----- Right Button Up: end resize -----
|
||
if (wParam == WM_RBUTTONUP && g_resizing)
|
||
{
|
||
// Flush final size (may have been throttled)
|
||
RECT nr = g_resizeWndRect;
|
||
int w = nr.right - nr.left;
|
||
int h = nr.bottom - nr.top;
|
||
SetWindowPos(g_resizeTarget, nullptr, nr.left, nr.top, w, h, SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);
|
||
EndInteraction(false, true);
|
||
return 1; // swallow the right button release
|
||
}
|
||
}
|
||
|
||
forward:
|
||
return CallNextHookEx(g_hhkMouse, nCode, wParam, lParam);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Drag / resize move helpers – called directly from the LL mouse hook.
|
||
// DeferWindowPos batches the target + overlay into one DWM composition pass.
|
||
// ---------------------------------------------------------------------------
|
||
static void HandleDragMove(POINT pt)
|
||
{
|
||
if (!g_dragging || !g_dragTarget)
|
||
return;
|
||
|
||
// On the first move, restore maximized windows
|
||
if (g_dragFirstMove)
|
||
{
|
||
g_dragFirstMove = false;
|
||
if (IsZoomed(g_dragTarget))
|
||
{
|
||
RECT maxRect;
|
||
GetWindowRect(g_dragTarget, &maxRect);
|
||
int maxW = maxRect.right - maxRect.left;
|
||
int maxH = maxRect.bottom - maxRect.top;
|
||
|
||
ShowWindow(g_dragTarget, SW_RESTORE);
|
||
|
||
GetWindowRect(g_dragTarget, &g_dragWndRect);
|
||
int restoredW = g_dragWndRect.right - g_dragWndRect.left;
|
||
int restoredH = g_dragWndRect.bottom - g_dragWndRect.top;
|
||
|
||
// Preserve the relative grab position in both axes so the cursor stays
|
||
// at the same proportional spot within the restored window.
|
||
float ratioL = (maxW > 0) ? static_cast<float>(g_dragStart.x - maxRect.left) / maxW : 0.5f;
|
||
float ratioT = (maxH > 0) ? static_cast<float>(g_dragStart.y - maxRect.top) / maxH : 0.5f;
|
||
int newX = g_dragStart.x - static_cast<int>(restoredW * ratioL);
|
||
int newY = g_dragStart.y - static_cast<int>(restoredH * ratioT);
|
||
SetWindowPos(g_dragTarget, nullptr, newX, newY, 0, 0,
|
||
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);
|
||
|
||
g_dragStart = pt;
|
||
g_dragWndRect = {newX, newY, newX + restoredW, newY + restoredH};
|
||
|
||
// Corner radius / invisible-border margins differ once restored.
|
||
PrepareOverlayMetrics(g_dragTarget);
|
||
}
|
||
}
|
||
|
||
int dx = pt.x - g_dragStart.x;
|
||
int dy = pt.y - g_dragStart.y;
|
||
int newX = g_dragWndRect.left + dx;
|
||
int newY = g_dragWndRect.top + dy;
|
||
int w = g_dragWndRect.right - g_dragWndRect.left;
|
||
int h = g_dragWndRect.bottom - g_dragWndRect.top;
|
||
|
||
// 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);
|
||
}
|
||
|
||
static void HandleDragResize(POINT pt)
|
||
{
|
||
if (!g_resizing || !g_resizeTarget)
|
||
return;
|
||
|
||
// On the first resize, restore maximized windows
|
||
if (g_resizeFirstMove)
|
||
{
|
||
g_resizeFirstMove = false;
|
||
if (IsZoomed(g_resizeTarget))
|
||
{
|
||
RECT maxRect = g_resizeWndRect;
|
||
int maxW = maxRect.right - maxRect.left;
|
||
int maxH = maxRect.bottom - maxRect.top;
|
||
|
||
float ratioL = (maxW > 0) ? static_cast<float>(pt.x - maxRect.left) / maxW : 0.5f;
|
||
float ratioT = (maxH > 0) ? static_cast<float>(pt.y - maxRect.top) / maxH : 0.5f;
|
||
|
||
ShowWindow(g_resizeTarget, SW_RESTORE);
|
||
GetWindowRect(g_resizeTarget, &g_resizeWndRect);
|
||
|
||
int newW = g_resizeWndRect.right - g_resizeWndRect.left;
|
||
int newH = g_resizeWndRect.bottom - g_resizeWndRect.top;
|
||
|
||
int newLeft = pt.x - static_cast<int>(ratioL * newW);
|
||
int newTop = pt.y - static_cast<int>(ratioT * newH);
|
||
SetWindowPos(g_resizeTarget, nullptr, newLeft, newTop, newW, newH,
|
||
SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);
|
||
g_resizeWndRect = {newLeft, newTop, newLeft + newW, newTop + newH};
|
||
|
||
// Corner radius / invisible-border margins differ once restored.
|
||
PrepareOverlayMetrics(g_resizeTarget);
|
||
|
||
g_resizeLast = pt;
|
||
g_currentHandle = GetClosestHandle(pt, g_resizeWndRect);
|
||
}
|
||
}
|
||
|
||
int dx = pt.x - g_resizeLast.x;
|
||
int dy = pt.y - g_resizeLast.y;
|
||
|
||
// Re-evaluate closest handle as the cursor moves
|
||
ResizeHandle newHandle = GetClosestHandle(pt, g_resizeWndRect);
|
||
if (newHandle != g_currentHandle)
|
||
{
|
||
g_currentHandle = newHandle;
|
||
HCURSOR hCur = CursorForHandle(g_currentHandle);
|
||
SetClassLongPtrW(g_hOverlay, GCLP_HCURSOR, reinterpret_cast<LONG_PTR>(hCur));
|
||
SetCursor(hCur);
|
||
}
|
||
|
||
// Apply delta to the edges indicated by the current handle
|
||
RECT nr = g_resizeWndRect;
|
||
switch (g_currentHandle)
|
||
{
|
||
case RESIZE_TOP_LEFT: nr.left += dx; nr.top += dy; break;
|
||
case RESIZE_TOP: nr.top += dy; break;
|
||
case RESIZE_TOP_RIGHT: nr.right += dx; nr.top += dy; break;
|
||
case RESIZE_RIGHT: nr.right += dx; break;
|
||
case RESIZE_BOTTOM_RIGHT: nr.right += dx; nr.bottom += dy; break;
|
||
case RESIZE_BOTTOM: nr.bottom += dy; break;
|
||
case RESIZE_BOTTOM_LEFT: nr.left += dx; nr.bottom += dy; break;
|
||
case RESIZE_LEFT: nr.left += dx; break;
|
||
default: break;
|
||
}
|
||
|
||
// Enforce minimum window size
|
||
bool leftMoving = (g_currentHandle == RESIZE_TOP_LEFT || g_currentHandle == RESIZE_BOTTOM_LEFT || g_currentHandle == RESIZE_LEFT);
|
||
bool topMoving = (g_currentHandle == RESIZE_TOP_LEFT || g_currentHandle == RESIZE_TOP || g_currentHandle == RESIZE_TOP_RIGHT);
|
||
|
||
if (nr.right - nr.left < MIN_WINDOW_WIDTH)
|
||
{
|
||
if (leftMoving) nr.left = nr.right - MIN_WINDOW_WIDTH;
|
||
else nr.right = nr.left + MIN_WINDOW_WIDTH;
|
||
}
|
||
if (nr.bottom - nr.top < MIN_WINDOW_HEIGHT)
|
||
{
|
||
if (topMoving) nr.top = nr.bottom - MIN_WINDOW_HEIGHT;
|
||
else nr.bottom = nr.top + MIN_WINDOW_HEIGHT;
|
||
}
|
||
|
||
g_resizeWndRect = nr;
|
||
g_resizeLast = pt;
|
||
|
||
int w = nr.right - nr.left;
|
||
int h = nr.bottom - nr.top;
|
||
|
||
// Move target + overlay (separate SetWindowPos – DeferWindowPos doesn't
|
||
// work reliably for cross-process target windows)
|
||
SetWindowPos(g_resizeTarget, nullptr, nr.left, nr.top, w, h,
|
||
SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);
|
||
RepositionOverlay(nr.left, nr.top, w, h);
|
||
}
|
||
static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
|
||
{
|
||
switch (msg)
|
||
{
|
||
case WM_INVALIDATE_EXCLUDED_CACHE:
|
||
g_excludedCache.clear();
|
||
return 0;
|
||
|
||
case WM_TRAY_ICON:
|
||
if (LOWORD(lParam) == WM_RBUTTONUP)
|
||
{
|
||
ShowTrayMenu(hwnd);
|
||
}
|
||
return 0;
|
||
|
||
case WM_COMMAND:
|
||
if (LOWORD(wParam) == IDM_EXIT)
|
||
{
|
||
PostQuitMessage(0);
|
||
}
|
||
return 0;
|
||
|
||
case WM_DESTROY:
|
||
RemoveTrayIcon();
|
||
PostQuitMessage(0);
|
||
return 0;
|
||
}
|
||
|
||
return DefWindowProcW(hwnd, msg, wParam, lParam);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Entry point
|
||
// ---------------------------------------------------------------------------
|
||
const std::wstring instanceMutexName = L"Local\\PowerToys_GrabAndMove_InstanceMutex";
|
||
|
||
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR lpCmdLine, int)
|
||
{
|
||
g_hInstance = hInstance;
|
||
TraceLoggingRegister(g_hProvider);
|
||
|
||
// Prevent multiple instances
|
||
auto mutex = CreateMutexW(nullptr, TRUE, instanceMutexName.c_str());
|
||
if (GetLastError() == ERROR_ALREADY_EXISTS)
|
||
{
|
||
TraceLoggingUnregister(g_hProvider);
|
||
return 1;
|
||
}
|
||
|
||
// Require runner PID on the command line; refuse to run standalone
|
||
std::wstring pid = std::wstring(lpCmdLine);
|
||
if (pid.empty())
|
||
{
|
||
MessageBoxW(nullptr, L"GrabAndMove can't run as a standalone. Start it from PowerToys.", L"GrabAndMove", MB_ICONERROR);
|
||
TraceLoggingUnregister(g_hProvider);
|
||
return 1;
|
||
}
|
||
|
||
auto mainThreadId = GetCurrentThreadId();
|
||
ProcessWaiter::OnProcessTerminate(pid, [mainThreadId](int err) {
|
||
if (err != ERROR_SUCCESS)
|
||
{
|
||
// Failed to wait for parent process exit
|
||
}
|
||
PostThreadMessage(mainThreadId, WM_QUIT, 0, 0);
|
||
});
|
||
|
||
// Load initial settings from JSON before anything else
|
||
LoadSettingsFromFile();
|
||
|
||
// Open the named event for settings reload notifications
|
||
g_hReloadSettingsEvent = CreateEventW(nullptr, FALSE, FALSE, GRABANDMOVE_REFRESH_SETTINGS_EVENT);
|
||
|
||
// Open the named event for exit signal from the module interface
|
||
g_hExitEvent = CreateEventW(nullptr, FALSE, FALSE, GRABANDMOVE_EXIT_EVENT);
|
||
|
||
if (g_hReloadSettingsEvent)
|
||
{
|
||
g_settingsThread = std::thread(SettingsWatcherThread, mainThreadId);
|
||
}
|
||
|
||
// Ensure common controls are initialised (for Shell_NotifyIcon)
|
||
INITCOMMONCONTROLSEX commonControls = { sizeof(commonControls), ICC_STANDARD_CLASSES };
|
||
InitCommonControlsEx(&commonControls);
|
||
|
||
// Initialise GDI+ for antialiased overlay border rendering
|
||
Gdiplus::GdiplusStartupInput gdiplusStartupInput;
|
||
Gdiplus::GdiplusStartup(&g_gdiplusToken, &gdiplusStartupInput, nullptr);
|
||
|
||
// Register a message-only window class
|
||
WNDCLASSEXW wc = {};
|
||
wc.cbSize = sizeof(wc);
|
||
wc.lpfnWndProc = WndProc;
|
||
wc.hInstance = hInstance;
|
||
wc.lpszClassName = CLASS_NAME;
|
||
if (!RegisterClassExW(&wc))
|
||
{
|
||
TraceLoggingUnregister(g_hProvider);
|
||
return 1;
|
||
}
|
||
|
||
// Register the overlay window class (layered per-pixel-alpha surface, ARROW cursor)
|
||
WNDCLASSEXW overlayWindowClass = {};
|
||
overlayWindowClass.cbSize = sizeof(overlayWindowClass);
|
||
overlayWindowClass.lpfnWndProc = DefWindowProcW;
|
||
overlayWindowClass.hInstance = hInstance;
|
||
overlayWindowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||
overlayWindowClass.hbrBackground = nullptr; // per-pixel alpha via UpdateLayeredWindow
|
||
overlayWindowClass.lpszClassName = OVERLAY_CLASS_NAME;
|
||
if (!RegisterClassExW(&overlayWindowClass))
|
||
{
|
||
TraceLoggingUnregister(g_hProvider);
|
||
return 1;
|
||
}
|
||
|
||
// Create a message-only window (invisible)
|
||
g_hMsgWnd = CreateWindowExW(0, CLASS_NAME, APP_TITLE, 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, hInstance, nullptr);
|
||
if (!g_hMsgWnd)
|
||
{
|
||
TraceLoggingUnregister(g_hProvider);
|
||
return 1;
|
||
}
|
||
|
||
// Pre-load system cursors (fix #6 – avoid LoadCursorW in hot path)
|
||
g_curSizeAll = LoadCursorW(nullptr, IDC_SIZEALL);
|
||
g_curSizeNWSE = LoadCursorW(nullptr, IDC_SIZENWSE);
|
||
g_curSizeNESW = LoadCursorW(nullptr, IDC_SIZENESW);
|
||
g_curSizeWE = LoadCursorW(nullptr, IDC_SIZEWE);
|
||
g_curSizeNS = LoadCursorW(nullptr, IDC_SIZENS);
|
||
|
||
// Install global low-level hooks
|
||
g_hhkKeyboard = SetWindowsHookExW(WH_KEYBOARD_LL, KeyboardProc, hInstance, 0);
|
||
g_hhkMouse = SetWindowsHookExW(WH_MOUSE_LL, MouseProc, hInstance, 0);
|
||
|
||
// Detect foreground switches to elevated processes (where key-up events stop arriving)
|
||
g_hWinEventHook = SetWinEventHook(
|
||
EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND,
|
||
nullptr, WinEventProc, 0, 0, WINEVENT_OUTOFCONTEXT);
|
||
|
||
if (!g_hhkKeyboard || !g_hhkMouse)
|
||
{
|
||
MessageBoxW(
|
||
nullptr,
|
||
L"Failed to install global hooks.\n"
|
||
L"Try running as Administrator.",
|
||
APP_TITLE,
|
||
MB_ICONERROR | MB_OK);
|
||
if (g_hhkKeyboard)
|
||
UnhookWindowsHookEx(g_hhkKeyboard);
|
||
if (g_hhkMouse)
|
||
UnhookWindowsHookEx(g_hhkMouse);
|
||
TraceLoggingUnregister(g_hProvider);
|
||
return 1;
|
||
}
|
||
|
||
// This is a good place to add system tray icon with AddTrayIcon(g_hMsgWnd);
|
||
|
||
// Message loop – required for low-level hooks to function
|
||
MSG msg;
|
||
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
|
||
{
|
||
TranslateMessage(&msg);
|
||
DispatchMessageW(&msg);
|
||
}
|
||
|
||
// Cleanup
|
||
g_running = false;
|
||
if (g_hReloadSettingsEvent)
|
||
{
|
||
SetEvent(g_hReloadSettingsEvent); // wake the thread so it exits
|
||
}
|
||
if (g_settingsThread.joinable())
|
||
{
|
||
g_settingsThread.join();
|
||
}
|
||
if (g_hReloadSettingsEvent)
|
||
{
|
||
CloseHandle(g_hReloadSettingsEvent);
|
||
}
|
||
if (g_hExitEvent)
|
||
{
|
||
CloseHandle(g_hExitEvent);
|
||
}
|
||
UnhookWindowsHookEx(g_hhkKeyboard);
|
||
UnhookWindowsHookEx(g_hhkMouse);
|
||
if (g_hWinEventHook)
|
||
{
|
||
UnhookWinEvent(g_hWinEventHook);
|
||
}
|
||
// Destroy persistent overlay window (fix #9)
|
||
if (g_hOverlay)
|
||
{
|
||
DestroyWindow(g_hOverlay);
|
||
g_hOverlay = nullptr;
|
||
}
|
||
RemoveTrayIcon();
|
||
if (g_gdiplusToken)
|
||
{
|
||
Gdiplus::GdiplusShutdown(g_gdiplusToken);
|
||
g_gdiplusToken = 0;
|
||
}
|
||
TraceLoggingUnregister(g_hProvider);
|
||
|
||
return static_cast<int>(msg.wParam);
|
||
}
|