keybinds 1.3.4__tar.gz → 1.3.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {keybinds-1.3.4 → keybinds-1.3.5}/PKG-INFO +31 -16
- {keybinds-1.3.4 → keybinds-1.3.5}/README.md +30 -15
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/__init__.py +4 -2
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/_backend.py +17 -7
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/_constants.py +9 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/_mouse.py +118 -3
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/types.py +25 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/winput/winput.py +3 -1
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds.egg-info/PKG-INFO +31 -16
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds.egg-info/SOURCES.txt +1 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/pyproject.toml +1 -1
- keybinds-1.3.5/tests/test_mouse_wheel.py +177 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/LICENSE +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/_async.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/_base_bind.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/_bind_registry.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/_dispatcher.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/_hook.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/_keyboard.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/_parsing.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/_state.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/_window.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/bind.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/decorators.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/diagnostics/__init__.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/diagnostics/analysis.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/diagnostics/core.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/diagnostics/models.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/diagnostics/reporting.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/diagnostics/tracing.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/logical/__init__.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/logical/abbreviation.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/logical/keyboard.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/logical/parsing.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/logical/translate.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/presets.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/simple.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/winput/LICENSE +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/winput/__init__.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds/winput/vk_codes.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds.egg-info/dependency_links.txt +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds.egg-info/requires.txt +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/keybinds.egg-info/top_level.txt +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/setup.cfg +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/setup.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/tests/test_bind_registry.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/tests/test_bind_wait_and_is_pressed.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/tests/test_decorators_and_simple.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/tests/test_diagnostics.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/tests/test_logical_parsing.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/tests/test_parsing.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/tests/test_runtime_bindings.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/tests/test_runtime_edge_cases.py +0 -0
- {keybinds-1.3.4 → keybinds-1.3.5}/tests/test_types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: keybinds
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.5
|
|
4
4
|
Summary: Flexible global keyboard & mouse hotkeys for Windows
|
|
5
5
|
Author: echolane
|
|
6
6
|
License: MIT License
|
|
@@ -55,14 +55,14 @@ Flexible global keyboard & mouse hotkeys for Windows.
|
|
|
55
55
|

|
|
56
56
|

|
|
57
57
|

|
|
58
|
-

|
|
59
59
|

|
|
60
60
|
|
|
61
61
|
**keybinds** is a Python library for global keyboard and mouse binds on Windows, with support for hotkeys, logical key binds, text matching, and text abbreviations.
|
|
62
62
|
|
|
63
63
|
It supports:
|
|
64
64
|
- global keyboard binds: single keys, chords (`ctrl+e`), and sequences (`g,k,i`)
|
|
65
|
-
- global mouse binds: `left`, `right`, `middle`, `x1`, `x2`
|
|
65
|
+
- global mouse binds: `left`, `right`, `middle`, `x1`, `x2`, `wheel_up`, `wheel_down`
|
|
66
66
|
- rich triggers: press, release, click, hold, repeat, double tap, and sequence
|
|
67
67
|
- suppression and injected-input policies
|
|
68
68
|
- sync and async callbacks
|
|
@@ -108,7 +108,7 @@ Stable decorator helpers:
|
|
|
108
108
|
|
|
109
109
|
```python
|
|
110
110
|
import keybinds
|
|
111
|
-
from keybinds
|
|
111
|
+
from keybinds import bind_key, bind_mouse
|
|
112
112
|
|
|
113
113
|
@bind_key("ctrl+e")
|
|
114
114
|
def inventory():
|
|
@@ -127,7 +127,7 @@ Decorators use the default hook automatically.
|
|
|
127
127
|
|
|
128
128
|
```python
|
|
129
129
|
import keybinds
|
|
130
|
-
from keybinds
|
|
130
|
+
from keybinds import bind_logical, bind_text, bind_abbreviation
|
|
131
131
|
|
|
132
132
|
# Experimental API: may change in future releases.
|
|
133
133
|
|
|
@@ -227,7 +227,7 @@ hook.bind("ctrl+e", lambda: print("Pressed"))
|
|
|
227
227
|
### Release
|
|
228
228
|
|
|
229
229
|
```python
|
|
230
|
-
from keybinds
|
|
230
|
+
from keybinds import BindConfig, Trigger
|
|
231
231
|
|
|
232
232
|
hook.bind(
|
|
233
233
|
"ctrl+t",
|
|
@@ -239,7 +239,7 @@ hook.bind(
|
|
|
239
239
|
### Hold
|
|
240
240
|
|
|
241
241
|
```python
|
|
242
|
-
from keybinds
|
|
242
|
+
from keybinds import BindConfig, Trigger, Timing
|
|
243
243
|
|
|
244
244
|
hook.bind(
|
|
245
245
|
"h",
|
|
@@ -254,7 +254,7 @@ hook.bind(
|
|
|
254
254
|
### Repeat (auto-fire while held)
|
|
255
255
|
|
|
256
256
|
```python
|
|
257
|
-
from keybinds
|
|
257
|
+
from keybinds import BindConfig, Trigger, Timing
|
|
258
258
|
|
|
259
259
|
hook.bind(
|
|
260
260
|
"space",
|
|
@@ -269,7 +269,7 @@ hook.bind(
|
|
|
269
269
|
### Double tap
|
|
270
270
|
|
|
271
271
|
```python
|
|
272
|
-
from keybinds
|
|
272
|
+
from keybinds import BindConfig, Trigger
|
|
273
273
|
|
|
274
274
|
hook.bind(
|
|
275
275
|
"g",
|
|
@@ -281,7 +281,7 @@ hook.bind(
|
|
|
281
281
|
### Sequence
|
|
282
282
|
|
|
283
283
|
```python
|
|
284
|
-
from keybinds
|
|
284
|
+
from keybinds import BindConfig, Trigger
|
|
285
285
|
|
|
286
286
|
hook.bind(
|
|
287
287
|
"g,k,i",
|
|
@@ -295,7 +295,7 @@ hook.bind(
|
|
|
295
295
|
## Mouse Basics
|
|
296
296
|
|
|
297
297
|
```python
|
|
298
|
-
from keybinds
|
|
298
|
+
from keybinds import MouseBindConfig, Trigger
|
|
299
299
|
|
|
300
300
|
hook.bind_mouse(
|
|
301
301
|
"middle",
|
|
@@ -312,12 +312,28 @@ Mouse buttons:
|
|
|
312
312
|
- `x1`
|
|
313
313
|
- `x2`
|
|
314
314
|
|
|
315
|
+
Mouse wheel (only `Trigger.ON_PRESS`):
|
|
316
|
+
|
|
317
|
+
- `wheel_up` / `wheel_down`
|
|
318
|
+
- `wheel_left` / `wheel_right` — horizontal (many mice never emit these)
|
|
319
|
+
|
|
320
|
+
Wheel units accumulate until `MouseBindConfig.wheel_delta` (default `120`, the Win32 notch). Smaller wheel steps add up until they reach `wheel_delta`. Scrolling the other way clears the leftover amount.
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
hook.bind_mouse("wheel_up", lambda: print("scroll up"))
|
|
324
|
+
hook.bind_mouse(
|
|
325
|
+
"wheel_down",
|
|
326
|
+
lambda: print("scroll down"),
|
|
327
|
+
config=MouseBindConfig(wheel_delta=40),
|
|
328
|
+
)
|
|
329
|
+
```
|
|
330
|
+
|
|
315
331
|
---
|
|
316
332
|
|
|
317
333
|
## Suppression (block input from apps)
|
|
318
334
|
|
|
319
335
|
```python
|
|
320
|
-
from keybinds
|
|
336
|
+
from keybinds import BindConfig, SuppressPolicy
|
|
321
337
|
|
|
322
338
|
hook.bind(
|
|
323
339
|
"ctrl+r",
|
|
@@ -341,7 +357,7 @@ Policies:
|
|
|
341
357
|
Control how injected input (macros, `SendInput`, automation tools) is handled:
|
|
342
358
|
|
|
343
359
|
```python
|
|
344
|
-
from keybinds
|
|
360
|
+
from keybinds import BindConfig, InjectedPolicy
|
|
345
361
|
|
|
346
362
|
hook.bind(
|
|
347
363
|
"f1",
|
|
@@ -361,7 +377,7 @@ Policies:
|
|
|
361
377
|
## Strict Chords
|
|
362
378
|
|
|
363
379
|
```python
|
|
364
|
-
from keybinds
|
|
380
|
+
from keybinds import BindConfig, Constraints, ChordPolicy
|
|
365
381
|
|
|
366
382
|
hook.bind(
|
|
367
383
|
"ctrl+shift+u",
|
|
@@ -397,8 +413,7 @@ Callbacks may be `async def`. If a callback returns an awaitable, **keybinds** s
|
|
|
397
413
|
|
|
398
414
|
```python
|
|
399
415
|
import asyncio
|
|
400
|
-
from keybinds import Hook
|
|
401
|
-
from keybinds.decorators import bind_key
|
|
416
|
+
from keybinds import Hook, bind_key
|
|
402
417
|
|
|
403
418
|
hook = Hook()
|
|
404
419
|
|
|
@@ -5,14 +5,14 @@ Flexible global keyboard & mouse hotkeys for Windows.
|
|
|
5
5
|

|
|
6
6
|

|
|
7
7
|

|
|
8
|
-

|
|
9
9
|

|
|
10
10
|
|
|
11
11
|
**keybinds** is a Python library for global keyboard and mouse binds on Windows, with support for hotkeys, logical key binds, text matching, and text abbreviations.
|
|
12
12
|
|
|
13
13
|
It supports:
|
|
14
14
|
- global keyboard binds: single keys, chords (`ctrl+e`), and sequences (`g,k,i`)
|
|
15
|
-
- global mouse binds: `left`, `right`, `middle`, `x1`, `x2`
|
|
15
|
+
- global mouse binds: `left`, `right`, `middle`, `x1`, `x2`, `wheel_up`, `wheel_down`
|
|
16
16
|
- rich triggers: press, release, click, hold, repeat, double tap, and sequence
|
|
17
17
|
- suppression and injected-input policies
|
|
18
18
|
- sync and async callbacks
|
|
@@ -58,7 +58,7 @@ Stable decorator helpers:
|
|
|
58
58
|
|
|
59
59
|
```python
|
|
60
60
|
import keybinds
|
|
61
|
-
from keybinds
|
|
61
|
+
from keybinds import bind_key, bind_mouse
|
|
62
62
|
|
|
63
63
|
@bind_key("ctrl+e")
|
|
64
64
|
def inventory():
|
|
@@ -77,7 +77,7 @@ Decorators use the default hook automatically.
|
|
|
77
77
|
|
|
78
78
|
```python
|
|
79
79
|
import keybinds
|
|
80
|
-
from keybinds
|
|
80
|
+
from keybinds import bind_logical, bind_text, bind_abbreviation
|
|
81
81
|
|
|
82
82
|
# Experimental API: may change in future releases.
|
|
83
83
|
|
|
@@ -177,7 +177,7 @@ hook.bind("ctrl+e", lambda: print("Pressed"))
|
|
|
177
177
|
### Release
|
|
178
178
|
|
|
179
179
|
```python
|
|
180
|
-
from keybinds
|
|
180
|
+
from keybinds import BindConfig, Trigger
|
|
181
181
|
|
|
182
182
|
hook.bind(
|
|
183
183
|
"ctrl+t",
|
|
@@ -189,7 +189,7 @@ hook.bind(
|
|
|
189
189
|
### Hold
|
|
190
190
|
|
|
191
191
|
```python
|
|
192
|
-
from keybinds
|
|
192
|
+
from keybinds import BindConfig, Trigger, Timing
|
|
193
193
|
|
|
194
194
|
hook.bind(
|
|
195
195
|
"h",
|
|
@@ -204,7 +204,7 @@ hook.bind(
|
|
|
204
204
|
### Repeat (auto-fire while held)
|
|
205
205
|
|
|
206
206
|
```python
|
|
207
|
-
from keybinds
|
|
207
|
+
from keybinds import BindConfig, Trigger, Timing
|
|
208
208
|
|
|
209
209
|
hook.bind(
|
|
210
210
|
"space",
|
|
@@ -219,7 +219,7 @@ hook.bind(
|
|
|
219
219
|
### Double tap
|
|
220
220
|
|
|
221
221
|
```python
|
|
222
|
-
from keybinds
|
|
222
|
+
from keybinds import BindConfig, Trigger
|
|
223
223
|
|
|
224
224
|
hook.bind(
|
|
225
225
|
"g",
|
|
@@ -231,7 +231,7 @@ hook.bind(
|
|
|
231
231
|
### Sequence
|
|
232
232
|
|
|
233
233
|
```python
|
|
234
|
-
from keybinds
|
|
234
|
+
from keybinds import BindConfig, Trigger
|
|
235
235
|
|
|
236
236
|
hook.bind(
|
|
237
237
|
"g,k,i",
|
|
@@ -245,7 +245,7 @@ hook.bind(
|
|
|
245
245
|
## Mouse Basics
|
|
246
246
|
|
|
247
247
|
```python
|
|
248
|
-
from keybinds
|
|
248
|
+
from keybinds import MouseBindConfig, Trigger
|
|
249
249
|
|
|
250
250
|
hook.bind_mouse(
|
|
251
251
|
"middle",
|
|
@@ -262,12 +262,28 @@ Mouse buttons:
|
|
|
262
262
|
- `x1`
|
|
263
263
|
- `x2`
|
|
264
264
|
|
|
265
|
+
Mouse wheel (only `Trigger.ON_PRESS`):
|
|
266
|
+
|
|
267
|
+
- `wheel_up` / `wheel_down`
|
|
268
|
+
- `wheel_left` / `wheel_right` — horizontal (many mice never emit these)
|
|
269
|
+
|
|
270
|
+
Wheel units accumulate until `MouseBindConfig.wheel_delta` (default `120`, the Win32 notch). Smaller wheel steps add up until they reach `wheel_delta`. Scrolling the other way clears the leftover amount.
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
hook.bind_mouse("wheel_up", lambda: print("scroll up"))
|
|
274
|
+
hook.bind_mouse(
|
|
275
|
+
"wheel_down",
|
|
276
|
+
lambda: print("scroll down"),
|
|
277
|
+
config=MouseBindConfig(wheel_delta=40),
|
|
278
|
+
)
|
|
279
|
+
```
|
|
280
|
+
|
|
265
281
|
---
|
|
266
282
|
|
|
267
283
|
## Suppression (block input from apps)
|
|
268
284
|
|
|
269
285
|
```python
|
|
270
|
-
from keybinds
|
|
286
|
+
from keybinds import BindConfig, SuppressPolicy
|
|
271
287
|
|
|
272
288
|
hook.bind(
|
|
273
289
|
"ctrl+r",
|
|
@@ -291,7 +307,7 @@ Policies:
|
|
|
291
307
|
Control how injected input (macros, `SendInput`, automation tools) is handled:
|
|
292
308
|
|
|
293
309
|
```python
|
|
294
|
-
from keybinds
|
|
310
|
+
from keybinds import BindConfig, InjectedPolicy
|
|
295
311
|
|
|
296
312
|
hook.bind(
|
|
297
313
|
"f1",
|
|
@@ -311,7 +327,7 @@ Policies:
|
|
|
311
327
|
## Strict Chords
|
|
312
328
|
|
|
313
329
|
```python
|
|
314
|
-
from keybinds
|
|
330
|
+
from keybinds import BindConfig, Constraints, ChordPolicy
|
|
315
331
|
|
|
316
332
|
hook.bind(
|
|
317
333
|
"ctrl+shift+u",
|
|
@@ -347,8 +363,7 @@ Callbacks may be `async def`. If a callback returns an awaitable, **keybinds** s
|
|
|
347
363
|
|
|
348
364
|
```python
|
|
349
365
|
import asyncio
|
|
350
|
-
from keybinds import Hook
|
|
351
|
-
from keybinds.decorators import bind_key
|
|
366
|
+
from keybinds import Hook, bind_key
|
|
352
367
|
|
|
353
368
|
hook = Hook()
|
|
354
369
|
|
|
@@ -21,6 +21,8 @@ from .types import (
|
|
|
21
21
|
Checks,
|
|
22
22
|
BindConfig,
|
|
23
23
|
MouseButton,
|
|
24
|
+
WHEEL_BUTTONS,
|
|
25
|
+
is_wheel_button,
|
|
24
26
|
MouseBindConfig,
|
|
25
27
|
LogicalConfig,
|
|
26
28
|
Callback,
|
|
@@ -36,12 +38,12 @@ from ._backend import reinstall_hooks, rehook
|
|
|
36
38
|
|
|
37
39
|
from ._constants import register_key_token
|
|
38
40
|
|
|
39
|
-
__version__ = "1.3.
|
|
41
|
+
__version__ = "1.3.5"
|
|
40
42
|
|
|
41
43
|
__all__ = [
|
|
42
44
|
"Trigger","SuppressPolicy","ChordPolicy","OrderPolicy","FocusPolicy","InjectedPolicy","TextBoundaryPolicy","TextBackspacePolicy","OsKeyRepeatPolicy","ReplacementPolicy",
|
|
43
45
|
"Timing","Constraints","Checks","BindConfig",
|
|
44
|
-
"MouseButton","MouseBindConfig","Callback","Predicate","LogicalConfig",
|
|
46
|
+
"MouseButton","WHEEL_BUTTONS","is_wheel_button","MouseBindConfig","Callback","Predicate","LogicalConfig",
|
|
45
47
|
"Bind","LogicalBind","TextAbbreviationBind","KeyboardBind","MouseBind","Hook","get_default_hook","set_default_hook","join","unbind",
|
|
46
48
|
"bind_key","bind_logical","bind_text","add_abbreviation","bind_abbreviation","bind_mouse","presets","register_key_token","winput",
|
|
47
49
|
"reinstall_hooks","rehook"
|
|
@@ -15,6 +15,8 @@ from ._constants import (
|
|
|
15
15
|
WM_RBUTTONDOWN, WM_RBUTTONUP,
|
|
16
16
|
WM_MBUTTONDOWN, WM_MBUTTONUP,
|
|
17
17
|
WM_XBUTTONDOWN, WM_XBUTTONUP,
|
|
18
|
+
MOUSE_BUTTON_ACTIONS,
|
|
19
|
+
MOUSE_WHEEL_ACTIONS,
|
|
18
20
|
)
|
|
19
21
|
from .types import MouseButton
|
|
20
22
|
from ._state import InputState
|
|
@@ -291,14 +293,22 @@ class _GlobalBackend:
|
|
|
291
293
|
return flags
|
|
292
294
|
|
|
293
295
|
def _on_mouse(self, event: winput.MouseEvent) -> int:
|
|
294
|
-
# Hard filter: only handle button up/down (everything else is noise).
|
|
295
296
|
act = event.action
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
297
|
+
|
|
298
|
+
# Wheel is a pulse, not a pressed button. Dispatch without touching
|
|
299
|
+
# pressed_mouse so hold/is_pressed for real buttons stay correct.
|
|
300
|
+
if act in MOUSE_WHEEL_ACTIONS:
|
|
301
|
+
state = self.current_state_snapshot()
|
|
302
|
+
flags = winput.WP_CONTINUE
|
|
303
|
+
for h in self._alive_hooks():
|
|
304
|
+
try:
|
|
305
|
+
flags |= h._handle_mouse_event(event, state)
|
|
306
|
+
except Exception:
|
|
307
|
+
print_exc()
|
|
308
|
+
return flags
|
|
309
|
+
|
|
310
|
+
# Hard filter: only handle button up/down (move is noise).
|
|
311
|
+
if act not in MOUSE_BUTTON_ACTIONS:
|
|
302
312
|
return winput.WP_CONTINUE
|
|
303
313
|
|
|
304
314
|
injected = bool(getattr(event, "injected", False))
|
|
@@ -40,6 +40,15 @@ WM_SYSKEYUP = winput.WM_SYSKEYUP
|
|
|
40
40
|
WM_MOUSEMOVE = getattr(winput, "WM_MOUSEMOVE", 0x0200)
|
|
41
41
|
WM_MOUSEWHEEL = getattr(winput, "WM_MOUSEWHEEL", 0x020A)
|
|
42
42
|
WM_MOUSEHWHEEL = getattr(winput, "WM_MOUSEHWHEEL", 0x020E)
|
|
43
|
+
WHEEL_DELTA = getattr(winput, "WHEEL_DELTA", 120)
|
|
44
|
+
|
|
45
|
+
MOUSE_BUTTON_ACTIONS = frozenset({
|
|
46
|
+
WM_LBUTTONDOWN, WM_LBUTTONUP,
|
|
47
|
+
WM_RBUTTONDOWN, WM_RBUTTONUP,
|
|
48
|
+
WM_MBUTTONDOWN, WM_MBUTTONUP,
|
|
49
|
+
WM_XBUTTONDOWN, WM_XBUTTONUP,
|
|
50
|
+
})
|
|
51
|
+
MOUSE_WHEEL_ACTIONS = frozenset({WM_MOUSEWHEEL, WM_MOUSEHWHEEL})
|
|
43
52
|
|
|
44
53
|
KEYEVENTF_KEYUP = getattr(winput, "KEYEVENTF_KEYUP", 0x0002)
|
|
45
54
|
KEYEVENTF_UNICODE = getattr(winput, "KEYEVENTF_UNICODE", 0x0004)
|
|
@@ -6,12 +6,22 @@ from typing import Callable, Optional, Union, Tuple
|
|
|
6
6
|
|
|
7
7
|
from . import winput
|
|
8
8
|
|
|
9
|
-
from .types import
|
|
9
|
+
from .types import (
|
|
10
|
+
Callback,
|
|
11
|
+
MouseBindConfig,
|
|
12
|
+
MouseButton,
|
|
13
|
+
SuppressPolicy,
|
|
14
|
+
InjectedPolicy,
|
|
15
|
+
Trigger,
|
|
16
|
+
is_wheel_button,
|
|
17
|
+
)
|
|
10
18
|
from ._constants import (
|
|
11
19
|
WM_LBUTTONDOWN, WM_LBUTTONUP,
|
|
12
20
|
WM_RBUTTONDOWN, WM_RBUTTONUP,
|
|
13
21
|
WM_MBUTTONDOWN, WM_MBUTTONUP,
|
|
14
22
|
WM_XBUTTONDOWN, WM_XBUTTONUP,
|
|
23
|
+
WM_MOUSEWHEEL, WM_MOUSEHWHEEL,
|
|
24
|
+
MOUSE_WHEEL_ACTIONS,
|
|
15
25
|
)
|
|
16
26
|
from .diagnostics import _DiagnosticsManager, _EventTrace
|
|
17
27
|
from ._base_bind import BaseBind
|
|
@@ -22,7 +32,7 @@ def _normalize_mouse_button(btn: object) -> MouseButton:
|
|
|
22
32
|
if isinstance(btn, MouseButton):
|
|
23
33
|
return btn
|
|
24
34
|
if isinstance(btn, str):
|
|
25
|
-
s = btn.strip().lower()
|
|
35
|
+
s = btn.strip().lower().replace("-", "_")
|
|
26
36
|
aliases = {
|
|
27
37
|
"left": MouseButton.LEFT,
|
|
28
38
|
"lmb": MouseButton.LEFT,
|
|
@@ -34,6 +44,20 @@ def _normalize_mouse_button(btn: object) -> MouseButton:
|
|
|
34
44
|
"mouse4": MouseButton.X1,
|
|
35
45
|
"x2": MouseButton.X2,
|
|
36
46
|
"mouse5": MouseButton.X2,
|
|
47
|
+
"wheelup": MouseButton.WHEEL_UP,
|
|
48
|
+
"wheel_up": MouseButton.WHEEL_UP,
|
|
49
|
+
"scrollup": MouseButton.WHEEL_UP,
|
|
50
|
+
"scroll_up": MouseButton.WHEEL_UP,
|
|
51
|
+
"wheeldown": MouseButton.WHEEL_DOWN,
|
|
52
|
+
"wheel_down": MouseButton.WHEEL_DOWN,
|
|
53
|
+
"scrolldown": MouseButton.WHEEL_DOWN,
|
|
54
|
+
"scroll_down": MouseButton.WHEEL_DOWN,
|
|
55
|
+
"wheelleft": MouseButton.WHEEL_LEFT,
|
|
56
|
+
"wheel_left": MouseButton.WHEEL_LEFT,
|
|
57
|
+
"hwheel_left": MouseButton.WHEEL_LEFT,
|
|
58
|
+
"wheelright": MouseButton.WHEEL_RIGHT,
|
|
59
|
+
"wheel_right": MouseButton.WHEEL_RIGHT,
|
|
60
|
+
"hwheel_right": MouseButton.WHEEL_RIGHT,
|
|
37
61
|
}
|
|
38
62
|
if s in aliases:
|
|
39
63
|
return aliases[s]
|
|
@@ -53,6 +77,11 @@ class MouseBind(BaseBind[winput.MouseEvent]):
|
|
|
53
77
|
) -> None:
|
|
54
78
|
super().__init__(callback, config=config or MouseBindConfig(), hwnd=hwnd, dispatch=dispatch, diagnostics=diagnostics)
|
|
55
79
|
self.button = _normalize_mouse_button(button)
|
|
80
|
+
if is_wheel_button(self.button) and self.config.trigger != Trigger.ON_PRESS:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"mouse wheel bind {self.button.name.lower()!r} only supports "
|
|
83
|
+
f"Trigger.ON_PRESS, got {self.config.trigger!r}"
|
|
84
|
+
)
|
|
56
85
|
self._set_diagnostics_identity(self.button.name.lower(), "mouse")
|
|
57
86
|
|
|
58
87
|
self._down_ms: Optional[int] = None
|
|
@@ -60,6 +89,7 @@ class MouseBind(BaseBind[winput.MouseEvent]):
|
|
|
60
89
|
self._tap_count: int = 0
|
|
61
90
|
self._tap_last_ms: int = 0
|
|
62
91
|
self._armed: bool = False
|
|
92
|
+
self._wheel_acc: int = 0
|
|
63
93
|
|
|
64
94
|
def _checks_ok(self, event: winput.MouseEvent, state: InputState, trace: Optional[_EventTrace] = None) -> bool:
|
|
65
95
|
return super()._checks_ok(event, state, trace=trace)
|
|
@@ -86,6 +116,34 @@ class MouseBind(BaseBind[winput.MouseEvent]):
|
|
|
86
116
|
return which == 2
|
|
87
117
|
return True
|
|
88
118
|
|
|
119
|
+
def _wheel_contribution(self, event: winput.MouseEvent) -> Optional[int]:
|
|
120
|
+
"""Return signed units toward this bind, 0 on same-axis reversal, None if unrelated."""
|
|
121
|
+
act = event.action
|
|
122
|
+
if act not in MOUSE_WHEEL_ACTIONS:
|
|
123
|
+
return None
|
|
124
|
+
try:
|
|
125
|
+
delta = int(getattr(event, "additional_data", 0) or 0)
|
|
126
|
+
except Exception:
|
|
127
|
+
delta = 0
|
|
128
|
+
if delta == 0:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
if act == WM_MOUSEWHEEL:
|
|
132
|
+
if self.button == MouseButton.WHEEL_UP:
|
|
133
|
+
return delta if delta > 0 else 0
|
|
134
|
+
if self.button == MouseButton.WHEEL_DOWN:
|
|
135
|
+
return -delta if delta < 0 else 0
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
if act == WM_MOUSEHWHEEL:
|
|
139
|
+
if self.button == MouseButton.WHEEL_RIGHT:
|
|
140
|
+
return delta if delta > 0 else 0
|
|
141
|
+
if self.button == MouseButton.WHEEL_LEFT:
|
|
142
|
+
return -delta if delta < 0 else 0
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
return None
|
|
146
|
+
|
|
89
147
|
def reset(self) -> None:
|
|
90
148
|
self._down_ms = None
|
|
91
149
|
self._press_suppress_up = False
|
|
@@ -93,6 +151,60 @@ class MouseBind(BaseBind[winput.MouseEvent]):
|
|
|
93
151
|
self._tap_last_ms = 0
|
|
94
152
|
self._hold_token += 1
|
|
95
153
|
self._armed = False
|
|
154
|
+
self._wheel_acc = 0
|
|
155
|
+
|
|
156
|
+
def _handle_wheel(self, event: winput.MouseEvent, now_ms: int, trace: _EventTrace) -> int:
|
|
157
|
+
contrib = self._wheel_contribution(event)
|
|
158
|
+
if contrib is None:
|
|
159
|
+
return winput.WP_CONTINUE
|
|
160
|
+
|
|
161
|
+
if contrib == 0:
|
|
162
|
+
if self._wheel_acc:
|
|
163
|
+
trace.note("decision", "wheel_accumulator_reset", previous_acc=self._wheel_acc)
|
|
164
|
+
self._wheel_acc = 0
|
|
165
|
+
return winput.WP_CONTINUE
|
|
166
|
+
|
|
167
|
+
self._wheel_acc += contrib
|
|
168
|
+
quantum = int(self.config.wheel_delta) # type: ignore
|
|
169
|
+
# too lazy to fix this typing issue now
|
|
170
|
+
|
|
171
|
+
fired = 0
|
|
172
|
+
flags = winput.WP_CONTINUE
|
|
173
|
+
sup = self.config.suppress
|
|
174
|
+
trig_name = self.config.trigger.name.lower()
|
|
175
|
+
|
|
176
|
+
trace.note(
|
|
177
|
+
"decision",
|
|
178
|
+
"wheel_tick",
|
|
179
|
+
button=self.button.name.lower(),
|
|
180
|
+
delta=contrib,
|
|
181
|
+
accumulated=self._wheel_acc,
|
|
182
|
+
quantum=quantum,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
while self._wheel_acc >= quantum:
|
|
186
|
+
if not self.can_fire_now(now_ms, trace=trace):
|
|
187
|
+
break
|
|
188
|
+
self._wheel_acc -= quantum
|
|
189
|
+
self._fires += 1
|
|
190
|
+
self._last_fire_ms = now_ms
|
|
191
|
+
self._fire(trace.fire(trigger=trig_name))
|
|
192
|
+
fired += 1
|
|
193
|
+
|
|
194
|
+
if fired:
|
|
195
|
+
trace.note("decision", "wheel_notches_fired", notches=fired, remainder=self._wheel_acc)
|
|
196
|
+
|
|
197
|
+
if sup == SuppressPolicy.ALWAYS:
|
|
198
|
+
flags |= winput.WP_DONT_PASS_INPUT_ON
|
|
199
|
+
trace.suppress("suppressed_always")
|
|
200
|
+
elif fired and sup == SuppressPolicy.WHEN_MATCHED:
|
|
201
|
+
flags |= winput.WP_DONT_PASS_INPUT_ON
|
|
202
|
+
trace.suppress("suppressed_when_matched", trigger=trig_name)
|
|
203
|
+
elif fired and sup in (SuppressPolicy.WHILE_ACTIVE, SuppressPolicy.WHILE_EVALUATING):
|
|
204
|
+
flags |= winput.WP_DONT_PASS_INPUT_ON
|
|
205
|
+
trace.suppress("suppressed_while_active" if sup == SuppressPolicy.WHILE_ACTIVE else "suppressed_while_evaluating")
|
|
206
|
+
|
|
207
|
+
return flags
|
|
96
208
|
|
|
97
209
|
def _get_pressed_for_policy(self, state: InputState, *, inj: bool) -> set[MouseButton]:
|
|
98
210
|
pol = self.config.injected
|
|
@@ -132,7 +244,7 @@ class MouseBind(BaseBind[winput.MouseEvent]):
|
|
|
132
244
|
return [self.button] if self.is_pressed() else []
|
|
133
245
|
|
|
134
246
|
def handle(self, event: winput.MouseEvent, state: InputState) -> int:
|
|
135
|
-
#
|
|
247
|
+
# Move is filtered in the backend. Wheel and button events reach here.
|
|
136
248
|
with self._lock:
|
|
137
249
|
trace = self._trace(event)
|
|
138
250
|
now_ms = int(event.time)
|
|
@@ -153,6 +265,9 @@ class MouseBind(BaseBind[winput.MouseEvent]):
|
|
|
153
265
|
trace.skip("injected_only_but_physical")
|
|
154
266
|
return winput.WP_CONTINUE
|
|
155
267
|
|
|
268
|
+
if is_wheel_button(self.button):
|
|
269
|
+
return self._handle_wheel(event, now_ms, trace)
|
|
270
|
+
|
|
156
271
|
down_act, up_act = self._actions_for_button()
|
|
157
272
|
is_down = event.action == down_act
|
|
158
273
|
is_up = event.action == up_act
|
|
@@ -203,6 +203,22 @@ class MouseButton(Enum):
|
|
|
203
203
|
MIDDLE = auto()
|
|
204
204
|
X1 = auto()
|
|
205
205
|
X2 = auto()
|
|
206
|
+
WHEEL_UP = auto()
|
|
207
|
+
WHEEL_DOWN = auto()
|
|
208
|
+
WHEEL_LEFT = auto()
|
|
209
|
+
WHEEL_RIGHT = auto()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
WHEEL_BUTTONS = frozenset({
|
|
213
|
+
MouseButton.WHEEL_UP,
|
|
214
|
+
MouseButton.WHEEL_DOWN,
|
|
215
|
+
MouseButton.WHEEL_LEFT,
|
|
216
|
+
MouseButton.WHEEL_RIGHT,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def is_wheel_button(button: MouseButton) -> bool:
|
|
221
|
+
return button in WHEEL_BUTTONS
|
|
206
222
|
|
|
207
223
|
|
|
208
224
|
@dataclass(frozen=True)
|
|
@@ -216,9 +232,15 @@ class MouseBindConfig:
|
|
|
216
232
|
timing: Timing = field(default_factory=Timing)
|
|
217
233
|
constraints: Constraints = field(default_factory=Constraints)
|
|
218
234
|
checks: Checks = field(default_factory=Checks)
|
|
235
|
+
# Units accumulated before one wheel callback. Win32 notch is 120.
|
|
236
|
+
wheel_delta: int = 120
|
|
219
237
|
|
|
220
238
|
def __post_init__(self):
|
|
221
239
|
object.__setattr__(self, "checks", Checks.coerce(self.checks))
|
|
240
|
+
delta = int(self.wheel_delta)
|
|
241
|
+
if delta < 1:
|
|
242
|
+
raise ValueError("wheel_delta must be >= 1")
|
|
243
|
+
object.__setattr__(self, "wheel_delta", delta)
|
|
222
244
|
|
|
223
245
|
def soft_merge(self, patch: MouseBindConfig) -> MouseBindConfig:
|
|
224
246
|
"""Apply only non-default fields from `patch` (patch semantics)."""
|
|
@@ -328,6 +350,9 @@ def _merge_soft(lhs: TBind, rhs: TBind) -> TBind:
|
|
|
328
350
|
if rhs.focus != default_bind.focus:
|
|
329
351
|
out = replace(out, focus=rhs.focus)
|
|
330
352
|
|
|
353
|
+
if isinstance(out, MouseBindConfig) and rhs.wheel_delta != default_bind.wheel_delta:
|
|
354
|
+
out = replace(out, wheel_delta=rhs.wheel_delta)
|
|
355
|
+
|
|
331
356
|
out = replace(out, timing=_merge_dc_soft(out.timing, rhs.timing, _DEFAULT_TIMING))
|
|
332
357
|
out = replace(out, constraints=_merge_dc_soft(out.constraints, rhs.constraints, _DEFAULT_CONSTRAINTS))
|
|
333
358
|
out = replace(out, checks=_merge_dc_soft(out.checks, rhs.checks, _DEFAULT_CHECKS))
|
|
@@ -352,7 +352,9 @@ def _LowLevelMouseProc(nCode : int, wParam : int, lParam : int, cbfunc : Callabl
|
|
|
352
352
|
extra_data = GET_HWORD(ms.mouseData)
|
|
353
353
|
|
|
354
354
|
elif wParam == WM_MOUSEWHEEL or wParam == WM_MOUSEHWHEEL: # used scrollwheel
|
|
355
|
-
|
|
355
|
+
# Raw signed HIWORD. WHEEL_DELTA (120) is the notch quantum at the bind layer;
|
|
356
|
+
# dividing here would drop high-res / touchpad packets smaller than 120.
|
|
357
|
+
extra_data = GET_HWORD(ms.mouseData)
|
|
356
358
|
|
|
357
359
|
result = cbfunc(MouseEvent(
|
|
358
360
|
(ms.pt.x, ms.pt.y),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: keybinds
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.5
|
|
4
4
|
Summary: Flexible global keyboard & mouse hotkeys for Windows
|
|
5
5
|
Author: echolane
|
|
6
6
|
License: MIT License
|
|
@@ -55,14 +55,14 @@ Flexible global keyboard & mouse hotkeys for Windows.
|
|
|
55
55
|

|
|
56
56
|

|
|
57
57
|

|
|
58
|
-

|
|
59
59
|

|
|
60
60
|
|
|
61
61
|
**keybinds** is a Python library for global keyboard and mouse binds on Windows, with support for hotkeys, logical key binds, text matching, and text abbreviations.
|
|
62
62
|
|
|
63
63
|
It supports:
|
|
64
64
|
- global keyboard binds: single keys, chords (`ctrl+e`), and sequences (`g,k,i`)
|
|
65
|
-
- global mouse binds: `left`, `right`, `middle`, `x1`, `x2`
|
|
65
|
+
- global mouse binds: `left`, `right`, `middle`, `x1`, `x2`, `wheel_up`, `wheel_down`
|
|
66
66
|
- rich triggers: press, release, click, hold, repeat, double tap, and sequence
|
|
67
67
|
- suppression and injected-input policies
|
|
68
68
|
- sync and async callbacks
|
|
@@ -108,7 +108,7 @@ Stable decorator helpers:
|
|
|
108
108
|
|
|
109
109
|
```python
|
|
110
110
|
import keybinds
|
|
111
|
-
from keybinds
|
|
111
|
+
from keybinds import bind_key, bind_mouse
|
|
112
112
|
|
|
113
113
|
@bind_key("ctrl+e")
|
|
114
114
|
def inventory():
|
|
@@ -127,7 +127,7 @@ Decorators use the default hook automatically.
|
|
|
127
127
|
|
|
128
128
|
```python
|
|
129
129
|
import keybinds
|
|
130
|
-
from keybinds
|
|
130
|
+
from keybinds import bind_logical, bind_text, bind_abbreviation
|
|
131
131
|
|
|
132
132
|
# Experimental API: may change in future releases.
|
|
133
133
|
|
|
@@ -227,7 +227,7 @@ hook.bind("ctrl+e", lambda: print("Pressed"))
|
|
|
227
227
|
### Release
|
|
228
228
|
|
|
229
229
|
```python
|
|
230
|
-
from keybinds
|
|
230
|
+
from keybinds import BindConfig, Trigger
|
|
231
231
|
|
|
232
232
|
hook.bind(
|
|
233
233
|
"ctrl+t",
|
|
@@ -239,7 +239,7 @@ hook.bind(
|
|
|
239
239
|
### Hold
|
|
240
240
|
|
|
241
241
|
```python
|
|
242
|
-
from keybinds
|
|
242
|
+
from keybinds import BindConfig, Trigger, Timing
|
|
243
243
|
|
|
244
244
|
hook.bind(
|
|
245
245
|
"h",
|
|
@@ -254,7 +254,7 @@ hook.bind(
|
|
|
254
254
|
### Repeat (auto-fire while held)
|
|
255
255
|
|
|
256
256
|
```python
|
|
257
|
-
from keybinds
|
|
257
|
+
from keybinds import BindConfig, Trigger, Timing
|
|
258
258
|
|
|
259
259
|
hook.bind(
|
|
260
260
|
"space",
|
|
@@ -269,7 +269,7 @@ hook.bind(
|
|
|
269
269
|
### Double tap
|
|
270
270
|
|
|
271
271
|
```python
|
|
272
|
-
from keybinds
|
|
272
|
+
from keybinds import BindConfig, Trigger
|
|
273
273
|
|
|
274
274
|
hook.bind(
|
|
275
275
|
"g",
|
|
@@ -281,7 +281,7 @@ hook.bind(
|
|
|
281
281
|
### Sequence
|
|
282
282
|
|
|
283
283
|
```python
|
|
284
|
-
from keybinds
|
|
284
|
+
from keybinds import BindConfig, Trigger
|
|
285
285
|
|
|
286
286
|
hook.bind(
|
|
287
287
|
"g,k,i",
|
|
@@ -295,7 +295,7 @@ hook.bind(
|
|
|
295
295
|
## Mouse Basics
|
|
296
296
|
|
|
297
297
|
```python
|
|
298
|
-
from keybinds
|
|
298
|
+
from keybinds import MouseBindConfig, Trigger
|
|
299
299
|
|
|
300
300
|
hook.bind_mouse(
|
|
301
301
|
"middle",
|
|
@@ -312,12 +312,28 @@ Mouse buttons:
|
|
|
312
312
|
- `x1`
|
|
313
313
|
- `x2`
|
|
314
314
|
|
|
315
|
+
Mouse wheel (only `Trigger.ON_PRESS`):
|
|
316
|
+
|
|
317
|
+
- `wheel_up` / `wheel_down`
|
|
318
|
+
- `wheel_left` / `wheel_right` — horizontal (many mice never emit these)
|
|
319
|
+
|
|
320
|
+
Wheel units accumulate until `MouseBindConfig.wheel_delta` (default `120`, the Win32 notch). Smaller wheel steps add up until they reach `wheel_delta`. Scrolling the other way clears the leftover amount.
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
hook.bind_mouse("wheel_up", lambda: print("scroll up"))
|
|
324
|
+
hook.bind_mouse(
|
|
325
|
+
"wheel_down",
|
|
326
|
+
lambda: print("scroll down"),
|
|
327
|
+
config=MouseBindConfig(wheel_delta=40),
|
|
328
|
+
)
|
|
329
|
+
```
|
|
330
|
+
|
|
315
331
|
---
|
|
316
332
|
|
|
317
333
|
## Suppression (block input from apps)
|
|
318
334
|
|
|
319
335
|
```python
|
|
320
|
-
from keybinds
|
|
336
|
+
from keybinds import BindConfig, SuppressPolicy
|
|
321
337
|
|
|
322
338
|
hook.bind(
|
|
323
339
|
"ctrl+r",
|
|
@@ -341,7 +357,7 @@ Policies:
|
|
|
341
357
|
Control how injected input (macros, `SendInput`, automation tools) is handled:
|
|
342
358
|
|
|
343
359
|
```python
|
|
344
|
-
from keybinds
|
|
360
|
+
from keybinds import BindConfig, InjectedPolicy
|
|
345
361
|
|
|
346
362
|
hook.bind(
|
|
347
363
|
"f1",
|
|
@@ -361,7 +377,7 @@ Policies:
|
|
|
361
377
|
## Strict Chords
|
|
362
378
|
|
|
363
379
|
```python
|
|
364
|
-
from keybinds
|
|
380
|
+
from keybinds import BindConfig, Constraints, ChordPolicy
|
|
365
381
|
|
|
366
382
|
hook.bind(
|
|
367
383
|
"ctrl+shift+u",
|
|
@@ -397,8 +413,7 @@ Callbacks may be `async def`. If a callback returns an awaitable, **keybinds** s
|
|
|
397
413
|
|
|
398
414
|
```python
|
|
399
415
|
import asyncio
|
|
400
|
-
from keybinds import Hook
|
|
401
|
-
from keybinds.decorators import bind_key
|
|
416
|
+
from keybinds import Hook, bind_key
|
|
402
417
|
|
|
403
418
|
hook = Hook()
|
|
404
419
|
|
|
@@ -45,6 +45,7 @@ tests/test_bind_wait_and_is_pressed.py
|
|
|
45
45
|
tests/test_decorators_and_simple.py
|
|
46
46
|
tests/test_diagnostics.py
|
|
47
47
|
tests/test_logical_parsing.py
|
|
48
|
+
tests/test_mouse_wheel.py
|
|
48
49
|
tests/test_parsing.py
|
|
49
50
|
tests/test_runtime_bindings.py
|
|
50
51
|
tests/test_runtime_edge_cases.py
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_normalize_mouse_button_accepts_wheel_aliases(runtime_env):
|
|
7
|
+
normalize = runtime_env._mouse._normalize_mouse_button
|
|
8
|
+
MouseButton = runtime_env.types.MouseButton
|
|
9
|
+
|
|
10
|
+
assert normalize("wheel_up") is MouseButton.WHEEL_UP
|
|
11
|
+
assert normalize("scrollup") is MouseButton.WHEEL_UP
|
|
12
|
+
assert normalize("wheel-down") is MouseButton.WHEEL_DOWN
|
|
13
|
+
assert normalize("hwheel_left") is MouseButton.WHEEL_LEFT
|
|
14
|
+
assert normalize("hwheel_right") is MouseButton.WHEEL_RIGHT
|
|
15
|
+
assert normalize(MouseButton.WHEEL_UP) is MouseButton.WHEEL_UP
|
|
16
|
+
|
|
17
|
+
with pytest.raises(ValueError):
|
|
18
|
+
normalize("wheel")
|
|
19
|
+
with pytest.raises(ValueError):
|
|
20
|
+
normalize("tilt_left")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_wheel_bind_rejects_non_press_triggers(runtime_env):
|
|
24
|
+
MouseBindConfig = runtime_env.types.MouseBindConfig
|
|
25
|
+
Trigger = runtime_env.types.Trigger
|
|
26
|
+
hook = runtime_env.make_hook()
|
|
27
|
+
|
|
28
|
+
with pytest.raises(ValueError, match="ON_PRESS"):
|
|
29
|
+
hook.bind_mouse(
|
|
30
|
+
"wheel_up",
|
|
31
|
+
lambda: None,
|
|
32
|
+
config=MouseBindConfig(trigger=Trigger.ON_HOLD),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_wheel_delta_rejects_non_positive(runtime_env):
|
|
37
|
+
MouseBindConfig = runtime_env.types.MouseBindConfig
|
|
38
|
+
with pytest.raises(ValueError, match="wheel_delta"):
|
|
39
|
+
MouseBindConfig(wheel_delta=0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_vertical_wheel_accumulates_to_configured_delta(runtime_env):
|
|
43
|
+
hook = runtime_env.make_hook()
|
|
44
|
+
driver = runtime_env.HookDriver(runtime_env, hook)
|
|
45
|
+
hits = []
|
|
46
|
+
|
|
47
|
+
bind = hook.bind_mouse("wheel_up", lambda: hits.append("up"))
|
|
48
|
+
hook.bind_mouse("wheel_down", lambda: hits.append("down"))
|
|
49
|
+
|
|
50
|
+
assert bind.config.wheel_delta == 120
|
|
51
|
+
assert driver.wheel(60) == runtime_env.winput.WP_CONTINUE
|
|
52
|
+
assert hits == []
|
|
53
|
+
assert bind._wheel_acc == 60
|
|
54
|
+
|
|
55
|
+
assert driver.wheel(60) == runtime_env.winput.WP_CONTINUE
|
|
56
|
+
assert hits == ["up"]
|
|
57
|
+
assert bind._wheel_acc == 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_custom_wheel_delta_changes_quantum(runtime_env):
|
|
61
|
+
MouseBindConfig = runtime_env.types.MouseBindConfig
|
|
62
|
+
hook = runtime_env.make_hook()
|
|
63
|
+
driver = runtime_env.HookDriver(runtime_env, hook)
|
|
64
|
+
hits = []
|
|
65
|
+
|
|
66
|
+
hook.bind_mouse(
|
|
67
|
+
"wheel_up",
|
|
68
|
+
lambda: hits.append("up"),
|
|
69
|
+
config=MouseBindConfig(wheel_delta=40),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
driver.wheel(40)
|
|
73
|
+
driver.wheel(80)
|
|
74
|
+
assert hits == ["up", "up", "up"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_wheel_delta_soft_merge_overrides_default(runtime_env):
|
|
78
|
+
MouseBindConfig = runtime_env.types.MouseBindConfig
|
|
79
|
+
SuppressPolicy = runtime_env.types.SuppressPolicy
|
|
80
|
+
|
|
81
|
+
merged = MouseBindConfig(suppress=SuppressPolicy.WHEN_MATCHED).soft_merge(
|
|
82
|
+
MouseBindConfig(wheel_delta=30)
|
|
83
|
+
)
|
|
84
|
+
assert merged.wheel_delta == 30
|
|
85
|
+
assert merged.suppress is SuppressPolicy.WHEN_MATCHED
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_vertical_wheel_fires_once_per_notch_including_multiples(runtime_env):
|
|
89
|
+
hook = runtime_env.make_hook()
|
|
90
|
+
driver = runtime_env.HookDriver(runtime_env, hook)
|
|
91
|
+
hits = []
|
|
92
|
+
|
|
93
|
+
hook.bind_mouse("wheel_down", lambda: hits.append("down"))
|
|
94
|
+
|
|
95
|
+
driver.wheel(-240)
|
|
96
|
+
assert hits == ["down", "down"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_opposite_vertical_direction_resets_remainder(runtime_env):
|
|
100
|
+
hook = runtime_env.make_hook()
|
|
101
|
+
driver = runtime_env.HookDriver(runtime_env, hook)
|
|
102
|
+
hits = []
|
|
103
|
+
|
|
104
|
+
up = hook.bind_mouse("wheel_up", lambda: hits.append("up"))
|
|
105
|
+
|
|
106
|
+
driver.wheel(60)
|
|
107
|
+
assert up._wheel_acc == 60
|
|
108
|
+
driver.wheel(-30)
|
|
109
|
+
assert up._wheel_acc == 0
|
|
110
|
+
driver.wheel(60)
|
|
111
|
+
assert hits == []
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_horizontal_wheel_is_independent_axis(runtime_env):
|
|
115
|
+
hook = runtime_env.make_hook()
|
|
116
|
+
driver = runtime_env.HookDriver(runtime_env, hook)
|
|
117
|
+
hits = []
|
|
118
|
+
|
|
119
|
+
hook.bind_mouse("wheel_left", lambda: hits.append("left"))
|
|
120
|
+
hook.bind_mouse("wheel_right", lambda: hits.append("right"))
|
|
121
|
+
hook.bind_mouse("wheel_up", lambda: hits.append("up"))
|
|
122
|
+
|
|
123
|
+
driver.wheel(-120, horizontal=True)
|
|
124
|
+
driver.wheel(120, horizontal=True)
|
|
125
|
+
driver.wheel(120)
|
|
126
|
+
|
|
127
|
+
assert hits == ["left", "right", "up"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_wheel_when_matched_suppresses_only_after_a_notch(runtime_env):
|
|
131
|
+
MouseBindConfig = runtime_env.types.MouseBindConfig
|
|
132
|
+
SuppressPolicy = runtime_env.types.SuppressPolicy
|
|
133
|
+
hook = runtime_env.make_hook()
|
|
134
|
+
driver = runtime_env.HookDriver(runtime_env, hook)
|
|
135
|
+
hits = []
|
|
136
|
+
|
|
137
|
+
hook.bind_mouse(
|
|
138
|
+
"wheel_up",
|
|
139
|
+
lambda: hits.append("up"),
|
|
140
|
+
config=MouseBindConfig(suppress=SuppressPolicy.WHEN_MATCHED),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
flags_partial = driver.wheel(40)
|
|
144
|
+
flags_full = driver.wheel(80)
|
|
145
|
+
|
|
146
|
+
assert flags_partial == runtime_env.winput.WP_CONTINUE
|
|
147
|
+
assert flags_full & runtime_env.winput.WP_DONT_PASS_INPUT_ON
|
|
148
|
+
assert hits == ["up"]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_wheel_is_never_pressed(runtime_env):
|
|
152
|
+
hook = runtime_env.make_hook()
|
|
153
|
+
driver = runtime_env.HookDriver(runtime_env, hook)
|
|
154
|
+
runtime_env.backend_singleton.current_state_snapshot = driver._state
|
|
155
|
+
bind = hook.bind_mouse("wheel_up", lambda: None)
|
|
156
|
+
|
|
157
|
+
driver.wheel(120)
|
|
158
|
+
assert bind.is_pressed() is False
|
|
159
|
+
assert bind.pressed_keys() == []
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_wheel_respects_injected_ignore(runtime_env):
|
|
163
|
+
MouseBindConfig = runtime_env.types.MouseBindConfig
|
|
164
|
+
InjectedPolicy = runtime_env.types.InjectedPolicy
|
|
165
|
+
hook = runtime_env.make_hook()
|
|
166
|
+
driver = runtime_env.HookDriver(runtime_env, hook)
|
|
167
|
+
hits = []
|
|
168
|
+
|
|
169
|
+
hook.bind_mouse(
|
|
170
|
+
"wheel_up",
|
|
171
|
+
lambda: hits.append("up"),
|
|
172
|
+
config=MouseBindConfig(injected=InjectedPolicy.IGNORE),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
driver.wheel(120, injected=True)
|
|
176
|
+
driver.wheel(120, injected=False)
|
|
177
|
+
assert hits == ["up"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|