[Keyboard Manager] Toggle module hotkey/shortcut (#42472)

## Summary of the Pull Request

Adds a keyboard shortcut to be able to toggle the Keyboard Manager
module on and off.

## PR Checklist

- [x] Closes: #4879 
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

Modeled the changes and addition of a global shortcut using the Color
Picker module.

Notes:
- This uses `KeyboardManagerSettings` and the associated .json settings
file. I don't think there's anything else in this module that uses this.
- The default key binding for this is `winkey + shift + k`
- I've had to update the `KeyboardManagerViewModel` to extend
`PageViewModelBase` as opposed to `Observable` to get it to work. But I
will say that there were some things in here that I didn't fully dig
into, so let me know if there's any potential things I'm missing.
- I'm not too sure how to update the Settings UI after a hotkey is
pressed (pressing the hotkey currently will not show the module being
toggled off) - can't find a good way to refresh the settings ui after
enabling/disabling the module. Any pointers here would be appreciated!



## Validation Steps Performed
- Manually validated the following items:
  - Using the default shortcut (`winkey + shift + k`)
  - Changing the shortcut
  - Ensuring the change is persistent

## Media


https://github.com/user-attachments/assets/e471b8df-787a-441e-b9e0-330361865c76

---------

Co-authored-by: Weike Qu <weikequ@get-stride.com>
Co-authored-by: Leilei Zhang <leilzh@microsoft.com>
Co-authored-by: vanzue <vanzue@outlook.com>
Co-authored-by: Kai Tao <69313318+vanzue@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Weike Qu
2026-02-12 04:01:40 -08:00
committed by GitHub
parent 587385d879
commit f88a4908ac
8 changed files with 308 additions and 66 deletions

View File

@@ -52,15 +52,15 @@
<PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj">
<Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project>
</ProjectReference>
<ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj">
<Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project>
</ProjectReference>
<ProjectReference Include="..\common\KeyboardManagerCommon.vcxproj">
<Project>{8affa899-0b73-49ec-8c50-0fadda57b2fc}</Project>
<ItemGroup>
<ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj">
<Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project>
</ProjectReference>
<ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj">
<Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project>
</ProjectReference>
<ProjectReference Include="..\common\KeyboardManagerCommon.vcxproj">
<Project>{8affa899-0b73-49ec-8c50-0fadda57b2fc}</Project>
</ProjectReference>
</ItemGroup>
<ItemGroup>
@@ -85,4 +85,4 @@
<Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild">
<Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h KeyboardManager.base.rc KeyboardManager.rc" />
</Target>
</Project>
</Project>

View File

@@ -28,12 +28,24 @@ BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lp
return TRUE;
}
namespace
{
const wchar_t JSON_KEY_PROPERTIES[] = L"properties";
const wchar_t JSON_KEY_WIN[] = L"win";
const wchar_t JSON_KEY_ALT[] = L"alt";
const wchar_t JSON_KEY_CTRL[] = L"ctrl";
const wchar_t JSON_KEY_SHIFT[] = L"shift";
const wchar_t JSON_KEY_CODE[] = L"code";
const wchar_t JSON_KEY_ACTIVATION_SHORTCUT[] = L"ToggleShortcut";
}
// Implement the PowerToy Module Interface and all the required methods.
class KeyboardManager : public PowertoyModuleIface
{
private:
// The PowerToy state.
bool m_enabled = false;
bool m_active = false;
// The PowerToy name that will be shown in the settings.
const std::wstring app_name = GET_RESOURCE_STRING(IDS_KEYBOARDMANAGER);
@@ -41,10 +53,146 @@ private:
//contains the non localized key of the powertoy
std::wstring app_key = KeyboardManagerConstants::ModuleName;
// Hotkey for toggling the module
Hotkey m_hotkey = { .key = 0 };
ULONGLONG m_lastHotkeyToggleTime = 0;
HANDLE m_hProcess = nullptr;
HANDLE m_hTerminateEngineEvent = nullptr;
void refresh_process_state()
{
if (m_hProcess && WaitForSingleObject(m_hProcess, 0) != WAIT_TIMEOUT)
{
CloseHandle(m_hProcess);
m_hProcess = nullptr;
m_active = false;
}
}
bool start_engine()
{
refresh_process_state();
if (m_hProcess)
{
m_active = true;
return true;
}
if (!m_hTerminateEngineEvent)
{
Logger::error(L"Cannot start keyboard manager engine because terminate event is not available");
m_active = false;
return false;
}
unsigned long powertoys_pid = GetCurrentProcessId();
std::wstring executable_args = std::to_wstring(powertoys_pid);
SHELLEXECUTEINFOW sei{ sizeof(sei) };
sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI };
sei.lpFile = L"KeyboardManagerEngine\\PowerToys.KeyboardManagerEngine.exe";
sei.nShow = SW_SHOWNORMAL;
sei.lpParameters = executable_args.data();
if (ShellExecuteExW(&sei) == false)
{
Logger::error(L"Failed to start keyboard manager engine");
auto message = get_last_error_message(GetLastError());
if (message.has_value())
{
Logger::error(message.value());
}
m_active = false;
return false;
}
m_hProcess = sei.hProcess;
if (m_hProcess)
{
SetPriorityClass(m_hProcess, REALTIME_PRIORITY_CLASS);
m_active = true;
return true;
}
m_active = false;
return false;
}
void stop_engine()
{
refresh_process_state();
if (!m_hProcess)
{
m_active = false;
return;
}
SetEvent(m_hTerminateEngineEvent);
auto waitResult = WaitForSingleObject(m_hProcess, 1500);
if (waitResult == WAIT_TIMEOUT)
{
TerminateProcess(m_hProcess, 0);
WaitForSingleObject(m_hProcess, 500);
}
CloseHandle(m_hProcess);
m_hProcess = nullptr;
ResetEvent(m_hTerminateEngineEvent);
m_active = false;
}
void parse_hotkey(PowerToysSettings::PowerToyValues& settings)
{
auto settingsObject = settings.get_raw_json();
if (settingsObject.GetView().Size())
{
try
{
auto jsonHotkeyObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES)
.GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT);
m_hotkey.win = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_WIN);
m_hotkey.alt = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_ALT);
m_hotkey.shift = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_SHIFT);
m_hotkey.ctrl = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_CTRL);
m_hotkey.key = static_cast<unsigned char>(jsonHotkeyObject.GetNamedNumber(JSON_KEY_CODE));
}
catch (...)
{
Logger::error("Failed to initialize Keyboard Manager toggle shortcut");
}
}
if (!m_hotkey.key)
{
// Set default: Win+Shift+K
m_hotkey.win = true;
m_hotkey.shift = true;
m_hotkey.ctrl = false;
m_hotkey.alt = false;
m_hotkey.key = 'K';
}
}
// Load the settings file.
void init_settings()
{
try
{
// Load and parse the settings file for this PowerToy.
PowerToysSettings::PowerToyValues settings =
PowerToysSettings::PowerToyValues::load_from_settings_file(get_key());
parse_hotkey(settings);
}
catch (std::exception&)
{
Logger::warn(L"An exception occurred while loading the settings file");
// Error while loading from the settings file. Let default values stay as they are.
}
}
public:
// Constructor
KeyboardManager()
@@ -65,8 +213,20 @@ public:
Logger::error(message.value());
}
}
init_settings();
};
~KeyboardManager()
{
stop_engine();
if (m_hTerminateEngineEvent)
{
CloseHandle(m_hTerminateEngineEvent);
m_hTerminateEngineEvent = nullptr;
}
}
// Destroy the powertoy and free memory
virtual void destroy() override
{
@@ -117,6 +277,7 @@ public:
// Parse the input JSON string.
PowerToysSettings::PowerToyValues values =
PowerToysSettings::PowerToyValues::from_json_string(config, get_key());
parse_hotkey(values);
// If you don't need to do any custom processing of the settings, proceed
// to persists the values calling:
@@ -134,33 +295,7 @@ public:
m_enabled = true;
// Log telemetry
Trace::EnableKeyboardManager(true);
unsigned long powertoys_pid = GetCurrentProcessId();
std::wstring executable_args = L"";
executable_args.append(std::to_wstring(powertoys_pid));
SHELLEXECUTEINFOW sei{ sizeof(sei) };
sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI };
sei.lpFile = L"KeyboardManagerEngine\\PowerToys.KeyboardManagerEngine.exe";
sei.nShow = SW_SHOWNORMAL;
sei.lpParameters = executable_args.data();
if (ShellExecuteExW(&sei) == false)
{
Logger::error(L"Failed to start keyboard manager engine");
auto message = get_last_error_message(GetLastError());
if (message.has_value())
{
Logger::error(message.value());
}
}
else
{
m_hProcess = sei.hProcess;
if (m_hProcess)
{
SetPriorityClass(m_hProcess, REALTIME_PRIORITY_CLASS);
}
}
start_engine();
}
// Disable the powertoy
@@ -169,15 +304,7 @@ public:
m_enabled = false;
// Log telemetry
Trace::EnableKeyboardManager(false);
if (m_hProcess)
{
SetEvent(m_hTerminateEngineEvent);
WaitForSingleObject(m_hProcess, 1500);
TerminateProcess(m_hProcess, 0);
m_hProcess = nullptr;
}
stop_engine();
}
// Returns if the powertoys is enabled
@@ -192,9 +319,54 @@ public:
return false;
}
// Return the invocation hotkey for toggling
virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override
{
if (m_hotkey.key)
{
if (hotkeys && buffer_size >= 1)
{
hotkeys[0] = m_hotkey;
}
return 1;
}
else
{
return 0;
}
}
// Process the hotkey event
virtual bool on_hotkey(size_t /*hotkeyId*/) override
{
if (!m_enabled)
{
return false;
}
constexpr ULONGLONG hotkeyToggleDebounceMs = 500;
const auto now = GetTickCount64();
if (now - m_lastHotkeyToggleTime < hotkeyToggleDebounceMs)
{
return true;
}
m_lastHotkeyToggleTime = now;
refresh_process_state();
if (m_active)
{
stop_engine();
}
else
{
start_engine();
}
return true;
}
};
extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create()
{
return new KeyboardManager();
}
}

View File

@@ -21,12 +21,17 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[CmdConfigureIgnoreAttribute]
public GenericProperty<List<string>> KeyboardConfigurations { get; set; }
public HotkeySettings DefaultToggleShortcut => new HotkeySettings(true, false, false, true, 0x4B);
public KeyboardManagerProperties()
{
ToggleShortcut = DefaultToggleShortcut;
KeyboardConfigurations = new GenericProperty<List<string>>(new List<string> { "default", });
ActiveConfiguration = new GenericProperty<string>("default");
}
public HotkeySettings ToggleShortcut { get; set; }
public string ToJsonString()
{
return JsonSerializer.Serialize(this);

View File

@@ -2,8 +2,9 @@
// 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.Serialization;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
namespace Microsoft.PowerToys.Settings.UI.Library
@@ -32,5 +33,18 @@ namespace Microsoft.PowerToys.Settings.UI.Library
{
return false;
}
public HotkeyAccessor[] GetAllHotkeyAccessors()
{
var hotkeyAccessors = new List<HotkeyAccessor>
{
new HotkeyAccessor(
() => Properties.ToggleShortcut,
value => Properties.ToggleShortcut = value ?? Properties.DefaultToggleShortcut,
"Toggle_Shortcut"),
};
return hotkeyAccessors.ToArray();
}
}
}

View File

@@ -10,7 +10,6 @@ using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using SettingsUILibrary = Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.SerializationContext;
@@ -24,6 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext;
[JsonSerializable(typeof(FileLocksmithSettings))]
[JsonSerializable(typeof(FindMyMouseSettings))]
[JsonSerializable(typeof(IList<PowerToysReleaseInfo>))]
[JsonSerializable(typeof(KeyboardManagerSettings))]
[JsonSerializable(typeof(LightSwitchSettings))]
[JsonSerializable(typeof(MeasureToolSettings))]
[JsonSerializable(typeof(MouseHighlighterSettings))]

View File

@@ -70,6 +70,13 @@
</tkcontrols:SettingsCard>
</controls:GPOInfoControl>
<tkcontrols:SettingsCard
Name="ToggleShortcut"
x:Uid="KeyboardManager_Toggle_Shortcut"
HeaderIcon="{ui:FontIcon Glyph=&#xEDA7;}">
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.ToggleShortcut, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<controls:SettingsGroup x:Uid="KeyboardManager_Keys" IsEnabled="{x:Bind ViewModel.Enabled, Mode=OneWay}">
<tkcontrols:SettingsCard
Name="KeyboardManagerRemapKeyboardButton"

View File

@@ -1876,6 +1876,12 @@ Made with 💗 by Microsoft and the PowerToys community.</value>
<data name="Activation_Shortcut.Description" xml:space="preserve">
<value>Customize the shortcut to activate this module</value>
</data>
<data name="KeyboardManager_Toggle_Shortcut.Header" xml:space="preserve">
<value>Toggle shortcut</value>
</data>
<data name="KeyboardManager_Toggle_Shortcut.Description" xml:space="preserve">
<value>Use a shortcut to toggle this module on or off (note that the Settings UI will not update)</value>
</data>
<data name="PasteAsPlainText_Shortcut.Header" xml:space="preserve">
<value>Paste as plain text directly</value>
</data>

View File

@@ -8,6 +8,7 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
@@ -18,18 +19,20 @@ using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
using Microsoft.PowerToys.Settings.UI.Library.ViewModels.Commands;
using Microsoft.PowerToys.Settings.UI.SerializationContext;
using Microsoft.PowerToys.Settings.Utilities;
using Microsoft.Win32;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
public partial class KeyboardManagerViewModel : Observable
public partial class KeyboardManagerViewModel : PageViewModelBase
{
private GeneralSettings GeneralSettingsConfig { get; set; }
private readonly SettingsUtils _settingsUtils;
private const string PowerToyName = KeyboardManagerSettings.ModuleName;
protected override string ModuleName => KeyboardManagerSettings.ModuleName;
private const string JsonFileType = ".json";
// Default editor path. Can be removed once the new WinUI3 editor is released.
@@ -74,15 +77,15 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
_settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils));
if (_settingsUtils.SettingsExists(PowerToyName))
if (_settingsUtils.SettingsExists(ModuleName))
{
try
{
Settings = _settingsUtils.GetSettingsOrDefault<KeyboardManagerSettings>(PowerToyName);
Settings = _settingsUtils.GetSettingsOrDefault<KeyboardManagerSettings>(ModuleName);
}
catch (Exception e)
{
Logger.LogError($"Exception encountered while reading {PowerToyName} settings.", e);
Logger.LogError($"Exception encountered while reading {ModuleName} settings.", e);
#if DEBUG
if (e is ArgumentException || e is ArgumentNullException || e is PathTooLongException)
{
@@ -100,7 +103,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
else
{
Settings = new KeyboardManagerSettings();
_settingsUtils.SaveSettings(Settings.ToJsonString(), PowerToyName);
_settingsUtils.SaveSettings(Settings.ToJsonString(), ModuleName);
}
}
@@ -174,6 +177,41 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings()
{
var hotkeysDict = new Dictionary<string, HotkeySettings[]>
{
[ModuleName] = [ToggleShortcut],
};
return hotkeysDict;
}
public HotkeySettings ToggleShortcut
{
get => Settings.Properties.ToggleShortcut;
set
{
if (Settings.Properties.ToggleShortcut != value)
{
Settings.Properties.ToggleShortcut = value ?? Settings.Properties.DefaultToggleShortcut;
OnPropertyChanged(nameof(ToggleShortcut));
NotifySettingsChanged();
}
}
}
private void NotifySettingsChanged()
{
// Using InvariantCulture as this is an IPC message
SendConfigMSG(
string.Format(
CultureInfo.InvariantCulture,
"{{ \"powertoys\": {{ \"{0}\": {1} }} }}",
ModuleName,
JsonSerializer.Serialize(Settings, SourceGenerationContextContext.Default.KeyboardManagerSettings)));
}
public static List<AppSpecificKeysDataModel> CombineShortcutLists(List<KeysDataModel> globalShortcutList, List<AppSpecificKeysDataModel> appSpecificShortcutList)
{
string allAppsDescription = "All Apps";
@@ -256,13 +294,13 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
if (editor != null && editor.HasExited)
{
Logger.LogInfo($"Previous instance of {PowerToyName} editor exited");
Logger.LogInfo($"Previous instance of {ModuleName} editor exited");
editor = null;
}
if (editor != null)
{
Logger.LogInfo($"The {PowerToyName} editor instance {editor.Id} exists. Bringing the process to the front");
Logger.LogInfo($"The {ModuleName} editor instance {editor.Id} exists. Bringing the process to the front");
BringProcessToFront(editor);
return;
}
@@ -298,14 +336,14 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
string path = Path.Combine(Environment.CurrentDirectory, editorPath);
Logger.LogInfo($"Starting {PowerToyName} editor from {path}");
Logger.LogInfo($"Starting {ModuleName} editor from {path}");
// InvariantCulture: type represents the KeyboardManagerEditorType enum value
editor = Process.Start(path, $"{type.ToString(CultureInfo.InvariantCulture)} {Environment.ProcessId}");
}
catch (Exception e)
{
Logger.LogError($"Exception encountered when opening an {PowerToyName} editor", e);
Logger.LogError($"Exception encountered when opening an {ModuleName} editor", e);
}
}
@@ -338,7 +376,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
while (!readSuccessfully && !ts.IsCancellationRequested)
{
profileExists = _settingsUtils.SettingsExists(PowerToyName, fileName);
profileExists = _settingsUtils.SettingsExists(ModuleName, fileName);
if (!profileExists)
{
break;
@@ -347,12 +385,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
try
{
_profile = _settingsUtils.GetSettingsOrDefault<KeyboardManagerProfile>(PowerToyName, fileName);
_profile = _settingsUtils.GetSettingsOrDefault<KeyboardManagerProfile>(ModuleName, fileName);
readSuccessfully = true;
}
catch (Exception e)
{
Logger.LogError($"Exception encountered when reading {PowerToyName} settings", e);
Logger.LogError($"Exception encountered when reading {ModuleName} settings", e);
}
}
@@ -379,23 +417,23 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
if (!completedInTime)
{
Logger.LogError($"Timeout encountered when loading {PowerToyName} profile");
Logger.LogError($"Timeout encountered when loading {ModuleName} profile");
}
}
catch (Exception e)
{
// Failed to load the configuration.
Logger.LogError($"Exception encountered when loading {PowerToyName} profile", e);
Logger.LogError($"Exception encountered when loading {ModuleName} profile", e);
success = false;
}
if (!profileExists)
{
Logger.LogInfo($"Couldn't load {PowerToyName} profile because it doesn't exist");
Logger.LogInfo($"Couldn't load {ModuleName} profile because it doesn't exist");
}
else if (!success)
{
Logger.LogError($"Couldn't load {PowerToyName} profile");
Logger.LogError($"Couldn't load {ModuleName} profile");
}
return success;