-
Notifications
You must be signed in to change notification settings - Fork 463
Expand file tree
/
Copy pathuser_interface.py
More file actions
352 lines (299 loc) · 12.8 KB
/
Copy pathuser_interface.py
File metadata and controls
352 lines (299 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Viewer/user input registry shared by simulator backends.
The simulator owns raw key capture because key events come from the viewer
backend. Consumers own semantics by registering the exact keys they use and
receiving opaque handles back. That keeps task code from polling arbitrary
global key state and makes key conflicts fail during setup.
Two API levels:
- :meth:`UserInterface.register_key` is the low-level primitive. It returns
a :class:`KeyBinding` handle. The handle exposes
:meth:`KeyBinding.pressed` / :meth:`KeyBinding.consume` /
:meth:`KeyBinding.down` so callers do not need to pass the handle
back to the UI on every read.
- :meth:`UserInterface.scope` returns a :class:`KeyBindingScope` that lets
a single owner declare named actions and access them by attribute, e.g.
``self._key_bindings.reset.consume()``. The scope owns its handles;
teardown via :meth:`KeyBindingScope.unregister_all` releases them in
one call.
Owner-scoping comes from API shape, not runtime caller introspection: each
scope object only contains its owner's handles, so an owner literally
cannot reach another owner's actions.
"""
from __future__ import annotations
import keyword
from dataclasses import dataclass, field
from typing import Callable, Dict, Iterable, List, Optional
@dataclass(frozen=True)
class KeyBinding:
"""Handle returned to the code that registered a key.
Callers should use the convenience methods (:meth:`pressed`,
:meth:`consume`, :meth:`down`) rather than calling the registry
directly. The handle is genuinely opaque: forging one externally
(constructing a matching dataclass instance from outside) will not
pass the registry's identity check, because ``_token`` is a unique
object created per registration inside ``UserInterface.register_key``.
"""
key: str
owner: str
description: str
_token: object = field(repr=False, compare=True)
_ui: "UserInterface" = field(repr=False, compare=False)
def pressed(self) -> bool:
"""Was this key pressed during the current step (without consuming)."""
return self._ui.was_pressed(self)
def consume(self) -> bool:
"""Was this key pressed this step; mark it consumed so a second
reader sees False. Use for one-shot actions like reset."""
return self._ui.consume_key_press(self)
def down(self) -> bool:
"""Is this key currently held down (level-triggered)."""
return self._ui.is_down(self)
@dataclass
class _KeyState:
handle: KeyBinding
on_press: Optional[Callable[[], None]]
is_down: bool = False
pressed: bool = False
consumed: bool = False
def _validate_action_name(name: str) -> None:
if not isinstance(name, str):
raise TypeError(
f"Action name must be a string, got {type(name).__name__}"
)
if not name.isidentifier():
raise ValueError(
f"Action name '{name}' is not a valid Python identifier"
)
if name.startswith("_"):
raise ValueError(
f"Action name '{name}' must not start with underscore "
"(would shadow scope internals)"
)
if keyword.iskeyword(name):
raise ValueError(f"Action name '{name}' is a Python keyword")
class KeyBindingScope:
"""Per-owner attribute-style access to registered keys.
The scope is sugar over :class:`UserInterface`. It lets one owner
declare named actions and access them via attribute. Action names must
be valid non-private Python identifiers because they become attribute
names; underscore-prefixed names would shadow the scope's own
internals and are rejected at registration time.
"""
def __init__(self, ui: "UserInterface", owner: str):
self._ui = ui
self._owner = owner
self._actions: Dict[str, KeyBinding] = {}
@property
def owner(self) -> str:
return self._owner
@property
def actions(self) -> Dict[str, KeyBinding]:
"""Read-only view of action_name -> handle for inspection."""
return dict(self._actions)
def register(
self,
key: str,
action: str,
description: str,
*,
on_press: Optional[Callable[[], None]] = None,
) -> KeyBinding:
"""Register a key as a named action in this scope.
Action names are scope-local: two different scopes may both have an
action named ``"reset"``, but the underlying keys must differ
(global key uniqueness is still enforced by :class:`UserInterface`).
"""
_validate_action_name(action)
if action in self._actions:
raise ValueError(
f"Action '{action}' is already registered in scope "
f"'{self._owner}'"
)
handle = self._ui.register_key(
key,
owner=self._owner,
description=description,
on_press=on_press,
)
self._actions[action] = handle
return handle
def unregister_all(self) -> None:
"""Release every handle this scope created. Safe to call once
per scope; calling twice is a no-op."""
for handle in list(self._actions.values()):
self._ui.unregister_key(handle)
self._actions.clear()
def __getattr__(self, action: str) -> KeyBinding:
# __getattr__ is only invoked if normal lookup fails, so the
# scope's own _ui / _owner / _actions resolve via __dict__ first.
actions = self.__dict__.get("_actions")
if actions is None or action not in actions:
registered = sorted(actions or ())
raise AttributeError(
f"No action '{action}' registered in scope "
f"'{self.__dict__.get('_owner', '?')}'. "
f"Registered actions: {registered}"
)
return actions[action]
def __contains__(self, action: str) -> bool:
return action in self._actions
class UserInterface:
"""Registry and per-step state for viewer/user input keys."""
def __init__(self) -> None:
self._keys: Dict[str, _KeyState] = {}
self._registration_callbacks: List[Callable[[KeyBinding], None]] = []
self.active_env_id: int = 0
@property
def registered_keys(self) -> Dict[str, KeyBinding]:
return {key: state.handle for key, state in self._keys.items()}
def registered_key_names(self) -> Iterable[str]:
return self._keys.keys()
def register_key(
self,
key: str,
*,
owner: str,
description: str,
on_press: Optional[Callable[[], None]] = None,
) -> KeyBinding:
normalized = self.normalize_key(key)
owner = owner.strip()
description = description.strip()
if not owner:
raise ValueError("User-interface key owner must be non-empty")
if not description:
raise ValueError("User-interface key description must be non-empty")
if normalized in self._keys:
existing = self._keys[normalized].handle
raise ValueError(
f"User-interface key '{normalized}' is already registered by "
f"'{existing.owner}' for: {existing.description}. "
f"Cannot also register it for '{owner}' ({description})."
)
handle = KeyBinding(
key=normalized,
owner=owner,
description=description,
_token=object(),
_ui=self,
)
self._keys[normalized] = _KeyState(handle=handle, on_press=on_press)
for callback in self._registration_callbacks:
callback(handle)
return handle
def unregister_key(self, handle: KeyBinding) -> None:
"""Release a previously-registered key.
Raises ``ValueError`` if the handle was not issued by this UI or if
it has already been released. Callers should release exactly once
per registration.
"""
state = self._state_for_handle(handle)
del self._keys[state.handle.key]
def scope(self, owner: str) -> KeyBindingScope:
"""Return a per-owner attribute-style facade over :meth:`register_key`.
Multiple scopes for the same owner string are allowed but each must
register different keys (global key uniqueness still applies via
:meth:`register_key`).
"""
owner = owner.strip()
if not owner:
raise ValueError("KeyBindingScope owner must be non-empty")
return KeyBindingScope(self, owner)
def add_registration_callback(
self,
callback: Callable[[KeyBinding], None],
*,
replay_existing: bool = False,
) -> None:
"""Notify a backend adapter whenever a key is registered.
Simulator backends that must explicitly subscribe viewer keys can use
this to handle keys registered after simulator construction, such as
env reset and interactive task-control bindings.
"""
self._registration_callbacks.append(callback)
if replay_existing:
for state in self._keys.values():
callback(state.handle)
def begin_step(self) -> None:
for state in self._keys.values():
state.pressed = False
state.consumed = False
def handle_key_event(self, key: str, *, pressed: bool = True) -> bool:
"""Record a raw backend key transition.
``pressed`` is level state from the simulator backend. ``KeyBinding.pressed``
is an edge signal and is raised only on a false->true transition, while
``KeyBinding.down`` mirrors the latest level state. Backends may therefore
call this every frame with their current key-down value without turning a
held key into repeated one-shot presses.
"""
normalized = self.normalize_key(key)
state = self._keys.get(normalized)
if state is None:
return False
was_down = state.is_down
state.is_down = pressed
if pressed and not was_down:
state.pressed = True
state.consumed = False
if state.on_press is not None:
state.on_press()
return True
def was_pressed(self, handle: KeyBinding) -> bool:
state = self._state_for_handle(handle)
return state.pressed and not state.consumed
def consume_key_press(self, handle: KeyBinding) -> bool:
state = self._state_for_handle(handle)
if state.pressed and not state.consumed:
state.consumed = True
return True
return False
def is_down(self, handle: KeyBinding) -> bool:
return self._state_for_handle(handle).is_down
def help_text(self) -> str:
"""Formatted listing of registered keys, grouped by owner.
Use to surface bindings on viewer startup. Returns an empty string
if no keys are registered.
"""
if not self._keys:
return ""
by_owner: Dict[str, List[KeyBinding]] = {}
for state in self._keys.values():
by_owner.setdefault(state.handle.owner, []).append(state.handle)
lines: List[str] = []
for owner in sorted(by_owner):
lines.append(f"[{owner}]")
for handle in sorted(by_owner[owner], key=lambda h: h.key):
lines.append(f" {handle.key:>4} {handle.description}")
return "\n".join(lines)
def _state_for_handle(self, handle: KeyBinding) -> _KeyState:
if not isinstance(handle, KeyBinding):
raise TypeError(
f"Expected KeyBinding, got {type(handle).__name__}"
)
normalized = self.normalize_key(handle.key)
state = self._keys.get(normalized)
if state is None:
raise ValueError(
f"Key handle '{handle.key}' is not registered with this "
"user interface"
)
# Identity check on the per-registration token rejects forged
# handles (constructed externally with matching field values) and
# stale handles (held across an unregister + re-register cycle).
if state.handle._token is not handle._token:
raise ValueError(
f"Key handle '{handle.key}' was not issued by this user "
"interface (token mismatch — handle was forged or released)"
)
return state
@staticmethod
def normalize_key(key: str) -> str:
if not isinstance(key, str) or len(key) == 0:
raise ValueError("User-interface keys must be non-empty strings")
key = key.strip()
if len(key) == 0:
raise ValueError("User-interface keys must be non-empty strings")
if len(key) == 1:
return key.upper()
return key