1- """
2- fury/ui/event_recorder.py
1+ """Fury UI event recorder, counter, and player.
32
43UI Event Recorder, Counter, and Player for FURY v2.
54
65Attaches to ``show_manager.renderer`` (a rendercanvas ``EventEmitter``-backed
7- ``Renderer``) and observes dict-based events dispatched through it. No VTK
6+ ``Renderer``) and observes dict-based events dispatched through it. No VTK
87dependency; works with the pygfx/rendercanvas/wgpu stack used by FURY v2.
9-
10- Classes
11- -------
12- RecordedEvent – Immutable snapshot of a single rendercanvas event.
13- EventRecorder – Hooks into ShowManager.renderer and records events to JSON.
14- EventCounter – Subclass that tallies events by type for test assertions.
15- EventPlayer – Replays a saved session into a ShowManager.renderer.
168"""
179
1810from __future__ import annotations
4436# RecordedEvent
4537# ---------------------------------------------------------------------------
4638
39+
4740@dataclass (frozen = True )
4841class RecordedEvent :
4942 """Immutable snapshot of a single rendercanvas UI event.
5043
51- Attributes
44+ Parameters
5245 ----------
5346 event_type : str
5447 rendercanvas event type string, e.g. ``"pointer_down"``.
@@ -91,14 +84,31 @@ class RecordedEvent:
9184 # ------------------------------------------------------------------
9285
9386 def to_dict (self ) -> Dict [str , Any ]:
94- """Return a JSON-serialisable representation."""
87+ """Return a JSON-serialisable representation.
88+
89+ Returns
90+ -------
91+ dict
92+ JSON-serialisable representation of this event.
93+ """
9594 d = asdict (self )
9695 d ["modifiers" ] = list (d ["modifiers" ])
9796 return d
9897
9998 @classmethod
10099 def from_dict (cls , data : Dict [str , Any ]) -> "RecordedEvent" :
101- """Reconstruct from a plain dict (e.g. loaded from JSON)."""
100+ """Reconstruct from a plain dict (e.g. loaded from JSON).
101+
102+ Parameters
103+ ----------
104+ data : dict
105+ Plain dict with event fields.
106+
107+ Returns
108+ -------
109+ RecordedEvent
110+ Reconstructed event instance.
111+ """
102112 return cls (
103113 event_type = data ["event_type" ],
104114 timestamp = data .get ("timestamp" , 0.0 ),
@@ -123,24 +133,33 @@ def from_rendercanvas_event(cls, event: Any) -> "RecordedEvent":
123133
124134 Parameters
125135 ----------
126- event :
136+ event : Any
127137 A rendercanvas event dict or event object.
128138
129139 Returns
130140 -------
131141 RecordedEvent
142+ Snapshot of the given event.
132143 """
133144 if isinstance (event , dict ):
145+
134146 def _get (key : str , default : Any = None ) -> Any :
135147 return event .get (key , default )
148+
136149 raw = dict (event )
137150 else :
151+
138152 def _get (key : str , default : Any = None ) -> Any : # type: ignore[misc]
139153 return getattr (event , key , default )
154+
140155 # Serialise object to a dict so raw is always JSON-safe.
141156 try :
142157 raw = {
143- "event_type" : getattr (event , "event_type" , getattr (event , "type" , "" )),
158+ "event_type" : getattr (
159+ event ,
160+ "event_type" ,
161+ getattr (event , "type" , "" ),
162+ ),
144163 "x" : float (getattr (event , "x" , 0 )),
145164 "y" : float (getattr (event , "y" , 0 )),
146165 "button" : int (getattr (event , "button" , 0 )),
@@ -149,7 +168,9 @@ def _get(key: str, default: Any = None) -> Any: # type: ignore[misc]
149168 "modifiers" : list (getattr (event , "modifiers" , [])),
150169 "dx" : float (getattr (event , "dx" , 0 )),
151170 "dy" : float (getattr (event , "dy" , 0 )),
152- "time_stamp" : float (getattr (event , "time_stamp" , time .perf_counter ())),
171+ "time_stamp" : float (
172+ getattr (event , "time_stamp" , time .perf_counter ())
173+ ),
153174 }
154175 except Exception :
155176 raw = {}
@@ -158,9 +179,10 @@ def _get(key: str, default: Any = None) -> Any: # type: ignore[misc]
158179 # attribute (some pygfx event objects).
159180 et = _get ("event_type" ) or _get ("type" ) or ""
160181
182+ ts = _get ("time_stamp" )
161183 return cls (
162184 event_type = str (et ),
163- timestamp = float (ts if ( ts := _get ( "time_stamp" )) is not None else time .perf_counter ()),
185+ timestamp = float (ts if ts is not None else time .perf_counter ()),
164186 x = float (_get ("x" ) or 0 ),
165187 y = float (_get ("y" ) or 0 ),
166188 button = int (_get ("button" ) or 0 ),
@@ -181,6 +203,7 @@ def to_rendercanvas_event(self) -> Dict[str, Any]:
181203 Returns
182204 -------
183205 dict
206+ Rendercanvas-compatible event dict.
184207 """
185208 if self .raw :
186209 evt = dict (self .raw )
@@ -205,12 +228,13 @@ def to_rendercanvas_event(self) -> Dict[str, Any]:
205228# EventRecorder
206229# ---------------------------------------------------------------------------
207230
231+
208232class EventRecorder :
209233 """Records UI events from a FURY v2 ShowManager.
210234
211235 Hooks into ``show_manager.renderer`` (the rendercanvas Renderer /
212236 EventEmitter) and registers an observer for every event type in
213- :attr:`DEFAULT_OBSERVED_EVENTS`. Each incoming event is packaged into a
237+ :attr:`DEFAULT_OBSERVED_EVENTS`. Each incoming event is packaged into a
214238 :class:`RecordedEvent` and appended to an internal log.
215239
216240 Parameters
@@ -222,7 +246,7 @@ class EventRecorder:
222246 --------
223247 >>> recorder = EventRecorder()
224248 >>> recorder.attach(show_manager)
225- >>> # … user interacts …
249+ >>> # ... user interacts ...
226250 >>> recorder.detach()
227251 >>> recorder.save("session.json")
228252 """
@@ -240,9 +264,7 @@ def __init__(
240264 self ._renderer : Any = None
241265 self ._recording : bool = False
242266 # Store a stable reference to the bound method so that add/remove
243- # handler identity checks (``cb is not callback``) work correctly.
244- # Python bound methods are not singletons — ``self._on_event is
245- # self._on_event`` can be False.
267+ # handler identity checks work correctly.
246268 self ._callback_ref : Callable = self ._on_event
247269
248270 # ------------------------------------------------------------------
@@ -251,12 +273,24 @@ def __init__(
251273
252274 @property
253275 def events (self ) -> List [RecordedEvent ]:
254- """A copy of the captured event log (read-only)."""
276+ """A copy of the captured event log (read-only).
277+
278+ Returns
279+ -------
280+ list[RecordedEvent]
281+ Copy of the internal event log.
282+ """
255283 return list (self ._events )
256284
257285 @property
258286 def is_recording (self ) -> bool :
259- """``True`` while actively recording."""
287+ """``True`` while actively recording.
288+
289+ Returns
290+ -------
291+ bool
292+ Whether the recorder is currently attached and recording.
293+ """
260294 return self ._recording
261295
262296 # ------------------------------------------------------------------
@@ -268,7 +302,7 @@ def attach(self, show_manager: Any) -> None:
268302
269303 Parameters
270304 ----------
271- show_manager :
305+ show_manager : Any
272306 A FURY v2 :class:`~fury.window.ShowManager`.
273307
274308 Raises
@@ -284,7 +318,8 @@ def attach(self, show_manager: Any) -> None:
284318 )
285319 renderer = self ._resolve_renderer (show_manager )
286320 self ._renderer = renderer
287- # rendercanvas Renderer exposes add_event_handler (wraps EventEmitter.add_handler)
321+ # rendercanvas Renderer exposes add_event_handler (wraps
322+ # EventEmitter.add_handler)
288323 renderer .add_event_handler (self ._callback_ref , * self ._observed )
289324 self ._recording = True
290325
@@ -298,7 +333,9 @@ def detach(self) -> None:
298333 if hasattr (self ._renderer , "remove_handler" ):
299334 self ._renderer .remove_handler (self ._callback_ref , * self ._observed )
300335 elif hasattr (self ._renderer , "remove_event_handler" ):
301- self ._renderer .remove_event_handler (self ._callback_ref , * self ._observed )
336+ self ._renderer .remove_event_handler (
337+ self ._callback_ref , * self ._observed
338+ )
302339 except Exception :
303340 pass # best-effort cleanup
304341 self ._renderer = None
@@ -346,7 +383,13 @@ def load(self, filepath: str) -> None:
346383 # ------------------------------------------------------------------
347384
348385 def _on_event (self , event : Any ) -> None :
349- """Observer callback — called for each registered event."""
386+ """Observer callback — called for each registered event.
387+
388+ Parameters
389+ ----------
390+ event : Any
391+ A rendercanvas event dict or event object.
392+ """
350393 self ._events .append (RecordedEvent .from_rendercanvas_event (event ))
351394
352395 @staticmethod
@@ -355,12 +398,12 @@ def _resolve_renderer(show_manager: Any) -> Any:
355398
356399 Parameters
357400 ----------
358- show_manager :
401+ show_manager : Any
359402 Any object exposing a ``renderer`` attribute.
360403
361404 Returns
362405 -------
363- renderer
406+ Any
364407 The renderer / EventEmitter instance.
365408
366409 Raises
@@ -380,17 +423,23 @@ def _resolve_renderer(show_manager: Any) -> Any:
380423# EventCounter
381424# ---------------------------------------------------------------------------
382425
426+
383427class EventCounter (EventRecorder ):
384428 """Records events *and* maintains per-type counts.
385429
386430 Useful when a test only needs to assert how many times a certain event
387431 type fired, without replaying the whole interaction.
388432
433+ Parameters
434+ ----------
435+ **kwargs : Any
436+ Keyword arguments forwarded to :class:`EventRecorder`.
437+
389438 Examples
390439 --------
391440 >>> counter = EventCounter()
392441 >>> counter.attach(show_manager)
393- >>> # … run test interaction …
442+ >>> # ... run test interaction ...
394443 >>> counter.detach()
395444 >>> assert counter.get_count("pointer_down") == 3
396445 >>> assert counter.total() == 10
@@ -410,16 +459,33 @@ def get_count(self, event_type: str) -> int:
410459 Parameters
411460 ----------
412461 event_type : str
413- rendercanvas event type string, e.g. ``"pointer_down"``.
462+ Rendercanvas event type string, e.g. ``"pointer_down"``.
463+
464+ Returns
465+ -------
466+ int
467+ Number of times the event type was observed.
414468 """
415469 return self ._counts .get (event_type , 0 )
416470
417471 def total (self ) -> int :
418- """Total number of events captured across all types."""
472+ """Total number of events captured across all types.
473+
474+ Returns
475+ -------
476+ int
477+ Sum of all event counts.
478+ """
419479 return sum (self ._counts .values ())
420480
421481 def counts (self ) -> Dict [str , int ]:
422- """Copy of the full ``{event_type: count}`` mapping."""
482+ """Copy of the full ``{event_type: count}`` mapping.
483+
484+ Returns
485+ -------
486+ dict
487+ Copy of the internal counts dict.
488+ """
423489 return dict (self ._counts )
424490
425491 def clear (self ) -> None :
@@ -432,9 +498,21 @@ def clear(self) -> None:
432498 # ------------------------------------------------------------------
433499
434500 def _on_event (self , event : Any ) -> None :
435- et = event .get ("event_type" , "unknown" ) if isinstance (event , dict ) else (
436- getattr (event , "event_type" , None ) or getattr (event , "type" , "unknown" )
437- )
501+ """Observer callback that also increments per-type counts.
502+
503+ Parameters
504+ ----------
505+ event : Any
506+ A rendercanvas event dict or event object.
507+ """
508+ if isinstance (event , dict ):
509+ et : str = event .get ("event_type" , "unknown" ) or "unknown"
510+ else :
511+ et = (
512+ getattr (event , "event_type" , None )
513+ or getattr (event , "type" , "unknown" )
514+ or "unknown"
515+ )
438516 self ._counts [et ] = self ._counts .get (et , 0 ) + 1
439517 super ()._on_event (event )
440518
@@ -443,6 +521,7 @@ def _on_event(self, event: Any) -> None:
443521# EventPlayer
444522# ---------------------------------------------------------------------------
445523
524+
446525class EventPlayer :
447526 """Replays a sequence of :class:`RecordedEvent` objects into a ShowManager.
448527
@@ -452,14 +531,14 @@ class EventPlayer:
452531 Parameters
453532 ----------
454533 recorder : EventRecorder, optional
455- Source of events to replay. Pass ``None`` and call :meth:`load`
534+ Source of events to replay. Pass ``None`` and call :meth:`load`
456535 before :meth:`play`.
457536 speed_factor : float
458537 Multiplier applied to inter-event delays.
459- ``1.0`` → real-time; ``0.0`` → instant (best for unit tests).
538+ ``1.0`` -> real-time; ``0.0`` -> instant (best for unit tests).
460539 on_event : callable, optional
461540 Hook called with ``(RecordedEvent, index)`` *before* each event is
462- injected. Use for inline assertions during replay.
541+ injected. Use for inline assertions during replay.
463542
464543 Examples
465544 --------
@@ -499,7 +578,7 @@ def play(self, show_manager: Any) -> None:
499578
500579 Parameters
501580 ----------
502- show_manager :
581+ show_manager : Any
503582 A FURY v2 ShowManager whose window has been initialised.
504583
505584 Raises
@@ -520,11 +599,13 @@ def play(self, show_manager: Any) -> None:
520599
521600 # Dispatch priority:
522601 # 1. renderer.emit — synchronous dict-based (works in tests with mocks)
523- # 2. show_manager.window._events.emit — raw EventEmitter on real FURY renderer
524- # 3. renderer.dispatch_event — last resort (expects Event object, not dict)
602+ # 2. show_manager.window._events.emit — raw EventEmitter on real FURY
603+ # 3. renderer.dispatch_event — last resort
525604 if hasattr (renderer , "emit" ):
526605 _dispatch = renderer .emit
527- elif hasattr (show_manager , "window" ) and hasattr (show_manager .window , "_events" ):
606+ elif hasattr (show_manager , "window" ) and hasattr (
607+ show_manager .window , "_events"
608+ ):
528609 _dispatch = show_manager .window ._events .emit
529610 elif hasattr (renderer , "dispatch_event" ):
530611 _dispatch = renderer .dispatch_event
0 commit comments