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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 8 additions & 3 deletions src/keybind_rules.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions src/version.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

#define WIND_VER_MAJOR 0
#define WIND_VER_MINOR 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"
2 changes: 1 addition & 1 deletion tests/fixtures/keybind_cases.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/test_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
11 changes: 7 additions & 4 deletions ui/src/lib/keybindRules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.`;
Expand Down
2 changes: 1 addition & 1 deletion ui/src/settings-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 6 additions & 4 deletions ui/tests/settings.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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();
Expand Down
Loading