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/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/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/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.Tests/ToggleWindowTargetResolverTests.cs b/AppSwitcher.Tests/ToggleWindowTargetResolverTests.cs new file mode 100644 index 0000000..5b8d90d --- /dev/null +++ b/AppSwitcher.Tests/ToggleWindowTargetResolverTests.cs @@ -0,0 +1,152 @@ +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_HidesSibling_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(sibling, ToggleWindowAction.Hide)); + } + + [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, + rememberedIsMinimized: true); + + 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/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/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/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/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/NativeMethods.txt b/AppSwitcher/NativeMethods.txt index e323824..f24fd77 100644 --- a/AppSwitcher/NativeMethods.txt +++ b/AppSwitcher/NativeMethods.txt @@ -1,5 +1,6 @@ ShowWindow SetForegroundWindow +BringWindowToTop GetApplicationUserModelId GetBinaryType GetForegroundWindow @@ -27,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/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..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; @@ -27,11 +28,17 @@ 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) { + // 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 +81,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 +100,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 +124,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 +132,88 @@ 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); } - if (appConfig.CycleMode == CycleMode.Hide) + var isOnThisApp = currentWindow is not null && + matchingWindows.Exists(w => w.ProcessId == currentWindow.ProcessId); + + if (!isOnThisApp || appConfig.CycleMode == CycleMode.NextApp) { - logger.LogDebug("Hiding {ProcessName}", appConfig.ProcessName); - HideWindow(window.Handle); + return ActivateFirst(appConfig, firstWindow); } - else if (appConfig.CycleMode == CycleMode.NextWindow) + + switch (appConfig.CycleMode) { - logger.LogDebug("Switching to next window of {ProcessName}", appConfig.ProcessName); - var nextWindow = GetNextWindow(matchingWindows, currentWindow); - logger.LogDebug("Selected next window: {Handle}", nextWindow.Handle); - ActivateWindow(nextWindow); + 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 null; + 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) + { + var rememberedWindow = _lastToggleWindows.TryGetValue(appConfig.ProcessName, out var rememberedHandle) + ? matchingWindows.Find(w => w.Handle == rememberedHandle) + : null; + + var matchingHandles = matchingWindows.ConvertAll(static w => w.Handle); + var resolution = ToggleWindowTargetResolver.Resolve( + matchingHandles, + currentWindow?.Handle, + rememberedWindow?.Handle, + currentIsMinimized: IsMinimized(currentWindow), + rememberedIsMinimized: IsMinimized(rememberedWindow)); + + if (resolution is not { } resolved) + { + return null; + } + + var target = matchingWindows.Find(w => w.Handle == resolved.Handle); + if (target is null) + { + return null; + } + + _lastToggleWindows[appConfig.ProcessName] = target.Handle; + + 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)); + 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,9 +238,13 @@ 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; + CancelImeCompositionInForegroundWindow(hwnd); if (PInvoke.IsIconic(hwnd)) { logger.LogDebug("Window is minimized - restoring..."); @@ -181,45 +262,137 @@ internal void 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()); - PInvoke.SetForegroundWindow(hwnd); + var setForegroundSucceeded = (bool)PInvoke.SetForegroundWindow(hwnd); - var fgHandle = PInvoke.GetForegroundWindow(); + if (PInvoke.GetForegroundWindow() != hwnd) + { + setForegroundSucceeded = ForceForegroundViaAttachThreadInput(hwnd); + } - if (fgHandle != hwnd) + if (PInvoke.GetForegroundWindow() != hwnd) { - var threadId1 = GetWindowThreadProcessId(hwnd); - var threadId2 = GetWindowThreadProcessId(fgHandle); + // 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; + 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); + } - if (threadId1 != threadId2) + 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.AttachThreadInput(threadId1, threadId2, true); - PInvoke.SetForegroundWindow(hwnd); - PInvoke.AttachThreadInput(threadId1, threadId2, false); + PInvoke.BringWindowToTop(hwnd); } - else + + return succeeded; + } + finally + { + if (attachedToTarget) { - PInvoke.SetForegroundWindow(hwnd); + PInvoke.AttachThreadInput(currentThreadId, targetThreadId, false); + } + if (attachedToForeground) + { + PInvoke.AttachThreadInput(currentThreadId, foregroundThreadId, false); } } + } - if (pulseBorder && configurationManager.GetConfiguration()?.PulseBorderEnabled == true) + // 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) { - // 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; + } + + 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 async Task PulseBorder(IntPtr hwnd, Color highlightColor, int durationMs = 400) + 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 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); @@ -244,11 +417,24 @@ 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); } - private static unsafe uint GetWindowThreadProcessId(HWND hwnd) - { - return PInvoke.GetWindowThreadProcessId(hwnd, null); - } + private bool WasStartedRecently(ApplicationConfiguration appConfig) => + _recentStarts.TryGetValue(appConfig.ProcessPath, out var startedAtTick) && + Stopwatch.GetElapsedTime(startedAtTick).TotalMilliseconds < StartDebounceMs; + + private static unsafe uint GetWindowThreadProcessId(HWND hwnd) => + PInvoke.GetWindowThreadProcessId(hwnd, null); } \ No newline at end of file diff --git a/AppSwitcher/ToggleWindowTargetResolver.cs b/AppSwitcher/ToggleWindowTargetResolver.cs new file mode 100644 index 0000000..98bdcd5 --- /dev/null +++ b/AppSwitcher/ToggleWindowTargetResolver.cs @@ -0,0 +1,91 @@ +using Windows.Win32.Foundation; + +namespace AppSwitcher; + +internal enum ToggleWindowAction +{ + Activate, + Hide +} + +/// +/// Pure resolution for : +/// prefer the focused matching window, else a remembered handle, 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. + /// + /// + /// 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 rememberedIsMinimized = false) + { + if (matchingHandles.Count == 0) + { + return null; + } + + 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 + ? 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..0894936 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/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/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/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 @@ - + + With multiple windows, Hide always targets the frontmost matching window. If you need to + stick to one specific window, use **Toggle Window** instead. + + +## 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** 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 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 windows. `Apps+T` toggles the one you were last working in. +Click the other terminal, then `Apps+T` starts toggling that one. diff --git a/docs/src/content/docs/configuration/hotkeys.mdx b/docs/src/content/docs/configuration/hotkeys.mdx index bb97c9b..fe6132d 100644 --- a/docs/src/content/docs/configuration/hotkeys.mdx +++ b/docs/src/content/docs/configuration/hotkeys.mdx @@ -34,7 +34,7 @@ Click the **trash icon** next to any hotkey to remove it. ### Editing a Hotkey -Click the **Key Button** on the left side of the application entry (the square containing the letter) and then press the new key you want to assign. +Click the **Key Button** on the left side of the application entry (the square containing the letter or punctuation) and then press the new key you want to assign. Letters `A–Z` and punctuation `` ` - = [ ] \ ; ' , . / `` are supported. After making your changes, click **Save & Apply** at the bottom to activate them.