ovui-widgets-app 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ovui_widgets/app/__init__.py +53 -0
- ovui_widgets/app/__main__.py +83 -0
- ovui_widgets/app/_input_bridge.py +288 -0
- ovui_widgets/app/_input_drain.py +232 -0
- ovui_widgets/app/_input_keymap.py +209 -0
- ovui_widgets/app/_message_dispatcher.py +307 -0
- ovui_widgets/app/application.py +4760 -0
- ovui_widgets/app/cli_settings.py +102 -0
- ovui_widgets/app/component_loader.py +74 -0
- ovui_widgets/app/components.py +151 -0
- ovui_widgets/app/create_menu.py +243 -0
- ovui_widgets/app/frame_clock.py +160 -0
- ovui_widgets/app/headless.py +89 -0
- ovui_widgets/app/inspector_state.py +3374 -0
- ovui_widgets/app/layout.py +466 -0
- ovui_widgets/app/menu_bar.py +1619 -0
- ovui_widgets/app/menu_hooks.py +585 -0
- ovui_widgets/app/native_runtime_bootstrap.py +121 -0
- ovui_widgets/app/settings_dialog.py +143 -0
- ovui_widgets/app/status_bar.py +80 -0
- ovui_widgets/app/style/__init__.py +73 -0
- ovui_widgets/app/style/constants.py +84 -0
- ovui_widgets/app/style/imgui_runtime.py +246 -0
- ovui_widgets/app/style/styles.py +788 -0
- ovui_widgets/app/testing/__init__.py +31 -0
- ovui_widgets/app/testing/mock_backend.py +35 -0
- ovui_widgets/app/widget_registry.py +169 -0
- ovui_widgets/app/window_hooks.py +279 -0
- ovui_widgets_agent_skills/__init__.py +50 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-styling/SKILL.md +91 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-styling/references/centralized-style-module.md +118 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-styling/references/developer-guide.md +152 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-styling/references/global-styles-and-startup.md +200 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-styling/references/naming-constants.md +119 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-styling/references/naming-selectors.md +71 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-styling/references/style-hierarchy.md +193 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-styling/references/style-mechanics.md +179 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-styling/references/target-architecture.md +65 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-widgets/SKILL.md +160 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-widgets/references/app-skeleton.md +239 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-widgets/references/menu-and-qa.md +139 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-widgets/references/runtime-environment.md +96 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-widgets/references/source-map.md +67 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-widgets/references/usd-and-dataflow.md +262 -0
- ovui_widgets_agent_skills/skills/omniverse-ui-widgets-app/SKILL.md +119 -0
- ovui_widgets_app-0.2.0.dist-info/METADATA +89 -0
- ovui_widgets_app-0.2.0.dist-info/RECORD +50 -0
- ovui_widgets_app-0.2.0.dist-info/WHEEL +5 -0
- ovui_widgets_app-0.2.0.dist-info/entry_points.txt +11 -0
- ovui_widgets_app-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: LicenseRef-NvidiaProprietary
|
|
3
|
+
#
|
|
4
|
+
# NVIDIA CORPORATION, its affiliates and licensors retain all intellectual
|
|
5
|
+
# property and proprietary rights in and to this material, related
|
|
6
|
+
# documentation and any modifications thereto. Any use, reproduction,
|
|
7
|
+
# disclosure or distribution of this material and related documentation
|
|
8
|
+
# without an express license agreement from NVIDIA CORPORATION or
|
|
9
|
+
# its affiliates is strictly prohibited.
|
|
10
|
+
|
|
11
|
+
"""OvGear: Kit-free 3D application built on standalone omni.ui.
|
|
12
|
+
|
|
13
|
+
Top-level imports are lazy so that ``import ovui_widgets.app`` does not pull in
|
|
14
|
+
``omni.ui`` (or anything that imports it). This is required by the
|
|
15
|
+
headless entrypoint (``python -m ovui_widgets.app.headless``), which must set
|
|
16
|
+
``OMNIUI_HEADLESS=1`` and ``OMNIUI_BACKEND=vulkan`` *before* the ovui
|
|
17
|
+
platform is initialised. Eagerly importing :class:`Application` here
|
|
18
|
+
would defeat that ordering, because Python imports the package
|
|
19
|
+
``ovui_widgets.app`` (and runs this file) before executing ``headless.py``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from ovui_widgets.common.version import package_version as _package_version
|
|
23
|
+
|
|
24
|
+
__version__ = _package_version("ovui-widgets-app")
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AppWindowContribution",
|
|
28
|
+
"AppWindowHandle",
|
|
29
|
+
"AppWindowRegistry",
|
|
30
|
+
"Application",
|
|
31
|
+
"ContentBrowserWindow",
|
|
32
|
+
"ErrorReporter",
|
|
33
|
+
"StatusBar",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def __getattr__(name):
|
|
38
|
+
if name == "Application":
|
|
39
|
+
from ovui_widgets.app.application import Application
|
|
40
|
+
return Application
|
|
41
|
+
if name in {"AppWindowContribution", "AppWindowHandle", "AppWindowRegistry"}:
|
|
42
|
+
from ovui_widgets.app import window_hooks
|
|
43
|
+
return getattr(window_hooks, name)
|
|
44
|
+
if name == "ContentBrowserWindow":
|
|
45
|
+
from ovui_widgets.content import ContentBrowserWindow
|
|
46
|
+
return ContentBrowserWindow
|
|
47
|
+
if name == "ErrorReporter":
|
|
48
|
+
from ovui_widgets.common.error_reporter import ErrorReporter
|
|
49
|
+
return ErrorReporter
|
|
50
|
+
if name == "StatusBar":
|
|
51
|
+
from ovui_widgets.app.status_bar import StatusBar
|
|
52
|
+
return StatusBar
|
|
53
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: LicenseRef-NvidiaProprietary
|
|
3
|
+
#
|
|
4
|
+
# NVIDIA CORPORATION, its affiliates and licensors retain all intellectual
|
|
5
|
+
# property and proprietary rights in and to this material, related
|
|
6
|
+
# documentation and any modifications thereto. Any use, reproduction,
|
|
7
|
+
# disclosure or distribution of this material and related documentation
|
|
8
|
+
# without an express license agreement from NVIDIA CORPORATION or
|
|
9
|
+
# its affiliates is strictly prohibited.
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _main_async() -> None:
|
|
17
|
+
"""Cooperative entry point for embedding OvGear in an existing asyncio loop.
|
|
18
|
+
|
|
19
|
+
Callers are responsible for the synchronous bootstrap (write_split_ini,
|
|
20
|
+
ui.init, style setup) before awaiting this coroutine. For a standalone
|
|
21
|
+
launch use :func:`main`, which delegates to :meth:`Application.run`.
|
|
22
|
+
"""
|
|
23
|
+
from ovui_widgets.app.application import Application
|
|
24
|
+
|
|
25
|
+
app = Application()
|
|
26
|
+
app._running = True
|
|
27
|
+
try:
|
|
28
|
+
await app.run_async()
|
|
29
|
+
finally:
|
|
30
|
+
app._running = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
|
|
34
|
+
from ovui_widgets.app.cli_settings import extract_setting_overrides
|
|
35
|
+
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
description="OvGear — standalone 3D USD viewer",
|
|
38
|
+
epilog=(
|
|
39
|
+
"Settings overrides: any --/path/to/key=value argument sets the "
|
|
40
|
+
"corresponding dotted setting at launch (e.g. --/ui/theme=light "
|
|
41
|
+
"sets ui.theme). Overrides take precedence over persisted settings."
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"usd_file",
|
|
46
|
+
nargs="?",
|
|
47
|
+
default=None,
|
|
48
|
+
help="Optional path to a USD file (.usd/.usda/.usdc/.usdz) to open on startup.",
|
|
49
|
+
)
|
|
50
|
+
if argv is None:
|
|
51
|
+
argv = sys.argv[1:]
|
|
52
|
+
try:
|
|
53
|
+
overrides, remaining = extract_setting_overrides(list(argv))
|
|
54
|
+
except ValueError as exc:
|
|
55
|
+
parser.error(str(exc))
|
|
56
|
+
args = parser.parse_args(remaining)
|
|
57
|
+
args.settings_overrides = overrides
|
|
58
|
+
return args
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: Optional[List[str]] = None) -> None:
|
|
62
|
+
"""Launch OvGear. Used by both `python -m ovui_widgets.app` and the `ovui-widgets` console script."""
|
|
63
|
+
args = _parse_args(argv if argv is not None else sys.argv[1:])
|
|
64
|
+
from ovui_widgets.app.native_runtime_bootstrap import (
|
|
65
|
+
install_preconstructed_renderer,
|
|
66
|
+
preconstruct_selected_native_renderer,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
bootstrap = preconstruct_selected_native_renderer()
|
|
70
|
+
from ovui_widgets.app.application import Application
|
|
71
|
+
|
|
72
|
+
app = Application(settings_overrides=args.settings_overrides)
|
|
73
|
+
install_preconstructed_renderer(app, bootstrap)
|
|
74
|
+
app.run(usd_path=args.usd_file)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main_sync() -> None:
|
|
78
|
+
"""Console script entry point declared in pyproject.toml. Delegates to main()."""
|
|
79
|
+
main()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
main()
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: LicenseRef-NvidiaProprietary
|
|
3
|
+
#
|
|
4
|
+
# NVIDIA CORPORATION, its affiliates and licensors retain all intellectual
|
|
5
|
+
# property and proprietary rights in and to this material, related
|
|
6
|
+
# documentation and any modifications thereto. Any use, reproduction,
|
|
7
|
+
# disclosure or distribution of this material and related documentation
|
|
8
|
+
# without an express license agreement from NVIDIA CORPORATION or
|
|
9
|
+
# its affiliates is strictly prohibited.
|
|
10
|
+
|
|
11
|
+
"""Tier 3 livestream input bridge — SDK callback ↔ ovgear main loop.
|
|
12
|
+
|
|
13
|
+
The ovstream SDK delivers input events on a worker thread; ovgear's
|
|
14
|
+
ImGui IO must be written on the main loop thread before
|
|
15
|
+
``ui.next_frame()``. ``RemoteInputBridge`` is the lock-protected
|
|
16
|
+
hand-off:
|
|
17
|
+
|
|
18
|
+
* ``MouseEventType.MOVE`` events coalesce into a single atomic
|
|
19
|
+
``(x, y)`` — only the most recent cursor position survives until
|
|
20
|
+
the next drain.
|
|
21
|
+
* Button presses, wheel ticks, keyboard events and unicode text
|
|
22
|
+
strings append to a deque.
|
|
23
|
+
* Consecutive wheel events with the same modifier mask accumulate
|
|
24
|
+
their deltas, so a fast scroll spinning out 30 ticks in one frame
|
|
25
|
+
collapses to a single injected scroll.
|
|
26
|
+
|
|
27
|
+
Coordinates arrive in streamed-frame pixel space — no scaling. The SDK
|
|
28
|
+
clamps inclusively to its announced extents
|
|
29
|
+
(``input_handler.cpp:119–120``), so ``x == width`` is reachable. The
|
|
30
|
+
bridge accepts that range and clamps it to the ovui-indexable range
|
|
31
|
+
``[0, width-1] × [0, height-1]``. The first out-of-range coord raises
|
|
32
|
+
a one-time WARN; subsequent out-of-range traffic is silent so a
|
|
33
|
+
mis-sized client cannot flood the log.
|
|
34
|
+
|
|
35
|
+
This module is the **skeleton** (issue #34, Step 3.2). The pre-tick
|
|
36
|
+
drain loop (Step 3.3), mouse-button enum mapping (Step 3.4), modifier
|
|
37
|
+
disconnect cleanup (Step 3.5), and ``Server`` callback wiring (Step
|
|
38
|
+
3.6) layer on top of this state object.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import logging
|
|
44
|
+
import threading
|
|
45
|
+
from collections import deque
|
|
46
|
+
from typing import Deque, List, Tuple, Union
|
|
47
|
+
|
|
48
|
+
from ovstream import KeyboardEvent, KeyState, MouseEvent, MouseEventType
|
|
49
|
+
|
|
50
|
+
_LOG = logging.getLogger("ovui_widgets.app.input_bridge")
|
|
51
|
+
|
|
52
|
+
# NVST key codes that count as "modifier keys" for disconnect cleanup
|
|
53
|
+
# (KeyDefs.h:138–155). Caps Lock and Num Lock are deliberately omitted:
|
|
54
|
+
# they are toggle keys, not held modifiers, so synthesising a release
|
|
55
|
+
# on disconnect would falsely tell ovui the user just released CapsLock
|
|
56
|
+
# even when they are still toggling it.
|
|
57
|
+
#
|
|
58
|
+
# Tracking the actual key codes (not an aggregate ``modifiers`` bitmask)
|
|
59
|
+
# is the source-of-truth fix for the Codex Step 3.5 NOT-GOOD finding:
|
|
60
|
+
# the runtime ovstream WebRTC handler at
|
|
61
|
+
# ``kit-livestream/sdk/src/webrtc/input_handler.cpp:205–224`` collapses
|
|
62
|
+
# RSHIFT / RCONTROL / RALT into the *generic* ``kModShift / kModControl
|
|
63
|
+
# / kModAlt`` bits and reuses bits ``0x0010`` and ``0x0020`` for
|
|
64
|
+
# CapsLock / NumLock. The aggregate ``modifiers`` mask therefore tells
|
|
65
|
+
# us *something* is held but not which side; only the per-event
|
|
66
|
+
# ``key_code`` is side-aware.
|
|
67
|
+
_MODIFIER_NVST_KEY_CODES = frozenset({
|
|
68
|
+
0x0301, # NVST_KEY_SHIFT
|
|
69
|
+
0x0302, # NVST_KEY_LSHIFT
|
|
70
|
+
0x0303, # NVST_KEY_RSHIFT
|
|
71
|
+
0x0304, # NVST_KEY_CONTROL
|
|
72
|
+
0x0305, # NVST_KEY_LCONTROL
|
|
73
|
+
0x0306, # NVST_KEY_RCONTROL
|
|
74
|
+
0x0307, # NVST_KEY_ALT
|
|
75
|
+
0x0308, # NVST_KEY_LALT
|
|
76
|
+
0x0309, # NVST_KEY_RALT
|
|
77
|
+
0x0310, # NVST_KEY_META
|
|
78
|
+
0x0311, # NVST_KEY_LMETA
|
|
79
|
+
0x0312, # NVST_KEY_RMETA
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
# Discrete events the bridge accepts in its deque. KeyboardEvent and
|
|
83
|
+
# MouseEvent are stored verbatim; unicode-text callbacks land as plain
|
|
84
|
+
# ``str`` so the drain can dispatch with ``isinstance``.
|
|
85
|
+
BridgeEvent = Union[KeyboardEvent, MouseEvent, str]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class RemoteInputBridge:
|
|
89
|
+
"""Lock-protected SDK→main-loop event buffer."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, width: int, height: int) -> None:
|
|
92
|
+
if width <= 0 or height <= 0:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"extents must be positive, got width={width} height={height}"
|
|
95
|
+
)
|
|
96
|
+
self._lock = threading.Lock()
|
|
97
|
+
self._width: int = int(width)
|
|
98
|
+
self._height: int = int(height)
|
|
99
|
+
self._mouse_xy: Tuple[int, int] = (0, 0)
|
|
100
|
+
self._mouse_modifier_mask: int = 0
|
|
101
|
+
self._mouse_modifier_dirty: bool = False
|
|
102
|
+
self._events: Deque[BridgeEvent] = deque()
|
|
103
|
+
# Press-ordered list of NVST modifier key codes that the SDK
|
|
104
|
+
# delivered DOWN events for and has not yet delivered matching
|
|
105
|
+
# UP events for. The disconnect-cleanup path reads this so the
|
|
106
|
+
# synthesised releases use the **same** key code (and therefore
|
|
107
|
+
# the same ImGui side) the client originally pressed.
|
|
108
|
+
self._held_modifier_keys: List[int] = []
|
|
109
|
+
# Cached aggregate ``modifiers`` mask from the last keyboard
|
|
110
|
+
# event. Kept for diagnostics only — Codex's Step 3.5 review
|
|
111
|
+
# established that this mask is not side-aware on the runtime
|
|
112
|
+
# ovstream WebRTC path, so cleanup reads the held-keys list
|
|
113
|
+
# above instead.
|
|
114
|
+
self._last_modifier_mask: int = 0
|
|
115
|
+
self._out_of_range_warned: bool = False
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
# Configuration
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def set_extents(self, width: int, height: int) -> None:
|
|
122
|
+
"""Update the clamp window (e.g. on resolution change)."""
|
|
123
|
+
if width <= 0 or height <= 0:
|
|
124
|
+
return
|
|
125
|
+
with self._lock:
|
|
126
|
+
self._width = int(width)
|
|
127
|
+
self._height = int(height)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# SDK-side callbacks (run on the ovstream worker thread)
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def on_mouse_event(self, event: MouseEvent) -> None:
|
|
134
|
+
mouse_modifier_mask = int(event.modifiers)
|
|
135
|
+
if event.type == MouseEventType.BUTTON and event.button_state == KeyState.UP:
|
|
136
|
+
mouse_modifier_mask = 0
|
|
137
|
+
if event.type == MouseEventType.MOVE:
|
|
138
|
+
x, y = self._clamp(event.x, event.y)
|
|
139
|
+
with self._lock:
|
|
140
|
+
self._mouse_xy = (x, y)
|
|
141
|
+
self._set_mouse_modifier_mask_unlocked(mouse_modifier_mask)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
if event.type == MouseEventType.WHEEL:
|
|
145
|
+
with self._lock:
|
|
146
|
+
self._set_mouse_modifier_mask_unlocked(mouse_modifier_mask)
|
|
147
|
+
tail = self._events[-1] if self._events else None
|
|
148
|
+
if (
|
|
149
|
+
isinstance(tail, MouseEvent)
|
|
150
|
+
and tail.type == MouseEventType.WHEEL
|
|
151
|
+
and tail.modifiers == event.modifiers
|
|
152
|
+
):
|
|
153
|
+
tail.data += event.data
|
|
154
|
+
tail.data2 += event.data2
|
|
155
|
+
tail.scroll_x += event.scroll_x
|
|
156
|
+
tail.scroll_y += event.scroll_y
|
|
157
|
+
return
|
|
158
|
+
self._events.append(event)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# MouseEventType.BUTTON
|
|
162
|
+
with self._lock:
|
|
163
|
+
self._set_mouse_modifier_mask_unlocked(mouse_modifier_mask)
|
|
164
|
+
self._events.append(event)
|
|
165
|
+
|
|
166
|
+
def on_keyboard_event(self, event: KeyboardEvent) -> None:
|
|
167
|
+
# Steady-state: the SDK delivers each modifier press/release as
|
|
168
|
+
# an ordinary KeyboardEvent (``InputHandler.cpp:230–249``), so
|
|
169
|
+
# the bridge appends it verbatim. The held-keys list and the
|
|
170
|
+
# diagnostic mask are tracked **only** to support disconnect
|
|
171
|
+
# cleanup — no diff-and-emit synthesis in the steady-state
|
|
172
|
+
# path (Codex finding 10b).
|
|
173
|
+
key_code = int(event.key_code)
|
|
174
|
+
with self._lock:
|
|
175
|
+
self._events.append(event)
|
|
176
|
+
self._last_modifier_mask = int(event.modifiers)
|
|
177
|
+
if key_code in _MODIFIER_NVST_KEY_CODES:
|
|
178
|
+
if event.key_state == KeyState.DOWN:
|
|
179
|
+
if key_code not in self._held_modifier_keys:
|
|
180
|
+
self._held_modifier_keys.append(key_code)
|
|
181
|
+
else:
|
|
182
|
+
# KeyState.UP — drop the key from the held list if
|
|
183
|
+
# present. ``list.remove`` raises ValueError if the
|
|
184
|
+
# item is missing; guard so a stray UP without a
|
|
185
|
+
# matching DOWN does not crash the worker thread.
|
|
186
|
+
try:
|
|
187
|
+
self._held_modifier_keys.remove(key_code)
|
|
188
|
+
except ValueError:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
def on_unicode(self, text: str) -> None:
|
|
192
|
+
if not text:
|
|
193
|
+
return
|
|
194
|
+
with self._lock:
|
|
195
|
+
self._events.append(text)
|
|
196
|
+
|
|
197
|
+
def on_connection(self, connected: bool) -> None:
|
|
198
|
+
"""Handle a connection state change from the ovstream ``Server``.
|
|
199
|
+
|
|
200
|
+
On disconnect, every modifier the SDK delivered a DOWN event
|
|
201
|
+
for (and has not yet delivered a matching UP event for) is
|
|
202
|
+
converted into a synthetic ``KeyState.UP``
|
|
203
|
+
:class:`KeyboardEvent` and appended to the deque. The drain
|
|
204
|
+
dispatches those releases through the same
|
|
205
|
+
:func:`_input_drain.drain_bridge_into_ui` path as ordinary
|
|
206
|
+
keyboard events, so ovui sees a release for every modifier the
|
|
207
|
+
remote client was holding when the link dropped — preventing a
|
|
208
|
+
sticky Shift / Ctrl / Alt / Super state.
|
|
209
|
+
|
|
210
|
+
The synthesised release uses the **same NVST key code** the
|
|
211
|
+
client pressed, so right-side modifiers release right-side
|
|
212
|
+
ImGui keys (Codex Step 3.5 NOT-GOOD fix). Aggregate
|
|
213
|
+
``modifiers`` mask bits are not consulted because the runtime
|
|
214
|
+
ovstream WebRTC path collapses RSHIFT/RCONTROL/RALT into the
|
|
215
|
+
generic Shift/Control/Alt bits and reuses bits 0x0010/0x0020
|
|
216
|
+
for CapsLock/NumLock.
|
|
217
|
+
|
|
218
|
+
Idempotent: clearing the held list at the end means a second
|
|
219
|
+
``on_connection(False)`` finds it empty and is a no-op.
|
|
220
|
+
"""
|
|
221
|
+
if connected:
|
|
222
|
+
return
|
|
223
|
+
with self._lock:
|
|
224
|
+
for nvst_key in self._held_modifier_keys:
|
|
225
|
+
self._events.append(
|
|
226
|
+
KeyboardEvent(
|
|
227
|
+
key_code=nvst_key,
|
|
228
|
+
scan_code=0,
|
|
229
|
+
modifiers=0,
|
|
230
|
+
key_state=KeyState.UP,
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
self._held_modifier_keys.clear()
|
|
234
|
+
self._last_modifier_mask = 0
|
|
235
|
+
|
|
236
|
+
# ------------------------------------------------------------------
|
|
237
|
+
# Main-loop side
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def drain(self) -> Tuple[Tuple[int, int], List[BridgeEvent]]:
|
|
241
|
+
"""Snapshot the current cursor + queued events, clearing the deque."""
|
|
242
|
+
with self._lock:
|
|
243
|
+
xy = self._mouse_xy
|
|
244
|
+
events = list(self._events)
|
|
245
|
+
self._events.clear()
|
|
246
|
+
return xy, events
|
|
247
|
+
|
|
248
|
+
def mouse_modifier_mask(self) -> int:
|
|
249
|
+
"""Return the latest mouse-event modifier mask."""
|
|
250
|
+
with self._lock:
|
|
251
|
+
return self._mouse_modifier_mask
|
|
252
|
+
|
|
253
|
+
def consume_mouse_modifier_state(self) -> Tuple[int, bool]:
|
|
254
|
+
"""Return ``(mask, changed)`` and clear the changed flag."""
|
|
255
|
+
with self._lock:
|
|
256
|
+
mask = self._mouse_modifier_mask
|
|
257
|
+
changed = self._mouse_modifier_dirty
|
|
258
|
+
self._mouse_modifier_dirty = False
|
|
259
|
+
return mask, changed
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
# Internals
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def _set_mouse_modifier_mask_unlocked(self, mask: int) -> None:
|
|
266
|
+
mask = int(mask)
|
|
267
|
+
if mask != self._mouse_modifier_mask:
|
|
268
|
+
self._mouse_modifier_mask = mask
|
|
269
|
+
self._mouse_modifier_dirty = True
|
|
270
|
+
|
|
271
|
+
def _clamp(self, x: int, y: int) -> Tuple[int, int]:
|
|
272
|
+
with self._lock:
|
|
273
|
+
w = self._width
|
|
274
|
+
h = self._height
|
|
275
|
+
out_of_range = x < 0 or y < 0 or x > (w - 1) or y > (h - 1)
|
|
276
|
+
clamped_x = min(max(int(x), 0), w - 1)
|
|
277
|
+
clamped_y = min(max(int(y), 0), h - 1)
|
|
278
|
+
if out_of_range:
|
|
279
|
+
with self._lock:
|
|
280
|
+
fire_warn = not self._out_of_range_warned
|
|
281
|
+
self._out_of_range_warned = True
|
|
282
|
+
if fire_warn:
|
|
283
|
+
_LOG.warning(
|
|
284
|
+
"coord out of range (%d,%d) for extents %dx%d; "
|
|
285
|
+
"clamping to (%d,%d). Subsequent out-of-range events will be silent.",
|
|
286
|
+
x, y, w, h, clamped_x, clamped_y,
|
|
287
|
+
)
|
|
288
|
+
return clamped_x, clamped_y
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: LicenseRef-NvidiaProprietary
|
|
3
|
+
#
|
|
4
|
+
# NVIDIA CORPORATION, its affiliates and licensors retain all intellectual
|
|
5
|
+
# property and proprietary rights in and to this material, related
|
|
6
|
+
# documentation and any modifications thereto. Any use, reproduction,
|
|
7
|
+
# disclosure or distribution of this material and related documentation
|
|
8
|
+
# without an express license agreement from NVIDIA CORPORATION or
|
|
9
|
+
# its affiliates is strictly prohibited.
|
|
10
|
+
|
|
11
|
+
"""Tier 3 input bridge → ovui native input dispatcher.
|
|
12
|
+
|
|
13
|
+
The :class:`ovui_widgets.app._input_bridge.RemoteInputBridge` accumulates events
|
|
14
|
+
on the ovstream worker thread; this module is the main-loop side that
|
|
15
|
+
translates them into ``omni.ui._ui._inject_*`` calls.
|
|
16
|
+
|
|
17
|
+
Critical ordering: the dispatcher must run **before**
|
|
18
|
+
``await ui.next_frame()`` so the injected events land in *that* tick's
|
|
19
|
+
ImGui IO. ovui's ``HeadlessVulkanPlatform.cpp`` runs
|
|
20
|
+
``applyInjectedInput()`` at line 485, immediately before
|
|
21
|
+
``ImGui::NewFrame()`` at line 487; an event injected after the
|
|
22
|
+
``next_frame`` await would not be seen until the *next* tick.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any, Callable, Optional
|
|
28
|
+
|
|
29
|
+
from ovstream import KeyboardEvent, KeyState, MouseEvent, MouseEventType
|
|
30
|
+
|
|
31
|
+
from ovui_widgets.app._input_bridge import RemoteInputBridge
|
|
32
|
+
from ovui_widgets.app._input_keymap import nvst_to_imgui_key
|
|
33
|
+
|
|
34
|
+
# ImGui's explicit modifier-key slots. These are inside
|
|
35
|
+
# ImGuiKey_NamedKey_BEGIN..END, so the existing standalone _inject_key_event
|
|
36
|
+
# binding accepts them without needing a native API change.
|
|
37
|
+
_IMGUI_KEY_RESERVED_MOD_CTRL = 663
|
|
38
|
+
_IMGUI_KEY_RESERVED_MOD_SHIFT = 664
|
|
39
|
+
_IMGUI_KEY_RESERVED_MOD_ALT = 665
|
|
40
|
+
_IMGUI_KEY_RESERVED_MOD_SUPER = 666
|
|
41
|
+
|
|
42
|
+
_NVST_MOD_SHIFT = 0x0001
|
|
43
|
+
_NVST_MOD_CTRL = 0x0002
|
|
44
|
+
_NVST_MOD_ALT = 0x0004
|
|
45
|
+
_NVST_MOD_SUPER = 0x0008
|
|
46
|
+
|
|
47
|
+
# ovstream's ``MouseButton`` enum and ImGui's ``ImGuiMouseButton_`` enum
|
|
48
|
+
# disagree on the order of the middle and right buttons:
|
|
49
|
+
#
|
|
50
|
+
# ovstream (NONE=0, LEFT=1, MIDDLE=2, RIGHT=3, EXTRA1=4, EXTRA2=5)
|
|
51
|
+
# ImGui (Left=0, Right=1, Middle=2, …) — imgui.h:2042–2047
|
|
52
|
+
#
|
|
53
|
+
# ovui's ``injectMouseButton(int button, bool pressed)`` passes the int
|
|
54
|
+
# straight to ``io.AddMouseButtonEvent`` (StandaloneInit.cpp:330–336),
|
|
55
|
+
# so the integer fed in IS the ImGui button index. Use an explicit
|
|
56
|
+
# table — a subtract-one shortcut would silently swap MIDDLE and RIGHT.
|
|
57
|
+
_NVST_TO_IMGUI_BUTTON: dict[int, int] = {
|
|
58
|
+
1: 0, # MouseButton.LEFT → ImGuiMouseButton_Left (0)
|
|
59
|
+
3: 1, # MouseButton.RIGHT → ImGuiMouseButton_Right (1)
|
|
60
|
+
2: 2, # MouseButton.MIDDLE → ImGuiMouseButton_Middle (2)
|
|
61
|
+
4: 3, # MouseButton.EXTRA1 → 3
|
|
62
|
+
5: 4, # MouseButton.EXTRA2 → 4
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _nvst_printable_char(key_code: int, modifiers: int) -> str:
|
|
67
|
+
"""Return the printable character for *key_code* under *modifiers*, or ''.
|
|
68
|
+
|
|
69
|
+
The NVST SDK delivers keyboard events as physical key codes only; ImGui's
|
|
70
|
+
InputText widgets additionally require ``io.AddInputCharactersUTF8`` to
|
|
71
|
+
insert a glyph into the edit buffer. This function synthesises that
|
|
72
|
+
character for the printable ASCII subset of the NVST code space:
|
|
73
|
+
|
|
74
|
+
0x20 → space
|
|
75
|
+
0x30–0x39 → digits 0–9 (identical to ASCII code points)
|
|
76
|
+
0x41–0x5A → letter keys A–Z (NVST always uses uppercase VK codes)
|
|
77
|
+
|
|
78
|
+
Shift state is bit 0 of *modifiers* (``kModShift``, KeyDefs.h).
|
|
79
|
+
CapsLock state is bit 4 (``kModCapsLock``). Letters are uppercase when
|
|
80
|
+
Shift XOR CapsLock is set, matching standard keyboard behaviour.
|
|
81
|
+
|
|
82
|
+
Control (bit 1, ``kModControl``) and Alt (bit 2, ``kModAlt``) suppress
|
|
83
|
+
character synthesis — Ctrl+key and Alt+key combos are accelerators, not
|
|
84
|
+
text input.
|
|
85
|
+
"""
|
|
86
|
+
if modifiers & 0x0006: # kModControl | kModAlt
|
|
87
|
+
return ''
|
|
88
|
+
if key_code == 0x0020:
|
|
89
|
+
return ' '
|
|
90
|
+
if 0x0030 <= key_code <= 0x0039:
|
|
91
|
+
return chr(key_code)
|
|
92
|
+
if 0x0041 <= key_code <= 0x005A:
|
|
93
|
+
shift = bool(modifiers & 0x0001)
|
|
94
|
+
caps = bool(modifiers & 0x0010)
|
|
95
|
+
return chr(key_code) if (shift ^ caps) else chr(key_code + 32)
|
|
96
|
+
return ''
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _inject_imgui_modifier_state(ui_native: Any, modifiers: int) -> None:
|
|
100
|
+
"""Submit ImGui's aggregate modifier state from an NVST modifier mask.
|
|
101
|
+
|
|
102
|
+
The livestream SDK may report Alt/Ctrl/Shift/Super on mouse events even
|
|
103
|
+
when it does not deliver separate keyboard events. Scene gestures compare
|
|
104
|
+
against ImGui's aggregate modifier slots (``io.KeyAlt`` etc.), so injecting
|
|
105
|
+
only the physical key is not enough for Alt+LMB camera navigation.
|
|
106
|
+
"""
|
|
107
|
+
mask = int(modifiers)
|
|
108
|
+
ui_native._inject_key_event(
|
|
109
|
+
_IMGUI_KEY_RESERVED_MOD_CTRL,
|
|
110
|
+
bool(mask & _NVST_MOD_CTRL),
|
|
111
|
+
)
|
|
112
|
+
ui_native._inject_key_event(
|
|
113
|
+
_IMGUI_KEY_RESERVED_MOD_SHIFT,
|
|
114
|
+
bool(mask & _NVST_MOD_SHIFT),
|
|
115
|
+
)
|
|
116
|
+
ui_native._inject_key_event(
|
|
117
|
+
_IMGUI_KEY_RESERVED_MOD_ALT,
|
|
118
|
+
bool(mask & _NVST_MOD_ALT),
|
|
119
|
+
)
|
|
120
|
+
ui_native._inject_key_event(
|
|
121
|
+
_IMGUI_KEY_RESERVED_MOD_SUPER,
|
|
122
|
+
bool(mask & _NVST_MOD_SUPER),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _mouse_scroll_delta(event: MouseEvent) -> tuple[float, float]:
|
|
127
|
+
"""Return the wheel delta, preferring modern ovstream float fields."""
|
|
128
|
+
scroll_x = float(getattr(event, "scroll_x", 0.0) or 0.0)
|
|
129
|
+
scroll_y = float(getattr(event, "scroll_y", 0.0) or 0.0)
|
|
130
|
+
if scroll_x != 0.0 or scroll_y != 0.0:
|
|
131
|
+
return scroll_x, scroll_y
|
|
132
|
+
return float(event.data), float(event.data2)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def drain_bridge_into_ui(
|
|
136
|
+
bridge: RemoteInputBridge,
|
|
137
|
+
ui_native: Any,
|
|
138
|
+
*,
|
|
139
|
+
on_left_click: Optional[Callable[[int, int], None]] = None,
|
|
140
|
+
on_char: Optional[Callable[[str], None]] = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Drain *bridge* and inject every event into *ui_native*.
|
|
143
|
+
|
|
144
|
+
*ui_native* is the ``omni.ui._ui`` C-binding submodule (or any
|
|
145
|
+
object exposing the same ``_inject_*`` surface — a mock works for
|
|
146
|
+
tests).
|
|
147
|
+
|
|
148
|
+
Mouse-position injection is unconditional: the latest cursor
|
|
149
|
+
coordinates always go in first so subsequent button/wheel events
|
|
150
|
+
fire at the up-to-date hover target. Discrete events are dispatched
|
|
151
|
+
in the order they were enqueued.
|
|
152
|
+
|
|
153
|
+
The function never raises on bridge contents — unmappable keys
|
|
154
|
+
(``ImGuiKey_None``), the ``MouseButton.NONE`` button, and out-of-
|
|
155
|
+
range button indices are silently dropped rather than fed into the
|
|
156
|
+
ImGui IO.
|
|
157
|
+
|
|
158
|
+
*on_left_click*, if provided, is called with the current ``(x, y)``
|
|
159
|
+
stream coordinates whenever a left-button DOWN event is drained. The
|
|
160
|
+
application uses this to synthesise widget focus for widgets (such as
|
|
161
|
+
the Stage Browser filter bar) whose ``set_mouse_pressed_fn`` callbacks
|
|
162
|
+
do not fire from ImGui IO injection alone. The callback runs before
|
|
163
|
+
``ui.next_frame()``, so any programmatic focus changes it makes land
|
|
164
|
+
in the same tick.
|
|
165
|
+
|
|
166
|
+
*on_char*, if provided, is called with each printable ASCII character
|
|
167
|
+
synthesised from a key-down event (via :func:`_nvst_printable_char`).
|
|
168
|
+
The application uses this to route typed characters to widget models
|
|
169
|
+
(e.g. the Stage Browser filter) that require direct model updates
|
|
170
|
+
because ``io.AddInputCharactersUTF8`` alone is insufficient when the
|
|
171
|
+
target widget hasn't captured ImGui keyboard focus.
|
|
172
|
+
"""
|
|
173
|
+
(mouse_x, mouse_y), events = bridge.drain()
|
|
174
|
+
if hasattr(bridge, "consume_mouse_modifier_state"):
|
|
175
|
+
mouse_modifiers, modifiers_changed = bridge.consume_mouse_modifier_state()
|
|
176
|
+
if modifiers_changed:
|
|
177
|
+
_inject_imgui_modifier_state(ui_native, mouse_modifiers)
|
|
178
|
+
elif hasattr(bridge, "mouse_modifier_mask"):
|
|
179
|
+
mouse_modifiers = bridge.mouse_modifier_mask()
|
|
180
|
+
if mouse_modifiers:
|
|
181
|
+
_inject_imgui_modifier_state(ui_native, mouse_modifiers)
|
|
182
|
+
ui_native._inject_mouse_move(int(mouse_x), int(mouse_y))
|
|
183
|
+
|
|
184
|
+
for event in events:
|
|
185
|
+
if isinstance(event, MouseEvent):
|
|
186
|
+
if event.modifiers:
|
|
187
|
+
_inject_imgui_modifier_state(ui_native, int(event.modifiers))
|
|
188
|
+
if event.type == MouseEventType.BUTTON:
|
|
189
|
+
imgui_button = _NVST_TO_IMGUI_BUTTON.get(int(event.data))
|
|
190
|
+
# ``MouseButton.NONE`` (0) and any future SDK addition
|
|
191
|
+
# outside the table drop silently — feeding an
|
|
192
|
+
# unrecognised index into ovui would corrupt
|
|
193
|
+
# ``MouseDown[]`` (the inject helper takes the int
|
|
194
|
+
# verbatim and indexes a fixed-size array).
|
|
195
|
+
if imgui_button is not None:
|
|
196
|
+
ui_native._inject_mouse_button(
|
|
197
|
+
imgui_button,
|
|
198
|
+
event.button_state == KeyState.DOWN,
|
|
199
|
+
)
|
|
200
|
+
if (imgui_button == 0
|
|
201
|
+
and event.button_state == KeyState.DOWN
|
|
202
|
+
and on_left_click is not None):
|
|
203
|
+
on_left_click(int(mouse_x), int(mouse_y))
|
|
204
|
+
if event.button_state == KeyState.UP and event.modifiers:
|
|
205
|
+
_inject_imgui_modifier_state(ui_native, 0)
|
|
206
|
+
continue
|
|
207
|
+
if event.type == MouseEventType.WHEEL:
|
|
208
|
+
ui_native._inject_mouse_scroll(*_mouse_scroll_delta(event))
|
|
209
|
+
continue
|
|
210
|
+
# MouseEventType.MOVE never reaches the deque (it coalesces
|
|
211
|
+
# into the atomic position read above). Defensive: skip
|
|
212
|
+
# silently rather than raise if the bridge ever changes.
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
if isinstance(event, KeyboardEvent):
|
|
216
|
+
imgui_key = nvst_to_imgui_key(int(event.key_code))
|
|
217
|
+
if imgui_key != 0:
|
|
218
|
+
ui_native._inject_key_event(
|
|
219
|
+
imgui_key,
|
|
220
|
+
event.key_state == KeyState.DOWN,
|
|
221
|
+
)
|
|
222
|
+
if event.key_state == KeyState.DOWN:
|
|
223
|
+
ch = _nvst_printable_char(int(event.key_code), int(event.modifiers))
|
|
224
|
+
if ch:
|
|
225
|
+
ui_native._inject_text_input(ch)
|
|
226
|
+
if on_char is not None:
|
|
227
|
+
on_char(ch)
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
if isinstance(event, str):
|
|
231
|
+
ui_native._inject_text_input(event)
|
|
232
|
+
continue
|