diff --git a/src/input_router.cpp b/src/input_router.cpp index 43c3464..9fb5709 100644 --- a/src/input_router.cpp +++ b/src/input_router.cpp @@ -40,6 +40,12 @@ static std::atomic g_swallowedDown[6] = {}; // index = button id 1..5 (# static std::atomic g_btnDir[6] = {}; static std::atomic g_btnMods[6] = {}; static WheelAccum g_wheelAcc; // hook thread only +// One mask keystroke per Alt/Win hold is enough (#301): set when injected, cleared when the keyboard +// hook sees Alt or Win released. Without it a spun wheel injected two key events per notch. +static std::atomic g_maskedThisHold{false}; +// PublishButtonHeld runs on the hook, tick and main threads; unserialised, a stale recompute could +// land after a newer one and strand a direction as held (#301). +static std::atomic_flag g_publishLock = ATOMIC_FLAG_INIT; // The modifiers held right now (bit 1 Ctrl, 2 Alt, 4 Shift, 8 Win). Modifiers are never swallowed, // so the async state is current for them even inside the hook. @@ -54,6 +60,9 @@ static int HeldModsNow() { // One unassigned keystroke so a held Alt/Win is not released "alone" after we swallowed the event // in between (Start menu / menu-bar activation, see NeedsMaskKey). Injected: our hooks pass it. static void InjectMaskKey() { + // While the keyboard hook is suspended nothing would clear the once-per-hold flag, so mask + // every time then (the old behaviour). + if (g_router && g_router->kbHookActive() && g_maskedThisHold.exchange(true)) return; INPUT in[2]{}; in[0].type = INPUT_KEYBOARD; in[0].ki.wVk = (WORD)kMaskVk; in[0].ki.dwExtraInfo = (ULONG_PTR)kWindInjectTag; in[1] = in[0]; in[1].ki.dwFlags = KEYEVENTF_KEYUP; @@ -89,6 +98,7 @@ bool InputRouter::matchButton(int button, int heldMods, int& dir, int& mods) con } // Recompute the per-direction held flags from the per-button records. static void PublishButtonHeld(InputState& st) { + while (g_publishLock.test_and_set(std::memory_order_acquire)) {} // held for a few loads bool in = false, out = false; int inM = 0, outM = 0; for (int b = 1; b <= 5; ++b) { const int d = g_btnDir[b].load(std::memory_order_relaxed); @@ -99,6 +109,7 @@ static void PublishButtonHeld(InputState& st) { st.outHeldMods.store(outM, std::memory_order_relaxed); st.inHeld.store(in); st.outHeld.store(out); + g_publishLock.clear(std::memory_order_release); } // Shared by the WH_MOUSE_LL hook (below) and main's WM_INPUT path: map a button to held. A press // holds the direction of the slot it matches (with the modifiers held now); a release frees it. @@ -137,11 +148,9 @@ void InputRouter::setButtonBinds(int inButtonId, int inMods, int inButtonId2, in // progress would never get its UP event matched and inHeld/outHeld would stick true). for (int b = 1; b <= 5; ++b) g_btnDir[b].store(0, std::memory_order_relaxed); PublishButtonHeld(state_); - // Also clear the swallowed-DOWN records: a remap mid-press (exactly what keybind capture does) - // must not let a stale flag cause a later, unrelated UP to be swallowed (-> stuck button). - // Left/right/middle are NOT cleared here: an up whose down we swallowed must still be - // swallowed, or the app sees a lone click-up (the balanced rule); they clear on their up. - g_swallowedDown[1].store(false); g_swallowedDown[2].store(false); + // The swallowed-DOWN records are NOT cleared (#301): an UP whose DOWN we swallowed must still + // be swallowed after a remap mid-press, or the app sees a lone button-up (a side button's + // XBUTTONUP alone is browser Back/Forward). Each record clears on its own UP. } bool InputRouter::isBoundKey(int vk) const { @@ -234,6 +243,9 @@ static LRESULT CALLBACK KbProc(int code, WPARAM wParam, LPARAM lParam) { int vk = static_cast(ks->vkCode); bool down = (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN); bool up = (wParam == WM_KEYUP || wParam == WM_SYSKEYUP); + // Alt or Win released: the next hold gets its own mask keystroke. + if (up && (vk == VK_LMENU || vk == VK_RMENU || vk == VK_MENU || vk == VK_LWIN || vk == VK_RWIN)) + g_maskedThisHold.store(false, std::memory_order_relaxed); // Any key activity, down OR up: tracking's keyboard gate (#289). Ups count so a focus change // committed by a release (Alt+Tab held for a while) is still keyboard-driven (review). if (down || up) g_router->noteAnyKeyDown(GetTickCount64()); @@ -312,7 +324,7 @@ static LRESULT CALLBACK MouseProc(int code, WPARAM wParam, LPARAM lParam) { g_btnMods[cb].store(mods, std::memory_order_relaxed); g_btnDir[cb].store(dir, std::memory_order_relaxed); PublishButtonHeld(g_router->state()); - if (g_router->swallowEnabled()) { + if (g_router->swallowEnabled() && g_router->keyboardHookWanted()) { g_swallowedDown[cb].store(true); if (NeedsMaskKey(held)) InjectMaskKey(); return 1; @@ -332,8 +344,12 @@ static LRESULT CALLBACK MouseProc(int code, WPARAM wParam, LPARAM lParam) { if (ModsSatisfied(wm, held)) { const int steps = g_wheelAcc.add((short)HIWORD(mi->mouseData)); if (steps) g_router->state().wheelSteps.fetch_add(steps, std::memory_order_relaxed); - if (NeedsMaskKey(held)) InjectMaskKey(); - return 1; + // Pass-through apps (and swallow off) still zoom, and get the notch too. + if (g_router->swallowEnabled() && g_router->keyboardHookWanted()) { + if (NeedsMaskKey(held)) InjectMaskKey(); + return 1; + } + return CallNextHookEx(g_mouseHook, code, wParam, lParam); } g_wheelAcc.reset(); // a fraction never carries into an unrelated gesture } @@ -501,22 +517,14 @@ bool InputRouter::start(int inButtonId, int inButtonId2, int outButtonId, int ou // Raw Input registration (RIDEV_INPUTSINK) + WM_INPUT decoding live in main.cpp's // message-only window, which calls AccumulateRaw() with the decoded deltas. } -// Synthesize an XBUTTON UP for any side-button whose DOWN we swallowed but whose UP we have not yet -// seen/passed through. Called when the hook is torn down: if we vanish mid-press (e.g. another -// instance signals us to quit while a side-button DOWN is outstanding, or shutdown races a press), -// the system would otherwise be left believing the button is held forever, breaking clicks -// system-wide. This GUARANTEES we never strand a button no matter how teardown is triggered. +// Called when the hook is torn down (quit, shutdown racing a press). +// No button-up is synthesised (#301): a swallowed DOWN never reached the system, so nothing believes +// the button is held, and a lone synthesised UP had effects of its own (a right-click menu, browser +// Back/Forward, a drag finished in the wrong window). Only our own records are cleared. static void ReleaseSwallowedButtons() { - for (int id = 1; id <= 5; ++id) { - if (!g_swallowedDown[id].exchange(false)) continue; - INPUT in{}; - in.type = INPUT_MOUSE; - if (id <= 2) { in.mi.dwFlags = MOUSEEVENTF_XUP; in.mi.mouseData = (id == 1) ? XBUTTON1 : XBUTTON2; } - else in.mi.dwFlags = id == 3 ? MOUSEEVENTF_LEFTUP : id == 4 ? MOUSEEVENTF_RIGHTUP : MOUSEEVENTF_MIDDLEUP; - SendInput(1, &in, sizeof(in)); - } + for (int id = 1; id <= 5; ++id) g_swallowedDown[id].store(false); } -// Keyboard analogue of ReleaseSwallowedButtons: synthesize a KEYUP for any bound key whose DOWN we +// Keys differ from buttons: synthesize a KEYUP for any bound key whose DOWN we // swallowed but whose UP we never passed through, so teardown mid-press can't leave any consumer // believing the key is held. A lone keyup with no matching down is harmless (apps ignore it). static void ReleaseSwallowedKeys() { @@ -587,7 +595,7 @@ void InputRouter::stop() { } else if (g_mouseHook) { // hookless/no-thread paths: unhook directly UnhookWindowsHookEx(g_mouseHook); g_mouseHook = nullptr; } - ReleaseSwallowedButtons(); // never leave a swallowed side-button stranded as held + ReleaseSwallowedButtons(); // drop our swallow records; no synthetic button-up (#301) ReleaseSwallowedKeys(); // ...nor a swallowed keyboard bind for (auto& d : g_commitDown) d.store(false, std::memory_order_relaxed); // clear inspect click latches hookActive_.store(false); diff --git a/src/input_router.h b/src/input_router.h index 5ab4549..9e2fcc5 100644 --- a/src/input_router.h +++ b/src/input_router.h @@ -125,6 +125,9 @@ class InputRouter { // LL hook cannot block raw input, which is what games read), so the hook buys nothing there // while costing the game its pacing. Idempotent: only posts to the hook thread on a change. void setKeyboardHookWanted(bool want); + // False while the foreground app is on noSwallowApps (or a game suspended the keyboard hook): + // click and wheel binds then pass through to it too, like the keys ("the app also receives it"). + bool keyboardHookWanted() const { return kbHookWanted_.load(std::memory_order_relaxed); } // Count of successful re-installs this session (diagnostics / tests). unsigned kbHookReinstalls() const { return kbHookReinstalls_.load(std::memory_order_relaxed); } // Magnify model only: make the keyboard hook skip INJECTED events entirely. The magnify model diff --git a/src/keybind_rules.h b/src/keybind_rules.h index 81ee248..bcddef9 100644 --- a/src/keybind_rules.h +++ b/src/keybind_rules.h @@ -26,10 +26,10 @@ inline bool IsModifierVk(int vk) { return vk == 0x10 || vk == 0x11 || vk == 0x12 || (vk >= 0xA0 && vk <= 0xA5) || vk == 0x5B || vk == 0x5C; } // Keys that type a character: letters, digits, Space and the OEM punctuation keys (incl. <> on -// ISO keyboards, VK_OEM_102). +// ISO keyboards, VK_OEM_102; the Brazilian ABNT keys 0xC1/0xC2 and VK_OEM_AX 0xE1). inline bool IsTypingVk(int vk) { return (vk >= 0x30 && vk <= 0x39) || (vk >= 0x41 && vk <= 0x5A) || vk == 0x20 || - (vk >= 0xBA && vk <= 0xC0) || (vk >= 0xDB && vk <= 0xDF) || vk == 0xE2; + (vk >= 0xBA && vk <= 0xC2) || (vk >= 0xDB && vk <= 0xDF) || vk == 0xE1 || vk == 0xE2; } // Keys that may be bound with no modifier (owner, 2026-09-30): navigation incl. arrows and Delete, // F1-F24, Pause, ScrollLock, the numpad. @@ -57,17 +57,19 @@ inline BindVerdict CheckKeyBind(int vk, int mods) { return BindVerdict::SystemReserved; if (ctrl && !alt && !win && vk == 0x1B) return BindVerdict::SystemReserved; // Ctrl+Esc, Ctrl+Shift+Esc if (ctrl && alt && (vk == 0x2E || vk == 0x6E)) return BindVerdict::SystemReserved; // Ctrl+Alt+Delete + if (ctrl && alt && !win && vk == 0x09) return BindVerdict::SystemReserved; // Ctrl+Alt+Tab (void)shift; // Windows-reserved Win combos (the shell takes nearly every one of these). if (win && ((vk >= 0x30 && vk <= 0x39) || (vk >= 0x41 && vk <= 0x5A) || vk == 0x09 || vk == 0x20 || (vk >= 0x25 && vk <= 0x28) || vk == 0xBB || vk == 0xBD || vk == 0xBC || vk == 0xBE || - vk == 0x6B || vk == 0x6D || vk == 0x13 || vk == 0x2C || vk == 0x1B || vk == 0x0D)) + vk == 0x6B || vk == 0x6D || vk == 0x13 || vk == 0x2C || vk == 0x1B || vk == 0x0D || + vk == 0x24 /*Win+Home: minimise others*/ || vk == 0x70 /*Win+F1: help*/)) return BindVerdict::WindowsReserved; return BindVerdict::Ok; } -// The wheel, and left/right/middle click, as zoom binds: a modifier is mandatory, and neither Ctrl -// alone nor Shift alone (browser zoom / horizontal scroll; multi-select / range select). +// The wheel, and left/right/middle click, as zoom binds: a modifier is mandatory, never Shift alone +// (horizontal scroll / range select), and for clicks never Ctrl alone (multi-select). // Ctrl+wheel is allowed (owner decision 2026-09-30, #295): Wind swallows the notch, so it zooms // the screen instead of the browser or app. Shift+wheel is horizontal scroll, which people use. inline BindVerdict CheckWheelBind(int mods) { diff --git a/src/version.h b/src/version.h index 6463fba..8f21889 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 14 -#define WIND_VER_PATCH 0 +#define WIND_VER_PATCH 1 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.14.0" +#define WIND_VERSION_STR "0.14.1" diff --git a/tests/fixtures/keybind_cases.txt b/tests/fixtures/keybind_cases.txt index ec02d4e..e42ecba 100644 --- a/tests/fixtures/keybind_cases.txt +++ b/tests/fixtures/keybind_cases.txt @@ -52,7 +52,6 @@ key 21 3 ok key 41 1 ok key 43 1 ok key 21 8 ok -key 70 8 ok key 22 9 ok key 41 5 ok key 21 4 ok @@ -122,3 +121,28 @@ click 3 3 ok click 4 2 ok click 5 8 ok click 9 3 never + +# added for #301: gaps the #285 review found +key 24 8 windows +key 70 8 windows +key 24 1 ok +key 70 1 ok +key 9 3 system +key 9 11 windows +key C1 0 notalone +key C1 4 shifttypes +key C2 3 altgr +key E1 0 notalone +key E1 1 ok +key C3 0 notalone +key 1B 3 ok +key 73 3 ok +wheel 9 ok +wheel 6 ok +wheel 15 ok +click 3 9 ok +click 4 12 ok +click 5 4 shiftalone +click 5 0 needsmod +click 2 1 ok +click 6 3 never diff --git a/ui/src/lib/keybindRules.js b/ui/src/lib/keybindRules.js index 44cbb9c..719f314 100644 --- a/ui/src/lib/keybindRules.js +++ b/ui/src/lib/keybindRules.js @@ -5,7 +5,7 @@ export const MOD = { ctrl: 1, alt: 2, shift: 4, win: 8 }; const isModifierVk = vk => vk === 0x10 || vk === 0x11 || vk === 0x12 || (vk >= 0xA0 && vk <= 0xA5) || vk === 0x5B || vk === 0x5C; const isTypingVk = vk => (vk >= 0x30 && vk <= 0x39) || (vk >= 0x41 && vk <= 0x5A) || vk === 0x20 || - (vk >= 0xBA && vk <= 0xC0) || (vk >= 0xDB && vk <= 0xDF) || vk === 0xE2; + (vk >= 0xBA && vk <= 0xC2) || (vk >= 0xDB && vk <= 0xDF) || vk === 0xE1 || vk === 0xE2; const allowedAlone = vk => (vk >= 0x21 && vk <= 0x28) || vk === 0x2D || vk === 0x2E || (vk >= 0x70 && vk <= 0x87) || vk === 0x13 || vk === 0x91 || (vk >= 0x60 && vk <= 0x6F); @@ -22,8 +22,9 @@ export function checkKeyBind(vk, mods) { if (alt && !ctrl && !win && [0x73, 0x09, 0x1B, 0x20].includes(vk)) return 'system'; if (ctrl && !alt && !win && vk === 0x1B) return 'system'; if (ctrl && alt && (vk === 0x2E || vk === 0x6E)) return 'system'; + if (ctrl && alt && !win && vk === 0x09) return 'system'; if (win && ((vk >= 0x30 && vk <= 0x39) || (vk >= 0x41 && vk <= 0x5A) || vk === 0x09 || vk === 0x20 || - (vk >= 0x25 && vk <= 0x28) || [0xBB, 0xBD, 0xBC, 0xBE, 0x6B, 0x6D, 0x13, 0x2C, 0x1B, 0x0D].includes(vk))) + (vk >= 0x25 && vk <= 0x28) || [0xBB, 0xBD, 0xBC, 0xBE, 0x6B, 0x6D, 0x13, 0x2C, 0x1B, 0x0D, 0x24, 0x70].includes(vk))) return 'windows'; return 'ok'; }