initial commit
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user