Compare commits

...

32 Commits

Author SHA1 Message Date
Yu Leng
c27ce19ce2 [KBM] Fix check-spelling failures in CLI command template PR
- Remove internal superpowers planning/design docs (not product content;
  avoids whitelisting a username and agent jargon in the global dictionary)
- Add powertoyscli and retargets to spell-check expect.txt
- Reword "non-existent" -> "nonexistent" (line_forbidden.patterns rule)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:46:38 +08:00
Yu Leng
46e3215c10 [KBM] Address review findings in CLI command template feature
Cleanup and small correctness fixes from a self-review of the command
template work:

- MappingConfiguration.cpp: extract ReadTemplateMetadata/WriteTemplateMetadata
  helpers, removing four near-duplicate template (de)serialization blocks.
- MainPage.SaveRunTemplateMapping: preserve StartInDirectory/IfRunningAction/
  Visibility/Elevation so re-saving an edited template mapping no longer resets
  them to defaults; persist null instead of an empty {} parameter dictionary.
- KeyboardMappingService.ReadTemplateFields: broaden the catch so malformed
  on-disk metadata can never leak the other native-allocated strings.
- CommandTemplateCatalog: remove the unused TryFind method (and now-unused
  System.Linq using).
- PowerToysInstallResolver: drop the redundant %ProgramW6432% candidate (the
  editor is always 64-bit, so %ProgramFiles% already covers it).
- KeysDataModel: align templateParameters JsonIgnore with templateId
  (WhenWritingNull) for consistency.
- CommandTemplatePickerViewModel.ApplyTemplate: notify IsAllValid so the host
  re-evaluates Save-button state on template selection, not only on param edits.
- UnifiedMappingControl: select the template before switching action type in
  OnCommandClick to avoid a transient stale validation; clear the cached
  missing-template fallback command in Reset().

Builds clean (C++/C#/WinUI); KBM template unit tests 15/15 and KeysDataModel
template-field tests 3/3 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:11:01 +08:00
Yu Leng
f0a828ee22 [KBM] Fix C4190 build error in template metadata helper
SetTemplateMetadata previously delegated to a std::wstring-returning
SerializeTemplateParameters defined inside the wrapper's extern "C"
block; a C-linkage function may not return a C++ type (warning C4190,
treated as error). Inline the JSON serialization so both helpers return
void. Verified: KeyboardManagerEditorLibraryWrapper.vcxproj builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:05:13 +08:00
Yu Leng
b2bd24db0d [KBM] Fix review findings in CLI command template feature
Addresses correctness, robustness, and round-trip issues found while
reviewing the CLI-command-template work:

- Required-parameter validation: gate Save on IsAllValid and bubble
  parameter changes to the host (previously could save "--open-settings=").
- FFI read-back: carry templateId/templateParameters back to C# so a
  template mapping survives a rebuild from default.json (struct + both
  GetShortcutRemap[ByType] + KeyboardMappingService projection).
- Catalog load: wrap menu build in try/catch so a malformed catalog
  degrades gracefully instead of crashing the editor at startup.
- Install location: retarget the per-user PowerToys.exe path to a
  machine-wide install when the LOCALAPPDATA path is absent.
- Missing-template "Keep as plain command": preserve the resolved
  command instead of leaving an empty, unsavable OpenApp form.
- TemplateResolver: single-pass substitution + CommandLineToArgvW
  quoting (prevents substitution-injection and arg-splitting).
- C++ load: type-check templateParameters before reading so malformed
  optional metadata no longer drops the whole mapping; dedupe GetObjectW.
- schemaVersion: accept forward-compatible (>=1) catalogs; honor iconGlyph.
- Fix SelectionChanged/AppSpecificCheckBox handler re-subscription leak.
- Add KeyboardManagerEditorUI.UnitTests (resolver + catalog model);
  15 tests, wired into PowerToys.slnx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:36:28 +08:00
Yu Leng
28d6fe1615 Merge remote-tracking branch 'origin/main' into yuleng/kbm/command 2026-06-15 11:16:42 +08:00
Yu Leng (from Dev Box)
fbc1a0c3da [KBM] Restore module grouping under Run PowerToys Command
Build the command menu as Run PowerToys Command > <module> > <command>
again (a MenuFlyoutSubItem per catalog module) instead of flattening all
commands directly, so commands stay grouped by module as the catalog grows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:31:03 +08:00
Yu Leng (from Dev Box)
20df1fd96e Merge remote-tracking branch 'origin/main' into yuleng/kbm/command 2026-06-02 15:17:47 +08:00
Yu Leng (from Dev Box)
71d91a9616 [KBM] Remove dead resource keys left by the cascading-menu change
ActionType_RunTemplate.Content (the submenu now uses ActionType_RunTemplate_Text)
and TemplatePickerPlaceholder.Text (belonged to the removed in-picker button) are
no longer referenced by any XAML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:13:25 +08:00
Yu Leng (from Dev Box)
bd97ba31e8 [KBM] Remove redundant in-picker command button and stale resources
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:02:32 +08:00
Yu Leng (from Dev Box)
245a6db963 [KBM] Address review: init action button label in Loaded; drop unused x:Name
Move the initial UpdateActionButtonContent call from the constructor to
UserControl_Loaded so the menu items' localized Text is guaranteed populated
before it is read. Remove the unused x:Name on the action MenuFlyout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:59:22 +08:00
Yu Leng (from Dev Box)
314f9fe751 [KBM] Make action selector a cascading menu hosting PowerToys commands
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:49:42 +08:00
Yu Leng (from Dev Box)
832db1bfea [KBM] Add SelectCommand/CurrentCommandDisplay to template picker
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:44:59 +08:00
Yu Leng (from Dev Box)
412028c861 [KBM] Rename Run from template action to Run PowerToys Command
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:42:32 +08:00
Yu Leng (from Dev Box)
dda2a89aa6 [KBM] Refactor: split template model/VM classes into separate files
Extract CommandTemplateModule, CommandTemplate, TemplateParameter and
TemplateChoice out of PowerToysCliCatalog.cs, and TemplateChoiceViewModel
out of TemplateParameterViewModel.cs, into their own files. Modernize the
null check to ArgumentNullException.ThrowIfNull and rename the
missing-template InfoBar resources to MissingTemplateInfoBar.*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:42:06 +08:00
Yu Leng (from Dev Box)
164ac6074a Merge remote-tracking branch 'origin/main' into yuleng/kbm/command 2026-05-29 13:59:01 +08:00
Yu Leng
09cb927356 KBM: Document Task 20 architectural revision and C++ wiring decision
Captures the implementation-time discovery that the new editor's save
path goes through a C++ FFI chain rather than directly through
KeysDataModel, and the decision to wire the template fields end-to-end
through the C++ stack instead of relying on a CLR-only model. Notes
that this decision subsumes the originally planned Task 1b (legacy
editor JSON round-trip fix) by making the fields first-class known
fields in MappingConfiguration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:02:27 +08:00
Yu Leng
78c0e3e131 KBM: Full C++ wiring for template persistence in default.json
Adds templateId / templateParameters round-trip through the full stack:
Shortcut struct → MappingConfiguration (load+save) → EditorLibraryWrapper
(AddShortcutRemap) → C# P/Invoke → KeyboardMappingService. Non-template
mappings produce clean JSON (fields only emitted when non-empty). New
params default to nullptr so existing callers are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:00:33 +08:00
Yu Leng
ebc44a0e9d KBM: Wire RunTemplate action type into UnifiedMappingControl save/load
- Add TemplatePicker_MissingTemplateKeepRequested event handler to fix build failure
- Add RunTemplate to ActionType enum and wire CurrentActionType, SetActionType, IsInputComplete
- Add public getters GetResolvedTemplateExecutable/Args, GetCurrentTemplateId/ParameterValues
- Add SetRunTemplate setter for the load path
- Add TemplateId/TemplateParameters fields to ShortcutKeyMapping for persistence
- Add SaveRunTemplateMapping in MainPage and wire the save dispatch switch
- Add load-path detection in ProgramShortcutsList_ItemClick to restore RunTemplate state
- Wire TemplatePicker.SelectionChanged so validation re-runs on template selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:50:37 +08:00
Yu Leng
d88dca2c1e KBM: Add RunTemplate action type entry to UnifiedMappingControl XAML
Adds a 6th ComboBoxItem 'Run from template' (Tag=RunTemplate) to
ActionTypeComboBox, and a matching Case in ActionSwitchPresenter
that hosts the new CommandTemplatePickerControl. Wires the
picker's MissingTemplateKeepRequested event to the code-behind
handler that will be added with Task 20's save/load wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:44:38 +08:00
Yu Leng
f893fc7a77 KBM: Add CommandTemplatePickerControl (XAML + code-behind)
XAML: DropDownButton + MenuFlyout for cascading template selection,
ItemsControl + ParamSelector for dynamic parameter form, live preview
TextBlock (Consolas, OneWay-bound to ViewModel.ResolvedCommandLine),
and a Warning-severity InfoBar for the missing-template degradation
path. Every DataTemplate declares x:DataType for AOT-safe x:Bind.

Code-behind: BuildFlyout populates the MenuFlyout programmatically
from CommandTemplateCatalog.Instance (WinUI3 MenuFlyout doesn't
support HierarchicalDataTemplate). OnCommandPicked routes flyout
clicks through ViewModel.SelectTemplate. LoadExisting/Reset/
ResolveCurrent/CurrentTemplateId/CurrentParameterValues are the
public surface the parent UnifiedMappingControl uses for save/load.
Missing-template path raises MissingTemplateKeepRequested event so
the parent can switch ActionType to OpenApp (Option B degradation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:43:51 +08:00
Yu Leng
7c3c5514ee KBM: Add template picker ViewModels and DataTemplateSelector
- TemplateParameterViewModel: per-parameter VM. Localizes label/choices
  via ResourceHelper.GetString. Validates required+non-empty. Two-way
  binding via Value (Text) or SelectedChoice (Combo, which mirrors to
  Value on selection change).
- CommandTemplatePickerViewModel: orchestrates selection, parameter
  collection, live preview via TemplateResolver. Owns the
  ObservableCollection<TemplateParameterViewModel> the UI ItemsControl
  binds to. Subscribes to per-param PropertyChanged to recompute the
  preview on any value edit.
- TemplateParameterSelector: maps TemplateParameter.Type ("Text" |
  "Combo") to the corresponding XAML DataTemplate. Pure switch; no
  reflection — AOT friendly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:43:01 +08:00
Yu Leng
3a012d4cf1 KBM: Add resource keys for template picker UI and v1 catalog
20 new keys: action-type label, picker button + placeholder, preview
label, missing-template InfoBar text + 2 button labels, Settings
module + 2 command display strings, Module parameter label, and 7
module display names (ColorPicker through ZoomIt). Translation
pipeline (Crowdin/Touchdown) picks these up automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:42:02 +08:00
Yu Leng
9d58b1bdd1 KBM: Add catalog loader, resolver, and EmbeddedResource wiring
- CommandTemplateCatalog: Lazy singleton, loads from embedded
  powertoyscli.json via source-gen JsonSerializerContext.
  Validates schemaVersion and asserts >=1 module loaded.
- TemplateResolver: Pure substitution of {paramName} placeholders.
  No shell semantics, no quoting (v1 catalog values are safe).
- KeyboardManagerEditorUI.csproj: powertoyscli.json marked as
  EmbeddedResource with explicit LogicalName for predictable
  Assembly.GetManifestResourceStream lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:41:14 +08:00
Yu Leng
6fd36f9579 KBM: Add template catalog models, JSON context, and v1 powertoyscli.json
- POCO models: PowerToysCliCatalog, CommandTemplateModule,
  CommandTemplate, TemplateParameter, TemplateChoice
- Source-generated JsonSerializerContext (AOT-friendly)
- v1 catalog with 'Settings' module: openMain (no params),
  openModule (Combo param for 7 PowerToys modules)
- Executable uses %LOCALAPPDATA%\PowerToys\PowerToys.exe
  (per Task 3 finding: per-user install, ExpandEnvironmentStrings
  applied at trigger time)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:40:28 +08:00
Yu Leng
a8b79158f1 KBM: Add round-trip tests for template fields in KeysDataModel
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:39:55 +08:00
Yu Leng
ff578d15a3 KBM: Register Dictionary<string,string> in JsonSerializerContext for template parameters
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:39:25 +08:00
Yu Leng
bccceba97b KBM: Add TemplateId/TemplateParameters to KeysDataModel 2026-05-19 21:38:32 +08:00
Yu Leng
e03d048e8a KBM: Phase 0 Task 3 - PowerToys.exe path resolution findings
Documents which Win32 APIs the KBM engine uses per elevation mode,
confirms ExpandEnvironmentStrings is applied before launch, confirms
no App Paths or main-folder PATH registration in the installer, and
locks in %LOCALAPPDATA%\PowerToys\PowerToys.exe as the executable
value for the powertoyscli.json templates (Task 9).
2026-05-19 21:34:45 +08:00
Yu Leng
806ff3c07a KBM: Phase 0 Task 2 - engine write-path findings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:32:41 +08:00
Yu Leng
c1ecdda60c KBM: Phase 0 Task 1 - legacy editor JSON round-trip findings 2026-05-19 21:29:51 +08:00
Yu Leng
43e530d2e1 KBM: Implementation plan for CLI command templates
29 tasks across 13 phases. Front-loads three pre-implementation
verification tasks (legacy editor JSON round-trip, engine
read-only confirmation, PowerToys.exe path resolution) before
any production code. Each task is bite-sized with concrete code
or commands. Data-layer changes covered by MSTest unit tests in
Settings.UI.UnitTests; catalog/resolver verified via startup
smoke check plus manual end-to-end UI tests in Phase 11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:13:55 +08:00
Yu Leng
016e0732b0 KBM: Design doc for CLI command template mappings
Adds the brainstormed design for a new "Run from template" action type
in the new KeyboardManagerEditorUI: 3-level cascading menu (PowerToys
command -> Module -> Command) with dynamically rendered Text/Combo
parameters that resolve at save time into a standard RunProgram mapping.
v1 ships powertoyscli.json with one Settings module containing two
templates; the C++ engine and legacy editor are untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:04:51 +08:00
37 changed files with 1909 additions and 104 deletions

View File

@@ -1396,6 +1396,7 @@ POWERRENAMECONTEXTMENU
powerrenameinput
POWERRENAMETEST
POWERTOYNAME
powertoyscli
powertoyssetup
powertoysusersetup
Powrprof
@@ -1556,6 +1557,7 @@ RESIZETOFIT
resmimetype
RESOURCEID
RESTORETOMAXIMIZED
retargets
RETURNONLYFSDIRS
Revalidates
RGBQUAD

View File

@@ -500,6 +500,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/keyboardmanager/KeyboardManagerEditorUI.UnitTests/KeyboardManagerEditorUI.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/keyboardmanager/KeyboardManagerEngine/KeyboardManagerEngine.vcxproj" Id="ba661f5b-1d5a-4ffc-9bf1-fc39df280bdd" />
<Project Path="src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManagerEngineLibrary.vcxproj" Id="e496b7fc-1e99-4bab-849b-0e8367040b02" />
</Folder>

View File

@@ -6,6 +6,8 @@
#include <string>
#include <memory>
#include <common/utils/json.h>
#include <common/utils/logger_helper.h>
#include <keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.h>
#include <keyboardmanager/KeyboardManagerEditorLibrary/EditorHelpers.h>
@@ -41,6 +43,35 @@ extern "C"
return buffer;
}
// Populates the template metadata fields on a marshaled mapping. Always sets both pointers
// (the editor frees every field), using empty strings for non-template / non-RunProgram entries.
// Note: kept void-returning so it is valid inside the surrounding extern "C" block (a C-linkage
// function may not return a C++ type such as std::wstring).
void SetTemplateMetadata(ShortcutMapping* mapping, const Shortcut& targetShortcut)
{
mapping->templateId = AllocateAndCopyString(targetShortcut.templateId);
if (targetShortcut.templateParameters.empty())
{
mapping->templateParametersJson = AllocateAndCopyString(L"");
return;
}
json::JsonObject paramsObj;
for (auto const& [k, v] : targetShortcut.templateParameters)
{
paramsObj.SetNamedValue(k, json::JsonValue::CreateStringValue(v));
}
mapping->templateParametersJson = AllocateAndCopyString(paramsObj.Stringify().c_str());
}
void SetEmptyTemplateMetadata(ShortcutMapping* mapping)
{
mapping->templateId = AllocateAndCopyString(L"");
mapping->templateParametersJson = AllocateAndCopyString(L"");
}
int GetSingleKeyRemapCount(void* config)
{
auto mapping = static_cast<MappingConfiguration*>(config);
@@ -382,6 +413,17 @@ bool GetShortcutRemapByType(void* config, int operationType, int index, Shortcut
mapping->uriToOpen = AllocateAndCopyString(L"");
}
// Carry template metadata back to the editor so a template mapping re-opens as RunTemplate.
// Only RunProgram shortcuts ever carry it; every other entry gets empty (but allocated) fields.
if (targetShortcutUnion.index() == 1)
{
SetTemplateMetadata(mapping, std::get<Shortcut>(targetShortcutUnion));
}
else
{
SetEmptyTemplateMetadata(mapping);
}
return true;
}
@@ -483,6 +525,17 @@ bool GetShortcutRemapByType(void* config, int operationType, int index, Shortcut
mapping->uriToOpen = AllocateAndCopyString(L"");
}
// Carry template metadata back to the editor so a template mapping re-opens as RunTemplate.
// Only RunProgram shortcuts ever carry it; every other entry gets empty (but allocated) fields.
if (targetShortcutUnion.index() == 1)
{
SetTemplateMetadata(mapping, std::get<Shortcut>(targetShortcutUnion));
}
else
{
SetEmptyTemplateMetadata(mapping);
}
return true;
}
@@ -527,13 +580,15 @@ bool GetShortcutRemapByType(void* config, int operationType, int index, Shortcut
const wchar_t* originalKeys,
const wchar_t* targetKeys,
const wchar_t* targetApp,
int operationType,
int operationType,
const wchar_t* appPathOrUri,
const wchar_t* args,
const wchar_t* startDirectory,
int elevation,
int ifRunningAction,
int visibility)
int visibility,
const wchar_t* templateId,
const wchar_t* templateParametersJson)
{
auto mappingConfig = static_cast<MappingConfiguration*>(config);
@@ -558,6 +613,28 @@ bool GetShortcutRemapByType(void* config, int operationType, int index, Shortcut
std::get<Shortcut>(targetShortcut).alreadyRunningAction = static_cast<Shortcut::ProgramAlreadyRunningAction>(ifRunningAction);
std::get<Shortcut>(targetShortcut).startWindowType = static_cast<Shortcut::StartWindowType>(visibility);
std::get<Shortcut>(targetShortcut).operationType = static_cast<Shortcut::OperationType>(operationType);
// Optional template metadata — only set when provided.
if (templateId && templateId[0] != L'\0')
{
std::get<Shortcut>(targetShortcut).templateId = std::wstring(templateId);
}
if (templateParametersJson && templateParametersJson[0] != L'\0')
{
json::JsonObject paramsObj;
if (json::JsonObject::TryParse(templateParametersJson, paramsObj))
{
for (auto const& kv : paramsObj)
{
if (kv.Value().ValueType() == json::JsonValueType::String)
{
std::get<Shortcut>(targetShortcut).templateParameters.emplace(
std::wstring(kv.Key()),
std::wstring(kv.Value().GetString()));
}
}
}
}
break;
case 2:
targetShortcut = Shortcut(targetKeys);

View File

@@ -33,6 +33,8 @@ struct ShortcutMapping
wchar_t* programPath;
wchar_t* programArgs;
wchar_t* uriToOpen;
wchar_t* templateId;
wchar_t* templateParametersJson;
};
extern "C"
@@ -69,7 +71,9 @@ extern "C"
const wchar_t* startDirectory = nullptr,
int elevation = 0,
int ifRunningAction = 0,
int visibility = 0);
int visibility = 0,
const wchar_t* templateId = nullptr,
const wchar_t* templateParametersJson = nullptr);
__declspec(dllexport) void GetKeyDisplayName(int keyCode, wchar_t* keyName, int maxCount);
__declspec(dllexport) int GetKeyCodeFromName(const wchar_t* keyName);

View File

@@ -0,0 +1,125 @@
// 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.Linq;
using System.Text.Json;
using KeyboardManagerEditorUI.Templates;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace KeyboardManagerEditorUI.UnitTests
{
/// <summary>
/// Validates that the catalog data model deserializes the shipped powertoyscli.json schema
/// shape correctly. (Reflection-based deserialization mirrors the source-generated context.)
/// </summary>
[TestClass]
public class CommandTemplateCatalogModelTests
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private const string SeedCatalog = @"
{
""schemaVersion"": 1,
""modules"": [
{
""id"": ""settings"",
""displayResourceKey"": ""TemplateModule_Settings"",
""iconGlyph"": """",
""commands"": [
{
""id"": ""settings.openMain"",
""displayResourceKey"": ""TemplateCmd_Settings_OpenMain"",
""executable"": ""%LOCALAPPDATA%\\PowerToys\\PowerToys.exe"",
""argsTemplate"": ""--open-settings"",
""parameters"": []
},
{
""id"": ""settings.openModule"",
""displayResourceKey"": ""TemplateCmd_Settings_OpenModule"",
""executable"": ""%LOCALAPPDATA%\\PowerToys\\PowerToys.exe"",
""argsTemplate"": ""--open-settings={module}"",
""parameters"": [
{
""name"": ""module"",
""labelResourceKey"": ""TemplateParam_Module"",
""type"": ""Combo"",
""required"": true,
""choices"": [
{ ""value"": ""ColorPicker"", ""displayResourceKey"": ""Module_ColorPicker"" }
]
}
]
}
]
}
]
}";
[TestMethod]
public void Deserialize_SeedCatalog_MapsAllFields()
{
var catalog = JsonSerializer.Deserialize<PowerToysCliCatalog>(SeedCatalog, Options);
Assert.IsNotNull(catalog);
Assert.AreEqual(1, catalog!.SchemaVersion);
Assert.AreEqual(1, catalog.Modules.Count);
var module = catalog.Modules[0];
Assert.AreEqual("settings", module.Id);
Assert.AreEqual("TemplateModule_Settings", module.DisplayResourceKey);
Assert.AreEqual(2, module.Commands.Count);
var openMain = module.Commands.Single(c => c.Id == "settings.openMain");
Assert.AreEqual("--open-settings", openMain.ArgsTemplate);
Assert.AreEqual(0, openMain.Parameters.Count);
var openModule = module.Commands.Single(c => c.Id == "settings.openModule");
Assert.AreEqual("--open-settings={module}", openModule.ArgsTemplate);
Assert.AreEqual(1, openModule.Parameters.Count);
var param = openModule.Parameters[0];
Assert.AreEqual("module", param.Name);
Assert.AreEqual("Combo", param.Type);
Assert.IsTrue(param.Required);
Assert.IsNotNull(param.Choices);
Assert.AreEqual("ColorPicker", param.Choices![0].Value);
Assert.AreEqual("Module_ColorPicker", param.Choices[0].DisplayResourceKey);
}
[TestMethod]
public void Deserialize_ForwardCompatibleVersion_StillParses()
{
// A newer, additive schemaVersion with an unknown field must deserialize (unknown fields ignored).
const string newer = @"
{
""schemaVersion"": 2,
""futureField"": ""ignored"",
""modules"": [
{ ""id"": ""m"", ""displayResourceKey"": ""k"", ""commands"": [] }
]
}";
var catalog = JsonSerializer.Deserialize<PowerToysCliCatalog>(newer, Options);
Assert.IsNotNull(catalog);
Assert.AreEqual(2, catalog!.SchemaVersion);
Assert.AreEqual(1, catalog.Modules.Count);
}
[TestMethod]
public void Deserialize_OptionalChoices_DefaultToNull()
{
var param = JsonSerializer.Deserialize<TemplateParameter>(
@"{ ""name"": ""p"", ""type"": ""Text"" }", Options);
Assert.IsNotNull(param);
Assert.AreEqual("p", param!.Name);
Assert.IsNull(param.Choices);
}
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<SelfContained>true</SelfContained>
<RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier>
<RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
<OutputPath>$(RepoRoot)$(Configuration)\$(Platform)\tests\KeyboardManagerEditorUITests\</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<!--
Source-link the pure (WinUI-free) template logic so it can be unit-tested without referencing
the KeyboardManagerEditorUI WinExe app. These files have no UI/WinRT dependencies.
-->
<ItemGroup>
<Compile Include="..\KeyboardManagerEditorUI\Templates\TemplateResolver.cs" Link="Templates\TemplateResolver.cs" />
<Compile Include="..\KeyboardManagerEditorUI\Templates\CommandTemplate.cs" Link="Templates\CommandTemplate.cs" />
<Compile Include="..\KeyboardManagerEditorUI\Templates\CommandTemplateModule.cs" Link="Templates\CommandTemplateModule.cs" />
<Compile Include="..\KeyboardManagerEditorUI\Templates\PowerToysCliCatalog.cs" Link="Templates\PowerToysCliCatalog.cs" />
<Compile Include="..\KeyboardManagerEditorUI\Templates\TemplateParameter.cs" Link="Templates\TemplateParameter.cs" />
<Compile Include="..\KeyboardManagerEditorUI\Templates\TemplateChoice.cs" Link="Templates\TemplateChoice.cs" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,145 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using KeyboardManagerEditorUI.Templates;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace KeyboardManagerEditorUI.UnitTests
{
[TestClass]
public class TemplateResolverTests
{
private static CommandTemplate Template(string args, params TemplateParameter[] parameters)
=> new()
{
Id = "test.cmd",
Executable = "%LOCALAPPDATA%\\PowerToys\\PowerToys.exe",
ArgsTemplate = args,
Parameters = new List<TemplateParameter>(parameters),
};
private static TemplateParameter Param(string name, string type = "Text", bool required = true)
=> new() { Name = name, Type = type, Required = required };
[TestMethod]
public void Resolve_SubstitutesPresentValue()
{
var result = TemplateResolver.Resolve(
Template("--open-settings={module}", Param("module")),
new Dictionary<string, string> { ["module"] = "ColorPicker" });
Assert.AreEqual("%LOCALAPPDATA%\\PowerToys\\PowerToys.exe", result.Executable);
Assert.AreEqual("--open-settings=ColorPicker", result.Args);
}
[TestMethod]
public void Resolve_MissingValue_SubstitutesEmpty()
{
var result = TemplateResolver.Resolve(
Template("--open-settings={module}", Param("module")),
new Dictionary<string, string>());
Assert.AreEqual("--open-settings=", result.Args);
}
[TestMethod]
public void Resolve_NullValues_SubstitutesEmpty()
{
var result = TemplateResolver.Resolve(
Template("--open-settings={module}", Param("module")),
values: null);
Assert.AreEqual("--open-settings=", result.Args);
}
[TestMethod]
public void Resolve_NoParameters_ReturnsTemplateVerbatim()
{
var result = TemplateResolver.Resolve(Template("--open-settings"), null);
Assert.AreEqual("--open-settings", result.Args);
}
[TestMethod]
public void Resolve_UnknownPlaceholder_LeftUntouched()
{
// {other} is not a declared parameter, so it must be preserved literally.
var result = TemplateResolver.Resolve(
Template("--a={module} --b={other}", Param("module")),
new Dictionary<string, string> { ["module"] = "X" });
Assert.AreEqual("--a=X --b={other}", result.Args);
}
[TestMethod]
public void Resolve_IsSinglePass_DoesNotReSubstituteInjectedPlaceholder()
{
// If the first parameter's value contains "{second}", the second pass must NOT replace it.
var result = TemplateResolver.Resolve(
Template("{first}-{second}", Param("first"), Param("second")),
new Dictionary<string, string>
{
["first"] = "{second}",
["second"] = "INJECTED",
});
Assert.AreEqual("{second}-INJECTED", result.Args);
}
[TestMethod]
public void Resolve_NoSubstringCollisionBetweenSimilarNames()
{
var result = TemplateResolver.Resolve(
Template("{module}|{moduleVersion}", Param("module"), Param("moduleVersion")),
new Dictionary<string, string> { ["module"] = "A", ["moduleVersion"] = "B" });
Assert.AreEqual("A|B", result.Args);
}
[TestMethod]
public void Resolve_ValueWithSpace_IsQuoted()
{
var result = TemplateResolver.Resolve(
Template("--path={p}", Param("p")),
new Dictionary<string, string> { ["p"] = "C:\\Program Files\\App" });
Assert.AreEqual("--path=\"C:\\Program Files\\App\"", result.Args);
}
[TestMethod]
public void Resolve_SimpleValue_NotQuoted()
{
var result = TemplateResolver.Resolve(
Template("--x={p}", Param("p")),
new Dictionary<string, string> { ["p"] = "Plain" });
Assert.AreEqual("--x=Plain", result.Args);
}
[TestMethod]
public void Resolve_ValueWithQuote_IsEscaped()
{
var result = TemplateResolver.Resolve(
Template("--x={p}", Param("p")),
new Dictionary<string, string> { ["p"] = "a\"b c" });
// Embedded quote is backslash-escaped and the whole value wrapped in quotes.
Assert.AreEqual("--x=\"a\\\"b c\"", result.Args);
}
[TestMethod]
public void QuoteArgumentIfNeeded_TrailingBackslashesBeforeClosingQuote_AreDoubled()
{
// "a b\\" must become "a b\\\\" so the backslashes don't escape the closing quote.
Assert.AreEqual("\"a b\\\\\"", TemplateResolver.QuoteArgumentIfNeeded("a b\\"));
}
[TestMethod]
public void QuoteArgumentIfNeeded_Empty_ReturnsEmpty()
{
Assert.AreEqual(string.Empty, TemplateResolver.QuoteArgumentIfNeeded(string.Empty));
}
}
}

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="KeyboardManagerEditorUI.Controls.CommandTemplatePickerControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:KeyboardManagerEditorUI.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:KeyboardManagerEditorUI.ViewModels"
mc:Ignorable="d">
<UserControl.Resources>
<DataTemplate x:Key="TextParamTemplate" x:DataType="vm:TemplateParameterViewModel">
<TextBox
Header="{x:Bind Label}"
Text="{x:Bind Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</DataTemplate>
<DataTemplate x:Key="ComboParamTemplate" x:DataType="vm:TemplateParameterViewModel">
<ComboBox
HorizontalAlignment="Stretch"
Header="{x:Bind Label}"
ItemsSource="{x:Bind Choices}"
SelectedItem="{x:Bind SelectedChoice, Mode=TwoWay}"
DisplayMemberPath="DisplayText" />
</DataTemplate>
<local:TemplateParameterSelector
x:Key="ParamSelector"
ComboTemplate="{StaticResource ComboParamTemplate}"
TextTemplate="{StaticResource TextParamTemplate}" />
</UserControl.Resources>
<StackPanel Orientation="Vertical" Spacing="12">
<TextBlock
x:Name="SelectionDescriptionText"
FontStyle="Italic"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.SelectionDescription, Mode=OneWay}" />
<Rectangle
Height="1"
HorizontalAlignment="Stretch"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
<ItemsControl
ItemsSource="{x:Bind ViewModel.CurrentParameters, Mode=OneWay}"
ItemTemplateSelector="{StaticResource ParamSelector}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" Spacing="12" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<Rectangle
Height="1"
HorizontalAlignment="Stretch"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
<TextBlock x:Uid="TemplatePreviewLabel" FontWeight="SemiBold" />
<TextBlock
FontFamily="Consolas"
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
IsTextSelectionEnabled="True"
Text="{x:Bind ViewModel.ResolvedCommandLine, Mode=OneWay}"
TextWrapping="Wrap" />
<InfoBar
x:Name="MissingTemplateInfoBar"
x:Uid="MissingTemplateInfoBar"
IsClosable="False"
IsOpen="False"
Severity="Warning">
<StackPanel
Margin="0,0,0,8"
Orientation="Horizontal"
Spacing="8">
<Button
x:Name="MissingTemplateKeepButton"
x:Uid="TemplateMissingKeepButton"
Click="MissingTemplateKeepButton_Click" />
</StackPanel>
</InfoBar>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,112 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using KeyboardManagerEditorUI.Helpers;
using KeyboardManagerEditorUI.Templates;
using KeyboardManagerEditorUI.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace KeyboardManagerEditorUI.Controls
{
public sealed partial class CommandTemplatePickerControl : UserControl
{
public CommandTemplatePickerControl()
{
InitializeComponent();
ViewModel = new CommandTemplatePickerViewModel();
// Re-raise SelectionChanged when parameter validity could have changed so the host
// (UnifiedMappingControl/MainPage) re-evaluates Save-button enablement, not just on
// the initial template pick.
ViewModel.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(CommandTemplatePickerViewModel.IsAllValid))
{
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
};
}
public CommandTemplatePickerViewModel ViewModel { get; }
public event EventHandler? SelectionChanged;
/// <summary>
/// Gets a value indicating whether a template is selected and all its required
/// parameters have values. Drives Save-button enablement for the RunTemplate action.
/// </summary>
public bool IsTemplateInputValid =>
ViewModel.SelectedTemplate is not null && ViewModel.IsAllValid;
public event EventHandler? MissingTemplateKeepRequested;
public TemplateResolver.Resolved? ResolveCurrent()
{
if (ViewModel.SelectedTemplate is null)
{
return null;
}
return TemplateResolver.Resolve(
ViewModel.SelectedTemplate,
ViewModel.CollectParameterValues());
}
public string? CurrentTemplateId => ViewModel.SelectedTemplate?.Id;
public Dictionary<string, string> CurrentParameterValues => ViewModel.CollectParameterValues();
public void LoadExisting(string templateId, IReadOnlyDictionary<string, string>? values)
{
try
{
ViewModel.LoadExisting(templateId, values);
MissingTemplateInfoBar.IsOpen = false;
}
catch (InvalidOperationException)
{
ShowMissingTemplateInfoBar();
}
}
public void Reset()
{
ViewModel.Clear();
MissingTemplateInfoBar.IsOpen = false;
}
/// <summary>
/// Selects a command template by id (driven by the host action menu) and notifies listeners.
/// </summary>
public void SelectCommand(string templateId)
{
ViewModel.SelectTemplate(templateId);
MissingTemplateInfoBar.IsOpen = false;
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Gets the display name of the currently selected command (empty when none selected).
/// </summary>
public string CurrentCommandDisplay =>
ViewModel.SelectedTemplate is { } t
? ResourceHelper.GetString(t.DisplayResourceKey)
: string.Empty;
private void ShowMissingTemplateInfoBar()
{
MissingTemplateInfoBar.IsOpen = true;
}
private void MissingTemplateKeepButton_Click(object sender, RoutedEventArgs e)
{
MissingTemplateInfoBar.IsOpen = false;
MissingTemplateKeepRequested?.Invoke(this, EventArgs.Empty);
}
}
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using KeyboardManagerEditorUI.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace KeyboardManagerEditorUI.Controls
{
public sealed partial class TemplateParameterSelector : DataTemplateSelector
{
public DataTemplate? TextTemplate { get; set; }
public DataTemplate? ComboTemplate { get; set; }
protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
{
if (item is TemplateParameterViewModel vm)
{
return vm.Type switch
{
"Combo" => ComboTemplate ?? TextTemplate!,
_ => TextTemplate!,
};
}
return TextTemplate!;
}
}
}

View File

@@ -175,52 +175,67 @@
Orientation="Vertical"
Spacing="8">
<TextBlock x:Uid="ActionLabel" FontWeight="SemiBold" />
<ComboBox
x:Name="ActionTypeComboBox"
<DropDownButton
x:Name="ActionTypeButton"
HorizontalAlignment="Stretch"
SelectionChanged="ActionTypeComboBox_SelectionChanged">
<ComboBoxItem
x:Uid="ActionType_KeyOrShortcut"
IsSelected="True"
Tag="KeyOrShortcut">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xEDA7;" />
<TextBlock x:Uid="ActionType_KeyOrShortcut_Text" />
</StackPanel>
</ComboBoxItem>
<ComboBoxItem x:Uid="ActionType_Text" Tag="Text">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE8D2;" />
<TextBlock x:Uid="ActionType_Text_Text" />
</StackPanel>
</ComboBoxItem>
<ComboBoxItem x:Uid="ActionType_OpenUrl" Tag="OpenUrl">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE774;" />
<TextBlock x:Uid="ActionType_OpenUrl_Text" />
</StackPanel>
</ComboBoxItem>
<ComboBoxItem x:Uid="ActionType_OpenApp" Tag="OpenApp">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xECAA;" />
<TextBlock x:Uid="ActionType_OpenApp_Text" />
</StackPanel>
</ComboBoxItem>
<ComboBoxItem x:Uid="ActionType_Disable" Tag="Disable">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE711;" />
<TextBlock x:Uid="ActionType_Disable_Text" />
</StackPanel>
</ComboBoxItem>
<!--
<ComboBoxItem x:Uid="ActionType_MouseClick" Tag="MouseClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE962;" />
<TextBlock x:Uid="ActionType_MouseClick_Text" />
</StackPanel>
</ComboBoxItem>
-->
</ComboBox>
HorizontalContentAlignment="Left">
<DropDownButton.Flyout>
<MenuFlyout Placement="Bottom">
<MenuFlyoutItem
x:Name="ActionItem_KeyOrShortcut"
x:Uid="ActionType_KeyOrShortcut_Text"
Click="OnActionTypeClick"
Tag="KeyOrShortcut">
<MenuFlyoutItem.Icon>
<FontIcon FontSize="14" Glyph="&#xEDA7;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutItem
x:Name="ActionItem_Text"
x:Uid="ActionType_Text_Text"
Click="OnActionTypeClick"
Tag="Text">
<MenuFlyoutItem.Icon>
<FontIcon FontSize="14" Glyph="&#xE8D2;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutItem
x:Name="ActionItem_OpenUrl"
x:Uid="ActionType_OpenUrl_Text"
Click="OnActionTypeClick"
Tag="OpenUrl">
<MenuFlyoutItem.Icon>
<FontIcon FontSize="14" Glyph="&#xE774;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutItem
x:Name="ActionItem_OpenApp"
x:Uid="ActionType_OpenApp_Text"
Click="OnActionTypeClick"
Tag="OpenApp">
<MenuFlyoutItem.Icon>
<FontIcon FontSize="14" Glyph="&#xECAA;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutItem
x:Name="ActionItem_Disable"
x:Uid="ActionType_Disable_Text"
Click="OnActionTypeClick"
Tag="Disable">
<MenuFlyoutItem.Icon>
<FontIcon FontSize="14" Glyph="&#xE711;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutSubItem
x:Name="RunPtCommandSubItem"
x:Uid="ActionType_RunTemplate_Text">
<MenuFlyoutSubItem.Icon>
<FontIcon FontSize="14" Glyph="&#xE756;" />
</MenuFlyoutSubItem.Icon>
</MenuFlyoutSubItem>
</MenuFlyout>
</DropDownButton.Flyout>
</DropDownButton>
<Rectangle
Height="1"
Margin="0,12,0,12"
@@ -229,7 +244,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
@@ -398,6 +413,12 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
</tkcontrols:Case>
<!-- Run From Template Action -->
<tkcontrols:Case Value="RunTemplate">
<local:CommandTemplatePickerControl
x:Name="TemplatePicker"
MissingTemplateKeepRequested="TemplatePicker_MissingTemplateKeepRequested" />
</tkcontrols:Case>
</tkcontrols:SwitchPresenter>
</StackPanel>
<!-- Validation InfoBar spanning all columns -->

View File

@@ -8,6 +8,7 @@ using System.Collections.ObjectModel;
using System.Linq;
using KeyboardManagerEditorUI.Helpers;
using KeyboardManagerEditorUI.Interop;
using KeyboardManagerEditorUI.Templates;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.Storage;
@@ -44,6 +45,13 @@ namespace KeyboardManagerEditorUI.Controls
private bool _urlPathDirty;
private bool _programPathDirty;
private string _currentActionTag = "KeyOrShortcut";
// Resolved program path/args captured when loading a template mapping, so the
// missing-template "Keep as plain command" path can preserve the command.
private string _templateFallbackProgramPath = string.Empty;
private string _templateFallbackProgramArgs = string.Empty;
public bool AllowChords { get; set; } = true;
#endregion
@@ -79,6 +87,7 @@ namespace KeyboardManagerEditorUI.Controls
OpenApp,
MouseClick,
Disable,
RunTemplate,
}
/// <summary>
@@ -119,26 +128,16 @@ namespace KeyboardManagerEditorUI.Controls
/// <summary>
/// Gets the current action type.
/// </summary>
public ActionType CurrentActionType
public ActionType CurrentActionType => _currentActionTag switch
{
get
{
if (ActionTypeComboBox?.SelectedItem is ComboBoxItem item)
{
return item.Tag?.ToString() switch
{
"Text" => ActionType.Text,
"OpenUrl" => ActionType.OpenUrl,
"OpenApp" => ActionType.OpenApp,
"MouseClick" => ActionType.MouseClick,
"Disable" => ActionType.Disable,
_ => ActionType.KeyOrShortcut,
};
}
return ActionType.KeyOrShortcut;
}
}
"Text" => ActionType.Text,
"OpenUrl" => ActionType.OpenUrl,
"OpenApp" => ActionType.OpenApp,
"MouseClick" => ActionType.MouseClick,
"Disable" => ActionType.Disable,
"RunTemplate" => ActionType.RunTemplate,
_ => ActionType.KeyOrShortcut,
};
#endregion
@@ -163,6 +162,8 @@ namespace KeyboardManagerEditorUI.Controls
RaiseValidationStateChanged();
};
BuildRunPtCommandMenu();
this.Unloaded += UnifiedMappingControl_Unloaded;
}
@@ -172,24 +173,49 @@ namespace KeyboardManagerEditorUI.Controls
private void UserControl_Loaded(object sender, RoutedEventArgs e)
{
// Set up event handlers for app-specific checkbox
// Set up event handlers for app-specific checkbox.
// Detach first so re-entering the visual tree (dialog reopened) does not stack handlers.
AppSpecificCheckBox.Checked -= AppSpecificCheckBox_Changed;
AppSpecificCheckBox.Unchecked -= AppSpecificCheckBox_Changed;
AppSpecificCheckBox.Checked += AppSpecificCheckBox_Changed;
AppSpecificCheckBox.Unchecked += AppSpecificCheckBox_Changed;
// Wire template picker selection/validity changes so validation re-runs when the user
// picks a template or edits its parameters. Guarded against duplicate subscriptions.
if (TemplatePicker != null)
{
TemplatePicker.SelectionChanged -= TemplatePicker_SelectionChanged;
TemplatePicker.SelectionChanged += TemplatePicker_SelectionChanged;
}
// Activate keyboard hook for the trigger input
if (TriggerKeyToggleBtn.IsChecked == true)
{
_currentInputMode = KeyInputMode.OriginalKeys;
KeyboardHookHelper.Instance.ActivateHook(this);
}
// Initialize the action button label here (not in the constructor) so the flyout
// items' localized Text is guaranteed populated before it is read.
UpdateActionButtonContent(_currentActionTag);
}
private void UnifiedMappingControl_Unloaded(object sender, RoutedEventArgs e)
{
// Detach handlers wired in UserControl_Loaded so they don't accumulate across reopens.
AppSpecificCheckBox.Checked -= AppSpecificCheckBox_Changed;
AppSpecificCheckBox.Unchecked -= AppSpecificCheckBox_Changed;
if (TemplatePicker != null)
{
TemplatePicker.SelectionChanged -= TemplatePicker_SelectionChanged;
}
Reset();
CleanupKeyboardHook();
}
private void TemplatePicker_SelectionChanged(object? sender, EventArgs e) => RaiseValidationStateChanged();
#endregion
#region Trigger Type Handling
@@ -242,29 +268,128 @@ namespace KeyboardManagerEditorUI.Controls
#region Action Type Handling
private void ActionTypeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
private void OnActionTypeClick(object sender, RoutedEventArgs e)
{
if (ActionTypeComboBox?.SelectedItem is ComboBoxItem item)
if (sender is FrameworkElement fe && fe.Tag is string tag)
{
string? tag = item.Tag?.ToString();
SetActionTag(tag);
}
}
// Cleanup keyboard hook when switching away from key/shortcut
if (tag != "KeyOrShortcut")
private void OnCommandClick(object sender, RoutedEventArgs e)
{
if (sender is MenuFlyoutItem item && item.Tag is string templateId)
{
// Select the template first so the action-type switch validates against the picked
// command (not the previously-selected one), avoiding a transient stale Save state.
TemplatePicker?.SelectCommand(templateId);
SetActionTag("RunTemplate");
UpdateActionButtonContent("RunTemplate", item.Text);
}
}
private void SetActionTag(string tag)
{
_currentActionTag = tag;
if (ActionSwitchPresenter != null)
{
ActionSwitchPresenter.Value = tag;
}
// Cleanup keyboard hook when switching away from key/shortcut
if (tag != "KeyOrShortcut")
{
if (_currentInputMode == KeyInputMode.RemappedKeys)
{
if (_currentInputMode == KeyInputMode.RemappedKeys)
{
CleanupKeyboardHook();
}
CleanupKeyboardHook();
}
if (ActionKeyToggleBtn?.IsChecked == true)
{
ActionKeyToggleBtn.IsChecked = false;
}
if (ActionKeyToggleBtn?.IsChecked == true)
{
ActionKeyToggleBtn.IsChecked = false;
}
}
HideValidationMessage();
RaiseValidationStateChanged();
UpdateActionButtonContent(tag);
}
private void UpdateActionButtonContent(string tag, string? commandDisplay = null)
{
if (ActionTypeButton == null)
{
return;
}
string cmd = commandDisplay ?? TemplatePicker?.CurrentCommandDisplay ?? string.Empty;
string text = tag switch
{
"Text" => ActionItem_Text.Text,
"OpenUrl" => ActionItem_OpenUrl.Text,
"OpenApp" => ActionItem_OpenApp.Text,
"Disable" => ActionItem_Disable.Text,
"RunTemplate" => string.IsNullOrEmpty(cmd)
? RunPtCommandSubItem.Text
: $"{RunPtCommandSubItem.Text}: {cmd}",
_ => ActionItem_KeyOrShortcut.Text,
};
ActionTypeButton.Content = text;
}
private void BuildRunPtCommandMenu()
{
// A malformed/missing catalog must not crash construction of the whole mapping editor.
// Degrade gracefully: log and leave the "Run PowerToys command" submenu empty/disabled.
try
{
foreach (var module in CommandTemplateCatalog.Instance.Data.Modules)
{
var moduleSub = new MenuFlyoutSubItem
{
Text = ResourceHelper.GetString(module.DisplayResourceKey),
};
if (!string.IsNullOrEmpty(module.IconGlyph))
{
moduleSub.Icon = new FontIcon { Glyph = module.IconGlyph };
}
foreach (var cmd in module.Commands)
{
var item = new MenuFlyoutItem
{
Text = ResourceHelper.GetString(cmd.DisplayResourceKey),
Tag = cmd.Id,
};
item.Click += OnCommandClick;
moduleSub.Items.Add(item);
}
RunPtCommandSubItem.Items.Add(moduleSub);
}
}
catch (Exception ex)
{
ManagedCommon.Logger.LogError($"Failed to build PowerToys command template menu: {ex.Message}");
RunPtCommandSubItem.IsEnabled = false;
}
}
private void TemplatePicker_MissingTemplateKeepRequested(object sender, EventArgs e)
{
// The user chose to keep the resolved command but discard the template association.
// Switch to OpenApp first so the program-path inputs are realized, then populate them
// with the previously-resolved command so the mapping is preserved (not blanked).
SetActionType(ActionType.OpenApp);
SetProgramPath(_templateFallbackProgramPath);
SetProgramArgs(_templateFallbackProgramArgs);
// Clear the picker so it doesn't retain stale template state.
TemplatePicker?.Reset();
}
private void ActionKeyToggleBtn_Checked(object sender, RoutedEventArgs e)
@@ -798,6 +923,29 @@ namespace KeyboardManagerEditorUI.Controls
/// </summary>
public ProgramAlreadyRunningAction GetIfRunningAction() => (ProgramAlreadyRunningAction)(IfRunningComboBox?.SelectedIndex ?? 0);
/// <summary>
/// Gets the resolved template executable (for RunTemplate action type).
/// Returns null if no template is selected or the template cannot be resolved.
/// </summary>
public string? GetResolvedTemplateExecutable() => TemplatePicker?.ResolveCurrent()?.Executable;
/// <summary>
/// Gets the resolved template arguments (for RunTemplate action type).
/// Returns null if no template is selected or the template cannot be resolved.
/// </summary>
public string? GetResolvedTemplateArgs() => TemplatePicker?.ResolveCurrent()?.Args;
/// <summary>
/// Gets the template id of the currently selected template (for RunTemplate action type).
/// Returns null if no template is selected.
/// </summary>
public string? GetCurrentTemplateId() => TemplatePicker?.CurrentTemplateId;
/// <summary>
/// Gets the current template parameter values (for RunTemplate action type).
/// </summary>
public Dictionary<string, string> GetCurrentTemplateParameterValues() => TemplatePicker?.CurrentParameterValues ?? new Dictionary<string, string>();
#endregion
#region Public API - Validation
@@ -820,6 +968,7 @@ namespace KeyboardManagerEditorUI.Controls
ActionType.OpenUrl => !string.IsNullOrWhiteSpace(UrlPathInput?.Text),
ActionType.OpenApp => !string.IsNullOrWhiteSpace(ProgramPathInput?.Text),
ActionType.Disable => true,
ActionType.RunTemplate => TemplatePicker?.IsTemplateInputValid == true,
_ => false,
};
}
@@ -865,11 +1014,6 @@ namespace KeyboardManagerEditorUI.Controls
/// </summary>
public void SetActionType(ActionType actionType)
{
if (ActionTypeComboBox == null)
{
return;
}
string tag = actionType switch
{
ActionType.Text => "Text",
@@ -877,17 +1021,11 @@ namespace KeyboardManagerEditorUI.Controls
ActionType.OpenApp => "OpenApp",
ActionType.Disable => "Disable",
ActionType.MouseClick => "MouseClick",
ActionType.RunTemplate => "RunTemplate",
_ => "KeyOrShortcut",
};
foreach (var item in ActionTypeComboBox.Items)
{
if (item is ComboBoxItem comboBoxItem && comboBoxItem.Tag is string itemTag && itemTag == tag)
{
ActionTypeComboBox.SelectedItem = comboBoxItem;
return;
}
}
SetActionTag(tag);
}
/// <summary>
@@ -978,6 +1116,26 @@ namespace KeyboardManagerEditorUI.Controls
}
}
/// <summary>
/// Loads an existing template mapping into the picker (for RunTemplate action type).
/// Switches the action type to RunTemplate and calls LoadExisting on the picker.
/// </summary>
public void SetRunTemplate(
string templateId,
IReadOnlyDictionary<string, string>? parameterValues,
string fallbackProgramPath = "",
string fallbackProgramArgs = "")
{
// Remember the already-resolved command so "Keep as plain command" can preserve it
// if the stored templateId is no longer in the catalog.
_templateFallbackProgramPath = fallbackProgramPath ?? string.Empty;
_templateFallbackProgramArgs = fallbackProgramArgs ?? string.Empty;
SetActionType(ActionType.RunTemplate);
TemplatePicker?.LoadExisting(templateId, parameterValues);
UpdateActionButtonContent("RunTemplate");
}
/// <summary>
/// Sets whether the mapping is app-specific.
/// </summary>
@@ -1121,10 +1279,7 @@ namespace KeyboardManagerEditorUI.Controls
TriggerTypeComboBox.SelectedIndex = 0;
}
if (ActionTypeComboBox != null)
{
ActionTypeComboBox.SelectedIndex = 0;
}
SetActionTag("KeyOrShortcut");
if (MouseTriggerComboBox != null)
{
@@ -1186,6 +1341,13 @@ namespace KeyboardManagerEditorUI.Controls
VisibilityComboBox.SelectedIndex = 0;
}
// Reset template picker
TemplatePicker?.Reset();
// Clear the cached missing-template fallback command so it can't leak into the next open.
_templateFallbackProgramPath = string.Empty;
_templateFallbackProgramArgs = string.Empty;
HideValidationMessage();
}

View File

@@ -83,7 +83,9 @@ namespace KeyboardManagerEditorUI.Interop
[MarshalAs(UnmanagedType.LPWStr)] string? startDirectory = null,
int elevation = 0,
int ifRunningAction = 0,
int visibility = 0);
int visibility = 0,
[MarshalAs(UnmanagedType.LPWStr)] string? templateId = null,
[MarshalAs(UnmanagedType.LPWStr)] string? templateParametersJson = null);
// Delete Mapping Functions
[DllImport(DllName, CallingConvention = Convention)]
@@ -165,6 +167,8 @@ namespace KeyboardManagerEditorUI.Interop
public IntPtr ProgramPath;
public IntPtr ProgramArgs;
public IntPtr UriToOpen;
public IntPtr TemplateId;
public IntPtr TemplateParametersJson;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]

View File

@@ -7,7 +7,9 @@ using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using KeyboardManagerEditorUI.Templates;
using ManagedCommon;
namespace KeyboardManagerEditorUI.Interop
@@ -61,6 +63,7 @@ namespace KeyboardManagerEditorUI.Interop
var mapping = default(ShortcutMapping);
if (KeyboardManagerInterop.GetShortcutRemap(_configHandle, i, ref mapping))
{
var (templateId, templateParameters) = ReadTemplateFields(ref mapping);
result.Add(new ShortcutKeyMapping
{
OriginalKeys = KeyboardManagerInterop.GetStringAndFree(mapping.OriginalKeys),
@@ -71,6 +74,8 @@ namespace KeyboardManagerEditorUI.Interop
ProgramPath = KeyboardManagerInterop.GetStringAndFree(mapping.ProgramPath),
ProgramArgs = KeyboardManagerInterop.GetStringAndFree(mapping.ProgramArgs),
UriToOpen = KeyboardManagerInterop.GetStringAndFree(mapping.UriToOpen),
TemplateId = templateId,
TemplateParameters = templateParameters,
});
}
}
@@ -78,6 +83,33 @@ namespace KeyboardManagerEditorUI.Interop
return result;
}
// Reads (and frees) the template metadata strings returned by the native layer.
// Must be called for every mapping so the native-allocated strings are always freed.
private static (string? TemplateId, Dictionary<string, string>? TemplateParameters) ReadTemplateFields(ref ShortcutMapping mapping)
{
string templateId = KeyboardManagerInterop.GetStringAndFree(mapping.TemplateId);
string templateParametersJson = KeyboardManagerInterop.GetStringAndFree(mapping.TemplateParametersJson);
Dictionary<string, string>? parameters = null;
if (!string.IsNullOrEmpty(templateParametersJson))
{
try
{
parameters = JsonSerializer.Deserialize(
templateParametersJson,
CommandTemplateJsonContext.Default.DictionaryStringString);
}
catch (Exception)
{
// Malformed on-disk metadata must never break config load — the two template
// strings are already freed above; swallow so the caller still frees the rest.
parameters = null;
}
}
return (string.IsNullOrEmpty(templateId) ? null : templateId, parameters);
}
public List<ShortcutKeyMapping> GetShortcutMappingsByType(ShortcutOperationType operationType)
{
var result = new List<ShortcutKeyMapping>();
@@ -88,6 +120,7 @@ namespace KeyboardManagerEditorUI.Interop
var mapping = default(ShortcutMapping);
if (KeyboardManagerInterop.GetShortcutRemapByType(_configHandle, (int)operationType, i, ref mapping))
{
var (templateId, templateParameters) = ReadTemplateFields(ref mapping);
result.Add(new ShortcutKeyMapping
{
OriginalKeys = KeyboardManagerInterop.GetStringAndFree(mapping.OriginalKeys),
@@ -98,6 +131,8 @@ namespace KeyboardManagerEditorUI.Interop
ProgramPath = KeyboardManagerInterop.GetStringAndFree(mapping.ProgramPath),
ProgramArgs = KeyboardManagerInterop.GetStringAndFree(mapping.ProgramArgs),
UriToOpen = KeyboardManagerInterop.GetStringAndFree(mapping.UriToOpen),
TemplateId = templateId,
TemplateParameters = templateParameters,
});
}
}
@@ -221,6 +256,14 @@ namespace KeyboardManagerEditorUI.Interop
if (shortcutKeyMapping.OperationType == ShortcutOperationType.RunProgram)
{
string? templateParametersJson = null;
if (shortcutKeyMapping.TemplateParameters is not null && shortcutKeyMapping.TemplateParameters.Count > 0)
{
templateParametersJson = JsonSerializer.Serialize(
shortcutKeyMapping.TemplateParameters,
CommandTemplateJsonContext.Default.DictionaryStringString);
}
return KeyboardManagerInterop.AddShortcutRemap(
_configHandle,
shortcutKeyMapping.OriginalKeys,
@@ -232,7 +275,9 @@ namespace KeyboardManagerEditorUI.Interop
string.IsNullOrEmpty(shortcutKeyMapping.StartInDirectory) ? null : shortcutKeyMapping.StartInDirectory,
(int)shortcutKeyMapping.Elevation,
(int)shortcutKeyMapping.IfRunningAction,
(int)shortcutKeyMapping.Visibility);
(int)shortcutKeyMapping.Visibility,
shortcutKeyMapping.TemplateId,
templateParametersJson);
}
else if (shortcutKeyMapping.OperationType == ShortcutOperationType.OpenUri)
{

View File

@@ -36,6 +36,18 @@ namespace KeyboardManagerEditorUI.Interop
public string UriToOpen { get; set; } = string.Empty;
/// <summary>
/// When non-null, indicates the mapping was created from a command template.
/// Used to re-open the template picker on edit.
/// </summary>
public string? TemplateId { get; set; }
/// <summary>
/// Parameter values captured at save time for a template-based mapping.
/// Null when <see cref="TemplateId"/> is null.
/// </summary>
public Dictionary<string, string>? TemplateParameters { get; set; }
public enum ElevationLevel
{
NonElevated = 0,

View File

@@ -76,6 +76,11 @@
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Templates\powertoyscli.json">
<LogicalName>KeyboardManagerEditorUI.Templates.powertoyscli.json</LogicalName>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Page Update="Styles\Colors.xaml">
<Generator>MSBuild:Compile</Generator>

View File

@@ -260,13 +260,16 @@ namespace KeyboardManagerEditorUI.Pages
UnifiedMappingControl.Reset();
UnifiedMappingControl.SetTriggerKeys(programShortcut.Shortcut.ToList());
UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.OpenApp);
UnifiedMappingControl.SetProgramPath(programShortcut.AppToRun);
UnifiedMappingControl.SetProgramArgs(programShortcut.Args);
// Check if this program shortcut was originally created from a command template.
string? templateId = null;
Dictionary<string, string>? templateParameters = null;
if (!string.IsNullOrEmpty(programShortcut.Id) &&
SettingsManager.EditorSettings.ShortcutSettingsDictionary.TryGetValue(programShortcut.Id, out var settings))
{
templateId = settings.Shortcut.TemplateId;
templateParameters = settings.Shortcut.TemplateParameters;
var mapping = settings.Shortcut;
UnifiedMappingControl.SetStartInDirectory(mapping.StartInDirectory);
UnifiedMappingControl.SetElevationLevel(mapping.Elevation);
@@ -274,6 +277,20 @@ namespace KeyboardManagerEditorUI.Pages
UnifiedMappingControl.SetIfRunningAction(mapping.IfRunningAction);
}
if (!string.IsNullOrEmpty(templateId))
{
// Restore as RunTemplate: the picker handles missing-template degradation internally.
// Pass the resolved command so "Keep as plain command" can preserve it if the
// stored templateId is no longer present in the catalog.
UnifiedMappingControl.SetRunTemplate(templateId, templateParameters, programShortcut.AppToRun, programShortcut.Args);
}
else
{
UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.OpenApp);
UnifiedMappingControl.SetProgramPath(programShortcut.AppToRun);
UnifiedMappingControl.SetProgramArgs(programShortcut.Args);
}
UnifiedMappingControl.SetAppSpecific(!programShortcut.IsAllApps, programShortcut.AppName);
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog_TitleEdit");
await ShowRemappingDialog();
@@ -394,6 +411,7 @@ namespace KeyboardManagerEditorUI.Pages
UnifiedMappingControl.ActionType.OpenUrl => SaveUrlMapping(triggerKeys),
UnifiedMappingControl.ActionType.OpenApp => SaveProgramMapping(triggerKeys),
UnifiedMappingControl.ActionType.Disable => SaveDisableMapping(triggerKeys),
UnifiedMappingControl.ActionType.RunTemplate => SaveRunTemplateMapping(triggerKeys),
UnifiedMappingControl.ActionType.MouseClick => throw new NotImplementedException("Mouse click remapping is not yet supported."),
_ => false,
};
@@ -439,6 +457,8 @@ namespace KeyboardManagerEditorUI.Pages
triggerKeys, UnifiedMappingControl.GetProgramPath(), isAppSpecific, appName, _mappingService!, _isEditMode),
UnifiedMappingControl.ActionType.Disable => ValidationHelper.ValidateDisableMapping(
triggerKeys, isAppSpecific, appName, _mappingService!, _isEditMode, editingRemapping),
UnifiedMappingControl.ActionType.RunTemplate => ValidationHelper.ValidateAppMapping(
triggerKeys, UnifiedMappingControl.GetResolvedTemplateExecutable() ?? string.Empty, isAppSpecific, appName, _mappingService!, _isEditMode),
_ => ValidationErrorType.NoError,
};
}
@@ -683,6 +703,51 @@ namespace KeyboardManagerEditorUI.Pages
return saved;
}
private bool SaveRunTemplateMapping(List<string> triggerKeys)
{
string? programPath = UnifiedMappingControl.GetResolvedTemplateExecutable();
if (string.IsNullOrEmpty(programPath))
{
// No template selected or unresolvable — validation should have caught this.
return false;
}
// Retarget the template's default per-user path to a machine-wide install when needed,
// so the saved mapping launches regardless of how PowerToys was installed.
programPath = Templates.PowerToysInstallResolver.ResolveExecutable(programPath);
string originalKeysString = string.Join(";", triggerKeys.Select(k => _mappingService!.GetKeyCodeFromName(k).ToString(CultureInfo.InvariantCulture)));
// Preserve the run-options that were loaded onto the control when editing an existing
// template mapping so re-saving does not silently reset them to defaults.
var templateParameters = UnifiedMappingControl.GetCurrentTemplateParameterValues();
var shortcutKeyMapping = new ShortcutKeyMapping
{
OperationType = ShortcutOperationType.RunProgram,
OriginalKeys = originalKeysString,
TargetKeys = originalKeysString,
ProgramPath = programPath,
ProgramArgs = UnifiedMappingControl.GetResolvedTemplateArgs() ?? string.Empty,
StartInDirectory = UnifiedMappingControl.GetStartInDirectory(),
IfRunningAction = UnifiedMappingControl.GetIfRunningAction(),
Visibility = UnifiedMappingControl.GetVisibility(),
Elevation = UnifiedMappingControl.GetElevationLevel(),
TargetApp = UnifiedMappingControl.GetIsAppSpecific() ? UnifiedMappingControl.GetAppName() : string.Empty,
TemplateId = UnifiedMappingControl.GetCurrentTemplateId(),
TemplateParameters = templateParameters.Count > 0 ? templateParameters : null,
};
bool saved = _mappingService!.AddShortcutMapping(shortcutKeyMapping);
if (saved)
{
_mappingService.SaveSettings();
SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping);
}
return saved;
}
#endregion
#region Delete Handlers

View File

@@ -501,4 +501,52 @@
<data name="CheckServiceBtn.Content" xml:space="preserve">
<value>Check service status</value>
</data>
<data name="ActionType_RunTemplate_Text.Text" xml:space="preserve">
<value>Run PowerToys Command</value>
</data>
<data name="TemplatePreviewLabel.Text" xml:space="preserve">
<value>Preview</value>
</data>
<data name="MissingTemplateInfoBar.Title" xml:space="preserve">
<value>Template no longer available</value>
</data>
<data name="MissingTemplateInfoBar.Message" xml:space="preserve">
<value>The template originally used for this mapping is no longer in the catalog.</value>
</data>
<data name="TemplateMissingKeepButton.Content" xml:space="preserve">
<value>Keep as plain command</value>
</data>
<data name="TemplateModule_Settings" xml:space="preserve">
<value>Settings</value>
</data>
<data name="TemplateCmd_Settings_OpenMain" xml:space="preserve">
<value>Open Settings</value>
</data>
<data name="TemplateCmd_Settings_OpenModule" xml:space="preserve">
<value>Open Settings for module...</value>
</data>
<data name="TemplateParam_Module" xml:space="preserve">
<value>Module</value>
</data>
<data name="Module_ColorPicker" xml:space="preserve">
<value>Color Picker</value>
</data>
<data name="Module_FancyZones" xml:space="preserve">
<value>FancyZones</value>
</data>
<data name="Module_KeyboardManager" xml:space="preserve">
<value>Keyboard Manager</value>
</data>
<data name="Module_PowerLauncher" xml:space="preserve">
<value>PowerToys Run</value>
</data>
<data name="Module_Hosts" xml:space="preserve">
<value>Hosts File Editor</value>
</data>
<data name="Module_RegistryPreview" xml:space="preserve">
<value>Registry Preview</value>
</data>
<data name="Module_ZoomIt" xml:space="preserve">
<value>ZoomIt</value>
</data>
</root>

View File

@@ -0,0 +1,21 @@
// 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.Templates
{
public sealed class CommandTemplate
{
public string Id { get; init; } = string.Empty;
public string DisplayResourceKey { get; init; } = string.Empty;
public string Executable { get; init; } = string.Empty;
public string ArgsTemplate { get; init; } = string.Empty;
public List<TemplateParameter> Parameters { get; init; } = new();
}
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Text.Json;
namespace KeyboardManagerEditorUI.Templates
{
public sealed class CommandTemplateCatalog
{
private const string ResourceName = "KeyboardManagerEditorUI.Templates.powertoyscli.json";
private const int SupportedSchemaVersion = 1;
private static readonly Lazy<CommandTemplateCatalog> _instance = new(() => Load());
public static CommandTemplateCatalog Instance => _instance.Value;
public PowerToysCliCatalog Data { get; }
private CommandTemplateCatalog(PowerToysCliCatalog data)
{
Data = data;
}
private static CommandTemplateCatalog Load()
{
var assembly = typeof(CommandTemplateCatalog).Assembly;
using var stream = assembly.GetManifestResourceStream(ResourceName)
?? throw new InvalidOperationException(
$"Embedded resource '{ResourceName}' not found. " +
"Check KeyboardManagerEditorUI.csproj <EmbeddedResource> entry.");
var data = JsonSerializer.Deserialize(
stream,
CommandTemplateJsonContext.Default.PowerToysCliCatalog)
?? throw new InvalidOperationException(
$"Failed to deserialize '{ResourceName}' — JsonSerializer returned null.");
if (data.SchemaVersion < 1)
{
throw new InvalidOperationException(
$"Invalid powertoyscli.json schemaVersion={data.SchemaVersion}; " +
$"expected >= 1.");
}
if (data.SchemaVersion > SupportedSchemaVersion)
{
// Newer catalogs are read best-effort: unknown additive fields are ignored by
// the deserializer, so a forward-compatible bump must not break older binaries.
ManagedCommon.Logger.LogWarning(
$"powertoyscli.json schemaVersion={data.SchemaVersion} is newer than " +
$"supported {SupportedSchemaVersion}; reading best-effort.");
}
if (data.Modules.Count == 0)
{
throw new InvalidOperationException(
"powertoyscli.json has zero modules — at least one module is required.");
}
return new CommandTemplateCatalog(data);
}
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace KeyboardManagerEditorUI.Templates
{
[JsonSerializable(typeof(PowerToysCliCatalog))]
[JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
WriteIndented = false,
ReadCommentHandling = JsonCommentHandling.Skip)]
internal sealed partial class CommandTemplateJsonContext : JsonSerializerContext
{
}
}

View File

@@ -0,0 +1,19 @@
// 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.Templates
{
public sealed class CommandTemplateModule
{
public string Id { get; init; } = string.Empty;
public string DisplayResourceKey { get; init; } = string.Empty;
public string? IconGlyph { get; init; }
public List<CommandTemplate> Commands { get; init; } = new();
}
}

View File

@@ -0,0 +1,15 @@
// 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.Templates
{
public sealed class PowerToysCliCatalog
{
public int SchemaVersion { get; init; }
public List<CommandTemplateModule> Modules { get; init; } = new();
}
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
namespace KeyboardManagerEditorUI.Templates
{
/// <summary>
/// Resolves the on-disk location of a PowerToys executable referenced by a command template.
/// Templates ship a per-user (<c>%LOCALAPPDATA%</c>) path by default; this retargets to a
/// machine-wide (<c>%ProgramFiles%</c>) install when the per-user path is absent so the saved
/// mapping works regardless of install scope.
/// </summary>
internal static class PowerToysInstallResolver
{
// Candidate install locations, in preference order. Env-var form is preserved in the saved
// value so the engine expands it at trigger time (and it survives a reinstall in place).
private static readonly string[] Candidates =
{
@"%LOCALAPPDATA%\PowerToys\PowerToys.exe",
// The editor is always a 64-bit process (x64/ARM64; no x86 build), so %ProgramFiles%
// already resolves to the native 64-bit Program Files where a machine-wide install lives.
@"%ProgramFiles%\PowerToys\PowerToys.exe",
};
/// <summary>
/// Returns an executable path that exists on disk. If <paramref name="executable"/> already
/// resolves to an existing file it is returned unchanged. For a nonexistent
/// <c>PowerToys.exe</c> path, known install locations are probed. If none exist the original
/// value is returned (the engine will surface a "program not found" error at trigger time).
/// </summary>
public static string ResolveExecutable(string executable)
{
if (string.IsNullOrEmpty(executable))
{
return executable;
}
if (File.Exists(Environment.ExpandEnvironmentVariables(executable)))
{
return executable;
}
// Only retarget the known PowerToys.exe; leave arbitrary executables untouched.
string fileName = Path.GetFileName(Environment.ExpandEnvironmentVariables(executable));
if (!string.Equals(fileName, "PowerToys.exe", StringComparison.OrdinalIgnoreCase))
{
return executable;
}
foreach (var candidate in Candidates)
{
if (File.Exists(Environment.ExpandEnvironmentVariables(candidate)))
{
return candidate;
}
}
return executable;
}
}
}

View File

@@ -0,0 +1,13 @@
// 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.
namespace KeyboardManagerEditorUI.Templates
{
public sealed class TemplateChoice
{
public string Value { get; init; } = string.Empty;
public string DisplayResourceKey { get; init; } = string.Empty;
}
}

View File

@@ -0,0 +1,21 @@
// 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.Templates
{
public sealed class TemplateParameter
{
public string Name { get; init; } = string.Empty;
public string LabelResourceKey { get; init; } = string.Empty;
public string Type { get; init; } = "Text";
public bool Required { get; init; } = true;
public List<TemplateChoice>? Choices { get; init; }
}
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
namespace KeyboardManagerEditorUI.Templates
{
public static class TemplateResolver
{
public readonly record struct Resolved(string Executable, string Args);
// Matches {paramName} placeholders. Resolution is single-pass over the original
// template so a substituted value can never be re-interpreted as another placeholder
// (prevents substitution-injection once free-text parameters are introduced).
private static readonly Regex PlaceholderRegex = new(@"\{(\w+)\}");
// Characters that force argument quoting per CommandLineToArgvW parsing rules.
private static readonly char[] QuoteTriggers = { ' ', '\t', '\n', '\v', '"' };
public static Resolved Resolve(
CommandTemplate template,
IReadOnlyDictionary<string, string>? values)
{
var argsTemplate = template.ArgsTemplate ?? string.Empty;
// Pre-compute the (quoted-if-needed) replacement for every declared parameter.
var substitutions = new Dictionary<string, string>();
foreach (var p in template.Parameters)
{
string raw = string.Empty;
if (values is not null && values.TryGetValue(p.Name, out var v))
{
raw = v ?? string.Empty;
}
substitutions[p.Name] = QuoteArgumentIfNeeded(raw);
}
// Single pass: each {name} is replaced exactly once against the original template.
// Unknown placeholders are left untouched (matches prior behavior).
string args = PlaceholderRegex.Replace(argsTemplate, m =>
substitutions.TryGetValue(m.Groups[1].Value, out var replacement)
? replacement
: m.Value);
return new Resolved(template.Executable ?? string.Empty, args);
}
// Quotes a value for a Windows command line (CommandLineToArgvW rules) only when it
// contains whitespace or a quote, so simple values (e.g. fixed combo choices) and
// empty values pass through unchanged.
internal static string QuoteArgumentIfNeeded(string value)
{
if (value.Length == 0 || value.IndexOfAny(QuoteTriggers) < 0)
{
return value;
}
var sb = new StringBuilder();
sb.Append('"');
int backslashes = 0;
foreach (char c in value)
{
if (c == '\\')
{
backslashes++;
}
else if (c == '"')
{
// Escape the run of backslashes preceding a quote, then the quote itself.
sb.Append('\\', (backslashes * 2) + 1);
backslashes = 0;
sb.Append('"');
}
else
{
sb.Append('\\', backslashes);
backslashes = 0;
sb.Append(c);
}
}
// Escape trailing backslashes so they don't escape the closing quote.
sb.Append('\\', backslashes * 2);
sb.Append('"');
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"modules": [
{
"id": "settings",
"displayResourceKey": "TemplateModule_Settings",
"iconGlyph": "",
"commands": [
{
"id": "settings.openMain",
"displayResourceKey": "TemplateCmd_Settings_OpenMain",
"executable": "%LOCALAPPDATA%\\PowerToys\\PowerToys.exe",
"argsTemplate": "--open-settings",
"parameters": []
},
{
"id": "settings.openModule",
"displayResourceKey": "TemplateCmd_Settings_OpenModule",
"executable": "%LOCALAPPDATA%\\PowerToys\\PowerToys.exe",
"argsTemplate": "--open-settings={module}",
"parameters": [
{
"name": "module",
"labelResourceKey": "TemplateParam_Module",
"type": "Combo",
"required": true,
"choices": [
{ "value": "ColorPicker", "displayResourceKey": "Module_ColorPicker" },
{ "value": "FancyZones", "displayResourceKey": "Module_FancyZones" },
{ "value": "KeyboardManager", "displayResourceKey": "Module_KeyboardManager" },
{ "value": "PowerLauncher", "displayResourceKey": "Module_PowerLauncher" },
{ "value": "Hosts", "displayResourceKey": "Module_Hosts" },
{ "value": "RegistryPreview", "displayResourceKey": "Module_RegistryPreview" },
{ "value": "ZoomIt", "displayResourceKey": "Module_ZoomIt" }
]
}
]
}
]
}
]
}

View File

@@ -0,0 +1,186 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using KeyboardManagerEditorUI.Helpers;
using KeyboardManagerEditorUI.Templates;
namespace KeyboardManagerEditorUI.ViewModels
{
public sealed class CommandTemplatePickerViewModel : INotifyPropertyChanged
{
private CommandTemplate? _selectedTemplate;
private string _selectionDescription = string.Empty;
private string _resolvedCommandLine = string.Empty;
public ObservableCollection<TemplateParameterViewModel> CurrentParameters { get; } = new();
public CommandTemplate? SelectedTemplate
{
get => _selectedTemplate;
private set
{
_selectedTemplate = value;
OnPropertyChanged();
}
}
public string SelectionDescription
{
get => _selectionDescription;
private set
{
_selectionDescription = value;
OnPropertyChanged();
}
}
public string ResolvedCommandLine
{
get => _resolvedCommandLine;
private set
{
_resolvedCommandLine = value;
OnPropertyChanged();
}
}
public bool IsAllValid => CurrentParameters.All(p => p.IsValid);
public void SelectTemplate(string templateId)
{
var (module, template) = FindWithModule(templateId);
ApplyTemplate(module, template, prefilledValues: null);
}
public void LoadExisting(string templateId, IReadOnlyDictionary<string, string>? values)
{
var (module, template) = FindWithModule(templateId);
if (template is null)
{
throw new InvalidOperationException($"Template '{templateId}' not found in catalog.");
}
ApplyTemplate(module, template, values);
}
public void Clear()
{
SelectedTemplate = null;
SelectionDescription = string.Empty;
ResolvedCommandLine = string.Empty;
DetachParameterListeners();
CurrentParameters.Clear();
}
public Dictionary<string, string> CollectParameterValues()
{
return CurrentParameters.ToDictionary(p => p.Name, p => p.Value);
}
private (CommandTemplateModule? Module, CommandTemplate? Template) FindWithModule(string templateId)
{
foreach (var m in CommandTemplateCatalog.Instance.Data.Modules)
{
var t = m.Commands.FirstOrDefault(c => c.Id == templateId);
if (t is not null)
{
return (m, t);
}
}
return (null, null);
}
private void ApplyTemplate(
CommandTemplateModule? module,
CommandTemplate? template,
IReadOnlyDictionary<string, string>? prefilledValues)
{
DetachParameterListeners();
CurrentParameters.Clear();
SelectedTemplate = template;
if (template is null || module is null)
{
SelectionDescription = string.Empty;
ResolvedCommandLine = string.Empty;
OnPropertyChanged(nameof(IsAllValid));
return;
}
SelectionDescription =
$"{ResourceHelper.GetString(module.DisplayResourceKey)} → {ResourceHelper.GetString(template.DisplayResourceKey)}";
foreach (var p in template.Parameters)
{
var vm = new TemplateParameterViewModel(p);
if (prefilledValues is not null && prefilledValues.TryGetValue(p.Name, out var v))
{
if (vm.Choices is not null)
{
vm.SelectedChoice = vm.Choices.FirstOrDefault(c => c.Value == v);
}
else
{
vm.Value = v;
}
}
vm.PropertyChanged += Parameter_PropertyChanged;
CurrentParameters.Add(vm);
}
RecomputePreview();
// Notify so the host re-evaluates Save-button enablement on template selection — not only
// on later parameter edits (the load/edit path does not raise SelectionChanged itself).
OnPropertyChanged(nameof(IsAllValid));
}
private void DetachParameterListeners()
{
foreach (var p in CurrentParameters)
{
p.PropertyChanged -= Parameter_PropertyChanged;
}
}
private void Parameter_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TemplateParameterViewModel.Value))
{
RecomputePreview();
OnPropertyChanged(nameof(IsAllValid));
}
}
private void RecomputePreview()
{
if (_selectedTemplate is null)
{
ResolvedCommandLine = string.Empty;
return;
}
var resolved = TemplateResolver.Resolve(_selectedTemplate, CollectParameterValues());
ResolvedCommandLine = string.IsNullOrEmpty(resolved.Args)
? resolved.Executable
: $"{resolved.Executable} {resolved.Args}";
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? prop = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));
}
}

View File

@@ -0,0 +1,19 @@
// 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.
namespace KeyboardManagerEditorUI.ViewModels
{
public sealed class TemplateChoiceViewModel
{
public TemplateChoiceViewModel(string value, string displayText)
{
Value = value;
DisplayText = displayText;
}
public string Value { get; }
public string DisplayText { get; }
}
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using KeyboardManagerEditorUI.Helpers;
using KeyboardManagerEditorUI.Templates;
namespace KeyboardManagerEditorUI.ViewModels
{
public sealed class TemplateParameterViewModel : INotifyPropertyChanged
{
private string _value = string.Empty;
private TemplateChoiceViewModel? _selectedChoice;
public TemplateParameterViewModel(TemplateParameter definition)
{
ArgumentNullException.ThrowIfNull(definition);
Name = definition.Name;
Label = ResourceHelper.GetString(definition.LabelResourceKey);
Type = definition.Type;
Required = definition.Required;
if (definition.Choices is not null)
{
Choices = definition.Choices
.Select(c => new TemplateChoiceViewModel(c.Value, ResourceHelper.GetString(c.DisplayResourceKey)))
.ToList();
}
}
public string Name { get; }
public string Label { get; }
public string Type { get; }
public bool Required { get; }
public IReadOnlyList<TemplateChoiceViewModel>? Choices { get; }
public string Value
{
get => _value;
set
{
if (_value != value)
{
_value = value ?? string.Empty;
OnPropertyChanged();
OnPropertyChanged(nameof(IsValid));
}
}
}
public TemplateChoiceViewModel? SelectedChoice
{
get => _selectedChoice;
set
{
if (!ReferenceEquals(_selectedChoice, value))
{
_selectedChoice = value;
Value = value?.Value ?? string.Empty;
OnPropertyChanged();
}
}
}
public bool IsValid => !Required || !string.IsNullOrEmpty(Value);
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? prop = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));
}
}

View File

@@ -67,6 +67,12 @@ namespace KeyboardManagerConstants
// Name of the property use to store openUri.
inline const std::wstring ShortcutOpenURI = L"openUri";
// Name of the property used to store the CLI command template ID (optional, round-tripped through the editor).
inline const std::wstring TemplateIdSettingName = L"templateId";
// Name of the property used to store CLI command template parameter values (optional, round-tripped through the editor).
inline const std::wstring TemplateParametersSettingName = L"templateParameters";
// Name of the property use to store shortcutOperationType.
inline const std::wstring ShortcutOperationType = L"operationType";

View File

@@ -10,6 +10,58 @@
#include "RemapShortcut.h"
#include "Helpers.h"
namespace
{
// Reads optional CLI command-template metadata (templateId + templateParameters) from a settings
// entry into the shortcut. Safe to call for legacy entries: missing/malformed metadata is ignored
// so it can never discard the surrounding mapping.
void ReadTemplateMetadata(const json::JsonObject& entryObj, Shortcut& shortcut)
{
if (entryObj.HasKey(KeyboardManagerConstants::TemplateIdSettingName))
{
shortcut.templateId = entryObj.GetNamedString(KeyboardManagerConstants::TemplateIdSettingName, L"");
}
if (entryObj.HasKey(KeyboardManagerConstants::TemplateParametersSettingName))
{
// Type-check before reading: malformed (non-object) metadata must not discard the whole mapping.
auto paramsValue = entryObj.GetNamedValue(KeyboardManagerConstants::TemplateParametersSettingName);
if (paramsValue.ValueType() == json::JsonValueType::Object)
{
for (auto const& kv : paramsValue.GetObjectW())
{
if (kv.Value().ValueType() == json::JsonValueType::String)
{
shortcut.templateParameters.emplace(
std::wstring(kv.Key()),
std::wstring(kv.Value().GetString()));
}
}
}
}
}
// Writes optional CLI command-template metadata to a settings entry — only emitted when set so
// non-template mappings produce clean JSON.
void WriteTemplateMetadata(json::JsonObject& keys, const Shortcut& shortcut)
{
if (!shortcut.templateId.empty())
{
keys.SetNamedValue(KeyboardManagerConstants::TemplateIdSettingName, json::value(shortcut.templateId));
}
if (!shortcut.templateParameters.empty())
{
json::JsonObject paramsObj;
for (auto const& [k, v] : shortcut.templateParameters)
{
paramsObj.SetNamedValue(k, json::JsonValue::CreateStringValue(v));
}
keys.SetNamedValue(KeyboardManagerConstants::TemplateParametersSettingName, paramsObj);
}
}
}
// Function to clear the OS Level shortcut remapping table
void MappingConfiguration::ClearOSLevelShortcuts()
{
@@ -258,6 +310,9 @@ bool MappingConfiguration::LoadAppSpecificShortcutRemaps(const json::JsonObject&
tempShortcut.alreadyRunningAction = static_cast<Shortcut::ProgramAlreadyRunningAction>(runProgramAlreadyRunningAction);
tempShortcut.startWindowType = static_cast<Shortcut::StartWindowType>(runProgramStartWindowType);
// Optional template metadata (preserved through round-trip; safe to omit on legacy entries).
ReadTemplateMetadata(it.GetObjectW(), tempShortcut);
AddAppSpecificShortcut(targetApp.c_str(), originalShortcut, tempShortcut);
}
else if (operationType == 2)
@@ -353,6 +408,9 @@ bool MappingConfiguration::LoadShortcutRemaps(const json::JsonObject& jsonData,
tempShortcut.alreadyRunningAction = static_cast<Shortcut::ProgramAlreadyRunningAction>(runProgramAlreadyRunningAction);
tempShortcut.startWindowType = static_cast<Shortcut::StartWindowType>(runProgramStartWindowType);
// Optional template metadata (preserved through round-trip; safe to omit on legacy entries).
ReadTemplateMetadata(it.GetObjectW(), tempShortcut);
AddOSLevelShortcut(originalShortcut, tempShortcut);
}
else if (operationType == 2)
@@ -525,6 +583,8 @@ bool MappingConfiguration::SaveSettingsToFile()
keys.SetNamedValue(KeyboardManagerConstants::RunProgramArgsSettingName, json::value(targetShortcut.runProgramArgs));
keys.SetNamedValue(KeyboardManagerConstants::RunProgramStartInDirSettingName, json::value(targetShortcut.runProgramStartInDir));
WriteTemplateMetadata(keys, targetShortcut);
// we need to add this dummy data for backwards compatibility
keys.SetNamedValue(KeyboardManagerConstants::NewTextSettingName, json::value(L"*Unsupported*"));
}
@@ -590,6 +650,8 @@ bool MappingConfiguration::SaveSettingsToFile()
keys.SetNamedValue(KeyboardManagerConstants::RunProgramArgsSettingName, json::value(targetShortcut.runProgramArgs));
keys.SetNamedValue(KeyboardManagerConstants::RunProgramStartInDirSettingName, json::value(targetShortcut.runProgramStartInDir));
WriteTemplateMetadata(keys, targetShortcut);
// we need to add this dummy data for backwards compatibility
keys.SetNamedValue(KeyboardManagerConstants::NewTextSettingName, json::value(L"*Unsupported*"));
}

View File

@@ -2,6 +2,7 @@
#include "ModifierKey.h"
#include <compare>
#include <map>
#include <tuple>
#include <variant>
namespace KeyboardManagerInput
@@ -71,6 +72,12 @@ public:
std::wstring runProgramStartInDir;
std::wstring uriToOpen;
// Optional: when non-empty, indicates this mapping was created from a CLI command template.
std::wstring templateId;
// Optional: parameter values captured at template save time. Lexicographically ordered for stable JSON output.
std::map<std::wstring, std::wstring> templateParameters;
ProgramAlreadyRunningAction alreadyRunningAction = ProgramAlreadyRunningAction::ShowWindow;
ElevationLevel elevationLevel = ElevationLevel::NonElevated;
OperationType operationType = OperationType::RemapShortcut;

View File

@@ -47,6 +47,14 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonPropertyName("operationType")]
public int OperationType { get; set; }
[JsonPropertyName("templateId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string TemplateId { get; set; }
[JsonPropertyName("templateParameters")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, string> TemplateParameters { get; set; }
private enum KeyboardManagerEditorType
{
KeyEditor = 0,

View File

@@ -150,6 +150,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonSerializable(typeof(VcpCodeDisplayInfo))]
[JsonSerializable(typeof(VcpValueInfo))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSerializable(typeof(List<MonitorInfo>))]
[JsonSerializable(typeof(List<VcpCodeDisplayInfo>))]
[JsonSerializable(typeof(List<VcpValueInfo>))]

View File

@@ -0,0 +1,78 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.Settings.UnitTest.ModelsTests
{
[TestClass]
public class KeysDataModelTemplateFieldsTests
{
[TestMethod]
public void TemplateFields_RoundTripThroughJson()
{
var original = new KeysDataModel
{
OriginalKeys = "162;67",
NewRemapKeys = string.Empty,
OperationType = 1,
RunProgramFilePath = "%LOCALAPPDATA%\\PowerToys\\PowerToys.exe",
RunProgramArgs = "--open-settings=ColorPicker",
TemplateId = "settings.openModule",
TemplateParameters = new Dictionary<string, string>
{
{ "module", "ColorPicker" },
},
};
var json = JsonSerializer.Serialize(original);
var decoded = JsonSerializer.Deserialize<KeysDataModel>(json);
Assert.AreEqual("settings.openModule", decoded.TemplateId);
Assert.IsNotNull(decoded.TemplateParameters);
Assert.AreEqual("ColorPicker", decoded.TemplateParameters["module"]);
}
[TestMethod]
public void TemplateFields_OmittedFromJsonWhenNull()
{
var entry = new KeysDataModel
{
OriginalKeys = "162;67",
NewRemapKeys = "162;86",
OperationType = 0,
TemplateId = null,
TemplateParameters = null,
};
var json = JsonSerializer.Serialize(entry);
Assert.IsFalse(json.Contains("templateId"), "templateId should be omitted when null");
Assert.IsFalse(json.Contains("templateParameters"), "templateParameters should be omitted when null");
}
[TestMethod]
public void TemplateFields_PresentInJsonWhenSet()
{
var entry = new KeysDataModel
{
OriginalKeys = "162;67",
NewRemapKeys = string.Empty,
OperationType = 1,
RunProgramFilePath = "%LOCALAPPDATA%\\PowerToys\\PowerToys.exe",
RunProgramArgs = "--open-settings",
TemplateId = "settings.openMain",
TemplateParameters = new Dictionary<string, string>(),
};
var json = JsonSerializer.Serialize(entry);
Assert.IsTrue(json.Contains("\"templateId\""), "templateId is non-null, should be serialized");
}
}
}