Source code for spacr.qt.preview_registry

"""Which modules get a Live Preview, and how one is attached from outside.

Four modules have a preview: Mask, Measure, Timelapse and Motility. Each one
costs a thirteen-line arm in ``AppScreen._build_runtime_panel``, two attribute
names in a null-out block, and a row in a toggle table two hundred lines
further down.

A fifth would cost the same, which is why there has never been one. The two
modules that would benefit most are the ones whose entire job is "did the mask
come out right", Cellpose Masks and Plaque Assay, and neither was worth
touching the shared screen for.

This module is the seam that makes the fifth free. A module declares a
preview here; the strip above the settings form grows a toggle for it; the
card is inserted above the Run row through the same ``_runtime_wrap`` /
``_actions_row`` anchors :mod:`spacr.qt.prerun` uses. Nothing inside
``AppScreen`` changes, and the four previews it already builds are left
alone — a module the shared screen has already served is skipped here rather
than given a second card.

A module that has been FOLDED into a host reaches the same machinery from
the other end. Its own screen is not built any more, so ``install`` --
which answers for the screen's own key -- can never attach its panel;
:func:`attach_folded` lets the host ask for it by name and keeps it hidden
behind whatever the host uses to reveal the rest of that module. Mask
Generation's tracking switch is the case it was written for.

**The sampling contract is inherited, not reimplemented.** The panels reached
through this registry are the shipped ones, which group a plate into image
sets from file names alone and open a bounded, reproducible random sample of
it. Nothing here enumerates, opens or lists a directory, so nothing here can
regress that. A new panel registered through this seam must keep the same
promise — see :mod:`spacr.qt.widgets.preview_controls`.
"""
from __future__ import annotations

from dataclasses import dataclass, field
import logging
from typing import Any, Callable, Dict, Optional, Tuple

from PySide6.QtCore import QObject, Qt, QTimer
from PySide6.QtWidgets import QMainWindow, QToolButton, QWidget

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


@dataclass(frozen=True)
[docs] class PreviewSpec: """One module's preview declaration. :ivar builder: ``"module:function"`` returning ``(panel, card)``, the shape every existing ``build_*_preview_card`` already has. Named rather than imported so declaring a preview costs no import at launch — a preview panel drags in the imaging stack. :ivar title: the toggle's label. :ivar tooltip: what the toggle promises. :ivar propagation: rename map applied to whatever the panel hands back through ``set_propagate_callback``, so a panel written for one module's setting names can serve another's. :ivar owned_by_screen: True for the ones ``AppScreen`` already builds. They are declared here so this registry is the single answer to "which modules have a preview", and skipped at install time so they do not get a second card. :ivar fill: ``"module:function"`` taking ``(host, card)`` and building the panel into the card, returning it. Given, a preview ATTACHED through this registry builds only its card at install -- ``builder`` is called with ``panel_later=True`` -- and the panel the first time the card is shown or the panel is asked for, so a hidden preview costs a module's open nothing. """ builder: str title: str = "Live preview" tooltip: str = "" propagation: Dict[str, str] = field(default_factory=dict) owned_by_screen: bool = False fill: str = ""
#: app key -> its preview. The ones marked ``owned_by_screen`` are built by #: ``AppScreen`` itself; the rest are attached by :func:`install`. PREVIEWS: Dict[str, PreviewSpec] = { 'host_pathogen': PreviewSpec( builder='spacr.qt.widgets.host_pathogen_preview:build_host_pathogen_preview_card', fill='spacr.qt.widgets.host_pathogen_preview:fill_host_pathogen_preview_card', title='Live preview', tooltip='Inspect one measured field, its host/vacuole/parasite masks and recruitment results.'), "mask": PreviewSpec( builder="spacr.qt.screens.app_screen:_build_live_preview_card", owned_by_screen=True), "measure": PreviewSpec( builder="spacr.qt.screens.app_screen:_build_measure_preview_card", title="Crop preview", owned_by_screen=True), "timelapse": PreviewSpec( builder="spacr.qt.widgets.timelapse_preview:" "build_timelapse_preview_card", fill="spacr.qt.widgets.timelapse_preview:" "_fill_timelapse_preview_card", title="Track preview", owned_by_screen=True), "motility": PreviewSpec( builder="spacr.qt.widgets.motility_preview:" "build_motility_preview_card", title="Track preview", owned_by_screen=True), "cellpose_masks": PreviewSpec( builder="spacr.qt.screens.app_screen:_build_live_preview_card", tooltip="Segment one sampled field with these settings before " "committing the plate.", propagation={ "cell_diameter": "diameter", "cell_flow_threshold": "flow_threshold", "cell_cellprob_threshold": "CP_prob", "model_name": "model_name", "custom_model": "custom_model", "normalize": "normalize", }), "analyze_plaques": PreviewSpec( builder="spacr.qt.widgets.plaque_preview:build_plaque_preview_card", owned_by_screen=True, tooltip="Check the plaque diameter and thresholds on one sampled " "field before running the assay.", propagation={ "cell_diameter": "diameter", "cell_flow_threshold": "flow_threshold", "cell_cellprob_threshold": "CP_prob", "plaque_model": "plaque_model", "diameter": "diameter", "flow_threshold": "flow_threshold", "CP_prob": "CP_prob", "plaque_mode": "plaque_mode", "figure_detector": "figure_detector", "figure_imgsz": "figure_imgsz", "figure_confidence": "figure_confidence", "figure_read_text": "figure_read_text", "confirm_annotations": "confirm_annotations", "text_reach_above": "text_reach_above", "text_reach_left": "text_reach_left", "text_reach_below": "text_reach_below", "text_use_above": "text_use_above", "text_use_left": "text_use_left", "text_use_below": "text_use_below", "text_panel_reach": "text_panel_reach", "text_min_confidence": "text_min_confidence", "text_ignore": "text_ignore", "text_order": "text_order", "text_separator": "text_separator", "text_reread": "text_reread", "text_reread_scale": "text_reread_scale", "src": "src", }), }
[docs] def register_preview(app_key: str, spec: PreviewSpec, *, replace: bool = False) -> PreviewSpec: """Declare a preview for ``app_key``. :param app_key: the module's app key. :param spec: its declaration. :param replace: overwrite an existing declaration instead of raising. :raises ValueError: on a second declaration without ``replace`` — two modules quietly claiming one key is the failure a registry exists to make loud. """ key = str(app_key) if key in PREVIEWS and not replace: raise ValueError( f"a preview for {key!r} is already registered; pass " "replace=True if that is really what you mean") PREVIEWS[key] = spec return spec
[docs] def unregister_preview(app_key: str) -> bool: """Drop a declaration. ``True`` if there was one. :param app_key: app key whose preview declaration is removed; converted with ``str()`` first. """ return PREVIEWS.pop(str(app_key), None) is not None
[docs] def preview_app_keys() -> Tuple[str, ...]: """Every module with a preview, however it is attached.""" return tuple(PREVIEWS)
def _resolve(builder: str) -> Optional[Callable[[Any], Tuple[Any, Any]]]: """Import a preview builder named as ``module:function``. :param builder: the dotted module path and function name. :returns: the callable, or ``None`` when the string is malformed or the import fails -- a module whose preview cannot be resolved still opens, without one. """ module_name, _, func_name = str(builder).partition(":") if not module_name or not func_name: return None import importlib try: return getattr(importlib.import_module(module_name), func_name) except Exception: LOG.debug("could not resolve preview builder %r", builder, exc_info=True) return None class _PreviewHost(QObject): """Owns one attached preview: its card, its toggle, and the translation. A ``QObject`` parented to the screen, with bound-method slots — the alternative, closures captured by the toggle, keeps the screen alive through its own button. """ def __init__(self, screen: QWidget, spec: PreviewSpec, panel, card, fill: Optional[Callable[[Any, Any], Any]] = None): """Bind one preview to the screen that shows it. :param screen: the module screen the preview belongs to, and this object's Qt parent. :param spec: what the preview is and how to build it. :param panel: the preview widget itself, or ``None`` when ``fill`` builds it later. :param card: the container the panel sits in, shown and hidden by :meth:`on_toggled`. :param fill: builds the panel into ``card`` the first time the card is shown or :attr:`panel` is read; see :attr:`PreviewSpec.fill`. Nothing is built here. The panel is PRIMED on first show, so a screen with a preview costs nothing until the user opens it. """ super().__init__(screen) self._screen = screen self._spec = spec self._panel = panel self._fill = fill self.card = card self._primed = False if fill is not None: build_later = getattr(card, "build_body_when_first_shown", None) if callable(build_later): build_later(self._build_panel) else: self._connect_panel() @property def panel(self): """The preview widget, built first if it is still waiting.""" if self._fill is not None: self._build_panel() return self._panel @panel.setter def panel(self, value) -> None: """Replace the preview widget; a panel still waiting is not built.""" self._fill = None self._panel = value def panel_is_built(self) -> bool: """Whether the panel exists yet. For tests and diagnostics.""" return self._fill is None def _connect_panel(self) -> None: """Route the panel's propagated settings to the form.""" register_cb = getattr(self._panel, "set_propagate_callback", None) if callable(register_cb): register_cb(self.on_propagate) def _build_panel(self) -> None: """Build the panel into its card, once, as install used to. It is then translated and polished; see :func:`_dress_a_late_panel`. """ fill, self._fill = self._fill, None if fill is None: return self._panel = fill(self._screen, self.card) self._connect_panel() _dress_a_late_panel(self._screen, self._panel) def on_toggled(self, on: bool) -> None: """Show or hide the preview card.""" if on and not self._primed: self.prime() self.card.setVisible(bool(on)) def prime(self) -> None: """Push the module's current settings into the panel, once. Deferred to the first time the card is shown rather than done at install: reading the form costs a pass over every widget, and a preview nobody opened should cost nothing. """ self._primed = True apply_settings = getattr(self.panel, "apply_settings", None) model = getattr(self._screen, "_settings_model", None) if not callable(apply_settings) or model is None: return try: apply_settings(model.collect()) except Exception: LOG.debug("could not prime the preview for %r", getattr(self._screen, "app_key", "?"), exc_info=True) def on_propagate(self, values: Dict[str, Any]) -> None: """Translate the panel's setting names, then write them to the form. Unmapped names are dropped rather than passed through: a module that has no ``cell_channel`` gains nothing from being offered one, and ``set_value_for_key`` would return False for each in silence, leaving "propagate" looking like it worked. """ rename = self._spec.propagation model = getattr(self._screen, "_settings_model", None) if model is None: return for source, value in dict(values or {}).items(): target = rename.get(source) if rename else source if target is None: continue try: model.set_value_for_key(target, value) except Exception: LOG.debug("could not propagate %r", target, exc_info=True)
[docs] def install(screen: QWidget) -> Optional[_PreviewHost]: """Attach ``screen``'s declared preview, if it has one to attach. Returns ``None`` when the module declares no preview, when ``AppScreen`` already built one for it, when the screen has no runtime panel to insert into, or when one is already installed. Never raises: a missing preview must not cost anyone a module. :param screen: app screen widget; its ``app_key`` attribute selects the declared preview, and the installed host is remembered on it so a second call returns the same one. """ if getattr(screen, "_registry_preview", None) is not None: return screen._registry_preview app_key = str(getattr(screen, "app_key", "") or "") spec = PREVIEWS.get(app_key) if spec is None or spec.owned_by_screen: return None host = _attach(screen, app_key, spec) if host is not None: screen._registry_preview = host return host
[docs] def attach_folded(screen: QWidget, app_key: str) -> Optional[_PreviewHost]: """Attach ANOTHER module's declared preview to ``screen``. A module folded into a host as settings categories brings its panel with it. Mask Generation has the Cellpose live preview and no track preview; Timelapse's whole preview is the tracking one, built by the screen the fold means nobody opens any more. Without this the switch would reveal the tracking settings and nothing that shows what they do -- a capability the tile had and the button did not, which is the one thing a fold must not cost. ``owned_by_screen`` is deliberately ignored: it means "``AppScreen`` builds this one for its own key", and the point here is that the key is somebody else's. The card and its toggle both start hidden; the host reveals them when the fold is switched on. :param screen: the HOST screen. :param app_key: the folded module's key. :returns: the host, or None when there is nothing to attach. """ key = str(app_key) attached = getattr(screen, "_folded_previews", None) if attached is None: attached = screen._folded_previews = {} if key in attached: return attached[key] spec = PREVIEWS.get(key) if spec is None: return None host = _attach(screen, key, spec, in_heading=True) if host is None: return None host.toggle.setVisible(False) attached[key] = host return host
def _attach(screen: QWidget, app_key: str, spec: PreviewSpec, *, in_heading: bool = False) -> Optional[_PreviewHost]: """Build ``spec``'s card, insert it hidden, and give it a toggle. :param in_heading: put the toggle in the Actions heading row when the screen has one. A folded preview asks for this (item 520): its toggle used to land in the Actions body or the settings strip, and both fold away when a focus pane such as Plot figures opens, which took the Track preview switch off Mask the moment a run drew its first figure. The heading row is the one that stays on screen, and the host's own Live switch already lives there. """ build = _resolve(spec.builder) if build is None: return None fill = _resolve(spec.fill) if spec.fill else None try: if fill is not None: panel, card = build(screen, panel_later=True) else: panel, card = build(screen) except Exception: LOG.debug("preview builder failed for %r", app_key, exc_info=True) return None if not _insert_above_console(screen, card): card.setParent(None) card.deleteLater() return None card.setVisible(False) adopt = getattr(screen, "adopt_runtime_pane", None) if callable(adopt): try: adopt(card, focus=True) except Exception: # noqa: BLE001 LOG.debug("could not name the preview's pane", exc_info=True) from .widgets.preview_refresh import install_refresh_button host = _PreviewHost(screen, spec, panel, card, fill) if fill is None: install_refresh_button(screen, card, panel) else: install_refresh_button(screen, card, None, panel_getter=lambda: host.panel) if app_key == 'host_pathogen': from .widgets.ai_toggle_label import AiToggleLabel toggle = AiToggleLabel(screen, text='Live', tooltip=spec.tooltip) else: toggle = QToolButton() toggle.setObjectName("SettingsPreviewToggle") toggle.setText(spec.title) toggle.setCheckable(True) toggle.setCursor(Qt.PointingHandCursor) toggle.setToolTip(spec.tooltip or "Show a preview of what these settings produce.") toggle.toggled.connect(host.on_toggled) host.toggle = toggle bar = getattr(screen, "_settings_search", None) heading = getattr(screen, '_actions_heading_row', None) if (app_key == 'host_pathogen' or in_heading) and heading is not None: heading.addWidget(toggle) elif bar is not None and hasattr(bar, "add_trailing_widget"): bar.add_trailing_widget(toggle) else: toggle.setParent(screen) _insert_above_actions(screen, toggle) return host def _dress_a_late_panel(screen: QWidget, panel: QWidget) -> None: """Translate and polish a preview panel built after its screen opened. Through the screen's own hook, the one :class:`~spacr.qt.screens.app_screen.AppScreen` runs over its deferred parts; a screen without it gets nothing. The layout-container sweep that hook's sibling runs is deliberately NOT run: a card attached here always arrived after the screen's sweep, so its containers were never made transparent at install either. The language pass is new -- a card inserted into the runtime splitter was never reached by the screen's late-caption watcher, so an attached preview opened in English on a translated screen. Never raises. """ hook = getattr(screen, "_translate_a_late_part", None) if callable(hook): try: hook(panel) except Exception: LOG.debug("could not translate the preview panel", exc_info=True) def _insert_above_console(screen: QWidget, widget: QWidget) -> bool: """Put a preview card in the runtime splitter, directly ABOVE the console. WHY IT WOULD OTHERWISE BE UNDER IT. Every preview card went through :func:`_insert_above_actions`, which puts a widget in the runtime panel just above the Run row -- and the figures/console splitter is added to that same panel BEFORE the actions row. So "above the Run button" is below the console, and the preview landed under the log it was supposed to be read beside. Mask never showed the bug and that is why it went unnoticed: its screen builds the live preview into the splitter itself, between the figures and the console, and never calls this path. The modules that get their preview from the registry -- Plaque Assay and Cellpose Masks -- got the Run-row placement instead, so the same card sat in two different places depending on which screen mounted it. The splitter is the right home rather than a different index in the panel: a card above the console INSIDE it can be resized against the console, which is the whole reason Mask's is there. :param screen: the module screen. :param widget: the card to insert. :returns: whether it went into the splitter. False means the caller should fall back, and :func:`_insert_above_actions` is still that fallback -- a screen with no splitter, or one whose console is not in it, is better off with the preview above the Run row than with no preview at all. """ splitter = getattr(screen, "_runtime_splitter", None) console = getattr(screen, "_console_wrap", None) if splitter is None or console is None: return _insert_above_actions(screen, widget) try: index = splitter.indexOf(console) except (AttributeError, RuntimeError): return _insert_above_actions(screen, widget) if index < 0: return _insert_above_actions(screen, widget) splitter.insertWidget(index, widget) return True def _insert_above_actions(screen: QWidget, widget: QWidget) -> bool: """Put ``widget`` in the runtime panel just above the Run row. Both anchors are attributes ``AppScreen`` keeps for exactly this kind of reach, so nothing here depends on that panel's internal layout order. The same helper :mod:`spacr.qt.prerun` uses, for the same reason: above the actions row is the last thing the eye crosses on the way to Run. """ wrap = getattr(screen, "_runtime_wrap", None) actions = getattr(screen, "_actions_row", None) if wrap is None or actions is None: return False layout = wrap.layout() holder = actions.parentWidget() if (holder is not None and holder is not wrap and wrap.isAncestorOf(holder) and holder.layout() is not None and holder.layout().indexOf(actions) >= 0): layout = holder.layout() if layout is None: return False index = layout.indexOf(actions) layout.insertWidget(index if index >= 0 else layout.count(), widget) return True class _StackWatcher(QObject): """Attaches a declared preview to each screen as it is first shown.""" def __init__(self, window: QMainWindow): """Watch a window's stack and install into each screen as it is shown. :param window: the main window. Its stack is read at install time, not here, so this works for screens created after the watcher -- and it is the QObject PARENT, so a currentChanged arriving during teardown cannot reach a watcher holding a deleted stack. """ super().__init__(window) self._window = window def on_current_changed(self, _index: int) -> None: """Install into whatever screen the stack just switched to.""" self.install_current() def install_current(self) -> Optional[_PreviewHost]: """Install into the stack's current widget, if it declares one.""" try: screen = self._window._stack.currentWidget() except Exception: return None if screen is None: return None return install(screen)
[docs] def install_window_hooks(window: QMainWindow) -> Optional[_StackWatcher]: """Follow ``window``'s screen stack, attaching declared previews. Called once from :func:`spacr.qt.shortcuts.install`, after the settings strip's own hook so the toggle has somewhere to go. :param window: main window whose ``_stack`` screen stack is followed; without one nothing is installed, and a watcher already stored on it is returned instead of a new one. """ stack = getattr(window, "_stack", None) if stack is None: return None if getattr(window, "_preview_watcher", None) is not None: return window._preview_watcher watcher = _StackWatcher(window) try: stack.currentChanged.connect(watcher.on_current_changed) except Exception: LOG.debug("could not follow the screen stack", exc_info=True) return None window._preview_watcher = watcher QTimer.singleShot(0, watcher.install_current) return watcher