From 9284648da3157ed2062868f950f97a7a24cbfce9 Mon Sep 17 00:00:00 2001 From: xuziyang <767637918@qq.com> Date: Tue, 18 Aug 2026 14:28:42 +0800 Subject: [PATCH 1/6] feat(cycle): add Toggle Window mode for last-active window Keep Hide as Z-order first. Toggle Window remembers the last toggled HWND and only show/hides that window so multi-window apps stick to one. Prefer the remembered handle so minimize handing focus to a sibling does not retarget. Debounce StartIfNotRunning for 10s while the first instance has no window yet, and treat a failed SetForegroundWindow as an unsuccessful switch. Register Switcher as a singleton so toggle memory, start debounce, and next-window cycle state persist. --- .agents/architecture.md | 2 +- .../ApplicationConfigurationDocumentTests.cs | 1 + .../ToggleWindowTargetResolverTests.cs | 148 ++++++++++++++++ AppSwitcher/Configuration/Configuration.cs | 3 +- AppSwitcher/Input/Hook.cs | 25 +++ AppSwitcher/ServicesConfiguration.cs | 2 +- AppSwitcher/Switcher.cs | 159 +++++++++++++++--- AppSwitcher/ToggleWindowTargetResolver.cs | 70 ++++++++ .../UI/Controls/CycleModeSelector.xaml | 8 +- .../UI/Controls/CycleModeSelector.xaml.cs | 1 + .../Converters/CycleModeToLabelConverter.cs | 1 + README.md | 3 +- .../docs/configuration/cycle-modes.mdx | 28 ++- 13 files changed, 423 insertions(+), 28 deletions(-) create mode 100644 AppSwitcher.Tests/ToggleWindowTargetResolverTests.cs create mode 100644 AppSwitcher/ToggleWindowTargetResolver.cs diff --git a/.agents/architecture.md b/.agents/architecture.md index 6ca73d2..fabdf52 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -35,7 +35,7 @@ Use Microsoft.Extensions.DependencyInjection: ```csharp services.AddSingleton(); -services.AddTransient(); // Switcher is transient, NOT singleton +services.AddSingleton(); // session state: next-window cycle, toggle memory, start debounce services.AddSingleton(); ``` diff --git a/AppSwitcher.Tests/Configuration/Storage/ApplicationConfigurationDocumentTests.cs b/AppSwitcher.Tests/Configuration/Storage/ApplicationConfigurationDocumentTests.cs index 36122a6..6ffe4d5 100644 --- a/AppSwitcher.Tests/Configuration/Storage/ApplicationConfigurationDocumentTests.cs +++ b/AppSwitcher.Tests/Configuration/Storage/ApplicationConfigurationDocumentTests.cs @@ -95,6 +95,7 @@ public void FromApplicationConfiguration_MapsPackagedFields() [InlineData(CycleMode.NextApp)] [InlineData(CycleMode.Hide)] [InlineData(CycleMode.NextWindow)] + [InlineData(CycleMode.ToggleWindow)] public void RoundTrip_PreservesAllData_Win32(CycleMode cycleMode) { var original = new ApplicationConfiguration(Key.C, @"C:\apps\tool.exe", cycleMode, false); diff --git a/AppSwitcher.Tests/ToggleWindowTargetResolverTests.cs b/AppSwitcher.Tests/ToggleWindowTargetResolverTests.cs new file mode 100644 index 0000000..9af860d --- /dev/null +++ b/AppSwitcher.Tests/ToggleWindowTargetResolverTests.cs @@ -0,0 +1,148 @@ +using AppSwitcher; +using AwesomeAssertions; +using Windows.Win32.Foundation; +using Xunit; + +namespace AppSwitcher.Tests; + +public class ToggleWindowTargetResolverTests +{ + private static HWND H(int value) => new((nint)value); + + [Fact] + public void Resolve_ReturnsNull_WhenNoMatchingWindows() + { + var result = ToggleWindowTargetResolver.Resolve([], H(1), H(2)); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_ActivatesRemembered_WhenSiblingIsForeground() + { + var remembered = H(10); + var sibling = H(20); + + var result = ToggleWindowTargetResolver.Resolve( + [remembered, sibling], + currentHandle: sibling, + rememberedHandle: remembered); + + result.Should().Be(new ToggleWindowTargetResolver.Result(remembered, ToggleWindowAction.Activate)); + } + + [Fact] + public void Resolve_HidesRemembered_WhenForegroundIsRemembered() + { + var remembered = H(10); + var sibling = H(20); + + var result = ToggleWindowTargetResolver.Resolve( + [remembered, sibling], + currentHandle: remembered, + rememberedHandle: remembered); + + result.Should().Be(new ToggleWindowTargetResolver.Result(remembered, ToggleWindowAction.Hide)); + } + + [Fact] + public void Resolve_AfterHide_RestoresRemembered_WhenSiblingTookFocus() + { + var remembered = H(10); + var sibling = H(20); + HWND[] matching = [remembered, sibling]; + + var hide = ToggleWindowTargetResolver.Resolve(matching, currentHandle: remembered, rememberedHandle: remembered); + + hide.Should().Be(new ToggleWindowTargetResolver.Result(remembered, ToggleWindowAction.Hide)); + + var restore = ToggleWindowTargetResolver.Resolve(matching, currentHandle: sibling, rememberedHandle: remembered); + + restore.Should().Be(new ToggleWindowTargetResolver.Result(remembered, ToggleWindowAction.Activate)); + } + + [Fact] + public void Resolve_ActivatesRemembered_WhenForegroundIsOtherApp() + { + var w1 = H(10); + var w2 = H(20); + var other = H(99); + + var result = ToggleWindowTargetResolver.Resolve([w1, w2], currentHandle: other, rememberedHandle: w2); + + result.Should().Be(new ToggleWindowTargetResolver.Result(w2, ToggleWindowAction.Activate)); + } + + [Fact] + public void Resolve_ActivatesFirst_WhenNoRememberedAndForegroundOtherApp() + { + var w1 = H(10); + var w2 = H(20); + var other = H(99); + + var result = ToggleWindowTargetResolver.Resolve([w1, w2], currentHandle: other, rememberedHandle: null); + + result.Should().Be(new ToggleWindowTargetResolver.Result(w1, ToggleWindowAction.Activate)); + } + + [Fact] + public void Resolve_ActivatesFirst_WhenRememberedMissingFromMatching() + { + var w1 = H(10); + var w2 = H(20); + var closed = H(30); + var other = H(99); + + var result = ToggleWindowTargetResolver.Resolve([w1, w2], currentHandle: other, rememberedHandle: closed); + + result.Should().Be(new ToggleWindowTargetResolver.Result(w1, ToggleWindowAction.Activate)); + } + + [Fact] + public void Resolve_UsesCurrent_WhenRememberedMissingAndForegroundMatches() + { + var w1 = H(10); + var w2 = H(20); + var closed = H(30); + + var result = ToggleWindowTargetResolver.Resolve([w1, w2], currentHandle: w2, rememberedHandle: closed); + + result.Should().Be(new ToggleWindowTargetResolver.Result(w2, ToggleWindowAction.Hide)); + } + + [Fact] + public void Resolve_UsesRemembered_WhenCurrentHandleUnknown() + { + var w1 = H(10); + var w2 = H(20); + + var result = ToggleWindowTargetResolver.Resolve([w1, w2], currentHandle: null, rememberedHandle: w2); + + result.Should().Be(new ToggleWindowTargetResolver.Result(w2, ToggleWindowAction.Activate)); + } + + [Fact] + public void Resolve_HidesFirst_WhenForegroundIsFirstMatching() + { + var w1 = H(10); + var w2 = H(20); + + var result = ToggleWindowTargetResolver.Resolve([w1, w2], currentHandle: w1, rememberedHandle: null); + + result.Should().Be(new ToggleWindowTargetResolver.Result(w1, ToggleWindowAction.Hide)); + } + + [Fact] + public void Resolve_Activates_WhenForegroundMatchingWindowIsMinimized() + { + var weixin = H(0x1073a); + + var result = ToggleWindowTargetResolver.Resolve( + [weixin], + currentHandle: weixin, + rememberedHandle: weixin, + currentIsMinimized: true); + + result.Should().Be(new ToggleWindowTargetResolver.Result(weixin, ToggleWindowAction.Activate)); + } +} diff --git a/AppSwitcher/Configuration/Configuration.cs b/AppSwitcher/Configuration/Configuration.cs index 4ad377d..577be8b 100644 --- a/AppSwitcher/Configuration/Configuration.cs +++ b/AppSwitcher/Configuration/Configuration.cs @@ -8,7 +8,8 @@ public enum CycleMode { NextApp = 0, Hide, - NextWindow + NextWindow, + ToggleWindow } internal record Configuration( diff --git a/AppSwitcher/Input/Hook.cs b/AppSwitcher/Input/Hook.cs index 1d71626..9e3a4fd 100644 --- a/AppSwitcher/Input/Hook.cs +++ b/AppSwitcher/Input/Hook.cs @@ -238,6 +238,7 @@ private void HandleLetterPressed(KeyboardHookEventArgs e, Key letter) var letterDownTick = Stopwatch.GetTimestamp(); var currentWindow = windowEnumerator.GetCurrentWindow(); var result = switcher.Execute(matchingApps); + LogSwitchOutcome(letter, result); var isAppStealingKeyEvents = result != null && _processesStealingKeyEvents.Contains(result.ProcessName); if (_config.StatsEnabled && result is { WasStarted: false }) @@ -297,6 +298,25 @@ private void HandleLetterPressed(KeyboardHookEventArgs e, Key letter) } } + private void LogSwitchOutcome(Key letter, AppSwitchResult? result) + { + if (result is { WasStarted: true }) + { + logger.LogInformation("{Modifier} + {Letter}: starting {ProcessName} (no running window found)", + _config!.Modifier, letter, result.ProcessName); + } + else if (result is not null) + { + logger.LogInformation("{Modifier} + {Letter}: switching to {ProcessName} window {Handle}", + _config!.Modifier, letter, result.ProcessName, result.Handle); + } + else + { + // The specific reason (hide, in-app cycle, etc.) is logged by Switcher at Info level. + logger.LogDebug("{Modifier} + {Letter}: no foreground change", _config!.Modifier, letter); + } + } + private void MonitorPotentialElevation(AppSwitchResult result) { _ = Task.Run(async () => @@ -340,6 +360,11 @@ private void HandleDigitPressed(KeyboardHookEventArgs e, Key digit) } logger.LogDebug("{Modifier} + {Digit} detected, switched to window #{Number}", _config.Modifier, digit, index + 1); + if (window is not null) + { + logger.LogInformation("{Modifier} + {Digit}: switching to window #{Number} of {ProcessName}", + _config.Modifier, digit, index + 1, window.ProcessName); + } RefreshOrHideOverlay(); } } diff --git a/AppSwitcher/ServicesConfiguration.cs b/AppSwitcher/ServicesConfiguration.cs index f74b581..2a8f018 100644 --- a/AppSwitcher/ServicesConfiguration.cs +++ b/AppSwitcher/ServicesConfiguration.cs @@ -55,7 +55,7 @@ internal static class ServicesConfiguration services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddTransient(); + services.AddSingleton(); services.AddTransient(); services.AddSingleton(); services.AddTransient(); diff --git a/AppSwitcher/Switcher.cs b/AppSwitcher/Switcher.cs index 1e4aac6..23f7b22 100644 --- a/AppSwitcher/Switcher.cs +++ b/AppSwitcher/Switcher.cs @@ -31,7 +31,13 @@ public static AppSwitchResult FromStarted(string processPath) => internal class Switcher(ILogger logger, IWindowEnumerator windowEnumerator, ConfigurationManager configurationManager) { + // Avoid re-launching slow-starting apps when the hotkey is pressed repeatedly + // while the first instance is still creating its window. + private const int StartDebounceMs = 10_000; + private readonly List _nextWindows = []; + private readonly Dictionary _lastToggleWindows = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _recentStarts = new(StringComparer.OrdinalIgnoreCase); public AppSwitchResult? Execute(IReadOnlyList appGroup) { @@ -74,8 +80,11 @@ public bool SwitchToWindowByIndex( var appName = focusedAppWindow.GetProductName() ?? Path.GetFileNameWithoutExtension(focusedAppWindow.ProcessImagePath); logger.LogDebug("Switching to window #{Number} of {AppName}", index + 1, appName); - ActivateWindow(focusedAppWindows[index]); - focusedWindow = focusedAppWindows[index]; + if (ActivateWindow(focusedAppWindows[index])) + { + focusedWindow = focusedAppWindows[index]; + } + return true; } @@ -90,11 +99,18 @@ public bool SwitchToWindowByIndex( var matchingWindows = topLevelWindows.Where(w => w.ProcessImagePath.EndsWith(appConfig.ProcessName, StringComparison.CurrentCultureIgnoreCase)) .ToList(); - var window = matchingWindows.FirstOrDefault(); - if (window is null) + if (matchingWindows.Count == 0) { if (appConfig.StartIfNotRunning) { + if (WasStartedRecently(appConfig)) + { + logger.LogInformation( + "{ProcessName} was started less than {DebounceSeconds}s ago and has no window yet - ignoring repeated start request", + appConfig.ProcessName, StartDebounceMs / 1000); + return null; + } + if (appConfig.Type == ApplicationType.Packaged) { Process.Start("explorer.exe", $"shell:AppsFolder\\{appConfig.Aumid}"); @@ -107,6 +123,7 @@ public bool SwitchToWindowByIndex( logger.LogInformation("Starting {ProcessName}", appConfig.ProcessName); } + _recentStarts[appConfig.ProcessPath] = Stopwatch.GetTimestamp(); return AppSwitchResult.FromStarted(appConfig.ProcessPath); } logger.LogWarning("{ProcessName} process not found", appConfig.ProcessName); @@ -114,29 +131,89 @@ public bool SwitchToWindowByIndex( } var currentWindow = windowEnumerator.GetCurrentWindow(); + var firstWindow = matchingWindows[0]; - if (currentWindow?.ProcessId != window.ProcessId || appConfig.CycleMode == CycleMode.NextApp) + // ToggleWindow has its own remembered-target logic; other modes share the off-app gate. + if (appConfig.CycleMode == CycleMode.ToggleWindow) { - _nextWindows.Clear(); - logger.LogDebug("Switching to {ProcessName}", appConfig.ProcessName); - ActivateWindow(window); - return AppSwitchResult.FromApplicationWindow(window); + return ExecuteToggleWindow(appConfig, matchingWindows, currentWindow); + } + + var isOnThisApp = currentWindow is not null && + matchingWindows.Exists(w => w.ProcessId == currentWindow.ProcessId); + + if (!isOnThisApp || appConfig.CycleMode == CycleMode.NextApp) + { + return ActivateFirst(appConfig, firstWindow); } if (appConfig.CycleMode == CycleMode.Hide) { - logger.LogDebug("Hiding {ProcessName}", appConfig.ProcessName); - HideWindow(window.Handle); + logger.LogInformation("Hiding {ProcessName} window {Handle} (state {State})", + appConfig.ProcessName, firstWindow.Handle, firstWindow.State); + HideWindow(firstWindow.Handle); + return null; } - else if (appConfig.CycleMode == CycleMode.NextWindow) + + if (appConfig.CycleMode == CycleMode.NextWindow) { - logger.LogDebug("Switching to next window of {ProcessName}", appConfig.ProcessName); - var nextWindow = GetNextWindow(matchingWindows, currentWindow); - logger.LogDebug("Selected next window: {Handle}", nextWindow.Handle); + var nextWindow = GetNextWindow(matchingWindows, currentWindow!); + logger.LogInformation("Switching to next window of {ProcessName}: {Handle} (state {State})", + appConfig.ProcessName, nextWindow.Handle, nextWindow.State); ActivateWindow(nextWindow); + return null; } - return null; + return ActivateFirst(appConfig, firstWindow); + } + + private AppSwitchResult? ActivateFirst(ApplicationConfiguration appConfig, ApplicationWindow window) + { + _nextWindows.Clear(); + logger.LogDebug("Switching to {ProcessName}", appConfig.ProcessName); + return TryActivate(window); + } + + private AppSwitchResult? ExecuteToggleWindow( + ApplicationConfiguration appConfig, + List matchingWindows, + ApplicationWindow? currentWindow) + { + HWND? remembered = _lastToggleWindows.TryGetValue(appConfig.ProcessName, out var handle) + ? handle + : null; + + var matchingHandles = matchingWindows.ConvertAll(static w => w.Handle); + var resolution = ToggleWindowTargetResolver.Resolve( + matchingHandles, + currentWindow?.Handle, + remembered, + currentIsMinimized: IsMinimized(currentWindow)); + + if (resolution is null) + { + return null; + } + + var target = matchingWindows.Find(w => w.Handle == resolution.Value.Handle); + if (target is null) + { + return null; + } + + _lastToggleWindows[appConfig.ProcessName] = target.Handle; + + if (resolution.Value.Action == ToggleWindowAction.Hide) + { + logger.LogInformation("ToggleWindow: hiding {ProcessName} window {Handle} (state {State}, foreground is {Foreground})", + appConfig.ProcessName, target.Handle, target.State, DescribeWindow(currentWindow)); + HideWindow(target.Handle); + return null; + } + + logger.LogInformation("ToggleWindow: activating {ProcessName} window {Handle} (state {State}, foreground is {Foreground})", + appConfig.ProcessName, target.Handle, target.State, DescribeWindow(currentWindow)); + return TryActivate(target); } private ApplicationWindow GetNextWindow(List matchingWindows, ApplicationWindow window) @@ -161,7 +238,10 @@ private ApplicationWindow GetNextWindow(List matchingWindows, return matchingWindows.First(w => !_nextWindows.Contains(w.Handle)); } - internal void ActivateWindow(ApplicationWindow window, bool pulseBorder = true) + private AppSwitchResult? TryActivate(ApplicationWindow window) => + ActivateWindow(window) ? AppSwitchResult.FromApplicationWindow(window) : null; + + internal bool ActivateWindow(ApplicationWindow window, bool pulseBorder = true) { var hwnd = window.Handle; if (PInvoke.IsIconic(hwnd)) @@ -185,7 +265,7 @@ internal void ActivateWindow(ApplicationWindow window, bool pulseBorder = true) INPUT[] inputs = [input]; PInvoke.SendInput(inputs, Marshal.SizeOf(typeof(INPUT))); - PInvoke.SetForegroundWindow(hwnd); + var setForegroundSucceeded = (bool)PInvoke.SetForegroundWindow(hwnd); var fgHandle = PInvoke.GetForegroundWindow(); @@ -206,13 +286,50 @@ internal void ActivateWindow(ApplicationWindow window, bool pulseBorder = true) } } - if (pulseBorder && configurationManager.GetConfiguration()?.PulseBorderEnabled == true) + var activated = PInvoke.GetForegroundWindow() == hwnd; + if (!activated) + { + logger.LogWarning( + "Failed to bring {ProcessName} window {Handle} to the foreground (SetForegroundWindow returned {SetForegroundResult}); {ForegroundInfo}", + window.ProcessName, hwnd, setForegroundSucceeded, DescribeForegroundWindow()); + } + + if (activated && pulseBorder && configurationManager.GetConfiguration()?.PulseBorderEnabled == true) { // it needs to go async because we're calling it from a keyboard hook which has narrow time limits _ = PulseBorder(hwnd, Color.Gold, 100); } + + return activated; + } + + private string DescribeForegroundWindow() + { + try + { + return DescribeWindow(windowEnumerator.GetCurrentWindow()); + } + catch (Exception ex) + { + return $"unidentifiable: {ex.Message}"; + } } + private static string DescribeWindow(ApplicationWindow? window) => + window is null + ? "unknown" + : $"{window.ProcessName} window {window.Handle} (\"{window.Title}\", state {window.State}, " + + $"position ({window.Position.X},{window.Position.Y}), size {window.Size.Width}x{window.Size.Height})"; + + private static bool IsMinimized(ApplicationWindow? window) => + window is + { + State: SHOW_WINDOW_CMD.SW_SHOWMINIMIZED + or SHOW_WINDOW_CMD.SW_MINIMIZE + or SHOW_WINDOW_CMD.SW_SHOWMINNOACTIVE + or SHOW_WINDOW_CMD.SW_FORCEMINIMIZE + }; + private async Task PulseBorder(IntPtr hwnd, Color highlightColor, int durationMs = 400) { var color = highlightColor.R | (highlightColor.G << 8) | (highlightColor.B << 16); @@ -247,6 +364,10 @@ internal void HideWindow(HWND hwnd) PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_MINIMIZE); } + private bool WasStartedRecently(ApplicationConfiguration appConfig) => + _recentStarts.TryGetValue(appConfig.ProcessPath, out var startedAtTick) && + Stopwatch.GetElapsedTime(startedAtTick).TotalMilliseconds < StartDebounceMs; + private static unsafe uint GetWindowThreadProcessId(HWND hwnd) { return PInvoke.GetWindowThreadProcessId(hwnd, null); diff --git a/AppSwitcher/ToggleWindowTargetResolver.cs b/AppSwitcher/ToggleWindowTargetResolver.cs new file mode 100644 index 0000000..bd6f1b7 --- /dev/null +++ b/AppSwitcher/ToggleWindowTargetResolver.cs @@ -0,0 +1,70 @@ +using Windows.Win32.Foundation; + +namespace AppSwitcher; + +internal enum ToggleWindowAction +{ + Activate, + Hide +} + +/// +/// Pure resolution for : +/// prefer a remembered handle, else the focused matching window, else the first match. +/// +internal static class ToggleWindowTargetResolver +{ + public readonly record struct Result(HWND Handle, ToggleWindowAction Action); + + /// Matching windows in enumeration order (first = preferred fallback). + /// Foreground window handle, or null if unknown. + /// Last toggled handle for this app, or null. + /// + /// True when is a minimized window. + /// Minimized windows can remain the foreground HWND; they should be restored, not hidden again. + /// + /// Null when is empty. + public static Result? Resolve( + IReadOnlyList matchingHandles, + HWND? currentHandle, + HWND? rememberedHandle, + bool currentIsMinimized = false) + { + if (matchingHandles.Count == 0) + { + return null; + } + + // Remembered stays sticky so minimize handing focus to a sibling does not retarget. + var target = Contains(matchingHandles, rememberedHandle) + ? rememberedHandle!.Value + : Contains(matchingHandles, currentHandle) + ? currentHandle!.Value + : matchingHandles[0]; + + var isForegroundTarget = currentHandle is { } current && current == target; + var action = isForegroundTarget && !currentIsMinimized + ? ToggleWindowAction.Hide + : ToggleWindowAction.Activate; + + return new Result(target, action); + } + + private static bool Contains(IReadOnlyList handles, HWND? handle) + { + if (handle is not { } h) + { + return false; + } + + for (var i = 0; i < handles.Count; i++) + { + if (handles[i] == h) + { + return true; + } + } + + return false; + } +} diff --git a/AppSwitcher/UI/Controls/CycleModeSelector.xaml b/AppSwitcher/UI/Controls/CycleModeSelector.xaml index 95e4538..333e109 100644 --- a/AppSwitcher/UI/Controls/CycleModeSelector.xaml +++ b/AppSwitcher/UI/Controls/CycleModeSelector.xaml @@ -27,12 +27,18 @@ Click="CycleModeButton_Click" Tag="Hide" Style="{StaticResource CycleModeButtonStyle}" - ToolTipService.ToolTip="Subsequent hotkey will toggle visibility of the application"/> + ToolTipService.ToolTip="Subsequent hotkey will toggle visibility of the application (Z-order first window)"/> + diff --git a/AppSwitcher/UI/Controls/CycleModeSelector.xaml.cs b/AppSwitcher/UI/Controls/CycleModeSelector.xaml.cs index 2feeb98..c5738ff 100644 --- a/AppSwitcher/UI/Controls/CycleModeSelector.xaml.cs +++ b/AppSwitcher/UI/Controls/CycleModeSelector.xaml.cs @@ -49,6 +49,7 @@ private void UpdateButtonStates() NextAppButton.Appearance = GetButtonAppearance(NextAppButton.Tag?.ToString()); HideButton.Appearance = GetButtonAppearance(HideButton.Tag?.ToString()); NextWindowButton.Appearance = GetButtonAppearance(NextWindowButton.Tag?.ToString()); + ToggleWindowButton.Appearance = GetButtonAppearance(ToggleWindowButton.Tag?.ToString()); return; ControlAppearance GetButtonAppearance(string? tag) => diff --git a/AppSwitcher/UI/Converters/CycleModeToLabelConverter.cs b/AppSwitcher/UI/Converters/CycleModeToLabelConverter.cs index c7a4cac..afc3bed 100644 --- a/AppSwitcher/UI/Converters/CycleModeToLabelConverter.cs +++ b/AppSwitcher/UI/Converters/CycleModeToLabelConverter.cs @@ -15,6 +15,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture CycleMode.NextApp => "Next App", CycleMode.Hide => "Hide", CycleMode.NextWindow => "Next Window", + CycleMode.ToggleWindow => "Toggle Window", _ => value.ToString() ?? string.Empty } : string.Empty; diff --git a/README.md b/README.md index 1190633..3c41abf 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,11 @@ You stop thinking about where a window is and just jump there. ## ✨ Key features -- [**Three Intelligent Cycle Modes:**](https://app-switcher.com/docs/configuration/cycle-modes/) +- [**Four Intelligent Cycle Modes:**](https://app-switcher.com/docs/configuration/cycle-modes/) * `NextApp`: Cycle between different apps assigned to the same key. * `NextWindow`: Cycle through all open windows of a *single* app (great for multi-instance browsers). * `Hide`: Minimize the app if you press the hotkey while it's already focused (the ultimate "toggle"). + * `ToggleWindow`: Always show/hide the last active window of the app (sticks to one window). - **Start if not running:** optional per-app setting to start the app when no matching process is running. - **Packaged app support:** works with modern Windows packaged apps (for example Windows Terminal) in addition to classic desktop apps. - [**Peek mode:**](https://app-switcher.com/docs/configuration/peek-mode/) hold the hotkey to peek at the target app and release to return to the original app. diff --git a/docs/src/content/docs/configuration/cycle-modes.mdx b/docs/src/content/docs/configuration/cycle-modes.mdx index 358a8da..fe551eb 100644 --- a/docs/src/content/docs/configuration/cycle-modes.mdx +++ b/docs/src/content/docs/configuration/cycle-modes.mdx @@ -1,6 +1,6 @@ --- title: Cycle Modes -description: Understand the three hotkey cycle modes and when to use each one. +description: Understand the four hotkey cycle modes and when to use each one. --- import { Aside } from '@astrojs/starlight/components'; @@ -18,7 +18,7 @@ Pressing the hotkey cycles through all open windows of the target application, o ### Direct Selection (Numeric Keys) You don’t have to cycle one by one. AppSwitcher assigns a **stable number (1–0)** to every open window of the target application. -* **How it works:** While holding your modifier key, simply tap a number to "teleport" directly to that specific window. For example, if `Modifier + E` is your shortcut for Vivaldi, pressing `Modifier + E` then `2` takes you straight to your second window. +* **How it works:** While holding your modifier key, simply tap a number to "teleport" directly to that specific window. For example, if `modifier + E` is your shortcut for Vivaldi, pressing `modifier + E` then `2` takes you straight to your second window. * **No Overlay Required:** This feature is always active. While the **Show Overlay** setting (see screenshot) helps visualize which number belongs to which window, the shortcuts work even if the overlay is turned off. * **Stable Indexing:** These numbers are tied to the internal window handle. They won't shift positions while you are working; they only change if a window is closed and a new one is opened. @@ -42,7 +42,27 @@ each one in sequence. Useful for toggling between two related apps (e.g. a brows **Best for:** Apps you want to quickly show and dismiss (e.g. terminal, music player). -Pressing the hotkey brings the app into focus. Pressing it again minimises the focused window. -Subsequent presses toggle visibility. +Pressing the hotkey brings the app into focus (its frontmost window). Pressing it again minimises that frontmost window. +Subsequent presses toggle visibility of the frontmost window in Z-order. **Example:** `Apps+T` opens your terminal. Pressing `Apps+T` again minimises it out of the way. + + + +## Toggle Window + +**Best for:** Multi-window apps where you always want the *same* window (the last one you used). + +Pressing the hotkey shows or hides the last active window of that app. It does **not** jump to +other open windows of the same process. + +* First press focuses a window and remembers it. +* Next press minimises that same window. +* Press again to restore it — even if other windows of the app are open, or if minimizing handed focus to a sibling. +* The remembered window stays sticky until it is closed; focusing another window of the same app does not retarget. + +**Example:** You have two Terminal tabs/windows. `Apps+T` keeps toggling the terminal you were +last working in, without cycling to the other one. From 398440a90ab5556ddd315fcfb42111b417eb64cf Mon Sep 17 00:00:00 2001 From: xuziyang <767637918@qq.com> Date: Wed, 19 Aug 2026 14:23:28 +0800 Subject: [PATCH 2/6] fix(cycle): retarget Toggle Window when a sibling is focused Prefer the foreground matching window so focusing another window of the same app changes the toggle target. Still restore a minimized remembered window when hide hands focus to a sibling. --- .../ToggleWindowTargetResolverTests.cs | 10 +++-- AppSwitcher/Switcher.cs | 6 ++- AppSwitcher/ToggleWindowTargetResolver.cs | 37 +++++++++++++++---- .../UI/Controls/CycleModeSelector.xaml | 2 +- README.md | 2 +- .../docs/configuration/cycle-modes.mdx | 12 +++--- 6 files changed, 49 insertions(+), 20 deletions(-) diff --git a/AppSwitcher.Tests/ToggleWindowTargetResolverTests.cs b/AppSwitcher.Tests/ToggleWindowTargetResolverTests.cs index 9af860d..5b8d90d 100644 --- a/AppSwitcher.Tests/ToggleWindowTargetResolverTests.cs +++ b/AppSwitcher.Tests/ToggleWindowTargetResolverTests.cs @@ -18,7 +18,7 @@ public void Resolve_ReturnsNull_WhenNoMatchingWindows() } [Fact] - public void Resolve_ActivatesRemembered_WhenSiblingIsForeground() + public void Resolve_HidesSibling_WhenSiblingIsForeground() { var remembered = H(10); var sibling = H(20); @@ -28,7 +28,7 @@ public void Resolve_ActivatesRemembered_WhenSiblingIsForeground() currentHandle: sibling, rememberedHandle: remembered); - result.Should().Be(new ToggleWindowTargetResolver.Result(remembered, ToggleWindowAction.Activate)); + result.Should().Be(new ToggleWindowTargetResolver.Result(sibling, ToggleWindowAction.Hide)); } [Fact] @@ -56,7 +56,11 @@ public void Resolve_AfterHide_RestoresRemembered_WhenSiblingTookFocus() hide.Should().Be(new ToggleWindowTargetResolver.Result(remembered, ToggleWindowAction.Hide)); - var restore = ToggleWindowTargetResolver.Resolve(matching, currentHandle: sibling, rememberedHandle: remembered); + var restore = ToggleWindowTargetResolver.Resolve( + matching, + currentHandle: sibling, + rememberedHandle: remembered, + rememberedIsMinimized: true); restore.Should().Be(new ToggleWindowTargetResolver.Result(remembered, ToggleWindowAction.Activate)); } diff --git a/AppSwitcher/Switcher.cs b/AppSwitcher/Switcher.cs index 23f7b22..1c8a111 100644 --- a/AppSwitcher/Switcher.cs +++ b/AppSwitcher/Switcher.cs @@ -182,13 +182,17 @@ public bool SwitchToWindowByIndex( HWND? remembered = _lastToggleWindows.TryGetValue(appConfig.ProcessName, out var handle) ? handle : null; + var rememberedWindow = remembered is { } rememberedHandle + ? matchingWindows.Find(w => w.Handle == rememberedHandle) + : null; var matchingHandles = matchingWindows.ConvertAll(static w => w.Handle); var resolution = ToggleWindowTargetResolver.Resolve( matchingHandles, currentWindow?.Handle, remembered, - currentIsMinimized: IsMinimized(currentWindow)); + currentIsMinimized: IsMinimized(currentWindow), + rememberedIsMinimized: IsMinimized(rememberedWindow)); if (resolution is null) { diff --git a/AppSwitcher/ToggleWindowTargetResolver.cs b/AppSwitcher/ToggleWindowTargetResolver.cs index bd6f1b7..98bdcd5 100644 --- a/AppSwitcher/ToggleWindowTargetResolver.cs +++ b/AppSwitcher/ToggleWindowTargetResolver.cs @@ -10,7 +10,7 @@ internal enum ToggleWindowAction /// /// Pure resolution for : -/// prefer a remembered handle, else the focused matching window, else the first match. +/// prefer the focused matching window, else a remembered handle, else the first match. /// internal static class ToggleWindowTargetResolver { @@ -23,24 +23,45 @@ internal static class ToggleWindowTargetResolver /// True when is a minimized window. /// Minimized windows can remain the foreground HWND; they should be restored, not hidden again. /// + /// + /// True when is a minimized window. + /// After a hide, Windows often focuses a sibling; restore the remembered window instead of retargeting. + /// /// Null when is empty. public static Result? Resolve( IReadOnlyList matchingHandles, HWND? currentHandle, HWND? rememberedHandle, - bool currentIsMinimized = false) + bool currentIsMinimized = false, + bool rememberedIsMinimized = false) { if (matchingHandles.Count == 0) { return null; } - // Remembered stays sticky so minimize handing focus to a sibling does not retarget. - var target = Contains(matchingHandles, rememberedHandle) - ? rememberedHandle!.Value - : Contains(matchingHandles, currentHandle) - ? currentHandle!.Value - : matchingHandles[0]; + var currentIsMatch = Contains(matchingHandles, currentHandle); + var rememberedIsMatch = Contains(matchingHandles, rememberedHandle); + + // After we hide the remembered window, Windows often focuses a sibling. + // Restore that minimized window instead of adopting the sibling as the new target. + HWND target; + if (rememberedIsMatch && rememberedIsMinimized && currentIsMatch && currentHandle != rememberedHandle) + { + target = rememberedHandle!.Value; + } + else if (currentIsMatch) + { + target = currentHandle!.Value; + } + else if (rememberedIsMatch) + { + target = rememberedHandle!.Value; + } + else + { + target = matchingHandles[0]; + } var isForegroundTarget = currentHandle is { } current && current == target; var action = isForegroundTarget && !currentIsMinimized diff --git a/AppSwitcher/UI/Controls/CycleModeSelector.xaml b/AppSwitcher/UI/Controls/CycleModeSelector.xaml index 333e109..0894936 100644 --- a/AppSwitcher/UI/Controls/CycleModeSelector.xaml +++ b/AppSwitcher/UI/Controls/CycleModeSelector.xaml @@ -39,6 +39,6 @@ Click="CycleModeButton_Click" Tag="ToggleWindow" Style="{StaticResource CycleModeButtonStyle}" - ToolTipService.ToolTip="Always show/hide the last active window of this app (does not jump to other windows)"/> + ToolTipService.ToolTip="Show/hide the last used window; focusing another window of this app retargets"/> diff --git a/README.md b/README.md index 3c41abf..0bfae12 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ You stop thinking about where a window is and just jump there. * `NextApp`: Cycle between different apps assigned to the same key. * `NextWindow`: Cycle through all open windows of a *single* app (great for multi-instance browsers). * `Hide`: Minimize the app if you press the hotkey while it's already focused (the ultimate "toggle"). - * `ToggleWindow`: Always show/hide the last active window of the app (sticks to one window). + * `ToggleWindow`: Show/hide the last used window of the app (focusing another window of the same app retargets). - **Start if not running:** optional per-app setting to start the app when no matching process is running. - **Packaged app support:** works with modern Windows packaged apps (for example Windows Terminal) in addition to classic desktop apps. - [**Peek mode:**](https://app-switcher.com/docs/configuration/peek-mode/) hold the hotkey to peek at the target app and release to return to the original app. diff --git a/docs/src/content/docs/configuration/cycle-modes.mdx b/docs/src/content/docs/configuration/cycle-modes.mdx index fe551eb..6869888 100644 --- a/docs/src/content/docs/configuration/cycle-modes.mdx +++ b/docs/src/content/docs/configuration/cycle-modes.mdx @@ -56,13 +56,13 @@ Subsequent presses toggle visibility of the frontmost window in Z-order. **Best for:** Multi-window apps where you always want the *same* window (the last one you used). -Pressing the hotkey shows or hides the last active window of that app. It does **not** jump to -other open windows of the same process. +Pressing the hotkey shows or hides the last active window of that app. It does **not** cycle +through other open windows of the same process. * First press focuses a window and remembers it. * Next press minimises that same window. -* Press again to restore it — even if other windows of the app are open, or if minimizing handed focus to a sibling. -* The remembered window stays sticky until it is closed; focusing another window of the same app does not retarget. +* Press again to restore it — even if minimizing handed focus to a sibling. +* Focusing another window of the same app retargets: the next hotkey show/hides that window instead. -**Example:** You have two Terminal tabs/windows. `Apps+T` keeps toggling the terminal you were -last working in, without cycling to the other one. +**Example:** You have two Terminal windows. `Apps+T` toggles the one you were last working in. +Click the other terminal, then `Apps+T` starts toggling that one. From 8babb322f5fe69f9150db15c80f54215684f115e Mon Sep 17 00:00:00 2001 From: xuziyang <767637918@qq.com> Date: Thu, 20 Aug 2026 16:00:20 +0800 Subject: [PATCH 3/6] feat(hotkeys): allow punctuation keys as application shortcuts Accept the 11 unshifted OEM punctuation keys in addition to A-Z, and show them as US-layout characters in the UI and overlay. --- .../ConfigurationValidatorTests.cs | 17 ++++++- .../Extensions/KeyExtensionsTests.cs | 46 ++++++++++++++++++- .../Input/KeyStateMachineTests.cs | 22 +++++++-- .../Configuration/ConfigurationValidator.cs | 9 ++-- AppSwitcher/Extensions/KeyExtensions.cs | 38 +++++++++++---- AppSwitcher/Input/KeyStateMachine.cs | 8 ++-- .../UI/Controls/KeyAssignmentButton.xaml.cs | 9 ++-- AppSwitcher/UI/Pages/General.xaml | 2 +- .../UI/ViewModels/AppOverlayViewModel.cs | 12 +++-- .../Common/DynamicApplicationViewModel.cs | 3 +- AppSwitcher/UI/Windows/AppOverlayWindow.xaml | 4 +- README.md | 2 +- .../docs/configuration/choosing-modifier.mdx | 4 +- .../content/docs/configuration/hotkeys.mdx | 2 +- .../content/docs/configuration/peek-mode.mdx | 2 +- 15 files changed, 140 insertions(+), 40 deletions(-) diff --git a/AppSwitcher.Tests/Configuration/ConfigurationValidatorTests.cs b/AppSwitcher.Tests/Configuration/ConfigurationValidatorTests.cs index 8af42ae..f605da9 100644 --- a/AppSwitcher.Tests/Configuration/ConfigurationValidatorTests.cs +++ b/AppSwitcher.Tests/Configuration/ConfigurationValidatorTests.cs @@ -88,7 +88,9 @@ public void ValidateAndLog_ReturnsError_WhenProcessFileDoesNotExist() [InlineData(Key.F1)] [InlineData(Key.Return)] [InlineData(Key.Space)] - public void ValidateAndLog_ReturnsError_WhenApplicationKeyIsNotALetter(Key key) + [InlineData(Key.Oem8)] + [InlineData(Key.OemBackslash)] + public void ValidateAndLog_ReturnsError_WhenApplicationKeyIsNotALetterOrPunctuation(Key key) { var tempFile = Path.GetTempFileName(); try @@ -108,7 +110,18 @@ public void ValidateAndLog_ReturnsError_WhenApplicationKeyIsNotALetter(Key key) [InlineData(Key.A)] [InlineData(Key.M)] [InlineData(Key.Z)] - public void ValidateAndLog_ReturnsSuccess_WhenApplicationKeyIsALetter(Key key) + [InlineData(Key.OemTilde)] + [InlineData(Key.OemMinus)] + [InlineData(Key.OemPlus)] + [InlineData(Key.OemOpenBrackets)] + [InlineData(Key.OemCloseBrackets)] + [InlineData(Key.OemPipe)] + [InlineData(Key.OemSemicolon)] + [InlineData(Key.OemQuotes)] + [InlineData(Key.OemComma)] + [InlineData(Key.OemPeriod)] + [InlineData(Key.OemQuestion)] + public void ValidateAndLog_ReturnsSuccess_WhenApplicationKeyIsALetterOrPunctuation(Key key) { var tempFile = Path.GetTempFileName(); try diff --git a/AppSwitcher.Tests/Extensions/KeyExtensionsTests.cs b/AppSwitcher.Tests/Extensions/KeyExtensionsTests.cs index 096ccb8..cf31be3 100644 --- a/AppSwitcher.Tests/Extensions/KeyExtensionsTests.cs +++ b/AppSwitcher.Tests/Extensions/KeyExtensionsTests.cs @@ -13,8 +13,52 @@ public class KeyExtensionsTests [InlineData(Key.D0, "0")] [InlineData(Key.D1, "1")] [InlineData(Key.D9, "9")] - public void KeyExtensions_ToFriendlyString_ReturnsProperString(Key key, string expected) + [InlineData(Key.OemTilde, "`")] + [InlineData(Key.OemMinus, "-")] + [InlineData(Key.OemPlus, "=")] + [InlineData(Key.OemOpenBrackets, "[")] + [InlineData(Key.OemCloseBrackets, "]")] + [InlineData(Key.OemPipe, "\\")] + [InlineData(Key.OemSemicolon, ";")] + [InlineData(Key.OemQuotes, "'")] + [InlineData(Key.OemComma, ",")] + [InlineData(Key.OemPeriod, ".")] + [InlineData(Key.OemQuestion, "/")] + public void ToFriendlyString_ReturnsProperString(Key key, string expected) { key.ToFriendlyString().Should().Be(expected); } + + [Theory] + [InlineData(Key.A)] + [InlineData(Key.Z)] + [InlineData(Key.OemTilde)] + [InlineData(Key.OemMinus)] + [InlineData(Key.OemPlus)] + [InlineData(Key.OemOpenBrackets)] + [InlineData(Key.OemCloseBrackets)] + [InlineData(Key.OemPipe)] + [InlineData(Key.OemSemicolon)] + [InlineData(Key.OemQuotes)] + [InlineData(Key.OemComma)] + [InlineData(Key.OemPeriod)] + [InlineData(Key.OemQuestion)] + public void IsApplicationKey_ReturnsTrue_ForLettersAndPunctuation(Key key) + { + key.IsApplicationKey().Should().BeTrue(); + } + + [Theory] + [InlineData(Key.None)] + [InlineData(Key.D0)] + [InlineData(Key.D9)] + [InlineData(Key.F1)] + [InlineData(Key.Space)] + [InlineData(Key.Return)] + [InlineData(Key.Oem8)] + [InlineData(Key.OemBackslash)] + public void IsApplicationKey_ReturnsFalse_ForNonApplicationKeys(Key key) + { + key.IsApplicationKey().Should().BeFalse(); + } } diff --git a/AppSwitcher.Tests/Input/KeyStateMachineTests.cs b/AppSwitcher.Tests/Input/KeyStateMachineTests.cs index 57bf62f..eda3bbf 100644 --- a/AppSwitcher.Tests/Input/KeyStateMachineTests.cs +++ b/AppSwitcher.Tests/Input/KeyStateMachineTests.cs @@ -125,7 +125,18 @@ public void ProcessKeyUp_ReturnsNoOp_ForNonModifierKey() [Theory] [InlineData(Key.A)] [InlineData(Key.Z)] - public void ProcessKeyDown_ReturnsLetterKeyPressed_WhenLetterPressedWhileModifierHeld(Key key) + [InlineData(Key.OemTilde)] + [InlineData(Key.OemMinus)] + [InlineData(Key.OemPlus)] + [InlineData(Key.OemOpenBrackets)] + [InlineData(Key.OemCloseBrackets)] + [InlineData(Key.OemPipe)] + [InlineData(Key.OemSemicolon)] + [InlineData(Key.OemQuotes)] + [InlineData(Key.OemComma)] + [InlineData(Key.OemPeriod)] + [InlineData(Key.OemQuestion)] + public void ProcessKeyDown_ReturnsLetterKeyPressed_WhenLetterOrPunctuationPressedWhileModifierHeld(Key key) { _sut.ProcessKeyDown(Key.Apps); @@ -217,12 +228,15 @@ public void ProcessKeyUp_ReturnsModifierReleasedAfterAction_WhenMultipleLettersP // ── Unrelated key handling ────────────────────────────────────────────── - [Fact] - public void ProcessKeyDown_ReturnsUnrelatedKeyReset_WhenUnrelatedKeyPressedWhileModifierHeld() + [Theory] + [InlineData(Key.F5)] + [InlineData(Key.Oem8)] + [InlineData(Key.OemBackslash)] + public void ProcessKeyDown_ReturnsUnrelatedKeyReset_WhenUnrelatedKeyPressedWhileModifierHeld(Key key) { _sut.ProcessKeyDown(Key.Apps); - var result = _sut.ProcessKeyDown(Key.F5); + var result = _sut.ProcessKeyDown(key); result.Should().BeOfType(); _sut.IsModifierHeld.Should().BeFalse(); diff --git a/AppSwitcher/Configuration/ConfigurationValidator.cs b/AppSwitcher/Configuration/ConfigurationValidator.cs index c68fd08..e998376 100644 --- a/AppSwitcher/Configuration/ConfigurationValidator.cs +++ b/AppSwitcher/Configuration/ConfigurationValidator.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Logging; +using AppSwitcher.Extensions; +using Microsoft.Extensions.Logging; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Windows.Input; @@ -62,13 +63,13 @@ public ValidationResult ValidateApplication(IApplicationConfiguration app) { if (!File.Exists(app.ProcessPath)) { - return ValidationResult.Error(app.ProcessName,"Application process path missing or invalid. Re-add the application to fix it."); + return ValidationResult.Error(app.ProcessName, "Application process path missing or invalid. Re-add the application to fix it."); } } - if (app.Key is Key.None or not (>=Key.A and <= Key.Z)) + if (!app.Key.IsApplicationKey()) { - return ValidationResult.Error(app.ProcessName, "Application key invalid - it should be a single letter"); + return ValidationResult.Error(app.ProcessName, "Application key invalid - it should be a single letter or punctuation key"); } return ValidationResult.Success; diff --git a/AppSwitcher/Extensions/KeyExtensions.cs b/AppSwitcher/Extensions/KeyExtensions.cs index e826263..fab3122 100644 --- a/AppSwitcher/Extensions/KeyExtensions.cs +++ b/AppSwitcher/Extensions/KeyExtensions.cs @@ -7,13 +7,35 @@ public static class KeyExtensions public static bool IsLetter(this Key key) => key is >= Key.A and <= Key.Z; public static bool IsDigit(this Key key) => key is >= Key.D0 and <= Key.D9; - public static string ToFriendlyString(this Key key) - { - if (key.IsDigit()) - { - return ((char)('0'+ key - Key.D0)).ToString(); - } + public static bool IsPunctuation(this Key key) => key is + Key.OemTilde or + Key.OemMinus or + Key.OemPlus or + Key.OemOpenBrackets or + Key.OemCloseBrackets or + Key.OemPipe or + Key.OemSemicolon or + Key.OemQuotes or + Key.OemComma or + Key.OemPeriod or + Key.OemQuestion; + + public static bool IsApplicationKey(this Key key) => key.IsLetter() || key.IsPunctuation(); - return key.ToString(); - } + public static string ToFriendlyString(this Key key) => key switch + { + _ when key.IsDigit() => ((char)('0' + key - Key.D0)).ToString(), + Key.OemTilde => "`", + Key.OemMinus => "-", + Key.OemPlus => "=", + Key.OemOpenBrackets => "[", + Key.OemCloseBrackets => "]", + Key.OemPipe => "\\", + Key.OemSemicolon => ";", + Key.OemQuotes => "'", + Key.OemComma => ",", + Key.OemPeriod => ".", + Key.OemQuestion => "/", + _ => key.ToString() + }; } \ No newline at end of file diff --git a/AppSwitcher/Input/KeyStateMachine.cs b/AppSwitcher/Input/KeyStateMachine.cs index 37cfc14..b51ec83 100644 --- a/AppSwitcher/Input/KeyStateMachine.cs +++ b/AppSwitcher/Input/KeyStateMachine.cs @@ -62,7 +62,7 @@ public KeyTransition ProcessKeyDown(Key key) return new KeyTransition.NoOp(); } - if (key.IsLetter()) + if (key.IsApplicationKey()) { _state = State.ModifierHeldWithActionKey; return new KeyTransition.LetterKeyPressed(key); @@ -184,13 +184,13 @@ internal abstract record ModifierTransition(bool HasSideEffect) : KeyTransition; /// Modifier was pressed. distinguishes first press from key-repeat. public sealed record ModifierPressed(bool IsFirstPress, bool HasSideEffect) : ModifierTransition(HasSideEffect); - /// Modifier released without any letter or digit key pressed while held. + /// Modifier released without any letter, punctuation, or digit key pressed while held. public sealed record ModifierReleasedClean(long HeldDurationMs, bool HasSideEffect) : ModifierTransition(HasSideEffect); - /// Modifier released after at least one letter or digit was pressed while held (matched or not). + /// Modifier released after at least one letter, punctuation, or digit was pressed while held (matched or not). public sealed record ModifierReleasedAfterAction(bool HasSideEffect) : ModifierTransition(HasSideEffect); - /// A letter key was pressed while the modifier is held. + /// A letter or punctuation application key was pressed while the modifier is held. public sealed record LetterKeyPressed(Key Key) : KeyTransition; /// A digit key was pressed while the modifier is held. diff --git a/AppSwitcher/UI/Controls/KeyAssignmentButton.xaml.cs b/AppSwitcher/UI/Controls/KeyAssignmentButton.xaml.cs index f16a9ee..8a67a19 100644 --- a/AppSwitcher/UI/Controls/KeyAssignmentButton.xaml.cs +++ b/AppSwitcher/UI/Controls/KeyAssignmentButton.xaml.cs @@ -1,11 +1,12 @@ +using AppSwitcher.Extensions; using System.Windows; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; using ControlAppearance = Wpf.Ui.Controls.ControlAppearance; -using UserControl = System.Windows.Controls.UserControl; -using KeyEventArgs = System.Windows.Input.KeyEventArgs; using KeyboardFocusChangedEventArgs = System.Windows.Input.KeyboardFocusChangedEventArgs; +using KeyEventArgs = System.Windows.Input.KeyEventArgs; +using UserControl = System.Windows.Controls.UserControl; namespace AppSwitcher.UI.Controls; @@ -114,7 +115,7 @@ private void Button_PreviewKeyDown(object sender, KeyEventArgs e) return; } - if (e.Key is >= Key.A and <= Key.Z) + if (e.Key.IsApplicationKey()) { SetCurrentValue(KeyProperty, e.Key); // Update via binding to propagate to parent StopListening(); @@ -141,7 +142,7 @@ private void UpdateDisplay(Key key) return; } - ButtonContent = key == Key.None ? "?" : key.ToString(); + ButtonContent = key == Key.None ? "?" : key.ToFriendlyString(); ButtonAppearance = ControlAppearance.Secondary; } diff --git a/AppSwitcher/UI/Pages/General.xaml b/AppSwitcher/UI/Pages/General.xaml index be163e9..6f717d6 100644 --- a/AppSwitcher/UI/Pages/General.xaml +++ b/AppSwitcher/UI/Pages/General.xaml @@ -165,7 +165,7 @@ TextWrapping="WrapWithOverflow" /> diff --git a/AppSwitcher/UI/ViewModels/AppOverlayViewModel.cs b/AppSwitcher/UI/ViewModels/AppOverlayViewModel.cs index 01fb92b..e5f3241 100644 --- a/AppSwitcher/UI/ViewModels/AppOverlayViewModel.cs +++ b/AppSwitcher/UI/ViewModels/AppOverlayViewModel.cs @@ -1,13 +1,17 @@ +using AppSwitcher.Extensions; +using CommunityToolkit.Mvvm.ComponentModel; +using JetBrains.Annotations; using System.Collections.ObjectModel; using System.Windows.Input; using System.Windows.Media; -using CommunityToolkit.Mvvm.ComponentModel; -using JetBrains.Annotations; using System.Windows.Media.Imaging; namespace AppSwitcher.UI.ViewModels; -public record OverlayAppItem(Key HotkeyKey, string Name, ImageSource? Icon, bool IsActive = false, bool IsElevated = false); +public record OverlayAppItem(Key HotkeyKey, string Name, ImageSource? Icon, bool IsActive = false, bool IsElevated = false) +{ + public string HotkeyDisplay => HotkeyKey.ToFriendlyString(); +} public partial class AppOverlayViewModel : ObservableObject { @@ -92,4 +96,4 @@ public void Update( OnPropertyChanged(nameof(ShowLaunchableApps)); OnPropertyChanged(nameof(ShowDynamicApps)); } -} +} \ No newline at end of file diff --git a/AppSwitcher/UI/ViewModels/Common/DynamicApplicationViewModel.cs b/AppSwitcher/UI/ViewModels/Common/DynamicApplicationViewModel.cs index aba25a5..9f75570 100644 --- a/AppSwitcher/UI/ViewModels/Common/DynamicApplicationViewModel.cs +++ b/AppSwitcher/UI/ViewModels/Common/DynamicApplicationViewModel.cs @@ -1,4 +1,5 @@ using AppSwitcher.Configuration; +using AppSwitcher.Extensions; using System.Windows.Input; using System.Windows.Media; @@ -7,7 +8,7 @@ namespace AppSwitcher.UI.ViewModels.Common; internal class DynamicApplicationViewModel { public Key Key { get; init; } - public string KeyLetter => Key.ToString(); + public string KeyLetter => Key.ToFriendlyString(); public string ProcessName { get; init; } = null!; public string ProcessPath { get; init; } = null!; public string DisplayName { get; init; } = null!; diff --git a/AppSwitcher/UI/Windows/AppOverlayWindow.xaml b/AppSwitcher/UI/Windows/AppOverlayWindow.xaml index 997d623..d008aa3 100644 --- a/AppSwitcher/UI/Windows/AppOverlayWindow.xaml +++ b/AppSwitcher/UI/Windows/AppOverlayWindow.xaml @@ -86,9 +86,9 @@ - + diff --git a/docs/src/content/docs/configuration/peek-mode.mdx b/docs/src/content/docs/configuration/peek-mode.mdx index efd5694..8a91f17 100644 --- a/docs/src/content/docs/configuration/peek-mode.mdx +++ b/docs/src/content/docs/configuration/peek-mode.mdx @@ -40,7 +40,7 @@ The return is instantaneous: no extra keypress, no context menu, no delay. Peek mode only arms under specific conditions. It will **not** activate when: - You switch between windows of the **same application** (e.g. cycling between two browser windows). Peek is a cross-process feature. -- You use a **numeric shortcut** (`Modifier + 1–0`) to select a window. Only letter-key hotkeys arm peek mode. +- You use a **numeric shortcut** (`Modifier + 1–0`) to select a window. Only letter and punctuation hotkeys arm peek mode. ## Staying in the Target App From 45f88f675b3711b0af15db91278a35f968e7547e Mon Sep 17 00:00:00 2001 From: xuziyang <767637918@qq.com> Date: Wed, 26 Aug 2026 11:27:35 +0800 Subject: [PATCH 4/6] fix(activate): attach calling thread to foreground thread to bypass foreground lock SetForegroundWindow kept failing while a ShellHost.exe light-dismiss flyout (Quick Settings) held the foreground. The AttachThreadInput fallback attached the target and foreground threads to each other, but SetForegroundWindow runs on our own thread, which never shared input state with the foreground thread - so the rights check still failed. Attach the calling thread to the foreground (and target) thread instead, and add SwitchToThisWindow as a final fallback. --- AppSwitcher/NativeMethods.txt | 1 + AppSwitcher/Switcher.cs | 67 +++++++++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/AppSwitcher/NativeMethods.txt b/AppSwitcher/NativeMethods.txt index e323824..99e298d 100644 --- a/AppSwitcher/NativeMethods.txt +++ b/AppSwitcher/NativeMethods.txt @@ -1,5 +1,6 @@ ShowWindow SetForegroundWindow +BringWindowToTop GetApplicationUserModelId GetBinaryType GetForegroundWindow diff --git a/AppSwitcher/Switcher.cs b/AppSwitcher/Switcher.cs index 1c8a111..9ad447e 100644 --- a/AppSwitcher/Switcher.cs +++ b/AppSwitcher/Switcher.cs @@ -271,23 +271,17 @@ internal bool ActivateWindow(ApplicationWindow window, bool pulseBorder = true) var setForegroundSucceeded = (bool)PInvoke.SetForegroundWindow(hwnd); - var fgHandle = PInvoke.GetForegroundWindow(); - - if (fgHandle != hwnd) + if (PInvoke.GetForegroundWindow() != hwnd) { - var threadId1 = GetWindowThreadProcessId(hwnd); - var threadId2 = GetWindowThreadProcessId(fgHandle); + setForegroundSucceeded = ForceForegroundViaAttachThreadInput(hwnd); + } - if (threadId1 != threadId2) - { - PInvoke.AttachThreadInput(threadId1, threadId2, true); - PInvoke.SetForegroundWindow(hwnd); - PInvoke.AttachThreadInput(threadId1, threadId2, false); - } - else - { - PInvoke.SetForegroundWindow(hwnd); - } + if (PInvoke.GetForegroundWindow() != hwnd) + { + // Last resort: the mechanism used by the Alt+Tab switcher, not subject to foreground-lock + // restrictions - needed e.g. when a ShellHost.exe light-dismiss flyout (Quick Settings) + // holds the foreground. + PInvoke.SwitchToThisWindow(hwnd, true); } var activated = PInvoke.GetForegroundWindow() == hwnd; @@ -307,6 +301,49 @@ internal bool ActivateWindow(ApplicationWindow window, bool pulseBorder = true) return activated; } + // SetForegroundWindow is called on our own thread, so it is our thread that must share input + // state with the foreground window's thread to pass the foreground-rights check (the previous + // version attached the target and foreground threads to each other, which grants nothing). + private static bool ForceForegroundViaAttachThreadInput(HWND hwnd) + { + var foregroundHandle = PInvoke.GetForegroundWindow(); + if (foregroundHandle == hwnd || foregroundHandle == HWND.Null) + { + return foregroundHandle == hwnd; + } + + var currentThreadId = PInvoke.GetCurrentThreadId(); + var foregroundThreadId = GetWindowThreadProcessId(foregroundHandle); + var targetThreadId = GetWindowThreadProcessId(hwnd); + + var attachedToForeground = foregroundThreadId != currentThreadId && + PInvoke.AttachThreadInput(currentThreadId, foregroundThreadId, true); + var attachedToTarget = targetThreadId != currentThreadId && + targetThreadId != foregroundThreadId && + PInvoke.AttachThreadInput(currentThreadId, targetThreadId, true); + try + { + var succeeded = (bool)PInvoke.SetForegroundWindow(hwnd); + if (PInvoke.GetForegroundWindow() != hwnd) + { + PInvoke.BringWindowToTop(hwnd); + } + + return succeeded; + } + finally + { + if (attachedToTarget) + { + PInvoke.AttachThreadInput(currentThreadId, targetThreadId, false); + } + if (attachedToForeground) + { + PInvoke.AttachThreadInput(currentThreadId, foregroundThreadId, false); + } + } + } + private string DescribeForegroundWindow() { try From 1bf64fcbdac37f911dcf941badc33cd2043b2720 Mon Sep 17 00:00:00 2001 From: xuziyang <767637918@qq.com> Date: Fri, 28 Aug 2026 15:42:59 +0800 Subject: [PATCH 5/6] refactor(switcher): simplify cycle dispatch and toggle resolution --- AppSwitcher/Switcher.cs | 62 ++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/AppSwitcher/Switcher.cs b/AppSwitcher/Switcher.cs index 9ad447e..df03731 100644 --- a/AppSwitcher/Switcher.cs +++ b/AppSwitcher/Switcher.cs @@ -27,7 +27,7 @@ public static AppSwitchResult FromApplicationWindow(ApplicationWindow w) => public static AppSwitchResult FromStarted(string processPath) => new(null, processPath, HWND.Null, SHOW_WINDOW_CMD.SW_SHOW, NeedsElevation: false, WasStarted: true); -}; +} internal class Switcher(ILogger logger, IWindowEnumerator windowEnumerator, ConfigurationManager configurationManager) { @@ -147,24 +147,22 @@ public bool SwitchToWindowByIndex( return ActivateFirst(appConfig, firstWindow); } - if (appConfig.CycleMode == CycleMode.Hide) + switch (appConfig.CycleMode) { - logger.LogInformation("Hiding {ProcessName} window {Handle} (state {State})", - appConfig.ProcessName, firstWindow.Handle, firstWindow.State); - HideWindow(firstWindow.Handle); - return null; - } - - if (appConfig.CycleMode == CycleMode.NextWindow) - { - var nextWindow = GetNextWindow(matchingWindows, currentWindow!); - logger.LogInformation("Switching to next window of {ProcessName}: {Handle} (state {State})", - appConfig.ProcessName, nextWindow.Handle, nextWindow.State); - ActivateWindow(nextWindow); - return null; + case CycleMode.Hide: + logger.LogInformation("Hiding {ProcessName} window {Handle} (state {State})", + appConfig.ProcessName, firstWindow.Handle, firstWindow.State); + HideWindow(firstWindow.Handle); + return null; + case CycleMode.NextWindow: + var nextWindow = GetNextWindow(matchingWindows, currentWindow!); + logger.LogInformation("Switching to next window of {ProcessName}: {Handle} (state {State})", + appConfig.ProcessName, nextWindow.Handle, nextWindow.State); + ActivateWindow(nextWindow); + return null; + default: + return ActivateFirst(appConfig, firstWindow); } - - return ActivateFirst(appConfig, firstWindow); } private AppSwitchResult? ActivateFirst(ApplicationConfiguration appConfig, ApplicationWindow window) @@ -179,10 +177,7 @@ public bool SwitchToWindowByIndex( List matchingWindows, ApplicationWindow? currentWindow) { - HWND? remembered = _lastToggleWindows.TryGetValue(appConfig.ProcessName, out var handle) - ? handle - : null; - var rememberedWindow = remembered is { } rememberedHandle + var rememberedWindow = _lastToggleWindows.TryGetValue(appConfig.ProcessName, out var rememberedHandle) ? matchingWindows.Find(w => w.Handle == rememberedHandle) : null; @@ -190,16 +185,16 @@ public bool SwitchToWindowByIndex( var resolution = ToggleWindowTargetResolver.Resolve( matchingHandles, currentWindow?.Handle, - remembered, + rememberedWindow?.Handle, currentIsMinimized: IsMinimized(currentWindow), rememberedIsMinimized: IsMinimized(rememberedWindow)); - if (resolution is null) + if (resolution is not { } resolved) { return null; } - var target = matchingWindows.Find(w => w.Handle == resolution.Value.Handle); + var target = matchingWindows.Find(w => w.Handle == resolved.Handle); if (target is null) { return null; @@ -207,7 +202,7 @@ public bool SwitchToWindowByIndex( _lastToggleWindows[appConfig.ProcessName] = target.Handle; - if (resolution.Value.Action == ToggleWindowAction.Hide) + if (resolved.Action == ToggleWindowAction.Hide) { logger.LogInformation("ToggleWindow: hiding {ProcessName} window {Handle} (state {State}, foreground is {Foreground})", appConfig.ProcessName, target.Handle, target.State, DescribeWindow(currentWindow)); @@ -265,9 +260,8 @@ internal bool ActivateWindow(ApplicationWindow window, bool pulseBorder = true) // Hack for SetForegroundWindow limitations (see Remarks here: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks) // https://github.com/microsoft/PowerToys/blob/8b8c75b9a54a342660ce8475989ef283fe39e8fe/src/common/ManagedCommon/WindowHelpers.cs#L11 - INPUT input = new() { type = INPUT_TYPE.INPUT_MOUSE, Anonymous = new() { mi = new() } }; - INPUT[] inputs = [input]; - PInvoke.SendInput(inputs, Marshal.SizeOf(typeof(INPUT))); + INPUT[] inputs = [new() { type = INPUT_TYPE.INPUT_MOUSE, Anonymous = new() { mi = new() } }]; + PInvoke.SendInput(inputs, Marshal.SizeOf()); var setForegroundSucceeded = (bool)PInvoke.SetForegroundWindow(hwnd); @@ -371,13 +365,13 @@ or SHOW_WINDOW_CMD.SW_SHOWMINNOACTIVE or SHOW_WINDOW_CMD.SW_FORCEMINIMIZE }; - private async Task PulseBorder(IntPtr hwnd, Color highlightColor, int durationMs = 400) + private Task PulseBorder(IntPtr hwnd, Color highlightColor, int durationMs = 400) { var color = highlightColor.R | (highlightColor.G << 8) | (highlightColor.B << 16); // ReSharper disable once InconsistentNaming - var DWMWA_COLOR_DEFAULT = -1; + const int DWMWA_COLOR_DEFAULT = -1; - await Task.Run(async () => + return Task.Run(async () => { SetDwmColor(color); await Task.Delay(durationMs); @@ -409,8 +403,6 @@ private bool WasStartedRecently(ApplicationConfiguration appConfig) => _recentStarts.TryGetValue(appConfig.ProcessPath, out var startedAtTick) && Stopwatch.GetElapsedTime(startedAtTick).TotalMilliseconds < StartDebounceMs; - private static unsafe uint GetWindowThreadProcessId(HWND hwnd) - { - return PInvoke.GetWindowThreadProcessId(hwnd, null); - } + private static unsafe uint GetWindowThreadProcessId(HWND hwnd) => + PInvoke.GetWindowThreadProcessId(hwnd, null); } \ No newline at end of file From 7305dae51336bc0f554c2495bf4cb1b7055251d1 Mon Sep 17 00:00:00 2001 From: xuziyang <767637918@qq.com> Date: Fri, 28 Aug 2026 15:43:31 +0800 Subject: [PATCH 6/6] fix(ime): cancel active composition before switching windows Chinese IMEs (WeType, Microsoft Pinyin) commit the raw composition string (e.g. pinyin letters) into the app when keyboard focus is yanked away mid-composition, leaving garbage like "shui'le" in WeChat's input box. Cancel the composition via IMM (NI_COMPOSITIONSTR/CPS_CANCEL) before activating another window or hiding the foreground window. Cancellation is requested unconditionally since TSF-based IMEs may not report the composition length through the IMM bridge; outcome is logged for diagnostics. --- AppSwitcher/Input/ImeComposition.cs | 41 +++++++++++++++++++++++++++++ AppSwitcher/NativeMethods.txt | 7 +++++ AppSwitcher/Switcher.cs | 32 ++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 AppSwitcher/Input/ImeComposition.cs diff --git a/AppSwitcher/Input/ImeComposition.cs b/AppSwitcher/Input/ImeComposition.cs new file mode 100644 index 0000000..fad4d3d --- /dev/null +++ b/AppSwitcher/Input/ImeComposition.cs @@ -0,0 +1,41 @@ +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Input.Ime; + +namespace AppSwitcher.Input; + +/// +/// Chinese IMEs (WeType, Microsoft Pinyin) commit the raw composition string (e.g. pinyin letters) +/// into the app when keyboard focus is yanked away while a composition is in progress. Cancelling +/// the composition before switching windows leaves the input field clean instead. +/// +internal sealed record ImeCancelResult(bool ContextAcquired, int CompositionLength, bool CancelAccepted); + +internal static class ImeComposition +{ + /// Cancels an in-progress IME composition in the given window, if any. + public static ImeCancelResult CancelActiveComposition(HWND hwnd) + { + var himc = PInvoke.ImmGetContext(hwnd); + if (himc == HIMC.Null) + { + return new ImeCancelResult(false, 0, false); + } + + try + { + var length = CompositionLength(himc); + // CPS_CANCEL is a no-op without an active composition, but TSF-based IMEs may not + // report the composition length through the IMM bridge - ask for cancel regardless. + var accepted = PInvoke.ImmNotifyIME(himc, NOTIFY_IME_ACTION.NI_COMPOSITIONSTR, NOTIFY_IME_INDEX.CPS_CANCEL, 0); + return new ImeCancelResult(true, length, accepted); + } + finally + { + PInvoke.ImmReleaseContext(hwnd, himc); + } + } + + private static unsafe int CompositionLength(HIMC himc) => + PInvoke.ImmGetCompositionString(himc, IME_COMPOSITION_STRING.GCS_COMPSTR, null, 0); +} diff --git a/AppSwitcher/NativeMethods.txt b/AppSwitcher/NativeMethods.txt index 99e298d..f24fd77 100644 --- a/AppSwitcher/NativeMethods.txt +++ b/AppSwitcher/NativeMethods.txt @@ -28,5 +28,12 @@ DwmGetWindowAttribute DwmSetWindowAttribute SwitchToThisWindow AssocQueryString +ImmGetContext +ImmReleaseContext +ImmGetCompositionString +ImmNotifyIME +IME_COMPOSITION_STRING +NOTIFY_IME_ACTION +NOTIFY_IME_INDEX WINDOW_STYLE MAX_PATH diff --git a/AppSwitcher/Switcher.cs b/AppSwitcher/Switcher.cs index df03731..c006f53 100644 --- a/AppSwitcher/Switcher.cs +++ b/AppSwitcher/Switcher.cs @@ -1,4 +1,5 @@ using AppSwitcher.Configuration; +using AppSwitcher.Input; using AppSwitcher.WindowDiscovery; using Microsoft.Extensions.Logging; using Windows.Win32.Foundation; @@ -243,6 +244,7 @@ private ApplicationWindow GetNextWindow(List matchingWindows, internal bool ActivateWindow(ApplicationWindow window, bool pulseBorder = true) { var hwnd = window.Handle; + CancelImeCompositionInForegroundWindow(hwnd); if (PInvoke.IsIconic(hwnd)) { logger.LogDebug("Window is minimized - restoring..."); @@ -338,6 +340,25 @@ private static bool ForceForegroundViaAttachThreadInput(HWND hwnd) } } + // A pending IME composition in the window that is about to lose focus would get committed + // as raw text (e.g. pinyin letters) by Chinese IMEs - cancel it cleanly instead. + private void CancelImeCompositionInForegroundWindow(HWND targetHandle) + { + var foreground = PInvoke.GetForegroundWindow(); + if (foreground == HWND.Null || foreground == targetHandle) + { + return; + } + + var result = ImeComposition.CancelActiveComposition(foreground); + if (result.ContextAcquired) + { + logger.LogInformation( + "IME composition cancel in foreground window {Handle}: length {CompositionLength}, cancel accepted: {CancelAccepted}", + foreground, result.CompositionLength, result.CancelAccepted); + } + } + private string DescribeForegroundWindow() { try @@ -396,6 +417,17 @@ unsafe void SetDwmColor(int colorRef) internal void HideWindow(HWND hwnd) { + // Hiding the foreground window mid-composition would commit raw IME text - cancel it first. + if (PInvoke.GetForegroundWindow() == hwnd) + { + var result = ImeComposition.CancelActiveComposition(hwnd); + if (result.ContextAcquired) + { + logger.LogInformation( + "IME composition cancel before hiding window {Handle}: length {CompositionLength}, cancel accepted: {CancelAccepted}", + hwnd, result.CompositionLength, result.CancelAccepted); + } + } PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_MINIMIZE); }