spacr.qt.linked_selection

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

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 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 spacr.qt.widgets.home.HomePage documents for the run registry — so connect bound methods and drop them on close.

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 ObjectRequest through 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 open_objects(); the Annotate screen calls register_object_opener() once. Neither plot has to know Annotate exists, and Annotate does not grow a method per caller.

Exceptions

NoObjectOpener

Nothing is registered to open objects of the requested kind.

Classes

LinkedSelection

The shared filter and selection every linked view reads.

LinkedView

What a view mixes in to join the linked selection.

Functions

has_object_opener(→ bool)

Whether anything process-wide can open kind.

linked_selection(→ LinkedSelection)

The process-wide LinkedSelection (created on first use).

object_opener_kinds(→ Tuple[str, ...])

Every kind registered process-wide, sorted.

open_objects(→ Any)

Show exactly these objects, wherever kind is registered.

open_request(→ Any)

Route an already-built ObjectRequest.

register_object_opener(→ Optional[ObjectOpener])

Offer to open objects of kind process-wide.

unregister_object_opener(→ bool)

Withdraw kind process-wide; True if this call removed it.

Module Contents

exception spacr.qt.linked_selection.NoObjectOpener[source]

Bases: 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 has_object_opener() first.

Initialize self. See help(type(self)) for accurate signature.

class spacr.qt.linked_selection.LinkedSelection(parent=None)[source]

Bases: PySide6.QtCore.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 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 LinkedSelection.

Parameters:

parent – parent widget.

Create the shared link with no filter, no selection and no openers.

Parameters:

parent – parent object, or None.

clear_filter() → None[source]

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.

clear_selection() → None[source]

Return to the resting state.

Distinct from selecting nothing: 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.

has_object_opener(kind: str) → bool[source]

Whether anything is registered for kind.

For greying out a menu entry rather than offering one that raises.

Parameters:

kind – the destination name, stripped of surrounding whitespace before lookup.

object_opener_kinds() → Tuple[str, ...][source]

Every registered kind, sorted — for diagnostics and menus.

open_objects(keys: Any, *, reason: str, kind: str = DEFAULT_OPEN_KIND, source: str = '', timelapse: bool = False, context: Mapping[str, Any] | None = None) → Any[source]

Show exactly these objects, wherever kind is registered.

The instance-level form of the module function of the same name; see open_objects() for the argument contract.

Parameters:
  • keys – what to open: a pandas.DataFrame carrying the object key columns, a Selection, one key string, or an iterable of key strings, as for open_objects().

  • reason – why these objects, in the words the destination will show; required and non-blank.

open_request(request: spacr.selection.ObjectRequest) → Any[source]

Route an already-built request, and return what the opener returned.

Split from 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 as_selection().

Parameters:

request – the built 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.

register_object_opener(kind: str, fn: ObjectOpener) → ObjectOpener | None[source]

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 unregister_object_opener() is identity-checked for the same reason — a screen closing must not take the registration of the screen that replaced it.

Parameters:
  • kind – the destination name, stripped of surrounding whitespace; must not be blank.

  • fn – the callable that receives each ObjectRequest for kind; must be callable.

Raises:
  • ValueError – on a blank kind.

  • TypeError – if fn is not callable — caught here, where the registration is, rather than at the click that would have used it.

select_frame(frame: pandas.DataFrame, source: str = '', *, timelapse: bool = False) → None[source]

Convenience: publish the rows of frame as the selection.

Parameters:

frame – the rows to publish, turned into a selection by spacr.selection.Selection.from_frame().

set_filter(data_filter: spacr.selection.DataFilter) → None[source]

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.

Parameters:

data_filter – the new shared filter; stored as given and filter_changed is emitted.

set_selection(selection: spacr.selection.Selection) → None[source]

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.

Parameters:

selection – the new shared selection; stored as given and selection_changed is emitted.

unregister_object_opener(kind: str, fn: ObjectOpener | None = None) → bool[source]

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.

Parameters:

kind – the destination name, stripped of surrounding whitespace before lookup.

visible(frame: pandas.DataFrame) → pandas.DataFrame[source]

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.

Parameters:

frame – the rows to narrow; the active filter’s apply is called on it.

property filter: spacr.selection.DataFilter[source]

The active filter. Mutate through 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.

property selection: spacr.selection.Selection[source]

What is currently selected across the linked views.

Returns:

the selection.

class spacr.qt.linked_selection.LinkedView[source]

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. 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. 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.

clear_linked_selection() → None[source]

Return everyone to the resting state (not to an empty selection).

Subscribe to the shared filter and selection as source.

Parameters:
  • source – this view’s name, stamped onto everything it publishes.

  • link – the LinkedSelection to join. Defaults to the process-wide one; a test (or a second window) passes its own.

  • echo – hear your own published selections too. Off by default — a view that just drew a lasso does not need to be told about it.

Returns:

the link, so a caller can keep the reference.

Calling this twice re-subscribes rather than double-subscribing: a screen that re-links on reload would otherwise get two callbacks per change, and repaint twice for every lasso for the rest of the session.

linked_visible(frame: pandas.DataFrame) → pandas.DataFrame[source]

frame narrowed by the shared filter. A selection never hides.

Parameters:

frame – the rows to narrow by the shared filter.

on_linked_filter_changed(data_filter: spacr.selection.DataFilter) → None[source]

The shared population moved: re-query and re-lay-out.

Default: nothing, so a view can subscribe for selections alone.

Parameters:

data_filter – the shared filter now in force.

on_linked_selection_changed(selection: spacr.selection.Selection) → None[source]

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.

Parameters:

selection – the shared selection now in force.

open_objects(keys: Any, *, reason: str, kind: str = DEFAULT_OPEN_KIND, timelapse: bool = False, context: Mapping[str, Any] | None = None) → Any[source]

Ask for these objects to be shown, as this view.

The same call as open_objects() with source filled in.

Parameters:
  • keys – what to open: a pandas.DataFrame carrying the object key columns, a Selection, one key string, or an iterable of key strings, as for open_objects().

  • reason – why these objects, in the words the destination will show; required and non-blank.

publish_filter(data_filter: spacr.selection.DataFilter) → None[source]

Narrow the shared population from this view.

Parameters:

data_filter – the filter to install as the shared one.

publish_selection(keys: Any, *, timelapse: bool = False) → spacr.selection.Selection[source]

Publish keys as the shared highlight, stamped with this view.

keys is anything as_key_index() takes — the lassoed rows as a frame, or bare keys.

Parameters:

keys – the lassoed rows as a frame, or bare keys; anything as_key_index() accepts.

Stop listening. Safe to call on a view that never linked.

property is_linked: bool[source]

Whether this view is currently subscribed.

The link this view is on — the process-wide one until it joins one.

Non-None before link_selection(), deliberately: publishing without subscribing is a legitimate half of the contract (a view that drives the others but redraws itself), and it should not need to subscribe just to reach visible().

spacr.qt.linked_selection.has_object_opener(kind: str) → bool[source]

Whether anything process-wide can open kind.

Ask before offering the action, so an unavailable destination is a greyed menu entry rather than a NoObjectOpener on click.

Parameters:

kind – the destination name, stripped of surrounding whitespace before lookup.

spacr.qt.linked_selection.linked_selection() → LinkedSelection[source]

The process-wide LinkedSelection (created on first use).

spacr.qt.linked_selection.object_opener_kinds() → Tuple[str, ...][source]

Every kind registered process-wide, sorted.

spacr.qt.linked_selection.open_objects(keys: Any, *, reason: str, kind: str = DEFAULT_OPEN_KIND, source: str = '', timelapse: bool = False, context: Mapping[str, Any] | None = None) → Any[source]

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.

Parameters:
  • keys – what to open — a pandas.DataFrame carrying OBJECT_KEY_COLUMNS, a Selection, one key string, or an iterable of key strings. Order is kept and duplicates dropped, so “worst first” survives the trip. See as_key_index().

  • reason – why these objects, in the words the destination will show. Required and non-blank.

  • kind – the destination; defaults to DEFAULT_OPEN_KIND.

  • source – the view asking, for the destination’s header.

  • timelapse – the keys carry a timepoint.

  • 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:

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.

spacr.qt.linked_selection.open_request(request: spacr.selection.ObjectRequest) → Any[source]

Route an already-built ObjectRequest.

For building the request where the data is — off the event loop — and routing it on the GUI thread.

Parameters:

request – the built request, routed to the opener registered for its kind.

spacr.qt.linked_selection.register_object_opener(kind: str, fn: ObjectOpener) → ObjectOpener | None[source]

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 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.

Parameters:
  • kind – the destination name, e.g. 'annotate'; must not be blank.

  • fn – the opener, called with each ObjectRequest for kind; its return value goes back to the caller.

Returns:

the opener this one displaced, or None.

spacr.qt.linked_selection.unregister_object_opener(kind: str, fn: ObjectOpener | None = None) → bool[source]

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.

Parameters:

kind – the destination name, stripped of surrounding whitespace before lookup.