mctrl 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.
- mctrl-0.1.0.dist-info/METADATA +692 -0
- mctrl-0.1.0.dist-info/RECORD +33 -0
- mctrl-0.1.0.dist-info/WHEEL +4 -0
- mctrl-0.1.0.dist-info/entry_points.txt +3 -0
- mindcontrol/__init__.py +10 -0
- mindcontrol/__main__.py +4 -0
- mindcontrol/app.py +432 -0
- mindcontrol/autotune.py +493 -0
- mindcontrol/calibrate.py +199 -0
- mindcontrol/capture.py +159 -0
- mindcontrol/config.py +243 -0
- mindcontrol/control/__init__.py +1 -0
- mindcontrol/control/bridge.py +360 -0
- mindcontrol/control/events.py +34 -0
- mindcontrol/control/keyboard.py +101 -0
- mindcontrol/control/modes.py +194 -0
- mindcontrol/control/mouse.py +256 -0
- mindcontrol/debug_view.py +188 -0
- mindcontrol/devices.py +222 -0
- mindcontrol/filters.py +95 -0
- mindcontrol/fusion.py +240 -0
- mindcontrol/geometry.py +200 -0
- mindcontrol/gestures/__init__.py +1 -0
- mindcontrol/gestures/engine.py +465 -0
- mindcontrol/logs.py +87 -0
- mindcontrol/models.py +59 -0
- mindcontrol/pipeline.py +376 -0
- mindcontrol/record.py +454 -0
- mindcontrol/replay.py +193 -0
- mindcontrol/session.py +328 -0
- mindcontrol/tracking/__init__.py +1 -0
- mindcontrol/tracking/gaze.py +272 -0
- mindcontrol/tracking/hands.py +102 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Client for the native interaction helper.
|
|
2
|
+
|
|
3
|
+
The helper (``native/``, Swift) owns the cursor. This module is the near end of the
|
|
4
|
+
socket to it: it finds the binary, keeps it running, and turns each gesture intent
|
|
5
|
+
into one 48-byte datagram.
|
|
6
|
+
|
|
7
|
+
Why the work moved out of Python at all is a measurement, not a preference. The
|
|
8
|
+
snapping this exists to serve needs to ask the system what is on screen, and
|
|
9
|
+
accessibility queries are synchronous IPC into other applications:
|
|
10
|
+
|
|
11
|
+
=========================================== ========== ==========
|
|
12
|
+
operation pyobjc native
|
|
13
|
+
=========================================== ========== ==========
|
|
14
|
+
one attribute read 1140 us 382 us
|
|
15
|
+
whole-window tree walk (2419 nodes) 2490 ms 4251 ms
|
|
16
|
+
single-point hit test - 0.43 ms
|
|
17
|
+
=========================================== ========== ==========
|
|
18
|
+
|
|
19
|
+
Walking a window is hopeless in either language. The hit test is affordable in
|
|
20
|
+
both, but it is only 3x cheaper here -- the rest is IPC, which no language avoids.
|
|
21
|
+
What actually cannot be done in this process is the other half: integrating the
|
|
22
|
+
cursor at display rate on a thread that is never behind the GIL while MediaPipe is
|
|
23
|
+
running inference, and holding the sole write handle on the cursor so that a warp
|
|
24
|
+
and a hand delta can never be posted from two threads against the same stale
|
|
25
|
+
position.
|
|
26
|
+
|
|
27
|
+
Sending is fire and forget. Deltas travel rather than positions, so nothing here
|
|
28
|
+
ever needs to read the cursor back, which keeps the round trip count at zero.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import socket
|
|
36
|
+
import struct
|
|
37
|
+
import subprocess
|
|
38
|
+
import time
|
|
39
|
+
from dataclasses import asdict
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from ..config import STATE_DIR, NativeConfig
|
|
43
|
+
|
|
44
|
+
SOCKET_PATH = STATE_DIR / "bridge.sock"
|
|
45
|
+
TUNING_PATH = STATE_DIR / "bridge-tuning.json"
|
|
46
|
+
|
|
47
|
+
_MAGIC = 0x4D494E44 # "MIND", the same tag the synthetic events carry
|
|
48
|
+
_VERSION = 1
|
|
49
|
+
# magic, version, intent, sequence, flags, a, b, sent_at, button, pad
|
|
50
|
+
_FRAME = struct.Struct("<IHHIIdddII")
|
|
51
|
+
|
|
52
|
+
MOVE_BY = 1
|
|
53
|
+
WARP_TO_FRACTION = 2
|
|
54
|
+
CLICK = 3
|
|
55
|
+
PRESS = 4
|
|
56
|
+
RELEASE = 5
|
|
57
|
+
SCROLL = 6
|
|
58
|
+
SET_MODE = 7
|
|
59
|
+
RELEASE_ALL = 8
|
|
60
|
+
RELOAD_CONFIG = 9
|
|
61
|
+
SHUTDOWN = 10
|
|
62
|
+
|
|
63
|
+
ENGAGED = 1 << 0
|
|
64
|
+
POINTING = 1 << 1
|
|
65
|
+
SWEEPING = 1 << 2
|
|
66
|
+
|
|
67
|
+
_BUTTONS = {"left": 0, "right": 1}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def helper_path() -> Path | None:
|
|
71
|
+
"""Locate the helper binary, preferring an explicit override.
|
|
72
|
+
|
|
73
|
+
The release build is checked before the debug one so that a stale debug
|
|
74
|
+
binary left over from development does not quietly win.
|
|
75
|
+
"""
|
|
76
|
+
override = os.environ.get("MINDCONTROL_BRIDGE")
|
|
77
|
+
if override:
|
|
78
|
+
candidate = Path(override).expanduser()
|
|
79
|
+
return candidate if candidate.is_file() else None
|
|
80
|
+
|
|
81
|
+
root = Path(__file__).resolve().parents[2] / "native" / ".build"
|
|
82
|
+
for build in ("release", "debug"):
|
|
83
|
+
candidate = root / build / "mindcontrol-bridge"
|
|
84
|
+
if candidate.is_file():
|
|
85
|
+
return candidate
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def package_dir() -> Path:
|
|
90
|
+
"""The Swift package, which lives beside the Python one."""
|
|
91
|
+
return Path(__file__).resolve().parents[2] / "native"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_hint() -> str:
|
|
95
|
+
return "run `mindcontrol bridge` to build it"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build(*, release: bool = True) -> int:
|
|
99
|
+
"""Compile the helper. Returns swift's exit status, or 127 if it is absent."""
|
|
100
|
+
package = package_dir()
|
|
101
|
+
if not (package / "Package.swift").is_file():
|
|
102
|
+
print(f"[bridge] no Swift package at {package}")
|
|
103
|
+
return 2
|
|
104
|
+
command = ["swift", "build", "--package-path", str(package)]
|
|
105
|
+
if release:
|
|
106
|
+
command += ["-c", "release"]
|
|
107
|
+
print(f"[bridge] {' '.join(command)}")
|
|
108
|
+
try:
|
|
109
|
+
return subprocess.run(command, check=False).returncode
|
|
110
|
+
except FileNotFoundError:
|
|
111
|
+
print(
|
|
112
|
+
"[bridge] swift not found. Install the Xcode command line tools with "
|
|
113
|
+
"`xcode-select --install`."
|
|
114
|
+
)
|
|
115
|
+
return 127
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run(*, rebuild: bool = False, debug: bool = False) -> int:
|
|
119
|
+
"""CLI entry: build the helper if needed, then report where it stands."""
|
|
120
|
+
if rebuild or helper_path() is None:
|
|
121
|
+
status = build(release=not debug)
|
|
122
|
+
if status != 0:
|
|
123
|
+
return status
|
|
124
|
+
|
|
125
|
+
binary = helper_path()
|
|
126
|
+
if binary is None:
|
|
127
|
+
print("[bridge] built, but no binary was produced")
|
|
128
|
+
return 1
|
|
129
|
+
print(f"[bridge] helper at {binary}")
|
|
130
|
+
|
|
131
|
+
trusted = _accessibility_granted()
|
|
132
|
+
if trusted is None:
|
|
133
|
+
print("[bridge] could not check Accessibility permission")
|
|
134
|
+
elif trusted:
|
|
135
|
+
print("[bridge] Accessibility permission granted to this process")
|
|
136
|
+
else:
|
|
137
|
+
print(
|
|
138
|
+
"[bridge] no Accessibility permission yet. Snapping and highlighting need it.\n"
|
|
139
|
+
" The helper asks for it in its own right the first time it runs, so\n"
|
|
140
|
+
" look for it in System Settings > Privacy & Security > Accessibility."
|
|
141
|
+
)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _accessibility_granted() -> bool | None:
|
|
146
|
+
try:
|
|
147
|
+
from ApplicationServices import AXIsProcessTrusted
|
|
148
|
+
except ImportError:
|
|
149
|
+
return None
|
|
150
|
+
return bool(AXIsProcessTrusted())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Bridge:
|
|
154
|
+
"""Owns the helper process and the socket to it.
|
|
155
|
+
|
|
156
|
+
Every method is safe to call when the helper is absent or has died; the caller
|
|
157
|
+
checks :attr:`connected` to decide whether to fall back to posting events
|
|
158
|
+
itself. Nothing here raises on a send failure -- a dropped frame costs a
|
|
159
|
+
fraction of a millimetre of cursor travel, and tearing down the pipeline over
|
|
160
|
+
one would be a far worse outcome.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, cfg: NativeConfig, double_click_ms: float = 400.0, *, spawn: bool = True):
|
|
164
|
+
self._cfg = cfg
|
|
165
|
+
# Lives in [gestures] rather than [native], because it is a property of how
|
|
166
|
+
# you pinch, not of the helper. The helper needs it because it is the side
|
|
167
|
+
# that stamps the click-state field two quick pinches chain through.
|
|
168
|
+
self._double_click_ms = double_click_ms
|
|
169
|
+
self._spawn = spawn
|
|
170
|
+
self._socket: socket.socket | None = None
|
|
171
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
172
|
+
self._sequence = 0
|
|
173
|
+
self._flags = -1
|
|
174
|
+
self._next_attempt = 0.0
|
|
175
|
+
self._attempts = 0
|
|
176
|
+
self.error: str | None = None
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------ lifecycle
|
|
179
|
+
|
|
180
|
+
def start(self) -> bool:
|
|
181
|
+
"""Write the tuning file, start the helper, and connect. False if unavailable."""
|
|
182
|
+
if not self._cfg.enabled:
|
|
183
|
+
self.error = "native bridge disabled in config"
|
|
184
|
+
return False
|
|
185
|
+
binary = helper_path()
|
|
186
|
+
if binary is None:
|
|
187
|
+
self.error = f"native helper not built; {build_hint()}"
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
self.write_tuning()
|
|
191
|
+
if self._spawn and not self._launch(binary):
|
|
192
|
+
return False
|
|
193
|
+
return self._connect()
|
|
194
|
+
|
|
195
|
+
def _launch(self, binary: Path) -> bool:
|
|
196
|
+
if self._process is not None and self._process.poll() is None:
|
|
197
|
+
return True
|
|
198
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
try:
|
|
200
|
+
self._process = subprocess.Popen(
|
|
201
|
+
[
|
|
202
|
+
str(binary),
|
|
203
|
+
"--socket",
|
|
204
|
+
str(SOCKET_PATH),
|
|
205
|
+
"--tuning",
|
|
206
|
+
str(TUNING_PATH),
|
|
207
|
+
],
|
|
208
|
+
stdout=subprocess.DEVNULL,
|
|
209
|
+
# The helper reports permission problems and its listening address
|
|
210
|
+
# on stderr; inheriting it puts those where the user is looking.
|
|
211
|
+
stderr=None,
|
|
212
|
+
)
|
|
213
|
+
except OSError as problem:
|
|
214
|
+
self.error = f"could not start native helper: {problem}"
|
|
215
|
+
return False
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
# Longer than the helper's own eviction grace, which is the slowest thing that
|
|
219
|
+
# can stand between launching it and it listening: a helper that is patiently
|
|
220
|
+
# waiting for a previous one to stand down must not be given up on as broken.
|
|
221
|
+
_CONNECT_TIMEOUT = 8.0
|
|
222
|
+
|
|
223
|
+
def _connect(self) -> bool:
|
|
224
|
+
"""Connect the datagram socket, waiting for the helper to bind it."""
|
|
225
|
+
deadline = time.monotonic() + self._CONNECT_TIMEOUT
|
|
226
|
+
while time.monotonic() < deadline:
|
|
227
|
+
if self._process is not None and self._process.poll() is not None:
|
|
228
|
+
self.error = f"native helper exited with status {self._process.returncode}"
|
|
229
|
+
return False
|
|
230
|
+
try:
|
|
231
|
+
handle = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
232
|
+
handle.connect(str(SOCKET_PATH))
|
|
233
|
+
except OSError:
|
|
234
|
+
time.sleep(0.05)
|
|
235
|
+
continue
|
|
236
|
+
self._socket = handle
|
|
237
|
+
self.error = None
|
|
238
|
+
self._flags = -1
|
|
239
|
+
return True
|
|
240
|
+
self.error = f"native helper did not open {SOCKET_PATH}"
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def connected(self) -> bool:
|
|
245
|
+
return self._socket is not None
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def alive(self) -> bool:
|
|
249
|
+
"""True when the helper process is still running, if we started it."""
|
|
250
|
+
return self._process is None or self._process.poll() is None
|
|
251
|
+
|
|
252
|
+
def stop(self) -> None:
|
|
253
|
+
if self._socket is not None:
|
|
254
|
+
self._send(SHUTDOWN)
|
|
255
|
+
self._socket.close()
|
|
256
|
+
self._socket = None
|
|
257
|
+
if self._process is not None:
|
|
258
|
+
try:
|
|
259
|
+
self._process.wait(timeout=2.0)
|
|
260
|
+
except subprocess.TimeoutExpired:
|
|
261
|
+
self._process.terminate()
|
|
262
|
+
try:
|
|
263
|
+
self._process.wait(timeout=1.0)
|
|
264
|
+
except subprocess.TimeoutExpired:
|
|
265
|
+
self._process.kill()
|
|
266
|
+
self._process = None
|
|
267
|
+
|
|
268
|
+
def write_tuning(self) -> None:
|
|
269
|
+
"""Publish the native-side knobs as JSON.
|
|
270
|
+
|
|
271
|
+
The TOML stays the single place anything is tuned; this is a projection of
|
|
272
|
+
it, so the helper does not need a TOML parser to honour an edit.
|
|
273
|
+
"""
|
|
274
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
275
|
+
payload = {key: value for key, value in asdict(self._cfg).items() if key != "enabled"}
|
|
276
|
+
payload["double_click_ms"] = self._double_click_ms
|
|
277
|
+
TUNING_PATH.write_text(json.dumps(payload, indent=2))
|
|
278
|
+
|
|
279
|
+
def apply_config(self, cfg: NativeConfig, double_click_ms: float | None = None) -> None:
|
|
280
|
+
"""Adopt edited settings and tell the helper to re-read them."""
|
|
281
|
+
self._cfg = cfg
|
|
282
|
+
if double_click_ms is not None:
|
|
283
|
+
self._double_click_ms = double_click_ms
|
|
284
|
+
self.write_tuning()
|
|
285
|
+
self._send(RELOAD_CONFIG)
|
|
286
|
+
|
|
287
|
+
# -------------------------------------------------------------------- sending
|
|
288
|
+
|
|
289
|
+
def _send(self, intent: int, a: float = 0.0, b: float = 0.0, flags: int = 0, button: int = 0):
|
|
290
|
+
handle = self._socket
|
|
291
|
+
if handle is None:
|
|
292
|
+
return
|
|
293
|
+
self._sequence = (self._sequence + 1) & 0xFFFFFFFF
|
|
294
|
+
frame = _FRAME.pack(
|
|
295
|
+
_MAGIC, _VERSION, intent, self._sequence, flags, a, b, time.monotonic(), button, 0
|
|
296
|
+
)
|
|
297
|
+
try:
|
|
298
|
+
handle.send(frame)
|
|
299
|
+
except OSError as problem:
|
|
300
|
+
# The helper has gone. Drop to the fallback path rather than raising
|
|
301
|
+
# into the frame loop, and let reconnect() pick it up if it returns.
|
|
302
|
+
self.error = f"native helper unreachable: {problem}"
|
|
303
|
+
self._socket = None
|
|
304
|
+
handle.close()
|
|
305
|
+
|
|
306
|
+
def reconnect(self) -> bool:
|
|
307
|
+
"""Try to re-establish a dropped helper, with backoff. False if not yet."""
|
|
308
|
+
if self.connected:
|
|
309
|
+
return True
|
|
310
|
+
now = time.monotonic()
|
|
311
|
+
if now < self._next_attempt:
|
|
312
|
+
return False
|
|
313
|
+
self._attempts += 1
|
|
314
|
+
self._next_attempt = now + min(0.5 * self._attempts, 10.0)
|
|
315
|
+
binary = helper_path()
|
|
316
|
+
if binary is None:
|
|
317
|
+
return False
|
|
318
|
+
if self._spawn and not self._launch(binary):
|
|
319
|
+
return False
|
|
320
|
+
if self._connect():
|
|
321
|
+
self._attempts = 0
|
|
322
|
+
return True
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
# --------------------------------------------------------------------- intents
|
|
326
|
+
|
|
327
|
+
def move_by(self, dx: float, dy: float) -> None:
|
|
328
|
+
self._send(MOVE_BY, dx, dy)
|
|
329
|
+
|
|
330
|
+
def warp_to_fraction(self, fx: float, fy: float) -> None:
|
|
331
|
+
self._send(WARP_TO_FRACTION, fx, fy)
|
|
332
|
+
|
|
333
|
+
def click(self, button: str = "left") -> None:
|
|
334
|
+
self._send(CLICK, button=_BUTTONS.get(button, 0))
|
|
335
|
+
|
|
336
|
+
def press(self, button: str = "left") -> None:
|
|
337
|
+
self._send(PRESS, button=_BUTTONS.get(button, 0))
|
|
338
|
+
|
|
339
|
+
def release(self, button: str = "left") -> None:
|
|
340
|
+
self._send(RELEASE, button=_BUTTONS.get(button, 0))
|
|
341
|
+
|
|
342
|
+
def scroll(self, dx: float, dy: float) -> None:
|
|
343
|
+
self._send(SCROLL, dx, dy)
|
|
344
|
+
|
|
345
|
+
def release_all(self) -> None:
|
|
346
|
+
self._send(RELEASE_ALL)
|
|
347
|
+
|
|
348
|
+
def set_mode(self, *, engaged: bool, pointing: bool, sweeping: bool) -> None:
|
|
349
|
+
"""Tell the helper what kind of gesture is running.
|
|
350
|
+
|
|
351
|
+
Sent only on change. The helper needs this to know when to look for targets
|
|
352
|
+
at all: a scroll or a swipe is not aiming at anything, and snapping during
|
|
353
|
+
one would fight the hand.
|
|
354
|
+
"""
|
|
355
|
+
flags = (ENGAGED if engaged else 0) | (POINTING if pointing else 0)
|
|
356
|
+
flags |= SWEEPING if sweeping else 0
|
|
357
|
+
if flags == self._flags:
|
|
358
|
+
return
|
|
359
|
+
self._flags = flags
|
|
360
|
+
self._send(SET_MODE, flags=flags)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared plumbing for posting synthetic events.
|
|
2
|
+
|
|
3
|
+
Every event this app injects is stamped with the same marker so the physical-input
|
|
4
|
+
watcher in `modes.py` can recognise and ignore it. If the app could not tell its
|
|
5
|
+
own output apart from the user's input, it would suspend itself the moment it
|
|
6
|
+
moved the cursor.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import Quartz
|
|
12
|
+
|
|
13
|
+
EVENT_MARKER = 0x4D494E44 # "MIND"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_source():
|
|
17
|
+
"""An event source tagged as ours, or None if the system refuses one."""
|
|
18
|
+
source = Quartz.CGEventSourceCreate(Quartz.kCGEventSourceStateHIDSystemState)
|
|
19
|
+
if source is not None:
|
|
20
|
+
Quartz.CGEventSourceSetUserData(source, EVENT_MARKER)
|
|
21
|
+
return source
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def post(event) -> None:
|
|
25
|
+
"""Tag and dispatch one event to the HID tap."""
|
|
26
|
+
if event is None:
|
|
27
|
+
return
|
|
28
|
+
Quartz.CGEventSetIntegerValueField(event, Quartz.kCGEventSourceUserData, EVENT_MARKER)
|
|
29
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, event)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_ours(event) -> bool:
|
|
33
|
+
"""True when an observed event was injected by this app."""
|
|
34
|
+
return Quartz.CGEventGetIntegerValueField(event, Quartz.kCGEventSourceUserData) == EVENT_MARKER
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Synthetic key input via Quartz.
|
|
2
|
+
|
|
3
|
+
Used for the system-level gestures -- desktop switching, Mission Control,
|
|
4
|
+
dictation -- where the right thing to send is the shortcut a user would type.
|
|
5
|
+
Sending real shortcuts means these gestures work with whatever the user has
|
|
6
|
+
already configured, instead of this app reimplementing window management.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import Quartz
|
|
12
|
+
|
|
13
|
+
from ..config import KeyBinding
|
|
14
|
+
from .events import create_source, post
|
|
15
|
+
|
|
16
|
+
# macOS virtual key codes. Only the keys worth binding to a gesture are listed.
|
|
17
|
+
KEY_CODES: dict[str, int] = {
|
|
18
|
+
"left": 0x7B,
|
|
19
|
+
"right": 0x7C,
|
|
20
|
+
"down": 0x7D,
|
|
21
|
+
"up": 0x7E,
|
|
22
|
+
"space": 0x31,
|
|
23
|
+
"tab": 0x30,
|
|
24
|
+
"return": 0x24,
|
|
25
|
+
"escape": 0x35,
|
|
26
|
+
"delete": 0x33,
|
|
27
|
+
"f1": 0x7A,
|
|
28
|
+
"f2": 0x78,
|
|
29
|
+
"f3": 0x63,
|
|
30
|
+
"f4": 0x76,
|
|
31
|
+
"f5": 0x60,
|
|
32
|
+
"f6": 0x61,
|
|
33
|
+
"f7": 0x62,
|
|
34
|
+
"f8": 0x64,
|
|
35
|
+
"f9": 0x65,
|
|
36
|
+
"f10": 0x6D,
|
|
37
|
+
"f11": 0x67,
|
|
38
|
+
"f12": 0x6F,
|
|
39
|
+
"a": 0x00,
|
|
40
|
+
"c": 0x08,
|
|
41
|
+
"d": 0x02,
|
|
42
|
+
"h": 0x04,
|
|
43
|
+
"m": 0x2E,
|
|
44
|
+
"n": 0x2D,
|
|
45
|
+
"s": 0x01,
|
|
46
|
+
"t": 0x11,
|
|
47
|
+
"v": 0x09,
|
|
48
|
+
"w": 0x0D,
|
|
49
|
+
"z": 0x06,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
MODIFIER_FLAGS: dict[str, int] = {
|
|
53
|
+
"cmd": Quartz.kCGEventFlagMaskCommand,
|
|
54
|
+
"command": Quartz.kCGEventFlagMaskCommand,
|
|
55
|
+
"ctrl": Quartz.kCGEventFlagMaskControl,
|
|
56
|
+
"control": Quartz.kCGEventFlagMaskControl,
|
|
57
|
+
"alt": Quartz.kCGEventFlagMaskAlternate,
|
|
58
|
+
"option": Quartz.kCGEventFlagMaskAlternate,
|
|
59
|
+
"shift": Quartz.kCGEventFlagMaskShift,
|
|
60
|
+
"fn": Quartz.kCGEventFlagMaskSecondaryFn,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Keyboard:
|
|
65
|
+
"""Sends keystrokes for named actions defined in config."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, keys: dict[str, KeyBinding]) -> None:
|
|
68
|
+
self._source = create_source()
|
|
69
|
+
self._keys = keys
|
|
70
|
+
|
|
71
|
+
def update_bindings(self, keys: dict[str, KeyBinding]) -> None:
|
|
72
|
+
self._keys = keys
|
|
73
|
+
|
|
74
|
+
def tap(self, binding: KeyBinding) -> bool:
|
|
75
|
+
"""Press and release one key with modifiers. False if the key is unknown."""
|
|
76
|
+
code = KEY_CODES.get(binding.key.lower())
|
|
77
|
+
if code is None:
|
|
78
|
+
print(f"[keyboard] no key code for {binding.key!r}; add it to KEY_CODES")
|
|
79
|
+
return False
|
|
80
|
+
flags = 0
|
|
81
|
+
for name in binding.mods:
|
|
82
|
+
flag = MODIFIER_FLAGS.get(name.lower())
|
|
83
|
+
if flag is None:
|
|
84
|
+
print(f"[keyboard] unknown modifier {name!r}")
|
|
85
|
+
continue
|
|
86
|
+
flags |= flag
|
|
87
|
+
|
|
88
|
+
for pressed in (True, False):
|
|
89
|
+
event = Quartz.CGEventCreateKeyboardEvent(self._source, code, pressed)
|
|
90
|
+
if event is not None and flags:
|
|
91
|
+
Quartz.CGEventSetFlags(event, flags)
|
|
92
|
+
post(event)
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
def run_action(self, action: str) -> bool:
|
|
96
|
+
"""Fire a named action such as ``desktop_left``, if it is bound."""
|
|
97
|
+
binding = self._keys.get(action)
|
|
98
|
+
if binding is None:
|
|
99
|
+
print(f"[keyboard] action {action!r} has no key binding in [keys]")
|
|
100
|
+
return False
|
|
101
|
+
return self.tap(binding)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Mode arbitration between your hands and your hardware.
|
|
2
|
+
|
|
3
|
+
The rule that makes gesture control livable: the physical mouse and keyboard
|
|
4
|
+
always win, immediately, without asking. Touch either one and gesture output
|
|
5
|
+
stops mid-motion; stop touching them and it comes back on its own. You never
|
|
6
|
+
"exit" hand mode, you just reach for the trackpad.
|
|
7
|
+
|
|
8
|
+
Three modes:
|
|
9
|
+
|
|
10
|
+
``OFF`` nothing is driven; only a held open palm is watched for.
|
|
11
|
+
``ACTIVE`` hands are driving the cursor.
|
|
12
|
+
``SUSPENDED`` hands are tracked but muted, because hardware was just used.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from enum import Enum
|
|
21
|
+
|
|
22
|
+
import Quartz
|
|
23
|
+
|
|
24
|
+
from ..config import ModesConfig
|
|
25
|
+
from .events import is_ours
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Mode(Enum):
|
|
29
|
+
OFF = "off"
|
|
30
|
+
ACTIVE = "active"
|
|
31
|
+
SUSPENDED = "suspended"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PhysicalInputWatcher:
|
|
35
|
+
"""Watches for real mouse and keyboard use on a private run loop.
|
|
36
|
+
|
|
37
|
+
A listen-only event tap needs a CFRunLoop to deliver callbacks, and the main
|
|
38
|
+
thread already belongs to the menu bar, so the tap gets its own thread.
|
|
39
|
+
Our own synthetic events arrive here too and are filtered out by their tag.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
EVENTS = (
|
|
43
|
+
Quartz.kCGEventMouseMoved,
|
|
44
|
+
Quartz.kCGEventLeftMouseDown,
|
|
45
|
+
Quartz.kCGEventRightMouseDown,
|
|
46
|
+
Quartz.kCGEventScrollWheel,
|
|
47
|
+
Quartz.kCGEventKeyDown,
|
|
48
|
+
Quartz.kCGEventFlagsChanged,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def __init__(self, on_input: Callable[[], None]) -> None:
|
|
52
|
+
self._on_input = on_input
|
|
53
|
+
self._thread: threading.Thread | None = None
|
|
54
|
+
self._runloop = None
|
|
55
|
+
self._tap = None
|
|
56
|
+
self.error: str | None = None
|
|
57
|
+
|
|
58
|
+
def start(self) -> bool:
|
|
59
|
+
started = threading.Event()
|
|
60
|
+
self._thread = threading.Thread(target=self._run, args=(started,), name="input-watch")
|
|
61
|
+
self._thread.daemon = True
|
|
62
|
+
self._thread.start()
|
|
63
|
+
started.wait(timeout=3.0)
|
|
64
|
+
return self._tap is not None
|
|
65
|
+
|
|
66
|
+
def _run(self, started: threading.Event) -> None:
|
|
67
|
+
mask = 0
|
|
68
|
+
for event_type in self.EVENTS:
|
|
69
|
+
mask |= Quartz.CGEventMaskBit(event_type)
|
|
70
|
+
self._tap = Quartz.CGEventTapCreate(
|
|
71
|
+
Quartz.kCGSessionEventTap,
|
|
72
|
+
Quartz.kCGHeadInsertEventTap,
|
|
73
|
+
Quartz.kCGEventTapOptionListenOnly,
|
|
74
|
+
mask,
|
|
75
|
+
self._callback,
|
|
76
|
+
None,
|
|
77
|
+
)
|
|
78
|
+
if self._tap is None:
|
|
79
|
+
self.error = (
|
|
80
|
+
"could not observe input; grant Accessibility permission to enable "
|
|
81
|
+
"automatic hand-off to the mouse and keyboard"
|
|
82
|
+
)
|
|
83
|
+
started.set()
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
source = Quartz.CFMachPortCreateRunLoopSource(None, self._tap, 0)
|
|
87
|
+
self._runloop = Quartz.CFRunLoopGetCurrent()
|
|
88
|
+
Quartz.CFRunLoopAddSource(self._runloop, source, Quartz.kCFRunLoopCommonModes)
|
|
89
|
+
Quartz.CGEventTapEnable(self._tap, True)
|
|
90
|
+
started.set()
|
|
91
|
+
Quartz.CFRunLoopRun()
|
|
92
|
+
|
|
93
|
+
def _callback(self, proxy, event_type, event, refcon):
|
|
94
|
+
# A tap can be disabled by the system if it ever runs too slowly; the
|
|
95
|
+
# documented recovery is simply to switch it back on.
|
|
96
|
+
if event_type in (
|
|
97
|
+
Quartz.kCGEventTapDisabledByTimeout,
|
|
98
|
+
Quartz.kCGEventTapDisabledByUserInput,
|
|
99
|
+
):
|
|
100
|
+
Quartz.CGEventTapEnable(self._tap, True)
|
|
101
|
+
return event
|
|
102
|
+
if not is_ours(event):
|
|
103
|
+
self._on_input()
|
|
104
|
+
return event
|
|
105
|
+
|
|
106
|
+
def stop(self) -> None:
|
|
107
|
+
if self._tap is not None:
|
|
108
|
+
Quartz.CGEventTapEnable(self._tap, False)
|
|
109
|
+
if self._runloop is not None:
|
|
110
|
+
Quartz.CFRunLoopStop(self._runloop)
|
|
111
|
+
if self._thread is not None:
|
|
112
|
+
self._thread.join(timeout=1.0)
|
|
113
|
+
self._thread = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ModeManager:
|
|
117
|
+
"""Owns the current mode and the rules for changing it."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, cfg: ModesConfig, on_change: Callable[[Mode], None] | None = None) -> None:
|
|
120
|
+
self._cfg = cfg
|
|
121
|
+
self._on_change = on_change
|
|
122
|
+
self._lock = threading.Lock()
|
|
123
|
+
self._mode = Mode.ACTIVE if cfg.start_engaged else Mode.OFF
|
|
124
|
+
self._last_physical_input = 0.0
|
|
125
|
+
self._watcher: PhysicalInputWatcher | None = None
|
|
126
|
+
self.watcher_error: str | None = None
|
|
127
|
+
|
|
128
|
+
def start(self) -> None:
|
|
129
|
+
if not self._cfg.suspend_on_physical_input:
|
|
130
|
+
return
|
|
131
|
+
self._watcher = PhysicalInputWatcher(self._note_physical_input)
|
|
132
|
+
if not self._watcher.start():
|
|
133
|
+
self.watcher_error = self._watcher.error
|
|
134
|
+
print(f"[modes] {self.watcher_error}")
|
|
135
|
+
self._watcher = None
|
|
136
|
+
|
|
137
|
+
def stop(self) -> None:
|
|
138
|
+
if self._watcher is not None:
|
|
139
|
+
self._watcher.stop()
|
|
140
|
+
self._watcher = None
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def mode(self) -> Mode:
|
|
144
|
+
"""Current mode, resolving an expired suspension on read."""
|
|
145
|
+
with self._lock:
|
|
146
|
+
if self._mode is Mode.SUSPENDED:
|
|
147
|
+
idle = time.monotonic() - self._last_physical_input
|
|
148
|
+
if idle >= self._cfg.resume_after_s:
|
|
149
|
+
self._set(Mode.ACTIVE)
|
|
150
|
+
return self._mode
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def engaged(self) -> bool:
|
|
154
|
+
"""True when gesture output should actually reach the system."""
|
|
155
|
+
return self.mode is Mode.ACTIVE
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def watching_for_engage(self) -> bool:
|
|
159
|
+
"""True when the engage gesture is the only thing being listened for."""
|
|
160
|
+
return self.mode is Mode.OFF
|
|
161
|
+
|
|
162
|
+
def toggle(self) -> Mode:
|
|
163
|
+
"""Flip between off and active, clearing any suspension."""
|
|
164
|
+
with self._lock:
|
|
165
|
+
self._set(Mode.OFF if self._mode in (Mode.ACTIVE, Mode.SUSPENDED) else Mode.ACTIVE)
|
|
166
|
+
return self._mode
|
|
167
|
+
|
|
168
|
+
def set_mode(self, mode: Mode) -> None:
|
|
169
|
+
with self._lock:
|
|
170
|
+
self._set(mode)
|
|
171
|
+
|
|
172
|
+
def _note_physical_input(self) -> None:
|
|
173
|
+
"""Called from the tap thread on any real input."""
|
|
174
|
+
with self._lock:
|
|
175
|
+
self._last_physical_input = time.monotonic()
|
|
176
|
+
# Only an actively driving session gets suspended. If control is off,
|
|
177
|
+
# using the mouse should not silently arm it.
|
|
178
|
+
if self._mode is Mode.ACTIVE:
|
|
179
|
+
self._set(Mode.SUSPENDED)
|
|
180
|
+
|
|
181
|
+
def _set(self, mode: Mode) -> None:
|
|
182
|
+
"""Change mode. Caller holds the lock; the callback runs outside it."""
|
|
183
|
+
if mode is self._mode:
|
|
184
|
+
return
|
|
185
|
+
self._mode = mode
|
|
186
|
+
if self._on_change is not None:
|
|
187
|
+
self._on_change(mode)
|
|
188
|
+
|
|
189
|
+
def describe(self) -> str:
|
|
190
|
+
mode = self.mode
|
|
191
|
+
if mode is Mode.SUSPENDED:
|
|
192
|
+
remaining = self._cfg.resume_after_s - (time.monotonic() - self._last_physical_input)
|
|
193
|
+
return f"suspended ({max(remaining, 0.0):.1f}s)"
|
|
194
|
+
return mode.value
|