Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 31 additions & 23 deletions src/input_router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ static std::atomic<bool> g_swallowedDown[6] = {}; // index = button id 1..5 (#
static std::atomic<int> g_btnDir[6] = {};
static std::atomic<int> 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<bool> 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.
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -234,6 +243,9 @@ static LRESULT CALLBACK KbProc(int code, WPARAM wParam, LPARAM lParam) {
int vk = static_cast<int>(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());
Expand Down Expand Up @@ -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;
Expand All @@ -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
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/input_router.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions src/keybind_rules.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions src/version.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
26 changes: 25 additions & 1 deletion tests/fixtures/keybind_cases.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
5 changes: 3 additions & 2 deletions ui/src/lib/keybindRules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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';
}
Expand Down
Loading