Files
SteamlessController/src/app/KeyInput.cpp
T
Dylan DeverillandClaude Sonnet 5 09ca4c30b4 Let Escape be captured as a key-combo binding (#105)
Escape used to be intercepted globally as the remap window's cancel
affordance, so pressing it while listening just exited capture instead
of binding anything. Now it only dismisses the unsaved-changes modal
or the game picker; while a row is listening, Escape falls through to
the normal capture path (alone or as part of a combo), and the VK
table gained the missing entry so it resolves to a real binding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:41:48 -07:00

260 lines
10 KiB
C++

#include "KeyInput.h"
#include "InputInjection.h"
#include <Windows.h>
#include <iterator>
namespace {
struct JsCodeToVk { const char* code; uint16_t vk; };
// KeyboardEvent.code values are physical positions, so a binding made on one
// keyboard layout means the same physical key on another.
const JsCodeToVk kCodeMap[] = {
// Letters
{"KeyA",'A'},{"KeyB",'B'},{"KeyC",'C'},{"KeyD",'D'},{"KeyE",'E'},{"KeyF",'F'},
{"KeyG",'G'},{"KeyH",'H'},{"KeyI",'I'},{"KeyJ",'J'},{"KeyK",'K'},{"KeyL",'L'},
{"KeyM",'M'},{"KeyN",'N'},{"KeyO",'O'},{"KeyP",'P'},{"KeyQ",'Q'},{"KeyR",'R'},
{"KeyS",'S'},{"KeyT",'T'},{"KeyU",'U'},{"KeyV",'V'},{"KeyW",'W'},{"KeyX",'X'},
{"KeyY",'Y'},{"KeyZ",'Z'},
// Digit row
{"Digit0",'0'},{"Digit1",'1'},{"Digit2",'2'},{"Digit3",'3'},{"Digit4",'4'},
{"Digit5",'5'},{"Digit6",'6'},{"Digit7",'7'},{"Digit8",'8'},{"Digit9",'9'},
// Whitespace and editing
{"Space", VK_SPACE},
{"Enter", VK_RETURN},
{"Tab", VK_TAB},
{"Backspace", VK_BACK},
{"Delete", VK_DELETE},
{"Insert", VK_INSERT},
{"Escape", VK_ESCAPE},
// Navigation
{"ArrowUp", VK_UP},
{"ArrowDown", VK_DOWN},
{"ArrowLeft", VK_LEFT},
{"ArrowRight", VK_RIGHT},
{"Home", VK_HOME},
{"End", VK_END},
{"PageUp", VK_PRIOR},
{"PageDown", VK_NEXT},
// Modifiers — bound to the specific left/right key, not the merged VK_SHIFT
{"ShiftLeft", VK_LSHIFT}, {"ShiftRight", VK_RSHIFT},
{"ControlLeft", VK_LCONTROL}, {"ControlRight", VK_RCONTROL},
{"AltLeft", VK_LMENU}, {"AltRight", VK_RMENU},
// Present for completeness, not because they can be captured: Windows
// opens the Start menu on the way down, so the page is never told. A
// binding that wants Windows carries it as a modifier flag instead.
{"MetaLeft", VK_LWIN}, {"MetaRight", VK_RWIN},
// Function row
{"F1",VK_F1},{"F2",VK_F2},{"F3",VK_F3},{"F4",VK_F4},{"F5",VK_F5},{"F6",VK_F6},
{"F7",VK_F7},{"F8",VK_F8},{"F9",VK_F9},{"F10",VK_F10},{"F11",VK_F11},{"F12",VK_F12},
// Punctuation (OEM codes are positional, which suits a physical binding)
{"Minus", VK_OEM_MINUS},
{"Equal", VK_OEM_PLUS},
{"BracketLeft", VK_OEM_4},
{"BracketRight", VK_OEM_6},
{"Backslash", VK_OEM_5},
{"Semicolon", VK_OEM_1},
{"Quote", VK_OEM_7},
{"Backquote", VK_OEM_3},
{"Comma", VK_OEM_COMMA},
{"Period", VK_OEM_PERIOD},
{"Slash", VK_OEM_2},
// Numpad
{"Numpad0",VK_NUMPAD0},{"Numpad1",VK_NUMPAD1},{"Numpad2",VK_NUMPAD2},
{"Numpad3",VK_NUMPAD3},{"Numpad4",VK_NUMPAD4},{"Numpad5",VK_NUMPAD5},
{"Numpad6",VK_NUMPAD6},{"Numpad7",VK_NUMPAD7},{"Numpad8",VK_NUMPAD8},
{"Numpad9",VK_NUMPAD9},
{"NumpadAdd", VK_ADD},
{"NumpadSubtract", VK_SUBTRACT},
{"NumpadMultiply", VK_MULTIPLY},
{"NumpadDivide", VK_DIVIDE},
{"NumpadDecimal", VK_DECIMAL},
// Windows gives numpad Enter the same virtual key as the main one and
// separates them by scan code alone. A binding stores only the VK, so this
// one behaves as a plain Enter — which is what nearly all software wants.
{"NumpadEnter", VK_RETURN},
// Locks
{"CapsLock", VK_CAPITAL},
{"NumLock", VK_NUMLOCK},
{"ScrollLock", VK_SCROLL},
};
// The navigation cluster shares scan codes with the numpad and is told apart
// only by the 0xE0 extended prefix — drop it and an Up arrow arrives as numpad
// 8. MapVirtualKey is documented to report that prefix under MAPVK_VK_TO_VSC_EX
// but does not actually do so for these keys, so the set is spelled out here.
bool IsExtendedKey(uint16_t vk) {
switch (vk) {
case VK_RCONTROL: case VK_RMENU:
case VK_INSERT: case VK_DELETE:
case VK_HOME: case VK_END:
case VK_PRIOR: case VK_NEXT:
case VK_UP: case VK_DOWN:
case VK_LEFT: case VK_RIGHT:
case VK_NUMLOCK: case VK_DIVIDE:
case VK_SNAPSHOT:
case VK_LWIN: case VK_RWIN: case VK_APPS:
return true;
default:
return false;
}
}
struct ScanCode { WORD scan; bool extended; };
ScanCode ScanCodeFor(uint16_t vk) {
// _EX is still the right lookup for the scan code itself: it distinguishes
// left from right modifiers, which the plain mapping collapses.
const UINT mapped = MapVirtualKeyW(vk, MAPVK_VK_TO_VSC_EX);
return { static_cast<WORD>(mapped & 0xFF), IsExtendedKey(vk) };
}
} // namespace
uint16_t VkFromJsCode(const std::string& jsCode) {
for (const auto& e : kCodeMap)
if (jsCode == e.code) return e.vk;
return 0;
}
namespace {
// The physical key each modifier flag sends. Left-hand keys throughout:
// applications test the merged state, and a left modifier is what a user
// pressing the shortcut themselves would almost always produce.
struct ModifierKey { BackButtonBinding::Modifier flag; uint16_t vk; const wchar_t* name; };
// Order matters twice over: it is the order modifiers are pressed in, and the
// order they read in a label. Ctrl, Alt, Shift, Win is the convention Windows
// itself uses when it spells out a shortcut.
const ModifierKey kModifierKeys[] = {
{ BackButtonBinding::ModCtrl, VK_LCONTROL, L"Ctrl" },
{ BackButtonBinding::ModAlt, VK_LMENU, L"Alt" },
{ BackButtonBinding::ModShift, VK_LSHIFT, L"Shift" },
{ BackButtonBinding::ModWin, VK_LWIN, L"Win" },
};
// How many live bindings are currently holding each modifier down, indexed
// alongside kModifierKeys. Only ever touched from the read thread that
// dispatches bindings, which is the same thread for every slot.
int g_modifierHolds[std::size(kModifierKeys)] = {};
// Whether this app is the one holding the key down, as opposed to having
// found it already held. Recorded rather than re-tested on release, because
// the test cannot tell the two apart: an injected key reads as pressed to
// GetAsyncKeyState exactly like a real one, so asking again on the way out
// would see our own press and decline to undo it.
bool g_modifierSentByUs[std::size(kModifierKeys)] = {};
// Whether the key is down right now, by anyone's hand. Checked only before a
// press, where the answer means "the user is holding it" — nothing of ours is
// down at that point, or the hold count would have caught it first.
bool PhysicallyHeld(uint16_t vk) {
// The merged virtual key, not the left/right one: the user may be holding
// the right-hand key while this sends the left, and either satisfies the
// shortcut.
uint16_t merged = vk;
switch (vk) {
case VK_LCONTROL: merged = VK_CONTROL; break;
case VK_LMENU: merged = VK_MENU; break;
case VK_LSHIFT: merged = VK_SHIFT; break;
default: break;
}
return (GetAsyncKeyState(merged) & 0x8000) != 0;
}
} // namespace
void SendModifiers(uint8_t mods, bool down) {
for (size_t i = 0; i < std::size(kModifierKeys); ++i) {
const auto& m = kModifierKeys[i];
if (!(mods & m.flag)) continue;
if (down) {
// Only the first binding to want it does anything; the rest just
// join the count so the key outlives their individual releases.
if (g_modifierHolds[i]++ > 0) continue;
// Already down under the user's own finger — leave it theirs, and
// remember not to release what we never pressed.
g_modifierSentByUs[i] = !PhysicallyHeld(m.vk);
if (g_modifierSentByUs[i]) SendKeyInput(m.vk, true);
} else {
if (g_modifierHolds[i] == 0) continue; // never pressed, or already undone
if (--g_modifierHolds[i] > 0) continue; // another binding still wants it
if (!g_modifierSentByUs[i]) continue;
g_modifierSentByUs[i] = false;
SendKeyInput(m.vk, false);
}
}
}
std::wstring KeyComboDisplayName(const BackButtonBinding& binding) {
if (binding.kind != BackButtonBinding::Kind::Key) return L"Key";
std::wstring name;
for (const auto& m : kModifierKeys) {
if (!(binding.mods & m.flag)) continue;
name += m.name;
name += L" + ";
}
return name + KeyDisplayName(binding.code);
}
std::wstring KeyDisplayName(uint16_t vk) {
if (vk == 0) return L"Key";
const ScanCode sc = ScanCodeFor(vk);
if (sc.scan == 0) return L"Key";
// GetKeyNameText takes the scan code and extended flag positioned as they
// appear in a WM_KEYDOWN lParam.
LONG lParam = static_cast<LONG>(sc.scan) << 16;
if (sc.extended) lParam |= (1L << 24);
wchar_t buf[64] = {};
const int n = GetKeyNameTextW(lParam, buf, static_cast<int>(std::size(buf)));
return n > 0 ? std::wstring(buf, static_cast<size_t>(n)) : std::wstring(L"Key");
}
void SendKeyInput(uint16_t vk, bool down) {
if (vk == 0) return;
const ScanCode sc = ScanCodeFor(vk);
if (sc.scan == 0) return;
INPUT input = {};
input.type = INPUT_KEYBOARD;
input.ki.wVk = 0; // ignored, and must be zero, when sending a scan code
input.ki.wScan = sc.scan;
input.ki.dwFlags = KEYEVENTF_SCANCODE;
if (sc.extended) input.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
if (!down) input.ki.dwFlags |= KEYEVENTF_KEYUP;
InputInjection::Send(input, "key");
}
std::chrono::milliseconds KeyRepeatDelay() {
// 0-3, meaning roughly 250ms to 1000ms in 250ms steps.
DWORD setting = 1;
if (!SystemParametersInfoW(SPI_GETKEYBOARDDELAY, 0, &setting, 0) || setting > 3)
setting = 1;
return std::chrono::milliseconds(250 * (static_cast<int>(setting) + 1));
}
std::chrono::milliseconds KeyRepeatInterval() {
// 0-31, meaning roughly 2.5 to 30 repeats per second. Windows documents only
// the endpoints, so interpolate the rate between them.
DWORD setting = 31;
if (!SystemParametersInfoW(SPI_GETKEYBOARDSPEED, 0, &setting, 0) || setting > 31)
setting = 31;
const double perSecond = 2.5 + (30.0 - 2.5) * (static_cast<double>(setting) / 31.0);
return std::chrono::milliseconds(static_cast<long long>(1000.0 / perSecond + 0.5));
}