From b0d0e73deea6c187a336c6c81a4b77ccacedde1b Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:18:51 +0200 Subject: [PATCH 1/9] docs(keybinds): wheel zoom + safe keybinds spec and plan (#285) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- ...2026-09-30-wheel-zoom-and-safe-keybinds.md | 29 +++++ ...-30-wheel-zoom-and-safe-keybinds-design.md | 110 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-30-wheel-zoom-and-safe-keybinds.md create mode 100644 docs/superpowers/specs/2026-09-30-wheel-zoom-and-safe-keybinds-design.md 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..17d99e7 --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-wheel-zoom-and-safe-keybinds-design.md @@ -0,0 +1,110 @@ +# 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), `zoomWheelStepPct` (per notch, 5-100, default 25). +- 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). From 2cac913fb2cdc41af817531f3cf5afaa3ccab52e Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:41:07 +0200 Subject: [PATCH 2/9] feat(keybinds): one safety rule set for every bind (#285) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/config.cpp | 45 ++++++++--- src/config.h | 13 +++- src/keybind_rules.h | 102 +++++++++++++++++++++++++ tests/fixtures/keybind_cases.txt | 124 +++++++++++++++++++++++++++++++ tests/test_config.cpp | 24 ++++++ tests/test_keybind_rules.cpp | 42 +++++++++++ 6 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 src/keybind_rules.h create mode 100644 tests/fixtures/keybind_cases.txt create mode 100644 tests/test_keybind_rules.cpp diff --git a/src/config.cpp b/src/config.cpp index 4af673b..d4c57ef 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,12 @@ 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 == "zoomWheelStepPct") c.zoomWheelStepPct = 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 +314,26 @@ 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; + c.zoomWheelStepPct = (int)clampd(c.zoomWheelStepPct, 5, 100); return c; } } @@ -351,8 +371,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 +385,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). zoomWheelStepPct: zoom per notch, 5-100.\n" + "zoomWheelMods=0\nzoomWheelStepPct=25\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..5008b43 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,16 @@ 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 zoomWheelStepPct = 25; 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/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/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 armed && refusal}{refusal}{/if} @@ -157,4 +207,5 @@ .keycap { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); background: var(--chip); font-size: 11.5px; color: var(--text); cursor: pointer; } .keycap.armed { outline: 2px solid var(--accent); } .keycap:disabled { opacity: .5; cursor: default; } + .refusal { display: block; margin-top: 4px; font-size: 11.5px; color: var(--warn, #e0a030); max-width: 280px; } diff --git a/ui/src/lib/Row.svelte b/ui/src/lib/Row.svelte index 5454f24..0b2406e 100644 --- a/ui/src/lib/Row.svelte +++ b/ui/src/lib/Row.svelte @@ -127,7 +127,7 @@ - diff --git a/ui/src/lib/keybindRules.js b/ui/src/lib/keybindRules.js new file mode 100644 index 0000000..41dfc12 --- /dev/null +++ b/ui/src/lib/keybindRules.js @@ -0,0 +1,59 @@ +// Mirror of src/keybind_rules.h (issue #285). Both are tested against tests/fixtures/keybind_cases.txt +// (ui/tests/keybind-rules.spec.js and tests/test_keybind_rules.cpp), so a rule changed in one place +// fails the other's tests until it is changed there too. +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; +const allowedAlone = vk => (vk >= 0x21 && vk <= 0x28) || vk === 0x2D || vk === 0x2E || (vk >= 0x70 && vk <= 0x87) || + vk === 0x13 || vk === 0x91 || (vk >= 0x60 && vk <= 0x6F); + +export function checkKeyBind(vk, mods) { + if (vk === 0) return 'ok'; + if (vk <= 0 || vk > 255) return 'never'; + if ([0x01, 0x02, 0x04, 0x05, 0x06, 0x08].includes(vk)) return 'never'; + if (isModifierVk(vk)) return 'modifier'; + mods &= 15; + if (mods === 0) return allowedAlone(vk) ? 'ok' : 'notalone'; + const ctrl = !!(mods & 1), alt = !!(mods & 2), win = !!(mods & 8); + if (mods === MOD.shift && isTypingVk(vk)) return 'shifttypes'; + if (ctrl && alt && !win && isTypingVk(vk)) return 'altgr'; + 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 (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))) + return 'windows'; + return 'ok'; +} +export function checkWheelBind(mods) { + mods &= 15; + if (mods === 0) return 'needsmod'; + if (mods === MOD.ctrl) return 'ctrlalone'; + if (mods === MOD.shift) return 'shiftalone'; + return 'ok'; +} +export function checkClickBind(button, mods) { + if (button === 0 || button === 1 || button === 2) return 'ok'; + if (button < 0 || button > 5) return 'never'; + return checkWheelBind(mods); +} + +// Why a press was refused, in plain words (shown under the keycap and spoken). `what` is the +// readable combo ("Ctrl+Alt+2", "Ctrl+wheel", "A"). +export function refusalText(verdict, what) { + switch (verdict) { + case 'never': return `${what} can't be a keybind.`; + case 'modifier': return 'Hold the modifier and press another key.'; + case 'notalone': return `${what} alone would stop you typing or using it. Add Ctrl, Alt or Win.`; + case 'shifttypes': return `${what} types a character. Add Ctrl, Alt or Win.`; + case 'altgr': return `${what} is AltGr on many keyboards and types a character. Pick another key.`; + case 'system': return `${what} is reserved by Windows.`; + case 'windows': return `${what} is a Windows shortcut. Pick another key.`; + case 'needsmod': return `${what} needs a modifier: hold Alt, Win or Ctrl+Alt while you do it.`; + case 'ctrlalone': return `${what} is used by apps (zoom, select). Add Alt, Shift or Win.`; + case 'shiftalone': return `${what} is used by apps (scroll, select). Add Ctrl, Alt or Win.`; + default: return ''; + } +} diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index 7f0a37b..60679f6 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -7,16 +7,22 @@ // Copy pass same day: plain language (no "swallow"), no toggle labels starting with "Enable", // no desc that restates its label, consequences kept only where they change a decision. export const sections = [ - { id:'keybinds', label:'Keybinds', icon:'keys', desc:'Hold to zoom. Each binding takes a mouse side-button or a key. Right-click to clear.', rows: [ + { id:'keybinds', label:'Keybinds', icon:'keys', desc:'Hold to zoom. A binding can be a key, a key combination, a mouse side-button, or a click with modifiers. Right-click to clear.', rows: [ // One row per direction with TWO capture slots (the 'Alternate keybinds' gate left the UI // 2026-08-22): the *2 keys feed the second keycap, either slot works alone, both fire the // same action (the core OR-combines them). { key:'__zoomIn', type:'keybind', label:'Zoom in', - buttonKey:'zoomInButton', vkKey:'zoomInVk', modsKey:'zoomInMods', - buttonKey2:'zoomInButton2', vkKey2:'zoomInVk2', modsKey2:'zoomInMods2' }, + buttonKey:'zoomInButton', vkKey:'zoomInVk', modsKey:'zoomInMods', buttonModsKey:'zoomInButtonMods', + buttonKey2:'zoomInButton2', vkKey2:'zoomInVk2', modsKey2:'zoomInMods2', buttonModsKey2:'zoomInButton2Mods' }, { key:'__zoomOut', type:'keybind', label:'Zoom out', - buttonKey:'zoomOutButton', vkKey:'zoomOutVk', modsKey:'zoomOutMods', - buttonKey2:'zoomOutButton2', vkKey2:'zoomOutVk2', modsKey2:'zoomOutMods2' }, + buttonKey:'zoomOutButton', vkKey:'zoomOutVk', modsKey:'zoomOutMods', buttonModsKey:'zoomOutButtonMods', + buttonKey2:'zoomOutButton2', vkKey2:'zoomOutVk2', modsKey2:'zoomOutMods2', buttonModsKey2:'zoomOutButton2Mods' }, + // Scroll-wheel zoom (#285): the modifiers held while turning the wheel; up = in, down = out. + { key:'__zoomWheel', type:'keybind', label:'Zoom with the scroll wheel', + desc:'Hold these keys and turn the wheel: up zooms in, down zooms out. Needs a modifier, not Ctrl or Shift alone.', + wheel:true, modsKey:'zoomWheelMods' }, + { key:'zoomWheelStepPct', type:'slider', label:'Wheel zoom step', desc:'How much one notch of the wheel zooms.', + min:5, max:100, step:5, def:25, unit:'%' }, // Keyboard-hook suspension (issue #156): trades key-interception for smooth panning, per app. { key:'noSwallowApps', type:'applist', label:'Pass zoom keys to these apps', desc:'Fixes stuttery panning in some games. The app will also receive the key.', diff --git a/ui/tests/keybind-rules.spec.js b/ui/tests/keybind-rules.spec.js new file mode 100644 index 0000000..ea250ed --- /dev/null +++ b/ui/tests/keybind-rules.spec.js @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { checkKeyBind, checkWheelBind, checkClickBind } from '../src/lib/keybindRules.js'; + +// The UI mirror must agree with the C++ rules on every shared case (issue #285). +test('UI keybind rules match the shared case list', () => { + const here = dirname(fileURLToPath(import.meta.url)); + const lines = readFileSync(join(here, '..', '..', 'tests', 'fixtures', 'keybind_cases.txt'), 'utf8').split(/\r?\n/); + let n = 0; + for (const line of lines) { + if (!line || line.startsWith('#')) continue; + const p = line.trim().split(/\s+/); + let got; + if (p[0] === 'key') got = checkKeyBind(parseInt(p[1], 16), Number(p[2])); + else if (p[0] === 'wheel') got = checkWheelBind(Number(p[1])); + else if (p[0] === 'click') got = checkClickBind(Number(p[1]), Number(p[2])); + const want = p[p.length - 1]; + expect(got, line).toBe(want); + n++; + } + expect(n).toBeGreaterThan(90); +}); diff --git a/ui/tests/settings.spec.js b/ui/tests/settings.spec.js index a915adf..af649a7 100644 --- a/ui/tests/settings.spec.js +++ b/ui/tests/settings.spec.js @@ -534,3 +534,66 @@ test('Mouse edge margin: a slider in the Tracking section, 0% by default (issue await expect(row).toBeVisible(); await expect(row).toContainText('0'); }); + +// --- Safe keybinds, click and wheel binds (issue #285) ------------------------------------- +const zoomInCap = page => page.getByText('Zoom in', { exact: true }).locator('xpath=../..').getByRole('button').first(); +const lastSet = (page, key) => page.evaluate(k => (window.__sets.filter(s => s.key === k).at(-1) || {}).value, key); + +test('typing keys alone and system combos are refused with a reason; the row keeps listening (#285)', async ({ page }) => { + await page.goto('/'); + const cap = zoomInCap(page); + await cap.click(); + await page.keyboard.press('a'); + await expect(page.locator('.refusal', { hasText: /alone would stop you typing/ })).toBeVisible(); + await page.keyboard.press('Alt+F4'); + await expect(page.locator('.refusal', { hasText: /reserved by Windows/ })).toBeVisible(); + await page.keyboard.press('Control+Alt+2'); // AltGr @ on Nordic layouts + await expect(page.locator('.refusal', { hasText: /AltGr/ })).toBeVisible(); + expect(await page.evaluate(() => window.__sets.filter(s => s.key === 'zoomInVk' && s.value !== '0').length)).toBe(0); + await page.keyboard.press('PageUp'); // allowed alone + expect(await lastSet(page, 'zoomInVk')).toBe('33'); + await expect(cap).toHaveText(/PageUp/); +}); +test('combos with Ctrl, Ctrl+Alt and Win are accepted (#285)', async ({ page }) => { + await page.goto('/'); + const cap = zoomInCap(page); + for (const [keys, vk, mods, label] of [['Control+F1', '112', '1', 'Ctrl+F1'], ['Control+Alt+PageUp', '33', '3', 'Ctrl+Alt+PageUp'], + ['Meta+PageUp', '33', '8', 'Win+PageUp']]) { + await cap.click(); + await page.keyboard.press(keys); + expect(await lastSet(page, 'zoomInVk')).toBe(vk); + expect(await lastSet(page, 'zoomInMods')).toBe(mods); + await expect(cap).toHaveText(label); + } +}); +test('a click with modifiers binds; a plain, Ctrl or Shift click is refused (#285)', async ({ page }) => { + await page.goto('/'); + const cap = zoomInCap(page); + await cap.click(); + const box = await cap.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); await page.mouse.up(); // plain left click + await expect(page.locator('.refusal', { hasText: /needs a modifier/ })).toBeVisible(); + await page.keyboard.down('Control'); await page.mouse.down(); await page.mouse.up(); await page.keyboard.up('Control'); + await expect(page.locator('.refusal', { hasText: /used by apps/ })).toBeVisible(); + await page.keyboard.down('Control'); await page.keyboard.down('Alt'); + await page.mouse.down(); await page.mouse.up(); + await page.keyboard.up('Alt'); await page.keyboard.up('Control'); + expect(await lastSet(page, 'zoomInButton')).toBe('3'); + expect(await lastSet(page, 'zoomInButtonMods')).toBe('3'); + await expect(cap).toHaveText('Ctrl+Alt+Left click'); +}); +test('the wheel row binds Alt+wheel and refuses Ctrl or Shift alone (#285)', async ({ page }) => { + await page.goto('/'); + const cap = page.getByText('Zoom with the scroll wheel', { exact: true }).locator('xpath=../..').getByRole('button').first(); + await cap.click(); + const box = await cap.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.keyboard.down('Control'); await page.mouse.wheel(0, -100); await page.keyboard.up('Control'); + await expect(page.locator('.refusal', { hasText: /used by apps \(zoom, select\)/ })).toBeVisible(); + await page.keyboard.down('Shift'); await page.mouse.wheel(0, -100); await page.keyboard.up('Shift'); + await expect(page.locator('.refusal', { hasText: /used by apps \(scroll, select\)/ })).toBeVisible(); + await page.keyboard.down('Alt'); await page.mouse.wheel(0, -100); await page.keyboard.up('Alt'); + expect(await lastSet(page, 'zoomWheelMods')).toBe('2'); + await expect(cap).toHaveText('Alt+Wheel'); +}); From ccac7807f7e3a9b9299ec69725ef975b1d6e954a Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:55:31 +0200 Subject: [PATCH 5/9] fix(input): skip only Wind's own tagged injections in the bind matcher (#285) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/input_router.cpp | 8 ++++---- src/magnify_model.cpp | 3 +++ src/main.cpp | 4 +++- src/pointer_binds.h | 5 +++++ 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/input_router.cpp b/src/input_router.cpp index 6822e01..9f07c4a 100644 --- a/src/input_router.cpp +++ b/src/input_router.cpp @@ -55,7 +55,7 @@ static int HeldModsNow() { // 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].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)); } @@ -262,9 +262,9 @@ static LRESULT CALLBACK MouseProc(int code, WPARAM wParam, LPARAM lParam) { // 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. Injected clicks (Inspect's own, - // other tools') are never binds. - if (!(mi->flags & LLMHF_INJECTED)) { + // 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; diff --git a/src/magnify_model.cpp b/src/magnify_model.cpp index ddb078a..3fc3de9 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)); diff --git a/src/main.cpp b/src/main.cpp index 6a0d685..2e9dc13 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 @@ -1722,7 +1723,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; diff --git a/src/pointer_binds.h b/src/pointer_binds.h index 2739d95..d36fa6e 100644 --- a/src/pointer_binds.h +++ b/src/pointer_binds.h @@ -33,6 +33,11 @@ inline int PickButtonSlot(const ButtonSlot* slots, int n, int button, int held) 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 { From 83a3487fa9739670c3dc9a52f4d971faa1d92789 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:59:56 +0200 Subject: [PATCH 6/9] docs(keybinds): README + CLAUDE.md for safe binds, click and wheel zoom; version 0.12.0 (#285) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- CLAUDE.md | 15 +++++++++++---- README.md | 11 +++++++++-- src/version.h | 6 +++--- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 26f203b..876971b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -225,10 +225,17 @@ 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) - measured both ways. 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. 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..5f2846a 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,10 @@ 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), `zoomWheelStepPct` (default 25) - scroll-wheel zoom. - `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/src/version.h b/src/version.h index d391b12..737d2b7 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 12 +#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.12.0" From 57faf63561f897cad0b89893b0b48d215bff2aa4 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:00:05 +0200 Subject: [PATCH 7/9] docs(keybinds): state what the mask-key measurement covered (#285) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 876971b..471325f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,7 +233,7 @@ restartWind), `dirty`, `openIni`, `exportDiagnostics`, `pickExe`, `mpoState`, `s 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) - measured both ways. Wind's own + 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. Down/up swallows are balanced (only swallow an UP whose DOWN we swallowed) and released on teardown so a key is never stranded. From 8ff5966f4f3fdaf65b2f58e2b0a7a804868c8c59 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:59:21 +0200 Subject: [PATCH 8/9] fix(keybinds): review fixes; wheel zoom follows the zoom speed sliders (#285) - a key bind is swallowed only with its modifiers held (Ctrl+F1 no longer eats F1) - right-click binds with modifiers are no longer cleared by the contextmenu event - raw-input safety net for left/right/middle click binds (lost UP) - stored binds the rules refuse are reset once with a notice in Settings - onboarding saves click-bind modifiers; side buttons keep their modifiers - native Magnifier model passes every wheel notch on - all binds are set before the hooks go live - wheel notch = 0.1 s of holding at zoomInSpeed/zoomOutSpeed; zoomWheelStepPct removed Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- CLAUDE.md | 4 +- README.md | 3 +- ...-30-wheel-zoom-and-safe-keybinds-design.md | 6 ++- src/config.cpp | 6 +-- src/config.h | 1 - src/input_router.cpp | 49 ++++++++++++++----- src/input_router.h | 10 +++- src/magnifier_model.h | 3 ++ src/magnify_model.cpp | 32 ++++++++++++ src/magnify_model.h | 1 + src/main.cpp | 32 ++++++------ src/zoom_controller.cpp | 12 +++++ src/zoom_controller.h | 4 ++ tests/test_config.cpp | 5 +- tests/test_pointer_binds.cpp | 16 ++++++ ui/src/Onboarding.svelte | 6 +-- ui/src/Settings.svelte | 22 +++++++++ ui/src/lib/KeybindCapture.svelte | 15 ++++-- ui/src/lib/keybindRules.js | 25 ++++++++++ ui/src/settings-schema.js | 4 +- ui/tests/onboarding.spec.js | 15 ++++++ ui/tests/settings.spec.js | 45 ++++++++++++++++- 22 files changed, 268 insertions(+), 48 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 471325f..7fcb9b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,7 +235,9 @@ restartWind), `dirty`, `openIni`, `exportDiagnostics`, `pickExe`, `mpoState`, `s 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. Down/up swallows are balanced + 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 5f2846a..c909b48 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,8 @@ Profiles (tray -> Profiles, or the Settings titlebar) snapshot the whole file pe - `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), `zoomWheelStepPct` (default 25) - scroll-wheel zoom. +- `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/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 index 17d99e7..73b2db6 100644 --- 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 @@ -66,7 +66,11 @@ not a lone tap. Injected with `LLKHF_INJECTED`; Wind's own hook passes it throug ## 5. Wheel zoom -- Config: `zoomWheelMods` (modifier mask, 0 = off), `zoomWheelStepPct` (per notch, 5-100, default 25). +- 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: diff --git a/src/config.cpp b/src/config.cpp index d4c57ef..e9f7a5f 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -151,7 +151,6 @@ Config ParseConfig(const std::string& text) { 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 == "zoomWheelStepPct") c.zoomWheelStepPct = 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); @@ -333,7 +332,6 @@ Config ParseConfig(const std::string& text) { 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; - c.zoomWheelStepPct = (int)clampd(c.zoomWheelStepPct, 5, 100); return c; } } @@ -389,8 +387,8 @@ std::string DefaultIniText() { "; (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). zoomWheelStepPct: zoom per notch, 5-100.\n" - "zoomWheelMods=0\nzoomWheelStepPct=25\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 5008b43..4b451ca 100644 --- a/src/config.h +++ b/src/config.h @@ -35,7 +35,6 @@ struct Config { // 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 zoomWheelStepPct = 25; 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 9f07c4a..9ca8e5a 100644 --- a/src/input_router.cpp +++ b/src/input_router.cpp @@ -153,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); @@ -176,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() && @@ -187,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, @@ -220,16 +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()) { - // First swallowed down of this press (auto-repeat re-fires): if Alt or Win is - // held, mask it so its release is not a lone tap (Start / menu bar, #285). - if (!g_kbSwallowedDown[vk].exchange(true) && NeedsMaskKey(HeldModsNow())) - InjectMaskKey(); - 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); @@ -279,6 +305,7 @@ static LRESULT CALLBACK MouseProc(int code, WPARAM wParam, LPARAM lParam) { 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()); diff --git a/src/input_router.h b/src/input_router.h index 094d1b5..be1c3e6 100644 --- a/src/input_router.h +++ b/src/input_router.h @@ -68,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 @@ -171,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/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 3fc3de9..c0ebe67 100644 --- a/src/magnify_model.cpp +++ b/src/magnify_model.cpp @@ -126,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 2e9dc13..4b3e186 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -876,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); } @@ -1049,8 +1050,9 @@ static void RunTick(TickState& t) { bool modKeyDown = modifierActive && (GetAsyncKeyState(quickZoomModVk) & 0x8000) != 0; // 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)); - if (wheelSteps != 0 && !t.model->selfDrivenZoom()) // native Magnifier gets its own notch below - t.zoom.stepTarget(wheelSteps, t.cfg.zoomWheelStepPct / 100.0); + // 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 @@ -1099,10 +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 - // Native Magnifier: a wheel step is one tick of its own notch drive (#285). - int nativeDir = (inHeld ? 1 : 0) - (outHeld ? 1 : 0); - if (nativeDir == 0 && wheelSteps != 0) nativeDir = wheelSteps > 0 ? 1 : -1; + 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; @@ -2404,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); @@ -2618,21 +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; } - // Button binds with modifiers + left/right/middle click, and the wheel (#285). - 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_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/zoom_controller.cpp b/src/zoom_controller.cpp index 48dfbc2..aff6519 100644 --- a/src/zoom_controller.cpp +++ b/src/zoom_controller.cpp @@ -30,6 +30,18 @@ void ZoomController::setProfile(double inSpeed, double outSpeed, bool smooth, // 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_; diff --git a/src/zoom_controller.h b/src/zoom_controller.h index fc53cbf..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: @@ -28,6 +30,8 @@ class ZoomController { // 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_; diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 19a0aae..115c679 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -516,13 +516,12 @@ TEST_CASE("unsafe binds in an ini read as unbound; safe ones survive (#285)") { } TEST_CASE("click and wheel binds need a modifier, never Ctrl or Shift alone (#285)") { Config c = ParseConfig("zoomInButton=3\nzoomInButtonMods=3\nzoomOutButton=4\nzoomOutButtonMods=1\n" - "zoomInButton2=2\nzoomWheelMods=2\nzoomWheelStepPct=500\n"); + "zoomInButton2=2\nzoomWheelMods=2\n"); CHECK(c.zoomInButton == 3); CHECK(c.zoomInButtonMods == 3); // Ctrl+Alt+left click CHECK(c.zoomOutButton == 0); CHECK(c.zoomOutButtonMods == 0); // Ctrl+right click: refused CHECK(c.zoomInButton2 == 2); // side button alone: fine - CHECK(c.zoomWheelMods == 2); CHECK(c.zoomWheelStepPct == 100); + CHECK(c.zoomWheelMods == 2); CHECK(ParseConfig("zoomWheelMods=1\n").zoomWheelMods == 0); // Ctrl+wheel: browser zoom CHECK(ParseConfig("zoomWheelMods=4\n").zoomWheelMods == 0); // Shift+wheel CHECK(ParseConfig("zoomInButton=5\n").zoomInButton == 0); // bare middle click - CHECK(ParseConfig("").zoomWheelStepPct == 25); } diff --git a/tests/test_pointer_binds.cpp b/tests/test_pointer_binds.cpp index fa844ba..e2e631c 100644 --- a/tests/test_pointer_binds.cpp +++ b/tests/test_pointer_binds.cpp @@ -1,3 +1,4 @@ +#include #include "doctest.h" #include "../src/pointer_binds.h" #include "../src/zoom_controller.h" @@ -55,6 +56,21 @@ TEST_CASE("the glide is smooth: the first tick moves part of the way, not the wh z.tick(0.007); CHECK(z.level() > 1.0); CHECK(z.level() < 1.3); } +TEST_CASE("a wheel notch zooms as far as holding does in 0.1 s at the same speed slider") { + // hold rate = speed x 2.5 doublings/s, so a notch = 2^(speed x 0.25) + CHECK(WheelNotchStep(1.0) == doctest::Approx(0.18921).epsilon(0.001)); // x1.19 + CHECK(WheelNotchStep(2.7) == doctest::Approx(0.59587).epsilon(0.001)); // x1.60 + CHECK(WheelNotchStep(4.0) == doctest::Approx(1.0)); // x2 at the top + ZoomController z(1.0, 100.0); + z.setProfile(2.0, 0.5, false, 3.0, 0.6); // in fast, out slow + z.wheelNotches(2); + for (int i = 0; i < 300; ++i) z.tick(0.007); + CHECK(z.level() == doctest::Approx(std::pow(2.0, 2 * 2.0 * 0.25)).epsilon(0.001)); // 2 notches x 0.5 doublings = 2x + const double before = z.level(); + z.wheelNotches(-2); // down uses the OUT speed + for (int i = 0; i < 300; ++i) z.tick(0.007); + CHECK(z.level() == doctest::Approx(before / std::pow(1.0 + WheelNotchStep(0.5), 2)).epsilon(0.001)); +} TEST_CASE("holding a zoom key takes over from a wheel glide at once") { ZoomController z(1.0, 10.0); z.stepTarget(3, 0.25); diff --git a/ui/src/Onboarding.svelte b/ui/src/Onboarding.svelte index bf43adf..441b1ad 100644 --- a/ui/src/Onboarding.svelte +++ b/ui/src/Onboarding.svelte @@ -11,7 +11,7 @@ // while those stay live underneath is misleading. Writing 0 makes the display match reality; the // KeybindCapture below then writes setConfig live as the user captures. let keys = { zoomInButton:'0', zoomInVk:'0', zoomOutButton:'0', zoomOutVk:'0', - zoomInMods:'0', zoomOutMods:'0' }; + zoomInMods:'0', zoomOutMods:'0', zoomInButtonMods:'0', zoomOutButtonMods:'0' }; onMount(() => { for (const k of Object.keys(keys)) setConfig(k, keys[k]); }); function live(patch) { for (const k of Object.keys(patch)) setConfig(k, patch[k]); @@ -26,8 +26,8 @@ } function back() { if (cur > 0) cur -= 1; } function skip() { setConfig('onboarded', '1'); onDone(); } - const zoomInRow = { label:'Zoom in', desc:'Hold to magnify', buttonKey:'zoomInButton', vkKey:'zoomInVk', modsKey:'zoomInMods' }; - const zoomOutRow = { label:'Zoom out', desc:'Hold to zoom back', buttonKey:'zoomOutButton', vkKey:'zoomOutVk', modsKey:'zoomOutMods' }; + const zoomInRow = { label:'Zoom in', desc:'Hold to magnify', buttonKey:'zoomInButton', vkKey:'zoomInVk', modsKey:'zoomInMods', buttonModsKey:'zoomInButtonMods' }; + const zoomOutRow = { label:'Zoom out', desc:'Hold to zoom back', buttonKey:'zoomOutButton', vkKey:'zoomOutVk', modsKey:'zoomOutMods', buttonModsKey:'zoomOutButtonMods' };
diff --git a/ui/src/Settings.svelte b/ui/src/Settings.svelte index b82cf91..5421570 100644 --- a/ui/src/Settings.svelte +++ b/ui/src/Settings.svelte @@ -13,6 +13,7 @@ import { ic } from './lib/icons.js'; import { scrollspy, scrollToSection } from './lib/scrollspy.js'; import { dialog } from './lib/dialog.js'; + import { droppedBinds } from './lib/keybindRules.js'; let values = {}, saved = {}, active = sections[0].id, theme = 'auto', scroller; const railItems = sections.map(s => ({ id: s.id, label: s.label, icon: s.icon })); @@ -20,8 +21,15 @@ // Reusable so a profile switch/create/delete can re-pull the whole config after the host // rewrites the live ini (the staged/saved state is replaced wholesale on purpose). + // Binds from an older version that the safety rules now refuse (#285): reset once, and said why. + let dropped = []; async function loadValues() { const cfg = await getConfig(); + const found = droppedBinds(cfg); + if (found.length) { + for (const d of found) for (const k of d.keys) { cfg[k] = '0'; setConfig(k, '0'); } + dropped = found; + } const v = {}; for (const s of sections) for (const r of s.rows) { if (r.key[0] !== '_') v[r.key] = (r.key in cfg) ? cfg[r.key] : r.def; @@ -309,6 +317,20 @@
{/if} + {#if dropped.length} +
+ +
+ {/if} {#if writeError}