diff --git a/CLAUDE.md b/CLAUDE.md index 26f203b..7fcb9b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -225,10 +225,19 @@ restartWind), `dirty`, `openIni`, `exportDiagnostics`, `pickExe`, `mpoState`, `s never appears in `GetAsyncKeyState`, so the keyboard hook is the AUTHORITY for bound-key down-state (`keyPressed()`); `main.cpp` reads it when `kbHookActive()`, else falls back to polling (install failure / `WIND_NOHOOK`). hide-cursor + hotkey-mode quick-zoom are swallowed by `RegisterHotKey` - instead, not this hook. SAFETY: `IsForbiddenBindVk` (pure, in `config.cpp`) blocks binding keys - that would be catastrophic to lose system-wide - left/right click (1/2), Backspace (8), Win - (0x5B/0x5C) - enforced in three places: the hook never swallows them, `ParseConfig` sanitizes them - out of the ini, and the config UI's keybind capture refuses them. Down/up swallows are balanced + instead, not this hook. SAFETY (#285): ONE rule set for every bind, `src/keybind_rules.h` + (`CheckKeyBind`/`CheckWheelBind`/`CheckClickBind`), mirrored in `ui/src/lib/keybindRules.js`; both + are tested against `tests/fixtures/keybind_cases.txt`, so change the rules in BOTH or the tests + fail. `ParseConfig` reads any unsafe bind as unbound, the UI refuses it with a reason, and the hook + still never swallows `IsForbiddenBindVk` keys. AltGr sends Ctrl+Alt, so Ctrl+Alt + a typing key is + refused (the owner types on a Norwegian layout). Button binds: 1/2 side, 3/4/5 left/right/middle + (these need modifiers, never Ctrl or Shift alone, like the wheel); the most specific matching slot + wins. SWALLOWING WITH ALT OR WIN HELD INJECTS ONE MASK KEY (VK 0xE8): otherwise Windows sees the + modifier tapped alone (Start opens, the app's menu bar activates; Alt measured both ways, Win fixed-case only). Wind's own + injections carry `kWindInjectTag` in dwExtraInfo and are skipped by the bind matcher; other + injectors count as real input. The quick-zoom modifier only turns binds that LACK it into taps. A KEY bind is swallowed only when a + bind on that key has all its modifiers held (`keyBindMatches`), decided once per press; the old + VK-only test ate a plain F1 system-wide for a Ctrl+F1 bind. Down/up swallows are balanced (only swallow an UP whose DOWN we swallowed) and released on teardown so a key is never stranded. `cursorLockVk` (Inspect mode) is VK-only (no mods), swallowed like `recenterVk`. Inspect mode is a FREEZE-cursor + free-look reticle toggle (driven entirely in `main.cpp` RunTick, diff --git a/README.md b/README.md index c2b38ab..c909b48 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,14 @@ do not apply there: `sharpness`, `hdrTonemap`, `bilinear`, `outline*`, `brightne ## Controls Zoom binds ship **unbound** - the first-launch guided setup captures your choice (mouse side-buttons and/or keyboard keys, with optional alternates). Everything is rebindable in -Settings; bound keys are swallowed so they never double-fire into the focused app. +Settings; bound keys are swallowed so they never double-fire into the focused app. A bind can be +a key, a key combination (Ctrl, Alt, Shift, Win), a mouse side-button, or a left/right/middle click +with modifiers. Binds that would break normal use are refused with the reason: typing keys alone, +Shift or AltGr (Ctrl+Alt) plus a typing key, and combos Windows reserves (Alt+F4, Win+L, ...). - Hold your **zoom-in** bind - zoom in (smooth ramp). Hold **zoom-out** - zoom back. +- **Scroll-wheel zoom** (optional): hold the modifiers you chose (for example Alt, or Ctrl+Alt; + never Ctrl or Shift alone) and turn the wheel - up zooms in, down zooms out. - Release - zoom stays at the current level. - **Quick zoom** (default Ctrl + a zoom key, or a dedicated hotkey) - toggle between 1x and your remembered level. @@ -160,8 +165,11 @@ closes itself if the magnifier exits. Every ini key below keeps working even whe Settings row. Profiles (tray -> Profiles, or the Settings titlebar) snapshot the whole file per activity. -- `zoomInButton`/`zoomOutButton` (mouse side-buttons) and `zoomInVk`/`zoomOutVk` (keyboard) - +- `zoomInButton`/`zoomOutButton` (1/2 mouse side-buttons, 3/4/5 left/right/middle click with + `zoomInButtonMods` etc.) and `zoomInVk`/`zoomOutVk` + `zoomInMods`/`zoomOutMods` (keyboard) - hold to zoom; all ship unbound until the guided setup. Alternates: `*2` variants. +- `zoomWheelMods` (0 = off) - scroll-wheel zoom. A notch zooms as far as holding the bind does in + 0.1 s, so `zoomInSpeed`/`zoomOutSpeed` set its speed too. - `maxLevel`, `zoomInSpeed`/`zoomOutSpeed`, `smoothZoom*` - zoom range and feel. - `cursorSensitivity`, `cursorSmoothing` - pan speed and inertia. - `bilinear`, `sharpness`, `cursorConstantSize` (default 0: the cursor grows with the zoom), diff --git a/docs/superpowers/plans/2026-09-30-wheel-zoom-and-safe-keybinds.md b/docs/superpowers/plans/2026-09-30-wheel-zoom-and-safe-keybinds.md new file mode 100644 index 0000000..5947020 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-wheel-zoom-and-safe-keybinds.md @@ -0,0 +1,29 @@ +# Scroll-wheel zoom and safe keybinds: implementation plan + +**Spec:** `docs/superpowers/specs/2026-09-30-wheel-zoom-and-safe-keybinds-design.md`. +Branch `feat/285-wheel-zoom` (worktree `Wind-wheel`). + +## Global constraints +- No em-dashes. Pure headers do not include ``. +- The hook stays cheap: no allocation or blocking work in the mouse/keyboard hook callbacks. +- Swallows stay balanced (only swallow an up whose down was swallowed); wheel notches have no up. + +## Tasks +1. **Rules** - `src/keybind_rules.h` (`CheckKeyBind`, `CheckWheelBind`, `CheckClickBind`, reason codes) and + `tests/fixtures/keybind_cases.txt` + `tests/test_keybind_rules.cpp`. `ParseConfig` sanitises every + bind with them (replaces the bare `IsForbiddenBindVk` sanitising; the hook keeps its own + never-swallow check). Commit `feat(keybinds): one safety rule set for every bind (#285)`. +2. **Mask keystroke** - input router: when a swallowed key-down belongs to a combo with Alt or Win, + inject VK 0xE8 down/up once. Test the decision as a pure function. Commit. +3. **Clicks** - config `...ButtonMods` per zoom slot, button values 3-5; the mouse hook swallows a + matching click down/up as a balanced pair and reports it held; RunTick's inHeld/outHeld include it. + Pure matcher tested (mods subset, balanced up). Commit. +4. **Wheel** - config `zoomWheelMods`, `zoomWheelStepPct`; `WheelAccum` (pure: 120-unit accumulation) + and `ZoomController::stepTarget(n, step)` + target glide in `tick` (pure, tested); mouse hook swallows + matching notches and queues whole steps to the tick; RunTick feeds them to the controller. Commit. +5. **UI** - `ui/src/lib/keybindRules.js` (+ tests against the shared case list), `KeybindCapture` + refuses with a reason (visible + live region), a wheel-capture row and a step slider in the Keybinds + section. Playwright tests per spec section 7. Commit. +6. **Verify + ship** - unit + UI tests, build, deploy, owner's-PC checks per spec section 7 (SendInput), + docs (CLAUDE.md input-swallowing gotcha: the new rule set and the mask keystroke; README feature + line), version 0.12.x minor bump, review workflow, PR, owner says merge. diff --git a/docs/superpowers/specs/2026-09-30-wheel-zoom-and-safe-keybinds-design.md b/docs/superpowers/specs/2026-09-30-wheel-zoom-and-safe-keybinds-design.md new file mode 100644 index 0000000..73b2db6 --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-wheel-zoom-and-safe-keybinds-design.md @@ -0,0 +1,114 @@ +# Scroll-wheel zoom and safe keybinds (issue #285) + +Date: 2026-09-30. Owner: Max. Status: awaiting approval (spec + plan together). + +## 1. Goal + +1. Zoom with the mouse wheel while a modifier combo is held (for example Alt+scroll or Ctrl+Alt+scroll). +2. Make the keybind setter safe and complete for every bind row: single keys, key combos with + any mix of Ctrl/Alt/Shift/Win, the wheel with modifiers, mouse side buttons, and left/right/middle + click with modifiers. Anything that + would break normal use of the PC is refused with a spoken and visible reason. + +## 2. Owner decisions (2026-09-30) + +- **Keys alone (no modifier) allowed:** PageUp, PageDown, Home, End, Insert, Delete, the four + arrows, F1-F24, Pause, ScrollLock, numpad keys. Everything else alone is refused: letters, digits, + Space, Enter, Tab, Esc, Backspace, punctuation, CapsLock, PrintScreen, the Apps key, NumLock, + and a bare modifier or Windows key. +- **Combos refused: system-critical only.** App shortcuts (Ctrl+C and so on) stay allowed. +- **Wheel:** needs at least one modifier, and never Ctrl alone (browser zoom) or Shift alone + (horizontal scroll). Unbound by default. +- **Left/right/middle click** (added the same day): bindable as a hold-to-zoom bind, with the same + modifier rule as the wheel. Never alone; never Ctrl alone or Shift alone (Ctrl/Shift+click select + in every app). + +## 3. The rules (one pure function, mirrored in the UI) + +`src/keybind_rules.h` (pure, doctested) and `ui/src/lib/keybindRules.js` (Playwright/unit-tested), +both checked against one shared case list `tests/fixtures/keybind_cases.txt` so they cannot drift. + +`KeyBindVerdict CheckKeyBind(vk, mods)` returns OK or a reason: + +1. Never bindable at all: left/right click, Backspace (today's `IsForbiddenBindVk`). +2. The main key cannot be a modifier or a Windows key (Ctrl/Alt/Shift/Win alone). +3. No modifier: only the allowed-alone list in section 2. +4. Shift is the only modifier and the key types a character (letter, digit, punctuation, Space): + refused, it would swallow capital letters and symbols. +5. **AltGr (added for this owner's Norwegian keyboard):** Ctrl+Alt (with or without Shift, no Win) + plus a key that types a character is refused, because AltGr sends Ctrl+Alt and those combos + type @ { } [ ] $ and so on. +6. System-critical combos refused: Alt+F4, Alt+Tab, Alt+Shift+Tab, Alt+Esc, Alt+Space, Ctrl+Esc, + Ctrl+Shift+Esc, Ctrl+Alt+Delete, and Windows-reserved Win combos: Win + any letter, digit, + Tab, Space, arrow, Plus/Minus (native Magnifier), comma, period, Pause, PrintScreen. Win with + PageUp/PageDown/Home/End/Insert/Delete/F-keys/numpad stays allowed. +7. Everything else is OK. + +`CheckWheelBind(mods)` and `CheckClickBind(button, mods)` (left/right/middle): at least one modifier; +not exactly Ctrl; not exactly Shift. Side buttons (4/5) may be bound alone, as today, or with modifiers. + +**Bind slots:** each zoom slot's button key (`zoomInButton`, `zoomOutButton` and the `2` slots) gains +values 3 = left, 4 = right, 5 = middle (1/2 stay the side buttons), plus a new `...ButtonMods` mask per +slot (0 = none; required for 3-5). A button bind is held while the button is down and all its +modifiers are held; its down and up are swallowed as a pair (balanced: an up is swallowed only if its +down was, even if the modifiers were released first, so the app never sees a lone up). + +`ParseConfig` applies the same rules to every stored bind: an unsafe bind in an ini (hand-edited or +from an older version) is read as unbound and logged, as `IsForbiddenBindVk` does today. + +## 4. Swallowing combos with Alt or Win + +Wind swallows the main key (or wheel notch, or click) of a bind but not the held modifier. Windows then sees +Alt or Win pressed and released on its own: releasing Win opens Start, releasing Alt moves focus +to the app's menu bar. When Wind swallows an event of a combo that includes Alt or Win, it injects +one masking keystroke (VK 0xE8, unassigned; the standard technique) so the modifier's release is +not a lone tap. Injected with `LLKHF_INJECTED`; Wind's own hook passes it through. + +## 5. Wheel zoom + +- Config: `zoomWheelMods` (modifier mask, 0 = off). AMENDED 2026-09-30 (owner): no separate step + setting. A notch zooms as far as holding the bind does in 0.1 s at the same speed slider + (`zoomInSpeed` up, `zoomOutSpeed` down), so ~10 notches a second feels like holding and faster or + slower scrolling scales from there (x1.19 per notch at speed 1.0, x1.60 at 2.7). The native + Magnifier model passes each notch on as one Magnifier notch (its ZoomIncrement sets the size). +- The mouse hook (`WH_MOUSE_LL`) sees `WM_MOUSEWHEEL`. When the held modifiers include every bit of + `zoomWheelMods` (extra modifiers allowed, like the key combos), the notch is swallowed so the app + under the pointer does not scroll, and counted (high-resolution wheels send partial notches: + deltas accumulate to 120 per step). +- Wheel up = zoom in, down = zoom out. Each step moves a TARGET level by x(1 + step) or /(1 + step), + clamped to [1, maxLevel]; the zoom controller glides to the target with the existing ease-out time + constant, so fast scrolling feels continuous rather than notchy. Holding a zoom key or button + takes over at once (the target is dropped). Zooming out to 1.0 ends the session like any zoom-out. +- Both engines; respects maxLevel and the MPO walls (the level pipeline is unchanged downstream). +- Settings: a "Zoom with the scroll wheel" row whose capture records the modifiers held when the + wheel is turned, plus a "Wheel step" slider. + +## 6. Keybind setter (UI) + +- Every keybind row uses `CheckKeyBind`/`CheckWheelBind`. A refused press keeps the row listening and + says why, visibly and to screen readers ("Alt+F4 is reserved by Windows", "A alone would stop you + typing A"). +- Capture works for keys, combos with Win (Windows may keep some Win combos to itself; the setter is + tested against the ones it can receive, and the reserved ones are refused anyway), mouse side + buttons and left/right/middle click with modifiers (rows that allow buttons), and the wheel (the + wheel row only). Right-click keeps clearing a row when pressed WITHOUT modifiers; a right-click + with modifiers is a capture. +- The existing live-apply, Escape-to-cancel, Tab-leaves and right-click-clears behaviour stays. + +## 7. Testing and verification + +- Doctests: every rule in section 3 through the shared case list; wheel delta accumulation and + target stepping; `ParseConfig` sanitising. +- Playwright: the same case list through `keybindRules.js`; the setter refuses and explains, accepts + PageUp alone, Ctrl+F1, Ctrl+Alt+PageUp, Win+PageUp; the wheel row captures Alt+wheel and refuses + Ctrl+wheel and Shift+wheel; a zoom row captures Ctrl+Alt+left click and refuses a bare left click, + Ctrl+click and Shift+click. +- On the owner's PC, by Claude before the owner tests: real input through SendInput: bind + Ctrl+Alt+wheel, verify zoom in/out, that the app under the pointer does not scroll, that + Ctrl+wheel still zooms a browser page; Ctrl+Alt+left-click held zooms in and the click never + reaches the app, while a plain click still works; Win+PageUp bound, press it and verify Start does not open; + Alt+PageUp bound, verify the focused app's menu bar is not activated. + +## 8. Delivery + +Branch `feat/285-wheel-zoom`, one PR, minor version bump (feature). diff --git a/src/config.cpp b/src/config.cpp index 4af673b..e9f7a5f 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -1,4 +1,5 @@ #include "config.h" +#include "keybind_rules.h" // one safety rule set for every bind (#285) #include #include #include @@ -145,6 +146,11 @@ Config ParseConfig(const std::string& text) { else if (key == "zoomOutMods") c.zoomOutMods = std::stoi(val); else if (key == "zoomInMods2") c.zoomInMods2 = std::stoi(val); else if (key == "zoomOutMods2") c.zoomOutMods2 = std::stoi(val); + else if (key == "zoomInButtonMods") c.zoomInButtonMods = std::stoi(val); + else if (key == "zoomOutButtonMods") c.zoomOutButtonMods = std::stoi(val); + else if (key == "zoomInButton2Mods") c.zoomInButton2Mods = std::stoi(val); + else if (key == "zoomOutButton2Mods") c.zoomOutButton2Mods = std::stoi(val); + else if (key == "zoomWheelMods") c.zoomWheelMods = std::stoi(val); else if (key == "maxLevel") c.maxLevel = std::stod(val); else if (key == "zoomInSpeed") c.zoomInSpeed = std::stod(val); else if (key == "zoomOutSpeed") c.zoomOutSpeed = std::stod(val); @@ -307,13 +313,25 @@ Config ParseConfig(const std::string& text) { // Reject keybinds to keys Wind must never swallow (see IsForbiddenBindVk). A bound key is // eaten system-wide, so binding e.g. Backspace or the Windows key would make it unusable // everywhere; treat a forbidden bind as unbound regardless of how it got into the ini. - auto sanitizeVk = [](int& vk) { if (IsForbiddenBindVk(vk)) vk = 0; }; - sanitizeVk(c.zoomInVk); sanitizeVk(c.zoomInVk2); - sanitizeVk(c.zoomOutVk); sanitizeVk(c.zoomOutVk2); - sanitizeVk(c.recenterVk); - sanitizeVk(c.cursorLockVk); - sanitizeVk(c.hideCursorVk); - sanitizeVk(c.quickZoomVk); + // Since #285 every bind goes through the one shared rule set (src/keybind_rules.h), which also + // refuses typing keys alone, Shift/AltGr + a typing key and system-reserved combos: an unsafe bind + // in an ini (hand-edited, or from an older version) reads as unbound. + auto sanitizeKey = [](int& vk, int* mods) { + const int m = mods ? *mods : 0; + if (CheckKeyBind(vk, m) != BindVerdict::Ok) { vk = 0; if (mods) *mods = 0; } + }; + sanitizeKey(c.zoomInVk, &c.zoomInMods); sanitizeKey(c.zoomInVk2, &c.zoomInMods2); + sanitizeKey(c.zoomOutVk, &c.zoomOutMods); sanitizeKey(c.zoomOutVk2, &c.zoomOutMods2); + sanitizeKey(c.recenterVk, nullptr); + sanitizeKey(c.cursorLockVk, nullptr); + sanitizeKey(c.hideCursorVk, &c.hideCursorMods); + sanitizeKey(c.quickZoomVk, &c.quickZoomMods); + auto sanitizeButton = [](int& b, int& mods) { + if (CheckClickBind(b, mods) != BindVerdict::Ok) { b = 0; mods = 0; } + }; + sanitizeButton(c.zoomInButton, c.zoomInButtonMods); sanitizeButton(c.zoomInButton2, c.zoomInButton2Mods); + sanitizeButton(c.zoomOutButton, c.zoomOutButtonMods); sanitizeButton(c.zoomOutButton2, c.zoomOutButton2Mods); + if (c.zoomWheelMods != 0 && CheckWheelBind(c.zoomWheelMods) != BindVerdict::Ok) c.zoomWheelMods = 0; return c; } } @@ -351,8 +369,9 @@ std::string DefaultIniText() { "zoomInButton=0\nzoomOutButton=0\n" "; Keyboard hold-to-zoom (Virtual-Key codes, decimal; 0=unbound). Works without a\n" "; side-button mouse. The bound key is SWALLOWED (it won't reach the focused app), so\n" - "; it can't double-fire. Left/right click, Backspace, and the Windows keys can't be\n" - "; bound (they'd be lost system-wide). e.g. 33=PageUp 34=PageDown 107/109=NumPad +/- 112=F1.\n" + "; it can't double-fire. Typing keys, Backspace, a bare modifier, system combos (Alt+F4,\n" + "; Alt+Tab...) and Windows-reserved Win combos can't be bound (they'd be lost system-wide;\n" + "; src/keybind_rules.h). e.g. 33=PageUp 34=PageDown 107/109=NumPad +/- 112=F1.\n" "zoomInVk=0\nzoomOutVk=0\n" "; Modifier mask required with each zoom key (bit 1=Ctrl, 2=Alt, 4=Shift, 8=Win;\n" "; 0=no modifier). e.g. 3 = Ctrl+Alt. Extra modifiers held don't disqualify.\n" @@ -364,6 +383,12 @@ std::string DefaultIniText() { "; when a primary slot holds a key.\n" "zoomInButton2=0\nzoomOutButton2=0\n" "zoomInVk2=0\nzoomOutVk2=0\nzoomInMods2=0\nzoomOutMods2=0\n" + "; Button binds may also be 3=left, 4=right, 5=middle click, which need a modifier mask\n" + "; (zoomInButtonMods etc., same bits; never Ctrl or Shift alone). Optional for 1/2.\n" + "zoomInButtonMods=0\nzoomOutButtonMods=0\nzoomInButton2Mods=0\nzoomOutButton2Mods=0\n" + "; zoomWheelMods: modifiers that make the scroll wheel zoom (0=off; e.g. 2=Alt, 3=Ctrl+Alt;\n" + "; never Ctrl or Shift alone). Speed: zoomInSpeed (up), zoomOutSpeed (down).\n" + "zoomWheelMods=0\n" "; hideCursorVk/hideCursorMods: hotkey to toggle the magnified cursor on/off while\n" "; zoomed (does not reset zoom). VK + mods, 0=unbound.\n" "hideCursorVk=0\nhideCursorMods=0\n" diff --git a/src/config.h b/src/config.h index b341794..4b451ca 100644 --- a/src/config.h +++ b/src/config.h @@ -2,7 +2,8 @@ #include namespace wind { struct Config { - int zoomInButton = 0; // 1 = XBUTTON1 (back), 2 = XBUTTON2 (forward); 0 = unbound + int zoomInButton = 0; // 1 = XBUTTON1 (back), 2 = XBUTTON2 (forward), 3 = left, 4 = right, + // 5 = middle click (3-5 need a modifier, #285); 0 = unbound int zoomOutButton = 0; // shipped unbound - onboarding captures the user's choice // Keyboard hold-to-zoom (Virtual-Key codes; 0 = unbound). Polled via GetAsyncKeyState and // OR-combined with the mouse side-buttons, so the app is usable without side-buttons. @@ -25,6 +26,15 @@ struct Config { int zoomOutMods = 0; int zoomInMods2 = 0; int zoomOutMods2 = 0; + // Modifier mask per BUTTON binding (same bits). Required for left/right/middle click (button + // 3/4/5, #285; never Ctrl or Shift alone), optional for the side buttons. 0 = none. + int zoomInButtonMods = 0; + int zoomOutButtonMods = 0; + int zoomInButton2Mods = 0; + int zoomOutButton2Mods = 0; + // Scroll-wheel zoom (#285): the modifiers that make the wheel zoom (0 = off; never Ctrl or Shift + // alone), and how much one notch zooms, in percent (5..100). + int zoomWheelMods = 0; int recenterVk = 0; // VK code; 0 = unbound. Tap to recenter the lens on the cursor. int cursorLockVk = 0; // VK code; 0 = unbound. Tap to toggle Inspect mode (cursor lock) // while zoomed. Swallowed system-wide like recenterVk (VK only, diff --git a/src/input_router.cpp b/src/input_router.cpp index 441ccdc..9ca8e5a 100644 --- a/src/input_router.cpp +++ b/src/input_router.cpp @@ -2,6 +2,7 @@ #include "mag_thread.h" // the hook thread owns the Magnification runtime (issue #206) #include "hook_transform.h" // ...and writes the transform inline from MouseProc (#206 stage 2) #include "config.h" // IsForbiddenBindVk (keyboard-bind safety blocklist) +#include "pointer_binds.h" // button/wheel bind matching, the mask keystroke (#285) #include "logging.h" // hook-watchdog events (issue #156) #include #include @@ -32,7 +33,32 @@ static bool g_hookOk = false; // result of SetWindowsHookExW, publi // previous binding can't cause a later UP to be wrongly swallowed. ATOMIC: touched by three // contexts - the hook thread (MouseProc), the tick thread (setButtons on hot-reload), and the // teardown caller (ReleaseSwallowedButtons via stop()) - so plain bools would be a data race. -static std::atomic g_swallowedDown[3] = {}; +static std::atomic g_swallowedDown[6] = {}; // index = button id 1..5 (#285: 3/4/5 = L/R/M) +// Which direction a pressed bound button is holding (1 in, 2 out, 0 none) and that bind's modifiers, +// per button id. The directions' held flags are derived from these, so two buttons on the same +// direction (a side button and Ctrl+Alt+click) can never release each other. +static std::atomic g_btnDir[6] = {}; +static std::atomic g_btnMods[6] = {}; +static WheelAccum g_wheelAcc; // hook thread only + +// 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. +static int HeldModsNow() { + int m = 0; + if (GetAsyncKeyState(VK_CONTROL) & 0x8000) m |= kModCtrl; + if (GetAsyncKeyState(VK_MENU) & 0x8000) m |= kModAlt; + if (GetAsyncKeyState(VK_SHIFT) & 0x8000) m |= kModShift; + if ((GetAsyncKeyState(VK_LWIN) & 0x8000) || (GetAsyncKeyState(VK_RWIN) & 0x8000)) m |= kModWin; + return m; +} +// 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() { + 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; + SendInput(2, in, sizeof(INPUT)); +} // Inspect-mode click routing: a real left/right press while Inspect is on is swallowed (it would land // at the frozen cursor, not where the crosshair is aiming); the tick fires a clean click at the look // point instead. g_commitDown[btn] remembers THAT button's swallowed DOWN so only its own matching UP is @@ -49,13 +75,44 @@ static int xbuttonIdFromHook(WPARAM wParam, LPARAM lParam) { return 0; } -// Shared by the WH_MOUSE_LL hook (below) and main's WM_INPUT path: map an XBUTTON id to held. -// A direction holds if the pressed button matches EITHER its primary or alternate binding. -void InputRouter::setButtonState(int xbuttonId, bool down) { - if (xbuttonId == inButtonId_.load(std::memory_order_relaxed) - || xbuttonId == inButtonId2_.load(std::memory_order_relaxed)) state_.inHeld.store(down); - if (xbuttonId == outButtonId_.load(std::memory_order_relaxed) - || xbuttonId == outButtonId2_.load(std::memory_order_relaxed)) state_.outHeld.store(down); +bool InputRouter::matchButton(int button, int heldMods, int& dir, int& mods) const { + const ButtonSlot slots[4] = { + { inButtonId_.load(std::memory_order_relaxed), inButtonMods_.load(std::memory_order_relaxed), 1 }, + { inButtonId2_.load(std::memory_order_relaxed), inButtonMods2_.load(std::memory_order_relaxed), 1 }, + { outButtonId_.load(std::memory_order_relaxed), outButtonMods_.load(std::memory_order_relaxed), 2 }, + { outButtonId2_.load(std::memory_order_relaxed), outButtonMods2_.load(std::memory_order_relaxed), 2 }, + }; + const int i = PickButtonSlot(slots, 4, button, heldMods); + if (i < 0) return false; + dir = slots[i].dir; mods = slots[i].mods; + return true; +} +// Recompute the per-direction held flags from the per-button records. +static void PublishButtonHeld(InputState& st) { + 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); + if (d == 1) { in = true; inM |= g_btnMods[b].load(std::memory_order_relaxed); } + if (d == 2) { out = true; outM |= g_btnMods[b].load(std::memory_order_relaxed); } + } + st.inHeldMods.store(inM, std::memory_order_relaxed); + st.outHeldMods.store(outM, std::memory_order_relaxed); + st.inHeld.store(in); + st.outHeld.store(out); +} +// 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. +void InputRouter::setButtonState(int buttonId, bool down) { + if (buttonId < 1 || buttonId > 5) return; + if (down) { + int dir = 0, mods = 0; + if (!matchButton(buttonId, HeldModsNow(), dir, mods)) return; + g_btnMods[buttonId].store(mods, std::memory_order_relaxed); + g_btnDir[buttonId].store(dir, std::memory_order_relaxed); + } else { + g_btnDir[buttonId].store(0, std::memory_order_relaxed); + } + PublishButtonHeld(state_); } bool InputRouter::isZoomButton(int xbuttonId) const { return xbuttonId == inButtonId_.load(std::memory_order_relaxed) @@ -64,16 +121,26 @@ bool InputRouter::isZoomButton(int xbuttonId) const { || xbuttonId == outButtonId2_.load(std::memory_order_relaxed); } void InputRouter::setButtons(int inButtonId, int inButtonId2, int outButtonId, int outButtonId2) { + setButtonBinds(inButtonId, 0, inButtonId2, 0, outButtonId, 0, outButtonId2, 0); +} +void InputRouter::setButtonBinds(int inButtonId, int inMods, int inButtonId2, int inMods2, + int outButtonId, int outMods, int outButtonId2, int outMods2) { + inButtonMods_.store(inMods, std::memory_order_relaxed); + inButtonMods2_.store(inMods2, std::memory_order_relaxed); + outButtonMods_.store(outMods, std::memory_order_relaxed); + outButtonMods2_.store(outMods2, std::memory_order_relaxed); inButtonId_.store(inButtonId, std::memory_order_relaxed); inButtonId2_.store(inButtonId2, std::memory_order_relaxed); outButtonId_.store(outButtonId, std::memory_order_relaxed); outButtonId2_.store(outButtonId2, std::memory_order_relaxed); // Clear any held state from the previous mapping (else a press of the OLD button that was in // progress would never get its UP event matched and inHeld/outHeld would stick true). - state_.inHeld.store(false); - state_.outHeld.store(false); + 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); } @@ -86,6 +153,22 @@ bool InputRouter::isBoundKey(int vk) const { || vk == kbRecenterVk_.load(std::memory_order_relaxed) || vk == kbCursorLockVk_.load(std::memory_order_relaxed); } +void InputRouter::setKeyMods(int zoomInMods, int zoomInMods2, int zoomOutMods, int zoomOutMods2) { + kbZoomInMods_.store(zoomInMods, std::memory_order_relaxed); + kbZoomInMods2_.store(zoomInMods2, std::memory_order_relaxed); + kbZoomOutMods_.store(zoomOutMods, std::memory_order_relaxed); + kbZoomOutMods2_.store(zoomOutMods2, std::memory_order_relaxed); +} +bool InputRouter::keyBindMatches(int vk, int heldMods) const { + if (!isBoundKey(vk)) return false; + auto slot = [&](const std::atomic& v, const std::atomic& m) { + return vk == v.load(std::memory_order_relaxed) && ModsSatisfied(m.load(std::memory_order_relaxed), heldMods); + }; + return slot(kbZoomInVk_, kbZoomInMods_) || slot(kbZoomInVk2_, kbZoomInMods2_) + || slot(kbZoomOutVk_, kbZoomOutMods_) || slot(kbZoomOutVk2_, kbZoomOutMods2_) + || vk == kbRecenterVk_.load(std::memory_order_relaxed) + || vk == kbCursorLockVk_.load(std::memory_order_relaxed); +} bool InputRouter::keyPressed(int vk) const { if (vk <= 0 || vk > 255) return false; return g_kbPressed[vk].load(std::memory_order_relaxed); @@ -109,7 +192,10 @@ void InputRouter::rawKeyUp(int vk) { g_kbSwallowedDown[vk].store(false, std::memory_order_relaxed); } void InputRouter::rawButtonUp(int xbuttonId) { - if (xbuttonId != 1 && xbuttonId != 2) return; + if (xbuttonId < 1 || xbuttonId > 5) return; + // Left/right/middle (3-5) only matter while one of them holds a zoom; every ordinary click + // passes straight through here. + if (xbuttonId >= 3 && g_btnDir[xbuttonId].load(std::memory_order_relaxed) == 0) return; // Same reordering guard as rawKeyUp: no auto-repeat exists for a side-button, so a stale raw // UP landing after the hook's next DOWN would silently end a zoom hold until re-pressed. if (hookActive() && @@ -120,7 +206,7 @@ void InputRouter::noteHookKeyDown(int vk) { if (vk > 0 && vk < 256) kbLastHookDownMs_[vk].store(GetTickCount64(), std::memory_order_relaxed); } void InputRouter::noteHookButtonDown(int xbuttonId) { - if (xbuttonId == 1 || xbuttonId == 2) + if (xbuttonId >= 1 && xbuttonId <= 5) btnLastHookDownMs_[xbuttonId].store(GetTickCount64(), std::memory_order_relaxed); } void InputRouter::setKeys(int zoomInVk, int zoomInVk2, int zoomOutVk, int zoomOutVk2, int recenterVk, @@ -153,13 +239,23 @@ static LRESULT CALLBACK KbProc(int code, WPARAM wParam, LPARAM lParam) { if ((down || up) && g_router->isBoundKey(vk)) { bool swallow = false; if (down) { - // Auto-repeat re-fires WM_KEYDOWN; storing true each time is idempotent. main reads - // this as the physical down-state and does its own rising-edge work for taps. - g_kbPressed[vk].store(true); + // Auto-repeat re-fires WM_KEYDOWN. main reads g_kbPressed as the physical down-state + // and does its own rising-edge work for taps. + const bool firstDown = !g_kbPressed[vk].exchange(true); g_router->noteHookKeyDown(vk); // recency guard for the raw UP safety net - if (g_router->swallowEnabled()) { - g_kbSwallowedDown[vk].store(true); - swallow = true; + if (firstDown) { + // Decide ONCE per press: swallow only if a bind on this key has all its modifiers + // held now (#285: Ctrl+F1 must not eat a plain F1). Auto-repeat then follows that + // decision, so a key the app already saw going down is never swallowed mid-press. + const int held = HeldModsNow(); + if (g_router->swallowEnabled() && g_router->keyBindMatches(vk, held)) { + g_kbSwallowedDown[vk].store(true); + // Alt or Win held: mask it so its release is not a lone tap (Start / menu bar). + if (NeedsMaskKey(held)) InjectMaskKey(); + swallow = true; + } + } else { + swallow = g_kbSwallowedDown[vk].load(); } } else { // up: swallow iff we swallowed its DOWN, so the system's down/up view stays balanced. g_kbPressed[vk].store(false); @@ -191,6 +287,55 @@ static LRESULT CALLBACK MouseProc(int code, WPARAM wParam, LPARAM lParam) { // Inspect-mode click-to-look-point. Swallow the real DOWN (it would land at the frozen cursor) // and signal the tick, which fires a clean absolute click at the crosshair. Swallow the matching // real UP too. Our own injected click carries LLMHF_INJECTED, so it skips this and passes through. + // Click zoom binds (#285): left/right/middle with modifiers. Checked before Inspect's click + // routing, so a bound Ctrl+Alt+click zooms even in Inspect. Wind's OWN injections (tagged: + // Inspect's clicks, the native-Magnifier notches) are never binds; other injectors are. + if ((unsigned long long)mi->dwExtraInfo != kWindInjectTag) { + int cb = 0; bool cbDown = false, cbUp = false; + switch (wParam) { + case WM_LBUTTONDOWN: cb = 3; cbDown = true; break; + case WM_LBUTTONUP: cb = 3; cbUp = true; break; + case WM_RBUTTONDOWN: cb = 4; cbDown = true; break; + case WM_RBUTTONUP: cb = 4; cbUp = true; break; + case WM_MBUTTONDOWN: cb = 5; cbDown = true; break; + case WM_MBUTTONUP: cb = 5; cbUp = true; break; + default: break; + } + if (cb && cbDown) { + const int held = HeldModsNow(); + int dir = 0, mods = 0; + if (g_router->matchButton(cb, held, dir, mods)) { + g_router->noteHookButtonDown(cb); // recency guard for the raw UP safety net + 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()) { + g_swallowedDown[cb].store(true); + if (NeedsMaskKey(held)) InjectMaskKey(); + return 1; + } + } + } else if (cb && cbUp) { + if (g_btnDir[cb].exchange(0, std::memory_order_relaxed) != 0) PublishButtonHeld(g_router->state()); + // Balanced: swallow an up iff we swallowed its down, even if the modifiers were + // released in between, so the app never sees a lone click-up. + if (g_swallowedDown[cb].exchange(false)) return 1; + } + // Scroll-wheel zoom: a notch with the bound modifiers held zooms instead of scrolling. + if (wParam == WM_MOUSEWHEEL) { + const int wm = g_router->wheelMods(); + if (wm != 0) { + const int held = HeldModsNow(); + 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; + } + g_wheelAcc.reset(); // a fraction never carries into an unrelated gesture + } + } + } if (!(mi->flags & LLMHF_INJECTED)) { int cDown = (wParam == WM_LBUTTONDOWN) ? 1 : (wParam == WM_RBUTTONDOWN) ? 2 : 0; int cUp = (wParam == WM_LBUTTONUP) ? 1 : (wParam == WM_RBUTTONUP) ? 2 : 0; @@ -225,11 +370,13 @@ static LRESULT CALLBACK MouseProc(int code, WPARAM wParam, LPARAM lParam) { g_router->setButtonState(id, down); bool swallow = false; if (down) { - // Swallow the DOWN only if it is a zoom button now; remember it so the matching UP - // is swallowed too (keeps the system's down/up view balanced). - if (g_router->swallowEnabled() && g_router->isZoomButton(id)) { + // Swallow the DOWN only if it matched a zoom bind now (modifiers included, #285); + // remember it so the matching UP is swallowed too (balanced down/up view). + if (g_router->swallowEnabled() && g_btnDir[id].load(std::memory_order_relaxed) != 0) { g_swallowedDown[id].store(true); swallow = true; + const int held = HeldModsNow(); + if (NeedsMaskKey(held)) InjectMaskKey(); } } else { // up: swallow iff we swallowed its DOWN. Never swallow an UP whose DOWN the // system already saw - that is exactly what left the button stuck-down. @@ -357,12 +504,12 @@ bool InputRouter::start(int inButtonId, int inButtonId2, int outButtonId, int ou // 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. static void ReleaseSwallowedButtons() { - for (int id = 1; id <= 2; ++id) { + for (int id = 1; id <= 5; ++id) { if (!g_swallowedDown[id].exchange(false)) continue; INPUT in{}; in.type = INPUT_MOUSE; - in.mi.dwFlags = MOUSEEVENTF_XUP; - in.mi.mouseData = (id == 1) ? XBUTTON1 : XBUTTON2; + 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)); } } diff --git a/src/input_router.h b/src/input_router.h index f498049..be1c3e6 100644 --- a/src/input_router.h +++ b/src/input_router.h @@ -6,8 +6,15 @@ namespace wind { struct InputState { std::atomic rawDx{0}; // summed since last drain std::atomic rawDy{0}; - std::atomic inHeld{false}; // zoom-in side button physically down + std::atomic inHeld{false}; // a zoom-in BUTTON bind is held (side button or click + mods) std::atomic outHeld{false}; + // The modifier masks of the button binds currently holding each direction (OR of all), so the + // tick can tell a bind that includes the quick-zoom modifier from one that does not (#285). + std::atomic inHeldMods{0}; + std::atomic outHeldMods{0}; + // Scroll-wheel zoom (#285): whole wheel steps the hook swallowed, + = in, - = out. The tick + // drains them (drainWheelSteps). + std::atomic wheelSteps{0}; // Inspect-mode click routing (tick <-> WH_MOUSE_LL hook). While Inspect is on the real cursor is // frozen elsewhere, so the hook swallows a real left/right click (it would land at the frozen point) // and hands the tick PER-BUTTON pending counts; the tick fires a clean click at the look point per @@ -61,7 +68,12 @@ class InputRouter { // Clears the per-key pressed/swallowed records so a remap mid-press can't strand a key. void setKeys(int zoomInVk, int zoomInVk2, int zoomOutVk, int zoomOutVk2, int recenterVk, int cursorLockVk); - // Whether vk is one of the configured (non-forbidden) keyboard binds: decides track+swallow. + // The zoom key binds' modifier masks (recenter and Inspect have none). The hook swallows a bound + // key only when one of its binds has every modifier held, so Ctrl+F1 leaves a plain F1 alone. + void setKeyMods(int zoomInMods, int zoomInMods2, int zoomOutMods, int zoomOutMods2); + // Whether a press of vk with these modifiers held is one of the binds (the hook's swallow test). + bool keyBindMatches(int vk, int heldMods) const; + // Whether vk is one of the configured (non-forbidden) keyboard binds: decides tracking. bool isBoundKey(int vk) const; // Physical down-state of a keyboard key, as tracked by the keyboard hook. This is the authority // when kbHookActive() (a swallowed key never shows in GetAsyncKeyState), so main reads it instead @@ -131,6 +143,15 @@ class InputRouter { // Atomic so the hook thread's reads in setButtonState/isZoomButton stay race-free, and the // held flags are cleared so a stale press of the previous button does not stick. void setButtons(int inButtonId, int inButtonId2, int outButtonId, int outButtonId2); + // #285: the same with a modifier mask per slot, and button ids 3/4/5 = left/right/middle click. + void setButtonBinds(int inButtonId, int inMods, int inButtonId2, int inMods2, + int outButtonId, int outMods, int outButtonId2, int outMods2); + // The slot a button press belongs to, given the modifiers held (hook thread). False = not a bind. + bool matchButton(int button, int heldMods, int& dir, int& mods) const; + // Scroll-wheel zoom: the modifier mask that makes the wheel zoom (0 = off). + void setWheelMods(int mods) { wheelMods_.store(mods, std::memory_order_relaxed); } + int wheelMods() const { return wheelMods_.load(std::memory_order_relaxed); } + int drainWheelSteps() { return state_.wheelSteps.exchange(0, std::memory_order_relaxed); } private: InputState state_; // Primary + alternate side-button per direction (1 = XBUTTON1, 2 = XBUTTON2, 0 = none); set in @@ -139,6 +160,11 @@ class InputRouter { std::atomic inButtonId2_{0}; std::atomic outButtonId_{1}; std::atomic outButtonId2_{0}; + std::atomic inButtonMods_{0}; + std::atomic inButtonMods2_{0}; + std::atomic outButtonMods_{0}; + std::atomic outButtonMods2_{0}; + std::atomic wheelMods_{0}; bool swallow_ = true; std::atomic hookActive_{false}; // true once the LL hook is installed (not WIND_NOHOOK) // Configured keyboard binds (VK codes; 0 = unbound). Atomic so the keyboard hook thread reads @@ -150,9 +176,10 @@ class InputRouter { std::atomic kbZoomOutVk2_{0}; std::atomic kbRecenterVk_{0}; std::atomic kbCursorLockVk_{0}; + std::atomic kbZoomInMods_{0}, kbZoomInMods2_{0}, kbZoomOutMods_{0}, kbZoomOutMods2_{0}; // Recency stamps for the raw-UP reordering guards (see rawKeyUp/rawButtonUp). std::atomic kbLastHookDownMs_[256]{}; - std::atomic btnLastHookDownMs_[3]{}; + std::atomic btnLastHookDownMs_[6]{}; // button ids 1..5 std::atomic kbHookActive_{false}; // true once the LL KEYBOARD hook is installed std::atomic kbHookReinstalls_{0}; // watchdog recoveries this session std::atomic kbHookWanted_{true}; // false while a fullscreen game is foreground diff --git a/src/keybind_rules.h b/src/keybind_rules.h new file mode 100644 index 0000000..97fcf49 --- /dev/null +++ b/src/keybind_rules.h @@ -0,0 +1,102 @@ +#pragma once +// One safety rule set for every keybind (issue #285; spec 2026-09-30-wheel-zoom-and-safe-keybinds). +// Pure; tests/test_keybind_rules.cpp checks it against tests/fixtures/keybind_cases.txt, and the +// Settings UI mirror (ui/src/lib/keybindRules.js) is checked against the same file, so the two can +// never drift. A bound key/button/notch is SWALLOWED system-wide while its modifiers are held, so a +// bind is refused when losing that input would break normal use of the PC. +namespace wind { + +enum ModBit { kModCtrl = 1, kModAlt = 2, kModShift = 4, kModWin = 8 }; + +enum class BindVerdict { + Ok = 0, + NeverBindable, // left/right click as a KEY, Backspace, mouse VKs + ModifierAsKey, // Ctrl/Alt/Shift/Win as the main key + NotAlone, // a key that may not be bound without a modifier + ShiftTypes, // Shift + a typing key: would swallow capitals and symbols + AltGrTypes, // Ctrl+Alt + a typing key: AltGr sends Ctrl+Alt (@ { } [ ] $ on Nordic layouts) + SystemReserved, // Alt+F4, Alt+Tab, Ctrl+Esc, Ctrl+Shift+Esc, Ctrl+Alt+Delete, ... + WindowsReserved, // Win + letter/digit/Tab/Space/arrow/...: taken by the shell + NeedsModifier, // wheel or left/right/middle click with no modifier + CtrlAlone, // wheel/click with Ctrl only: browser zoom / multi-select + ShiftAlone, // wheel/click with Shift only: horizontal scroll / range select +}; + +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). +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; +} +// Keys that may be bound with no modifier (owner, 2026-09-30): navigation incl. arrows and Delete, +// F1-F24, Pause, ScrollLock, the numpad. +inline bool AllowedAlone(int vk) { + return (vk >= 0x21 && vk <= 0x28) || // PageUp PageDown End Home Left Up Right Down + vk == 0x2D || vk == 0x2E || // Insert Delete + (vk >= 0x70 && vk <= 0x87) || // F1..F24 + vk == 0x13 || vk == 0x91 || // Pause ScrollLock + (vk >= 0x60 && vk <= 0x6F); // Num0..Num9, Num* Num+ separator Num- Num. Num/ +} + +inline BindVerdict CheckKeyBind(int vk, int mods) { + if (vk == 0) return BindVerdict::Ok; // unbound + if (vk <= 0 || vk > 255) return BindVerdict::NeverBindable; + if (vk == 0x01 || vk == 0x02 || vk == 0x04 || vk == 0x05 || vk == 0x06 || vk == 0x08) + return BindVerdict::NeverBindable; // mouse VKs, Backspace + if (IsModifierVk(vk)) return BindVerdict::ModifierAsKey; + mods &= (kModCtrl | kModAlt | kModShift | kModWin); + if (mods == 0) return AllowedAlone(vk) ? BindVerdict::Ok : BindVerdict::NotAlone; + const bool ctrl = mods & kModCtrl, alt = mods & kModAlt, shift = mods & kModShift, win = mods & kModWin; + if (mods == kModShift && IsTypingVk(vk)) return BindVerdict::ShiftTypes; + if (ctrl && alt && !win && IsTypingVk(vk)) return BindVerdict::AltGrTypes; + // System-critical combos. + if (alt && !ctrl && !win && (vk == 0x73 /*F4*/ || vk == 0x09 /*Tab*/ || vk == 0x1B /*Esc*/ || vk == 0x20 /*Space*/)) + 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 + (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)) + 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). +inline BindVerdict CheckWheelBind(int mods) { + mods &= (kModCtrl | kModAlt | kModShift | kModWin); + if (mods == 0) return BindVerdict::NeedsModifier; + if (mods == kModCtrl) return BindVerdict::CtrlAlone; + if (mods == kModShift) return BindVerdict::ShiftAlone; + return BindVerdict::Ok; +} +// Button ids as stored in the zoom slots: 1 = XBUTTON1 (back), 2 = XBUTTON2 (forward), 3 = left, +// 4 = right, 5 = middle. Side buttons may be bound alone (as always). +inline BindVerdict CheckClickBind(int button, int mods) { + if (button == 0 || button == 1 || button == 2) return BindVerdict::Ok; + if (button < 0 || button > 5) return BindVerdict::NeverBindable; + return CheckWheelBind(mods); +} + +inline const char* BindVerdictName(BindVerdict v) { + switch (v) { + case BindVerdict::Ok: return "ok"; + case BindVerdict::NeverBindable: return "never"; + case BindVerdict::ModifierAsKey: return "modifier"; + case BindVerdict::NotAlone: return "notalone"; + case BindVerdict::ShiftTypes: return "shifttypes"; + case BindVerdict::AltGrTypes: return "altgr"; + case BindVerdict::SystemReserved: return "system"; + case BindVerdict::WindowsReserved: return "windows"; + case BindVerdict::NeedsModifier: return "needsmod"; + case BindVerdict::CtrlAlone: return "ctrlalone"; + case BindVerdict::ShiftAlone: return "shiftalone"; + } + return "?"; +} +} // namespace wind diff --git a/src/magnifier_model.h b/src/magnifier_model.h index 2a4010d..b4be2ea 100644 --- a/src/magnifier_model.h +++ b/src/magnifier_model.h @@ -63,5 +63,8 @@ struct IMagnifierModel { // the rest of the zoom pipeline when selfDrivenZoom() is true. virtual bool selfDrivenZoom() const { return false; } virtual void nativeZoomTick(int dir, const Config& cfg) { (void)dir; (void)cfg; } + // Magnify model: pass scroll-wheel zoom notches straight on (#285), one Magnifier notch each + // (steps > 0 = in). Magnifier's own ZoomIncrement sets the size of a notch there. + virtual void nativeWheelNotches(int steps) { (void)steps; } }; } diff --git a/src/magnify_model.cpp b/src/magnify_model.cpp index ddb078a..c0ebe67 100644 --- a/src/magnify_model.cpp +++ b/src/magnify_model.cpp @@ -1,5 +1,6 @@ #include "magnify_model.h" #include "logging.h" +#include "pointer_binds.h" // kWindInjectTag (#285) #include #include #include @@ -66,6 +67,8 @@ void InjectZoomNotch(bool zoomIn) { in[1].type = INPUT_KEYBOARD; in[1].ki.wVk = VK_MENU; in[2].type = INPUT_MOUSE; in[2].mi.dwFlags = MOUSEEVENTF_WHEEL; in[2].mi.mouseData = (DWORD)(zoomIn ? WHEEL_DELTA : -WHEEL_DELTA); + for (auto& i : in) { if (i.type == INPUT_MOUSE) i.mi.dwExtraInfo = (ULONG_PTR)wind::kWindInjectTag; + else i.ki.dwExtraInfo = (ULONG_PTR)wind::kWindInjectTag; } // never a wheel bind (#285) in[3].type = INPUT_KEYBOARD; in[3].ki.wVk = VK_MENU; in[3].ki.dwFlags = KEYEVENTF_KEYUP; in[4].type = INPUT_KEYBOARD; in[4].ki.wVk = VK_CONTROL; in[4].ki.dwFlags = KEYEVENTF_KEYUP; SendInput(5, in, sizeof(INPUT)); @@ -123,6 +126,38 @@ void MagnifyModel::nativeZoomTick(int dir, const Config& cfg) { InjectZoomNotch(dir > 0); } +// The user is turning the wheel with the bind's modifiers held, which may already include Ctrl or +// Alt. Inject only the ones NOT held: releasing a physically held modifier would clear its logical +// state, and the user's next notch would then miss the bind and scroll the app instead. +static void InjectWheelNotches(int count, bool zoomIn) { + const bool ctrlHeld = (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; + const bool altHeld = (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; + INPUT in[4 + 8] = {}; + int n = 0; + auto key = [&](WORD vk, bool up) { + in[n].type = INPUT_KEYBOARD; in[n].ki.wVk = vk; in[n].ki.dwFlags = up ? KEYEVENTF_KEYUP : 0; + in[n].ki.dwExtraInfo = (ULONG_PTR)wind::kWindInjectTag; ++n; + }; + if (!ctrlHeld) key(VK_CONTROL, false); + if (!altHeld) key(VK_MENU, false); + for (int i = 0; i < count; ++i) { + in[n].type = INPUT_MOUSE; in[n].mi.dwFlags = MOUSEEVENTF_WHEEL; + in[n].mi.mouseData = (DWORD)(zoomIn ? WHEEL_DELTA : -WHEEL_DELTA); + in[n].mi.dwExtraInfo = (ULONG_PTR)wind::kWindInjectTag; ++n; + } + if (!altHeld) key(VK_MENU, true); + if (!ctrlHeld) key(VK_CONTROL, true); + SendInput((UINT)n, in, sizeof(INPUT)); +} + +void MagnifyModel::nativeWheelNotches(int steps) { + if (steps == 0 || !ready_) return; + if (!MagnifierWindowPresent()) { launchMagnifier(); return; } + int count = steps > 0 ? steps : -steps; + if (count > 8) count = 8; // a long free-spin: Magnifier eases each notch, more is just lag + InjectWheelNotches(count, steps > 0); +} + void MagnifyModel::shutdown() { if (!ready_) return; if (MagnifierWindowPresent()) InjectWinChord(VK_ESCAPE); diff --git a/src/magnify_model.h b/src/magnify_model.h index 665e555..d40a7ae 100644 --- a/src/magnify_model.h +++ b/src/magnify_model.h @@ -27,6 +27,7 @@ class MagnifyModel : public IMagnifierModel { bool supportsInspect() const override { return false; } bool selfDrivenZoom() const override { return true; } void nativeZoomTick(int dir, const Config& cfg) override; + void nativeWheelNotches(int steps) override; private: void launchMagnifier(); diff --git a/src/main.cpp b/src/main.cpp index 1853761..4b3e186 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -40,6 +40,7 @@ #include "tray_host.h" // WindTray.exe owns the icon and menu (#291) #include "gain_learner.h" // learned pointer ballistics: locked pan at TRUE desktop speed #include "tray_ipc.h" // the status block shared with WindTray.exe +#include "pointer_binds.h" // kWindInjectTag: tag our own injected clicks (#285) // txPace=2 composite signal (see config.h). One thread blocks in DwmFlush forever and pulses an // auto-reset event per real composite; the pacing loop waits on the event WITH A TIMEOUT, so a @@ -372,6 +373,8 @@ struct TickState { // present divisor (0 = present tick) double quickZoomStored = 0.0; // remembered quick-zoom level (0 = none yet); in-memory bool prevInHeld = false; // for rising-edge detection of the zoom-in channel + bool prevInPlain = false; // quick-zoom tap edges: binds without the modifier (#285) + bool prevOutPlain = false; bool prevOutHeld = false; // Diagnostics (issue #113): held-state edge logging for the intermittent stuck side-button. Track // the previous-tick held flags + how long the current held episode has lasted, so we can log a @@ -858,10 +861,13 @@ static void RunTick(TickState& t) { // this the hook would keep firing the OLD button (the new VK works via GetAsyncKeyState // but the mouse mapping is captured once in g_input.start at app launch). if (nc.zoomInButton != t.cfg.zoomInButton || nc.zoomOutButton != t.cfg.zoomOutButton - || nc.zoomInButton2 != t.cfg.zoomInButton2 || nc.zoomOutButton2 != t.cfg.zoomOutButton2) { - g_input.setButtons(nc.zoomInButton, nc.zoomInButton2, - nc.zoomOutButton, nc.zoomOutButton2); + || nc.zoomInButton2 != t.cfg.zoomInButton2 || nc.zoomOutButton2 != t.cfg.zoomOutButton2 + || nc.zoomInButtonMods != t.cfg.zoomInButtonMods || nc.zoomOutButtonMods != t.cfg.zoomOutButtonMods + || nc.zoomInButton2Mods != t.cfg.zoomInButton2Mods || nc.zoomOutButton2Mods != t.cfg.zoomOutButton2Mods) { + g_input.setButtonBinds(nc.zoomInButton, nc.zoomInButtonMods, nc.zoomInButton2, nc.zoomInButton2Mods, + nc.zoomOutButton, nc.zoomOutButtonMods, nc.zoomOutButton2, nc.zoomOutButton2Mods); } + g_input.setWheelMods(nc.zoomWheelMods); // scroll-wheel zoom (#285); one relaxed store // Re-bind the keyboard hook's tracked/swallowed keys when any keyboard zoom/recenter // bind changed (else the hook keeps swallowing the OLD key and ignores the new one). if (nc.zoomInVk != t.cfg.zoomInVk || nc.zoomOutVk != t.cfg.zoomOutVk @@ -870,6 +876,7 @@ static void RunTick(TickState& t) { g_input.setKeys(nc.zoomInVk, nc.zoomInVk2, nc.zoomOutVk, nc.zoomOutVk2, nc.recenterVk, nc.cursorLockVk); } + g_input.setKeyMods(nc.zoomInMods, nc.zoomInMods2, nc.zoomOutMods, nc.zoomOutMods2); if (nc.hideCursorVk != t.cfg.hideCursorVk || nc.hideCursorMods != t.cfg.hideCursorMods) { RegisterHideCursorHotkey(t.hwnd, nc.hideCursorVk, nc.hideCursorMods); } @@ -1005,12 +1012,31 @@ static void RunTick(TickState& t) { return true; }; auto comboHeld = [&](int vk, int mods) { return keyDown(vk) && modsHeld(mods); }; - bool inHeld = g_input.state().inHeld.load() - || comboHeld(t.cfg.zoomInVk, t.cfg.zoomInMods) - || comboHeld(t.cfg.zoomInVk2, t.cfg.zoomInMods2); - bool outHeld = g_input.state().outHeld.load() - || comboHeld(t.cfg.zoomOutVk, t.cfg.zoomOutMods) - || comboHeld(t.cfg.zoomOutVk2, t.cfg.zoomOutMods2); + // Quick-zoom modifier mode (see below): holding the modifier turns a zoom-key press into a + // quick-zoom tap. A bind that ITSELF includes that modifier (Ctrl+Alt+click with the Ctrl + // modifier, #285) is a normal hold-to-zoom bind, so every held bind is sorted into "includes the + // quick-zoom modifier" (qz) or not (plain). + const bool hotkeyMode = t.cfg.quickZoomHotkeyMode != 0; + const std::string& qzMod = t.cfg.quickZoomModifier; + int quickZoomModVk = VK_CONTROL, qzBit = 1; + if (_stricmp(qzMod.c_str(), "alt") == 0) { quickZoomModVk = VK_MENU; qzBit = 2; } + else if (_stricmp(qzMod.c_str(), "shift") == 0) { quickZoomModVk = VK_SHIFT; qzBit = 4; } + const bool modifierActive = !hotkeyMode && _stricmp(qzMod.c_str(), "none") != 0; + bool inPlain = false, inQz = false, outPlain = false, outQz = false; + auto sortHeld = [&](bool held, int mods, bool& plain, bool& qz) { + if (!held) return; + if (modifierActive && (mods & qzBit)) qz = true; else plain = true; + }; + sortHeld(g_input.state().inHeld.load(), g_input.state().inHeldMods.load(), inPlain, inQz); + sortHeld(comboHeld(t.cfg.zoomInVk, t.cfg.zoomInMods), t.cfg.zoomInMods, inPlain, inQz); + sortHeld(comboHeld(t.cfg.zoomInVk2, t.cfg.zoomInMods2), t.cfg.zoomInMods2, inPlain, inQz); + sortHeld(g_input.state().outHeld.load(), g_input.state().outHeldMods.load(), outPlain, outQz); + sortHeld(comboHeld(t.cfg.zoomOutVk, t.cfg.zoomOutMods), t.cfg.zoomOutMods, outPlain, outQz); + sortHeld(comboHeld(t.cfg.zoomOutVk2, t.cfg.zoomOutMods2), t.cfg.zoomOutMods2, outPlain, outQz); + bool inHeld = inPlain || inQz; + bool outHeld = outPlain || outQz; + // Scroll-wheel zoom (#285): whole steps the hook swallowed since the last tick. + const int wheelSteps = g_input.drainWheelSteps(); // Apply the live zoom profile every frame (free hot-reload; setProfile does not reset level). // (The old transform <=1.0x ramp-speed cap was a blind TDR mitigation; the resets were // root-caused elsewhere - issue #148 - so the user's configured speed applies everywhere.) @@ -1021,14 +1047,12 @@ static void RunTick(TickState& t) { // (Ctrl/Alt/Shift; "None" = off) and tap a zoom key. While the modifier is held it toggles quick // zoom (below) instead of hold-zooming, so suppress the hold-zoom direction (the toggle snaps the // level). Hotkey mode (==1): a dedicated hotkey toggles it and the modifier is inert here. - bool hotkeyMode = t.cfg.quickZoomHotkeyMode != 0; - const std::string& qzMod = t.cfg.quickZoomModifier; - int quickZoomModVk = VK_CONTROL; - if (_stricmp(qzMod.c_str(), "alt") == 0) quickZoomModVk = VK_MENU; - else if (_stricmp(qzMod.c_str(), "shift") == 0) quickZoomModVk = VK_SHIFT; - bool modifierActive = !hotkeyMode && _stricmp(qzMod.c_str(), "none") != 0; bool modKeyDown = modifierActive && (GetAsyncKeyState(quickZoomModVk) & 0x8000) != 0; - t.zoom.setDirection(modKeyDown ? ZoomDir::None : ResolveDirection(inHeld, outHeld)); + // With the modifier down only binds that include it hold-zoom; the others are quick-zoom taps. + t.zoom.setDirection(modKeyDown ? ResolveDirection(inQz, outQz) : ResolveDirection(inHeld, outHeld)); + // The wheel zooms at the user's zoom speeds (a notch = 0.1 s of holding the bind). + if (wheelSteps != 0 && !t.model->selfDrivenZoom()) // native Magnifier gets its own notches below + t.zoom.wheelNotches(wheelSteps); // Clamp the dt fed to the zoom so a single long tick (cold first capture, alt-tab, any hitch) // can't jump the zoom level mid-ramp - it should always ease in/out at a steady rate regardless // of frame-time spikes. Raw dt is kept below for the diagnostics block (which must see true @@ -1077,7 +1101,9 @@ static void RunTick(TickState& t) { // diagnostics block at the bottom is skipped too; the magnify category logs direction edges.) if (t.model->selfDrivenZoom()) { int rdx, rdy; g_input.drainRaw(rdx, rdy); // keep the raw accumulator drained - t.model->nativeZoomTick((inHeld ? 1 : 0) - (outHeld ? 1 : 0), t.cfg); + const int nativeDir = (inHeld ? 1 : 0) - (outHeld ? 1 : 0); + t.model->nativeZoomTick(nativeDir, t.cfg); + t.model->nativeWheelNotches(wheelSteps); // every wheel notch becomes one Magnifier notch (#285) t.prevInHeld = inHeld; t.prevOutHeld = outHeld; t.prevLvl = 1.0; t.prevActive = false; t.prevInspect = false; return; @@ -1089,8 +1115,10 @@ static void RunTick(TickState& t) { // flag) OR the modifier held + a rising edge of either zoom key (modifier mode). The snap flows // into the SAME-tick zoom-in/out transitions below (which key off lvl vs prevLvl). // prevInHeld/prevOutHeld update every tick (outside the gate) so re-enabling can't fire a stale edge. - bool inEdge = inHeld && !t.prevInHeld; - bool outEdge = outHeld && !t.prevOutHeld; + // Quick-zoom taps come only from binds WITHOUT the modifier (a bind that includes it zooms). + bool inEdge = inPlain && !t.prevInPlain; + bool outEdge = outPlain && !t.prevOutPlain; + t.prevInPlain = inPlain; t.prevOutPlain = outPlain; t.prevInHeld = inHeld; t.prevOutHeld = outHeld; bool hotkeyTrigger = t.quickZoomHotkey.exchange(false); // always consume (only set in hotkey mode) bool modZoomTrigger = modKeyDown && (inEdge || outEdge); // modKeyDown implies modifier mode + enabled @@ -1696,7 +1724,8 @@ static void RunTick(TickState& t) { auto fireClicks = [&](DWORD downF, DWORD upF, int count) { for (int k = 0; k < count; ++k) { INPUT clk[3] = {}; - for (int i = 0; i < 3; ++i) { clk[i].type = INPUT_MOUSE; clk[i].mi.dx = ax; clk[i].mi.dy = ay; } + for (int i = 0; i < 3; ++i) { clk[i].type = INPUT_MOUSE; clk[i].mi.dx = ax; clk[i].mi.dy = ay; + clk[i].mi.dwExtraInfo = (ULONG_PTR)wind::kWindInjectTag; } // never a click bind (#285) clk[0].mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; clk[1].mi.dwFlags = downF | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; clk[2].mi.dwFlags = upF | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; @@ -2376,6 +2405,10 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { USHORT bf = m.usButtonFlags; if (bf & RI_MOUSE_BUTTON_4_UP) g_input.rawButtonUp(1); if (bf & RI_MOUSE_BUTTON_5_UP) g_input.rawButtonUp(2); + // Same net for left/right/middle click binds (#285); a no-op unless one holds a zoom. + if (bf & RI_MOUSE_LEFT_BUTTON_UP) g_input.rawButtonUp(3); + if (bf & RI_MOUSE_RIGHT_BUTTON_UP) g_input.rawButtonUp(4); + if (bf & RI_MOUSE_MIDDLE_BUTTON_UP) g_input.rawButtonUp(5); if (!g_input.hookActive()) { if (bf & RI_MOUSE_BUTTON_4_DOWN) g_input.setButtonState(1, true); if (bf & RI_MOUSE_BUTTON_5_DOWN) g_input.setButtonState(2, true); @@ -2590,17 +2623,20 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { // means ownership can never move once MagInitialize has run, so this needs a restart to change. wind::SetMagThreadClaimEnabled(cfg.txHookWrite != 0); wind::SetHookFrameGate(cfg.txHookWrite == 2); // mode 2 = one hook write per composite + // Every bind, modifiers included, is in place BEFORE the hooks go live (#285): installed first + // with bare button ids, a Ctrl+Alt+left bind briefly matched (and ate) plain left clicks. + g_input.setButtonBinds(cfg.zoomInButton, cfg.zoomInButtonMods, cfg.zoomInButton2, cfg.zoomInButton2Mods, + cfg.zoomOutButton, cfg.zoomOutButtonMods, cfg.zoomOutButton2, cfg.zoomOutButton2Mods); + g_input.setWheelMods(cfg.zoomWheelMods); + g_input.setKeys(cfg.zoomInVk, cfg.zoomInVk2, cfg.zoomOutVk, cfg.zoomOutVk2, cfg.recenterVk, + cfg.cursorLockVk); + g_input.setKeyMods(cfg.zoomInMods, cfg.zoomInMods2, cfg.zoomOutMods, cfg.zoomOutMods2); if (!g_input.start(cfg.zoomInButton, cfg.zoomInButton2, cfg.zoomOutButton, cfg.zoomOutButton2, /*swallow=*/true)) { MessageBoxW(nullptr, L"Failed to install the mouse hook.", L"Wind", MB_ICONERROR); return 1; } g_track.start(); // tracking (issue #276): caret/focus watcher, starts alongside the input router - // Configure the keyboard hook's bound keys (zoom in/out primary+alt, recenter, Inspect-mode - // cursor-lock, and magnifier-model swap) so it swallows them and tracks their state. Kept in - // sync on hot-reload below. - g_input.setKeys(cfg.zoomInVk, cfg.zoomInVk2, cfg.zoomOutVk, cfg.zoomOutVk2, cfg.recenterVk, - cfg.cursorLockVk); // Target monitor for this session: the cursor's monitor when multiMonitor is on, else the // primary. The first zoom-in re-checks and retargets if the cursor moved to another monitor. diff --git a/src/pointer_binds.h b/src/pointer_binds.h new file mode 100644 index 0000000..d36fa6e --- /dev/null +++ b/src/pointer_binds.h @@ -0,0 +1,53 @@ +#pragma once +// Mouse-button and wheel zoom binds (issue #285). Pure; tests/test_pointer_binds.cpp. The WH_MOUSE_LL +// hook calls these with the held-modifier mask read at the event (bit 1 Ctrl, 2 Alt, 4 Shift, 8 Win), +// so it stays a handful of integer operations per event. +#include "keybind_rules.h" +namespace wind { + +// One zoom slot's button bind: button 1/2 = side buttons, 3 = left, 4 = right, 5 = middle +// (0 = none); mods = required modifiers; dir 1 = zoom in, 2 = zoom out. +struct ButtonSlot { int button = 0; int mods = 0; int dir = 0; }; + +// Every required modifier is held; extra modifiers do not disqualify (like the key combos). +inline bool ModsSatisfied(int bindMods, int held) { return (held & bindMods) == bindMods; } + +inline int ModCount(int m) { int n = 0; for (int b = 1; b <= 8; b <<= 1) if (m & b) ++n; return n; } + +// The slot this press belongs to: the matching slot with the MOST modifiers wins, so Ctrl+Alt+left +// (zoom in) and Ctrl+Alt+Shift+left (zoom out) can coexist. -1 = not a zoom bind: leave the event alone. +inline int PickButtonSlot(const ButtonSlot* slots, int n, int button, int held) { + int best = -1, bestMods = -1; + for (int i = 0; i < n; ++i) { + if (slots[i].button == 0 || slots[i].button != button || slots[i].dir == 0) continue; + if (!ModsSatisfied(slots[i].mods, held)) continue; + const int c = ModCount(slots[i].mods); + if (c > bestMods) { best = i; bestMods = c; } + } + return best; +} + +// Swallowing an event while Alt or Win is held leaves Windows seeing that modifier pressed and +// released on its own: Win opens Start, Alt activates the focused app's menu bar. One masking +// keystroke (an unassigned VK) in between prevents both. +inline bool NeedsMaskKey(int held) { return (held & (kModAlt | kModWin)) != 0; } +inline constexpr int kMaskVk = 0xE8; // VK 0xE8: unassigned + +// dwExtraInfo on every event Wind itself injects (Inspect's clicks, the native-Magnifier wheel +// notches, the mask keystroke). The bind matcher skips exactly these, so Wind never swallows its own +// input, while other injectors (AutoHotkey remaps, accessibility tools) count like a real device. +inline constexpr unsigned long long kWindInjectTag = 0x57494E44ull; // "WIND" + +// Wheel deltas arrive in units of 120 per notch, or in smaller pieces from high-resolution wheels +// and touchpads. Whole steps come out; the remainder carries (sign-correct in both directions). +struct WheelAccum { + int acc = 0; + int add(int delta) { + acc += delta; + const int steps = acc / 120; // truncates toward zero: -130 -> -1, remainder -10 + acc -= steps * 120; + return steps; + } + void reset() { acc = 0; } +}; +} // namespace wind diff --git a/src/version.h b/src/version.h index d391b12..3154500 100644 --- a/src/version.h +++ b/src/version.h @@ -3,8 +3,8 @@ #pragma once #define WIND_VER_MAJOR 0 -#define WIND_VER_MINOR 11 -#define WIND_VER_PATCH 2 +#define WIND_VER_MINOR 13 +#define WIND_VER_PATCH 0 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.11.2" +#define WIND_VERSION_STR "0.13.0" diff --git a/src/zoom_controller.cpp b/src/zoom_controller.cpp index 93c93e0..aff6519 100644 --- a/src/zoom_controller.cpp +++ b/src/zoom_controller.cpp @@ -26,7 +26,41 @@ void ZoomController::setProfile(double inSpeed, double outSpeed, bool smooth, // ~150ms and direction reversals round off instead of snapping. Time-based, so VRR tick-interval // variation does not change the felt ease (the same law as the mapper's easing). +// Wheel glide time constant: short enough that a notch feels immediate, long enough that it is a +// glide and not a jump (~95% of the way in ~0.25 s). +static constexpr double kWheelTau = 0.08; + +// One wheel notch zooms as far as HOLDING the zoom bind does in this long, at the same speed +// slider, so scrolling about 10 notches a second feels like holding; faster or slower scrolling +// zooms faster or slower from there. At speed 1.0 a notch is x1.19; at 2.7 it is x1.60. +static constexpr double kWheelNotchSeconds = 0.1; +double WheelNotchStep(double speed) { + return std::pow(2.0, speed * kZoomDoublingsPerSecond * kWheelNotchSeconds) - 1.0; +} +void ZoomController::wheelNotches(int steps) { + if (steps == 0) return; + stepTarget(steps, WheelNotchStep(steps > 0 ? inSpeed_ : outSpeed_)); +} + +void ZoomController::stepTarget(int steps, double step) { + if (steps == 0 || step <= 0.0) return; + const double base = target_ > 0.0 ? target_ : level_; + const double t = base * std::pow(1.0 + step, (double)steps); + target_ = std::min(maxLevel_, std::max(minLevel_, t)); +} + void ZoomController::tick(double dt) { + if (dir_ != ZoomDir::None) target_ = 0.0; // a held zoom takes over from the wheel + if (target_ > 0.0 && dt > 0.0) { + // Exponential approach in LOG space: equal steps look equal at any zoom. + const double a = 1.0 - std::exp(-dt / kWheelTau); + const double lg = std::log(level_) + (std::log(target_) - std::log(level_)) * a; + level_ = std::exp(lg); + if (std::abs(std::log(level_ / target_)) < 0.002) { level_ = target_; target_ = 0.0; } + level_ = std::min(maxLevel_, std::max(minLevel_, level_)); + rate_ = 0.0; heldIn_ = 0.0; + return; + } // Track continuous zoom-in hold time for the smooth-zoom ease-in; any non-In direction // (release or reverse) resets it, so each fresh zoom-in starts slow again. if (dir_ == ZoomDir::In && dt > 0.0) heldIn_ += dt; @@ -63,8 +97,9 @@ void ZoomController::tick(double dt) { // (rampStopped in the transform model) is not deferred by an invisible decaying rate. if (dir_ == ZoomDir::None && (level_ >= maxLevel_ || level_ <= minLevel_)) rate_ = 0.0; } -void ZoomController::reset() { level_ = minLevel_; dir_ = ZoomDir::None; heldIn_ = 0.0; rate_ = 0.0; } +void ZoomController::reset() { level_ = minLevel_; dir_ = ZoomDir::None; heldIn_ = 0.0; rate_ = 0.0; target_ = 0.0; } void ZoomController::setLevel(double l) { + target_ = 0.0; // a snap (quick zoom, keep-level) cancels any wheel glide level_ = std::min(maxLevel_, std::max(minLevel_, l)); } diff --git a/src/zoom_controller.h b/src/zoom_controller.h index 9c18ee0..77ca7a1 100644 --- a/src/zoom_controller.h +++ b/src/zoom_controller.h @@ -5,6 +5,8 @@ enum class ZoomDir { None, In, Out }; // Pure: given which side buttons are physically held, what should the zoom do. // Both held is ambiguous, so freeze. ZoomDir ResolveDirection(bool inHeld, bool outHeld); +// Zoom per wheel notch (x(1+step)) for a zoom-speed slider value (#285): the hold rate over 0.1 s. +double WheelNotchStep(double speed); class ZoomController { public: @@ -24,9 +26,17 @@ class ZoomController { double level() const { return level_; } void reset(); // level=min, dir=None, held cleared void setLevel(double l); // instant snap to a level (clamped to [min,max]); dir_ untouched + // Scroll-wheel zoom (#285): move a TARGET level by x(1+step) per step (negative = out), clamped; + // tick() glides the level to it. Steps stack on the target, so fast scrolling reads as one + // continuous zoom. A held zoom direction takes over at once (the target is dropped). + void stepTarget(int steps, double step); + // The wheel at the user's zoom speeds: up uses inSpeed, down uses outSpeed (see WheelNotchStep). + void wheelNotches(int steps); + bool hasTarget() const { return target_ > 0.0; } private: double minLevel_, maxLevel_; double level_; + double target_ = 0.0; // wheel target level (0 = none) ZoomDir dir_ = ZoomDir::None; double inSpeed_ = 1.0, outSpeed_ = 1.0; // defaults reproduce today's behavior bool smooth_ = false; diff --git a/tests/fixtures/keybind_cases.txt b/tests/fixtures/keybind_cases.txt new file mode 100644 index 0000000..ea4409b --- /dev/null +++ b/tests/fixtures/keybind_cases.txt @@ -0,0 +1,124 @@ +# Shared keybind safety cases (issue #285). One case per line: +# key mods: bit 1 Ctrl, 2 Alt, 4 Shift, 8 Win +# wheel +# click + + + {/if} {#if writeError}