Source code for spacr.qt.dialogs

"""Standardize movement and resizing of spaCR modal dialogs.

Some desktop window managers attach a parented modal ``QDialog`` to its main
window. :func:`detach_from_window_manager` preserves ownership and modality
while presenting it as an independently movable top-level window.

The resizing helpers remove explicit minimum sizes when appropriate, wrap
content in a scroll area only when its layout prevents useful shrinking, add
a visible size grip, and preserve the dialog's natural opening size. Qt's own
specialized dialogs, simple message dialogs, and content that already scrolls
are left unchanged. :func:`install_the_dialog_filters` applies these rules to
new dialogs at application level.
"""

from __future__ import annotations

import logging
from typing import Any

_DIALOG_IMPLEMENTATION_NOTES = r"""

TWO THINGS EVERY DIALOG IN THE APPLICATION GETS, and neither is asked for
by the dialogs themselves: a window the WM will let the user DRAG, and a
window the user can RESIZE both ways. One application-wide filter does
both, because a rule applied by hand is a rule that holds until the next
dialog is written -- the first of these was called from six files while
more than twenty others opened dialogs without it.

--------------------------------------------------------------------------
ONE: A WINDOW THE USER CAN DRAG
--------------------------------------------------------------------------

A ``QDialog`` created with a parent is *transient-for* that parent, and
``exec()`` makes it modal. A window manager that implements **attached modal
dialogs** -- GNOME/Mutter does by default, and it is not alone -- treats that
combination as an invitation to glue the dialog to its parent: it is drawn
centred on the parent, it cannot be dragged anywhere else, and pulling at it
manipulates the parent window instead. On a maximised main window that reads
as "the app un-maximised itself when I tried to move the settings".

The tell is which dialogs misbehave. In spaCR, Preferences
(``PreferencesDialog(...).exec()``) and Annotate's settings
(``_SettingsDialog(...).exec()``) are attached; the UMAP search settings
(``UmapSearchSettingsDialog(panel).show()``) and the Mask and crop live
preview settings are not. The first two are modal and the rest are modeless
-- which is exactly the WM's rule, and nothing to do with how any of them
are built.

The fix is to stop advertising them as dialogs. Clearing ``Qt.Dialog`` from
the window type and setting ``Qt.Window`` makes the WM see an ordinary
top-level window with its own frame, which nothing attaches. The parent is
kept, so the dialog still stacks above the app and is still owned by it, and
the modality is untouched -- ``exec()`` still blocks and still returns
``Accepted``.

Not done instead:

* **Dropping the parent.** It detaches, but the dialog then stops stacking
  above the main window and can be lost behind it, and Qt no longer destroys
  it with its owner.
* **Making the dialogs modeless.** Every caller reads the ``exec()`` result
  to decide whether to apply the settings. Changing that is a different and
  much larger piece of work.
* **A platform check.** The flags are harmless on Windows and macOS -- a
  dialog with a normal window type is a normal window there too -- and a
  conditional would mean the layout differs by platform for no gain.

--------------------------------------------------------------------------
TWO: A WINDOW THE USER CAN RESIZE
--------------------------------------------------------------------------

Asked for as "i should be able to resize all settings windows horizontally
and verticaly", and nothing was stopping it in the way anyone expected.
There is one ``setFixedWidth`` in the application and it is not on a
settings window; no dialog sets a layout size constraint; the WM glue above
was already fixed.

THE FLOOR IS THE CONTENT. Qt sets a window's minimum size from its
layout's total minimum, so a dialog cannot be dragged in past the point
where every field is fully visible. Measured on ``PictureSettingsDialog``:
size 645x318, minimumSize 645x318, a resize to 300x200 answered 645x318. It
grew and could not shrink, and with no size grip there was not even a
handle saying it should be possible.

Three steps, in the order of what they cost, and the cheap one is enough
for a third of them:

* an explicit minimum the dialog set on ITSELF comes off first
  (:func:`drop_the_explicit_floor`) -- six dialogs have one, and it
  outranks anything the contents say;
* the contents move into a scroll area only if the dialog is STILL stuck
  (:func:`let_the_content_scroll`). A window smaller than its contents can
  only mean a window showing part of them;
* a size grip goes on every one of them (:func:`give_it_a_size_grip`), so
  the affordance is visible on a frameless window that has no corner drawn.

WHAT IS NOT TOUCHED, and each is a decision rather than an omission: Qt's
own dialogs, which lay out their own internals; a dialog that is a message
-- a sentence and two buttons has nothing to scroll and no room to give;
and a dialog whose contents are already a scroll area of its own, which
would gain a second set of scroll bars around the first.

AND IT OPENS AT THE SIZE IT ALWAYS DID (:func:`open_at_its_natural_size`).
The floor was load-bearing for that, and taking it away silently made wide
dialogs open narrow. Checked against every dialog in the sweep.
"""

LOG = logging.getLogger("spacr.qt.dialogs")


[docs] def detach_from_window_manager(dialog: Any) -> Any: """Prevent a window manager from attaching ``dialog`` to its parent. Call this before ``exec()`` when a modal dialog must remain independently movable. Repeated calls and dialogs without parents are supported. :param dialog: the ``QDialog`` (or any ``QWidget`` shown as a window). :returns: the same object, so it can be used inline. """ try: from PySide6.QtCore import Qt except Exception: return dialog try: flags = dialog.windowFlags() dialog.setWindowFlags((flags & ~Qt.WindowType.Dialog & ~Qt.WindowType.WindowStaysOnTopHint) | Qt.WindowType.Window) except Exception: pass return dialog
#: Marks a dialog this module has already taken in hand, so a dialog shown, #: hidden and shown again is not rebuilt on every show. RESIZABLE = "spacrMadeResizable" #: Marks a dialog the application filter has already detached. #: #: ON THE DIALOG, NOT IN A SET OF `id()`s, and the difference is whether a #: settings window opens attached the SECOND time it is opened. Every modal #: dialog in spaCR is a temporary -- `PreferencesDialog(...).exec()` builds #: it, runs it and drops it -- and CPython hands the address of a released #: object straight back to the next one of the same size: measured here, #: twenty out of twenty new dialogs landed on the address of the dialog #: just released, and three hundred opened and closed in a row occupied #: five distinct addresses between them. An `id()` remembered in the filter #: therefore matched a dialog it had never seen, which was skipped and left #: glued to the main window -- the complaint this filter exists to answer. #: A mark on the object dies with the object. FILTER_DETACHED = "spacrDetachedByFilter" #: Marks a dialog whose contents were moved into a scroll area. Separate #: from :data:`RESIZABLE` because only some of them need it: a dialog with #: a list or a text box in it already has slack, and the wrap is the #: expensive half of this. SCROLLS = "spacrContentScrolls" #: Set on a dialog that must keep the layout its author gave it. Nothing in #: spaCR sets it today; it is the same escape hatch ``spacrNoGlass`` is, #: for the next dialog whose contents cannot survive being moved. NO_SCROLL = "spacrNoScroll" #: Marks a dialog that has not yet been given its opening size back, and #: holds the floor it used to have. See :func:`open_at_its_natural_size`. OPENS_AT = "spacrOpensAt" #: How much of each side a settings window has to be able to lose before #: this leaves its layout alone. #: #: A THIRD. The question a threshold answers is not "did the number #: change" but "can the user put this window where they want it", and a #: window that gives back fifty pixels answers no -- `SettingsAdvisorDialog` #: measured 900 wide with a floor of 847, `UmapDisplaySettings` 234 with a #: floor of 214. Both look resizable to a test that only asks whether the #: floor is lower than the ceiling, and neither is resizable to a person. SLACK = 1.0 / 3.0 #: The smallest a scrolled dialog's contents area may be made, per side. #: #: NOT ZERO, and not the content's own minimum either. Zero lets a window #: be dragged down to nothing at all; the content's minimum is the floor #: this exists to remove. This is a floor the user cannot get stuck at: #: wide enough for the scroll bar and a readable strip beside it. Eighty is #: also deliberately below the natural content height of the application's #: smallest real settings dialog. At 120, Qt 6.11 on Ubuntu made #: ``_WellChoice`` open exactly at this floor (120 plus its margins), so the #: supposedly resizable window could not become one pixel shorter. SMALLEST = 80 #: Width moved from the dialog's left margin into the wrapped form. The #: controls stay at exactly the same screen coordinate and the opening size #: is unchanged, but the holder retains a predictable piece of empty surface #: from which a frameless window can be dragged. Depending on font metrics, #: the fields could otherwise consume every pixel of the holder. DRAG_HANDLE = 6 def _field_types(): """The widget classes that count as a field, and the ones that nest. :returns: ``(fields, nesting)`` -- what to count, and what to look inside for children that must NOT be counted again. """ from PySide6.QtWidgets import ( QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QLineEdit, QPlainTextEdit, QRadioButton, QSlider, QTextEdit, ) fields = (QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QLineEdit, QPlainTextEdit, QRadioButton, QSlider, QTextEdit) nesting = (QAbstractItemView, QAbstractSpinBox, QComboBox, QLineEdit, QPlainTextEdit, QTextEdit) return fields, nesting
[docs] def fields_in(dialog) -> int: """Return the number of independent data-entry fields in ``dialog``. Editors nested inside another field, such as a spin box's line editor, are not counted separately. Push buttons are not considered fields. :param dialog: the widget whose descendant widgets are searched for data-entry fields. """ from PySide6.QtWidgets import QWidget fields, nesting = _field_types() found = 0 for child in dialog.findChildren(QWidget): if not isinstance(child, fields): continue parent = child.parentWidget() owned = False while parent is not None and parent is not dialog: if isinstance(parent, nesting): owned = True break parent = parent.parentWidget() if not owned: found += 1 return found
[docs] def more_than_a_message(dialog) -> bool: """Return whether ``dialog`` contains resizable interactive content. A dialog qualifies when it contains at least one data-entry field or an existing scroll area. Simple confirmation and message dialogs do not. :param dialog: the widget searched for data-entry fields and scroll areas. """ from PySide6.QtWidgets import QAbstractScrollArea if fields_in(dialog): return True return bool(dialog.findChildren(QAbstractScrollArea))
[docs] def window_floor(dialog): """Return the explicit minimum size enforced for ``dialog``. Unlike ``minimumSizeHint()``, this value directly constrains manual and initial window resizing. :param dialog: the widget whose ``minimumSize()`` is returned. """ return dialog.minimumSize()
[docs] def content_floor(dialog): """Return the layout's minimum size for rendering without clipping. :param dialog: the widget whose ``minimumSizeHint()`` is returned. """ return dialog.minimumSizeHint()
[docs] def is_stuck_at_its_contents(dialog) -> bool: """Return whether content prevents useful shrinking in either dimension. :data:`SLACK` defines the minimum proportional reduction required for a dialog to be considered usefully resizable. Call this before changing its layout or minimum size. :param dialog: the dialog to measure; its size hint (at least its explicit minimum) is compared with its layout's minimum size hint. """ opening = dialog.sizeHint().expandedTo(window_floor(dialog)) content = content_floor(dialog) return (content.width() > opening.width() * (1.0 - SLACK) or content.height() > opening.height() * (1.0 - SLACK))
def _already_scrolls(layout) -> bool: """Whether ``layout`` holds nothing but a scroll area of the dialog's own. Such a dialog scrolls already, and wrapping it would put a second set of scroll bars around the first. """ from PySide6.QtWidgets import QAbstractScrollArea widgets = [layout.itemAt(i).widget() for i in range(layout.count())] widgets = [w for w in widgets if w is not None] return len(widgets) == 1 and isinstance(widgets[0], QAbstractScrollArea) #: Built on first use, because this module is imported where PySide6 is #: not -- see the guarded import in `detach_from_window_manager`. _SCROLL_CLASS = None def _form_scroll_class(): """The scroll area a wrapped dialog gets. Defined once, on first use.""" global _SCROLL_CLASS if _SCROLL_CLASS is not None: return _SCROLL_CLASS from PySide6.QtCore import QSize from PySide6.QtWidgets import QScrollArea class _FormScroll(QScrollArea): """A scroll area that keeps the dialog's size and drops its floor. BOTH HINTS ARE OVERRIDDEN, and each for a measured reason. `QScrollArea.sizeHint` is its widget's hint **bounded to 36x24 font heights** -- about 576x384 here. Wrapping a 645-pixel-wide settings dialog in a stock scroll area therefore makes it want to open 70 pixels narrower than the form inside it, with a scroll bar already showing on a window nobody has touched. Passing the inner widget's hint through keeps the window the size it was. `QAbstractScrollArea.minimumSizeHint` is built from its scroll bars, which is small -- but small is not the same as *known*, and the floor is the whole point of this class. :data:`SMALLEST` says what it is. """ def sizeHint(self): # noqa: N802 - Qt naming """The inner form's own width, so the dialog opens wide enough to read.""" inner = self.widget() if inner is None: return super().sizeHint() frame = 2 * self.frameWidth() hint = inner.sizeHint() return QSize(hint.width() + frame, hint.height() + frame) def minimumSizeHint(self): # noqa: N802 - Qt naming """A floor small enough that the dialog can always be shrunk.""" return QSize(SMALLEST, SMALLEST) _SCROLL_CLASS = _FormScroll return _SCROLL_CLASS _DRAG_CLASS = None def _drag_class(): """The filter that keeps a wrapped dialog draggable. Defined once.""" global _DRAG_CLASS if _DRAG_CLASS is not None: return _DRAG_CLASS from PySide6.QtCore import QEvent, QObject, Qt class _DragTheWindowByTheForm(QObject): """Move the window by dragging the empty space between its fields. WITHOUT THIS, WRAPPING TAKES THE HANDLE AWAY. A glassed dialog is frameless -- there is no title bar -- and `spacr.qt.widgets.glass._DragByBackground` gives that back by starting a drag wherever `dialog.childAt(...)` answers None, which is the empty background between the controls. Putting the contents in a scroll area makes that answer the scroll area for the whole window, so every press lands on "a child" and the window stops moving. The empty space did not go anywhere -- it belongs to the holder widget now, so the same rule is applied there and the handle is back where it was. """ def __init__(self, holder): """Move the window when its holder's empty space is dragged. :param holder: the widget that owns the empty space, and the QObject parent. NOT the window and not the scroll area: putting the contents in a scroll area moved the empty space to this holder, which is the whole point of the class. """ super().__init__(holder) self._holder = holder self._grab = None holder.installEventFilter(self) def eventFilter(self, watched, event): # noqa: N802 - Qt naming """Move the window when its holder's empty space is dragged.""" holder = getattr(self, "_holder", None) if holder is None or watched is not holder: return False try: kind = event.type() if (kind == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton): if holder.childAt(event.position().toPoint()) is not None: return False window = holder.window() self._grab = (event.globalPosition().toPoint() - window.frameGeometry().topLeft()) return True if kind == QEvent.Type.MouseMove and self._grab is not None: holder.window().move( event.globalPosition().toPoint() - self._grab) return True if (kind == QEvent.Type.MouseButtonRelease and self._grab is not None): self._grab = None return True except Exception: # noqa: BLE001 LOG.debug("a drag from the form went wrong", exc_info=True) self._grab = None return False _DRAG_CLASS = _DragTheWindowByTheForm return _DRAG_CLASS def _qts_own_dialogs(): """The Qt dialogs that lay out their own internals. Not ours to move. BY TYPE, NOT BY MODULE, and the difference is Preferences. The first draft asked whether the class came from a ``spacr`` module, which reads well and excludes the most important settings window in the application: ``PreferencesDialog`` was a factory that built a PLAIN ``QDialog`` and filled it, so its class was Qt's while every one of its thirty controls was spaCR's. Seven more windows are built the same way -- the shortcut sheet, the settings diff, the sweep panel's editor, the montage view's and the drag-and-drop prompts. What actually must be left alone is a dialog whose CONTENTS are Qt's: a file chooser, a message box, a colour or font picker, an input prompt, a progress dialog, a wizard. Each of those arranges its own children and documents behaviour that moving them would break. """ from PySide6.QtWidgets import ( QColorDialog, QErrorMessage, QFileDialog, QFontDialog, QInputDialog, QMessageBox, QProgressDialog, QWizard, ) return (QColorDialog, QErrorMessage, QFileDialog, QFontDialog, QInputDialog, QMessageBox, QProgressDialog, QWizard)
[docs] def wants_resizing(dialog) -> bool: """Return whether spaCR should add standardized resizing to ``dialog``. The dialog must contain interactive content and must not be a specialized Qt dialog, explicitly exempt, or already processed. :param dialog: the widget to judge. Only a :class:`QDialog` that is not one of Qt's own specialised dialogs, is not flagged exempt or already resizable, and has a non-empty layout can qualify. """ from PySide6.QtWidgets import QDialog if not isinstance(dialog, QDialog): return False if isinstance(dialog, _qts_own_dialogs()): return False if dialog.property(NO_SCROLL) or dialog.property(RESIZABLE): return False layout = dialog.layout() if layout is None or layout.count() == 0: return False return more_than_a_message(dialog)
[docs] def drop_the_explicit_floor(dialog) -> bool: """Clear an explicit minimum size and report whether one was present. The original value can be retained separately and passed to :func:`open_at_its_natural_size` so the initial window size is preserved. :param dialog: the dialog whose explicit minimum size is reset to zero when one is set. """ from PySide6.QtCore import QSize explicit = dialog.minimumSize() if explicit == QSize(0, 0): return False dialog.setMinimumSize(0, 0) return True
[docs] def let_the_content_scroll(dialog) -> bool: """Move a dialog's layout into a resizable scroll area. The existing layout and widgets are transferred to a transparent holder, while the original outer margins remain on the dialog. The holder expands with the viewport, and content exceeding the current window size scrolls. :param dialog: the dialog whose existing layout is moved into a scroll area; it must already have a layout. It is flagged as scrolling afterwards. :returns: ``True`` after the content has been moved. """ from PySide6.QtWidgets import QFrame, QVBoxLayout, QWidget from .theme import make_transparent layout = dialog.layout() margins = layout.getContentsMargins() handle = min(DRAG_HANDLE, margins[0]) layout.setContentsMargins(handle, 0, 0, 0) holder = QWidget() holder.setLayout(layout) scroll = _form_scroll_class()(dialog) scroll.setFrameShape(QFrame.Shape.NoFrame) scroll.setWidgetResizable(True) scroll.setWidget(holder) outer = QVBoxLayout(dialog) outer.setContentsMargins(margins[0] - handle, *margins[1:]) outer.setSpacing(0) outer.addWidget(scroll) make_transparent(scroll, holder) _drag_class()(holder) dialog.setProperty(SCROLLS, True) return True
[docs] def open_at_its_natural_size(dialog) -> bool: """Apply a stored natural opening size once and clear the stored value. This is called after Qt's initial size adjustment so removing a minimum size does not cause a dialog to open smaller than its original layout. Later shows retain the size selected by the user. :param dialog: the dialog whose stored opening size, if any, is applied (never shrinking its current size) and then cleared. :returns: ``True`` if a stored size was applied. """ floor = dialog.property(OPENS_AT) dialog.setProperty(OPENS_AT, None) if floor is None: return False dialog.resize(dialog.size().expandedTo(floor)) return True
[docs] def give_it_a_size_grip(dialog) -> bool: """Enable a transparent corner size grip on ``dialog``. :param dialog: the :class:`QDialog` whose size grip is enabled and made transparent. :returns: ``True`` if the dialog contains a size-grip widget. """ from PySide6.QtWidgets import QSizeGrip from .theme import make_transparent dialog.setSizeGripEnabled(True) grips = dialog.findChildren(QSizeGrip) make_transparent(*grips) return bool(grips)
[docs] def make_the_window_resizable(dialog) -> bool: """Apply standardized resizing behavior to one eligible dialog. Explicit minimum sizes are cleared first. If the content still prevents useful shrinking and does not already scroll, it is moved into a scroll area. A size grip is then enabled. Repeated calls leave the dialog unchanged. :param dialog: the widget to process; anything :func:`wants_resizing` rejects is left untouched and returns ``False``. :returns: ``True`` if the dialog was eligible and processed. """ if not wants_resizing(dialog): return False dialog.setProperty(RESIZABLE, True) try: from PySide6.QtWidgets import QWidget for child in dialog.findChildren(QWidget): child.ensurePolished() dialog.layout().activate() floor = window_floor(dialog) stuck = is_stuck_at_its_contents(dialog) lowered = drop_the_explicit_floor(dialog) if stuck and not _already_scrolls(dialog.layout()): lowered = let_the_content_scroll(dialog) or lowered if lowered: dialog.setProperty(OPENS_AT, floor) give_it_a_size_grip(dialog) return True except Exception: # noqa: BLE001 LOG.debug("could not make a dialog resizable", exc_info=True) return False
#: The property `spacr.qt.widgets.glass` sets on a dialog whose flags it #: has already rewritten. Named here rather than imported, because #: importing the widgets package from this one would be a cycle. _GLASS_DETACHED = "spacrDetached" class _DetachEveryDialog: """Application-wide filter: every dialog is a window the user can drag, and one the user can resize. Asked 2026-08-21: "the settings for your data settings window should be movable without moving the main window. this should be tru of all settings windows or any popup window from spacr." ONE PLACE, NOT ONE CALL SITE PER DIALOG. :func:`detach_from_window_manager` already existed and was being called from six files while more than twenty others opened dialogs without it -- which is what "all settings windows" cannot be built out of. A rule applied by hand is a rule that holds until the next dialog is written. HOOKED ON `Polish`, WHICH IS THE ONLY MOMENT THAT WORKS. `setWindowFlags` on a widget that is already visible destroys and recreates its native window, so Qt hides it -- detaching on `Show` makes the dialog flash or vanish. `Polish` is delivered during the first show sequence and BEFORE the window is mapped, so the flags are already right when it appears. Measured order on PySide6: WinIdChange, Polish, Show, ShowToParent. Each dialog is detached ONCE, and the mark that says so is :data:`FILTER_DETACHED`, set on the dialog itself. The flag change is idempotent, but doing it repeatedly on a dialog that is shown, hidden and shown again would recreate the native window every time and lose its position -- which is the thing this exists to protect. AND THE RESIZING RIDES ON THE SAME EVENT, for the same reason there is one filter and not one call per dialog. It is NOT held to Polish, though: moving a layout into a scroll area is an ordinary layout change rather than a window recreation, so Show is a safe fallback for a dialog that builds its form after its first polish. The size a dialog OPENS at is restored on Show and only on Show -- see :func:`open_at_its_natural_size`, where the measurement is. THE TWO MOMENTS ARE RESOLVED ONCE, HERE, AND NOT PER EVENT. This filter is on the QApplication, so its first line runs for every event in the process -- 94,431 of them during one Regression open. It began by importing ``QEvent`` and ``QDialog`` and reading ``event.type()`` up to three times, every one of those times; it now imports at construction and reads the type once. Qt is still imported inside the function that needs it everywhere else in this module, which is the rule here, and a filter is only ever constructed with an application running. """ def __init__(self): """Resolve the enum members and the class this filter compares to.""" from PySide6.QtCore import QEvent from PySide6.QtWidgets import QDialog self._polish = QEvent.Type.Polish self._show = QEvent.Type.Show self.moments = frozenset({QEvent.Type.Polish, QEvent.Type.Show}) self._dialog = QDialog def eventFilter(self, obj, event): # noqa: N802 - Qt naming """Detach every dialog from the window manager, and make it resizable. Skipped when the glass installer already did it: that detaches and goes frameless in ONE flags change, and repeating it recreates the native window a second time -- on some window managers what comes back has square opaque corners behind the rounded card. The flags may only be rewritten before the window is mapped, but moving the contents into a scroll area is an ordinary layout change and is safe on Show too, which matters for a dialog that builds its form after its first polish and would otherwise never be reached. :param obj: the object the event is for. :param event: the event. :returns: ``False`` -- observed, never consumed. """ try: kind = event.type() if kind not in self.moments: return False polished = kind == self._polish if polished and isinstance(obj, self._dialog): if obj.property(_GLASS_DETACHED): obj.setProperty(FILTER_DETACHED, True) elif not obj.property(FILTER_DETACHED): obj.setProperty(FILTER_DETACHED, True) detach_from_window_manager(obj) if wants_resizing(obj): make_the_window_resizable(obj) if kind == self._show and obj.property(OPENS_AT) is not None: open_at_its_natural_size(obj) except Exception: pass return False #: Kept alive for the life of the application. An event filter that is #: garbage collected stops filtering, silently. _DETACHER = None #: WHICH application it was installed on. Tracking the filter alone was a #: bug: a filter belongs to one `QApplication`, so when the application is #: torn down and rebuilt -- which every Qt test session does, and which a #: relaunch inside one process does too -- the filter goes with it while #: `_DETACHER` stayed non-None. The next call then reported "already #: installed" and returned, leaving nothing filtering and nothing saying so. #: #: Found by the suite: two of this module's own tests passed alone and #: failed in a full run, which is the signature of state surviving a #: teardown it should not have. _DETACHED_APP = None
[docs] def detach_all_dialogs(app) -> bool: """Install the application-wide detacher. Returns True if it installed. Idempotent PER APPLICATION: calling it twice on the same app leaves one filter, and calling it on a NEW app installs again, because the old filter died with the old app. :param app: the :class:`QApplication` that receives the event filter. ``None``, or the application already holding the filter, returns ``False`` without installing. """ global _DETACHER, _DETACHED_APP if app is None: return False if _DETACHER is not None and _DETACHED_APP is app: return False try: from PySide6.QtCore import QObject class _Filter(QObject): """Applies the detacher to every dialog the application opens. An application-wide event filter rather than a per-dialog hook: dialogs are constructed all over the package, including inside Qt itself, and any hook a caller has to remember will be missed by the one that matters. Defined here rather than at module scope so it holds the detacher by closure and there is no second place to keep them in step. """ def __init__(self): """Wrap the detacher this filter applies to every dialog.""" super().__init__() self._inner = _DetachEveryDialog() self._moments = self._inner.moments def eventFilter(self, obj, event): # noqa: N802 - Qt naming """Forward the two events the detacher acts on, and no others. THE TEST IS HERE AS WELL AS INSIDE because this method is what Qt calls for every event in the application, and a Python call that returns False is not free at 94,431 of them per module open. The detacher makes the same test for anyone calling it directly. """ if event.type() not in self._moments: return False return self._inner.eventFilter(obj, event) from .gil_priority import _watch_application_events _DETACHER = _Filter() _watch_application_events(app, _DETACHER, _DETACHER._moments) _DETACHED_APP = app return True except Exception: _DETACHER = None _DETACHED_APP = None return False