mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-01 16:09:46 +02:00
Compare commits
32 Commits
main
...
yuleng/kbm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27ce19ce2 | ||
|
|
46e3215c10 | ||
|
|
f0a828ee22 | ||
|
|
b2bd24db0d | ||
|
|
28d6fe1615 | ||
|
|
fbc1a0c3da | ||
|
|
20df1fd96e | ||
|
|
71d91a9616 | ||
|
|
bd97ba31e8 | ||
|
|
245a6db963 | ||
|
|
314f9fe751 | ||
|
|
832db1bfea | ||
|
|
412028c861 | ||
|
|
dda2a89aa6 | ||
|
|
164ac6074a | ||
|
|
09cb927356 | ||
|
|
78c0e3e131 | ||
|
|
ebc44a0e9d | ||
|
|
d88dca2c1e | ||
|
|
f893fc7a77 | ||
|
|
7c3c5514ee | ||
|
|
3a012d4cf1 | ||
|
|
9d58b1bdd1 | ||
|
|
6fd36f9579 | ||
|
|
a8b79158f1 | ||
|
|
ff578d15a3 | ||
|
|
bccceba97b | ||
|
|
e03d048e8a | ||
|
|
806ff3c07a | ||
|
|
c1ecdda60c | ||
|
|
43e530d2e1 | ||
|
|
016e0732b0 |
2
.github/actions/spell-check/expect.txt
vendored
2
.github/actions/spell-check/expect.txt
vendored
@@ -1396,6 +1396,7 @@ POWERRENAMECONTEXTMENU
|
||||
powerrenameinput
|
||||
POWERRENAMETEST
|
||||
POWERTOYNAME
|
||||
powertoyscli
|
||||
powertoyssetup
|
||||
powertoysusersetup
|
||||
Powrprof
|
||||
@@ -1556,6 +1557,7 @@ RESIZETOFIT
|
||||
resmimetype
|
||||
RESOURCEID
|
||||
RESTORETOMAXIMIZED
|
||||
retargets
|
||||
RETURNONLYFSDIRS
|
||||
Revalidates
|
||||
RGBQUAD
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="" />
|
||||
<TextBlock x:Uid="ActionType_KeyOrShortcut_Text" />
|
||||
</StackPanel>
|
||||
</ComboBoxItem>
|
||||
<ComboBoxItem x:Uid="ActionType_Text" Tag="Text">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<TextBlock x:Uid="ActionType_Text_Text" />
|
||||
</StackPanel>
|
||||
</ComboBoxItem>
|
||||
<ComboBoxItem x:Uid="ActionType_OpenUrl" Tag="OpenUrl">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<TextBlock x:Uid="ActionType_OpenUrl_Text" />
|
||||
</StackPanel>
|
||||
</ComboBoxItem>
|
||||
<ComboBoxItem x:Uid="ActionType_OpenApp" Tag="OpenApp">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<TextBlock x:Uid="ActionType_OpenApp_Text" />
|
||||
</StackPanel>
|
||||
</ComboBoxItem>
|
||||
<ComboBoxItem x:Uid="ActionType_Disable" Tag="Disable">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<TextBlock x:Uid="ActionType_Disable_Text" />
|
||||
</StackPanel>
|
||||
</ComboBoxItem>
|
||||
<!--
|
||||
<ComboBoxItem x:Uid="ActionType_MouseClick" Tag="MouseClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<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="" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
</MenuFlyoutItem>
|
||||
<MenuFlyoutItem
|
||||
x:Name="ActionItem_Text"
|
||||
x:Uid="ActionType_Text_Text"
|
||||
Click="OnActionTypeClick"
|
||||
Tag="Text">
|
||||
<MenuFlyoutItem.Icon>
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
</MenuFlyoutItem>
|
||||
<MenuFlyoutItem
|
||||
x:Name="ActionItem_OpenUrl"
|
||||
x:Uid="ActionType_OpenUrl_Text"
|
||||
Click="OnActionTypeClick"
|
||||
Tag="OpenUrl">
|
||||
<MenuFlyoutItem.Icon>
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
</MenuFlyoutItem>
|
||||
<MenuFlyoutItem
|
||||
x:Name="ActionItem_OpenApp"
|
||||
x:Uid="ActionType_OpenApp_Text"
|
||||
Click="OnActionTypeClick"
|
||||
Tag="OpenApp">
|
||||
<MenuFlyoutItem.Icon>
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
</MenuFlyoutItem>
|
||||
<MenuFlyoutItem
|
||||
x:Name="ActionItem_Disable"
|
||||
x:Uid="ActionType_Disable_Text"
|
||||
Click="OnActionTypeClick"
|
||||
Tag="Disable">
|
||||
<MenuFlyoutItem.Icon>
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
</MenuFlyoutItem>
|
||||
<MenuFlyoutSubItem
|
||||
x:Name="RunPtCommandSubItem"
|
||||
x:Uid="ActionType_RunTemplate_Text">
|
||||
<MenuFlyoutSubItem.Icon>
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</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 -->
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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*"));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>))]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user