diff --git a/.github/actions/spell-check/allow/names.txt b/.github/actions/spell-check/allow/names.txt
index bea601b3d1..45bdbcd04a 100644
--- a/.github/actions/spell-check/allow/names.txt
+++ b/.github/actions/spell-check/allow/names.txt
@@ -223,6 +223,7 @@ Moq
mozilla
mspaint
Newtonsoft
+NVIDIA
onenote
openai
Quickime
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs
index cb08a22dee..8e43235240 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs
@@ -31,7 +31,11 @@ public record SettingsModel
public bool ShowSystemTrayIcon { get; init; } = true;
- public bool IgnoreShortcutWhenFullscreen { get; init; }
+ public bool IgnoreShortcutWhenFullscreen { get; init; } = true;
+
+ public bool IgnoreShortcutWhenBusy { get; init; }
+
+ public bool AllowBreakthroughShortcut { get; init; }
public bool AllowExternalReload { get; init; }
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs
index 60baa76750..4c43cc7173 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs
@@ -137,6 +137,24 @@ public partial class SettingsViewModel : INotifyPropertyChanged
}
}
+ public bool IgnoreShortcutWhenBusy
+ {
+ get => _settingsService.Settings.IgnoreShortcutWhenBusy;
+ set
+ {
+ _settingsService.UpdateSettings(s => s with { IgnoreShortcutWhenBusy = value });
+ }
+ }
+
+ public bool AllowBreakthroughShortcut
+ {
+ get => _settingsService.Settings.AllowBreakthroughShortcut;
+ set
+ {
+ _settingsService.UpdateSettings(s => s with { AllowBreakthroughShortcut = value });
+ }
+ }
+
public bool DisableAnimations
{
get => _settingsService.Settings.DisableAnimations;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/NativeMethods.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/NativeMethods.cs
deleted file mode 100644
index a3227ca77c..0000000000
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/NativeMethods.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-// 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 System.Runtime.InteropServices;
-using System.Security;
-
-namespace Microsoft.CmdPal.UI.Helpers;
-
-[SuppressUnmanagedCodeSecurity]
-internal static class NativeMethods
-{
- [DllImport("shell32.dll")]
- public static extern int SHQueryUserNotificationState(out UserNotificationState state);
-}
-
-internal enum UserNotificationState : int
-{
- QUNS_NOT_PRESENT = 1,
- QUNS_BUSY,
- QUNS_RUNNING_D3D_FULL_SCREEN,
- QUNS_PRESENTATION_MODE,
- QUNS_ACCEPTS_NOTIFICATIONS,
- QUNS_QUIET_TIME,
- QUNS_APP,
-}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowHelper.cs
index deddf13d5d..c36282f6df 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowHelper.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowHelper.cs
@@ -1,28 +1,115 @@
-// Copyright (c) Microsoft Corporation
+// 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 System.Runtime.InteropServices;
+using System.Diagnostics;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.UI.Shell;
namespace Microsoft.CmdPal.UI.Helpers;
internal sealed partial class WindowHelper
{
- public static bool IsWindowFullscreen()
+ ///
+ /// Known applications whose visible windows can trigger a QUNS_BUSY state
+ /// even when the user is not actually in a fullscreen/presentation scenario.
+ ///
+ internal sealed record KnownTriggerApp(string ProcessName, string WindowClassName, string DisplayName);
+
+ internal static readonly KnownTriggerApp[] KnownTriggerWindowClasses =
+ [
+ new("NVIDIA overlay", "CEF-OSC-WIDGET", "NVIDIA Overlay"),
+ ];
+
+ public static QUERY_USER_NOTIFICATION_STATE? GetUserNotificationState()
{
- UserNotificationState state;
-
// https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ne-shellapi-query_user_notification_state
- if (Marshal.GetExceptionForHR(NativeMethods.SHQueryUserNotificationState(out state)) is null)
- {
- if (state == UserNotificationState.QUNS_RUNNING_D3D_FULL_SCREEN ||
- state == UserNotificationState.QUNS_BUSY ||
- state == UserNotificationState.QUNS_PRESENTATION_MODE)
- {
- return true;
- }
- }
+ return PInvoke.SHQueryUserNotificationState(out var state).Succeeded ? state : null;
+ }
- return false;
+ internal static UserNotificationFlags GetUserNotificationFlags()
+ {
+ return GetUserNotificationFlags(GetUserNotificationState());
+ }
+
+ internal static UserNotificationFlags GetUserNotificationFlags(QUERY_USER_NOTIFICATION_STATE? state)
+ {
+ return new UserNotificationFlags(
+ IsRunningD3DFullScreen: state is QUERY_USER_NOTIFICATION_STATE.QUNS_RUNNING_D3D_FULL_SCREEN,
+ IsPresentationMode: state is QUERY_USER_NOTIFICATION_STATE.QUNS_PRESENTATION_MODE,
+ IsBusy: state is QUERY_USER_NOTIFICATION_STATE.QUNS_BUSY);
+ }
+
+ ///
+ /// Returns the display names of known trigger apps that currently have visible
+ /// windows matching their expected window class and process name.
+ ///
+ public static unsafe List FindVisibleTriggerApps()
+ {
+ var detected = new HashSet(StringComparer.Ordinal);
+
+ PInvoke.EnumWindows(
+ (HWND hWnd, LPARAM lParam) =>
+ {
+ if (!PInvoke.IsWindowVisible(hWnd))
+ {
+ return true; // continue
+ }
+
+ var className = GetWindowClassName(hWnd);
+ if (className is null)
+ {
+ return true;
+ }
+
+ foreach (var app in KnownTriggerWindowClasses)
+ {
+ if (!string.Equals(className, app.WindowClassName, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ // Window class matches — verify the process name.
+ uint pid;
+ if (PInvoke.GetWindowThreadProcessId(hWnd, &pid) == 0)
+ {
+ continue;
+ }
+
+ try
+ {
+ using var process = Process.GetProcessById((int)pid);
+ if (string.Equals(process.ProcessName, app.ProcessName, StringComparison.OrdinalIgnoreCase))
+ {
+ detected.Add(app.DisplayName);
+ }
+ }
+ catch
+ {
+ // Process may have exited between enumeration and lookup.
+ }
+ }
+
+ return true;
+ },
+ 0);
+
+ return [.. detected];
+ }
+
+ private static unsafe string? GetWindowClassName(HWND hWnd)
+ {
+ const int maxLength = 256;
+ fixed (char* buffer = new char[maxLength])
+ {
+ var length = PInvoke.GetClassName(hWnd, buffer, maxLength);
+ return length > 0 ? new string(buffer, 0, length) : null;
+ }
+ }
+
+ internal readonly record struct UserNotificationFlags(bool IsRunningD3DFullScreen, bool IsPresentationMode, bool IsBusy)
+ {
+ public bool IsFullscreenState => IsRunningD3DFullScreen || IsPresentationMode;
}
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs
index 780c6ca846..0ebe003d62 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs
@@ -73,7 +73,11 @@ public sealed partial class MainWindow : WindowEx,
private readonly HiddenOwnerWindowBehavior _hiddenOwnerBehavior = new();
private readonly IThemeService _themeService;
private readonly WindowThemeSynchronizer _windowThemeSynchronizer;
+ private readonly List _breakthroughTimestamps = [];
+
private bool _ignoreHotKeyWhenFullScreen = true;
+ private bool _ignoreHotKeyWhenBusy;
+ private bool _allowBreakthroughShortcut;
private bool _suppressDpiChange;
private bool _themeServiceInitialized;
@@ -369,6 +373,8 @@ public sealed partial class MainWindow : WindowEx,
App.Current.Services.GetService()!.SetupTrayIcon(settings.ShowSystemTrayIcon);
_ignoreHotKeyWhenFullScreen = settings.IgnoreShortcutWhenFullscreen;
+ _ignoreHotKeyWhenBusy = settings.IgnoreShortcutWhenBusy;
+ _allowBreakthroughShortcut = settings.AllowBreakthroughShortcut;
_autoGoHomeInterval = settings.AutoGoHomeInterval;
_autoGoHomeTimer.Interval = _autoGoHomeInterval;
@@ -1208,10 +1214,25 @@ public sealed partial class MainWindow : WindowEx,
private void HandleSummon(string commandId)
{
- if (_ignoreHotKeyWhenFullScreen)
+ var isRootHotkey = string.IsNullOrEmpty(commandId);
+ if (isRootHotkey && IsPaletteVisibleToUser())
{
- // If we're in full screen mode, ignore the hotkey
- if (WindowHelper.IsWindowFullscreen())
+ HandleSummonCore(commandId);
+ return;
+ }
+
+ var notificationFlags = WindowHelper.GetUserNotificationFlags();
+ var shouldSuppress =
+ (_ignoreHotKeyWhenFullScreen && notificationFlags.IsFullscreenState) ||
+ (_ignoreHotKeyWhenBusy && notificationFlags.IsBusy);
+
+ if (shouldSuppress)
+ {
+ if (_allowBreakthroughShortcut && IsBreakthroughTriggered())
+ {
+ // Rapid-press breakthrough: let it through
+ }
+ else
{
return;
}
@@ -1220,12 +1241,9 @@ public sealed partial class MainWindow : WindowEx,
HandleSummonCore(commandId);
}
- private void HandleSummonCore(string commandId)
+ private bool IsPaletteVisibleToUser()
{
- var isRootHotkey = string.IsNullOrEmpty(commandId);
- PowerToysTelemetry.Log.WriteEvent(new CmdPalHotkeySummoned(isRootHotkey));
-
- var isVisible = this.Visible;
+ var isVisible = Visible;
unsafe
{
@@ -1240,6 +1258,36 @@ public sealed partial class MainWindow : WindowEx,
}
}
+ return isVisible;
+ }
+
+ private bool IsBreakthroughTriggered()
+ {
+ const int requiredPresses = 3;
+ var windowTicks = 2 * Stopwatch.Frequency; // 2 seconds
+ var now = Stopwatch.GetTimestamp();
+
+ _breakthroughTimestamps.Add(now);
+
+ // Prune timestamps outside the window
+ _breakthroughTimestamps.RemoveAll(t => now - t > windowTicks);
+
+ if (_breakthroughTimestamps.Count >= requiredPresses)
+ {
+ _breakthroughTimestamps.Clear();
+ return true;
+ }
+
+ return false;
+ }
+
+ private void HandleSummonCore(string commandId)
+ {
+ var isRootHotkey = string.IsNullOrEmpty(commandId);
+ PowerToysTelemetry.Log.WriteEvent(new CmdPalHotkeySummoned(isRootHotkey));
+
+ var isVisible = IsPaletteVisibleToUser();
+
// Note to future us: the wParam will have the index of the hotkey we registered.
// We can use that in the future to differentiate the hotkeys we've pressed
// so that we can bind hotkeys to individual commands
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt
index 7d0a2c71f3..426d9a2ffb 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt
@@ -112,3 +112,7 @@ AttachThreadInput
GetWindowPlacement
WINDOWPLACEMENT
WM_DPICHANGED
+
+QUERY_USER_NOTIFICATION_STATE
+EnumWindows
+IsWindowVisible
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml
index 04d9c9003f..5e6ccd70fd 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml
@@ -55,7 +55,20 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs
index cc78a00f56..a14e8573b2 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs
@@ -2,22 +2,32 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
+using System.ComponentModel;
using System.Globalization;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Windows.Win32.UI.Shell;
namespace Microsoft.CmdPal.UI.Settings;
-public sealed partial class GeneralPage : Page
+public sealed partial class GeneralPage : Page, INotifyPropertyChanged
{
private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
private readonly SettingsViewModel? viewModel;
private readonly IApplicationInfoService _appInfoService;
+ private readonly ISettingsService _settingsService;
+ private readonly DispatcherTimer _notificationStateTimer;
+
+ private bool _isNotificationStateSuppressing;
+ private string _notificationStateMessage = string.Empty;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
public GeneralPage()
{
@@ -25,9 +35,41 @@ public sealed partial class GeneralPage : Page
var topLevelCommandManager = App.Current.Services.GetService()!;
var themeService = App.Current.Services.GetService()!;
- var settingsService = App.Current.Services.GetRequiredService();
+ _settingsService = App.Current.Services.GetRequiredService();
_appInfoService = App.Current.Services.GetRequiredService();
- viewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService);
+ viewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, _settingsService);
+
+ _notificationStateTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
+ _notificationStateTimer.Tick += NotificationStateTimer_Tick;
+
+ Loaded += GeneralPage_Loaded;
+ Unloaded += GeneralPage_Unloaded;
+ }
+
+ public bool IsNotificationStateSuppressing
+ {
+ get => _isNotificationStateSuppressing;
+ private set
+ {
+ if (_isNotificationStateSuppressing != value)
+ {
+ _isNotificationStateSuppressing = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsNotificationStateSuppressing)));
+ }
+ }
+ }
+
+ public string NotificationStateMessage
+ {
+ get => _notificationStateMessage;
+ private set
+ {
+ if (_notificationStateMessage != value)
+ {
+ _notificationStateMessage = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(NotificationStateMessage)));
+ }
+ }
}
public string ApplicationVersion
@@ -39,4 +81,85 @@ public sealed partial class GeneralPage : Page
return string.Format(CultureInfo.CurrentCulture, versionNo, version);
}
}
+
+ private void GeneralPage_Loaded(object sender, RoutedEventArgs e)
+ {
+ _settingsService.SettingsChanged += SettingsService_SettingsChanged;
+ UpdateNotificationState();
+ _notificationStateTimer.Start();
+ }
+
+ private void GeneralPage_Unloaded(object sender, RoutedEventArgs e)
+ {
+ _notificationStateTimer.Stop();
+ _settingsService.SettingsChanged -= SettingsService_SettingsChanged;
+ }
+
+ private void NotificationStateTimer_Tick(object? sender, object e)
+ {
+ UpdateNotificationState();
+ }
+
+ private void SettingsService_SettingsChanged(ISettingsService sender, SettingsModel settings)
+ {
+ if (DispatcherQueue.HasThreadAccess)
+ {
+ UpdateNotificationState();
+ return;
+ }
+
+ DispatcherQueue.TryEnqueue(UpdateNotificationState);
+ }
+
+ private void UpdateNotificationState()
+ {
+ var state = WindowHelper.GetUserNotificationState();
+ var notificationFlags = WindowHelper.GetUserNotificationFlags(state);
+
+ if (IsActivationShortcutSuppressed(
+ notificationFlags.IsFullscreenState,
+ notificationFlags.IsBusy,
+ viewModel?.IgnoreShortcutWhenFullscreen == true,
+ viewModel?.IgnoreShortcutWhenBusy == true))
+ {
+ var stateDescription = state switch
+ {
+ QUERY_USER_NOTIFICATION_STATE.QUNS_RUNNING_D3D_FULL_SCREEN => ResourceLoaderInstance.GetString("NotificationState_D3DFullScreen"),
+ QUERY_USER_NOTIFICATION_STATE.QUNS_PRESENTATION_MODE => ResourceLoaderInstance.GetString("NotificationState_PresentationMode"),
+ QUERY_USER_NOTIFICATION_STATE.QUNS_BUSY => ResourceLoaderInstance.GetString("NotificationState_Busy"),
+ _ => string.Empty,
+ };
+
+ var messageFormat = ResourceLoaderInstance.GetString("Settings_GeneralPage_NotificationState_InfoBar");
+ var message = string.Format(CultureInfo.CurrentCulture, messageFormat, stateDescription);
+
+ if (state is QUERY_USER_NOTIFICATION_STATE.QUNS_BUSY)
+ {
+ var triggerApps = WindowHelper.FindVisibleTriggerApps();
+ if (triggerApps.Count > 0)
+ {
+ var triggerFormat = ResourceLoaderInstance.GetString("NotificationState_TriggerApps");
+ message += " " + string.Format(CultureInfo.CurrentCulture, triggerFormat, string.Join(", ", triggerApps));
+ }
+ }
+
+ NotificationStateMessage = message;
+ IsNotificationStateSuppressing = true;
+ }
+ else
+ {
+ NotificationStateMessage = string.Empty;
+ IsNotificationStateSuppressing = false;
+ }
+ }
+
+ private static bool IsActivationShortcutSuppressed(
+ bool isFullscreenState,
+ bool isBusyState,
+ bool ignoreShortcutWhenFullscreen,
+ bool ignoreShortcutWhenBusy)
+ {
+ return (ignoreShortcutWhenFullscreen && isFullscreenState) ||
+ (ignoreShortcutWhenBusy && isBusyState);
+ }
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw
index 447e0502af..57df03fb19 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw
@@ -347,6 +347,33 @@ Right-click to remove the key combination, thereby deactivating the shortcut.
Preventing disruption of the program running in fullscreen by unintentional activation of shortcut
+
+ Ignore shortcut when the system heuristically detects fullscreen
+
+
+ Windows may detect that a fullscreen application is running or Presentation Settings are applied. Some applications (such as NVIDIA overlay) can trigger this incorrectly, preventing the shortcut from working
+
+
+ Allow breakthrough with rapid shortcut presses
+
+
+ Press the activation shortcut 3 times within 2 seconds to bypass the fullscreen or busy state guard
+
+
+ Your activation shortcut might not work right now because {0}.
+
+
+ a full-screen app is running
+
+
+ presentation mode is on
+
+
+ Windows thinks a full-screen app or presentation is active
+
+
+ Likely caused by: {0}.
+
Highlight search on activate
@@ -1125,4 +1152,4 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Right
Right section label in pin to dock dialog (code access, horizontal end)
-
\ No newline at end of file
+