initial commit
This commit is contained in:
+178
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Interaction Controller - Injects mouse clicks into a running Ruffle window
|
||||
at specified timestamps using xdotool.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
log = logging.getLogger("interaction")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClickEvent:
|
||||
"""A single click interaction to perform."""
|
||||
t: float # Time (seconds from start of recording) to fire this click
|
||||
x: int # X coordinate relative to the window
|
||||
y: int # Y coordinate relative to the window
|
||||
label: str = "" # Human-readable label
|
||||
button: int = 1 # Mouse button (1=left, 2=middle, 3=right)
|
||||
double: bool = False # Whether to double-click
|
||||
|
||||
|
||||
class InteractionController:
|
||||
"""
|
||||
Schedules and dispatches click events into a running Ruffle window.
|
||||
|
||||
Usage
|
||||
-----
|
||||
ctrl = InteractionController(window_id="0x1234abc", clicks=[...])
|
||||
ctrl.start(recording_start_time=time.monotonic())
|
||||
# ... recording happens ...
|
||||
ctrl.stop()
|
||||
"""
|
||||
|
||||
def __init__(self, window_id: str, clicks: List[dict]):
|
||||
self.window_id = window_id
|
||||
self.clicks = [
|
||||
ClickEvent(
|
||||
t=c["t"],
|
||||
x=c["x"],
|
||||
y=c["y"],
|
||||
label=c.get("label", ""),
|
||||
button=c.get("button", 1),
|
||||
double=c.get("double", False),
|
||||
)
|
||||
for c in sorted(clicks, key=lambda c: c["t"])
|
||||
]
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
self._recording_start: float = 0.0
|
||||
|
||||
def start(self, recording_start_time: float):
|
||||
"""Begin the click scheduler. Call immediately after recording starts."""
|
||||
self._recording_start = recording_start_time
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._schedule_clicks, daemon=True)
|
||||
self._thread.start()
|
||||
log.info(f"InteractionController started with {len(self.clicks)} click(s).")
|
||||
|
||||
def stop(self):
|
||||
"""Cancel any pending clicks."""
|
||||
self._stop_event.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
def wait_until_done(self):
|
||||
"""Block until all clicks have been dispatched."""
|
||||
if self._thread:
|
||||
self._thread.join()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _schedule_clicks(self):
|
||||
for click in self.clicks:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
elapsed = time.monotonic() - self._recording_start
|
||||
wait = click.t - elapsed
|
||||
if wait > 0:
|
||||
# Sleep in small increments so we can respond to stop events
|
||||
deadline = time.monotonic() + wait
|
||||
while time.monotonic() < deadline:
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
time.sleep(min(0.05, deadline - time.monotonic()))
|
||||
|
||||
if not self._stop_event.is_set():
|
||||
self._fire_click(click)
|
||||
|
||||
def _fire_click(self, click: ClickEvent):
|
||||
"""Execute a click using xdotool."""
|
||||
try:
|
||||
# Move mouse to position within window
|
||||
subprocess.run(
|
||||
[
|
||||
"xdotool", "mousemove",
|
||||
"--window", self.window_id,
|
||||
str(click.x), str(click.y),
|
||||
],
|
||||
check=True,
|
||||
timeout=2.0,
|
||||
)
|
||||
time.sleep(0.05) # Brief settle time
|
||||
|
||||
# Fire the click(s)
|
||||
click_cmd = ["xdotool", "click", "--window", self.window_id, str(click.button)]
|
||||
if click.double:
|
||||
subprocess.run(click_cmd, check=True, timeout=2.0)
|
||||
time.sleep(0.1)
|
||||
subprocess.run(click_cmd, check=True, timeout=2.0)
|
||||
|
||||
log.info(
|
||||
f" Click fired: label='{click.label}' "
|
||||
f"t={click.t:.2f}s x={click.x} y={click.y} "
|
||||
f"btn={click.button} double={click.double}"
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
log.error(f"xdotool click failed: {e}")
|
||||
except FileNotFoundError:
|
||||
log.error("xdotool not found. Install with: sudo dnf install xdotool")
|
||||
except subprocess.TimeoutExpired:
|
||||
log.error("xdotool click timed out.")
|
||||
|
||||
|
||||
class InteractionMapper:
|
||||
"""
|
||||
Helper for discovering interaction points interactively.
|
||||
Runs Ruffle in the foreground and records the user's clicks + timestamps
|
||||
so they can be saved to a config file.
|
||||
"""
|
||||
|
||||
def __init__(self, window_id: str):
|
||||
self.window_id = window_id
|
||||
self._recorded: list = []
|
||||
self._start_time: float = 0.0
|
||||
self._running = False
|
||||
|
||||
def start_recording(self):
|
||||
"""
|
||||
Listen for clicks in the Ruffle window via xdotool key event tracking.
|
||||
Note: This is a best-effort approach — for accurate mapping, use the
|
||||
interactive GUI tool (gui.py) which shows a live overlay.
|
||||
"""
|
||||
self._start_time = time.monotonic()
|
||||
self._running = True
|
||||
log.info("Interaction recording started. Press Ctrl+C to stop.")
|
||||
try:
|
||||
while self._running:
|
||||
# Poll for click position using xdotool
|
||||
result = subprocess.run(
|
||||
["xdotool", "getmouselocation", "--window", self.window_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1.0,
|
||||
)
|
||||
# This is polled, not event-driven — use GUI for real click capture
|
||||
time.sleep(0.1)
|
||||
except KeyboardInterrupt:
|
||||
self._running = False
|
||||
log.info("Interaction recording stopped.")
|
||||
|
||||
def get_recorded(self) -> list:
|
||||
return self._recorded
|
||||
|
||||
def record_click_at(self, x: int, y: int, label: str = ""):
|
||||
"""Manually register a click event (called from GUI or external hook)."""
|
||||
t = time.monotonic() - self._start_time
|
||||
entry = {"t": round(t, 2), "x": x, "y": y, "label": label or f"click_{len(self._recorded)+1}"}
|
||||
self._recorded.append(entry)
|
||||
log.info(f"Recorded interaction: {entry}")
|
||||
return entry
|
||||
Reference in New Issue
Block a user