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¶
Nothing is registered to open objects of the requested kind. |
Classes¶
The shared filter and selection every linked view reads. |
|
What a view mixes in to join the linked selection. |
Functions¶
|
Whether anything process-wide can open |
|
The process-wide |
|
Every kind registered process-wide, sorted. |
|
Show exactly these objects, wherever |
|
Route an already-built |
|
Offer to open objects of |
|
Withdraw |
Module Contents¶
- exception spacr.qt.linked_selection.NoObjectOpener[source]¶
Bases:
LookupErrorNothing 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.QObjectThe 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
ObjectRequestwas 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
changedwould 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.Selectionkeeps “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
kindis 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.DataFramecarrying the object key columns, aSelection, one key string, or an iterable of key strings, as foropen_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 emptykindmeansDEFAULT_OPEN_KIND. It is passed to the registered opener and then emitted onobjects_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
ObjectRequestforkind; must be callable.
- Raises:
ValueError – on a blank kind.
TypeError – if
fnis 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
frameas 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.
DataFilterholds 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_changedis emitted.
- set_selection(selection: spacr.selection.Selection) None[source]¶
Publish a new highlighted subset.
selection.sourcenames 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_changedis emitted.
- unregister_object_opener(kind: str, fn: ObjectOpener | None = None) bool[source]¶
Withdraw
kind;Trueif this call is what removed it.With
fngiven, 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’scloseEventruns after the second has registered, and an unconditional withdrawal would leave the live screen unreachable.Compared with
==, notis. The pattern this module documents —register_object_opener("annotate", self.open_object_request)in the constructor andunregister_object_opener("annotate", self.open_object_request)incloseEvent— 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 nextopen_objectsreached 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, apartial) 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]¶
framenarrowed 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
applyis 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
QWidgetsubclasses, 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 printslibpyside: Failed to disconnectto stderr where noexceptcan 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
closeEventis missed.
- clear_linked_selection() None[source]¶
Return everyone to the resting state (not to an empty selection).
- link_selection(source: str, *, link: LinkedSelection | None = None, echo: bool = False) LinkedSelection[source]¶
Subscribe to the shared filter and selection as
source.- Parameters:
source – this view’s name, stamped onto everything it publishes.
link – the
LinkedSelectionto 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]¶
framenarrowed 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 thatselection.keys is Noneis 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()withsourcefilled in.- Parameters:
keys – what to open: a
pandas.DataFramecarrying the object key columns, aSelection, one key string, or an iterable of key strings, as foropen_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
keysas the shared highlight, stamped with this view.keysis anythingas_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.
- property link: LinkedSelection[source]¶
The link this view is on — the process-wide one until it joins one.
Non-
Nonebeforelink_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 reachvisible().
- 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
NoObjectOpeneron 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
kindis 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.DataFramecarryingOBJECT_KEY_COLUMNS, aSelection, one key string, or an iterable of key strings. Order is kept and duplicates dropped, so “worst first” survives the trip. Seeas_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:
NoObjectOpener – nothing is registered for
kind.ValueError – blank
reason, or a restingSelection.TypeError –
keysis not something that names objects.
An empty
keysis 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
kindprocess-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) -> Anyis the one method it has to grow. Everything the caller wanted to say is on the request:request.keys(an Index ofOBJECT_KEY_COLUMNSkeys, in the caller’s order, de-duplicated),request.reason(put it in the header),request.source,request.timelapseandrequest.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
ObjectRequestforkind; 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
kindprocess-wide;Trueif 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.