diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index c632009f466d..181662fb67f8 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -126,6 +126,7 @@ boxmodel BPBF bpmf bpp +brb Browsable BROWSEINFO bsd @@ -750,6 +751,7 @@ KILLFOCUS killrunner kmph Kybd +Laf lastcodeanalysissucceeded LASTEXITCODE LAYOUTRTL @@ -941,6 +943,7 @@ mstsc msvcp MT MTND +Mtnhu multimonitor MULTIPLEUSE multizone @@ -1470,6 +1473,7 @@ SIZENWSE SIZEWE SKEXP SKIPOWNPROCESS +skm sku SLGP sln @@ -1608,6 +1612,7 @@ templatenamespace testprocess TEXCOORD TEXTINCLUDE +textinputmethod tfopen tgz themeresources @@ -1716,6 +1721,7 @@ vcruntime vcvars VDesktop vdupq +VEb VERBSONLY VERBW VERIFYCONTEXT @@ -1792,6 +1798,7 @@ windowssearch windowssettings WINDOWSTYLES WINDOWSTYLESICON +windowsupdate winerror WINEVENT winget @@ -2056,6 +2063,7 @@ framechanged FRestore fsanitize ftps +FTt fuzzingtesting fxf gameid 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 >= 10000; + } } } diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.cpp b/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.cpp index 21f8fd54e542..bbad6304a463 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.cpp @@ -678,6 +678,50 @@ 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; + } + + bool DeleteExpandMapping(void* config, const wchar_t* abbreviation, const wchar_t* targetApp) + { + auto mappingConfig = static_cast(config); + if (!abbreviation) + { + return false; + } + + std::wstring abbrev = abbreviation; + std::wstring app = targetApp ? targetApp : L""; + + auto& mappings = mappingConfig->expandMappings; + auto it = std::remove_if(mappings.begin(), mappings.end(), [&](const ExpandMapping& m) { + return _wcsicmp(m.abbreviation.c_str(), abbrev.c_str()) == 0 && + _wcsicmp(m.appName.c_str(), app.c_str()) == 0; + }); + + if (it != mappings.end()) + { + mappings.erase(it, mappings.end()); + return true; + } + + return false; + } + // 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..0d9edd2d94eb 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.h +++ b/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.h @@ -79,6 +79,9 @@ 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 DeleteExpandMapping(void* config, const wchar_t* abbreviation, 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/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 ccb59882bac7..a127a8b778f6 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml @@ -47,6 +47,12 @@ + + + + + + + + + + + + + + + + + - + - - + + @@ -229,7 +273,7 @@ + Value="KeyOrShortcut"> + @@ -78,6 +88,7 @@ public enum ActionType OpenUrl, OpenApp, MouseClick, + ReplaceWith, Disable, } @@ -108,6 +119,7 @@ public TriggerType CurrentTriggerType return item.Tag?.ToString() switch { "Mouse" => TriggerType.Mouse, + "Expand" => TriggerType.Expand, _ => TriggerType.KeyOrShortcut, }; } @@ -123,6 +135,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 @@ -163,6 +181,12 @@ public UnifiedMappingControl() 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; } @@ -200,13 +224,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) @@ -248,6 +285,12 @@ private void ActionTypeComboBox_SelectionChanged(object sender, SelectionChanged { string? tag = item.Tag?.ToString(); + // Sync SwitchPresenter with the selected action type + if (tag != null && ActionSwitchPresenter != null) + { + ActionSwitchPresenter.Value = tag; + } + // Cleanup keyboard hook when switching away from key/shortcut if (tag != "KeyOrShortcut") { @@ -671,6 +714,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(); @@ -807,7 +869,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(TextContentBox?.Text); + } + + // Trigger keys are always required for other trigger types if (_triggerKeys.Count == 0) { return false; @@ -870,6 +939,12 @@ public void SetActionType(ActionType actionType) return; } + // ReplaceWith is handled by SwitchActionTypeToReplaceWith(), not by index + if (actionType == ActionType.ReplaceWith) + { + return; + } + string tag = actionType switch { ActionType.Text => "Text", @@ -1009,6 +1084,11 @@ private void UncheckAllToggleButtons() { ActionKeyToggleBtn.IsChecked = false; } + + if (ExpandTriggerKeyToggleBtn?.IsChecked == true) + { + ExpandTriggerKeyToggleBtn.IsChecked = false; + } } private static void SetDropDownsEnabled(ItemsControl itemsControl, bool enabled) @@ -1089,6 +1169,21 @@ private void UpdateInlineValidation() return; } + break; + + case ActionType.ReplaceWith: + if (ExpandAbbreviationBox != null && _expandAbbreviationDirty && string.IsNullOrWhiteSpace(ExpandAbbreviationBox.Text)) + { + ShowValidationErrorFromType(ValidationErrorType.EmptyExpandAbbreviation); + return; + } + + if (TextContentBox != null && _textContentDirty && string.IsNullOrWhiteSpace(TextContentBox.Text)) + { + ShowValidationErrorFromType(ValidationErrorType.EmptyExpandedText); + return; + } + break; } @@ -1111,6 +1206,24 @@ public void Reset() _textContentDirty = false; _urlPathDirty = false; _programPathDirty = false; + _expandAbbreviationDirty = false; + + // Reset expand state + _expandTriggerKey = "Space"; + _isCapturingExpandTriggerKey = false; + + if (ExpandAbbreviationBox != null) + { + ExpandAbbreviationBox.Text = string.Empty; + } + + if (ExpandTriggerKeyVisual != null) + { + ExpandTriggerKeyVisual.Content = "Space"; + } + + // Restore normal action types if currently in Expand mode + RestoreNormalActionTypes(); // Hide any validation messages HideValidationMessage(); @@ -1168,6 +1281,7 @@ public void Reset() { AppSpecificCheckBox.IsChecked = false; AppSpecificCheckBox.IsEnabled = false; + AppSpecificCheckBox.Visibility = Visibility.Visible; } // Reset app combo boxes @@ -1287,6 +1401,167 @@ 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 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 || ActionTypeTextItem == null) + { + return; + } + + // Select the "Text" action item and override its label to "Replace with" + ActionTypeComboBox.SelectedItem = ActionTypeTextItem; + ActionTypeComboBox.IsEnabled = false; + + if (ActionTypeTextLabel != null) + { + _savedTextLabel = ActionTypeTextLabel.Text; + ActionTypeTextLabel.Text = "Replace with"; + } + + if (ActionTypeTextIcon != null) + { + _savedTextIconGlyph = ActionTypeTextIcon.Glyph; + ActionTypeTextIcon.Glyph = "\uE8C8"; + } + } + + private void RestoreNormalActionTypes() + { + if (ActionTypeComboBox == null) + { + return; + } + + // Restore original label and icon on the "Text" action item + if (ActionTypeTextLabel != null && _savedTextLabel != null) + { + ActionTypeTextLabel.Text = _savedTextLabel; + _savedTextLabel = null; + } + + if (ActionTypeTextIcon != null && _savedTextIconGlyph != null) + { + ActionTypeTextIcon.Glyph = _savedTextIconGlyph; + _savedTextIconGlyph = 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() => TextContentBox?.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 (TextContentBox != null) + { + TextContentBox.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 8733bae21c8a..fa323e1b3468 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ValidationErrorType.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ValidationErrorType.cs @@ -25,5 +25,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 8b27e785c3be..a2482d79a3f5 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ValidationHelper.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ValidationHelper.cs @@ -28,6 +28,9 @@ public static class ValidationHelper { ValidationErrorType.EmptyUrl, (ResourceHelper.GetString("Validation_EmptyUrl_Title"), ResourceHelper.GetString("Validation_EmptyUrl_Message")) }, { ValidationErrorType.EmptyProgramPath, (ResourceHelper.GetString("Validation_EmptyProgramPath_Title"), ResourceHelper.GetString("Validation_EmptyProgramPath_Message")) }, { ValidationErrorType.OneKeyMapping, (ResourceHelper.GetString("Validation_OneKeyMapping_Title"), ResourceHelper.GetString("Validation_OneKeyMapping_Message")) }, + { ValidationErrorType.EmptyExpandAbbreviation, (ResourceHelper.GetString("Validation_EmptyExpandAbbreviation_Title"), ResourceHelper.GetString("Validation_EmptyExpandAbbreviation_Message")) }, + { ValidationErrorType.EmptyExpandedText, (ResourceHelper.GetString("Validation_EmptyExpandedText_Title"), ResourceHelper.GetString("Validation_EmptyExpandedText_Message")) }, + { ValidationErrorType.DuplicateExpandAbbreviation, (ResourceHelper.GetString("Validation_DuplicateExpandAbbreviation_Title"), ResourceHelper.GetString("Validation_DuplicateExpandAbbreviation_Message")) }, }; public static ValidationErrorType ValidateKeyMapping( @@ -261,6 +264,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/KeyboardManagerInterop.cs b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/KeyboardManagerInterop.cs index 846c4c1bda02..a38aa18e83e6 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/KeyboardManagerInterop.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/KeyboardManagerInterop.cs @@ -85,6 +85,22 @@ 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); + + [DllImport(DllName, CallingConvention = Convention, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool DeleteExpandMapping( + IntPtr config, + [MarshalAs(UnmanagedType.LPWStr)] string abbreviation, + [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 1f53aa71009c..6c0832c78824 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/KeyboardMappingService.cs +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Interop/KeyboardMappingService.cs @@ -253,6 +253,16 @@ 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 DeleteExpandMapping(string abbreviation, string targetApp) + { + return KeyboardManagerInterop.DeleteExpandMapping(_configHandle, abbreviation, targetApp); + } + public bool SaveSettings() { return KeyboardManagerInterop.SaveMappingSettings(_configHandle); 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 c5bfbea7f9cf..37bccf314a6e 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml @@ -540,6 +540,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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); @@ -92,6 +94,7 @@ public enum ItemType TextMapping, ProgramShortcut, UrlShortcut, + ExpandMapping, } public ItemType Type { get; set; } @@ -305,6 +308,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; @@ -367,7 +397,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.ShowValidationErrorFromType(ValidationErrorType.EmptyOriginalKeys); args.Cancel = true; @@ -394,6 +427,7 @@ private void RemappingDialog_PrimaryButtonClick(ContentDialog sender, ContentDia UnifiedMappingControl.ActionType.OpenUrl => SaveUrlMapping(triggerKeys), UnifiedMappingControl.ActionType.OpenApp => SaveProgramMapping(triggerKeys), UnifiedMappingControl.ActionType.Disable => SaveDisableMapping(triggerKeys), + UnifiedMappingControl.ActionType.ReplaceWith => SaveExpandMapping(), UnifiedMappingControl.ActionType.MouseClick => throw new NotImplementedException("Mouse click remapping is not yet supported."), _ => false, }; @@ -439,6 +473,8 @@ private ValidationErrorType ValidateMapping(UnifiedMappingControl.ActionType act triggerKeys, UnifiedMappingControl.GetProgramPath(), isAppSpecific, appName, _mappingService!, _isEditMode), UnifiedMappingControl.ActionType.Disable => ValidationHelper.ValidateDisableMapping( triggerKeys, isAppSpecific, appName, _mappingService!, _isEditMode, editingRemapping), + UnifiedMappingControl.ActionType.ReplaceWith => ValidationHelper.ValidateExpandMapping( + UnifiedMappingControl.GetExpandAbbreviation(), UnifiedMappingControl.GetExpandedText(), ExpandMappings.ToList(), _isEditMode), _ => ValidationErrorType.NoError, }; } @@ -683,6 +719,44 @@ 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; + } + + string targetApp = UnifiedMappingControl.GetIsAppSpecific() ? UnifiedMappingControl.GetAppName() : string.Empty; + + var shortcutKeyMapping = new ShortcutKeyMapping + { + OperationType = ShortcutOperationType.ExpandText, + OriginalKeys = abbreviation, + TargetKeys = triggerKey, + TargetText = expandedText, + TargetApp = targetApp, + }; + + // 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; + } + #endregion #region Delete Handlers @@ -739,6 +813,19 @@ private void HandleRemappingDelete(Remapping remapping) private void HandleShortcutDelete(IToggleableShortcut shortcut) { + if (shortcut is ExpandMapping) + { + ShortcutKeyMapping skm = SettingsManager.EditorSettings.ShortcutSettingsDictionary[shortcut.Id].Shortcut; + if (shortcut.IsActive) + { + _mappingService!.DeleteExpandMapping(skm.OriginalKeys, skm.TargetApp); + _mappingService.SaveSettings(); + } + + SettingsManager.RemoveShortcutKeyMappingFromSettings(shortcut.Id); + return; + } + bool deleted = shortcut.Shortcut.Count == 1 ? DeleteSingleKeyToTextMapping(shortcut.Shortcut[0]) // Remapping has its own handler, single key will always be text mapping : DeleteMultiKeyShortcut(shortcut); @@ -804,14 +891,34 @@ private void EnableShortcut(IToggleableShortcut shortcut) return; } + if (shortcut is ExpandMapping expandMapping) + { + ShortcutKeyMapping skm = SettingsManager.EditorSettings.ShortcutSettingsDictionary[shortcut.Id].Shortcut; + int triggerKeyVk = KeyboardManagerInterop.GetKeyCodeFromName(skm.TargetKeys); + if (triggerKeyVk == 0) + { + triggerKeyVk = 0x20; + } + + bool saved = _mappingService!.AddExpandMapping(skm.OriginalKeys, triggerKeyVk, skm.TargetText, skm.TargetApp); + if (saved) + { + shortcut.IsActive = true; + SettingsManager.ToggleShortcutKeyMappingActiveState(shortcut.Id); + _mappingService.SaveSettings(); + } + + return; + } + ShortcutKeyMapping shortcutKeyMapping = SettingsManager.EditorSettings.ShortcutSettingsDictionary[shortcut.Id].Shortcut; - bool saved = shortcut.Shortcut.Count == 1 + bool saved2 = 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) + if (saved2) { shortcut.IsActive = true; SettingsManager.ToggleShortcutKeyMappingActiveState(shortcut.Id); @@ -829,11 +936,25 @@ private void DisableShortcut(IToggleableShortcut shortcut) return; } - bool deleted = shortcut.Shortcut.Count == 1 + if (shortcut is ExpandMapping expandMapping) + { + ShortcutKeyMapping skm = SettingsManager.EditorSettings.ShortcutSettingsDictionary[shortcut.Id].Shortcut; + bool deleted = _mappingService!.DeleteExpandMapping(skm.OriginalKeys, skm.TargetApp); + if (deleted) + { + shortcut.IsActive = false; + SettingsManager.ToggleShortcutKeyMappingActiveState(shortcut.Id); + _mappingService.SaveSettings(); + } + + return; + } + + bool deleted2 = shortcut.Shortcut.Count == 1 ? DeleteSingleKeyToTextMapping(shortcut.Shortcut[0]) : DeleteMultiKeyMapping(shortcut.Shortcut, shortcut.AppName); - if (deleted) + if (deleted2) { shortcut.IsActive = false; SettingsManager.ToggleShortcutKeyMappingActiveState(shortcut.Id); @@ -882,12 +1003,13 @@ private void LoadAllMappings() LoadTextMappings(); LoadProgramShortcuts(); LoadUrlShortcuts(); + LoadExpandMappings(); UpdateHasAnyMappings(); } private void UpdateHasAnyMappings() { - bool hasAny = RemappingList.Count > 0 || DisabledList.Count > 0 || TextMappings.Count > 0 || ProgramShortcuts.Count > 0 || UrlShortcuts.Count > 0; + bool hasAny = RemappingList.Count > 0 || DisabledList.Count > 0 || TextMappings.Count > 0 || ProgramShortcuts.Count > 0 || UrlShortcuts.Count > 0 || ExpandMappings.Count > 0; MappingState = hasAny ? "HasMappings" : "Empty"; } @@ -1024,6 +1146,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 f29097c8d557..695a787784fa 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/Strings/en-US/Resources.resw +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/Strings/en-US/Resources.resw @@ -378,6 +378,42 @@ Mouse click action - coming soon + + 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 + Missing original keys @@ -450,6 +486,24 @@ A single key cannot be remapped to a Program or URL shortcut. Please choose a combination of keys. + + Missing abbreviation + + + Please enter the abbreviation text that will trigger the expansion. + + + Missing expanded text + + + Please enter the text that the abbreviation will expand to. + + + Duplicate abbreviation + + + This abbreviation is already used by another text expansion. + Shortcuts must start with a modifier key (Ctrl, Alt, Shift, or Win). @@ -501,4 +555,4 @@ Check service status - \ No newline at end of file + 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 636054dcfa01..7a3a10b415c6 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 @@ -1803,4 +1804,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 @@ + + + + + + + + + + +