diff --git a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py index 93c8ab0..8f24008 100644 --- a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py +++ b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py @@ -115,6 +115,30 @@ class Mesh3dPanel(QFrame): #: ``set_domain_latch``). Held until the view exists. self._domain_latch = None + # Reserve this top-level window's native handle *now*, before the window is + # shown and while no OpenGL widget exists yet. + # + # The view is built on first use, so on a first run — the pane starts on a box, + # which has no 3D scene — the GL widget is created later, while the window is + # already on screen. Creating the first *native* child of a visible window makes + # Qt re-create the window, and Qt does that by hiding it and showing it again: + # measured here as a 250–400 ms disappearance of the whole application, with the + # popup or transient dialog that was open losing its activation and being + # dismissed with it. (``QOpenGLWidget`` sets ``WA_NativeWindow`` on itself.) + # Switching the sensor to ``finger`` in the sensor dialog is exactly that moment, + # which is why it read as "the window closes and reopens and the dialog closes by + # itself". + # + # One zero-sized native child is enough: Qt then already owns a native window + # handle when the 3D widget is added, and no recreation — and so no hide/show — + # happens. It reserves the handle only; it creates no context, no scene and no GL + # import, so the pane stays as lazy as it was. There is no "re-layout" to force + # here: the window was never meant to be hidden at all. + self._native_handle = QWidget(self) + self._native_handle.setObjectName("mesh3dNativeHandle") + self._native_handle.setGeometry(0, 0, 0, 0) + self._native_handle.winId() # creating the handle *is* the point + root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) # The header carries the title and the two actions that belong to *looking*: @@ -324,7 +348,6 @@ class Mesh3dPanel(QFrame): if self._loaded or self._source is None or self._view is None: return self._loaded = True - view = self._view if self._loader is None: self._loader = SceneAssetLoader(self) self._loader.loaded.connect(self._on_loaded) diff --git a/apps/studio/tests/test_sensor_switch_window.py b/apps/studio/tests/test_sensor_switch_window.py new file mode 100644 index 0000000..2aed373 --- /dev/null +++ b/apps/studio/tests/test_sensor_switch_window.py @@ -0,0 +1,170 @@ +"""The main window must never be hidden while the operator switches sensor type. + +The 3D pane is built on first use, so on a first run — the pane starts on a box, which +has no 3D scene — its ``QOpenGLWidget`` is created later, while the window is already on +screen. Creating the first *native* child of a visible window makes Qt re-create that +window, and Qt does that by hiding the window and showing it again: the whole +application disappears for a few hundred milliseconds and whatever popup or dialog was +open is dismissed with it. Switching the sensor to ``finger`` in the sensor dialog is +exactly that moment. + +These are the acceptance conditions: the window never hides and never takes a close +event, and the dialog and popup that were open are not silently dismissed. +""" + +from __future__ import annotations + +from PySide6.QtCore import QObject +from PySide6.QtWidgets import QWidget + +from orisys_studio.scene3d.view import gl_available +from orisys_studio.ui.mesh3d_panel import Mesh3dPanel + +#: The window's own lifecycle, as the operator experiences it: a hide is the window +#: vanishing, a close is the application going away, and neither may happen here. +WATCHED = ("hideEvent", "closeEvent") + + +class _LifecycleWatcher(QObject): + """Record every hide/close the window receives, in order.""" + + def __init__(self, window) -> None: + super().__init__(window) + self.seen: list[str] = [] + for name in WATCHED: + setattr(self, f"_original_{name}", getattr(window, name)) + window.hideEvent = self.hideEvent + window.closeEvent = self.closeEvent + + def hideEvent(self, event) -> None: + self.seen.append("hideEvent") + self._original_hideEvent(event) + + def closeEvent(self, event) -> None: + self.seen.append("closeEvent") + self._original_closeEvent(event) + + +def _pick(window, sensor_type: str) -> None: + """Do what the operator does: choose ``sensor_type`` in the rail's sensor dialog.""" + combo = window.sensor_picker.type_combo + combo.setCurrentIndex(combo.findData(sensor_type)) + + +def test_reserving_the_native_handle_costs_nothing_at_construction(qt_app, make_window): + """The handle is reserved, and reserving it does not build the pane. + + ``winId()`` on the handle forces the native window to exist, which is what makes Qt + reuse this top-level window later instead of re-creating it; and reserving it must + still not import GL, read a scene or create a context. The window platforms give a + native window a minimum size, so the reservation is checked by *where* it is and that + it never shows, not by a pixel size. + """ + window = make_window(sensor_type="box") + try: + panel = window.mesh3d_panel + assert isinstance(panel, Mesh3dPanel) + assert panel.view is None, "the pane was built before anything asked for it" + assert panel.scene_path() is None + handle = panel.findChild(QWidget, "mesh3dNativeHandle") + assert handle is not None, "the pane did not reserve a native handle" + assert handle.internalWinId() != 0, ( + "the handle exists but Qt has not made it native, which is the whole point" + ) + assert handle.isVisible() is False, ( + "the reserved handle is a handle, not something to show" + ) + assert handle.x() == 0 and handle.y() == 0, ( + "the reserved handle must not take room from the pane" + ) + + window.resize(1440, 880) + window.show() + qt_app.processEvents() + assert handle.isVisible() is False, "the reserved handle showed up on screen" + assert panel.width() > 1, "the reservation took the pane's own space" + assert panel.view is None, "showing the window built the pane by itself" + finally: + window.close() + + +def test_switching_to_a_finger_never_hides_the_window_or_dismisses_the_dialog( + qt_app, make_window +): + available, reason = gl_available() + if not available: + import pytest + + pytest.skip(f"no 3D widget on this machine: {reason}") + + window = make_window(sensor_type="box", source_mode="camera") + window.resize(1440, 880) + window.show() + qt_app.processEvents() + + # The first run's distinguishing state: nothing has built the pane yet. + assert window.mesh3d_panel.view is None + + window._open_sensor_dialog() + dialog = window.sensor_dialog + assert dialog is not None and dialog.isVisible() + + # The popup an operator's click leaves open. Qt dismisses both of these along with + # the window, so both are the thing this guards. + combo = window.sensor_picker.type_combo + combo.showPopup() + qt_app.processEvents() + popup = combo.view() + assert popup is not None and popup.isVisible() + + watcher = _LifecycleWatcher(window) + try: + _pick(window, "finger") + qt_app.processEvents() + + assert watcher.seen == [], ( + "the window was hidden/closed by the sensor switch: " + repr(watcher.seen) + ) + assert window.isVisible(), "the window left the screen during the switch" + assert dialog.isVisible(), "the sensor dialog was dismissed by the switch" + assert popup.isVisible(), "the popup was dismissed along with the window" + assert window.mesh3d_panel.view is not None, ( + "no 3D pane was built, so the case this guards was never reached" + ) + assert window.sensor_picker.sensor_type() == "finger" + finally: + dialog.close() + window.close() + + +def test_the_switch_is_quiet_on_later_runs_too(qt_app, make_window): + """The other direction: the pane already exists, and back-and-forth stays quiet. + + This is the second-run shape (a saved ``finger`` preference builds the pane at + start-up). A layout change that hid the window would be a bug on any run, not only + the first. + """ + available, reason = gl_available() + if not available: + import pytest + + pytest.skip(f"no 3D widget on this machine: {reason}") + + window = make_window(sensor_type="finger", source_mode="camera") + window.resize(1440, 880) + window.show() + qt_app.processEvents() + assert window.mesh3d_panel.view is not None, ( + "a saved finger preference must build the pane before the window is shown" + ) + + watcher = _LifecycleWatcher(window) + try: + _pick(window, "box") + qt_app.processEvents() + _pick(window, "finger") + qt_app.processEvents() + assert watcher.seen == [], repr(watcher.seen) + assert window.isVisible() + finally: + window.close()