win-mouse-listener 0.1.0__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.
- win_mouse_listener/__init__.py +102 -0
- win_mouse_listener/_listener.py +366 -0
- win_mouse_listener/_types.py +30 -0
- win_mouse_listener/_win32.py +216 -0
- win_mouse_listener-0.1.0.dist-info/METADATA +210 -0
- win_mouse_listener-0.1.0.dist-info/RECORD +8 -0
- win_mouse_listener-0.1.0.dist-info/WHEEL +5 -0
- win_mouse_listener-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Non-blocking Windows mouse listener (Raw Input based).
|
|
3
|
+
|
|
4
|
+
Public API mirrors the shape of the `mouse` library's event protocol but
|
|
5
|
+
never installs a hook, so callbacks cannot block the mouse.
|
|
6
|
+
|
|
7
|
+
Quick start
|
|
8
|
+
-----------
|
|
9
|
+
import win_mouse_listen as wm
|
|
10
|
+
|
|
11
|
+
wm.hook(lambda ev: print(ev)) # auto-starts on first hook
|
|
12
|
+
...
|
|
13
|
+
wm.unhook_all()
|
|
14
|
+
wm.stop()
|
|
15
|
+
"""
|
|
16
|
+
import threading as _threading
|
|
17
|
+
|
|
18
|
+
from ._types import (
|
|
19
|
+
LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN, VERTICAL, HORIZONTAL,
|
|
20
|
+
ButtonEvent, WheelEvent, MoveEvent,
|
|
21
|
+
)
|
|
22
|
+
from ._listener import Listener
|
|
23
|
+
|
|
24
|
+
__version__ = '0.1.0'
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
'LEFT', 'RIGHT', 'MIDDLE', 'X', 'X2', 'UP', 'DOWN',
|
|
28
|
+
'VERTICAL', 'HORIZONTAL',
|
|
29
|
+
'ButtonEvent', 'WheelEvent', 'MoveEvent',
|
|
30
|
+
'Listener',
|
|
31
|
+
'start', 'stop',
|
|
32
|
+
'hook', 'unhook', 'unhook_all',
|
|
33
|
+
'poll', 'get_events', 'clear_buffer', 'pending_count',
|
|
34
|
+
'is_pressed', 'position',
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
_default = None
|
|
38
|
+
_default_lock = _threading.Lock()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_default():
|
|
42
|
+
global _default
|
|
43
|
+
with _default_lock:
|
|
44
|
+
if _default is None:
|
|
45
|
+
_default = Listener()
|
|
46
|
+
return _default
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def start():
|
|
50
|
+
"""Start the default listener (no-op if already running)."""
|
|
51
|
+
_get_default().start()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def stop():
|
|
55
|
+
"""Stop and reset the default listener."""
|
|
56
|
+
global _default
|
|
57
|
+
with _default_lock:
|
|
58
|
+
if _default is not None:
|
|
59
|
+
_default.stop()
|
|
60
|
+
_default = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def hook(callback):
|
|
64
|
+
"""Register a callback. Returns the callback for chaining.
|
|
65
|
+
|
|
66
|
+
The default listener starts automatically on first use.
|
|
67
|
+
"""
|
|
68
|
+
return _get_default().hook(callback)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def unhook(callback):
|
|
72
|
+
_get_default().unhook(callback)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def unhook_all():
|
|
76
|
+
_get_default().unhook_all()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def poll():
|
|
80
|
+
"""Non-blocking pop of one event. Returns None if empty."""
|
|
81
|
+
return _get_default().poll()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_events(max_count=None):
|
|
85
|
+
"""Non-blocking pop of multiple events."""
|
|
86
|
+
return _get_default().get_events(max_count)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def clear_buffer():
|
|
90
|
+
_get_default().clear_buffer()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def pending_count():
|
|
94
|
+
return _get_default().pending_count()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_pressed(button=LEFT):
|
|
98
|
+
return _get_default().is_pressed(button)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def position():
|
|
102
|
+
return _get_default().position()
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Listener implementation using Raw Input."""
|
|
3
|
+
import ctypes
|
|
4
|
+
import threading
|
|
5
|
+
import time as _time
|
|
6
|
+
import traceback as _tb
|
|
7
|
+
from collections import deque
|
|
8
|
+
|
|
9
|
+
from ._types import (
|
|
10
|
+
ButtonEvent, WheelEvent, MoveEvent,
|
|
11
|
+
LEFT, RIGHT, MIDDLE, X, X2, UP, DOWN,
|
|
12
|
+
)
|
|
13
|
+
from . import _win32 as w
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Listener(object):
|
|
17
|
+
"""Non-blocking Windows mouse listener backed by Raw Input.
|
|
18
|
+
|
|
19
|
+
Raw Input does not sit in the mouse event chain, so callbacks can be as
|
|
20
|
+
slow as you like without ever affecting the mouse cursor.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, buffer_size=16384, dispatch_queue_size=16384,
|
|
24
|
+
debug=False):
|
|
25
|
+
self._buffer = deque(maxlen=buffer_size)
|
|
26
|
+
self._dispatch_queue = deque(maxlen=dispatch_queue_size)
|
|
27
|
+
self._pressed = set()
|
|
28
|
+
self._handlers = []
|
|
29
|
+
|
|
30
|
+
self._shutdown = threading.Event()
|
|
31
|
+
self._ready = threading.Event()
|
|
32
|
+
self._running = False
|
|
33
|
+
self._error = None
|
|
34
|
+
self._debug = debug
|
|
35
|
+
|
|
36
|
+
self._hwnd = None
|
|
37
|
+
self._hinstance = None
|
|
38
|
+
self._class_name = None
|
|
39
|
+
self._wndproc_ref = None
|
|
40
|
+
self._thread = None
|
|
41
|
+
self._thread_id = 0
|
|
42
|
+
self._dispatcher = None
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------ 生命周期
|
|
45
|
+
def start(self):
|
|
46
|
+
if self._running:
|
|
47
|
+
return
|
|
48
|
+
self._running = True
|
|
49
|
+
self._error = None
|
|
50
|
+
self._shutdown.clear()
|
|
51
|
+
self._ready.clear()
|
|
52
|
+
|
|
53
|
+
self._thread = threading.Thread(
|
|
54
|
+
target=self._message_loop_thread,
|
|
55
|
+
name='win-mouse-listen', daemon=True)
|
|
56
|
+
self._thread.start()
|
|
57
|
+
|
|
58
|
+
self._dispatcher = threading.Thread(
|
|
59
|
+
target=self._dispatcher_loop,
|
|
60
|
+
name='win-mouse-dispatch', daemon=True)
|
|
61
|
+
self._dispatcher.start()
|
|
62
|
+
|
|
63
|
+
if not self._ready.wait(timeout=3.0):
|
|
64
|
+
raise RuntimeError(
|
|
65
|
+
"Listener thread did not become ready in time")
|
|
66
|
+
if self._error:
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
"Failed to start listener: " + self._error)
|
|
69
|
+
|
|
70
|
+
def stop(self, timeout=2.0):
|
|
71
|
+
if not self._running:
|
|
72
|
+
return
|
|
73
|
+
self._shutdown.set()
|
|
74
|
+
|
|
75
|
+
if self._thread_id:
|
|
76
|
+
w._user32.PostThreadMessageW(self._thread_id, w.WM_QUIT, 0, 0)
|
|
77
|
+
|
|
78
|
+
if self._thread is not None:
|
|
79
|
+
self._thread.join(timeout=timeout)
|
|
80
|
+
self._thread = None
|
|
81
|
+
if self._dispatcher is not None:
|
|
82
|
+
self._dispatcher.join(timeout=timeout)
|
|
83
|
+
self._dispatcher = None
|
|
84
|
+
|
|
85
|
+
self._running = False
|
|
86
|
+
|
|
87
|
+
def __enter__(self):
|
|
88
|
+
self.start()
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def __exit__(self, exc_type, exc, tb):
|
|
92
|
+
self.stop()
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------ 消息循环线程
|
|
96
|
+
def _message_loop_thread(self):
|
|
97
|
+
try:
|
|
98
|
+
self._thread_id = w._kernel32.GetCurrentThreadId()
|
|
99
|
+
self._hinstance = w._kernel32.GetModuleHandleW(None)
|
|
100
|
+
|
|
101
|
+
def wnd_proc(hwnd, msg, wparam, lparam):
|
|
102
|
+
return self._on_message(hwnd, msg, wparam, lparam)
|
|
103
|
+
|
|
104
|
+
self._wndproc_ref = w.WNDPROC(wnd_proc)
|
|
105
|
+
|
|
106
|
+
self._class_name = 'WinMouseListen_{}_{}'.format(
|
|
107
|
+
id(self), self._thread_id)
|
|
108
|
+
|
|
109
|
+
wc = w.WNDCLASSEXW()
|
|
110
|
+
wc.cbSize = ctypes.sizeof(w.WNDCLASSEXW)
|
|
111
|
+
wc.style = 0
|
|
112
|
+
wc.lpfnWndProc = self._wndproc_ref
|
|
113
|
+
wc.cbClsExtra = 0
|
|
114
|
+
wc.cbWndExtra = 0
|
|
115
|
+
wc.hInstance = self._hinstance
|
|
116
|
+
wc.hIcon = None
|
|
117
|
+
wc.hCursor = None
|
|
118
|
+
wc.hbrBackground = None
|
|
119
|
+
wc.lpszMenuName = None
|
|
120
|
+
wc.lpszClassName = self._class_name
|
|
121
|
+
wc.hIconSm = None
|
|
122
|
+
|
|
123
|
+
atom = w._user32.RegisterClassExW(ctypes.byref(wc))
|
|
124
|
+
if not atom:
|
|
125
|
+
self._error = 'RegisterClassExW failed: {}'.format(
|
|
126
|
+
ctypes.get_last_error())
|
|
127
|
+
self._ready.set()
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
hwnd = w._user32.CreateWindowExW(
|
|
131
|
+
0, self._class_name, '', 0,
|
|
132
|
+
0, 0, 0, 0,
|
|
133
|
+
w.HWND_MESSAGE, None, self._hinstance, None,
|
|
134
|
+
)
|
|
135
|
+
if not hwnd:
|
|
136
|
+
self._error = 'CreateWindowExW failed: {}'.format(
|
|
137
|
+
ctypes.get_last_error())
|
|
138
|
+
self._ready.set()
|
|
139
|
+
return
|
|
140
|
+
self._hwnd = hwnd
|
|
141
|
+
|
|
142
|
+
rid = w.RAWINPUTDEVICE()
|
|
143
|
+
rid.usUsagePage = w.HID_USAGE_PAGE_GENERIC
|
|
144
|
+
rid.usUsage = w.HID_USAGE_GENERIC_MOUSE
|
|
145
|
+
rid.dwFlags = w.RIDEV_INPUTSINK
|
|
146
|
+
rid.hwndTarget = hwnd
|
|
147
|
+
|
|
148
|
+
ok = w._user32.RegisterRawInputDevices(
|
|
149
|
+
ctypes.byref(rid), 1, ctypes.sizeof(w.RAWINPUTDEVICE))
|
|
150
|
+
if not ok:
|
|
151
|
+
self._error = 'RegisterRawInputDevices failed: {}'.format(
|
|
152
|
+
ctypes.get_last_error())
|
|
153
|
+
self._ready.set()
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# 强制为当前线程创建消息队列,确保 PostThreadMessageW 生效
|
|
157
|
+
msg = w.MSG()
|
|
158
|
+
w._user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, 0)
|
|
159
|
+
|
|
160
|
+
self._ready.set()
|
|
161
|
+
|
|
162
|
+
if self._debug:
|
|
163
|
+
import sys
|
|
164
|
+
sys.stderr.write(
|
|
165
|
+
'[win_mouse_listen] listening hwnd={:#x} tid={}\n'
|
|
166
|
+
.format(hwnd, self._thread_id))
|
|
167
|
+
sys.stderr.flush()
|
|
168
|
+
|
|
169
|
+
while True:
|
|
170
|
+
ret = w._user32.GetMessageW(ctypes.byref(msg), None, 0, 0)
|
|
171
|
+
if ret <= 0: # 0 = WM_QUIT, -1 = error
|
|
172
|
+
break
|
|
173
|
+
w._user32.TranslateMessage(ctypes.byref(msg))
|
|
174
|
+
w._user32.DispatchMessageW(ctypes.byref(msg))
|
|
175
|
+
|
|
176
|
+
except Exception:
|
|
177
|
+
self._error = _tb.format_exc()
|
|
178
|
+
self._ready.set()
|
|
179
|
+
|
|
180
|
+
finally:
|
|
181
|
+
if self._hwnd:
|
|
182
|
+
try:
|
|
183
|
+
w._user32.DestroyWindow(self._hwnd)
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
self._hwnd = None
|
|
187
|
+
if self._class_name and self._hinstance:
|
|
188
|
+
try:
|
|
189
|
+
w._user32.UnregisterClassW(
|
|
190
|
+
self._class_name, self._hinstance)
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
self._class_name = None
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------ WndProc
|
|
196
|
+
def _on_message(self, hwnd, msg, wparam, lparam):
|
|
197
|
+
if msg == w.WM_INPUT:
|
|
198
|
+
try:
|
|
199
|
+
self._handle_wm_input(lparam)
|
|
200
|
+
except Exception:
|
|
201
|
+
if self._debug:
|
|
202
|
+
_tb.print_exc()
|
|
203
|
+
return 0
|
|
204
|
+
if msg == w.WM_DESTROY:
|
|
205
|
+
w._user32.PostQuitMessage(0)
|
|
206
|
+
return 0
|
|
207
|
+
return w._user32.DefWindowProcW(hwnd, msg, wparam, lparam)
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------ Raw Input 解析
|
|
210
|
+
def _handle_wm_input(self, hrawinput):
|
|
211
|
+
size = ctypes.c_uint(0)
|
|
212
|
+
header_size = ctypes.sizeof(w.RAWINPUTHEADER)
|
|
213
|
+
|
|
214
|
+
# 第一次调用:取所需缓冲区大小
|
|
215
|
+
res = w._user32.GetRawInputData(
|
|
216
|
+
hrawinput, w.RID_INPUT,
|
|
217
|
+
None, ctypes.byref(size), header_size,
|
|
218
|
+
)
|
|
219
|
+
if res == 0xFFFFFFFF or size.value < ctypes.sizeof(w.RAWINPUT):
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
buf = ctypes.create_string_buffer(size.value)
|
|
223
|
+
|
|
224
|
+
# 第二次调用:真正读取
|
|
225
|
+
res = w._user32.GetRawInputData(
|
|
226
|
+
hrawinput, w.RID_INPUT,
|
|
227
|
+
buf, ctypes.byref(size), header_size,
|
|
228
|
+
)
|
|
229
|
+
if res == 0xFFFFFFFF:
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
raw = ctypes.cast(
|
|
233
|
+
buf, ctypes.POINTER(w.RAWINPUT)).contents
|
|
234
|
+
if raw.header.dwType != w.RIM_TYPEMOUSE:
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
self._process_mouse(raw.mouse)
|
|
238
|
+
|
|
239
|
+
def _process_mouse(self, mouse):
|
|
240
|
+
t = _time.time()
|
|
241
|
+
bf = mouse.buttons.button_flags.usButtonFlags
|
|
242
|
+
bd = mouse.buttons.button_flags.usButtonData
|
|
243
|
+
flags = mouse.usFlags
|
|
244
|
+
dx, dy = mouse.lLastX, mouse.lLastY
|
|
245
|
+
|
|
246
|
+
events = []
|
|
247
|
+
|
|
248
|
+
if bf & w.RI_MOUSE_LEFT_BUTTON_DOWN:
|
|
249
|
+
events.append(ButtonEvent(DOWN, LEFT, t))
|
|
250
|
+
if bf & w.RI_MOUSE_LEFT_BUTTON_UP:
|
|
251
|
+
events.append(ButtonEvent(UP, LEFT, t))
|
|
252
|
+
if bf & w.RI_MOUSE_RIGHT_BUTTON_DOWN:
|
|
253
|
+
events.append(ButtonEvent(DOWN, RIGHT, t))
|
|
254
|
+
if bf & w.RI_MOUSE_RIGHT_BUTTON_UP:
|
|
255
|
+
events.append(ButtonEvent(UP, RIGHT, t))
|
|
256
|
+
if bf & w.RI_MOUSE_MIDDLE_BUTTON_DOWN:
|
|
257
|
+
events.append(ButtonEvent(DOWN, MIDDLE, t))
|
|
258
|
+
if bf & w.RI_MOUSE_MIDDLE_BUTTON_UP:
|
|
259
|
+
events.append(ButtonEvent(UP, MIDDLE, t))
|
|
260
|
+
if bf & w.RI_MOUSE_BUTTON_4_DOWN:
|
|
261
|
+
events.append(ButtonEvent(DOWN, X, t))
|
|
262
|
+
if bf & w.RI_MOUSE_BUTTON_4_UP:
|
|
263
|
+
events.append(ButtonEvent(UP, X, t))
|
|
264
|
+
if bf & w.RI_MOUSE_BUTTON_5_DOWN:
|
|
265
|
+
events.append(ButtonEvent(DOWN, X2, t))
|
|
266
|
+
if bf & w.RI_MOUSE_BUTTON_5_UP:
|
|
267
|
+
events.append(ButtonEvent(UP, X2, t))
|
|
268
|
+
|
|
269
|
+
if bf & w.RI_MOUSE_WHEEL:
|
|
270
|
+
events.append(WheelEvent(ctypes.c_short(bd).value, t))
|
|
271
|
+
if bf & w.RI_MOUSE_HWHEEL:
|
|
272
|
+
events.append(WheelEvent(ctypes.c_short(bd).value, t))
|
|
273
|
+
|
|
274
|
+
if dx != 0 or dy != 0 or (flags & w.MOUSE_MOVE_ABSOLUTE):
|
|
275
|
+
pt = w.POINT()
|
|
276
|
+
w._user32.GetCursorPos(ctypes.byref(pt))
|
|
277
|
+
|
|
278
|
+
if flags & w.MOUSE_MOVE_ABSOLUTE:
|
|
279
|
+
# 绝对模式(触摸板 / RDP)下 lLastX/lLastY 不是相对位移,
|
|
280
|
+
# 直接给 0,避免误导。
|
|
281
|
+
rdx = rdy = 0
|
|
282
|
+
else:
|
|
283
|
+
rdx, rdy = dx, dy
|
|
284
|
+
|
|
285
|
+
events.append(MoveEvent(pt.x, pt.y, t, rdx, rdy))
|
|
286
|
+
|
|
287
|
+
if not events:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
buf = self._buffer
|
|
291
|
+
dq = self._dispatch_queue
|
|
292
|
+
has_handlers = bool(self._handlers)
|
|
293
|
+
|
|
294
|
+
for e in events:
|
|
295
|
+
if isinstance(e, ButtonEvent):
|
|
296
|
+
if e.event_type == UP:
|
|
297
|
+
self._pressed.discard(e.button)
|
|
298
|
+
else:
|
|
299
|
+
self._pressed.add(e.button)
|
|
300
|
+
buf.append(e)
|
|
301
|
+
if has_handlers:
|
|
302
|
+
dq.append(e)
|
|
303
|
+
|
|
304
|
+
# ------------------------------------------------------------------ 分发线程
|
|
305
|
+
def _dispatcher_loop(self):
|
|
306
|
+
while not self._shutdown.is_set():
|
|
307
|
+
if not self._dispatch_queue:
|
|
308
|
+
_time.sleep(0.001)
|
|
309
|
+
continue
|
|
310
|
+
try:
|
|
311
|
+
event = self._dispatch_queue.popleft()
|
|
312
|
+
except IndexError:
|
|
313
|
+
continue
|
|
314
|
+
for h in self._handlers:
|
|
315
|
+
try:
|
|
316
|
+
h(event)
|
|
317
|
+
except Exception:
|
|
318
|
+
if self._debug:
|
|
319
|
+
_tb.print_exc()
|
|
320
|
+
|
|
321
|
+
# ------------------------------------------------------------------ 公共 API
|
|
322
|
+
def hook(self, callback):
|
|
323
|
+
if callback not in self._handlers:
|
|
324
|
+
self._handlers = self._handlers + [callback]
|
|
325
|
+
if not self._running:
|
|
326
|
+
self.start()
|
|
327
|
+
return callback
|
|
328
|
+
|
|
329
|
+
def unhook(self, callback):
|
|
330
|
+
self._handlers = [h for h in self._handlers if h != callback]
|
|
331
|
+
|
|
332
|
+
def unhook_all(self):
|
|
333
|
+
self._handlers = []
|
|
334
|
+
|
|
335
|
+
def poll(self):
|
|
336
|
+
try:
|
|
337
|
+
return self._buffer.popleft()
|
|
338
|
+
except IndexError:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
def get_events(self, max_count=None):
|
|
342
|
+
if max_count is None:
|
|
343
|
+
events = list(self._buffer)
|
|
344
|
+
self._buffer.clear()
|
|
345
|
+
return events
|
|
346
|
+
out = []
|
|
347
|
+
for _ in range(max_count):
|
|
348
|
+
try:
|
|
349
|
+
out.append(self._buffer.popleft())
|
|
350
|
+
except IndexError:
|
|
351
|
+
break
|
|
352
|
+
return out
|
|
353
|
+
|
|
354
|
+
def clear_buffer(self):
|
|
355
|
+
self._buffer.clear()
|
|
356
|
+
|
|
357
|
+
def pending_count(self):
|
|
358
|
+
return len(self._buffer)
|
|
359
|
+
|
|
360
|
+
def is_pressed(self, button=LEFT):
|
|
361
|
+
return button in self._pressed
|
|
362
|
+
|
|
363
|
+
def position(self):
|
|
364
|
+
pt = w.POINT()
|
|
365
|
+
w._user32.GetCursorPos(ctypes.byref(pt))
|
|
366
|
+
return (pt.x, pt.y)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Event types and constants, compatible with the `mouse` library."""
|
|
3
|
+
from collections import namedtuple
|
|
4
|
+
|
|
5
|
+
# Buttons
|
|
6
|
+
LEFT = 'left'
|
|
7
|
+
RIGHT = 'right'
|
|
8
|
+
MIDDLE = 'middle'
|
|
9
|
+
X = 'x'
|
|
10
|
+
X2 = 'x2'
|
|
11
|
+
|
|
12
|
+
# Event types
|
|
13
|
+
UP = 'up'
|
|
14
|
+
DOWN = 'down'
|
|
15
|
+
|
|
16
|
+
# Wheel directions (for future use)
|
|
17
|
+
VERTICAL = 'vertical'
|
|
18
|
+
HORIZONTAL = 'horizontal'
|
|
19
|
+
|
|
20
|
+
ButtonEvent = namedtuple('ButtonEvent', ['event_type', 'button', 'time'])
|
|
21
|
+
WheelEvent = namedtuple('WheelEvent', ['delta', 'time'])
|
|
22
|
+
MoveEvent = namedtuple('MoveEvent', ['x', 'y', 'time', 'dx', 'dy'])
|
|
23
|
+
MoveEvent.__new__.__defaults__ = (0, 0)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
'LEFT', 'RIGHT', 'MIDDLE', 'X', 'X2',
|
|
28
|
+
'UP', 'DOWN', 'VERTICAL', 'HORIZONTAL',
|
|
29
|
+
'ButtonEvent', 'WheelEvent', 'MoveEvent',
|
|
30
|
+
]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Win32 ctypes bindings for the Raw Input API.
|
|
3
|
+
|
|
4
|
+
Raw Input is a pure-listening mechanism: mouse events are processed by the
|
|
5
|
+
system normally, and a *copy* is delivered to our hidden window as WM_INPUT.
|
|
6
|
+
No hook is inserted into the input chain, so we can never block the mouse.
|
|
7
|
+
"""
|
|
8
|
+
import sys
|
|
9
|
+
import ctypes
|
|
10
|
+
|
|
11
|
+
if not sys.platform.startswith('win'):
|
|
12
|
+
raise ImportError("win_mouse_listen is Windows-only.")
|
|
13
|
+
|
|
14
|
+
_user32 = ctypes.WinDLL('user32', use_last_error=True)
|
|
15
|
+
_kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Constants
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
WM_INPUT = 0x00FF
|
|
22
|
+
WM_DESTROY = 0x0002
|
|
23
|
+
WM_CLOSE = 0x0010
|
|
24
|
+
WM_QUIT = 0x0012
|
|
25
|
+
|
|
26
|
+
HWND_MESSAGE = ctypes.c_void_p(-3)
|
|
27
|
+
|
|
28
|
+
RIDEV_INPUTSINK = 0x00000100
|
|
29
|
+
RIDEV_REMOVE = 0x00000001
|
|
30
|
+
RID_INPUT = 0x10000003
|
|
31
|
+
RIM_TYPEMOUSE = 0
|
|
32
|
+
|
|
33
|
+
HID_USAGE_PAGE_GENERIC = 0x01
|
|
34
|
+
HID_USAGE_GENERIC_MOUSE = 0x02
|
|
35
|
+
|
|
36
|
+
MOUSE_MOVE_RELATIVE = 0x00
|
|
37
|
+
MOUSE_MOVE_ABSOLUTE = 0x01
|
|
38
|
+
|
|
39
|
+
RI_MOUSE_LEFT_BUTTON_DOWN = 0x0001
|
|
40
|
+
RI_MOUSE_LEFT_BUTTON_UP = 0x0002
|
|
41
|
+
RI_MOUSE_RIGHT_BUTTON_DOWN = 0x0004
|
|
42
|
+
RI_MOUSE_RIGHT_BUTTON_UP = 0x0008
|
|
43
|
+
RI_MOUSE_MIDDLE_BUTTON_DOWN = 0x0010
|
|
44
|
+
RI_MOUSE_MIDDLE_BUTTON_UP = 0x0020
|
|
45
|
+
RI_MOUSE_BUTTON_4_DOWN = 0x0040
|
|
46
|
+
RI_MOUSE_BUTTON_4_UP = 0x0080
|
|
47
|
+
RI_MOUSE_BUTTON_5_DOWN = 0x0100
|
|
48
|
+
RI_MOUSE_BUTTON_5_UP = 0x0200
|
|
49
|
+
RI_MOUSE_WHEEL = 0x0400
|
|
50
|
+
RI_MOUSE_HWHEEL = 0x0800
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Structures
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
LRESULT = ctypes.c_ssize_t
|
|
57
|
+
WNDPROC = ctypes.WINFUNCTYPE(
|
|
58
|
+
LRESULT,
|
|
59
|
+
ctypes.c_void_p, # HWND
|
|
60
|
+
ctypes.c_uint, # UINT
|
|
61
|
+
ctypes.c_size_t, # WPARAM
|
|
62
|
+
ctypes.c_ssize_t, # LPARAM
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class POINT(ctypes.Structure):
|
|
67
|
+
_fields_ = [('x', ctypes.c_long), ('y', ctypes.c_long)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MSG(ctypes.Structure):
|
|
71
|
+
_fields_ = [
|
|
72
|
+
('hwnd', ctypes.c_void_p),
|
|
73
|
+
('message', ctypes.c_uint),
|
|
74
|
+
('wParam', ctypes.c_size_t),
|
|
75
|
+
('lParam', ctypes.c_ssize_t),
|
|
76
|
+
('time', ctypes.c_ulong),
|
|
77
|
+
('pt', POINT),
|
|
78
|
+
('lPrivate', ctypes.c_ulong),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class WNDCLASSEXW(ctypes.Structure):
|
|
83
|
+
_fields_ = [
|
|
84
|
+
('cbSize', ctypes.c_uint),
|
|
85
|
+
('style', ctypes.c_uint),
|
|
86
|
+
('lpfnWndProc', WNDPROC),
|
|
87
|
+
('cbClsExtra', ctypes.c_int),
|
|
88
|
+
('cbWndExtra', ctypes.c_int),
|
|
89
|
+
('hInstance', ctypes.c_void_p),
|
|
90
|
+
('hIcon', ctypes.c_void_p),
|
|
91
|
+
('hCursor', ctypes.c_void_p),
|
|
92
|
+
('hbrBackground', ctypes.c_void_p),
|
|
93
|
+
('lpszMenuName', ctypes.c_wchar_p),
|
|
94
|
+
('lpszClassName', ctypes.c_wchar_p),
|
|
95
|
+
('hIconSm', ctypes.c_void_p),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RAWINPUTDEVICE(ctypes.Structure):
|
|
100
|
+
_fields_ = [
|
|
101
|
+
('usUsagePage', ctypes.c_ushort),
|
|
102
|
+
('usUsage', ctypes.c_ushort),
|
|
103
|
+
('dwFlags', ctypes.c_ulong),
|
|
104
|
+
('hwndTarget', ctypes.c_void_p),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RAWINPUTHEADER(ctypes.Structure):
|
|
109
|
+
_fields_ = [
|
|
110
|
+
('dwType', ctypes.c_ulong),
|
|
111
|
+
('dwSize', ctypes.c_ulong),
|
|
112
|
+
('hDevice', ctypes.c_void_p),
|
|
113
|
+
('wParam', ctypes.c_size_t),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _RAWMOUSE_BUTTONS(ctypes.Structure):
|
|
118
|
+
_fields_ = [
|
|
119
|
+
('usButtonFlags', ctypes.c_ushort),
|
|
120
|
+
('usButtonData', ctypes.c_ushort),
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _RAWMOUSE_BUTTONS_UNION(ctypes.Union):
|
|
125
|
+
_fields_ = [
|
|
126
|
+
('ulButtons', ctypes.c_ulong),
|
|
127
|
+
('button_flags', _RAWMOUSE_BUTTONS),
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class RAWMOUSE(ctypes.Structure):
|
|
132
|
+
_fields_ = [
|
|
133
|
+
('usFlags', ctypes.c_ushort),
|
|
134
|
+
('_padding', ctypes.c_ushort),
|
|
135
|
+
('buttons', _RAWMOUSE_BUTTONS_UNION),
|
|
136
|
+
('ulRawButtons', ctypes.c_ulong),
|
|
137
|
+
('lLastX', ctypes.c_long),
|
|
138
|
+
('lLastY', ctypes.c_long),
|
|
139
|
+
('ulExtraInformation', ctypes.c_ulong),
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class RAWINPUT(ctypes.Structure):
|
|
144
|
+
_fields_ = [
|
|
145
|
+
('header', RAWINPUTHEADER),
|
|
146
|
+
('mouse', RAWMOUSE),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Function signatures
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
_user32.DefWindowProcW.argtypes = [
|
|
154
|
+
ctypes.c_void_p, ctypes.c_uint, ctypes.c_size_t, ctypes.c_ssize_t,
|
|
155
|
+
]
|
|
156
|
+
_user32.DefWindowProcW.restype = LRESULT
|
|
157
|
+
|
|
158
|
+
_user32.GetMessageW.argtypes = [
|
|
159
|
+
ctypes.POINTER(MSG), ctypes.c_void_p, ctypes.c_uint, ctypes.c_uint,
|
|
160
|
+
]
|
|
161
|
+
_user32.GetMessageW.restype = ctypes.c_int
|
|
162
|
+
|
|
163
|
+
_user32.PeekMessageW.argtypes = [
|
|
164
|
+
ctypes.POINTER(MSG), ctypes.c_void_p,
|
|
165
|
+
ctypes.c_uint, ctypes.c_uint, ctypes.c_uint,
|
|
166
|
+
]
|
|
167
|
+
_user32.PeekMessageW.restype = ctypes.c_int
|
|
168
|
+
|
|
169
|
+
_user32.TranslateMessage.argtypes = [ctypes.POINTER(MSG)]
|
|
170
|
+
_user32.TranslateMessage.restype = ctypes.c_int
|
|
171
|
+
|
|
172
|
+
_user32.DispatchMessageW.argtypes = [ctypes.POINTER(MSG)]
|
|
173
|
+
_user32.DispatchMessageW.restype = LRESULT
|
|
174
|
+
|
|
175
|
+
_user32.PostQuitMessage.argtypes = [ctypes.c_int]
|
|
176
|
+
_user32.PostQuitMessage.restype = None
|
|
177
|
+
|
|
178
|
+
_user32.PostThreadMessageW.argtypes = [
|
|
179
|
+
ctypes.c_ulong, ctypes.c_uint, ctypes.c_size_t, ctypes.c_ssize_t,
|
|
180
|
+
]
|
|
181
|
+
_user32.PostThreadMessageW.restype = ctypes.c_int
|
|
182
|
+
|
|
183
|
+
_user32.RegisterClassExW.argtypes = [ctypes.POINTER(WNDCLASSEXW)]
|
|
184
|
+
_user32.RegisterClassExW.restype = ctypes.c_ushort
|
|
185
|
+
|
|
186
|
+
_user32.UnregisterClassW.argtypes = [ctypes.c_wchar_p, ctypes.c_void_p]
|
|
187
|
+
_user32.UnregisterClassW.restype = ctypes.c_int
|
|
188
|
+
|
|
189
|
+
_user32.CreateWindowExW.argtypes = [
|
|
190
|
+
ctypes.c_ulong, ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_ulong,
|
|
191
|
+
ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int,
|
|
192
|
+
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p,
|
|
193
|
+
]
|
|
194
|
+
_user32.CreateWindowExW.restype = ctypes.c_void_p
|
|
195
|
+
|
|
196
|
+
_user32.DestroyWindow.argtypes = [ctypes.c_void_p]
|
|
197
|
+
_user32.DestroyWindow.restype = ctypes.c_int
|
|
198
|
+
|
|
199
|
+
_user32.RegisterRawInputDevices.argtypes = [
|
|
200
|
+
ctypes.POINTER(RAWINPUTDEVICE), ctypes.c_uint, ctypes.c_uint,
|
|
201
|
+
]
|
|
202
|
+
_user32.RegisterRawInputDevices.restype = ctypes.c_int
|
|
203
|
+
|
|
204
|
+
_user32.GetRawInputData.argtypes = [
|
|
205
|
+
ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p,
|
|
206
|
+
ctypes.POINTER(ctypes.c_uint), ctypes.c_uint,
|
|
207
|
+
]
|
|
208
|
+
_user32.GetRawInputData.restype = ctypes.c_uint
|
|
209
|
+
|
|
210
|
+
_user32.GetCursorPos.argtypes = [ctypes.POINTER(POINT)]
|
|
211
|
+
_user32.GetCursorPos.restype = ctypes.c_int
|
|
212
|
+
|
|
213
|
+
_kernel32.GetModuleHandleW.argtypes = [ctypes.c_wchar_p]
|
|
214
|
+
_kernel32.GetModuleHandleW.restype = ctypes.c_void_p
|
|
215
|
+
|
|
216
|
+
_kernel32.GetCurrentThreadId.restype = ctypes.c_ulong
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: win-mouse-listener
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Non-blocking Windows mouse listener using Raw Input API
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://example.invalid/win-mouse-listener
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# win-mouse-listen
|
|
13
|
+
|
|
14
|
+
基于 **Windows Raw Input API** 的非阻塞鼠标监听模块。协议与 [`mouse`](https://pypi.org/project/mouse/) 库兼容,但**不会插入鼠标事件链**,因此:
|
|
15
|
+
|
|
16
|
+
- 回调无论多慢,**都不会让鼠标卡顿**;
|
|
17
|
+
- **不需要管理员权限**;
|
|
18
|
+
- 支持按键、滚轮、移动,移动事件额外附带设备原始增量 `dx / dy`,可用于「触顶/触底持续移动」等场景。
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 为什么不用 `WH_MOUSE_LL` 钩子
|
|
23
|
+
|
|
24
|
+
`mouse` 库底层使用 `SetWindowsHookEx(WH_MOUSE_LL)`。虽然它也能全局监听,但它**位于鼠标事件链上**——系统必须等你的钩子返回,才会继续把事件传给其他程序。只要你的回调里出现了锁等待、GIL 争抢、I/O 阻塞,鼠标就会卡。
|
|
25
|
+
|
|
26
|
+
Raw Input 完全不同:鼠标事件由系统正常处理,同时复制一份 `WM_INPUT` 消息投递给你的隐藏窗口。**你的窗口处理再慢,也不会影响鼠标**。
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 安装
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install win-mouse-listen
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
或从本地 whl 安装:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install dist/win_mouse_listen-0.1.0-py3-none-any.whl
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 快速开始
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import time
|
|
48
|
+
import win_mouse_listen as wm
|
|
49
|
+
|
|
50
|
+
# 注册回调,第一次 hook 会自动启动监听
|
|
51
|
+
wm.hook(lambda ev: print('event:', ev))
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
while True:
|
|
55
|
+
time.sleep(1)
|
|
56
|
+
except KeyboardInterrupt:
|
|
57
|
+
wm.stop()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
或者使用类接口:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from win_mouse_listen import Listener
|
|
64
|
+
|
|
65
|
+
with Listener() as l:
|
|
66
|
+
l.hook(print)
|
|
67
|
+
time.sleep(10)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 事件协议
|
|
73
|
+
|
|
74
|
+
与 `mouse` 库兼容的 namedtuple:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
ButtonEvent(event_type, button, time)
|
|
78
|
+
WheelEvent(delta, time)
|
|
79
|
+
MoveEvent(x, y, time, dx, dy)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- `event_type`:`'up'` 或 `'down'`
|
|
83
|
+
- `button`:`LEFT` / `RIGHT` / `MIDDLE` / `X` / `X2`
|
|
84
|
+
- `time`:Unix 时间戳(float,秒)
|
|
85
|
+
- `x, y`:屏幕坐标,由 `GetCursorPos` 获取,**钳制在屏幕内**
|
|
86
|
+
- `dx, dy`:本次事件的**设备原始增量**,即使光标已经贴到屏幕边缘,仍会持续给出同方向的非零值
|
|
87
|
+
|
|
88
|
+
> **注意**:`MoveEvent` 是 5 字段版本,与 `mouse.MoveEvent` 的 3 字段版本**不兼容 `isinstance`**。如果你的代码依赖 `mouse` 库的类型,请改用 duck typing 判断字段。
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## API
|
|
93
|
+
|
|
94
|
+
### 回调
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
wm.hook(callback) # 注册回调,返回 callback
|
|
98
|
+
wm.unhook(callback) # 注销单个回调
|
|
99
|
+
wm.unhook_all() # 注销所有回调
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
回调在独立的分发线程中执行,**阻塞回调不会影响鼠标**。
|
|
103
|
+
|
|
104
|
+
### 生命周期
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
wm.start() # 显式启动(hook 会自动启动)
|
|
108
|
+
wm.stop() # 停止并重置默认监听器
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### 非阻塞查询
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
wm.poll() # 取一个事件,没有则返回 None
|
|
115
|
+
wm.get_events(max_count=None) # 批量取事件
|
|
116
|
+
wm.clear_buffer() # 清空缓冲区
|
|
117
|
+
wm.pending_count() # 当前缓冲的事件数量
|
|
118
|
+
wm.is_pressed(button=LEFT) # 指定按键是否按下
|
|
119
|
+
wm.position() # 当前鼠标屏幕坐标 (x, y)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## 典型用法:检测「触顶 / 触底持续移动」
|
|
125
|
+
|
|
126
|
+
某些场景下需要判断鼠标已经顶到屏幕边缘,但用户仍在继续往同方向移动。传统做法是用「越界负坐标」反推增量,麻烦又容易出错。使用 `dx / dy` 直接判断即可:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
import win_mouse_listen as wm
|
|
130
|
+
|
|
131
|
+
SCREEN_TOP_Y = 0
|
|
132
|
+
SCREEN_BOTTOM_Y = 1079
|
|
133
|
+
|
|
134
|
+
def on_move(ev):
|
|
135
|
+
if ev.y <= SCREEN_TOP_Y and ev.dy < 0:
|
|
136
|
+
print('向上越界移动', ev.dy)
|
|
137
|
+
elif ev.y >= SCREEN_BOTTOM_Y and ev.dy > 0:
|
|
138
|
+
print('向下越界移动', ev.dy)
|
|
139
|
+
|
|
140
|
+
wm.hook(on_move)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
> 提示:高回报率鼠标每秒会产生数百到上千个 `MoveEvent`,每个事件的 `dx/dy` 通常只有个位数。如果需要按距离阈值触发逻辑,建议在时间窗口内累加。
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 限制与注意事项
|
|
148
|
+
|
|
149
|
+
1. **绝对模式**:触摸板、数位板、RDP 远程桌面等设备上报的是**绝对坐标**(`usFlags & MOUSE_MOVE_ABSOLUTE`)。此时 `lLastX/lLastY` 不是增量,模块会将 `dx/dy` 置 0,避免出现巨大数值。物理鼠标不受影响。
|
|
150
|
+
|
|
151
|
+
2. **多显示器**:`x, y` 来自 `GetCursorPos`,在虚拟屏幕坐标系下,副屏位于主屏左侧或上方时 `x/y` 本来就可能为负。这和「触顶越界」是两回事。
|
|
152
|
+
|
|
153
|
+
3. **仅支持 Windows**:模块依赖 `user32.dll`,其他平台会直接 `ImportError`。
|
|
154
|
+
|
|
155
|
+
4. **不需要管理员**:Raw Input 对普通用户会话开放,无需提权。
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 打包
|
|
160
|
+
|
|
161
|
+
项目根目录执行:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pip install build
|
|
165
|
+
python -m build
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
产物在 `dist/`:
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
dist/
|
|
172
|
+
├── win_mouse_listen-0.1.0-py3-none-any.whl
|
|
173
|
+
└── win_mouse_listen-0.1.0.tar.gz
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
本地安装测试:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
pip install dist/win_mouse_listen-0.1.0-py3-none-any.whl
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## 项目结构
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
win_mouse_listen/
|
|
188
|
+
├── __init__.py # 公共 API 与模块级单例
|
|
189
|
+
├── _types.py # 事件协议(ButtonEvent / WheelEvent / MoveEvent)
|
|
190
|
+
├── _win32.py # ctypes 绑定与常量
|
|
191
|
+
└── _listener.py # 核心监听器实现
|
|
192
|
+
pyproject.toml
|
|
193
|
+
README.md
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## 设计要点
|
|
199
|
+
|
|
200
|
+
- **Raw Input 而非全局钩子**:鼠标事件不经过本模块,回调再慢也不会卡鼠标。
|
|
201
|
+
- **隐藏消息窗口**:`HWND_MESSAGE` 类型窗口,不出现在任务栏。
|
|
202
|
+
- **双重缓冲**:内部维护 `deque(maxlen=N)`,满了自动丢弃最旧事件,永不阻塞钩子线程。
|
|
203
|
+
- **无锁读取**:利用 CPython 中 `deque.append/popleft`、`list` 引用替换的原子性,避免在热路径上加锁。
|
|
204
|
+
- **分发线程独立**:用户回调在专用线程执行,隔离用户代码。
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
win_mouse_listener/__init__.py,sha256=FR-fjfJaBoxvAdyuF6mqKbO0fgua4iVxiZq4_FNOryQ,2317
|
|
2
|
+
win_mouse_listener/_listener.py,sha256=JWlpmIsj7JVrV0ds5ISs42cYS4WsE1mogtxEIduiFHg,12461
|
|
3
|
+
win_mouse_listener/_types.py,sha256=dVVreO4Kbrlorz8gzRJXmQABF7dZICg3TZwDEhKAP_g,746
|
|
4
|
+
win_mouse_listener/_win32.py,sha256=oDanjiWks3L6QbgSqHXhZ1qJnXewBLKJOXkOvwmyzTk,6768
|
|
5
|
+
win_mouse_listener-0.1.0.dist-info/METADATA,sha256=R6vnzRl0VMsFKJQ_VhVZ6d2_UqPt5Uzvt5fS9CqWpYM,6262
|
|
6
|
+
win_mouse_listener-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
win_mouse_listener-0.1.0.dist-info/top_level.txt,sha256=pidTNon3_LsMDFmGANGqOq7yHr5xvHpR-lfYYsV09PU,19
|
|
8
|
+
win_mouse_listener-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
win_mouse_listener
|