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.
- away/__init__.py +4 -0
- away/__main__.py +3 -0
- away/actions.py +329 -0
- away/app/__init__.py +1 -0
- away/app/autopause.py +119 -0
- away/app/autostart.py +83 -0
- away/app/cursor_overlay.py +110 -0
- away/app/frozen.py +175 -0
- away/app/models.py +87 -0
- away/app/overlay.py +61 -0
- away/app/runner.py +723 -0
- away/app/selftest.py +194 -0
- away/app/settings_gui.py +1202 -0
- away/app/setup_wizard.py +130 -0
- away/app/tray.py +197 -0
- away/app/uninstall.py +56 -0
- away/bindings.py +548 -0
- away/cli.py +747 -0
- away/config.py +411 -0
- away/control/__init__.py +1 -0
- away/control/keys.py +245 -0
- away/control/mouse.py +237 -0
- away/data/gesture_tcn.json +15 -0
- away/data/gesture_tcn.metrics.json +18 -0
- away/data/gesture_tcn.onnx +0 -0
- away/data/static_pose.metrics.json +77 -0
- away/data/static_pose.npz +0 -0
- away/events.py +67 -0
- away/face/__init__.py +1 -0
- away/face/commands.py +101 -0
- away/face/gate.py +65 -0
- away/face/landmarker.py +48 -0
- away/face/speaker.py +74 -0
- away/face/speaking.py +50 -0
- away/face/tracking.py +52 -0
- away/fusion.py +90 -0
- away/hands/__init__.py +1 -0
- away/hands/cursor.py +128 -0
- away/hands/dataset.py +171 -0
- away/hands/filters.py +71 -0
- away/hands/gestures.py +196 -0
- away/hands/landmarks.py +135 -0
- away/hands/model.py +158 -0
- away/hands/recorder.py +86 -0
- away/hands/runtime.py +67 -0
- away/hands/stabilize.py +110 -0
- away/hands/synthetic.py +192 -0
- away/hands/tracker.py +98 -0
- away/hands/train.py +220 -0
- away/multicam/__init__.py +1 -0
- away/multicam/calibration.py +208 -0
- away/multicam/capture.py +232 -0
- away/multicam/fusion.py +329 -0
- away/multicam/geometry.py +317 -0
- away/paths.py +54 -0
- away/py.typed +0 -0
- away/voice/__init__.py +1 -0
- away/voice/always_on.py +505 -0
- away/voice/asr.py +138 -0
- away/voice/cleanup.py +269 -0
- away/voice/context.py +89 -0
- away/voice/dictation.py +203 -0
- away/voice/edit.py +234 -0
- away/voice/numbers.py +150 -0
- away/voice/output.py +59 -0
- away/voice/vad.py +140 -0
- away/wifi/__init__.py +1 -0
- away/wifi/classifier.py +91 -0
- away/wifi/features.py +150 -0
- away/wifi/sources.py +243 -0
- project_away-0.1.1.dist-info/METADATA +406 -0
- project_away-0.1.1.dist-info/RECORD +75 -0
- project_away-0.1.1.dist-info/WHEEL +5 -0
- project_away-0.1.1.dist-info/entry_points.txt +5 -0
- project_away-0.1.1.dist-info/top_level.txt +1 -0
away/__init__.py
ADDED
away/__main__.py
ADDED
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()
|