"""ScriptRecorder -- records user input and generates replayable test scripts."""
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from ..input import DeviceButton, Input, Key
from ..input.controls import key_code, key_for_code
from ..input.enums import PointerPhase, PointerType
from ..input.events import InputEvent, KeyEvent, PointerEvent
from ..input.state import _Input, current_input
from ..node import Node
log = logging.getLogger(__name__)
__all__ = ["RecordedEvent", "ScriptRecorder"]
# Threshold for grouping rapid keypresses into type_text() calls
_TYPE_GROUP_THRESHOLD = 0.2 # seconds
# Minimum distance squared for significant mouse movement
_MOVE_THRESHOLD_SQ = 25.0 # 5px
[docs]
@dataclass
class RecordedEvent:
"""A single recorded input event.
``button`` is a named ``Mouse`` control for ``click`` events, ``None``
otherwise. Codegen emits explicit ``Mouse.LEFT/RIGHT/MIDDLE`` so
the generated tests stay readable.
"""
timestamp: float # seconds since recording started
event_type: str # "click", "key_press", "key_release", "mouse_move", "text", "scroll"
x: float = 0.0
y: float = 0.0
button: DeviceButton | None = None
key: str = ""
char: str = ""
target_path: str = "" # widget path if identifiable
[docs]
class ScriptRecorder(Node):
"""Records user input events and generates replayable test scripts.
Attach to a scene to record interactions. Call stop_recording() to get
Python source code that replays the session using UITestHarness,
DemoRunner steps, or raw InputSimulator API.
Toggle with F9 or programmatically via start/stop_recording().
"""
format: str = "harness" # "harness", "demo_steps", or "raw_input"
def __init__(self, format: str = "harness", **kwargs):
super().__init__(**kwargs)
self.name = self.name or "ScriptRecorder"
self.format = format
self._recording = False
self._events: list[RecordedEvent] = []
self._start_time = 0.0
self._last_mouse_pos: tuple[float, float] = (0.0, 0.0)
# The live Input handlers this recorder shadows while it is hooked. Each
# wrapper calls through to the one it replaced, and a value of None is
# also what says the hook is not installed.
# The concrete input state the hook was installed on. The ambient
# ``Input`` is a proxy that resolves to whichever state is active at the
# moment it is read, so hooking and unhooking through it can reach two
# different objects: unhooking under a different active state raised and
# left the hook installed, feeding a recorder that had stopped.
self._hooked_input: _Input | None = None
# ------------------------------------------------------------------ public API
[docs]
def start_recording(self) -> None:
"""Start capturing input events."""
if self._recording:
return
self._recording = True
self._events.clear()
self._start_time = time.monotonic()
self._hook_input()
assert self._hooked_input is not None
self._last_mouse_pos = self._hooked_input._mouse_pos
[docs]
def stop_recording(self) -> str:
"""Stop recording and return generated test code in the current format."""
self._recording = False
self._unhook_input()
return self._generate(self.format)
[docs]
def save_recording(self, path: Path, format: str | None = None) -> None:
"""Save recorded script to a file."""
code = self._generate(format or self.format)
Path(path).write_text(code, encoding="utf-8")
[docs]
@property
def is_recording(self) -> bool:
return self._recording
[docs]
@property
def event_count(self) -> int:
return len(self._events)
[docs]
def record_event(self, event: RecordedEvent) -> None:
"""Manually inject a recorded event (for testing the recorder itself)."""
self._events.append(event)
# ------------------------------------------------------------------ lifecycle
[docs]
def on_update(self, dt: float) -> None:
if Input.is_key_just_pressed(Key.F9):
if self._recording:
self.stop_recording()
else:
self.start_recording()
# ------------------------------------------------------------------ input hooking
def _resolve_input(self) -> _Input:
"""The input state of the tree this recorder is in, whose events it records."""
tree = self.tree
if tree is None:
return current_input()
state: _Input = tree.input
return state
def _hook_input(self) -> None:
"""Watch every event this tree delivers, through ``tree.input_delivered``.
The seam every observer uses: the recorder sees each event once, after
delivery, whatever claimed it, and changes nothing about how it was
delivered. A recorder in no tree has nothing to watch.
"""
self._hooked_input = self._resolve_input()
tree = self.tree
if tree is not None:
tree.input_delivered.connect(self._on_delivered)
def _unhook_input(self) -> None:
"""Stop watching the tree the hook was installed on."""
tree = self.tree
if tree is not None:
tree.input_delivered.disconnect(self._on_delivered)
self._hooked_input = None
def _on_delivered(self, event: InputEvent, _claimed_by: Node | None) -> None:
"""One delivered event, recorded in the recorder's own vocabulary."""
if not self._recording:
return
if isinstance(event, KeyEvent):
if not event.echo:
code = key_code(event.key)
if code is not None:
self._on_key_event(code, event.pressed)
elif isinstance(event, PointerEvent) and event.pointer_type is PointerType.MOUSE:
if event.phase is PointerPhase.MOVE:
self._on_mouse_move_event(float(event.position[0]), float(event.position[1]))
elif event.phase in (PointerPhase.DOWN, PointerPhase.UP) and event.button is not None:
index = self._resolve_input()._mouse_layout().button_index(event.button)
if index is not None:
self._on_mouse_button_event(index, event.phase is PointerPhase.DOWN)
# ------------------------------------------------------------------ event capture
def _elapsed(self) -> float:
return time.monotonic() - self._start_time
def _recorded_mouse_pos(self) -> tuple[float, float]:
"""The cursor position of the state being recorded, not of the ambient one."""
state = self._hooked_input
if state is None: # not hooked: whatever is active is the only answer
state = current_input()
return state._mouse_pos
def _on_key_event(self, key: int, pressed: bool) -> None:
ts = self._elapsed()
pos = self._recorded_mouse_pos()
# Resolve printable character
char = ""
control = key_for_code(key)
# Printable ASCII range (space through tilde) -- single char keys
if control.name is not None and 32 <= key <= 126:
char = chr(key) if key != 32 else " "
# Lowercase letters
if 65 <= key <= 90:
char = chr(key + 32)
# The key's own name, which the emitted script spells back as Key.<NAME>.
key_name = control.name if control.name is not None else f"key_{key}"
event_type = "key_press" if pressed else "key_release"
self._events.append(
RecordedEvent(
timestamp=ts,
event_type=event_type,
key=key_name,
char=char,
x=pos[0],
y=pos[1],
)
)
def _on_mouse_button_event(self, button: int, pressed: bool) -> None:
ts = self._elapsed()
mx, my = self._recorded_mouse_pos()
target = self._identify_target(mx, my)
if pressed:
state = self._hooked_input if self._hooked_input is not None else current_input()
named = state._mouse_layout().button_control(button)
btn_enum = named if named.name is not None else None
self._events.append(
RecordedEvent(
timestamp=ts,
event_type="click",
x=mx,
y=my,
button=btn_enum,
target_path=target,
)
)
# We record both press and release for raw format; click event covers press
def _on_mouse_move_event(self, x: float, y: float) -> None:
# Collapse redundant moves: only record if distance is significant
dx = x - self._last_mouse_pos[0]
dy = y - self._last_mouse_pos[1]
if dx * dx + dy * dy < _MOVE_THRESHOLD_SQ:
return
self._last_mouse_pos = (x, y)
ts = self._elapsed()
self._events.append(
RecordedEvent(
timestamp=ts,
event_type="mouse_move",
x=x,
y=y,
)
)
# ------------------------------------------------------------------ target identification
def _identify_target(self, x: float, y: float) -> str:
"""Walk the scene tree to find the deepest Control containing (x, y)."""
root = self._tree.root if self._tree else None
if root is None:
return ""
return _find_deepest_control(root, x, y)
# ------------------------------------------------------------------ code generation
def _generate(self, fmt: str) -> str:
if fmt == "harness":
return self._gen_harness()
elif fmt == "demo_steps":
return self._gen_demo_steps()
elif fmt == "raw_input":
return self._gen_raw_input()
raise ValueError(f"Unknown format: {fmt!r}. Use 'harness', 'demo_steps', or 'raw_input'.")
def _gen_harness(self) -> str:
lines = [
"# Recorded by ScriptRecorder",
"from simvx.core.input import Mouse",
]
for group in self._group_events():
if group["type"] == "text":
lines.append(f"harness.type_text({group['text']!r})")
elif group["type"] == "click":
evt = group["event"]
comment = f" # {evt.target_path}" if evt.target_path else ""
btn = _button_literal(evt.button)
lines.append(f"harness.click(({int(evt.x)}, {int(evt.y)}), button={btn}){comment}")
lines.append("harness.tick()")
elif group["type"] == "key_press":
evt = group["event"]
lines.append(f"harness.press_key({evt.key!r})")
lines.append("harness.tick()")
elif group["type"] == "scroll":
evt = group["event"]
lines.append(f"harness.scroll(({int(evt.x)}, {int(evt.y)}))")
return "\n".join(lines) + "\n"
def _gen_demo_steps(self) -> str:
lines = [
"# Recorded by ScriptRecorder",
"from simvx.core.scripted_demo import Click, TypeText, PressKey, Wait, MoveTo",
"from simvx.core.input import Key, Mouse",
"steps = [",
]
groups = self._group_events()
prev_time = 0.0
for group in groups:
# Insert Wait steps for gaps > 0.3s
gap = group["timestamp"] - prev_time
if gap > 0.3:
lines.append(f" Wait({gap:.1f}),")
prev_time = group.get("end_time", group["timestamp"])
if group["type"] == "text":
lines.append(f" TypeText({group['text']!r}, delay_per_char=0.05),")
elif group["type"] == "click":
evt = group["event"]
comment = f" # {evt.target_path}" if evt.target_path else ""
btn = _button_literal(evt.button)
lines.append(f" Click({int(evt.x)}, {int(evt.y)}, button={btn}),{comment}")
elif group["type"] == "key_press":
evt = group["event"]
lines.append(f" PressKey(Key.{evt.key.upper()}),")
lines.append("]")
return "\n".join(lines) + "\n"
def _gen_raw_input(self) -> str:
lines = [
"# Recorded by ScriptRecorder",
"from simvx.core.testing.input_sim import InputSimulator",
"from simvx.core.input import Key, Mouse",
"sim = InputSimulator(runner.tree) # the tree the recording is replayed into",
]
for evt in self._events:
if evt.event_type == "click":
comment = f" # {evt.target_path}" if evt.target_path else ""
btn = _button_literal(evt.button)
lines.append(f"sim.click(({int(evt.x)}, {int(evt.y)}), button={btn}){comment}")
elif evt.event_type == "key_press":
lines.append(f"sim.tap_key(Key.{evt.key.upper()})")
elif evt.event_type == "mouse_move":
lines.append(f"sim.move_mouse({int(evt.x)}, {int(evt.y)})")
return "\n".join(lines) + "\n"
# ------------------------------------------------------------------ event grouping
def _group_events(self) -> list[dict]:
"""Group events: consecutive printable key_presses within threshold become type_text."""
groups: list[dict] = []
i = 0
events = self._events
while i < len(events):
evt = events[i]
if evt.event_type == "key_press" and evt.char and len(evt.char) == 1 and evt.char.isprintable():
# Start a text group
chars = [evt.char]
start_time = evt.timestamp
end_time = evt.timestamp
j = i + 1
while j < len(events):
nxt = events[j]
# Skip key_release events within the group
if nxt.event_type == "key_release":
j += 1
continue
if (
nxt.event_type == "key_press"
and nxt.char
and len(nxt.char) == 1
and nxt.char.isprintable()
and nxt.timestamp - end_time <= _TYPE_GROUP_THRESHOLD
):
chars.append(nxt.char)
end_time = nxt.timestamp
j += 1
else:
break
if len(chars) >= 2:
groups.append(
{
"type": "text",
"text": "".join(chars),
"timestamp": start_time,
"end_time": end_time,
}
)
i = j
continue
# Single char -- fall through to key_press
if evt.event_type == "click":
groups.append({"type": "click", "event": evt, "timestamp": evt.timestamp})
elif evt.event_type == "key_press":
groups.append({"type": "key_press", "event": evt, "timestamp": evt.timestamp})
elif evt.event_type == "scroll":
groups.append({"type": "scroll", "event": evt, "timestamp": evt.timestamp})
i += 1
return groups
# ============================================================================
# Internal helpers for ScriptRecorder
# ============================================================================
def _button_literal(button: DeviceButton | None) -> str:
"""Render a named mouse control (or ``None``) as a code-emit-safe ``Mouse`` literal."""
if button is None or button.name is None:
return "Mouse.LEFT"
return f"Mouse.{button.name.upper()}"
def _find_deepest_control(node: Node, x: float, y: float) -> str:
"""Walk the tree to find the deepest Control whose global rect contains (x, y)."""
from ..ui.core import Control
best: Control | None = None
best_path: str = ""
def walk(n: Node, path: str) -> None:
nonlocal best, best_path
if isinstance(n, Control):
rx, ry, rw, rh = n.get_global_rect()
if rx <= x < rx + rw and ry <= y < ry + rh:
best = n
best_path = path
for child in n.children:
child_path = f"{path}/{child.name}" if path else child.name
walk(child, child_path)
walk(node, node.name)
return best_path