diff --git a/.github/actions/spell-check/allow/code.txt b/.github/actions/spell-check/allow/code.txt
index 5b80ff7d36..7e895c4df2 100644
--- a/.github/actions/spell-check/allow/code.txt
+++ b/.github/actions/spell-check/allow/code.txt
@@ -330,6 +330,7 @@ xes
PACKAGEVERSIONNUMBER
APPXMANIFESTVERSION
PROGMAN
+ROOTOWNER
# MRU lists
CACHEWRITE
diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt
index b79f362d73..68c1e2dcf1 100644
--- a/.github/actions/spell-check/expect.txt
+++ b/.github/actions/spell-check/expect.txt
@@ -10,6 +10,7 @@ ACCESSDENIED
ACCESSTOKEN
acfs
ACIE
+ACRYLICBLURBEHIND
acrt
ACTIVATEAPP
ACTIVATEOPTIONS
@@ -93,6 +94,7 @@ ASSOCSTR
ASYNCWINDOWPLACEMENT
ASYNCWINDOWPOS
atl
+AWC
ATRIOX
aumid
AUO
@@ -465,6 +467,7 @@ dwm
dwmapi
DWMCOLORIZATIONCOLORCHANGED
DWMCOMPOSITIONCHANGED
+DWMSBT
DWMNCRENDERINGCHANGED
Dwmp
DWMSENDICONICLIVEPREVIEWBITMAP
@@ -1391,6 +1394,7 @@ pnid
PNMLINK
Poc
Podcasts
+PARGB
POINTERID
POINTERUPDATE
Pokedex
@@ -1857,6 +1861,7 @@ SYSLIB
sysmenu
systemai
SYSTEMAPPS
+SYSTEMBACKDROP
SYSTEMMODAL
systemroot
SYSTEMTIME
@@ -1931,6 +1936,7 @@ tracerpt
trackbar
trafficmanager
transicc
+TRANSIENTWINDOW
transitioning
TRAYMOUSEMESSAGE
triaging
@@ -2075,7 +2081,7 @@ WANTNUKEWARNING
WANTPALM
WASDK
wbem
-Wca
+wca
WCE
wcex
WCRAPI
@@ -2103,6 +2109,7 @@ winappcli
winappsdk
windir
WINDOWCREATED
+WINDOWCOMPOSITIONATTRIBDATA
WINDOWDESTROYED
windowedge
WINDOWINFO
diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json
index 8bca6ee4ae..b045420879 100644
--- a/.pipelines/ESRPSigning_core.json
+++ b/.pipelines/ESRPSigning_core.json
@@ -33,6 +33,7 @@
"PowerToys.AlwaysOnTop.exe",
"PowerToys.AlwaysOnTopModuleInterface.dll",
+ "PowerToys.AltWindowCycle.dll",
"PowerToys.CmdNotFoundModuleInterface.dll",
diff --git a/PowerToys.slnx b/PowerToys.slnx
index 164a5d28e5..c553485a74 100644
--- a/PowerToys.slnx
+++ b/PowerToys.slnx
@@ -158,6 +158,10 @@
+
+
+
+
diff --git a/doc/images/icons/WindowHopper.png b/doc/images/icons/WindowHopper.png
new file mode 100644
index 0000000000..2981f50618
Binary files /dev/null and b/doc/images/icons/WindowHopper.png differ
diff --git a/doc/images/overview/Original/WindowHopper.png b/doc/images/overview/Original/WindowHopper.png
new file mode 100644
index 0000000000..1d6b41750d
Binary files /dev/null and b/doc/images/overview/Original/WindowHopper.png differ
diff --git a/doc/images/overview/WindowHopper_large.png b/doc/images/overview/WindowHopper_large.png
new file mode 100644
index 0000000000..5195ea0780
Binary files /dev/null and b/doc/images/overview/WindowHopper_large.png differ
diff --git a/doc/images/overview/WindowHopper_small.png b/doc/images/overview/WindowHopper_small.png
new file mode 100644
index 0000000000..2b7cc4dee9
Binary files /dev/null and b/doc/images/overview/WindowHopper_small.png differ
diff --git a/src/common/GPOWrapper/GPOWrapper.cpp b/src/common/GPOWrapper/GPOWrapper.cpp
index bcef3ff995..0f4ad1f9e9 100644
--- a/src/common/GPOWrapper/GPOWrapper.cpp
+++ b/src/common/GPOWrapper/GPOWrapper.cpp
@@ -8,6 +8,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation
{
return static_cast(powertoys_gpo::getConfiguredAlwaysOnTopEnabledValue());
}
+ GpoRuleConfigured GPOWrapper::GetConfiguredAltWindowCycleEnabledValue()
+ {
+ return static_cast(powertoys_gpo::getConfiguredAltWindowCycleEnabledValue());
+ }
GpoRuleConfigured GPOWrapper::GetConfiguredAwakeEnabledValue()
{
return static_cast(powertoys_gpo::getConfiguredAwakeEnabledValue());
diff --git a/src/common/GPOWrapper/GPOWrapper.h b/src/common/GPOWrapper/GPOWrapper.h
index 6a361e2750..b1af9a00d1 100644
--- a/src/common/GPOWrapper/GPOWrapper.h
+++ b/src/common/GPOWrapper/GPOWrapper.h
@@ -8,6 +8,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation
{
GPOWrapper() = default;
static GpoRuleConfigured GetConfiguredAlwaysOnTopEnabledValue();
+ static GpoRuleConfigured GetConfiguredAltWindowCycleEnabledValue();
static GpoRuleConfigured GetConfiguredAwakeEnabledValue();
static GpoRuleConfigured GetConfiguredCmdNotFoundEnabledValue();
static GpoRuleConfigured GetConfiguredCmdPalEnabledValue();
diff --git a/src/common/GPOWrapper/GPOWrapper.idl b/src/common/GPOWrapper/GPOWrapper.idl
index d9f2c8f14e..157f57f859 100644
--- a/src/common/GPOWrapper/GPOWrapper.idl
+++ b/src/common/GPOWrapper/GPOWrapper.idl
@@ -12,6 +12,7 @@ namespace PowerToys
};
[default_interface] static runtimeclass GPOWrapper {
static GpoRuleConfigured GetConfiguredAlwaysOnTopEnabledValue();
+ static GpoRuleConfigured GetConfiguredAltWindowCycleEnabledValue();
static GpoRuleConfigured GetConfiguredAwakeEnabledValue();
static GpoRuleConfigured GetConfiguredCmdNotFoundEnabledValue();
static GpoRuleConfigured GetConfiguredCmdPalEnabledValue();
diff --git a/src/common/ManagedCommon/ModuleType.cs b/src/common/ManagedCommon/ModuleType.cs
index eb9c18fc26..815a4b75f7 100644
--- a/src/common/ManagedCommon/ModuleType.cs
+++ b/src/common/ManagedCommon/ModuleType.cs
@@ -7,6 +7,7 @@ namespace ManagedCommon
public enum ModuleType
{
AdvancedPaste,
+ AltWindowCycle,
AlwaysOnTop,
Awake,
ColorPicker,
diff --git a/src/common/utils/gpo.h b/src/common/utils/gpo.h
index 700c6d5241..1d420596c4 100644
--- a/src/common/utils/gpo.h
+++ b/src/common/utils/gpo.h
@@ -72,6 +72,7 @@ namespace powertoys_gpo
const std::wstring POLICY_CONFIGURE_ENABLED_QOI_THUMBNAILS = L"ConfigureEnabledUtilityFileExplorerQOIThumbnails";
const std::wstring POLICY_CONFIGURE_ENABLED_NEWPLUS = L"ConfigureEnabledUtilityNewPlus";
const std::wstring POLICY_CONFIGURE_ENABLED_WORKSPACES = L"ConfigureEnabledUtilityWorkspaces";
+ const std::wstring POLICY_CONFIGURE_ENABLED_ALT_WINDOW_CYCLE = L"ConfigureEnabledUtilityAltWindowCycle";
// The registry value names for PowerToys installer and update policies.
const std::wstring POLICY_DISABLE_PER_USER_INSTALLATION = L"PerUserInstallationDisabled";
@@ -288,6 +289,11 @@ namespace powertoys_gpo
return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_ALWAYS_ON_TOP);
}
+ inline gpo_rule_configured_t getConfiguredAltWindowCycleEnabledValue()
+ {
+ return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_ALT_WINDOW_CYCLE);
+ }
+
inline gpo_rule_configured_t getConfiguredAwakeEnabledValue()
{
return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_AWAKE);
diff --git a/src/gpo/assets/PowerToys.admx b/src/gpo/assets/PowerToys.admx
index a19a267bbb..eabd7cac97 100644
--- a/src/gpo/assets/PowerToys.admx
+++ b/src/gpo/assets/PowerToys.admx
@@ -91,6 +91,16 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/gpo/assets/en-US/PowerToys.adml b/src/gpo/assets/en-US/PowerToys.adml
index 77f0e439ad..30442270f5 100644
--- a/src/gpo/assets/en-US/PowerToys.adml
+++ b/src/gpo/assets/en-US/PowerToys.adml
@@ -244,6 +244,7 @@ If you don't configure this policy, the user will be able to control the setting
Configure global utility enabled state
Advanced Paste: Configure enabled state
Always On Top: Configure enabled state
+ Window Hopper: Configure enabled state
Awake: Configure enabled state
Color Picker: Configure enabled state
Command Not Found: Configure enabled state
diff --git a/src/modules/AltWindowCycle/AltWindowCycle.cpp b/src/modules/AltWindowCycle/AltWindowCycle.cpp
new file mode 100644
index 0000000000..4beb20cfb8
--- /dev/null
+++ b/src/modules/AltWindowCycle/AltWindowCycle.cpp
@@ -0,0 +1,1694 @@
+// AltWindowCycle.cpp
+// Adapts an Alt+` window-cycling overlay proof-of-concept for the PowerToys in-proc module.
+// All overlay windows and the Switcher state machine live on a dedicated UI thread;
+// the runner's on_hotkey callback only posts a message.
+// Thumbnail previews default to DWM compositor thumbnails for visual fidelity.
+
+#include "pch.h"
+
+#include "AltWindowCycle.h"
+#include "AltWindowCycleLogic.h"
+
+#include
+#include
+#include
+#include
+#include
+
+// Win11 system backdrop / corner attributes (in case SDK is older).
+#ifndef DWMWA_SYSTEMBACKDROP_TYPE
+#define DWMWA_SYSTEMBACKDROP_TYPE 38
+#endif
+#ifndef DWMSBT_TRANSIENTWINDOW
+#define DWMSBT_TRANSIENTWINDOW 3
+#endif
+#ifndef DWMWA_CLOAKED
+#define DWMWA_CLOAKED 14
+#endif
+#ifndef DWMWA_WINDOW_CORNER_PREFERENCE
+#define DWMWA_WINDOW_CORNER_PREFERENCE 33
+#endif
+#ifndef DWMWCP_ROUND
+#define DWMWCP_ROUND 2
+#endif
+#ifndef DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
+#define DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 ((DPI_AWARENESS_CONTEXT)-4)
+#endif
+#ifndef DWMWA_SYSTEMBACKDROP_TYPE
+#define DWMWA_SYSTEMBACKDROP_TYPE 38
+#endif
+#ifndef DWMSBT_TRANSIENTWINDOW
+#define DWMSBT_TRANSIENTWINDOW 3
+#endif
+
+namespace AltTabStyle
+{
+ // Alt-Tab In-App Acrylic Thin tints (ABGR, alpha byte 0 = fully blurred tint).
+ // Dark: TintColor=#545454. Light: a soft #F3F3F3 mica-like tint.
+ constexpr DWORD AcrylicThinGradientDarkABGR = 0x00545454;
+ constexpr DWORD AcrylicThinGradientLightABGR = 0x00F3F3F3;
+
+ // Reads HKCU Personalize\AppsUseLightTheme; defaults to dark when unset.
+ inline bool IsLightTheme()
+ {
+ DWORD value = 0;
+ DWORD size = sizeof(value);
+ HKEY key = nullptr;
+ if (RegOpenKeyExW(HKEY_CURRENT_USER,
+ L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
+ 0, KEY_READ, &key) == ERROR_SUCCESS)
+ {
+ DWORD type = 0;
+ if (RegQueryValueExW(key, L"AppsUseLightTheme", nullptr, &type,
+ reinterpret_cast(&value), &size) != ERROR_SUCCESS ||
+ type != REG_DWORD)
+ {
+ value = 0;
+ }
+ RegCloseKey(key);
+ }
+ return value != 0;
+ }
+
+ // Opaque approximation of the acrylic tint (alpha dropped), used as the
+ // backdrop fill on OS builds without the public system-backdrop API.
+ constexpr COLORREF AbgrToRef(DWORD abgr)
+ {
+ return RGB(abgr & 0xFF, (abgr >> 8) & 0xFF, (abgr >> 16) & 0xFF);
+ }
+ constexpr COLORREF BackdropSolidRef(bool light)
+ {
+ return AbgrToRef(light ? AcrylicThinGradientLightABGR : AcrylicThinGradientDarkABGR);
+ }
+
+ inline COLORREF AccentFallbackRef() { return RGB(0, 120, 215); }
+ constexpr COLORREF HeaderTextRef(bool light)
+ {
+ return light ? RGB(26, 26, 26) : RGB(235, 235, 235);
+ }
+
+ inline Gdiplus::Color Transparent() { return Gdiplus::Color(0, 0, 0, 0); }
+
+ // WinUI CardBackgroundFillColorDefault. Same for every tile; selection is shown
+ // only by the accent focus outline. Semi-transparent so the acrylic shows through.
+ inline Gdiplus::Color Card(bool light)
+ {
+ return light ? Gdiplus::Color(179, 255, 255, 255) // #B3FFFFFF
+ : Gdiplus::Color(210, 18, 18, 18); // dark, mostly-opaque tile
+ }
+ constexpr COLORREF CardRef(bool light)
+ {
+ // Opaque approximation of the card-over-acrylic color, available for any
+ // surface that needs a solid blend background.
+ return light ? RGB(248, 248, 248) : RGB(20, 20, 20);
+ }
+ // WinUI CardStrokeColorDefault: a subtle 1px edge around each card.
+ inline Gdiplus::Color CardStroke(bool light)
+ {
+ return light ? Gdiplus::Color(15, 0, 0, 0)
+ : Gdiplus::Color(25, 0, 0, 0);
+ }
+ inline Gdiplus::Color Accent(COLORREF accent)
+ {
+ return Gdiplus::Color(255, GetRValue(accent), GetGValue(accent), GetBValue(accent));
+ }
+ inline Gdiplus::Color SurfaceStrokeDefault(bool light)
+ {
+ return light ? Gdiplus::Color(24, 0, 0, 0)
+ : Gdiplus::Color(64, 255, 255, 255);
+ }
+ inline Gdiplus::Color PreviewStroke(bool light)
+ {
+ return light ? Gdiplus::Color(28, 0, 0, 0)
+ : Gdiplus::Color(60, 255, 255, 255);
+ }
+ inline Gdiplus::Color FocusShadow(bool light)
+ {
+ // Contrast hairline just inside the accent ring: white in light mode,
+ // black in dark mode.
+ return light ? Gdiplus::Color(120, 255, 255, 255)
+ : Gdiplus::Color(150, 0, 0, 0);
+ }
+}
+
+// True on Windows 11 22H2+ (build 22621), where DWMWA_SYSTEMBACKDROP_TYPE is a
+// public, documented way to get acrylic. Below that (Windows 10, Windows 11
+// 21H2) we fall back to an opaque solid fill instead of the private composition
+// API. RtlGetVersion is used because GetVersionEx is shimmed by app compat.
+static bool SupportsSystemBackdrop()
+{
+ using RtlGetVersionPtr = LONG(WINAPI*)(PRTL_OSVERSIONINFOW);
+ static const DWORD build = []() -> DWORD {
+ if (HMODULE nt = GetModuleHandleW(L"ntdll.dll"))
+ {
+ auto fn = reinterpret_cast(GetProcAddress(nt, "RtlGetVersion"));
+ RTL_OSVERSIONINFOW vi{};
+ vi.dwOSVersionInfoSize = sizeof(vi);
+ if (fn && fn(&vi) == 0)
+ return vi.dwBuildNumber;
+ }
+ return 0;
+ }();
+ return build >= 22621;
+}
+
+// thumbHost fallback-fill state. On the acrylic path thumbHost paints nothing so
+// the system backdrop shows through; on the fallback path ThumbHostProc fills it
+// opaque with this tinted brush.
+static HBRUSH g_thumbSolidBrush = nullptr;
+static bool g_thumbSolidMode = false;
+
+static LRESULT CALLBACK ThumbHostProc(HWND h, UINT msg, WPARAM w, LPARAM l)
+{
+ if (msg == WM_ERASEBKGND)
+ {
+ if (g_thumbSolidMode && g_thumbSolidBrush)
+ {
+ RECT rc{};
+ GetClientRect(h, &rc);
+ FillRect(reinterpret_cast(w), &rc, g_thumbSolidBrush);
+ }
+ return 1;
+ }
+ return DefWindowProcW(h, msg, w, l);
+}
+
+// Apply the overlay backdrop to thumbHost. Win11 22H2+ gets public acrylic via
+// DWMWA_SYSTEMBACKDROP_TYPE; older builds get an opaque tinted fill.
+static void ApplyBackdrop(HWND hwnd, bool light)
+{
+ if (SupportsSystemBackdrop())
+ {
+ g_thumbSolidMode = false;
+ MARGINS glass{ -1, -1, -1, -1 };
+ DwmExtendFrameIntoClientArea(hwnd, &glass);
+ int backdrop = DWMSBT_TRANSIENTWINDOW;
+ DwmSetWindowAttribute(hwnd, DWMWA_SYSTEMBACKDROP_TYPE, &backdrop, sizeof(backdrop));
+ }
+ else
+ {
+ g_thumbSolidMode = true;
+ if (g_thumbSolidBrush)
+ DeleteObject(g_thumbSolidBrush);
+ g_thumbSolidBrush = CreateSolidBrush(AltTabStyle::BackdropSolidRef(light));
+ }
+}
+
+// =================== Window enumeration / activation ===================
+
+static std::wstring ProcessImagePath(HWND hwnd)
+{
+ DWORD pid = 0;
+ GetWindowThreadProcessId(hwnd, &pid);
+ if (!pid)
+ return std::wstring();
+
+ HANDLE proc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
+ if (!proc)
+ return std::wstring();
+
+ wchar_t buf[MAX_PATH * 2];
+ DWORD len = static_cast(sizeof(buf) / sizeof(buf[0]));
+ std::wstring result;
+ if (QueryFullProcessImageNameW(proc, 0, buf, &len))
+ result.assign(buf, len);
+ CloseHandle(proc);
+ return result;
+}
+
+// True for paths whose filename is ApplicationFrameHost.exe (the shared host that
+// owns every UWP/packaged app's top-level ApplicationFrameWindow).
+static bool IsApplicationFrameHost(const std::wstring& path)
+{
+ size_t slash = path.find_last_of(L"\\/");
+ const wchar_t* name = path.c_str() + (slash == std::wstring::npos ? 0 : slash + 1);
+ return _wcsicmp(name, L"ApplicationFrameHost.exe") == 0;
+}
+
+struct CoreWindowFind
+{
+ DWORD hostPid = 0;
+ HWND found = nullptr;
+};
+
+static BOOL CALLBACK FindCoreWindowProc(HWND child, LPARAM lp)
+{
+ CoreWindowFind* cf = reinterpret_cast(lp);
+ wchar_t cls[64];
+ if (GetClassNameW(child, cls, ARRAYSIZE(cls)) &&
+ wcscmp(cls, L"Windows.UI.Core.CoreWindow") == 0)
+ {
+ DWORD pid = 0;
+ GetWindowThreadProcessId(child, &pid);
+ if (pid && pid != cf->hostPid)
+ {
+ cf->found = child;
+ return FALSE; // stop enumerating
+ }
+ }
+ return TRUE;
+}
+
+// Image path that identifies the *real* owning app. For UWP/packaged windows the
+// top-level window belongs to ApplicationFrameHost.exe, so all packaged apps would
+// otherwise group together. Resolve to the hosted CoreWindow's actual process so
+// each packaged app is grouped on its own.
+static std::wstring RealProcessImagePath(HWND hwnd)
+{
+ std::wstring path = ProcessImagePath(hwnd);
+ if (!IsApplicationFrameHost(path))
+ return path;
+
+ DWORD hostPid = 0;
+ GetWindowThreadProcessId(hwnd, &hostPid);
+
+ CoreWindowFind cf;
+ cf.hostPid = hostPid;
+ EnumChildWindows(hwnd, FindCoreWindowProc, reinterpret_cast(&cf));
+ if (cf.found)
+ {
+ std::wstring real = ProcessImagePath(cf.found);
+ if (!real.empty())
+ return real;
+ }
+ return path;
+}
+
+// Queries the Win32 state that feeds the pure Alt-Tab eligibility predicate. Keeps
+// the original short-circuits so no extra system calls are made for windows that are
+// already disqualified (e.g. the DWM cloak query is skipped for invisible windows).
+static AltWindowCycleLogic::WindowEligibility QueryWindowEligibility(HWND hwnd)
+{
+ AltWindowCycleLogic::WindowEligibility eligibility;
+
+ eligibility.isVisible = IsWindowVisible(hwnd) != FALSE;
+ if (!eligibility.isVisible)
+ return eligibility;
+
+ int cloaked = 0;
+ eligibility.isCloaked = SUCCEEDED(DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, &cloaked, sizeof(cloaked))) && cloaked;
+ if (eligibility.isCloaked)
+ return eligibility;
+
+ HWND walk = GetAncestor(hwnd, GA_ROOTOWNER);
+ HWND tryPopup = nullptr;
+ for (;;)
+ {
+ tryPopup = GetLastActivePopup(walk);
+ if (tryPopup == walk)
+ break;
+ if (IsWindowVisible(tryPopup))
+ break;
+ walk = tryPopup;
+ }
+ eligibility.isAltTabRepresentative = (walk == hwnd);
+ if (!eligibility.isAltTabRepresentative)
+ return eligibility;
+
+ LONG_PTR exStyle = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
+ eligibility.isToolWindow = (exStyle & WS_EX_TOOLWINDOW) != 0;
+ eligibility.isAppWindow = (exStyle & WS_EX_APPWINDOW) != 0;
+ return eligibility;
+}
+
+struct EnumCtx
+{
+ std::vector candidates;
+};
+
+static BOOL CALLBACK EnumProc(HWND hwnd, LPARAM lp)
+{
+ EnumCtx* ctx = reinterpret_cast(lp);
+
+ AltWindowCycleLogic::CandidateWindow candidate;
+ candidate.id = reinterpret_cast(hwnd);
+ candidate.eligibility = QueryWindowEligibility(hwnd);
+
+ // Resolve the owning process only for eligible windows, matching the original
+ // short-circuit (avoids an OpenProcess for windows that are dropped anyway).
+ if (AltWindowCycleLogic::IsAltTabEligible(candidate.eligibility))
+ candidate.processKey = RealProcessImagePath(hwnd);
+
+ ctx->candidates.push_back(std::move(candidate));
+ return TRUE;
+}
+
+// Collect Alt-Tab-eligible windows of the foreground app in Z-order (MRU).
+static bool GetAppWindows(HWND& foreground, std::vector& windows)
+{
+ windows.clear();
+ foreground = GetForegroundWindow();
+ if (!foreground)
+ return false;
+
+ const std::wstring foregroundKey = RealProcessImagePath(foreground);
+ if (foregroundKey.empty())
+ return false;
+
+ EnumCtx ctx;
+ EnumWindows(EnumProc, reinterpret_cast(&ctx));
+
+ const std::vector selected =
+ AltWindowCycleLogic::SelectCycleWindows(foregroundKey, ctx.candidates);
+
+ windows.reserve(selected.size());
+ for (const unsigned long long id : selected)
+ windows.push_back(reinterpret_cast(static_cast(id)));
+
+ return !windows.empty();
+}
+
+static void ForceForeground(HWND hwnd)
+{
+ if (IsIconic(hwnd))
+ ShowWindow(hwnd, SW_RESTORE);
+
+ DWORD fgThread = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
+ DWORD myThread = GetCurrentThreadId();
+
+ if (fgThread && fgThread != myThread)
+ AttachThreadInput(myThread, fgThread, TRUE);
+
+ BringWindowToTop(hwnd);
+ SetForegroundWindow(hwnd);
+ SetFocus(hwnd);
+
+ if (fgThread && fgThread != myThread)
+ AttachThreadInput(myThread, fgThread, FALSE);
+}
+
+// =================== UI-thread message plumbing ===============================
+
+// Custom thread messages posted to the dedicated UI thread.
+// WM_AWC_HOTKEY: wParam = forward flag (0/1), lParam = held modifier mask.
+static const UINT WM_AWC_HOTKEY = WM_APP + 1;
+static const UINT WM_AWC_CANCEL = WM_APP + 2;
+
+static DWORD g_uiThreadId = 0;
+
+// Escape has to cancel the overlay, but the overlay is WS_EX_NOACTIVATE and never
+// receives keyboard focus. A low-level hook installed only while the overlay is
+// visible keeps Escape out of the runner's global hotkey table, so PowerToys never
+// claims Alt+Escape (a Windows shell shortcut) while the module sits idle.
+static HHOOK g_escapeHook = nullptr;
+
+static LRESULT CALLBACK EscapeHookProc(int code, WPARAM wParam, LPARAM lParam)
+{
+ if (code == HC_ACTION && (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN))
+ {
+ const auto* keyInfo = reinterpret_cast(lParam);
+
+ // Keep this callback bounded: teardown runs on the UI thread's own message
+ // loop rather than inline, so the OS low-level hook timeout is never at risk.
+ if (keyInfo && keyInfo->vkCode == VK_ESCAPE && g_uiThreadId &&
+ PostThreadMessageW(g_uiThreadId, WM_AWC_CANCEL, 0, 0))
+ {
+ return 1;
+ }
+ }
+
+ return CallNextHookEx(nullptr, code, wParam, lParam);
+}
+
+// =================== Switcher class ===================
+
+class Switcher
+{
+public:
+ bool Init(HINSTANCE instance);
+ void Shutdown();
+ void OnHotkey(bool forward, unsigned int holdModifiers);
+ void OnCancel();
+
+private:
+ enum class St { Idle, Visible };
+
+ static const UINT_PTR TIMER_ID = 1;
+ static const UINT TIMER_MS = 25;
+
+ static LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);
+
+ void OnTick();
+ void Commit();
+ void Cancel();
+ void ShowOverlayWindow();
+ void HideOverlayWindow();
+ void SetSelection(int index);
+ void RenderLayered();
+ void OnThemeChanged();
+ void ComputeLayout(const RECT& work, int& x, int& y, int& panelW, int& panelH);
+ void RegisterThumbnails();
+ void UnregisterThumbnails();
+ void InstallEscapeHook();
+ void RemoveEscapeHook();
+ void EnsureFont();
+
+ RECT TileRect(int index) const;
+ RECT PreviewRect(const RECT& tile) const;
+ RECT HeaderRect(const RECT& tile) const;
+
+ int Scaled(int v) const { return AltWindowCycleLogic::ScaledValue(scale, v); }
+
+ HINSTANCE hinst = nullptr;
+ HWND overlay = nullptr;
+ HWND thumbHost = nullptr;
+ int ovX = 0, ovY = 0, ovW = 0, ovH = 0;
+
+ St state = St::Idle;
+ std::vector windows;
+ std::vector thumbs;
+ std::vector icons;
+ std::vector titles;
+ HWND anchorWindow = nullptr;
+ int selected = 0;
+ int pageStart = 0;
+ unsigned int activeHoldModifiers = AltWindowCycleLogic::ModifierAlt;
+
+ double scale = 1.0;
+ AltWindowCycleLogic::OverlayLayout overlayLayout;
+ int cols = 1, rows = 1;
+ int pad = 0, gap = 0, tileW = 0, tileH = 0, previewH = 0, inner = 0, radius = 0;
+ int cardTrimBottom = 0;
+ int headerH = 0, iconSize = 0;
+
+ HFONT font = nullptr;
+ double fontScale = 0.0;
+};
+
+// ---- lifecycle ---------------------------------------------------------------
+
+bool Switcher::Init(HINSTANCE instance)
+{
+ hinst = instance;
+
+ WNDCLASSW hc = {};
+ hc.style = CS_HREDRAW | CS_VREDRAW;
+ hc.lpfnWndProc = &ThumbHostProc;
+ hc.hInstance = hinst;
+ hc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
+ hc.hbrBackground = nullptr;
+ hc.lpszClassName = L"AltWindowCycleThumbHost";
+ RegisterClassW(&hc);
+
+ thumbHost = CreateWindowExW(
+ WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
+ hc.lpszClassName, L"", WS_POPUP | WS_DISABLED,
+ 0, 0, 0, 0, nullptr, nullptr, hinst, nullptr);
+ if (!thumbHost)
+ return false;
+
+ WNDCLASSW wc = {};
+ wc.style = CS_HREDRAW | CS_VREDRAW;
+ wc.lpfnWndProc = &Switcher::WndProc;
+ wc.hInstance = hinst;
+ wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
+ wc.hbrBackground = nullptr;
+ wc.lpszClassName = L"AltWindowCycleOverlay";
+ RegisterClassW(&wc);
+
+ overlay = CreateWindowExW(
+ WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_LAYERED,
+ wc.lpszClassName, L"", WS_POPUP,
+ 0, 0, 0, 0, nullptr, nullptr, hinst, nullptr);
+ if (!overlay)
+ return false;
+
+ SetWindowLongPtrW(overlay, GWLP_USERDATA, reinterpret_cast(this));
+ return true;
+}
+
+void Switcher::Shutdown()
+{
+ RemoveEscapeHook();
+ UnregisterThumbnails();
+ if (thumbHost)
+ {
+ DestroyWindow(thumbHost);
+ thumbHost = nullptr;
+ }
+ if (font)
+ {
+ DeleteObject(font);
+ font = nullptr;
+ }
+ if (overlay)
+ {
+ KillTimer(overlay, TIMER_ID);
+ DestroyWindow(overlay);
+ overlay = nullptr;
+ }
+ UnregisterClassW(L"AltWindowCycleOverlay", hinst);
+ UnregisterClassW(L"AltWindowCycleThumbHost", hinst);
+ if (g_thumbSolidBrush)
+ {
+ DeleteObject(g_thumbSolidBrush);
+ g_thumbSolidBrush = nullptr;
+ }
+ state = St::Idle;
+}
+
+// ---- state machine -----------------------------------------------------------
+
+#pragma warning(suppress : 26497)
+static unsigned int CurrentModifiersDown()
+{
+ unsigned int modifiers = 0;
+ const auto isDown = [](int vk) {
+ return (GetAsyncKeyState(vk) & 0x8000) != 0;
+ };
+
+ if (isDown(VK_MENU))
+ {
+ modifiers |= AltWindowCycleLogic::ModifierAlt;
+ }
+ if (isDown(VK_CONTROL))
+ {
+ modifiers |= AltWindowCycleLogic::ModifierCtrl;
+ }
+ if (isDown(VK_SHIFT))
+ {
+ modifiers |= AltWindowCycleLogic::ModifierShift;
+ }
+ if (isDown(VK_LWIN) || isDown(VK_RWIN))
+ {
+ modifiers |= AltWindowCycleLogic::ModifierWin;
+ }
+
+ return modifiers;
+}
+
+void Switcher::OnHotkey(bool forward, unsigned int holdModifiers)
+{
+ if (state == St::Idle)
+ {
+ HWND fg;
+ if (!GetAppWindows(fg, windows) || windows.size() < 2)
+ return;
+
+ int idx = -1;
+ for (size_t i = 0; i < windows.size(); ++i)
+ if (windows[i] == fg) { idx = static_cast(i); break; }
+ if (idx < 0)
+ idx = 0;
+
+ anchorWindow = fg;
+ activeHoldModifiers = AltWindowCycleLogic::StableHoldModifiers(holdModifiers);
+ const auto firstHotkey = AltWindowCycleLogic::BeginCycle(idx, static_cast(windows.size()), forward);
+ if (firstHotkey.action != AltWindowCycleLogic::FirstHotkeyAction::ShowOverlay)
+ return;
+
+ selected = firstHotkey.selected;
+ ShowOverlayWindow();
+ state = St::Visible;
+ SetTimer(overlay, TIMER_ID, TIMER_MS, nullptr);
+ }
+ else
+ {
+ selected = AltWindowCycleLogic::WrapIndex(forward ? selected + 1 : selected - 1, static_cast(windows.size()));
+ SetSelection(selected);
+ }
+}
+
+void Switcher::OnTick()
+{
+ if (!AltWindowCycleLogic::AreRequiredModifiersDown(activeHoldModifiers, CurrentModifiersDown()))
+ {
+ Commit();
+ return;
+ }
+}
+
+void Switcher::OnCancel()
+{
+ if (state == St::Visible)
+ {
+ Cancel();
+ }
+}
+
+void Switcher::Commit()
+{
+ KillTimer(overlay, TIMER_ID);
+ if (state == St::Visible)
+ HideOverlayWindow();
+ anchorWindow = nullptr;
+
+ if (selected >= 0 && selected < static_cast(windows.size()))
+ {
+ HWND target = windows[selected];
+ if (IsWindow(target))
+ ForceForeground(target);
+ }
+ state = St::Idle;
+}
+
+void Switcher::Cancel()
+{
+ KillTimer(overlay, TIMER_ID);
+ if (state == St::Visible)
+ HideOverlayWindow();
+ anchorWindow = nullptr;
+ state = St::Idle;
+}
+
+// ---- overlay window ----------------------------------------------------------
+
+static HMONITOR GetAnchorMonitor(HWND anchor)
+{
+ return MonitorFromWindow(anchor, MONITOR_DEFAULTTONEAREST);
+}
+
+static UINT GetMonitorEffectiveDpi(HMONITOR mon)
+{
+ UINT dpiX = 96, dpiY = 96;
+ if (mon && SUCCEEDED(GetDpiForMonitor(mon, MDT_EFFECTIVE_DPI, &dpiX, &dpiY)) && dpiX)
+ return dpiX;
+ return 96;
+}
+
+static RECT GetWorkArea(HMONITOR mon)
+{
+ MONITORINFO mi = {};
+ mi.cbSize = sizeof(mi);
+ if (mon && GetMonitorInfoW(mon, &mi))
+ return mi.rcWork;
+ RECT fallback = { 0, 0, 1920, 1080 };
+ return fallback;
+}
+
+static UINT GetDpiForHostOnMonitor(HWND host, HMONITOR mon, const RECT& work)
+{
+ if (host && mon)
+ {
+ SetWindowPos(host, nullptr, work.left, work.top, 1, 1,
+ SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOOWNERZORDER | SWP_HIDEWINDOW);
+ UINT dpi = GetDpiForWindow(host);
+ if (dpi)
+ return dpi;
+ }
+ return GetMonitorEffectiveDpi(mon);
+}
+
+static HICON GetWindowIcon(HWND hwnd)
+{
+ DWORD_PTR res = 0;
+ HICON icon = nullptr;
+ if (SendMessageTimeoutW(hwnd, WM_GETICON, ICON_SMALL2, 0, SMTO_ABORTIFHUNG, 100, &res) && res)
+ icon = reinterpret_cast(res);
+ if (!icon && SendMessageTimeoutW(hwnd, WM_GETICON, ICON_BIG, 0, SMTO_ABORTIFHUNG, 100, &res) && res)
+ icon = reinterpret_cast(res);
+ if (!icon)
+ icon = reinterpret_cast(GetClassLongPtrW(hwnd, GCLP_HICONSM));
+ if (!icon)
+ icon = reinterpret_cast(GetClassLongPtrW(hwnd, GCLP_HICON));
+ return icon;
+}
+
+static std::wstring GetTitle(HWND hwnd);
+
+void Switcher::ShowOverlayWindow()
+{
+ if (windows.empty())
+ return;
+
+ int anchorIdx = selected;
+ if (anchorIdx < 0) anchorIdx = 0;
+ if (anchorIdx >= static_cast(windows.size())) anchorIdx = 0;
+ HWND anchor = IsWindow(anchorWindow) ? anchorWindow : windows[anchorIdx];
+
+ HMONITOR mon = GetAnchorMonitor(anchor);
+ RECT work = GetWorkArea(mon);
+ UINT dpi = GetDpiForHostOnMonitor(thumbHost, mon, work);
+ scale = dpi / 96.0;
+
+ int x, y, panelW, panelH;
+ ComputeLayout(work, x, y, panelW, panelH);
+
+ EnsureFont();
+
+ icons.clear();
+ titles.clear();
+ for (HWND w : windows)
+ {
+ icons.push_back(GetWindowIcon(w));
+ titles.push_back(GetTitle(w));
+ }
+
+ ovX = x; ovY = y; ovW = panelW; ovH = panelH;
+
+ SetWindowPos(thumbHost, HWND_TOPMOST, x, y, panelW, panelH,
+ SWP_NOACTIVATE | SWP_NOOWNERZORDER);
+ ApplyBackdrop(thumbHost, AltTabStyle::IsLightTheme());
+ DWORD cornerPref = DWMWCP_ROUND;
+ DwmSetWindowAttribute(thumbHost, DWMWA_WINDOW_CORNER_PREFERENCE,
+ &cornerPref, sizeof(cornerPref));
+ HRGN rgn = CreateRoundRectRgn(0, 0, panelW + 1, panelH + 1,
+ 2 * Scaled(8), 2 * Scaled(8));
+ if (rgn != nullptr)
+ {
+ // SetWindowRgn takes ownership of the region on success; on failure the
+ // caller still owns it, so delete it to avoid leaking the HRGN.
+ if (SetWindowRgn(thumbHost, rgn, FALSE) == 0)
+ {
+ DeleteObject(rgn);
+ }
+ }
+ RedrawWindow(thumbHost, nullptr, nullptr,
+ RDW_INVALIDATE | RDW_ERASE | RDW_UPDATENOW | RDW_ALLCHILDREN);
+ RegisterThumbnails();
+
+ // Update the hidden layered bitmap before showing it. Otherwise, monitor/DPI
+ // switches can flash the previous-size overlay for one frame.
+ RenderLayered();
+ SetWindowPos(thumbHost, HWND_TOPMOST, x, y, panelW, panelH,
+ SWP_NOACTIVATE | SWP_SHOWWINDOW);
+ SetWindowPos(overlay, HWND_TOPMOST, x, y, panelW, panelH,
+ SWP_NOACTIVATE | SWP_SHOWWINDOW);
+ SetWindowPos(thumbHost, overlay, 0, 0, 0, 0,
+ SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE);
+ InstallEscapeHook();
+}
+
+void Switcher::HideOverlayWindow()
+{
+ RemoveEscapeHook();
+ ShowWindow(overlay, SW_HIDE);
+ ShowWindow(thumbHost, SW_HIDE);
+ UnregisterThumbnails();
+ icons.clear();
+ titles.clear();
+}
+
+void Switcher::InstallEscapeHook()
+{
+ if (!g_escapeHook)
+ {
+ g_escapeHook = SetWindowsHookExW(WH_KEYBOARD_LL, EscapeHookProc, nullptr, 0);
+ }
+}
+
+void Switcher::RemoveEscapeHook()
+{
+ if (g_escapeHook)
+ {
+ UnhookWindowsHookEx(g_escapeHook);
+ g_escapeHook = nullptr;
+ }
+}
+
+void Switcher::SetSelection(int index)
+{
+ selected = index;
+ const int nextPageStart = AltWindowCycleLogic::PageStartForSelection(
+ selected,
+ static_cast(windows.size()),
+ overlayLayout.pageSize);
+ if (nextPageStart != pageStart)
+ {
+ pageStart = nextPageStart;
+ RegisterThumbnails();
+ }
+ RenderLayered();
+}
+
+// React to a live OS Light/Dark theme switch while the overlay is visible:
+// refresh the backdrop (re-tints the solid fallback) and repaint the chrome.
+void Switcher::OnThemeChanged()
+{
+ if (state != St::Visible)
+ return;
+ ApplyBackdrop(thumbHost, AltTabStyle::IsLightTheme());
+ RedrawWindow(thumbHost, nullptr, nullptr,
+ RDW_INVALIDATE | RDW_ERASE | RDW_UPDATENOW | RDW_ALLCHILDREN);
+ RenderLayered();
+}
+
+void Switcher::ComputeLayout(const RECT& work, int& x, int& y, int& panelW, int& panelH)
+{
+ overlayLayout = AltWindowCycleLogic::ComputeOverlayLayout(work, static_cast(windows.size()), scale);
+ pad = overlayLayout.pad;
+ gap = overlayLayout.gap;
+ tileW = overlayLayout.tileW;
+ headerH = overlayLayout.headerH;
+ previewH = overlayLayout.previewH;
+ inner = overlayLayout.inner;
+ radius = overlayLayout.radius;
+ cardTrimBottom = overlayLayout.cardTrimBottom;
+ iconSize = overlayLayout.iconSize;
+ tileH = overlayLayout.tileH;
+ cols = overlayLayout.cols;
+ rows = overlayLayout.rows;
+ pageStart = AltWindowCycleLogic::PageStartForSelection(
+ selected,
+ static_cast(windows.size()),
+ overlayLayout.pageSize);
+
+ x = overlayLayout.panelX;
+ y = overlayLayout.panelY;
+ panelW = overlayLayout.panelW;
+ panelH = overlayLayout.panelH;
+}
+
+RECT Switcher::TileRect(int index) const
+{
+ return AltWindowCycleLogic::TileRect(overlayLayout, index);
+}
+
+RECT Switcher::PreviewRect(const RECT& tile) const
+{
+ return AltWindowCycleLogic::PreviewRect(overlayLayout, tile);
+}
+
+RECT Switcher::HeaderRect(const RECT& tile) const
+{
+ return AltWindowCycleLogic::HeaderRect(overlayLayout, tile);
+}
+
+static SIZE QueryThumbSize(HTHUMBNAIL th)
+{
+ SIZE s = { 0, 0 };
+ DwmQueryThumbnailSourceSize(th, &s);
+ return s;
+}
+
+static SIZE ClientSourceSize(HWND hwnd)
+{
+ RECT cr = {};
+ if (GetClientRect(hwnd, &cr))
+ {
+ SIZE s = { cr.right - cr.left, cr.bottom - cr.top };
+ if (s.cx > 0 && s.cy > 0)
+ return s;
+ }
+ return { 0, 0 };
+}
+
+void Switcher::RegisterThumbnails()
+{
+ UnregisterThumbnails();
+
+ const int pageEnd = (std::min)(
+ pageStart + overlayLayout.pageSize,
+ static_cast(windows.size()));
+ for (int windowIndex = pageStart, slot = 0; windowIndex < pageEnd; ++windowIndex, ++slot)
+ {
+ RECT dest = PreviewRect(TileRect(slot));
+ HTHUMBNAIL th = nullptr;
+ if (FAILED(DwmRegisterThumbnail(thumbHost, windows[windowIndex], &th)) || !th)
+ {
+ thumbs.push_back(nullptr);
+ continue;
+ }
+ thumbs.push_back(th);
+
+ SIZE clientSize = IsIconic(windows[windowIndex]) ? SIZE{ 0, 0 } : ClientSourceSize(windows[windowIndex]);
+ BOOL clientOnly = TRUE;
+ if (clientSize.cx <= 0 || clientSize.cy <= 0)
+ {
+ clientSize = QueryThumbSize(th);
+ clientOnly = FALSE;
+ }
+ RECT avail = { 0, 0, clientSize.cx, clientSize.cy };
+ if (clientOnly)
+ {
+ int ix = (std::min)(2, (int)(avail.right - avail.left) / 4);
+ int iy = (std::min)(2, (int)(avail.bottom - avail.top) / 4);
+ avail.left += ix; avail.right -= ix;
+ avail.top += iy; avail.bottom -= iy;
+ }
+ RECT rcSrc = AltWindowCycleLogic::CoverSource(dest, avail);
+ DWM_THUMBNAIL_PROPERTIES props = {};
+ props.dwFlags = DWM_TNP_RECTDESTINATION | DWM_TNP_RECTSOURCE |
+ DWM_TNP_VISIBLE | DWM_TNP_OPACITY | DWM_TNP_SOURCECLIENTAREAONLY;
+ props.rcDestination = dest;
+ props.rcSource = rcSrc;
+ props.opacity = 255;
+ props.fVisible = TRUE;
+ props.fSourceClientAreaOnly = clientOnly;
+ DwmUpdateThumbnailProperties(th, &props);
+ }
+}
+
+void Switcher::UnregisterThumbnails()
+{
+ for (HTHUMBNAIL th : thumbs)
+ if (th)
+ DwmUnregisterThumbnail(th);
+ thumbs.clear();
+}
+
+void Switcher::EnsureFont()
+{
+ if (font && fontScale == scale)
+ return;
+ if (font)
+ {
+ DeleteObject(font);
+ font = nullptr;
+ }
+ int height = -Scaled(14);
+ font = CreateFontW(height, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
+ DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
+ CLEARTYPE_NATURAL_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI");
+ fontScale = scale;
+}
+
+// ---- rendering helpers -------------------------------------------------------
+
+static void BuildRoundRect(Gdiplus::GraphicsPath& path, const Gdiplus::RectF& r, Gdiplus::REAL rad)
+{
+ path.Reset();
+ Gdiplus::REAL d = rad * 2;
+ if (d <= 0 || d > r.Width || d > r.Height)
+ {
+ path.AddRectangle(r);
+ return;
+ }
+ path.AddArc(r.X, r.Y, d, d, 180, 90);
+ path.AddArc(r.GetRight() - d, r.Y, d, d, 270, 90);
+ path.AddArc(r.GetRight() - d, r.GetBottom() - d, d, d, 0, 90);
+ path.AddArc(r.X, r.GetBottom() - d, d, d, 90, 90);
+ path.CloseFigure();
+}
+
+// Square top edge, rounded bottom corners. Used for the live preview viewport so
+// the full-bleed DWM thumbnail reads as rounded at the bottom of the card.
+static void BuildBottomRoundRect(Gdiplus::GraphicsPath& path, const Gdiplus::RectF& r, Gdiplus::REAL rad)
+{
+ path.Reset();
+ Gdiplus::REAL d = rad * 2;
+ if (d <= 0 || d > r.Width || d > r.Height)
+ {
+ path.AddRectangle(r);
+ return;
+ }
+ path.AddLine(r.X, r.Y, r.GetRight(), r.Y);
+ path.AddLine(r.GetRight(), r.Y, r.GetRight(), r.GetBottom() - rad);
+ path.AddArc(r.GetRight() - d, r.GetBottom() - d, d, d, 0, 90);
+ path.AddArc(r.X, r.GetBottom() - d, d, d, 90, 90);
+ path.CloseFigure();
+}
+
+static Gdiplus::RectF InflateF(const RECT& r, int by)
+{
+ return Gdiplus::RectF(
+ static_cast(r.left - by),
+ static_cast(r.top - by),
+ static_cast((r.right - r.left) + 2 * by),
+ static_cast((r.bottom - r.top) + 2 * by));
+}
+
+static std::wstring GetTitle(HWND hwnd)
+{
+ wchar_t buf[256];
+ int len = GetWindowTextW(hwnd, buf, static_cast(sizeof(buf) / sizeof(buf[0])));
+ return std::wstring(buf, len > 0 ? len : 0);
+}
+
+// Draw HICON into a premultiplied-alpha DIB without disturbing the alpha channel
+// of already-opaque pixels outside the icon shape.
+static void DrawIconOverPARGB(void* destBits, int destW, int destH,
+ HICON icon, int x, int y, int size)
+{
+ if (!destBits || !icon || destW <= 0 || destH <= 0 || size <= 0)
+ return;
+
+ HDC screen = GetDC(nullptr);
+ HDC iconDC = CreateCompatibleDC(screen);
+ if (!iconDC)
+ {
+ ReleaseDC(nullptr, screen);
+ return;
+ }
+
+ BITMAPINFO bi = {};
+ bi.bmiHeader.biSize = sizeof(bi.bmiHeader);
+ bi.bmiHeader.biWidth = size;
+ bi.bmiHeader.biHeight = -size;
+ bi.bmiHeader.biPlanes = 1;
+ bi.bmiHeader.biBitCount = 32;
+ bi.bmiHeader.biCompression = BI_RGB;
+
+ void* iconBits = nullptr;
+ HBITMAP dib = CreateDIBSection(screen, &bi, DIB_RGB_COLORS, &iconBits, nullptr, 0);
+ if (!dib)
+ {
+ DeleteDC(iconDC);
+ ReleaseDC(nullptr, screen);
+ return;
+ }
+
+ HGDIOBJ oldBmp = SelectObject(iconDC, dib);
+ ZeroMemory(iconBits, static_cast(size) * size * 4);
+ DrawIconEx(iconDC, 0, 0, icon, size, size, 0, nullptr, DI_NORMAL);
+
+ BYTE* src = static_cast(iconBits);
+ BYTE* dst = static_cast(destBits);
+
+ bool hasAlpha = false;
+ for (int i = 0; i < size * size; ++i)
+ {
+ if (src[i * 4 + 3] != 0)
+ {
+ hasAlpha = true;
+ break;
+ }
+ }
+
+ // Legacy (1-bit mask) icons carry no per-pixel alpha, so DrawIconEx leaves the
+ // alpha channel at 0 and a pure-black opaque pixel is indistinguishable from a
+ // transparent one by color alone. Render the icon over a white background on
+ // a second pass: pixels identical on both backgrounds are opaque (preserving black
+ // detail), pixels that differ by ~full white are transparent.
+ void* whiteBits = nullptr;
+ HBITMAP whiteDib = nullptr;
+ if (!hasAlpha)
+ {
+ whiteDib = CreateDIBSection(screen, &bi, DIB_RGB_COLORS, &whiteBits, nullptr, 0);
+ if (whiteDib)
+ {
+ SelectObject(iconDC, whiteDib);
+ memset(whiteBits, 0xFF, static_cast(size) * size * 4);
+ DrawIconEx(iconDC, 0, 0, icon, size, size, 0, nullptr, DI_NORMAL);
+ }
+ }
+ BYTE* white = static_cast(whiteBits);
+
+ for (int sy = 0; sy < size; ++sy)
+ {
+ int dy = y + sy;
+ if (dy < 0 || dy >= destH)
+ continue;
+ for (int sx = 0; sx < size; ++sx)
+ {
+ int dx = x + sx;
+ if (dx < 0 || dx >= destW)
+ continue;
+
+ size_t srcOff = (static_cast(sy) * size + sx) * 4;
+ BYTE* s = src + srcOff;
+ BYTE* d = dst + (static_cast(dy) * destW + dx) * 4;
+
+ if (hasAlpha)
+ {
+ int a = s[3];
+ if (a == 0)
+ continue;
+
+ int sb = s[0], sg = s[1], sr = s[2];
+ if (sb > a || sg > a || sr > a)
+ {
+ sb = (sb * a + 127) / 255;
+ sg = (sg * a + 127) / 255;
+ sr = (sr * a + 127) / 255;
+ }
+ int inv = 255 - a;
+ d[0] = static_cast((std::min)(255, sb + (d[0] * inv + 127) / 255));
+ d[1] = static_cast((std::min)(255, sg + (d[1] * inv + 127) / 255));
+ d[2] = static_cast((std::min)(255, sr + (d[2] * inv + 127) / 255));
+ d[3] = 255;
+ }
+ else
+ {
+ bool opaque;
+ if (white)
+ {
+ BYTE* whitePx = white + srcOff;
+ int diff = (whitePx[0] - s[0]) + (whitePx[1] - s[1]) + (whitePx[2] - s[2]);
+ opaque = diff < 384; // < half of 3*255 → covered on both backgrounds
+ }
+ else
+ {
+ opaque = (s[0] || s[1] || s[2]);
+ }
+ if (opaque)
+ {
+ d[0] = s[0]; d[1] = s[1]; d[2] = s[2]; d[3] = 255;
+ }
+ }
+ }
+ }
+
+ SelectObject(iconDC, oldBmp);
+ DeleteObject(dib);
+ if (whiteDib)
+ DeleteObject(whiteDib);
+ DeleteDC(iconDC);
+ ReleaseDC(nullptr, screen);
+}
+
+static void DrawHeaderText(BYTE* destBits, int destW, int destH, HFONT fontHandle,
+ const RECT& rc, const std::wstring& text,
+ COLORREF textColor,
+ UINT alignFlag = DT_LEFT)
+{
+ if (!destBits || destW <= 0 || destH <= 0 || text.empty() || !fontHandle)
+ return;
+
+ int w = rc.right - rc.left;
+ int h = rc.bottom - rc.top;
+ if (w <= 0 || h <= 0)
+ return;
+
+ HDC screen = GetDC(nullptr);
+ HDC textDC = CreateCompatibleDC(screen);
+
+ BITMAPINFO bi = {};
+ bi.bmiHeader.biSize = sizeof(bi.bmiHeader);
+ bi.bmiHeader.biWidth = w;
+ bi.bmiHeader.biHeight = -h;
+ bi.bmiHeader.biPlanes = 1;
+ bi.bmiHeader.biBitCount = 32;
+ bi.bmiHeader.biCompression = BI_RGB;
+
+ void* scratchBits = nullptr;
+ HBITMAP scratch = CreateDIBSection(screen, &bi, DIB_RGB_COLORS, &scratchBits, nullptr, 0);
+ if (!scratch)
+ {
+ DeleteDC(textDC);
+ ReleaseDC(nullptr, screen);
+ return;
+ }
+
+ HGDIOBJ oldBmp = SelectObject(textDC, scratch);
+ HGDIOBJ oldFont = SelectObject(textDC, fontHandle);
+ RECT fill = { 0, 0, w, h };
+ // Render white text on a black scratch; the luminance doubles as a coverage
+ // mask so the glyphs can be composited onto the translucent card without
+ // painting an opaque background box behind them.
+ HBRUSH bg = CreateSolidBrush(RGB(0, 0, 0));
+ FillRect(textDC, &fill, bg);
+ DeleteObject(bg);
+
+ int oldBk = SetBkMode(textDC, TRANSPARENT);
+ COLORREF oldColor = SetTextColor(textDC, RGB(255, 255, 255));
+
+ RECT textRc = fill;
+ DrawTextW(textDC, text.c_str(), static_cast(text.size()), &textRc,
+ alignFlag | DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX);
+
+ SetTextColor(textDC, oldColor);
+ SetBkMode(textDC, oldBk);
+ SelectObject(textDC, oldFont);
+
+ const int tb = GetBValue(textColor);
+ const int tg = GetGValue(textColor);
+ const int tr = GetRValue(textColor);
+ const BYTE* src = static_cast(scratchBits);
+ for (int y = 0; y < h; ++y)
+ {
+ int dy = rc.top + y;
+ if (dy < 0 || dy >= destH)
+ continue;
+ for (int x = 0; x < w; ++x)
+ {
+ int dx = rc.left + x;
+ if (dx < 0 || dx >= destW)
+ continue;
+
+ const BYTE* s = src + (static_cast(y) * w + x) * 4;
+ int a = (s[0] + s[1] + s[2] + 1) / 3; // glyph coverage 0..255
+ if (a == 0)
+ continue;
+
+ // Source-over onto premultiplied-alpha dest (BGRA). The text is opaque,
+ // so its premultiplied contribution is colour * coverage.
+ BYTE* d = destBits + (static_cast(dy) * destW + dx) * 4;
+ int inv = 255 - a;
+ d[0] = static_cast((tb * a + 127) / 255 + (d[0] * inv + 127) / 255);
+ d[1] = static_cast((tg * a + 127) / 255 + (d[1] * inv + 127) / 255);
+ d[2] = static_cast((tr * a + 127) / 255 + (d[2] * inv + 127) / 255);
+ d[3] = static_cast(a + (d[3] * inv + 127) / 255);
+ }
+ }
+
+ SelectObject(textDC, oldBmp);
+ DeleteObject(scratch);
+ DeleteDC(textDC);
+ ReleaseDC(nullptr, screen);
+}
+
+static COLORREF GetAccentColor()
+{
+ DWORD color = 0, size = sizeof(color), type = 0;
+ HKEY key = nullptr;
+ if (RegOpenKeyExW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\DWM",
+ 0, KEY_QUERY_VALUE, &key) == ERROR_SUCCESS)
+ {
+ LONG r = RegQueryValueExW(key, L"AccentColor", nullptr, &type,
+ reinterpret_cast(&color), &size);
+ RegCloseKey(key);
+ if (r == ERROR_SUCCESS && type == REG_DWORD)
+ return color & 0x00FFFFFF;
+ }
+ DWORD argb = 0;
+ BOOL opaque = FALSE;
+ if (SUCCEEDED(DwmGetColorizationColor(&argb, &opaque)))
+ return RGB((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF);
+
+ return AltTabStyle::AccentFallbackRef();
+}
+
+void Switcher::RenderLayered()
+{
+ int w = ovW, h = ovH;
+ if (w <= 0 || h <= 0)
+ return;
+
+ HDC screenDC = GetDC(nullptr);
+ HDC memDC = CreateCompatibleDC(screenDC);
+
+ BITMAPINFO bi = {};
+ bi.bmiHeader.biSize = sizeof(bi.bmiHeader);
+ bi.bmiHeader.biWidth = w;
+ bi.bmiHeader.biHeight = -h;
+ bi.bmiHeader.biPlanes = 1;
+ bi.bmiHeader.biBitCount = 32;
+ bi.bmiHeader.biCompression = BI_RGB;
+
+ void* bits = nullptr;
+ HBITMAP dib = CreateDIBSection(screenDC, &bi, DIB_RGB_COLORS, &bits, nullptr, 0);
+ if (!dib)
+ {
+ DeleteDC(memDC);
+ ReleaseDC(nullptr, screenDC);
+ return;
+ }
+ HGDIOBJ oldBmp = SelectObject(memDC, dib);
+ ZeroMemory(bits, static_cast(w) * h * 4);
+
+ {
+ Gdiplus::Bitmap bmp(w, h, w * 4, PixelFormat32bppPARGB,
+ static_cast(bits));
+ Gdiplus::Graphics g(&bmp);
+ g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ g.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit);
+
+ // Leave the panel background transparent; acrylic lives on thumbHost.
+ bool light = AltTabStyle::IsLightTheme();
+ Gdiplus::REAL panelRadius = static_cast(Scaled(8));
+ RECT panelRect = { 0, 0, w, h };
+ Gdiplus::GraphicsPath panel;
+ BuildRoundRect(panel, InflateF(panelRect, 0), panelRadius);
+ Gdiplus::SolidBrush panelBrush(AltTabStyle::Transparent());
+ g.SetCompositingMode(Gdiplus::CompositingModeSourceCopy);
+ g.FillPath(&panelBrush, &panel);
+ g.SetCompositingMode(Gdiplus::CompositingModeSourceOver);
+ Gdiplus::Pen panelStroke(AltTabStyle::SurfaceStrokeDefault(light),
+ static_cast((std::max)(1, Scaled(1))));
+ g.DrawPath(&panelStroke, &panel);
+
+ COLORREF accent = GetAccentColor();
+ Gdiplus::Color accentClr = AltTabStyle::Accent(accent);
+
+ const int pageEnd = (std::min)(
+ pageStart + overlayLayout.pageSize,
+ static_cast(windows.size()));
+ for (int windowIndex = pageStart, slot = 0; windowIndex < pageEnd; ++windowIndex, ++slot)
+ {
+ RECT tile = TileRect(slot);
+ bool sel = (windowIndex == selected);
+ RECT pv = PreviewRect(tile);
+
+ // Full rounded card chrome. The live DWM preview is a rectangular
+ // viewport inside it, which avoids fighting public DWM's square thumbnail.
+ Gdiplus::GraphicsPath cardPath;
+ BuildRoundRect(cardPath, InflateF(tile, 0), static_cast(radius));
+ Gdiplus::SolidBrush cardBrush(AltTabStyle::Card(light));
+ g.FillPath(&cardBrush, &cardPath);
+
+ g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ Gdiplus::Pen cardPen(AltTabStyle::CardStroke(light),
+ static_cast((std::max)(1, Scaled(1))));
+ g.DrawPath(&cardPen, &cardPath);
+
+ int pw = pv.right - pv.left;
+ int ph = pv.bottom - pv.top;
+ if (pw > 0 && ph > 0)
+ {
+ Gdiplus::RectF pvF(
+ static_cast(pv.left),
+ static_cast(pv.top),
+ static_cast(pw),
+ static_cast(ph));
+
+ // Bottom-rounded viewport for the DWM thumbnail (square top, rounded
+ // bottom corners matching the card radius).
+ Gdiplus::GraphicsPath pvHole;
+ BuildBottomRoundRect(pvHole, pvF, static_cast(radius));
+
+ // The DWM thumbnail is a square rectangle that lives on the acrylic
+ // host behind this layer. Lay an OPAQUE card backing across the whole
+ // preview (clipped to the rounded card so it can't spill past the
+ // card's corners), then punch the rounded-bottom hole. The thumbnail
+ // only shows through the rounded viewport; its square bottom corners
+ // stay hidden behind the opaque backing. (A translucent card alone
+ // would let those square corners bleed through.)
+ COLORREF cardSolid = AltTabStyle::CardRef(light);
+ Gdiplus::SolidBrush previewBacking(Gdiplus::Color(
+ 255, GetRValue(cardSolid), GetGValue(cardSolid), GetBValue(cardSolid)));
+ g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ g.SetClip(&cardPath, Gdiplus::CombineModeReplace);
+ g.SetCompositingMode(Gdiplus::CompositingModeSourceOver);
+ g.FillRectangle(&previewBacking, pvF);
+
+ // Punch the rounded-bottom transparent hole; the acrylic host with the
+ // live thumbnail composites behind it.
+ g.SetCompositingMode(Gdiplus::CompositingModeSourceCopy);
+ Gdiplus::SolidBrush previewBrush(AltTabStyle::Transparent());
+ g.FillPath(&previewBrush, &pvHole);
+ g.SetCompositingMode(Gdiplus::CompositingModeSourceOver);
+ g.ResetClip();
+ }
+
+ // Header tab: app icon + window title.
+ RECT hdr = HeaderRect(tile);
+ int textLeft = hdr.left;
+ HICON ic = (windowIndex < static_cast(icons.size())) ? icons[windowIndex] : nullptr;
+ if (ic)
+ {
+ int iy = tile.top + (headerH - iconSize) / 2;
+ g.Flush();
+ DrawIconOverPARGB(bits, w, h, ic, hdr.left, iy, iconSize);
+ textLeft = hdr.left + iconSize + Scaled(8);
+ }
+
+ const std::wstring* text = (windowIndex < static_cast(titles.size())) ? &titles[windowIndex] : nullptr;
+ if (text)
+ {
+ g.Flush();
+ RECT textRc = { textLeft, tile.top, hdr.right, tile.top + headerH };
+ DrawHeaderText(static_cast(bits), w, h, font, textRc, *text,
+ AltTabStyle::HeaderTextRef(light));
+ }
+
+ // Two-ring accent focus ring around the selected tile.
+ if (sel)
+ {
+ int gPad = Scaled(6);
+ int gOut = gPad + Scaled(2);
+ int outerRadius = Scaled(18);
+ int innerRadius = outerRadius - Scaled(2);
+ Gdiplus::GraphicsPath innerRing, out;
+ BuildRoundRect(innerRing, InflateF(tile, gPad),
+ static_cast(innerRadius));
+ BuildRoundRect(out, InflateF(tile, gOut),
+ static_cast(outerRadius));
+ Gdiplus::Pen darkPen(AltTabStyle::FocusShadow(light),
+ static_cast((std::max)(1, Scaled(1))));
+ Gdiplus::Pen accentPen(accentClr,
+ static_cast((std::max)(2, Scaled(4))));
+ g.DrawPath(&darkPen, &innerRing);
+ g.DrawPath(&accentPen, &out);
+ }
+ }
+
+ // Pagination affordance: when the cycle set doesn't fit on one screenful,
+ // the panel is otherwise indistinguishable from "these are all my windows".
+ const int totalPages = AltWindowCycleLogic::PageCount(
+ static_cast(windows.size()), overlayLayout.pageSize);
+ if (totalPages > 1)
+ {
+ const int currentPage = (pageStart / (std::max)(1, overlayLayout.pageSize)) + 1;
+ const std::wstring pageText =
+ std::to_wstring(currentPage) + L" / " + std::to_wstring(totalPages);
+ RECT pageRc = { pad, h - pad, w - pad, h };
+ DrawHeaderText(static_cast(bits), w, h, font, pageRc, pageText,
+ AltTabStyle::HeaderTextRef(light), DT_CENTER);
+ }
+
+ g.Flush();
+ }
+
+ POINT ptDst = { ovX, ovY };
+ SIZE sz = { w, h };
+ POINT ptSrc = { 0, 0 };
+ BLENDFUNCTION bf = { AC_SRC_OVER, 0, 255, AC_SRC_ALPHA };
+ UpdateLayeredWindow(overlay, screenDC, &ptDst, &sz, memDC, &ptSrc, 0, &bf, ULW_ALPHA);
+
+ SelectObject(memDC, oldBmp);
+ DeleteObject(dib);
+ DeleteDC(memDC);
+ ReleaseDC(nullptr, screenDC);
+}
+
+LRESULT CALLBACK Switcher::WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
+{
+ Switcher* self = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
+ if (self)
+ {
+ switch (msg)
+ {
+ case WM_TIMER:
+ if (wParam == TIMER_ID)
+ {
+ self->OnTick();
+ return 0;
+ }
+ break;
+ case WM_ERASEBKGND:
+ return 1;
+ case WM_SETTINGCHANGE:
+ if (lParam &&
+ lstrcmpiW(reinterpret_cast(lParam), L"ImmersiveColorSet") == 0)
+ {
+ self->OnThemeChanged();
+ return 0;
+ }
+ break;
+ }
+ }
+ return DefWindowProcW(hwnd, msg, wParam, lParam);
+}
+
+// =================== Dedicated UI thread =====================================
+
+static Switcher g_switcher;
+static HANDLE g_uiThread = nullptr;
+static std::atomic g_initOk{ false };
+static std::atomic g_shutdownRequested{ false };
+static HINSTANCE g_threadHinst = nullptr;
+static HANDLE g_threadReadyEvent = nullptr;
+
+// The runner calls enable()/disable()/on_hotkey() on the same thread that services
+// the centralized WH_KEYBOARD_LL hook, and Windows silently drops hooks that exceed
+// LowLevelHooksTimeout (300 ms by default). Every wait on that thread stays well
+// under that budget; only destroy(), which is followed by FreeLibrary, joins
+// without a bound.
+static const DWORD UIThreadInitWaitMs = 200;
+static const DWORD UIThreadJoinTimeoutMs = 200;
+static const DWORD UIThreadJoinPollMs = 20;
+
+static void CloseUIThreadHandle()
+{
+ CloseHandle(g_uiThread);
+ g_uiThread = nullptr;
+ g_uiThreadId = 0;
+ g_threadHinst = nullptr;
+ g_initOk.store(false);
+}
+
+static void ClearExitedUIThread()
+{
+ if (g_uiThread && WaitForSingleObject(g_uiThread, 0) == WAIT_OBJECT_0)
+ {
+ CloseUIThreadHandle();
+ }
+}
+
+// Asks the UI thread to quit and waits up to `timeoutMs` for it to exit. Returns
+// true when the thread is gone and the globals have been reset; false means the
+// thread is still winding down and the handle was deliberately left open.
+static bool RequestShutdownAndJoinUIThread(DWORD timeoutMs)
+{
+ if (!g_uiThread)
+ {
+ return true;
+ }
+
+ g_shutdownRequested.store(true);
+
+ const bool bounded = timeoutMs != INFINITE;
+ const ULONGLONG deadline = bounded ? GetTickCount64() + timeoutMs : 0;
+
+ // The queue may not exist yet when initialization times out, so WM_QUIT is
+ // re-posted until the bounded UI-thread work completes and the thread exits.
+ for (;;)
+ {
+ if (g_uiThreadId)
+ {
+ PostThreadMessageW(g_uiThreadId, WM_QUIT, 0, 0);
+ }
+
+ DWORD slice = UIThreadJoinPollMs;
+ if (bounded)
+ {
+ const ULONGLONG now = GetTickCount64();
+ if (now >= deadline)
+ {
+ return false;
+ }
+
+ slice = static_cast((std::min)(UIThreadJoinPollMs, deadline - now));
+ }
+
+ if (WaitForSingleObject(g_uiThread, slice) == WAIT_OBJECT_0)
+ {
+ break;
+ }
+ }
+
+ CloseUIThreadHandle();
+ return true;
+}
+
+static bool PostHotkeyToUIThread(bool forward, unsigned int holdModifiers)
+{
+ return g_uiThreadId &&
+ PostThreadMessageW(g_uiThreadId, WM_AWC_HOTKEY, forward ? 1u : 0u, static_cast(holdModifiers)) != FALSE;
+}
+
+static DWORD WINAPI UIThreadProc(LPVOID param)
+{
+ UNREFERENCED_PARAMETER(param);
+
+ HINSTANCE hinst = g_threadHinst;
+ HANDLE readyEvent = g_threadReadyEvent;
+ g_threadReadyEvent = nullptr;
+
+ // Set per-monitor v2 DPI awareness on this thread so overlay layout and DWM
+ // thumbnail rects line up on any monitor. Does not affect the host process.
+ SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
+
+ Gdiplus::GdiplusStartupInput gdiplusStartupInput = {};
+ ULONG_PTR token = 0;
+ Gdiplus::GdiplusStartup(&token, &gdiplusStartupInput, nullptr);
+
+ const bool initOk = g_switcher.Init(hinst);
+ g_initOk.store(initOk);
+
+ // Signal the caller that initialization is complete (success or failure).
+ if (readyEvent)
+ {
+ SetEvent(readyEvent);
+ CloseHandle(readyEvent);
+ }
+
+ if (!initOk || g_shutdownRequested.load())
+ {
+ g_switcher.Shutdown();
+ if (token)
+ Gdiplus::GdiplusShutdown(token);
+ return initOk ? 0 : 1;
+ }
+
+ MSG msg;
+ while (GetMessageW(&msg, nullptr, 0, 0) > 0)
+ {
+ if (msg.hwnd == nullptr && msg.message == WM_AWC_HOTKEY)
+ {
+ g_switcher.OnHotkey(msg.wParam != 0, static_cast(msg.lParam));
+ }
+ else if (msg.hwnd == nullptr && msg.message == WM_AWC_CANCEL)
+ {
+ g_switcher.OnCancel();
+ }
+ else
+ {
+ TranslateMessage(&msg);
+ DispatchMessageW(&msg);
+ }
+ }
+
+ // Destroy all overlay resources BEFORE GdiplusShutdown.
+ g_switcher.Shutdown();
+
+ if (token)
+ Gdiplus::GdiplusShutdown(token);
+
+ return 0;
+}
+
+// =================== Public module API =======================================
+
+bool InitializeAltWindowCycle(HINSTANCE hinst)
+{
+ ClearExitedUIThread();
+
+ // A previous disable() may have left a thread winding down. Give it a short,
+ // bounded chance to finish rather than racing a second UI thread onto the
+ // single global Switcher.
+ if (g_uiThread != nullptr && g_shutdownRequested.load())
+ {
+ RequestShutdownAndJoinUIThread(UIThreadJoinTimeoutMs);
+ }
+
+ if (g_uiThread != nullptr)
+ {
+ if (g_shutdownRequested.load())
+ {
+ Logger::error("AltWindowCycle UI thread from a previous session is still running; enable aborted");
+ return false;
+ }
+
+ return true; // already running or still starting
+ }
+
+ g_initOk.store(false);
+ g_shutdownRequested.store(false);
+
+ HANDLE readyEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr);
+ if (!readyEvent)
+ return false;
+
+ HANDLE readyEventForThread = nullptr;
+ if (!DuplicateHandle(GetCurrentProcess(), readyEvent, GetCurrentProcess(), &readyEventForThread, 0, FALSE, DUPLICATE_SAME_ACCESS))
+ {
+ CloseHandle(readyEvent);
+ return false;
+ }
+
+ g_threadHinst = hinst;
+ g_threadReadyEvent = readyEventForThread;
+
+ DWORD tid = 0;
+ g_uiThread = CreateThread(nullptr, 0, UIThreadProc, nullptr, 0, &tid);
+ if (!g_uiThread)
+ {
+ g_threadReadyEvent = nullptr;
+ CloseHandle(readyEventForThread);
+ CloseHandle(readyEvent);
+ return false;
+ }
+ g_uiThreadId = tid;
+
+ // Briefly wait for Init() so an outright failure is reported synchronously. A
+ // timeout is not fatal: the thread keeps starting and the first hotkey simply
+ // arrives once its message queue exists.
+ const DWORD waitResult = WaitForSingleObject(readyEvent, UIThreadInitWaitMs);
+ CloseHandle(readyEvent);
+ if (waitResult == WAIT_OBJECT_0 && !g_initOk.load())
+ {
+ RequestShutdownAndJoinUIThread(UIThreadJoinTimeoutMs);
+ return false;
+ }
+
+ return true;
+}
+
+void ShutdownAltWindowCycle(bool blockUntilExit)
+{
+ if (!RequestShutdownAndJoinUIThread(blockUntilExit ? INFINITE : UIThreadJoinTimeoutMs))
+ {
+ Logger::warn("AltWindowCycle UI thread did not exit within the disable() budget; it will finish asynchronously");
+ }
+}
+
+bool HandleAltWindowCycleHotkey(bool forward, unsigned int holdModifiers)
+{
+ ClearExitedUIThread();
+ if (!g_uiThread || g_shutdownRequested.load())
+ return false;
+
+ return PostHotkeyToUIThread(forward, holdModifiers);
+}
diff --git a/src/modules/AltWindowCycle/AltWindowCycle.h b/src/modules/AltWindowCycle/AltWindowCycle.h
new file mode 100644
index 0000000000..db18b289f4
--- /dev/null
+++ b/src/modules/AltWindowCycle/AltWindowCycle.h
@@ -0,0 +1,21 @@
+#pragma once
+
+// Starts the dedicated UI thread that owns all overlay windows and the Switcher
+// state machine. Must be called from the PowerToys module enable() path.
+// Safe to call multiple times (idempotent).
+bool InitializeAltWindowCycle(HINSTANCE hinst);
+
+// Stops the UI thread and destroys all overlay resources. Safe to call when not
+// initialized (idempotent).
+//
+// The runner drives enable()/disable() on the same thread that services the
+// centralized low-level keyboard hook, so disable() must not stall it: pass
+// `blockUntilExit = false` there and let the UI thread finish on its own. Pass
+// `blockUntilExit = true` only from destroy(), where the runner unloads the DLL
+// afterwards and the thread must be gone first.
+void ShutdownAltWindowCycle(bool blockUntilExit);
+
+// Called from on_hotkey() on the runner thread. Posts to the UI thread without
+// enumerating windows. `holdModifiers` is an AltWindowCycleLogic modifier mask
+// that controls which modifier release commits the visible cycle.
+bool HandleAltWindowCycleHotkey(bool forward, unsigned int holdModifiers);
diff --git a/src/modules/AltWindowCycle/AltWindowCycle.rc b/src/modules/AltWindowCycle/AltWindowCycle.rc
new file mode 100644
index 0000000000..e9b85fafdf
--- /dev/null
+++ b/src/modules/AltWindowCycle/AltWindowCycle.rc
@@ -0,0 +1,40 @@
+#include
+#include "resource.h"
+#include "../../common/version/version.h"
+
+#define APSTUDIO_READONLY_SYMBOLS
+#include "winres.h"
+#undef APSTUDIO_READONLY_SYMBOLS
+
+1 VERSIONINFO
+FILEVERSION FILE_VERSION
+PRODUCTVERSION PRODUCT_VERSION
+FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
+#ifdef _DEBUG
+FILEFLAGS VS_FF_DEBUG
+#else
+FILEFLAGS 0x0L
+#endif
+FILEOS VOS_NT_WINDOWS32
+FILETYPE VFT_DLL
+FILESUBTYPE VFT2_UNKNOWN
+BEGIN
+ BLOCK "StringFileInfo"
+ BEGIN
+ BLOCK "040904b0" // US English (0x0409), Unicode (0x04B0) charset
+ BEGIN
+ VALUE "CompanyName", COMPANY_NAME
+ VALUE "FileDescription", FILE_DESCRIPTION
+ VALUE "FileVersion", FILE_VERSION_STRING
+ VALUE "InternalName", INTERNAL_NAME
+ VALUE "LegalCopyright", COPYRIGHT_NOTE
+ VALUE "OriginalFilename", ORIGINAL_FILENAME
+ VALUE "ProductName", PRODUCT_NAME
+ VALUE "ProductVersion", PRODUCT_VERSION_STRING
+ END
+ END
+ BLOCK "VarFileInfo"
+ BEGIN
+ VALUE "Translation", 0x409, 1200 // US English (0x0409), Unicode (1200) charset
+ END
+END
diff --git a/src/modules/AltWindowCycle/AltWindowCycle.vcxproj b/src/modules/AltWindowCycle/AltWindowCycle.vcxproj
new file mode 100644
index 0000000000..008499528e
--- /dev/null
+++ b/src/modules/AltWindowCycle/AltWindowCycle.vcxproj
@@ -0,0 +1,130 @@
+
+
+
+
+
+ 15.0
+ {4d1d41c7-e22b-4a8b-8805-660a100e1d79}
+ Win32Proj
+ AltWindowCycle
+ AltWindowCycle
+
+
+
+ DynamicLibrary
+ true
+ Unicode
+
+
+ DynamicLibrary
+ false
+ true
+ Unicode
+
+
+
+
+
+
+
+
+
+
+
+ $(RepoRoot)$(Platform)\$(Configuration)\
+ PowerToys.AltWindowCycle
+
+
+ true
+
+
+ false
+
+
+
+ Level3
+ Disabled
+ true
+ _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)
+ true
+ MultiThreadedDebug
+ stdcpplatest
+ Use
+ pch.h
+
+
+ Windows
+ true
+ $(OutDir)$(TargetName)$(TargetExt)
+ dwmapi.lib;gdi32.lib;gdiplus.lib;shcore.lib;%(AdditionalDependencies)
+
+
+
+
+ Level3
+ MaxSpeed
+ true
+ true
+ true
+ NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)
+ true
+ MultiThreaded
+ stdcpplatest
+ Use
+ pch.h
+
+
+ Windows
+ true
+ true
+ true
+ $(OutDir)$(TargetName)$(TargetExt)
+ dwmapi.lib;gdi32.lib;gdiplus.lib;shcore.lib;%(AdditionalDependencies)
+
+
+
+
+ $(RepoRoot)src\;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Create
+
+
+
+
+
+
+
+
+ {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}
+
+
+ {6955446d-23f7-4023-9bb3-8657f904af99}
+
+
+
+
+
+
+
+
+
+
+
+ This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.
+
+
+
+
+
diff --git a/src/modules/AltWindowCycle/AltWindowCycle.vcxproj.filters b/src/modules/AltWindowCycle/AltWindowCycle.vcxproj.filters
new file mode 100644
index 0000000000..2470812045
--- /dev/null
+++ b/src/modules/AltWindowCycle/AltWindowCycle.vcxproj.filters
@@ -0,0 +1,56 @@
+
+
+
+
+ {4FC737F1-C7A5-4376-A066-2A32D752A2FF}
+ cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx
+
+
+ {93995380-89BD-4b04-88EB-625FBE52EBFB}
+ h;hh;hpp;hxx;hm;inl;inc;ipp;xsd
+
+
+ {67DA6AB6-F800-4c08-8B7A-83BB121AAD01}
+ rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms
+
+
+
+
+ Source Files
+
+
+ Source Files
+
+
+ Source Files
+
+
+ Source Files
+
+
+
+
+ Header Files
+
+
+ Header Files
+
+
+ Header Files
+
+
+ Header Files
+
+
+ Header Files
+
+
+
+
+ Resource Files
+
+
+
+
+
+
diff --git a/src/modules/AltWindowCycle/AltWindowCycleLogic.h b/src/modules/AltWindowCycle/AltWindowCycleLogic.h
new file mode 100644
index 0000000000..3e88c1b110
--- /dev/null
+++ b/src/modules/AltWindowCycle/AltWindowCycleLogic.h
@@ -0,0 +1,295 @@
+#pragma once
+
+#ifndef WIN32_LEAN_AND_MEAN
+#define WIN32_LEAN_AND_MEAN
+#endif
+#include
+
+#include
+#include
+#include
+#include
+
+namespace AltWindowCycleLogic
+{
+ constexpr int DefaultMaxColumns = 6;
+ constexpr unsigned int ModifierAlt = 1u << 0;
+ constexpr unsigned int ModifierCtrl = 1u << 1;
+ constexpr unsigned int ModifierShift = 1u << 2;
+ constexpr unsigned int ModifierWin = 1u << 3;
+ constexpr unsigned int AllModifiers = ModifierAlt | ModifierCtrl | ModifierShift | ModifierWin;
+
+ struct OverlayLayout
+ {
+ double scale = 1.0;
+ int pad = 0;
+ int gap = 0;
+ int tileW = 0;
+ int tileH = 0;
+ int headerH = 0;
+ int previewH = 0;
+ int inner = 0;
+ int radius = 0;
+ int cardTrimBottom = 0;
+ int iconSize = 0;
+ int cols = 1;
+ int rows = 0;
+ int pageSize = 0;
+ int panelX = 0;
+ int panelY = 0;
+ int panelW = 0;
+ int panelH = 0;
+ };
+
+ enum class FirstHotkeyAction
+ {
+ Ignore,
+ ShowOverlay,
+ };
+
+ struct FirstHotkeyResult
+ {
+ int selected = 0;
+ FirstHotkeyAction action = FirstHotkeyAction::Ignore;
+ };
+
+ constexpr int ScaledValue(double scale, int value)
+ {
+ return static_cast(value * scale + 0.5);
+ }
+
+ constexpr unsigned int StableHoldModifiers(unsigned int configuredModifiers)
+ {
+ const unsigned int sanitized = configuredModifiers & AllModifiers;
+ const unsigned int nonShiftModifiers = sanitized & ~ModifierShift;
+ return nonShiftModifiers != 0 ? nonShiftModifiers : sanitized;
+ }
+
+ constexpr bool AreRequiredModifiersDown(unsigned int requiredModifiers, unsigned int downModifiers)
+ {
+ return requiredModifiers != 0 && (downModifiers & requiredModifiers) == requiredModifiers;
+ }
+
+ inline int WrapIndex(int index, int count)
+ {
+ if (count <= 0)
+ {
+ return 0;
+ }
+
+ return ((index % count) + count) % count;
+ }
+
+ inline FirstHotkeyResult BeginCycle(int currentIndex, int windowCount, bool forward)
+ {
+ if (windowCount < 2)
+ {
+ return {};
+ }
+
+ const int normalizedCurrentIndex = currentIndex < 0 ? 0 : currentIndex;
+ return {
+ WrapIndex(forward ? normalizedCurrentIndex + 1 : normalizedCurrentIndex - 1, windowCount),
+ FirstHotkeyAction::ShowOverlay
+ };
+ }
+
+ inline OverlayLayout ComputeOverlayLayout(const RECT& work, int windowCount, double scale, int maxColumns = DefaultMaxColumns)
+ {
+ OverlayLayout layout;
+ layout.scale = scale;
+ layout.pad = ScaledValue(scale, 32);
+ layout.gap = ScaledValue(scale, 26);
+ layout.tileW = ScaledValue(scale, 270);
+ layout.headerH = ScaledValue(scale, 48);
+ layout.previewH = ScaledValue(scale, 142);
+ layout.inner = ScaledValue(scale, 6);
+ layout.radius = ScaledValue(scale, 10);
+ layout.cardTrimBottom = 0;
+ layout.iconSize = ScaledValue(scale, 16);
+ layout.tileH = layout.headerH + layout.inner + layout.previewH + layout.inner;
+
+ const int workW = work.right - work.left;
+ const int workH = work.bottom - work.top;
+ const int safeWindowCount = (std::max)(0, windowCount);
+ const int safeMaxColumns = (std::max)(1, maxColumns);
+ const int columnsFromWork = (std::max)(1, (workW - 2 * layout.pad + layout.gap) / (layout.tileW + layout.gap));
+
+ layout.cols = (std::min)(safeWindowCount, (std::min)(safeMaxColumns, columnsFromWork));
+ if (layout.cols < 1)
+ {
+ layout.cols = 1;
+ }
+
+ const int totalRows = (safeWindowCount + layout.cols - 1) / layout.cols;
+ const int rowsFromWork = (std::max)(1, (workH - 2 * layout.pad + layout.gap) / (layout.tileH + layout.gap));
+ layout.rows = (std::min)(totalRows, rowsFromWork);
+ layout.pageSize = (std::min)(safeWindowCount, layout.cols * layout.rows);
+ layout.panelW = 2 * layout.pad + layout.cols * layout.tileW + (layout.cols - 1) * layout.gap;
+ layout.panelH = 2 * layout.pad + layout.rows * layout.tileH + (std::max)(0, layout.rows - 1) * layout.gap;
+ layout.panelX = work.left + (workW - layout.panelW) / 2;
+ layout.panelY = work.top + (workH - layout.panelH) / 2;
+ if (layout.panelX < work.left)
+ {
+ layout.panelX = work.left;
+ }
+ if (layout.panelY < work.top)
+ {
+ layout.panelY = work.top;
+ }
+
+ return layout;
+ }
+
+ inline int PageStartForSelection(int selectedIndex, int windowCount, int pageSize)
+ {
+ if (windowCount <= 0 || pageSize <= 0)
+ {
+ return 0;
+ }
+
+ const int normalizedIndex = (std::min)((std::max)(0, selectedIndex), windowCount - 1);
+ return (normalizedIndex / pageSize) * pageSize;
+ }
+
+ // Number of fixed-size pages the cycle set is split across. 0 when there is
+ // nothing to page; 1 means every window fits on screen at once.
+ inline int PageCount(int windowCount, int pageSize)
+ {
+ if (windowCount <= 0 || pageSize <= 0)
+ {
+ return 0;
+ }
+
+ return (windowCount + pageSize - 1) / pageSize;
+ }
+
+ inline RECT TileRect(const OverlayLayout& layout, int index)
+ {
+ const int col = index % layout.cols;
+ const int row = index / layout.cols;
+ const int left = layout.pad + col * (layout.tileW + layout.gap);
+ const int top = layout.pad + row * (layout.tileH + layout.gap);
+ return { left, top, left + layout.tileW, top + layout.tileH - layout.cardTrimBottom };
+ }
+
+ inline RECT PreviewRect(const OverlayLayout& layout, const RECT& tile)
+ {
+ // Sits directly below the header band, inset by the 1px card stroke on the
+ // left/right/bottom so the card border stays visible around the image.
+ const int stroke = ScaledValue(layout.scale, 1);
+ return {
+ tile.left + stroke,
+ tile.top + layout.headerH,
+ tile.right - stroke,
+ tile.bottom - stroke
+ };
+ }
+
+ inline RECT HeaderRect(const OverlayLayout& layout, const RECT& tile)
+ {
+ const int margin = ScaledValue(layout.scale, 12);
+ return { tile.left + margin, tile.top, tile.right - margin, tile.top + layout.headerH };
+ }
+
+ inline RECT CoverSource(const RECT& dest, const RECT& avail)
+ {
+ const int aw = avail.right - avail.left;
+ const int ah = avail.bottom - avail.top;
+ const int dw = dest.right - dest.left;
+ const int dh = dest.bottom - dest.top;
+ if (aw <= 0 || ah <= 0 || dw <= 0 || dh <= 0)
+ {
+ return avail;
+ }
+
+ const double destA = static_cast(dw) / dh;
+ const double srcA = static_cast(aw) / ah;
+ if (srcA > destA)
+ {
+ const int cw = (std::max)(1, static_cast(ah * destA + 0.5));
+ const int x = avail.left + (aw - cw) / 2;
+ return { x, avail.top, x + cw, avail.bottom };
+ }
+
+ const int ch = (std::max)(1, static_cast(aw / destA + 0.5));
+ const int y = avail.top + (ah - ch) / 2;
+ return { avail.left, y, avail.right, y + ch };
+ }
+
+ // =================== Same-process window selection ===================
+ //
+ // The live module queries Win32 for each window's state; these helpers own the
+ // pure decision + ordering logic so the Alt-Tab cycle set can be unit-tested
+ // without a live desktop.
+
+ struct WindowEligibility
+ {
+ bool isVisible = false;
+ bool isCloaked = false;
+ // True when the window is the representative window for its owner chain, i.e.
+ // the GetLastActivePopup walk from its root owner lands back on the window
+ // itself. Owned secondary windows (whose owner is visible) are not.
+ bool isAltTabRepresentative = false;
+ bool isToolWindow = false;
+ bool isAppWindow = false;
+ };
+
+ // Mirrors the classic "IsAltTabWindow" predicate: a window participates in the
+ // cycle only when it is visible, not cloaked, the representative window of its
+ // owner chain, and either not a tool window or explicitly opted in as an app window.
+ constexpr bool IsAltTabEligible(const WindowEligibility& window)
+ {
+ return window.isVisible &&
+ !window.isCloaked &&
+ window.isAltTabRepresentative &&
+ (!window.isToolWindow || window.isAppWindow);
+ }
+
+ struct CandidateWindow
+ {
+ unsigned long long id = 0;
+ WindowEligibility eligibility;
+ // Resolved owning-process image path (UWP windows resolved to the real app,
+ // not ApplicationFrameHost). Empty when it could not be determined.
+ std::wstring processKey;
+ };
+
+ // Case-insensitive process-key match, mirroring the live _wcsicmp grouping. An
+ // empty candidate key never matches.
+ inline bool ProcessKeyEquals(const std::wstring& candidateKey, const std::wstring& foregroundKey)
+ {
+ return !candidateKey.empty() && _wcsicmp(candidateKey.c_str(), foregroundKey.c_str()) == 0;
+ }
+
+ // Given the foreground app's process key and the windows enumerated in Z-order
+ // (top-most first == MRU), return the ids that make up the Alt-Tab cycle set for
+ // that app, preserving enumeration order. Ineligible windows (invisible, cloaked,
+ // owned, or tool-only) and windows from other processes are dropped. Returns empty
+ // when the foreground key is unknown.
+ inline std::vector SelectCycleWindows(
+ const std::wstring& foregroundProcessKey,
+ const std::vector& enumeratedInZOrder)
+ {
+ std::vector result;
+ if (foregroundProcessKey.empty())
+ {
+ return result;
+ }
+
+ for (const auto& candidate : enumeratedInZOrder)
+ {
+ if (!IsAltTabEligible(candidate.eligibility))
+ {
+ continue;
+ }
+
+ if (ProcessKeyEquals(candidate.processKey, foregroundProcessKey))
+ {
+ result.push_back(candidate.id);
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/src/modules/AltWindowCycle/UnitTests/AltWindowCycleLogicTests.cpp b/src/modules/AltWindowCycle/UnitTests/AltWindowCycleLogicTests.cpp
new file mode 100644
index 0000000000..3140fd0f58
--- /dev/null
+++ b/src/modules/AltWindowCycle/UnitTests/AltWindowCycleLogicTests.cpp
@@ -0,0 +1,424 @@
+#pragma warning(push)
+#pragma warning(disable : 26466)
+#include "CppUnitTest.h"
+#pragma warning(pop)
+
+#include "..\AltWindowCycleLogic.h"
+
+using namespace Microsoft::VisualStudio::CppUnitTestFramework;
+
+namespace AltWindowCycleUnitTests
+{
+ namespace
+ {
+ void AssertRectEqual(const RECT& expected, const RECT& actual)
+ {
+ Assert::AreEqual(expected.left, actual.left, L"left");
+ Assert::AreEqual(expected.top, actual.top, L"top");
+ Assert::AreEqual(expected.right, actual.right, L"right");
+ Assert::AreEqual(expected.bottom, actual.bottom, L"bottom");
+ }
+
+ AltWindowCycleLogic::CandidateWindow MakeCandidate(
+ unsigned long long id,
+ const std::wstring& processKey,
+ bool visible = true,
+ bool cloaked = false,
+ bool representative = true,
+ bool toolWindow = false,
+ bool appWindow = false)
+ {
+ AltWindowCycleLogic::CandidateWindow candidate;
+ candidate.id = id;
+ candidate.processKey = processKey;
+ candidate.eligibility.isVisible = visible;
+ candidate.eligibility.isCloaked = cloaked;
+ candidate.eligibility.isAltTabRepresentative = representative;
+ candidate.eligibility.isToolWindow = toolWindow;
+ candidate.eligibility.isAppWindow = appWindow;
+ return candidate;
+ }
+
+ void AssertIds(std::initializer_list expected, const std::vector& actual)
+ {
+ Assert::AreEqual(static_cast(expected.size()), static_cast(actual.size()), L"cycle set size");
+ size_t i = 0;
+ for (const unsigned long long id : expected)
+ {
+ Assert::IsTrue(id == actual[i], L"cycle set id/order mismatch");
+ ++i;
+ }
+ }
+ }
+
+ TEST_CLASS(AltWindowCycleLogicTests)
+ {
+ public:
+ TEST_METHOD(ComputeOverlayLayoutUsesExpectedUnscaledGeometry)
+ {
+ const RECT work = { 0, 0, 1920, 1080 };
+
+ const auto layout = AltWindowCycleLogic::ComputeOverlayLayout(work, 4, 1.0);
+
+ Assert::AreEqual(32, layout.pad);
+ Assert::AreEqual(26, layout.gap);
+ Assert::AreEqual(270, layout.tileW);
+ Assert::AreEqual(48, layout.headerH);
+ Assert::AreEqual(142, layout.previewH);
+ Assert::AreEqual(6, layout.inner);
+ Assert::AreEqual(10, layout.radius);
+ Assert::AreEqual(16, layout.iconSize);
+ Assert::AreEqual(202, layout.tileH);
+ Assert::AreEqual(4, layout.cols);
+ Assert::AreEqual(1, layout.rows);
+ Assert::AreEqual(4, layout.pageSize);
+ Assert::AreEqual(1222, layout.panelW);
+ Assert::AreEqual(266, layout.panelH);
+ Assert::AreEqual(349, layout.panelX);
+ Assert::AreEqual(407, layout.panelY);
+ }
+
+ TEST_METHOD(ComputeOverlayLayoutUsesRoundedScaledGeometry)
+ {
+ const RECT work = { 0, 0, 1920, 1080 };
+
+ const auto layout = AltWindowCycleLogic::ComputeOverlayLayout(work, 6, 1.5);
+
+ Assert::AreEqual(48, layout.pad);
+ Assert::AreEqual(39, layout.gap);
+ Assert::AreEqual(405, layout.tileW);
+ Assert::AreEqual(72, layout.headerH);
+ Assert::AreEqual(213, layout.previewH);
+ Assert::AreEqual(9, layout.inner);
+ Assert::AreEqual(15, layout.radius);
+ Assert::AreEqual(24, layout.iconSize);
+ Assert::AreEqual(303, layout.tileH);
+ Assert::AreEqual(4, layout.cols);
+ Assert::AreEqual(2, layout.rows);
+ Assert::AreEqual(6, layout.pageSize);
+ Assert::AreEqual(1833, layout.panelW);
+ Assert::AreEqual(741, layout.panelH);
+ Assert::AreEqual(43, layout.panelX);
+ Assert::AreEqual(169, layout.panelY);
+ }
+
+ TEST_METHOD(ComputeOverlayLayoutHandlesEmptyWindowCount)
+ {
+ const RECT work = { 10, 20, 810, 620 };
+
+ const auto layout = AltWindowCycleLogic::ComputeOverlayLayout(work, 0, 1.0);
+
+ Assert::AreEqual(1, layout.cols);
+ Assert::AreEqual(0, layout.rows);
+ Assert::AreEqual(0, layout.pageSize);
+ }
+
+ TEST_METHOD(TilePreviewAndHeaderRectsUsePanelRelativeInsetViewport)
+ {
+ const RECT work = { 0, 0, 1920, 1080 };
+ const auto layout = AltWindowCycleLogic::ComputeOverlayLayout(work, 4, 1.0);
+
+ const RECT tile = AltWindowCycleLogic::TileRect(layout, 0);
+ AssertRectEqual({ 32, 32, 302, 234 }, tile);
+
+ const RECT preview = AltWindowCycleLogic::PreviewRect(layout, tile);
+ AssertRectEqual({ 33, 80, 301, 233 }, preview);
+
+ const RECT header = AltWindowCycleLogic::HeaderRect(layout, tile);
+ AssertRectEqual({ 44, 32, 290, 80 }, header);
+ }
+
+ TEST_METHOD(CoverSourceCropsWideSourceToDestinationAspectRatio)
+ {
+ const RECT dest = { 0, 0, 100, 100 };
+ const RECT avail = { 0, 0, 400, 200 };
+
+ const RECT source = AltWindowCycleLogic::CoverSource(dest, avail);
+
+ AssertRectEqual({ 100, 0, 300, 200 }, source);
+ }
+
+ TEST_METHOD(CoverSourceCropsTallSourceToDestinationAspectRatio)
+ {
+ const RECT dest = { 0, 0, 100, 100 };
+ const RECT avail = { 0, 0, 200, 400 };
+
+ const RECT source = AltWindowCycleLogic::CoverSource(dest, avail);
+
+ AssertRectEqual({ 0, 100, 200, 300 }, source);
+ }
+
+ TEST_METHOD(CoverSourceReturnsAvailableRegionForInvalidInputs)
+ {
+ const RECT dest = { 0, 0, 0, 100 };
+ const RECT avail = { 1, 2, 3, 4 };
+
+ const RECT source = AltWindowCycleLogic::CoverSource(dest, avail);
+
+ AssertRectEqual(avail, source);
+ }
+
+ TEST_METHOD(WrapIndexWrapsForwardAndBackward)
+ {
+ Assert::AreEqual(0, AltWindowCycleLogic::WrapIndex(3, 3));
+ Assert::AreEqual(2, AltWindowCycleLogic::WrapIndex(-1, 3));
+ Assert::AreEqual(1, AltWindowCycleLogic::WrapIndex(4, 3));
+ Assert::AreEqual(0, AltWindowCycleLogic::WrapIndex(4, 0));
+ }
+
+ TEST_METHOD(StableHoldModifiersPreferNonShiftModifiers)
+ {
+ Assert::AreEqual(AltWindowCycleLogic::ModifierAlt, AltWindowCycleLogic::StableHoldModifiers(AltWindowCycleLogic::ModifierAlt | AltWindowCycleLogic::ModifierShift));
+ Assert::AreEqual(AltWindowCycleLogic::ModifierCtrl, AltWindowCycleLogic::StableHoldModifiers(AltWindowCycleLogic::ModifierCtrl | AltWindowCycleLogic::ModifierShift));
+ Assert::AreEqual(AltWindowCycleLogic::ModifierWin, AltWindowCycleLogic::StableHoldModifiers(AltWindowCycleLogic::ModifierWin));
+ Assert::AreEqual(AltWindowCycleLogic::ModifierShift, AltWindowCycleLogic::StableHoldModifiers(AltWindowCycleLogic::ModifierShift));
+ Assert::AreEqual(0u, AltWindowCycleLogic::StableHoldModifiers(0));
+ }
+
+ TEST_METHOD(AreRequiredModifiersDownRequiresEveryHeldModifier)
+ {
+ Assert::IsFalse(AltWindowCycleLogic::AreRequiredModifiersDown(0, AltWindowCycleLogic::ModifierAlt));
+ Assert::IsFalse(AltWindowCycleLogic::AreRequiredModifiersDown(AltWindowCycleLogic::ModifierAlt, 0));
+ Assert::IsFalse(AltWindowCycleLogic::AreRequiredModifiersDown(AltWindowCycleLogic::ModifierAlt | AltWindowCycleLogic::ModifierCtrl, AltWindowCycleLogic::ModifierAlt));
+ Assert::IsTrue(AltWindowCycleLogic::AreRequiredModifiersDown(AltWindowCycleLogic::ModifierAlt, AltWindowCycleLogic::ModifierAlt | AltWindowCycleLogic::ModifierShift));
+ Assert::IsTrue(AltWindowCycleLogic::AreRequiredModifiersDown(AltWindowCycleLogic::ModifierAlt | AltWindowCycleLogic::ModifierCtrl, AltWindowCycleLogic::ModifierAlt | AltWindowCycleLogic::ModifierCtrl | AltWindowCycleLogic::ModifierShift));
+ }
+
+ TEST_METHOD(BeginCycleSelectsNextOrPreviousAndShowsOverlay)
+ {
+ const auto forward = AltWindowCycleLogic::BeginCycle(0, 3, true);
+ Assert::AreEqual(1, forward.selected);
+ Assert::IsTrue(forward.action == AltWindowCycleLogic::FirstHotkeyAction::ShowOverlay);
+
+ const auto backward = AltWindowCycleLogic::BeginCycle(0, 3, false);
+ Assert::AreEqual(2, backward.selected);
+ Assert::IsTrue(backward.action == AltWindowCycleLogic::FirstHotkeyAction::ShowOverlay);
+
+ const auto ignored = AltWindowCycleLogic::BeginCycle(0, 1, true);
+ Assert::AreEqual(0, ignored.selected);
+ Assert::IsTrue(ignored.action == AltWindowCycleLogic::FirstHotkeyAction::Ignore);
+ }
+
+ // =================== Same-process window selection ===================
+
+ TEST_METHOD(IsAltTabEligibleAcceptsVisibleRepresentativeNonToolWindow)
+ {
+ Assert::IsTrue(AltWindowCycleLogic::IsAltTabEligible({ true, false, true, false }));
+ }
+
+ TEST_METHOD(IsAltTabEligibleRejectsEachDisqualifyingCondition)
+ {
+ // Invisible.
+ Assert::IsFalse(AltWindowCycleLogic::IsAltTabEligible({ false, false, true, false }));
+ // Cloaked (e.g. a background virtual-desktop window).
+ Assert::IsFalse(AltWindowCycleLogic::IsAltTabEligible({ true, true, true, false }));
+ // Owned / not the representative window of its owner chain.
+ Assert::IsFalse(AltWindowCycleLogic::IsAltTabEligible({ true, false, false, false }));
+ // Tool window.
+ Assert::IsFalse(AltWindowCycleLogic::IsAltTabEligible({ true, false, true, true }));
+ }
+
+ TEST_METHOD(IsAltTabEligibleAcceptsToolWindowOptedInAsAppWindow)
+ {
+ Assert::IsTrue(AltWindowCycleLogic::IsAltTabEligible({ true, false, true, true, true }));
+ }
+
+ TEST_METHOD(ProcessKeyEqualsIsCaseInsensitiveAndRejectsEmptyCandidate)
+ {
+ Assert::IsTrue(AltWindowCycleLogic::ProcessKeyEquals(L"C:\\Apps\\Foo.exe", L"c:\\apps\\foo.EXE"));
+ Assert::IsFalse(AltWindowCycleLogic::ProcessKeyEquals(L"C:\\Apps\\Foo.exe", L"C:\\Apps\\Bar.exe"));
+ Assert::IsFalse(AltWindowCycleLogic::ProcessKeyEquals(L"", L"C:\\Apps\\Foo.exe"));
+ }
+
+ TEST_METHOD(SelectCycleWindowsKeepsOnlyForegroundProcessInEnumerationOrder)
+ {
+ const std::wstring fg = L"C:\\Apps\\Editor.exe";
+ const std::vector candidates = {
+ MakeCandidate(10, L"C:\\Apps\\Editor.exe"),
+ MakeCandidate(20, L"C:\\Apps\\Browser.exe"),
+ MakeCandidate(30, L"c:\\apps\\editor.EXE"), // same app, different casing
+ MakeCandidate(40, L"C:\\Apps\\Browser.exe"),
+ MakeCandidate(50, L"C:\\Apps\\Editor.exe"),
+ };
+
+ AssertIds({ 10, 30, 50 }, AltWindowCycleLogic::SelectCycleWindows(fg, candidates));
+ }
+
+ TEST_METHOD(SelectCycleWindowsExcludesIneligibleWindowsOfTheSameApp)
+ {
+ const std::wstring fg = L"C:\\Apps\\Editor.exe";
+ const std::vector candidates = {
+ MakeCandidate(1, fg), // eligible
+ MakeCandidate(2, fg, /*visible*/ false),
+ MakeCandidate(3, fg, /*visible*/ true, /*cloaked*/ true),
+ MakeCandidate(4, fg, /*visible*/ true, /*cloaked*/ false, /*representative*/ false),
+ MakeCandidate(5, fg, /*visible*/ true, /*cloaked*/ false, /*representative*/ true, /*tool*/ true),
+ MakeCandidate(6, fg), // eligible
+ };
+
+ AssertIds({ 1, 6 }, AltWindowCycleLogic::SelectCycleWindows(fg, candidates));
+ }
+
+ TEST_METHOD(SelectCycleWindowsReturnsEmptyWhenForegroundKeyUnknown)
+ {
+ const std::vector candidates = {
+ MakeCandidate(1, L"C:\\Apps\\Editor.exe"),
+ };
+
+ Assert::AreEqual(0u, static_cast(AltWindowCycleLogic::SelectCycleWindows(L"", candidates).size()));
+ }
+
+ TEST_METHOD(SelectCycleWindowsHandlesZeroAndSingleCandidate)
+ {
+ const std::wstring fg = L"C:\\Apps\\Editor.exe";
+
+ Assert::AreEqual(0u, static_cast(AltWindowCycleLogic::SelectCycleWindows(fg, {}).size()));
+
+ const std::vector single = { MakeCandidate(99, fg) };
+ AssertIds({ 99 }, AltWindowCycleLogic::SelectCycleWindows(fg, single));
+ }
+
+ TEST_METHOD(SelectCycleWindowsIncludesEveryWindowOfATiedProcess)
+ {
+ // Several windows of the same app (the real grouping key is the resolved
+ // process image path, so multiple windows / duplicate pids all cycle).
+ const std::wstring fg = L"C:\\Apps\\Editor.exe";
+ const std::vector candidates = {
+ MakeCandidate(7, fg),
+ MakeCandidate(8, fg),
+ MakeCandidate(9, fg),
+ };
+
+ AssertIds({ 7, 8, 9 }, AltWindowCycleLogic::SelectCycleWindows(fg, candidates));
+ }
+
+ TEST_METHOD(SelectCycleWindowsDropsCandidatesWithNoResolvedProcess)
+ {
+ const std::wstring fg = L"C:\\Apps\\Editor.exe";
+ const std::vector candidates = {
+ MakeCandidate(1, fg),
+ MakeCandidate(2, L""), // eligible flags but process could not be resolved
+ MakeCandidate(3, fg),
+ };
+
+ AssertIds({ 1, 3 }, AltWindowCycleLogic::SelectCycleWindows(fg, candidates));
+ }
+
+ // =================== Layout / index edge cases ===================
+
+ TEST_METHOD(ComputeOverlayLayoutClampsColumnsToWindowCount)
+ {
+ const RECT work = { 0, 0, 1920, 1080 };
+
+ const auto layout = AltWindowCycleLogic::ComputeOverlayLayout(work, 1, 1.0);
+
+ Assert::AreEqual(1, layout.cols);
+ Assert::AreEqual(1, layout.rows);
+ }
+
+ TEST_METHOD(ComputeOverlayLayoutRespectsMaxColumns)
+ {
+ const RECT work = { 0, 0, 1920, 1080 };
+
+ const auto layout = AltWindowCycleLogic::ComputeOverlayLayout(work, 10, 1.0, 3);
+
+ Assert::AreEqual(3, layout.cols);
+ Assert::AreEqual(4, layout.rows); // ceil(10 / 3)
+ }
+
+ TEST_METHOD(ComputeOverlayLayoutClampsColumnsAndRowsToNarrowWorkArea)
+ {
+ const RECT work = { 0, 0, 640, 480 };
+
+ const auto layout = AltWindowCycleLogic::ComputeOverlayLayout(work, 8, 1.0);
+
+ // (640 - 64 + 26) / (270 + 26) = 2 columns fit the work area.
+ Assert::AreEqual(2, layout.cols);
+ Assert::AreEqual(1, layout.rows);
+ Assert::AreEqual(2, layout.pageSize);
+ Assert::IsTrue(layout.panelH <= work.bottom - work.top);
+ }
+
+ TEST_METHOD(ComputeOverlayLayoutScalesPaddingAcrossDpi)
+ {
+ const RECT work = { 0, 0, 1920, 1080 };
+
+ Assert::AreEqual(32, AltWindowCycleLogic::ComputeOverlayLayout(work, 4, 1.0).pad);
+ Assert::AreEqual(40, AltWindowCycleLogic::ComputeOverlayLayout(work, 4, 1.25).pad);
+ Assert::AreEqual(48, AltWindowCycleLogic::ComputeOverlayLayout(work, 4, 1.5).pad);
+ Assert::AreEqual(64, AltWindowCycleLogic::ComputeOverlayLayout(work, 4, 2.0).pad);
+ }
+
+ TEST_METHOD(ComputeOverlayLayoutPaginatesRowsThatDoNotFitWorkArea)
+ {
+ const RECT work = { 0, 0, 1920, 1080 };
+
+ const auto layout = AltWindowCycleLogic::ComputeOverlayLayout(work, 13, 1.5);
+
+ Assert::AreEqual(4, layout.cols);
+ Assert::AreEqual(2, layout.rows);
+ Assert::AreEqual(8, layout.pageSize);
+ Assert::IsTrue(layout.panelH <= work.bottom - work.top);
+ }
+
+ TEST_METHOD(PageStartForSelectionKeepsSelectedWindowOnPage)
+ {
+ Assert::AreEqual(0, AltWindowCycleLogic::PageStartForSelection(0, 13, 8));
+ Assert::AreEqual(0, AltWindowCycleLogic::PageStartForSelection(7, 13, 8));
+ Assert::AreEqual(8, AltWindowCycleLogic::PageStartForSelection(8, 13, 8));
+ Assert::AreEqual(8, AltWindowCycleLogic::PageStartForSelection(12, 13, 8));
+ Assert::AreEqual(0, AltWindowCycleLogic::PageStartForSelection(-1, 13, 8));
+ Assert::AreEqual(8, AltWindowCycleLogic::PageStartForSelection(13, 13, 8));
+ Assert::AreEqual(0, AltWindowCycleLogic::PageStartForSelection(0, 0, 0));
+ }
+
+ TEST_METHOD(PageCountReportsPagesForPaginationAffordance)
+ {
+ Assert::AreEqual(1, AltWindowCycleLogic::PageCount(1, 8));
+ Assert::AreEqual(1, AltWindowCycleLogic::PageCount(8, 8));
+ Assert::AreEqual(2, AltWindowCycleLogic::PageCount(9, 8));
+ Assert::AreEqual(2, AltWindowCycleLogic::PageCount(13, 8));
+ Assert::AreEqual(3, AltWindowCycleLogic::PageCount(17, 8));
+ Assert::AreEqual(0, AltWindowCycleLogic::PageCount(0, 8));
+ Assert::AreEqual(0, AltWindowCycleLogic::PageCount(13, 0));
+ }
+
+ TEST_METHOD(TileRectAdvancesAcrossColumnsAndRows)
+ {
+ const RECT work = { 0, 0, 1920, 1080 };
+ const auto layout = AltWindowCycleLogic::ComputeOverlayLayout(work, 8, 1.0);
+
+ Assert::AreEqual(6, layout.cols); // sanity: second row exists
+
+ // Second column, first row.
+ AssertRectEqual({ 328, 32, 598, 234 }, AltWindowCycleLogic::TileRect(layout, 1));
+ // First column, second row.
+ AssertRectEqual({ 32, 260, 302, 462 }, AltWindowCycleLogic::TileRect(layout, 6));
+ }
+
+ TEST_METHOD(WrapIndexNormalizesNegativeAndOversizedIndices)
+ {
+ Assert::AreEqual(2, AltWindowCycleLogic::WrapIndex(-4, 3));
+ Assert::AreEqual(1, AltWindowCycleLogic::WrapIndex(7, 3));
+ Assert::AreEqual(0, AltWindowCycleLogic::WrapIndex(0, 5));
+ Assert::AreEqual(0, AltWindowCycleLogic::WrapIndex(-3, 3));
+ }
+
+ TEST_METHOD(BeginCycleClampsNegativeCurrentIndex)
+ {
+ const auto forward = AltWindowCycleLogic::BeginCycle(-1, 3, true);
+ Assert::AreEqual(1, forward.selected);
+ Assert::IsTrue(forward.action == AltWindowCycleLogic::FirstHotkeyAction::ShowOverlay);
+
+ const auto backward = AltWindowCycleLogic::BeginCycle(-5, 3, false);
+ Assert::AreEqual(2, backward.selected);
+ }
+
+ TEST_METHOD(StableHoldModifiersStripsUnknownBitsAndPrefersNonShift)
+ {
+ const unsigned int expected =
+ AltWindowCycleLogic::ModifierAlt | AltWindowCycleLogic::ModifierCtrl | AltWindowCycleLogic::ModifierWin;
+ Assert::AreEqual(expected, AltWindowCycleLogic::StableHoldModifiers(0xFFu));
+ }
+ };
+}
diff --git a/src/modules/AltWindowCycle/UnitTests/AltWindowCycleUnitTests.vcxproj b/src/modules/AltWindowCycle/UnitTests/AltWindowCycleUnitTests.vcxproj
new file mode 100644
index 0000000000..0702920436
--- /dev/null
+++ b/src/modules/AltWindowCycle/UnitTests/AltWindowCycleUnitTests.vcxproj
@@ -0,0 +1,49 @@
+
+
+
+
+ 17.0
+ {9EBE5C3A-4A37-477E-9176-07C7CF0EBA6E}
+ Win32Proj
+ AltWindowCycleUnitTests
+ NativeUnitTestProject
+ AltWindowCycle.UnitTests
+
+
+ DynamicLibrary
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+ $(RepoRoot)$(Platform)\$(Configuration)\tests\AltWindowCycle\
+
+
+
+ ..;$(RepoRoot)src\;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories)
+ WIN32;%(PreprocessorDefinitions)
+ true
+ NotUsing
+
+
+ $(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories)
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/AltWindowCycle/UnitTests/AltWindowCycleUnitTests.vcxproj.filters b/src/modules/AltWindowCycle/UnitTests/AltWindowCycleUnitTests.vcxproj.filters
new file mode 100644
index 0000000000..7c49eebe84
--- /dev/null
+++ b/src/modules/AltWindowCycle/UnitTests/AltWindowCycleUnitTests.vcxproj.filters
@@ -0,0 +1,23 @@
+
+
+
+
+ {1345DADF-2D71-454C-A6D9-B9698E920E1A}
+ cpp;c;cc;cxx
+
+
+ {8439D5F4-E4A2-4A72-A6D7-579E2A8D1260}
+ h;hh;hpp;hxx
+
+
+
+
+ Source Files
+
+
+
+
+ Header Files
+
+
+
diff --git a/src/modules/AltWindowCycle/dllmain.cpp b/src/modules/AltWindowCycle/dllmain.cpp
new file mode 100644
index 0000000000..e4ec851151
--- /dev/null
+++ b/src/modules/AltWindowCycle/dllmain.cpp
@@ -0,0 +1,305 @@
+// dllmain.cpp : Defines the entry point for the DLL application.
+#include "pch.h"
+
+#include
+#include
+#include
+
+#include "AltWindowCycle.h"
+#include "AltWindowCycleLogic.h"
+#include "trace.h"
+
+extern "C" IMAGE_DOS_HEADER __ImageBase;
+
+BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lpReserved*/)
+{
+ switch (ul_reason_for_call)
+ {
+ case DLL_PROCESS_ATTACH:
+ Trace::RegisterProvider();
+ break;
+ case DLL_THREAD_ATTACH:
+ case DLL_THREAD_DETACH:
+ break;
+ case DLL_PROCESS_DETACH:
+ Trace::UnregisterProvider();
+ break;
+ }
+ return TRUE;
+}
+
+namespace
+{
+ const wchar_t JSON_KEY_PROPERTIES[] = L"properties";
+ const wchar_t JSON_KEY_WIN[] = L"win";
+ const wchar_t JSON_KEY_ALT[] = L"alt";
+ const wchar_t JSON_KEY_CTRL[] = L"ctrl";
+ const wchar_t JSON_KEY_SHIFT[] = L"shift";
+ const wchar_t JSON_KEY_CODE[] = L"code";
+ const wchar_t JSON_KEY_NEXT_WINDOW_SHORTCUT[] = L"next_window_shortcut";
+ const wchar_t JSON_KEY_PREVIOUS_WINDOW_SHORTCUT[] = L"previous_window_shortcut";
+
+ // ` ~ key (VK_OEM_3) on US layouts.
+ const unsigned char DEFAULT_BACKTICK_VK = 0xC0;
+
+ // The configurable hotkeys stay in Settings UI order. Escape is handled by a
+ // scoped low-level hook inside the module while the overlay is visible, so it
+ // deliberately does not appear here.
+ enum HotkeyId : size_t
+ {
+ HotkeyNext = 0,
+ HotkeyPrevious,
+ HotkeyCount
+ };
+
+ static unsigned int ModifierMaskFromHotkey(const PowertoyModuleIface::Hotkey& hotkey)
+ {
+ unsigned int modifiers = 0;
+ if (hotkey.alt)
+ {
+ modifiers |= AltWindowCycleLogic::ModifierAlt;
+ }
+ if (hotkey.ctrl)
+ {
+ modifiers |= AltWindowCycleLogic::ModifierCtrl;
+ }
+ if (hotkey.shift)
+ {
+ modifiers |= AltWindowCycleLogic::ModifierShift;
+ }
+ if (hotkey.win)
+ {
+ modifiers |= AltWindowCycleLogic::ModifierWin;
+ }
+
+ return AltWindowCycleLogic::StableHoldModifiers(modifiers);
+ }
+
+ // The overlay commits when the held modifier is released, so a shortcut with no
+ // modifier at all would flash the overlay and immediately dismiss it. Settings UI
+ // won't produce one, but a hand-edited settings.json can.
+ static bool HasModifier(const PowertoyModuleIface::Hotkey& hotkey)
+ {
+ return hotkey.win || hotkey.ctrl || hotkey.shift || hotkey.alt;
+ }
+}
+
+// Implement the PowerToy Module Interface and all the required methods.
+class AltWindowCycle : public PowertoyModuleIface
+{
+private:
+ bool m_enabled = false;
+
+ // Cycle to the next window of the focused app.
+ PowertoyModuleIface::Hotkey m_nextHotkey;
+ // Cycle to the previous window of the focused app.
+ PowertoyModuleIface::Hotkey m_previousHotkey;
+
+ void init_settings();
+ void parse_settings(PowerToysSettings::PowerToyValues& settings);
+
+ static void parse_hotkey(const winrt::Windows::Data::Json::JsonObject& properties,
+ const wchar_t* key,
+ PowertoyModuleIface::Hotkey& hotkey)
+ {
+ try
+ {
+ auto jsonHotkeyObject = properties.GetNamedObject(key);
+ hotkey.win = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_WIN);
+ hotkey.alt = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_ALT);
+ hotkey.shift = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_SHIFT);
+ hotkey.ctrl = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_CTRL);
+ hotkey.key = static_cast(jsonHotkeyObject.GetNamedNumber(JSON_KEY_CODE));
+ }
+ catch (...)
+ {
+ Logger::error("Failed to initialize AltWindowCycle shortcut from settings");
+ }
+ }
+
+public:
+ AltWindowCycle()
+ {
+ LoggerHelpers::init_logger(L"AltWindowCycle", L"ModuleInterface", "AltWindowCycle");
+ init_settings();
+ }
+
+ virtual void destroy() override
+ {
+ // The runner unloads this DLL right after destroy() returns, so the UI
+ // thread must be fully gone before the module code disappears.
+ ShutdownAltWindowCycle(true); // idempotent
+ delete this;
+ }
+
+ virtual const wchar_t* get_name() override
+ {
+ return L"AltWindowCycle";
+ }
+
+ virtual const wchar_t* get_key() override
+ {
+ return L"AltWindowCycle";
+ }
+
+ virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override
+ {
+ return powertoys_gpo::getConfiguredAltWindowCycleEnabledValue();
+ }
+
+ virtual bool get_config(wchar_t* buffer, int* buffer_size) override
+ {
+ HINSTANCE hinstance = reinterpret_cast(&__ImageBase);
+
+ PowerToysSettings::Settings settings(hinstance, get_name());
+ settings.set_description(L"Cycle between the windows of the currently focused application.");
+
+ return settings.serialize_to_buffer(buffer, buffer_size);
+ }
+
+ virtual void call_custom_action(const wchar_t* /*action*/) override
+ {
+ }
+
+ virtual void set_config(const wchar_t* config) override
+ {
+ try
+ {
+ PowerToysSettings::PowerToyValues values =
+ PowerToysSettings::PowerToyValues::from_json_string(config, get_key());
+
+ parse_settings(values);
+ values.save_to_settings_file();
+ }
+ catch (std::exception&)
+ {
+ // Improper JSON.
+ }
+ }
+
+ virtual void enable() override
+ {
+ m_enabled = InitializeAltWindowCycle(reinterpret_cast(&__ImageBase));
+ if (m_enabled)
+ {
+ Trace::EnableAltWindowCycle(true);
+ }
+ }
+
+ virtual void disable() override
+ {
+ m_enabled = false;
+ Trace::EnableAltWindowCycle(false);
+
+ // disable() runs on the runner thread that services the centralized
+ // low-level keyboard hook, so it must never block on the UI thread.
+ ShutdownAltWindowCycle(false);
+ }
+
+ virtual bool is_enabled() override
+ {
+ return m_enabled;
+ }
+
+ virtual bool is_enabled_by_default() const override
+ {
+ return false;
+ }
+
+ virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override
+ {
+ if (hotkeys && buffer_size >= HotkeyCount)
+ {
+ hotkeys[HotkeyNext] = m_nextHotkey;
+ hotkeys[HotkeyPrevious] = m_previousHotkey;
+ }
+
+ return HotkeyCount;
+ }
+
+ virtual bool on_hotkey(size_t hotkeyId) override
+ {
+ if (!m_enabled)
+ {
+ return false;
+ }
+
+ if (hotkeyId == HotkeyNext)
+ {
+ Trace::CycleWindow(true);
+ return HandleAltWindowCycleHotkey(true, ModifierMaskFromHotkey(m_nextHotkey));
+ }
+
+ if (hotkeyId == HotkeyPrevious)
+ {
+ Trace::CycleWindow(false);
+ return HandleAltWindowCycleHotkey(false, ModifierMaskFromHotkey(m_previousHotkey));
+ }
+
+ return false;
+ }
+};
+
+void AltWindowCycle::init_settings()
+{
+ try
+ {
+ PowerToysSettings::PowerToyValues settings =
+ PowerToysSettings::PowerToyValues::load_from_settings_file(AltWindowCycle::get_key());
+ parse_settings(settings);
+ }
+ catch (std::exception&)
+ {
+ // Error while loading from the settings file. Let default values stay as they are.
+ }
+}
+
+void AltWindowCycle::parse_settings(PowerToysSettings::PowerToyValues& settings)
+{
+ // Reset to defaults before parsing so removed/invalid values fall back cleanly.
+ m_nextHotkey = PowertoyModuleIface::Hotkey{};
+ m_previousHotkey = PowertoyModuleIface::Hotkey{};
+
+ auto settingsObject = settings.get_raw_json();
+ if (settingsObject.GetView().Size() && settingsObject.HasKey(JSON_KEY_PROPERTIES))
+ {
+ auto properties = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES);
+ if (properties.HasKey(JSON_KEY_NEXT_WINDOW_SHORTCUT))
+ {
+ parse_hotkey(properties, JSON_KEY_NEXT_WINDOW_SHORTCUT, m_nextHotkey);
+ }
+ if (properties.HasKey(JSON_KEY_PREVIOUS_WINDOW_SHORTCUT))
+ {
+ parse_hotkey(properties, JSON_KEY_PREVIOUS_WINDOW_SHORTCUT, m_previousHotkey);
+ }
+ }
+ else
+ {
+ Logger::info("AltWindowCycle settings are empty");
+ }
+
+ // Default: Alt+` cycles to the next window of the focused app.
+ if (!m_nextHotkey.key || !HasModifier(m_nextHotkey))
+ {
+ m_nextHotkey.win = false;
+ m_nextHotkey.ctrl = false;
+ m_nextHotkey.shift = false;
+ m_nextHotkey.alt = true;
+ m_nextHotkey.key = DEFAULT_BACKTICK_VK;
+ }
+
+ // Default: Shift+Alt+` cycles to the previous window of the focused app.
+ if (!m_previousHotkey.key || !HasModifier(m_previousHotkey))
+ {
+ m_previousHotkey.win = false;
+ m_previousHotkey.ctrl = false;
+ m_previousHotkey.shift = true;
+ m_previousHotkey.alt = true;
+ m_previousHotkey.key = DEFAULT_BACKTICK_VK;
+ }
+}
+
+extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create()
+{
+ return new AltWindowCycle();
+}
diff --git a/src/modules/AltWindowCycle/packages.config b/src/modules/AltWindowCycle/packages.config
new file mode 100644
index 0000000000..97349a856f
--- /dev/null
+++ b/src/modules/AltWindowCycle/packages.config
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/modules/AltWindowCycle/pch.cpp b/src/modules/AltWindowCycle/pch.cpp
new file mode 100644
index 0000000000..1d9f38c57d
--- /dev/null
+++ b/src/modules/AltWindowCycle/pch.cpp
@@ -0,0 +1 @@
+#include "pch.h"
diff --git a/src/modules/AltWindowCycle/pch.h b/src/modules/AltWindowCycle/pch.h
new file mode 100644
index 0000000000..6cca81757f
--- /dev/null
+++ b/src/modules/AltWindowCycle/pch.h
@@ -0,0 +1,15 @@
+#pragma once
+
+#define WIN32_LEAN_AND_MEAN
+#include
+#include
+
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+#include
diff --git a/src/modules/AltWindowCycle/resource.h b/src/modules/AltWindowCycle/resource.h
new file mode 100644
index 0000000000..2c37d4ef03
--- /dev/null
+++ b/src/modules/AltWindowCycle/resource.h
@@ -0,0 +1,13 @@
+//{{NO_DEPENDENCIES}}
+// Microsoft Visual C++ generated include file.
+// Used by AltWindowCycle.rc
+
+//////////////////////////////
+// Non-localizable
+
+#define FILE_DESCRIPTION "PowerToys Window Hopper"
+#define INTERNAL_NAME "AltWindowCycle"
+#define ORIGINAL_FILENAME "PowerToys.AltWindowCycle.dll"
+
+// Non-localizable
+//////////////////////////////
diff --git a/src/modules/AltWindowCycle/trace.cpp b/src/modules/AltWindowCycle/trace.cpp
new file mode 100644
index 0000000000..3668901de9
--- /dev/null
+++ b/src/modules/AltWindowCycle/trace.cpp
@@ -0,0 +1,33 @@
+#include "pch.h"
+#include "trace.h"
+
+#include
+
+TRACELOGGING_DEFINE_PROVIDER(
+ g_hProvider,
+ "Microsoft.PowerToys",
+ // {38e8889b-9731-53f5-e901-e8a7c1753074}
+ (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74),
+ TraceLoggingOptionProjectTelemetry());
+
+// Log if the user has AltWindowCycle enabled or disabled
+void Trace::EnableAltWindowCycle(const bool enabled) noexcept
+{
+ TraceLoggingWriteWrapper(
+ g_hProvider,
+ "AltWindowCycle_EnableAltWindowCycle",
+ ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance),
+ TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE),
+ TraceLoggingBoolean(enabled, "Enabled"));
+}
+
+// Log that the user invoked the module to cycle a window
+void Trace::CycleWindow(const bool forward) noexcept
+{
+ TraceLoggingWriteWrapper(
+ g_hProvider,
+ "AltWindowCycle_CycleWindow",
+ ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance),
+ TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE),
+ TraceLoggingBoolean(forward, "Forward"));
+}
diff --git a/src/modules/AltWindowCycle/trace.h b/src/modules/AltWindowCycle/trace.h
new file mode 100644
index 0000000000..67b943ba9b
--- /dev/null
+++ b/src/modules/AltWindowCycle/trace.h
@@ -0,0 +1,13 @@
+#pragma once
+
+#include
+
+class Trace : public telemetry::TraceBase
+{
+public:
+ // Log if the user has AltWindowCycle enabled or disabled
+ static void EnableAltWindowCycle(const bool enabled) noexcept;
+
+ // Log that the user invoked the module to cycle a window
+ static void CycleWindow(const bool forward) noexcept;
+};
diff --git a/src/runner/main.cpp b/src/runner/main.cpp
index 1a132ad816..dd7a480835 100644
--- a/src/runner/main.cpp
+++ b/src/runner/main.cpp
@@ -288,6 +288,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow
L"PowerToys.LightSwitchModuleInterface.dll",
L"PowerToys.PowerDisplayModuleInterface.dll",
L"PowerToys.GrabAndMoveModuleInterface.dll",
+ L"PowerToys.AltWindowCycle.dll",
};
for (auto moduleSubdir : knownModules)
diff --git a/src/settings-ui/QuickAccess.UI/Helpers/ModuleGpoHelper.cs b/src/settings-ui/QuickAccess.UI/Helpers/ModuleGpoHelper.cs
index bfde5227c2..55fd433bd3 100644
--- a/src/settings-ui/QuickAccess.UI/Helpers/ModuleGpoHelper.cs
+++ b/src/settings-ui/QuickAccess.UI/Helpers/ModuleGpoHelper.cs
@@ -25,6 +25,7 @@ internal static class ModuleGpoHelper
ModuleType.FancyZones => GPOWrapper.GetConfiguredFancyZonesEnabledValue(),
ModuleType.FileLocksmith => GPOWrapper.GetConfiguredFileLocksmithEnabledValue(),
ModuleType.FindMyMouse => GPOWrapper.GetConfiguredFindMyMouseEnabledValue(),
+ ModuleType.AltWindowCycle => GPOWrapper.GetConfiguredAltWindowCycleEnabledValue(),
ModuleType.Hosts => GPOWrapper.GetConfiguredHostsFileEditorEnabledValue(),
ModuleType.ImageResizer => GPOWrapper.GetConfiguredImageResizerEnabledValue(),
ModuleType.KeyboardManager => GPOWrapper.GetConfiguredKeyboardManagerEnabledValue(),
diff --git a/src/settings-ui/Settings.UI.Library/AltWindowCycleProperties.cs b/src/settings-ui/Settings.UI.Library/AltWindowCycleProperties.cs
new file mode 100644
index 0000000000..ddf6635055
--- /dev/null
+++ b/src/settings-ui/Settings.UI.Library/AltWindowCycleProperties.cs
@@ -0,0 +1,47 @@
+// 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.Serialization;
+
+using Settings.UI.Library.Attributes;
+
+namespace Microsoft.PowerToys.Settings.UI.Library
+{
+ public class AltWindowCycleProperties
+ {
+ // Alt+` cycles to the next window of the focused app.
+ [JsonIgnore]
+ [CmdConfigureIgnore]
+ public HotkeySettings DefaultNextWindowShortcut => new HotkeySettings(false, false, true, false, 0xC0);
+
+ // Shift+Alt+` cycles to the previous window of the focused app.
+ [JsonIgnore]
+ [CmdConfigureIgnore]
+ public HotkeySettings DefaultPreviousWindowShortcut => new HotkeySettings(false, false, true, true, 0xC0);
+
+ private HotkeySettings _nextWindowShortcut;
+
+ private HotkeySettings _previousWindowShortcut;
+
+ [JsonPropertyName("next_window_shortcut")]
+ public HotkeySettings NextWindowShortcut
+ {
+ get => _nextWindowShortcut ?? DefaultNextWindowShortcut;
+ set => _nextWindowShortcut = value;
+ }
+
+ [JsonPropertyName("previous_window_shortcut")]
+ public HotkeySettings PreviousWindowShortcut
+ {
+ get => _previousWindowShortcut ?? DefaultPreviousWindowShortcut;
+ set => _previousWindowShortcut = value;
+ }
+
+ public AltWindowCycleProperties()
+ {
+ NextWindowShortcut = DefaultNextWindowShortcut;
+ PreviousWindowShortcut = DefaultPreviousWindowShortcut;
+ }
+ }
+}
diff --git a/src/settings-ui/Settings.UI.Library/AltWindowCycleSettings.cs b/src/settings-ui/Settings.UI.Library/AltWindowCycleSettings.cs
new file mode 100644
index 0000000000..790f6dec21
--- /dev/null
+++ b/src/settings-ui/Settings.UI.Library/AltWindowCycleSettings.cs
@@ -0,0 +1,57 @@
+// 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.Collections.Generic;
+using System.Text.Json.Serialization;
+using ManagedCommon;
+using Microsoft.PowerToys.Settings.UI.Library.Helpers;
+using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
+
+namespace Microsoft.PowerToys.Settings.UI.Library
+{
+ public class AltWindowCycleSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig
+ {
+ public const string ModuleName = "AltWindowCycle";
+
+ [JsonPropertyName("properties")]
+ public AltWindowCycleProperties Properties { get; set; }
+
+ public AltWindowCycleSettings()
+ {
+ Name = ModuleName;
+ Properties = new AltWindowCycleProperties();
+ Version = "1.0";
+ }
+
+ public string GetModuleName()
+ {
+ return Name;
+ }
+
+ public ModuleType GetModuleType() => ModuleType.AltWindowCycle;
+
+ public HotkeyAccessor[] GetAllHotkeyAccessors()
+ {
+ var hotkeyAccessors = new List
+ {
+ new HotkeyAccessor(
+ () => Properties.NextWindowShortcut,
+ value => Properties.NextWindowShortcut = value ?? Properties.DefaultNextWindowShortcut,
+ "AltWindowCycle_NextWindowShortcut"),
+ new HotkeyAccessor(
+ () => Properties.PreviousWindowShortcut,
+ value => Properties.PreviousWindowShortcut = value ?? Properties.DefaultPreviousWindowShortcut,
+ "AltWindowCycle_PreviousWindowShortcut"),
+ };
+
+ return hotkeyAccessors.ToArray();
+ }
+
+ // This can be utilized in the future if the settings.json file is to be modified/deleted.
+ public bool UpgradeSettingsConfiguration()
+ {
+ return false;
+ }
+ }
+}
diff --git a/src/settings-ui/Settings.UI.Library/EnabledModules.cs b/src/settings-ui/Settings.UI.Library/EnabledModules.cs
index 2680ae262c..15527cdc6f 100644
--- a/src/settings-ui/Settings.UI.Library/EnabledModules.cs
+++ b/src/settings-ui/Settings.UI.Library/EnabledModules.cs
@@ -218,6 +218,22 @@ namespace Microsoft.PowerToys.Settings.UI.Library
}
}
+ private bool altWindowCycle; // defaulting to off
+
+ [JsonPropertyName("AltWindowCycle")]
+ public bool AltWindowCycle
+ {
+ get => altWindowCycle;
+ set
+ {
+ if (altWindowCycle != value)
+ {
+ LogTelemetryEvent(value);
+ altWindowCycle = value;
+ }
+ }
+ }
+
private bool mouseHighlighter = true;
[JsonPropertyName("MouseHighlighter")]
diff --git a/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs b/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs
index 8734620153..ec3def6f89 100644
--- a/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs
+++ b/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs
@@ -30,6 +30,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
return moduleType switch
{
ModuleType.AdvancedPaste => "ms-appx:///Assets/Settings/Icons/AdvancedPaste.png",
+ ModuleType.AltWindowCycle => "ms-appx:///Assets/Settings/Icons/WindowHopper.png",
ModuleType.Workspaces => "ms-appx:///Assets/Settings/Icons/Workspaces.png",
ModuleType.PowerOCR => "ms-appx:///Assets/Settings/Icons/TextExtractor.png",
ModuleType.PowerAccent => "ms-appx:///Assets/Settings/Icons/QuickAccent.png",
@@ -56,6 +57,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
ModuleType.FancyZones => generalSettingsConfig.Enabled.FancyZones,
ModuleType.FileLocksmith => generalSettingsConfig.Enabled.FileLocksmith,
ModuleType.FindMyMouse => generalSettingsConfig.Enabled.FindMyMouse,
+ ModuleType.AltWindowCycle => generalSettingsConfig.Enabled.AltWindowCycle,
ModuleType.Hosts => generalSettingsConfig.Enabled.Hosts,
ModuleType.ImageResizer => generalSettingsConfig.Enabled.ImageResizer,
ModuleType.KeyboardManager => generalSettingsConfig.Enabled.KeyboardManager,
@@ -97,6 +99,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
case ModuleType.FancyZones: generalSettingsConfig.Enabled.FancyZones = isEnabled; break;
case ModuleType.FileLocksmith: generalSettingsConfig.Enabled.FileLocksmith = isEnabled; break;
case ModuleType.FindMyMouse: generalSettingsConfig.Enabled.FindMyMouse = isEnabled; break;
+ case ModuleType.AltWindowCycle: generalSettingsConfig.Enabled.AltWindowCycle = isEnabled; break;
case ModuleType.Hosts: generalSettingsConfig.Enabled.Hosts = isEnabled; break;
case ModuleType.ImageResizer: generalSettingsConfig.Enabled.ImageResizer = isEnabled; break;
case ModuleType.KeyboardManager: generalSettingsConfig.Enabled.KeyboardManager = isEnabled; break;
@@ -141,6 +144,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
ModuleType.FancyZones => FancyZonesSettings.ModuleName,
ModuleType.FileLocksmith => FileLocksmithSettings.ModuleName,
ModuleType.FindMyMouse => FindMyMouseSettings.ModuleName,
+ ModuleType.AltWindowCycle => AltWindowCycleSettings.ModuleName,
ModuleType.Hosts => HostsSettings.ModuleName,
ModuleType.ImageResizer => ImageResizerSettings.ModuleName,
ModuleType.KeyboardManager => KeyboardManagerSettings.ModuleName,
diff --git a/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs b/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs
index 96c6fb682b..16a71048a5 100644
--- a/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs
+++ b/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs
@@ -44,6 +44,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonSerializable(typeof(GeneralSettings))]
[JsonSerializable(typeof(OutGoingGeneralSettings))]
[JsonSerializable(typeof(AdvancedPasteSettings))]
+ [JsonSerializable(typeof(AltWindowCycleSettings))]
[JsonSerializable(typeof(AlwaysOnTopSettings))]
[JsonSerializable(typeof(AwakeSettings))]
[JsonSerializable(typeof(CmdNotFoundSettings))]
diff --git a/src/settings-ui/Settings.UI.UnitTests/ModelsTests/AltWindowCycleSettingsTests.cs b/src/settings-ui/Settings.UI.UnitTests/ModelsTests/AltWindowCycleSettingsTests.cs
new file mode 100644
index 0000000000..3827458f34
--- /dev/null
+++ b/src/settings-ui/Settings.UI.UnitTests/ModelsTests/AltWindowCycleSettingsTests.cs
@@ -0,0 +1,116 @@
+// 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.VisualStudio.TestTools.UnitTesting;
+
+namespace CommonLibTest
+{
+ [TestClass]
+ public class AltWindowCycleSettingsTests
+ {
+ // VK_OEM_3 (the backtick / grave-accent key) that both default shortcuts bind to.
+ private const int OemTilde = 0xC0;
+
+ [TestMethod]
+ public void Defaults_ShouldMatchDocumentedHotkeys()
+ {
+ var settings = new AltWindowCycleSettings();
+
+ Assert.AreEqual(AltWindowCycleSettings.ModuleName, settings.Name);
+ Assert.AreEqual("1.0", settings.Version);
+ Assert.IsNotNull(settings.Properties);
+
+ // Next window: Alt+`
+ AssertHotkey(settings.Properties.NextWindowShortcut, win: false, ctrl: false, alt: true, shift: false, code: OemTilde);
+
+ // Previous window: Shift+Alt+`
+ AssertHotkey(settings.Properties.PreviousWindowShortcut, win: false, ctrl: false, alt: true, shift: true, code: OemTilde);
+ }
+
+ [TestMethod]
+ public void ToJsonString_ShouldContainExpectedKeys()
+ {
+ var settings = new AltWindowCycleSettings();
+
+ var json = settings.ToJsonString();
+
+ StringAssert.Contains(json, AltWindowCycleSettings.ModuleName);
+ StringAssert.Contains(json, "\"properties\"");
+ StringAssert.Contains(json, "next_window_shortcut");
+ StringAssert.Contains(json, "previous_window_shortcut");
+ }
+
+ [TestMethod]
+ public void RoundTrip_WithDefaults_ShouldPreserveAllValues()
+ {
+ var original = new AltWindowCycleSettings();
+
+ var deserialized = JsonSerializer.Deserialize(original.ToJsonString());
+
+ Assert.IsNotNull(deserialized);
+ Assert.AreEqual(original.Name, deserialized.Name);
+ Assert.AreEqual(original.Version, deserialized.Version);
+ Assert.IsNotNull(deserialized.Properties);
+ AssertHotkeyEqual(original.Properties.NextWindowShortcut, deserialized.Properties.NextWindowShortcut);
+ AssertHotkeyEqual(original.Properties.PreviousWindowShortcut, deserialized.Properties.PreviousWindowShortcut);
+ }
+
+ [TestMethod]
+ public void RoundTrip_WithCustomHotkeys_ShouldPreserveOverrides()
+ {
+ var original = new AltWindowCycleSettings();
+ original.Properties.NextWindowShortcut = new HotkeySettings(true, false, false, false, 0x4E); // Win+N
+ original.Properties.PreviousWindowShortcut = new HotkeySettings(true, false, false, true, 0x50); // Win+Shift+P
+
+ var deserialized = JsonSerializer.Deserialize(original.ToJsonString());
+
+ Assert.IsNotNull(deserialized);
+ AssertHotkey(deserialized.Properties.NextWindowShortcut, win: true, ctrl: false, alt: false, shift: false, code: 0x4E);
+ AssertHotkey(deserialized.Properties.PreviousWindowShortcut, win: true, ctrl: false, alt: false, shift: true, code: 0x50);
+ }
+
+ [TestMethod]
+ public void Deserialize_WithNullHotkeys_ShouldUseDefaults()
+ {
+ const string Json = "{\"properties\":{\"next_window_shortcut\":null,\"previous_window_shortcut\":null}}";
+
+ var deserialized = JsonSerializer.Deserialize(Json);
+
+ Assert.IsNotNull(deserialized);
+ AssertHotkey(deserialized.Properties.NextWindowShortcut, win: false, ctrl: false, alt: true, shift: false, code: OemTilde);
+ AssertHotkey(deserialized.Properties.PreviousWindowShortcut, win: false, ctrl: false, alt: true, shift: true, code: OemTilde);
+ }
+
+ [TestMethod]
+ public void ShouldBeRegisteredInSerializationContext()
+ {
+ var options = new JsonSerializerOptions
+ {
+ TypeInfoResolver = SettingsSerializationContext.Default,
+ };
+
+ var typeInfo = options.TypeInfoResolver?.GetTypeInfo(typeof(AltWindowCycleSettings), options);
+
+ Assert.IsNotNull(typeInfo, "AltWindowCycleSettings must be registered in SettingsSerializationContext for Native AOT serialization.");
+ }
+
+ private static void AssertHotkey(HotkeySettings hotkey, bool win, bool ctrl, bool alt, bool shift, int code)
+ {
+ Assert.IsNotNull(hotkey);
+ Assert.AreEqual(win, hotkey.Win, "Win modifier mismatch.");
+ Assert.AreEqual(ctrl, hotkey.Ctrl, "Ctrl modifier mismatch.");
+ Assert.AreEqual(alt, hotkey.Alt, "Alt modifier mismatch.");
+ Assert.AreEqual(shift, hotkey.Shift, "Shift modifier mismatch.");
+ Assert.AreEqual(code, hotkey.Code, "Key code mismatch.");
+ }
+
+ private static void AssertHotkeyEqual(HotkeySettings expected, HotkeySettings actual)
+ {
+ AssertHotkey(actual, expected.Win, expected.Ctrl, expected.Alt, expected.Shift, expected.Code);
+ }
+ }
+}
diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/AltWindowCycle.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/AltWindowCycle.cs
new file mode 100644
index 0000000000..a4ee978b08
--- /dev/null
+++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/AltWindowCycle.cs
@@ -0,0 +1,141 @@
+// 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;
+using System.IO.Abstractions;
+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;
+using Moq;
+
+namespace ViewModelTests
+{
+ [TestClass]
+ public class AltWindowCycle
+ {
+ private Mock mockGeneralSettingsUtils;
+
+ private Mock mockAltWindowCycleSettingsUtils;
+
+ [TestInitialize]
+ public void SetUpStubSettingUtils()
+ {
+ mockGeneralSettingsUtils = ISettingsUtilsMocks.GetStubSettingsUtils();
+ mockAltWindowCycleSettingsUtils = ISettingsUtilsMocks.GetStubSettingsUtils();
+ }
+
+ private AltWindowCycleViewModel CreateViewModel(Mock settingsUtilsMock, Func ipcCallback = null)
+ {
+ ipcCallback ??= msg => 0;
+ return new AltWindowCycleViewModel(
+ settingsUtilsMock.Object,
+ SettingsRepository.GetInstance(mockGeneralSettingsUtils.Object),
+ SettingsRepository.GetInstance(mockAltWindowCycleSettingsUtils.Object),
+ ipcCallback);
+ }
+
+ [TestMethod]
+ public void Constructor_ShouldLoadDefaultShortcutsFromSettings()
+ {
+ var settingsUtilsMock = new Mock(new FileSystem(), null);
+ var viewModel = CreateViewModel(settingsUtilsMock);
+
+ Assert.IsNotNull(viewModel.NextWindowShortcut);
+ Assert.IsTrue(viewModel.NextWindowShortcut.Alt);
+ Assert.IsFalse(viewModel.NextWindowShortcut.Shift);
+ Assert.AreEqual(0xC0, viewModel.NextWindowShortcut.Code);
+
+ Assert.IsNotNull(viewModel.PreviousWindowShortcut);
+ Assert.IsTrue(viewModel.PreviousWindowShortcut.Alt);
+ Assert.IsTrue(viewModel.PreviousWindowShortcut.Shift);
+ Assert.AreEqual(0xC0, viewModel.PreviousWindowShortcut.Code);
+ }
+
+ [TestMethod]
+ public void IsEnabled_WhenSet_ShouldSendEnabledGeneralSettings()
+ {
+ var settingsUtilsMock = new Mock(new FileSystem(), null);
+
+ var ipcInvoked = false;
+ Func sendMockIPCConfigMSG = msg =>
+ {
+ OutGoingGeneralSettings snd = JsonSerializer.Deserialize(msg);
+ Assert.IsTrue(snd.GeneralSettings.Enabled.AltWindowCycle);
+ ipcInvoked = true;
+ return 0;
+ };
+
+ var viewModel = CreateViewModel(settingsUtilsMock, sendMockIPCConfigMSG);
+
+ viewModel.IsEnabled = true;
+
+ Assert.IsTrue(ipcInvoked, "Enabling the module should send an OutGoingGeneralSettings IPC message.");
+ Assert.IsTrue(viewModel.IsEnabled);
+ }
+
+ [TestMethod]
+ public void GpoNotConfigured_ShouldNotReportPolicyManaged()
+ {
+ var settingsUtilsMock = new Mock(new FileSystem(), null);
+ var viewModel = CreateViewModel(settingsUtilsMock);
+
+ // With no enterprise policy applied in the test environment, the module must be
+ // user-controllable (not reported as GPO-managed).
+ Assert.IsFalse(viewModel.IsEnabledGpoConfigured);
+ }
+
+ [TestMethod]
+ public void NextWindowShortcut_WhenChanged_ShouldPersistSettings()
+ {
+ var settingsUtilsMock = new Mock(new FileSystem(), null);
+ var viewModel = CreateViewModel(settingsUtilsMock);
+
+ var newShortcut = new HotkeySettings(true, true, false, false, 0x4E); // Win+Ctrl+N
+ viewModel.NextWindowShortcut = newShortcut;
+
+ Assert.AreSame(newShortcut, viewModel.NextWindowShortcut);
+ settingsUtilsMock.Verify(
+ x => x.SaveSettings(
+ It.Is(json => json.Contains("next_window_shortcut")),
+ It.Is(name => name == AltWindowCycleSettings.ModuleName),
+ It.IsAny()),
+ Times.AtLeastOnce);
+ }
+
+ [TestMethod]
+ public void PreviousWindowShortcut_WhenChanged_ShouldPersistSettings()
+ {
+ var settingsUtilsMock = new Mock(new FileSystem(), null);
+ var viewModel = CreateViewModel(settingsUtilsMock);
+
+ var newShortcut = new HotkeySettings(true, true, false, true, 0x50); // Win+Ctrl+Shift+P
+ viewModel.PreviousWindowShortcut = newShortcut;
+
+ Assert.AreSame(newShortcut, viewModel.PreviousWindowShortcut);
+ settingsUtilsMock.Verify(
+ x => x.SaveSettings(
+ It.Is(json => json.Contains("previous_window_shortcut")),
+ It.Is(name => name == AltWindowCycleSettings.ModuleName),
+ It.IsAny()),
+ Times.AtLeastOnce);
+ }
+
+ [TestMethod]
+ public void NextWindowShortcut_WhenSetToNull_ShouldFallBackToDefault()
+ {
+ var settingsUtilsMock = new Mock(new FileSystem(), null);
+ var viewModel = CreateViewModel(settingsUtilsMock);
+
+ viewModel.NextWindowShortcut = null;
+
+ Assert.IsNotNull(viewModel.NextWindowShortcut);
+ Assert.IsTrue(viewModel.NextWindowShortcut.Alt);
+ Assert.IsFalse(viewModel.NextWindowShortcut.Shift);
+ Assert.AreEqual(0xC0, viewModel.NextWindowShortcut.Code);
+ }
+ }
+}
diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/OobeShellViewModelTests.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/OobeShellViewModelTests.cs
new file mode 100644
index 0000000000..b9599655ab
--- /dev/null
+++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/OobeShellViewModelTests.cs
@@ -0,0 +1,29 @@
+// 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.Linq;
+
+using Microsoft.PowerToys.Settings.UI.OOBE.Enums;
+using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace ViewModelTests
+{
+ [TestClass]
+ public class OobeShellViewModelTests
+ {
+ [TestMethod]
+ public void ModulesPlacesAltWindowCycleImmediatelyBeforeWorkspaces()
+ {
+ var viewModel = new OobeShellViewModel();
+ var moduleNames = viewModel.Modules.Select(module => module.ModuleName).ToList();
+
+ int altWindowCycleIndex = moduleNames.IndexOf(PowerToysModules.AltWindowCycle.ToString());
+ int workspacesIndex = moduleNames.IndexOf(PowerToysModules.Workspaces.ToString());
+
+ Assert.IsTrue(altWindowCycleIndex >= 0);
+ Assert.AreEqual(altWindowCycleIndex + 1, workspacesIndex);
+ }
+ }
+}
diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/WindowHopper.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/WindowHopper.png
new file mode 100644
index 0000000000..3f67dccd5a
Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/WindowHopper.png differ
diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/WindowHopper.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/WindowHopper.png
new file mode 100644
index 0000000000..08c2342f9a
Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/WindowHopper.png differ
diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/WindowHopper.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/WindowHopper.png
new file mode 100644
index 0000000000..5195ea0780
Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/WindowHopper.png differ
diff --git a/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs b/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs
index d615d7aa9b..af781e81dd 100644
--- a/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs
+++ b/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs
@@ -27,6 +27,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers
case ModuleType.FancyZones: return GPOWrapper.GetConfiguredFancyZonesEnabledValue();
case ModuleType.FileLocksmith: return GPOWrapper.GetConfiguredFileLocksmithEnabledValue();
case ModuleType.FindMyMouse: return GPOWrapper.GetConfiguredFindMyMouseEnabledValue();
+ case ModuleType.AltWindowCycle: return GPOWrapper.GetConfiguredAltWindowCycleEnabledValue();
case ModuleType.Hosts: return GPOWrapper.GetConfiguredHostsFileEditorEnabledValue();
case ModuleType.ImageResizer: return GPOWrapper.GetConfiguredImageResizerEnabledValue();
case ModuleType.KeyboardManager: return GPOWrapper.GetConfiguredKeyboardManagerEnabledValue();
@@ -68,6 +69,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers
ModuleType.FancyZones => typeof(FancyZonesPage),
ModuleType.FileLocksmith => typeof(FileLocksmithPage),
ModuleType.FindMyMouse => typeof(MouseUtilsPage),
+ ModuleType.AltWindowCycle => typeof(AltWindowCyclePage),
ModuleType.GeneralSettings => typeof(GeneralPage),
ModuleType.Hosts => typeof(HostsPage),
ModuleType.ImageResizer => typeof(ImageResizerPage),
diff --git a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs
index a4c26d208c..ee2ca98f2a 100644
--- a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs
+++ b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs
@@ -37,5 +37,6 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Enums
GrabAndMove,
NewPlus,
ZoomIt,
+ AltWindowCycle,
}
}
diff --git a/src/settings-ui/Settings.UI/OOBE/ViewModel/OobeShellViewModel.cs b/src/settings-ui/Settings.UI/OOBE/ViewModel/OobeShellViewModel.cs
index 8482c5226a..439259fa12 100644
--- a/src/settings-ui/Settings.UI/OOBE/ViewModel/OobeShellViewModel.cs
+++ b/src/settings-ui/Settings.UI/OOBE/ViewModel/OobeShellViewModel.cs
@@ -44,6 +44,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.ViewModel
(PowerToysModules.TextExtractor, false),
(PowerToysModules.MeasureTool, false),
(PowerToysModules.Hosts, false),
+ (PowerToysModules.AltWindowCycle, false),
(PowerToysModules.Workspaces, false),
(PowerToysModules.GrabAndMove, false),
(PowerToysModules.RegistryPreview, false),
diff --git a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs
index b85afe1f1c..2f67f52faa 100644
--- a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs
+++ b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs
@@ -22,6 +22,7 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext;
[JsonSerializable(typeof(Dictionary>))]
[JsonSerializable(typeof(FileLocksmithSettings))]
[JsonSerializable(typeof(FindMyMouseSettings))]
+[JsonSerializable(typeof(AltWindowCycleSettings))]
[JsonSerializable(typeof(IList))]
[JsonSerializable(typeof(KeyboardManagerSettings))]
[JsonSerializable(typeof(LightSwitchSettings))]
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs
index 1a5bf974f2..e23eb1dbde 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs
+++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs
@@ -416,6 +416,7 @@ namespace Microsoft.PowerToys.Settings.UI
case "Dashboard": return typeof(DashboardPage);
case "Overview": return typeof(GeneralPage);
case "AdvancedPaste": return typeof(AdvancedPastePage);
+ case "AltWindowCycle": return typeof(AltWindowCyclePage);
case "AlwaysOnTop": return typeof(AlwaysOnTopPage);
case "Awake": return typeof(AwakePage);
case "CmdNotFound": return typeof(CmdNotFoundPage);
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAltWindowCycle.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAltWindowCycle.xaml
new file mode 100644
index 0000000000..2792d6a861
--- /dev/null
+++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAltWindowCycle.xaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAltWindowCycle.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAltWindowCycle.xaml.cs
new file mode 100644
index 0000000000..8f9cd45382
--- /dev/null
+++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAltWindowCycle.xaml.cs
@@ -0,0 +1,49 @@
+// 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 Microsoft.PowerToys.Settings.UI.Library;
+using Microsoft.PowerToys.Settings.UI.OOBE.Enums;
+using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel;
+using Microsoft.PowerToys.Settings.UI.Views;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Navigation;
+
+namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
+{
+ public sealed partial class OobeAltWindowCycle : Page
+ {
+ public OobePowerToysModule ViewModel { get; }
+
+ public OobeAltWindowCycle()
+ {
+ this.InitializeComponent();
+ ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.AltWindowCycle);
+ DataContext = ViewModel;
+ }
+
+ private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
+ {
+ if (OobeWindow.OpenMainWindowCallback != null)
+ {
+ OobeWindow.OpenMainWindowCallback(typeof(AltWindowCyclePage));
+ }
+
+ ViewModel.LogOpeningSettingsEvent();
+ }
+
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ ViewModel.LogOpeningModuleEvent();
+
+ AltWindowCycleSettings settings = SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig;
+ NextWindowHotkeyControl.Keys = settings.Properties.NextWindowShortcut.GetKeysList();
+ PreviousWindowHotkeyControl.Keys = settings.Properties.PreviousWindowShortcut.GetKeysList();
+ }
+
+ protected override void OnNavigatedFrom(NavigationEventArgs e)
+ {
+ ViewModel.LogClosingModuleEvent();
+ }
+ }
+}
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml
index 40ae3906a7..1b24d4db15 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml
+++ b/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml
@@ -166,6 +166,11 @@
x:Uid="Shell_TextExtractor"
Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/TextExtractor.png}"
Tag="TextExtractor" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AltWindowCyclePage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/AltWindowCyclePage.xaml.cs
new file mode 100644
index 0000000000..69c62926d7
--- /dev/null
+++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AltWindowCyclePage.xaml.cs
@@ -0,0 +1,31 @@
+// 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 Microsoft.PowerToys.Settings.UI.Helpers;
+using Microsoft.PowerToys.Settings.UI.Library;
+using Microsoft.PowerToys.Settings.UI.ViewModels;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Microsoft.PowerToys.Settings.UI.Views
+{
+ public sealed partial class AltWindowCyclePage : NavigablePage, IRefreshablePage
+ {
+ private AltWindowCycleViewModel ViewModel { get; set; }
+
+ public AltWindowCyclePage()
+ {
+ var settingsUtils = SettingsUtils.Default;
+ ViewModel = new AltWindowCycleViewModel(settingsUtils, SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage);
+ DataContext = ViewModel;
+ InitializeComponent();
+
+ Loaded += (s, e) => ViewModel.OnPageLoaded();
+ }
+
+ public void RefreshEnabledState()
+ {
+ ViewModel.RefreshEnabledState();
+ }
+ }
+}
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml
index bba25fbe1e..bd12048ec7 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml
+++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml
@@ -288,6 +288,12 @@
helpers:NavHelper.NavigateTo="views:GrabAndMovePage"
AutomationProperties.AutomationId="GrabAndMoveNavItem"
Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/GrabAndMove.png}" />
+
to crop an application's window into a screenshot window. The screenshot won't update with the original window's content.
+
+ Window Hopper lets you cycle between windows of the currently focused application.
+ "Window Hopper" is the name of the utility
+
+
+ Window Hopper
+ "Window Hopper" is the name of the utility
+
+
+ Activation
+
+
+ Enable Window Hopper
+ "Window Hopper" is the name of the utility
+
+
+ Cycle to the next window of the focused app
+
+
+ Next window
+
+
+ Cycle to the previous window of the focused app
+
+
+ Previous window
+
+
+ Window Hopper
+ "Window Hopper" is the name of the utility
+
+
+ Window Hopper
+ "Window Hopper" is the name of the utility
+
+
+ Window Hopper lets you cycle between windows of the currently focused application.
+ "Window Hopper" is the name of the utility
+
+
+ to cycle to the next window of the focused app.
+
+
+ to cycle to the previous window of the focused app.
+
+
+ Use the next and previous shortcuts to move through only the windows that belong to your currently focused app.
+
Always On Top is a quick and easy way to pin windows on top.
diff --git a/src/settings-ui/Settings.UI/ViewModels/AltWindowCycleViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/AltWindowCycleViewModel.cs
new file mode 100644
index 0000000000..f3e6c834e1
--- /dev/null
+++ b/src/settings-ui/Settings.UI/ViewModels/AltWindowCycleViewModel.cs
@@ -0,0 +1,174 @@
+// 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;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using global::PowerToys.GPOWrapper;
+using Microsoft.PowerToys.Settings.UI.Helpers;
+using Microsoft.PowerToys.Settings.UI.Library;
+using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
+using Microsoft.PowerToys.Settings.UI.Library.Utilities;
+using Microsoft.PowerToys.Settings.UI.SerializationContext;
+
+namespace Microsoft.PowerToys.Settings.UI.ViewModels
+{
+ public partial class AltWindowCycleViewModel : PageViewModelBase
+ {
+ protected override string ModuleName => AltWindowCycleSettings.ModuleName;
+
+ private SettingsUtils SettingsUtils { get; set; }
+
+ private GeneralSettings GeneralSettingsConfig { get; set; }
+
+ private AltWindowCycleSettings Settings { get; set; }
+
+ private Func SendConfigMSG { get; }
+
+ public AltWindowCycleViewModel(SettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository moduleSettingsRepository, Func ipcMSGCallBackFunc)
+ {
+ ArgumentNullException.ThrowIfNull(settingsUtils);
+
+ SettingsUtils = settingsUtils;
+
+ // To obtain the general settings configurations of PowerToys Settings.
+ ArgumentNullException.ThrowIfNull(settingsRepository);
+
+ GeneralSettingsConfig = settingsRepository.SettingsConfig;
+
+ InitializeEnabledValue();
+
+ // To obtain the settings configurations of AltWindowCycle.
+ ArgumentNullException.ThrowIfNull(moduleSettingsRepository);
+
+ Settings = moduleSettingsRepository.SettingsConfig;
+
+ _nextWindowShortcut = Settings.Properties.NextWindowShortcut;
+ _previousWindowShortcut = Settings.Properties.PreviousWindowShortcut;
+
+ // set the callback functions value to handle outgoing IPC message.
+ SendConfigMSG = ipcMSGCallBackFunc;
+ }
+
+ private void InitializeEnabledValue()
+ {
+ _enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredAltWindowCycleEnabledValue();
+ if (_enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled)
+ {
+ // Get the enabled state from GPO.
+ _enabledStateIsGPOConfigured = true;
+ _isEnabled = _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled;
+ }
+ else
+ {
+ _isEnabled = GeneralSettingsConfig.Enabled.AltWindowCycle;
+ }
+ }
+
+ public override Dictionary GetAllHotkeySettings()
+ {
+ var hotkeysDict = new Dictionary
+ {
+ [ModuleName] = [NextWindowShortcut, PreviousWindowShortcut],
+ };
+
+ return hotkeysDict;
+ }
+
+ public bool IsEnabled
+ {
+ get => _isEnabled;
+
+ set
+ {
+ if (_enabledStateIsGPOConfigured)
+ {
+ // If it's GPO configured, shouldn't be able to change this state.
+ return;
+ }
+
+ if (value != _isEnabled)
+ {
+ _isEnabled = value;
+
+ // Set the status in the general settings configuration
+ GeneralSettingsConfig.Enabled.AltWindowCycle = value;
+ OutGoingGeneralSettings snd = new OutGoingGeneralSettings(GeneralSettingsConfig);
+
+ SendConfigMSG(snd.ToString());
+ OnPropertyChanged(nameof(IsEnabled));
+ }
+ }
+ }
+
+ public bool IsEnabledGpoConfigured
+ {
+ get => _enabledStateIsGPOConfigured;
+ }
+
+ public HotkeySettings NextWindowShortcut
+ {
+ get => _nextWindowShortcut;
+
+ set
+ {
+ if (value != _nextWindowShortcut)
+ {
+ _nextWindowShortcut = value ?? Settings.Properties.DefaultNextWindowShortcut;
+
+ Settings.Properties.NextWindowShortcut = _nextWindowShortcut;
+ NotifyPropertyChanged();
+ SendSettingsConfigMessage();
+ }
+ }
+ }
+
+ public HotkeySettings PreviousWindowShortcut
+ {
+ get => _previousWindowShortcut;
+
+ set
+ {
+ if (value != _previousWindowShortcut)
+ {
+ _previousWindowShortcut = value ?? Settings.Properties.DefaultPreviousWindowShortcut;
+
+ Settings.Properties.PreviousWindowShortcut = _previousWindowShortcut;
+ NotifyPropertyChanged();
+ SendSettingsConfigMessage();
+ }
+ }
+ }
+
+ public void NotifyPropertyChanged([CallerMemberName] string propertyName = null)
+ {
+ OnPropertyChanged(propertyName);
+ SettingsUtils.SaveSettings(Settings.ToJsonString(), AltWindowCycleSettings.ModuleName);
+ }
+
+ public void RefreshEnabledState()
+ {
+ InitializeEnabledValue();
+ OnPropertyChanged(nameof(IsEnabled));
+ }
+
+ private void SendSettingsConfigMessage()
+ {
+ SendConfigMSG(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ "{{ \"powertoys\": {{ \"{0}\": {1} }} }}",
+ AltWindowCycleSettings.ModuleName,
+ JsonSerializer.Serialize(Settings, SourceGenerationContextContext.Default.AltWindowCycleSettings)));
+ }
+
+ private GpoRuleConfigured _enabledGpoRuleConfiguration;
+ private bool _enabledStateIsGPOConfigured;
+ private bool _isEnabled;
+ private HotkeySettings _nextWindowShortcut;
+ private HotkeySettings _previousWindowShortcut;
+ }
+}
diff --git a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs
index c003fe81e1..b2ca080ee8 100644
--- a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs
+++ b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs
@@ -493,6 +493,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
ModuleType.EnvironmentVariables => GetModuleItemsEnvironmentVariables(),
ModuleType.FancyZones => GetModuleItemsFancyZones(),
ModuleType.FindMyMouse => GetModuleItemsFindMyMouse(),
+ ModuleType.AltWindowCycle => GetModuleItemsAltWindowCycle(),
ModuleType.Hosts => GetModuleItemsHosts(),
ModuleType.KeyboardManager => GetModuleItemsKeyboardManager(),
ModuleType.LightSwitch => GetModuleItemsLightSwitch(),
@@ -626,6 +627,18 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
return new ObservableCollection(list);
}
+ private ObservableCollection GetModuleItemsAltWindowCycle()
+ {
+ ISettingsRepository moduleSettingsRepository = SettingsRepository.GetInstance(SettingsUtils.Default);
+ var settings = moduleSettingsRepository.SettingsConfig;
+ var list = new List
+ {
+ new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("AltWindowCycle_NextWindowShortcut/Header"), Shortcut = settings.Properties.NextWindowShortcut.GetKeysList() },
+ new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("AltWindowCycle_PreviousWindowShortcut/Header"), Shortcut = settings.Properties.PreviousWindowShortcut.GetKeysList() },
+ };
+ return new ObservableCollection(list);
+ }
+
private ObservableCollection GetModuleItemsHosts()
{
var list = new List
diff --git a/tools/Verification scripts/verify-installation-script.ps1 b/tools/Verification scripts/verify-installation-script.ps1
index 87986b005f..45a47809f8 100644
--- a/tools/Verification scripts/verify-installation-script.ps1
+++ b/tools/Verification scripts/verify-installation-script.ps1
@@ -334,6 +334,7 @@ function Test-CoreFiles {
# Module executables and libraries
'PowerToys.AlwaysOnTop.exe',
'PowerToys.AlwaysOnTopModuleInterface.dll',
+ 'PowerToys.AltWindowCycle.dll',
'PowerToys.CmdNotFoundModuleInterface.dll',
'PowerToys.ColorPicker.dll',
'PowerToys.ColorPickerUI.dll',