From 85d079102c3199f54ee3a94d7d0192950738039f Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:27:59 +0200 Subject: [PATCH] feat(keybinds): allow Ctrl+wheel as the wheel-zoom bind (#295) Wind swallows the notch, so Ctrl+wheel zooms the screen instead of the page. Shift+wheel and Ctrl+click stay refused. Version 0.13.1. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- CLAUDE.md | 3 ++- README.md | 4 ++-- .../2026-09-30-wheel-zoom-and-safe-keybinds-design.md | 4 +++- src/config.cpp | 2 +- src/config.h | 4 ++-- src/keybind_rules.h | 11 ++++++++--- src/version.h | 4 ++-- tests/fixtures/keybind_cases.txt | 2 +- tests/test_config.cpp | 4 ++-- ui/src/lib/keybindRules.js | 11 +++++++---- ui/src/settings-schema.js | 2 +- ui/tests/settings.spec.js | 10 ++++++---- 12 files changed, 37 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7fcb9b2..9170c48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,7 +231,8 @@ restartWind), `dirty`, `openIni`, `exportDiagnostics`, `pickExe`, `mpoState`, `s 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 + (these need modifiers, never Ctrl or Shift alone; the wheel allows Ctrl alone, #295, since the notch + is swallowed); 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 diff --git a/README.md b/README.md index c909b48..e900229 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,8 @@ with modifiers. Binds that would break normal use are refused with the reason: t 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. +- **Scroll-wheel zoom** (optional): hold the modifiers you chose (for example Ctrl, + Alt or Ctrl+Alt; never 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. 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 73b2db6..4609cd4 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 @@ -17,7 +17,9 @@ Date: 2026-09-30. Owner: Max. Status: awaiting approval (spec + plan together). 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 +- **Wheel:** needs at least one modifier, and never Shift alone. (AMENDED 2026-09-30, #295: Ctrl + alone is allowed; Wind swallows the notch, so it zooms the screen instead of the page.) Originally + also 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 diff --git a/src/config.cpp b/src/config.cpp index e9f7a5f..3b7e4c2 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -387,7 +387,7 @@ 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). Speed: zoomInSpeed (up), zoomOutSpeed (down).\n" + "; never Shift alone; Ctrl zooms the screen, not the page). 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" diff --git a/src/config.h b/src/config.h index 4b451ca..e29e0c6 100644 --- a/src/config.h +++ b/src/config.h @@ -32,8 +32,8 @@ struct Config { 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). + // Scroll-wheel zoom (#285): the modifiers that make the wheel zoom (0 = off; never Shift alone, + // Ctrl alone is fine, #295). Its speed follows zoomInSpeed/zoomOutSpeed. int zoomWheelMods = 0; int recenterVk = 0; // VK code; 0 = unbound. Tap to recenter the lens on the cursor. int cursorLockVk = 0; // VK code; 0 = unbound. Tap to toggle Inspect mode (cursor lock) diff --git a/src/keybind_rules.h b/src/keybind_rules.h index 97fcf49..81ee248 100644 --- a/src/keybind_rules.h +++ b/src/keybind_rules.h @@ -18,7 +18,7 @@ enum class BindVerdict { 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 + CtrlAlone, // click with Ctrl only: multi-select, open in new tab ShiftAlone, // wheel/click with Shift only: horizontal scroll / range select }; @@ -68,10 +68,11 @@ inline BindVerdict CheckKeyBind(int vk, int mods) { // 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). +// Ctrl+wheel is allowed (owner decision 2026-09-30, #295): Wind swallows the notch, so it zooms +// the screen instead of the browser or app. Shift+wheel is horizontal scroll, which people use. inline BindVerdict CheckWheelBind(int mods) { 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; } @@ -80,7 +81,11 @@ inline BindVerdict CheckWheelBind(int mods) { 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); + 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; } inline const char* BindVerdictName(BindVerdict v) { diff --git a/src/version.h b/src/version.h index 3154500..e6b203e 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 13 -#define WIND_VER_PATCH 0 +#define WIND_VER_PATCH 1 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.13.0" +#define WIND_VERSION_STR "0.13.1" diff --git a/tests/fixtures/keybind_cases.txt b/tests/fixtures/keybind_cases.txt index ea4409b..ec02d4e 100644 --- a/tests/fixtures/keybind_cases.txt +++ b/tests/fixtures/keybind_cases.txt @@ -100,7 +100,7 @@ key 61 8 ok # wheel wheel 0 needsmod -wheel 1 ctrlalone +wheel 1 ok wheel 4 shiftalone wheel 2 ok wheel 8 ok diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 115c679..f7598f2 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -514,14 +514,14 @@ TEST_CASE("unsafe binds in an ini read as unbound; safe ones survive (#285)") { CHECK(c.recenterVk == 0); // R alone CHECK(c.cursorLockVk == 113); // F2 alone } -TEST_CASE("click and wheel binds need a modifier, never Ctrl or Shift alone (#285)") { +TEST_CASE("click and wheel binds need a modifier; Ctrl+wheel is fine, Ctrl+click and Shift are not (#285, #295)") { Config c = ParseConfig("zoomInButton=3\nzoomInButtonMods=3\nzoomOutButton=4\nzoomOutButtonMods=1\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(ParseConfig("zoomWheelMods=1\n").zoomWheelMods == 0); // Ctrl+wheel: browser zoom + CHECK(ParseConfig("zoomWheelMods=1\n").zoomWheelMods == 1); // Ctrl+wheel: Wind zooms instead CHECK(ParseConfig("zoomWheelMods=4\n").zoomWheelMods == 0); // Shift+wheel CHECK(ParseConfig("zoomInButton=5\n").zoomInButton == 0); // bare middle click } diff --git a/ui/src/lib/keybindRules.js b/ui/src/lib/keybindRules.js index 1f02dc0..44cbb9c 100644 --- a/ui/src/lib/keybindRules.js +++ b/ui/src/lib/keybindRules.js @@ -30,14 +30,17 @@ export function checkKeyBind(vk, mods) { export function checkWheelBind(mods) { mods &= 15; if (mods === 0) return 'needsmod'; - if (mods === MOD.ctrl) return 'ctrlalone'; - if (mods === MOD.shift) return 'shiftalone'; + if (mods === MOD.shift) return 'shiftalone'; // Ctrl+wheel is allowed (#295): Wind eats the notch 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); + mods &= 15; + if (mods === 0) return 'needsmod'; + if (mods === MOD.ctrl) return 'ctrlalone'; + if (mods === MOD.shift) return 'shiftalone'; + return 'ok'; } // Stored binds the rules refuse (#285). The core already reads each as unbound, so an old ini whose @@ -66,7 +69,7 @@ export function droppedBinds(cfg) { } // 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"). +// readable combo ("Ctrl+Alt+2", "Ctrl+Left click", "A"). export function refusalText(verdict, what) { switch (verdict) { case 'never': return `${what} can't be a keybind.`; diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index 2dd49b7..ad8f8c0 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -19,7 +19,7 @@ export const sections = [ 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, at your zoom speeds. Needs a modifier, not Ctrl or Shift alone.', + desc:'Hold these keys and turn the wheel: up zooms in, down zooms out, at your zoom speeds. Needs a modifier, not Shift alone. Ctrl zooms the screen instead of the page.', wheel:true, modsKey:'zoomWheelMods' }, // Keyboard-hook suspension (issue #156): trades key-interception for smooth panning, per app. { key:'noSwallowApps', type:'applist', label:'Pass zoom keys to these apps', diff --git a/ui/tests/settings.spec.js b/ui/tests/settings.spec.js index dc8eee0..dd8c6b1 100644 --- a/ui/tests/settings.spec.js +++ b/ui/tests/settings.spec.js @@ -583,19 +583,21 @@ test('a click with modifiers binds; a plain, Ctrl or Shift click is refused (#28 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 }) => { +test('the wheel row binds Ctrl+wheel and Alt+wheel and refuses Shift alone (#285, #295)', 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'); + await cap.click(); + await page.keyboard.down('Control'); await page.mouse.wheel(0, -100); await page.keyboard.up('Control'); + expect(await lastSet(page, 'zoomWheelMods')).toBe('1'); + await expect(cap).toHaveText('Ctrl+Wheel'); }); test('a right-click with modifiers binds and stays bound; a plain right-click still clears (#285)', async ({ page }) => { @@ -624,7 +626,7 @@ test('a side button keeps its modifiers (Ctrl+Mouse button 4) (#285)', async ({ await expect(cap).toHaveText('Ctrl+Mouse button 4'); }); test('stored binds the rules refuse are reset once, with a notice naming them (#285)', async ({ page }) => { - await page.addInitScript(() => { window.__cfgExtra = { cursorLockVk: '82', zoomWheelMods: '1' }; }); + await page.addInitScript(() => { window.__cfgExtra = { cursorLockVk: '82', zoomWheelMods: '4' }; }); await page.goto('/'); const dlg = page.getByRole('dialog', { name: 'Some keybinds were removed' }); await expect(dlg).toBeVisible();