keybinds 0.0.1__py3-none-any.whl
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/__init__.py +42 -0
- keybinds/_backend.py +244 -0
- keybinds/_constants.py +124 -0
- keybinds/_dispatcher.py +40 -0
- keybinds/_hook.py +215 -0
- keybinds/_keyboard.py +356 -0
- keybinds/_mouse.py +280 -0
- keybinds/_parsing.py +55 -0
- keybinds/_state.py +21 -0
- keybinds/_utils.py +18 -0
- keybinds/bind.py +7 -0
- keybinds/decorators.py +57 -0
- keybinds/presets.py +407 -0
- keybinds/types.py +289 -0
- keybinds/winput/LICENSE +19 -0
- keybinds/winput/__init__.py +1 -0
- keybinds/winput/vk_codes.py +270 -0
- keybinds/winput/winput.py +557 -0
- keybinds-0.0.1.dist-info/METADATA +570 -0
- keybinds-0.0.1.dist-info/RECORD +23 -0
- keybinds-0.0.1.dist-info/WHEEL +5 -0
- keybinds-0.0.1.dist-info/licenses/LICENSE +21 -0
- keybinds-0.0.1.dist-info/top_level.txt +1 -0
keybinds/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
if sys.platform != "win32":
|
|
4
|
+
raise RuntimeError("keybinds works on Windows only")
|
|
5
|
+
|
|
6
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
7
|
+
|
|
8
|
+
from . import winput
|
|
9
|
+
|
|
10
|
+
from .types import (
|
|
11
|
+
Trigger,
|
|
12
|
+
SuppressPolicy,
|
|
13
|
+
ChordPolicy,
|
|
14
|
+
OrderPolicy,
|
|
15
|
+
Timing,
|
|
16
|
+
Constraints,
|
|
17
|
+
Checks,
|
|
18
|
+
BindConfig,
|
|
19
|
+
MouseButton,
|
|
20
|
+
MouseBindConfig,
|
|
21
|
+
Callback,
|
|
22
|
+
Predicate,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .bind import Bind, MouseBind, Hook, get_default_hook, set_default_hook, join
|
|
26
|
+
from .decorators import bind_key, bind_mouse
|
|
27
|
+
from . import presets
|
|
28
|
+
|
|
29
|
+
from ._constants import register_key_token
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
__version__ = version("keybinds")
|
|
33
|
+
except PackageNotFoundError:
|
|
34
|
+
__version__ = "0.0.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Trigger","SuppressPolicy","ChordPolicy","OrderPolicy",
|
|
38
|
+
"Timing","Constraints","Checks","BindConfig",
|
|
39
|
+
"MouseButton","MouseBindConfig","Callback","Predicate",
|
|
40
|
+
"Bind","MouseBind","Hook","get_default_hook","set_default_hook","join",
|
|
41
|
+
"bind_key","bind_mouse","presets","register_key_token","winput"
|
|
42
|
+
]
|
keybinds/_backend.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# keybinds/_backend.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
import weakref
|
|
6
|
+
from traceback import print_exc
|
|
7
|
+
from typing import Optional, List
|
|
8
|
+
|
|
9
|
+
from . import winput
|
|
10
|
+
|
|
11
|
+
from ._constants import (
|
|
12
|
+
WM_KEYDOWN, WM_SYSKEYDOWN, WM_KEYUP, WM_SYSKEYUP,
|
|
13
|
+
WM_LBUTTONDOWN, WM_LBUTTONUP,
|
|
14
|
+
WM_RBUTTONDOWN, WM_RBUTTONUP,
|
|
15
|
+
WM_MBUTTONDOWN, WM_MBUTTONUP,
|
|
16
|
+
WM_XBUTTONDOWN, WM_XBUTTONUP,
|
|
17
|
+
)
|
|
18
|
+
from .types import MouseButton
|
|
19
|
+
from ._state import InputState
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _GlobalBackend:
|
|
23
|
+
"""
|
|
24
|
+
One per-process backend:
|
|
25
|
+
- installs winput hooks once
|
|
26
|
+
- runs wait_messages() once
|
|
27
|
+
- dispatches events to all active Hook instances
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_instance: Optional["_GlobalBackend"] = None
|
|
31
|
+
_instance_lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._hooks: List[weakref.ReferenceType] = []
|
|
35
|
+
self._hooks_lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
self._thread: Optional[threading.Thread] = None
|
|
38
|
+
self._thread_started = False
|
|
39
|
+
|
|
40
|
+
# physical only
|
|
41
|
+
self._pressed_keys: set[int] = set()
|
|
42
|
+
self._pressed_mouse: set[MouseButton] = set()
|
|
43
|
+
|
|
44
|
+
# physical + injected
|
|
45
|
+
self._pressed_keys_all: set[int] = set()
|
|
46
|
+
self._pressed_mouse_all: set[MouseButton] = set()
|
|
47
|
+
|
|
48
|
+
# injected only
|
|
49
|
+
self._pressed_keys_injected: set[int] = set()
|
|
50
|
+
self._pressed_mouse_injected: set[MouseButton] = set()
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def instance(cls) -> _GlobalBackend:
|
|
54
|
+
with cls._instance_lock:
|
|
55
|
+
if cls._instance is None:
|
|
56
|
+
cls._instance = _GlobalBackend()
|
|
57
|
+
return cls._instance
|
|
58
|
+
|
|
59
|
+
# -------------------------
|
|
60
|
+
# lifecycle
|
|
61
|
+
# -------------------------
|
|
62
|
+
|
|
63
|
+
def register(self, hook_obj) -> None:
|
|
64
|
+
"""Register a Hook frontend and ensure backend thread is running."""
|
|
65
|
+
with self._hooks_lock:
|
|
66
|
+
self._hooks.append(weakref.ref(hook_obj))
|
|
67
|
+
|
|
68
|
+
self._ensure_thread()
|
|
69
|
+
|
|
70
|
+
def unregister(self, hook_obj) -> None:
|
|
71
|
+
"""Unregister a Hook frontend. (We keep backend thread alive; optional stop can be added later.)"""
|
|
72
|
+
with self._hooks_lock:
|
|
73
|
+
new_list = []
|
|
74
|
+
for r in self._hooks:
|
|
75
|
+
o = r()
|
|
76
|
+
if o is None:
|
|
77
|
+
continue
|
|
78
|
+
if o is hook_obj:
|
|
79
|
+
continue
|
|
80
|
+
new_list.append(r)
|
|
81
|
+
self._hooks = new_list
|
|
82
|
+
|
|
83
|
+
def _ensure_thread(self) -> None:
|
|
84
|
+
with self._hooks_lock:
|
|
85
|
+
if self._thread_started:
|
|
86
|
+
return
|
|
87
|
+
self._thread_started = True
|
|
88
|
+
|
|
89
|
+
t = threading.Thread(target=self._thread_main, name="keybinds-backend", daemon=True)
|
|
90
|
+
self._thread = t
|
|
91
|
+
t.start()
|
|
92
|
+
|
|
93
|
+
def _thread_main(self) -> None:
|
|
94
|
+
# Install hooks and pump messages on the SAME thread.
|
|
95
|
+
try:
|
|
96
|
+
winput.hook_keyboard(self._on_keyboard)
|
|
97
|
+
winput.hook_mouse(self._on_mouse)
|
|
98
|
+
winput.wait_messages()
|
|
99
|
+
except Exception:
|
|
100
|
+
# If something goes wrong, try to unhook to restore input.
|
|
101
|
+
try:
|
|
102
|
+
winput.unhook_keyboard()
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
try:
|
|
106
|
+
winput.unhook_mouse()
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
# -------------------------
|
|
111
|
+
# dispatch
|
|
112
|
+
# -------------------------
|
|
113
|
+
|
|
114
|
+
def _alive_hooks(self) -> list:
|
|
115
|
+
"""Return strong refs to currently alive Hook objects."""
|
|
116
|
+
out = []
|
|
117
|
+
with self._hooks_lock:
|
|
118
|
+
new_refs = []
|
|
119
|
+
for r in self._hooks:
|
|
120
|
+
o = r()
|
|
121
|
+
if o is None:
|
|
122
|
+
continue
|
|
123
|
+
out.append(o)
|
|
124
|
+
new_refs.append(r)
|
|
125
|
+
self._hooks = new_refs
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
def _on_keyboard(self, event: winput.KeyboardEvent) -> int:
|
|
129
|
+
vk = int(event.vkCode)
|
|
130
|
+
is_down = event.action in (WM_KEYDOWN, WM_SYSKEYDOWN)
|
|
131
|
+
is_up = event.action in (WM_KEYUP, WM_SYSKEYUP)
|
|
132
|
+
|
|
133
|
+
injected = bool(getattr(event, "injected", False))
|
|
134
|
+
|
|
135
|
+
# Choose domain for repeat detection on KEYDOWN only
|
|
136
|
+
if is_down:
|
|
137
|
+
base = self._pressed_keys_injected if injected else self._pressed_keys
|
|
138
|
+
was_down = vk in base
|
|
139
|
+
else:
|
|
140
|
+
was_down = False
|
|
141
|
+
|
|
142
|
+
if is_down:
|
|
143
|
+
self._pressed_keys_all.add(vk)
|
|
144
|
+
elif is_up:
|
|
145
|
+
self._pressed_keys_all.discard(vk)
|
|
146
|
+
|
|
147
|
+
if is_down:
|
|
148
|
+
if injected:
|
|
149
|
+
self._pressed_keys_injected.add(vk)
|
|
150
|
+
else:
|
|
151
|
+
self._pressed_keys.add(vk)
|
|
152
|
+
|
|
153
|
+
elif is_up:
|
|
154
|
+
self._pressed_keys.discard(vk)
|
|
155
|
+
self._pressed_keys_injected.discard(vk)
|
|
156
|
+
|
|
157
|
+
# Mark OS auto-repeat (keydown while already pressed in that domain)
|
|
158
|
+
try:
|
|
159
|
+
setattr(event, "_sb_is_repeat", bool(is_down and was_down))
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
state = InputState(
|
|
164
|
+
self._pressed_keys,
|
|
165
|
+
self._pressed_mouse,
|
|
166
|
+
self._pressed_keys_all,
|
|
167
|
+
self._pressed_mouse_all,
|
|
168
|
+
self._pressed_keys_injected,
|
|
169
|
+
self._pressed_mouse_injected,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
flags = winput.WP_CONTINUE
|
|
173
|
+
for h in self._alive_hooks():
|
|
174
|
+
try:
|
|
175
|
+
flags |= h._handle_keyboard_event(event, state)
|
|
176
|
+
except Exception:
|
|
177
|
+
print_exc()
|
|
178
|
+
return flags
|
|
179
|
+
|
|
180
|
+
def _on_mouse(self, event: winput.MouseEvent) -> int:
|
|
181
|
+
# Hard filter: only handle button up/down (everything else is noise).
|
|
182
|
+
act = event.action
|
|
183
|
+
if act not in (
|
|
184
|
+
WM_LBUTTONDOWN, WM_LBUTTONUP,
|
|
185
|
+
WM_RBUTTONDOWN, WM_RBUTTONUP,
|
|
186
|
+
WM_MBUTTONDOWN, WM_MBUTTONUP,
|
|
187
|
+
WM_XBUTTONDOWN, WM_XBUTTONUP,
|
|
188
|
+
):
|
|
189
|
+
return winput.WP_CONTINUE
|
|
190
|
+
|
|
191
|
+
injected = bool(getattr(event, "injected", False))
|
|
192
|
+
|
|
193
|
+
def _apply(target: set[MouseButton]) -> None:
|
|
194
|
+
if act == WM_LBUTTONDOWN:
|
|
195
|
+
target.add(MouseButton.LEFT)
|
|
196
|
+
elif act == WM_LBUTTONUP:
|
|
197
|
+
target.discard(MouseButton.LEFT)
|
|
198
|
+
elif act == WM_RBUTTONDOWN:
|
|
199
|
+
target.add(MouseButton.RIGHT)
|
|
200
|
+
elif act == WM_RBUTTONUP:
|
|
201
|
+
target.discard(MouseButton.RIGHT)
|
|
202
|
+
elif act == WM_MBUTTONDOWN:
|
|
203
|
+
target.add(MouseButton.MIDDLE)
|
|
204
|
+
elif act == WM_MBUTTONUP:
|
|
205
|
+
target.discard(MouseButton.MIDDLE)
|
|
206
|
+
elif act == WM_XBUTTONDOWN:
|
|
207
|
+
which = int(getattr(event, "additional_data", 0) or 0)
|
|
208
|
+
if which == 1:
|
|
209
|
+
target.add(MouseButton.X1)
|
|
210
|
+
elif which == 2:
|
|
211
|
+
target.add(MouseButton.X2)
|
|
212
|
+
elif act == WM_XBUTTONUP:
|
|
213
|
+
which = int(getattr(event, "additional_data", 0) or 0)
|
|
214
|
+
if which == 1:
|
|
215
|
+
target.discard(MouseButton.X1)
|
|
216
|
+
elif which == 2:
|
|
217
|
+
target.discard(MouseButton.X2)
|
|
218
|
+
|
|
219
|
+
# Update ALL-state always
|
|
220
|
+
_apply(self._pressed_mouse_all)
|
|
221
|
+
|
|
222
|
+
# Update injected-only state only for injected events
|
|
223
|
+
if injected:
|
|
224
|
+
_apply(self._pressed_mouse_injected)
|
|
225
|
+
else:
|
|
226
|
+
# Update PHYSICAL-state only for non-injected events
|
|
227
|
+
_apply(self._pressed_mouse)
|
|
228
|
+
|
|
229
|
+
state = InputState(
|
|
230
|
+
self._pressed_keys,
|
|
231
|
+
self._pressed_mouse,
|
|
232
|
+
self._pressed_keys_all,
|
|
233
|
+
self._pressed_mouse_all,
|
|
234
|
+
self._pressed_keys_injected,
|
|
235
|
+
self._pressed_mouse_injected,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
flags = winput.WP_CONTINUE
|
|
239
|
+
for h in self._alive_hooks():
|
|
240
|
+
try:
|
|
241
|
+
flags |= h._handle_mouse_event(event, state)
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
return flags
|
keybinds/_constants.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Set
|
|
4
|
+
|
|
5
|
+
from . import winput
|
|
6
|
+
|
|
7
|
+
# Common VK codes (fallbacks); winput also exposes VK_* constants.
|
|
8
|
+
VK_SHIFT = getattr(winput, "VK_SHIFT", 0x10)
|
|
9
|
+
VK_CONTROL = getattr(winput, "VK_CONTROL", 0x11)
|
|
10
|
+
VK_MENU = getattr(winput, "VK_MENU", 0x12) # ALT
|
|
11
|
+
VK_LSHIFT = getattr(winput, "VK_LSHIFT", 0xA0)
|
|
12
|
+
VK_RSHIFT = getattr(winput, "VK_RSHIFT", 0xA1)
|
|
13
|
+
VK_LCONTROL = getattr(winput, "VK_LCONTROL", 0xA2)
|
|
14
|
+
VK_RCONTROL = getattr(winput, "VK_RCONTROL", 0xA3)
|
|
15
|
+
VK_LMENU = getattr(winput, "VK_LMENU", 0xA4)
|
|
16
|
+
VK_RMENU = getattr(winput, "VK_RMENU", 0xA5)
|
|
17
|
+
VK_LWIN = getattr(winput, "VK_LWIN", 0x5B)
|
|
18
|
+
VK_RWIN = getattr(winput, "VK_RWIN", 0x5C)
|
|
19
|
+
|
|
20
|
+
# Mouse wParams
|
|
21
|
+
WM_LBUTTONDOWN = winput.WM_LBUTTONDOWN
|
|
22
|
+
WM_LBUTTONUP = winput.WM_LBUTTONUP
|
|
23
|
+
WM_RBUTTONDOWN = winput.WM_RBUTTONDOWN
|
|
24
|
+
WM_RBUTTONUP = winput.WM_RBUTTONUP
|
|
25
|
+
WM_MBUTTONDOWN = winput.WM_MBUTTONDOWN
|
|
26
|
+
WM_MBUTTONUP = winput.WM_MBUTTONUP
|
|
27
|
+
WM_XBUTTONDOWN = winput.WM_XBUTTONDOWN
|
|
28
|
+
WM_XBUTTONUP = winput.WM_XBUTTONUP
|
|
29
|
+
|
|
30
|
+
WM_KEYDOWN = winput.WM_KEYDOWN
|
|
31
|
+
WM_KEYUP = winput.WM_KEYUP
|
|
32
|
+
WM_SYSKEYDOWN = winput.WM_SYSKEYDOWN
|
|
33
|
+
WM_SYSKEYUP = winput.WM_SYSKEYUP
|
|
34
|
+
|
|
35
|
+
# mouse move/wheel constants may exist; values are stable win32.
|
|
36
|
+
WM_MOUSEMOVE = getattr(winput, "WM_MOUSEMOVE", 0x0200)
|
|
37
|
+
WM_MOUSEWHEEL = getattr(winput, "WM_MOUSEWHEEL", 0x020A)
|
|
38
|
+
WM_MOUSEHWHEEL = getattr(winput, "WM_MOUSEHWHEEL", 0x020E)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_MOD_GROUPS: Dict[str, Set[int]] = {
|
|
42
|
+
"shift": {VK_SHIFT, VK_LSHIFT, VK_RSHIFT},
|
|
43
|
+
"ctrl": {VK_CONTROL, VK_LCONTROL, VK_RCONTROL},
|
|
44
|
+
"control": {VK_CONTROL, VK_LCONTROL, VK_RCONTROL},
|
|
45
|
+
"alt": {VK_MENU, VK_LMENU, VK_RMENU},
|
|
46
|
+
"menu": {VK_MENU, VK_LMENU, VK_RMENU},
|
|
47
|
+
"win": {VK_LWIN, VK_RWIN},
|
|
48
|
+
"lwin": {VK_LWIN},
|
|
49
|
+
"rwin": {VK_RWIN},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
SPECIAL_KEYS: Dict[str, int] = {
|
|
54
|
+
"esc": getattr(winput, "VK_ESCAPE", 0x1B),
|
|
55
|
+
"escape": getattr(winput, "VK_ESCAPE", 0x1B),
|
|
56
|
+
"enter": getattr(winput, "VK_RETURN", 0x0D),
|
|
57
|
+
"return": getattr(winput, "VK_RETURN", 0x0D),
|
|
58
|
+
"tab": getattr(winput, "VK_TAB", 0x09),
|
|
59
|
+
"space": getattr(winput, "VK_SPACE", 0x20),
|
|
60
|
+
"backspace": getattr(winput, "VK_BACK", 0x08),
|
|
61
|
+
"delete": getattr(winput, "VK_DELETE", 0x2E),
|
|
62
|
+
"del": getattr(winput, "VK_DELETE", 0x2E),
|
|
63
|
+
"insert": getattr(winput, "VK_INSERT", 0x2D),
|
|
64
|
+
"home": getattr(winput, "VK_HOME", 0x24),
|
|
65
|
+
"end": getattr(winput, "VK_END", 0x23),
|
|
66
|
+
"pgup": getattr(winput, "VK_PRIOR", 0x21),
|
|
67
|
+
"pageup": getattr(winput, "VK_PRIOR", 0x21),
|
|
68
|
+
"pgdn": getattr(winput, "VK_NEXT", 0x22),
|
|
69
|
+
"pagedown": getattr(winput, "VK_NEXT", 0x22),
|
|
70
|
+
"up": getattr(winput, "VK_UP", 0x26),
|
|
71
|
+
"down": getattr(winput, "VK_DOWN", 0x28),
|
|
72
|
+
"left": getattr(winput, "VK_LEFT", 0x25),
|
|
73
|
+
"right": getattr(winput, "VK_RIGHT", 0x27),
|
|
74
|
+
"volumeup": getattr(winput, "VK_VOLUMEUP", 0xAF),
|
|
75
|
+
"volumedown": getattr(winput, "VK_VOLUMEDOWN", 0xAE),
|
|
76
|
+
"mute": getattr(winput, "VK_VOLUMEMUTE", 0xAD),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
SPECIAL_KEYS.update({
|
|
80
|
+
"`": getattr(winput, "VK_OEM_3", 0xC0), # `~
|
|
81
|
+
"backtick": getattr(winput, "VK_OEM_3", 0xC0),
|
|
82
|
+
"grave": getattr(winput, "VK_OEM_3", 0xC0),
|
|
83
|
+
"tilde": getattr(winput, "VK_OEM_3", 0xC0),
|
|
84
|
+
|
|
85
|
+
"-": getattr(winput, "VK_OEM_MINUS", 0xBD), # -_
|
|
86
|
+
"=": getattr(winput, "VK_OEM_PLUS", 0xBB), # =+
|
|
87
|
+
"[": getattr(winput, "VK_OEM_4", 0xDB), # [{
|
|
88
|
+
"]": getattr(winput, "VK_OEM_6", 0xDD), # ]}
|
|
89
|
+
"\\": getattr(winput, "VK_OEM_5", 0xDC), # \|
|
|
90
|
+
";": getattr(winput, "VK_OEM_1", 0xBA), # ;:
|
|
91
|
+
"'": getattr(winput, "VK_OEM_7", 0xDE), # '"
|
|
92
|
+
",": getattr(winput, "VK_OEM_COMMA", 0xBC), # ,<
|
|
93
|
+
".": getattr(winput, "VK_OEM_PERIOD", 0xBE), # .>
|
|
94
|
+
"/": getattr(winput, "VK_OEM_2", 0xBF), # /?
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
for i in range(1, 25):
|
|
98
|
+
SPECIAL_KEYS[f"f{i}"] = getattr(winput, f"VK_F{i}", 0x70 + (i - 1))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_modifier_vk(vk: int) -> bool:
|
|
102
|
+
return vk in (
|
|
103
|
+
VK_SHIFT,
|
|
104
|
+
VK_LSHIFT,
|
|
105
|
+
VK_RSHIFT,
|
|
106
|
+
VK_CONTROL,
|
|
107
|
+
VK_LCONTROL,
|
|
108
|
+
VK_RCONTROL,
|
|
109
|
+
VK_MENU,
|
|
110
|
+
VK_LMENU,
|
|
111
|
+
VK_RMENU,
|
|
112
|
+
VK_LWIN,
|
|
113
|
+
VK_RWIN,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def register_key_token(name: str, vk: int) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Register a custom key token at runtime.
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
register_key_token("`", 0xC0)
|
|
123
|
+
"""
|
|
124
|
+
SPECIAL_KEYS[name.lower()] = vk
|
keybinds/_dispatcher.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import queue
|
|
4
|
+
import threading
|
|
5
|
+
from traceback import print_exc
|
|
6
|
+
from typing import Callable, List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _CallbackDispatcher:
|
|
10
|
+
"""Executes user callbacks on a small worker pool.
|
|
11
|
+
|
|
12
|
+
Critical for low-level hooks: do *not* create a new Thread per event.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, workers: int = 1) -> None:
|
|
16
|
+
self._q: "queue.SimpleQueue[Optional[Callable[[], None]]]" = queue.SimpleQueue()
|
|
17
|
+
self._threads: List[threading.Thread] = []
|
|
18
|
+
self._workers = max(1, int(workers))
|
|
19
|
+
for i in range(self._workers):
|
|
20
|
+
t = threading.Thread(target=self._worker, name=f"bind-worker-{i}", daemon=True)
|
|
21
|
+
t.start()
|
|
22
|
+
self._threads.append(t)
|
|
23
|
+
|
|
24
|
+
def submit(self, fn: Callable[[], None]) -> None:
|
|
25
|
+
self._q.put(fn)
|
|
26
|
+
|
|
27
|
+
def stop(self) -> None:
|
|
28
|
+
for _ in range(self._workers):
|
|
29
|
+
self._q.put(None)
|
|
30
|
+
|
|
31
|
+
def _worker(self) -> None:
|
|
32
|
+
while True:
|
|
33
|
+
fn = self._q.get()
|
|
34
|
+
if fn is None:
|
|
35
|
+
return
|
|
36
|
+
try:
|
|
37
|
+
fn()
|
|
38
|
+
except Exception:
|
|
39
|
+
# never let user callbacks kill the worker
|
|
40
|
+
print_exc()
|
keybinds/_hook.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import signal
|
|
4
|
+
import threading
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Callable, Optional, Union, Generator
|
|
7
|
+
|
|
8
|
+
from ._backend import _GlobalBackend
|
|
9
|
+
from ._dispatcher import _CallbackDispatcher
|
|
10
|
+
from ._keyboard import Bind
|
|
11
|
+
from ._mouse import MouseBind
|
|
12
|
+
from .types import BindConfig, MouseBindConfig, MouseButton
|
|
13
|
+
|
|
14
|
+
_default_hook: Optional[Hook] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_default_hook() -> Hook:
|
|
18
|
+
global _default_hook
|
|
19
|
+
if _default_hook is None:
|
|
20
|
+
_default_hook = Hook()
|
|
21
|
+
return _default_hook
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def set_default_hook(hook: Hook) -> None:
|
|
25
|
+
global _default_hook
|
|
26
|
+
_default_hook = hook
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def join(hook: Optional[Hook] = None) -> None:
|
|
30
|
+
"""Block until the hook is stopped.
|
|
31
|
+
|
|
32
|
+
If hook is None, the default hook will be used.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
if hook is None:
|
|
36
|
+
hook = get_default_hook()
|
|
37
|
+
|
|
38
|
+
def handler(sig, frame):
|
|
39
|
+
hook.stop()
|
|
40
|
+
|
|
41
|
+
signal.signal(signal.SIGINT, handler)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
hook.wait()
|
|
45
|
+
finally:
|
|
46
|
+
hook.close()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Hook:
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
callback_workers: int = 1,
|
|
54
|
+
default_config: Optional[BindConfig] = None,
|
|
55
|
+
default_mouse_config: Optional[MouseBindConfig] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._lock = threading.Lock()
|
|
58
|
+
self._stop_event = threading.Event()
|
|
59
|
+
|
|
60
|
+
self._pause_count = 0
|
|
61
|
+
self._paused = False
|
|
62
|
+
|
|
63
|
+
self.default_config = default_config
|
|
64
|
+
self.default_mouse_config = default_mouse_config
|
|
65
|
+
|
|
66
|
+
self._dispatcher = _CallbackDispatcher(workers=callback_workers)
|
|
67
|
+
|
|
68
|
+
# binds live in this frontend
|
|
69
|
+
self._keyboard_binds: list[Bind] = []
|
|
70
|
+
self._mouse_binds: list[MouseBind] = []
|
|
71
|
+
|
|
72
|
+
# snapshots used by backend hot path (tuples are cheap to iterate)
|
|
73
|
+
self._keyboard_snapshot: tuple[Bind, ...] = ()
|
|
74
|
+
self._mouse_snapshot: tuple[MouseBind, ...] = ()
|
|
75
|
+
|
|
76
|
+
# Attach to global backend (installs hooks once)
|
|
77
|
+
_GlobalBackend.instance().register(self)
|
|
78
|
+
|
|
79
|
+
# -------------------------
|
|
80
|
+
# public API
|
|
81
|
+
# -------------------------
|
|
82
|
+
|
|
83
|
+
def bind(self, expr: str, callback: Callable[[], None], *, config: Optional[BindConfig] = None, hwnd=None) -> Bind:
|
|
84
|
+
cfg = config or self.default_config or BindConfig()
|
|
85
|
+
b = Bind(expr, callback, config=cfg, hwnd=hwnd, dispatch=self._dispatcher.submit)
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._keyboard_binds.append(b)
|
|
88
|
+
self._keyboard_snapshot = tuple(self._keyboard_binds)
|
|
89
|
+
return b
|
|
90
|
+
|
|
91
|
+
def bind_mouse(self, button: Union[MouseButton, str], callback: Callable[[], None], *, config: Optional[MouseBindConfig] = None, hwnd=None) -> MouseBind:
|
|
92
|
+
cfg = config or self.default_mouse_config or MouseBindConfig()
|
|
93
|
+
b = MouseBind(button, callback, config=cfg, hwnd=hwnd, dispatch=self._dispatcher.submit)
|
|
94
|
+
with self._lock:
|
|
95
|
+
self._mouse_binds.append(b)
|
|
96
|
+
self._mouse_snapshot = tuple(self._mouse_binds)
|
|
97
|
+
return b
|
|
98
|
+
|
|
99
|
+
def unbind(self, b: Bind) -> None:
|
|
100
|
+
with self._lock:
|
|
101
|
+
try:
|
|
102
|
+
self._keyboard_binds.remove(b)
|
|
103
|
+
except ValueError:
|
|
104
|
+
return
|
|
105
|
+
self._keyboard_snapshot = tuple(self._keyboard_binds)
|
|
106
|
+
|
|
107
|
+
def unbind_mouse(self, b: MouseBind) -> None:
|
|
108
|
+
with self._lock:
|
|
109
|
+
try:
|
|
110
|
+
self._mouse_binds.remove(b)
|
|
111
|
+
except ValueError:
|
|
112
|
+
return
|
|
113
|
+
self._mouse_snapshot = tuple(self._mouse_binds)
|
|
114
|
+
|
|
115
|
+
def pause(self) -> None:
|
|
116
|
+
"""Pause the hook (no callbacks will be called until resume() is called).
|
|
117
|
+
|
|
118
|
+
Useful for temporarily disabling the hook while it's running.
|
|
119
|
+
"""
|
|
120
|
+
with self._lock:
|
|
121
|
+
self._pause_count += 1
|
|
122
|
+
self._paused = True
|
|
123
|
+
|
|
124
|
+
def resume(self) -> None:
|
|
125
|
+
"""Resume the hook (callbacks will be called again after calling pause()).
|
|
126
|
+
|
|
127
|
+
Useful for re-enabling the hook after temporarily disabling it with pause().
|
|
128
|
+
"""
|
|
129
|
+
with self._lock:
|
|
130
|
+
if self._pause_count == 0:
|
|
131
|
+
return
|
|
132
|
+
self._pause_count -= 1
|
|
133
|
+
if self._pause_count == 0:
|
|
134
|
+
self._paused = False
|
|
135
|
+
|
|
136
|
+
def is_paused(self) -> bool:
|
|
137
|
+
"""Get whether the hook is currently paused.
|
|
138
|
+
|
|
139
|
+
Returns True if the hook is paused, False otherwise.
|
|
140
|
+
"""
|
|
141
|
+
return self._paused
|
|
142
|
+
|
|
143
|
+
@contextmanager
|
|
144
|
+
def paused(self) -> Generator[None, None, None]:
|
|
145
|
+
"""Pause and resume the hook using the with statement.
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
>>> with hook.paused():
|
|
149
|
+
# do something that requires the hook to be paused
|
|
150
|
+
"""
|
|
151
|
+
self.pause()
|
|
152
|
+
try:
|
|
153
|
+
yield
|
|
154
|
+
finally:
|
|
155
|
+
self.resume()
|
|
156
|
+
|
|
157
|
+
def stop(self) -> None:
|
|
158
|
+
"""Signal to stop waiting in wait().
|
|
159
|
+
|
|
160
|
+
After calling stop(), wait() will return True as soon as possible.
|
|
161
|
+
"""
|
|
162
|
+
self._stop_event.set()
|
|
163
|
+
|
|
164
|
+
def wait(self, timeout: Optional[float] = None) -> bool:
|
|
165
|
+
"""Wait for stop() to be called.
|
|
166
|
+
|
|
167
|
+
If timeout is not None, wait up to that amount of time (in seconds) for stop() to be called.
|
|
168
|
+
Returns True if stop() was called, False otherwise.
|
|
169
|
+
"""
|
|
170
|
+
if timeout is not None:
|
|
171
|
+
return self._stop_event.wait(timeout)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
while not self._stop_event.wait(0.2):
|
|
175
|
+
pass
|
|
176
|
+
return True
|
|
177
|
+
except KeyboardInterrupt:
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
def join(self) -> None:
|
|
181
|
+
"""Block until the hook is stopped."""
|
|
182
|
+
join(self)
|
|
183
|
+
|
|
184
|
+
def close(self) -> None:
|
|
185
|
+
# just detach this frontend; backend keeps running if others exist
|
|
186
|
+
_GlobalBackend.instance().unregister(self)
|
|
187
|
+
self._dispatcher.stop()
|
|
188
|
+
|
|
189
|
+
# -------------------------
|
|
190
|
+
# called by backend
|
|
191
|
+
# -------------------------
|
|
192
|
+
|
|
193
|
+
def _handle_keyboard_event(self, event, state) -> int:
|
|
194
|
+
if self._paused:
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
snap = self._keyboard_snapshot
|
|
198
|
+
if not snap:
|
|
199
|
+
return 0 # winput.WP_CONTINUE (backend ORs anyway)
|
|
200
|
+
flags = 0
|
|
201
|
+
for b in snap:
|
|
202
|
+
flags |= b.handle(event, state)
|
|
203
|
+
return flags
|
|
204
|
+
|
|
205
|
+
def _handle_mouse_event(self, event, state) -> int:
|
|
206
|
+
if self._paused:
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
snap = self._mouse_snapshot
|
|
210
|
+
if not snap:
|
|
211
|
+
return 0
|
|
212
|
+
flags = 0
|
|
213
|
+
for b in snap:
|
|
214
|
+
flags |= b.handle(event, state)
|
|
215
|
+
return flags
|