From b5b7e28cf1317d5c665c00956d68d19b76b595ea Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Wed, 5 Aug 2026 18:41:34 -0700 Subject: [PATCH] Allow back paddles to be bound to keyboard keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Press-to-bind only listened to the controller, so the remap window could offer gamepad buttons and mouse clicks but nothing from the keyboard. Capture a key press while a row is listening and bind that instead. Capture happens in the remap page's keydown handler rather than through a keyboard hook. The page is already focused while listening, and a low-level hook is a heavy, AV-bait mechanism for a convenience feature. The page reports the physical KeyboardEvent.code, which KeyInput turns into a virtual-key code, so a binding made on one layout means the same physical key on another. Escape stays the cancel affordance and is deliberately not bindable. Keys are delivered with SendInput as scan codes rather than virtual keys, because software that reads scan codes directly sees nothing useful otherwise. Note that injected input does not reach every game — anti-cheat and some input paths ignore it — so this is realistically a desktop, emulator, and browser feature. Gamepad bindings remain the answer in game. Extended keys are flagged from an explicit set. MapVirtualKey is documented to report the 0xE0 prefix under MAPVK_VK_TO_VSC_EX but does not do so for the navigation cluster, which shares scan codes with the numpad — a test caught Up arrow being delivered as numpad 8. Labels come from GetKeyNameText against the active layout, so a non-US keyboard shows its own key names, and are JSON-escaped on the way to the page: the backslash key really is named "\". Paddle release tracking now records what the press actually sent instead of re-reading the binding on release, so rebinding a paddle mid-hold releases the input that was pressed rather than stranding it down. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 1 + src/app/ControllerManager.cpp | 79 ++++++++++++------ src/app/ControllerManager.h | 2 +- src/app/KeyInput.cpp | 153 ++++++++++++++++++++++++++++++++++ src/app/KeyInput.h | 25 ++++++ src/app/RemapWindow.cpp | 105 +++++++++++++++++++---- src/app/RemapWindow.h | 4 + 7 files changed, 328 insertions(+), 41 deletions(-) create mode 100644 src/app/KeyInput.cpp create mode 100644 src/app/KeyInput.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ee2f2c..b32f73b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,6 +90,7 @@ add_executable(SteamlessController WIN32 src/app/ControllerManager.cpp src/app/VirtualController.cpp src/app/TrackpadMouse.cpp + src/app/KeyInput.cpp src/app/SteamWatcher.cpp src/app/DeviceRestart.cpp src/app/EventLog.cpp diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index 9156e55..02dfddf 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -2,6 +2,7 @@ #include "EventLog.h" #include "VirtualController.h" #include "TrackpadMouse.h" +#include "KeyInput.h" #include "steam/SteamController.h" #include #include @@ -9,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -34,10 +36,11 @@ struct ControllerManager::Slot { // thread for that timeout on every one of those attempts. std::chrono::steady_clock::time_point lastSilentAt{}; - // Mouse buttons currently held down by a back paddle, so leaving game mode - // mid-press releases them instead of stranding the button down. - bool paddleMouseLeftDown = false; - bool paddleMouseRightDown = false; + // What each paddle (L4, L5, R4, R5) is currently holding down, so leaving + // game mode mid-press releases it instead of stranding the input down. + // Records what the press actually sent rather than reading the binding + // again on release, so rebinding a paddle mid-hold still releases cleanly. + BackButtonBinding paddleHeld[4]; // Haptic edge-detection state for touch (movement ticks). bool hapticWasRightTouching = false; @@ -204,7 +207,7 @@ ControllerManager::~ControllerManager() { if (slot->gameModeActive) { StopReadLoop(*slot); slot->trackpad.Reset(); - ReleasePaddleMouseButtons(*slot); + ReleaseHeldPaddleInputs(*slot); slot->vc.reset(); slot->gameModeActive = false; } @@ -284,17 +287,44 @@ void ControllerManager::SetBackButtonConfig(const BackButtonConfig& cfg) { // change takes effect on the very next report. } -void ControllerManager::ReleasePaddleMouseButtons(Slot& slot) { - auto send = [](DWORD flags) { +// Sends the key or mouse event a binding stands for. Gamepad actions reach the +// virtual pad instead and are ignored here. Returns whether anything was sent. +static bool SendPaddleInput(const BackButtonBinding& binding, bool down) { + auto sendMouse = [](DWORD flags) { INPUT inp{}; inp.type = INPUT_MOUSE; inp.mi.dwFlags = flags; SendInput(1, &inp, sizeof(INPUT)); }; - if (slot.paddleMouseLeftDown) send(MOUSEEVENTF_LEFTUP); - if (slot.paddleMouseRightDown) send(MOUSEEVENTF_RIGHTUP); - slot.paddleMouseLeftDown = false; - slot.paddleMouseRightDown = false; + + switch (binding.kind) { + case BackButtonBinding::Kind::Key: + SendKeyInput(binding.code, down); + return true; + + case BackButtonBinding::Kind::MouseButton: + // Extended mouse buttons are not bindable yet. + return false; + + case BackButtonBinding::Kind::Action: + if (binding.IsAction(BackButtonAction::LeftMouseButton)) { + sendMouse(down ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP); + return true; + } + if (binding.IsAction(BackButtonAction::RightMouseButton)) { + sendMouse(down ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_RIGHTUP); + return true; + } + return false; + } + return false; +} + +void ControllerManager::ReleaseHeldPaddleInputs(Slot& slot) { + for (auto& held : slot.paddleHeld) { + SendPaddleInput(held, false); + held = BackButtonBinding{}; + } } void ControllerManager::SetControllerPlatform(ControllerPlatform platform) { @@ -458,7 +488,7 @@ ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, EventLog::Write("GAMEMODE: enabled %ls", slot.path.c_str()); slot.gameModeActive = true; slot.trackpad.Reset(); - ReleasePaddleMouseButtons(slot); + ReleaseHeldPaddleInputs(slot); slot.trackpad.SetTrackpadEnabled(m_trackpadMouseEnabled); slot.trackpad.SetUseLeftTrackpad(m_useLeftTrackpad); StartReadLoop(slot); @@ -472,7 +502,7 @@ void ControllerManager::DisableGameModeSlot(Slot& slot) { if (slot.sc->IsOpen()) slot.sc->SetRumble(0, 0); slot.trackpad.Reset(); - ReleasePaddleMouseButtons(slot); + ReleaseHeldPaddleInputs(slot); slot.vc.reset(); slot.sc->EnableLizardMode(); // Reopen shared so Steam can obtain write access — game mode is no longer active. @@ -778,8 +808,9 @@ void ControllerManager::ReadLoop(Slot* slot) { slot->hapticWasLeftTouching = lt; } - // Fire mouse button events for back paddles mapped to LeftMouseButton/RightMouseButton. - // Uses edge detection so we only send DOWN on press and UP on release. + // Deliver back paddles bound to a key or a mouse button. These go out + // through SendInput rather than the virtual pad. Edge-detected so each + // press sends exactly one down and each release exactly one up. if (hasPrev) { struct PaddleEdge { bool cur; bool prev; const BackButtonBinding& binding; }; const PaddleEdge edges[] = { @@ -788,17 +819,17 @@ void ControllerManager::ReadLoop(Slot* slot) { { n>2 && (buf[2]&SteamController::BTN_R4)!=0, (prevBuf[2]&SteamController::BTN_R4)!=0, m_backConfig.r4 }, { n>3 && (buf[3]&SteamController::BTN_R5)!=0, (prevBuf[3]&SteamController::BTN_R5)!=0, m_backConfig.r5 }, }; - for (const auto& e : edges) { + for (size_t i = 0; i < std::size(edges); ++i) { + const auto& e = edges[i]; if (e.cur == e.prev) continue; - DWORD flag = 0; - if (e.binding.IsAction(BackButtonAction::LeftMouseButton)) { - flag = e.cur ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP; - slot->paddleMouseLeftDown = e.cur; - } else if (e.binding.IsAction(BackButtonAction::RightMouseButton)) { - flag = e.cur ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_RIGHTUP; - slot->paddleMouseRightDown = e.cur; + if (e.cur) { + if (SendPaddleInput(e.binding, true)) slot->paddleHeld[i] = e.binding; + } else { + // Release what the press sent, not what the binding says + // now — the two differ if the user rebound mid-hold. + SendPaddleInput(slot->paddleHeld[i], false); + slot->paddleHeld[i] = BackButtonBinding{}; } - if (flag) { INPUT inp{}; inp.type = INPUT_MOUSE; inp.mi.dwFlags = flag; SendInput(1, &inp, sizeof(INPUT)); } } } diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index c68e2d5..0ad91f3 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -79,7 +79,7 @@ private: GameModeOutcome EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, uint32_t stateWaitMs); void DisableGameModeSlot(Slot& slot); - void ReleasePaddleMouseButtons(Slot& slot); + void ReleaseHeldPaddleInputs(Slot& slot); void StartReadLoop(Slot& slot); void StopReadLoop(Slot& slot); void ReadLoop(Slot* slot); diff --git a/src/app/KeyInput.cpp b/src/app/KeyInput.cpp new file mode 100644 index 0000000..f322b27 --- /dev/null +++ b/src/app/KeyInput.cpp @@ -0,0 +1,153 @@ +#include "KeyInput.h" +#include +#include + +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. Escape is absent on +// purpose — the remap window uses it to cancel listening. +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}, + + // 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}, + + // 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(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; +} + +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(sc.scan) << 16; + if (sc.extended) lParam |= (1L << 24); + + wchar_t buf[64] = {}; + const int n = GetKeyNameTextW(lParam, buf, static_cast(std::size(buf))); + return n > 0 ? std::wstring(buf, static_cast(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; + SendInput(1, &input, sizeof(INPUT)); +} diff --git a/src/app/KeyInput.h b/src/app/KeyInput.h new file mode 100644 index 0000000..20eadca --- /dev/null +++ b/src/app/KeyInput.h @@ -0,0 +1,25 @@ +#pragma once +#include +#include + +// Keyboard support for paddle bindings. +// +// Capture happens in the remap window's WebView2 page rather than through a +// keyboard hook: the page is already focused while a row is listening, and a +// low-level hook is a heavy, AV-bait mechanism for a convenience feature. +// The page reports the physical key as a KeyboardEvent.code string, which this +// module turns into a virtual-key code. + +// Maps a KeyboardEvent.code value ("KeyA", "Space", "ArrowUp") to a Windows +// virtual-key code. Returns 0 for anything unrecognised or not bindable. +uint16_t VkFromJsCode(const std::string& jsCode); + +// Human-readable name for a virtual-key code, from the active keyboard layout — +// so a non-US layout shows its own key names. Falls back to "Key" if the layout +// has no name for it. +std::wstring KeyDisplayName(uint16_t vk); + +// Presses or releases a key. Sends a scan code rather than a virtual key, and +// flags the extended keys, because software that reads scan codes directly +// (games, in particular) sees nothing useful otherwise. +void SendKeyInput(uint16_t vk, bool down); diff --git a/src/app/RemapWindow.cpp b/src/app/RemapWindow.cpp index 1d0157c..263eab7 100644 --- a/src/app/RemapWindow.cpp +++ b/src/app/RemapWindow.cpp @@ -1,4 +1,5 @@ #include "RemapWindow.h" +#include "KeyInput.h" #include "ControllerManager.h" #include #include @@ -104,7 +105,7 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back

Back Button Mapping

-

Click Rebind on a button, then press any gamepad button on your controller. The new binding shows up here instantly. Pick Off to stop a paddle doing anything at all.

+

Click Rebind on a button, then press any gamepad button on your controller — or any key on your keyboard. The new binding shows up here instantly. Pick Off to stop a paddle doing anything at all.

LEFT GRIP
@@ -134,6 +135,9 @@ var DEFAULTS = {L4:'leftMouse',L5:'none',R4:'leftMouse',R5:'none'}; // ---- State ---- var bindings = {L4:'leftMouse',L5:'none',R4:'leftMouse',R5:'none'}; var listening = null; +// Display names for key bindings, keyed by binding id. Supplied by C++ from the +// active keyboard layout — the catalog below can't cover them. +var keyLabels = {}; var flash = null; var flashTimer = null; var applyTimer = null; @@ -189,9 +193,12 @@ if(window.chrome&&window.chrome.webview){ var msg=JSON.parse(e.data); if(msg.type==='init'){ bindings=msg.bindings; + keyLabels=msg.labels||{}; renderAll(); } else if(msg.type==='buttonCaptured'){ - if(listening&&byId[msg.button]) doBind(listening,msg.button); + if(!listening) return; + if(msg.label) keyLabels[msg.button]=msg.label; + doBind(listening,msg.button); } }); } @@ -240,6 +247,16 @@ function applyBindings(){ postMsg({type:'apply',bindings:bindings}); } // ---- Render helpers ---- +// Resolves a binding id to something renderable. Keys aren't in the static +// catalog, so they're built on the fly from the label C++ sent. +function inputInfo(id){ + if(byId[id]) return byId[id]; + if(id&&id.indexOf('key:')===0){ + var lbl=keyLabels[id]||'Key'; + return {id:id,glyph:(lbl.length<=3?lbl:'KEY'),label:lbl,bg:'#4a3524',fg:'#f2d5ab'}; + } + return byId['none']; +} function chipHTML(t,cls){ return '
'+t.glyph+'
'; } @@ -249,11 +266,11 @@ function renderRow(def){ var isL=listening===def.id, isF=flash===def.id; el.className='row'+(isL?' listening':isF?' flash':''); var curBind=bindings[def.id]; - var tgt=byId[curBind]||byId['none']; + var tgt=inputInfo(curBind); var rightHTML; if(isL){ rightHTML='
'+ - 'Press a button...'+ + 'Press a button or key...'+ ''+ ''+ '
'; @@ -307,7 +324,14 @@ document.getElementById('titlebar').addEventListener('mousedown',function(e){ }); document.addEventListener('keydown',function(e){ - if(e.key==='Escape') cancelListening(); + // Escape stays the cancel affordance, so it is deliberately not bindable. + if(e.key==='Escape'){cancelListening();return;} + if(!listening||e.repeat) return; + // Swallow the key so it can't also drive the page (Tab moving focus, Space + // clicking the focused button) while a row is listening. + e.preventDefault(); + e.stopPropagation(); + postMsg({type:'keyCaptured',code:e.code}); }); renderAll(); @@ -418,18 +442,10 @@ LRESULT RemapWindow::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { return hit; } - case WM_BUTTON_CAPTURED: { + case WM_BUTTON_CAPTURED: // Marshalled from the read thread via PostMessage, packed into WPARAM. - const std::string id = - BackButtonBinding::Unpack(static_cast(wp)).Id(); - if (m_webview) { - std::wstring json = L"{\"type\":\"buttonCaptured\",\"button\":\""; - json += std::wstring(id.begin(), id.end()); - json += L"\"}"; - m_webview->PostWebMessageAsString(json.c_str()); - } + PostCapturedBinding(BackButtonBinding::Unpack(static_cast(wp))); return 0; - } case WM_CLOSE: ShowWindow(hwnd, SW_HIDE); @@ -633,6 +649,32 @@ void RemapWindow::OnControllerReady(ICoreWebView2Controller* ctrl) { // --------------------------------------------------------------------------- // Minimal JSON string extractor — finds "key":"value" in a flat JSON object. +// Key names come from the keyboard layout, so they can contain characters that +// would otherwise terminate or corrupt the JSON string we build by hand — the +// backslash key is the obvious one. +static std::wstring JsonEscape(const std::wstring& s) { + std::wstring out; + out.reserve(s.size() + 8); + for (wchar_t c : s) { + switch (c) { + case L'"': out += L"\\\""; break; + case L'\\': out += L"\\\\"; break; + default: + if (c >= 0x20) out += c; // drop control characters + break; + } + } + return out; +} + +// Display label for a binding, or empty when the page's static catalog already +// has a name for it. Only keys need one — their names come from the layout. +static std::wstring BindingLabel(const BackButtonBinding& binding) { + return binding.kind == BackButtonBinding::Kind::Key + ? KeyDisplayName(binding.code) + : std::wstring(); +} + static std::string JsonStr(const std::string& json, const std::string& key) { std::string needle = "\"" + key + "\":\""; size_t pos = json.find(needle); @@ -672,6 +714,16 @@ void RemapWindow::OnWebMessage(const std::wstring& raw) { }); } + } else if (type == "keyCaptured") { + // Arrives from the page's keydown handler, already on the UI thread. + // Unmappable keys are ignored so the row keeps listening rather than + // binding to nothing. + const uint16_t vk = VkFromJsCode(JsonStr(msg, "code")); + if (vk != 0) { + if (m_mgr) m_mgr->StopButtonCapture(); + PostCapturedBinding(BackButtonBinding::FromKey(vk)); + } + } else if (type == "stopListening") { if (m_mgr) m_mgr->StopButtonCapture(); @@ -695,6 +747,14 @@ void RemapWindow::PostToWebView(const std::wstring& jsonStr) { if (m_webview) m_webview->PostWebMessageAsString(jsonStr.c_str()); } +void RemapWindow::PostCapturedBinding(const BackButtonBinding& binding) { + const std::string id = binding.Id(); + std::wstring json = L"{\"type\":\"buttonCaptured\",\"button\":\""; + json += std::wstring(id.begin(), id.end()); + json += L"\",\"label\":\"" + JsonEscape(BindingLabel(binding)) + L"\"}"; + PostToWebView(json); +} + void RemapWindow::SendInitState() { if (!m_webview) return; @@ -704,12 +764,25 @@ void RemapWindow::SendInitState() { return std::wstring(s.begin(), s.end()); }; + // Key bindings need a label the page cannot derive on its own — their names + // come from the keyboard layout. Sent as an id→name map so a paddle pair + // bound to the same key contributes one entry. + std::wstring labels; + for (const auto* b : { &m_config.l4, &m_config.l5, &m_config.r4, &m_config.r5 }) { + const std::wstring label = BindingLabel(*b); + if (label.empty()) continue; + const std::wstring entry = L"\"" + wid(*b) + L"\":\"" + JsonEscape(label) + L"\""; + if (labels.find(entry) != std::wstring::npos) continue; + if (!labels.empty()) labels += L","; + labels += entry; + } + std::wstring json = L"{\"type\":\"init\",\"bindings\":{" L"\"L4\":\"" + wid(m_config.l4) + L"\"," L"\"L5\":\"" + wid(m_config.l5) + L"\"," L"\"R4\":\"" + wid(m_config.r4) + L"\"," L"\"R5\":\"" + wid(m_config.r5) + L"\"" - L"}}"; + L"},\"labels\":{" + labels + L"}}"; PostToWebView(json); } diff --git a/src/app/RemapWindow.h b/src/app/RemapWindow.h index 0de2e08..3a9908b 100644 --- a/src/app/RemapWindow.h +++ b/src/app/RemapWindow.h @@ -34,6 +34,10 @@ private: void PostToWebView(const std::wstring& jsonStr); void SendInitState(); + // Tells the page a binding was captured. The page applies it to whichever + // row is listening — it owns that state, not us. + void PostCapturedBinding(const BackButtonBinding& binding); + // Called from the read thread via PostMessage — marshals a captured button // back to the UI thread so we can call PostWebMessageAsString safely. static constexpr UINT WM_BUTTON_CAPTURED = WM_APP + 1;