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 +