Source code for spacr.qt.linked_selection

"""Process-wide linked selection and filter, so the views stop being islands.

:mod:`spacr.selection` holds the logic and knows nothing about Qt. This module
is the thin part that makes it *shared*: one object per process that every open
view subscribes to, so lassoing a cluster in the UMAP highlights the same cells
on the plate heatmap, in the measurement table and in the crop grid.

Why a singleton rather than passing a model around
--------------------------------------------------

The views are constructed independently by ``AppScreen`` as the user opens
tabs; none of them owns another, and there is no common parent below the main
window to hang a shared model off. A process-wide accessor is the same shape
:func:`spacr.qt.bridge.registry` already uses for run state, for the same
reason, and it means a view added later joins the conversation by importing one
function.

The subscription rule
---------------------

**Views must disconnect in** ``closeEvent``. This object outlives every screen,
and holds plain references to whatever connected to it. A lambda would keep a
destroyed page alive as a receiver — the exact leak
:class:`spacr.qt.widgets.home.HomePage` documents for the run registry — so
connect bound methods and drop them on close.

:class:`LinkedView` is that rule written down once. A view joins with three
lines rather than re-deriving the echo-suppression and disconnect dance::

    class UmapView(LinkedView, QWidget):          # 1. mix it in, FIRST
        def __init__(self, parent=None):
            super().__init__(parent)
            self.link_selection("umap")           # 2. subscribe + name yourself

        def closeEvent(self, event):
            self.unlink_selection()               # 3. and let go
            super().closeEvent(event)

        # then override only what it cares about
        def on_linked_selection_changed(self, selection):
            self._repaint_highlight(selection)

and publishes with ``self.publish_selection(frame_or_keys)``, which stamps the
view's own name so the view does not repaint for the echo of its own lasso.

Opening objects somewhere else
------------------------------

Linking answers "we are all looking at the same population". It does not
answer "show me *these twelve* crops, in this order, because a model got them
wrong" — that is a jump to another view, not a change of shared state, so it
travels as a one-shot :class:`~spacr.selection.ObjectRequest` through
:func:`open_objects` instead.

The point of routing it is that neither end imports the other. A scatter plot
that wants a crop shown, and a confusion matrix that wants a cell's errors
shown, both call :func:`open_objects`; the Annotate screen calls
:func:`register_object_opener` once. Neither plot has to know Annotate exists,
and Annotate does not grow a method per caller.
"""
from __future__ import annotations

from dataclasses import replace
from typing import TYPE_CHECKING, Any, Callable, Dict, Mapping, Optional, Tuple

from PySide6.QtCore import QObject, Signal

from ..selection import DataFilter, ObjectRequest, Selection, as_key_index

if TYPE_CHECKING:
    import pandas as pd

__all__ = [
    "LinkedSelection",
    "LinkedView",
    "NoObjectOpener",
    "ObjectOpener",
    "DEFAULT_OPEN_KIND",
    "linked_selection",
    "open_objects",
    "open_request",
    "register_object_opener",
    "unregister_object_opener",
    "has_object_opener",
    "object_opener_kinds",
]

#: The destination :func:`open_objects` routes to when the caller does not say
#: otherwise. "Show me these objects" almost always means "show me the crops",
#: and the crop grid is Annotate.
DEFAULT_OPEN_KIND = "annotate"

#: What :func:`register_object_opener` takes: one call, one request, any
#: return value (the screen that opened, ``True``, or ``None`` — it is passed
#: back to the caller unchanged).
ObjectOpener = Callable[[ObjectRequest], Any]


[docs] class NoObjectOpener(LookupError): """Nothing is registered to open objects of the requested kind. A caller for which a missing destination is expected, such as an optional context-menu action, should call :func:`has_object_opener` first. """
[docs] class LinkedSelection(QObject): """The shared filter and selection every linked view reads. Signals: * filter_changed() — the population narrowed or widened * selection_changed() — the highlighted subset moved * objects_opened(request) — an :class:`~spacr.selection.ObjectRequest` was routed somewhere. Emitted after the opener returned, so a view following along never chases a jump that failed. The first two are separate signals because they cost different amounts to honour. A filter change means a view has to re-query and re-lay-out; a selection change usually means it only has to repaint. Collapsing them into one ``changed`` would make every lasso trigger a full reload of a million-row table. The opener registry lives on the instance rather than in a module global so that a test — or a second window — gets its own routing table by constructing its own :class:`LinkedSelection`. :param parent: parent widget. """ filter_changed = Signal() selection_changed = Signal() objects_opened = Signal(object) def __init__(self, parent=None): """Create the shared link with no filter, no selection and no openers. :param parent: parent object, or ``None``. """ super().__init__(parent) self._filter = DataFilter() self._selection = Selection.none() self._openers: Dict[str, ObjectOpener] = {} @property
[docs] def filter(self) -> DataFilter: """The active filter. Mutate through :meth:`set_filter`, not in place. Returned rather than copied because a copy per read would be wasteful on a hot path, but mutating it directly will not emit — which is the one way to get views showing different populations. """ return self._filter
[docs] def set_filter(self, data_filter: DataFilter) -> None: """Replace the filter and tell every view, even if it looks the same. No equality short-circuit on purpose. ``DataFilter`` holds a list of dataclasses, and a caller that mutated one in place then handed the same object back would compare equal to itself and emit nothing, leaving the views showing a population that no longer matches the controls. :param data_filter: the new shared filter; stored as given and ``filter_changed`` is emitted. """ self._filter = data_filter self.filter_changed.emit()
[docs] def clear_filter(self) -> None: """Drop the shared filter, showing every row again. BROADCAST, like any other filter change: every linked view is showing a subset because of this filter, so clearing it silently would leave them all filtered by something no longer there. """ self.set_filter(DataFilter())
@property
[docs] def selection(self) -> Selection: """What is currently selected across the linked views. :returns: the selection. """ return self._selection
[docs] def set_selection(self, selection: Selection) -> None: """Publish a new highlighted subset. ``selection.source`` names the view that made it, so a view can ignore the echo of its own selection rather than re-applying what it just drew — which otherwise costs a repaint per view per lasso, and can loop if a view normalises what it publishes. :param selection: the new shared selection; stored as given and ``selection_changed`` is emitted. """ self._selection = selection self.selection_changed.emit()
[docs] def clear_selection(self) -> None: """Return to the resting state. Distinct from selecting nothing: :class:`spacr.selection.Selection` keeps "no selection" and "an empty selection" apart so views can draw the resting state differently from a lasso that caught nothing. """ self.set_selection(Selection.none())
[docs] def select_frame(self, frame: pd.DataFrame, source: str = "", *, timelapse: bool = False) -> None: """Convenience: publish the rows of ``frame`` as the selection. :param frame: the rows to publish, turned into a selection by :meth:`spacr.selection.Selection.from_frame`. """ self.set_selection( Selection.from_frame(frame, source=source, timelapse=timelapse))
[docs] def visible(self, frame: pd.DataFrame) -> pd.DataFrame: """``frame`` narrowed by the active filter. The one call a view needs to honour the filter. Selection is deliberately NOT applied — a selection highlights, it does not hide, and a view that dropped unselected rows would make the lasso destructive. :param frame: the rows to narrow; the active filter's ``apply`` is called on it. """ return self._filter.apply(frame)
[docs] def register_object_opener(self, kind: str, fn: ObjectOpener) -> Optional[ObjectOpener]: """Offer to open objects of ``kind``, and return whoever had it before. Registering a kind that is already taken REPLACES it, because that is what re-opening a screen looks like: the second Annotate is the live one and the first is on its way out. The displaced opener is returned rather than dropped so a caller that wants to chain or restore it can. The matching :meth:`unregister_object_opener` is identity-checked for the same reason — a screen closing must not take the registration of the screen that replaced it. :param kind: the destination name, stripped of surrounding whitespace; must not be blank. :param fn: the callable that receives each :class:`~spacr.selection.ObjectRequest` for ``kind``; must be callable. :raises ValueError: on a blank kind. :raises TypeError: if ``fn`` is not callable — caught here, where the registration is, rather than at the click that would have used it. """ key = str(kind).strip() if not key: raise ValueError("an object opener needs a non-blank kind") if not callable(fn): raise TypeError( f"object opener for {key!r} is not callable " f"({type(fn).__name__})") previous = self._openers.get(key) self._openers[key] = fn return previous
[docs] def unregister_object_opener(self, kind: str, fn: Optional[ObjectOpener] = None) -> bool: """Withdraw ``kind``; ``True`` if this call is what removed it. With ``fn`` given, removes it only if that is the opener currently registered. A closing screen should always pass its own opener: two Annotate screens opened in a session means the first one's ``closeEvent`` runs *after* the second has registered, and an unconditional withdrawal would leave the live screen unreachable. Compared with ``==``, not ``is``. The pattern this module documents — ``register_object_opener("annotate", self.open_object_request)`` in the constructor and ``unregister_object_opener("annotate", self.open_object_request)`` in ``closeEvent`` — hands over a *different bound-method object* each time, because Python builds a new one on every attribute access. Under an identity check that made every withdrawal a silent no-op, so the process-wide registry kept a reference to a destroyed screen and the next ``open_objects`` reached it. Bound methods compare equal exactly when their ``__self__`` and ``__func__`` match, which is the question being asked; anything that does not define ``__eq__`` (a lambda, a ``partial``) still falls back to identity, so the two-screen guarantee above is unchanged. :param kind: the destination name, stripped of surrounding whitespace before lookup. """ key = str(kind).strip() current = self._openers.get(key) if current is None or (fn is not None and current != fn): return False del self._openers[key] return True
[docs] def has_object_opener(self, kind: str) -> bool: """Whether anything is registered for ``kind``. For greying out a menu entry rather than offering one that raises. :param kind: the destination name, stripped of surrounding whitespace before lookup. """ return str(kind).strip() in self._openers
[docs] def object_opener_kinds(self) -> Tuple[str, ...]: """Every registered kind, sorted — for diagnostics and menus.""" return tuple(sorted(self._openers))
[docs] def open_objects(self, keys: Any, *, reason: str, kind: str = DEFAULT_OPEN_KIND, source: str = "", timelapse: bool = False, context: Optional[Mapping[str, Any]] = None) -> Any: """Show exactly these objects, wherever ``kind`` is registered. The instance-level form of the module function of the same name; see :func:`open_objects` for the argument contract. :param keys: what to open: a :class:`pandas.DataFrame` carrying the object key columns, a :class:`~spacr.selection.Selection`, one key string, or an iterable of key strings, as for :func:`open_objects`. :param reason: why these objects, in the words the destination will show; required and non-blank. """ return self.open_request(ObjectRequest( keys=keys, reason=reason, source=source, kind=kind, timelapse=timelapse, context=context or {}))
[docs] def open_request(self, request: ObjectRequest) -> Any: """Route an already-built request, and return what the opener returned. Split from :meth:`open_objects` so a request can be built where the data is — in a worker, off the event loop — and routed later on the GUI thread, without that hop having to re-list every argument. The request is NOT published as the shared selection. Opening a subset somewhere and highlighting it everywhere are separate acts, and doing both here would wipe the lasso the user opened it from. A receiver that wants both publishes :meth:`~spacr.selection.ObjectRequest.as_selection`. :param request: the built :class:`~spacr.selection.ObjectRequest`; an empty ``kind`` means ``DEFAULT_OPEN_KIND``. It is passed to the registered opener and then emitted on ``objects_opened``. :raises NoObjectOpener: if nothing is registered for the kind. """ kind = request.kind or DEFAULT_OPEN_KIND opener = self._openers.get(kind) if opener is None: known = ", ".join(self.object_opener_kinds()) or "nothing" raise NoObjectOpener( f"nothing is registered to open {kind!r} objects " f"(registered: {known}). The screen that opens them may not " f"have been created yet — ask has_object_opener({kind!r}) " f"before offering the action.") if request.kind != kind: request = replace(request, kind=kind) result = opener(request) self.objects_opened.emit(request) return result
[docs] class LinkedView: """What a view mixes in to join the linked selection. A mixin rather than a base class: the views are already ``QWidget`` subclasses, and this holds no Qt state of its own — no ``__init__``, only class-level defaults — so it composes with any of them. **List it first** (``class UmapView(LinkedView, QWidget)``), so its methods win over anything Qt happens to name the same. The three lines of the contract are in the module docstring. What the mixin buys over hand-wiring, beyond the typing: * **Echo suppression.** :meth:`publish_selection` stamps the view's own name, and the subscriber drops selections carrying it. Without that, every lasso costs the drawing view a repaint of what it already drew, and a view that normalises what it publishes oscillates. * **One disconnect.** :meth:`unlink_selection` is idempotent and flag-guarded: Qt does not raise on a disconnect that finds nothing, it prints ``libpyside: Failed to disconnect`` to stderr where no ``except`` can reach it, and a screen closed twice — which Qt does on teardown — would print one every time. * **Bound methods only.** The process-wide link outlives every screen; the slots it holds are bound methods of the view, so Qt severs them when the widget is destroyed even if a ``closeEvent`` is missed. """ #: The name this view publishes under, and the name it ignores the echo #: of. Set by :meth:`link_selection`. link_source: str = "" _link: Optional[LinkedSelection] = None _link_connected: bool = False _link_echo: bool = False @property
[docs] def is_linked(self) -> bool: """Whether this view is currently subscribed.""" return self._link_connected
@property
[docs] def on_linked_filter_changed(self, data_filter: DataFilter) -> None: """The shared population moved: re-query and re-lay-out. Default: nothing, so a view can subscribe for selections alone. :param data_filter: the shared filter now in force. """
[docs] def on_linked_selection_changed(self, selection: Selection) -> None: """The highlighted subset moved: repaint. Not called for this view's own selections unless it linked with ``echo=True``. Remember that ``selection.keys is None`` is the resting state — draw it differently from a lasso that caught nothing. Default: nothing, so a view can subscribe for the filter alone. :param selection: the shared selection now in force. """
[docs] def publish_selection(self, keys: Any, *, timelapse: bool = False) -> Selection: """Publish ``keys`` as the shared highlight, stamped with this view. ``keys`` is anything :func:`~spacr.selection.as_key_index` takes — the lassoed rows as a frame, or bare keys. :param keys: the lassoed rows as a frame, or bare keys; anything :func:`~spacr.selection.as_key_index` accepts. """ selection = Selection(keys=as_key_index(keys, timelapse=timelapse), source=self.link_source) self.link.set_selection(selection) return selection
[docs] def clear_linked_selection(self) -> None: """Return everyone to the resting state (not to an empty selection).""" self.link.clear_selection()
[docs] def publish_filter(self, data_filter: DataFilter) -> None: """Narrow the shared population from this view. :param data_filter: the filter to install as the shared one. """ self.link.set_filter(data_filter)
[docs] def linked_visible(self, frame: pd.DataFrame) -> pd.DataFrame: """``frame`` narrowed by the shared filter. A selection never hides. :param frame: the rows to narrow by the shared filter. """ return self.link.visible(frame)
[docs] def open_objects(self, keys: Any, *, reason: str, kind: str = DEFAULT_OPEN_KIND, timelapse: bool = False, context: Optional[Mapping[str, Any]] = None) -> Any: """Ask for these objects to be shown, as this view. The same call as :func:`open_objects` with ``source`` filled in. :param keys: what to open: a :class:`pandas.DataFrame` carrying the object key columns, a :class:`~spacr.selection.Selection`, one key string, or an iterable of key strings, as for :func:`open_objects`. :param reason: why these objects, in the words the destination will show; required and non-blank. """ return self.link.open_objects( keys, reason=reason, kind=kind, source=self.link_source, timelapse=timelapse, context=context)
def _linked_filter_changed(self) -> None: """Hand the link's new filter to this view.""" self.on_linked_filter_changed(self.link.filter) def _linked_selection_changed(self) -> None: """Hand the link's new selection to this view, unless this view published it. A view that published a selection has already applied it, so hearing it back would be an echo -- and for a view that re-publishes on apply, an endless one. Echo suppression is switched off by ``_link_echo`` for the tests that need to see the round trip. """ selection = self.link.selection if (not self._link_echo and self.link_source and selection.source == self.link_source): return self.on_linked_selection_changed(selection)
_LINKED: Optional[LinkedSelection] = None
[docs] def linked_selection() -> LinkedSelection: """The process-wide :class:`LinkedSelection` (created on first use).""" global _LINKED if _LINKED is None: _LINKED = LinkedSelection() return _LINKED
[docs] def register_object_opener(kind: str, fn: ObjectOpener) -> Optional[ObjectOpener]: """Offer to open objects of ``kind`` process-wide. The half of the routing contract a *destination* implements. Annotate, in its constructor:: register_object_opener("annotate", self.open_object_request) and in ``closeEvent``:: unregister_object_opener("annotate", self.open_object_request) where ``open_object_request(request: ObjectRequest) -> Any`` is the one method it has to grow. Everything the caller wanted to say is on the request: ``request.keys`` (an Index of :data:`~spacr.selection.OBJECT_KEY_COLUMNS` keys, in the caller's order, de-duplicated), ``request.reason`` (put it in the header), ``request.source``, ``request.timelapse`` and ``request.context``. The return value is handed back to the caller unchanged. :param kind: the destination name, e.g. ``'annotate'``; must not be blank. :param fn: the opener, called with each :class:`~spacr.selection.ObjectRequest` for ``kind``; its return value goes back to the caller. :returns: the opener this one displaced, or ``None``. """ return linked_selection().register_object_opener(kind, fn)
[docs] def unregister_object_opener(kind: str, fn: Optional[ObjectOpener] = None) -> bool: """Withdraw ``kind`` process-wide; ``True`` if this call removed it. Pass the opener you registered: with two screens of the same kind open, the one closing must not withdraw the one that replaced it. :param kind: the destination name, stripped of surrounding whitespace before lookup. """ return linked_selection().unregister_object_opener(kind, fn)
[docs] def has_object_opener(kind: str) -> bool: """Whether anything process-wide can open ``kind``. Ask before offering the action, so an unavailable destination is a greyed menu entry rather than a :class:`NoObjectOpener` on click. :param kind: the destination name, stripped of surrounding whitespace before lookup. """ return linked_selection().has_object_opener(kind)
[docs] def object_opener_kinds() -> Tuple[str, ...]: """Every kind registered process-wide, sorted.""" return linked_selection().object_opener_kinds()
[docs] def open_objects(keys: Any, *, reason: str, kind: str = DEFAULT_OPEN_KIND, source: str = "", timelapse: bool = False, context: Optional[Mapping[str, Any]] = None) -> Any: """Show exactly these objects, wherever ``kind`` is registered. The half of the routing contract a *caller* uses. A scatter plot:: open_objects(row, reason="clicked in the UMAP", source="umap") A confusion-matrix cell, worst errors first:: open_objects(errors_sorted_by_confidence, reason="predicted infected · annotated uninfected", source="classifier_evaluation", context={"scores": scores_by_key}) Neither imports Annotate, and Annotate grows no method per caller. :param keys: what to open — a :class:`pandas.DataFrame` carrying :data:`~spacr.selection.OBJECT_KEY_COLUMNS`, a :class:`~spacr.selection.Selection`, one key string, or an iterable of key strings. Order is kept and duplicates dropped, so "worst first" survives the trip. See :func:`~spacr.selection.as_key_index`. :param reason: why these objects, in the words the destination will show. Required and non-blank. :param kind: the destination; defaults to :data:`DEFAULT_OPEN_KIND`. :param source: the view asking, for the destination's header. :param timelapse: the keys carry a timepoint. :param context: extras for the destination (per-key scores, a column to annotate into). Copied; the destination cannot mutate the caller's. :returns: whatever the opener returned, unchanged. :raises NoObjectOpener: nothing is registered for ``kind``. :raises ValueError: blank ``reason``, or a resting ``Selection``. :raises TypeError: ``keys`` is not something that names objects. An empty ``keys`` is not an error: a confusion-matrix cell with no errors in it is a real answer, and the destination showing "0 objects · <reason>" beats an exception every caller has to guard. """ return linked_selection().open_objects( keys, reason=reason, kind=kind, source=source, timelapse=timelapse, context=context)
[docs] def open_request(request: ObjectRequest) -> Any: """Route an already-built :class:`~spacr.selection.ObjectRequest`. For building the request where the data is — off the event loop — and routing it on the GUI thread. :param request: the built request, routed to the opener registered for its ``kind``. """ return linked_selection().open_request(request)