Source code for spacr.qt.widgets.feature_dictionary

"""The in-app feature dictionary: look a measurement up without leaving spaCR.

A finished run writes hundreds of columns per object with names like
``cell_channel_1_percentile_75``. :mod:`spacr.feature_dict` has known what
those mean for a while, but only as an *export*: you could write a markdown
file describing a database, and that was the whole interface. So the moment a
user actually needs the answer — reading a results table, staring at a
regression coefficient, hovering a UMAP axis — they have to leave the app.

This module is the missing half:

:class:`FeatureDictionaryPanel`
    the searchable panel. Search by column name, by substring, or by *idea*
    ("intensity", "texture", "shape", "distance", "how big", "blurry"), filter
    by object type and concept, and read the definition, the unit, which
    objects the feature exists for, which channel it applies to and which
    module computes it.

:class:`FeatureDictionaryDialog` / :func:`open_feature_dictionary`
    the same panel as a non-modal window, optionally opened straight onto one
    column.

:func:`register`
    puts the panel in the app registry (Tools section) and its QSS in the
    theme, through the ``register_app`` / ``register_widget_qss`` seams —
    this module owns its own registration and edits neither ``app.py`` nor
    ``theme.py``.

:func:`install_window_hooks`
    the two reach-me-from-where-I-am routes: a **Help ▸ Feature Dictionary…**
    action, and a **"What is this?"** item on the context menu of any results
    table in the app.

The context-menu route is deliberately an application-level event filter
rather than an edit to each table screen. There are eleven table-bearing
screens and none of them claims a context menu today, so a filter reaches all
of them — including the ones built lazily, long after this hook ran — and
reaches any table added later for free. It stands aside for any widget that
has claimed its own context menu (``CustomContextMenu`` / ``ActionsContextMenu``),
so adopting one later silently takes precedence over this.
"""
from __future__ import annotations

import logging
from typing import Optional

from PySide6.QtCore import QEvent, QObject, Qt, Signal
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
    QAbstractItemView,
    QApplication,
    QComboBox,
    QSplitter,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QLineEdit,
    QListWidget,
    QListWidgetItem,
    QMainWindow,
    QMenu,
    QPushButton,
    QTextBrowser,
    QVBoxLayout,
    QWidget,
)

from ...feature_dict import (
    CHANNEL_PAIR,
    CHANNEL_SINGLE,
    CONCEPTS,
    FEATURE_FAMILIES,
    OBJECT_TYPES,
    FeatureDoc,
    doc_for,
    parse_column,
    search_features,
)

from ...object_roles import ORGANELLE_ROLES
from ...schema import object_type_summary
from ..gil_priority import (_stop_watching_application_events,
                            _watch_application_events)
from ..i18n import tr
from .workflow_diagram import DiagramDialog

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

#: App registry key. Load-bearing once shipped — saved user state keys off it.
APP_KEY = "feature_dict"
APP_NAME = "Feature Dictionary"
APP_DESC = "Search definitions of measured features by name or concept"
APP_CLI_NOTE = (
    "Feature Dictionary is an interactive browser; open it from spaCR's "
    "Help menu or call spacr.feature_dict from Python."
)
APP_NAME_TRANSLATIONS = (
    "Egenskapsordlista", "Merkmalswörterbuch",
    "Diccionario de características", "特征词典",
    "Dicionário de características", "विशेषता शब्दकोश", "특성 사전",
    "Eiginleikaorðabók", "Dictionnaire des caractéristiques",
)

#: ``objectName`` of the panel, and the name its QSS block registers under.
OBJECT_NAME = "FeatureDictionary"

#: Label of the Help menu action. Kept verbatim: `spacr/qt/i18n.py` keys its
#: catalog on the English string.
HELP_ACTION_TEXT = "Feature Dictionary…"
#: Label of the table context-menu action.
CONTEXT_ACTION_TEXT = "What is this?"

_PLACEHOLDER = ("Search a column name, or an idea: intensity, texture, "
                "shape, distance…")

_ANY_CONCEPT = "Any concept"
_ANY_OBJECT = "Any object"

#: How many columns of a table have to be recognisable before the context
#: menu offers to explain an *unrecognised* one. Below this the table is not a
#: measurements table and the menu would be noise on somebody else's grid.
_MEASUREMENT_TABLE_THRESHOLD = 3



def _escape(text: object) -> str:
    """Minimal HTML escape for the detail pane."""
    return (str(text)
            .replace("&", "&")
            .replace("<", "&lt;")
            .replace(">", "&gt;"))


def _channel_sentence(scope: str) -> str:
    """Say, in words, how channels enter a feature."""
    if scope == CHANNEL_SINGLE:
        return ("One column per channel — the <code>channel_&lt;i&gt;</code> "
                "in the name is which channel this number was measured in.")
    if scope == CHANNEL_PAIR:
        return ("One column per channel PAIR — the two "
                "<code>channel_&lt;i&gt;</code> infixes are the two channels "
                "being compared.")
    return "No channel: this number does not depend on what was imaged."


def _objects_sentence(doc: FeatureDoc) -> str:
    """Say which object types actually have this feature."""
    if not doc.object_types:
        if doc.kind == "metadata":
            return "Not a per-object measurement."
        return ("Not written for any object type by a standard run — see the "
                "note below.")
    listed = object_type_summary(doc.object_types)
    missing = [o for o in OBJECT_TYPES if o not in doc.object_types]
    if not missing:
        return f"Written for every object type ({listed})."
    return (f"Written for {listed} — and NOT for "
            f"{object_type_summary(missing)}.")


def _doc_html(doc: FeatureDoc, entry=None) -> str:
    """Render one feature as the detail pane's HTML.

    :param doc: the feature.
    :param entry: optional :class:`spacr.feature_dict.FeatureEntry` for one
        concrete column, which pins the object type, the channel and the unit
        to what that column actually is.
    """
    rows: list[str] = []

    def field(name: str, value: object) -> None:
        """Add one field to the entry, skipping empty values."""
        if value in (None, "", ()):
            return
        rows.append(
            f"<tr><td style='padding-right:12px; vertical-align:top;'>"
            f"<b>{_escape(name)}</b></td><td>{value}</td></tr>")

    heading = _escape(entry.column if entry is not None else doc.title)
    parts = [f"<h3 style='margin-bottom:2px;'>{heading}</h3>"]
    if entry is not None and entry.key:
        parts.append(
            f"<p style='margin-top:0;'><i>an instance of "
            f"<b>{_escape(doc.title)}</b></i></p>")

    description = doc.description
    if entry is not None and entry.description:
        description = entry.description
    if description:
        parts.append(f"<p>{_escape(description)}</p>")
    else:
        parts.append("<p><i>No definition — see the note below.</i></p>")

    unit = doc.unit
    if entry is not None and entry.unit:
        unit = entry.unit
    field("Unit", _escape(unit) if unit else "<i>none (an identifier)</i>")
    field("Family", f"{_escape(doc.family)} — "
                    f"{_escape(FEATURE_FAMILIES.get(doc.family, ''))}")
    field("Objects", _escape(_objects_sentence(doc)))
    if (entry is not None and entry.object_type and doc.family != "meta"
            and not doc.key.startswith("organelle_summary_")):
        field("This column", f"the {_escape(entry.object_type)} table")
    channel_text = _channel_sentence(doc.channel_scope)
    if entry is not None and entry.channel is not None:
        which = f"channel {entry.channel}"
        if entry.channel_2 is not None:
            which = f"channels {entry.channel} and {entry.channel_2}"
        channel_text = f"This column is {which}. " + channel_text
    field("Channel", channel_text)
    field("Module", f"<code>{_escape(doc.module)}</code>")
    field("Computed by", f"<code>{_escape(doc.computed_by)}</code>")
    if doc.written_when:
        field("Written when", _escape(doc.written_when))
    if doc.concepts:
        field("Concepts", _escape(", ".join(doc.concepts)))
    if doc.examples:
        field("Example column",
              "<br>".join(f"<code>{_escape(x)}</code>" for x in doc.examples))

    notes = doc.notes
    if entry is not None and entry.notes:
        notes = entry.notes
    if notes:
        field("Note", _escape(notes))

    parts.append("<table>" + "".join(rows) + "</table>")
    return "".join(parts)


def _unknown_html(column: str) -> str:
    """The honest answer for a column the dictionary cannot explain."""
    return (
        f"<h3 style='margin-bottom:2px;'>{_escape(column)}</h3>"
        "<p><b>Not in the dictionary.</b> This column's name does not parse "
        "as a spaCR measurement and no curated entry matches it, so spaCR "
        "does not know what it means. It was <i>not</i> guessed at from the "
        "name.</p>"
        "<p>Likely explanations: a column added by hand or by another tool, "
        "a user-supplied custom feature (see <code>spacr.custom_features"
        "</code>), an annotation column named by whoever ran the annotation "
        "app, or a column from a spaCR version older than this one.</p>")



[docs] class FeatureDictionaryPanel(QWidget): """Searchable dictionary of every measurement spaCR writes. Constructing it costs one :func:`spacr.feature_dict.search_features` call and touches no database, so it is cheap to embed and testable without an event loop. :param parent: optional Qt parent. :param column: optional column name to open on. """ #: Emitted with the curated key whenever the selection changes. feature_selected = Signal(str) def __init__(self, parent: Optional[QWidget] = None, column: Optional[str] = None): """Build the feature dictionary panel. :param parent: parent widget, or ``None``. :param column: a measurement to open pinned to; ``None`` opens on the whole dictionary. """ super().__init__(parent) self.setObjectName(OBJECT_NAME) self._hits: list = [] #: The concrete column the detail pane is pinned to, if any. self._column: Optional[str] = None #: The feature the detail pane is SHOWING. Not derived from the list #: selection: a column always resolves, even to a feature the free-text #: search did not surface, and the pane shows it either way. self._doc: Optional[FeatureDoc] = None outer = QVBoxLayout(self) outer.setContentsMargins(12, 12, 12, 12) outer.setSpacing(8) blurb = QLabel( "Definitions for measurements produced by spaCR. Search by " "column name or by the biological or quantitative concept.") blurb.setObjectName("FeatureDictionaryBlurb") blurb.setWordWrap(True) outer.addWidget(blurb) controls = QHBoxLayout() controls.setSpacing(8) self._search = QLineEdit() self._search.setObjectName("FeatureDictionarySearch") self._search.setPlaceholderText(_PLACEHOLDER) self._search.setClearButtonEnabled(True) self._search.textChanged.connect(self._refresh) controls.addWidget(self._search, 1) self._concept = QComboBox() self._concept.setObjectName("FeatureDictionaryConcept") self._concept.addItem(_ANY_CONCEPT, None) for name, concept in CONCEPTS.items(): self._concept.addItem(name, name) self._concept.setItemData(self._concept.count() - 1, concept.gloss, Qt.ToolTipRole) self._concept.currentIndexChanged.connect(self._refresh) controls.addWidget(self._concept) self._object = QComboBox() self._object.setObjectName("FeatureDictionaryObject") self._object.addItem(_ANY_OBJECT, None) for obj in OBJECT_TYPES: if obj in ORGANELLE_ROLES and obj != "organelle": continue self._object.addItem(tr("Organelle") if obj == "organelle" else obj, obj) self._object.currentIndexChanged.connect(self._refresh) controls.addWidget(self._object) outer.addLayout(controls) body = QSplitter(Qt.Horizontal) body.setChildrenCollapsible(False) body.setHandleWidth(1) body.setStyleSheet("QSplitter::handle:horizontal { background: #168cff; }") self._list = QListWidget() self._list.setObjectName("FeatureDictionaryList") self._list.currentRowChanged.connect(self._on_row_changed) body.addWidget(self._list) self._detail = QTextBrowser() self._detail.setObjectName("FeatureDictionaryDetail") self._detail.setOpenExternalLinks(False) body.addWidget(self._detail) body.setStretchFactor(0, 2) body.setStretchFactor(1, 3) outer.addWidget(body, 1) self._status = QLabel("") self._status.setObjectName("FeatureDictionaryStatus") outer.addWidget(self._status) if column: self.show_column(column) else: self._refresh()
[docs] def set_query(self, text: str) -> None: """Type ``text`` into the search box and re-run the search. :param text: the search text; ``None`` is read as empty. Any column pinned by :meth:`show_column` is released. """ self._column = None text = str(text or "") if text == self._search.text(): self._refresh() else: self._search.setText(text)
[docs] def show_column(self, column: str) -> None: """Explain one concrete column name. Searches for it (so the list shows the feature and its neighbours) and pins the detail pane to *that column* — its object type, its channel, its resolved unit — rather than to the generic feature. A name the dictionary cannot explain is reported as unknown. It is never approximated to the nearest-looking entry. :param column: the measurement column name, e.g. as it appears in a table header; stripped, and ``None`` or empty is reported as not in the dictionary. """ column = str(column or "").strip() self._column = column or None entry = parse_column(column) if column else None self._search.blockSignals(True) self._search.setText(column) self._search.blockSignals(False) self._concept.setCurrentIndex(0) self._object.setCurrentIndex(0) self._refresh() if entry is None or entry.family == "unknown" or not entry.key: self._list.setCurrentRow(-1) self._doc = None self._detail.setHtml(_unknown_html(column)) self._status.setText( f"{column or '(no column)'} — not in the dictionary") return self._select_key(entry.key)
[docs] def current_doc(self) -> Optional[FeatureDoc]: """The feature the detail pane is showing, or ``None``.""" return self._doc
[docs] def result_keys(self) -> list[str]: """Curated keys currently listed, best match first.""" return [hit.doc.key for hit in self._hits]
[docs] def detail_text(self) -> str: """The detail pane's rendered text — what the user actually reads.""" return self._detail.toPlainText()
def _select_key(self, key: str) -> None: """Select the row holding ``key``, adding it if the search missed.""" for row, hit in enumerate(self._hits): if hit.doc.key == key: self._list.setCurrentRow(row) return doc = doc_for(key) if doc is not None: self._render(doc) def _refresh(self, *_args) -> None: """Re-run the search and repopulate the list.""" query = self._search.text() concept = self._concept.currentData() obj = self._object.currentData() try: self._hits = search_features( query, concept=concept, object_type=obj, limit=300) except Exception: LOG.exception("Feature search failed for %r", query) self._hits = [] self._list.blockSignals(True) self._list.clear() for hit in self._hits: doc = hit.doc where = object_type_summary(doc.object_types) if doc.object_types else doc.kind item = QListWidgetItem(f"{doc.title}\n{doc.family} · {where}") item.setData(Qt.UserRole, doc.key) item.setToolTip(doc.description or "No definition.") self._list.addItem(item) self._list.blockSignals(False) if self._hits: self._status.setText( f"{len(self._hits)} feature(s)" + (f" matching “{query}”" if query.strip() else "")) if self._column is None: self._list.setCurrentRow(0) else: self._status.setText( f"Nothing matches “{query}”. Try an idea instead of a name: " "intensity, texture, shape, distance, size.") self._doc = None self._detail.setHtml( "<p><i>No feature matches that search.</i></p>") def _on_row_changed(self, row: int) -> None: """Show the selected feature, unpinning the asked-about column if it moved. The detail pane is pinned to a concrete column only while the selection is still that column's feature -- once the user moves off it, the pane describes the feature in general rather than that one instance of it. :param row: the newly selected row; out of range does nothing. """ if not (0 <= row < len(self._hits)): return doc = self._hits[row].doc entry = None if self._column: candidate = parse_column(self._column) if candidate.key == doc.key: entry = candidate else: self._column = None self._render(doc, entry) def _render(self, doc: FeatureDoc, entry=None) -> None: """Render one feature into the detail pane and announce it. :param doc: the feature to describe. :param entry: the concrete column it was reached through, when there is one, so the pane can name the object and channel as well. """ self._doc = doc self._detail.setHtml(_doc_html(doc, entry)) self.feature_selected.emit(doc.key)
[docs] class FeatureDictionaryDialog(DiagramDialog): """:class:`FeatureDictionaryPanel` in a non-modal window. :param parent: parent widget. :param column: the measurement to open on. ``None`` opens on the whole dictionary rather than on a lookup nobody asked for. """ def __init__(self, parent: Optional[QWidget] = None, column: Optional[str] = None): """Wrap the dictionary panel in a non-modal window. :param parent: parent widget, or ``None``. :param column: the measurement to open on; ``None`` opens on the whole dictionary. """ super().__init__(parent) self.setObjectName("FeatureDictionaryDialog") self.setWindowTitle(APP_NAME) self.setModal(False) self.resize(1100, 760) layout = QVBoxLayout(self) layout.setContentsMargins(14, 14, 14, 14) self.panel = FeatureDictionaryPanel(self, column=column) layout.addWidget(self.panel, 1) footer = QHBoxLayout() footer.setContentsMargins(12, 0, 12, 12) footer.addStretch(1) close = QPushButton(tr("Close")) close.setObjectName("DangerButton") close.setProperty("buttonActionRole", "negative") close.clicked.connect(self.close) footer.addWidget(close) layout.addLayout(footer)
[docs] def show_column(self, column: str) -> None: """Forward to the panel. :param column: the measurement column name to explain, passed to :meth:`FeatureDictionaryPanel.show_column`. """ self.panel.show_column(column)
#: The one dialog, reused so repeated lookups do not stack windows. _DIALOG: Optional[FeatureDictionaryDialog] = None
[docs] def open_feature_dictionary(parent: Optional[QWidget] = None, column: Optional[str] = None ) -> FeatureDictionaryDialog: """Show the dictionary, optionally opened onto ``column``. Reuses a single window: looking up six columns in a row should leave one dictionary open, not six. """ global _DIALOG if _DIALOG is None: _DIALOG = FeatureDictionaryDialog(parent) dialog = _DIALOG _DIALOG.destroyed.connect( lambda *_args, closing=dialog: _forget_dialog(closing)) if column: _DIALOG.show_column(column) _DIALOG.show() _DIALOG.raise_() _DIALOG.activateWindow() return _DIALOG
def _forget_dialog(dialog: FeatureDictionaryDialog) -> None: """Drop the cache only when Qt destroyed the dialog it still names.""" global _DIALOG if _DIALOG is dialog: _DIALOG = None
[docs] def close_feature_dictionary() -> None: """Close and forget the shared dialog. Used by tests and by teardown.""" global _DIALOG if _DIALOG is not None: dialog, _DIALOG = _DIALOG, None dialog.close() dialog.deleteLater()
[docs] def make_screen(host=None) -> QWidget: """Screen factory for :func:`spacr.qt.app.register_app`.""" return FeatureDictionaryPanel()
def _panel_qss(palette: dict, opacity) -> str: """QSS block for the panel, rendered against the live theme palette.""" surface = palette["surface_alt"] return f""" QWidget#{OBJECT_NAME} {{ background: transparent; }} QWidget#{OBJECT_NAME} QLabel#FeatureDictionaryBlurb, QWidget#{OBJECT_NAME} QLabel#FeatureDictionaryStatus {{ color: {palette['fg_muted']}; }} QWidget#{OBJECT_NAME} QListWidget#FeatureDictionaryList {{ background: {surface}; border: 1px solid {palette['border_soft']}; border-radius: 8px; }} QWidget#{OBJECT_NAME} QTextBrowser#FeatureDictionaryDetail {{ background: {surface}; border: 1px solid {palette['border_soft']}; border-radius: 8px; padding: 8px; }} """
[docs] def register() -> bool: """Register the app row and the QSS block. Idempotent. Called from :func:`spacr.qt.run` before the main window is built, because the sidebar, the menu bar and Home all read the registry during ``MainWindow.__init__``. :returns: ``True`` when the app row is in the registry afterwards. """ ok = True try: from ..app import APPS, SECTION_TOOLS, STAGE_ALPHA, register_app if not any(row[0] == APP_KEY for row in APPS): register_app( APP_KEY, APP_NAME, APP_DESC, SECTION_TOOLS, factory=make_screen, stage=STAGE_ALPHA, translations=APP_NAME_TRANSLATIONS, api_module="feature_dict", cli_note=APP_CLI_NOTE, ) except Exception: LOG.exception("Could not register the Feature Dictionary app") ok = False try: from ..theme import register_widget_qss, widget_qss_names if OBJECT_NAME not in widget_qss_names(): register_widget_qss(OBJECT_NAME, _panel_qss) except Exception: LOG.exception("Could not register the Feature Dictionary QSS") return ok
def _find_menu(window: QMainWindow, title: str) -> Optional[QMenu]: """The window's menu-bar menu titled ``title``, ignoring ``&``. Found through ``findChildren`` rather than by walking the menu bar's actions and calling ``QAction.menu()``. That reading is the obvious one and it does not survive: the QMenu wrapper it returns is only valid while the QAction wrapper it came off is alive, so the menu went stale the moment the action list fell out of scope — "Internal C++ object (PySide6.QtWidgets.QMenu) already deleted" on the very next line, and, when the wrappers were kept alive to work around it, a segfault during the next event dispatch. ``findChildren`` hands back children the menu bar owns in C++, which stay valid for as long as the window does. """ try: bar = window.menuBar() if bar is None: return None menus = bar.findChildren(QMenu) except Exception: return None for menu in menus: try: if menu.title().replace("&", "") == title: return menu except RuntimeError: continue return None
[docs] def install_help_action(window: QMainWindow) -> Optional[QAction]: """Add **Feature Dictionary…** to the window's Help menu. Returns the action, or ``None`` when there is no Help menu (a bare QMainWindow in a test) or one is already installed. :param window: the main window whose **Help** menu gets the action; the action is inserted before the menu's first separator, or appended when it has none. """ menu = _find_menu(window, "Help") if menu is None: return None for act in menu.actions(): if act.text() == HELP_ACTION_TEXT: return None action = QAction(HELP_ACTION_TEXT, window) from ..menus import set_menu_role set_menu_role(action, "none") action.setStatusTip( "Look up what any measured feature means — its definition, its unit, " "which objects it exists for and which module computes it.") action.triggered.connect( lambda checked=False: open_feature_dictionary(window)) before = None for act in menu.actions(): if act.isSeparator(): before = act break if before is not None: menu.insertAction(before, action) else: menu.addAction(action) return action
[docs] def column_name_at(widget: QObject, pos) -> Optional[str]: """The name of the column under ``pos`` in ``widget``, or ``None``. Handles both halves of the gesture: a right-click on a header section and a right-click on a cell. :param widget: the widget the right-click landed on: a horizontal ``QHeaderView``, an item view, or an item view's viewport. Anything else gives ``None``. :param pos: the click position as a ``QPoint`` in ``widget``'s own coordinates. """ if isinstance(widget, QHeaderView): if widget.orientation() != Qt.Horizontal: return None index = widget.logicalIndexAt(pos) model = widget.model() if index < 0 or model is None: return None value = model.headerData(index, Qt.Horizontal, Qt.DisplayRole) return None if value is None else str(value) view = widget.parent() if not isinstance(widget, QAbstractItemView) else widget if not isinstance(view, QAbstractItemView): return None model = view.model() if model is None: return None viewport = view.viewport() local_pos = (viewport.mapFrom(view, pos) if widget is view else pos) index = view.indexAt(local_pos) if not index.isValid(): return None value = model.headerData(index.column(), Qt.Horizontal, Qt.DisplayRole) return None if value is None else str(value)
def _table_looks_measured(model) -> bool: """Whether enough of a model's columns are spaCR measurements.""" if model is None: return False recognised = 0 for col in range(min(model.columnCount(), 40)): value = model.headerData(col, Qt.Horizontal, Qt.DisplayRole) if value is None: continue if parse_column(str(value)).family != "unknown": recognised += 1 if recognised >= _MEASUREMENT_TABLE_THRESHOLD: return True return False def _model_of(widget: QObject): """The item model behind a header, a view or a viewport.""" if isinstance(widget, (QHeaderView, QAbstractItemView)): return widget.model() parent = widget.parent() return parent.model() if isinstance(parent, QAbstractItemView) else None def _menu_family(widget: QObject) -> list[QObject]: """``widget`` plus everything a context-menu event passes through. A QContextMenuEvent that the header does not accept is PROPAGATED to the table behind it, so checking only the object the filter was handed lets a header with its own menu be answered by this one on the second pass — which is precisely the case the guard exists to prevent. """ family = [widget, widget.parent()] view = widget if isinstance(widget, QAbstractItemView) else widget.parent() if isinstance(view, QAbstractItemView): family.append(view) for getter in ("horizontalHeader", "viewport"): member = getattr(view, getter, None) if callable(member): try: family.append(member()) except Exception: pass return [obj for obj in family if obj is not None] def _claims_own_menu(widget: QObject) -> bool: """Whether this table, or any part of it, has claimed its own menu.""" claimed = (Qt.ContextMenuPolicy.CustomContextMenu, Qt.ContextMenuPolicy.ActionsContextMenu) for candidate in _menu_family(widget): policy = getattr(candidate, "contextMenuPolicy", None) if callable(policy) and policy() in claimed: return True return False def _default_menu_runner(menu: QMenu, global_pos) -> None: """Show a context menu at ``global_pos`` and block until it closes.""" menu.exec(global_pos) #: How the context menu is shown. Replaced by :func:`set_menu_runner` so a #: headless test can drive the whole gesture without entering a modal event #: loop. Injected rather than monkey-patched: ``QMenu.exec`` is a C++ slot, a #: test that rebinds it on the class does not intercept the call from inside #: the filter, and the run simply hangs on a menu nobody can click. _MENU_RUNNER = _default_menu_runner
[docs] def set_menu_runner(runner) -> None: """Replace the context-menu runner. ``None`` restores the default. :param runner: a callable ``runner(menu, global_pos)`` that shows the ``QMenu`` at that global position, or ``None`` for the default, which calls ``menu.exec``. """ global _MENU_RUNNER _MENU_RUNNER = runner or _default_menu_runner
try: from shiboken6 import isValid as _SHIBOKEN_IS_VALID except Exception: # noqa: BLE001 _SHIBOKEN_IS_VALID = None def _still_alive(wrapped) -> bool: """Whether a PySide wrapper still owns a live C++ object. True when the question cannot be asked -- a plain Python object, or a shiboken without `isValid` -- because refusing an event that is perfectly fine would break the feature this module exists for. """ if wrapped is None: return False if _SHIBOKEN_IS_VALID is None: return True try: return bool(_SHIBOKEN_IS_VALID(wrapped)) except Exception: # noqa: BLE001 return True
[docs] class FeatureHelpFilter(QObject): """Adds **What is this?** to the context menu of every results table. Installed on the :class:`QApplication`, so it covers the screens that do not exist yet when it is installed — every app screen in spaCR is built lazily on first navigation. """
[docs] def eventFilter(self, obj: QObject, event: QEvent) -> bool: """Watch the widgets this filter is installed on. THE EVENT TYPE IS READ FIRST, and the liveness checks follow it. This filter is on the QApplication, so both `_still_alive` calls used to run for every event in the process -- 94,431 of them during one Regression open, and a profile counted the pair at 839,906 calls in one Mask open. Reading the type of an object whose C++ half has gone raises, which the `except` below already answers with the same `False`, so the order costs nothing and the checks now run only for a context menu. :param obj: the object the event is for. :param event: the event. :returns: True to stop the event going further. """ try: kind = event.type() except (AttributeError, RuntimeError, ReferenceError): return False if kind != QEvent.Type.ContextMenu: return False if not _still_alive(event) or not _still_alive(obj): return False try: if not isinstance(obj, (QHeaderView, QAbstractItemView, QWidget)): return False if _claims_own_menu(obj): return False view = obj if isinstance(obj, QAbstractItemView) else obj.parent() vertical_header = getattr(view, "verticalHeader", None) if callable(vertical_header): header = vertical_header() if (header is not None and header.rect().contains( header.mapFromGlobal(event.globalPos()))): return False column = column_name_at(obj, event.pos()) if column is None: return False entry = parse_column(column) if (entry.family == "unknown" and not _table_looks_measured(_model_of(obj))): return False menu = QMenu(obj if isinstance(obj, QWidget) else None) action = menu.addAction(CONTEXT_ACTION_TEXT) action.setStatusTip(f"Explain the column “{column}”") action.triggered.connect( lambda checked=False, name=column: open_feature_dictionary(None, name)) _MENU_RUNNER(menu, event.globalPos()) event.accept() return True except Exception: LOG.debug("Feature help context menu failed", exc_info=True) return False
_FILTER: Optional[FeatureHelpFilter] = None
[docs] def install_context_menu_filter(app: Optional[QApplication] = None ) -> Optional[FeatureHelpFilter]: """Install the table context-menu filter on the QApplication. Idempotent.""" global _FILTER app = app or QApplication.instance() if app is None: return None if _FILTER is None: _FILTER = FeatureHelpFilter() _watch_application_events(app, _FILTER, (QEvent.Type.ContextMenu,)) return _FILTER
[docs] def remove_context_menu_filter(app: Optional[QApplication] = None) -> bool: """Remove the filter again. ``True`` if there was one.""" global _FILTER app = app or QApplication.instance() if _FILTER is None: return False if app is not None: _stop_watching_application_events(app, _FILTER) _FILTER = None return True
[docs] def install_window_hooks(window: QMainWindow) -> None: """Wire the dictionary into a live main window. Called from :func:`spacr.qt.shortcuts.install`, which runs once from ``MainWindow.__init__`` after the menu bar exists. Every failure is logged and swallowed: a missing help entry must not cost anyone a window. :param window: the main window; its Help menu gets the dictionary action, and the process-wide context-menu filter is installed. """ try: install_help_action(window) except Exception: LOG.exception("Could not add the Feature Dictionary help action") try: install_context_menu_filter() except Exception: LOG.exception("Could not install the feature help context menu")