project-away 0.1.1__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.
Files changed (75) hide show
  1. away/__init__.py +4 -0
  2. away/__main__.py +3 -0
  3. away/actions.py +329 -0
  4. away/app/__init__.py +1 -0
  5. away/app/autopause.py +119 -0
  6. away/app/autostart.py +83 -0
  7. away/app/cursor_overlay.py +110 -0
  8. away/app/frozen.py +175 -0
  9. away/app/models.py +87 -0
  10. away/app/overlay.py +61 -0
  11. away/app/runner.py +723 -0
  12. away/app/selftest.py +194 -0
  13. away/app/settings_gui.py +1202 -0
  14. away/app/setup_wizard.py +130 -0
  15. away/app/tray.py +197 -0
  16. away/app/uninstall.py +56 -0
  17. away/bindings.py +548 -0
  18. away/cli.py +747 -0
  19. away/config.py +411 -0
  20. away/control/__init__.py +1 -0
  21. away/control/keys.py +245 -0
  22. away/control/mouse.py +237 -0
  23. away/data/gesture_tcn.json +15 -0
  24. away/data/gesture_tcn.metrics.json +18 -0
  25. away/data/gesture_tcn.onnx +0 -0
  26. away/data/static_pose.metrics.json +77 -0
  27. away/data/static_pose.npz +0 -0
  28. away/events.py +67 -0
  29. away/face/__init__.py +1 -0
  30. away/face/commands.py +101 -0
  31. away/face/gate.py +65 -0
  32. away/face/landmarker.py +48 -0
  33. away/face/speaker.py +74 -0
  34. away/face/speaking.py +50 -0
  35. away/face/tracking.py +52 -0
  36. away/fusion.py +90 -0
  37. away/hands/__init__.py +1 -0
  38. away/hands/cursor.py +128 -0
  39. away/hands/dataset.py +171 -0
  40. away/hands/filters.py +71 -0
  41. away/hands/gestures.py +196 -0
  42. away/hands/landmarks.py +135 -0
  43. away/hands/model.py +158 -0
  44. away/hands/recorder.py +86 -0
  45. away/hands/runtime.py +67 -0
  46. away/hands/stabilize.py +110 -0
  47. away/hands/synthetic.py +192 -0
  48. away/hands/tracker.py +98 -0
  49. away/hands/train.py +220 -0
  50. away/multicam/__init__.py +1 -0
  51. away/multicam/calibration.py +208 -0
  52. away/multicam/capture.py +232 -0
  53. away/multicam/fusion.py +329 -0
  54. away/multicam/geometry.py +317 -0
  55. away/paths.py +54 -0
  56. away/py.typed +0 -0
  57. away/voice/__init__.py +1 -0
  58. away/voice/always_on.py +505 -0
  59. away/voice/asr.py +138 -0
  60. away/voice/cleanup.py +269 -0
  61. away/voice/context.py +89 -0
  62. away/voice/dictation.py +203 -0
  63. away/voice/edit.py +234 -0
  64. away/voice/numbers.py +150 -0
  65. away/voice/output.py +59 -0
  66. away/voice/vad.py +140 -0
  67. away/wifi/__init__.py +1 -0
  68. away/wifi/classifier.py +91 -0
  69. away/wifi/features.py +150 -0
  70. away/wifi/sources.py +243 -0
  71. project_away-0.1.1.dist-info/METADATA +406 -0
  72. project_away-0.1.1.dist-info/RECORD +75 -0
  73. project_away-0.1.1.dist-info/WHEEL +5 -0
  74. project_away-0.1.1.dist-info/entry_points.txt +5 -0
  75. project_away-0.1.1.dist-info/top_level.txt +1 -0
away/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Project Away: hands-free PC control (gestures, dictation, speaker gating, Wi-Fi CSI)."""
2
+
3
+ __version__ = "0.1.1"
4
+ __all__ = ["__version__"]
away/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from away.cli import main
2
+
3
+ raise SystemExit(main())
away/actions.py ADDED
@@ -0,0 +1,329 @@
1
+ """Action mapping and dispatch.
2
+
3
+ The gesture state machine only *detects* (``click``, ``drag_start``, ``scroll``...).
4
+ :class:`Dispatcher` maps each detected event to a trigger (``pinch``,
5
+ ``pinch_hold``, ``two_finger_scroll``...), looks the trigger up in the active
6
+ profile of :class:`~away.bindings.BindingsConfig` and hands the resulting
7
+ :class:`~away.bindings.Action` to :class:`ActionExecutor`, which talks to the
8
+ mouse/keyboard backend, launches apps, or calls the app's hooks for
9
+ dictation, mic, voice-mode and pause actions.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shlex
16
+ import subprocess
17
+ import time
18
+ import webbrowser
19
+ from collections.abc import Callable
20
+ from typing import Any
21
+
22
+ from away.bindings import NONE, Action, BindingsConfig, normalize_phrase
23
+ from away.control.keys import parse_combo, to_pynput_hotkey
24
+ from away.control.mouse import MouseController
25
+ from away.hands.gestures import GestureEvent, GestureStateMachine
26
+
27
+ # detected gesture event -> trigger name (``move`` depends on its anchor)
28
+ EVENT_TRIGGER = {
29
+ "click": "pinch",
30
+ "double_click": "double_pinch",
31
+ "drag_start": "pinch_hold",
32
+ "drag_end": "pinch_hold",
33
+ "scroll": "two_finger_scroll",
34
+ "clutch": "open_palm",
35
+ "swipe_left": "swipe_left",
36
+ "swipe_right": "swipe_right",
37
+ "push": "push", # multi-camera depth: hand pushed toward the screen
38
+ "dwell": "dwell", # cursor rested in place (hands.dwell)
39
+ }
40
+ PINCH_CLICK_EVENTS = ("click", "double_click", "drag_start")
41
+ SCROLL_STEP = 3.0 # wheel notches for mouse:scroll_up / scroll_down
42
+ URL_PREFIXES = ("http://", "https://", "mailto:", "file://", "ftp://")
43
+ HOOK_KINDS = ("dictation", "mic", "voice", "pause")
44
+
45
+ Hook = Callable[[str, dict[str, Any]], None]
46
+
47
+
48
+ def event_trigger(ev: GestureEvent) -> str | None:
49
+ if ev.kind == "move":
50
+ return "pinch_hold" if ev.data.get("anchor") == "pinch" else "point"
51
+ if ev.kind == "custom":
52
+ name = ev.data.get("name")
53
+ return str(name) if name else None
54
+ return EVENT_TRIGGER.get(ev.kind)
55
+
56
+
57
+ def _default_launch(argv: list[str]) -> None: # pragma: no cover - starts a process
58
+ subprocess.Popen(argv, start_new_session=True)
59
+
60
+
61
+ def _default_shell(cmd: str) -> None: # pragma: no cover - starts a process
62
+ subprocess.Popen(cmd, shell=True, start_new_session=True)
63
+
64
+
65
+ class ActionExecutor:
66
+ """Runs one :class:`Action`. Launch/shell/URL openers are injectable for tests."""
67
+
68
+ def __init__(
69
+ self,
70
+ mouse: MouseController,
71
+ allow_shell: bool = False,
72
+ hooks: dict[str, Hook] | None = None,
73
+ launcher: Callable[[list[str]], None] = _default_launch,
74
+ shell_runner: Callable[[str], None] = _default_shell,
75
+ url_opener: Callable[[str], Any] = webbrowser.open,
76
+ ) -> None:
77
+ self.mouse = mouse
78
+ self.allow_shell = allow_shell
79
+ self.hooks: dict[str, Hook] = dict(hooks or {})
80
+ self.launcher = launcher
81
+ self.shell_runner = shell_runner
82
+ self.url_opener = url_opener
83
+ self.log: list[str] = [] # human-readable record of what ran / was refused
84
+ self.errors: list[tuple[str, BaseException]] = []
85
+
86
+ @property
87
+ def backend(self) -> Any:
88
+ return self.mouse.backend
89
+
90
+ def run(self, action: Action, data: dict[str, Any] | None = None, *, extra_click_only: bool = False) -> bool:
91
+ """Execute ``action``; returns False if nothing ran (none, refused, missing hook, error)."""
92
+ if action.kind == "none":
93
+ return False
94
+ try:
95
+ return self._run(action, data or {}, extra_click_only)
96
+ except Exception as exc: # an action must never take down the pipeline
97
+ self.errors.append((str(action), exc))
98
+ self.log.append(f"error {action}: {exc}")
99
+ return False
100
+
101
+ def _run(self, a: Action, data: dict[str, Any], extra_click_only: bool) -> bool:
102
+ b = self.backend
103
+ if a.kind == "mouse":
104
+ if a.arg == "click":
105
+ b.click(1)
106
+ elif a.arg == "double_click":
107
+ b.click(1 if extra_click_only else 2)
108
+ elif a.arg in ("right_click", "middle_click"):
109
+ btn = a.arg.split("_")[0]
110
+ if hasattr(b, "click_button"):
111
+ b.click_button(btn, 1)
112
+ else:
113
+ self.log.append(f"backend cannot {a.arg}")
114
+ return False
115
+ elif a.arg in ("scroll_up", "scroll_down"):
116
+ b.scroll(SCROLL_STEP if a.arg == "scroll_up" else -SCROLL_STEP)
117
+ else: # move/drag/scroll are continuous; the Dispatcher routes those
118
+ return False
119
+ elif a.kind == "key":
120
+ keys = parse_combo(a.arg)
121
+ if len(keys) == 1:
122
+ b.key(keys[0])
123
+ elif hasattr(b, "hotkey"):
124
+ b.hotkey(keys)
125
+ else:
126
+ self.log.append(f"backend cannot press combo {a.arg}")
127
+ return False
128
+ elif a.kind == "type":
129
+ if hasattr(b, "type_text"):
130
+ b.type_text(a.arg)
131
+ else:
132
+ return False
133
+ elif a.kind == "launch":
134
+ if a.arg.lower().startswith(URL_PREFIXES):
135
+ self.url_opener(a.arg)
136
+ else:
137
+ self.launcher(shlex.split(a.arg, posix=os.name != "nt"))
138
+ elif a.kind == "shell":
139
+ if not self.allow_shell:
140
+ self.log.append(f"refused {a}: bindings.allow_shell is false")
141
+ return False
142
+ self.shell_runner(a.arg)
143
+ elif a.kind in HOOK_KINDS:
144
+ hook = self.hooks.get(a.kind)
145
+ if hook is None:
146
+ self.log.append(f"no handler for {a}")
147
+ return False
148
+ hook(a.arg, data)
149
+ else:
150
+ return False
151
+ self.log.append(f"ran {a}")
152
+ return True
153
+
154
+
155
+ class Dispatcher:
156
+ """Maps detected events and named triggers to actions under the active profile."""
157
+
158
+ def __init__(
159
+ self,
160
+ bindings: BindingsConfig,
161
+ executor: ActionExecutor,
162
+ gestures: GestureStateMachine | None = None,
163
+ active_app: Callable[[], str | None] | None = None,
164
+ paused: Callable[[], bool] = lambda: False,
165
+ app_poll: float = 0.5,
166
+ clock: Callable[[], float] = time.monotonic,
167
+ ) -> None:
168
+ self.bindings = bindings
169
+ self.executor = executor
170
+ self.gestures = gestures
171
+ self.active_app = active_app
172
+ self.paused = paused
173
+ self.app_poll = app_poll
174
+ self.clock = clock
175
+ self._profile = bindings.profile_for_app(None)
176
+ self._app_checked = -1e9
177
+ self.last_app: str | None = None
178
+ self._last_fire: dict[str, float] = {}
179
+ self.fired: list[tuple[str, str]] = [] # (trigger_id, action) history for the GUI/tests
180
+ self.on_fire: Callable[[str, Action], None] | None = None
181
+ self.sync()
182
+
183
+ # ------------------------------------------------------------ profile
184
+ @property
185
+ def profile(self) -> str:
186
+ now = self.clock()
187
+ if self.active_app is not None and now - self._app_checked >= self.app_poll:
188
+ self._app_checked = now
189
+ try:
190
+ self.last_app = self.active_app()
191
+ except Exception:
192
+ self.last_app = None
193
+ name = self.bindings.profile_for_app(self.last_app)
194
+ if name != self._profile:
195
+ self._profile = name
196
+ self.sync()
197
+ elif self.active_app is None:
198
+ name = self.bindings.profile_for_app(None)
199
+ if name != self._profile:
200
+ self._profile = name
201
+ self.sync()
202
+ return self._profile
203
+
204
+ def set_bindings(self, bindings: BindingsConfig) -> None:
205
+ self.bindings = bindings
206
+ self.executor.allow_shell = bindings.allow_shell
207
+ self._app_checked = -1e9
208
+ self._profile = bindings.profile_for_app(self.last_app)
209
+ self.sync()
210
+
211
+ def sync(self) -> None:
212
+ """Push mapping-dependent switches into the detector (open palm pauses only if bound to pause:toggle)."""
213
+ if self.gestures is not None:
214
+ act = self.bindings.resolve("gestures", "open_palm", self._profile)
215
+ self.gestures.clutch_pauses = act == Action("pause", "toggle")
216
+
217
+ def resolve(self, category: str, trigger: str) -> Action:
218
+ return self.bindings.resolve(category, trigger, self.profile)
219
+
220
+ # ------------------------------------------------------------ gestures
221
+ def on_gesture(self, ev: GestureEvent) -> Action:
222
+ mouse = self.executor.mouse
223
+ if ev.kind == "hand_lost":
224
+ mouse.handle(ev)
225
+ return NONE
226
+ trig = event_trigger(ev)
227
+ if trig is None:
228
+ return NONE
229
+ act = self.resolve("gestures", trig)
230
+ if ev.kind == "drag_end":
231
+ mouse.handle(ev) # always release a held button
232
+ return act
233
+ if self.paused() and act.kind != "pause":
234
+ return NONE
235
+ if ev.kind in PINCH_CLICK_EVENTS and act.kind == "mouse":
236
+ mouse.steady(ev.t) # steady click: land where the cursor was before the pinch began
237
+ cont = {"point": "move", "pinch_hold": "drag", "two_finger_scroll": "scroll"}.get(trig)
238
+ if cont is not None and act == Action("mouse", cont):
239
+ if ev.kind in ("move", "drag_start", "scroll"):
240
+ mouse.handle(ev)
241
+ self._record(f"gesture:{trig}", act, quiet=ev.kind != "drag_start")
242
+ return act
243
+ if ev.kind == "move":
244
+ return NONE # a non-drag action on pinch_hold fires once on drag_start, not every frame
245
+ if trig in ("two_finger_scroll",) or ev.kind == "custom":
246
+ last = self._last_fire.get(trig, -1e9)
247
+ if ev.t - last < self.bindings.discrete_cooldown:
248
+ return NONE
249
+ self._last_fire[trig] = ev.t
250
+ extra_only = False
251
+ if trig == "double_pinch" and act == Action("mouse", "double_click"):
252
+ # the first tap of a double pinch already produced a left click when pinch -> mouse:click
253
+ extra_only = self.resolve("gestures", "pinch") == Action("mouse", "click")
254
+ if self.executor.run(act, dict(ev.data), extra_click_only=extra_only):
255
+ self._record(f"gesture:{trig}", act)
256
+ return act
257
+
258
+ # ------------------------------------------------------------ other sources
259
+ def trigger(self, category: str, name: str, data: dict[str, Any] | None = None) -> Action:
260
+ """Fire a voice/face/hotkey trigger by name. Returns the action (``none`` if unbound or paused)."""
261
+ if category == "voice":
262
+ name = normalize_phrase(name)
263
+ act = self.resolve(category, name)
264
+ if self.paused() and act.kind == "mouse":
265
+ return NONE
266
+ if self.executor.run(act, dict(data or {})):
267
+ prefix = {"voice": "voice", "face": "face", "hotkeys": "hotkey", "gestures": "gesture"}[category]
268
+ self._record(f"{prefix}:{name}", act)
269
+ return act
270
+ return NONE if act.kind == "none" else act
271
+
272
+ def _record(self, trig: str, act: Action, quiet: bool = False) -> None:
273
+ if quiet:
274
+ return
275
+ self.fired.append((trig, str(act)))
276
+ del self.fired[:-200]
277
+ if self.on_fire is not None:
278
+ self.on_fire(trig, act)
279
+
280
+
281
+ class CustomGestureDetector:
282
+ """Fires a custom gesture when the temporal model's probability for it rises above ``min_conf``.
283
+
284
+ Rising-edge + cooldown so one performance fires once.
285
+ """
286
+
287
+ def __init__(self, names: list[str], min_conf: float = 0.7, cooldown: float = 1.0, release: float = 0.4) -> None:
288
+ self.names = list(names)
289
+ self.min_conf = min_conf
290
+ self.release = release
291
+ self.cooldown = cooldown
292
+ self._armed = dict.fromkeys(self.names, True)
293
+ self._last = dict.fromkeys(self.names, -1e9)
294
+
295
+ def update(self, t: float, probs: dict[str, float] | None) -> list[str]:
296
+ if not probs:
297
+ return []
298
+ out = []
299
+ for n in self.names:
300
+ p = float(probs.get(n, 0.0))
301
+ if p < self.release:
302
+ self._armed[n] = True
303
+ elif p >= self.min_conf and self._armed[n] and t - self._last[n] >= self.cooldown:
304
+ self._armed[n] = False
305
+ self._last[n] = t
306
+ out.append(n)
307
+ return out
308
+
309
+
310
+ def hotkey_slots(voice_cfg: Any) -> dict[str, str]:
311
+ """Named hotkey slot -> key combo, from the voice config."""
312
+ return {"ptt": str(voice_cfg.hotkey), "mute": str(voice_cfg.mute_hotkey)}
313
+
314
+
315
+ def hotkey_map(bindings: BindingsConfig, voice_cfg: Any, profile: str = "default") -> dict[str, str]:
316
+ """pynput GlobalHotKeys combo -> hotkey trigger name for every bound, non-``none`` hotkey.
317
+
318
+ In ``push_to_talk`` mode the ``ptt`` slot is handled by the hold-key listener instead.
319
+ """
320
+ slots = hotkey_slots(voice_cfg)
321
+ out: dict[str, str] = {}
322
+ for name, act in bindings.effective(profile)["hotkeys"].items():
323
+ if act.kind == "none":
324
+ continue
325
+ if name == "ptt" and getattr(voice_cfg, "mode", "") == "push_to_talk":
326
+ continue
327
+ combo = slots.get(name, name)
328
+ out[to_pynput_hotkey(combo)] = name
329
+ return out
away/app/__init__.py ADDED
@@ -0,0 +1 @@
1
+
away/app/autopause.py ADDED
@@ -0,0 +1,119 @@
1
+ """Auto-pause gesture control while the physical mouse or keyboard is in use.
2
+
3
+ :class:`ManualInputGuard` is pure logic with an injectable clock. The app feeds it
4
+ events from pynput listeners; Away's own synthetic input is reported through
5
+ :class:`GuardedBackend` so it is not mistaken for the user (an OS listener sees
6
+ both). Idea from Tracky Mouse (MIT), implementation is original.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import time
13
+ from collections.abc import Callable
14
+ from typing import Any
15
+
16
+
17
+ class ManualInputGuard:
18
+ def __init__(
19
+ self,
20
+ idle_ms: float = 1500.0,
21
+ clock: Callable[[], float] = time.monotonic,
22
+ synthetic_window_s: float = 0.08,
23
+ key_window_s: float = 0.5,
24
+ tol_px: float = 3.0,
25
+ ) -> None:
26
+ self.idle_s = idle_ms / 1000.0
27
+ self.clock = clock
28
+ self.synthetic_window_s = synthetic_window_s
29
+ self.key_window_s = key_window_s
30
+ self.tol_px = tol_px
31
+ self.enabled = True
32
+ self._until = -1e9
33
+ self._synth_pos: tuple[float, float] | None = None
34
+ self._synth_t = -1e9
35
+ self._synth_key_t = -1e9
36
+
37
+ # Away's own output --------------------------------------------------------
38
+ def note_synthetic_move(self, x: float, y: float) -> None:
39
+ self._synth_pos, self._synth_t = (x, y), self.clock()
40
+
41
+ def note_synthetic_key(self) -> None:
42
+ self._synth_key_t = self.clock()
43
+
44
+ # the user's input -----------------------------------------------------------
45
+ def on_mouse_move(self, x: float, y: float) -> bool:
46
+ now = self.clock()
47
+ if self._synth_pos is not None and (
48
+ now - self._synth_t <= self.synthetic_window_s or math.dist((x, y), self._synth_pos) <= self.tol_px
49
+ ):
50
+ return False
51
+ return self._manual(now)
52
+
53
+ def on_click(self) -> bool:
54
+ now = self.clock()
55
+ if now - self._synth_t <= self.synthetic_window_s or now - self._synth_key_t <= self.key_window_s:
56
+ return False
57
+ return self._manual(now)
58
+
59
+ def on_key(self) -> bool:
60
+ now = self.clock()
61
+ if now - self._synth_key_t <= self.key_window_s:
62
+ return False
63
+ return self._manual(now)
64
+
65
+ def _manual(self, now: float) -> bool:
66
+ if not self.enabled:
67
+ return False
68
+ self._until = now + self.idle_s
69
+ return True
70
+
71
+ @property
72
+ def active(self) -> bool:
73
+ """True while gesture control should stay paused."""
74
+ return self.enabled and self.clock() < self._until
75
+
76
+
77
+ class GuardedBackend:
78
+ """Wraps a mouse backend and tells the guard about every synthetic event it sends."""
79
+
80
+ def __init__(self, backend: Any, guard: ManualInputGuard) -> None:
81
+ self._b = backend
82
+ self._g = guard
83
+
84
+ def move(self, x: int, y: int) -> None:
85
+ self._g.note_synthetic_move(x, y)
86
+ self._b.move(x, y)
87
+
88
+ def __getattr__(self, name: str) -> Any:
89
+ attr = getattr(self._b, name)
90
+ if callable(attr) and name in (
91
+ "click",
92
+ "press",
93
+ "release",
94
+ "scroll",
95
+ "key",
96
+ "click_button",
97
+ "hotkey",
98
+ "type_text",
99
+ ):
100
+
101
+ def wrapped(*a: Any, **k: Any) -> Any:
102
+ if name in ("key", "hotkey", "type_text"):
103
+ self._g.note_synthetic_key()
104
+ else:
105
+ self._g._synth_t = self._g.clock()
106
+ return attr(*a, **k)
107
+
108
+ return wrapped
109
+ return attr
110
+
111
+
112
+ def start_listeners(guard: ManualInputGuard) -> list[Any]: # pragma: no cover - needs a desktop session
113
+ from pynput import keyboard, mouse
114
+
115
+ ml = mouse.Listener(on_move=lambda x, y: guard.on_mouse_move(x, y), on_click=lambda *a: guard.on_click())
116
+ kl = keyboard.Listener(on_press=lambda _k: guard.on_key())
117
+ ml.start()
118
+ kl.start()
119
+ return [ml, kl]
away/app/autostart.py ADDED
@@ -0,0 +1,83 @@
1
+ """Start-at-login integration per OS (user level, no admin rights)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ LABEL = "com.fernandogarzaaa.projectaway"
10
+ RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
11
+
12
+
13
+ def launch_command() -> list[str]:
14
+ if getattr(sys, "frozen", False): # PyInstaller bundle
15
+ # inside an AppImage sys.executable is a temporary mount; $APPIMAGE is the file the user keeps
16
+ return [os.environ.get("APPIMAGE") or sys.executable, "tray"]
17
+ return [sys.executable, "-m", "away", "tray"]
18
+
19
+
20
+ def linux_desktop_file(home: Path) -> Path:
21
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config")
22
+ return base / "autostart" / "project-away.desktop"
23
+
24
+
25
+ def mac_plist(home: Path) -> Path:
26
+ return home / "Library" / "LaunchAgents" / f"{LABEL}.plist"
27
+
28
+
29
+ def desktop_entry(cmd: list[str]) -> str:
30
+ exec_line = " ".join(f'"{c}"' if " " in c else c for c in cmd)
31
+ return (
32
+ "[Desktop Entry]\nType=Application\nName=Project Away\n"
33
+ f"Exec={exec_line}\nX-GNOME-Autostart-enabled=true\nNoDisplay=false\n"
34
+ "Comment=Hands-free PC control\n"
35
+ )
36
+
37
+
38
+ def plist(cmd: list[str]) -> str:
39
+ args = "\n".join(f" <string>{c}</string>" for c in cmd)
40
+ return (
41
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
42
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
43
+ '<plist version="1.0"><dict>\n'
44
+ f" <key>Label</key><string>{LABEL}</string>\n"
45
+ f" <key>ProgramArguments</key><array>\n{args}\n </array>\n"
46
+ " <key>RunAtLoad</key><true/>\n"
47
+ "</dict></plist>\n"
48
+ )
49
+
50
+
51
+ def enable(platform: str | None = None, home: Path | None = None, cmd: list[str] | None = None) -> str:
52
+ plat = platform or sys.platform
53
+ home = home or Path.home()
54
+ cmd = cmd or launch_command()
55
+ if plat.startswith("win"): # pragma: no cover - needs Windows
56
+ import winreg # type: ignore[import-not-found,unused-ignore]
57
+
58
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0, winreg.KEY_SET_VALUE) as k:
59
+ winreg.SetValueEx(k, "ProjectAway", 0, winreg.REG_SZ, " ".join(f'"{c}"' for c in cmd))
60
+ return "HKCU\\" + RUN_KEY + "\\ProjectAway"
61
+ path = mac_plist(home) if plat == "darwin" else linux_desktop_file(home)
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ path.write_text(plist(cmd) if plat == "darwin" else desktop_entry(cmd))
64
+ return str(path)
65
+
66
+
67
+ def disable(platform: str | None = None, home: Path | None = None) -> bool:
68
+ plat = platform or sys.platform
69
+ home = home or Path.home()
70
+ if plat.startswith("win"): # pragma: no cover - needs Windows
71
+ import winreg # type: ignore[import-not-found,unused-ignore]
72
+
73
+ try:
74
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0, winreg.KEY_SET_VALUE) as k:
75
+ winreg.DeleteValue(k, "ProjectAway")
76
+ return True
77
+ except FileNotFoundError:
78
+ return False
79
+ path = mac_plist(home) if plat == "darwin" else linux_desktop_file(home)
80
+ if path.exists():
81
+ path.unlink()
82
+ return True
83
+ return False
@@ -0,0 +1,110 @@
1
+ """At-cursor feedback ring (tkinter): pinch progress, drag, paused, dwell, hand lost.
2
+
3
+ :func:`ring_spec` is pure (tested). :func:`run_cursor_overlay` polls a state
4
+ function (``Pipeline.cursor_state``) about 30 times a second and draws a small
5
+ ring next to the cursor in a borderless, always-on-top window. On Windows the
6
+ window is made click-through; elsewhere it sits offset from the hotspot so it
7
+ never covers what you click. Idea from Tracky Mouse's dwell/gesture overlay (MIT);
8
+ implementation is original.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import sys
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ COLORS = {
19
+ "idle": "#7CFC9A",
20
+ "pinch": "#FFD54F",
21
+ "drag": "#4FC3F7",
22
+ "paused": "#9E9E9E",
23
+ "dwell": "#CE93D8",
24
+ "hand_lost": "#9E9E9E",
25
+ "click": "#FFFFFF",
26
+ }
27
+ SIZE = 34 # window size in px
28
+ OFFSET = (18, 18) # from the cursor hotspot (keeps the ring out from under the pointer)
29
+
30
+
31
+ def ring_spec(state: dict[str, Any]) -> dict[str, Any]:
32
+ """What to draw: ``{"color", "extent", "filled", "visible", "width"}``; ``extent`` in degrees (0..360)."""
33
+ s = str(state.get("state", "idle"))
34
+ pinch = float(state.get("pinch", 0.0) or 0.0)
35
+ dwell = float(state.get("dwell", 0.0) or 0.0)
36
+ if s == "paused":
37
+ return {"color": COLORS["paused"], "extent": 360.0, "filled": False, "visible": True, "width": 2}
38
+ if s == "hand_lost":
39
+ return {"color": COLORS["hand_lost"], "extent": 360.0, "filled": False, "visible": True, "width": 1}
40
+ if s == "drag":
41
+ return {"color": COLORS["drag"], "extent": 360.0, "filled": True, "visible": True, "width": 4}
42
+ if s == "dwell":
43
+ return {"color": COLORS["dwell"], "extent": 360.0 * dwell, "filled": False, "visible": True, "width": 4}
44
+ if s == "pinch":
45
+ return {"color": COLORS["pinch"], "extent": 360.0, "filled": True, "visible": True, "width": 4}
46
+ # idle: the ring fills as the pinch closes, invisible when the hand is relaxed
47
+ return {"color": COLORS["idle"], "extent": 360.0 * pinch, "filled": False, "visible": pinch > 0.05, "width": 3}
48
+
49
+
50
+ def _click_through(root: Any) -> None: # pragma: no cover - Windows only
51
+ if not sys.platform.startswith("win"):
52
+ return
53
+ import ctypes
54
+
55
+ hwnd = ctypes.windll.user32.GetParent(root.winfo_id())
56
+ style = ctypes.windll.user32.GetWindowLongW(hwnd, -20) # GWL_EXSTYLE
57
+ ctypes.windll.user32.SetWindowLongW(hwnd, -20, style | 0x00080000 | 0x00000020) # LAYERED | TRANSPARENT
58
+
59
+
60
+ def run_cursor_overlay(state_fn: Callable[[], dict[str, Any]], status_bus: Any = None) -> None: # pragma: no cover
61
+ import tkinter as tk
62
+
63
+ key = "#010203"
64
+ root = tk.Tk()
65
+ root.overrideredirect(True)
66
+ root.attributes("-topmost", True)
67
+ try:
68
+ root.attributes("-transparentcolor", key) # Windows
69
+ except tk.TclError:
70
+ with contextlib.suppress(tk.TclError):
71
+ root.attributes("-alpha", 0.85) # X11/macOS: no colour keying; a slightly see-through square
72
+ cv = tk.Canvas(root, width=SIZE, height=SIZE, bg=key, highlightthickness=0)
73
+ cv.pack()
74
+ root.update_idletasks()
75
+ _click_through(root)
76
+ if status_bus is not None:
77
+ from away.app.overlay import status_text
78
+
79
+ top = tk.Toplevel(root)
80
+ top.overrideredirect(True)
81
+ top.attributes("-topmost", True)
82
+ top.geometry("+20+20")
83
+ label = tk.Label(top, text="Project Away", bg="#111", fg="#7CFC9A", padx=10, pady=4)
84
+ label.pack()
85
+ status_bus.subscribe("*", lambda ev: (s := status_text(ev)) and label.after(0, label.config, {"text": s}))
86
+
87
+ def tick() -> None:
88
+ try:
89
+ st = state_fn()
90
+ except Exception:
91
+ st = {"state": "hand_lost"}
92
+ spec = ring_spec(st)
93
+ cv.delete("all")
94
+ if spec["visible"]:
95
+ pad = spec["width"] + 1
96
+ box = (pad, pad, SIZE - pad, SIZE - pad)
97
+ if spec["filled"]:
98
+ cv.create_oval(*box, outline=spec["color"], width=spec["width"], fill=spec["color"])
99
+ elif spec["extent"] >= 359.9:
100
+ cv.create_oval(*box, outline=spec["color"], width=spec["width"])
101
+ elif spec["extent"] > 0:
102
+ cv.create_arc(
103
+ *box, start=90, extent=-spec["extent"], style="arc", outline=spec["color"], width=spec["width"]
104
+ )
105
+ x, y = int(st.get("x", 0)), int(st.get("y", 0))
106
+ root.geometry(f"{SIZE}x{SIZE}+{x + OFFSET[0]}+{y + OFFSET[1]}")
107
+ root.after(33, tick)
108
+
109
+ tick()
110
+ root.mainloop()