#!/usr/bin/env python3 """ Interaction Mapper GUI A Tkinter-based tool that: 1. Launches a SWF file in Ruffle 2. Shows a transparent overlay with a timer 3. Records every click (timestamp + coordinates) as you interact 4. Saves the interactions to the config file Run: python3 map_interactions.py my_animation.swf """ import argparse import json import logging import subprocess import sys import threading import time import tkinter as tk from pathlib import Path from tkinter import messagebox, simpledialog from config_manager import ConfigManager from recorder import find_binary, find_window_id, get_window_geometry, RUFFLE_CANDIDATES log = logging.getLogger("map_interactions") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") class InteractionMapperGUI: def __init__(self, swf_path: Path, config_path: str): self.swf_path = swf_path self.config_mgr = ConfigManager(config_path) self.recorded_clicks = [] self.start_time = None self.emulator_proc = None self.ruffle_window_id = None self._running = False self.root = tk.Tk() self.root.title(f"Interaction Mapper — {swf_path.name}") self.root.configure(bg="#1a1a2e") self.root.resizable(False, False) self._build_ui() def _build_ui(self): root = self.root PAD = 16 FONT_MONO = ("Courier New", 11) FONT_LABEL = ("Helvetica", 10) BG = "#1a1a2e" FG = "#e2e8f0" ACCENT = "#7c3aed" GREEN = "#22c55e" RED = "#ef4444" CARD = "#16213e" root.configure(bg=BG) # Title tk.Label( root, text="⚡ SWF Interaction Mapper", font=("Helvetica", 14, "bold"), bg=BG, fg=ACCENT ).pack(pady=(PAD, 4)) tk.Label( root, text=f"File: {self.swf_path.name}", font=FONT_LABEL, bg=BG, fg="#94a3b8" ).pack() # Timer display timer_frame = tk.Frame(root, bg=CARD, padx=PAD, pady=PAD) timer_frame.pack(fill="x", padx=PAD, pady=(PAD, 0)) tk.Label(timer_frame, text="Elapsed Time", font=FONT_LABEL, bg=CARD, fg="#94a3b8").pack() self.timer_label = tk.Label( timer_frame, text="00:00.000", font=("Courier New", 28, "bold"), bg=CARD, fg=GREEN ) self.timer_label.pack() # Instructions instr_frame = tk.Frame(root, bg=CARD, padx=PAD, pady=8) instr_frame.pack(fill="x", padx=PAD, pady=4) instructions = ( "1. Click 'Launch & Start' to open the SWF\n" "2. Click 'Record Click' each time an interaction fires\n" "3. Enter coordinates from the Ruffle window\n" "4. Click 'Save Config' when done" ) tk.Label(instr_frame, text=instructions, font=("Helvetica", 9), bg=CARD, fg="#94a3b8", justify="left").pack(anchor="w") # Recorded clicks list list_frame = tk.Frame(root, bg=CARD, padx=PAD, pady=PAD) list_frame.pack(fill="both", expand=True, padx=PAD, pady=4) tk.Label(list_frame, text="Recorded Interactions", font=("Helvetica", 10, "bold"), bg=CARD, fg=FG).pack(anchor="w") self.clicks_listbox = tk.Listbox( list_frame, font=FONT_MONO, bg="#0f172a", fg=FG, selectbackground=ACCENT, height=10, width=52, borderwidth=0, highlightthickness=1, highlightbackground=ACCENT ) self.clicks_listbox.pack(fill="both", expand=True, pady=4) # Buttons btn_frame = tk.Frame(root, bg=BG) btn_frame.pack(fill="x", padx=PAD, pady=PAD) def btn(parent, text, cmd, color=ACCENT): return tk.Button( parent, text=text, command=cmd, bg=color, fg="white", font=("Helvetica", 10, "bold"), relief="flat", padx=12, pady=6, cursor="hand2", activebackground=color, activeforeground="white", ) btn(btn_frame, "▶ Launch & Start", self._launch_and_start, GREEN).pack(side="left", padx=2) btn(btn_frame, "⊕ Record Click", self._record_click_dialog, ACCENT).pack(side="left", padx=2) btn(btn_frame, "✕ Delete Selected", self._delete_selected, RED).pack(side="left", padx=2) btn(btn_frame, "💾 Save Config", self._save_config, "#0ea5e9").pack(side="right", padx=2) self.status_label = tk.Label( root, text="Ready. Click 'Launch & Start' to begin.", font=("Helvetica", 9), bg=BG, fg="#94a3b8" ) self.status_label.pack(pady=(0, PAD)) root.protocol("WM_DELETE_WINDOW", self._on_close) root.after(100, self._update_timer) def _launch_and_start(self): if self._running: self._set_status("Already running!") return binary = find_binary(RUFFLE_CANDIDATES) if not binary: messagebox.showerror( "Ruffle Not Found", "Ruffle binary not found.\n\n" "Download from: https://github.com/ruffle-rs/ruffle/releases\n" "Place at: ~/.local/bin/ruffle" ) return self._set_status(f"Launching {self.swf_path.name}...") self.emulator_proc = subprocess.Popen( [binary, str(self.swf_path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) self.start_time = time.monotonic() self._running = True self._set_status(f"Running. Record clicks as they happen in Ruffle.") def _record_click_dialog(self): if not self._running: self._set_status("Launch the SWF first!") return elapsed = time.monotonic() - self.start_time # Try to auto-detect mouse position in Ruffle window auto_x, auto_y = None, None if self.ruffle_window_id is None: self.ruffle_window_id = find_window_id("Ruffle", timeout=1.0) if self.ruffle_window_id: try: result = subprocess.run( ["xdotool", "getmouselocation", "--shell"], capture_output=True, text=True, timeout=1.0 ) geo = get_window_geometry(self.ruffle_window_id) if result.returncode == 0 and geo: d = {} for line in result.stdout.splitlines(): if "=" in line: k, v = line.split("=", 1) d[k.strip().lower()] = int(v.strip()) auto_x = d.get("x", 0) - geo.get("x", 0) auto_y = d.get("y", 0) - geo.get("y", 0) except Exception: pass # Dialog dialog = tk.Toplevel(self.root) dialog.title("Record Interaction") dialog.configure(bg="#1a1a2e") dialog.grab_set() dialog.resizable(False, False) tk.Label(dialog, text=f"Time: {elapsed:.2f}s", font=("Courier New", 12, "bold"), bg="#1a1a2e", fg="#22c55e").pack(pady=(12, 4)) fields = {} for label, default in [ ("X coordinate", str(auto_x or 0)), ("Y coordinate", str(auto_y or 0)), ("Label", f"click_{len(self.recorded_clicks)+1}"), ]: f = tk.Frame(dialog, bg="#1a1a2e") f.pack(fill="x", padx=16, pady=2) tk.Label(f, text=label, width=14, anchor="w", bg="#1a1a2e", fg="#e2e8f0", font=("Helvetica", 10)).pack(side="left") e = tk.Entry(f, bg="#0f172a", fg="#e2e8f0", insertbackground="white", font=("Courier New", 10), width=20) e.insert(0, default) e.pack(side="left", padx=4) fields[label] = e def confirm(): try: x = int(fields["X coordinate"].get()) y = int(fields["Y coordinate"].get()) label = fields["Label"].get().strip() or f"click_{len(self.recorded_clicks)+1}" entry = {"t": round(elapsed, 3), "x": x, "y": y, "label": label} self.recorded_clicks.append(entry) self.clicks_listbox.insert( tk.END, f" t={entry['t']:7.3f}s x={x:4d} y={y:4d} [{label}]" ) self._set_status(f"Recorded: {label} at t={elapsed:.2f}s ({x},{y})") dialog.destroy() except ValueError: messagebox.showerror("Invalid Input", "X and Y must be integers.", parent=dialog) tk.Button( dialog, text="✓ Confirm", command=confirm, bg="#7c3aed", fg="white", font=("Helvetica", 10, "bold"), relief="flat", padx=12, pady=6 ).pack(pady=12) def _delete_selected(self): sel = self.clicks_listbox.curselection() if not sel: return idx = sel[0] self.clicks_listbox.delete(idx) del self.recorded_clicks[idx] self._set_status(f"Deleted interaction {idx + 1}.") def _save_config(self): if not self.recorded_clicks: if not messagebox.askyesno("No Interactions", "No interactions recorded. Save empty entry?"): return for click in self.recorded_clicks: self.config_mgr.add_interaction(self.swf_path.name, click) self.config_mgr.save() self._set_status(f"✓ Saved {len(self.recorded_clicks)} interaction(s) to config.") messagebox.showinfo( "Saved", f"Saved {len(self.recorded_clicks)} interaction(s) to:\n{self.config_mgr.config_path}" ) def _update_timer(self): if self._running and self.start_time: elapsed = time.monotonic() - self.start_time minutes = int(elapsed // 60) seconds = elapsed % 60 self.timer_label.config(text=f"{minutes:02d}:{seconds:06.3f}") self.root.after(50, self._update_timer) def _set_status(self, msg: str): self.status_label.config(text=msg) log.info(msg) def _on_close(self): if self.emulator_proc: try: self.emulator_proc.terminate() except Exception: pass self.root.destroy() def run(self): self.root.mainloop() def main(): parser = argparse.ArgumentParser( description="GUI tool for recording SWF interaction points." ) parser.add_argument("swf", help="SWF file to map interactions for") parser.add_argument( "-c", "--config", default="configs/swf_config.json", help="Config file to save interactions to", ) args = parser.parse_args() swf_path = Path(args.swf) if not swf_path.exists(): print(f"Error: SWF file not found: {swf_path}", file=sys.stderr) sys.exit(1) app = InteractionMapperGUI(swf_path, args.config) app.run() if __name__ == "__main__": main()