diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index c26d17f63c..5e714e88db 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -205,6 +205,7 @@ comdlg comexp cominterop commandpalette +commoncontrols compmgmt COMPOSITIONFULL CONFIGW @@ -677,6 +678,7 @@ jpnime Jsons jsonval jxr +kbmcontrols keybd KEYBDDATA KEYBDINPUT @@ -1530,6 +1532,7 @@ tlc TPMLEFTALIGN TPMRETURNCMD TNP +Toggleable Toolhelp toolwindow TOPDOWNDIB @@ -2203,6 +2206,7 @@ wft wikimedia wikipedia windowedge +WINDOWSAPPRUNTIME windowsml winexe winforms diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c87bd62b01..0a929b9067 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -33,4 +33,4 @@ These are auto-applied based on file location: ## Detailed Documentation - [Architecture](../doc/devdocs/core/architecture.md) -- [Coding Style](../doc/devdocs/development/style.md) +- [Coding Style](../doc/devdocs/development/style.md) \ No newline at end of file diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index df3a610cf0..3833f59652 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -106,7 +106,12 @@ "PowerToys.SvgThumbnailProvider.dll", "PowerToys.SvgThumbnailProvider.exe", "PowerToys.SvgThumbnailProviderCpp.dll", + "PowerToys.KeyboardManager.dll", + "KeyboardManagerEditor\\PowerToys.KeyboardManagerEditor.exe", + "KeyboardManagerEditorUI\\PowerToys.KeyboardManagerEditorUI.exe", + "KeyboardManagerEngine\\PowerToys.KeyboardManagerEngine.exe", + "PowerToys.KeyboardManagerEditorLibraryWrapper.dll", "WinUI3Apps\\PowerToys.HostsModuleInterface.dll", "WinUI3Apps\\PowerToys.HostsUILib.dll", "WinUI3Apps\\PowerToys.Hosts.dll", diff --git a/Directory.Build.props b/Directory.Build.props index a2e19ad31d..aa2d3fc600 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -20,6 +20,7 @@ direct false $(Platform) + false true diff --git a/Directory.Packages.props b/Directory.Packages.props index 6ce7261dfa..4c8d382c7b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,7 +26,7 @@ - + diff --git a/PowerToys.slnx b/PowerToys.slnx index 9b95ddac1d..44b02db3fc 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -497,6 +497,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -720,31 +745,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/common/Common.UI.Controls/Controls/KeyVisual/KeyVisual.xaml b/src/common/Common.UI.Controls/Controls/KeyVisual/KeyVisual.xaml index 147c7d782a..6d3547dc33 100644 --- a/src/common/Common.UI.Controls/Controls/KeyVisual/KeyVisual.xaml +++ b/src/common/Common.UI.Controls/Controls/KeyVisual/KeyVisual.xaml @@ -1,11 +1,11 @@ + xmlns:commoncontrols="using:Microsoft.PowerToys.Common.UI.Controls"> - + diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/IconLabelControl.xaml.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/IconLabelControl.xaml.cs new file mode 100644 index 0000000000..4e12beaa44 --- /dev/null +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/IconLabelControl.xaml.cs @@ -0,0 +1,88 @@ +// 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 KeyboardManagerEditorUI.Helpers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace KeyboardManagerEditorUI.Controls +{ + [TemplatePart(Name = TypeIconPart, Type = typeof(FontIcon))] + [TemplatePart(Name = LabelTextPart, Type = typeof(TextBlock))] + public sealed partial class IconLabelControl : Control + { + private const string TypeIconPart = "TypeIcon"; + private const string LabelTextPart = "LabelText"; + + private FontIcon? _typeIcon; + private TextBlock? _labelText; + + public static readonly DependencyProperty ActionTypeProperty = + DependencyProperty.Register( + nameof(ActionType), + typeof(ActionType), + typeof(IconLabelControl), + new PropertyMetadata(ActionType.Text, OnActionTypeChanged)); + + public static readonly DependencyProperty LabelProperty = + DependencyProperty.Register( + nameof(Label), + typeof(string), + typeof(IconLabelControl), + new PropertyMetadata(string.Empty)); + + public ActionType ActionType + { + get => (ActionType)GetValue(ActionTypeProperty); + set => SetValue(ActionTypeProperty, value); + } + + public string Label + { + get => (string)GetValue(LabelProperty); + set => SetValue(LabelProperty, value); + } + + public IconLabelControl() + { + this.DefaultStyleKey = typeof(IconLabelControl); + } + + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + _typeIcon = GetTemplateChild(TypeIconPart) as FontIcon; + _labelText = GetTemplateChild(LabelTextPart) as TextBlock; + + UpdateIcon(); + } + + private static void OnActionTypeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is IconLabelControl control) + { + control.UpdateIcon(); + } + } + + private void UpdateIcon() + { + if (_typeIcon == null) + { + return; + } + + _typeIcon.Glyph = ActionType switch + { + ActionType.Program => "\uECAA", + ActionType.Text => "\uE8D2", + ActionType.Shortcut => "\uEDA7", + ActionType.MouseClick => "\uE962", + ActionType.Url => "\uE774", + _ => "\uE8A5", + }; + } + } +} diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml new file mode 100644 index 0000000000..d3c9ea780d --- /dev/null +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml @@ -0,0 +1,387 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/MainWindow.xaml.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/MainWindow.xaml.cs deleted file mode 100644 index 2fa4779c19..0000000000 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/MainWindow.xaml.cs +++ /dev/null @@ -1,42 +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; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices; -using System.Runtime.InteropServices.WindowsRuntime; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Controls.Primitives; -using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Input; -using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Navigation; -using Windows.Foundation; -using Windows.Foundation.Collections; - -namespace KeyboardManagerEditorUI -{ - /// - /// An empty window that can be used on its own or navigated to within a Frame. - /// - public sealed partial class MainWindow : Window - { - [DllImport("KeyboardManagerEditorLibraryWrapper.dll", CallingConvention = CallingConvention.Cdecl)] - private static extern bool CheckIfRemappingsAreValid(); - - public MainWindow() - { - this.InitializeComponent(); - } - - private void MyButton_Click(object sender, RoutedEventArgs e) - { - // Call the C++ function to check if the current remappings are valid - myButton.Content = CheckIfRemappingsAreValid() ? "Valid" : "Invalid"; - } - } -} diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Package.appxmanifest b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Package.appxmanifest index a85dcbffdb..f131ae118c 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Package.appxmanifest +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Package.appxmanifest @@ -15,9 +15,9 @@ - KeyboardManagerEditorUI + Keyboard Manager haoliuu - Assets\StoreLogo.png + Assets\KeyboardManagerEditor\StoreLogo.png @@ -34,13 +34,13 @@ Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$"> - - + Square150x150Logo="Assets\KeyboardManagerEditor\Square150x150Logo.png" + Square44x44Logo="Assets\KeyboardManagerEditor\Square44x44Logo.png"> + + diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml new file mode 100644 index 0000000000..7fbc97502e --- /dev/null +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml @@ -0,0 +1,548 @@ + + + + + 800 + 800 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml.cs new file mode 100644 index 0000000000..e6b345d1b5 --- /dev/null +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml.cs @@ -0,0 +1,897 @@ +// 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; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using KeyboardManagerEditorUI.Controls; +using KeyboardManagerEditorUI.Helpers; +using KeyboardManagerEditorUI.Interop; +using KeyboardManagerEditorUI.Settings; +using ManagedCommon; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using static KeyboardManagerEditorUI.Interop.ShortcutKeyMapping; + +namespace KeyboardManagerEditorUI.Pages +{ + /// + /// A consolidated page that displays all mappings from Remappings, Text, Programs, and URLs pages. + /// +#pragma warning disable SA1124 // Do not use regions + public sealed partial class MainPage : Page, IDisposable, INotifyPropertyChanged + { + private KeyboardMappingService? _mappingService; + private bool _disposed; + private bool _isEditMode; + private EditingItem? _editingItem; + private string _mappingState = "Empty"; + + public event PropertyChangedEventHandler? PropertyChanged; + + public string MappingState + { + get => _mappingState; + private set + { + if (_mappingState != value) + { + _mappingState = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(MappingState))); + } + } + } + + public ObservableCollection RemappingList { get; } = new(); + + public ObservableCollection TextMappings { get; } = new(); + + public ObservableCollection ProgramShortcuts { get; } = new(); + + public ObservableCollection UrlShortcuts { get; } = new(); + + [DllImport("PowerToys.KeyboardManagerEditorLibraryWrapper.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)] + private static extern void GetKeyDisplayName(int keyCode, [Out] StringBuilder keyName, int maxLength); + + private sealed class EditingItem + { + public enum ItemType + { + Remapping, + TextMapping, + ProgramShortcut, + UrlShortcut, + } + + public ItemType Type { get; set; } + + public object Item { get; set; } = null!; + + public List OriginalTriggerKeys { get; set; } = new(); + + public string? AppName { get; set; } + + public bool IsAllApps { get; set; } = true; + } + + public MainPage() + { + this.InitializeComponent(); + + try + { + _mappingService = new KeyboardMappingService(); + LoadAllMappings(); + } + catch (Exception ex) + { + Logger.LogError("Failed to initialize KeyboardMappingService in MainPage page: " + ex.Message); + } + + Unloaded += All_Unloaded; + } + + private void All_Unloaded(object sender, RoutedEventArgs e) => Dispose(); + + #region Dialog Show Methods + + private async void NewRemappingBtn_Click(object sender, RoutedEventArgs e) + { + _isEditMode = false; + _editingItem = null; + UnifiedMappingControl.Reset(); + RemappingDialog.Title = "New remapping"; + await ShowRemappingDialog(); + } + + private async void RemappingsList_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is not Remapping remapping) + { + return; + } + + _isEditMode = true; + _editingItem = new EditingItem + { + Type = EditingItem.ItemType.Remapping, + Item = remapping, + OriginalTriggerKeys = remapping.Shortcut.ToList(), + AppName = remapping.AppName, + IsAllApps = remapping.IsAllApps, + }; + + UnifiedMappingControl.Reset(); + UnifiedMappingControl.SetTriggerKeys(remapping.Shortcut.ToList()); + UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.KeyOrShortcut); + UnifiedMappingControl.SetActionKeys(remapping.RemappedKeys.ToList()); + UnifiedMappingControl.SetAppSpecific(!remapping.IsAllApps, remapping.AppName); + RemappingDialog.Title = "Edit remapping"; + await ShowRemappingDialog(); + } + + private async void TextMappingsList_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is not TextMapping textMapping) + { + return; + } + + _isEditMode = true; + _editingItem = new EditingItem + { + Type = EditingItem.ItemType.TextMapping, + Item = textMapping, + OriginalTriggerKeys = textMapping.Shortcut.ToList(), + AppName = textMapping.AppName, + IsAllApps = textMapping.IsAllApps, + }; + + UnifiedMappingControl.Reset(); + UnifiedMappingControl.SetTriggerKeys(textMapping.Shortcut.ToList()); + UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.Text); + UnifiedMappingControl.SetTextContent(textMapping.Text); + UnifiedMappingControl.SetAppSpecific(!textMapping.IsAllApps, textMapping.AppName); + RemappingDialog.Title = "Edit remapping"; + await ShowRemappingDialog(); + } + + private async void ProgramShortcutsList_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is not ProgramShortcut programShortcut) + { + return; + } + + _isEditMode = true; + _editingItem = new EditingItem + { + Type = EditingItem.ItemType.ProgramShortcut, + Item = programShortcut, + OriginalTriggerKeys = programShortcut.Shortcut.ToList(), + AppName = programShortcut.AppName, + IsAllApps = programShortcut.IsAllApps, + }; + + UnifiedMappingControl.Reset(); + UnifiedMappingControl.SetTriggerKeys(programShortcut.Shortcut.ToList()); + UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.OpenApp); + UnifiedMappingControl.SetProgramPath(programShortcut.AppToRun); + UnifiedMappingControl.SetProgramArgs(programShortcut.Args); + + if (!string.IsNullOrEmpty(programShortcut.Id) && + SettingsManager.EditorSettings.ShortcutSettingsDictionary.TryGetValue(programShortcut.Id, out var settings)) + { + var mapping = settings.Shortcut; + UnifiedMappingControl.SetStartInDirectory(mapping.StartInDirectory); + UnifiedMappingControl.SetElevationLevel(mapping.Elevation); + UnifiedMappingControl.SetVisibility(mapping.Visibility); + UnifiedMappingControl.SetIfRunningAction(mapping.IfRunningAction); + } + + UnifiedMappingControl.SetAppSpecific(!programShortcut.IsAllApps, programShortcut.AppName); + RemappingDialog.Title = "Edit remapping"; + await ShowRemappingDialog(); + } + + private async void UrlShortcutsList_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is not URLShortcut urlShortcut) + { + return; + } + + _isEditMode = true; + _editingItem = new EditingItem + { + Type = EditingItem.ItemType.UrlShortcut, + Item = urlShortcut, + OriginalTriggerKeys = urlShortcut.Shortcut.ToList(), + AppName = urlShortcut.AppName, + IsAllApps = urlShortcut.IsAllApps, + }; + + UnifiedMappingControl.Reset(); + UnifiedMappingControl.SetTriggerKeys(urlShortcut.Shortcut.ToList()); + UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.OpenUrl); + UnifiedMappingControl.SetUrl(urlShortcut.URL); + UnifiedMappingControl.SetAppSpecific(!urlShortcut.IsAllApps, urlShortcut.AppName); + RemappingDialog.Title = "Edit remapping"; + await ShowRemappingDialog(); + } + + private async System.Threading.Tasks.Task ShowRemappingDialog() + { + RemappingDialog.PrimaryButtonClick += RemappingDialog_PrimaryButtonClick; + UnifiedMappingControl.ValidationStateChanged += UnifiedMappingControl_ValidationStateChanged; + RemappingDialog.IsPrimaryButtonEnabled = UnifiedMappingControl.IsInputComplete(); + + await RemappingDialog.ShowAsync(); + + RemappingDialog.PrimaryButtonClick -= RemappingDialog_PrimaryButtonClick; + UnifiedMappingControl.ValidationStateChanged -= UnifiedMappingControl_ValidationStateChanged; + _isEditMode = false; + _editingItem = null; + KeyboardHookHelper.Instance.CleanupHook(); + } + + private void UnifiedMappingControl_ValidationStateChanged(object? sender, EventArgs e) + { + if (!UnifiedMappingControl.IsInputComplete()) + { + RemappingDialog.IsPrimaryButtonEnabled = false; + return; + } + + if (_mappingService != null) + { + List triggerKeys = UnifiedMappingControl.GetTriggerKeys(); + if (triggerKeys?.Count > 0) + { + ValidationErrorType error = ValidateMapping(UnifiedMappingControl.CurrentActionType, triggerKeys); + if (error != ValidationErrorType.NoError) + { + UnifiedMappingControl.ShowValidationErrorFromType(error); + RemappingDialog.IsPrimaryButtonEnabled = false; + return; + } + } + } + + UnifiedMappingControl.HideValidationMessage(); + RemappingDialog.IsPrimaryButtonEnabled = true; + } + + #endregion + + #region Save Logic + + private void RemappingDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + UnifiedMappingControl.HideValidationMessage(); + + if (_mappingService == null) + { + Logger.LogError("Mapping service is null, cannot save mapping"); + UnifiedMappingControl.ShowValidationError("Error", "Mapping service is not available."); + args.Cancel = true; + return; + } + + try + { + List triggerKeys = UnifiedMappingControl.GetTriggerKeys(); + + if (triggerKeys == null || triggerKeys.Count == 0) + { + UnifiedMappingControl.ShowValidationError("Missing Original Keys", "Please enter at least one original key to create a remapping."); + args.Cancel = true; + return; + } + + ValidationErrorType validationError = ValidateMapping(UnifiedMappingControl.CurrentActionType, triggerKeys); + if (validationError != ValidationErrorType.NoError) + { + UnifiedMappingControl.ShowValidationErrorFromType(validationError); + args.Cancel = true; + return; + } + + if (_isEditMode && _editingItem != null) + { + DeleteExistingMapping(); + } + + bool saved = UnifiedMappingControl.CurrentActionType switch + { + UnifiedMappingControl.ActionType.KeyOrShortcut => SaveKeyOrShortcutMapping(triggerKeys), + UnifiedMappingControl.ActionType.Text => SaveTextMapping(triggerKeys), + UnifiedMappingControl.ActionType.OpenUrl => SaveUrlMapping(triggerKeys), + UnifiedMappingControl.ActionType.OpenApp => SaveProgramMapping(triggerKeys), + UnifiedMappingControl.ActionType.MouseClick => throw new NotImplementedException("Mouse click remapping is not yet supported."), + _ => false, + }; + + if (saved) + { + LoadAllMappings(); + } + else + { + UnifiedMappingControl.ShowValidationError("Save Failed", "Failed to save the remapping. Please try again."); + args.Cancel = true; + } + } + catch (NotImplementedException ex) + { + UnifiedMappingControl.ShowValidationError("Not Implemented", ex.Message); + args.Cancel = true; + } + catch (Exception ex) + { + Logger.LogError("Error saving mapping: " + ex.Message); + UnifiedMappingControl.ShowValidationError("Error", "An error occurred while saving: " + ex.Message); + args.Cancel = true; + } + } + + private ValidationErrorType ValidateMapping(UnifiedMappingControl.ActionType actionType, List triggerKeys) + { + bool isAppSpecific = UnifiedMappingControl.GetIsAppSpecific(); + string appName = UnifiedMappingControl.GetAppName(); + Remapping? editingRemapping = _isEditMode && _editingItem?.Item is Remapping r ? r : null; + + return actionType switch + { + UnifiedMappingControl.ActionType.KeyOrShortcut => ValidationHelper.ValidateKeyMapping( + triggerKeys, UnifiedMappingControl.GetActionKeys(), isAppSpecific, appName, _mappingService!, _isEditMode, editingRemapping), + UnifiedMappingControl.ActionType.Text => ValidationHelper.ValidateTextMapping( + triggerKeys, UnifiedMappingControl.GetTextContent(), isAppSpecific, appName, _mappingService!, _isEditMode), + UnifiedMappingControl.ActionType.OpenUrl => ValidationHelper.ValidateUrlMapping( + triggerKeys, UnifiedMappingControl.GetUrl(), isAppSpecific, appName, _mappingService!, _isEditMode), + UnifiedMappingControl.ActionType.OpenApp => ValidationHelper.ValidateAppMapping( + triggerKeys, UnifiedMappingControl.GetProgramPath(), isAppSpecific, appName, _mappingService!, _isEditMode), + _ => ValidationErrorType.NoError, + }; + } + + private void DeleteExistingMapping() + { + if (_editingItem == null || _mappingService == null) + { + return; + } + + try + { + switch (_editingItem.Type) + { + case EditingItem.ItemType.Remapping when _editingItem.Item is Remapping remapping: + RemappingHelper.DeleteRemapping(_mappingService, remapping); + break; + + default: + if (_editingItem.Item is IToggleableShortcut shortcut) + { + DeleteShortcutMapping(_editingItem.OriginalTriggerKeys, _editingItem.AppName ?? string.Empty); + if (!string.IsNullOrEmpty(shortcut.Id)) + { + SettingsManager.RemoveShortcutKeyMappingFromSettings(shortcut.Id); + } + } + + break; + } + } + catch (Exception ex) + { + Logger.LogError("Error deleting existing mapping: " + ex.Message); + } + } + + private void DeleteShortcutMapping(List originalKeys, string targetApp = "") + { + bool deleted = originalKeys.Count == 1 + ? DeleteSingleKeyToTextMapping(originalKeys[0]) + : DeleteMultiKeyMapping(originalKeys, targetApp); + + if (deleted) + { + _mappingService!.SaveSettings(); + } + } + + private bool DeleteMultiKeyMapping(List originalKeys, string targetApp = "") + { + string originalKeysString = string.Join(";", originalKeys.Select(k => _mappingService!.GetKeyCodeFromName(k).ToString(CultureInfo.InvariantCulture))); + return _mappingService!.DeleteShortcutMapping(originalKeysString, targetApp); + } + + private bool SaveKeyOrShortcutMapping(List triggerKeys) + { + List actionKeys = UnifiedMappingControl.GetActionKeys(); + if (actionKeys == null || actionKeys.Count == 0) + { + return false; + } + + return RemappingHelper.SaveMapping( + _mappingService!, + triggerKeys, + actionKeys, + UnifiedMappingControl.GetIsAppSpecific(), + UnifiedMappingControl.GetAppName()); + } + + private bool SaveTextMapping(List triggerKeys) + { + string textContent = UnifiedMappingControl.GetTextContent(); + bool isAppSpecific = UnifiedMappingControl.GetIsAppSpecific(); + string appName = UnifiedMappingControl.GetAppName(); + + if (string.IsNullOrEmpty(textContent)) + { + return false; + } + + return triggerKeys.Count == 1 + ? SaveSingleKeyToTextMapping(triggerKeys[0], textContent, isAppSpecific, appName) + : SaveShortcutToTextMapping(triggerKeys, textContent, isAppSpecific, appName); + } + + private bool SaveSingleKeyToTextMapping(string keyName, string textContent, bool isAppSpecific, string appName) + { + int originalKey = _mappingService!.GetKeyCodeFromName(keyName); + if (originalKey == 0) + { + return false; + } + + var shortcutKeyMapping = new ShortcutKeyMapping + { + OperationType = ShortcutOperationType.RemapText, + OriginalKeys = originalKey.ToString(CultureInfo.InvariantCulture), + TargetKeys = textContent, + TargetText = textContent, + TargetApp = isAppSpecific ? appName : string.Empty, + }; + + bool saved = _mappingService.AddSingleKeyToTextMapping(originalKey, textContent); + if (saved) + { + _mappingService.SaveSettings(); + SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping); + } + + return saved; + } + + private bool SaveShortcutToTextMapping(List triggerKeys, string textContent, bool isAppSpecific, string appName) + { + string originalKeysString = string.Join(";", triggerKeys.Select(k => _mappingService!.GetKeyCodeFromName(k).ToString(CultureInfo.InvariantCulture))); + + var shortcutKeyMapping = new ShortcutKeyMapping + { + OperationType = ShortcutOperationType.RemapText, + OriginalKeys = originalKeysString, + TargetKeys = textContent, + TargetText = textContent, + TargetApp = isAppSpecific ? appName : string.Empty, + }; + + bool saved = isAppSpecific && !string.IsNullOrEmpty(appName) + ? _mappingService!.AddShortcutMapping(originalKeysString, textContent, appName, ShortcutOperationType.RemapText) + : _mappingService!.AddShortcutMapping(originalKeysString, textContent, operationType: ShortcutOperationType.RemapText); + + if (saved) + { + _mappingService.SaveSettings(); + SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping); + } + + return saved; + } + + private bool SaveUrlMapping(List triggerKeys) + { + string url = UnifiedMappingControl.GetUrl(); + if (string.IsNullOrEmpty(url)) + { + return false; + } + + string originalKeysString = string.Join(";", triggerKeys.Select(k => _mappingService!.GetKeyCodeFromName(k).ToString(CultureInfo.InvariantCulture))); + + var shortcutKeyMapping = new ShortcutKeyMapping + { + OperationType = ShortcutOperationType.OpenUri, + OriginalKeys = originalKeysString, + TargetKeys = originalKeysString, + UriToOpen = url, + TargetApp = UnifiedMappingControl.GetIsAppSpecific() ? UnifiedMappingControl.GetAppName() : string.Empty, + }; + + bool saved = _mappingService!.AddShortcutMapping(shortcutKeyMapping); + if (saved) + { + _mappingService.SaveSettings(); + SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping); + } + + return saved; + } + + private bool SaveProgramMapping(List triggerKeys) + { + string programPath = UnifiedMappingControl.GetProgramPath(); + if (string.IsNullOrEmpty(programPath)) + { + return false; + } + + string originalKeysString = string.Join(";", triggerKeys.Select(k => _mappingService!.GetKeyCodeFromName(k).ToString(CultureInfo.InvariantCulture))); + + var shortcutKeyMapping = new ShortcutKeyMapping + { + OperationType = ShortcutOperationType.RunProgram, + OriginalKeys = originalKeysString, + TargetKeys = originalKeysString, + ProgramPath = programPath, + ProgramArgs = UnifiedMappingControl.GetProgramArgs(), + StartInDirectory = UnifiedMappingControl.GetStartInDirectory(), + IfRunningAction = UnifiedMappingControl.GetIfRunningAction(), + Visibility = UnifiedMappingControl.GetVisibility(), + Elevation = UnifiedMappingControl.GetElevationLevel(), + TargetApp = UnifiedMappingControl.GetIsAppSpecific() ? UnifiedMappingControl.GetAppName() : string.Empty, + }; + + bool saved = _mappingService!.AddShortcutMapping(shortcutKeyMapping); + if (saved) + { + _mappingService.SaveSettings(); + SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping); + } + + return saved; + } + + #endregion + + #region Delete Handlers + + private async void DeleteMapping_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuFlyoutItem menuFlyoutItem || _mappingService == null) + { + return; + } + + if (await DeleteConfirmationDialog.ShowAsync() != ContentDialogResult.Primary) + { + return; + } + + try + { + switch (menuFlyoutItem.Tag) + { + case Remapping remapping: + HandleRemappingDelete(remapping); + UpdateHasAnyMappings(); + break; + + case IToggleableShortcut shortcut: + HandleShortcutDelete(shortcut); + LoadAllMappings(); + break; + } + } + catch (Exception ex) + { + Logger.LogError("Error deleting mapping: " + ex.Message); + } + } + + private void HandleRemappingDelete(Remapping remapping) + { + if (!remapping.IsActive) + { + SettingsManager.RemoveShortcutKeyMappingFromSettings(remapping.Id); + LoadRemappings(); + } + else if (RemappingHelper.DeleteRemapping(_mappingService!, remapping)) + { + LoadRemappings(); + } + else + { + Logger.LogWarning($"Failed to delete remapping: {string.Join("+", remapping.Shortcut)}"); + } + } + + private void HandleShortcutDelete(IToggleableShortcut shortcut) + { + bool deleted = shortcut.Shortcut.Count == 1 + ? DeleteSingleKeyToTextMapping(shortcut.Shortcut[0]) // Remapping has its own handler, single key will always be text mapping + : DeleteMultiKeyShortcut(shortcut); + + if (deleted) + { + _mappingService!.SaveSettings(); + } + + SettingsManager.RemoveShortcutKeyMappingFromSettings(shortcut.Id); + } + + private bool DeleteMultiKeyShortcut(IToggleableShortcut shortcut) + { + string originalKeys = string.Join(";", shortcut.Shortcut.Select(k => _mappingService!.GetKeyCodeFromName(k))); + return _mappingService!.DeleteShortcutMapping(originalKeys, shortcut.AppName); + } + + #endregion + + #region Toggle Switch Handlers + + private void ToggleSwitch_Toggled(object sender, RoutedEventArgs e) + { + if (sender is not ToggleSwitch toggleSwitch || toggleSwitch.DataContext is not IToggleableShortcut shortcut || _mappingService == null) + { + return; + } + + try + { + if (toggleSwitch.IsOn) + { + EnableShortcut(shortcut); + } + else + { + DisableShortcut(shortcut); + } + } + catch (Exception ex) + { + Logger.LogError("Error toggling shortcut active state: " + ex.Message); + } + } + + private void EnableShortcut(IToggleableShortcut shortcut) + { + if (shortcut is Remapping remapping) + { + RemappingHelper.SaveMapping(_mappingService!, remapping.Shortcut, remapping.RemappedKeys, !remapping.IsAllApps, remapping.AppName, false); + shortcut.IsActive = true; + SettingsManager.ToggleShortcutKeyMappingActiveState(shortcut.Id); + return; + } + + ShortcutKeyMapping shortcutKeyMapping = SettingsManager.EditorSettings.ShortcutSettingsDictionary[shortcut.Id].Shortcut; + bool saved = shortcut.Shortcut.Count == 1 + ? _mappingService!.AddSingleKeyToTextMapping(_mappingService.GetKeyCodeFromName(shortcut.Shortcut[0]), shortcutKeyMapping.TargetText) + : shortcutKeyMapping.OperationType == ShortcutOperationType.RemapText + ? _mappingService!.AddShortcutMapping(shortcutKeyMapping.OriginalKeys, shortcutKeyMapping.TargetText, operationType: ShortcutOperationType.RemapText) + : _mappingService!.AddShortcutMapping(shortcutKeyMapping); + + if (saved) + { + shortcut.IsActive = true; + SettingsManager.ToggleShortcutKeyMappingActiveState(shortcut.Id); + _mappingService.SaveSettings(); + } + } + + private void DisableShortcut(IToggleableShortcut shortcut) + { + if (shortcut is Remapping remapping) + { + shortcut.IsActive = false; + RemappingHelper.DeleteRemapping(_mappingService!, remapping, false); + SettingsManager.ToggleShortcutKeyMappingActiveState(shortcut.Id); + return; + } + + bool deleted = shortcut.Shortcut.Count == 1 + ? DeleteSingleKeyToTextMapping(shortcut.Shortcut[0]) + : DeleteMultiKeyMapping(shortcut.Shortcut, shortcut.AppName); + + if (deleted) + { + shortcut.IsActive = false; + SettingsManager.ToggleShortcutKeyMappingActiveState(shortcut.Id); + _mappingService!.SaveSettings(); + } + } + + private bool DeleteSingleKeyToTextMapping(string keyName) + { + int originalKey = _mappingService!.GetKeyCodeFromName(keyName); + return originalKey != 0 && _mappingService.DeleteSingleKeyToTextMapping(originalKey); + } + + #endregion + + #region Load Methods + + private void LoadAllMappings() + { + LoadRemappings(); + LoadTextMappings(); + LoadProgramShortcuts(); + LoadUrlShortcuts(); + UpdateHasAnyMappings(); + } + + private void UpdateHasAnyMappings() + { + bool hasAny = RemappingList.Count > 0 || TextMappings.Count > 0 || ProgramShortcuts.Count > 0 || UrlShortcuts.Count > 0; + MappingState = hasAny ? "HasMappings" : "Empty"; + } + + private void LoadRemappings() + { + SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.RemapShortcut, out var remapShortcutIds); + + if (_mappingService == null || remapShortcutIds == null) + { + return; + } + + RemappingList.Clear(); + + foreach (var id in remapShortcutIds) + { + ShortcutSettings shortcutSettings = SettingsManager.EditorSettings.ShortcutSettingsDictionary[id]; + ShortcutKeyMapping mapping = shortcutSettings.Shortcut; + var originalKeyNames = ParseKeyCodes(mapping.OriginalKeys); + var remappedKeyNames = ParseKeyCodes(mapping.TargetKeys); + + RemappingList.Add(new Remapping + { + Shortcut = originalKeyNames, + RemappedKeys = remappedKeyNames, + IsAllApps = string.IsNullOrEmpty(mapping.TargetApp), + AppName = mapping.TargetApp ?? string.Empty, + Id = shortcutSettings.Id, + IsActive = shortcutSettings.IsActive, + }); + } + } + + private void LoadTextMappings() + { + SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.RemapText, out var remapShortcutIds); + + if (_mappingService == null || remapShortcutIds == null) + { + return; + } + + TextMappings.Clear(); + + foreach (var id in remapShortcutIds) + { + ShortcutSettings shortcutSettings = SettingsManager.EditorSettings.ShortcutSettingsDictionary[id]; + ShortcutKeyMapping mapping = shortcutSettings.Shortcut; + var originalKeyNames = ParseKeyCodes(mapping.OriginalKeys); + + TextMappings.Add(new TextMapping + { + Shortcut = originalKeyNames, + Text = mapping.TargetText, + IsAllApps = string.IsNullOrEmpty(mapping.TargetApp), + AppName = mapping.TargetApp ?? string.Empty, + Id = shortcutSettings.Id, + IsActive = shortcutSettings.IsActive, + }); + } + } + + private void LoadProgramShortcuts() + { + SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.RunProgram, out var remapShortcutIds); + + if (_mappingService == null || remapShortcutIds == null) + { + return; + } + + ProgramShortcuts.Clear(); + + foreach (var id in remapShortcutIds) + { + ShortcutSettings shortcutSettings = SettingsManager.EditorSettings.ShortcutSettingsDictionary[id]; + ShortcutKeyMapping mapping = shortcutSettings.Shortcut; + var originalKeyNames = ParseKeyCodes(mapping.OriginalKeys); + + ProgramShortcuts.Add(new ProgramShortcut + { + Shortcut = originalKeyNames, + AppToRun = mapping.ProgramPath, + Args = mapping.ProgramArgs, + IsActive = shortcutSettings.IsActive, + Id = shortcutSettings.Id, + IsAllApps = string.IsNullOrEmpty(mapping.TargetApp), + AppName = mapping.TargetApp ?? string.Empty, + StartInDirectory = mapping.StartInDirectory, + Elevation = mapping.Elevation.ToString(), + IfRunningAction = mapping.IfRunningAction.ToString(), + Visibility = mapping.Visibility.ToString(), + }); + } + } + + private void LoadUrlShortcuts() + { + SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.OpenUri, out var remapShortcutIds); + + if (_mappingService == null || remapShortcutIds == null) + { + return; + } + + UrlShortcuts.Clear(); + + foreach (var id in remapShortcutIds) + { + ShortcutSettings shortcutSettings = SettingsManager.EditorSettings.ShortcutSettingsDictionary[id]; + ShortcutKeyMapping mapping = shortcutSettings.Shortcut; + var originalKeyNames = ParseKeyCodes(mapping.OriginalKeys); + + UrlShortcuts.Add(new URLShortcut + { + Shortcut = originalKeyNames, + URL = mapping.UriToOpen, + Id = shortcutSettings.Id, + IsActive = shortcutSettings.IsActive, + IsAllApps = string.IsNullOrEmpty(mapping.TargetApp), + AppName = mapping.TargetApp ?? string.Empty, + }); + } + } + + private List ParseKeyCodes(string keyCodesString) + { + return keyCodesString.Split(';') + .Where(keyCode => int.TryParse(keyCode, out int code)) + .Select(keyCode => _mappingService!.GetKeyDisplayName(int.Parse(keyCode, CultureInfo.InvariantCulture))) + .ToList(); + } + + #endregion + + #region IDisposable + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _mappingService?.Dispose(); + _mappingService = null; + } + + _disposed = true; + } + + #endregion + } +} +#pragma warning restore SA1124 // Do not use regions diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Settings/EditorSettings.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Settings/EditorSettings.cs new file mode 100644 index 0000000000..5d106bffbc --- /dev/null +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Settings/EditorSettings.cs @@ -0,0 +1,20 @@ +// 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.Collections.Generic; +using KeyboardManagerEditorUI.Interop; + +namespace KeyboardManagerEditorUI.Settings +{ + public class EditorSettings + { + public Dictionary ShortcutSettingsDictionary { get; set; } = new Dictionary(); + + public Dictionary> ProfileDictionary { get; set; } = new Dictionary>(); + + public Dictionary> ShortcutsByOperationType { get; set; } = new Dictionary>(); + + public string ActiveProfile { get; set; } = string.Empty; + } +} diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Settings/SettingsManager.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Settings/SettingsManager.cs new file mode 100644 index 0000000000..017135ec18 --- /dev/null +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Settings/SettingsManager.cs @@ -0,0 +1,275 @@ +// 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; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using KeyboardManagerEditorUI.Interop; + +namespace KeyboardManagerEditorUI.Settings +{ + internal static class SettingsManager + { + private static readonly string _settingsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", + "PowerToys", + "Keyboard Manager"); + + private static readonly string _settingsFilePath = Path.Combine(_settingsDirectory, "editorSettings.json"); + + private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + + private static readonly KeyboardMappingService _mappingService = new KeyboardMappingService(); + + public static EditorSettings EditorSettings { get; set; } + + static SettingsManager() + { + EditorSettings = LoadSettings(); + } + + public static EditorSettings LoadSettings() + { + try + { + if (!File.Exists(_settingsFilePath)) + { + EditorSettings createdSettings = CreateSettingsFromKeyboardManagerService(); + WriteSettings(createdSettings); + return createdSettings; + } + + string json = File.ReadAllText(_settingsFilePath); + return JsonSerializer.Deserialize(json, _jsonOptions) ?? new EditorSettings(); + } + catch (Exception) + { + return new EditorSettings(); + } + } + + public static bool WriteSettings(EditorSettings editorSettings) + { + try + { + Directory.CreateDirectory(_settingsDirectory); + string json = JsonSerializer.Serialize(editorSettings, _jsonOptions); + File.WriteAllText(_settingsFilePath, json); + return true; + } + catch (Exception) + { + return false; + } + } + + public static bool WriteSettings() => WriteSettings(EditorSettings); + + private static EditorSettings CreateSettingsFromKeyboardManagerService() + { + EditorSettings settings = new EditorSettings(); + + // Process all shortcut mappings (RunProgram, OpenUri, RemapShortcut, RemapText) + foreach (ShortcutKeyMapping mapping in _mappingService.GetShortcutMappings()) + { + AddShortcutMapping(settings, mapping); + } + + // Process single key to key mappings + foreach (var mapping in _mappingService.GetSingleKeyMappings()) + { + var shortcutMapping = new ShortcutKeyMapping + { + OperationType = ShortcutOperationType.RemapShortcut, + OriginalKeys = mapping.OriginalKey.ToString(CultureInfo.InvariantCulture), + TargetKeys = mapping.TargetKey, + }; + AddShortcutMapping(settings, shortcutMapping); + } + + // Process single key to text mappings + foreach (var mapping in _mappingService.GetKeyToTextMappings()) + { + var shortcutMapping = new ShortcutKeyMapping + { + OperationType = ShortcutOperationType.RemapText, + OriginalKeys = mapping.OriginalKey.ToString(CultureInfo.InvariantCulture), + TargetKeys = mapping.TargetText, + TargetText = mapping.TargetText, + }; + AddShortcutMapping(settings, shortcutMapping); + } + + return settings; + } + + public static void CorrelateServiceAndEditorMappings() + { + bool shortcutSettingsChanged = false; + + // Process all shortcut mappings + foreach (ShortcutKeyMapping mapping in _mappingService.GetShortcutMappings()) + { + if (!EditorSettings.ShortcutSettingsDictionary.Values.Any(s => s.Shortcut.OriginalKeys == mapping.OriginalKeys)) + { + AddShortcutMapping(EditorSettings, mapping); + shortcutSettingsChanged = true; + } + } + + // Process single key to key mappings + foreach (var mapping in _mappingService.GetSingleKeyMappings()) + { + var shortcutMapping = new ShortcutKeyMapping + { + OperationType = ShortcutOperationType.RemapShortcut, + OriginalKeys = mapping.OriginalKey.ToString(CultureInfo.InvariantCulture), + TargetKeys = mapping.TargetKey, + }; + + if (!MappingExists(shortcutMapping)) + { + AddShortcutMapping(EditorSettings, shortcutMapping); + shortcutSettingsChanged = true; + } + } + + // Process single key to text mappings + foreach (var mapping in _mappingService.GetKeyToTextMappings()) + { + var shortcutMapping = new ShortcutKeyMapping + { + OperationType = ShortcutOperationType.RemapText, + OriginalKeys = mapping.OriginalKey.ToString(CultureInfo.InvariantCulture), + TargetKeys = mapping.TargetText, + TargetText = mapping.TargetText, + }; + + if (!EditorSettings.ShortcutSettingsDictionary.Values.Any(s => s.Shortcut.OriginalKeys == shortcutMapping.OriginalKeys)) + { + AddShortcutMapping(EditorSettings, shortcutMapping); + shortcutSettingsChanged = true; + } + } + + // Mark inactive mappings + var singleKeyMappings = _mappingService.GetSingleKeyMappings(); + var keyToTextMappings = _mappingService.GetKeyToTextMappings(); + var shortcutKeyMappings = _mappingService.GetShortcutMappings(); + + foreach (ShortcutSettings shortcutSettings in EditorSettings.ShortcutSettingsDictionary.Values.ToList()) + { + bool foundInService = IsMappingActiveInService( + shortcutSettings, + keyToTextMappings, + singleKeyMappings, + shortcutKeyMappings); + + if (!foundInService) + { + shortcutSettingsChanged = true; + shortcutSettings.IsActive = false; + } + } + + if (shortcutSettingsChanged) + { + WriteSettings(); + } + } + + public static void AddShortcutKeyMappingToSettings(ShortcutKeyMapping shortcutKeyMapping) + { + AddShortcutMapping(EditorSettings, shortcutKeyMapping); + WriteSettings(); + } + + public static void RemoveShortcutKeyMappingFromSettings(string guid) + { + ShortcutOperationType operationType = EditorSettings.ShortcutSettingsDictionary[guid].Shortcut.OperationType; + EditorSettings.ShortcutSettingsDictionary.Remove(guid); + + if (EditorSettings.ShortcutsByOperationType.TryGetValue(operationType, out var value)) + { + value.Remove(guid); + } + + WriteSettings(); + } + + public static void ToggleShortcutKeyMappingActiveState(string guid) + { + if (EditorSettings.ShortcutSettingsDictionary.TryGetValue(guid, out ShortcutSettings? shortcutSettings)) + { + shortcutSettings.IsActive = !shortcutSettings.IsActive; + WriteSettings(); + } + } + + private static void AddShortcutMapping(EditorSettings settings, ShortcutKeyMapping mapping) + { + string guid = Guid.NewGuid().ToString(); + var shortcutSettings = new ShortcutSettings + { + Id = guid, + Shortcut = mapping, + IsActive = true, + }; + + settings.ShortcutSettingsDictionary[guid] = shortcutSettings; + + if (!settings.ShortcutsByOperationType.TryGetValue(mapping.OperationType, out System.Collections.Generic.List? value)) + { + value = new System.Collections.Generic.List(); + settings.ShortcutsByOperationType[mapping.OperationType] = value; + } + + value.Add(guid); + } + + private static bool MappingExists(ShortcutKeyMapping mapping) + { + return EditorSettings.ShortcutSettingsDictionary.Values.Any(s => + s.Shortcut.OperationType == mapping.OperationType && + s.Shortcut.OriginalKeys == mapping.OriginalKeys && + s.Shortcut.TargetKeys == mapping.TargetKeys); + } + + private static bool IsMappingActiveInService( + ShortcutSettings shortcutSettings, + List keyToTextMappings, + List singleKeyMappings, + List shortcutKeyMappings) + { + if (string.IsNullOrEmpty(shortcutSettings.Shortcut.OriginalKeys)) + { + return false; + } + + bool isSingleKey = shortcutSettings.Shortcut.OriginalKeys.Split(';').Length == 1; + + if (isSingleKey && int.TryParse(shortcutSettings.Shortcut.OriginalKeys, out int keyCode)) + { + if (shortcutSettings.Shortcut.OperationType == ShortcutOperationType.RemapText) + { + return keyToTextMappings.Any(m => + m.OriginalKey == keyCode && + m.TargetText == shortcutSettings.Shortcut.TargetText); + } + else if (shortcutSettings.Shortcut.OperationType == ShortcutOperationType.RemapShortcut) + { + return singleKeyMappings.Any(m => + m.OriginalKey == keyCode && + m.TargetKey == shortcutSettings.Shortcut.TargetKeys); + } + } + + return shortcutKeyMappings.Any(m => m.OriginalKeys == shortcutSettings.Shortcut.OriginalKeys); + } + } +} diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Settings/ShortcutSettings.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Settings/ShortcutSettings.cs new file mode 100644 index 0000000000..f158c3a839 --- /dev/null +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Settings/ShortcutSettings.cs @@ -0,0 +1,24 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using KeyboardManagerEditorUI.Interop; + +namespace KeyboardManagerEditorUI.Settings +{ + public class ShortcutSettings + { + public string Id { get; set; } = string.Empty; + + public ShortcutKeyMapping Shortcut { get; set; } = new ShortcutKeyMapping(); + + public List Profiles { get; set; } = new List(); + + public bool IsActive { get; set; } = true; + } +} diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Strings/en-US/Resources.resw b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Strings/en-US/Resources.resw new file mode 100644 index 0000000000..00a32af4d5 --- /dev/null +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Strings/en-US/Resources.resw @@ -0,0 +1,295 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Add new remapping + + + Nothing mapped yet + + + Create a key or shortcut remapping to customize how your keyboard works. + + + Keys and shortcuts + + + Text + + + Programs + + + Urls + + + maps to + + + inserts + + + opens + + + in + + + Delete + + + Arguments: + + + Start in: + + + Elevation: + + + If running: + + + Window: + + + Add new remapping + + + Save + + + Cancel + + + Are you sure? + + + You are about to delete this remapping. + + + Delete + + + Cancel + + + Trigger + + + Action + + + Key or shortcut + + + Key or shortcut + + + Mouse button + + + Mouse button + + + Allow chords + + + Exact match + + + Left + + + Center + + + Right + + + Button 1 + + + Button 2 + + + Only apply to a specific app + + + App name + + + Enter app name (e.g., notepad.exe) + + + Remap to key or shortcut + + + Remap to key or shortcut + + + Insert text + + + Insert text + + + Open URL + + + Open URL + + + Open app + + + Open app + + + Remap to mouse click + + + Remap to mouse click + + + Text to type + + + Enter the text to type when triggered + + + URL to open + + + https://example.com + + + Program path + + + C:\Program Files\... + + + Select program path + + + Arguments (optional) + + + --arg1 value1 + + + Start in directory (optional) + + + C:\Users\... + + + Select start directory + + + Run as + + + Normal + + + Elevated + + + Different user + + + If already running + + + Show window + + + Start another + + + Do nothing + + + Close + + + End task + + + Window visibility + + + Normal + + + Hidden + + + Minimized + + + Maximized + + + Mouse click action - coming soon + + \ No newline at end of file diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Styles/Button.xaml b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Styles/Button.xaml new file mode 100644 index 0000000000..ca66de1435 --- /dev/null +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Styles/Button.xaml @@ -0,0 +1,759 @@ + + + + + + + diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Styles/Colors.xaml b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Styles/Colors.xaml new file mode 100644 index 0000000000..cb4c37b56b --- /dev/null +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Styles/Colors.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.cpp b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.cpp index 179103d41b..0e8529396b 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.cpp @@ -538,7 +538,10 @@ namespace KeyboardEventHandlers // Release original shortcut state (release in reverse order of shortcut to be accurate) Helpers::SetModifierKeyEvents(it->first, it->second.modifierKeysInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); - Helpers::SetTextKeyEvents(keyEventList, remapping); + // Send modifier release events first, then paste text via clipboard + ii.SendVirtualInput(keyEventList); + keyEventList.clear(); + Helpers::SendTextViaClipboard(remapping); } it->second.isShortcutInvoked = true; @@ -719,7 +722,8 @@ namespace KeyboardEventHandlers else if (remapToText) { auto& remapping = std::get(it->second.targetShortcut); - Helpers::SetTextKeyEvents(keyEventList, remapping); + Helpers::SendTextViaClipboard(remapping); + return 1; } ii.SendVirtualInput(keyEventList); @@ -1793,9 +1797,7 @@ namespace KeyboardEventHandlers return 0; } - std::vector keyEventList; - Helpers::SetTextKeyEvents(keyEventList, *remapping); - ii.SendVirtualInput(keyEventList); + Helpers::SendTextViaClipboard(*remapping); return 1; } diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineTest/SetKeyEventTests.cpp b/src/modules/keyboardmanager/KeyboardManagerEngineTest/SetKeyEventTests.cpp index 75be7288de..c80d4a3cf9 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineTest/SetKeyEventTests.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEngineTest/SetKeyEventTests.cpp @@ -60,4 +60,85 @@ namespace RemappingLogicTests Assert::AreEqual(0, inputs[1].ki.wScan); } }; + + // Tests for the SetTextKeyEvents method + TEST_CLASS (SetTextKeyEventsTests) + { + public: + // Test that plain ASCII text produces KEYEVENTF_UNICODE events with correct scan codes + TEST_METHOD (SetTextKeyEvents_ShouldUseUnicodeFlag_WhenTextIsPlainAscii) + { + std::vector inputs; + std::wstring text = L"abc"; + + Helpers::SetTextKeyEvents(inputs, text); + + // 3 characters × 2 events (down+up) = 6 events + Assert::AreEqual(6, inputs.size()); + for (size_t i = 0; i < inputs.size(); i++) + { + Assert::AreEqual(true, bool(inputs[i].ki.dwFlags & KEYEVENTF_UNICODE)); + } + Assert::AreEqual(L'a', inputs[0].ki.wScan); + Assert::AreEqual(L'b', inputs[2].ki.wScan); + Assert::AreEqual(L'c', inputs[4].ki.wScan); + } + + // Test that each character generates a keydown and keyup event pair + TEST_METHOD (SetTextKeyEvents_ShouldGenerateDownUpPairs_WhenTextHasMultipleChars) + { + std::vector inputs; + std::wstring text = L"xy"; + + Helpers::SetTextKeyEvents(inputs, text); + + Assert::AreEqual(4, inputs.size()); + // First event: 'x' keydown (no KEYEVENTF_KEYUP flag) + Assert::AreEqual(L'x', inputs[0].ki.wScan); + Assert::IsFalse(bool(inputs[0].ki.dwFlags & KEYEVENTF_KEYUP)); + // Second event: 'x' keyup + Assert::AreEqual(L'x', inputs[1].ki.wScan); + Assert::AreEqual(true, bool(inputs[1].ki.dwFlags & KEYEVENTF_KEYUP)); + } + + // Test that newline characters are passed through as Unicode events (actual newline handling is done via clipboard) + TEST_METHOD (SetTextKeyEvents_ShouldPassNewlinesAsUnicode_WhenTextContainsNewlines) + { + std::vector inputs; + std::wstring text = L"a\r\nb"; + + Helpers::SetTextKeyEvents(inputs, text); + + // All 4 characters (a, \r, \n, b) × 2 events = 8 events + Assert::AreEqual(8, inputs.size()); + Assert::AreEqual(L'a', inputs[0].ki.wScan); + Assert::AreEqual(L'\r', inputs[2].ki.wScan); + Assert::AreEqual(L'\n', inputs[4].ki.wScan); + Assert::AreEqual(L'b', inputs[6].ki.wScan); + } + + // Test empty string produces no events + TEST_METHOD (SetTextKeyEvents_ShouldProduceNoEvents_WhenTextIsEmpty) + { + std::vector inputs; + std::wstring text = L""; + + Helpers::SetTextKeyEvents(inputs, text); + + Assert::AreEqual(0, inputs.size()); + } + + // Test that extraInfo flag is set correctly for KBM identification + TEST_METHOD (SetTextKeyEvents_ShouldSetExtraInfoFlag_WhenTextIsProvided) + { + std::vector inputs; + std::wstring text = L"a"; + + Helpers::SetTextKeyEvents(inputs, text); + + Assert::AreEqual(2, inputs.size()); + Assert::AreEqual(KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, inputs[0].ki.dwExtraInfo); + Assert::AreEqual(KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, inputs[1].ki.dwExtraInfo); + } + }; } diff --git a/src/modules/keyboardmanager/common/Helpers.cpp b/src/modules/keyboardmanager/common/Helpers.cpp index b619ae73d3..596b4343c8 100644 --- a/src/modules/keyboardmanager/common/Helpers.cpp +++ b/src/modules/keyboardmanager/common/Helpers.cpp @@ -1,6 +1,8 @@ #include "pch.h" #include "Helpers.h" #include +#include +#include #include #include @@ -325,12 +327,161 @@ namespace Helpers } } + // Helper to set clipboard text. Returns true on success. + static bool SetClipboardText(const std::wstring& text) + { + if (!OpenClipboard(nullptr)) + { + return false; + } + + EmptyClipboard(); + + size_t byteSize = (text.size() + 1) * sizeof(wchar_t); + HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, byteSize); + if (!hMem) + { + CloseClipboard(); + return false; + } + + wchar_t* pMem = static_cast(GlobalLock(hMem)); + if (!pMem) + { + GlobalFree(hMem); + CloseClipboard(); + return false; + } + + wcscpy_s(pMem, text.size() + 1, text.c_str()); + GlobalUnlock(hMem); + + if (!SetClipboardData(CF_UNICODETEXT, hMem)) + { + GlobalFree(hMem); + CloseClipboard(); + return false; + } + + // Exclude this entry from clipboard history and cloud clipboard so the + // temporary paste text does not pollute the user's clipboard history. + static const UINT excludeFromHistory = RegisterClipboardFormat(L"ExcludeClipboardContentFromMonitorProcessing"); + if (excludeFromHistory != 0) + { + HGLOBAL hExclude = GlobalAlloc(GMEM_MOVEABLE, sizeof(DWORD)); + if (hExclude) + { + SetClipboardData(excludeFromHistory, hExclude); + } + } + + CloseClipboard(); + return true; + } + + // Simulate Ctrl+V paste keystroke, tagged with KBM flag so our own hook + // passes it through without re-intercepting. + static void SendPasteKeystroke() + { + INPUT pasteInputs[4]{}; + + pasteInputs[0].type = INPUT_KEYBOARD; + pasteInputs[0].ki.wVk = VK_CONTROL; + pasteInputs[0].ki.wScan = static_cast(MapVirtualKey(VK_CONTROL, MAPVK_VK_TO_VSC)); + pasteInputs[0].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG; + + pasteInputs[1].type = INPUT_KEYBOARD; + pasteInputs[1].ki.wVk = 'V'; + pasteInputs[1].ki.wScan = static_cast(MapVirtualKey('V', MAPVK_VK_TO_VSC)); + pasteInputs[1].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG; + + pasteInputs[2].type = INPUT_KEYBOARD; + pasteInputs[2].ki.wVk = 'V'; + pasteInputs[2].ki.dwFlags = KEYEVENTF_KEYUP; + pasteInputs[2].ki.wScan = static_cast(MapVirtualKey('V', MAPVK_VK_TO_VSC)); + pasteInputs[2].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG; + + pasteInputs[3].type = INPUT_KEYBOARD; + pasteInputs[3].ki.wVk = VK_CONTROL; + pasteInputs[3].ki.dwFlags = KEYEVENTF_KEYUP; + pasteInputs[3].ki.wScan = static_cast(MapVirtualKey(VK_CONTROL, MAPVK_VK_TO_VSC)); + pasteInputs[3].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG; + + SendInput(ARRAYSIZE(pasteInputs), pasteInputs, sizeof(INPUT)); + } + + // Serializes clipboard operations so rapid text remappings don't race. + static std::mutex clipboardMutex; + + // Function to send text via clipboard paste (Ctrl+V). + // Saves the previous clipboard content and restores it asynchronously. + // The clipboard entry is excluded from clipboard history via + // ExcludeClipboardContentFromMonitorProcessing (set in SetClipboardText). + bool SendTextViaClipboard(const std::wstring& text) + { + // Acquire the mutex so that the entire snapshot-paste-restore cycle + // is atomic with respect to other text remapping calls. + std::unique_lock lock(clipboardMutex); + + // Snapshot current clipboard state + bool hadOriginalText = false; + std::wstring originalClipboardText; + if (OpenClipboard(nullptr)) + { + if (IsClipboardFormatAvailable(CF_UNICODETEXT)) + { + HANDLE hData = GetClipboardData(CF_UNICODETEXT); + if (hData) + { + wchar_t* pText = static_cast(GlobalLock(hData)); + if (pText) + { + originalClipboardText = pText; + hadOriginalText = true; + GlobalUnlock(hData); + } + } + } + CloseClipboard(); + } + + // Place our text on the clipboard (with history exclusion) + if (!SetClipboardText(text)) + { + return false; + } + + SendPasteKeystroke(); + + // Restore clipboard after a delay on a background thread. + // Ctrl+V is asynchronous (SendInput queues the input), so the target + // app needs time to process the keystroke and read the clipboard. + // The lock is moved into the thread so the next call blocks until + // restoration completes. + std::thread([lock = std::move(lock), originalClipboardText = std::move(originalClipboardText), hadOriginalText]() { + Sleep(500); + if (hadOriginalText) + { + SetClipboardText(originalClipboardText); + } + else + { + if (OpenClipboard(nullptr)) + { + EmptyClipboard(); + CloseClipboard(); + } + } + }).detach(); + + return true; + } + // Function to filter the key codes for artificial key codes int32_t FilterArtificialKeys(const int32_t& key) { switch (key) { - // If a key is remapped to VK_WIN_BOTH, we send VK_LWIN instead case CommonSharedConstants::VK_WIN_BOTH: return VK_LWIN; } diff --git a/src/modules/keyboardmanager/common/Helpers.h b/src/modules/keyboardmanager/common/Helpers.h index 8f38bbbbe4..db3cbf3eb1 100644 --- a/src/modules/keyboardmanager/common/Helpers.h +++ b/src/modules/keyboardmanager/common/Helpers.h @@ -41,6 +41,9 @@ namespace Helpers // Function to set key events for remapping text. void SetTextKeyEvents(std::vector& keyEventArray, const std::wstring& remapping); + // Function to send text via clipboard paste (Ctrl+V). Saves and restores previous clipboard content. + bool SendTextViaClipboard(const std::wstring& text); + // Function to return window handle for a full screen UWP app HWND GetFullscreenUWPWindowHandle(); diff --git a/src/modules/keyboardmanager/common/Shortcut.h b/src/modules/keyboardmanager/common/Shortcut.h index 439559c9d0..bac8dbc975 100644 --- a/src/modules/keyboardmanager/common/Shortcut.h +++ b/src/modules/keyboardmanager/common/Shortcut.h @@ -39,7 +39,8 @@ public: { RemapShortcut = 0, RunProgram = 1, - OpenURI = 2 + OpenURI = 2, + RemapText = 3 }; enum StartWindowType @@ -47,7 +48,7 @@ public: Normal = 0, Hidden = 1, Minimized = 2, - Maximized = 2 + Maximized = 3 }; enum ProgramAlreadyRunningAction diff --git a/src/modules/keyboardmanager/dll/dllmain.cpp b/src/modules/keyboardmanager/dll/dllmain.cpp index 499b4a9693..61d5e9b0f4 100644 --- a/src/modules/keyboardmanager/dll/dllmain.cpp +++ b/src/modules/keyboardmanager/dll/dllmain.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lpReserved*/) { @@ -37,6 +39,8 @@ namespace const wchar_t JSON_KEY_SHIFT[] = L"shift"; const wchar_t JSON_KEY_CODE[] = L"code"; const wchar_t JSON_KEY_ACTIVATION_SHORTCUT[] = L"ToggleShortcut"; + const wchar_t JSON_KEY_EDITOR_SHORTCUT[] = L"EditorShortcut"; + const wchar_t JSON_KEY_USE_NEW_EDITOR[] = L"useNewEditor"; } // Implement the PowerToy Module Interface and all the required methods. @@ -56,11 +60,22 @@ private: // Hotkey for toggling the module Hotkey m_hotkey = { .key = 0 }; + // Hotkey for opening the editor + Hotkey m_editorHotkey = { .key = 0 }; + + // Whether to use the new WinUI3 editor + bool m_useNewEditor = false; + ULONGLONG m_lastHotkeyToggleTime = 0; HANDLE m_hProcess = nullptr; + HANDLE m_hEditorProcess = nullptr; HANDLE m_hTerminateEngineEvent = nullptr; + HANDLE m_open_new_editor_event_handle{ nullptr }; + std::thread m_toggle_thread; + std::atomic m_toggle_thread_running{ false }; + void refresh_process_state() { @@ -174,6 +189,49 @@ private: m_hotkey.alt = false; m_hotkey.key = 'K'; } + + // Parse editor shortcut + if (settingsObject.GetView().Size()) + { + try + { + auto jsonEditorHotkeyObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES) + .GetNamedObject(JSON_KEY_EDITOR_SHORTCUT); + m_editorHotkey.win = jsonEditorHotkeyObject.GetNamedBoolean(JSON_KEY_WIN); + m_editorHotkey.alt = jsonEditorHotkeyObject.GetNamedBoolean(JSON_KEY_ALT); + m_editorHotkey.shift = jsonEditorHotkeyObject.GetNamedBoolean(JSON_KEY_SHIFT); + m_editorHotkey.ctrl = jsonEditorHotkeyObject.GetNamedBoolean(JSON_KEY_CTRL); + m_editorHotkey.key = static_cast(jsonEditorHotkeyObject.GetNamedNumber(JSON_KEY_CODE)); + } + catch (...) + { + Logger::error("Failed to initialize Keyboard Manager editor shortcut"); + } + } + + if (!m_editorHotkey.key) + { + // Set default: Win+Shift+Q + m_editorHotkey.win = true; + m_editorHotkey.shift = true; + m_editorHotkey.ctrl = false; + m_editorHotkey.alt = false; + m_editorHotkey.key = 'Q'; + } + + // Parse useNewEditor setting + if (settingsObject.GetView().Size()) + { + try + { + auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + m_useNewEditor = propertiesObject.GetNamedBoolean(JSON_KEY_USE_NEW_EDITOR, false); + } + catch (...) + { + Logger::warn("Failed to parse useNewEditor setting, defaulting to false"); + } + } } // Load the settings file. @@ -214,17 +272,30 @@ public: } } + m_open_new_editor_event_handle = CreateDefaultEvent(CommonSharedConstants::OPEN_NEW_KEYBOARD_MANAGER_EVENT); + init_settings(); }; ~KeyboardManager() { + StopOpenEditorListener(); stop_engine(); if (m_hTerminateEngineEvent) { CloseHandle(m_hTerminateEngineEvent); m_hTerminateEngineEvent = nullptr; } + if (m_open_new_editor_event_handle) + { + CloseHandle(m_open_new_editor_event_handle); + m_open_new_editor_event_handle = nullptr; + } + if (m_hEditorProcess) + { + CloseHandle(m_hEditorProcess); + m_hEditorProcess = nullptr; + } } // Destroy the powertoy and free memory @@ -296,6 +367,7 @@ public: // Log telemetry Trace::EnableKeyboardManager(true); start_engine(); + StartOpenEditorListener(); } // Disable the powertoy @@ -304,6 +376,7 @@ public: m_enabled = false; // Log telemetry Trace::EnableKeyboardManager(false); + StopOpenEditorListener(); stop_engine(); } @@ -319,25 +392,148 @@ public: return false; } - // Return the invocation hotkey for toggling + // Return the invocation hotkeys for toggling and opening the editor virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override { + size_t count = 0; + + // Hotkey 0: toggle engine if (m_hotkey.key) { - if (hotkeys && buffer_size >= 1) + if (hotkeys && buffer_size > count) { - hotkeys[0] = m_hotkey; + hotkeys[count] = m_hotkey; } - return 1; + count++; } - else + + // Hotkey 1: open editor (only when using new editor) + if (m_useNewEditor && m_editorHotkey.key) { - return 0; + if (hotkeys && buffer_size > count) + { + hotkeys[count] = m_editorHotkey; + } + count++; + } + + return count; + } + + void StartOpenEditorListener() + { + if (m_toggle_thread_running || !m_open_new_editor_event_handle) + { + return; + } + + m_toggle_thread_running = true; + m_toggle_thread = std::thread([this]() { + while (m_toggle_thread_running) + { + const DWORD wait_result = WaitForSingleObject(m_open_new_editor_event_handle, 500); + if (!m_toggle_thread_running) + { + break; + } + + if (wait_result == WAIT_OBJECT_0) + { + launch_editor(); + ResetEvent(m_open_new_editor_event_handle); + } + } + }); + } + + void StopOpenEditorListener() + { + if (!m_toggle_thread_running) + { + return; + } + + m_toggle_thread_running = false; + if (m_open_new_editor_event_handle) + { + SetEvent(m_open_new_editor_event_handle); + } + if (m_toggle_thread.joinable()) + { + m_toggle_thread.join(); } } + bool launch_editor() + { + // Check if editor is already running + if (m_hEditorProcess) + { + if (WaitForSingleObject(m_hEditorProcess, 0) == WAIT_TIMEOUT) + { + // Editor still running, bring it to front + DWORD editorPid = GetProcessId(m_hEditorProcess); + if (editorPid) + { + AllowSetForegroundWindow(editorPid); + + // Find the editor's main window and set it to foreground + EnumWindows([](HWND hwnd, LPARAM lParam) -> BOOL { + DWORD windowPid = 0; + GetWindowThreadProcessId(hwnd, &windowPid); + if (windowPid == static_cast(lParam) && IsWindowVisible(hwnd)) + { + SetForegroundWindow(hwnd); + if (IsIconic(hwnd)) + { + ShowWindow(hwnd, SW_RESTORE); + } + return FALSE; // Stop enumerating + } + return TRUE; + }, static_cast(editorPid)); + } + return true; + } + else + { + CloseHandle(m_hEditorProcess); + m_hEditorProcess = nullptr; + } + } + + unsigned long powertoys_pid = GetCurrentProcessId(); + std::wstring executable_args = std::to_wstring(powertoys_pid); + + SHELLEXECUTEINFOW sei{ sizeof(sei) }; + sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI }; + sei.lpFile = L"WinUI3Apps\\PowerToys.KeyboardManagerEditorUI.exe"; + sei.nShow = SW_SHOWNORMAL; + sei.lpParameters = executable_args.data(); + if (ShellExecuteExW(&sei) == false) + { + Logger::error(L"Failed to start new keyboard manager editor"); + auto message = get_last_error_message(GetLastError()); + if (message.has_value()) + { + Logger::error(message.value()); + } + return false; + } + + m_hEditorProcess = sei.hProcess; + + // Log telemetry for editor launch + if (m_hEditorProcess) + { + Trace::LaunchEditor(true); // true = launched via hotkey/event + } + + return m_hEditorProcess != nullptr; + } + // Process the hotkey event - virtual bool on_hotkey(size_t /*hotkeyId*/) override + virtual bool on_hotkey(size_t hotkeyId) override { if (!m_enabled) { @@ -352,14 +548,23 @@ public: } m_lastHotkeyToggleTime = now; - refresh_process_state(); - if (m_active) + if (hotkeyId == 0) { - stop_engine(); + // Toggle engine on/off + refresh_process_state(); + if (m_active) + { + stop_engine(); + } + else + { + start_engine(); + } } - else + else if (hotkeyId == 1) { - start_engine(); + // Open the new editor (only in new editor mode) + launch_editor(); } return true; diff --git a/src/modules/keyboardmanager/dll/trace.cpp b/src/modules/keyboardmanager/dll/trace.cpp index 82057baefb..9f78074c64 100644 --- a/src/modules/keyboardmanager/dll/trace.cpp +++ b/src/modules/keyboardmanager/dll/trace.cpp @@ -20,3 +20,14 @@ void Trace::EnableKeyboardManager(const bool enabled) noexcept TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), TraceLoggingBoolean(enabled, "Enabled")); } + +// Log when the editor is launched +void Trace::LaunchEditor(const bool viaHotkey) noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "KeyboardManager_LaunchEditor", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(viaHotkey, "ViaHotkey")); +} diff --git a/src/modules/keyboardmanager/dll/trace.h b/src/modules/keyboardmanager/dll/trace.h index 55e5ff6867..81680f5a45 100644 --- a/src/modules/keyboardmanager/dll/trace.h +++ b/src/modules/keyboardmanager/dll/trace.h @@ -7,4 +7,7 @@ class Trace : public telemetry::TraceBase public: // Log if the user has KBM enabled or disabled - Can also be used to see how often users have to restart the keyboard hook static void EnableKeyboardManager(const bool enabled) noexcept; + + // Log when the editor is launched + static void LaunchEditor(const bool viaHotkey) noexcept; }; diff --git a/src/settings-ui/QuickAccess.UI/Services/QuickAccessLauncher.cs b/src/settings-ui/QuickAccess.UI/Services/QuickAccessLauncher.cs index fcf88fd26f..d280c15a9d 100644 --- a/src/settings-ui/QuickAccess.UI/Services/QuickAccessLauncher.cs +++ b/src/settings-ui/QuickAccess.UI/Services/QuickAccessLauncher.cs @@ -6,6 +6,8 @@ using System.Threading; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Controls; using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events; +using Microsoft.PowerToys.Telemetry; using PowerToys.Interop; namespace Microsoft.PowerToys.QuickAccess.Services @@ -27,6 +29,12 @@ namespace Microsoft.PowerToys.QuickAccess.Services if (moduleRun) { _coordinator?.OnModuleLaunched(moduleType); + + // Send telemetry event for module launch from Quick Access + if (moduleType == ModuleType.KeyboardManager) + { + PowerToysTelemetry.Log.WriteEvent(new ModuleLaunchedFromSettingsEvent("KeyboardManagerWinUI")); + } } _coordinator?.HideFlyout(); diff --git a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs index 1347ce86c1..bf3ce1e960 100644 --- a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs +++ b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs @@ -126,6 +126,13 @@ namespace Microsoft.PowerToys.Settings.UI.Controls eventHandle.Set(); } + return true; + case ModuleType.KeyboardManager: + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.OpenNewKeyboardManagerEvent())) + { + eventHandle.Set(); + } + return true; default: return false; diff --git a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs index d24edf2d0d..e32ae0d464 100644 --- a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs +++ b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs @@ -17,6 +17,10 @@ namespace Microsoft.PowerToys.Settings.UI.Controls public partial class QuickAccessViewModel : Observable { private readonly ISettingsRepository _settingsRepository; + + // Pulling in KBMSettingsRepository separately as we need to listen to changes in the + // UseNewEditor property to determine the visibility of the KeyboardManager quick access item. + private readonly SettingsRepository _kbmSettingsRepository; private readonly IQuickAccessLauncher _launcher; private readonly Func _isModuleGpoDisabled; private readonly Func _isModuleGpoEnabled; @@ -44,6 +48,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls _generalSettings.AddEnabledModuleChangeNotification(ModuleEnabledChanged); _settingsRepository.SettingsChanged += OnSettingsChanged; + _kbmSettingsRepository = SettingsRepository.GetInstance(SettingsUtils.Default); + _kbmSettingsRepository.SettingsChanged += OnKbmSettingsChanged; + InitializeItems(); } @@ -67,6 +74,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls AddFlyoutMenuItem(ModuleType.EnvironmentVariables); AddFlyoutMenuItem(ModuleType.FancyZones); AddFlyoutMenuItem(ModuleType.Hosts); + AddFlyoutMenuItem(ModuleType.KeyboardManager); AddFlyoutMenuItem(ModuleType.LightSwitch); // AddFlyoutMenuItem(ModuleType.PowerDisplay); // TEMPORARILY_DISABLED: PowerDisplay @@ -89,7 +97,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { Title = _resourceLoader.GetString(Microsoft.PowerToys.Settings.UI.Library.Helpers.ModuleHelper.GetModuleLabelResourceName(moduleType)), Tag = moduleType, - Visible = _isModuleGpoEnabled(moduleType) || Microsoft.PowerToys.Settings.UI.Library.Helpers.ModuleHelper.GetIsModuleEnabled(_generalSettings, moduleType), + Visible = GetItemVisibility(moduleType), Description = GetModuleToolTip(moduleType), Icon = Microsoft.PowerToys.Settings.UI.Library.Helpers.ModuleHelper.GetModuleTypeFluentIconName(moduleType), Command = new RelayCommand(() => _launcher.Launch(moduleType)), @@ -115,11 +123,38 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { if (item.Tag is ModuleType moduleType) { - item.Visible = _isModuleGpoEnabled(moduleType) || Microsoft.PowerToys.Settings.UI.Library.Helpers.ModuleHelper.GetIsModuleEnabled(_generalSettings, moduleType); + bool visible = GetItemVisibility(moduleType); + + item.Visible = visible; } } } + private void OnKbmSettingsChanged(KeyboardManagerSettings newSettings) + { + if (_dispatcherQueue != null) + { + _dispatcherQueue.TryEnqueue(() => + { + RefreshItemsVisibility(); + }); + } + } + + private bool GetItemVisibility(ModuleType moduleType) + { + // Generally, if gpo is enabled or if module enabled, then quick access item is visible. + bool visible = _isModuleGpoEnabled(moduleType) || Microsoft.PowerToys.Settings.UI.Library.Helpers.ModuleHelper.GetIsModuleEnabled(_generalSettings, moduleType); + + // For KeyboardManager Quick Access item is only shown when using the new editor + if (moduleType == ModuleType.KeyboardManager) + { + visible = visible && _kbmSettingsRepository.SettingsConfig.Properties.UseNewEditor; + } + + return visible; + } + private string GetModuleToolTip(ModuleType moduleType) { return moduleType switch @@ -127,6 +162,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls ModuleType.ColorPicker => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.ToString(), ModuleType.FancyZones => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.FancyzonesEditorHotkey.Value.ToString(), ModuleType.PowerDisplay => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.ToString(), + ModuleType.KeyboardManager => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.DefaultEditorShortcut.ToString(), ModuleType.LightSwitch => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ToggleThemeHotkey.Value.ToString(), ModuleType.PowerLauncher => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.OpenPowerLauncher.ToString(), ModuleType.PowerOCR => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.ToString(), diff --git a/src/settings-ui/Settings.UI.Library/KeyboardManagerProperties.cs b/src/settings-ui/Settings.UI.Library/KeyboardManagerProperties.cs index 1da3bfd72b..a523716577 100644 --- a/src/settings-ui/Settings.UI.Library/KeyboardManagerProperties.cs +++ b/src/settings-ui/Settings.UI.Library/KeyboardManagerProperties.cs @@ -23,15 +23,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library public HotkeySettings DefaultToggleShortcut => new HotkeySettings(true, false, false, true, 0x4B); + public HotkeySettings DefaultEditorShortcut => new HotkeySettings(true, false, false, true, 0x51); + public KeyboardManagerProperties() { ToggleShortcut = DefaultToggleShortcut; + EditorShortcut = DefaultEditorShortcut; KeyboardConfigurations = new GenericProperty>(new List { "default", }); ActiveConfiguration = new GenericProperty("default"); } public HotkeySettings ToggleShortcut { get; set; } + public HotkeySettings EditorShortcut { get; set; } + + [JsonPropertyName("useNewEditor")] + public bool UseNewEditor { get; set; } + public string ToJsonString() { return JsonSerializer.Serialize(this); diff --git a/src/settings-ui/Settings.UI.Library/KeyboardManagerSettings.cs b/src/settings-ui/Settings.UI.Library/KeyboardManagerSettings.cs index 84ba811b90..406f62cdc9 100644 --- a/src/settings-ui/Settings.UI.Library/KeyboardManagerSettings.cs +++ b/src/settings-ui/Settings.UI.Library/KeyboardManagerSettings.cs @@ -42,6 +42,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library () => Properties.ToggleShortcut, value => Properties.ToggleShortcut = value ?? Properties.DefaultToggleShortcut, "Toggle_Shortcut"), + new HotkeyAccessor( + () => Properties.EditorShortcut, + value => Properties.EditorShortcut = value ?? Properties.DefaultEditorShortcut, + "Editor_Shortcut"), }; return hotkeyAccessors.ToArray(); diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/ModuleLaunchedFromSettingsEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ModuleLaunchedFromSettingsEvent.cs new file mode 100644 index 0000000000..f5c7f92c1d --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ModuleLaunchedFromSettingsEvent.cs @@ -0,0 +1,26 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events +{ + [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public class ModuleLaunchedFromSettingsEvent : EventBase, IEvent + { + public string ModuleName { get; set; } + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public ModuleLaunchedFromSettingsEvent(string moduleName) + { + EventName = "PowerToys_ModuleLaunchedFromSettings"; + ModuleName = moduleName; + } + } +} diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/KeyboardManager.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/KeyboardManager.cs index 89491b2b99..3d9278a7f1 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/KeyboardManager.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/KeyboardManager.cs @@ -97,7 +97,7 @@ namespace ViewModelTests var expectedEntry = new AppSpecificKeysDataModel(); expectedEntry.OriginalKeys = entry.OriginalKeys; expectedEntry.NewRemapKeys = entry.NewRemapKeys; - expectedEntry.TargetApp = "All Apps"; + expectedEntry.TargetApp = "All apps"; expectedResult.Add(expectedEntry); Assert.AreEqual(expectedResult.Count, result.Count); @@ -123,7 +123,7 @@ namespace ViewModelTests var expectedEntry = new AppSpecificKeysDataModel(); expectedEntry.OriginalKeys = entry.OriginalKeys; expectedEntry.NewRemapKeys = entry.NewRemapKeys; - expectedEntry.TargetApp = "All Apps"; + expectedEntry.TargetApp = "All apps"; expectedResult.Add(expectedEntry); var x = expectedResult[0].Equals(result[0]); Assert.AreEqual(expectedResult.Count, result.Count); @@ -181,7 +181,7 @@ namespace ViewModelTests var expectedFirstEntry = new AppSpecificKeysDataModel(); expectedFirstEntry.OriginalKeys = firstListEntry.OriginalKeys; expectedFirstEntry.NewRemapKeys = firstListEntry.NewRemapKeys; - expectedFirstEntry.TargetApp = "All Apps"; + expectedFirstEntry.TargetApp = "All apps"; expectedResult.Add(expectedFirstEntry); var expectedSecondEntry = new AppSpecificKeysDataModel(); expectedSecondEntry.OriginalKeys = secondListEntry.OriginalKeys; diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml index 9f42bc45cb..9943a26959 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml @@ -15,219 +15,276 @@ mc:Ignorable="d"> - - + + + + + + + + + + + + + + + + + + + + - - - + + - - - + + + - - + + + + - - + - - + + + + + + + - - - + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - + + + - - - + + + + + + + + + + - - - - - - - - - - + - + + + + + + + - - - - - - - + - - - + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + +