From 679eb2253269fbe0b06aaf4c3289c81625f9cd5f Mon Sep 17 00:00:00 2001 From: "Yu Leng (from Dev Box)" Date: Thu, 19 Mar 2026 10:03:38 +0800 Subject: [PATCH 01/10] [KBM] Add Expand (text replacement) action type to EditorUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new "Expand" trigger type that allows users to define text expansion mappings (type abbreviation → expand to full text) in the Keyboard Manager editor. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controls/IconLabelControl.xaml.cs | 1 + .../Controls/UnifiedMappingControl.xaml | 58 ++++ .../Controls/UnifiedMappingControl.xaml.cs | 294 +++++++++++++++++- .../Helpers/ActionType.cs | 1 + .../Helpers/ExpandMapping.cs | 27 ++ .../Helpers/ValidationErrorType.cs | 3 + .../Helpers/ValidationHelper.cs | 31 ++ .../Interop/ShortcutOperationType.cs | 1 + .../Pages/MainPage.xaml | 81 +++++ .../Pages/MainPage.xaml.cs | 95 +++++- .../Strings/en-US/Resources.resw | 38 ++- 11 files changed, 624 insertions(+), 6 deletions(-) create mode 100644 src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ExpandMapping.cs diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/IconLabelControl.xaml.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/IconLabelControl.xaml.cs index 4e12beaa4406..d487855e464a 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/IconLabelControl.xaml.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/IconLabelControl.xaml.cs @@ -81,6 +81,7 @@ private void UpdateIcon() ActionType.Shortcut => "\uEDA7", ActionType.MouseClick => "\uE962", ActionType.Url => "\uE774", + ActionType.Expand => "\uE8C8", _ => "\uE8A5", }; } diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml index d3c9ea780de0..abe46df72312 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml @@ -46,6 +46,12 @@ + + + + + + + + + + + + + + + + + + + + + ? _savedActionTypeItems; public bool AllowChords { get; set; } = true; @@ -65,6 +75,7 @@ public enum TriggerType { KeyOrShortcut, Mouse, + Expand, } /// @@ -77,6 +88,7 @@ public enum ActionType OpenUrl, OpenApp, MouseClick, + ReplaceWith, } /// @@ -106,6 +118,7 @@ public TriggerType CurrentTriggerType return item.Tag?.ToString() switch { "Mouse" => TriggerType.Mouse, + "Expand" => TriggerType.Expand, _ => TriggerType.KeyOrShortcut, }; } @@ -129,6 +142,7 @@ public ActionType CurrentActionType "OpenUrl" => ActionType.OpenUrl, "OpenApp" => ActionType.OpenApp, "MouseClick" => ActionType.MouseClick, + "ReplaceWith" => ActionType.ReplaceWith, _ => ActionType.KeyOrShortcut, }; } @@ -188,13 +202,26 @@ private void TriggerTypeComboBox_SelectionChanged(object sender, SelectionChange { string? tag = item.Tag?.ToString(); - // Cleanup keyboard hook when switching to mouse - if (tag == "Mouse") + // Cleanup keyboard hook when switching to mouse or expand + if (tag == "Mouse" || tag == "Expand") { CleanupKeyboardHook(); UncheckAllToggleButtons(); } + + if (tag == "Expand") + { + SwitchActionTypeToReplaceWith(); + HideAppSpecific(); + } + else + { + RestoreNormalActionTypes(); + ShowAppSpecific(); + } } + + RaiseValidationStateChanged(); } private void TriggerKeyToggleBtn_Checked(object sender, RoutedEventArgs e) @@ -419,6 +446,25 @@ private async void StartInSelectButton_Click(object sender, RoutedEventArgs e) public void OnKeyDown(VirtualKey key, List formattedKeys) { + // Expand trigger key capture: only accept a single key + if (_isCapturingExpandTriggerKey && formattedKeys.Count > 0) + { + _expandTriggerKey = formattedKeys[0]; + if (ExpandTriggerKeyVisual != null) + { + ExpandTriggerKeyVisual.Content = _expandTriggerKey; + } + + // Auto-uncheck the toggle after capturing one key + if (ExpandTriggerKeyToggleBtn?.IsChecked == true) + { + ExpandTriggerKeyToggleBtn.IsChecked = false; + } + + RaiseValidationStateChanged(); + return; + } + if (_currentInputMode == KeyInputMode.OriginalKeys) { _triggerKeys.Clear(); @@ -555,7 +601,14 @@ public string GetAppName() /// public bool IsInputComplete() { - // Trigger keys are always required + // Expand trigger: abbreviation + expanded text required (no trigger keys needed) + if (CurrentTriggerType == TriggerType.Expand) + { + return !string.IsNullOrWhiteSpace(ExpandAbbreviationBox?.Text) + && !string.IsNullOrWhiteSpace(ExpandedTextBox?.Text); + } + + // Trigger keys are always required for other trigger types if (_triggerKeys.Count == 0) { return false; @@ -612,6 +665,12 @@ public void SetActionKeys(List keys) /// public void SetActionType(ActionType actionType) { + // ReplaceWith is handled by SwitchActionTypeToReplaceWith(), not by index + if (actionType == ActionType.ReplaceWith) + { + return; + } + int index = actionType switch { ActionType.Text => 1, @@ -746,6 +805,11 @@ private void UncheckAllToggleButtons() { ActionKeyToggleBtn.IsChecked = false; } + + if (ExpandTriggerKeyToggleBtn?.IsChecked == true) + { + ExpandTriggerKeyToggleBtn.IsChecked = false; + } } private void CleanupKeyboardHook() @@ -793,6 +857,21 @@ private void UpdateInlineValidation() return; } + break; + + case ActionType.ReplaceWith: + if (ExpandAbbreviationBox != null && _expandAbbreviationDirty && string.IsNullOrWhiteSpace(ExpandAbbreviationBox.Text)) + { + ShowValidationErrorFromType(ValidationErrorType.EmptyExpandAbbreviation); + return; + } + + if (ExpandedTextBox != null && _expandedTextDirty && string.IsNullOrWhiteSpace(ExpandedTextBox.Text)) + { + ShowValidationErrorFromType(ValidationErrorType.EmptyExpandedText); + return; + } + break; } @@ -815,6 +894,30 @@ public void Reset() _textContentDirty = false; _urlPathDirty = false; _programPathDirty = false; + _expandAbbreviationDirty = false; + _expandedTextDirty = false; + + // Reset expand state + _expandTriggerKey = "Space"; + _isCapturingExpandTriggerKey = false; + + if (ExpandAbbreviationBox != null) + { + ExpandAbbreviationBox.Text = string.Empty; + } + + if (ExpandedTextBox != null) + { + ExpandedTextBox.Text = string.Empty; + } + + if (ExpandTriggerKeyVisual != null) + { + ExpandTriggerKeyVisual.Content = "Space"; + } + + // Restore normal action types if currently in Expand mode + RestoreNormalActionTypes(); // Hide any validation messages HideValidationMessage(); @@ -872,6 +975,7 @@ public void Reset() { AppSpecificCheckBox.IsChecked = false; AppSpecificCheckBox.IsEnabled = false; + AppSpecificCheckBox.Visibility = Visibility.Visible; } // Reset app combo boxes @@ -991,6 +1095,190 @@ private void AllowChordsCheckBox_Click(object sender, RoutedEventArgs e) { AllowChords = AllowChordsCheckBox.IsChecked == true; } + + #region Expand Trigger Handling + + private void ExpandAbbreviationBox_GotFocus(object sender, RoutedEventArgs e) + { + CleanupKeyboardHook(); + UncheckAllToggleButtons(); + } + + private void ExpandAbbreviationBox_TextChanged(object sender, TextChangedEventArgs e) + { + _expandAbbreviationDirty = true; + RaiseValidationStateChanged(); + } + + private void ExpandTriggerKeyToggleBtn_Checked(object sender, RoutedEventArgs e) + { + if (ExpandTriggerKeyToggleBtn.IsChecked == true) + { + _isCapturingExpandTriggerKey = true; + _currentInputMode = KeyInputMode.OriginalKeys; + + // Uncheck other toggles + UncheckAllToggleButtons(); + + KeyboardHookHelper.Instance.ActivateHook(this); + } + } + + private void ExpandTriggerKeyToggleBtn_Unchecked(object sender, RoutedEventArgs e) + { + if (_isCapturingExpandTriggerKey) + { + _isCapturingExpandTriggerKey = false; + CleanupKeyboardHook(); + } + } + + private void ExpandedTextBox_GotFocus(object sender, RoutedEventArgs e) + { + CleanupKeyboardHook(); + UncheckAllToggleButtons(); + } + + private void ExpandedTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + _expandedTextDirty = true; + RaiseValidationStateChanged(); + } + + private void HideAppSpecific() + { + if (AppSpecificCheckBox != null) + { + AppSpecificCheckBox.Visibility = Visibility.Collapsed; + } + + if (AppNameTextBox != null) + { + AppNameTextBox.Visibility = Visibility.Collapsed; + } + } + + private void ShowAppSpecific() + { + if (AppSpecificCheckBox != null) + { + AppSpecificCheckBox.Visibility = Visibility.Visible; + } + } + + private void SwitchActionTypeToReplaceWith() + { + if (ActionTypeComboBox == null) + { + return; + } + + // Save current items if not already saved + if (_savedActionTypeItems == null) + { + _savedActionTypeItems = new List(); + foreach (var actionItem in ActionTypeComboBox.Items) + { + _savedActionTypeItems.Add(actionItem); + } + } + + // Replace with single "Replace with" item + ActionTypeComboBox.Items.Clear(); + + var resourceLoader = new ResourceLoader(); + var replaceWithItem = new ComboBoxItem + { + Tag = "ReplaceWith", + Content = new StackPanel + { + Orientation = Microsoft.UI.Xaml.Controls.Orientation.Horizontal, + Spacing = 8, + Children = + { + new FontIcon { Glyph = "\uE8C8", FontSize = 14 }, + new TextBlock { Text = resourceLoader.GetString("ActionType_ReplaceWith_Text/Text") }, + }, + }, + }; + + ActionTypeComboBox.Items.Add(replaceWithItem); + ActionTypeComboBox.SelectedIndex = 0; + ActionTypeComboBox.IsEnabled = false; + } + + private void RestoreNormalActionTypes() + { + if (ActionTypeComboBox == null || _savedActionTypeItems == null) + { + return; + } + + ActionTypeComboBox.Items.Clear(); + foreach (var actionItem in _savedActionTypeItems) + { + ActionTypeComboBox.Items.Add(actionItem); + } + + _savedActionTypeItems = null; + ActionTypeComboBox.SelectedIndex = 0; + ActionTypeComboBox.IsEnabled = true; + } + + #endregion + + #region Expand Public API - Getters + + public string GetExpandAbbreviation() => ExpandAbbreviationBox?.Text ?? string.Empty; + + public string GetExpandTriggerKey() => _expandTriggerKey; + + public string GetExpandedText() => ExpandedTextBox?.Text ?? string.Empty; + + #endregion + + #region Expand Public API - Setters + + public void SetExpandAbbreviation(string abbreviation) + { + if (ExpandAbbreviationBox != null) + { + ExpandAbbreviationBox.Text = abbreviation; + } + } + + public void SetExpandTriggerKey(string triggerKey) + { + _expandTriggerKey = triggerKey; + if (ExpandTriggerKeyVisual != null) + { + ExpandTriggerKeyVisual.Content = triggerKey; + } + } + + public void SetExpandedText(string text) + { + if (ExpandedTextBox != null) + { + ExpandedTextBox.Text = text; + } + } + + public void SetTriggerType(TriggerType triggerType) + { + int index = triggerType switch + { + TriggerType.Expand => 1, + _ => 0, + }; + + if (TriggerTypeComboBox != null) + { + TriggerTypeComboBox.SelectedIndex = index; + } + } + + #endregion } } diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ActionType.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ActionType.cs index acaf60a4c347..a1cc84fe5b5c 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ActionType.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ActionType.cs @@ -11,5 +11,6 @@ public enum ActionType Shortcut, MouseClick, Url, + Expand, } } diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ExpandMapping.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ExpandMapping.cs new file mode 100644 index 000000000000..a3d3a499b083 --- /dev/null +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ExpandMapping.cs @@ -0,0 +1,27 @@ +// 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; + +namespace KeyboardManagerEditorUI.Helpers +{ + public class ExpandMapping : IToggleableShortcut + { + public List Shortcut { get; set; } = new List(); + + public string Abbreviation { get; set; } = string.Empty; + + public string TriggerKey { get; set; } = "Space"; + + public string ExpandedText { get; set; } = string.Empty; + + public bool IsAllApps { get; set; } = true; + + public string AppName { get; set; } = string.Empty; + + public bool IsActive { get; set; } = true; + + public string Id { get; set; } = string.Empty; + } +} diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ValidationErrorType.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ValidationErrorType.cs index 7177f497af52..2568691bd486 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ValidationErrorType.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ValidationErrorType.cs @@ -24,5 +24,8 @@ public enum ValidationErrorType EmptyUrl, EmptyProgramPath, OneKeyMapping, + EmptyExpandAbbreviation, + EmptyExpandedText, + DuplicateExpandAbbreviation, } } diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ValidationHelper.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ValidationHelper.cs index 526d6e01d2a1..3e474993d3e9 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ValidationHelper.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ValidationHelper.cs @@ -27,6 +27,9 @@ public static class ValidationHelper { ValidationErrorType.EmptyUrl, ("Missing URL", "Please enter the URL to open when the shortcut is pressed.") }, { ValidationErrorType.EmptyProgramPath, ("Missing Program Path", "Please enter the program path to launch when the shortcut is pressed.") }, { ValidationErrorType.OneKeyMapping, ("Invalid Remapping", "A single key cannot be remapped to a Program or URL shortcut. Please choose a combination of keys.") }, + { ValidationErrorType.EmptyExpandAbbreviation, ("Missing Abbreviation", "Please enter the abbreviation text that will trigger the expansion.") }, + { ValidationErrorType.EmptyExpandedText, ("Missing Expanded Text", "Please enter the text that the abbreviation will expand to.") }, + { ValidationErrorType.DuplicateExpandAbbreviation, ("Duplicate Abbreviation", "This abbreviation is already used by another text expansion.") }, }; public static ValidationErrorType ValidateKeyMapping( @@ -214,6 +217,34 @@ public static bool IsKeyOrphaned(int originalKey, KeyboardMappingService mapping return true; } + public static ValidationErrorType ValidateExpandMapping( + string abbreviation, + string expandedText, + List existingMappings, + bool isEditMode = false) + { + if (string.IsNullOrWhiteSpace(abbreviation)) + { + return ValidationErrorType.EmptyExpandAbbreviation; + } + + if (string.IsNullOrWhiteSpace(expandedText)) + { + return ValidationErrorType.EmptyExpandedText; + } + + int upperLimit = isEditMode ? 1 : 0; + int duplicateCount = existingMappings.Count(m => + string.Equals(m.Abbreviation, abbreviation, StringComparison.OrdinalIgnoreCase)); + + if (duplicateCount > upperLimit) + { + return ValidationErrorType.DuplicateExpandAbbreviation; + } + + return ValidationErrorType.NoError; + } + private static ValidationErrorType ValidateProgramOrUrlMapping( List originalKeys, bool isAppSpecific, diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/ShortcutOperationType.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/ShortcutOperationType.cs index 4a7472d1b707..815307606ae4 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/ShortcutOperationType.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/ShortcutOperationType.cs @@ -16,5 +16,6 @@ public enum ShortcutOperationType RunProgram = 1, OpenUri = 2, RemapText = 3, + ExpandText = 4, } } diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml index 7fbc97502e90..a25c929c969c 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml @@ -516,6 +516,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml.cs index e6b345d1b568..142e43af7d0b 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml.cs @@ -56,6 +56,8 @@ private set public ObservableCollection UrlShortcuts { get; } = new(); + public ObservableCollection ExpandMappings { get; } = new(); + [DllImport("PowerToys.KeyboardManagerEditorLibraryWrapper.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)] private static extern void GetKeyDisplayName(int keyCode, [Out] StringBuilder keyName, int maxLength); @@ -67,6 +69,7 @@ public enum ItemType TextMapping, ProgramShortcut, UrlShortcut, + ExpandMapping, } public ItemType Type { get; set; } @@ -226,6 +229,33 @@ private async void UrlShortcutsList_ItemClick(object sender, ItemClickEventArgs await ShowRemappingDialog(); } + private async void ExpandMappingsList_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is not ExpandMapping expandMapping) + { + return; + } + + _isEditMode = true; + _editingItem = new EditingItem + { + Type = EditingItem.ItemType.ExpandMapping, + Item = expandMapping, + OriginalTriggerKeys = new List(), + AppName = expandMapping.AppName, + IsAllApps = expandMapping.IsAllApps, + }; + + UnifiedMappingControl.Reset(); + UnifiedMappingControl.SetTriggerType(UnifiedMappingControl.TriggerType.Expand); + UnifiedMappingControl.SetExpandAbbreviation(expandMapping.Abbreviation); + UnifiedMappingControl.SetExpandTriggerKey(expandMapping.TriggerKey); + UnifiedMappingControl.SetExpandedText(expandMapping.ExpandedText); + UnifiedMappingControl.SetAppSpecific(!expandMapping.IsAllApps, expandMapping.AppName); + RemappingDialog.Title = "Edit remapping"; + await ShowRemappingDialog(); + } + private async System.Threading.Tasks.Task ShowRemappingDialog() { RemappingDialog.PrimaryButtonClick += RemappingDialog_PrimaryButtonClick; @@ -288,7 +318,10 @@ private void RemappingDialog_PrimaryButtonClick(ContentDialog sender, ContentDia { List triggerKeys = UnifiedMappingControl.GetTriggerKeys(); - if (triggerKeys == null || triggerKeys.Count == 0) + // Expand trigger type does not use traditional trigger keys + bool isExpandMode = UnifiedMappingControl.CurrentTriggerType == UnifiedMappingControl.TriggerType.Expand; + + if (!isExpandMode && (triggerKeys == null || triggerKeys.Count == 0)) { UnifiedMappingControl.ShowValidationError("Missing Original Keys", "Please enter at least one original key to create a remapping."); args.Cancel = true; @@ -314,6 +347,7 @@ private void RemappingDialog_PrimaryButtonClick(ContentDialog sender, ContentDia UnifiedMappingControl.ActionType.Text => SaveTextMapping(triggerKeys), UnifiedMappingControl.ActionType.OpenUrl => SaveUrlMapping(triggerKeys), UnifiedMappingControl.ActionType.OpenApp => SaveProgramMapping(triggerKeys), + UnifiedMappingControl.ActionType.ReplaceWith => SaveExpandMapping(), UnifiedMappingControl.ActionType.MouseClick => throw new NotImplementedException("Mouse click remapping is not yet supported."), _ => false, }; @@ -357,6 +391,8 @@ private ValidationErrorType ValidateMapping(UnifiedMappingControl.ActionType act triggerKeys, UnifiedMappingControl.GetUrl(), isAppSpecific, appName, _mappingService!, _isEditMode), UnifiedMappingControl.ActionType.OpenApp => ValidationHelper.ValidateAppMapping( triggerKeys, UnifiedMappingControl.GetProgramPath(), isAppSpecific, appName, _mappingService!, _isEditMode), + UnifiedMappingControl.ActionType.ReplaceWith => ValidationHelper.ValidateExpandMapping( + UnifiedMappingControl.GetExpandAbbreviation(), UnifiedMappingControl.GetExpandedText(), ExpandMappings.ToList(), _isEditMode), _ => ValidationErrorType.NoError, }; } @@ -561,6 +597,31 @@ private bool SaveProgramMapping(List triggerKeys) return saved; } + private bool SaveExpandMapping() + { + string abbreviation = UnifiedMappingControl.GetExpandAbbreviation(); + string triggerKey = UnifiedMappingControl.GetExpandTriggerKey(); + string expandedText = UnifiedMappingControl.GetExpandedText(); + + if (string.IsNullOrWhiteSpace(abbreviation) || string.IsNullOrWhiteSpace(expandedText)) + { + return false; + } + + var shortcutKeyMapping = new ShortcutKeyMapping + { + OperationType = ShortcutOperationType.ExpandText, + OriginalKeys = abbreviation, + TargetKeys = triggerKey, + TargetText = expandedText, + TargetApp = UnifiedMappingControl.GetIsAppSpecific() ? UnifiedMappingControl.GetAppName() : string.Empty, + }; + + // For now, only persist to editor settings (backend logic will be added later) + SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping); + return true; + } + #endregion #region Delete Handlers @@ -726,12 +787,13 @@ private void LoadAllMappings() LoadTextMappings(); LoadProgramShortcuts(); LoadUrlShortcuts(); + LoadExpandMappings(); UpdateHasAnyMappings(); } private void UpdateHasAnyMappings() { - bool hasAny = RemappingList.Count > 0 || TextMappings.Count > 0 || ProgramShortcuts.Count > 0 || UrlShortcuts.Count > 0; + bool hasAny = RemappingList.Count > 0 || TextMappings.Count > 0 || ProgramShortcuts.Count > 0 || UrlShortcuts.Count > 0 || ExpandMappings.Count > 0; MappingState = hasAny ? "HasMappings" : "Empty"; } @@ -857,6 +919,35 @@ private void LoadUrlShortcuts() } } + private void LoadExpandMappings() + { + SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.ExpandText, out var expandIds); + + if (expandIds == null) + { + return; + } + + ExpandMappings.Clear(); + + foreach (var id in expandIds) + { + ShortcutSettings shortcutSettings = SettingsManager.EditorSettings.ShortcutSettingsDictionary[id]; + ShortcutKeyMapping mapping = shortcutSettings.Shortcut; + + ExpandMappings.Add(new ExpandMapping + { + Abbreviation = mapping.OriginalKeys, + TriggerKey = mapping.TargetKeys, + ExpandedText = mapping.TargetText, + IsAllApps = string.IsNullOrEmpty(mapping.TargetApp), + AppName = mapping.TargetApp ?? string.Empty, + Id = shortcutSettings.Id, + IsActive = shortcutSettings.IsActive, + }); + } + } + private List ParseKeyCodes(string keyCodesString) { return keyCodesString.Split(';') diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Strings/en-US/Resources.resw b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Strings/en-US/Resources.resw index 00a32af4d5ca..76578766ff79 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Strings/en-US/Resources.resw +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Strings/en-US/Resources.resw @@ -292,4 +292,40 @@ Mouse click action - coming soon - \ No newline at end of file + + Text expand + + + Text expand + + + Abbreviation + + + e.g. brb + + + Trigger key + + + Replace with + + + Replace with + + + Expanded text + + + e.g. be right back + + + Text expansions + + + expands to + + + via + + From a67cfd7f39d6792bfc6e5fec27078dd17b288dac Mon Sep 17 00:00:00 2001 From: "Yu Leng (from Dev Box)" Date: Fri, 20 Mar 2026 08:36:10 +0800 Subject: [PATCH 02/10] [KBM] Fix Action panel not showing for Text Expand trigger type Manually sync SwitchPresenter.Value after programmatic ComboBox item changes, since WinUI binding on SelectedItem.Tag does not reliably update when items are cleared and replaced at runtime. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controls/UnifiedMappingControl.xaml.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs index b64d2c7f0e98..e6b253ac7603 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs @@ -1205,6 +1205,10 @@ private void SwitchActionTypeToReplaceWith() ActionTypeComboBox.Items.Add(replaceWithItem); ActionTypeComboBox.SelectedIndex = 0; ActionTypeComboBox.IsEnabled = false; + + // Manually update SwitchPresenter since programmatic ComboBox item changes + // may not reliably trigger the SelectedItem.Tag binding update in WinUI. + ActionSwitchPresenter.Value = "ReplaceWith"; } private void RestoreNormalActionTypes() @@ -1223,6 +1227,12 @@ private void RestoreNormalActionTypes() _savedActionTypeItems = null; ActionTypeComboBox.SelectedIndex = 0; ActionTypeComboBox.IsEnabled = true; + + // Manually sync SwitchPresenter with the restored first item's Tag. + if (ActionTypeComboBox.SelectedItem is ComboBoxItem selectedItem && selectedItem.Tag is string tag) + { + ActionSwitchPresenter.Value = tag; + } } #endregion From 48e56690f7904ee91bd53188a5804b572e89fe99 Mon Sep 17 00:00:00 2001 From: "Yu Leng (from Dev Box)" Date: Fri, 20 Mar 2026 09:02:02 +0800 Subject: [PATCH 03/10] [KBM] Fix Action panel not showing for Text Expand trigger type Replace XAML binding on SwitchPresenter.Value with code-behind sync to avoid binding/direct-assignment conflict when ComboBox items are dynamically replaced for the Expand trigger type. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controls/UnifiedMappingControl.xaml | 2 +- .../Controls/UnifiedMappingControl.xaml.cs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml index abe46df72312..6b0f56aa20c6 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml @@ -265,7 +265,7 @@ + Value="KeyOrShortcut"> Date: Fri, 20 Mar 2026 09:21:25 +0800 Subject: [PATCH 04/10] [KBM] Reuse Text action for Expand trigger instead of separate ReplaceWith case When Text Expand trigger is selected, select the existing "Insert text" ComboBoxItem, override its label/icon to "Replace with", and disable the ComboBox. This avoids dynamic ComboBox item replacement that broke the SwitchPresenter binding. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controls/UnifiedMappingControl.xaml | 21 +--- .../Controls/UnifiedMappingControl.xaml.cs | 109 +++++++----------- 2 files changed, 43 insertions(+), 87 deletions(-) diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml index 6b0f56aa20c6..955eb7bbb0e4 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml @@ -230,10 +230,10 @@ - + - - + + @@ -409,20 +409,7 @@ - - - - + ? _savedActionTypeItems; + // Saved label/icon for restoring "Text" action item after Expand mode + private string? _savedTextLabel; + private string? _savedTextIconGlyph; public bool AllowChords { get; set; } = true; @@ -134,6 +134,12 @@ public ActionType CurrentActionType { get { + // When in Expand trigger mode, the action is always ReplaceWith + if (CurrentTriggerType == TriggerType.Expand) + { + return ActionType.ReplaceWith; + } + if (ActionTypeComboBox?.SelectedItem is ComboBoxItem item) { return item.Tag?.ToString() switch @@ -142,7 +148,6 @@ public ActionType CurrentActionType "OpenUrl" => ActionType.OpenUrl, "OpenApp" => ActionType.OpenApp, "MouseClick" => ActionType.MouseClick, - "ReplaceWith" => ActionType.ReplaceWith, _ => ActionType.KeyOrShortcut, }; } @@ -611,7 +616,7 @@ public bool IsInputComplete() if (CurrentTriggerType == TriggerType.Expand) { return !string.IsNullOrWhiteSpace(ExpandAbbreviationBox?.Text) - && !string.IsNullOrWhiteSpace(ExpandedTextBox?.Text); + && !string.IsNullOrWhiteSpace(TextContentBox?.Text); } // Trigger keys are always required for other trigger types @@ -872,7 +877,7 @@ private void UpdateInlineValidation() return; } - if (ExpandedTextBox != null && _expandedTextDirty && string.IsNullOrWhiteSpace(ExpandedTextBox.Text)) + if (TextContentBox != null && _textContentDirty && string.IsNullOrWhiteSpace(TextContentBox.Text)) { ShowValidationErrorFromType(ValidationErrorType.EmptyExpandedText); return; @@ -901,7 +906,6 @@ public void Reset() _urlPathDirty = false; _programPathDirty = false; _expandAbbreviationDirty = false; - _expandedTextDirty = false; // Reset expand state _expandTriggerKey = "Space"; @@ -912,10 +916,7 @@ public void Reset() ExpandAbbreviationBox.Text = string.Empty; } - if (ExpandedTextBox != null) - { - ExpandedTextBox.Text = string.Empty; - } + // TextContentBox is shared with expand mode; cleared below with other action fields if (ExpandTriggerKeyVisual != null) { @@ -1139,18 +1140,6 @@ private void ExpandTriggerKeyToggleBtn_Unchecked(object sender, RoutedEventArgs } } - private void ExpandedTextBox_GotFocus(object sender, RoutedEventArgs e) - { - CleanupKeyboardHook(); - UncheckAllToggleButtons(); - } - - private void ExpandedTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - _expandedTextDirty = true; - RaiseValidationStateChanged(); - } - private void HideAppSpecific() { if (AppSpecificCheckBox != null) @@ -1174,71 +1163,51 @@ private void ShowAppSpecific() private void SwitchActionTypeToReplaceWith() { - if (ActionTypeComboBox == null) + if (ActionTypeComboBox == null || ActionTypeTextItem == null) { return; } - // Save current items if not already saved - if (_savedActionTypeItems == null) - { - _savedActionTypeItems = new List(); - foreach (var actionItem in ActionTypeComboBox.Items) - { - _savedActionTypeItems.Add(actionItem); - } - } - - // Replace with single "Replace with" item - ActionTypeComboBox.Items.Clear(); + // Select the "Text" action item and override its label to "Replace with" + ActionTypeComboBox.SelectedItem = ActionTypeTextItem; + ActionTypeComboBox.IsEnabled = false; var resourceLoader = new ResourceLoader(); - var replaceWithItem = new ComboBoxItem + if (ActionTypeTextLabel != null) { - Tag = "ReplaceWith", - Content = new StackPanel - { - Orientation = Microsoft.UI.Xaml.Controls.Orientation.Horizontal, - Spacing = 8, - Children = - { - new FontIcon { Glyph = "\uE8C8", FontSize = 14 }, - new TextBlock { Text = resourceLoader.GetString("ActionType_ReplaceWith_Text/Text") }, - }, - }, - }; - - ActionTypeComboBox.Items.Add(replaceWithItem); - ActionTypeComboBox.SelectedIndex = 0; - ActionTypeComboBox.IsEnabled = false; + _savedTextLabel = ActionTypeTextLabel.Text; + ActionTypeTextLabel.Text = resourceLoader.GetString("ActionType_ReplaceWith_Text/Text"); + } - // Manually update SwitchPresenter since programmatic ComboBox item changes - // may not reliably trigger the SelectedItem.Tag binding update in WinUI. - ActionSwitchPresenter.Value = "ReplaceWith"; + if (ActionTypeTextIcon != null) + { + _savedTextIconGlyph = ActionTypeTextIcon.Glyph; + ActionTypeTextIcon.Glyph = "\uE8C8"; + } } private void RestoreNormalActionTypes() { - if (ActionTypeComboBox == null || _savedActionTypeItems == null) + if (ActionTypeComboBox == null) { return; } - ActionTypeComboBox.Items.Clear(); - foreach (var actionItem in _savedActionTypeItems) + // Restore original label and icon on the "Text" action item + if (ActionTypeTextLabel != null && _savedTextLabel != null) { - ActionTypeComboBox.Items.Add(actionItem); + ActionTypeTextLabel.Text = _savedTextLabel; + _savedTextLabel = null; } - _savedActionTypeItems = null; - ActionTypeComboBox.SelectedIndex = 0; - ActionTypeComboBox.IsEnabled = true; - - // Manually sync SwitchPresenter with the restored first item's Tag. - if (ActionTypeComboBox.SelectedItem is ComboBoxItem selectedItem && selectedItem.Tag is string tag) + if (ActionTypeTextIcon != null && _savedTextIconGlyph != null) { - ActionSwitchPresenter.Value = tag; + ActionTypeTextIcon.Glyph = _savedTextIconGlyph; + _savedTextIconGlyph = null; } + + ActionTypeComboBox.SelectedIndex = 0; + ActionTypeComboBox.IsEnabled = true; } #endregion @@ -1249,7 +1218,7 @@ private void RestoreNormalActionTypes() public string GetExpandTriggerKey() => _expandTriggerKey; - public string GetExpandedText() => ExpandedTextBox?.Text ?? string.Empty; + public string GetExpandedText() => TextContentBox?.Text ?? string.Empty; #endregion @@ -1274,9 +1243,9 @@ public void SetExpandTriggerKey(string triggerKey) public void SetExpandedText(string text) { - if (ExpandedTextBox != null) + if (TextContentBox != null) { - ExpandedTextBox.Text = text; + TextContentBox.Text = text; } } From 7c7f8fb43a43fd820ae40d4e05d5f829ee796282 Mon Sep 17 00:00:00 2001 From: "Yu Leng (from Dev Box)" Date: Fri, 20 Mar 2026 09:34:00 +0800 Subject: [PATCH 05/10] [KBM] Fix SA1512 StyleCop violation in Reset() Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controls/UnifiedMappingControl.xaml.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs index 1ba67e4b24a2..d1968bbe4b5a 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs @@ -916,8 +916,6 @@ public void Reset() ExpandAbbreviationBox.Text = string.Empty; } - // TextContentBox is shared with expand mode; cleared below with other action fields - if (ExpandTriggerKeyVisual != null) { ExpandTriggerKeyVisual.Content = "Space"; From 7fe58e8cdb98cbce339764eec20226785c3e2b03 Mon Sep 17 00:00:00 2001 From: "Yu Leng (from Dev Box)" Date: Fri, 20 Mar 2026 09:48:13 +0800 Subject: [PATCH 06/10] [KBM] Fix crash: ResourceLoader unavailable in unpackaged WinUI3 app Replace `new ResourceLoader()` with a string literal since the EditorUI runs as an unpackaged app where ResourceLoader is not supported. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controls/UnifiedMappingControl.xaml.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs index d1968bbe4b5a..b33d416a98f0 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs @@ -9,7 +9,6 @@ using KeyboardManagerEditorUI.Helpers; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using Windows.ApplicationModel.Resources; using Windows.Storage; using Windows.Storage.Pickers; using Windows.System; @@ -1170,11 +1169,10 @@ private void SwitchActionTypeToReplaceWith() ActionTypeComboBox.SelectedItem = ActionTypeTextItem; ActionTypeComboBox.IsEnabled = false; - var resourceLoader = new ResourceLoader(); if (ActionTypeTextLabel != null) { _savedTextLabel = ActionTypeTextLabel.Text; - ActionTypeTextLabel.Text = resourceLoader.GetString("ActionType_ReplaceWith_Text/Text"); + ActionTypeTextLabel.Text = "Replace with"; } if (ActionTypeTextIcon != null) From 0e137a32137eb7be754dd75631995038d63e6115 Mon Sep 17 00:00:00 2001 From: "Yu Leng (from Dev Box)" Date: Fri, 10 Apr 2026 02:26:39 +0800 Subject: [PATCH 07/10] [KBM] Add Text Expand engine backend using TSF4 API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the engine-side logic for the Text Expand feature using Windows TSF4 (Text Services Framework 4) APIs. When a trigger key is pressed, the engine reads the last N characters from the focused text box via TSF4 and replaces the abbreviation with expanded text — without touching the clipboard. - Add Tsf4TextReplacer (Initialize/TryExpand/Shutdown) with LAF unlock - Add ExpandMapping data structure, JSON load/save in MappingConfiguration - Add HandleExpandTextEvent in keyboard event pipeline - Wire C# editor to persist expand mappings to engine config (default.json) - Add TSF4 support status card in Settings UI (KBM page) - Disable Text Expand option in KBM editor when OS lacks TSF4 support - Register KeyboardManagerEngine in sparse package manifest for identity Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 71 ++++++++ src/PackageIdentity/AppxManifest.xml | 10 ++ src/common/ManagedCommon/OSVersionHelper.cs | 9 + .../KeyboardManagerEditorLibraryWrapper.cpp | 18 ++ .../KeyboardManagerEditorLibraryWrapper.h | 2 + .../Controls/UnifiedMappingControl.xaml | 2 +- .../Controls/UnifiedMappingControl.xaml.cs | 6 + .../Interop/KeyboardManagerInterop.cs | 9 + .../Interop/KeyboardMappingService.cs | 5 + .../Pages/MainPage.xaml.cs | 17 +- .../KeyboardManagerEngine/main.cpp | 5 + .../KeyboardEventHandlers.cpp | 60 +++++++ .../KeyboardEventHandlers.h | 3 + .../KeyboardManager.cpp | 10 +- .../KeyboardManagerEngineLibrary.vcxproj | 10 ++ .../Tsf4TextReplacer.cpp | 164 ++++++++++++++++++ .../Tsf4TextReplacer.h | 24 +++ .../KeyboardManagerEngineLibrary/pch.h | 1 + .../common/KeyboardManagerConstants.h | 9 + .../common/MappingConfiguration.cpp | 70 ++++++++ .../common/MappingConfiguration.h | 15 ++ .../Views/KeyboardManagerPage.xaml | 46 +++++ .../Settings.UI/Strings/en-us/Resources.resw | 32 ++++ .../ViewModels/KeyboardManagerViewModel.cs | 7 + 24 files changed, 601 insertions(+), 4 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/modules/keyboardmanager/KeyboardManagerEngineLibrary/Tsf4TextReplacer.cpp create mode 100644 src/modules/keyboardmanager/KeyboardManagerEngineLibrary/Tsf4TextReplacer.h diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000000..48afbeab3ef0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Communication + +Always respond in Chinese (中文). + +## Project Overview + +PowerToys is a collection of Windows productivity utilities written in C++ and C#. The main solution is `PowerToys.slnx`. Each utility is a "module" loaded by the Runner via a standardized DLL interface. + +## Architecture + +- **Runner** (`src/runner/`): Main executable (PowerToys.exe). Loads module DLLs, manages hotkeys, shows tray icon, bridges modules to Settings UI via named pipes (JSON IPC). +- **Settings UI** (`src/settings-ui/`): WinUI/WPF configuration app. Communicates with Runner over named pipes. Changes to IPC contracts must update both sides in the same PR. +- **Modules** (`src/modules/`): ~30 individual utilities, each implementing the module interface (`src/modules/interface/`). Four module types: simple (self-contained DLL), external app launcher (separate process + IPC), context handler (shell extension), registry-based (preview handlers). +- **Common** (`src/common/`): Shared libraries — logging, IPC, settings serialization, DPI utilities, telemetry, JSON/string helpers. Changes here affect the entire codebase. +- **Installer** (`installer/`): WiX-based installer projects. Separate solution at `installer/PowerToysSetup.slnx`. + +## Build Commands + +Prerequisites: Visual Studio 2022 17.4+ or VS 2026, Windows 10 1803+. Initialize submodules once: `git submodule update --init --recursive`. + +| Task | Command | +|------|---------| +| First build / NuGet restore | `tools\build\build-essentials.cmd` | +| Build current folder's project | `cd` to the `.csproj`/`.vcxproj` folder, then `tools\build\build.cmd` | +| Build with options | `tools\build\build.ps1 -Platform x64 -Configuration Release` | +| Full installer build | `tools\build\build-installer.ps1 -Platform x64 -Configuration Release -PerUser true -InstallerSuffix wix5` | +| Format XAML | `.\.pipelines\applyXamlStyling.ps1 -Main` | +| Format C++ | `src\codeAnalysis\format_sources.ps1` (formats git-modified files) | + +Build logs appear next to the solution/project: `build...errors.log` (check first), `.all.log`, `.trace.binlog`. + +## Testing + +- **Do NOT use `dotnet test`** — use VS Test Explorer (`Ctrl+E, T`) or `vstest.console.exe` with filters. +- Build the test project first (exit code 0) before running tests. +- Test projects are named `*UnitTests` or `*UITests`, typically sibling folders or 1-2 levels up from the product code. +- UI Tests require WinAppDriver v1.2.1 and Developer Mode enabled. + +## Style and Formatting + +- **C++**: `src/.clang-format` — auto-format with `Ctrl+K Ctrl+D` in VS or run `format_sources.ps1`. +- **C#**: `src/.editorconfig` + StyleCop.Analyzers. +- **XAML**: XamlStyler — VS extension or `applyXamlStyling.ps1`. +- Follow existing style in modified files. New code should follow Modern C++ / C++ Core Guidelines. + +## Logging + +- **C++**: spdlog via `init_logger()`. Include `spdlog.props` in `.vcxproj`. Use `Logger::info/warn/error/debug`. +- **C#**: `ManagedCommon.Logger`. Call `Logger.InitializeLogger("\\Module\\Logs")` at startup. Use `Logger.LogInfo/LogWarning/LogError/LogDebug`. +- Log files: `%LOCALAPPDATA%\Microsoft\PowerToys\Logs`. Low-privilege processes use `%USERPROFILE%\AppData\LocalLow\Microsoft\PowerToys`. +- No logging in hot paths (hooks, tight loops, timers). + +## Key Constraints + +- Atomic PRs: one logical change per PR, no drive-by refactors. +- IPC/JSON contract changes must update both `src/runner/` and `src/settings-ui/` together. +- Changes to `src/common/` public APIs: grep the entire codebase for usages and update all callers. +- New third-party dependencies must be MIT-licensed (or PM-approved) and added to `NOTICE.md`. +- Settings schema changes require migration logic and serialization tests. +- New modules with file I/O or user input must include fuzzing tests. + +## Documentation + +- [Architecture](doc/devdocs/core/architecture.md), [Runner](doc/devdocs/core/runner.md), [Settings](doc/devdocs/core/settings/readme.md) +- [Coding Guidelines](doc/devdocs/development/guidelines.md), [Style](doc/devdocs/development/style.md), [Logging](doc/devdocs/development/logging.md) +- [Build Guidelines](tools/build/BUILD-GUIDELINES.md), [Module Interface](doc/devdocs/modules/interface.md) +- [AGENTS.md](AGENTS.md) — full AI contributor guide with detailed conventions diff --git a/src/PackageIdentity/AppxManifest.xml b/src/PackageIdentity/AppxManifest.xml index 502cc33ff00d..d14bc56ea54d 100644 --- a/src/PackageIdentity/AppxManifest.xml +++ b/src/PackageIdentity/AppxManifest.xml @@ -68,6 +68,16 @@ AppListEntry="none"> + + + + = 10 && Environment.OSVersion.Version.Build > 22000; } + + /// + /// TSF4 (Text Services Framework 4) APIs require Windows 11 build 20000+. + /// TODO: Confirm the exact minimum build number once finalized. + /// + public static bool IsTsf4Supported() + { + return Environment.OSVersion.Version.Major >= 10 && Environment.OSVersion.Version.Build >= 99999; + } } } diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.cpp b/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.cpp index 21f8fd54e542..9add2da12e2c 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.cpp @@ -678,6 +678,24 @@ bool GetShortcutRemapByType(void* config, int operationType, int index, Shortcut return false; } + bool AddExpandMapping(void* config, const wchar_t* abbreviation, int triggerKey, const wchar_t* expandedText, const wchar_t* targetApp) + { + auto mappingConfig = static_cast(config); + if (!abbreviation || !expandedText) + { + return false; + } + + ExpandMapping mapping; + mapping.abbreviation = abbreviation; + mapping.triggerKey = static_cast(triggerKey); + mapping.expandedText = expandedText; + mapping.appName = targetApp ? targetApp : L""; + + mappingConfig->expandMappings.push_back(std::move(mapping)); + return true; + } + // Function to delete a shortcut remapping bool DeleteShortcutRemap(void* config, const wchar_t* originalKeys, const wchar_t* targetApp) { diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.h b/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.h index 7a5684b14e15..853f679dca52 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.h +++ b/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.h @@ -79,6 +79,8 @@ extern "C" __declspec(dllexport) bool IsShortcutIllegal(const wchar_t* shortcutKeys); __declspec(dllexport) bool AreShortcutsEqual(const wchar_t* lShort, const wchar_t* rShort); + __declspec(dllexport) bool AddExpandMapping(void* config, const wchar_t* abbreviation, int triggerKey, const wchar_t* expandedText, const wchar_t* targetApp); + __declspec(dllexport) bool DeleteSingleKeyRemap(void* config, int originalKey); __declspec(dllexport) bool DeleteSingleKeyToTextRemap(void* config, int originalKey); __declspec(dllexport) bool DeleteShortcutRemap(void* config, const wchar_t* originalKeys, const wchar_t* targetApp); diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml index 955eb7bbb0e4..4e9e79a93c54 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml @@ -46,7 +46,7 @@ - + diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs index ab4ed3be5c34..4f42d3f7113a 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs @@ -169,6 +169,12 @@ public UnifiedMappingControl() _triggerKeys.CollectionChanged += (_, _) => RaiseValidationStateChanged(); _actionKeys.CollectionChanged += (_, _) => RaiseValidationStateChanged(); + // Disable Text Expand option when TSF4 API is not available on this OS + if (!ManagedCommon.OSVersionHelper.IsTsf4Supported()) + { + TriggerType_ExpandItem.IsEnabled = false; + } + this.Unloaded += UnifiedMappingControl_Unloaded; } diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/KeyboardManagerInterop.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/KeyboardManagerInterop.cs index ee66add15347..f35f7405e5e8 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/KeyboardManagerInterop.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/KeyboardManagerInterop.cs @@ -85,6 +85,15 @@ internal static extern bool AddShortcutRemap( int ifRunningAction = 0, int visibility = 0); + [DllImport(DllName, CallingConvention = Convention, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool AddExpandMapping( + IntPtr config, + [MarshalAs(UnmanagedType.LPWStr)] string abbreviation, + int triggerKey, + [MarshalAs(UnmanagedType.LPWStr)] string expandedText, + [MarshalAs(UnmanagedType.LPWStr)] string targetApp); + // Delete Mapping Functions [DllImport(DllName, CallingConvention = Convention)] [return: MarshalAs(UnmanagedType.Bool)] diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/KeyboardMappingService.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/KeyboardMappingService.cs index f4c2e6a6965c..58605f8ab5db 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/KeyboardMappingService.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/KeyboardMappingService.cs @@ -238,6 +238,11 @@ public bool AddShortcutMapping(ShortcutKeyMapping shortcutKeyMapping) (int)shortcutKeyMapping.OperationType); } + public bool AddExpandMapping(string abbreviation, int triggerKey, string expandedText, string targetApp) + { + return KeyboardManagerInterop.AddExpandMapping(_configHandle, abbreviation, triggerKey, expandedText, targetApp); + } + public bool SaveSettings() { return KeyboardManagerInterop.SaveMappingSettings(_configHandle); diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml.cs index 142e43af7d0b..cf0f467bec55 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml.cs @@ -608,16 +608,29 @@ private bool SaveExpandMapping() return false; } + string targetApp = UnifiedMappingControl.GetIsAppSpecific() ? UnifiedMappingControl.GetAppName() : string.Empty; + var shortcutKeyMapping = new ShortcutKeyMapping { OperationType = ShortcutOperationType.ExpandText, OriginalKeys = abbreviation, TargetKeys = triggerKey, TargetText = expandedText, - TargetApp = UnifiedMappingControl.GetIsAppSpecific() ? UnifiedMappingControl.GetAppName() : string.Empty, + TargetApp = targetApp, }; - // For now, only persist to editor settings (backend logic will be added later) + // Resolve trigger key name to VK code (e.g., "Space" → 0x20) + int triggerKeyVk = KeyboardManagerInterop.GetKeyCodeFromName(triggerKey); + if (triggerKeyVk == 0) + { + triggerKeyVk = 0x20; // Default to VK_SPACE + } + + // Persist to engine config (default.json) via C++ interop + _mappingService?.AddExpandMapping(abbreviation, triggerKeyVk, expandedText, targetApp); + _mappingService?.SaveSettings(); + + // Also persist to editor settings for UI state SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping); return true; } diff --git a/src/modules/keyboardmanager/KeyboardManagerEngine/main.cpp b/src/modules/keyboardmanager/KeyboardManagerEngine/main.cpp index 0d5cfc0955d3..42179cf5e44b 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngine/main.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEngine/main.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -73,6 +74,9 @@ int WINAPI wWinMain(_In_ HINSTANCE /*hInstance*/, }); } + // Initialize TSF4 text input provider on the main thread (before the message loop). + Tsf4TextReplacer::Initialize(); + auto kbm = KeyboardManager(); if (kbm.HasRegisteredRemappings()) kbm.StartLowlevelKeyboardHook(); @@ -84,6 +88,7 @@ int WINAPI wWinMain(_In_ HINSTANCE /*hInstance*/, run_message_loop({}, {}, { { KeyboardManager::StartHookMessageID, StartHookFunc } }); kbm.StopLowlevelKeyboardHook(); + Tsf4TextReplacer::Shutdown(); Trace::UnregisterProvider(); trace.Flush(); diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.cpp b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.cpp index 0e8529396bc2..f7657a527993 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.cpp @@ -8,6 +8,7 @@ #include #include #include +#include "Tsf4TextReplacer.h" #include #include @@ -1801,4 +1802,63 @@ namespace KeyboardEventHandlers return 1; } + + // Function to handle text expansion (abbreviation → expanded text) via TSF4. + // When the trigger key is pressed, reads the last N characters from the focused + // text box. If they match an abbreviation, replaces them with the expanded text. + intptr_t HandleExpandTextEvent(LowlevelKeyboardEvent* data, State& state) + { + if (GeneratedByKBM(data)) + { + return 0; + } + + if (data->wParam != WM_KEYDOWN) + { + return 0; + } + + if (!Tsf4TextReplacer::IsAvailable()) + { + return 0; + } + + DWORD vkCode = data->lParam->vkCode; + + // Check if this key is a trigger key for any expand mapping. + // Also filter by the current application if app-specific. + std::wstring currentApp; + bool currentAppResolved = false; + + for (const auto& mapping : state.expandMappings) + { + if (mapping.triggerKey != vkCode) + { + continue; + } + + // Check app-specific filter + if (!mapping.appName.empty()) + { + if (!currentAppResolved) + { + currentApp = Helpers::GetCurrentApplication(false); + currentAppResolved = true; + } + + // Case-insensitive comparison + if (_wcsicmp(currentApp.c_str(), mapping.appName.c_str()) != 0) + { + continue; + } + } + + if (Tsf4TextReplacer::TryExpand(mapping.abbreviation, mapping.expandedText)) + { + return 1; // Swallow the trigger key + } + } + + return 0; + } } diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.h b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.h index 8797aac31135..96c1bdb0f59e 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.h +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.h @@ -82,6 +82,9 @@ namespace KeyboardEventHandlers // Function to generate a unicode string in response to a single keypress intptr_t HandleSingleKeyToTextRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state); + // Function to handle text expansion (abbreviation → expanded text) via TSF4 + intptr_t HandleExpandTextEvent(LowlevelKeyboardEvent* data, State& state); + // Function to ensure Ctrl/Shift/Alt modifier key state is not detected as pressed down by applications which detect keys at a lower level than hooks when it is remapped for scenarios where its required void ResetIfModifierKeyForLowerLevelKeyHandlers(KeyboardManagerInput::InputInterface& ii, DWORD key, DWORD target); }; diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManager.cpp b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManager.cpp index 3eb326152474..00259347ebf2 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManager.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManager.cpp @@ -181,7 +181,7 @@ bool KeyboardManager::HasRegisteredRemappings() const bool KeyboardManager::HasRegisteredRemappingsUnchecked() const { - return !(state.appSpecificShortcutReMap.empty() && state.appSpecificShortcutReMapSortedKeys.empty() && state.osLevelShortcutReMap.empty() && state.osLevelShortcutReMapSortedKeys.empty() && state.singleKeyReMap.empty() && state.singleKeyToTextReMap.empty()); + return !(state.appSpecificShortcutReMap.empty() && state.appSpecificShortcutReMapSortedKeys.empty() && state.osLevelShortcutReMap.empty() && state.osLevelShortcutReMapSortedKeys.empty() && state.singleKeyReMap.empty() && state.singleKeyToTextReMap.empty() && state.expandMappings.empty()); } intptr_t KeyboardManager::HandleKeyboardHookEvent(LowlevelKeyboardEvent* data) noexcept @@ -233,6 +233,14 @@ intptr_t KeyboardManager::HandleKeyboardHookEvent(LowlevelKeyboardEvent* data) n return 1; } + // Handle text expansion (abbreviation + trigger key → expanded text via TSF4) + intptr_t ExpandTextResult = KeyboardEventHandlers::HandleExpandTextEvent(data, state); + + if (ExpandTextResult == 1) + { + return 1; + } + // Handle an os-level shortcut remapping return KeyboardEventHandlers::HandleOSLevelShortcutRemapEvent(inputHandler, data, state); } diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManagerEngineLibrary.vcxproj b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManagerEngineLibrary.vcxproj index f169c213cc7a..6291d3a91da1 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManagerEngineLibrary.vcxproj +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManagerEngineLibrary.vcxproj @@ -38,10 +38,12 @@ + + Create @@ -56,6 +58,14 @@ + + + + $(WindowsSdkDir)References\10.0.26100.0\Windows.UI.Input.Preview.Text.PreviewTextContract\1.0.0.0\Windows.UI.Input.Preview.Text.PreviewTextContract.winmd + true + false + + diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/Tsf4TextReplacer.cpp b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/Tsf4TextReplacer.cpp new file mode 100644 index 000000000000..d7353c1be111 --- /dev/null +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/Tsf4TextReplacer.cpp @@ -0,0 +1,164 @@ +#include "pch.h" +#include "Tsf4TextReplacer.h" + +#include +#include +#include + +using namespace winrt::Windows::UI::Input::Preview::Text; +using namespace winrt::Windows::UI::Text::Core; + +namespace +{ + TextInputProvider s_provider{ nullptr }; + bool s_initialized = false; + bool s_available = false; + + // LAF token computed for PFN: Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe + constexpr wchar_t LafFeatureId[] = L"com.microsoft.windows.textinputmethod"; + constexpr wchar_t LafToken[] = L"fs0FTtO4rVEbMtnhuNmCNA=="; + constexpr wchar_t LafPublisherId[] = L"8wekyb3d8bbwe"; + + void UnlockTextInputMethodFeature() noexcept + { + try + { + std::wstring attestation = std::wstring(LafPublisherId) + L" has registered their use of " + LafFeatureId + L" with Microsoft and agrees to the terms of use."; + + auto result = winrt::Windows::ApplicationModel::LimitedAccessFeatures::TryUnlockFeature( + LafFeatureId, LafToken, attestation); + + Logger::info(L"TSF4 LAF unlock status: {}", static_cast(result.Status())); + } + catch (const winrt::hresult_error& ex) + { + Logger::warn(L"TSF4 LAF unlock failed: {}", ex.message().c_str()); + } + catch (...) + { + Logger::warn(L"TSF4 LAF unlock failed (no package identity?)"); + } + } +} + +namespace Tsf4TextReplacer +{ + void Initialize() noexcept + { + if (s_initialized) + { + return; + } + + s_initialized = true; + + UnlockTextInputMethodFeature(); + + try + { + auto service = TextInputService::GetForCurrentThread(); + if (!service) + { + Logger::warn(L"TSF4: TextInputService not available on this thread"); + return; + } + + s_provider = service.CreateTextInputProvider(L""); + if (!s_provider) + { + Logger::warn(L"TSF4: Failed to create TextInputProvider"); + return; + } + + TextInputServiceSubscription subscription{}; + subscription.requiredEnabledFeatures = TextBoxFeatures::None; + s_provider.SetSubscription(subscription); + + s_available = true; + Logger::info(L"TSF4: Text input provider initialized successfully"); + } + catch (const winrt::hresult_error& ex) + { + Logger::warn(L"TSF4: Initialization failed: {}", ex.message().c_str()); + } + catch (...) + { + Logger::warn(L"TSF4: Initialization failed with unknown exception"); + } + } + + bool IsAvailable() noexcept + { + return s_available && s_provider; + } + + bool TryExpand(const std::wstring& abbreviation, const std::wstring& expandedText) noexcept + { + if (!s_available || !s_provider) + { + return false; + } + + try + { + if (!s_provider.HasFocusedTextBox()) + { + return false; + } + + auto session = s_provider.CreateEditSession(); + if (!session) + { + return false; + } + + int textLength = session.TextLength(); + int abbrevLen = static_cast(abbreviation.length()); + + if (textLength < abbrevLen) + { + session.SubmitPayload(); + return false; + } + + // Read the last N characters (where N = abbreviation length) + CoreTextRange range{}; + range.StartCaretPosition = textLength - abbrevLen; + range.EndCaretPosition = textLength; + + winrt::hstring tail = session.GetText(range); + + // Case-insensitive comparison + if (_wcsicmp(tail.c_str(), abbreviation.c_str()) == 0) + { + session.ReplaceText(range, expandedText); + session.SubmitPayload(); + return true; + } + + session.SubmitPayload(); + return false; + } + catch (const winrt::hresult_error&) + { + return false; + } + catch (...) + { + return false; + } + } + + void Shutdown() noexcept + { + try + { + s_provider = nullptr; + s_available = false; + s_initialized = false; + } + catch (...) + { + } + } +} diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/Tsf4TextReplacer.h b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/Tsf4TextReplacer.h new file mode 100644 index 000000000000..f9c818680d26 --- /dev/null +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/Tsf4TextReplacer.h @@ -0,0 +1,24 @@ +#pragma once + +// TSF4 (Text Services Framework 4) text expansion utilities. +// Uses Windows.UI.Input.Preview.Text APIs to read/replace text in the +// currently focused text box for the Expand (abbreviation expansion) feature. + +namespace Tsf4TextReplacer +{ + // Initialize the TSF4 provider on the current thread. + // Must be called on a thread that has a message pump (typically the main thread). + void Initialize() noexcept; + + // Whether TSF4 was initialized and is available. + bool IsAvailable() noexcept; + + // Try to expand an abbreviation in the focused text box. + // Reads the last |abbreviation.length()| characters before the cursor. + // If they match |abbreviation| (case-insensitive), replaces them with |expandedText|. + // Returns true if expansion occurred, false otherwise. + bool TryExpand(const std::wstring& abbreviation, const std::wstring& expandedText) noexcept; + + // Release TSF4 resources. Called on shutdown. + void Shutdown() noexcept; +} diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/pch.h b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/pch.h index 817bab9a85f0..779639357b6b 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/pch.h +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/pch.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include \ No newline at end of file diff --git a/src/modules/keyboardmanager/common/KeyboardManagerConstants.h b/src/modules/keyboardmanager/common/KeyboardManagerConstants.h index 474fecea5942..26c3daf978ce 100644 --- a/src/modules/keyboardmanager/common/KeyboardManagerConstants.h +++ b/src/modules/keyboardmanager/common/KeyboardManagerConstants.h @@ -76,6 +76,15 @@ namespace KeyboardManagerConstants // Name of the property use to store the target application. inline const std::wstring TargetAppSettingName = L"targetApp"; + // Name of the property used to store expand (abbreviation) mappings. + inline const std::wstring ExpandMappingsSettingName = L"expandMappings"; + + // Name of the property used to store the trigger key for expand mappings. + inline const std::wstring ExpandTriggerKeySettingName = L"triggerKey"; + + // Name of the property used to store the expanded text for expand mappings. + inline const std::wstring ExpandedTextSettingName = L"expandedText"; + // Name of the default configuration. inline const std::wstring DefaultConfiguration = L"default"; diff --git a/src/modules/keyboardmanager/common/MappingConfiguration.cpp b/src/modules/keyboardmanager/common/MappingConfiguration.cpp index bbc41fb4c8be..df7a5301db4f 100644 --- a/src/modules/keyboardmanager/common/MappingConfiguration.cpp +++ b/src/modules/keyboardmanager/common/MappingConfiguration.cpp @@ -407,6 +407,60 @@ bool MappingConfiguration::LoadShortcutRemaps(const json::JsonObject& jsonData, return result; } +bool MappingConfiguration::LoadExpandMappings(const json::JsonObject& jsonData) +{ + bool result = true; + + try + { + expandMappings.clear(); + + if (!jsonData.HasKey(KeyboardManagerConstants::ExpandMappingsSettingName)) + { + return result; + } + + auto expandArray = jsonData.GetNamedArray(KeyboardManagerConstants::ExpandMappingsSettingName); + for (const auto& it : expandArray) + { + try + { + auto obj = it.GetObjectW(); + ExpandMapping mapping; + mapping.abbreviation = obj.GetNamedString(KeyboardManagerConstants::OriginalKeysSettingName).c_str(); + mapping.expandedText = obj.GetNamedString(KeyboardManagerConstants::ExpandedTextSettingName).c_str(); + + if (obj.HasKey(KeyboardManagerConstants::ExpandTriggerKeySettingName)) + { + mapping.triggerKey = static_cast(obj.GetNamedNumber(KeyboardManagerConstants::ExpandTriggerKeySettingName)); + } + + if (obj.HasKey(KeyboardManagerConstants::TargetAppSettingName)) + { + mapping.appName = obj.GetNamedString(KeyboardManagerConstants::TargetAppSettingName).c_str(); + } + + if (!mapping.abbreviation.empty() && !mapping.expandedText.empty()) + { + expandMappings.push_back(std::move(mapping)); + } + } + catch (...) + { + Logger::error(L"Improper expand mapping JSON. Try the next mapping."); + result = false; + } + } + } + catch (...) + { + Logger::error(L"Improper JSON format for expand mappings. Skip."); + result = false; + } + + return result; +} + bool MappingConfiguration::LoadSettings() { Logger::trace(L"SettingsHelper::LoadSettings()"); @@ -435,6 +489,7 @@ bool MappingConfiguration::LoadSettings() result = LoadShortcutRemaps(*configFile, KeyboardManagerConstants::RemapShortcutsSettingName) && result; result = LoadShortcutRemaps(*configFile, KeyboardManagerConstants::RemapShortcutsToTextSettingName) && result; result = LoadSingleKeyToTextRemaps(*configFile) && result; + result = LoadExpandMappings(*configFile) && result; return result; } @@ -632,10 +687,25 @@ bool MappingConfiguration::SaveSettingsToFile() remapKeys.SetNamedValue(KeyboardManagerConstants::InProcessRemapKeysSettingName, inProcessRemapKeysArray); remapKeysToText.SetNamedValue(KeyboardManagerConstants::InProcessRemapKeysSettingName, inProcessRemapKeysToTextArray); + json::JsonArray expandMappingsArray; + for (const auto& em : expandMappings) + { + json::JsonObject obj; + obj.SetNamedValue(KeyboardManagerConstants::OriginalKeysSettingName, json::value(em.abbreviation)); + obj.SetNamedValue(KeyboardManagerConstants::ExpandedTextSettingName, json::value(em.expandedText)); + obj.SetNamedValue(KeyboardManagerConstants::ExpandTriggerKeySettingName, json::JsonValue::CreateNumberValue(static_cast(em.triggerKey))); + if (!em.appName.empty()) + { + obj.SetNamedValue(KeyboardManagerConstants::TargetAppSettingName, json::value(em.appName)); + } + expandMappingsArray.Append(obj); + } + configJson.SetNamedValue(KeyboardManagerConstants::RemapKeysSettingName, remapKeys); configJson.SetNamedValue(KeyboardManagerConstants::RemapKeysToTextSettingName, remapKeysToText); configJson.SetNamedValue(KeyboardManagerConstants::RemapShortcutsSettingName, remapShortcuts); configJson.SetNamedValue(KeyboardManagerConstants::RemapShortcutsToTextSettingName, remapShortcutsToText); + configJson.SetNamedValue(KeyboardManagerConstants::ExpandMappingsSettingName, expandMappingsArray); try { diff --git a/src/modules/keyboardmanager/common/MappingConfiguration.h b/src/modules/keyboardmanager/common/MappingConfiguration.h index 8f984affb9c7..e14119c4dc9e 100644 --- a/src/modules/keyboardmanager/common/MappingConfiguration.h +++ b/src/modules/keyboardmanager/common/MappingConfiguration.h @@ -11,6 +11,17 @@ using SingleKeyToTextRemapTable = SingleKeyRemapTable; using ShortcutRemapTable = std::map; using AppSpecificShortcutRemapTable = std::map; +// A single expand mapping: abbreviation + trigger key → expanded text +struct ExpandMapping +{ + std::wstring abbreviation; + DWORD triggerKey = VK_SPACE; // Virtual key code of the trigger key + std::wstring expandedText; + std::wstring appName; // Empty = all apps +}; + +using ExpandMappingTable = std::vector; + class MappingConfiguration { public: @@ -66,6 +77,9 @@ class MappingConfiguration AppSpecificShortcutRemapTable appSpecificShortcutReMap; std::map> appSpecificShortcutReMapSortedKeys; + // Stores expand (abbreviation → text) mappings + ExpandMappingTable expandMappings; + // Stores the current configuration name. std::wstring currentConfig = KeyboardManagerConstants::DefaultConfiguration; @@ -74,4 +88,5 @@ class MappingConfiguration bool LoadSingleKeyToTextRemaps(const json::JsonObject& jsonData); bool LoadShortcutRemaps(const json::JsonObject& jsonData, const std::wstring& objectName); bool LoadAppSpecificShortcutRemaps(const json::JsonObject& remapShortcutsData); + bool LoadExpandMappings(const json::JsonObject& jsonData); }; \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml index ff213f52512f..133904b656a4 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml @@ -269,6 +269,52 @@ + + + + + + + + + + +