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.
@@ -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