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,692 @@
1
+ Metadata-Version: 2.5
2
+ Name: mctrl
3
+ Version: 0.1.0
4
+ Summary: Control your Mac with hand and eye movements picked up by any camera you own.
5
+ Project-URL: Homepage, https://github.com/GriffinCanCode/mctrl
6
+ Project-URL: Repository, https://github.com/GriffinCanCode/mctrl
7
+ License: MIT
8
+ Requires-Python: <3.13,>=3.11
9
+ Requires-Dist: mediapipe<1.0,>=0.10.18
10
+ Requires-Dist: numpy>=1.26
11
+ Requires-Dist: opencv-python>=4.10.0
12
+ Requires-Dist: pyobjc-framework-applicationservices>=10.3
13
+ Requires-Dist: pyobjc-framework-avfoundation>=10.3
14
+ Requires-Dist: pyobjc-framework-cocoa>=10.3
15
+ Requires-Dist: pyobjc-framework-quartz>=10.3
16
+ Requires-Dist: rumps>=0.4.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # mindcontrol
20
+
21
+ Control your Mac with your hands and eyes, using the cameras you already own.
22
+
23
+ It runs as a menu-bar background app. Your gaze aims the cursor across the
24
+ screen, your hand refines the last inch and clicks, and the moment you touch the
25
+ real mouse or keyboard the hands get out of the way. Stand up, walk around, sit
26
+ back down — control follows whichever camera can see you.
27
+
28
+ ## The gesture vocabulary
29
+
30
+ The whole system is built on one distinction: **a hand that is talking to the
31
+ computer looks different from a hand that is just there.** Only the poses below
32
+ do anything. Your hand can rest on the desk, hold a coffee, or gesture while you
33
+ talk, and nothing happens.
34
+
35
+ ### Getting in and out
36
+
37
+ **Open palm, held still for 1 second** — engage or disengage hand control. This
38
+ is the only gesture recognised while control is off, so you can always put your
39
+ hands down safely.
40
+
41
+ **Touch the mouse, trackpad or keyboard** — control suspends instantly,
42
+ mid-motion. It comes back on its own about three seconds after you stop, or
43
+ immediately if you palm-toggle. You never "exit" hand mode; you just reach for
44
+ the trackpad and it yields.
45
+
46
+ ### Pointing
47
+
48
+ **Gaze** aims. Look somewhere and the cursor jumps to that region once your eyes
49
+ settle there for ~150ms. Eyes are excellent at crossing a screen and poor at
50
+ holding still, which is why they only ever do the coarse half.
51
+
52
+ **Ready pose** — index finger and thumb both out, hand relaxed in a loose C —
53
+ turns your hand into a trackpad floating in the air. Move it and the cursor
54
+ moves relative to where it already is, so small wrist motions do fine work.
55
+ Pointer acceleration means slow movement is precise and fast movement is
56
+ sweeping.
57
+
58
+ Gaze politely stands down whenever the hand is moving, dragging or scrolling, so
59
+ the two never fight over the cursor.
60
+
61
+ ### Acting
62
+
63
+ **Pinch thumb to index, quick tap** — left click.
64
+
65
+ **Two quick pinch taps** — double click. Sent as a real chained click, so Finder
66
+ and everything else treat it exactly like a trackpad double click.
67
+
68
+ **Pinch thumb to middle finger** — right click.
69
+
70
+ **Pinch and hold, then move** — grab and drag. Hold the pinch past a quarter
71
+ second and you are now holding the thing under the cursor; release to drop it.
72
+ This is the main way to manipulate anything directly.
73
+
74
+ **Close your hand into a fist and move** — scroll and pan, as if you had grabbed
75
+ the page and were pulling it. Open your hand to let go.
76
+
77
+ ### System control
78
+
79
+ **Open palm, swipe left or right** — switch desktops.
80
+
81
+ **Open palm, push up** — Mission Control.
82
+
83
+ **Thumb and pinky out ("telephone hand"), held** — toggle dictation, for typing
84
+ by voice. macOS has no shortcut for this that can be synthesised reliably, so
85
+ assign a real key in System Settings > Keyboard > Dictation and mirror it under
86
+ `[keys]` in `config.toml` (default `f5`).
87
+
88
+ An open palm that *moves* is a swipe and an open palm that *sits still* is the
89
+ engage toggle, so those two can never be confused. Every held gesture fires once
90
+ and then waits for you to change pose, so holding a palm for three seconds
91
+ toggles control exactly once.
92
+
93
+ ## Install
94
+
95
+ Requires macOS on Apple silicon and Python 3.11 or 3.12.
96
+
97
+ ```bash
98
+ uv sync # installs the exact versions in uv.lock
99
+ ```
100
+
101
+ MediaPipe is pinned below 1.0 deliberately: the 1.x macOS arm64 wheels abort
102
+ inside `TensorsToDetectionsCalculator` asking for a Metal service the wheel does
103
+ not ship. `uv.lock` is checked in because that is not the only sharp edge in this
104
+ dependency set — the 0.10 line has also disagreed with itself about NumPy 2 — so
105
+ the combination known to work is recorded rather than re-resolved.
106
+
107
+ The two model bundles (~11 MB) download themselves into
108
+ `~/.cache/mindcontrol/models/` the first time you run.
109
+
110
+ Then build the native helper, which is what makes the cursor smooth and lets it
111
+ snap to what you are aiming at:
112
+
113
+ ```bash
114
+ mindcontrol bridge # compiles native/ and reports where it stands
115
+ ```
116
+
117
+ Needs the Xcode command line tools (`xcode-select --install`). If you skip this
118
+ the app still runs — it posts events straight from Python instead — but you get
119
+ one cursor move per camera frame and nothing snaps or highlights. The menu-bar
120
+ status line ends in `snapping` or `raw pointer` so you can tell which you have.
121
+
122
+ ### As a Mac app
123
+
124
+ To get an icon you can keep in the Dock instead of a shell you have to keep open:
125
+
126
+ ```bash
127
+ make app # build/MindControl.app, from its own CPython up
128
+ make install # copy it into /Applications
129
+ make dmg # dist/MindControl-<version>.dmg, to install it somewhere else
130
+ ```
131
+
132
+ The first build takes a few minutes because it fetches a whole interpreter and
133
+ every pinned wheel. Afterwards use `make update`, which reinstalls just this
134
+ project and the helper into the existing bundle, syncs the handful of changed
135
+ files into `/Applications` and relaunches — about fifteen seconds. `make help`
136
+ lists the rest.
137
+
138
+ The bundle carries its own CPython and the whole pinned dependency set, so once
139
+ it is installed it reads nothing out of the checkout it was built from. The
140
+ native helper is built into it and pointed at through `MINDCONTROL_BRIDGE`, and
141
+ `config.toml` is copied into `~/.config/mindcontrol/` the first time it runs,
142
+ after which that copy is yours to edit.
143
+
144
+ It is a status-bar app, so opening it puts the glyph in the menu bar and nothing
145
+ in the Dock. There is no terminal for it to complain to either, so everything it
146
+ would have printed goes to `~/.local/state/mindcontrol/app.log`.
147
+
148
+ The icon is drawn rather than drawn on: `packaging/icon.py` renders it with
149
+ CoreGraphics at build time. It is deliberately full-bleed, because macOS 26
150
+ rounds, masks and shadows an app icon itself and reads artwork that arrives with
151
+ its own corners as a picture to inset into a plate. Pass `--plate` for the older
152
+ look.
153
+
154
+ ## Permissions
155
+
156
+ Three grants are needed, all for whichever app launches the process — your
157
+ terminal, if you start it from a shell, and `MindControl.app` itself if you
158
+ installed the bundle, which is the tidier of the two:
159
+
160
+ **Camera** — macOS asks the first time. If you miss the prompt, System Settings
161
+ > Privacy & Security > Camera.
162
+
163
+ **Accessibility** — required to move the cursor and to notice when you touch the
164
+ real mouse. System Settings > Privacy & Security > Accessibility. Without it the
165
+ app runs and tracks your hands perfectly while silently failing to move
166
+ anything, so it says so loudly at startup.
167
+
168
+ **Menu Bar** — macOS 26 hosts every third-party status item through Control
169
+ Center. System Settings > Menu Bar > Allow in the Menu Bar. Without it the
170
+ process is running and there is nothing to click. If MindControl is not in that
171
+ list, the bundle never registered; rebuild with `make app`.
172
+
173
+ The native helper needs the same grant **in its own right**, because macOS
174
+ attaches the permission to a binary rather than to a project. That is a feature
175
+ here: granted to `mindcontrol-bridge` it survives rebuilding your virtualenv,
176
+ which a grant made to `.venv/bin/python` does not. The helper also uses it for a
177
+ second purpose — asking the window server what is on screen, which is how it
178
+ knows what to highlight. Without it, motion and clicks still work and snapping
179
+ silently does not, so it says so on startup too.
180
+
181
+ `make permissions` opens those panes. Installed as a bundle there is only one
182
+ entry to enable, `MindControl`: the helper is spawned by the app and lives
183
+ inside it, so macOS holds the app responsible for what it asks for and the grant
184
+ covers both. Running from a checkout is where the helper needs its own entry,
185
+ because there the responsible process is your terminal.
186
+
187
+ ### Keeping the grants across updates
188
+
189
+ An ad-hoc signature — the default, because it needs nothing set up — pins the
190
+ bundle's designated requirement to a hash of its own contents:
191
+
192
+ ```
193
+ $ codesign -d -r- /Applications/MindControl.app
194
+ designated => cdhash H"e3bbe92e…"
195
+ ```
196
+
197
+ Every update changes that hash, so macOS sees a different application, asks for
198
+ the camera again and quietly stops honouring the Accessibility entry — while
199
+ still showing its switch as on, because the entry belongs to the copy you built
200
+ last time. That failure is worth recognising: the app runs, tracks your hands,
201
+ and moves nothing, which looks like a bug in the tracking and is a signature.
202
+
203
+ Two copies of the bundle do the same thing to each other. `build/MindControl.app`
204
+ and `/Applications/MindControl.app` are two applications with one identifier, so
205
+ grant the one you run, which `make run` and `make update` take to be the
206
+ installed one.
207
+
208
+ Signing with a certificate pins the requirement to the certificate instead, which
209
+ does not change. Any code-signing certificate on your keychain is found and used
210
+ without being asked for:
211
+
212
+ ```
213
+ $ packaging/identity.sh
214
+ Developer ID Application: …
215
+ ```
216
+
217
+ With none, builds are ad-hoc and the grants are given again each time.
218
+ `SIGN_IDENTITY` overrides the choice, and `SIGN_IDENTITY=-` forces ad-hoc. If you
219
+ have no certificate, Keychain Access > Certificate Assistant > Create a
220
+ Certificate… makes one: identity type **Self Signed Root**, certificate type
221
+ **Code Signing**. Grant the three permissions once to a build signed that way and
222
+ later `make update`s keep them.
223
+
224
+ ## Running
225
+
226
+ ```bash
227
+ mindcontrol # menu-bar background app
228
+ mindcontrol --debug # foreground, with the tuning overlay
229
+ mindcontrol --debug --no-overlay # foreground, status line only
230
+ mindcontrol calibrate # nine-point gaze calibration
231
+ mindcontrol cameras # list capture devices
232
+ mindcontrol record # capture a labelled gesture session
233
+ mindcontrol autotune # fit thresholds to that session
234
+ mindcontrol replay # run a session back through the engine
235
+ mindcontrol bridge # build the native helper
236
+ ```
237
+
238
+ When snapping picks the wrong thing, ask the helper what it can see under the
239
+ cursor:
240
+
241
+ ```bash
242
+ native/.build/release/mindcontrol-bridge --inspect
243
+ ```
244
+
245
+ It prints every candidate with its role, its distance, and its size, and marks
246
+ the one it would choose with `->`. This is how every selection bug so far was
247
+ found rather than reasoned about: a container directly under the cursor scoring
248
+ zero and so beating the button inside it, word lookup asking for a fixed 96
249
+ characters of context in documents shorter than that, a panel-sized `AXGroup`
250
+ winning over empty space, and the whole Dock going dark because `AXDockItem` was a
251
+ role the ranking had never been shown. It takes no single-instance claim, so it
252
+ works while the real helper is running.
253
+
254
+ When the cursor ends up somewhere you did not ask for, the goal, the destination
255
+ and the posted position are three different numbers, and the only useful question
256
+ is which of them disagreed:
257
+
258
+ ```bash
259
+ native/.build/release/mindcontrol-bridge --trace # one line per tick, on stderr
260
+ ```
261
+
262
+ Every button also prints where it actually landed and what it was aimed at, which
263
+ the per-tick lines cannot show: a click is precisely the moment the cursor stops
264
+ being where the tick said it was. Reading a press resolve to a word 130 px away
265
+ is how the stale-position bug above was found, after two rounds of guessing at it.
266
+
267
+ The menu-bar glyph is your state at a glance: `◉` engaged, `◐` suspended because
268
+ you touched hardware, `○` off. The menu carries a live status line (mode, hands
269
+ seen, cameras, frame rate, whether gaze is calibrated), an engage toggle, the
270
+ overlay toggle, and calibration.
271
+
272
+ MediaPipe announces its GL version, its XNNPACK delegate and two feedback
273
+ managers on every start, which buries the output you actually want to read during
274
+ a guided recording. Those lines are suppressed while the models load:
275
+
276
+ ```bash
277
+ MINDCONTROL_VERBOSE=1 mindcontrol record # put them back
278
+ ```
279
+
280
+ Suppression only covers model construction, and only when it succeeds. If a model
281
+ fails to load, everything it logged on the way down is printed, since that is the
282
+ one time those lines are worth having.
283
+
284
+ ### Calibrating gaze
285
+
286
+ Run `mindcontrol calibrate`, or pick "Calibrate gaze..." from the menu. Nine
287
+ dots appear; look at each until its ring closes. Escape cancels and leaves any
288
+ existing calibration untouched.
289
+
290
+ Calibration learns your main display specifically — the one the camera watched
291
+ you look at. Hand movement can roam across every monitor; gaze warps land on
292
+ that one screen. Recalibrate if you move the camera or change seat.
293
+
294
+ Until you calibrate, everything except gaze works, and the pointer is purely
295
+ hand-driven.
296
+
297
+ ## Using more than one camera
298
+
299
+ Find out what you have:
300
+
301
+ ```bash
302
+ mindcontrol cameras # names, resolutions, and which indices work
303
+ mindcontrol cameras --preview # a frame from each, to see which is which
304
+ ```
305
+
306
+ Names come from AVFoundation, whose ordering has been seen to disagree with
307
+ OpenCV's indices, and there is no shared identifier to reconcile them. `--preview`
308
+ is the only way to be certain, and being certain matters: `primary_gaze` pointed
309
+ at the wrong camera means gaze estimated from a view of the wall.
310
+
311
+ Then say which cameras to use and which one watches your eyes:
312
+
313
+ ```toml
314
+ [cameras]
315
+ devices = [0, 1]
316
+ primary_gaze = 0
317
+ ```
318
+
319
+ Nothing else changes. Each camera gets its own thread and its own inference, and
320
+ the results are merged by how confident each camera is. The payoff is occlusion:
321
+ a pinch hidden behind your palm from the laptop is obvious from a camera at your
322
+ side, and either one can carry the gesture.
323
+
324
+ Hand *shape* is combined across cameras by confidence-weighted vote. Hand
325
+ *position* is not averaged — each camera sees you from a different place, so an
326
+ average would describe a hand that exists nowhere. One camera leads for position,
327
+ and when the lead changes the new leader's coordinates are shifted onto where the
328
+ pointer already was, so the handover costs neither a jump nor the motion in that
329
+ frame. Losing the motion mattered: a swipe is judged on accumulated travel, and a
330
+ handover mid-sweep used to erase it.
331
+
332
+ Gaze runs on the primary camera only. It is the expensive model, and only a
333
+ camera near the screen you are looking at can say anything useful.
334
+
335
+ More cameras is not automatically better, and the reason is the vote. A camera
336
+ that sees the hand nearly edge-on still gets a say in the blended shape, and its
337
+ say is wrong in a particular direction: fingers foreshorten, so an open palm can
338
+ read as something closed. On one recorded session a phone added as a third view
339
+ was worth two extra clicks and cost five swipes — 7 down to 2 — because the palm
340
+ that has to stay open through a sweep kept flickering. Judge a camera by replaying
341
+ a session with and without it, not by how good its own picture looks.
342
+
343
+ Three cameras measured at 19ms median per poll on an M4 Max, against a 33ms
344
+ budget at 30fps, so time is not what limits how many you add. Two things to know
345
+ if a camera might come and go — an iPhone over Continuity does:
346
+
347
+ List it **last**. Indices are positional, so a device that disappears from the
348
+ middle renumbers everything after it, and a config naming cameras by number would
349
+ quietly start pointing at different lenses. Lose the trailing index and the rest
350
+ keep their meaning.
351
+
352
+ Expect a wait on start. Cameras are given up to twelve seconds to deliver their
353
+ first frame before recording begins, because a Continuity camera has been
354
+ measured taking most of five, and frames captured while one is still waking are
355
+ indistinguishable afterwards from a camera that saw nothing. Any camera that
356
+ never wakes is named, and the session goes ahead without it.
357
+
358
+ ## Tuning
359
+
360
+ The shipped thresholds are reasoned, not measured. They were chosen against hand
361
+ proportions in the literature, and nobody's hands are the literature. Rather than
362
+ nudging numbers until things feel right, you can measure yours once:
363
+
364
+ ```bash
365
+ mindcontrol record # ~1 minute: hold each pose when prompted
366
+ mindcontrol autotune # see what your hands imply, change nothing
367
+ mindcontrol autotune --apply
368
+ ```
369
+
370
+ `record` walks you through the poses and stores the raw landmarks together with
371
+ the pose it asked for. That pairing is what makes it useful: the label says which
372
+ cluster each sample belongs to, so a boundary can be *found* instead of guessed.
373
+
374
+ **Use one hand and keep the other out of frame.** A second hand resting in shot
375
+ is not idle data — it is a different pose wearing the same label, and it corrupts
376
+ every threshold fitted from that prompt. Both `autotune` and `replay` check for
377
+ this and tell you when a recording cannot answer the question you asked of it.
378
+
379
+ `autotune` reads those clusters and puts each threshold in the gap between them —
380
+ your pinched distances on one side, your open ones on the other. Percentiles
381
+ rather than extremes, so a single bad frame cannot move a threshold. **If two
382
+ clusters overlap it declines and tells you**, because a tuner that always emits a
383
+ number is indistinguishable from one that emits noise. Without `--apply` it only
384
+ reports. With it, `config.toml` is edited in place, comments intact.
385
+
386
+ `replay` runs a recording back through the gesture engine offline:
387
+
388
+ ```bash
389
+ mindcontrol replay
390
+ ```
391
+
392
+ It prints what each prompt actually classified as, and what the state machine did
393
+ about it — how many clicks your taps produced, whether a held pinch became one
394
+ drag or a burst of clicks, whether anything fired while control was off. Because
395
+ it reads recorded timestamps rather than the clock, every run is identical, so
396
+ this is also how you tell whether a config change helped or hurt.
397
+
398
+ Use it before `--apply`, not after. A fit can be sound and still be a regression,
399
+ because several of these thresholds trade one pose against another: raising
400
+ `thumb_extended` until a fist is recognised also makes it harder for a thumb to
401
+ count as *out*, which is what the telephone pose needs. Replay each candidate,
402
+ then write only the ones that earned it:
403
+
404
+ ```bash
405
+ mindcontrol autotune --apply --only thumb_extended,pinch_close
406
+ ```
407
+
408
+ When one gesture needs another attempt and the rest of the script already worked,
409
+ re-record just that part:
410
+
411
+ ```bash
412
+ mindcontrol record --focus pinch # ~27s
413
+ mindcontrol record --focus swipe # ~16s
414
+ mindcontrol record --focus poses
415
+ ```
416
+
417
+ Each group carries the prompts its fit *depends on*, not only the failing one. A
418
+ threshold is a boundary between two clusters, so recording just the pinch would
419
+ leave nothing to separate it from — `--focus pinch` therefore also captures the
420
+ open hands that form the other side of that boundary.
421
+
422
+ Recordings live in `~/.local/state/mindcontrol/sessions/`. They are worth keeping:
423
+ they turn "it feels wrong" into a number, and they let the test suite check real
424
+ gestures long after the moment you performed them.
425
+
426
+ ### Tuning by hand
427
+
428
+ Open the overlay (`mindcontrol --debug`) and watch the real numbers from your own
429
+ hands in your own light. It shows each hand's skeleton, its classified pose, its
430
+ live pinch distance, `x2` when cameras are being merged, and a small screen proxy
431
+ showing where gaze thinks you are looking.
432
+
433
+ Every threshold lives in `config.toml`, and "Reload config" applies edits without
434
+ restarting. Distances are in **palm units** — divided by the span from your wrist
435
+ to your middle knuckle — so they hold whether you are at the keyboard or across
436
+ the room.
437
+
438
+ The knobs worth reaching for first:
439
+
440
+ - **Cursor too twitchy** — lower `pointer.filter_fc_min`. **Too laggy** — raise it.
441
+ - **Cursor too slow to cross the screen** — raise `pointer.sensitivity` or
442
+ `pointer.gain_max`.
443
+ - **Clicks not registering** — raise `gestures.pinch_close` toward your measured
444
+ pinch distance. **Clicks firing on their own** — lower it.
445
+ - **Clicks sticking down** — lower `gestures.pinch_open`. Watch your resting
446
+ pinch number on the overlay; it must sit clearly above this value.
447
+ - **Taps turning into drags** — raise `gestures.tap_max_ms`.
448
+ - **Open palms triggering when your hand is sideways** — raise
449
+ `gestures.palm_facing` toward 0.3. It defaults to 0.0, accepting any
450
+ orientation, because a too-strict setting here would stop you engaging at all.
451
+ - **Accidental swipes** — raise `gestures.swipe_min_speed` or
452
+ `swipe_min_travel`.
453
+ - **Swipes never firing, though the palm is recognised when you hold it still** —
454
+ raise `gestures.swipe_grace_ms`. A sweeping palm is blurred and rotating, so the
455
+ pose drops out partway through; without a grace window the accumulated travel is
456
+ wiped mid-gesture and no swipe ever completes. 600 ms is enough for most hands.
457
+ Lower it if a fist just after a swipe fails to scroll.
458
+ - **Adding a camera made swipes *worse*** — check the pose report from
459
+ `mindcontrol replay` for the prompt that is failing. Handing the lead between
460
+ cameras is free (the new leader's own movement is stitched onto the track, so
461
+ `rebases` should stay near zero), but a camera with a poor view still pulls the
462
+ *shape* average around, and a swipe needs the open palm to survive the sweep.
463
+ A camera that sees your hand edge-on helps a still pinch and hurts a sweep.
464
+ - **Pinches doing nothing, or scrolling instead of clicking** — your pinch is
465
+ probably curling the other three fingers, which makes it a fist, and a fist
466
+ scrolls by design. Keep the middle, ring and little fingers out. No threshold
467
+ can separate the two: on a curled pinch they measure identically.
468
+ - **Gaze fighting your hand** — lower `gaze.hand_quiet_speed`. **Gaze warping on
469
+ small corrections** — raise `gaze.warp_min_distance`.
470
+
471
+ Set `pointer.mode` to `hands` to switch gaze off entirely, or `gaze` to lean on
472
+ it harder.
473
+
474
+ ## How it fits together
475
+
476
+ ```
477
+ Python -- what a hand meant
478
+ cameras (one thread each)
479
+ -> hand landmarks per camera tracking/hands.py
480
+ -> gaze features on the primary tracking/gaze.py
481
+ -> merge cameras into one view fusion.py
482
+ -> classify shape, run the machine geometry.py, gestures/engine.py
483
+ -> send intents over a socket control/bridge.py
484
+
485
+ Swift -- what the cursor does about it native/Sources/BridgeCore/
486
+ <- 48-byte datagrams Protocol.swift, Transport.swift
487
+ -> integrate motion at display rate Motion.swift
488
+ -> ask what is on screen Probe.swift, Targets.swift
489
+ -> post real input events Cursor.swift
490
+ -> draw the highlight Overlay.swift
491
+ -> be the only helper running Solitary.swift
492
+ -> assemble the above Run.swift
493
+ ```
494
+
495
+ `BridgeCore` is a library and `native/Sources/Bridge/main.swift` is a two-line
496
+ executable over it, so target selection and the wire format can be tested without
497
+ a screen, an Accessibility grant, or another application to interrogate:
498
+ `swift test --package-path native`.
499
+
500
+ `pipeline.py` runs the camera loop on a worker thread. `app.py` keeps the main
501
+ thread for the menu bar, because a macOS status item needs the Cocoa run loop.
502
+ `control/modes.py` arbitrates between your hands and your hardware, watching for
503
+ physical input on a private run loop of its own.
504
+
505
+ Every event the app injects is tagged, and the watcher ignores anything carrying
506
+ that tag. Without it the app would see its own cursor motion, conclude a human
507
+ had grabbed the mouse, and suspend itself the instant it started working. Both
508
+ sides stamp the same tag, so `control/events.py` and `Cursor.swift` have to agree
509
+ about it.
510
+
511
+ ### Why the cursor moved to Swift
512
+
513
+ Not for speed in the abstract. Three things could not be done from Python, and
514
+ each was measured before it was moved.
515
+
516
+ **Interpolation.** The camera has an opinion thirty times a second; the display
517
+ can show a new position a hundred and twenty times. Posting one move per camera
518
+ frame is four refreshes of stillness and then a jump, which is what "not smooth"
519
+ was. The helper accumulates deltas into a goal and walks the cursor there with a
520
+ critically damped spring, on a thread that asks for the user-interactive band and
521
+ is never behind the GIL while MediaPipe is running inference. Counted with an
522
+ event tap: **4.9 events per intent at 114 Hz**, 8.4 ms apart, largest step 7 px,
523
+ against 1.0 event per intent 33 ms apart and 20 px each.
524
+
525
+ **One writer.** Cursor motion used to be posted from wherever a frame arrived, so
526
+ a gaze warp and a hand delta could each be computed from a position the other had
527
+ already invalidated. That is what "overlapping with itself" was. Now one thread
528
+ owns the cursor and *clicks are queued to it too*, which makes the ordering total:
529
+ a press and its release always bracket exactly the motion between them. It costs a
530
+ click one tick — eight milliseconds — and no measured reversal survives it.
531
+
532
+ One thread is only one writer if there is also one process, and that does not come
533
+ for free. Binding the socket unlinks whatever was there, so a second helper takes
534
+ every frame and looks perfectly healthy while the first sits on its last goal with
535
+ a live motion thread, a live probe, and a second highlight window — started two
536
+ deliberately and got exactly that. So the claim is held as an `flock`, which the
537
+ kernel drops however the holder dies, and the newcomer evicts the incumbent rather
538
+ than refusing to start: an orphan nobody can see must not be able to block every
539
+ future launch. A helper also exits on its own once `getppid()` is 1, because a
540
+ parent that crashes never gets to clean up. `--inspect` deliberately takes no
541
+ claim, since diagnosing a *running* cursor is most of what it is for.
542
+
543
+ One writer is still not the *only* writer, because the rest of the system has
544
+ never agreed to that. Another application warping the pointer, a dialog taking
545
+ focus, Mission Control — after any of them the helper's idea of where the cursor
546
+ is describes somewhere it no longer is, and every target and pull computed from it
547
+ is wrong in a way nothing on screen reveals until a click lands in the wrong
548
+ place. Measured: two identical drags in a row, the second pressing on a word 130
549
+ px from where the cursor had been put, because the probe was still answering about
550
+ the first. So the real position is compared against the posted one every refresh,
551
+ and any disagreement is adopted as truth — the goal, the velocity, the held
552
+ target and every probe taken before that moment all discarded together.
553
+
554
+ Only while the cursor is at rest, though, and that restriction is the whole
555
+ reason it is safe: mid-flight the window server is legitimately a frame behind
556
+ what was just posted, and at the top speed allowed here a frame is two hundred
557
+ pixels, indistinguishable from a real jump. At rest there is nothing to mistake.
558
+ Worth knowing what "at rest" has to mean — the cursor stopped moving, *not* the
559
+ cursor reached its goal, because with a target in range the spring settles at the
560
+ goal plus the pull. Comparing against the goal held the check off permanently at
561
+ a 7 px steady-state offset, which is exactly how the bug above survived its first
562
+ fix.
563
+
564
+ **Knowing what is on screen.** Snapping needs to ask, and asking is synchronous
565
+ IPC into another application's main thread:
566
+
567
+ | | pyobjc | native Swift |
568
+ |---|---|---|
569
+ | one attribute read | 1140 µs | 382 µs |
570
+ | whole-window tree walk (2419 nodes) | 2490 ms | 4251 ms |
571
+ | single-point hit test | — | 0.43 ms (p95 3.46 ms) |
572
+ | four attributes, batched vs separate | — | 0.14 vs 0.31 ms |
573
+
574
+ The middle row is the important one: building a target list by walking a window is
575
+ hopeless in *either* language — four seconds, by which time the layout has changed.
576
+ So nothing walks the tree. The primitive that works is the single-point hit test,
577
+ which answers "what is here" rather than "what exists", and nearness is
578
+ reconstructed by asking about the cursor, the point it is heading for, and a ring
579
+ around that. Swift is only 1.2× faster per node, because the cost is the IPC — but
580
+ it is 3× faster per call, and the p95 is why the probe runs on a thread nobody
581
+ waits on, with a messaging timeout so an application wedged on its own main thread
582
+ costs one skipped probe rather than a frozen cursor.
583
+
584
+ ### What snapping actually does
585
+
586
+ Choosing a target is not finding the closest rectangle. Four corrections turn
587
+ proximity into intent, and they are tunable in `[native]`:
588
+
589
+ - **Role.** A hit test lands on whatever is deepest at the point, which is usually
590
+ the group containing the button — 18 of 25 sampled screen points returned an
591
+ `AXGroup`. A button outranks its container even when the container is nearer.
592
+ - **Scenery is refused outright.** Outranking a container is not enough when it is
593
+ the only candidate: a Finder window with the cursor in the gap between two icons
594
+ offered a 614×756 `AXGroup` at zero distance, which won, drew a highlight over
595
+ most of the window, and pulled nowhere at all — a large target's anchor clamps to
596
+ where the cursor already was. Nothing is the right answer over empty space.
597
+ Containers are therefore named and excluded rather than merely outranked. A role
598
+ the table has never seen is judged by its shape instead of refused, because
599
+ refusing all of them silently killed the entire Dock, where an icon is an
600
+ `AXDockItem`.
601
+ - **Heading.** Where the hand is travelling is better evidence than where it
602
+ currently is, so a target ahead of the cursor beats an equidistant one behind.
603
+ - **Stickiness.** The target already held keeps a bonus, or a cursor resting on the
604
+ boundary between two buttons alternates every probe and the highlight strobes.
605
+ This is the pinch detector's hysteresis applied to space instead of time.
606
+
607
+ The pull is a force, not a jump: it fades to nothing at the edge of the snap
608
+ radius so crossing that boundary is not felt, and a large target's anchor is the
609
+ nearest point on it, so the pull vanishes once you are inside and the hand has
610
+ full freedom again. Exactness comes at click time instead — a click resolves to
611
+ the highlighted target itself, so the highlight never promises something the click
612
+ does not honour. A drag freezes its target, except when it began on a word, in
613
+ which case it keeps snapping to words.
614
+
615
+ That last exception is what makes selecting a range land on whole ones, and it
616
+ needs the two ends resolved differently. A click on a word wants its middle, which
617
+ is where a caret belongs inside a word — but a press is the start of a selection,
618
+ so it takes the word's *leading* edge, and the release takes the far edge of the
619
+ word it ends on, measured against the word it began on so that dragging leftwards
620
+ takes the first word entirely rather than the last. Without it, dragging from
621
+ "quick" to "lazy" in TextEdit selected `ick brown fox jumps over the la`; with it,
622
+ the same drag selects whole words, and a drag that stays inside one word selects
623
+ that word.
624
+
625
+ The highlight is a single Core Animation layer, retargeted rather than redrawn.
626
+ That is why it cannot overlap itself, and why it glides at display rate even when
627
+ the probe underneath it is stuttering.
628
+
629
+ ### Text, and where it stops working
630
+
631
+ Aiming at a word uses three calls: the point maps to a character index, a clamped
632
+ window of text around that index gives the word's extent, and one bounds query
633
+ turns the extent into a rectangle. Verified in TextEdit: a 23×14 box on the word
634
+ directly under the cursor, and neighbouring words picked up from the probe ring
635
+ when the cursor is on a space between them.
636
+
637
+ Two things had to be true for that to be trustworthy. Prose is aimed *inside*, not
638
+ at, so a text element is only a target in its own right if it is short enough to be
639
+ a field — otherwise a 656×384 document view wins by default and outlines the very
640
+ text it is supposed to be pointing at. And there is no error for "no glyph here":
641
+ a position past the end of the text answers with index zero, indistinguishable from
642
+ a real hit on the first character, which offered the document's opening word to a
643
+ point measured 92 px away in the bottom margin. Since the bounds of the answer are
644
+ already being fetched, they are checked against the question — if the box is not on
645
+ the line that was asked about, the answer is discarded. Clicking blank space still
646
+ places the caret there; it simply no longer lights up a word to promise otherwise.
647
+
648
+ Chromium and Electron windows answer the character count with nothing, so no
649
+ amount of asking will find a word inside one — including in this project's own
650
+ editor. Whole-element snapping still works there; word snapping does not.
651
+ `PixelTextLocator` in `Probe.swift` is the seam for the way around it, reading
652
+ glyph boxes from the pixels instead. It is deliberately unimplemented: Vision's
653
+ fast recogniser is affordable at 4 ms on a 240×120 patch, but one-shot capture
654
+ measured 53 ms regardless of region size, so doing it properly means holding a
655
+ persistent `SCStream` on the focused window and asking for Screen Recording
656
+ permission on top of Accessibility. Every caller already copes with a locator that
657
+ declines to answer.
658
+
659
+ ## Tests
660
+
661
+ ```bash
662
+ uv sync --group dev
663
+ uv run pytest
664
+ ```
665
+
666
+ Three layers, deliberately separated:
667
+
668
+ - **Logic** — the state machine and the geometry, driven by stated measurements
669
+ and by synthetic hands built to human proportions. Fast, and independent of
670
+ cameras, lighting, and hands.
671
+ - **Machinery** — a scripted session replayed end to end, covering session I/O,
672
+ re-measurement, and every gesture the engine can emit. Deterministic, so it
673
+ runs anywhere.
674
+ - **Reality** — the same assertions against your own recording. These *skip*
675
+ until you have run `mindcontrol record`, rather than invent input, because a
676
+ test that fabricates its own data would report success while checking nothing
677
+ about the person using this.
678
+
679
+ When a reality test fails but the matching machinery test passes, the pipeline is
680
+ fine and the thresholds do not suit those hands: run `mindcontrol autotune`.
681
+
682
+ ## Limits worth knowing
683
+
684
+ Gaze from a webcam is good for regions, not for pixels; expect a few percent of
685
+ screen error, which is exactly why the hand does the last inch.
686
+
687
+ There is no air keyboard. Typing goes through dictation.
688
+
689
+ Gaze is calibrated for one display and one seating position.
690
+
691
+ Hand tracking needs your hand reasonably lit and reasonably unoccluded. A second
692
+ camera helps more than better thresholds.