[Feature] PowerToys hotkey conflict detection (#41029)

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
Implements comprehensive hotkey conflict detection and resolution system
for PowerToys, providing real-time conflict checking and centralized
management interface.

## PR Checklist

- [ ] **Closes:** #xxx
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized
- [x] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [x] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: [Shortcut conflict detction dev
spec](https://github.com/MicrosoftDocs/windows-dev-docs/pull/5519)

## TODO Lists
- [x] Add real-time hotkey validation functionality to the hotkey dialog
- [x] Immediately detect conflicts and update shortcut conflict status
after applying new shortcuts
- [x] Return conflict list from runner hotkey conflict detector for
conflict checking.
- [x] Implement the Tooltip for every shortcut control 
- [x] Add dialog UI for showing all the shortcut conflicts
- [x] Support changing shortcut directly inside the shortcut conflict
window/dialog, no need to nav to the settings page.
- [x] Redesign the `ShortcutConflictDialogContentControl` to align with
the spec
- [x] Add navigating and changing hotkey auctionability to the
`ShortcutConflictDialogContentControl`
- [x] Add telemetry. Impemented in [another
PR](https://github.com/shuaiyuanxx/PowerToys/pull/47)

## Shortcut Conflict Support Modules

![image](https://github.com/user-attachments/assets/3915174e-d1e7-4f86-8835-2a1bafcc85c9)

<details>
<summary>Demo videos</summary>


https://github.com/user-attachments/assets/476d992c-c6ca-4bcd-a3f2-b26cc612d1b9


https://github.com/user-attachments/assets/1c1a2537-de54-4db2-bdbf-6f1908ff1ce7


https://github.com/user-attachments/assets/9c992254-fc2b-402c-beec-20fceef25e6b


https://github.com/user-attachments/assets/d66abc1c-b8bf-45f8-a552-ec989dab310f
</details>

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
Manually validation performed.

---------

Signed-off-by: Shawn Yuan <shuaiyuan@microsoft.com>
Signed-off-by: Shuai Yuan <shuai.yuan.zju@gmail.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
This commit is contained in:
Shawn Yuan
2025-08-20 09:31:52 +08:00
committed by GitHub
parent ce4d8dc11e
commit 75526b9580
104 changed files with 4578 additions and 366 deletions

View File

@@ -3,6 +3,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls"
xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:Name="LayoutRoot"
@@ -39,6 +40,7 @@
Content="{Binding}"
CornerRadius="{StaticResource ControlCornerRadius}"
FontWeight="SemiBold"
IsInvalid="{Binding ElementName=LayoutRoot, Path=HasConflict}"
IsTabStop="False"
Style="{StaticResource AccentKeyVisualStyle}" />
</DataTemplate>

View File

@@ -3,9 +3,15 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using CommunityToolkit.WinUI;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts;
using Microsoft.PowerToys.Settings.UI.Services;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
@@ -33,8 +39,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
public static readonly DependencyProperty IsActiveProperty = DependencyProperty.Register("Enabled", typeof(bool), typeof(ShortcutControl), null);
public static readonly DependencyProperty HotkeySettingsProperty = DependencyProperty.Register("HotkeySettings", typeof(HotkeySettings), typeof(ShortcutControl), null);
public static readonly DependencyProperty AllowDisableProperty = DependencyProperty.Register("AllowDisable", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false, OnAllowDisableChanged));
public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false, OnHasConflictChanged));
public static readonly DependencyProperty TooltipProperty = DependencyProperty.Register("Tooltip", typeof(string), typeof(ShortcutControl), new PropertyMetadata(null, OnTooltipChanged));
private static ResourceLoader resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader;
@@ -58,6 +65,28 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
description.Text = text;
}
private static void OnHasConflictChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = d as ShortcutControl;
if (control == null)
{
return;
}
control.UpdateKeyVisualStyles();
}
private static void OnTooltipChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = d as ShortcutControl;
if (control == null)
{
return;
}
control.UpdateTooltip();
}
private ShortcutDialogContentControl c = new ShortcutDialogContentControl();
private ContentDialog shortcutDialog;
@@ -67,6 +96,18 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
set => SetValue(AllowDisableProperty, value);
}
public bool HasConflict
{
get => (bool)GetValue(HasConflictProperty);
set => SetValue(HasConflictProperty, value);
}
public string Tooltip
{
get => (string)GetValue(TooltipProperty);
set => SetValue(TooltipProperty, value);
}
public bool Enabled
{
get
@@ -101,14 +142,54 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
{
if (hotkeySettings != value)
{
// Unsubscribe from old settings
if (hotkeySettings != null)
{
hotkeySettings.PropertyChanged -= OnHotkeySettingsPropertyChanged;
}
hotkeySettings = value;
SetValue(HotkeySettingsProperty, value);
// Subscribe to new settings
if (hotkeySettings != null)
{
hotkeySettings.PropertyChanged += OnHotkeySettingsPropertyChanged;
// Update UI based on conflict properties
UpdateConflictStatusFromHotkeySettings();
}
SetKeys();
c.Keys = HotkeySettings.GetKeysList();
c.Keys = HotkeySettings?.GetKeysList();
}
}
}
private void OnHotkeySettingsPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(HotkeySettings.HasConflict) ||
e.PropertyName == nameof(HotkeySettings.ConflictDescription))
{
UpdateConflictStatusFromHotkeySettings();
}
}
private void UpdateConflictStatusFromHotkeySettings()
{
if (hotkeySettings != null)
{
// Update the ShortcutControl's conflict properties from HotkeySettings
HasConflict = hotkeySettings.HasConflict;
Tooltip = hotkeySettings.HasConflict ? hotkeySettings.ConflictDescription : null;
}
else
{
HasConflict = false;
Tooltip = null;
}
}
public ShortcutControl()
{
InitializeComponent();
@@ -136,6 +217,29 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
OnAllowDisableChanged(this, null);
}
private void UpdateKeyVisualStyles()
{
if (PreviewKeysControl?.ItemsSource != null)
{
// Force refresh of the ItemsControl to update KeyVisual styles
var items = PreviewKeysControl.ItemsSource;
PreviewKeysControl.ItemsSource = null;
PreviewKeysControl.ItemsSource = items;
}
}
private void UpdateTooltip()
{
if (!string.IsNullOrEmpty(Tooltip))
{
ToolTipService.SetToolTip(EditButton, Tooltip);
}
else
{
ToolTipService.SetToolTip(EditButton, null);
}
}
private void ShortcutControl_Unloaded(object sender, RoutedEventArgs e)
{
shortcutDialog.PrimaryButtonClick -= ShortcutDialog_PrimaryButtonClick;
@@ -147,6 +251,12 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
App.GetSettingsWindow().Activated -= ShortcutDialog_SettingsWindow_Activated;
}
// Unsubscribe from HotkeySettings property changes
if (hotkeySettings != null)
{
hotkeySettings.PropertyChanged -= OnHotkeySettingsPropertyChanged;
}
// Dispose the HotkeySettingsControlHook object to terminate the hook threads when the textbox is unloaded
hook?.Dispose();
@@ -168,6 +278,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
{
App.GetSettingsWindow().Activated += ShortcutDialog_SettingsWindow_Activated;
}
// Initialize tooltip when loaded
UpdateTooltip();
}
private void KeyEventHandler(int key, bool matchValue, int matchValueCode)
@@ -302,6 +415,8 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
KeyEventHandler(key, true, key);
c.Keys = internalSettings.GetKeysList();
c.ConflictMessage = string.Empty;
c.HasConflict = false;
if (internalSettings.GetKeysList().Count == 0)
{
@@ -336,12 +451,74 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
else
{
EnableKeys();
if (lastValidSettings.IsValid())
{
if (string.Equals(lastValidSettings.ToString(), hotkeySettings.ToString(), StringComparison.OrdinalIgnoreCase))
{
c.HasConflict = hotkeySettings.HasConflict;
c.ConflictMessage = hotkeySettings.ConflictDescription;
}
else
{
// Check for conflicts with the new hotkey settings
CheckForConflicts(lastValidSettings);
}
}
}
}
c.IsWarningAltGr = internalSettings.Ctrl && internalSettings.Alt && !internalSettings.Win && (internalSettings.Code > 0);
}
private void CheckForConflicts(HotkeySettings settings)
{
void UpdateUIForConflict(bool hasConflict, HotkeyConflictResponse hotkeyConflictResponse)
{
DispatcherQueue.TryEnqueue(() =>
{
if (hasConflict)
{
// Build conflict message from all conflicts - only show module names
var conflictingModules = new HashSet<string>();
foreach (var conflict in hotkeyConflictResponse.AllConflicts)
{
if (!string.IsNullOrEmpty(conflict.ModuleName))
{
conflictingModules.Add(conflict.ModuleName);
}
}
if (conflictingModules.Count > 0)
{
var moduleNames = conflictingModules.ToArray();
var conflictMessage = moduleNames.Length == 1
? $"Conflict detected with {moduleNames[0]}"
: $"Conflicts detected with: {string.Join(", ", moduleNames)}";
c.ConflictMessage = conflictMessage;
}
else
{
c.ConflictMessage = "Conflict detected with unknown module";
}
c.HasConflict = true;
}
else
{
c.ConflictMessage = string.Empty;
c.HasConflict = false;
}
});
}
HotkeyConflictHelper.CheckHotkeyConflict(
settings,
ShellPage.SendDefaultIPCMessage,
UpdateUIForConflict);
}
private void EnableKeys()
{
shortcutDialog.IsPrimaryButtonEnabled = true;
@@ -416,6 +593,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
c.Keys = null;
c.Keys = HotkeySettings.GetKeysList();
c.HasConflict = hotkeySettings.HasConflict;
c.ConflictMessage = hotkeySettings.ConflictDescription;
// 92 means the Win key. The logic is: warning should be visible if the shortcut contains Alt AND contains Ctrl AND NOT contains Win.
// Additional key must be present, as this is a valid, previously used shortcut shown at dialog open. Check for presence of non-modifier-key is not necessary therefore
c.IsWarningAltGr = c.Keys.Contains("Ctrl") && c.Keys.Contains("Alt") && !c.Keys.Contains(92);
@@ -434,16 +614,32 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
lastValidSettings = hotkeySettings;
shortcutDialog.Hide();
// Send RequestAllConflicts IPC to update the UI after changed hotkey settings.
GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
}
private void ShortcutDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
if (ComboIsValid(lastValidSettings))
{
HotkeySettings = lastValidSettings with { };
if (c.HasConflict)
{
lastValidSettings = lastValidSettings with { HasConflict = true };
}
else
{
lastValidSettings = lastValidSettings with { HasConflict = false };
}
HotkeySettings = lastValidSettings;
}
SetKeys();
// Send RequestAllConflicts IPC to update the UI after changed hotkey settings.
GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
shortcutDialog.Hide();
}
@@ -520,7 +716,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
private void SetKeys()
{
var keys = HotkeySettings.GetKeysList();
var keys = HotkeySettings?.GetKeysList();
if (keys != null && keys.Count > 0)
{

View File

@@ -63,6 +63,13 @@
IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}"
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}"
Severity="Warning" />
<InfoBar
Title="Hotkey Conflict"
IsClosable="False"
IsOpen="{Binding ElementName=ShortcutContentControl, Path=HasConflict, Mode=OneWay}"
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=HasConflict, Mode=OneWay}"
Message="{Binding ElementName=ShortcutContentControl, Path=ConflictMessage, Mode=OneWay}"
Severity="Warning" />
</Grid>
<tk7controls:MarkdownTextBlock
x:Uid="InvalidShortcutWarningLabel"
@@ -71,4 +78,4 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</StackPanel>
</Grid>
</UserControl>
</UserControl>

View File

@@ -11,6 +11,24 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
{
public sealed partial class ShortcutDialogContentControl : UserControl
{
public static readonly DependencyProperty KeysProperty = DependencyProperty.Register("Keys", typeof(List<object>), typeof(ShortcutDialogContentControl), new PropertyMetadata(default(string)));
public static readonly DependencyProperty IsErrorProperty = DependencyProperty.Register("IsError", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
public static readonly DependencyProperty IsWarningAltGrProperty = DependencyProperty.Register("IsWarningAltGr", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
public static readonly DependencyProperty ConflictMessageProperty = DependencyProperty.Register("ConflictMessage", typeof(string), typeof(ShortcutDialogContentControl), new PropertyMetadata(string.Empty));
public bool HasConflict
{
get => (bool)GetValue(HasConflictProperty);
set => SetValue(HasConflictProperty, value);
}
public string ConflictMessage
{
get => (string)GetValue(ConflictMessageProperty);
set => SetValue(ConflictMessageProperty, value);
}
public ShortcutDialogContentControl()
{
this.InitializeComponent();
@@ -22,22 +40,16 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
set { SetValue(KeysProperty, value); }
}
public static readonly DependencyProperty KeysProperty = DependencyProperty.Register("Keys", typeof(List<object>), typeof(SettingsPageControl), new PropertyMetadata(default(string)));
public bool IsError
{
get => (bool)GetValue(IsErrorProperty);
set => SetValue(IsErrorProperty, value);
}
public static readonly DependencyProperty IsErrorProperty = DependencyProperty.Register("IsError", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
public bool IsWarningAltGr
{
get => (bool)GetValue(IsWarningAltGrProperty);
set => SetValue(IsWarningAltGrProperty, value);
}
public static readonly DependencyProperty IsWarningAltGrProperty = DependencyProperty.Register("IsWarningAltGr", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
}
}

View File

@@ -16,6 +16,7 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ItemsControl
x:Name="ShortcutsControl"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
IsTabStop="False"
@@ -32,14 +33,27 @@
AutomationProperties.AccessibilityView="Raw"
Content="{Binding}"
FontSize="12"
IsTabStop="False" />
IsTabStop="False"
Style="{StaticResource DefaultKeyVisualStyle}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<tk7controls:MarkdownTextBlock
x:Name="LabelControl"
Grid.Column="1"
VerticalAlignment="Center"
Background="Transparent"
Text="{x:Bind Text}" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="LabelPlacementStates">
<VisualState x:Name="LabelAfter" />
<VisualState x:Name="LabelBefore">
<VisualState.Setters>
<Setter Target="LabelControl.(Grid.Column)" Value="0" />
<Setter Target="ShortcutsControl.(Grid.Column)" Value="1" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</UserControl>

View File

@@ -17,7 +17,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
set { SetValue(TextProperty, value); }
}
public static readonly DependencyProperty TextProperty = DependencyProperty.Register("Text", typeof(string), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string)));
public static readonly DependencyProperty TextProperty = DependencyProperty.Register(nameof(Text), typeof(string), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string)));
public List<object> Keys
{
@@ -25,11 +25,40 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
set { SetValue(KeysProperty, value); }
}
public static readonly DependencyProperty KeysProperty = DependencyProperty.Register("Keys", typeof(List<object>), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string)));
public static readonly DependencyProperty KeysProperty = DependencyProperty.Register(nameof(Keys), typeof(List<object>), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string)));
public LabelPlacement LabelPlacement
{
get { return (LabelPlacement)GetValue(LabelPlacementProperty); }
set { SetValue(LabelPlacementProperty, value); }
}
public static readonly DependencyProperty LabelPlacementProperty = DependencyProperty.Register(nameof(LabelPlacement), typeof(LabelPlacement), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(defaultValue: LabelPlacement.After, OnIsLabelPlacementChanged));
public ShortcutWithTextLabelControl()
{
this.InitializeComponent();
}
private static void OnIsLabelPlacementChanged(DependencyObject d, DependencyPropertyChangedEventArgs newValue)
{
if (d is ShortcutWithTextLabelControl labelControl)
{
if (labelControl.LabelPlacement == LabelPlacement.Before)
{
VisualStateManager.GoToState(labelControl, "LabelBefore", true);
}
else
{
VisualStateManager.GoToState(labelControl, "LabelAfter", true);
}
}
}
}
public enum LabelPlacement
{
Before,
After,
}
}