Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/source/concepts/visualization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,10 @@ Each Newton window has its own source. Kit visualizers share the Kit application
them reports the same keys; a consumer that listens to every visualizer must accept a key from more
than one source.

The visualizer connects native keyboard and focus events when its window initializes and
disconnects them before the window closes. A subscription only adds or removes a listener;
it does not initialize window resources. The source owns no held-key state or camera policy.

Close the subscription when you are done with it. A consumer that uses the camera's motion keys
also turns off keyboard camera movement with
:meth:`~isaaclab.visualizers.BaseVisualizer.set_camera_keys_enabled`; a Newton window then stops
Expand All @@ -747,9 +751,11 @@ while the right mouse button is held, is unaffected:
(held.add if pressed else held.discard)(code)

visualizer.set_camera_keys_enabled(False)
with source.add_key_listener(on_key, held.clear):
...
visualizer.set_camera_keys_enabled(True)
try:
with source.add_key_listener(on_key, held.clear):
... # use held while stepping the simulation
finally:
visualizer.set_camera_keys_enabled(True)

Subscribe, unsubscribe and close on the thread that steps the visualizer; callbacks run there too,
in event order. Events arrive as the window reports them: key repeat can arrive as further presses,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
* Simplified keyboard sources to listener registration and event forwarding; native window hooks
were initialized and closed by visualizers instead of changing with listener counts.
* Centralized the optional keyboard source and teardown in the base visualizer and removed
redundant subscription state.
18 changes: 14 additions & 4 deletions source/isaaclab/isaaclab/visualizers/base_visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ..utils import validate
from ..utils.backend_utils import FactoryBase
from .image_view import ImageView
from .key_event_source import KeyEventSource
from .visualizer_cfg import (
ImageViewCfg,
PerspectiveCameraCfg,
Expand All @@ -35,7 +36,6 @@
from ..renderers.base_renderer import VisualMaterialBatch
from ..sensors import Camera
from ..sim import SimulationContext
from .key_event_source import KeyEventSource


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -72,6 +72,7 @@ def __init__(self, cfg: VisualizerCfg):
self._sim_time = 0.0
self.current_image_view: ImageView | None = None
self._image_views: list[ImageView] = []
self._key_event_source: KeyEventSource | None = None

@property
def visual_material_writer(self) -> Callable[[tuple[VisualMaterialBatch, ...]], Any] | None:
Expand Down Expand Up @@ -237,12 +238,12 @@ def key_event_source(self) -> KeyEventSource | None:
"""Keyboard input from this visualizer's local window, or ``None`` when there is none.

Consumers subscribe to the keys pressed and released while the window has focus, e.g. for
keyboard teleoperation or script hotkeys. The source is created on first access after the
visualizer is initialized, and the visualizer closes it when the visualizer closes. ``None``
keyboard teleoperation or script hotkeys. The source connects when the visualizer initializes
its window, and the visualizer closes it before releasing that window. ``None``
before initialization, after close, and for backends or modes with no local window that can
report keys (the default).
"""
return None
return self._key_event_source

def set_camera_keys_enabled(self, enabled: bool) -> None:
"""Enable or disable moving this visualizer's camera with the keyboard.
Expand Down Expand Up @@ -446,3 +447,12 @@ def pause(self) -> None:
def stop(self) -> None:
"""Handle simulation stop. No-op by default."""
pass

def _close_key_event_source(self) -> None:
"""Close window input before native teardown; cleanup failures must not retain the window."""
source, self._key_event_source = self._key_event_source, None
if source is not None:
try:
source.close()
except Exception:
logger.exception("[%s] Keyboard input cleanup failed", type(self).__name__)
63 changes: 14 additions & 49 deletions source/isaaclab/isaaclab/visualizers/key_event_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,20 @@ class KeyboardSubscription:
"""

def __init__(self, source: KeyEventSource, on_key: KeyCallback, on_focus_lost: FocusLostCallback) -> None:
self._source = source
self._source: KeyEventSource | None = source
self._on_key = on_key
self._on_focus_lost = on_focus_lost
self._closed = False

@property
def closed(self) -> bool:
"""Whether the subscription no longer receives callbacks."""
return self._closed
return self._source is None

def close(self) -> None:
"""Unsubscribe."""
if not self._closed:
self._closed = True
self._source._remove_subscription(self)
source, self._source = self._source, None
if source is not None and not source.closed:
source._subscriptions.remove(self)

def __enter__(self) -> KeyboardSubscription:
return self
Expand All @@ -65,13 +64,13 @@ class KeyEventSource:
- Closing the source delivers a final focus loss to every listener; afterwards
:meth:`add_key_listener` raises :class:`RuntimeError`.

A backend subclasses this, implements :meth:`_start_listening`, :meth:`_stop_listening` and
:meth:`_release`, and reports events through :meth:`_emit_key` and :meth:`_emit_focus_lost`.
The visualizer connects native events when its window initializes and closes the source before
the window closes. Backend bridges report events through :meth:`_emit_key` and
:meth:`_emit_focus_lost`; adding or removing a listener never changes native window resources.
"""

def __init__(self) -> None:
self._subscriptions: list[KeyboardSubscription] = []
self._listening = False
self._closed = False

@property
Expand All @@ -80,7 +79,7 @@ def closed(self) -> bool:
return self._closed

def add_key_listener(self, on_key: KeyCallback, on_focus_lost: FocusLostCallback) -> KeyboardSubscription:
"""Subscribe to key presses and releases; the backend starts listening with the first listener.
"""Subscribe to key presses and releases from the initialized window.

Args:
on_key: Called with the key's W3C ``KeyboardEvent.code`` and whether it was pressed.
Expand All @@ -94,43 +93,20 @@ def add_key_listener(self, on_key: KeyCallback, on_focus_lost: FocusLostCallback
"""
if self._closed:
raise RuntimeError(f"{type(self).__name__} is closed")
if not self._listening:
self._start_listening()
self._listening = True
subscription = KeyboardSubscription(self, on_key, on_focus_lost)
self._subscriptions.append(subscription)
return subscription

def close(self) -> None:
"""Stop listening, release the backend, and deliver a final focus loss to every listener."""
"""Close every subscription and deliver a final focus loss to its listener."""
if self._closed:
return
self._closed = True
subscriptions, self._subscriptions = self._subscriptions, []
try:
if self._listening:
self._listening = False
self._stop_listening()
finally:
try:
self._release()
finally:
for subscription in subscriptions:
subscription._closed = True
_call(subscription._on_focus_lost)

# Backend hooks ---------------------------------------------------------------------------------

def _start_listening(self) -> None:
"""Hook the window's key events. Called when the first listener subscribes."""

def _stop_listening(self) -> None:
"""Unhook the window's key events. Called when the last listener unsubscribes, and on close."""

def _release(self) -> None:
"""Drop references to the backend's window and viewer. Called once, on close."""

# Event reporting -------------------------------------------------------------------------------
for subscription in subscriptions:
if not subscription.closed:
subscription.close()
_call(subscription._on_focus_lost)

def _emit_key(self, code: str, pressed: bool) -> None:
"""Report a key press or release to every listener."""
Expand All @@ -144,17 +120,6 @@ def _emit_focus_lost(self) -> None:
if not subscription.closed:
_call(subscription._on_focus_lost)

# Internals -------------------------------------------------------------------------------------

def _remove_subscription(self, subscription: KeyboardSubscription) -> None:
if subscription not in self._subscriptions:
return
self._subscriptions.remove(subscription)
if not self._subscriptions and self._listening:
# stays listening if unhooking raises, so close or the next listener does not hook twice
self._stop_listening()
self._listening = False


def _call(callback: Callable, *args) -> None:
try:
Expand Down
Loading
Loading