diff --git a/src/common/interop/Constants.cpp b/src/common/interop/Constants.cpp index b4bc535ee1..1df141d733 100644 --- a/src/common/interop/Constants.cpp +++ b/src/common/interop/Constants.cpp @@ -167,6 +167,10 @@ namespace winrt::PowerToys::Interop::implementation { return CommonSharedConstants::SHORTCUT_GUIDE_TRIGGER_EVENT; } + hstring Constants::ShortcutGuideWinKeyHoldEvent() + { + return CommonSharedConstants::SHORTCUT_GUIDE_WIN_KEY_HOLD_EVENT; + } hstring Constants::RegistryPreviewTriggerEvent() { return CommonSharedConstants::REGISTRY_PREVIEW_TRIGGER_EVENT; diff --git a/src/common/interop/Constants.h b/src/common/interop/Constants.h index a44b2d3c1d..964cf3f444 100644 --- a/src/common/interop/Constants.h +++ b/src/common/interop/Constants.h @@ -46,6 +46,7 @@ namespace winrt::PowerToys::Interop::implementation static hstring TerminatePeekEvent(); static hstring PowerAccentExitEvent(); static hstring ShortcutGuideTriggerEvent(); + static hstring ShortcutGuideWinKeyHoldEvent(); static hstring RegistryPreviewTriggerEvent(); static hstring GcodePreviewResizeEvent(); static hstring BgcodePreviewResizeEvent(); diff --git a/src/common/interop/Constants.idl b/src/common/interop/Constants.idl index 7ddcebca42..ec990faf66 100644 --- a/src/common/interop/Constants.idl +++ b/src/common/interop/Constants.idl @@ -42,6 +42,7 @@ namespace PowerToys static String TerminatePeekEvent(); static String PowerAccentExitEvent(); static String ShortcutGuideTriggerEvent(); + static String ShortcutGuideWinKeyHoldEvent(); static String RegistryPreviewTriggerEvent(); static String MeasureToolTriggerEvent(); static String GcodePreviewResizeEvent(); diff --git a/src/common/interop/shared_constants.h b/src/common/interop/shared_constants.h index 6fafbddf17..5edc4166a6 100644 --- a/src/common/interop/shared_constants.h +++ b/src/common/interop/shared_constants.h @@ -49,6 +49,7 @@ namespace CommonSharedConstants const wchar_t TERMINATE_COLOR_PICKER_SHARED_EVENT[] = L"Local\\TerminateColorPickerEvent-3d676258-c4d5-424e-a87a-4be22020e813"; const wchar_t SHORTCUT_GUIDE_TRIGGER_EVENT[] = L"Local\\ShortcutGuide-TriggerEvent-d4275ad3-2531-4d19-9252-c0becbd9b496"; + const wchar_t SHORTCUT_GUIDE_WIN_KEY_HOLD_EVENT[] = L"Local\\ShortcutGuide-WinKeyHoldEvent-b5eb7614-d1c4-49d7-9813-ab277aabdd80"; const wchar_t SHORTCUT_GUIDE_EXIT_EVENT[] = L"Local\\ShortcutGuide-ExitEvent-35697cdd-a3d2-47d6-a246-34efcc73eac0"; diff --git a/src/modules/ShortcutGuide/ShortcutGuide.Ui/Helpers/ShortcutGuideActivationPolicy.cs b/src/modules/ShortcutGuide/ShortcutGuide.Ui/Helpers/ShortcutGuideActivationPolicy.cs new file mode 100644 index 0000000000..3a6aad81b0 --- /dev/null +++ b/src/modules/ShortcutGuide/ShortcutGuide.Ui/Helpers/ShortcutGuideActivationPolicy.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.PowerToys.Settings.UI.Library; + +namespace ShortcutGuide.Helpers; + +public enum ShortcutGuideActivationSource +{ + None, + RegularHotkey, + WindowsKeyHold, +} + +public enum ShortcutGuideOverlaySurface +{ + Hidden, + TaskbarIndicators, + FullGuide, +} + +public enum ShortcutGuideActivationAction +{ + None, + ShowTaskbarIndicators, + ShowFullGuide, + Close, +} + +public static class ShortcutGuideActivationPolicy +{ + public static ShortcutGuideActivationAction GetActivationAction( + ShortcutGuideActivationSource activationSource, + bool isOverlayVisible, + ShortcutGuideActivationSource activeSource, + ShortcutGuideOverlaySurface activeSurface, + ShortcutGuideWindowsKeyAction windowsKeyAction) + { + if (!isOverlayVisible) + { + activeSource = ShortcutGuideActivationSource.None; + activeSurface = ShortcutGuideOverlaySurface.Hidden; + } + + if (activationSource == ShortcutGuideActivationSource.RegularHotkey) + { + if (activeSource == ShortcutGuideActivationSource.WindowsKeyHold || + activeSurface == ShortcutGuideOverlaySurface.TaskbarIndicators || + activeSurface == ShortcutGuideOverlaySurface.Hidden) + { + return ShortcutGuideActivationAction.ShowFullGuide; + } + + return ShortcutGuideActivationAction.Close; + } + + if (activationSource != ShortcutGuideActivationSource.WindowsKeyHold || + windowsKeyAction == ShortcutGuideWindowsKeyAction.Off || + isOverlayVisible || + activeSurface != ShortcutGuideOverlaySurface.Hidden) + { + return ShortcutGuideActivationAction.None; + } + + return windowsKeyAction == ShortcutGuideWindowsKeyAction.OpenShortcutGuide + ? ShortcutGuideActivationAction.ShowFullGuide + : ShortcutGuideActivationAction.ShowTaskbarIndicators; + } + + public static bool ShouldCloseOnWindowsKeyRelease( + ShortcutGuideActivationSource activeSource, + ShortcutGuideOverlaySurface activeSurface, + bool closeFullGuideOnRelease) + { + if (activeSource != ShortcutGuideActivationSource.WindowsKeyHold) + { + return false; + } + + return activeSurface == ShortcutGuideOverlaySurface.TaskbarIndicators || + (activeSurface == ShortcutGuideOverlaySurface.FullGuide && closeFullGuideOnRelease); + } +} diff --git a/src/modules/ShortcutGuide/ShortcutGuide.Ui/ShortcutGuideXAML/App.xaml.cs b/src/modules/ShortcutGuide/ShortcutGuide.Ui/ShortcutGuideXAML/App.xaml.cs index 5ff74521a7..c344eef5ad 100644 --- a/src/modules/ShortcutGuide/ShortcutGuide.Ui/ShortcutGuideXAML/App.xaml.cs +++ b/src/modules/ShortcutGuide/ShortcutGuide.Ui/ShortcutGuideXAML/App.xaml.cs @@ -42,9 +42,12 @@ namespace ShortcutGuide internal static string CurrentAppName { get; set; } = string.Empty; - private EventWaitHandle? _launchedEvent; - - private Thread? _listenForLaunchedEventThread; + private readonly SemaphoreSlim _activationGate = new(1, 1); + private EventWaitHandle? _regularHotkeyEvent; + private EventWaitHandle? _winKeyHoldEvent; + private Thread? _listenForActivationEventsThread; + private int _activeSource = (int)ShortcutGuideActivationSource.None; + private int _activeSurface = (int)ShortcutGuideOverlaySurface.Hidden; private static readonly UIntPtr _ignoreKeyEventFlag = 0x5557; @@ -67,6 +70,7 @@ namespace ShortcutGuide { this.LoadData(); OverlayWindow = new OverlayWindow(); + OverlayWindow.ClosingStarted += (_, _) => ResetActivationState(); OverlayWindow.Activate(); OverlayWindow.AppWindow.Hide(); OverlayWindow.Closed += (_, _) => @@ -83,22 +87,15 @@ namespace ShortcutGuide Current.Exit(); }; - try - { - _launchedEvent = EventWaitHandle.OpenExisting(Constants.ShortcutGuideTriggerEvent()); - Logger.LogInfo($"Opened Shortcut Guide trigger event '{Constants.ShortcutGuideTriggerEvent()}'."); - } - catch (Exception ex) - { - Logger.LogError($"Failed to open existing event '{Constants.ShortcutGuideTriggerEvent()}': {ex.Message}"); - } + _regularHotkeyEvent = TryOpenActivationEvent(Constants.ShortcutGuideTriggerEvent()); + _winKeyHoldEvent = TryOpenActivationEvent(Constants.ShortcutGuideWinKeyHoldEvent()); - _listenForLaunchedEventThread = new Thread(ListenForLaunchedEvents) + _listenForActivationEventsThread = new Thread(ListenForActivationEvents) { IsBackground = true, - Name = "ShortcutGuide-ShowEventListener", + Name = "ShortcutGuide-ActivationEventListener", }; - _listenForLaunchedEventThread.Start(); + _listenForActivationEventsThread.Start(); _winKeyUpKeyboardHook = new HotkeySettingsControlHook( (int key) => { @@ -106,26 +103,30 @@ namespace ShortcutGuide }, (int key) => { - if (OverlayWindow.AppWindow.IsVisible) + if (key is not 0x5B and not 0x5C) { - if (ShortcutGuideProperties.WindowsKeyAction.Value != ((int)ShortcutGuideWindowsKeyAction.OpenShortcutGuide) || ShortcutGuideProperties.CloseOnWindowsKeyRelease.Value) + return; + } + + var activeSource = (ShortcutGuideActivationSource)Volatile.Read(ref _activeSource); + var activeSurface = (ShortcutGuideOverlaySurface)Volatile.Read(ref _activeSurface); + bool isOverlayVisible = OverlayWindow.AppWindow.IsVisible; + if (isOverlayVisible || activeSource == ShortcutGuideActivationSource.WindowsKeyHold) + { + if (ShortcutGuideActivationPolicy.ShouldCloseOnWindowsKeyRelease( + activeSource, + activeSurface, + ShortcutGuideProperties.CloseOnWindowsKeyRelease.Value)) { - OverlayWindow.DispatcherQueue.TryEnqueue(() => - { - OverlayWindow.CloseAnimated(); - }); + OverlayWindow.DispatcherQueue.TryEnqueue(CloseOverlay); } NativeMethods.SendInput(1, [new() { Type = 1, Data = new() { Keyboard = new NativeMethods.KEYBDINPUT { WVk = 0xFF, DwFlags = 0x2 } } }], Marshal.SizeOf()); SendSingleKeyboardInput((short)key, 0x2); // key up } - else - { - SendSingleKeyboardInput((short)key, 0x2); // key up - } }, - () => true, - (int key, nuint specialFlags) => key == 91 && specialFlags != _ignoreKeyEventFlag); + () => OverlayWindow.AppWindow.IsVisible || (ShortcutGuideActivationSource)Volatile.Read(ref _activeSource) == ShortcutGuideActivationSource.WindowsKeyHold, + (int key, nuint specialFlags) => (key is 0x5B or 0x5C) && specialFlags != _ignoreKeyEventFlag); } catch (Exception ex) { @@ -142,6 +143,8 @@ namespace ShortcutGuide { 0xA5 => true, // VK_RMENU (Right Alt - AltGr) 0xA3 => true, // VK_RCONTROL + 0x5B => true, // VK_LWIN + 0x5C => true, // VK_RWIN 0x2D => true, // VK_INSERT 0x2E => true, // VK_DELETE 0x23 => true, // VK_END @@ -179,73 +182,35 @@ namespace ShortcutGuide NativeMethods.SendInput(1, inputs, Marshal.SizeOf()); } - private void ListenForLaunchedEvents() + private void ListenForActivationEvents() { - if (_launchedEvent == null) + List<(WaitHandle Handle, ShortcutGuideActivationSource Source)> activationEvents = []; + if (_regularHotkeyEvent != null) { + activationEvents.Add((_regularHotkeyEvent, ShortcutGuideActivationSource.RegularHotkey)); + } + + if (_winKeyHoldEvent != null) + { + activationEvents.Add((_winKeyHoldEvent, ShortcutGuideActivationSource.WindowsKeyHold)); + } + + if (activationEvents.Count == 0) + { + Logger.LogError("Failed to open any Shortcut Guide activation trigger events."); return; } - var handles = new WaitHandle[] { _launchedEvent }; + WaitHandle[] handles = activationEvents.ConvertAll(item => item.Handle).ToArray(); try { - Logger.LogInfo("Shortcut Guide show-event listener started."); + Logger.LogInfo("Shortcut Guide activation-event listener started."); while (true) { - var index = WaitHandle.WaitAny(handles); - if (index == 0) - { - Logger.LogInfo("Shortcut Guide trigger event signaled."); - OverlayWindow.DispatcherQueue.TryEnqueue(async () => - { - // VK_LWIN long-press shows only the taskbar pane. - // Use the Win32 key state directly: WPF's - // System.Windows.Input.Keyboard is not initialized - // on the WinUI UI thread. - const int VK_LWIN = 0x5B; - const int VK_RWIN = 0x5C; - bool winKeyDown = ((NativeMethods.GetAsyncKeyState(VK_LWIN) & 0x8000) != 0) || - ((NativeMethods.GetAsyncKeyState(VK_RWIN) & 0x8000) != 0); - if (winKeyDown && ShortcutGuideProperties.WindowsKeyAction.Value == (int)ShortcutGuideWindowsKeyAction.Off) - { - return; - } - - if (winKeyDown && ShortcutGuideProperties.WindowsKeyAction.Value == (int)ShortcutGuideWindowsKeyAction.TaskbarIndicators) - { - if (OverlayWindow.AppWindow.IsVisible) - { - return; - } - - OverlayWindow.MainPaneControl.Visibility = Visibility.Collapsed; - OverlayWindow.ShowOverlay(); - OverlayWindow.UpdateTaskbarPaneLayout(); - OverlayWindow.TaskbarPaneControl.Visibility = Visibility.Visible; - return; - } - - if (OverlayWindow.AppWindow.IsVisible) - { - OverlayWindow.CloseAnimated(); - OverlayWindow.MainPaneControl.Hide(); - } - else - { - Program.ForegroundWindowHandle = NativeMethods.GetForegroundWindow(); - OverlayWindow.MainPaneControl.Visibility = Visibility.Collapsed; - OverlayWindow.ShowOverlay(); - await OverlayWindow.MainPaneControl.Open(); - OverlayWindow.UpdateTaskbarPaneLayout(); - OverlayWindow.MainPaneControl.Visibility = Visibility.Visible; - OverlayWindow.MainPaneControl.FocusSearch(); - } - }); - } - else - { - break; - } + int eventIndex = WaitHandle.WaitAny(handles); + var activationSource = activationEvents[eventIndex].Source; + Logger.LogInfo($"Shortcut Guide trigger event signaled by {activationSource}."); + OverlayWindow.DispatcherQueue.TryEnqueue(() => _ = HandleActivationAsync(activationSource)); } } catch (ObjectDisposedException) @@ -256,6 +221,147 @@ namespace ShortcutGuide } } + private static EventWaitHandle? TryOpenActivationEvent(string eventName) + { + try + { + var activationEvent = EventWaitHandle.OpenExisting(eventName); + Logger.LogInfo($"Opened Shortcut Guide trigger event '{eventName}'."); + return activationEvent; + } + catch (WaitHandleCannotBeOpenedException ex) + { + Logger.LogError($"Failed to open Shortcut Guide trigger event '{eventName}': {ex.Message}"); + } + catch (UnauthorizedAccessException ex) + { + Logger.LogError($"Failed to open Shortcut Guide trigger event '{eventName}': {ex.Message}"); + } + catch (IOException ex) + { + Logger.LogError($"Failed to open Shortcut Guide trigger event '{eventName}': {ex.Message}"); + } + + return null; + } + + private async Task HandleActivationAsync(ShortcutGuideActivationSource activationSource) + { + await _activationGate.WaitAsync(); + try + { + bool isOverlayVisible = OverlayWindow.AppWindow.IsVisible; + var activeSource = (ShortcutGuideActivationSource)Volatile.Read(ref _activeSource); + var activeSurface = (ShortcutGuideOverlaySurface)Volatile.Read(ref _activeSurface); + var windowsKeyAction = (ShortcutGuideWindowsKeyAction)ShortcutGuideProperties.WindowsKeyAction.Value; + var action = ShortcutGuideActivationPolicy.GetActivationAction( + activationSource, + isOverlayVisible, + activeSource, + activeSurface, + windowsKeyAction); + + Logger.LogInfo($"Shortcut Guide activation action: {action}."); + switch (action) + { + case ShortcutGuideActivationAction.ShowTaskbarIndicators: + if (!TryBeginActivation(activationSource, ShortcutGuideOverlaySurface.TaskbarIndicators)) + { + break; + } + + Program.ForegroundWindowHandle = NativeMethods.GetForegroundWindow(); + OverlayWindow.MainPaneControl.Visibility = Visibility.Collapsed; + OverlayWindow.ShowOverlay(); + OverlayWindow.UpdateTaskbarPaneLayout(); + OverlayWindow.TaskbarPaneControl.Visibility = Visibility.Visible; + break; + + case ShortcutGuideActivationAction.ShowFullGuide: + bool isFullGuideVisible = isOverlayVisible && activeSurface == ShortcutGuideOverlaySurface.FullGuide; + if (!TryBeginActivation(activationSource, ShortcutGuideOverlaySurface.FullGuide)) + { + break; + } + + if (isFullGuideVisible) + { + OverlayWindow.MainPaneControl.Visibility = Visibility.Visible; + OverlayWindow.MainPaneControl.FocusSearch(); + break; + } + + if (!isOverlayVisible) + { + Program.ForegroundWindowHandle = NativeMethods.GetForegroundWindow(); + } + + OverlayWindow.MainPaneControl.Visibility = Visibility.Collapsed; + OverlayWindow.ShowOverlay(); + await OverlayWindow.MainPaneControl.Open(); + if ((ShortcutGuideActivationSource)Volatile.Read(ref _activeSource) != activationSource || + (ShortcutGuideOverlaySurface)Volatile.Read(ref _activeSurface) != ShortcutGuideOverlaySurface.FullGuide) + { + return; + } + + OverlayWindow.UpdateTaskbarPaneLayout(); + OverlayWindow.MainPaneControl.Visibility = Visibility.Visible; + OverlayWindow.MainPaneControl.FocusSearch(); + break; + + case ShortcutGuideActivationAction.Close: + CloseOverlay(); + break; + } + } + catch (Exception ex) + { + Logger.LogError("Failed to handle Shortcut Guide activation.", ex); + CloseOverlay(); + } + finally + { + _activationGate.Release(); + } + } + + private static void CloseOverlay() + { + OverlayWindow.CloseAnimated(); + } + + private void SetActivationState(ShortcutGuideActivationSource source, ShortcutGuideOverlaySurface surface) + { + Volatile.Write(ref _activeSource, (int)source); + Volatile.Write(ref _activeSurface, (int)surface); + } + + private void ResetActivationState() + { + SetActivationState(ShortcutGuideActivationSource.None, ShortcutGuideOverlaySurface.Hidden); + } + + private bool TryBeginActivation(ShortcutGuideActivationSource source, ShortcutGuideOverlaySurface surface) + { + SetActivationState(source, surface); + if (source != ShortcutGuideActivationSource.WindowsKeyHold || IsWindowsKeyPressed()) + { + return true; + } + + ResetActivationState(); + return false; + } + + private static bool IsWindowsKeyPressed() + { + const int VirtualKeyLeftWindows = 0x5B; + const int VirtualKeyRightWindows = 0x5C; + return (NativeMethods.GetAsyncKeyState(VirtualKeyLeftWindows) & 0x8000) != 0 || + (NativeMethods.GetAsyncKeyState(VirtualKeyRightWindows) & 0x8000) != 0; + } + private void LoadData() { SettingsUtils settingsUtils = SettingsUtils.Default; @@ -319,19 +425,20 @@ namespace ShortcutGuide public void Dispose() { - _launchedEvent?.Dispose(); + _regularHotkeyEvent?.Dispose(); + _winKeyHoldEvent?.Dispose(); - if (_listenForLaunchedEventThread == null) + if (_listenForActivationEventsThread == null) { return; } try { - if (!_listenForLaunchedEventThread.Join(TimeSpan.FromMilliseconds(250))) + if (!_listenForActivationEventsThread.Join(TimeSpan.FromMilliseconds(250))) { - _listenForLaunchedEventThread.Interrupt(); - _listenForLaunchedEventThread.Join(TimeSpan.FromMilliseconds(250)); + _listenForActivationEventsThread.Interrupt(); + _listenForActivationEventsThread.Join(TimeSpan.FromMilliseconds(250)); } } catch (ThreadInterruptedException) @@ -341,7 +448,7 @@ namespace ShortcutGuide { } - _listenForLaunchedEventThread = null; + _listenForActivationEventsThread = null; GC.SuppressFinalize(this); } } diff --git a/src/modules/ShortcutGuide/ShortcutGuide.Ui/ShortcutGuideXAML/OverlayWindow.xaml.cs b/src/modules/ShortcutGuide/ShortcutGuide.Ui/ShortcutGuideXAML/OverlayWindow.xaml.cs index 11aa52ab28..a07457aba3 100644 --- a/src/modules/ShortcutGuide/ShortcutGuide.Ui/ShortcutGuideXAML/OverlayWindow.xaml.cs +++ b/src/modules/ShortcutGuide/ShortcutGuide.Ui/ShortcutGuideXAML/OverlayWindow.xaml.cs @@ -66,6 +66,8 @@ namespace ShortcutGuide internal string CloseType => _closeType; + internal event EventHandler? ClosingStarted; + public MainPaneControl MainPaneControl => this.MainPane; internal TaskbarPaneControl TaskbarPaneControl => this.TaskbarPane; @@ -308,6 +310,7 @@ namespace ShortcutGuide } _isClosing = true; + ClosingStarted?.Invoke(this, EventArgs.Empty); // Collapse both pseudo-windows so their Implicit.HideAnimations play this.MainPane.Visibility = Visibility.Collapsed; @@ -375,6 +378,7 @@ namespace ShortcutGuide /// public void ShowOverlay() { + _closeTimer?.Stop(); _isClosing = false; RepositionToCursorMonitor(); diff --git a/src/modules/ShortcutGuide/ShortcutGuide.UnitTests/ActivationTests/ShortcutGuideActivationPolicyTests.cs b/src/modules/ShortcutGuide/ShortcutGuide.UnitTests/ActivationTests/ShortcutGuideActivationPolicyTests.cs new file mode 100644 index 0000000000..408e28699a --- /dev/null +++ b/src/modules/ShortcutGuide/ShortcutGuide.UnitTests/ActivationTests/ShortcutGuideActivationPolicyTests.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ShortcutGuide.Helpers; + +namespace ShortcutGuide.UnitTests.ActivationTests; + +[TestClass] +public sealed class ShortcutGuideActivationPolicyTests +{ + [TestMethod] + [DataRow(ShortcutGuideWindowsKeyAction.Off, ShortcutGuideActivationAction.None)] + [DataRow(ShortcutGuideWindowsKeyAction.TaskbarIndicators, ShortcutGuideActivationAction.ShowTaskbarIndicators)] + [DataRow(ShortcutGuideWindowsKeyAction.OpenShortcutGuide, ShortcutGuideActivationAction.ShowFullGuide)] + public void GetActivationAction_WindowsKeyHold_UsesConfiguredAction( + ShortcutGuideWindowsKeyAction windowsKeyAction, + ShortcutGuideActivationAction expected) + { + var action = ShortcutGuideActivationPolicy.GetActivationAction( + ShortcutGuideActivationSource.WindowsKeyHold, + isOverlayVisible: false, + ShortcutGuideActivationSource.None, + ShortcutGuideOverlaySurface.Hidden, + windowsKeyAction); + + Assert.AreEqual(expected, action); + } + + [TestMethod] + [DataRow(ShortcutGuideWindowsKeyAction.Off)] + [DataRow(ShortcutGuideWindowsKeyAction.TaskbarIndicators)] + [DataRow(ShortcutGuideWindowsKeyAction.OpenShortcutGuide)] + public void GetActivationAction_RegularHotkey_AlwaysOpensFullGuide(ShortcutGuideWindowsKeyAction windowsKeyAction) + { + var action = ShortcutGuideActivationPolicy.GetActivationAction( + ShortcutGuideActivationSource.RegularHotkey, + isOverlayVisible: false, + ShortcutGuideActivationSource.None, + ShortcutGuideOverlaySurface.Hidden, + windowsKeyAction); + + Assert.AreEqual(ShortcutGuideActivationAction.ShowFullGuide, action); + } + + [TestMethod] + public void GetActivationAction_RegularHotkey_PromotesHoldIndicators() + { + var action = ShortcutGuideActivationPolicy.GetActivationAction( + ShortcutGuideActivationSource.RegularHotkey, + isOverlayVisible: true, + ShortcutGuideActivationSource.WindowsKeyHold, + ShortcutGuideOverlaySurface.TaskbarIndicators, + ShortcutGuideWindowsKeyAction.TaskbarIndicators); + + Assert.AreEqual(ShortcutGuideActivationAction.ShowFullGuide, action); + } + + [TestMethod] + public void GetActivationAction_RegularHotkey_TakesOwnershipOfHoldGuide() + { + var action = ShortcutGuideActivationPolicy.GetActivationAction( + ShortcutGuideActivationSource.RegularHotkey, + isOverlayVisible: true, + ShortcutGuideActivationSource.WindowsKeyHold, + ShortcutGuideOverlaySurface.FullGuide, + ShortcutGuideWindowsKeyAction.OpenShortcutGuide); + + Assert.AreEqual(ShortcutGuideActivationAction.ShowFullGuide, action); + } + + [TestMethod] + public void GetActivationAction_RegularHotkey_ClosesRegularGuide() + { + var action = ShortcutGuideActivationPolicy.GetActivationAction( + ShortcutGuideActivationSource.RegularHotkey, + isOverlayVisible: true, + ShortcutGuideActivationSource.RegularHotkey, + ShortcutGuideOverlaySurface.FullGuide, + ShortcutGuideWindowsKeyAction.TaskbarIndicators); + + Assert.AreEqual(ShortcutGuideActivationAction.Close, action); + } + + [TestMethod] + public void GetActivationAction_HoldWhileRegularGuideVisible_DoesNothing() + { + var action = ShortcutGuideActivationPolicy.GetActivationAction( + ShortcutGuideActivationSource.WindowsKeyHold, + isOverlayVisible: true, + ShortcutGuideActivationSource.RegularHotkey, + ShortcutGuideOverlaySurface.FullGuide, + ShortcutGuideWindowsKeyAction.OpenShortcutGuide); + + Assert.AreEqual(ShortcutGuideActivationAction.None, action); + } + + [TestMethod] + [DataRow(ShortcutGuideActivationSource.RegularHotkey, ShortcutGuideOverlaySurface.FullGuide, true, false)] + [DataRow(ShortcutGuideActivationSource.RegularHotkey, ShortcutGuideOverlaySurface.FullGuide, false, false)] + [DataRow(ShortcutGuideActivationSource.WindowsKeyHold, ShortcutGuideOverlaySurface.TaskbarIndicators, false, true)] + [DataRow(ShortcutGuideActivationSource.WindowsKeyHold, ShortcutGuideOverlaySurface.FullGuide, true, true)] + [DataRow(ShortcutGuideActivationSource.WindowsKeyHold, ShortcutGuideOverlaySurface.FullGuide, false, false)] + public void ShouldCloseOnWindowsKeyRelease_UsesActivationOwnership( + ShortcutGuideActivationSource activeSource, + ShortcutGuideOverlaySurface activeSurface, + bool closeFullGuideOnRelease, + bool expected) + { + Assert.AreEqual( + expected, + ShortcutGuideActivationPolicy.ShouldCloseOnWindowsKeyRelease(activeSource, activeSurface, closeFullGuideOnRelease)); + } +} diff --git a/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/dllmain.cpp b/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/dllmain.cpp index 26804283e2..43a39d1bea 100644 --- a/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/dllmain.cpp +++ b/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/dllmain.cpp @@ -41,6 +41,12 @@ public: Logger::warn(L"Failed to create {} event. {}", CommonSharedConstants::SHORTCUT_GUIDE_TRIGGER_EVENT, get_last_error_or_default(GetLastError())); } + winKeyHoldEvent = CreateEvent(nullptr, false, false, CommonSharedConstants::SHORTCUT_GUIDE_WIN_KEY_HOLD_EVENT); + if (!winKeyHoldEvent) + { + Logger::warn(L"Failed to create {} event. {}", CommonSharedConstants::SHORTCUT_GUIDE_WIN_KEY_HOLD_EVENT, get_last_error_or_default(GetLastError())); + } + InitSettings(); } @@ -132,6 +138,10 @@ public: { CloseHandle(triggerEvent); } + if (winKeyHoldEvent) + { + CloseHandle(winKeyHoldEvent); + } delete this; } @@ -144,18 +154,17 @@ public: virtual void OnHotkeyEx() override { - Logger::trace("OnHotkeyEx()"); - if (!_enabled) + SignalEvent(triggerEvent, CommonSharedConstants::SHORTCUT_GUIDE_TRIGGER_EVENT, L"regular hotkey"); + } + + virtual bool on_hotkey(size_t hotkeyId) override + { + if (hotkeyId == PowertoyModuleIface::WIN_KEY_HOLD_HOTKEY_ID && m_windowsKeyAction != WindowsKeyAction::Off) { - return; + SignalEvent(winKeyHoldEvent, CommonSharedConstants::SHORTCUT_GUIDE_WIN_KEY_HOLD_EVENT, L"Windows key hold"); } - if (!IsProcessActive()) - { - StartProcess(); - } - - SetEvent(triggerEvent); + return false; } virtual void send_settings_telemetry() override @@ -170,6 +179,13 @@ public: virtual UINT milliseconds_win_key_must_be_pressed() override { return m_millisecondsWinKeyPressTimeForGlobalWindowsShortcuts; } private: + enum class WindowsKeyAction + { + Off = 0, + TaskbarIndicators = 1, + OpenShortcutGuide = 2, + }; + std::wstring app_name; //contains the non localized key of the powertoy std::wstring app_key; @@ -186,7 +202,28 @@ private: UINT m_millisecondsWinKeyPressTimeForTaskbarIconShortcuts = DEFAULT_MILLISECONDS_WIN_KEY_PRESS_TIME_FOR_TASKBAR_ICON_SHORTCUTS; HANDLE triggerEvent; + HANDLE winKeyHoldEvent; HANDLE exitEvent; + WindowsKeyAction m_windowsKeyAction = WindowsKeyAction::TaskbarIndicators; + + void SignalEvent(HANDLE eventHandle, const wchar_t* eventName, const wchar_t* activationSource) + { + Logger::trace(L"Shortcut Guide was invoked by {}", activationSource); + if (!_enabled) + { + return; + } + + if (!IsProcessActive() && !StartProcess()) + { + return; + } + + if (!SetEvent(eventHandle)) + { + Logger::error(L"Failed to signal {}. {}", eventName, get_last_error_or_default(GetLastError())); + } + } bool StartProcess(std::wstring args = L"") { @@ -199,6 +236,10 @@ private: { ResetEvent(triggerEvent); } + if (winKeyHoldEvent) + { + ResetEvent(winKeyHoldEvent); + } unsigned long powertoys_pid = GetCurrentProcessId(); std::wstring executable_args = L""; @@ -302,7 +343,7 @@ private: Logger::warn("Failed to initialize Shortcut Guide start shortcut"); } -try + try { auto propertiesObject = settingsObject.GetNamedObject(L"properties"); if (propertiesObject.HasKey(L"press_time")) @@ -324,7 +365,34 @@ try } } } - catch (...) { /* Keep defaults */ } + catch (...) + { /* Keep defaults */ + } + + try + { + auto propertiesObject = settingsObject.GetNamedObject(L"properties"); + if (propertiesObject.HasKey(L"win_key_action")) + { + const auto value = static_cast(propertiesObject.GetNamedObject(L"win_key_action").GetNamedNumber(L"value")); + switch (value) + { + case static_cast(WindowsKeyAction::Off): + m_windowsKeyAction = WindowsKeyAction::Off; + break; + case static_cast(WindowsKeyAction::OpenShortcutGuide): + m_windowsKeyAction = WindowsKeyAction::OpenShortcutGuide; + break; + case static_cast(WindowsKeyAction::TaskbarIndicators): + default: + m_windowsKeyAction = WindowsKeyAction::TaskbarIndicators; + break; + } + } + } + catch (...) + { /* Keep defaults */ + } } else { diff --git a/src/modules/interface/powertoy_module_interface.h b/src/modules/interface/powertoy_module_interface.h index b88763d1a3..1b4ef6e32b 100644 --- a/src/modules/interface/powertoy_module_interface.h +++ b/src/modules/interface/powertoy_module_interface.h @@ -135,6 +135,9 @@ public: return false; } + // Reserved ID used when the legacy Windows-key hold path invokes on_hotkey. + static constexpr size_t WIN_KEY_HOLD_HOTKEY_ID = static_cast(-1); + /* These are for enabling the legacy behavior of showing the shortcut guide after pressing the win key. * keep_track_of_pressed_win_key returns true if the module wants to keep track of the win key being pressed. * milliseconds_win_key_must_be_pressed returns the number of milliseconds the win key should be pressed before triggering the module. diff --git a/src/runner/centralized_hotkeys.cpp b/src/runner/centralized_hotkeys.cpp index d0ad870f52..8e9c4c6600 100644 --- a/src/runner/centralized_hotkeys.cpp +++ b/src/runner/centralized_hotkeys.cpp @@ -58,7 +58,7 @@ namespace CentralizedHotkeys ids[shortcut] = nextId++; } - if (!RegisterHotKey(runnerWindow, ids[shortcut], shortcut.modifiersMask, shortcut.vkCode)) + if (!RegisterHotKey(runnerWindow, ids[shortcut], shortcut.modifiersMask | MOD_NOREPEAT, shortcut.vkCode)) { Logger::warn(L"Failed to add {} shortcut. {}", ToWstring(shortcut), get_last_error_or_default(GetLastError())); return false; @@ -97,11 +97,13 @@ namespace CentralizedHotkeys void PopulateHotkey(Shortcut shortcut) { - if (!actions.empty()) + shortcut.modifiersMask &= static_cast(~MOD_NOREPEAT); + const auto actionIt = actions.find(shortcut); + if (actionIt != actions.end() && !actionIt->second.empty()) { try { - actions[shortcut].begin()->action(shortcut.modifiersMask, shortcut.vkCode); + actionIt->second.begin()->action(shortcut.modifiersMask, shortcut.vkCode); } catch(std::exception& ex) { diff --git a/src/runner/centralized_kb_hook.cpp b/src/runner/centralized_kb_hook.cpp index 3e05a6de40..505f01eb38 100644 --- a/src/runner/centralized_kb_hook.cpp +++ b/src/runner/centralized_kb_hook.cpp @@ -212,6 +212,46 @@ namespace CentralizedKeyboardHook pressedKeyDescriptors.insert({ .virtualKey = vk, .moduleName = moduleName, .action = std::move(action), .idTimer = timerId, .millisecondsToPress = milliseconds }); } + void ClearPressedKeyActions(const std::wstring& moduleName) noexcept + { + Logger::trace(L"UnRegister pressed key action for {}", moduleName); + std::unique_lock lock{ pressedKeyMutex }; + const DWORD trackedKey = vkCodePressed.load(); + bool removedTrackedKey = false; + auto it = pressedKeyDescriptors.begin(); + while (it != pressedKeyDescriptors.end()) + { + if (it->moduleName == moduleName) + { + removedTrackedKey |= it->virtualKey == trackedKey; + if (it->idTimer != 0) + { + KillTimer(runnerWindow, it->idTimer); + } + + it = pressedKeyDescriptors.erase(it); + } + else + { + ++it; + } + } + + if (pressedKeyDescriptors.empty()) + { + vkCodePressed = VK_DISABLED; + } + else if (removedTrackedKey) + { + PressedKeyDescriptor trackedKeyDescriptor{ .virtualKey = trackedKey }; + const auto [first, last] = pressedKeyDescriptors.equal_range(trackedKeyDescriptor); + if (first == last) + { + vkCodePressed = VK_DISABLED; + } + } + } + void ClearModuleHotkeys(const std::wstring& moduleName) noexcept { Logger::trace(L"UnRegister hotkey action for {}", moduleName); @@ -230,21 +270,7 @@ namespace CentralizedKeyboardHook } } } - { - std::unique_lock lock{ pressedKeyMutex }; - auto it = pressedKeyDescriptors.begin(); - while (it != pressedKeyDescriptors.end()) - { - if (it->moduleName == moduleName) - { - it = pressedKeyDescriptors.erase(it); - } - else - { - ++it; - } - } - } + ClearPressedKeyActions(moduleName); } void Start() noexcept diff --git a/src/runner/centralized_kb_hook.h b/src/runner/centralized_kb_hook.h index 1ae36ebacb..6c39bb5bca 100644 --- a/src/runner/centralized_kb_hook.h +++ b/src/runner/centralized_kb_hook.h @@ -10,6 +10,7 @@ namespace CentralizedKeyboardHook void Stop() noexcept; void SetHotkeyAction(const std::wstring& moduleName, const Hotkey& hotkey, std::function&& action) noexcept; void AddPressedKeyAction(const std::wstring& moduleName, const DWORD vk, const UINT milliseconds, std::function&& action) noexcept; + void ClearPressedKeyActions(const std::wstring& moduleName) noexcept; void ClearModuleHotkeys(const std::wstring& moduleName) noexcept; void RegisterWindow(HWND hwnd) noexcept; }; diff --git a/src/runner/powertoy_module.cpp b/src/runner/powertoy_module.cpp index eb1f7c4fd7..c76b1eabb3 100644 --- a/src/runner/powertoy_module.cpp +++ b/src/runner/powertoy_module.cpp @@ -79,6 +79,7 @@ void PowertoyModule::update_hotkeys() void PowertoyModule::UpdateHotkeyEx() { CentralizedHotkeys::UnregisterHotkeysForModule(pt_module->get_key()); + CentralizedKeyboardHook::ClearPressedKeyActions(pt_module->get_key()); auto container = pt_module->GetHotkeyEx(); if (container.has_value() && pt_module->is_enabled()) @@ -102,11 +103,15 @@ void PowertoyModule::UpdateHotkeyEx() // Just for enabling the shortcut guide legacy behavior of pressing the Windows Key. // This is not the sort of behavior we'd like to have generalized on other modules. // But this was a way to bring back the long windows key behavior that the community wanted back while maintaining the separate process. - if (pt_module->keep_track_of_pressed_win_key()) + if (pt_module->is_enabled() && pt_module->keep_track_of_pressed_win_key()) { auto modulePtr = pt_module.get(); auto action = [modulePtr] { - modulePtr->OnHotkeyEx(); + if (modulePtr->is_enabled()) + { + return modulePtr->on_hotkey(PowertoyModuleIface::WIN_KEY_HOLD_HOTKEY_ID); + } + return false; }; CentralizedKeyboardHook::AddPressedKeyAction(pt_module->get_key(), VK_LWIN, pt_module->milliseconds_win_key_must_be_pressed(), action);