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 @@ + + + + + + + + + + + + + + +