mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 01:59:34 +02:00
[Keyboard Manager] Harden text expansion input transactions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user