diff --git a/mods/simple-window-switcher.wh.cpp b/mods/simple-window-switcher.wh.cpp index 39c206061c..955a3aaeae 100644 --- a/mods/simple-window-switcher.wh.cpp +++ b/mods/simple-window-switcher.wh.cpp @@ -2,7 +2,7 @@ // @id simple-window-switcher // @name Simple Window Switcher // @description Replaces the default Alt+Tab with a lightweight window switcher inspired by ExplorerPatcher's Simple Window Switcher -// @version 2.0 +// @version 2.1 // @author Lone // @github https://github.com/Louis047 // @include windhawk.exe @@ -65,7 +65,7 @@ Additional improvements made by [Asteski](https://github.com/Asteski). $name: Style $description: Visual theme style for the switcher background. $options: - - none: None (Transparent) + - none: None (Solid/Transparent) - backdrop: Acrylic (Windows 10+) - mica: Mica Blur (Windows 11 only) - colorScheme: system @@ -266,6 +266,12 @@ Additional improvements made by [Asteski](https://github.com/Asteski). - showThumbnails: true $name: Show Thumbnails $description: Show DWM live thumbnail previews of windows. + - showCloseButton: true + $name: Show Close Button + $description: Show the 'X' button on hover to close windows. + - showOverflowIndicator: true + $name: Show Overflow Indicator + $description: Show chevron indicators at the edges when there are more windows off-screen. - showHoverBorder: true $name: Show Hover Border $description: Show a colored border around the thumbnail when hovered. @@ -432,11 +438,30 @@ Additional improvements made by [Asteski](https://github.com/Asteski). $name: Show Delay (ms) $description: Delay in milliseconds before showing the switcher (0 = instant). - scrollWheelBehavior: never - $name: Scroll Wheel to Change Selection + $name: Scroll Wheel Activation + $description: When the scroll wheel should be active. $options: - never: Never - always: Always - stickyOnly: Only in sticky mode + - scrollWheelAction: selection + $name: Scroll Wheel Action + $options: + - selection: Change Selection + - page: Scroll Pages + - scrollSecondaryAction: page + $name: Secondary Scroll Wheel Action (With Modifier) + $options: + - none: None + - selection: Change Selection + - page: Scroll Pages + - scrollSecondaryModifier: shift + $name: Secondary Scroll Wheel Modifier Key + $options: + - none: None + - shift: Shift + - ctrl: Ctrl + - alt: Alt - reverseScrollDirection: false $name: Reverse Scroll Direction - backwardShortcut: altShiftTab @@ -446,6 +471,12 @@ Additional improvements made by [Asteski](https://github.com/Asteski). - altShiftTab: Alt+Shift+Tab (default) - altShift: Alt+Shift - altBacktick: Alt+Backtick + - altBacktickBehavior: backward + $name: Alt+Backtick Behavior + $description: Action to perform when pressing Alt+` (Backtick). + $options: + - backward: Cycle Backward + - sameApp: Cycle Between Windows of Current Application - switcherDisplayBehavior: cursorMonitor $name: Switcher Display Behavior $options: @@ -454,7 +485,7 @@ Additional improvements made by [Asteski](https://github.com/Asteski). - cursorMonitor: Monitor Based on Cursor Location - perMonitorWindows: false $name: Display Windows Only from the Monitor Containing the Cursor - - virtualDesktopBehavior: currentOnly + - virtualDesktopBehavior: allDesktops $name: Virtual Desktop Behavior $description: Choose which virtual desktops to show windows from. $options: @@ -463,6 +494,9 @@ Additional improvements made by [Asteski](https://github.com/Asteski). - hideMinimizedWindows: false $name: Hide Minimized Windows $description: Hide minimized windows from the switcher. When "Group Windows by Application" is enabled, an application is only hidden if all of its windows are minimized. + - sortMinimizedWindowsToEnd: true + $name: Sort Minimized Windows to the End + $description: Sort minimized windows after all active windows. Disable this to keep minimized windows in their Z-order. $name: Accessibility - ExcludedWindows: - excludeByTitle: "" @@ -482,7 +516,7 @@ Additional improvements made by [Asteski](https://github.com/Asteski). $description: "Full path to an icon source (.ico, .exe or .dll); the first icon in the file is used. Leave empty to keep the default icon. Example: C:\\Icons\\myapp.ico" - appName: "" $name: Application Name - $description: "Custom name to display for matching tasks, replacing the detected application name. Leave empty to keep the default. Shown in the 'App name' and 'App name + Window title' title modes." + $description: "Custom name to display for matching tasks, replacing the detected application name. Leave empty to keep the default. Shown in the 'App name' and 'App name + Window title' title modes (requires 'Group Windows by Application' to be enabled)." $name: Custom Header $description: Assign a custom icon and/or application name to tasks based on their executable name. The first matching rule wins. @@ -567,7 +601,7 @@ struct WindowEntry { int drawnIconSz; // Size of the drawn icon }; struct Settings { - WCHAR theme[32]; WCHAR colorScheme[32]; WCHAR cornerPreference[32]; WCHAR scrollWheelBehavior[32]; WCHAR taskListOrientation[32]; WCHAR headerContentOrientation[32]; WCHAR iconSize[32]; WCHAR backwardShortcut[32]; WCHAR thumbnailPosition[32]; WCHAR thumbnailAlignment[32]; WCHAR switcherDisplayBehavior[32]; + WCHAR theme[32]; WCHAR colorScheme[32]; WCHAR cornerPreference[32]; WCHAR scrollWheelBehavior[32]; WCHAR scrollWheelAction[32]; WCHAR scrollSecondaryAction[32]; WCHAR scrollSecondaryModifier[32]; WCHAR taskListOrientation[32]; WCHAR headerContentOrientation[32]; WCHAR iconSize[32]; WCHAR backwardShortcut[32]; WCHAR altBacktickBehavior[32]; WCHAR thumbnailPosition[32]; WCHAR thumbnailAlignment[32]; WCHAR switcherDisplayBehavior[32]; WCHAR virtualDesktopBehavior[32]; // Global theme settings (apply to both light and dark) WCHAR highlightStyle[32]; int opacity; @@ -590,6 +624,8 @@ struct Settings { int rowWidth; bool stretchThumbnailsToTaskWidth; bool showThumbnails; + bool showCloseButton; + bool showOverflowIndicator; bool showTitle; bool showIcon; int maxWidthPercent; @@ -601,6 +637,7 @@ struct Settings { WCHAR showTitles[32]; bool restoreAllWindows; bool hideMinimizedWindows; + bool sortMinimizedWindowsToEnd; int customCornerRadius; WCHAR switcherPosition[32]; int switcherPositionMargin; @@ -631,6 +668,8 @@ static bool g_showAllMonitors = false; static HHOOK g_hMouseHook = NULL; static std::vector g_windows; static int g_selectedIndex = 0, g_hoverIndex = -1; +static bool g_isPaginatedView = false; +static bool g_isDryRunLayout = false; static HWND g_hoverWnd = NULL; static int g_layoutStartIndex = 0; // EP-style: first window index visible in the layout // App drill-in: when grouping by application, Ctrl drills into the selected app's @@ -652,6 +691,7 @@ static int g_autoFitScalePct = 100; static int g_winW = 0, g_winH = 0; static int g_activePadDivider = 0; static bool g_hotkeysRegistered = false; +static bool g_isAltBacktickSameApp = false; static HMONITOR g_hCurrentMonitor = NULL; static Settings g_settings; static HANDLE g_hSwitcherThread = NULL; @@ -975,11 +1015,14 @@ static bool ShouldListInAltTab(HWND hwnd) { // WS_EX_NOACTIVATE excludes unless WS_EX_APPWINDOW is also set if ((ex & WS_EX_NOACTIVATE) && !(ex & WS_EX_APPWINDOW)) return false; - // Owner chain: if window has a visible, enabled owner, exclude it - // unless WS_EX_APPWINDOW forces inclusion + // Native Alt+Tab usually prefers the active popup (child dialog). + // However, as requested, we want to show the PARENT window instead of the child. + // If a window has an owner, it is a child/popup. We skip it unless it explicitly requests to be an app window. + // We also do not check IsWindowEnabled(own), so even if the parent is disabled by a modal popup, it still appears. HWND own = GetWindow(hwnd, GW_OWNER); - bool ownVis = IsWindow(own) && IsWindowEnabled(own) && IsReallyVisible(own); - if (ownVis && !(ex & WS_EX_APPWINDOW)) return false; + if (IsWindow(own) && IsReallyVisible(own) && !(ex & WS_EX_APPWINDOW)) { + return false; + } // Check if an ancestor in the owner chain is a tool window if (IsOwnerToolWindow(hwnd)) return false; @@ -1148,8 +1191,8 @@ static BOOL CALLBACK EnumWindowsProc(HWND hWnd, LPARAM lParam) { } WindowEntry e = {}; e.hWnd = hWnd; - InternalGetWindowText(hWnd, e.title, 256); - if (!e.title[0]) GetWindowTextW(hWnd, e.title, 256); + GetWindowTextW(hWnd, e.title, 256); + if (!e.title[0]) InternalGetWindowText(hWnd, e.title, 256); bool excluded = false; for (const auto& pat : g_excludeTitlePatterns) { @@ -1614,9 +1657,11 @@ static void BuildWindowList() { return IsIconic(w.hWnd) != FALSE; // hide if minimized }), g_windows.end()); } - std::stable_sort(g_windows.begin(), g_windows.end(), [](const WindowEntry& a, const WindowEntry& b) { - return IsIconic(a.hWnd) < IsIconic(b.hWnd); - }); + if (g_settings.sortMinimizedWindowsToEnd) { + std::stable_sort(g_windows.begin(), g_windows.end(), [](const WindowEntry& a, const WindowEntry& b) { + return IsIconic(a.hWnd) < IsIconic(b.hWnd); + }); + } } // Layout + Thumbnails @@ -1887,10 +1932,17 @@ static void ComputeLayout(HMONITOR hMon) { g_windows[ji].rcCell = {0, 0, 0, 0}; g_windows[ji].rcThumbActual = {0, 0, 0, 0}; g_windows[ji].rcThumbSlot = {0, 0, 0, 0}; - for (const auto& kv : g_windows[ji].hThumbs) { - if (kv.second) DwmUnregisterThumbnail(kv.second); + // During dry-run layout passes (e.g. CyclePage page-map discovery), + // we do NOT destroy DWM thumbnail handles. Doing so on every scroll + // event triggers rapid DwmUnregister/Register cycles that cause + // visible flicker in the DWM compositor. The caller is responsible + // for proper cleanup when g_isDryRunLayout is false. + if (!g_isDryRunLayout) { + for (const auto& kv : g_windows[ji].hThumbs) { + if (kv.second) DwmUnregisterThumbnail(kv.second); + } + g_windows[ji].hThumbs.clear(); } - g_windows[ji].hThumbs.clear(); } placedCount = startIdx; }; @@ -1902,16 +1954,21 @@ static void ComputeLayout(HMONITOR hMon) { int i = (g_layoutStartIndex + idx) % n; auto& w = g_windows[i]; - if (g_layoutStartIndex > 0 && idx > 0 && i < g_layoutStartIndex - && ((g_layoutStartIndex + idx - 1) % n) >= g_layoutStartIndex - && curX > initialLeft + masterPad) { - if (curX - initialLeft > maxRowW) maxRowW = curX - initialLeft; - curX = initialLeft + masterPad; - if (curY + 2 * bottomInc - initialTop > maxH - masterPad) { + if (g_layoutStartIndex > 0 && idx > 0 && i < g_layoutStartIndex) { + if (g_isPaginatedView) { truncateRemaining(idx); break; } - curY = curY + bottomInc; + if (((g_layoutStartIndex + idx - 1) % n) >= g_layoutStartIndex + && curX > initialLeft + masterPad) { + if (curX - initialLeft > maxRowW) maxRowW = curX - initialLeft; + curX = initialLeft + masterPad; + if (curY + 2 * bottomInc - initialTop > maxH - masterPad) { + truncateRemaining(idx); + break; + } + curY = curY + bottomInc; + } } int width = 0; @@ -2075,16 +2132,21 @@ static void ComputeLayout(HMONITOR hMon) { int i = (g_layoutStartIndex + idx) % n; auto& w = g_windows[i]; - if (g_layoutStartIndex > 0 && idx > 0 && i < g_layoutStartIndex - && ((g_layoutStartIndex + idx - 1) % n) >= g_layoutStartIndex - && curY > initialTop + masterPad) { - curY = initialTop + masterPad; - curX = curX + curColMaxW + rightInc; - curColMaxW = 0; - if (curX + rightInc - initialLeft > maxW - masterPad) { + if (g_layoutStartIndex > 0 && idx > 0 && i < g_layoutStartIndex) { + if (g_isPaginatedView) { truncateRemaining(idx); break; } + if (((g_layoutStartIndex + idx - 1) % n) >= g_layoutStartIndex + && curY > initialTop + masterPad) { + curY = initialTop + masterPad; + curX = curX + curColMaxW + rightInc; + curColMaxW = 0; + if (curX + rightInc - initialLeft > maxW - masterPad) { + truncateRemaining(idx); + break; + } + } } int width = 0; @@ -2736,6 +2798,8 @@ static void DrawThumbnailShadow(HDC hdc, const RECT& rc, int cornerRadius) { } } +static bool IsWindowTruncated(int idx); + // Shared drawing routine for both layered and buffered paint paths static void DrawSwitcherContent(HDC hdc, bool fillBg, HWND hWnd) { RECT rcClient; GetClientRect(g_hSwitcher, &rcClient); @@ -2926,14 +2990,67 @@ static void DrawSwitcherContent(HDC hdc, bool fillBg, HWND hWnd) { } } } + + // Draw overflow indicator if any windows are truncated + if (g_settings.showOverflowIndicator && g_windows.size() > 0) { + int n = (int)g_windows.size(); + int visibleCount = 0; + for (int i = 0; i < n; i++) { + if (IsWindowTruncated((g_layoutStartIndex + i) % n)) break; + visibleCount++; + } + + bool hasPrev = (g_layoutStartIndex > 0); + bool hasNext = ((g_layoutStartIndex + visibleCount) < n); + + if (hasPrev || hasNext) { + SetTextColor(hdc, g_isDarkMode ? SWS_TEXT_DARK : SWS_TEXT_LIGHT); + + bool verticalLayout = LayoutIsVertical(); + int padTop = DpiScale(SWS_PAD_TOP, g_dpiY); + int padBot = DpiScale(SWS_PAD_BOTTOM, g_dpiY); + int padLeft = DpiScale(SWS_PAD_LEFT, g_dpiX); + int padRight = DpiScale(SWS_PAD_RIGHT, g_dpiX); + + if (hasPrev) { + RECT rcPrev = rcClient; + LPCWSTR textPrev = verticalLayout ? L"\x25C0" /* Left */ : L"\x25B2" /* Up */; + if (verticalLayout) { + rcPrev.right = rcPrev.left + padLeft; + rcPrev.left += DpiScale(6, g_dpiX); + rcPrev.right += DpiScale(6, g_dpiX); + DrawTextW(hdc, textPrev, -1, &rcPrev, DT_SINGLELINE | DT_VCENTER | DT_CENTER | DT_NOPREFIX); + } else { + rcPrev.bottom = rcPrev.top + padTop; + rcPrev.top += DpiScale(6, g_dpiY); + rcPrev.bottom += DpiScale(6, g_dpiY); + DrawTextW(hdc, textPrev, -1, &rcPrev, DT_SINGLELINE | DT_CENTER | DT_VCENTER | DT_NOPREFIX); + } + } + if (hasNext) { + RECT rcNext = rcClient; + LPCWSTR textNext = verticalLayout ? L"\x25B6" /* Right */ : L"\x25BC" /* Down */; + if (verticalLayout) { + rcNext.left = rcNext.right - padRight; + rcNext.left -= DpiScale(6, g_dpiX); + rcNext.right -= DpiScale(6, g_dpiX); + DrawTextW(hdc, textNext, -1, &rcNext, DT_SINGLELINE | DT_VCENTER | DT_CENTER | DT_NOPREFIX); + } else { + rcNext.top = rcNext.bottom - padBot; + rcNext.top -= DpiScale(6, g_dpiY); + rcNext.bottom -= DpiScale(6, g_dpiY); + DrawTextW(hdc, textNext, -1, &rcNext, DT_SINGLELINE | DT_CENTER | DT_VCENTER | DT_NOPREFIX); + } + } + } + } + SelectObject(hdc, hOldFont); } // Rendering -static bool IsWindowTruncated(int idx); - static void DrawSwitcherOverlay(HDC hdc, HWND hWnd) { if (g_windows.empty()) return; @@ -3068,7 +3185,7 @@ static void DrawSwitcherOverlay(HDC hdc, HWND hWnd) { } // Close button (positioned at top-right of the cell, in title area) - if (i == g_hoverIndex && g_hoverWnd == hWnd) { + if (g_settings.showCloseButton && i == g_hoverIndex && g_hoverWnd == hWnd) { int btnSz = DpiScale(24, g_dpiX); int bx, by; if (rowTitleH == 0 || (g_settings.showThumbnails && ThumbnailIsSide()) || BadgeLayoutActive()) { @@ -3317,6 +3434,22 @@ static void PaintSwitcher() { void* bits = NULL; HBITMAP hBmp = CreateDIBSection(hdcMem, &bmi, DIB_RGB_COLORS, &bits, NULL, 0); HBITMAP hOld = (HBITMAP)SelectObject(hdcMem, hBmp); + + INT cp = GetCornerPref(); + int radius = 0; + if (cp == 1 && wcscmp(g_settings.cornerPreference, L"custom") == 0) { + radius = MulDiv(g_settings.customCornerRadius, g_dpiX, 96); + } else if (cp == 2) { + radius = MulDiv(8, g_dpiX, 96); + } else if (cp == 3) { + radius = MulDiv(4, g_dpiX, 96); + } + if (radius > 0) { + HRGN hClip = CreateRoundRectRgn(0, 0, w + 1, h + 1, radius * 2, radius * 2); + SelectClipRgn(hdcMem, hClip); + DeleteObject(hClip); + } + DrawSwitcherContent(hdcMem, true, g_hSwitcher); POINT ptSrc = {0,0}; SIZE sz = {w, h}; BLENDFUNCTION bf = {AC_SRC_OVER, 0, 255, AC_SRC_ALPHA}; @@ -3333,12 +3466,14 @@ static void PaintSwitcher() { ReleaseDC(g_hSwitcher, hdcScreen); PaintSwitcherOverlay(); } else { - // Acrylic: trigger WM_PAINT via InvalidateRect - InvalidateRect(g_hSwitcher, NULL, TRUE); + // Acrylic: trigger WM_PAINT via InvalidateRect. + // Use FALSE for the erase parameter: TRUE would send WM_ERASEBKGND which + // blanks the window before WM_PAINT fires, producing a visible flicker gap. + InvalidateRect(g_hSwitcher, NULL, FALSE); UpdateWindow(g_hSwitcher); for (HWND hMirror : g_hMirrorSwitchers) { if (IsWindow(hMirror)) { - InvalidateRect(hMirror, NULL, TRUE); + InvalidateRect(hMirror, NULL, FALSE); UpdateWindow(hMirror); } } @@ -3349,13 +3484,11 @@ static void PaintSwitcher() { // Switcher Show / Hide / Switch static void GetOffscreenDelayPosition(int* x, int* y) { - // Put the 1x1 window just outside the virtual screen bounds. - // This avoids alpha/layered hacks while keeping the window shown/foreground. - int vx = GetSystemMetrics(SM_XVIRTUALSCREEN); - int vy = GetSystemMetrics(SM_YVIRTUALSCREEN); - - *x = vx - 2; - *y = vy - 2; + // Put the 1x1 window far off-screen. + // This avoids alpha/layered hacks while keeping the window shown/foreground, + // and prevents the momentary top-left flash. + *x = -9999; + *y = -9999; } static void CancelPendingShow() { @@ -3384,10 +3517,21 @@ static LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lPara if (ok) { int dir = (short)HIWORD(pMouseStruct->mouseData) > 0 ? -1 : 1; if (g_settings.reverseScrollDirection) dir = -dir; - // Defer the heavy CycleLinear work to the wndproc; low-level hook - // callbacks run synchronously on the raw-input path and must stay - // trivial or Windows will silently drop the hook. - PostMessage(g_hSwitcher, WM_SWS_SCROLL, (WPARAM)dir, 0); + + bool modActive = false; + if (wcscmp(g_settings.scrollSecondaryModifier, L"shift") == 0) modActive = (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; + else if (wcscmp(g_settings.scrollSecondaryModifier, L"ctrl") == 0) modActive = (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; + else if (wcscmp(g_settings.scrollSecondaryModifier, L"alt") == 0) modActive = (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; + + const WCHAR* actionStr = modActive ? g_settings.scrollSecondaryAction : g_settings.scrollWheelAction; + + int action = 0; // 0 = none, 1 = selection, 2 = page + if (wcscmp(actionStr, L"selection") == 0) action = 1; + else if (wcscmp(actionStr, L"page") == 0) action = 2; + + if (action > 0) { + PostMessage(g_hSwitcher, WM_SWS_SCROLL, (WPARAM)dir, (LPARAM)action); + } return 1; } } @@ -3503,7 +3647,31 @@ static BOOL WINAPI MirrorEnumProc(HMONITOR hM, HDC, LPRECT, LPARAM) { return TRUE; } +static void DestroyMirrorSwitchers() { + for (HWND hMirror : g_hMirrorSwitchers) { + if (IsWindow(hMirror)) DestroyWindow(hMirror); + } + g_hMirrorSwitchers.clear(); +} + +static std::wstring GetExeFromHwnd(HWND hWnd) { + WCHAR path[MAX_PATH] = {0}; + DWORD pid = 0; + GetWindowThreadProcessId(hWnd, &pid); + if (pid) { + HANDLE hProc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (hProc) { + DWORD size = MAX_PATH; + QueryFullProcessImageNameW(hProc, 0, path, &size); + CloseHandle(hProc); + } + } + return std::wstring(path); +} + static void ShowSwitcher(bool sticky) { + DestroyMirrorSwitchers(); + POINT pt; GetCursorPos(&pt); HMONITOR hMon = (wcscmp(g_settings.switcherDisplayBehavior, L"primaryOnly") == 0) ? MonitorFromWindow(GetDesktopWindow(), MONITOR_DEFAULTTOPRIMARY) : @@ -3511,6 +3679,17 @@ static void ShowSwitcher(bool sticky) { g_hCurrentMonitor = hMon; UnregisterThumbnails(); BuildWindowList(); + + if (g_isAltBacktickSameApp) { + std::wstring activeExe = GetExeFromHwnd(GetForegroundWindow()); + if (!activeExe.empty()) { + g_windows.erase(std::remove_if(g_windows.begin(), g_windows.end(), + [&](const WindowEntry& e) { return GetExeFromHwnd(e.hWnd) != activeExe; }), + g_windows.end()); + } + g_isAltBacktickSameApp = false; + } + if (g_windows.empty()) return; g_isDarkMode = ShouldUseDarkMode(); g_isSticky = sticky; @@ -3601,10 +3780,7 @@ static void HideSwitcher() { CancelPendingShow(); if (g_hSwitcher) KillTimer(g_hSwitcher, SWS_ALT_POLL_TIMER_ID); - for (HWND hMirror : g_hMirrorSwitchers) { - if (IsWindow(hMirror)) DestroyWindow(hMirror); - } - g_hMirrorSwitchers.clear(); + DestroyMirrorSwitchers(); UnregisterThumbnails(); if (g_hSwitcher) { @@ -3624,6 +3800,7 @@ static void HideSwitcher() { g_drilledIn = false; g_savedAppList.clear(); g_consumeEscUp = false; + g_isPaginatedView = false; } static void SwitchToSelected() { @@ -3633,16 +3810,29 @@ static void SwitchToSelected() { if (g_settings.showApplications && g_settings.restoreAllWindows) { groupWindows = g_windows[g_selectedIndex].groupWindows; } - HideSwitcher(); - for (HWND hw : groupWindows) { - if (IsWindow(hw) && hw != hT && IsIconic(hw)) ShowWindow(hw, SW_RESTORE); - } + + // Switch to the selected window FIRST while we still have foreground rights if (IsWindow(hT)) { HWND hP = GetLastActivePopup(hT); HWND hF = IsWindowVisible(hP) ? hP : hT; if (IsIconic(hF)) ShowWindow(hF, SW_RESTORE); - SwitchToThisWindow(hF, TRUE); + + // Forcefully attach thread input to guarantee SetForegroundWindow succeeds + DWORD fgThread = GetWindowThreadProcessId(GetForegroundWindow(), NULL); + DWORD myThread = GetCurrentThreadId(); + if (fgThread != myThread) AttachThreadInput(myThread, fgThread, TRUE); + + SetForegroundWindow(hF); + + if (fgThread != myThread) AttachThreadInput(myThread, fgThread, FALSE); + } + + for (HWND hw : groupWindows) { + if (IsWindow(hw) && hw != hT && IsIconic(hw)) ShowWindow(hw, SW_RESTORE); } + + // Hide the switcher AFTER we've successfully transferred focus and updated the Z-order + HideSwitcher(); } // Helper: check if a window is truncated (not placed in current layout) @@ -3655,8 +3845,10 @@ static bool IsWindowTruncated(int idx) { // Helper: recompute layout and reposition switcher window static void RecomputeAndReposition() { UnregisterThumbnails(); + DestroyMirrorSwitchers(); RegisterThumbnailsEarly(); HMONITOR hMon = MonitorFromWindow(g_hSwitcher, MONITOR_DEFAULTTONEAREST); + g_hCurrentMonitor = hMon; ComputeLayout(hMon); MONITORINFO mi = { sizeof(mi) }; GetMonitorInfoW(hMon, &mi); @@ -3678,7 +3870,23 @@ static void RecomputeAndReposition() { return; } - SetWindowPos(g_hSwitcher, HWND_TOPMOST, cx, cy, g_winW, g_winH, SWP_NOACTIVATE); + // Only call SetWindowPos when the size or position has actually changed. + // An unconditional SetWindowPos sends WM_SIZE which triggers a WM_PAINT + // before our own rendering pass completes, causing a momentary flicker. + RECT curWndRect = {}; + GetWindowRect(g_hSwitcher, &curWndRect); + bool posChanged = (curWndRect.left != cx || curWndRect.top != cy + || (curWndRect.right - curWndRect.left) != g_winW + || (curWndRect.bottom - curWndRect.top) != g_winH); + if (posChanged) { + SetWindowPos(g_hSwitcher, HWND_TOPMOST, cx, cy, g_winW, g_winH, SWP_NOACTIVATE); + } else { + SetWindowPos(g_hSwitcher, HWND_TOPMOST, cx, cy, g_winW, g_winH, + SWP_NOACTIVATE | SWP_NOSIZE | SWP_NOMOVE | SWP_NOREDRAW); + } + if (wcscmp(g_settings.switcherDisplayBehavior, L"allMonitors") == 0 || g_showAllMonitors) { + EnumDisplayMonitors(NULL, NULL, MirrorEnumProc, 0); + } if (g_hCloseBtnWnd && g_isVisible && !g_isPendingShow) { SetWindowPos(g_hCloseBtnWnd, HWND_TOPMOST, cx, cy, g_winW, g_winH, SWP_NOACTIVATE); } @@ -3703,8 +3911,8 @@ static void EnterAppGroup() { if (!IsWindow(hw)) continue; WindowEntry e = {}; e.hWnd = hw; - InternalGetWindowText(hw, e.title, 256); - if (!e.title[0]) GetWindowTextW(hw, e.title, 256); + GetWindowTextW(hw, e.title, 256); + if (!e.title[0]) InternalGetWindowText(hw, e.title, 256); e.hIcon = LoadWindowIcon(hw); g_windows.push_back(std::move(e)); } @@ -3749,6 +3957,7 @@ static void ToggleAppDrill() { // Linear navigation: Tab, Shift+Tab, Left, Right, Hotkeys, Scroll static void CycleLinear(int delta) { if (g_windows.empty()) return; + g_isPaginatedView = false; int n = (int)g_windows.size(); g_selectedIndex = ((g_selectedIndex + delta) % n + n) % n; @@ -3786,6 +3995,167 @@ static void CycleLinear(int delta) { PaintSwitcher(); } +static void CyclePage(int dir) { + if (g_windows.empty()) return; + int n = (int)g_windows.size(); + + // ── Step 1: Build a stable page map via a dry-run layout from index 0 ──── + // + // We temporarily set g_layoutStartIndex = 0 and g_isPaginatedView = false + // so ComputeLayout places ALL windows with no wrapping suppression. + // We then read the resulting rcCell coordinates to find where rows/columns + // are, and record the window index at which each new "page" starts. + // + // This is the only reliable approach because ComputeLayout's row/column + // break conditions depend on many DPI-scaled constants that would be + // error-prone to replicate here. + + HMONITOR hMon = g_hCurrentMonitor + ? g_hCurrentMonitor + : MonitorFromWindow(g_hSwitcher, MONITOR_DEFAULTTONEAREST); + + // Save current start index — the dry-run will overwrite g_layoutStartIndex. + // Also save g_winW/g_winH: the dry-run ComputeLayout calls will overwrite them + // with intermediate values, which would cause WM_PAINT to draw with wrong dims. + int savedStart = g_layoutStartIndex; + int savedWinW = g_winW; + int savedWinH = g_winH; + + // Dry-run: full layout pass from index 0, with wrapping allowed + g_isDryRunLayout = true; + g_layoutStartIndex = 0; + g_isPaginatedView = false; + ComputeLayout(hMon); // populates rcCell for all windows + + // ── Step 2: Walk rcCell coords to identify page boundaries ─────────────── + // + // In horizontal mode each page is a group of rows (by rcCell.top). + // In vertical mode each page is a group of columns (by rcCell.left). + // + // A page boundary occurs at the first row/column whose windows were + // TRUNCATED by ComputeLayout (rcCell all-zeros), because that is + // exactly where the layout engine ran out of screen space. + // + // pageStarts[p] = the window index (in layout order, which equals the + // absolute window index when g_layoutStartIndex == 0) at which page p + // starts. + + std::vector pageStarts; + pageStarts.push_back(0); + + bool vertical = LayoutIsVertical(); + int prevLine = -1; // previous row-top (horiz) or col-left (vert) + + for (int idx = 0; idx < n; idx++) { + auto& w = g_windows[idx]; // g_layoutStartIndex==0, so idx == window index + + // Truncated window signals the end of what fits on the current page + if (w.rcCell.left == 0 && w.rcCell.right == 0 && + w.rcCell.top == 0 && w.rcCell.bottom == 0) { + // Start a new page here + pageStarts.push_back(idx); + prevLine = -1; // reset for the next page's dry-run (see below) + break; // only one overflow region possible per layout pass + } + + int lineCoord = vertical ? w.rcCell.left : w.rcCell.top; + if (lineCoord != prevLine) { + prevLine = lineCoord; + } + } + + // If more than one page exists, we need to recursively find further page + // boundaries by repeating the dry-run from each new page start. + // We loop until no more pages are detected. + while (true) { + int lastPageStart = pageStarts.back(); + if (lastPageStart >= n) break; + + // Dry-run from the last page start + g_layoutStartIndex = lastPageStart; + g_isPaginatedView = true; // prevent wrapping past end + ComputeLayout(hMon); + + bool foundTruncation = false; + prevLine = -1; + for (int idx = 0; idx < n; idx++) { + int wi = (lastPageStart + idx) % n; + // Stop if we've wrapped past the array in paginated mode + if (idx > 0 && wi < lastPageStart) break; + + auto& w = g_windows[wi]; + if (w.rcCell.left == 0 && w.rcCell.right == 0 && + w.rcCell.top == 0 && w.rcCell.bottom == 0) { + // This window starts the next page + int nextStart = wi; + if (nextStart <= lastPageStart) break; // sanity: no progress + pageStarts.push_back(nextStart); + foundTruncation = true; + break; + } + } + if (!foundTruncation) break; // all remaining windows fit — done + } + + g_isDryRunLayout = false; + + // ── Step 3: Determine which page we're currently on ────────────────────── + int currentPage = 0; + for (int p = (int)pageStarts.size() - 1; p >= 0; p--) { + if (savedStart >= pageStarts[p]) { + currentPage = p; + break; + } + } + + // ── Step 4: Navigate to next or previous page ──────────────────────────── + int numPages = (int)pageStarts.size(); + int targetPage = currentPage; + + if (dir > 0) { + // Scroll forward: go to next page, clamp at last + if (currentPage + 1 < numPages) + targetPage = currentPage + 1; + } else { + // Scroll backward: go to previous page, clamp at first + if (currentPage - 1 >= 0) + targetPage = currentPage - 1; + } + + // ── Boundary guard: if already on the first/last page, restore state and + // exit immediately. Since g_isDryRunLayout prevented any thumbnails from + // being destroyed during the dry runs, no DWM cleanup or reflow is needed. + if (targetPage == currentPage) { + g_layoutStartIndex = savedStart; + g_winW = savedWinW; + g_winH = savedWinH; + g_isPaginatedView = true; + + // Restore the g_windows memory geometry (rcCell, rcThumbActual) to the + // current page. The dry-runs overwrote it with the last page's layout. + // We set g_isDryRunLayout = true so truncateRemaining doesn't touch DWM handles. + g_isDryRunLayout = true; + ComputeLayout(hMon); + g_isDryRunLayout = false; + + return; // nothing changed — no reflow, no repaint, no flicker + } + + // ── Step 5: Apply the new page and do the real reflow ──────────────────── + // Restore g_winW/g_winH so the window doesn't resize to a dry-run value + // before RecomputeAndReposition sets the correct final dimensions. + g_winW = savedWinW; + g_winH = savedWinH; + g_layoutStartIndex = pageStarts[targetPage]; + g_isPaginatedView = true; + RecomputeAndReposition(); // single real reflow — no flicker, no loops + + // ── Step 6: Place selection on the first visible window of the new page ── + g_selectedIndex = g_layoutStartIndex % n; + + PaintSwitcher(); +} + // Directional navigation: Up, Down (EP-style row-based with nearest-column match) // Walks in layout placement order (from g_layoutStartIndex, wrapping) instead of raw list index. static void CycleDirectional(int vertDelta) { @@ -3946,8 +4316,8 @@ static int HitTestThumb(int x, int y) { static void SWS_RegisterHotkeys(); static void UpdateEntryForWindow(WindowEntry& e) { - InternalGetWindowText(e.hWnd, e.title, 256); - if (!e.title[0]) GetWindowTextW(e.hWnd, e.title, 256); + GetWindowTextW(e.hWnd, e.title, 256); + if (!e.title[0]) InternalGetWindowText(e.hWnd, e.title, 256); e.hIcon = LoadWindowIcon(e.hWnd); if (g_settings.showApplications && wcscmp(g_settings.showTitles, L"windowTitle") != 0) { @@ -3973,6 +4343,10 @@ static void UpdateEntryForWindow(WindowEntry& e) { } } +static void SafeCloseWindow(HWND hWnd) { + PostMessage(hWnd, WM_SYSCOMMAND, SC_CLOSE, 0); +} + // Close the window for the entry at idx (graceful WM_CLOSE, same as the close // button), remove it from the list and relayout. Shared by the close button, // middle-click and the Q key. @@ -3983,12 +4357,12 @@ static void CloseSwitcherEntry(int idx) { if (g_settings.showApplications && g_windows[idx].groupWindows.size() > 1) { if (wcscmp(g_settings.groupCloseBehavior, L"closeAll") == 0) { for (HWND hw : g_windows[idx].groupWindows) { - PostMessage(hw, WM_CLOSE, 0, 0); + SafeCloseWindow(hw); } } else { // closeRecent (Default) HWND closedHwnd = g_windows[idx].hWnd; - PostMessage(closedHwnd, WM_CLOSE, 0, 0); + SafeCloseWindow(closedHwnd); auto& group = g_windows[idx].groupWindows; group.erase(std::remove(group.begin(), group.end(), closedHwnd), group.end()); @@ -4004,27 +4378,23 @@ static void CloseSwitcherEntry(int idx) { } } } else { - PostMessage(g_windows[idx].hWnd, WM_CLOSE, 0, 0); + SafeCloseWindow(g_windows[idx].hWnd); } if (eraseEntry) { + for (const auto& kv : g_windows[idx].hThumbs) { + if (kv.second) DwmUnregisterThumbnail(kv.second); + } + g_windows[idx].hThumbs.clear(); + g_windows.erase(g_windows.begin() + idx); } if (g_windows.empty()) { HideSwitcher(); return; } if (g_selectedIndex >= (int)g_windows.size()) g_selectedIndex = (int)g_windows.size() - 1; - UnregisterThumbnails(); - RegisterThumbnailsEarly(); - ComputeLayout(g_hCurrentMonitor); - // Resize and re-center the window to match new layout - MONITORINFO rmi = { sizeof(rmi) }; GetMonitorInfoW(g_hCurrentMonitor, &rmi); - int cx, cy; - GetSwitcherPosition(rmi.rcWork, &cx, &cy); - SetWindowPos(g_hSwitcher, HWND_TOPMOST, cx, cy, g_winW, g_winH, SWP_NOACTIVATE); - if (g_hCloseBtnWnd) { - SetWindowPos(g_hCloseBtnWnd, HWND_TOPMOST, cx, cy, g_winW, g_winH, SWP_NOACTIVATE); - } - RegisterThumbnails(); + + RecomputeAndReposition(); + g_hoverIndex = -1; g_hoverWnd = NULL; g_isCloseHovered = false; @@ -4072,6 +4442,7 @@ static LRESULT CALLBACK SwitcherWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPA int id = (int)wParam; bool isBackward = false; bool isCtrl = false; + bool isAltBacktickTrigger = false; switch (id) { case SWS_HOTKEY_ALTTAB: @@ -4097,14 +4468,20 @@ static LRESULT CALLBACK SwitcherWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPA isCtrl = true; break; case SWS_HOTKEY_ALTBACKTICK: - if (!UseAltBacktickBackward()) return 0; - isBackward = true; + if (wcscmp(g_settings.altBacktickBehavior, L"sameApp") == 0) { + isAltBacktickTrigger = true; + } else if (wcscmp(g_settings.altBacktickBehavior, L"backward") == 0 || UseAltBacktickBackward()) { + isBackward = true; + } else { + return 0; + } break; default: return 0; } if (!g_isVisible && !g_isPendingShow) { + if (isAltBacktickTrigger) g_isAltBacktickSameApp = true; ShowSwitcher(isCtrl); if (isBackward && g_windows.size() > 1) { @@ -4235,7 +4612,8 @@ static LRESULT CALLBACK SwitcherWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPA return 0; } if (wParam == VK_RETURN || wParam == VK_SPACE) { SwitchToSelected(); return 0; } - if (wParam == 'Q') { + bool isCtrlW = (wParam == 'W' && (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0); + if (wParam == 'Q' || wParam == VK_DELETE || isCtrlW) { bool isRepeat = (lParam & 0x40000000) != 0; if (!isRepeat) CloseSwitcherEntry(g_selectedIndex); return 0; @@ -4245,7 +4623,14 @@ static LRESULT CALLBACK SwitcherWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPA // (Removed duplicate combined case for WM_SYSKEYUP and WM_KEYUP) case WM_SWS_SCROLL: if (g_isVisible) { - CycleLinear((int)wParam); + int dir = (int)wParam; + int action = (int)lParam; + if (action == 1) { // selection + CycleLinear(dir); + } else if (action == 2) { // page + g_isPaginatedView = true; + CyclePage(dir); + } } return 0; case WM_SETCURSOR: @@ -4257,7 +4642,7 @@ static LRESULT CALLBACK SwitcherWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPA if (idx < 0) idx = HitTest(x, y); bool closeHovered = false; - if (idx >= 0) { + if (g_settings.showCloseButton && idx >= 0) { auto& e = g_windows[idx]; int titleH = GetHeaderRowHeightPx(); int btnSz = DpiScale(24, g_dpiX); @@ -4443,6 +4828,15 @@ static void LoadSettings() { g_settings.roundBadgeIconBackground = Wh_GetIntSetting(L"Appearance.Corners.roundBadgeIconBackground"); v = Wh_GetStringSetting(L"Accessibility.scrollWheelBehavior"); wcscpy_s(g_settings.scrollWheelBehavior, v ? v : L"never"); Wh_FreeStringSetting(v); + + v = Wh_GetStringSetting(L"Accessibility.scrollWheelAction"); + wcscpy_s(g_settings.scrollWheelAction, v ? v : L"selection"); Wh_FreeStringSetting(v); + + v = Wh_GetStringSetting(L"Accessibility.scrollSecondaryAction"); + wcscpy_s(g_settings.scrollSecondaryAction, v ? v : L"page"); Wh_FreeStringSetting(v); + + v = Wh_GetStringSetting(L"Accessibility.scrollSecondaryModifier"); + wcscpy_s(g_settings.scrollSecondaryModifier, v ? v : L"shift"); Wh_FreeStringSetting(v); v = Wh_GetStringSetting(L"Appearance.Orientation.taskListOrientation"); wcscpy_s(g_settings.taskListOrientation, v ? v : L"horizontal"); Wh_FreeStringSetting(v); v = Wh_GetStringSetting(L"Appearance.Orientation.headerContentOrientation"); @@ -4504,13 +4898,16 @@ static void LoadSettings() { wcscmp(g_settings.backwardShortcut, L"altBacktick") != 0) { wcscpy_s(g_settings.backwardShortcut, L"altShiftTab"); } + + v = Wh_GetStringSetting(L"Accessibility.altBacktickBehavior"); + wcscpy_s(g_settings.altBacktickBehavior, v ? v : L"backward"); Wh_FreeStringSetting(v); v = Wh_GetStringSetting(L"Accessibility.virtualDesktopBehavior"); - wcscpy_s(g_settings.virtualDesktopBehavior, v ? v : L"currentOnly"); + wcscpy_s(g_settings.virtualDesktopBehavior, v ? v : L"allDesktops"); Wh_FreeStringSetting(v); if (wcscmp(g_settings.virtualDesktopBehavior, L"currentOnly") != 0 && wcscmp(g_settings.virtualDesktopBehavior, L"allDesktops") != 0) { - wcscpy_s(g_settings.virtualDesktopBehavior, L"currentOnly"); + wcscpy_s(g_settings.virtualDesktopBehavior, L"allDesktops"); } g_settings.rowHeight = Wh_GetIntSetting(L"Dimensions.rowHeight"); @@ -4520,6 +4917,8 @@ static void LoadSettings() { g_settings.stretchThumbnailsToTaskWidth = Wh_GetIntSetting(L"Dimensions.stretchThumbnailsToTaskWidth"); g_settings.autoFitTasks = Wh_GetIntSetting(L"Dimensions.autoFitTasks"); g_settings.showThumbnails = Wh_GetIntSetting(L"Appearance.Thumbnails.showThumbnails"); + g_settings.showCloseButton = Wh_GetIntSetting(L"Appearance.Thumbnails.showCloseButton"); + g_settings.showOverflowIndicator = Wh_GetIntSetting(L"Appearance.Thumbnails.showOverflowIndicator"); g_settings.showHoverBorder = Wh_GetIntSetting(L"Appearance.Thumbnails.showHoverBorder"); g_settings.showThumbnailShadow = Wh_GetIntSetting(L"Appearance.Thumbnails.showThumbnailShadow"); g_settings.showTitle = Wh_GetIntSetting(L"Appearance.HeaderContent.showTitle"); @@ -4539,6 +4938,7 @@ static void LoadSettings() { g_settings.showApplications = Wh_GetIntSetting(L"Grouping.showApplications"); g_settings.restoreAllWindows = Wh_GetIntSetting(L"Grouping.restoreAllWindows"); g_settings.hideMinimizedWindows = Wh_GetIntSetting(L"Accessibility.hideMinimizedWindows"); + g_settings.sortMinimizedWindowsToEnd = Wh_GetIntSetting(L"Accessibility.sortMinimizedWindowsToEnd"); v = Wh_GetStringSetting(L"Grouping.showTitles"); wcscpy_s(g_settings.showTitles, v ? v : L"windowTitle"); Wh_FreeStringSetting(v); if (wcscmp(g_settings.showTitles, L"windowTitle") != 0 && @@ -4808,7 +5208,7 @@ static DWORD WINAPI SwitcherThread(LPVOID lpParam) { DWORD dwStyle = WS_POPUP | WS_THICKFRAME | WS_CLIPCHILDREN | WS_CLIPSIBLINGS; DWORD exStyle = WS_EX_TOOLWINDOW | WS_EX_TOPMOST | WS_EX_LAYERED; g_hSwitcher = CreateWindowExW(exStyle, SWS_CLASSNAME, L"", - dwStyle, 0, 0, 0, 0, NULL, NULL, GetModuleHandleW(NULL), NULL); + dwStyle, -9999, -9999, 1, 1, NULL, NULL, GetModuleHandleW(NULL), NULL); if (!g_hSwitcher) { Wh_Log(L"Failed to create switcher window"); return 1; } g_hCloseBtnWnd = CreateWindowExW( @@ -4861,7 +5261,7 @@ static DWORD WINAPI SwitcherThread(LPVOID lpParam) { // Tool Mod callbacks -bool WhTool_ModInit() { +BOOL WhTool_ModInit() { Wh_Log(L"Simple Window Switcher: WhTool_ModInit"); g_hSwitcherThread = CreateThread(NULL, 0, SwitcherThread, NULL, 0, &g_dwSwitcherThreadId); return g_hSwitcherThread != NULL;