fix(kit): stop SummaryStrip flashing a ghost window on every grid rebuild
set_summary replaced its count labels with label.setParent(None) while they were visible - reparenting a visible widget to None promotes it to a real top-level window, which Windows shows with full native decoration (app icon, min/max/close, label-sized blank body) until the deferred deleteLater runs. Every grid rebuild in every module flashed one such ghost per replaced label; the DA-07 optimistic-apply change made it glaring (8 flashes per device-row Active toggle, one per channel write). Fix: hide the dying label and let deleteLater collect it while still parented. Found via a live user session on DA-07 hardware: synthetic probes could not reproduce it (offscreen platforms swallow the flash; QTest clicks missed the toggle), so this adds an env-gated diagnostic - CIM_UI_SPY=<log path> installs a window spy (core/ui/window_spy.py, hooked in shell/app.py) that logs every top-level Show with the widget creation stack; the user's log produced 84 ghost windows pointing at the exact line. Screenshot kept in docs/samples; session write-up in docs/DA07-FIELD-NOTES.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,12 @@ class SummaryStrip(QFrame):
|
||||
"""Replace the left-side counts. ``state`` may be None, "warn", or "alarm"."""
|
||||
for label in self._segments:
|
||||
self._box.removeWidget(label)
|
||||
label.setParent(None)
|
||||
# Hide, DON'T setParent(None): reparenting a visible widget to None
|
||||
# promotes it to a top-level window, which flashes on screen as a tiny
|
||||
# native-decorated popup until deleteLater runs (one flash per grid
|
||||
# rebuild, suite-wide — found 2026-06-12 on DA-07 hardware via the
|
||||
# CIM_UI_SPY diagnostic). It stays a hidden child until deletion.
|
||||
label.hide()
|
||||
label.deleteLater()
|
||||
self._segments = []
|
||||
insert_at = 0
|
||||
|
||||
72
cim_suite/core/ui/window_spy.py
Normal file
72
cim_suite/core/ui/window_spy.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Env-gated UI diagnostic: log every top-level window Show with its creation stack.
|
||||
|
||||
Enable by setting ``CIM_UI_SPY`` to a log-file path before launching the suite
|
||||
(hooked in ``shell/app.py``, same pattern as the DA-07 ``CIM_DA07_CAPTURE`` frame
|
||||
recorder). Used to hunt transient "flashing window" bugs that only reproduce in a
|
||||
live session: whenever any top-level widget is shown, one log block records the
|
||||
widget's class, objectName, window flags, geometry — and, for widget classes whose
|
||||
``__init__`` we can instrument, the Python stack that created it.
|
||||
|
||||
Diagnostic only: off unless the env var is set; never imported by normal code paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import traceback
|
||||
import weakref
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject
|
||||
from PySide6.QtWidgets import QDialog, QFrame, QLabel, QWidget
|
||||
|
||||
_creation: "weakref.WeakKeyDictionary[QWidget, str]" = weakref.WeakKeyDictionary()
|
||||
|
||||
|
||||
def _patch_init(cls) -> None:
|
||||
orig = cls.__init__
|
||||
|
||||
def patched(self, *args, **kwargs):
|
||||
orig(self, *args, **kwargs)
|
||||
try:
|
||||
_creation[self] = "".join(traceback.format_stack(limit=16)[:-2])
|
||||
except TypeError:
|
||||
pass # not weakref-able
|
||||
|
||||
cls.__init__ = patched
|
||||
|
||||
|
||||
class _WindowSpy(QObject):
|
||||
def __init__(self, log_path: str, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._path = log_path
|
||||
self._t0 = time.monotonic()
|
||||
self._log(f"spy installed {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
def _log(self, msg: str) -> None:
|
||||
try:
|
||||
with open(self._path, "a", encoding="utf-8") as fh:
|
||||
fh.write(f"[{time.monotonic() - self._t0:9.3f}] {msg}\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def eventFilter(self, obj, event): # noqa: N802 (Qt override)
|
||||
try:
|
||||
if event.type() == QEvent.Type.Show and obj.isWidgetType() and obj.isWindow():
|
||||
stack = _creation.get(obj, " (creation stack not captured for this class)")
|
||||
self._log(
|
||||
f"SHOW WINDOW: {type(obj).__name__} objectName={obj.objectName()!r} "
|
||||
f"title={obj.windowTitle()!r} flags={int(obj.windowFlags()):#x} "
|
||||
f"geom={obj.geometry().x()},{obj.geometry().y()} "
|
||||
f"{obj.width()}x{obj.height()}\n{stack}"
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # widget mid-teardown
|
||||
return False
|
||||
|
||||
|
||||
def install_spy(app, log_path: str) -> None:
|
||||
"""Patch common widget constructors and watch every top-level Show."""
|
||||
for cls in (QWidget, QFrame, QLabel, QDialog):
|
||||
_patch_init(cls)
|
||||
spy = _WindowSpy(log_path, parent=app)
|
||||
app.installEventFilter(spy)
|
||||
@@ -32,6 +32,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
from .branding import app_icon
|
||||
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
# Diagnostic (off unless set): CIM_UI_SPY=<log path> logs every top-level
|
||||
# window Show with its creation stack — for hunting transient flashing
|
||||
# windows that only reproduce in a live session. See core/ui/window_spy.py.
|
||||
if os.environ.get("CIM_UI_SPY"):
|
||||
from cim_suite.core.ui.window_spy import install_spy
|
||||
|
||||
install_spy(app, os.environ["CIM_UI_SPY"])
|
||||
app.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings)
|
||||
app.setApplicationName("CIMTechniques Service Suite")
|
||||
app.setOrganizationName("CIMTechniques")
|
||||
|
||||
@@ -12,6 +12,39 @@ Companion docs: `HARDWARE-VERIFICATION.md` (the flagged-protocol checklist),
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-12 — Tiny "popup window" flashing on every write (suite-wide kit bug)
|
||||
|
||||
**Symptom:** a small, empty, native-decorated window (app icon + min/max/close,
|
||||
label-sized) flashed on top of the app on every settings write — 8× on a Devices-tab
|
||||
Active toggle (one per channel write). Screenshot: `docs/samples/DA-07 flashing
|
||||
pop-up.png`. Not DA-07-specific and **not** the VB6 "Working" indicator — it fired on
|
||||
any grid rebuild in any module; the optimistic-apply change (below) just multiplied
|
||||
the rebuilds that exposed it.
|
||||
|
||||
**Root cause:** `SummaryStrip.set_summary` (`core/ui/kit/summary_strip.py`) replaced
|
||||
its count labels with `label.setParent(None)` while the labels were **visible** —
|
||||
reparenting a visible widget to None promotes it to a top-level window, which Windows
|
||||
shows with full native decoration until the deferred `deleteLater` runs. Fix: hide
|
||||
the dying label and let `deleteLater` collect it while still parented (one line +
|
||||
regression test `tests/core/kit/test_summary_strip.py::
|
||||
test_set_summary_never_orphans_a_visible_label`).
|
||||
|
||||
**How it was found — the `CIM_UI_SPY` diagnostic (keep for next time):** the flash
|
||||
could not be reproduced by any synthetic probe (offscreen platforms swallow it, and
|
||||
QTest clicks missed the toggle hotspot), but an env-gated window spy
|
||||
(`core/ui/window_spy.py`, hooked in `shell/app.py`) run in the *user's live session*
|
||||
logged 84 ghost windows with full creation stack traces pointing at the exact line.
|
||||
Usage:
|
||||
|
||||
```powershell
|
||||
$env:CIM_UI_SPY="$PWD\ui-spy.log"; .venv\Scripts\python -m cim_suite.shell.app --simulate
|
||||
```
|
||||
|
||||
**Lesson:** when a UI glitch reproduces for the user but not for instrumented
|
||||
probes, instrument the user's own session instead of approximating it.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-12 — Tag column truncation confirmed + first real capture (BL-E5 part 1)
|
||||
|
||||
**Setup:** same session as the stale-model entry below. Ran an instrumented refresh
|
||||
|
||||
BIN
docs/samples/DA-07 flashing pop-up.png
Normal file
BIN
docs/samples/DA-07 flashing pop-up.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -21,10 +21,15 @@ def test_segments_render_microcaps_with_states(qtbot):
|
||||
|
||||
|
||||
def test_set_summary_replaces_previous_segments(qtbot):
|
||||
from PySide6.QtCore import QCoreApplication, QEvent
|
||||
|
||||
strip = SummaryStrip()
|
||||
qtbot.addWidget(strip)
|
||||
strip.set_summary([("2 devices", None)])
|
||||
strip.set_summary([("3 devices", None), ("1 alarm", "alarm")])
|
||||
# Replaced labels stay as hidden children until deleteLater processes (they
|
||||
# are deliberately NOT reparented to None — see set_summary). Flush them.
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
assert [i.text() for i in _items(strip)] == ["3 DEVICES", "1 ALARM"]
|
||||
|
||||
|
||||
@@ -41,3 +46,26 @@ def test_edit_hint_is_the_shared_kit_constant(qtbot):
|
||||
strip.set_hint(EDIT_HINT)
|
||||
assert strip._hint.text() == EDIT_HINT.upper()
|
||||
assert "CLICK A VALUE TO EDIT" in strip._hint.text()
|
||||
|
||||
|
||||
def test_set_summary_never_orphans_a_visible_label(qtbot):
|
||||
"""Regression (2026-06-12, found on DA-07 hardware session): replacing segments
|
||||
called setParent(None) on still-VISIBLE labels — reparenting a visible widget
|
||||
to None promotes it to a real top-level window, which flashes on screen as a
|
||||
tiny native-decorated popup until deleteLater runs. Old labels must be hidden
|
||||
before they are orphaned."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
strip = SummaryStrip()
|
||||
qtbot.addWidget(strip)
|
||||
strip.show()
|
||||
strip.set_summary([("16 slots", None), ("3 devices", None)])
|
||||
strip.set_summary([("16 slots", None), ("2 devices", None), ("1 warn", "warn")])
|
||||
# A replaced label awaiting deleteLater must still be a (hidden) child of
|
||||
# the strip - if it shows up as a top-level widget it WAS promoted to a window.
|
||||
ghosts = [
|
||||
w for w in QApplication.topLevelWidgets()
|
||||
if isinstance(w, QLabel)
|
||||
and w.objectName() in ("SummaryItem", "SummarySep")
|
||||
]
|
||||
assert ghosts == [], [g.text() for g in ghosts]
|
||||
|
||||
Reference in New Issue
Block a user