fix(shortcutguide): separate hotkey and Win hold activation (#50000)

## Summary of the Pull Request

Fixes a regression where `Win+Shift+/` was treated as a Windows-key hold
because both activation paths signaled the same event. Regular
activation now opens the full Shortcut Guide independently of the **Hold
Windows key** setting and remains visible after Win is released.

## PR Checklist

- [x] Closes: #49990
- [ ] **Communication:** Core contributor review is still required
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** N/A - no end-user-facing strings changed
- [x] **Dev docs:** N/A - no developer-facing contract or workflow
changed

## Detailed Description of the Pull Request / Additional comments

- Keeps regular activation on `OnHotkeyEx()` and routes Win-only holds
through the existing `on_hotkey(size_t)` module seam with a reserved ID.
- Adds a dedicated named event for Win-key hold activation while
preserving the existing regular trigger event and module ABI layout.
- Removes `GetAsyncKeyState` trigger-source inference from Shortcut
Guide UI.
- Tracks the source and visible surface explicitly so Win release closes
only hold-owned UI:
- regular hotkey always opens the full guide and is unaffected by hold
settings;
  - **Off** ignores Win-only holds;
  - **Taskbar indicators** closes on Win release;
- **Open Shortcut Guide** follows the configured close-on-release value.
- Handles both left and right Windows keys.
- Clears stale held-key registrations and pending timers before
re-registering them.
- Uses `MOD_NOREPEAT` for centralized activation hotkeys so a held chord
cannot repeatedly toggle the overlay.
- Normalizes `MOD_NOREPEAT` before centralized action lookup, validates
queued hold activations against the current Win-key state, and transfers
hold-owned full-guide surfaces to regular-hotkey ownership.
- Adds a pure activation policy and a data-driven unit-test matrix.

Touched areas:

- `src/runner/` - source-specific dispatch, held-key registration
cleanup, and repeat suppression.
- `src/common/interop/` - additive hold-event constant and WinRT
projection.
- `src/modules/ShortcutGuide/ShortcutGuideModuleInterface/` - dedicated
hold event signaling and hold-setting guard.
- `src/modules/ShortcutGuide/ShortcutGuide.Ui/` - explicit activation
routing and source-aware release behavior.
- `src/modules/ShortcutGuide/ShortcutGuide.UnitTests/` - activation and
release policy coverage.

**Risks and mitigations**

- The existing regular event and settings JSON remain unchanged.
- No virtual method or data member was added to `PowertoyModuleIface`;
the existing `on_hotkey(size_t)` method is reused.
- Duplicate hold callbacks are prevented during settings refresh, and
duplicate hold events are UI no-ops.
- No new telemetry or user-content logging was added.

## Validation Steps Performed

- Built the full x64 Release solution and confirmed the complete payload
starts without missing-module dialogs or Runner startup errors.
- After rebasing onto current `main`, reran
`tools\build\build-essentials.cmd -Platform x64 -Configuration Release`
and built the affected interop, Runner, module-interface,
index-generator, UI, and unit-test projects; all error logs were empty.
- Ran the x64 Release `ShortcutGuide.UnitTests.dll` with
`vstest.console.exe`: **43/43 passed**, including all 15
activation-policy cases.
- After addressing review feedback, rebuilt the x64 Release Runner,
Shortcut Guide UI, and unit-test projects; all builds passed and the
unit tests remained **43/43**.
- Signaled a queued hold after Win was released and confirmed no overlay
opened. Then exercised hold-owned full-guide to regular-hotkey ownership
transfer and confirmed both activations resolved to `ShowFullGuide` and
the guide remained visible after Win release.
- Held an injected `Win+Shift+/` chord for 1.4 seconds over Notepad: the
full guide opened, remained visible after Win release, and logs recorded
two regular activations (open/close) with **zero** hold activations.
- Triggered the dedicated hold event over Notepad in both hold modes:
- **Taskbar indicators:** the 2048x1104 overlay was visible and
`WS_EX_TOPMOST`.
- **Open Shortcut Guide:** the full guide and taskbar indicators were
visible in the same topmost overlay.

Manual verification matrix for a preview build:

1. Set **Hold Windows key** to Off, Taskbar indicators, and Open
Shortcut Guide.
2. In each mode, press and release `Win+Shift+/`; confirm one full panel
remains visible.
3. Hold LWin and RWin separately; confirm only the selected hold
behavior runs.
4. For Open Shortcut Guide, verify close-on-release enabled and
disabled.
5. Verify a second regular activation toggles the full panel closed.

For physical Win-hold checks, run PowerToys at the same or higher
integrity level as the foreground app. `RegisterHotKey` activation can
work across an elevation mismatch while Runner's low-level hold hook
cannot observe the key.

Closes #49990

---------

Copilot-Session: 8271bded-18e8-474e-8e3b-addd71f67f50
This commit is contained in:
Boliang Zhang
2026-08-19 16:11:11 +08:00
committed by GitHub
parent 5eeb979339
commit ab1f521067
14 changed files with 545 additions and 122 deletions

View File

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

View File

@@ -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();

View File

@@ -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();

View File

@@ -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";

View File

@@ -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);
}
}

View File

@@ -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<NativeMethods.INPUT>());
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<NativeMethods.INPUT>());
}
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);
}
}

View File

@@ -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
/// </summary>
public void ShowOverlay()
{
_closeTimer?.Stop();
_isClosing = false;
RepositionToCursorMonitor();

View File

@@ -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));
}
}

View File

@@ -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<int>(propertiesObject.GetNamedObject(L"win_key_action").GetNamedNumber(L"value"));
switch (value)
{
case static_cast<int>(WindowsKeyAction::Off):
m_windowsKeyAction = WindowsKeyAction::Off;
break;
case static_cast<int>(WindowsKeyAction::OpenShortcutGuide):
m_windowsKeyAction = WindowsKeyAction::OpenShortcutGuide;
break;
case static_cast<int>(WindowsKeyAction::TaskbarIndicators):
default:
m_windowsKeyAction = WindowsKeyAction::TaskbarIndicators;
break;
}
}
}
catch (...)
{ /* Keep defaults */
}
}
else
{

View File

@@ -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<size_t>(-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.

View File

@@ -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<WORD>(~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)
{

View File

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

View File

@@ -10,6 +10,7 @@ namespace CentralizedKeyboardHook
void Stop() noexcept;
void SetHotkeyAction(const std::wstring& moduleName, const Hotkey& hotkey, std::function<bool()>&& action) noexcept;
void AddPressedKeyAction(const std::wstring& moduleName, const DWORD vk, const UINT milliseconds, std::function<bool()>&& action) noexcept;
void ClearPressedKeyActions(const std::wstring& moduleName) noexcept;
void ClearModuleHotkeys(const std::wstring& moduleName) noexcept;
void RegisterWindow(HWND hwnd) noexcept;
};

View File

@@ -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);