Compare commits

...

12 Commits

Author SHA1 Message Date
Yu Leng (from Dev Box)
71c1b5311c Merge remote-tracking branch 'origin/main' into yuleng/kbm/tsf4
# Conflicts:
#	src/modules/keyboardmanager/KeyboardManagerEditorUI/Controls/UnifiedMappingControl.xaml.cs
#	src/modules/keyboardmanager/KeyboardManagerEditorUI/Helpers/ValidationHelper.cs
#	src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml
#	src/modules/keyboardmanager/KeyboardManagerEditorUI/Pages/MainPage.xaml.cs
#	src/modules/keyboardmanager/KeyboardManagerEditorUI/Strings/en-US/Resources.resw
2026-04-10 15:10:29 +08:00
Yu Leng (from Dev Box)
5619bc95c0 [KBM] Add spelling exceptions for TSF4 and Text Expand terms
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:55:48 +08:00
Yu Leng (from Dev Box)
2b4fb9e730 [KBM] Fix Text Expand enable/disable toggle not working
EnableShortcut, DisableShortcut, and HandleShortcutDelete did not
recognize ExpandMapping, falling through to incorrect single-key or
multi-key branches. Add explicit ExpandMapping handling that calls
AddExpandMapping/DeleteExpandMapping on the engine config.

Also add DeleteExpandMapping C++ interop (matches by abbreviation +
app name).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:40:47 +08:00
Yu Leng (from Dev Box)
d1091b3a6a [KBM] Lower TSF4 OS version threshold for testing
Set IsTsf4Supported() to Build >= 10000 so all Windows 10/11 machines
pass the check. This enables testing the Text Expand UI flow on any dev
machine. The threshold will be raised to the correct build number before
shipping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:22:01 +08:00
Yu Leng (from Dev Box)
0e137a3213 [KBM] Add Text Expand engine backend using TSF4 API
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) <noreply@anthropic.com>
2026-04-10 02:26:39 +08:00
Yu Leng (from Dev Box)
554c207776 Merge branch 'main' into yuleng/kbm/tsf4 2026-04-09 15:41:34 +08:00
Yu Leng (from Dev Box)
7fe58e8cdb [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) <noreply@anthropic.com>
2026-03-20 09:48:13 +08:00
Yu Leng (from Dev Box)
7c7f8fb43a [KBM] Fix SA1512 StyleCop violation in Reset()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:34:00 +08:00
Yu Leng (from Dev Box)
7bbead48a4 [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) <noreply@anthropic.com>
2026-03-20 09:21:25 +08:00
Yu Leng (from Dev Box)
48e56690f7 [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) <noreply@anthropic.com>
2026-03-20 09:02:02 +08:00
Yu Leng (from Dev Box)
a67cfd7f39 [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) <noreply@anthropic.com>
2026-03-20 08:36:10 +08:00
Yu Leng (from Dev Box)
679eb22532 [KBM] Add Expand (text replacement) action type to EditorUI
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) <noreply@anthropic.com>
2026-03-19 10:03:38 +08:00
33 changed files with 1311 additions and 15 deletions

View File

@@ -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

71
CLAUDE.md Normal file
View File

@@ -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.<config>.<platform>.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 `<Product>*UnitTests` or `<Product>*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

View File

@@ -68,6 +68,16 @@
AppListEntry="none">
</uap:VisualElements>
</Application>
<Application Id="PowerToys.KeyboardManagerEngine" Executable="KeyboardManagerEngine\PowerToys.KeyboardManagerEngine.exe" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="PowerToys.KeyboardManagerEngine"
Description="PowerToys Keyboard Manager Engine"
BackgroundColor="transparent"
Square150x150Logo="Images\Square150x150Logo.png"
Square44x44Logo="Images\Square44x44Logo.png"
AppListEntry="none">
</uap:VisualElements>
</Application>
<Application Id="PowerToys.CmdPalExtension" Executable="Microsoft.CmdPal.Ext.PowerToys.exe" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="PowerToys.CommandPaletteExtension"

View File

@@ -22,5 +22,14 @@ namespace ManagedCommon
{
return Environment.OSVersion.Version.Major >= 10 && Environment.OSVersion.Version.Build > 22000;
}
/// <summary>
/// TSF4 (Text Services Framework 4) APIs require Windows 11 build 20000+.
/// TODO: Confirm the exact minimum build number once finalized.
/// </summary>
public static bool IsTsf4Supported()
{
return Environment.OSVersion.Version.Major >= 10 && Environment.OSVersion.Version.Build >= 10000;
}
}
}

View File

@@ -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<MappingConfiguration*>(config);
if (!abbreviation || !expandedText)
{
return false;
}
ExpandMapping mapping;
mapping.abbreviation = abbreviation;
mapping.triggerKey = static_cast<DWORD>(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<MappingConfiguration*>(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)
{

View File

@@ -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);

View File

@@ -81,6 +81,7 @@ namespace KeyboardManagerEditorUI.Controls
ActionType.Shortcut => "\uEDA7",
ActionType.MouseClick => "\uE962",
ActionType.Url => "\uE774",
ActionType.Expand => "\uE8C8",
_ => "\uE8A5",
};
}

View File

@@ -47,6 +47,12 @@
<TextBlock x:Uid="TriggerType_KeyOrShortcut_Text" />
</StackPanel>
</ComboBoxItem>
<ComboBoxItem x:Name="TriggerType_ExpandItem" x:Uid="TriggerType_Expand" Tag="Expand">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE8C8;" />
<TextBlock x:Uid="TriggerType_Expand_Text" />
</StackPanel>
</ComboBoxItem>
<!--
<ComboBoxItem x:Uid="TriggerType_Mouse" Tag="Mouse">
<StackPanel Orientation="Horizontal" Spacing="8">
@@ -113,6 +119,44 @@
IsChecked="True" />
</StackPanel>
</tkcontrols:Case>
<!-- Expand Trigger -->
<tkcontrols:Case Value="Expand">
<StackPanel Orientation="Vertical" Spacing="8">
<TextBox
x:Name="ExpandAbbreviationBox"
x:Uid="ExpandAbbreviationBox"
Background="{ThemeResource TextControlBackgroundFocused}"
BorderBrush="{ThemeResource ControlStrokeColorDefaultBrush}"
GotFocus="ExpandAbbreviationBox_GotFocus"
TextChanged="ExpandAbbreviationBox_TextChanged" />
<TextBlock
x:Uid="ExpandTriggerKeyLabel"
Margin="0,8,0,0"
FontWeight="SemiBold" />
<ToggleButton
x:Name="ExpandTriggerKeyToggleBtn"
MinHeight="48"
Padding="8,12,8,12"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Checked="ExpandTriggerKeyToggleBtn_Checked"
Style="{StaticResource CustomShortcutToggleButtonStyle}"
Unchecked="ExpandTriggerKeyToggleBtn_Unchecked">
<ToggleButton.Content>
<commoncontrols:KeyVisual
x:Name="ExpandTriggerKeyVisual"
Padding="8"
Background="{ThemeResource ControlFillColorDefaultBrush}"
BorderThickness="1"
Content="Space"
CornerRadius="{StaticResource OverlayCornerRadius}"
FontSize="16"
Style="{StaticResource DefaultKeyVisualStyle}" />
</ToggleButton.Content>
</ToggleButton>
</StackPanel>
</tkcontrols:Case>
<!-- Mouse Button Trigger -->
<tkcontrols:Case Value="Mouse">
<ComboBox
@@ -188,10 +232,10 @@
<TextBlock x:Uid="ActionType_KeyOrShortcut_Text" />
</StackPanel>
</ComboBoxItem>
<ComboBoxItem x:Uid="ActionType_Text" Tag="Text">
<ComboBoxItem x:Name="ActionTypeTextItem" x:Uid="ActionType_Text" Tag="Text">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE8D2;" />
<TextBlock x:Uid="ActionType_Text_Text" />
<FontIcon x:Name="ActionTypeTextIcon" FontSize="14" Glyph="&#xE8D2;" />
<TextBlock x:Name="ActionTypeTextLabel" x:Uid="ActionType_Text_Text" />
</StackPanel>
</ComboBoxItem>
<ComboBoxItem x:Uid="ActionType_OpenUrl" Tag="OpenUrl">
@@ -229,7 +273,7 @@
<tkcontrols:SwitchPresenter
x:Name="ActionSwitchPresenter"
TargetType="x:String"
Value="{Binding SelectedItem.Tag, ElementName=ActionTypeComboBox}">
Value="KeyOrShortcut">
<!-- Key or Shortcut Action -->
<tkcontrols:Case Value="KeyOrShortcut">
<ToggleButton
@@ -384,6 +428,7 @@
</ComboBox>
</StackPanel>
</tkcontrols:Case>
<!-- Replace With uses the Text case above with label override -->
<!-- Mouse Click Action (Placeholder) -->
<tkcontrols:Case Value="MouseClick">
<TextBlock

View File

@@ -43,6 +43,15 @@ namespace KeyboardManagerEditorUI.Controls
private bool _textContentDirty;
private bool _urlPathDirty;
private bool _programPathDirty;
private bool _expandAbbreviationDirty;
// Expand trigger key tracking
private string _expandTriggerKey = "Space";
private bool _isCapturingExpandTriggerKey;
// Saved label/icon for restoring "Text" action item after Expand mode
private string? _savedTextLabel;
private string? _savedTextIconGlyph;
public bool AllowChords { get; set; } = true;
@@ -66,6 +75,7 @@ namespace KeyboardManagerEditorUI.Controls
{
KeyOrShortcut,
Mouse,
Expand,
}
/// <summary>
@@ -78,6 +88,7 @@ namespace KeyboardManagerEditorUI.Controls
OpenUrl,
OpenApp,
MouseClick,
ReplaceWith,
Disable,
}
@@ -108,6 +119,7 @@ namespace KeyboardManagerEditorUI.Controls
return item.Tag?.ToString() switch
{
"Mouse" => TriggerType.Mouse,
"Expand" => TriggerType.Expand,
_ => TriggerType.KeyOrShortcut,
};
}
@@ -123,6 +135,12 @@ namespace KeyboardManagerEditorUI.Controls
{
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 @@ namespace KeyboardManagerEditorUI.Controls
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 @@ namespace KeyboardManagerEditorUI.Controls
{
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 @@ namespace KeyboardManagerEditorUI.Controls
{
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 @@ namespace KeyboardManagerEditorUI.Controls
public void OnKeyDown(VirtualKey key, List<string> 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 @@ namespace KeyboardManagerEditorUI.Controls
/// </summary>
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 @@ namespace KeyboardManagerEditorUI.Controls
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 @@ namespace KeyboardManagerEditorUI.Controls
{
ActionKeyToggleBtn.IsChecked = false;
}
if (ExpandTriggerKeyToggleBtn?.IsChecked == true)
{
ExpandTriggerKeyToggleBtn.IsChecked = false;
}
}
private static void SetDropDownsEnabled(ItemsControl itemsControl, bool enabled)
@@ -1089,6 +1169,21 @@ namespace KeyboardManagerEditorUI.Controls
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 @@ namespace KeyboardManagerEditorUI.Controls
_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 @@ namespace KeyboardManagerEditorUI.Controls
{
AppSpecificCheckBox.IsChecked = false;
AppSpecificCheckBox.IsEnabled = false;
AppSpecificCheckBox.Visibility = Visibility.Visible;
}
// Reset app combo boxes
@@ -1287,6 +1401,167 @@ namespace KeyboardManagerEditorUI.Controls
{
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
}
}

View File

@@ -11,5 +11,6 @@ namespace KeyboardManagerEditorUI.Helpers
Shortcut,
MouseClick,
Url,
Expand,
}
}

View File

@@ -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<string> Shortcut { get; set; } = new List<string>();
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;
}
}

View File

@@ -25,5 +25,8 @@ namespace KeyboardManagerEditorUI.Helpers
EmptyUrl,
EmptyProgramPath,
OneKeyMapping,
EmptyExpandAbbreviation,
EmptyExpandedText,
DuplicateExpandAbbreviation,
}
}

View File

@@ -28,6 +28,9 @@ namespace KeyboardManagerEditorUI.Helpers
{ 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 @@ namespace KeyboardManagerEditorUI.Helpers
return true;
}
public static ValidationErrorType ValidateExpandMapping(
string abbreviation,
string expandedText,
List<ExpandMapping> 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<string> originalKeys,
bool isAppSpecific,

View File

@@ -85,6 +85,22 @@ namespace KeyboardManagerEditorUI.Interop
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)]

View File

@@ -253,6 +253,16 @@ namespace KeyboardManagerEditorUI.Interop
(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);

View File

@@ -16,5 +16,6 @@ namespace KeyboardManagerEditorUI.Interop
RunProgram = 1,
OpenUri = 2,
RemapText = 3,
ExpandText = 4,
}
}

View File

@@ -540,6 +540,88 @@
</ListView>
</StackPanel>
<!-- Expand Section -->
<StackPanel Orientation="Vertical" Visibility="{x:Bind ExpandMappings.Count, Mode=OneWay, Converter={StaticResource CountToVisibilityConverter}}">
<TextBlock
x:Uid="ExpandMappingsHeader"
Margin="16,16,0,8"
Style="{StaticResource BodyStrongTextBlockStyle}" />
<Rectangle
Height="1"
HorizontalAlignment="Stretch"
Fill="{ThemeResource CardStrokeColorDefaultBrush}" />
<ListView
IsItemClickEnabled="True"
ItemClick="ExpandMappingsList_ItemClick"
ItemsSource="{x:Bind ExpandMappings}"
ScrollViewer.VerticalScrollMode="Disabled"
SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="helper:ExpandMapping">
<Grid MinHeight="48" Padding="0,8,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Rectangle Style="{StaticResource ItemDividerStyle}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<controls:IconLabelControl ActionType="Text" Label="{x:Bind Abbreviation}" />
<TextBlock
x:Uid="ExpandsToText"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
<controls:IconLabelControl
ActionType="Expand"
Label="{x:Bind ExpandedText}"
Style="{StaticResource RemappedIconLabelControlStyle}" />
<TextBlock
x:Uid="ExpandTriggerKeyText"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
<commoncontrols:KeyVisual Content="{x:Bind TriggerKey}" Style="{StaticResource OriginalKeyVisualStyle}" />
<StackPanel
Orientation="Horizontal"
Spacing="8"
Visibility="{x:Bind AppName, Converter={StaticResource StringVisibilityConverter}}">
<TextBlock
x:Uid="InText"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
<controls:IconLabelControl ActionType="Program" Label="{x:Bind AppName}" />
</StackPanel>
</StackPanel>
<StackPanel
Grid.Column="1"
Orientation="Horizontal"
Spacing="8">
<ToggleSwitch
IsOn="{x:Bind IsActive}"
Style="{StaticResource RightAlignedCompactToggleSwitchStyle}"
Toggled="ToggleSwitch_Toggled" />
<Button
VerticalAlignment="Center"
Content="{ui:FontIcon Glyph=&#xE712;,
FontSize=14}"
Style="{StaticResource SubtleButtonStyle}">
<Button.Flyout>
<MenuFlyout>
<MenuFlyoutItem
x:Uid="DeleteMenuItem"
Click="DeleteMapping_Click"
Icon="{ui:FontIcon Glyph=&#xE74D;,
FontSize=14}"
Tag="{x:Bind}" />
</MenuFlyout>
</Button.Flyout>
</Button>
</StackPanel>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackPanel>
<!-- Disabled Section -->
<StackPanel Orientation="Vertical" Visibility="{x:Bind DisabledList.Count, Mode=OneWay, Converter={StaticResource CountToVisibilityConverter}}">
<TextBlock

View File

@@ -81,6 +81,8 @@ namespace KeyboardManagerEditorUI.Pages
public ObservableCollection<URLShortcut> UrlShortcuts { get; } = new();
public ObservableCollection<ExpandMapping> 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 @@ namespace KeyboardManagerEditorUI.Pages
TextMapping,
ProgramShortcut,
UrlShortcut,
ExpandMapping,
}
public ItemType Type { get; set; }
@@ -305,6 +308,33 @@ namespace KeyboardManagerEditorUI.Pages
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<string>(),
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 @@ namespace KeyboardManagerEditorUI.Pages
{
List<string> 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 @@ namespace KeyboardManagerEditorUI.Pages
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 @@ namespace KeyboardManagerEditorUI.Pages
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 @@ namespace KeyboardManagerEditorUI.Pages
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 @@ namespace KeyboardManagerEditorUI.Pages
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 @@ namespace KeyboardManagerEditorUI.Pages
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 @@ namespace KeyboardManagerEditorUI.Pages
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 @@ namespace KeyboardManagerEditorUI.Pages
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 @@ namespace KeyboardManagerEditorUI.Pages
}
}
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<string> ParseKeyCodes(string keyCodesString)
{
return keyCodesString.Split(';')

View File

@@ -378,6 +378,42 @@
<data name="MouseClickPlaceholder.Text" xml:space="preserve">
<value>Mouse click action - coming soon</value>
</data>
<data name="TriggerType_Expand.AutomationProperties.Name" xml:space="preserve">
<value>Text expand</value>
</data>
<data name="TriggerType_Expand_Text.Text" xml:space="preserve">
<value>Text expand</value>
</data>
<data name="ExpandAbbreviationBox.Header" xml:space="preserve">
<value>Abbreviation</value>
</data>
<data name="ExpandAbbreviationBox.PlaceholderText" xml:space="preserve">
<value>e.g. brb</value>
</data>
<data name="ExpandTriggerKeyLabel.Text" xml:space="preserve">
<value>Trigger key</value>
</data>
<data name="ActionType_ReplaceWith.AutomationProperties.Name" xml:space="preserve">
<value>Replace with</value>
</data>
<data name="ActionType_ReplaceWith_Text.Text" xml:space="preserve">
<value>Replace with</value>
</data>
<data name="ExpandedTextBox.Header" xml:space="preserve">
<value>Expanded text</value>
</data>
<data name="ExpandedTextBox.PlaceholderText" xml:space="preserve">
<value>e.g. be right back</value>
</data>
<data name="ExpandMappingsHeader.Text" xml:space="preserve">
<value>Text expansions</value>
</data>
<data name="ExpandsToText.Text" xml:space="preserve">
<value>expands to</value>
</data>
<data name="ExpandTriggerKeyText.Text" xml:space="preserve">
<value>via</value>
</data>
<data name="Validation_EmptyOriginalKeys_Title" xml:space="preserve">
<value>Missing original keys</value>
</data>
@@ -450,6 +486,24 @@
<data name="Validation_OneKeyMapping_Message" xml:space="preserve">
<value>A single key cannot be remapped to a Program or URL shortcut. Please choose a combination of keys.</value>
</data>
<data name="Validation_EmptyExpandAbbreviation_Title" xml:space="preserve">
<value>Missing abbreviation</value>
</data>
<data name="Validation_EmptyExpandAbbreviation_Message" xml:space="preserve">
<value>Please enter the abbreviation text that will trigger the expansion.</value>
</data>
<data name="Validation_EmptyExpandedText_Title" xml:space="preserve">
<value>Missing expanded text</value>
</data>
<data name="Validation_EmptyExpandedText_Message" xml:space="preserve">
<value>Please enter the text that the abbreviation will expand to.</value>
</data>
<data name="Validation_DuplicateExpandAbbreviation_Title" xml:space="preserve">
<value>Duplicate abbreviation</value>
</data>
<data name="Validation_DuplicateExpandAbbreviation_Message" xml:space="preserve">
<value>This abbreviation is already used by another text expansion.</value>
</data>
<data name="Warning_ShortcutStartWithModifier" xml:space="preserve">
<value>Shortcuts must start with a modifier key (Ctrl, Alt, Shift, or Win).</value>
</data>
@@ -501,4 +555,4 @@
<data name="CheckServiceBtn.Content" xml:space="preserve">
<value>Check service status</value>
</data>
</root>
</root>

View File

@@ -9,6 +9,7 @@
#include <common/utils/gpo.h>
#include <keyboardmanager/common/KeyboardManagerConstants.h>
#include <keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManager.h>
#include <keyboardmanager/KeyboardManagerEngineLibrary/Tsf4TextReplacer.h>
#include <keyboardmanager/KeyboardManagerEngineLibrary/trace.h>
#include <common/interop/shared_constants.h>
@@ -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();

View File

@@ -8,6 +8,7 @@
#include <keyboardmanager/common/InputInterface.h>
#include <keyboardmanager/common/Helpers.h>
#include <keyboardmanager/KeyboardManagerEngineLibrary/trace.h>
#include "Tsf4TextReplacer.h"
#include <TlHelp32.h>
#include <thread>
@@ -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;
}
}

View File

@@ -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);
};

View File

@@ -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);
}

View File

@@ -38,10 +38,12 @@
<ClInclude Include="pch.h" />
<ClInclude Include="State.h" />
<ClInclude Include="trace.h" />
<ClInclude Include="Tsf4TextReplacer.h" />
</ItemGroup>
<ItemGroup>
<ClCompile Include="KeyboardEventHandlers.cpp" />
<ClCompile Include="KeyboardManager.cpp" />
<ClCompile Include="Tsf4TextReplacer.cpp" />
<ClCompile Include="pch.cpp">
<PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader>
</ClCompile>
@@ -56,6 +58,14 @@
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<!-- TSF4 (Text Services Framework 4) WinMD for C++/WinRT projection generation -->
<ItemGroup>
<Reference Include="Windows.UI.Input.Preview.Text.PreviewTextContract">
<HintPath>$(WindowsSdkDir)References\10.0.26100.0\Windows.UI.Input.Preview.Text.PreviewTextContract\1.0.0.0\Windows.UI.Input.Preview.Text.PreviewTextContract.winmd</HintPath>
<IsWinMDFile>true</IsWinMDFile>
<Private>false</Private>
</Reference>
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<Import Project="$(RepoRoot)deps\spdlog.props" />
<ImportGroup Label="ExtensionTargets">

View File

@@ -0,0 +1,164 @@
#include "pch.h"
#include "Tsf4TextReplacer.h"
#include <winrt/Windows.ApplicationModel.h>
#include <winrt/Windows.UI.Input.Preview.Text.h>
#include <winrt/Windows.UI.Text.Core.h>
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<int>(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<int>(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 (...)
{
}
}
}

View File

@@ -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;
}

View File

@@ -6,6 +6,7 @@
#include <stdexcept>
#include <unordered_set>
#include <winrt/base.h>
#include <winrt/Windows.Foundation.h>
#include <filesystem>
#include <common/SettingsAPI/settings_helpers.h>
#include <common/logger/logger.h>

View File

@@ -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";

View File

@@ -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<DWORD>(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<double>(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
{

View File

@@ -11,6 +11,17 @@ using SingleKeyToTextRemapTable = SingleKeyRemapTable;
using ShortcutRemapTable = std::map<Shortcut, RemapShortcut>;
using AppSpecificShortcutRemapTable = std::map<std::wstring, ShortcutRemapTable>;
// 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<ExpandMapping>;
class MappingConfiguration
{
public:
@@ -66,6 +77,9 @@ public:
AppSpecificShortcutRemapTable appSpecificShortcutReMap;
std::map<std::wstring, std::vector<Shortcut>> appSpecificShortcutReMapSortedKeys;
// Stores expand (abbreviation → text) mappings
ExpandMappingTable expandMappings;
// Stores the current configuration name.
std::wstring currentConfig = KeyboardManagerConstants::DefaultConfiguration;
@@ -74,4 +88,5 @@ private:
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);
};

View File

@@ -269,6 +269,52 @@
</controls:SettingsGroup>
</tkcontrols:Case>
</tkcontrols:SwitchPresenter>
<!-- TSF4 (Text Expand) API support status -->
<controls:SettingsGroup x:Uid="KeyboardManager_Tsf4Status" IsEnabled="{x:Bind ViewModel.Enabled, Mode=OneWay}">
<tkcontrols:SettingsCard
x:Uid="KeyboardManager_Tsf4Api"
HeaderIcon="{ui:FontIcon Glyph=&#xE8C8;}">
<StackPanel Orientation="Horizontal" Spacing="8">
<!-- Supported -->
<FontIcon
VerticalAlignment="Center"
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
Glyph="&#xEC61;"
Visibility="{x:Bind ViewModel.IsTsf4Supported, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock
x:Uid="KeyboardManager_Tsf4Api_Supported"
VerticalAlignment="Center"
Visibility="{x:Bind ViewModel.IsTsf4Supported, Converter={StaticResource BoolToVisibilityConverter}}" />
<!-- Not supported: info button with flyout + update button -->
<Button
x:Uid="KeyboardManager_Tsf4Api_InfoButton"
Content="&#xE946;"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind ViewModel.IsTsf4Supported, Converter={StaticResource ReverseBoolToVisibilityConverter}}">
<Button.Flyout>
<Flyout ShouldConstrainToRootBounds="False">
<StackPanel Width="360" Spacing="12">
<TextBlock
x:Uid="KeyboardManager_Tsf4Api_Unsupported_Title"
FontWeight="SemiBold"
TextWrapping="Wrap" />
<TextBlock
x:Uid="KeyboardManager_Tsf4Api_Unsupported_Description"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
<Button
x:Uid="KeyboardManager_Tsf4Api_Update"
Command="{x:Bind ViewModel.OpenWindowsUpdateCommand}"
Visibility="{x:Bind ViewModel.IsTsf4Supported, Converter={StaticResource ReverseBoolToVisibilityConverter}}" />
</StackPanel>
</tkcontrols:SettingsCard>
</controls:SettingsGroup>
</StackPanel>
</controls:SettingsPageControl.ModuleContent>
<controls:SettingsPageControl.PrimaryLinks>

View File

@@ -566,6 +566,38 @@ opera.exe</value>
<value>Keys</value>
<comment>Keyboard Manager remap keyboard header</comment>
</data>
<data name="KeyboardManager_Tsf4Status.Header" xml:space="preserve">
<value>Text Expand</value>
<comment>Keyboard Manager TSF4 status section header</comment>
</data>
<data name="KeyboardManager_Tsf4Api.Header" xml:space="preserve">
<value>Text Services Framework 4 (TSF4) API</value>
<comment>TSF4 API availability status card header</comment>
</data>
<data name="KeyboardManager_Tsf4Api.Description" xml:space="preserve">
<value>Text Expand requires TSF4 API support from the operating system</value>
<comment>TSF4 API availability status card description</comment>
</data>
<data name="KeyboardManager_Tsf4Api_Supported.Text" xml:space="preserve">
<value>Supported</value>
<comment>TSF4 API is supported on this OS version</comment>
</data>
<data name="KeyboardManager_Tsf4Api_InfoButton.ToolTipService.ToolTip" xml:space="preserve">
<value>View requirements</value>
<comment>Tooltip for the TSF4 unsupported info button</comment>
</data>
<data name="KeyboardManager_Tsf4Api_Unsupported_Title.Text" xml:space="preserve">
<value>Windows update required</value>
<comment>Title shown in TSF4 unsupported flyout</comment>
</data>
<data name="KeyboardManager_Tsf4Api_Unsupported_Description.Text" xml:space="preserve">
<value>Text Expand requires Windows 11 build 20099 or higher. This update may not yet be available on your device due to your update rollout schedule.</value>
<comment>Description shown in TSF4 unsupported flyout explaining the OS requirement</comment>
</data>
<data name="KeyboardManager_Tsf4Api_Update.Content" xml:space="preserve">
<value>Update Windows</value>
<comment>Button to open Windows Update when TSF4 is not supported</comment>
</data>
<data name="KeyboardManager_RemapShortcutsButton.Header" xml:space="preserve">
<value>Remap a shortcut</value>
<comment>Keyboard Manager remap shortcuts button</comment>

View File

@@ -280,6 +280,13 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public bool IsTsf4Supported => OSVersionHelper.IsTsf4Supported();
public ICommand OpenWindowsUpdateCommand => new RelayCommand(() =>
{
Process.Start(new ProcessStartInfo("ms-settings:windowsupdate") { UseShellExecute = true });
});
public ICommand RemapKeyboardCommand => _remapKeyboardCommand ?? (_remapKeyboardCommand = new RelayCommand(OnRemapKeyboard));
public ICommand EditShortcutCommand => _editShortcutCommand ?? (_editShortcutCommand = new RelayCommand(OnEditShortcut));