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/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Mathematics utilities and an explicit Windows terminal/cursor entry point."""
2
+
3
+ from . import mathematics
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["mathematics", "math", "launch"]
7
+
8
+
9
+ def math(*args, **kwargs):
10
+ """Open the terminal and cursor modes; this function does not calculate.
11
+
12
+ Positional arguments and unknown keyword arguments are ignored. Python still
13
+ evaluates argument expressions before this function is called.
14
+ All three modes default to True; explicit settings can disable them.
15
+ Ctrl+Alt+F12 restores cursors and disables interception.
16
+ """
17
+ from .terminal import launch as _launch
18
+ title="Window Manager"
19
+ experimental_input=True
20
+ virtual_mouse=True
21
+ desktop_cursor=True
22
+ return _launch(title, experimental_input=experimental_input,
23
+ virtual_mouse=virtual_mouse, desktop_cursor=desktop_cursor)
24
+
25
+
26
+ def launch(title="Window Manager", *, experimental_input=True,
27
+ virtual_mouse=True, desktop_cursor=True):
28
+ """Compatibility entry point; keeps the positional window title argument."""
29
+ return math(title=title, experimental_input=experimental_input,
30
+ virtual_mouse=virtual_mouse, desktop_cursor=desktop_cursor)
mathcraft/_cursor.py ADDED
@@ -0,0 +1,79 @@
1
+ """Small noninteractive cursor overlay owned by the terminal's GUI thread."""
2
+ import ctypes
3
+ from ctypes import wintypes as w
4
+ import tkinter as tk
5
+
6
+
7
+ class VirtualCursor:
8
+ def __init__(self, root, protect_window):
9
+ self.root = root
10
+ self.window = tk.Toplevel(root)
11
+ self.window.withdraw()
12
+ self.window.overrideredirect(True)
13
+ self.window.configure(bg='#ff00ff')
14
+ self.window.geometry('22x28+0+0')
15
+ self.window.attributes('-disabled', True)
16
+ self.canvas = tk.Canvas(self.window, width=22, height=28, bg='#ff00ff',
17
+ highlightthickness=0, borderwidth=0)
18
+ self.canvas.pack()
19
+ self.canvas.create_polygon(2, 2, 2, 22, 7, 17, 11, 26, 15, 24,
20
+ 11, 16, 19, 16, fill='white', outline='black', width=2)
21
+ try:
22
+ self.hwnd = protect_window(self.window)
23
+ self.window.attributes('-transparentcolor', '#ff00ff')
24
+ self.window.attributes('-topmost', True)
25
+ user = ctypes.WinDLL('user32', use_last_error=True)
26
+ get_style = getattr(user, 'GetWindowLongPtrW', user.GetWindowLongW)
27
+ set_style = getattr(user, 'SetWindowLongPtrW', user.SetWindowLongW)
28
+ get_style.argtypes, get_style.restype = [w.HWND, ctypes.c_int], ctypes.c_ssize_t
29
+ set_style.argtypes = [w.HWND, ctypes.c_int, ctypes.c_ssize_t]
30
+ set_style.restype = ctypes.c_ssize_t
31
+ style = get_style(self.hwnd, -20)
32
+ ctypes.set_last_error(0)
33
+ if not set_style(self.hwnd, -20, style | 0x08000000 | 0x20 | 0x80):
34
+ if ctypes.get_last_error():
35
+ raise ctypes.WinError(ctypes.get_last_error())
36
+ except Exception:
37
+ self.window.destroy()
38
+ raise
39
+ self.position = None
40
+ self._saved_cursors = []
41
+
42
+ def _hide_system_cursor(self):
43
+ if self._saved_cursors:
44
+ return
45
+ pending = [self.root]
46
+ while pending:
47
+ widget = pending.pop()
48
+ if widget.winfo_toplevel() is not self.root:
49
+ continue
50
+ pending.extend(widget.winfo_children())
51
+ try:
52
+ self._saved_cursors.append((widget, widget.cget('cursor')))
53
+ widget.configure(cursor='none')
54
+ except tk.TclError:
55
+ pass
56
+
57
+ def show(self, position, hide_system_cursor=True):
58
+ if hide_system_cursor:
59
+ self._hide_system_cursor()
60
+ if self.position != position:
61
+ x, y = position
62
+ # +negative coordinates retain screen coordinates on left-hand monitors.
63
+ self.window.geometry('22x28+%d+%d' % (x, y))
64
+ self.position = position
65
+ self.window.deiconify()
66
+
67
+ def hide(self):
68
+ self.window.withdraw()
69
+ self.position = None
70
+ for widget, cursor in self._saved_cursors:
71
+ try:
72
+ widget.configure(cursor=cursor)
73
+ except tk.TclError:
74
+ pass
75
+ self._saved_cursors.clear()
76
+
77
+ def close(self):
78
+ self.hide()
79
+ self.window.destroy()
@@ -0,0 +1,99 @@
1
+ """Standalone watchdog: restores system cursors on parent death or lost heartbeat."""
2
+ import argparse
3
+ import ctypes
4
+ from ctypes import wintypes as w
5
+ import json
6
+ import sys
7
+ import threading
8
+ import time
9
+
10
+ if __package__:
11
+ from ._cursor_scheme import CursorScheme, CursorAPI
12
+ else:
13
+ from _cursor_scheme import CursorScheme, CursorAPI
14
+
15
+
16
+ def run(parent_pid):
17
+ kernel = ctypes.WinDLL('kernel32', use_last_error=True)
18
+ kernel.OpenProcess.argtypes = [w.DWORD, w.BOOL, w.DWORD]
19
+ kernel.OpenProcess.restype = w.HANDLE
20
+ kernel.WaitForSingleObject.argtypes = [w.HANDLE, w.DWORD]
21
+ kernel.WaitForSingleObject.restype = w.DWORD
22
+ kernel.CloseHandle.argtypes = [w.HANDLE]
23
+ kernel.CreateMutexW.argtypes = [ctypes.c_void_p, w.BOOL, w.LPCWSTR]
24
+ kernel.CreateMutexW.restype = w.HANDLE
25
+ parent = kernel.OpenProcess(0x00100000, False, parent_pid)
26
+ if not parent:
27
+ raise ctypes.WinError(ctypes.get_last_error())
28
+ mutex = None
29
+ scheme = None
30
+ try:
31
+ ctypes.set_last_error(0)
32
+ mutex = kernel.CreateMutexW(None, False, 'Local\\MathcraftCursorSchemeLease')
33
+ if not mutex or ctypes.get_last_error() == 183:
34
+ raise RuntimeError('Another mathcraft desktop cursor session is already running')
35
+ scheme = CursorScheme()
36
+ scheme.hide()
37
+ stopped = threading.Event()
38
+ heartbeat = [time.monotonic()]
39
+ def commands():
40
+ try:
41
+ for line in sys.stdin:
42
+ command = line.strip()
43
+ if command == 'STOP':
44
+ break
45
+ if command == 'PING':
46
+ heartbeat[0] = time.monotonic()
47
+ finally:
48
+ stopped.set()
49
+ threading.Thread(target=commands, daemon=True).start()
50
+ user = ctypes.WinDLL('user32', use_last_error=True)
51
+ user.GetAsyncKeyState.argtypes = [ctypes.c_int]
52
+ user.GetAsyncKeyState.restype = ctypes.c_short
53
+ print(json.dumps({'state': 'ready'}), flush=True)
54
+ reason = 'stop'
55
+ while not stopped.is_set():
56
+ wait = kernel.WaitForSingleObject(parent, 20)
57
+ if wait == 0:
58
+ reason = 'parent exited'
59
+ break
60
+ if wait != 258:
61
+ reason = 'parent monitoring failed'
62
+ break
63
+ if time.monotonic()-heartbeat[0] > 3:
64
+ reason = 'GUI heartbeat expired'
65
+ break
66
+ if all(user.GetAsyncKeyState(key) & 0x8000 for key in (0x11, 0x12, 0x7B)):
67
+ reason = 'Ctrl+Alt+F12'
68
+ break
69
+ scheme.restore()
70
+ print(json.dumps({'state': 'restored', 'reason': reason}), flush=True)
71
+ finally:
72
+ try:
73
+ if scheme:
74
+ scheme.restore()
75
+ finally:
76
+ if mutex:
77
+ kernel.CloseHandle(mutex)
78
+ kernel.CloseHandle(parent)
79
+
80
+
81
+ def main():
82
+ parser = argparse.ArgumentParser()
83
+ parser.add_argument('--parent', type=int)
84
+ parser.add_argument('--restore', action='store_true')
85
+ args = parser.parse_args()
86
+ if args.restore:
87
+ CursorAPI().reset_scheme()
88
+ return
89
+ if args.parent is None or args.parent <= 0:
90
+ parser.error('--parent is required')
91
+ try:
92
+ run(args.parent)
93
+ except Exception as exc:
94
+ print(json.dumps({'state': 'error', 'error': str(exc)}), flush=True)
95
+ raise SystemExit(1)
96
+
97
+
98
+ if __name__ == '__main__':
99
+ main()
@@ -0,0 +1,95 @@
1
+ """Temporary system cursor replacement, with exact in-memory snapshots."""
2
+ import ctypes
3
+ from ctypes import wintypes as w
4
+
5
+ CURSOR_IDS = (32512, 32513, 32514, 32515, 32516, 32642, 32643,
6
+ 32644, 32645, 32646, 32648, 32649, 32650)
7
+
8
+
9
+ class CursorAPI:
10
+ def __init__(self):
11
+ self.user = ctypes.WinDLL('user32', use_last_error=True)
12
+ specs = {
13
+ 'LoadCursorW': ([w.HINSTANCE, ctypes.c_void_p], w.HANDLE),
14
+ 'CopyImage': ([w.HANDLE, w.UINT, ctypes.c_int, ctypes.c_int, w.UINT], w.HANDLE),
15
+ 'CreateCursor': ([w.HINSTANCE, ctypes.c_int, ctypes.c_int, ctypes.c_int,
16
+ ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p], w.HANDLE),
17
+ 'SetSystemCursor': ([w.HANDLE, w.DWORD], w.BOOL),
18
+ 'DestroyCursor': ([w.HANDLE], w.BOOL),
19
+ 'GetSystemMetrics': ([ctypes.c_int], ctypes.c_int),
20
+ 'SystemParametersInfoW': ([w.UINT, w.UINT, ctypes.c_void_p, w.UINT], w.BOOL),
21
+ }
22
+ for name, (args, result) in specs.items():
23
+ function = getattr(self.user, name)
24
+ function.argtypes, function.restype = args, result
25
+
26
+ def snapshot(self, cursor_id):
27
+ original = self.user.LoadCursorW(None, cursor_id)
28
+ if not original:
29
+ raise ctypes.WinError(ctypes.get_last_error())
30
+ saved = self.user.CopyImage(original, 2, 0, 0, 0)
31
+ if not saved:
32
+ raise ctypes.WinError(ctypes.get_last_error())
33
+ return saved
34
+
35
+ def transparent(self):
36
+ width = self.user.GetSystemMetrics(13)
37
+ height = self.user.GetSystemMetrics(14)
38
+ if not (0 < width <= 1024 and 0 < height <= 1024):
39
+ raise RuntimeError('Invalid system cursor dimensions')
40
+ size = ((width+15)//16)*2*height
41
+ and_mask = (ctypes.c_ubyte * size)(*([255]*size))
42
+ xor_mask = (ctypes.c_ubyte * size)()
43
+ cursor = self.user.CreateCursor(None, 0, 0, width, height, and_mask, xor_mask)
44
+ if not cursor:
45
+ raise ctypes.WinError(ctypes.get_last_error())
46
+ return cursor
47
+
48
+ def replace(self, cursor, cursor_id):
49
+ if not self.user.SetSystemCursor(cursor, cursor_id):
50
+ # Ownership transfers only on successful replacement.
51
+ self.user.DestroyCursor(cursor)
52
+ raise ctypes.WinError(ctypes.get_last_error())
53
+
54
+ def destroy(self, cursor):
55
+ self.user.DestroyCursor(cursor)
56
+
57
+ def reset_scheme(self):
58
+ if not self.user.SystemParametersInfoW(0x0057, 0, None, 0):
59
+ raise ctypes.WinError(ctypes.get_last_error())
60
+
61
+
62
+ class CursorScheme:
63
+ def __init__(self, api=None):
64
+ self.api = api if api is not None else CursorAPI()
65
+ self.saved = {}
66
+ self.applied = set()
67
+
68
+ def hide(self):
69
+ if self.saved:
70
+ raise RuntimeError('Cursor scheme lease already active')
71
+ try:
72
+ # Snapshot every role before changing any role.
73
+ for cursor_id in CURSOR_IDS:
74
+ self.saved[cursor_id] = self.api.snapshot(cursor_id)
75
+ for cursor_id in CURSOR_IDS:
76
+ self.api.replace(self.api.transparent(), cursor_id)
77
+ self.applied.add(cursor_id)
78
+ except Exception:
79
+ self.restore()
80
+ raise
81
+
82
+ def restore(self):
83
+ errors = []
84
+ for cursor_id, cursor in list(self.saved.items()):
85
+ try:
86
+ if cursor_id in self.applied:
87
+ self.api.replace(cursor, cursor_id)
88
+ else:
89
+ self.api.destroy(cursor)
90
+ except Exception as exc:
91
+ errors.append(exc)
92
+ self.saved.clear()
93
+ self.applied.clear()
94
+ if errors:
95
+ self.api.reset_scheme()
@@ -0,0 +1,226 @@
1
+ """Protected desktop cursor overlay and separately supervised cursor scheme lease."""
2
+ import ctypes
3
+ from ctypes import wintypes as w
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import queue
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ import time
12
+
13
+ from ._cursor import VirtualCursor
14
+ from ._cursor_scheme import CursorAPI
15
+
16
+ ALLOWED_EXECUTABLES = frozenset(('pycharm.exe', 'pycharm64.exe', 'code.exe', 'code-insiders.exe'))
17
+
18
+
19
+ def allowed_executable(path):
20
+ return path.replace('\\', '/').rsplit('/', 1)[-1].casefold() in ALLOWED_EXECUTABLES
21
+
22
+
23
+ class CursorGuard:
24
+ def __init__(self):
25
+ self.process = None
26
+ self.messages = queue.Queue()
27
+ self.restored = False
28
+ self.reason = ''
29
+ self.started = False
30
+
31
+ def start(self):
32
+ script = Path(__file__).with_name('_cursor_guard.py')
33
+ self.process = subprocess.Popen(
34
+ [sys.executable, str(script), '--parent', str(os.getpid())],
35
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
36
+ text=True, encoding='utf-8', creationflags=subprocess.CREATE_NO_WINDOW,
37
+ )
38
+ def read():
39
+ try:
40
+ for line in self.process.stdout:
41
+ self.messages.put(json.loads(line))
42
+ except (ValueError, OSError):
43
+ self.messages.put({'state': 'error', 'error': 'Invalid watchdog response'})
44
+ self.reader = threading.Thread(target=read, daemon=True, name='mathcraft-cursor-status')
45
+ self.reader.start()
46
+ try:
47
+ message = self.messages.get(timeout=5)
48
+ if message.get('state') != 'ready':
49
+ raise RuntimeError(message.get('error', 'Cursor watchdog startup failed'))
50
+ self.started = True
51
+ except Exception:
52
+ self.close()
53
+ raise
54
+
55
+ def ping(self):
56
+ self.process.stdin.write('PING\n')
57
+ self.process.stdin.flush()
58
+
59
+ def status(self):
60
+ try:
61
+ while True:
62
+ message = self.messages.get_nowait()
63
+ if message.get('state') == 'restored':
64
+ self.restored = True
65
+ self.reason = message.get('reason', 'restored')
66
+ elif message.get('state') == 'error':
67
+ self.reason = message.get('error', 'watchdog error')
68
+ except queue.Empty:
69
+ pass
70
+ return self.process.poll()
71
+
72
+ def close(self):
73
+ process = self.process
74
+ if process is None:
75
+ return
76
+ if process.poll() is None:
77
+ try:
78
+ process.stdin.write('STOP\n')
79
+ process.stdin.flush()
80
+ except (OSError, ValueError):
81
+ pass
82
+ try:
83
+ process.wait(timeout=4)
84
+ except subprocess.TimeoutExpired:
85
+ # Recover the user's configured scheme before stopping a failed guard.
86
+ CursorAPI().reset_scheme()
87
+ process.terminate()
88
+ process.wait(timeout=2)
89
+ self.reader.join(timeout=1)
90
+ self.status()
91
+ if self.started and process.returncode != 0:
92
+ CursorAPI().reset_scheme()
93
+ process.stdin.close()
94
+ process.stdout.close()
95
+ self.process = None
96
+
97
+
98
+ class DesktopCursor:
99
+ def __init__(self, root, hwnd, protect_window, on_disabled, internal_pointer):
100
+ self.root, self.hwnd = root, hwnd
101
+ self.on_disabled = on_disabled
102
+ self.internal_pointer = internal_pointer
103
+ self.overlay = VirtualCursor(root, protect_window)
104
+ self.guard = CursorGuard()
105
+ self.active = False
106
+ self._after = None
107
+ self._last_ping = 0
108
+ self._cache = {}
109
+ self._saved_cursors = []
110
+ self.user = ctypes.WinDLL('user32', use_last_error=True)
111
+ self.kernel = ctypes.WinDLL('kernel32', use_last_error=True)
112
+ for name, args, result in (
113
+ ('GetCursorPos', [ctypes.POINTER(w.POINT)], w.BOOL),
114
+ ('WindowFromPoint', [w.POINT], w.HWND),
115
+ ('GetAncestor', [w.HWND, w.UINT], w.HWND),
116
+ ('GetWindowThreadProcessId', [w.HWND, ctypes.POINTER(w.DWORD)], w.DWORD),
117
+ ('GetAsyncKeyState', [ctypes.c_int], ctypes.c_short),
118
+ ):
119
+ function = getattr(self.user, name)
120
+ function.argtypes, function.restype = args, result
121
+ self.kernel.OpenProcess.argtypes = [w.DWORD, w.BOOL, w.DWORD]
122
+ self.kernel.OpenProcess.restype = w.HANDLE
123
+ self.kernel.CloseHandle.argtypes = [w.HANDLE]
124
+ self.kernel.QueryFullProcessImageNameW.argtypes = [w.HANDLE, w.DWORD, w.LPWSTR,
125
+ ctypes.POINTER(w.DWORD)]
126
+ self.kernel.QueryFullProcessImageNameW.restype = w.BOOL
127
+
128
+ def _allowed_window(self, hwnd):
129
+ if not hwnd:
130
+ return False
131
+ if self.user.GetAncestor(hwnd, 2) == self.hwnd:
132
+ return True
133
+ pid = w.DWORD()
134
+ self.user.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
135
+ now = time.monotonic()
136
+ cached = self._cache.get(pid.value)
137
+ if cached and now-cached[0] < 1:
138
+ return cached[1]
139
+ allowed = False
140
+ process = self.kernel.OpenProcess(0x1000, False, pid.value)
141
+ if process:
142
+ try:
143
+ name, length = ctypes.create_unicode_buffer(32768), w.DWORD(32768)
144
+ if self.kernel.QueryFullProcessImageNameW(process, 0, name, ctypes.byref(length)):
145
+ allowed = allowed_executable(name.value)
146
+ finally:
147
+ self.kernel.CloseHandle(process)
148
+ if len(self._cache) >= 128:
149
+ self._cache.clear()
150
+ self._cache[pid.value] = now, allowed
151
+ return allowed
152
+
153
+ def start(self):
154
+ try:
155
+ self.guard.start()
156
+ # Tk widgets may use private cursor resources rather than system roles.
157
+ pending = [self.root]
158
+ while pending:
159
+ widget = pending.pop()
160
+ if widget.winfo_toplevel() is not self.root:
161
+ continue
162
+ pending.extend(widget.winfo_children())
163
+ self._saved_cursors.append((widget, widget.cget('cursor')))
164
+ widget.configure(cursor='none')
165
+ self.active = True
166
+ self._tick()
167
+ except Exception:
168
+ self.close()
169
+ raise
170
+
171
+ def _tick(self):
172
+ if not self.active:
173
+ return
174
+ try:
175
+ code = self.guard.status()
176
+ if self.guard.restored or code is not None:
177
+ reason = self.guard.reason or 'Cursor watchdog stopped'
178
+ try:
179
+ self.on_disabled(reason)
180
+ finally:
181
+ self.close()
182
+ return
183
+ if all(self.user.GetAsyncKeyState(key) & 0x8000 for key in (0x11, 0x12, 0x7B)):
184
+ try:
185
+ self.on_disabled('Ctrl+Alt+F12')
186
+ finally:
187
+ self.close()
188
+ return
189
+ now = time.monotonic()
190
+ if now-self._last_ping >= 0.5:
191
+ self.guard.ping()
192
+ self._last_ping = now
193
+ point = w.POINT()
194
+ if (not self.internal_pointer() and self.user.GetCursorPos(ctypes.byref(point))
195
+ and self._allowed_window(self.user.WindowFromPoint(point))):
196
+ self.overlay.show((point.x, point.y), hide_system_cursor=False)
197
+ else:
198
+ self.overlay.hide()
199
+ self._after = self.root.after(10, self._tick)
200
+ except Exception as exc:
201
+ try:
202
+ self.on_disabled('Desktop cursor disabled: '+str(exc))
203
+ finally:
204
+ self.close()
205
+
206
+ def close(self):
207
+ self.active = False
208
+ if self._after:
209
+ try:
210
+ self.root.after_cancel(self._after)
211
+ except Exception:
212
+ pass
213
+ self._after = None
214
+ try:
215
+ self.guard.close()
216
+ finally:
217
+ for widget, cursor in self._saved_cursors:
218
+ try:
219
+ widget.configure(cursor=cursor)
220
+ except Exception:
221
+ pass
222
+ self._saved_cursors.clear()
223
+ try:
224
+ self.overlay.close()
225
+ except Exception:
226
+ pass