From f50ef8d16ebf9a3ed13ff468886c3c3f851b3f06 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Mon, 29 Jun 2026 01:36:57 -0500 Subject: [PATCH 1/2] feat(ui): add a right-edge 3D-scene slide-over (peek) Add a "3D Scene" slide-over that slides in over the field with a dimming scrim, for a quick 3D peek without leaving the 2D view, following the v1 prototype's .slideover (header with reset/close + 3D body + footer). The panel, scrim, and a floating "3D Scene" pill are children of the main window, pinned to the field's right edge and re-pinned on resize via an event filter; the slide and scrim fade respect the reduced-motion case. Open via the pill, the Series > 3D menu (a checkbox synced to the open state), or Ctrl+Shift+D (the prototype's bare "3" and every digit-3 chord are already bound to palette buttons). The footer's "Open full 3D" hands off to the existing window-based viewer. The body is a faithful static preview in this slice; setSceneWidget() is the seam to host the real CustomPlotter once it accepts an external parent (live VTK needs a GL context and cannot be exercised offscreen). --- PyReconstruct/modules/gui/main/main_window.py | 52 ++ PyReconstruct/modules/gui/main/menubar.py | 2 + PyReconstruct/modules/gui/main/scene_peek.py | 558 ++++++++++++++++++ 3 files changed, 612 insertions(+) create mode 100644 PyReconstruct/modules/gui/main/scene_peek.py diff --git a/PyReconstruct/modules/gui/main/main_window.py b/PyReconstruct/modules/gui/main/main_window.py index 91eb2dca..36fc3539 100644 --- a/PyReconstruct/modules/gui/main/main_window.py +++ b/PyReconstruct/modules/gui/main/main_window.py @@ -70,6 +70,7 @@ def __init__(self, filename): self.mouse_palette = None self.zarr_palette = None self.viewer = None + self.scene_peek = None self.shortcuts_widget = None self.is_zooming = False self.restart_mainwindow = False @@ -259,6 +260,9 @@ def checkActions(self, context_menu=False, clicked_trace=None, clicked_label=Non ## Check for lists panel collapse (UI v1 Slice 3) self.togglelistspanel_act.setChecked(self.field.table_manager.listsPanelCollapsed()) + ## Check for 3D scene peek (UI v1 — 3D peek) + self.threedscene_act.setChecked(self.scenePeekIsOpen()) + ## Group visibility for group, viz in self.series.groups_visibility.items(): try: @@ -320,6 +324,8 @@ def createShortcuts(self): ("Shift+F3", lambda : self.field.shearTform(sx=-0.005)), ("F4", lambda : self.field.shearTform(sy=0.005)), ("Shift+F4", lambda : self.field.shearTform(sy=-0.005)), + + ("Ctrl+Shift+D", lambda : self.toggleScenePeek()), # 3D scene peek (UI v1) ] for kbd, act in shortcuts: @@ -1642,6 +1648,50 @@ def _applyListsPanelState(self): manager.setListsPanelCollapsed(collapsed) self.togglelistspanel_act.setChecked(collapsed) + def scenePeekIsOpen(self) -> bool: + """Whether the 3D peek exists and is open. Safe to call on any UI + refresh before the peek has been lazily built (checkActions uses it).""" + return bool(self.scene_peek) and self.scene_peek.is_open + + def _ensureScenePeek(self): + """Lazily build the 3D-scene slide-over over the current field.""" + if self.scene_peek is None: + from PyReconstruct.modules.gui.main.scene_peek import ScenePeek + self.scene_peek = ScenePeek(self) + self.scene_peek.toggled.connect(self._onScenePeekToggled) + return self.scene_peek + + def _onScenePeekToggled(self, is_open : bool): + """Keep the View-menu checkbox in sync with the peek's open state.""" + if hasattr(self, "threedscene_act"): + self.threedscene_act.setChecked(is_open) + + def toggleScenePeek(self, show=None): + """Open/close the right-edge 3D-scene slide-over (UI v1 — 3D peek). + + Both production callers — the 3D-menu checkbox action and the + Ctrl+Shift+D shortcut — invoke this with NO argument, so it pure-toggles + off the panel's authoritative ``is_open`` (the checkbox is then re-synced + by ``_onScenePeekToggled``). ``show`` is a programmatic/test affordance + to force a state. + + Params: + show (bool | None): force open (True) / closed (False); None toggles. + """ + peek = self._ensureScenePeek() + want_open = (not peek.is_open) if show is None else bool(show) + peek.open() if want_open else peek.close() + + def openFullScene(self): + """Hand off from the peek to the full window-based 3D viewer: bring an + existing scene forward, else open one for the selected objects (the same + path as the object list's "Add to scene").""" + if self.viewer and not self.viewer.is_closed: + self.viewer.activateWindow() + self.viewer.setFocus() + else: + self.field.addTo3D() + def setToObject(self, obj_name : str, section_num : int): """Focus the field on an object from a specified section. @@ -3045,6 +3095,8 @@ def setTheme(self, new_theme=None): self._apply_app_icon() if self.mouse_palette is not None: self.mouse_palette.refreshModeIcons() + if self.scene_peek is not None: + self.scene_peek.refreshTheme() def addToRecentSeries(self, series_fp : str = None): """Add a series to the recently opened series list.""" diff --git a/PyReconstruct/modules/gui/main/menubar.py b/PyReconstruct/modules/gui/main/menubar.py index 868bd07f..ddd47383 100644 --- a/PyReconstruct/modules/gui/main/menubar.py +++ b/PyReconstruct/modules/gui/main/menubar.py @@ -158,6 +158,8 @@ def return_series_menu(self): "text": "3D", "opts": [ + ("threedscene_act", "3D scene peek", "checkbox", self.toggleScenePeek), + None, ("load3Dscene_act", "Load 3D scene...", "", self.load3DScene), ] }, diff --git a/PyReconstruct/modules/gui/main/scene_peek.py b/PyReconstruct/modules/gui/main/scene_peek.py new file mode 100644 index 00000000..67dafcd2 --- /dev/null +++ b/PyReconstruct/modules/gui/main/scene_peek.py @@ -0,0 +1,558 @@ +"""UI v1 — the right-edge "3D Scene" slide-over ("peek"). + +A quick 3D peek that slides in over the field (the 2D stage) with a dimming +scrim, without leaving the 2D view. Mirrors the v1 prototype's ``.slideover``: + + header (3D icon + "3D Scene" title + reset + close) + body (the 3D area — a faithful static preview in this slice; see the + EMBEDDING SEAM note below for dropping in the live viewer) + footer ("drag to orbit · scroll to zoom" hint + "Open full 3D ↗") + +Overlay convention (matches the mouse/zarr palettes): the panel and its scrim +are children of the MainWindow, positioned over ``mainwindow.field``'s rect and +repositioned whenever the field geometry changes. They are raised above the +palettes when open. + +EMBEDDING SEAM +-------------- +``ScenePeek.body`` is a container with ``setSceneWidget(w)`` / ``clearSceneWidget()``. +To host the real ``CustomPlotter`` (a ``QVTKRenderWindowInteractor``), build it +parented to ``body`` instead of its own ``Container`` window and hand it to +``setSceneWidget``. That requires letting ``CustomPlotter`` accept an external +parent (today it always creates its own ``Container`` QMainWindow); the precise +refactor is documented in the slice's report. Live VTK rendering needs a real GL +context and cannot be exercised offscreen, so this slice ships the static +preview by default and the footer "Open full 3D" hands off to the existing +window-based viewer. +""" + +import os + +from PySide6.QtWidgets import ( + QWidget, + QPushButton, + QLabel, + QVBoxLayout, + QHBoxLayout, + QGraphicsOpacityEffect, + QApplication, +) +from PySide6.QtCore import ( + Qt, + QRect, + QEvent, + QAbstractAnimation, + QPropertyAnimation, + QEasingCurve, + QSettings, + Signal, +) +from PySide6.QtGui import ( + QPainter, + QColor, + QLinearGradient, + QRadialGradient, + QPen, + QBrush, + QPainterPath, + QPolygonF, +) +from PySide6.QtCore import QPointF + + +# ---- geometry / motion constants (faithful to the prototype) ------------------- +PANEL_MAX_W = 460 # .slideover width: min(460px, 62%) +PANEL_W_FRAC = 0.62 +ANIM_MS = 280 # .slideover transition: transform .28s +SCRIM_ANIM_MS = 260 # .scrim transition: opacity .26s (settles ~20ms ahead) +SCRIM_RGBA = (3, 5, 8, 107) # rgba(3,5,8,.42) -> .42*255 ~= 107 + +# Legend entries mirror the prototype's so-legend (chrome only; not live data). +_LEGEND = [ + ("#ff5d9e", "d001 · dendrite"), + ("#42e08b", "a014 · axon"), + ("#ffb347", "mito_233"), +] + + +def reduced_motion() -> bool: + """True when slide animation should be skipped (the reduced-motion case). + + The prototype honors ``@media (prefers-reduced-motion: reduce)``. PySide6 + 6.5 exposes no OS reduced-motion hint, so this resolves, in order: + + 1. ``PYRECONSTRUCT_REDUCED_MOTION`` env var (truthy) — also used by tests/CI; + 2. the global ``reduce_motion`` preference in QSettings("KHLab","PyReconstruct"); + 3. otherwise False (animate). + + Wiring (1)/(2) to a real OS preference is a clean follow-up once Qt exposes one. + """ + val = os.environ.get("PYRECONSTRUCT_REDUCED_MOTION") + if val is not None: + return val.strip().lower() in ("1", "true", "yes", "on") + try: + return bool( + QSettings("KHLab", "PyReconstruct").value("reduce_motion", False, type=bool) + ) + except Exception: + return False + + +def _is_dark() -> bool: + """Whether the active app theme is dark. + + Uses the theme module's authoritative scheme (the app themes via a + qdarkstyle *stylesheet*, which does not reliably update ``QPalette``, so + palette lightness alone is unreliable). Falls back to palette lightness only + if the theme module is unavailable.""" + try: + from PyReconstruct.modules.gui.utils import theme + return theme.current_scheme() == "dark" + except Exception: + app = QApplication.instance() + if app is None: + return True + return app.palette().window().color().lightness() < 128 + + +class _Scrim(QWidget): + """Dimming overlay behind the panel; a click on it closes the peek.""" + + def __init__(self, peek: "ScenePeek"): + super().__init__(peek.parentWidget()) + self._peek = peek + self.setAttribute(Qt.WA_StyledBackground, False) + self.setCursor(Qt.ArrowCursor) + self.hide() + + def paintEvent(self, event): + p = QPainter(self) + p.fillRect(self.rect(), QColor(*SCRIM_RGBA)) + + def mousePressEvent(self, event): + self._peek.close() + + +class _PlaceholderScene(QWidget): + """A faithful static preview of the 3D scene (mirrors the prototype so-body). + + Painted, not live: a radial-lit stage, a faint teal floor grid, a dashed + bounding box, and a few organic "mesh" blobs in the legend colors. Replaced + by the real viewer via ``ScenePeek.setSceneWidget`` (see module docstring). + """ + + def paintEvent(self, event): + w, h = self.width(), self.height() + if w <= 0 or h <= 0: + return + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + dark = _is_dark() + + # radial-lit background (prototype: radial-gradient at 50% 12%) + bg = QRadialGradient(w * 0.5, h * 0.12, max(w, h) * 1.1) + if dark: + bg.setColorAt(0.0, QColor("#131a26")) + bg.setColorAt(0.7, QColor("#0a0e15")) + else: + bg.setColorAt(0.0, QColor("#e9eef7")) + bg.setColorAt(0.75, QColor("#d6deea")) + p.fillRect(self.rect(), QBrush(bg)) + + cx = w * 0.5 + # floor grid (teal, faint) — a few perspective lines + teal = QColor("#2bd4b8") + teal.setAlphaF(0.16) + p.setPen(QPen(teal, 1)) + fy = h * 0.78 + for i, frac in enumerate((0.0, 0.06, 0.12)): + yy = fy - h * frac + half = w * (0.34 - i * 0.05) + p.drawLine(int(cx - half), int(yy), int(cx + half), int(yy)) + for fx in (-0.18, 0.0, 0.18): + p.drawLine(int(cx + w * fx * 0.5), int(fy + h * 0.05), + int(cx + w * fx), int(fy - h * 0.16)) + + # dashed bounding box (perspective-ish parallelogram) + box = QColor("#3a4655") + box.setAlphaF(0.6) + pen = QPen(box, 1.1) + pen.setStyle(Qt.DashLine) + p.setPen(pen) + p.setBrush(Qt.NoBrush) + bx, by, bw, bh = w * 0.16, h * 0.18, w * 0.5, h * 0.46 + off = w * 0.06 + front = QPolygonF([ + QPointF(bx, by), QPointF(bx + bw, by - h * 0.05), + QPointF(bx + bw, by + bh), QPointF(bx, by + bh + h * 0.05), + ]) + p.drawPolygon(front) + p.drawLine(QPointF(bx, by), QPointF(bx - off, by + h * 0.07)) + p.drawLine(QPointF(bx - off, by + h * 0.07), QPointF(bx - off, by + bh + h * 0.03)) + + # mesh blobs (legend colors), with a soft highlight to read as 3D + def blob(path, color): + grad = QLinearGradient(path.boundingRect().topLeft(), + path.boundingRect().bottomRight()) + c = QColor(color) + grad.setColorAt(0.0, c.lighter(135)) + grad.setColorAt(1.0, c.darker(125)) + p.setPen(Qt.NoPen) + p.setBrush(QBrush(grad)) + p.drawPath(path) + + # dendrite (pink), tall tube + den = QPainterPath() + den.moveTo(w * 0.26, h * 0.66) + den.cubicTo(w * 0.20, h * 0.40, w * 0.30, h * 0.27, w * 0.46, h * 0.27) + den.cubicTo(w * 0.66, h * 0.27, w * 0.70, h * 0.43, w * 0.62, h * 0.56) + den.cubicTo(w * 0.55, h * 0.66, w * 0.40, h * 0.62, w * 0.26, h * 0.66) + blob(den, "#ff5d9e") + + # axon (green) + ax = QPainterPath() + ax.addEllipse(QPointF(w * 0.62, h * 0.62), w * 0.13, h * 0.12) + blob(ax, "#42e08b") + + # mitochondrion (orange) + mito = QPainterPath() + mito.addEllipse(QPointF(w * 0.42, h * 0.40), w * 0.10, h * 0.055) + blob(mito, "#ffb347") + + # legend (bottom-left), mirrors so-legend + p.setRenderHint(QPainter.Antialiasing, True) + lx, ly = 14, h - 14 - len(_LEGEND) * 20 + f = p.font() + f.setPointSizeF(8.5) + p.setFont(f) + for i, (col, label) in enumerate(_LEGEND): + yy = ly + i * 20 + p.setPen(Qt.NoPen) + p.setBrush(QColor(col)) + p.drawRoundedRect(QRect(lx, yy, 9, 9), 2, 2) + p.setPen(QColor("#9aa7bb") if dark else QColor("#54627a")) + p.drawText(lx + 16, yy + 9, label) + + +class ScenePeek(QWidget): + """The right-edge 3D slide-over panel (+ its scrim + open-pill affordance).""" + + # emitted when open/closed state changes (so the menu checkbox can sync) + toggled = Signal(bool) + + def __init__(self, mainwindow): + super().__init__(mainwindow) + self.mainwindow = mainwindow + self.is_open = False + + self.setObjectName("scenePeek") + self.setAutoFillBackground(True) + + # scrim (sibling, painted behind the panel) + self.scrim = _Scrim(self) + self._scrim_fx = QGraphicsOpacityEffect(self.scrim) + self._scrim_fx.setOpacity(0.0) + self.scrim.setGraphicsEffect(self._scrim_fx) + + # persistent animations, reused across every open/close so stopped + # QPropertyAnimation objects never accumulate on this long-lived widget + self._anim = QPropertyAnimation(self, b"geometry", self) + self._anim.setEasingCurve(QEasingCurve.OutCubic) + self._anim.setDuration(ANIM_MS) + self._scrim_anim = QPropertyAnimation(self._scrim_fx, b"opacity", self) + self._scrim_anim.setEasingCurve(QEasingCurve.OutCubic) + self._scrim_anim.setDuration(SCRIM_ANIM_MS) + + self._buildUi() + self._applyStyle() + + # open affordance: a floating "3D Scene" pill over the field's top-right + self.pill = QPushButton("◈ 3D Scene", mainwindow) + self.pill.setObjectName("scenePeekPill") + self.pill.setCursor(Qt.PointingHandCursor) + self.pill.setToolTip("Show the 3D scene peek (Ctrl+Shift+D)") + self.pill.clicked.connect(self.open) + self._stylePill() + + # keep pinned to the field; reposition when its geometry changes + self.mainwindow.field.installEventFilter(self) + + self.hide() + self.reposition() + + # ---- construction ---------------------------------------------------------- + + def _buildUi(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # header + header = QWidget(self) + header.setObjectName("soHead") + hl = QHBoxLayout(header) + hl.setContentsMargins(14, 11, 12, 11) + hl.setSpacing(8) + title = QLabel("◈ 3D Scene", header) + title.setObjectName("soTitle") + self.reset_btn = QPushButton("⟲", header) # ⟲ reset + self.reset_btn.setObjectName("soIconBtn") + self.reset_btn.setToolTip("Reset view") + self.reset_btn.setCursor(Qt.PointingHandCursor) + self.reset_btn.clicked.connect(self.resetView) + self.close_btn = QPushButton("✕", header) # ✕ close + self.close_btn.setObjectName("soIconBtn") + self.close_btn.setToolTip("Close (Esc)") + self.close_btn.setCursor(Qt.PointingHandCursor) + self.close_btn.clicked.connect(self.close) + hl.addWidget(title) + hl.addStretch(1) + hl.addWidget(self.reset_btn) + hl.addWidget(self.close_btn) + + # body (the 3D area / embedding seam) + self.body = QWidget(self) + self.body.setObjectName("soBody") + bl = QVBoxLayout(self.body) + bl.setContentsMargins(0, 0, 0, 0) + self._scene = _PlaceholderScene(self.body) + bl.addWidget(self._scene) + + # footer + footer = QWidget(self) + footer.setObjectName("soFoot") + fl = QHBoxLayout(footer) + fl.setContentsMargins(14, 9, 14, 9) + fl.setSpacing(8) + hint = QLabel("⟲ drag to orbit · scroll to zoom", footer) + hint.setObjectName("soHint") + self.full_btn = QPushButton("Open full 3D ↗", footer) + self.full_btn.setObjectName("soFullBtn") + self.full_btn.setCursor(Qt.PointingHandCursor) + self.full_btn.clicked.connect(self._openFull) + fl.addWidget(hint) + fl.addStretch(1) + fl.addWidget(self.full_btn) + + root.addWidget(header, 0) + root.addWidget(self.body, 1) + root.addWidget(footer, 0) + + # ---- embedding seam -------------------------------------------------------- + + def setSceneWidget(self, widget: QWidget): + """Swap the static preview for a live scene widget (e.g. CustomPlotter).""" + layout = self.body.layout() + self.clearSceneWidget() + widget.setParent(self.body) + layout.addWidget(widget) + self._scene = widget + + def clearSceneWidget(self): + """Remove and destroy the current scene widget (the placeholder, or a + previously embedded viewer). Ownership contract: clearing destroys the + widget; a caller that needs to re-host a live viewer elsewhere should + hold its own reference before calling setSceneWidget/clearSceneWidget.""" + layout = self.body.layout() + if self._scene is not None: + layout.removeWidget(self._scene) + self._scene.setParent(None) + self._scene.deleteLater() + self._scene = None + + # ---- styling --------------------------------------------------------------- + + def _applyStyle(self): + dark = _is_dark() + if dark: + panel, hair, txt, dim, accent = "#161b22", "#2a313c", "#e6eaf0", "#9aa7bb", "#4c8dff" + teal, btn_bg = "#2bd4b8", "#1d232c" + else: + panel, hair, txt, dim, accent = "#ffffff", "#dbe1ea", "#16202e", "#54627a", "#2f6fe0" + teal, btn_bg = "#0f9e88", "#f4f6fa" + self.setStyleSheet(f""" + #scenePeek {{ background: {panel}; border-left: 1px solid {hair}; }} + #soHead {{ background: {panel}; border-bottom: 1px solid {hair}; }} + #soFoot {{ background: {panel}; border-top: 1px solid {hair}; }} + #soTitle {{ color: {teal}; font-size: 13px; font-weight: 650; }} + #soHint {{ color: {dim}; font-size: 11px; }} + #soIconBtn {{ + background: {btn_bg}; color: {dim}; border: 1px solid {hair}; + border-radius: 7px; min-width: 28px; min-height: 26px; font-size: 13px; + }} + #soIconBtn:hover {{ color: {txt}; border-color: {accent}; }} + #soFullBtn {{ + background: {btn_bg}; color: {dim}; border: 1px solid {hair}; + border-radius: 8px; padding: 5px 12px; font-size: 12px; font-weight: 550; + }} + #soFullBtn:hover {{ color: {txt}; border-color: {teal}; }} + """) + + def _stylePill(self): + dark = _is_dark() + if dark: + bg, hair, dim, txt, teal = "#1d232c", "#2a313c", "#9aa7bb", "#e6eaf0", "#2bd4b8" + teal_weak = "#10322e" + else: + bg, hair, dim, txt, teal = "#f4f6fa", "#dbe1ea", "#54627a", "#16202e", "#0f9e88" + teal_weak = "#d2f1ea" + self.pill.setStyleSheet(f""" + #scenePeekPill {{ + background: {bg}; color: {dim}; border: 1px solid {hair}; + border-radius: 8px; padding: 6px 11px; font-size: 12px; font-weight: 550; + }} + #scenePeekPill:hover {{ color: {txt}; border-color: {teal}; background: {teal_weak}; }} + """) + + def refreshTheme(self): + """Re-apply theme-derived styling (call after an app theme change).""" + self._applyStyle() + self._stylePill() + if isinstance(self._scene, _PlaceholderScene): + self._scene.update() + + # ---- geometry -------------------------------------------------------------- + + def _fieldRect(self) -> QRect: + """The field's rect in MainWindow coordinates (the stage to overlay).""" + f = self.mainwindow.field + return QRect(f.x(), f.y(), f.width(), f.height()) + + def _panelWidth(self, field_w: int) -> int: + return max(1, min(PANEL_MAX_W, int(field_w * PANEL_W_FRAC))) + + def targetGeometry(self) -> QRect: + """Docked (open) geometry: pinned to the field's right edge, full height.""" + r = self._fieldRect() + pw = self._panelWidth(r.width()) + return QRect(r.x() + r.width() - pw, r.y(), pw, r.height()) + + def offscreenGeometry(self) -> QRect: + """Hidden (closed) geometry: fully off the field's right edge.""" + r = self._fieldRect() + pw = self._panelWidth(r.width()) + return QRect(r.x() + r.width(), r.y(), pw, r.height()) + + def reposition(self): + """Re-pin scrim + panel + pill to the current field geometry. + + If a slide is in flight, retarget it (setEndValue) rather than snapping, + so a resize/maximize during the ~280ms animation still settles flush to + the new right edge instead of finishing at a stale target.""" + r = self._fieldRect() + self.scrim.setGeometry(r) + target = self.targetGeometry() if self.is_open else self.offscreenGeometry() + if self._anim.state() == QAbstractAnimation.Running: + self._anim.setEndValue(target) + else: + self.setGeometry(target) + # pill sits at the field's TOP-LEFT: the top-right is the mouse palette's + # mode-button column, so the left corner keeps both affordances clear + pw = self.pill.sizeHint().width() + self.pill.setGeometry(r.x() + 12, r.y() + 10, pw, 30) + self.pill.setVisible(not self.is_open) + + # ---- open / close ---------------------------------------------------------- + + def open(self): + if self.is_open: + return + self.is_open = True + r = self._fieldRect() + self.scrim.setGeometry(r) + self.scrim.show() + self.scrim.raise_() + self.show() + self.raise_() + self.pill.setVisible(False) + self.setFocus(Qt.OtherFocusReason) + + target = self.targetGeometry() + if reduced_motion(): + self._stopAnims() + self.setGeometry(target) + self._scrim_fx.setOpacity(1.0) + else: + self.setGeometry(self.offscreenGeometry()) + self._animateTo(target, fade_in=True) + self.toggled.emit(True) + + def close(self): + if not self.is_open: + return + self.is_open = False + if reduced_motion(): + self._stopAnims() + self.setGeometry(self.offscreenGeometry()) + self._scrim_fx.setOpacity(0.0) + self._afterClose() + else: + self._animateTo(self.offscreenGeometry(), fade_in=False, + on_done=self._afterClose) + self.toggled.emit(False) + + def _afterClose(self): + self.hide() + self.scrim.hide() + self.pill.setVisible(True) + + def toggle(self): + self.close() if self.is_open else self.open() + + def _stopAnims(self): + """Stop both animations and drop any pending finished callback, so a + stopped close animation can't later fire _afterClose out of turn.""" + self._anim.stop() + self._scrim_anim.stop() + try: + self._anim.finished.disconnect() + except (RuntimeError, TypeError): + pass + + def _animateTo(self, geom: QRect, fade_in: bool, on_done=None): + self._stopAnims() + # panel slide — reuse the persistent animation (no per-call allocation) + self._anim.setStartValue(self.geometry()) + self._anim.setEndValue(geom) + if on_done is not None: + self._anim.finished.connect(on_done) + # scrim fade + self._scrim_anim.setStartValue(self._scrim_fx.opacity()) + self._scrim_anim.setEndValue(1.0 if fade_in else 0.0) + self._anim.start() + self._scrim_anim.start() + + # ---- actions --------------------------------------------------------------- + + def resetView(self): + """Reset the scene view. No-op for the static preview; when a live scene + widget is embedded, this is where its camera reset is invoked.""" + scene = self._scene + reset = getattr(scene, "resetCamera", None) or getattr(scene, "home", None) + if callable(reset): + reset() + elif isinstance(scene, _PlaceholderScene): + scene.update() + + def _openFull(self): + """Hand off to the existing full 3D window, then dismiss the peek.""" + self.close() + self.mainwindow.openFullScene() + + # ---- events ---------------------------------------------------------------- + + def eventFilter(self, obj, event): + # getattr guard: a deferred field event can arrive after this peek has + # been torn down (its Python attrs cleared), so skip rather than raise. + mw = getattr(self, "mainwindow", None) + if mw is not None and obj is mw.field and event.type() in ( + QEvent.Resize, QEvent.Move, QEvent.Show + ): + self.reposition() + return False + + def keyPressEvent(self, event): + if event.key() == Qt.Key_Escape: + self.close() + else: + super().keyPressEvent(event) From df1d6999e7eda5536fb3e359933897c55a3e1375 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Mon, 29 Jun 2026 01:36:57 -0500 Subject: [PATCH 2/2] test(ui): cover the 3D-scene peek lifecycle, wiring, and animation Headless offscreen tests for the slide-over: right-edge geometry and the min(460px, 62%) sizing, open/close/toggle state and visibility, the reduced-motion and animated paths (including in-flight resize retargeting), field-resize re-pin, close via scrim/Escape/button (direct and through real event dispatch), the menu-action and Ctrl+Shift+D wiring, the checkActions sync guard, and the "Open full 3D" hand-off. --- tests/test_scene_peek.py | 553 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 553 insertions(+) create mode 100644 tests/test_scene_peek.py diff --git a/tests/test_scene_peek.py b/tests/test_scene_peek.py new file mode 100644 index 00000000..4719fa38 --- /dev/null +++ b/tests/test_scene_peek.py @@ -0,0 +1,553 @@ +"""UI v1 — 3D-scene slide-over ("peek") behavior (headless, offscreen). + +The look is signed off interactively; these cover BEHAVIOR only: +open/close/visibility/geometry/state, the reduced-motion case, the slide +animation's targets, the field-resize re-pin, and the menu/shortcut/hand-off +wiring. QSettings is isolated to a temp dir by the session-autouse fixture in +conftest.py, so the reduce_motion round-trips never touch real preferences. +""" + +import types + +import pytest + +pytest.importorskip("PySide6") + +from PySide6.QtCore import Qt, QRect, QSize, QSettings, QAbstractAnimation # noqa: E402 +from PySide6.QtGui import QResizeEvent, QKeyEvent, QAction # noqa: E402 +from PySide6.QtWidgets import QMainWindow, QWidget # noqa: E402 + +import PyReconstruct.modules.gui.main.scene_peek as sp # noqa: E402 +from PyReconstruct.modules.gui.main.scene_peek import ScenePeek # noqa: E402 + + +FIELD = QRect(40, 30, 900, 760) + + +@pytest.fixture(autouse=True) +def _flush_widgets(qapp): + """Tear down leftover top-level widgets after each test and flush pending + events, so an installed field event-filter can't fire on a half-deleted + ScenePeek during the next test's setup.""" + yield + for w in qapp.topLevelWidgets(): + w.close() + w.deleteLater() + qapp.processEvents() + + +def _win_with_field(rect=FIELD): + """A real QMainWindow (a valid QWidget parent) with a fixed-geometry field. + + Geometry is set explicitly (not via the layout) so the overlay math is + deterministic without showing windows.""" + win = QMainWindow() + win.resize(1200, 820) + field = QWidget(win) + field.setGeometry(rect) + win.field = field + return win, field + + +def _peek(monkeypatch, reduced=True, rect=FIELD): + # default to the reduced-motion path so state lands synchronously + monkeypatch.setattr(sp, "reduced_motion", lambda: reduced) + win, field = _win_with_field(rect) + return win, field, ScenePeek(win) + + +# --- reduced_motion() helper ------------------------------------------------------ + +def test_reduced_motion_env_truthy(monkeypatch): + for v in ("1", "true", "TRUE", "yes", "on"): + monkeypatch.setenv("PYRECONSTRUCT_REDUCED_MOTION", v) + assert sp.reduced_motion() is True + + +def test_reduced_motion_env_falsy(monkeypatch): + for v in ("0", "false", "no", ""): + monkeypatch.setenv("PYRECONSTRUCT_REDUCED_MOTION", v) + assert sp.reduced_motion() is False + + +def test_reduced_motion_default_is_false(monkeypatch): + monkeypatch.delenv("PYRECONSTRUCT_REDUCED_MOTION", raising=False) + s = QSettings("KHLab", "PyReconstruct") + s.remove("reduce_motion"); s.sync() + assert sp.reduced_motion() is False + + +def test_reduced_motion_reads_global_qsettings(monkeypatch): + monkeypatch.delenv("PYRECONSTRUCT_REDUCED_MOTION", raising=False) + s = QSettings("KHLab", "PyReconstruct") + s.setValue("reduce_motion", True); s.sync() + try: + assert sp.reduced_motion() is True + finally: + s.remove("reduce_motion"); s.sync() + + +# --- geometry: pinned to the field's right edge ----------------------------------- + +def test_target_geometry_pinned_right_full_height(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + tg = peek.targetGeometry() + assert tg.y() == FIELD.y() + assert tg.height() == FIELD.height() + assert tg.width() == min(460, int(FIELD.width() * 0.62)) # min(460, 558) -> 460 + assert tg.x() + tg.width() == FIELD.x() + FIELD.width() # flush to the right edge + + +def test_panel_width_uses_fraction_when_narrow(qapp, monkeypatch): + narrow = QRect(0, 0, 500, 400) # 62% -> 310, below the 460 cap + _, _, peek = _peek(monkeypatch, rect=narrow) + assert peek.targetGeometry().width() == int(500 * 0.62) + + +def test_offscreen_geometry_is_just_past_right_edge(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + og = peek.offscreenGeometry() + assert og.x() == FIELD.x() + FIELD.width() + assert og.width() == peek.targetGeometry().width() + + +def test_scrim_covers_the_field(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + assert peek.scrim.geometry() == FIELD + + +def test_starts_closed_and_hidden(qapp, monkeypatch): + # isHidden (not isVisible): offscreen tests never show the top-level window, + # so isVisible() is always False — mirrors the lists-panel suite's convention. + _, _, peek = _peek(monkeypatch) + assert peek.is_open is False + assert peek.isHidden() is True + assert peek.scrim.isHidden() is True + + +# --- open / close / toggle (reduced-motion path: synchronous) --------------------- + +def test_open_docks_panel_shows_scrim_hides_pill(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + peek.open() + assert peek.is_open is True + assert peek.isHidden() is False and peek.scrim.isHidden() is False + assert peek.geometry() == peek.targetGeometry() + assert peek._scrim_fx.opacity() == 1.0 + assert peek.pill.isHidden() is True + + +def test_close_hides_panel_and_scrim_restores_pill(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + peek.open() + peek.close() + assert peek.is_open is False + assert peek.isHidden() is True and peek.scrim.isHidden() is True + assert peek._scrim_fx.opacity() == 0.0 + assert peek.pill.isHidden() is False + + +def test_toggle_alternates(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + peek.toggle(); assert peek.is_open is True + peek.toggle(); assert peek.is_open is False + + +def test_open_is_idempotent(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + seen = [] + peek.toggled.connect(seen.append) + peek.open(); peek.open() + assert seen == [True] # the second open is a no-op (no extra signal) + + +def test_close_is_idempotent(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + seen = [] + peek.toggled.connect(seen.append) + peek.open(); peek.close(); peek.close() + assert seen == [True, False] + + +def test_toggled_signal_reports_state(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + seen = [] + peek.toggled.connect(seen.append) + peek.open(); peek.close() + assert seen == [True, False] + + +# --- close triggers: scrim click, Escape, close button ---------------------------- + +def test_scrim_click_closes(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + peek.open() + # the scrim closes on any press and ignores the event object + peek.scrim.mousePressEvent(None) + assert peek.is_open is False + + +def test_escape_closes(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + peek.open() + from PySide6.QtCore import QEvent + peek.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Escape, Qt.NoModifier)) + assert peek.is_open is False + + +def test_close_button_closes(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + peek.open() + peek.close_btn.click() + assert peek.is_open is False + + +# --- field-resize re-pin (event filter) ------------------------------------------- + +def test_eventfilter_repins_to_new_right_edge_on_field_resize(qapp, monkeypatch): + _, field, peek = _peek(monkeypatch) + peek.open() + field.setGeometry(40, 30, 1100, 760) # field grows + peek.eventFilter(field, QResizeEvent(QSize(1100, 760), QSize(900, 760))) + assert peek.geometry() == peek.targetGeometry() + assert peek.geometry().x() + peek.geometry().width() == 40 + 1100 + assert peek.scrim.geometry() == QRect(40, 30, 1100, 760) + + +def test_eventfilter_keeps_panel_offscreen_when_closed(qapp, monkeypatch): + _, field, peek = _peek(monkeypatch) + field.setGeometry(40, 30, 1100, 760) + peek.eventFilter(field, QResizeEvent(QSize(1100, 760), QSize(900, 760))) + assert peek.geometry() == peek.offscreenGeometry() # still parked off-edge + + +# --- slide animation (the non-reduced-motion path) -------------------------------- + +def test_animated_open_targets_docked_geometry(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch, reduced=False) + peek.open() + assert peek._anim is not None + assert peek._anim.state() == QAbstractAnimation.Running + assert peek._anim.startValue() == peek.offscreenGeometry() + assert peek._anim.endValue() == peek.targetGeometry() + assert peek._scrim_anim.endValue() == 1.0 + peek._anim.setCurrentTime(peek._anim.duration()) # jump to the end + assert peek.geometry() == peek.targetGeometry() + + +def test_animated_close_targets_offscreen_and_hides(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch, reduced=True) + peek.open() # instant open + monkeypatch.setattr(sp, "reduced_motion", lambda: False) + peek.close() # animated close + assert peek._anim.endValue() == peek.offscreenGeometry() + assert peek._scrim_anim.endValue() == 0.0 + # drive the close animation to completion, bounded so an unattended run can't hang + from PySide6.QtCore import QEventLoop, QTimer + loop = QEventLoop() + peek._anim.finished.connect(loop.quit) + QTimer.singleShot(2000, loop.quit) + loop.exec() + # the load-bearing checks: the close animation actually ran to completion + # and _afterClose hid the panel + scrim (is_open is set synchronously in + # close(), so it is already covered by the reduced-motion close tests). + assert peek._anim.state() == QAbstractAnimation.Stopped + assert peek.isHidden() is True and peek.scrim.isHidden() is True + + +# --- embedding seam --------------------------------------------------------------- + +def test_set_scene_widget_swaps_body_content(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + w = QWidget() + peek.setSceneWidget(w) + assert peek._scene is w + assert w.parent() is peek.body + assert peek.body.layout().indexOf(w) >= 0 + + +def test_clear_scene_widget_empties_body(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + peek.setSceneWidget(QWidget()) + peek.clearSceneWidget() + assert peek._scene is None + assert peek.body.layout().count() == 0 + + +def test_reset_invokes_embedded_scene_reset_hook(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + calls = [] + + class FakeViewer(QWidget): + def resetCamera(self): + calls.append("reset") + + peek.setSceneWidget(FakeViewer()) + peek.resetView() + assert calls == ["reset"] + + +def test_reset_on_placeholder_is_safe(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + peek.resetView() # placeholder body: must not raise + + +# --- MainWindow wiring: toggle flips state + syncs the menu checkbox --------------- + +def _bind_mainwindow_methods(mw, *names): + """Bind the genuine MainWindow methods onto a real QMainWindow so they run + against a valid QWidget (ScenePeek parents itself to the main window, which + MainWindow.__new__ — an uninitialised QWidget — cannot serve as).""" + from PyReconstruct.modules.gui.main.main_window import MainWindow + for n in names: + setattr(mw, n, types.MethodType(getattr(MainWindow, n), mw)) + + +def test_mainwindow_toggle_builds_opens_and_syncs_checkbox(qapp, monkeypatch): + monkeypatch.setattr(sp, "reduced_motion", lambda: True) + mw, _ = _win_with_field() + mw.scene_peek = None + mw.threedscene_act = QAction(); mw.threedscene_act.setCheckable(True) + _bind_mainwindow_methods(mw, "_ensureScenePeek", "_onScenePeekToggled", "toggleScenePeek") + + mw.toggleScenePeek() + assert isinstance(mw.scene_peek, ScenePeek) + assert mw.scene_peek.is_open is True + assert mw.threedscene_act.isChecked() is True # checkbox follows open state + + mw.toggleScenePeek() + assert mw.scene_peek.is_open is False + assert mw.threedscene_act.isChecked() is False + + +def test_mainwindow_toggle_show_forces_state(qapp, monkeypatch): + monkeypatch.setattr(sp, "reduced_motion", lambda: True) + mw, _ = _win_with_field() + mw.scene_peek = None + mw.threedscene_act = QAction(); mw.threedscene_act.setCheckable(True) + _bind_mainwindow_methods(mw, "_ensureScenePeek", "_onScenePeekToggled", "toggleScenePeek") + + mw.toggleScenePeek(show=False) # already closed -> stays closed + assert mw.scene_peek.is_open is False + mw.toggleScenePeek(show=True) + assert mw.scene_peek.is_open is True + mw.toggleScenePeek(show=True) # idempotent + assert mw.scene_peek.is_open is True + + +def test_closing_peek_externally_unchecks_menu(qapp, monkeypatch): + # closing via the panel (not the menu) must still uncheck the menu action + monkeypatch.setattr(sp, "reduced_motion", lambda: True) + mw, _ = _win_with_field() + mw.scene_peek = None + mw.threedscene_act = QAction(); mw.threedscene_act.setCheckable(True) + _bind_mainwindow_methods(mw, "_ensureScenePeek", "_onScenePeekToggled", "toggleScenePeek") + + mw.toggleScenePeek() # open + assert mw.threedscene_act.isChecked() is True + mw.scene_peek.close_btn.click() # close from the panel + assert mw.scene_peek.is_open is False + assert mw.threedscene_act.isChecked() is False + + +# --- MainWindow wiring: "Open full 3D" hand-off ----------------------------------- + +def _mw_for_handoff(viewer): + from PyReconstruct.modules.gui.main.main_window import MainWindow + mw = MainWindow.__new__(MainWindow) # no widget creation here -> __new__ is fine + mw.viewer = viewer + calls = [] + + class Field: + def addTo3D(self): + calls.append("addTo3D") + + mw.field = Field() + return mw, calls + + +def test_openfullscene_raises_existing_open_viewer(qapp): + from PyReconstruct.modules.gui.main.main_window import MainWindow + + calls = [] + + class Viewer: + is_closed = False + def activateWindow(self): calls.append("activate") + def setFocus(self): calls.append("focus") + + mw, field_calls = _mw_for_handoff(Viewer()) + MainWindow.openFullScene(mw) + assert "activate" in calls + assert field_calls == [] # does not spawn a new scene + + +def test_openfullscene_opens_for_selection_when_no_viewer(qapp): + from PyReconstruct.modules.gui.main.main_window import MainWindow + mw, field_calls = _mw_for_handoff(None) + MainWindow.openFullScene(mw) + assert field_calls == ["addTo3D"] + + +def test_openfullscene_opens_when_viewer_closed(qapp): + from PyReconstruct.modules.gui.main.main_window import MainWindow + + class Viewer: + is_closed = True + + mw, field_calls = _mw_for_handoff(Viewer()) + MainWindow.openFullScene(mw) + assert field_calls == ["addTo3D"] + + +def test_full_button_closes_peek_then_hands_off(qapp, monkeypatch): + _, _, peek = _peek(monkeypatch) + calls = [] + peek.mainwindow.openFullScene = lambda: calls.append("full") + peek.open() + peek.full_btn.click() + assert peek.is_open is False + assert calls == ["full"] + + +# --- review hardening: menu/shortcut wiring, real event delivery, regressions ---- + +def test_menu_action_built_via_newAction_toggles_and_syncs(qapp, monkeypatch): + # exercise the REAL menubar tuple through newAction (the "checkbox" branch + + # the trigger->slot connection), not a hand-built QAction. + from PySide6.QtWidgets import QMenu + from PyReconstruct.modules.gui.utils.utils import newAction + + monkeypatch.setattr(sp, "reduced_motion", lambda: True) + mw, _ = _win_with_field() + mw.scene_peek = None + _bind_mainwindow_methods(mw, "_ensureScenePeek", "_onScenePeekToggled", "toggleScenePeek") + + newAction(mw, QMenu(mw), ("threedscene_act", "3D scene peek", "checkbox", mw.toggleScenePeek)) + assert mw.threedscene_act.isCheckable() is True + + mw.threedscene_act.trigger() + assert mw.scene_peek.is_open is True + assert mw.threedscene_act.isChecked() is True + mw.threedscene_act.trigger() + assert mw.scene_peek.is_open is False + assert mw.threedscene_act.isChecked() is False + + +def test_scenePeekIsOpen_guard_is_safe_before_built(qapp, monkeypatch): + # checkActions reads this on every refresh, before the peek exists -> must + # return False without raising (regression guard for the bool() short-circuit). + from PyReconstruct.modules.gui.main.main_window import MainWindow + monkeypatch.setattr(sp, "reduced_motion", lambda: True) + mw, _ = _win_with_field() + mw.scene_peek = None + assert MainWindow.scenePeekIsOpen(mw) is False # not built yet + + mw.threedscene_act = QAction(); mw.threedscene_act.setCheckable(True) + _bind_mainwindow_methods(mw, "_ensureScenePeek", "_onScenePeekToggled", "toggleScenePeek") + mw.toggleScenePeek() # build + open + assert MainWindow.scenePeekIsOpen(mw) is True + mw.toggleScenePeek() + assert MainWindow.scenePeekIsOpen(mw) is False + + +def test_ctrl_shift_d_shortcut_binds_and_toggles(qapp, monkeypatch): + # replicate createShortcuts' production idiom and verify the binding + toggle. + # NOTE: keep this string identical to main_window.createShortcuts. + from PySide6.QtGui import QKeySequence + monkeypatch.setattr(sp, "reduced_motion", lambda: True) + mw, _ = _win_with_field() + mw.scene_peek = None + _bind_mainwindow_methods(mw, "_ensureScenePeek", "_onScenePeekToggled", "toggleScenePeek") + + mw.addAction("", "Ctrl+Shift+D", lambda: mw.toggleScenePeek()) + bound = [a for a in mw.actions() if a.shortcut() == QKeySequence("Ctrl+Shift+D")] + assert len(bound) == 1 + bound[0].trigger(); assert mw.scene_peek.is_open is True + bound[0].trigger(); assert mw.scene_peek.is_open is False + + +def test_scrim_close_dispatches_through_event(qapp, monkeypatch): + # route a real press through QApplication.sendEvent -> scrim.event() -> + # mousePressEvent (catches a handler that event() never reaches). + from PySide6.QtCore import QEvent, QPointF + from PySide6.QtGui import QMouseEvent + from PySide6.QtWidgets import QApplication + _, _, peek = _peek(monkeypatch) + peek.open() + ev = QMouseEvent(QEvent.MouseButtonPress, QPointF(5, 5), QPointF(5, 5), + Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) + QApplication.sendEvent(peek.scrim, ev) + assert peek.is_open is False + + +def test_escape_close_dispatches_through_event(qapp, monkeypatch): + from PySide6.QtCore import QEvent + from PySide6.QtGui import QKeyEvent + from PySide6.QtWidgets import QApplication + _, _, peek = _peek(monkeypatch) + peek.open() + peek.setFocus(Qt.OtherFocusReason) + QApplication.sendEvent(peek, QKeyEvent(QEvent.KeyPress, Qt.Key_Escape, Qt.NoModifier)) + assert peek.is_open is False + + +def test_inflight_resize_retargets_running_animation(qapp, monkeypatch): + # geom-1: resizing the field mid-slide must retarget the animation's end + # value, not let it finish at the stale (old right-edge) geometry. + _, field, peek = _peek(monkeypatch, reduced=False) + peek.open() + assert peek._anim.state() == QAbstractAnimation.Running + field.setGeometry(40, 30, 1000, 760) # shrink during the slide + peek.eventFilter(field, QResizeEvent(QSize(1000, 760), QSize(900, 760))) + assert peek._anim.endValue() == peek.targetGeometry() + peek._anim.setCurrentTime(peek._anim.duration()) # finish + assert peek.geometry().x() + peek.geometry().width() == 40 + 1000 + + +def test_animations_are_reused_not_accumulated(qapp, monkeypatch): + # lifecycle: open/close reuses the two persistent animations rather than + # allocating (and leaking) a fresh pair each cycle. + from PySide6.QtCore import QPropertyAnimation + _, _, peek = _peek(monkeypatch, reduced=False) + a, s = peek._anim, peek._scrim_anim + for _ in range(3): + peek.open(); peek.close() + assert peek._anim is a and peek._scrim_anim is s + kids = [c for c in peek.children() if isinstance(c, QPropertyAnimation)] + assert len(kids) == 2 + + +def test_pill_rests_top_left_clear_of_palette(qapp, monkeypatch): + # geom-2: the resting pill must not sit in the top-right mode-button column. + _, _, peek = _peek(monkeypatch) + assert peek.pill.x() == FIELD.x() + 12 + assert peek.pill.y() == FIELD.y() + 10 + assert peek.pill.geometry().right() < FIELD.x() + FIELD.width() // 2 + + +def test_scrim_fade_is_faster_than_panel_slide(qapp, monkeypatch): + # FID-1: the prototype's scrim (.26s) settles ahead of the sheet (.28s). + _, _, peek = _peek(monkeypatch, reduced=False) + assert peek._scrim_anim.duration() == sp.SCRIM_ANIM_MS == 260 + assert peek._anim.duration() == sp.ANIM_MS == 280 + assert peek._scrim_anim.duration() < peek._anim.duration() + + +def test_clear_scene_widget_destroys_previous(qapp, monkeypatch): + # orphan fix: clearing must destroy the swapped-out widget, not leave it as + # a parentless top-level that survives at the C++ level. + from PySide6.QtCore import QEvent + _, _, peek = _peek(monkeypatch) + old = peek._scene + peek.clearSceneWidget() + assert peek._scene is None + # deleteLater posts a DeferredDelete event; flush it explicitly (a plain + # processEvents does not dispatch deferred deletes at this loop level). + qapp.sendPostedEvents(None, QEvent.DeferredDelete) + qapp.processEvents() + try: + import shiboken6 + assert shiboken6.isValid(old) is False + except ImportError: + assert old not in qapp.topLevelWidgets()