[Keyboard Manager] Harden text expansion input transactions

This commit is contained in:
Yu Leng
2026-08-28 18:00:59 +08:00
parent afbbc1acff
commit f9e5371731
16 changed files with 1211 additions and 218 deletions

View File

@@ -23,6 +23,47 @@ namespace
return message == WM_KEYUP || message == WM_SYSKEYUP;
}
std::optional<size_t> GetInjectedInputIdentity(const INPUT& event) noexcept
{
if (event.type != INPUT_KEYBOARD ||
(event.ki.dwFlags & KEYEVENTF_UNICODE) != 0 ||
event.ki.wVk == 0 || event.ki.wVk == KeyboardManagerConstants::DUMMY_KEY)
{
return std::nullopt;
}
const UINT mappedScanCode = MapVirtualKeyW(event.ki.wVk, MAPVK_VK_TO_VSC_EX);
DWORD scanCode = event.ki.wScan & 0xFF;
if (scanCode == 0)
{
scanCode = mappedScanCode & 0xFF;
}
if (scanCode == 0)
{
return std::nullopt;
}
constexpr size_t keyCount = 256;
const bool extended = (event.ki.dwFlags & KEYEVENTF_EXTENDEDKEY) != 0 ||
(mappedScanCode & 0xFF00) != 0;
return static_cast<size_t>(scanCode) +
(extended ? keyCount : 0);
}
void RecordAbandonedKeyUps(const std::vector<INPUT>& inputs, std::bitset<512>& keys) noexcept
{
for (const auto& event : inputs)
{
if (event.type == INPUT_KEYBOARD && (event.ki.dwFlags & KEYEVENTF_KEYUP) != 0)
{
if (const auto identity = GetInjectedInputIdentity(event))
{
keys.set(*identity);
}
}
}
}
constexpr bool IsHighSurrogate(const wchar_t value) noexcept
{
const auto codeUnit = static_cast<uint16_t>(value);
@@ -356,37 +397,6 @@ namespace
return input.SendVirtualInput(sentEvents);
}
void AppendTextUnit(std::vector<INPUT>& events, const wchar_t value)
{
if (value == L'\r' || value == L'\n')
{
Helpers::SetKeyEvent(
events,
INPUT_KEYBOARD,
VK_RETURN,
0,
KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG);
Helpers::SetKeyEvent(
events,
INPUT_KEYBOARD,
VK_RETURN,
KEYEVENTF_KEYUP,
KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG);
return;
}
INPUT down{};
down.type = INPUT_KEYBOARD;
down.ki.dwFlags = KEYEVENTF_UNICODE;
down.ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
down.ki.wScan = value;
events.push_back(down);
INPUT up = down;
up.ki.dwFlags |= KEYEVENTF_KEYUP;
events.push_back(up);
}
TextExpansionResult SendBackspaces(
KeyboardManagerInput::InputInterface& input,
const size_t count,
@@ -445,7 +455,7 @@ namespace
const std::function<void(std::vector<INPUT>)>& queueCleanup)
{
std::vector<INPUT> unit;
unit.reserve(2);
unit.reserve(4);
for (size_t index = 0; index < text.size(); ++index)
{
if (!isTargetCurrent())
@@ -461,10 +471,16 @@ namespace
}
unit.clear();
AppendTextUnit(unit, value);
Helpers::SetTextInputUnit(
unit,
value,
KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG);
if (IsHighSurrogate(value) && index + 1 < text.size() && IsLowSurrogate(text[index + 1]))
{
AppendTextUnit(unit, text[++index]);
Helpers::SetTextInputUnit(
unit,
text[++index],
KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG);
}
const auto result = input.SendVirtualInput(unit);
if (result.status == KeyboardManagerInput::SendVirtualInputStatus::None)
@@ -498,6 +514,14 @@ BufferTextExpansionBackend::BufferTextExpansionBackend(
bool BufferTextExpansionBackend::Start()
{
{
std::scoped_lock lock(pendingCleanupMutex);
if (!pendingCleanup.empty() || abandonedKeyUps.any())
{
return false;
}
cleanupAttemptsWithoutProgress = 0;
}
{
std::scoped_lock lock(bufferMutex);
ResetBufferLocked();
@@ -524,7 +548,12 @@ void BufferTextExpansionBackend::Stop() noexcept
}
ReleaseCapturedModifiers(modifierKeys);
RetryPendingCleanup();
// The queue is closed once started is false. Every retry either consumes at
// least one event or reaches the bounded no-progress fault threshold.
while (HasPendingWork())
{
RetryPendingCleanup();
}
{
std::scoped_lock lock(bufferMutex);
ResetBufferLocked();
@@ -532,6 +561,34 @@ void BufferTextExpansionBackend::Stop() noexcept
}
}
bool BufferTextExpansionBackend::IsReady() const noexcept
{
return started.load(std::memory_order_acquire);
}
bool BufferTextExpansionBackend::HasRecoveryKeyState() const noexcept
{
std::scoped_lock lock(pendingCleanupMutex);
return abandonedKeyUps.any();
}
bool BufferTextExpansionBackend::HandleRecoveryKeyEvent(const LowlevelKeyboardEvent* data) noexcept
{
const auto identity = Helpers::GetPhysicalKeyEventIndex(data);
if (!identity)
{
return false;
}
std::scoped_lock lock(pendingCleanupMutex);
const bool matches = abandonedKeyUps.test(*identity);
if (matches && IsKeyUp(data->wParam))
{
abandonedKeyUps.reset(*identity);
}
return matches;
}
void BufferTextExpansionBackend::TrackKeyboardEvent(const LowlevelKeyboardEvent* data) noexcept
{
if (!started.load(std::memory_order_acquire) || !data || !data->lParam)
@@ -936,13 +993,60 @@ void BufferTextExpansionBackend::RetryPendingCleanup() noexcept
const size_t injectedCount = (std::min)(cleanup.size(), static_cast<size_t>(result.injectedEventCount));
if (injectedCount == cleanup.size())
{
std::scoped_lock lock(pendingCleanupMutex);
if (pendingCleanup.empty())
{
cleanupAttemptsWithoutProgress = 0;
}
return;
}
std::vector<INPUT> remaining(cleanup.begin() + injectedCount, cleanup.end());
std::scoped_lock lock(pendingCleanupMutex);
remaining.insert(remaining.end(), pendingCleanup.begin(), pendingCleanup.end());
pendingCleanup = std::move(remaining);
cleanup.erase(cleanup.begin(), cleanup.begin() + injectedCount);
bool recoveryExhausted = false;
{
std::scoped_lock lock(pendingCleanupMutex);
if (injectedCount != 0)
{
cleanupAttemptsWithoutProgress = 0;
}
else
{
++cleanupAttemptsWithoutProgress;
}
if (cleanupAttemptsWithoutProgress >= MaximumCleanupAttemptsWithoutProgress)
{
started.store(false, std::memory_order_release);
RecordAbandonedKeyUps(cleanup, abandonedKeyUps);
RecordAbandonedKeyUps(pendingCleanup, abandonedKeyUps);
pendingCleanup.clear();
cleanupAttemptsWithoutProgress = 0;
recoveryExhausted = true;
}
else
{
try
{
cleanup.insert(cleanup.end(), pendingCleanup.begin(), pendingCleanup.end());
pendingCleanup = std::move(cleanup);
}
catch (...)
{
started.store(false, std::memory_order_release);
RecordAbandonedKeyUps(cleanup, abandonedKeyUps);
RecordAbandonedKeyUps(pendingCleanup, abandonedKeyUps);
pendingCleanup.clear();
cleanupAttemptsWithoutProgress = 0;
recoveryExhausted = true;
}
}
}
if (recoveryExhausted)
{
ResetBuffer();
Logger::error(L"Keyboard Manager Text Expansion cleanup could not recover; disabling Text Expansion until affected keys are released and settings are reloaded.");
}
}
bool BufferTextExpansionBackend::ShouldBlockNewInput() const noexcept

View File

@@ -1,6 +1,7 @@
#pragma once
#include <atomic>
#include <bitset>
#include <cstdint>
#include <functional>
#include <mutex>
@@ -56,6 +57,9 @@ public:
bool Start() override;
void Stop() noexcept override;
bool IsReady() const noexcept override;
bool HasRecoveryKeyState() const noexcept override;
bool HandleRecoveryKeyEvent(const LowlevelKeyboardEvent* data) noexcept override;
void TrackKeyboardEvent(const LowlevelKeyboardEvent* data) noexcept override;
void ResetBuffer() noexcept override;
TextExpansionResult PrepareActivation(const TextExpansionRequest& request) override;
@@ -100,4 +104,8 @@ private:
mutable std::mutex pendingCleanupMutex;
std::vector<INPUT> pendingCleanup;
size_t cleanupAttemptsWithoutProgress = 0;
std::bitset<512> abandonedKeyUps;
static constexpr size_t MaximumCleanupAttemptsWithoutProgress = 8;
};

View File

@@ -11,6 +11,12 @@ public:
virtual bool Start() = 0;
virtual void Stop() noexcept = 0;
// A backend can fault itself after a bounded input-recovery failure.
virtual bool IsReady() const noexcept = 0;
// Returns true when a raw physical cycle must bypass remaps to release key state
// left behind by an abandoned partial SendInput sequence.
virtual bool HasRecoveryKeyState() const noexcept = 0;
virtual bool HandleRecoveryKeyEvent(const LowlevelKeyboardEvent* data) noexcept = 0;
// Called only after the event has passed all higher-priority Keyboard Manager
// handlers and will be delivered to the foreground application.

View File

@@ -308,12 +308,16 @@ namespace KeyboardEventHandlers
*/
// Function to handle a shortcut remap
intptr_t HandleShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state, const std::optional<std::wstring>& activatedApp) noexcept
intptr_t HandleShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state, const std::optional<std::wstring>& activatedApp, const bool allowRemapTransition) noexcept
{
auto resetChordsResults = ResetChordsIfNeeded(data, state, activatedApp);
// Check if any shortcut is currently in the invoked state
bool isShortcutInvoked = state.CheckShortcutRemapInvoked(activatedApp);
if (!allowRemapTransition && !isShortcutInvoked)
{
return 0;
}
// Get shortcut table for given activatedApp
ShortcutRemapTable& reMap = state.GetShortcutRemapTable(activatedApp);
@@ -917,7 +921,7 @@ namespace KeyboardEventHandlers
Shortcut currentlyPressed = it->first;
currentlyPressed.actionKey = data->lParam->vkCode;
auto newRemappingIter = reMap.find(currentlyPressed);
if (newRemappingIter != reMap.end() && !newRemappingIter->first.HasChord())
if (allowRemapTransition && newRemappingIter != reMap.end() && !newRemappingIter->first.HasChord())
{
auto& newRemapping = newRemappingIter->second;
Shortcut from = std::get<Shortcut>(it->second.targetShortcut);
@@ -1757,11 +1761,16 @@ namespace KeyboardEventHandlers
// Function to handle an os-level shortcut remap
intptr_t HandleOSLevelShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state) noexcept
{
return HandleOSLevelShortcutRemapEventWithOptions(ii, data, state, true);
}
intptr_t HandleOSLevelShortcutRemapEventWithOptions(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state, const bool allowRemapTransition) noexcept
{
// Check if the key event was generated by KeyboardManager to avoid remapping events generated by us.
if (data->lParam->dwExtraInfo != KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG)
{
bool result = HandleShortcutRemapEvent(ii, data, state);
bool result = HandleShortcutRemapEvent(ii, data, state, std::nullopt, allowRemapTransition);
return result;
}
@@ -1770,6 +1779,11 @@ namespace KeyboardEventHandlers
// Function to handle an app-specific shortcut remap
intptr_t HandleAppSpecificShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state) noexcept
{
return HandleAppSpecificShortcutRemapEventWithOptions(ii, data, state, true);
}
intptr_t HandleAppSpecificShortcutRemapEventWithOptions(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state, const bool allowRemapTransition) noexcept
{
// Check if the key event was generated by KeyboardManager to avoid remapping events generated by us.
if (data->lParam->dwExtraInfo != KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG)
@@ -1818,7 +1832,7 @@ namespace KeyboardEventHandlers
if (it != state.appSpecificShortcutReMap.end())
{
bool result = HandleShortcutRemapEvent(ii, data, state, query_string);
bool result = HandleShortcutRemapEvent(ii, data, state, query_string, allowRemapTransition);
return result;
}
}

View File

@@ -26,7 +26,7 @@ namespace KeyboardEventHandlers
*/
// Function to handle a shortcut remap
intptr_t HandleShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state, const std::optional<std::wstring>& activatedApp = std::nullopt) noexcept;
intptr_t HandleShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state, const std::optional<std::wstring>& activatedApp = std::nullopt, bool allowRemapTransition = true) noexcept;
// Function to reset chord matching
void ResetAllStartedChords(State& state, const std::optional<std::wstring>& activatedApp);
@@ -75,9 +75,11 @@ namespace KeyboardEventHandlers
// Function to handle an os-level shortcut remap
intptr_t HandleOSLevelShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state) noexcept;
intptr_t HandleOSLevelShortcutRemapEventWithOptions(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state, bool allowRemapTransition) noexcept;
// Function to handle an app-specific shortcut remap
intptr_t HandleAppSpecificShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state) noexcept;
intptr_t HandleAppSpecificShortcutRemapEventWithOptions(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state, bool allowRemapTransition) noexcept;
// Function to generate a unicode string in response to a single keypress
intptr_t HandleSingleKeyToTextRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state);

View File

@@ -88,6 +88,7 @@ namespace
return rule.enabled;
});
}
}
KeyboardManager::KeyboardManager()
@@ -115,11 +116,6 @@ KeyboardManager::KeyboardManager()
get_last_error_or_default(errorCode));
return false;
});
if (HasEnabledTextExpansion(state.textExpansions) && !textExpansionController->Start())
{
Logger::error(L"Failed to start the Keyboard Manager Buffer Text Expansion backend.");
}
auto changeSettingsCallback = [](DWORD err) {
Logger::trace(L"{} event was signaled", KeyboardManagerConstants::SettingsEventName);
if (err != ERROR_SUCCESS)
@@ -210,7 +206,7 @@ void KeyboardManager::LoadSettings()
void KeyboardManager::ReloadSettings()
{
deferredReloadPosted.store(false, std::memory_order_release);
if (textExpansionController && textExpansionController->HasPendingWork())
if (HasPendingInputWork())
{
settingsReloadDeferred.store(true, std::memory_order_release);
ArmDeferredReloadTimer();
@@ -240,21 +236,6 @@ void KeyboardManager::ReloadSettings()
}
loadingSettings.store(false, std::memory_order_release);
if (textExpansionController)
{
if (HasEnabledTextExpansion(state.textExpansions))
{
if (!textExpansionController->Start())
{
Logger::error(L"Failed to start the Keyboard Manager Buffer Text Expansion backend after settings reload.");
}
}
else
{
textExpansionController->Stop();
}
}
if (HasRegisteredRemappingsUnchecked())
{
StartLowlevelKeyboardHook();
@@ -309,7 +290,7 @@ void CALLBACK KeyboardManager::DeferredReloadTimerProc(HWND, UINT, const UINT_PT
void KeyboardManager::QueueDeferredSettingsReloadIfReady() noexcept
{
if (!settingsReloadDeferred.load(std::memory_order_acquire) ||
(textExpansionController && textExpansionController->HasPendingWork()) ||
HasPendingInputWork() ||
deferredReloadPosted.exchange(true, std::memory_order_acq_rel))
{
return;
@@ -388,7 +369,21 @@ void KeyboardManager::StartLowlevelKeyboardHook()
}
}
if (hookHandle && !mouseHookHandle && HasEnabledTextExpansion(state.textExpansions))
bool textExpansionReady = false;
if (hookHandle && HasEnabledTextExpansion(state.textExpansions) && textExpansionController)
{
textExpansionReady = textExpansionController->Start(inputHandler);
if (!textExpansionReady)
{
Logger::error(L"Failed to start the Keyboard Manager Buffer Text Expansion backend.");
}
}
else if (textExpansionController)
{
textExpansionController->Stop();
}
if (hookHandle && textExpansionReady && !mouseHookHandle)
{
mouseHookHandle = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, GetModuleHandle(NULL), NULL);
if (!mouseHookHandle)
@@ -451,6 +446,12 @@ bool KeyboardManager::HasRegisteredRemappingsUnchecked() const
!(state.appSpecificShortcutReMap.empty() && state.appSpecificShortcutReMapSortedKeys.empty() && state.osLevelShortcutReMap.empty() && state.osLevelShortcutReMapSortedKeys.empty() && state.singleKeyReMap.empty() && state.singleKeyToTextReMap.empty());
}
bool KeyboardManager::HasPendingInputWork() const noexcept
{
return activeRemapPresses.any() || state.HasInvokedShortcutRemap() ||
(textExpansionController && textExpansionController->HasPendingWork());
}
intptr_t KeyboardManager::HandleKeyboardHookEvent(LowlevelKeyboardEvent* data) noexcept
{
// If key has suppress flag, then suppress it
@@ -459,28 +460,65 @@ intptr_t KeyboardManager::HandleKeyboardHookEvent(LowlevelKeyboardEvent* data) n
return 1;
}
const bool keyDown = data->wParam == WM_KEYDOWN || data->wParam == WM_SYSKEYDOWN;
const bool keyUp = data->wParam == WM_KEYUP || data->wParam == WM_SYSKEYUP;
const bool injectedByKeyboardManager =
(data->lParam->dwExtraInfo & CommonSharedConstants::KEYBOARDMANAGER_INJECTED_FLAG) != 0;
const DWORD physicalKey = data->lParam->vkCode;
const auto physicalPressIndex = Helpers::GetPhysicalKeyEventIndex(data);
const bool remapPressWasActive = !injectedByKeyboardManager && physicalPressIndex &&
activeRemapPresses.test(*physicalPressIndex);
if (remapPressWasActive && keyUp)
{
// The matching key-up must still traverse the old remap snapshot. The bit can
// be cleared now because deferred reload is queued only after this hook returns.
activeRemapPresses.reset(*physicalPressIndex);
}
const auto rememberHandledRemapPress = [&] {
if (!injectedByKeyboardManager && keyDown && physicalPressIndex)
{
activeRemapPresses.set(*physicalPressIndex);
}
};
const auto textExpansionDisposition = textExpansionController ?
textExpansionController->BeginKeyboardEvent(data) :
TextExpansionController::EventDisposition::Ignore;
const DWORD textExpansionPhysicalKey = data->lParam->vkCode;
if (textExpansionDisposition == TextExpansionController::EventDisposition::ForcePassThrough)
{
// Arming events still reach the foreground application, so track them unless
// the buffer is already suspended. A faulted recovery backend makes this a no-op.
const bool bufferSuspended = settingsReloadDeferred.load(std::memory_order_acquire) ||
loadingSettings.load(std::memory_order_acquire) ||
(editorIsRunningEvent != nullptr &&
WaitForSingleObject(editorIsRunningEvent, 0) == WAIT_OBJECT_0);
if (bufferSuspended)
{
textExpansionController->ResetBuffer();
}
else
{
textExpansionController->TrackKeyboardEvent(data);
}
return 0;
}
if (textExpansionDisposition == TextExpansionController::EventDisposition::Suppress)
{
return 1;
}
if (settingsReloadDeferred.load(std::memory_order_acquire))
const bool reloadDeferred = settingsReloadDeferred.load(std::memory_order_acquire);
if (reloadDeferred && textExpansionController && !injectedByKeyboardManager)
{
if (textExpansionController)
{
textExpansionController->ResetBuffer();
}
QueueDeferredSettingsReloadIfReady();
return 0;
// Keep the old remap snapshot active until every intercepted press gets its
// matching release, but do not collect text for a configuration being replaced.
textExpansionController->ResetBuffer();
}
if (loadingSettings)
{
if (textExpansionController)
if (textExpansionController && !injectedByKeyboardManager)
{
textExpansionController->ResetBuffer();
}
@@ -488,24 +526,34 @@ intptr_t KeyboardManager::HandleKeyboardHookEvent(LowlevelKeyboardEvent* data) n
}
// Suspend remapping if remap key/shortcut window is opened
if (editorIsRunningEvent != nullptr && WaitForSingleObject(editorIsRunningEvent, 0) == WAIT_OBJECT_0)
const bool editorIsOpen = editorIsRunningEvent != nullptr &&
WaitForSingleObject(editorIsRunningEvent, 0) == WAIT_OBJECT_0;
const bool shortcutRemapWasInvoked = editorIsOpen && state.HasInvokedShortcutRemap();
const bool drainShortcutOnly = editorIsOpen && shortcutRemapWasInvoked && !remapPressWasActive;
if (editorIsOpen)
{
if (textExpansionController)
if (textExpansionController && !injectedByKeyboardManager)
{
// Remapping is suspended while the editor is open, but the buffer backend
// still needs physical toggle-key transitions such as Caps Lock.
textExpansionController->TrackKeyboardEvent(data);
textExpansionController->ResetBuffer();
}
return 0;
if (!remapPressWasActive && !shortcutRemapWasInvoked)
{
return 0;
}
}
// Remap a key
intptr_t SingleKeyRemapResult = KeyboardEventHandlers::HandleSingleKeyRemapEvent(inputHandler, data, state);
intptr_t SingleKeyRemapResult = drainShortcutOnly ?
0 :
KeyboardEventHandlers::HandleSingleKeyRemapEvent(inputHandler, data, state);
// Single key remaps have priority. If a key is remapped, only the remapped version should be visible to the shortcuts and hence the event should be suppressed here.
if (SingleKeyRemapResult == 1)
{
rememberHandledRemapPress();
if (textExpansionController)
{
textExpansionController->NotifyHigherPriorityEventHandled(data);
@@ -519,11 +567,16 @@ intptr_t KeyboardManager::HandleKeyboardHookEvent(LowlevelKeyboardEvent* data) n
*/
// Handle an app-specific shortcut remapping
intptr_t AppSpecificShortcutRemapResult = KeyboardEventHandlers::HandleAppSpecificShortcutRemapEvent(inputHandler, data, state);
intptr_t AppSpecificShortcutRemapResult = KeyboardEventHandlers::HandleAppSpecificShortcutRemapEventWithOptions(
inputHandler,
data,
state,
!editorIsOpen);
// If an app-specific shortcut is remapped then the os-level shortcut remapping should be suppressed.
if (AppSpecificShortcutRemapResult == 1)
{
rememberHandledRemapPress();
if (textExpansionController)
{
textExpansionController->NotifyHigherPriorityEventHandled(data);
@@ -531,10 +584,13 @@ intptr_t KeyboardManager::HandleKeyboardHookEvent(LowlevelKeyboardEvent* data) n
return 1;
}
intptr_t SingleKeyToTextRemapResult = KeyboardEventHandlers::HandleSingleKeyToTextRemapEvent(inputHandler, data, state);
intptr_t SingleKeyToTextRemapResult = drainShortcutOnly ?
0 :
KeyboardEventHandlers::HandleSingleKeyToTextRemapEvent(inputHandler, data, state);
if (SingleKeyToTextRemapResult == 1)
{
rememberHandledRemapPress();
if (textExpansionController)
{
textExpansionController->NotifyHigherPriorityEventHandled(data);
@@ -544,9 +600,14 @@ intptr_t KeyboardManager::HandleKeyboardHookEvent(LowlevelKeyboardEvent* data) n
// Handle an os-level shortcut remapping. Existing remaps always take precedence
// over a new Text Expansion activation using the same key or shortcut.
const intptr_t OSLevelShortcutRemapResult = KeyboardEventHandlers::HandleOSLevelShortcutRemapEvent(inputHandler, data, state);
const intptr_t OSLevelShortcutRemapResult = KeyboardEventHandlers::HandleOSLevelShortcutRemapEventWithOptions(
inputHandler,
data,
state,
!editorIsOpen);
if (OSLevelShortcutRemapResult == 1)
{
rememberHandledRemapPress();
if (textExpansionController)
{
textExpansionController->NotifyHigherPriorityEventHandled(data);
@@ -554,12 +615,13 @@ intptr_t KeyboardManager::HandleKeyboardHookEvent(LowlevelKeyboardEvent* data) n
return 1;
}
if (textExpansionDisposition == TextExpansionController::EventDisposition::FreshActionKeyDown &&
if (!reloadDeferred && !editorIsOpen &&
textExpansionDisposition == TextExpansionController::EventDisposition::FreshActionKeyDown &&
textExpansionController)
{
const intptr_t activationResult = textExpansionController->TryActivate(
inputHandler,
textExpansionPhysicalKey,
data,
state.textExpansions);
if (activationResult == 1)
{
@@ -567,7 +629,7 @@ intptr_t KeyboardManager::HandleKeyboardHookEvent(LowlevelKeyboardEvent* data) n
}
}
if (textExpansionController)
if (textExpansionController && !reloadDeferred && !editorIsOpen)
{
textExpansionController->TrackKeyboardEvent(data);
}

View File

@@ -1,4 +1,6 @@
#pragma once
#include <bitset>
#include <common/hooks/LowlevelKeyboardEvent.h>
#include <common/utils/EventWaiter.h>
#include <keyboardmanager/common/Input.h>
@@ -23,7 +25,7 @@ public:
bool HasRegisteredRemappings() const;
// Applies a settings notification on the hook-owning thread. Reload is deferred
// until any Text Expansion transaction and physical press finishes.
// until active remap and Text Expansion transactions finish.
void ReloadSettings();
void CompletePendingTextExpansion() noexcept;
@@ -72,9 +74,11 @@ private:
void LoadSettings();
void ArmDeferredReloadTimer() noexcept;
void QueueDeferredSettingsReloadIfReady() noexcept;
bool HasPendingInputWork() const noexcept;
UINT_PTR deferredReloadTimer = 0;
uint64_t textExpansionInstanceId = 0;
std::bitset<512> activeRemapPresses;
// Function called by the hook procedure to handle the events. This is the starting point function for remapping
intptr_t HandleKeyboardHookEvent(LowlevelKeyboardEvent* data) noexcept;

View File

@@ -1,5 +1,6 @@
#include "pch.h"
#include "State.h"
#include <algorithm>
#include <optional>
// Function to get the iterator of a single key remap given the source key. Returns nullopt if it isn't remapped
@@ -41,6 +42,25 @@ bool State::CheckShortcutRemapInvoked(const std::optional<std::wstring>& appName
return false;
}
bool State::HasInvokedShortcutRemap() const noexcept
{
const auto tableHasInvokedRemap = [](const ShortcutRemapTable& table) {
return std::any_of(table.begin(), table.end(), [](const auto& entry) {
return entry.second.isShortcutInvoked;
});
};
if (tableHasInvokedRemap(osLevelShortcutReMap))
{
return true;
}
return std::any_of(
appSpecificShortcutReMap.begin(),
appSpecificShortcutReMap.end(),
[&](const auto& appEntry) { return tableHasInvokedRemap(appEntry.second); });
}
// Function to get the source and target of a shortcut remap given the source shortcut. Returns nullopt if it isn't remapped
ShortcutRemapTable& State::GetShortcutRemapTable(const std::optional<std::wstring>& appName)
{

View File

@@ -23,6 +23,10 @@ public:
bool CheckShortcutRemapInvoked(const std::optional<std::wstring>& appName);
// Returns whether any OS-level or app-specific shortcut transaction still
// owns synthetic key state that must be released before settings are replaced.
bool HasInvokedShortcutRemap() const noexcept;
// Function to get the source and target of a shortcut remap given the source shortcut. Returns nullopt if it isn't remapped
ShortcutRemapTable& GetShortcutRemapTable(const std::optional<std::wstring>& appName);
@@ -43,4 +47,4 @@ public:
// injection was previously blocked, indicating that its key-up should be passed
// through as well.
bool ConsumeSingleKeyRemapInjectionFailed(const DWORD sourceKey);
};
};

View File

@@ -1,6 +1,7 @@
#include "pch.h"
#include "TextExpansionController.h"
#include <algorithm>
#include <utility>
#include <common/interop/shared_constants.h>
@@ -80,14 +81,35 @@ TextExpansionController::~TextExpansionController()
Stop();
}
bool TextExpansionController::Start()
bool TextExpansionController::Start(KeyboardManagerInput::InputInterface& input)
{
if (backendReady.load(std::memory_order_acquire))
if (IsBackendReady())
{
return true;
}
const bool started = backend && backend->Start();
backendRecoveryPending.store(
backend && backend->HasRecoveryKeyState(),
std::memory_order_release);
if (started)
{
try
{
std::scoped_lock lock(pressStateMutex);
inputState = &input;
arming.store(HasPressedActionKey(), std::memory_order_release);
armingReleaseObserved.store(false, std::memory_order_release);
UpdateTrackedPressStateLocked();
}
catch (...)
{
backend->Stop();
backendRecoveryPending.store(backend->HasRecoveryKeyState(), std::memory_order_release);
backendReady.store(false, std::memory_order_release);
return false;
}
}
backendReady.store(started, std::memory_order_release);
return started;
}
@@ -100,6 +122,7 @@ void TextExpansionController::Stop() noexcept
if (backend)
{
backend->Stop();
backendRecoveryPending.store(backend->HasRecoveryKeyState(), std::memory_order_release);
}
std::scoped_lock lock(pressStateMutex);
@@ -107,6 +130,10 @@ void TextExpansionController::Stop() noexcept
recoverySuppressedKeys.clear();
higherPriorityModifierKeys.clear();
pendingActivationRelease.reset();
inputState = nullptr;
arming.store(false, std::memory_order_release);
armingReleaseObserved.store(false, std::memory_order_release);
hasTrackedPressState.store(false, std::memory_order_release);
}
TextExpansionController::EventDisposition TextExpansionController::BeginKeyboardEvent(
@@ -125,60 +152,134 @@ TextExpansionController::EventDisposition TextExpansionController::BeginKeyboard
return EventDisposition::Ignore;
}
const bool backendReadySnapshot = backendReady.load(std::memory_order_acquire);
const bool trackedPressState = hasTrackedPressState.load(std::memory_order_acquire);
const bool recoveryPending = backendRecoveryPending.load(std::memory_order_acquire);
if (!backendReadySnapshot && !trackedPressState && !recoveryPending)
{
return EventDisposition::Ignore;
}
const DWORD physicalKey = data->lParam->vkCode;
if (HandlePendingActivationReleaseEvent(physicalKey, keyDown, keyUp))
const size_t physicalKeyIdentity =
Helpers::GetPhysicalKeyEventIndex(data).value_or(static_cast<size_t>(physicalKey));
if (recoveryPending && backend && backend->HandleRecoveryKeyEvent(data))
{
backendRecoveryPending.store(backend->HasRecoveryKeyState(), std::memory_order_release);
if (keyUp)
{
std::scoped_lock lock(pressStateMutex);
actionKeyPresses.erase(physicalKeyIdentity);
recoverySuppressedKeys.erase(physicalKeyIdentity);
higherPriorityModifierKeys.erase(physicalKey);
UpdateTrackedPressStateLocked();
}
return EventDisposition::ForcePassThrough;
}
const bool backendIsReady = IsBackendReady();
if (!backendIsReady && !hasTrackedPressState.load(std::memory_order_acquire))
{
return EventDisposition::Ignore;
}
if (HandlePendingActivationReleaseEvent(physicalKey, physicalKeyIdentity, keyDown, keyUp))
{
return EventDisposition::Suppress;
}
{
std::scoped_lock lock(pressStateMutex);
if (const auto suppressed = recoverySuppressedKeys.find(physicalKey);
if (const auto suppressed = recoverySuppressedKeys.find(physicalKeyIdentity);
suppressed != recoverySuppressedKeys.end())
{
if (keyUp)
{
recoverySuppressedKeys.erase(suppressed);
UpdateTrackedPressStateLocked();
}
return EventDisposition::Suppress;
// Once recovery has faulted, no delayed synthetic key-up remains. Let the
// current physical press finish so its real key-up can restore key state.
return backendIsReady ? EventDisposition::Suppress : EventDisposition::Continue;
}
}
const bool blockNewInput = backendIsReady && backend->ShouldBlockNewInput();
if (Helpers::IsModifierKey(Helpers::ClearKeyNumpadOrigin(physicalKey)))
{
if (blockNewInput && keyDown)
{
{
std::scoped_lock lock(pressStateMutex);
recoverySuppressedKeys.insert(physicalKeyIdentity);
UpdateTrackedPressStateLocked();
}
QueueBackendWork(0);
return EventDisposition::Suppress;
}
std::scoped_lock lock(pressStateMutex);
if (keyUp)
{
higherPriorityModifierKeys.erase(physicalKey);
UpdateTrackedPressStateLocked();
}
// Modifier events not owned by a pending activation/recovery press continue
// through the normal remap pipeline.
return EventDisposition::Continue;
}
bool suppressPassthroughRepeat = false;
{
std::scoped_lock lock(pressStateMutex);
const auto activePress = actionKeyPresses.find(physicalKey);
const auto activePress = actionKeyPresses.find(physicalKeyIdentity);
if (activePress != actionKeyPresses.end())
{
const bool suppress = activePress->second == ActionKeyPressDisposition::Suppressed;
if (keyUp)
{
actionKeyPresses.erase(activePress);
UpdateTrackedPressStateLocked();
}
else if (!suppress && blockNewInput)
{
suppressPassthroughRepeat = true;
}
if (!suppressPassthroughRepeat)
{
return suppress ? EventDisposition::Suppress : EventDisposition::Continue;
}
return suppress ? EventDisposition::Suppress : EventDisposition::Continue;
}
}
if (ShouldForceArmingEvent(physicalKey, keyDown))
{
return EventDisposition::ForcePassThrough;
}
if (suppressPassthroughRepeat)
{
QueueBackendWork(0);
return EventDisposition::Suppress;
}
if (keyUp)
{
return EventDisposition::Continue;
}
if (backend && backend->ShouldBlockNewInput())
if (!backendIsReady)
{
std::scoped_lock lock(pressStateMutex);
recoverySuppressedKeys.insert(physicalKey);
return EventDisposition::Ignore;
}
if (blockNewInput)
{
{
std::scoped_lock lock(pressStateMutex);
recoverySuppressedKeys.insert(physicalKeyIdentity);
UpdateTrackedPressStateLocked();
}
QueueBackendWork(0);
return EventDisposition::Suppress;
}
@@ -188,14 +289,15 @@ TextExpansionController::EventDisposition TextExpansionController::BeginKeyboard
// Expansion activation after a settings or modifier-state change.
{
std::scoped_lock lock(pressStateMutex);
actionKeyPresses.emplace(physicalKey, ActionKeyPressDisposition::Passthrough);
actionKeyPresses.emplace(physicalKeyIdentity, ActionKeyPressDisposition::Passthrough);
UpdateTrackedPressStateLocked();
}
return EventDisposition::FreshActionKeyDown;
}
void TextExpansionController::NotifyHigherPriorityEventHandled(LowlevelKeyboardEvent* data) noexcept
{
if (!data || !data->lParam || !IsKeyDown(data->wParam) ||
if (!IsBackendReady() || !data || !data->lParam || !IsKeyDown(data->wParam) ||
(data->lParam->dwExtraInfo & CommonSharedConstants::KEYBOARDMANAGER_INJECTED_FLAG) != 0)
{
return;
@@ -208,12 +310,13 @@ void TextExpansionController::NotifyHigherPriorityEventHandled(LowlevelKeyboardE
{
std::scoped_lock lock(pressStateMutex);
higherPriorityModifierKeys.insert(physicalKey);
UpdateTrackedPressStateLocked();
}
}
void TextExpansionController::TrackKeyboardEvent(LowlevelKeyboardEvent* data) noexcept
{
if (!backendReady.load(std::memory_order_acquire) || !backend)
if (!IsBackendReady())
{
return;
}
@@ -230,7 +333,7 @@ void TextExpansionController::TrackKeyboardEvent(LowlevelKeyboardEvent* data) no
void TextExpansionController::ResetBuffer() noexcept
{
if (backend)
if (IsBackendReady())
{
backend->ResetBuffer();
}
@@ -238,17 +341,34 @@ void TextExpansionController::ResetBuffer() noexcept
intptr_t TextExpansionController::TryActivate(
KeyboardManagerInput::InputInterface& input,
const DWORD physicalKey,
LowlevelKeyboardEvent* data,
const TextExpansionTable& rules) noexcept
{
if (!IsBackendReady() || !data || !data->lParam)
{
return 0;
}
const DWORD physicalKey = data->lParam->vkCode;
const size_t physicalKeyIdentity =
Helpers::GetPhysicalKeyEventIndex(data).value_or(static_cast<size_t>(physicalKey));
const DWORD actionKey = Helpers::ClearKeyNumpadOrigin(physicalKey);
{
std::scoped_lock lock(pressStateMutex);
if (!higherPriorityModifierKeys.empty())
if (!higherPriorityModifierKeys.empty() || arming.load(std::memory_order_acquire))
{
return 0;
}
if (std::any_of(
actionKeyPresses.begin(),
actionKeyPresses.end(),
[physicalKeyIdentity](const auto& press) { return press.first != physicalKeyIdentity; }))
{
// A held non-modifier can repeat while the replacement transaction is
// waiting to commit, changing the target text behind the frozen suffix.
return 0;
}
}
bool configuredActionKey = false;
@@ -279,7 +399,7 @@ intptr_t TextExpansionController::TryActivate(
return 0;
}
if (candidates.empty() || !matchedActivation || !backendReady.load(std::memory_order_acquire) || !backend)
if (candidates.empty() || !matchedActivation || !IsBackendReady())
{
return 0;
}
@@ -317,11 +437,12 @@ intptr_t TextExpansionController::TryActivate(
std::scoped_lock lock(pressStateMutex);
pendingActivationRelease = PendingActivationRelease{
.generation = generation,
.physicalActionKey = physicalKey,
.physicalActionKeyIdentity = physicalKeyIdentity,
.actionReleased = false,
.activationModifierKeys = modifierKeys,
.pressedActivationModifierKeys = modifierKeys,
};
UpdateTrackedPressStateLocked();
}
suppress = true;
}
@@ -329,7 +450,7 @@ intptr_t TextExpansionController::TryActivate(
if (suppress)
{
std::scoped_lock lock(pressStateMutex);
if (const auto activePress = actionKeyPresses.find(physicalKey); activePress != actionKeyPresses.end())
if (const auto activePress = actionKeyPresses.find(physicalKeyIdentity); activePress != actionKeyPresses.end())
{
activePress->second = ActionKeyPressDisposition::Suppressed;
}
@@ -346,7 +467,7 @@ intptr_t TextExpansionController::TryActivate(
TextExpansionResult TextExpansionController::CompletePendingActivation(const uint64_t generation) noexcept
{
if (!backendReady.load(std::memory_order_acquire) || !backend)
if (!IsBackendReady())
{
return TextExpansionResult::FailedUnchanged;
}
@@ -356,7 +477,7 @@ TextExpansionResult TextExpansionController::CompletePendingActivation(const uin
if (generation == 0)
{
cleanupMessageQueued.store(false, std::memory_order_release);
backend->RetryPendingCleanup();
RetryPendingBackendWork();
return TextExpansionResult::FailedUnchanged;
}
@@ -376,6 +497,7 @@ TextExpansionResult TextExpansionController::CompletePendingActivation(const uin
return TextExpansionResult::FailedUnchanged;
}
pendingActivationRelease.reset();
UpdateTrackedPressStateLocked();
}
}
@@ -389,7 +511,7 @@ TextExpansionResult TextExpansionController::CompletePendingActivation(const uin
}
const auto result = backend->CompletePendingActivation();
backend->RetryPendingCleanup();
RetryPendingBackendWork();
return result;
}
catch (...)
@@ -432,6 +554,7 @@ bool TextExpansionController::QueueBackendWork(const uint64_t generation) noexce
bool TextExpansionController::HandlePendingActivationReleaseEvent(
const DWORD physicalKey,
const size_t physicalKeyIdentity,
const bool keyDown,
const bool keyUp) noexcept
{
@@ -445,7 +568,7 @@ bool TextExpansionController::HandlePendingActivationReleaseEvent(
}
auto& pending = *pendingActivationRelease;
if (physicalKey == pending.physicalActionKey)
if (physicalKeyIdentity == pending.physicalActionKeyIdentity)
{
if (keyDown)
{
@@ -473,13 +596,13 @@ bool TextExpansionController::HandlePendingActivationReleaseEvent(
// Any modifier pressed after Prepare (including the opposite side or a
// repress of an original side) is recovery input, not part of activation.
pending.suppressedNewModifierKeys.insert(physicalKey);
recoverySuppressedKeys.insert(physicalKey);
recoverySuppressedKeys.insert(physicalKeyIdentity);
suppress = true;
}
else if (isModifier && keyUp && pending.suppressedNewModifierKeys.contains(physicalKey))
{
pending.suppressedNewModifierKeys.erase(physicalKey);
recoverySuppressedKeys.erase(physicalKey);
recoverySuppressedKeys.erase(physicalKeyIdentity);
suppress = true;
}
else if (keyUp && pending.activationModifierKeys.contains(physicalKey))
@@ -516,6 +639,7 @@ bool TextExpansionController::HandlePendingActivationReleaseEvent(
if (pendingActivationRelease && pendingActivationRelease->generation == generationToQueue)
{
pendingActivationRelease.reset();
UpdateTrackedPressStateLocked();
}
}
@@ -529,31 +653,144 @@ bool TextExpansionController::HandlePendingActivationReleaseEvent(
bool TextExpansionController::HasPendingWork() const noexcept
{
if (arming.load(std::memory_order_acquire) &&
armingReleaseObserved.load(std::memory_order_acquire) &&
!HasPressedActionKey())
{
arming.store(false, std::memory_order_release);
armingReleaseObserved.store(false, std::memory_order_release);
}
if (!hasTrackedPressState.load(std::memory_order_acquire) &&
!backendReady.load(std::memory_order_acquire))
{
return false;
}
{
std::scoped_lock lock(pressStateMutex);
if (!actionKeyPresses.empty() || !recoverySuppressedKeys.empty() ||
!higherPriorityModifierKeys.empty())
!higherPriorityModifierKeys.empty() || arming.load(std::memory_order_acquire) ||
pendingActivationRelease.has_value())
{
return true;
}
}
return backend && backend->HasPendingWork();
const bool backendPending = backendReady.load(std::memory_order_acquire) &&
backend && backend->HasPendingWork();
if (!backendPending)
{
hasTrackedPressState.store(false, std::memory_order_release);
}
return backendPending;
}
bool TextExpansionController::HasPendingBackendWork() const noexcept
{
return backend && backend->HasPendingWork();
return backendReady.load(std::memory_order_acquire) && backend && backend->HasPendingWork();
}
void TextExpansionController::RetryPendingBackendWork() noexcept
{
if (backend)
if (backendReady.load(std::memory_order_acquire) && backend)
{
backend->RetryPendingCleanup();
backendRecoveryPending.store(backend->HasRecoveryKeyState(), std::memory_order_release);
if (!backend->IsReady())
{
backendReady.store(false, std::memory_order_release);
}
}
}
bool TextExpansionController::IsBackendReady() noexcept
{
if (!backendReady.load(std::memory_order_acquire) || !backend)
{
return false;
}
if (!backend->IsReady())
{
backendRecoveryPending.store(backend->HasRecoveryKeyState(), std::memory_order_release);
backendReady.store(false, std::memory_order_release);
return false;
}
return true;
}
bool TextExpansionController::HasPressedActionKey() const noexcept
{
if (!inputState)
{
return false;
}
try
{
for (DWORD key = 1; key <= 0xFF; ++key)
{
if (key == VK_LBUTTON || key == VK_RBUTTON || key == VK_MBUTTON ||
key == VK_XBUTTON1 || key == VK_XBUTTON2)
{
continue;
}
if (!Helpers::IsModifierKey(key) && inputState->GetVirtualKeyState(static_cast<int>(key)))
{
return true;
}
}
}
catch (...)
{
return true;
}
return false;
}
bool TextExpansionController::ShouldForceArmingEvent(
const DWORD physicalKey,
const bool keyDown) noexcept
{
if (!arming.load(std::memory_order_acquire) ||
Helpers::IsModifierKey(Helpers::ClearKeyNumpadOrigin(physicalKey)))
{
return false;
}
if (!keyDown || HasPressedActionKey())
{
if (!keyDown)
{
armingReleaseObserved.store(true, std::memory_order_release);
if (!HasPressedActionKey())
{
arming.store(false, std::memory_order_release);
armingReleaseObserved.store(false, std::memory_order_release);
std::scoped_lock lock(pressStateMutex);
UpdateTrackedPressStateLocked();
}
}
return true;
}
arming.store(false, std::memory_order_release);
armingReleaseObserved.store(false, std::memory_order_release);
std::scoped_lock lock(pressStateMutex);
UpdateTrackedPressStateLocked();
return false;
}
void TextExpansionController::UpdateTrackedPressStateLocked() noexcept
{
hasTrackedPressState.store(
!actionKeyPresses.empty() || !recoverySuppressedKeys.empty() ||
!higherPriorityModifierKeys.empty() || arming.load(std::memory_order_acquire) ||
pendingActivationRelease.has_value(),
std::memory_order_release);
}
bool TextExpansionController::ActivationMatches(
KeyboardManagerInput::InputInterface& input,
const Shortcut& activation,

View File

@@ -23,6 +23,7 @@ public:
Continue,
FreshActionKeyDown,
Suppress,
ForcePassThrough,
};
explicit TextExpansionController(
@@ -30,7 +31,7 @@ public:
std::function<bool(uint64_t)> queuePendingActivation = {});
~TextExpansionController();
bool Start();
bool Start(KeyboardManagerInput::InputInterface& input);
void Stop() noexcept;
// Called before editor/reload gates and before existing remaps. It fixes the
@@ -43,7 +44,7 @@ public:
// Called only for a fresh action-key down that existing remaps did not consume.
intptr_t TryActivate(
KeyboardManagerInput::InputInterface& input,
DWORD physicalActionKey,
LowlevelKeyboardEvent* data,
const TextExpansionTable& rules) noexcept;
TextExpansionResult CompletePendingActivation(uint64_t generation) noexcept;
@@ -61,7 +62,7 @@ private:
struct PendingActivationRelease
{
uint64_t generation = 0;
DWORD physicalActionKey = 0;
size_t physicalActionKeyIdentity = 0;
bool actionReleased = false;
bool commitQueued = false;
std::unordered_set<DWORD> activationModifierKeys;
@@ -73,8 +74,12 @@ private:
KeyboardManagerInput::InputInterface& input,
const Shortcut& activation,
DWORD physicalActionKey) const noexcept;
bool IsBackendReady() noexcept;
bool HasPressedActionKey() const noexcept;
bool ShouldForceArmingEvent(DWORD physicalKey, bool keyDown) noexcept;
void UpdateTrackedPressStateLocked() noexcept;
bool QueueBackendWork(uint64_t generation) noexcept;
bool HandlePendingActivationReleaseEvent(DWORD physicalKey, bool keyDown, bool keyUp) noexcept;
bool HandlePendingActivationReleaseEvent(DWORD physicalKey, size_t physicalKeyIdentity, bool keyDown, bool keyUp) noexcept;
std::unique_ptr<ITextExpansionBackend> backend;
std::function<bool(uint64_t)> queuePendingActivation;
@@ -82,10 +87,15 @@ private:
std::atomic_uint64_t nextActivationGeneration = 0;
std::atomic_uint64_t pendingActivationGeneration = 0;
std::atomic_bool cleanupMessageQueued = false;
std::atomic_bool backendRecoveryPending = false;
mutable std::atomic_bool hasTrackedPressState = false;
mutable std::atomic_bool arming = false;
mutable std::atomic_bool armingReleaseObserved = false;
KeyboardManagerInput::InputInterface* inputState = nullptr;
mutable std::mutex pressStateMutex;
std::unordered_map<DWORD, ActionKeyPressDisposition> actionKeyPresses;
std::unordered_set<DWORD> recoverySuppressedKeys;
std::unordered_map<size_t, ActionKeyPressDisposition> actionKeyPresses;
std::unordered_set<size_t> recoverySuppressedKeys;
std::unordered_set<DWORD> higherPriorityModifierKeys;
std::optional<PendingActivationRelease> pendingActivationRelease;
};

View File

@@ -9,6 +9,7 @@
#include "MockedInput.h"
#include <algorithm>
#include <array>
#include <cstdint>
#include <common/interop/shared_constants.h>
#include <functional>
@@ -595,7 +596,7 @@ namespace TextExpansionEngineTests
Assert::AreEqual(std::wstring(), fixture.input.GetInjectedUnicodeText());
}
TEST_METHOD (Complete_ShouldEmitCrLfAsOneBareEnterPress)
TEST_METHOD (Complete_ShouldEmitCrLfAsOneShiftEnterPress)
{
BackendFixture fixture;
fixture.TrackText(L"a");
@@ -606,25 +607,241 @@ namespace TextExpansionEngineTests
AssertResult(TextExpansionResult::Prepared, fixture.Prepare(request));
AssertResult(TextExpansionResult::Replaced, fixture.Complete());
std::vector<INPUT> enterEvents;
std::vector<INPUT> newlineEvents;
for (const auto& batch : fixture.input.GetSentInputBatches())
{
for (const auto& input : batch)
if (std::any_of(batch.begin(), batch.end(), [](const INPUT& input) {
return input.type == INPUT_KEYBOARD && input.ki.wVk == VK_RETURN;
}))
{
if (input.type == INPUT_KEYBOARD && input.ki.wVk == VK_RETURN)
{
enterEvents.push_back(input);
}
Assert::AreNotEqual(static_cast<WORD>(VK_SHIFT), input.ki.wVk);
newlineEvents = batch;
}
}
Assert::AreEqual(static_cast<size_t>(2), enterEvents.size());
Assert::IsTrue((enterEvents[0].ki.dwFlags & KEYEVENTF_KEYUP) == 0);
Assert::IsTrue((enterEvents[1].ki.dwFlags & KEYEVENTF_KEYUP) != 0);
Assert::AreEqual(static_cast<size_t>(4), newlineEvents.size());
Assert::AreEqual(static_cast<WORD>(VK_SHIFT), newlineEvents[0].ki.wVk);
Assert::IsTrue((newlineEvents[0].ki.dwFlags & KEYEVENTF_KEYUP) == 0);
Assert::AreEqual(static_cast<WORD>(VK_RETURN), newlineEvents[1].ki.wVk);
Assert::IsTrue((newlineEvents[1].ki.dwFlags & KEYEVENTF_KEYUP) == 0);
Assert::AreEqual(static_cast<WORD>(VK_RETURN), newlineEvents[2].ki.wVk);
Assert::IsTrue((newlineEvents[2].ki.dwFlags & KEYEVENTF_KEYUP) != 0);
Assert::AreEqual(static_cast<WORD>(VK_SHIFT), newlineEvents[3].ki.wVk);
Assert::IsTrue((newlineEvents[3].ki.dwFlags & KEYEVENTF_KEYUP) != 0);
Assert::AreEqual(std::wstring(L"firstsecond"), fixture.input.GetInjectedUnicodeText());
}
TEST_METHOD (Complete_ShouldRecoverEveryPartialShiftEnterPrefix)
{
for (size_t injectedPrefix = 1; injectedPrefix <= 3; ++injectedPrefix)
{
BackendFixture fixture;
fixture.TrackText(L"a");
const auto request = fixture.Request(
{ VK_SPACE },
{ { L"a", L"\n", 0 } });
AssertResult(TextExpansionResult::Prepared, fixture.Prepare(request));
size_t sendCalls = 0;
fixture.input.SetSendVirtualInputInjectedCount([&](const std::vector<INPUT>& inputs) {
++sendCalls;
if (sendCalls == 1)
{
return inputs.size();
}
if (sendCalls == 2)
{
return injectedPrefix;
}
if (sendCalls == 3)
{
return static_cast<size_t>(0);
}
return inputs.size();
});
AssertResult(TextExpansionResult::FailedChangedOrUnknown, fixture.Complete());
Assert::IsTrue(fixture.backend->ShouldBlockNewInput());
const auto& attemptedBatches = fixture.input.GetSentInputBatches();
Assert::AreEqual(static_cast<size_t>(3), attemptedBatches.size());
Assert::AreEqual(static_cast<size_t>(4), attemptedBatches[1].size());
const auto& cleanup = attemptedBatches[2];
if (injectedPrefix == 2)
{
Assert::AreEqual(static_cast<size_t>(2), cleanup.size());
Assert::AreEqual(static_cast<WORD>(VK_RETURN), cleanup[0].ki.wVk);
Assert::IsTrue((cleanup[0].ki.dwFlags & KEYEVENTF_KEYUP) != 0);
Assert::AreEqual(static_cast<WORD>(VK_SHIFT), cleanup[1].ki.wVk);
}
else
{
Assert::AreEqual(static_cast<size_t>(1), cleanup.size());
Assert::AreEqual(static_cast<WORD>(VK_SHIFT), cleanup[0].ki.wVk);
}
Assert::IsTrue((cleanup.back().ki.dwFlags & KEYEVENTF_KEYUP) != 0);
fixture.backend->RetryPendingCleanup();
Assert::IsFalse(fixture.backend->ShouldBlockNewInput());
}
}
TEST_METHOD (CleanupPermanentFailure_ShouldStopBlockingAndDisableBackend)
{
BackendFixture fixture;
fixture.TrackText(L"a");
const auto request = fixture.Request(
{ VK_SPACE },
{ { L"a", L"expanded", 0 } });
AssertResult(TextExpansionResult::Prepared, fixture.Prepare(request));
size_t sendCalls = 0;
fixture.input.SetSendVirtualInputInjectedCount([&](const std::vector<INPUT>&) {
++sendCalls;
return sendCalls == 1 ? static_cast<size_t>(1) : static_cast<size_t>(0);
});
AssertResult(TextExpansionResult::FailedChangedOrUnknown, fixture.Complete());
Assert::IsTrue(fixture.backend->ShouldBlockNewInput());
for (size_t retry = 0; retry < 32; ++retry)
{
fixture.backend->RetryPendingCleanup();
}
Assert::IsFalse(fixture.backend->ShouldBlockNewInput());
Assert::IsFalse(fixture.backend->IsReady());
Assert::IsTrue(sendCalls < 20);
AssertResult(TextExpansionResult::UnsupportedContext, fixture.Prepare(request));
TestKeyEvent recoveryDown(VK_BACK, 0x0E);
Assert::IsTrue(fixture.backend->HandleRecoveryKeyEvent(&recoveryDown.event));
Assert::IsFalse(fixture.backend->Start());
TestKeyEvent recoveryUp(VK_BACK, 0x0E, WM_KEYUP, LLKHF_UP);
Assert::IsTrue(fixture.backend->HandleRecoveryKeyEvent(&recoveryUp.event));
fixture.input.SetKeyboardState(VK_BACK, false);
Assert::IsTrue(fixture.backend->Start());
}
TEST_METHOD (ModifierReleasePartialFailure_ShouldRetryOnlyMissingModifierUp)
{
BackendFixture fixture;
fixture.TrackText(L"a");
fixture.SetLeftCtrl(true);
const auto request = fixture.Request(
{ VK_CONTROL, VK_SPACE },
{ { L"a", L"expanded", 0 } },
{ VK_LCONTROL });
AssertResult(TextExpansionResult::Prepared, fixture.Prepare(request));
size_t sendCalls = 0;
fixture.input.SetSendVirtualInputInjectedCount([&](const std::vector<INPUT>& inputs) {
++sendCalls;
if (sendCalls == 1)
{
return static_cast<size_t>(2);
}
if (sendCalls == 2)
{
return static_cast<size_t>(0);
}
return inputs.size();
});
AssertResult(TextExpansionResult::FailedChangedOrUnknown, fixture.Complete());
Assert::IsTrue(fixture.backend->ShouldBlockNewInput());
const auto& batches = fixture.input.GetSentInputBatches();
Assert::AreEqual(static_cast<size_t>(2), batches.size());
Assert::AreEqual(static_cast<size_t>(1), batches[1].size());
Assert::AreEqual(static_cast<WORD>(VK_LCONTROL), batches[1][0].ki.wVk);
Assert::IsTrue((batches[1][0].ki.dwFlags & KEYEVENTF_KEYUP) != 0);
fixture.backend->RetryPendingCleanup();
Assert::IsFalse(fixture.backend->ShouldBlockNewInput());
Assert::IsFalse(fixture.input.GetVirtualKeyState(VK_LCONTROL));
}
TEST_METHOD (CleanupFault_ShouldRecoverEachPhysicalShiftEnterKey)
{
BackendFixture fixture;
fixture.TrackText(L"a");
const auto request = fixture.Request(
{ VK_SPACE },
{ { L"a", L"\n", 0 } });
AssertResult(TextExpansionResult::Prepared, fixture.Prepare(request));
size_t sendCalls = 0;
fixture.input.SetSendVirtualInputInjectedCount([&](const std::vector<INPUT>& inputs) {
++sendCalls;
if (sendCalls == 1)
{
return inputs.size();
}
return sendCalls == 2 ? static_cast<size_t>(2) : static_cast<size_t>(0);
});
AssertResult(TextExpansionResult::FailedChangedOrUnknown, fixture.Complete());
for (size_t retry = 0; retry < 32; ++retry)
{
fixture.backend->RetryPendingCleanup();
}
Assert::IsFalse(fixture.backend->IsReady());
TestKeyEvent rightShiftDown(VK_RSHIFT, 0x36);
TestKeyEvent rightShiftUp(VK_RSHIFT, 0x36, WM_KEYUP, LLKHF_UP);
Assert::IsFalse(fixture.backend->HandleRecoveryKeyEvent(&rightShiftDown.event));
Assert::IsFalse(fixture.backend->HandleRecoveryKeyEvent(&rightShiftUp.event));
TestKeyEvent leftShiftDown(VK_LSHIFT, 0x2A);
TestKeyEvent leftShiftUp(VK_LSHIFT, 0x2A, WM_KEYUP, LLKHF_UP);
Assert::IsTrue(fixture.backend->HandleRecoveryKeyEvent(&leftShiftDown.event));
Assert::IsTrue(fixture.backend->HandleRecoveryKeyEvent(&leftShiftUp.event));
fixture.input.SetKeyboardState(VK_SHIFT, false);
Assert::IsFalse(fixture.backend->Start());
TestKeyEvent enterDown(VK_RETURN, 0x1C);
TestKeyEvent enterUp(VK_RETURN, 0x1C, WM_KEYUP, LLKHF_UP);
Assert::IsTrue(fixture.backend->HandleRecoveryKeyEvent(&enterDown.event));
Assert::IsTrue(fixture.backend->HandleRecoveryKeyEvent(&enterUp.event));
fixture.input.SetKeyboardState(VK_RETURN, false);
Assert::IsTrue(fixture.backend->Start());
}
TEST_METHOD (ModifierCleanupFault_ShouldPreserveLeftRightPhysicalIdentity)
{
constexpr std::array<DWORD, 8> modifiers{
VK_LWIN, VK_RWIN, VK_LCONTROL, VK_RCONTROL,
VK_LMENU, VK_RMENU, VK_LSHIFT, VK_RSHIFT,
};
for (const DWORD modifier : modifiers)
{
BackendFixture fixture;
fixture.TrackText(L"a");
const auto request = fixture.Request(
{ VK_SPACE },
{ { L"a", L"expanded", 0 } },
{ modifier });
AssertResult(TextExpansionResult::Prepared, fixture.Prepare(request));
size_t sendCalls = 0;
fixture.input.SetSendVirtualInputInjectedCount([&](const std::vector<INPUT>&) {
++sendCalls;
return sendCalls == 1 ? static_cast<size_t>(2) : static_cast<size_t>(0);
});
AssertResult(TextExpansionResult::FailedChangedOrUnknown, fixture.Complete());
for (size_t retry = 0; retry < 32; ++retry)
{
fixture.backend->RetryPendingCleanup();
}
const UINT mappedScan = MapVirtualKeyW(modifier, MAPVK_VK_TO_VSC_EX);
const DWORD scanCode = mappedScan & 0xFF;
const DWORD downFlags = (mappedScan & 0xFF00) != 0 ? LLKHF_EXTENDED : 0;
TestKeyEvent down(modifier, scanCode, WM_KEYDOWN, downFlags);
TestKeyEvent up(modifier, scanCode, WM_KEYUP, downFlags | LLKHF_UP);
Assert::IsTrue(fixture.backend->HandleRecoveryKeyEvent(&down.event));
Assert::IsTrue(fixture.backend->HandleRecoveryKeyEvent(&up.event));
}
}
TEST_METHOD (CancelPendingActivation_ShouldClearPendingWorkWithoutInput)
{
BackendFixture fixture;
@@ -672,5 +889,26 @@ namespace TextExpansionEngineTests
Assert::IsTrue(fixture.backend->Start());
AssertResult(TextExpansionResult::NoMatch, fixture.Prepare(request));
}
TEST_METHOD (Stop_ShouldDrainCleanupWhenEveryRetryMakesPartialProgress)
{
BackendFixture fixture;
fixture.TrackText(L"a");
const auto request = fixture.Request(
{ VK_SPACE },
{ { L"a", L"expanded", 0 } },
{ VK_LWIN, VK_RWIN, VK_LCONTROL, VK_RCONTROL,
VK_LMENU, VK_RMENU, VK_LSHIFT, VK_RSHIFT });
AssertResult(TextExpansionResult::Prepared, fixture.Prepare(request));
fixture.input.SetSendVirtualInputInjectedCount([](const std::vector<INPUT>& inputs) {
return inputs.empty() ? static_cast<size_t>(0) : static_cast<size_t>(1);
});
fixture.backend->Stop();
Assert::IsFalse(fixture.backend->HasPendingWork());
Assert::IsFalse(fixture.backend->IsReady());
Assert::AreEqual(static_cast<size_t>(10), fixture.input.GetSentInputBatches().size());
}
};
}

View File

@@ -43,6 +43,58 @@ namespace RemappingLogicTests
});
}
TEST_METHOD (HasInvokedShortcutRemap_ShouldInspectOSAndAppSpecificTables)
{
const Shortcut osSource(std::vector<int32_t>{ VK_CONTROL, 'A' });
const Shortcut appSource(std::vector<int32_t>{ VK_MENU, 'B' });
const Shortcut target(std::vector<int32_t>{ VK_SHIFT, 'V' });
testState.AddOSLevelShortcut(osSource, target);
testState.AddAppSpecificShortcut(L"test.exe", appSource, target);
Assert::IsFalse(testState.HasInvokedShortcutRemap());
testState.osLevelShortcutReMap[osSource].isShortcutInvoked = true;
Assert::IsTrue(testState.HasInvokedShortcutRemap());
testState.osLevelShortcutReMap[osSource].isShortcutInvoked = false;
testState.appSpecificShortcutReMap[L"test.exe"][appSource].isShortcutInvoked = true;
Assert::IsTrue(testState.HasInvokedShortcutRemap());
}
TEST_METHOD (DrainOnly_ShouldFinishInvokedShortcutWithoutTransitioningToAnotherRemap)
{
const Shortcut firstSource(std::vector<int32_t>{ VK_CONTROL, 'A' });
const Shortcut firstTarget(std::vector<int32_t>{ VK_MENU, 'V' });
const Shortcut secondSource(std::vector<int32_t>{ VK_CONTROL, 'X' });
const Shortcut secondTarget(std::vector<int32_t>{ VK_LWIN, 'C' });
testState.AddOSLevelShortcut(firstSource, firstTarget);
testState.AddOSLevelShortcut(secondSource, secondTarget);
const std::vector<INPUT> firstPress{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = VK_CONTROL } },
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 'A' } },
};
mockedInputHandler.SendVirtualInput(firstPress);
Assert::IsTrue(testState.osLevelShortcutReMap[firstSource].isShortcutInvoked);
mockedInputHandler.SetHookProc([this](LowlevelKeyboardEvent* data) {
if (data->lParam->dwExtraInfo == KeyboardManagerConstants::KEYBOARDMANAGER_SUPPRESS_FLAG)
{
return 1LL;
}
return KeyboardEventHandlers::HandleOSLevelShortcutRemapEventWithOptions(
mockedInputHandler,
data,
testState,
false);
});
const std::vector<INPUT> nextAction{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 'X' } },
};
mockedInputHandler.SendVirtualInput(nextAction);
Assert::IsFalse(testState.osLevelShortcutReMap[firstSource].isShortcutInvoked);
Assert::IsFalse(testState.osLevelShortcutReMap[secondSource].isShortcutInvoked);
}
// Tests for shortcut to shortcut remappings
// Test if correct keyboard states are set for a 2 key shortcut remap with different modifiers key down

View File

@@ -28,9 +28,11 @@ namespace TextExpansionEngineTests
const DWORD key,
const WPARAM message = WM_KEYDOWN,
const DWORD flags = 0,
const ULONG_PTR extraInfo = 0)
const ULONG_PTR extraInfo = 0,
const DWORD scanCode = 0)
{
keyboardData.vkCode = key;
keyboardData.scanCode = scanCode;
keyboardData.flags = flags;
keyboardData.dwExtraInfo = extraInfo;
event.wParam = message;
@@ -58,8 +60,10 @@ namespace TextExpansionEngineTests
{
public:
bool startResult = true;
bool ready = false;
bool pendingWork = false;
bool prepared = false;
DWORD recoveryKey = 0;
TextExpansionResult prepareResult = TextExpansionResult::NoMatch;
TextExpansionResult completeResult = TextExpansionResult::Replaced;
TextExpansionResult cancelResult = TextExpansionResult::FailedUnchanged;
@@ -81,15 +85,41 @@ namespace TextExpansionEngineTests
bool Start() override
{
++startCalls;
return startResult;
ready = startResult;
return ready;
}
void Stop() noexcept override
{
++stopCalls;
ready = false;
prepared = false;
}
bool IsReady() const noexcept override
{
return ready;
}
bool HasRecoveryKeyState() const noexcept override
{
return recoveryKey != 0;
}
bool HandleRecoveryKeyEvent(const LowlevelKeyboardEvent* data) noexcept override
{
if (!data || !data->lParam || recoveryKey == 0 ||
Helpers::ClearKeyNumpadOrigin(data->lParam->vkCode) != recoveryKey)
{
return false;
}
if (data->wParam == WM_KEYUP || data->wParam == WM_SYSKEYUP)
{
recoveryKey = 0;
}
return true;
}
void TrackKeyboardEvent(const LowlevelKeyboardEvent* data) noexcept override
{
++trackCalls;
@@ -172,7 +202,7 @@ namespace TextExpansionEngineTests
}
return queueResult;
});
Assert::IsTrue(controller->Start());
Assert::IsTrue(controller->Start(input));
}
TextExpansionController::EventDisposition Begin(
@@ -186,7 +216,8 @@ namespace TextExpansionEngineTests
intptr_t Activate(const DWORD key, const TextExpansionTable& rules)
{
return controller->TryActivate(input, key, rules);
TestKeyEvent event(key);
return controller->TryActivate(input, &event.event, rules);
}
TextExpansionResult Complete()
@@ -268,13 +299,17 @@ namespace TextExpansionEngineTests
auto backend = std::make_unique<FakeTextExpansionBackend>();
auto* backendView = backend.get();
TextExpansionController controller(std::move(backend));
KeyboardManagerInput::MockedInput input;
Assert::IsTrue(controller.Start());
Assert::IsTrue(controller.Start());
Assert::IsTrue(controller.Start(input));
Assert::IsTrue(controller.Start(input));
Assert::AreEqual(1, backendView->startCalls);
controller.Stop();
Assert::AreEqual(1, backendView->stopCalls);
TestKeyEvent stoppedKey('A');
AssertDisposition(TextExpansionController::EventDisposition::Ignore, controller.BeginKeyboardEvent(&stoppedKey.event));
Assert::IsFalse(controller.HasPendingWork());
}
TEST_METHOD (TrackKeyboardEventAndResetBuffer_ShouldForwardToBackend)
@@ -458,6 +493,20 @@ namespace TextExpansionEngineTests
Assert::AreEqual(1, fixture.backend->activateCalls);
}
TEST_METHOD (NumpadAliasChange_ShouldKeepPhysicalPressPairedByScanCode)
{
ControllerFixture fixture;
constexpr DWORD numpadScanCode = 0x52;
TestKeyEvent down(VK_NUMPAD0, WM_KEYDOWN, 0, 0, numpadScanCode);
AssertDisposition(TextExpansionController::EventDisposition::FreshActionKeyDown, fixture.controller->BeginKeyboardEvent(&down.event));
Assert::IsTrue(fixture.controller->HasPendingWork());
const DWORD numpadInsert = VK_INSERT | Helpers::GetNumpadOriginEncodingBit();
TestKeyEvent up(numpadInsert, WM_KEYUP, LLKHF_UP, 0, numpadScanCode);
AssertDisposition(TextExpansionController::EventDisposition::Continue, fixture.controller->BeginKeyboardEvent(&up.event));
Assert::IsFalse(fixture.controller->HasPendingWork());
}
TEST_METHOD (Shortcut_ShouldRequireExactModifierSet)
{
ControllerFixture fixture;
@@ -737,6 +786,58 @@ namespace TextExpansionEngineTests
Assert::IsTrue(fixture.controller->HasPendingWork());
}
TEST_METHOD (PendingBackendWork_ShouldSuppressAndPairNewModifierPress)
{
ControllerFixture fixture;
fixture.backend->pendingWork = true;
AssertDisposition(TextExpansionController::EventDisposition::Suppress, fixture.Begin(VK_LCONTROL));
AssertDisposition(TextExpansionController::EventDisposition::Suppress, fixture.Begin(VK_LCONTROL));
AssertDisposition(TextExpansionController::EventDisposition::Suppress, fixture.Begin(VK_LCONTROL, WM_KEYUP, LLKHF_UP));
}
TEST_METHOD (BackendRecoveryFault_ShouldPassSuppressedPhysicalPressThroughToItsKeyUp)
{
ControllerFixture fixture;
fixture.backend->pendingWork = true;
AssertDisposition(TextExpansionController::EventDisposition::Suppress, fixture.Begin(VK_LCONTROL));
fixture.backend->pendingWork = false;
fixture.backend->ready = false;
fixture.backend->recoveryKey = VK_LCONTROL;
fixture.controller->RetryPendingBackendWork();
AssertDisposition(TextExpansionController::EventDisposition::ForcePassThrough, fixture.Begin(VK_LCONTROL));
AssertDisposition(TextExpansionController::EventDisposition::ForcePassThrough, fixture.Begin(VK_LCONTROL, WM_KEYUP, LLKHF_UP));
Assert::IsFalse(fixture.controller->HasPendingWork());
}
TEST_METHOD (PendingBackendWork_ShouldSuppressPreexistingKeyRepeatButPassItsKeyUp)
{
ControllerFixture fixture;
AssertDisposition(TextExpansionController::EventDisposition::FreshActionKeyDown, fixture.Begin('B'));
fixture.backend->pendingWork = true;
AssertDisposition(TextExpansionController::EventDisposition::Suppress, fixture.Begin('B'));
AssertDisposition(TextExpansionController::EventDisposition::Continue, fixture.Begin('B', WM_KEYUP, LLKHF_UP));
}
TEST_METHOD (HeldActionKey_ShouldPreventPreparingActivation)
{
ControllerFixture fixture;
fixture.backend->prepareResult = TextExpansionResult::Prepared;
const TextExpansionTable rules{ MakeRule(L"rule-id", L"ab", { VK_SPACE }, L"expanded") };
AssertDisposition(TextExpansionController::EventDisposition::FreshActionKeyDown, fixture.Begin('B'));
AssertDisposition(TextExpansionController::EventDisposition::FreshActionKeyDown, fixture.Begin(VK_SPACE));
Assert::AreEqual(0, static_cast<int>(fixture.Activate(VK_SPACE, rules)));
Assert::AreEqual(0, fixture.backend->activateCalls);
AssertDisposition(TextExpansionController::EventDisposition::Continue, fixture.Begin('B', WM_KEYUP, LLKHF_UP));
AssertDisposition(TextExpansionController::EventDisposition::Continue, fixture.Begin(VK_SPACE, WM_KEYUP, LLKHF_UP));
}
TEST_METHOD (PreparedActivation_ShouldBlockAndPairOtherPhysicalInputUntilCompletion)
{
ControllerFixture fixture;
@@ -854,7 +955,27 @@ namespace TextExpansionEngineTests
Assert::AreEqual(1, fixture.backend->completeCalls);
}
TEST_METHOD (BackendStartFailure_ShouldLeaveActivationAsPassthrough)
TEST_METHOD (InactiveBackend_ShouldIgnoreKeyboardEventsWithoutTrackingPresses)
{
auto backend = std::make_unique<FakeTextExpansionBackend>();
auto* backendView = backend.get();
TextExpansionController controller(std::move(backend));
TestKeyEvent actionDown(VK_SPACE);
TestKeyEvent actionUp(VK_SPACE, WM_KEYUP, LLKHF_UP);
TestKeyEvent modifierDown(VK_LCONTROL);
AssertDisposition(TextExpansionController::EventDisposition::Ignore, controller.BeginKeyboardEvent(&actionDown.event));
AssertDisposition(TextExpansionController::EventDisposition::Ignore, controller.BeginKeyboardEvent(&actionUp.event));
AssertDisposition(TextExpansionController::EventDisposition::Ignore, controller.BeginKeyboardEvent(&modifierDown.event));
controller.TrackKeyboardEvent(&actionDown.event);
controller.ResetBuffer();
Assert::IsFalse(controller.HasPendingWork());
Assert::AreEqual(0, backendView->trackCalls);
Assert::AreEqual(0, backendView->resetBufferCalls);
}
TEST_METHOD (BackendStartFailure_ShouldRemainInactive)
{
auto backend = std::make_unique<FakeTextExpansionBackend>();
auto* backendView = backend.get();
@@ -863,11 +984,97 @@ namespace TextExpansionEngineTests
KeyboardManagerInput::MockedInput input;
const TextExpansionTable rules{ MakeRule(L"rule-id", L"brb", { VK_SPACE }, L"expanded") };
Assert::IsFalse(controller.Start());
Assert::IsFalse(controller.Start(input));
TestKeyEvent down(VK_SPACE);
AssertDisposition(TextExpansionController::EventDisposition::FreshActionKeyDown, controller.BeginKeyboardEvent(&down.event));
Assert::AreEqual(0, static_cast<int>(controller.TryActivate(input, VK_SPACE, rules)));
AssertDisposition(TextExpansionController::EventDisposition::Ignore, controller.BeginKeyboardEvent(&down.event));
Assert::AreEqual(0, static_cast<int>(controller.TryActivate(input, &down.event, rules)));
Assert::AreEqual(0, backendView->activateCalls);
Assert::IsFalse(controller.HasPendingWork());
}
TEST_METHOD (Start_ShouldTreatAlreadyHeldActionKeyAsPreexistingUntilItsKeyUp)
{
auto backend = std::make_unique<FakeTextExpansionBackend>();
auto* backendView = backend.get();
backendView->prepareResult = TextExpansionResult::Prepared;
TextExpansionController controller(std::move(backend));
KeyboardManagerInput::MockedInput input;
input.SetKeyboardState(VK_F8, true);
const TextExpansionTable rules{ MakeRule(L"rule-id", L"brb", { VK_F8 }, L"expanded") };
Assert::IsTrue(controller.Start(input));
TestKeyEvent repeat(VK_F8);
AssertDisposition(TextExpansionController::EventDisposition::ForcePassThrough, controller.BeginKeyboardEvent(&repeat.event));
controller.TrackKeyboardEvent(&repeat.event);
Assert::AreEqual(1, backendView->trackCalls);
Assert::AreEqual(0, backendView->activateCalls);
input.SetKeyboardState(VK_F8, false);
TestKeyEvent up(VK_F8, WM_KEYUP, LLKHF_UP);
AssertDisposition(TextExpansionController::EventDisposition::ForcePassThrough, controller.BeginKeyboardEvent(&up.event));
Assert::IsFalse(controller.HasPendingWork());
TestKeyEvent freshDown(VK_F8);
AssertDisposition(TextExpansionController::EventDisposition::FreshActionKeyDown, controller.BeginKeyboardEvent(&freshDown.event));
Assert::AreEqual(1, static_cast<int>(controller.TryActivate(input, &freshDown.event, rules)));
Assert::AreEqual(1, backendView->activateCalls);
}
TEST_METHOD (Arming_ShouldSurviveNumpadVirtualKeyAliasChanges)
{
auto backend = std::make_unique<FakeTextExpansionBackend>();
TextExpansionController controller(std::move(backend));
KeyboardManagerInput::MockedInput input;
input.SetKeyboardState(VK_NUMPAD0, true);
Assert::IsTrue(controller.Start(input));
constexpr DWORD numpadScanCode = 0x52;
const DWORD numpadInsert = VK_INSERT | Helpers::GetNumpadOriginEncodingBit();
TestKeyEvent repeat(numpadInsert, WM_KEYDOWN, 0, 0, numpadScanCode);
AssertDisposition(TextExpansionController::EventDisposition::ForcePassThrough, controller.BeginKeyboardEvent(&repeat.event));
TestKeyEvent up(numpadInsert, WM_KEYUP, LLKHF_UP, 0, numpadScanCode);
AssertDisposition(TextExpansionController::EventDisposition::ForcePassThrough, controller.BeginKeyboardEvent(&up.event));
input.SetKeyboardState(VK_NUMPAD0, false);
Assert::IsFalse(controller.HasPendingWork());
TestKeyEvent freshDown('B');
TestKeyEvent freshUp('B', WM_KEYUP, LLKHF_UP);
AssertDisposition(TextExpansionController::EventDisposition::FreshActionKeyDown, controller.BeginKeyboardEvent(&freshDown.event));
AssertDisposition(TextExpansionController::EventDisposition::Continue, controller.BeginKeyboardEvent(&freshUp.event));
Assert::IsFalse(controller.HasPendingWork());
}
TEST_METHOD (Start_ShouldArmForHeldCancelKey)
{
auto backend = std::make_unique<FakeTextExpansionBackend>();
TextExpansionController controller(std::move(backend));
KeyboardManagerInput::MockedInput input;
input.SetKeyboardState(VK_CANCEL, true);
Assert::IsTrue(controller.Start(input));
input.SetKeyboardState(VK_CANCEL, false);
TestKeyEvent up(VK_CANCEL, WM_KEYUP, LLKHF_UP);
AssertDisposition(TextExpansionController::EventDisposition::ForcePassThrough, controller.BeginKeyboardEvent(&up.event));
}
TEST_METHOD (Arming_ShouldWaitForEveryHeldActionKeyRelease)
{
auto backend = std::make_unique<FakeTextExpansionBackend>();
TextExpansionController controller(std::move(backend));
KeyboardManagerInput::MockedInput input;
input.SetKeyboardState('A', true);
input.SetKeyboardState('B', true);
Assert::IsTrue(controller.Start(input));
TestKeyEvent aUp('A', WM_KEYUP, LLKHF_UP);
AssertDisposition(TextExpansionController::EventDisposition::ForcePassThrough, controller.BeginKeyboardEvent(&aUp.event));
input.SetKeyboardState('A', false);
Assert::IsTrue(controller.HasPendingWork());
TestKeyEvent bUp('B', WM_KEYUP, LLKHF_UP);
AssertDisposition(TextExpansionController::EventDisposition::ForcePassThrough, controller.BeginKeyboardEvent(&bUp.event));
input.SetKeyboardState('B', false);
Assert::IsFalse(controller.HasPendingWork());
}
TEST_METHOD (InjectedEvents_ShouldBeIgnored)

View File

@@ -70,13 +70,47 @@ namespace Helpers
return false;
}
DWORD GetNumpadOriginEncodingBit()
{
// Intentionally do not mimic KF_EXTENDED to avoid confusion, because it's not the same thing
// See EncodeKeyNumpadOrigin.
return 1ull << 31;
}
// Function to check if the key is a modifier key
DWORD GetNumpadOriginEncodingBit()
{
// Intentionally do not mimic KF_EXTENDED to avoid confusion, because it's not the same thing
// See EncodeKeyNumpadOrigin.
return 1ull << 31;
}
std::optional<size_t> GetPhysicalKeyEventIndex(const LowlevelKeyboardEvent* data) noexcept
{
if (!data || !data->lParam)
{
return std::nullopt;
}
const DWORD key = ClearKeyNumpadOrigin(data->lParam->vkCode);
if (key > 0xFF)
{
return std::nullopt;
}
constexpr size_t keyCount = 256;
DWORD scanCode = data->lParam->scanCode & 0xFF;
if (scanCode == 0)
{
scanCode = MapVirtualKeyW(key, MAPVK_VK_TO_VSC) & 0xFF;
}
if (scanCode != 0)
{
bool extended = (data->lParam->flags & LLKHF_EXTENDED) != 0;
if (data->lParam->scanCode == 0 && IsNumpadOriginated(data->lParam->vkCode))
{
extended = key == VK_RETURN || key == VK_DIVIDE;
}
return static_cast<size_t>(scanCode) + (extended ? keyCount : 0);
}
return static_cast<size_t>(key) +
(IsNumpadOriginated(data->lParam->vkCode) ? keyCount : 0);
}
// Function to check if the key is a modifier key
bool IsModifierKey(DWORD key)
{
return (GetKeyType(key) != KeyType::Action);
@@ -177,7 +211,7 @@ namespace Helpers
}
// Function to set the value of a key event based on the arguments
void SetKeyEvent(std::vector<INPUT>& keyEventArray, DWORD inputType, WORD keyCode, DWORD flags, ULONG_PTR extraInfo)
void SetKeyEvent(std::vector<INPUT>& keyEventArray, DWORD inputType, WORD keyCode, DWORD flags, ULONG_PTR extraInfo)
{
INPUT keyEvent{};
keyEvent.type = inputType;
@@ -192,8 +226,31 @@ namespace Helpers
// Set wScan to the value from MapVirtualKey as some applications may use the scan code for handling input, for instance, Windows Terminal ignores non-character input which has scancode set to 0.
// MapVirtualKey returns 0 if the key code does not correspond to a physical key (such as unassigned/reserved keys). More details at https://github.com/microsoft/PowerToys/pull/7143#issue-498877747
keyEvent.ki.wScan = static_cast<WORD>(MapVirtualKey(keyCode, MAPVK_VK_TO_VSC));
keyEventArray.push_back(keyEvent);
}
keyEventArray.push_back(keyEvent);
}
void SetTextInputUnit(std::vector<INPUT>& inputArray, const wchar_t value, const ULONG_PTR extraInfo)
{
if (value == L'\r' || value == L'\n')
{
SetKeyEvent(inputArray, INPUT_KEYBOARD, VK_SHIFT, 0, extraInfo);
SetKeyEvent(inputArray, INPUT_KEYBOARD, VK_RETURN, 0, extraInfo);
SetKeyEvent(inputArray, INPUT_KEYBOARD, VK_RETURN, KEYEVENTF_KEYUP, extraInfo);
SetKeyEvent(inputArray, INPUT_KEYBOARD, VK_SHIFT, KEYEVENTF_KEYUP, extraInfo);
return;
}
INPUT down{};
down.type = INPUT_KEYBOARD;
down.ki.dwFlags = KEYEVENTF_UNICODE;
down.ki.dwExtraInfo = extraInfo;
down.ki.wScan = value;
inputArray.push_back(down);
INPUT up = down;
up.ki.dwFlags |= KEYEVENTF_KEYUP;
inputArray.push_back(up);
}
// Function to set the dummy key events used for remapping shortcuts, required to ensure releasing a modifier doesn't trigger another action (For example, Win->Start Menu or Alt->Menu bar)
void SetDummyKeyEvent(std::vector<INPUT>& keyEventArray, ULONG_PTR extraInfo)
@@ -314,11 +371,13 @@ namespace Helpers
// Shift+Enter. Each character is sent individually to avoid a synchronization
// error across key-down and key-up events that causes repeated or dropped characters
// when large batches of KEYEVENTF_UNICODE events are sent at once.
void SendTextInput(const std::wstring& text, KeyboardManagerInput::InputInterface& ii)
{
for (size_t i = 0; i < text.size(); ++i)
{
wchar_t c = text[i];
void SendTextInput(const std::wstring& text, KeyboardManagerInput::InputInterface& ii)
{
std::vector<INPUT> inputUnit;
inputUnit.reserve(4);
for (size_t i = 0; i < text.size(); ++i)
{
wchar_t c = text[i];
// Handle \r\n as a single newline
if (c == L'\r' && i + 1 < text.size() && text[i + 1] == L'\n')
@@ -326,58 +385,11 @@ namespace Helpers
++i;
}
if (c == L'\r' || c == L'\n')
{
// Send Shift+Enter instead of bare Enter so that chat apps
// (Teams, Slack, Discord, etc.) insert a new line rather than
// submitting the message. In plain text editors both behave
// the same.
INPUT returnInputs[4]{};
// Shift down
returnInputs[0].type = INPUT_KEYBOARD;
returnInputs[0].ki.wVk = VK_SHIFT;
returnInputs[0].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_SHIFT, MAPVK_VK_TO_VSC));
returnInputs[0].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
// Return down
returnInputs[1].type = INPUT_KEYBOARD;
returnInputs[1].ki.wVk = VK_RETURN;
returnInputs[1].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_RETURN, MAPVK_VK_TO_VSC));
returnInputs[1].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
// Return up
returnInputs[2].type = INPUT_KEYBOARD;
returnInputs[2].ki.wVk = VK_RETURN;
returnInputs[2].ki.dwFlags = KEYEVENTF_KEYUP;
returnInputs[2].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_RETURN, MAPVK_VK_TO_VSC));
returnInputs[2].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
// Shift up
returnInputs[3].type = INPUT_KEYBOARD;
returnInputs[3].ki.wVk = VK_SHIFT;
returnInputs[3].ki.dwFlags = KEYEVENTF_KEYUP;
returnInputs[3].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_SHIFT, MAPVK_VK_TO_VSC));
returnInputs[3].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
ii.SendVirtualInput(std::vector<INPUT>(returnInputs, returnInputs + ARRAYSIZE(returnInputs)));
continue;
}
INPUT charInputs[2]{};
charInputs[0].type = INPUT_KEYBOARD;
charInputs[0].ki.dwFlags = KEYEVENTF_UNICODE;
charInputs[0].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
charInputs[0].ki.wScan = c;
charInputs[1].type = INPUT_KEYBOARD;
charInputs[1].ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP;
charInputs[1].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
charInputs[1].ki.wScan = c;
ii.SendVirtualInput(std::vector<INPUT>(charInputs, charInputs + ARRAYSIZE(charInputs)));
}
}
inputUnit.clear();
SetTextInputUnit(inputUnit, c, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG);
ii.SendVirtualInput(inputUnit);
}
}
// Function to filter the key codes for artificial key codes
int32_t FilterArtificialKeys(const int32_t& key)

View File

@@ -1,5 +1,10 @@
#pragma once
#include "Shortcut.h"
#pragma once
#include <cstddef>
#include <optional>
#include <common/hooks/LowlevelKeyboardEvent.h>
#include "Shortcut.h"
#include "RemapShortcut.h"
class LayoutMap;
@@ -26,7 +31,11 @@ namespace Helpers
DWORD ClearKeyNumpadOrigin(const DWORD key);
bool IsNumpadOriginated(const DWORD key);
bool IsNumpadKeyThatIsAffectedByShift(const DWORD vkCode);
DWORD GetNumpadOriginEncodingBit();
DWORD GetNumpadOriginEncodingBit();
// Stable identity for one physical press. Unlike vkCode, scan code and the
// extended bit do not change when Shift or Num Lock changes a numpad key alias.
std::optional<size_t> GetPhysicalKeyEventIndex(const LowlevelKeyboardEvent* data) noexcept;
// Function to check if the key is a modifier key
bool IsModifierKey(DWORD key);
@@ -37,14 +46,18 @@ namespace Helpers
// Function to get the type of the key
KeyType GetKeyType(DWORD key);
// Function to set the value of a key event based on the arguments
void SetKeyEvent(std::vector<INPUT>& keyEventArray, DWORD inputType, WORD keyCode, DWORD flags, ULONG_PTR extraInfo);
// Function to set the value of a key event based on the arguments
void SetKeyEvent(std::vector<INPUT>& keyEventArray, DWORD inputType, WORD keyCode, DWORD flags, ULONG_PTR extraInfo);
// Appends one text input unit. Newlines use Shift+Enter so chat-style controls
// insert a line break instead of submitting their contents.
void SetTextInputUnit(std::vector<INPUT>& inputArray, wchar_t value, ULONG_PTR extraInfo);
// Function to set the dummy key events used for remapping shortcuts, required to ensure releasing a modifier doesn't trigger another action (For example, Win->Start Menu or Alt->Menu bar)
void SetDummyKeyEvent(std::vector<INPUT>& keyEventArray, ULONG_PTR extraInfo);
// Function to send text input directly, with multiline support.
// Sends each line via KEYEVENTF_UNICODE and newlines via VK_RETURN
// Sends each line via KEYEVENTF_UNICODE and newlines via Shift+Enter
// as separate SendInput calls to avoid mixing event types.
void SendTextInput(const std::wstring& text, KeyboardManagerInput::InputInterface& ii);
@@ -63,4 +76,4 @@ namespace Helpers
// Function to sort a vector of shortcuts based on its size
void SortShortcutVectorBasedOnSize(std::vector<Shortcut>& shortcutVector);
}
}