mathcraft-window-tools 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.
mathcraft/_input.py ADDED
@@ -0,0 +1,325 @@
1
+ """Opt-in, foreground-only hook experiment, not an input security boundary."""
2
+ import ctypes
3
+ from ctypes import wintypes as w
4
+ import queue
5
+ import threading
6
+ import time
7
+
8
+ LRESULT = ctypes.c_ssize_t
9
+ HOOKPROC = ctypes.WINFUNCTYPE(LRESULT, ctypes.c_int, w.WPARAM, w.LPARAM)
10
+
11
+
12
+ class KeyboardData(ctypes.Structure):
13
+ _fields_ = [('vk', w.DWORD), ('scan', w.DWORD), ('flags', w.DWORD),
14
+ ('time', w.DWORD), ('extra', ctypes.c_size_t)]
15
+
16
+
17
+ class MouseData(ctypes.Structure):
18
+ _fields_ = [('point', w.POINT), ('data', w.DWORD), ('flags', w.DWORD),
19
+ ('time', w.DWORD), ('extra', ctypes.c_size_t)]
20
+
21
+
22
+ class ForegroundInput:
23
+ """Suppress later LL hooks for supported keys/buttons in our client area.
24
+
25
+ Modifiers, system shortcuts and unsupported input pass through.
26
+ Raw Input and kernel observers are outside this experiment's scope.
27
+ """
28
+ def __init__(self, hwnd, virtual_mouse=False):
29
+ self.hwnd = hwnd
30
+ self.virtual_mouse = virtual_mouse
31
+ self.cursor_hwnd = None
32
+ self._anchor = None
33
+ self._position = None
34
+ self.events = queue.Queue(maxsize=512)
35
+ self.enabled = True
36
+ self.heartbeat = time.monotonic()
37
+ self._stop = threading.Event()
38
+ self._ready = threading.Event()
39
+ self._error = None
40
+ self._keys = set()
41
+ self._buttons = set()
42
+ self.user = ctypes.WinDLL('user32', use_last_error=True)
43
+ self.kernel = ctypes.WinDLL('kernel32', use_last_error=True)
44
+ specs = {
45
+ 'SetWindowsHookExW': ([ctypes.c_int, HOOKPROC, w.HINSTANCE, w.DWORD], w.HANDLE),
46
+ 'UnhookWindowsHookEx': ([w.HANDLE], w.BOOL),
47
+ 'CallNextHookEx': ([w.HANDLE, ctypes.c_int, w.WPARAM, w.LPARAM], LRESULT),
48
+ 'SetThreadDpiAwarenessContext': ([w.HANDLE], w.HANDLE),
49
+ 'GetForegroundWindow': ([], w.HWND),
50
+ 'GetAsyncKeyState': ([ctypes.c_int], ctypes.c_short),
51
+ 'GetKeyState': ([ctypes.c_int], ctypes.c_short),
52
+ 'GetWindowThreadProcessId': ([w.HWND, ctypes.POINTER(w.DWORD)], w.DWORD),
53
+ 'GetKeyboardLayout': ([w.DWORD], w.HANDLE),
54
+ 'ToUnicodeEx': ([w.UINT, w.UINT, ctypes.POINTER(w.BYTE), w.LPWSTR,
55
+ ctypes.c_int, w.UINT, w.HANDLE], ctypes.c_int),
56
+ 'ScreenToClient': ([w.HWND, ctypes.POINTER(w.POINT)], w.BOOL),
57
+ 'ClientToScreen': ([w.HWND, ctypes.POINTER(w.POINT)], w.BOOL),
58
+ 'GetCursorPos': ([ctypes.POINTER(w.POINT)], w.BOOL),
59
+ 'SetCursorPos': ([ctypes.c_int, ctypes.c_int], w.BOOL),
60
+ 'GetClientRect': ([w.HWND, ctypes.POINTER(w.RECT)], w.BOOL),
61
+ 'WindowFromPoint': ([w.POINT], w.HWND),
62
+ 'GetAncestor': ([w.HWND, w.UINT], w.HWND),
63
+ 'PeekMessageW': ([ctypes.POINTER(w.MSG), w.HWND, w.UINT, w.UINT, w.UINT], w.BOOL),
64
+ 'TranslateMessage': ([ctypes.POINTER(w.MSG)], w.BOOL),
65
+ 'DispatchMessageW': ([ctypes.POINTER(w.MSG)], LRESULT),
66
+ }
67
+ for name, (args, result) in specs.items():
68
+ function = getattr(self.user, name)
69
+ function.argtypes, function.restype = args, result
70
+ self.kernel.GetModuleHandleW.argtypes = [w.LPCWSTR]
71
+ self.kernel.GetModuleHandleW.restype = w.HMODULE
72
+ self._keyboard_callback = HOOKPROC(self._keyboard)
73
+ self._mouse_callback = HOOKPROC(self._mouse)
74
+ self._thread = threading.Thread(target=self._run, name='mathcraft-input', daemon=True)
75
+
76
+ def start(self):
77
+ self._thread.start()
78
+ if not self._ready.wait(3):
79
+ self.close()
80
+ raise RuntimeError('Input hook startup timed out')
81
+ if self._error:
82
+ raise self._error
83
+
84
+ def close(self):
85
+ self.enabled = False
86
+ self._stop.set()
87
+ if self._thread.is_alive():
88
+ self._thread.join(timeout=3)
89
+ if self._thread.is_alive():
90
+ raise RuntimeError('Input hook worker did not stop')
91
+
92
+ def disable(self, reason):
93
+ self.enabled = False
94
+ self.reason = reason
95
+
96
+ def _active(self):
97
+ return (self.enabled and time.monotonic()-self.heartbeat < 0.5
98
+ and self.user.GetForegroundWindow() == self.hwnd)
99
+
100
+ def _put(self, event):
101
+ try:
102
+ self.events.put_nowait(event)
103
+ return True
104
+ except queue.Full:
105
+ self.disable('Input queue overflow; interception disabled')
106
+ return False
107
+
108
+ def _next(self, code, message, address):
109
+ return self.user.CallNextHookEx(None, code, message, address)
110
+
111
+ def _down(self, key):
112
+ return bool(self.user.GetAsyncKeyState(key) & 0x8000)
113
+
114
+ def _keyboard(self, code, message, address):
115
+ try:
116
+ if code < 0:
117
+ return self._next(code, message, address)
118
+ data = ctypes.cast(address, ctypes.POINTER(KeyboardData)).contents
119
+ key = data.vk
120
+ down = message in (0x100, 0x104)
121
+ if down and key == 0x7B and self._down(0x11) and self._down(0x12):
122
+ self.disable('Ctrl+Alt+F12: interception disabled')
123
+ return self._next(code, message, address)
124
+ if not down and key in self._keys:
125
+ self._keys.discard(key)
126
+ return 1
127
+ if down and key in self._keys and not self._active():
128
+ self._keys.discard(key)
129
+ # Keep OS modifier state and shell shortcuts working normally.
130
+ supported = (key in (8, 9, 13, 27, 32, 33, 34, 35, 36, 37, 38, 39, 40, 45, 46)
131
+ or 0x30 <= key <= 0x5A or 0x60 <= key <= 0x6F
132
+ or 0xBA <= key <= 0xE2)
133
+ if (not down or not supported or data.flags & 0x10 or not self._active()
134
+ or self._down(0x12) or self._down(0x5B) or self._down(0x5C)):
135
+ return self._next(code, message, address)
136
+ shift, control = self._down(0x10), self._down(0x11)
137
+ if control and key not in (65, 67, 86, 88, 89, 90):
138
+ # Let Tk handle shortcuts we do not implement rather than drop them.
139
+ return self._next(code, message, address)
140
+ state = (w.BYTE * 256)()
141
+ state[key] = 0x80
142
+ state[0x10] = 0x80 if shift else 0
143
+ state[0x11] = 0x80 if control else 0
144
+ state[0x14] = self.user.GetKeyState(0x14) & 1
145
+ thread = self.user.GetWindowThreadProcessId(self.hwnd, None)
146
+ layout = self.user.GetKeyboardLayout(thread)
147
+ buffer = ctypes.create_unicode_buffer(16)
148
+ count = self.user.ToUnicodeEx(key, data.scan, state, buffer, 16, 4, layout)
149
+ if count < 0:
150
+ self.disable('Dead-key layout: interception disabled')
151
+ return self._next(code, message, address)
152
+ text = buffer[:count] if count > 0 else ''
153
+ if self._put(('key', key, text, shift, control)):
154
+ self._keys.add(key)
155
+ return 1
156
+ except Exception:
157
+ self.disable('Keyboard hook error; interception disabled')
158
+ return self._next(code, message, address)
159
+
160
+ @property
161
+ def pointer_position(self):
162
+ return self._position if self._anchor is not None else None
163
+
164
+ def _client_bounds(self):
165
+ origin, rect = w.POINT(), w.RECT()
166
+ if (not self.user.ClientToScreen(self.hwnd, ctypes.byref(origin))
167
+ or not self.user.GetClientRect(self.hwnd, ctypes.byref(rect))):
168
+ return None
169
+ return origin.x, origin.y, origin.x+rect.right, origin.y+rect.bottom
170
+
171
+ @staticmethod
172
+ def _inside(bounds, x, y):
173
+ return bool(bounds and bounds[0] <= x < bounds[2] and bounds[1] <= y < bounds[3])
174
+
175
+ def _release_pointer(self, restore=True):
176
+ anchor, position = self._anchor, self._position
177
+ self._anchor = None
178
+ self._position = None
179
+ if anchor is not None and restore:
180
+ actual = w.POINT()
181
+ # Do not overwrite a cursor relocation made by another application.
182
+ if (self.user.GetCursorPos(ctypes.byref(actual))
183
+ and (actual.x, actual.y) == anchor):
184
+ if not self.user.SetCursorPos(*position):
185
+ self.disable('Cursor restoration failed; interception disabled')
186
+
187
+ def _sync_pointer(self):
188
+ if self._anchor is not None and not self._active():
189
+ self._release_pointer()
190
+
191
+ def _virtual_move(self, x, y):
192
+ bounds = self._client_bounds()
193
+ if self._anchor is None:
194
+ actual = w.POINT()
195
+ if (not self.user.GetCursorPos(ctypes.byref(actual))
196
+ or not self._inside(bounds, actual.x, actual.y)):
197
+ return False
198
+ hit = self.user.WindowFromPoint(actual)
199
+ if self.user.GetAncestor(hit, 2) not in (self.hwnd, self.cursor_hwnd):
200
+ return False
201
+ self._anchor = (actual.x, actual.y)
202
+ self._position = self._anchor
203
+ ax, ay = self._anchor
204
+ px, py = self._position
205
+ self._position = (px+x-ax, py+y-ay)
206
+ if not self._inside(bounds, *self._position):
207
+ self._release_pointer()
208
+ return True
209
+ if self._buttons:
210
+ if not self._put(('motion', tuple(self._buttons), *self._position)):
211
+ self._release_pointer()
212
+ return True
213
+
214
+ def _mouse(self, code, message, address):
215
+ # Windows can invoke hooks under a different DPI context than our pump.
216
+ change_dpi = getattr(self.user, 'SetThreadDpiAwarenessContext', None)
217
+ previous = change_dpi(ctypes.c_void_p(-4)) if change_dpi else None
218
+ if change_dpi and not previous:
219
+ self.disable('DPI context unavailable; interception disabled')
220
+ return self._next(code, message, address)
221
+ try:
222
+ return self._mouse_event(code, message, address)
223
+ finally:
224
+ if previous:
225
+ change_dpi(previous)
226
+
227
+ def _mouse_event(self, code, message, address):
228
+ try:
229
+ if code < 0:
230
+ return self._next(code, message, address)
231
+ data = ctypes.cast(address, ctypes.POINTER(MouseData)).contents
232
+ virtual = getattr(self, 'virtual_mouse', False)
233
+ if data.flags & 1:
234
+ if virtual and message == 0x200:
235
+ self._release_pointer(restore=False)
236
+ return self._next(code, message, address)
237
+ pairs = {0x201: (1, True), 0x202: (1, False),
238
+ 0x204: (3, True), 0x205: (3, False),
239
+ 0x207: (2, True), 0x208: (2, False)}
240
+ if not self._active():
241
+ if virtual:
242
+ self._release_pointer()
243
+ # Pair only releases for button downs we previously consumed.
244
+ if message in pairs:
245
+ button, down = pairs[message]
246
+ if not down and button in self._buttons:
247
+ self._buttons.discard(button)
248
+ return 1
249
+ return self._next(code, message, address)
250
+ x, y = data.point.x, data.point.y
251
+ if virtual and message == 0x200:
252
+ if self._virtual_move(x, y):
253
+ return 1
254
+ return self._next(code, message, address)
255
+ if virtual and self.pointer_position is not None:
256
+ x, y = self.pointer_position
257
+ if message in pairs:
258
+ button, down = pairs[message]
259
+ if not down and button in self._buttons:
260
+ self._buttons.discard(button)
261
+ self._put(('mouse', button, False, x, y, 0))
262
+ return 1
263
+ hit = self.user.WindowFromPoint(w.POINT(x, y))
264
+ point, rect = w.POINT(x, y), w.RECT()
265
+ if (self.user.GetAncestor(hit, 2) not in (self.hwnd, getattr(self, 'cursor_hwnd', None))
266
+ or not self.user.ScreenToClient(self.hwnd, ctypes.byref(point))
267
+ or not self.user.GetClientRect(self.hwnd, ctypes.byref(rect))
268
+ or not (0 <= point.x < rect.right and 0 <= point.y < rect.bottom)):
269
+ if virtual:
270
+ self._release_pointer()
271
+ return self._next(code, message, address)
272
+ if message in pairs:
273
+ button, down = pairs[message]
274
+ if self._put(('mouse', button, down, x, y, 0)):
275
+ if down:
276
+ self._buttons.add(button)
277
+ return 1
278
+ elif message == 0x200 and self._buttons:
279
+ self._put(('motion', tuple(self._buttons), x, y))
280
+ elif message == 0x20A:
281
+ delta = ctypes.c_short(data.data >> 16).value
282
+ if self._put(('mouse', 0, False, x, y, delta)):
283
+ return 1
284
+ except Exception:
285
+ self.disable('Mouse hook error; interception disabled')
286
+ if getattr(self, 'virtual_mouse', False):
287
+ self._release_pointer()
288
+ return self._next(code, message, address)
289
+
290
+ def _run(self):
291
+ handles = []
292
+ previous_dpi = None
293
+ try:
294
+ previous_dpi = self.user.SetThreadDpiAwarenessContext(ctypes.c_void_p(-4))
295
+ if not previous_dpi:
296
+ raise ctypes.WinError(ctypes.get_last_error())
297
+ module = self.kernel.GetModuleHandleW(None)
298
+ for kind, callback in ((13, self._keyboard_callback), (14, self._mouse_callback)):
299
+ handle = self.user.SetWindowsHookExW(kind, callback, module, 0)
300
+ if not handle:
301
+ raise ctypes.WinError(ctypes.get_last_error())
302
+ handles.append(handle)
303
+ self._ready.set()
304
+ message = w.MSG()
305
+ while not self._stop.is_set():
306
+ self._sync_pointer()
307
+ while self.user.PeekMessageW(ctypes.byref(message), None, 0, 0, 1):
308
+ if message.message == 0x12:
309
+ return
310
+ self.user.TranslateMessage(ctypes.byref(message))
311
+ self.user.DispatchMessageW(ctypes.byref(message))
312
+ self._stop.wait(0.005)
313
+ except Exception as exc:
314
+ self._error = exc
315
+ self.disable('Input hook worker stopped')
316
+ finally:
317
+ self.enabled = False
318
+ for handle in reversed(handles):
319
+ self.user.UnhookWindowsHookEx(handle)
320
+ try:
321
+ self._release_pointer()
322
+ finally:
323
+ if previous_dpi:
324
+ self.user.SetThreadDpiAwarenessContext(previous_dpi)
325
+ self._ready.set()
@@ -0,0 +1,134 @@
1
+ """Standard-library mathematics plus small real-valued numerical utilities.
2
+
3
+ math/statistics exports retain their original Python semantics. Numerical
4
+ methods are approximations; their tolerances do not bound rounding error.
5
+ """
6
+ import math as _math
7
+ import statistics as _statistics
8
+
9
+ # Reuse the installed standard library rather than vendoring copied sources.
10
+ __all__ = [name for name in dir(_math) if not name.startswith('_')]
11
+ for _name in __all__:
12
+ globals()[_name] = getattr(_math, _name)
13
+ for _name in ('mean', 'fmean', 'median', 'mode', 'multimode', 'variance',
14
+ 'pvariance', 'stdev', 'pstdev', 'geometric_mean', 'harmonic_mean'):
15
+ globals()[_name] = getattr(_statistics, _name)
16
+ __all__.append(_name)
17
+
18
+
19
+ def dot(a, b):
20
+ """Dot product of equally sized real vectors (including empty vectors)."""
21
+ a, b = tuple(a), tuple(b)
22
+ if len(a) != len(b):
23
+ raise ValueError('Vector dimensions must match')
24
+ return _math.fsum(x * y for x, y in zip(a, b))
25
+
26
+
27
+ def norm(vector):
28
+ """Euclidean norm with scaled accumulation from math.hypot."""
29
+ return _math.hypot(*vector)
30
+
31
+
32
+ def cross(a, b):
33
+ """Cross product of two three-dimensional vectors."""
34
+ a, b = tuple(a), tuple(b)
35
+ if len(a) != 3 or len(b) != 3:
36
+ raise ValueError('Cross product requires two 3D vectors')
37
+ return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2],
38
+ a[0]*b[1]-a[1]*b[0]]
39
+
40
+
41
+ def _matrix(value):
42
+ rows = [list(row) for row in value]
43
+ if not rows or not rows[0] or any(len(row) != len(rows[0]) for row in rows):
44
+ raise ValueError('Matrix must be nonempty and rectangular')
45
+ return rows
46
+
47
+
48
+ def transpose(matrix):
49
+ """Return a new transposed rectangular matrix."""
50
+ return [list(column) for column in zip(*_matrix(matrix))]
51
+
52
+
53
+ def matmul(a, b):
54
+ """Multiply rectangular real matrices; inputs are never modified."""
55
+ a, b = _matrix(a), _matrix(b)
56
+ if len(a[0]) != len(b):
57
+ raise ValueError('Inner matrix dimensions must match')
58
+ columns = list(zip(*b))
59
+ return [[dot(row, column) for column in columns] for row in a]
60
+
61
+
62
+ def _finite(value, name):
63
+ value = float(value)
64
+ if not _math.isfinite(value):
65
+ raise ValueError(name + ' must be finite')
66
+ return value
67
+
68
+
69
+ def _count(value, name):
70
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
71
+ raise ValueError(name + ' must be a positive integer')
72
+ return value
73
+
74
+
75
+ def derivative(function, x, h=1e-5):
76
+ """Central finite difference with a positive absolute step."""
77
+ x, h = _finite(x, 'x'), _finite(h, 'h')
78
+ if h <= 0 or x+h == x or x-h == x:
79
+ raise ValueError('h must be positive and resolvable at x')
80
+ left, right = _finite(x-h, 'left sample'), _finite(x+h, 'right sample')
81
+ result = (_finite(function(right), 'f(x+h)') -
82
+ _finite(function(left), 'f(x-h)')) / (2*h)
83
+ return _finite(result, 'derivative')
84
+
85
+
86
+ def integrate(function, a, b, steps=1000):
87
+ """Composite trapezoidal rule with exactly steps subintervals."""
88
+ a, b = _finite(a, 'a'), _finite(b, 'b')
89
+ steps = _count(steps, 'steps')
90
+ if a == b:
91
+ return 0.0
92
+ width = _finite((b-a)/steps, 'step width')
93
+ if width == 0 or a+width == a:
94
+ raise ValueError('Integration step is too small')
95
+ ends = _finite(function(a), 'f(a)')/2 + _finite(function(b), 'f(b)')/2
96
+ interior = _math.fsum(_finite(function(a+i*width), 'integrand')
97
+ for i in range(1, steps))
98
+ return _finite(width*(ends+interior), 'integral')
99
+
100
+
101
+ def bisect(function, a, b, tolerance=1e-10, max_iterations=1000):
102
+ """Find a bracketed root of a continuous real function.
103
+
104
+ Return when bracket half-width <= tolerance or an exact zero is sampled.
105
+ Raise RuntimeError on iteration exhaustion or insufficient float precision.
106
+ """
107
+ a, b = _finite(a, 'a'), _finite(b, 'b')
108
+ tolerance = _finite(tolerance, 'tolerance')
109
+ max_iterations = _count(max_iterations, 'max_iterations')
110
+ if a >= b or tolerance <= 0:
111
+ raise ValueError('Require a < b and tolerance > 0')
112
+ fa, fb = _finite(function(a), 'f(a)'), _finite(function(b), 'f(b)')
113
+ if fa == 0:
114
+ return a
115
+ if fb == 0:
116
+ return b
117
+ if (fa > 0) == (fb > 0):
118
+ raise ValueError('Endpoints must bracket a root')
119
+ for _ in range(max_iterations):
120
+ midpoint = a/2 + b/2
121
+ fm = _finite(function(midpoint), 'f(midpoint)')
122
+ if fm == 0 or b/2-a/2 <= tolerance:
123
+ return midpoint
124
+ if midpoint == a or midpoint == b:
125
+ raise RuntimeError('Floating-point precision exhausted')
126
+ if (fa > 0) == (fm > 0):
127
+ a, fa = midpoint, fm
128
+ else:
129
+ b = midpoint
130
+ raise RuntimeError('Bisection did not converge within max_iterations')
131
+
132
+
133
+ __all__ += ['dot', 'norm', 'cross', 'transpose', 'matmul', 'derivative',
134
+ 'integrate', 'bisect']