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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down
29 changes: 29 additions & 0 deletions docs/superpowers/plans/2026-09-30-wheel-zoom-and-safe-keybinds.md
Original file line number Diff line number Diff line change
@@ -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 `<windows.h>`.
- 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.
Original file line number Diff line number Diff line change
@@ -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).
43 changes: 34 additions & 9 deletions src/config.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "config.h"
#include "keybind_rules.h" // one safety rule set for every bind (#285)
#include <cstring>
#include <sstream>
#include <string>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
Loading
Loading