"""A row of icon buttons, one per module folded into a host screen.
A module that has been folded into another one stops being a tile on Home
and becomes a button on its host's masthead. The button IS the module's
icon: no text beside it, the module's own one-line description as its
tooltip, and the same maturity colour on hover that the tile used to
light up in -- green-cyan for alpha, magenta for beta, blue for stable.
That last part is the whole point of doing it here rather than with a
plain ``QPushButton`` at each site. The hover colour is not decoration,
it is the maturity of the code behind the button, and it is read from
:data:`spacr.qt.theme.STAGE_HOVER` through :func:`spacr.qt.app.app_stage`
-- the same two tables the tiles read. A fold that hard-coded its colour
would drift the day a module was signed off, and the button would go on
promising alpha long after the tile had stopped.
Typical use, from a host screen's ``__init__``::
from spacr.qt.widgets.fold_strip import FoldStrip
self.folds = FoldStrip([
("train_cellpose", self._open_training),
("model_zoo", self._open_model_zoo),
], parent=self)
masthead_layout.addWidget(self.folds)
Each entry is ``(app_key, callback)``, or ``(app_key, callback, checkable)``
when the button is a switch rather than a press. The key is the registry
key the module had as a tile, which is what supplies the icon, the name
and the stage; the callback is what the button does when pressed.
A checkable fold controls a workflow implemented by settings on its host.
Timelapse settings are mounted on Mask Generation because tracking extends
the segmentation run; its button reveals those categories and enables the
corresponding pipeline stage. Motility Assay instead opens from Measure and
analyzes existing masks. A checkable button remains active while its workflow
is enabled, and its callback receives the new state.
"""
from __future__ import annotations
from typing import Callable, Iterable, Optional, Sequence, Tuple, Union
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QWidget
from .. import iconset
#: Edge of the square icon button, in logical pixels before font scaling.
BUTTON_PX = 51
#: Edge of the icon inside it. Smaller than the button so the hover fill
#: reads as a plate behind the mark rather than as a border touching it.
ICON_PX = 30
#: Space between two folded buttons.
#:
#: IT GREW WITH THEM. The complaint that sent the buttons up by half was
#: that they read as CROWDED, and buttons that grow while the gap stays
#: put are more crowded, not less -- the same gap between larger marks is
#: a smaller share of the strip.
GAP_PX = 9
#: The objectName every fold button carries, so one QSS rule in
#: :mod:`spacr.qt.theme` can style all of them at once.
BUTTON_NAME = "FoldButton"
#: One entry in a strip.
#:
#: ``(key, callback)`` for a button that opens something; the callback
#: takes no arguments, because "pressed" carries no state worth passing
#: on. ``(key, callback, True)`` for a switch, whose callback is handed
#: the new state so that one function answers both directions.
FoldEntry = Union[Tuple[str, Callable[[], None]],
Tuple[str, Callable[[bool], None], bool]]
#: How strongly a checked fold button is filled with its stage colour.
#:
#: Between the hover fill (0.22) and the pressed one (0.40) in
#: :mod:`spacr.qt.theme`, so a switched-on module reads as more than
#: hovered and less than held down -- and hovering one that is already on
#: still changes it, which is what tells a user the button is live.
CHECKED_ALPHA = 0.30
#: The host screens that fold other modules into themselves.
#:
#: Membership comes from each host's ``FOLDED_APPS`` (or Make Masks'
#: ``FOLD_ORDER``), while the name, description, and maturity come from the
#: available ``FOLD_FALLBACK`` tables. Keeping those questions separate is
#: important: Map Barcodes carries shared fallback copy for several other
#: hosts, but it does not draw their buttons.
FOLD_HOST_MODULES = (
"spacr.qt.screens.organism_screen",
"spacr.qt.screens.make_masks",
"spacr.qt.screens.foreign",
"spacr.qt.screens.map_barcodes",
"spacr.qt.screens.image_umap",
"spacr.qt.screens.regression",
"spacr.qt.screens.measure",
"spacr.qt.screens.mask",
"spacr.qt.screens.classify",
"spacr.qt.screens.annotate",
"spacr.qt.screens.graph_builder",
"spacr.qt.screens.qc_dashboard",
"spacr.qt.screens.db_browser",
)
def _registered(key: str):
"""``(name, description, stage)`` from the registry, or None.
THE STAGE IS ASKED FOR RATHER THAN READ, and the difference is visible:
:func:`spacr.qt.maturity.apply` reassesses modules at launch, so the
literal in the registry row is not always what the open window uses, and
a button built from the literal lights a beta module in the colour of
finished code.
None for a key with no row, so a fold that has no record anywhere is
still absent from :func:`folded_modules` -- which is what its invariant
test reports, and what would otherwise be papered over with the key
title-cased.
"""
try:
from .. import app as app_module
except Exception: # noqa: BLE001
return None
stage_of = getattr(app_module, "app_stage", None)
for row in getattr(app_module, "APPS", ()):
if row and row[0] == key:
stage = stage_of(key) if callable(stage_of) else "stable"
return (row[1] or "", row[2] or "", stage)
return None
def _declared(key: str):
"""``(name, description, stage)`` from the declared catalogue, or None.
THE LAST SOURCE, AFTER THE HOSTS' OWN RECORDS, and the order matters: a
host's ``FOLD_FALLBACK`` carries the maturity somebody ASSESSED, while
the catalogue carries the literal a module was declared with, and
:func:`spacr.qt.maturity.apply` moves the first at launch. Consulted
ahead of the fallback, this table lit three Regression buttons in the
colour of finished code for modules assessed alpha.
It is consulted at all because several rows only reach ``APPS`` through
:func:`spacr.qt.register_self_registering_modules`, which has not
necessarily run when the dock is built -- so a module that is perfectly
well registered in the running window was invisible for no reason but
the order of two calls. `app_catalog` is a static table with no Qt or
scientific imports behind it, so reading it costs nothing at startup,
which importing the screen would not.
"""
try:
from ..app_catalog import DECLARED_APPS
except Exception: # noqa: BLE001
return None
for declared in DECLARED_APPS:
if declared.key == key:
return (declared.name or "", declared.desc or "",
declared.stage or "stable")
return None
[docs]
def fold_label(key: str) -> Tuple[str, str, str]:
"""``(name, description, stage)`` for one folded key, from anywhere.
THE ANSWER FOR A CALLER THAT HAS A KEY AND NEEDS WORDS, whether or not
that key's host is one this module walks. :func:`folded_modules` walks
eight hosts; the dock draws rows for eleven, because four more grew fold
strips later and are named only in :data:`spacr.qt.app._EXTRA_FOLD_HOSTS`
-- so the seven modules folded onto Graph Builder, QC Dashboard and
Database Browser had no catalogue entry and their rows read as the raw
key: `lineage`, `tabulate`, `control_chart`.
Adding those hosts here would fix the rows and import three more screen
modules while the window is still being built, which is precisely the
startup cost this strip exists to avoid. Asking for the words instead
of for the host costs nothing.
:param key: the folded module's app key. The registry answers first, then
the hosts' fold fallbacks, then the declared catalogue; an unknown key
gets its title-cased name, no description and stage ``stable``.
"""
return _describe(key)
#: `_host_declarations` answers, keyed by dotted module name.
#:
#: THE SOURCE OF A HOST CANNOT CHANGE WHILE THE APPLICATION RUNS, so parsing
#: it twice can only ever produce the same answer twice. It was parsed a great
#: deal more than twice: `folded_fallback` asks `folded_modules` per key, and
#: `folded_modules` walks every host, so ONE Mask screen open ran `ast.parse`
#: 73 times and spent 0.71 s of its 2.05 s doing it -- a third of the open,
#: compiling Python nobody was going to execute.
#:
#: The cache lives HERE and deliberately not in `folded_modules`, whose answer
#: depends on registration state and is meant to grow as modules register --
#: and which two tests call twice, expecting the second answer to differ after
#: they replace this function. Replacing it bypasses this cache, which is the
#: behaviour those tests need.
_HOST_DECLARATION_CACHE: dict = {}
def _forget_host_declarations() -> None:
"""Drop the parsed-source cache.
For a test that edits a host's source on disk mid-process, which the
application never does.
"""
_HOST_DECLARATION_CACHE.clear()
from ..app import _TOP_LEVEL_CACHE
_TOP_LEVEL_CACHE.clear()
def _host_declarations(module_name: str):
"""``(folded keys, fallback table)`` read from a host's SOURCE.
Reads `FOLDED_APPS` (or `FOLD_ORDER`) and `FOLD_FALLBACK` out of the
syntax tree, which costs a file read and imports nothing. Three of the
six hosts build `FOLD_FALLBACK` from expressions rather than writing a
literal; those come back empty and the caller answers from the registry
and the declared catalogue instead, exactly as it already does for a key
no table names.
:param module_name: dotted name of the host module.
:returns: ``(members, table)``, or ``None`` when the source cannot be read.
"""
import ast
if module_name in _HOST_DECLARATION_CACHE:
return _HOST_DECLARATION_CACHE[module_name]
from ..app import _top_level_nodes
body = _top_level_nodes(module_name)
if body is None:
_HOST_DECLARATION_CACHE[module_name] = None
return None
constants: dict = {}
declared: dict = {}
siblings: dict = {}
package = module_name.rpartition(".")[0]
for node in body:
if isinstance(node, ast.ImportFrom) and node.level:
base = package
for _ in range(node.level - 1):
base = base.rpartition(".")[0]
if node.module:
base = f"{base}.{node.module}"
for alias in node.names:
siblings[alias.asname or alias.name] = f"{base}.{alias.name}"
if isinstance(node, ast.AnnAssign):
targets, value = [node.target], node.value
elif isinstance(node, ast.Assign):
targets, value = node.targets, node.value
else:
continue
if value is None:
continue
for target in targets:
if not isinstance(target, ast.Name):
continue
if isinstance(value, ast.Constant) and isinstance(value.value, str):
constants[target.id] = value.value
if target.id in ("FOLDED_APPS", "FOLD_ORDER", "FOLD_FALLBACK"):
declared[target.id] = value
def _as_string(node):
"""The string an AST node stands for, or ``None`` if it is not one.
Handles a literal, a constant in this module, and a constant reached
through an imported sibling -- read from the source rather than by
importing the module that defines it.
"""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
return constants.get(node.id)
if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
owner = siblings.get(node.value.id)
if owner:
return _sibling_constant(owner, node.attr)
return None
members: tuple = ()
for name in ("FOLDED_APPS", "FOLD_ORDER"):
node = declared.get(name)
if isinstance(node, (ast.Tuple, ast.List)):
keys = [_as_string(element) for element in node.elts]
if all(keys):
members = tuple(keys)
break
table: dict = {}
fallback = declared.get("FOLD_FALLBACK")
if isinstance(fallback, ast.Dict):
for key_node, value_node in zip(fallback.keys, fallback.values):
key = _as_string(key_node)
if key is None:
continue
try:
table[key] = ast.literal_eval(value_node)
except Exception: # noqa: BLE001
continue
_HOST_DECLARATION_CACHE[module_name] = (members, table)
return members, table
def _sibling_constant(module_name: str, name: str):
"""One module-level string constant, read from source without importing."""
import ast
from ..app import _top_level_nodes
for node in _top_level_nodes(module_name) or ():
if isinstance(node, ast.AnnAssign):
targets, value = [node.target], node.value
elif isinstance(node, ast.Assign):
targets, value = node.targets, node.value
else:
continue
if not isinstance(value, ast.Constant) or not isinstance(
value.value, str):
continue
for target in targets:
if isinstance(target, ast.Name) and target.id == name:
return value.value
return None
[docs]
def folded_modules() -> dict:
"""Every folded key, as ``key -> (name, description, stage, host)``.
The host is determined by the button list it actually draws, not by the
location of fallback text. The latter may be shared: Map Barcodes keeps
descriptions for folds owned by Image UMAP, Regression, Mask, and other
screens. Treating that shared catalog as membership sends documentation
and tutorial links to the wrong screen.
A host's own fallback record is preferred. If it does not keep one, the
other host tables are searched in declaration order. The first host to
list a duplicated key still wins, making an accidental double fold
deterministic until its invariant test reports the duplication.
AND THEN THE REGISTRY, for a fold that still holds its row. Those keep
their name, sentence and maturity where every other module keeps them,
so their hosts deliberately record nothing -- Regression says so in as
many words beside `investigate_hit` and `profiler`. Reading only the
fallback tables therefore left four folded modules out of this answer
entirely, and the dock drew their rows as the raw key: a user looking
for Investigate Hit found `investigate_hit` indented under Regression.
A copy of the registry text in each host would fix the rows and be the
same sentence written twice, with the copy free to go stale; asking the
registry costs nothing and cannot.
Imported lazily, one host at a time and each guarded: this module is
imported while a screen is being built, and the hosts import it back.
THE REGISTRY HALF IS ONLY COMPLETE ONCE THE REGISTRY IS. Several rows
arrive from :func:`spacr.qt.register_self_registering_modules`, so a
caller that asks before registration gets the folds whose hosts kept a
record and not the ones whose rows have not been added yet. Every caller
inside the application asks after the window is built; a test that asks
earlier has to register first, and one that does not is testing the
order it happened to import in.
:returns: a fresh dict; callers may keep or mutate it.
"""
hosts = []
for module_name in FOLD_HOST_MODULES:
declared = _host_declarations(module_name)
if declared is None:
continue
members, table = declared
hosts.append((module_name, members, table))
fallback_tables = [table for _name, _members, table in hosts if table]
found: dict = {}
for module_name, members, own_table in hosts:
for key in members:
entry = own_table.get(key)
if not entry:
entry = next(
(table[key] for table in fallback_tables if table.get(key)),
None,
)
if not entry:
entry = _registered(key) or _declared(key)
if key in found or not entry:
continue
name, description, stage = (tuple(entry) + ("", "", ""))[:3]
found[key] = (name, description, stage or "stable", module_name)
return found
[docs]
def folded_fallback(key: str) -> Tuple[str, str, str]:
"""``(name, description, stage)`` a folded key kept, or three blanks.
The answer for a module that is a button on some host's masthead rather
than a tile on Home. Blank when the key is not folded anywhere, so a
caller can tell "folded, and this is what it said" from "never heard of
it" -- which the registry cannot, because it answers both the same way.
:param key: the module's app key, looked up as a string in
:func:`folded_modules`.
"""
entry = folded_modules().get(str(key))
return (entry[0], entry[1], entry[2]) if entry else ("", "", "")
def _describe(key: str) -> Tuple[str, str, str]:
"""Return ``(name, description, stage)`` for one folded module's key.
THE REGISTRY ANSWERS FIRST, AND STOPS ANSWERING the day the key's row
is dropped -- which is how folding a module ends. From then on the
only record is the host's own ``FOLD_FALLBACK``, and it has to be
consulted for all three fields rather than just the name:
:func:`spacr.qt.app.app_stage` reports "stable" for a key it has never
heard of, so a module somebody assessed as alpha goes on lighting up
in the colour of finished code. The name matters as much -- without
the fallback it comes back as the key title-cased, which turns Explain
CV Model into "Explain Cv" and AnnData Export into "Anndata Export".
Imported lazily and defensively: this widget is constructed while a
screen is being built, and :mod:`spacr.qt.app` imports screens. A
module-level import would close that circle.
"""
default_name = key.replace("_", " ").title()
known = _registered(key)
if known is not None:
return (known[0] or default_name, known[1], known[2] or "stable")
kept_name, kept_description, kept_stage = folded_fallback(key)
if kept_name or kept_description:
return (kept_name or default_name, kept_description,
kept_stage or "stable")
declared = _declared(key)
if declared is not None:
return (declared[0] or default_name, declared[1],
declared[2] or "stable")
return default_name, "", "stable"
[docs]
class FoldStrip(QWidget):
"""The row of :class:`FoldButton` for one host screen."""
def __init__(
self,
folds: Iterable[FoldEntry],
parent: Optional[QWidget] = None,
) -> None:
"""Build one button per fold.
:param folds: ``(key, callback)`` for a button that opens
something, or ``(key, callback, True)`` for one that switches
a module on and off. A switch is handed the new state, so the
one callback answers both directions; a plain button is
called with no arguments, because "pressed" carries no state
worth passing on.
:param parent: the masthead the strip is hung on.
"""
super().__init__(parent)
self.buttons: list[FoldButton] = []
row = QHBoxLayout(self)
row.setContentsMargins(0, 0, 0, 0)
self._apply_icon_scale(None)
for entry in folds:
key, callback = entry[0], entry[1]
checkable = bool(entry[2]) if len(entry) > 2 else False
button = FoldButton(key, self, checkable=checkable)
if callable(callback):
if checkable:
button.toggled.connect(
lambda on, cb=callback: cb(on))
else:
button.clicked.connect(
lambda _checked=False, cb=callback: cb())
row.addWidget(button)
self.buttons.append(button)
row.addStretch(1)
def _apply_icon_scale(self, scale=None) -> None:
"""Re-space the strip at the interface scale.
THE GAP IS PART OF THE ICON GEOMETRY. :data:`GAP_PX` records why:
the buttons were sent up by half because the strip read as
crowded, and a gap that stays put while the marks grow is a
smaller share of the strip than it was -- more crowded, not less.
The same argument applies to the scale, so the gap follows it for
the same reason it followed the button.
Called at construction and again from
:func:`spacr.qt.preferences._rescale_icon_sizes`, which finds it by
name; the buttons re-square themselves through their own copy of
this method, so this one only has the spacing to do.
:param scale: the interface scale; the stored preference when None.
"""
from ..preferences import _scaled_side, get_font_scale
row = self.layout()
if row is None:
return
if scale is None:
scale = get_font_scale()
row.setSpacing(_scaled_side(GAP_PX, scale))
[docs]
def keys(self) -> Sequence[str]:
"""The registry keys this strip carries, in the order shown."""
return [b.app_key for b in self.buttons]
[docs]
def mark_folded_sections(key: str, sections: Iterable[QWidget]
) -> Tuple[str, ...]:
"""Mark settings-section headings with a folded module's icon.
:param key: Folded module registry key.
:param sections: Section widgets containing that module's settings.
:returns: Titles of sections marked successfully. Invalid sections and
modules without artwork are skipped.
"""
name = _describe(key)[0]
marked = []
for section in sections:
setter = getattr(section, "set_source_app", None)
if not callable(setter):
continue
try:
if setter(key, name):
marked.append(_category_title(section))
except Exception: # noqa: BLE001
continue
return tuple(marked)
[docs]
def mark_folded_categories(sections: Iterable[QWidget],
categories: dict) -> dict:
"""Mark host categories associated with folded modules.
:param sections: Host settings sections.
:param categories: Mapping from module key to category titles. Titles are
matched case-insensitively.
:returns: Mapping from module keys to titles marked successfully.
"""
by_title = {}
for section in sections:
by_title.setdefault(_category_title(section).strip().upper(),
[]).append(section)
marked = {}
for key, titles in (categories or {}).items():
found = []
for title in titles:
for section in by_title.get(str(title).strip().upper(), ()):
found.extend(mark_folded_sections(key, (section,)))
if found:
marked[key] = tuple(found)
return marked
def _category_title(section) -> str:
"""What a settings category is called, as it was written down.
``settingsCategorySource`` is the mixed-case name every settings
section carries; ``title()`` answers with the uppercased heading, so
it is the fallback rather than the first question.
"""
source = section.property("settingsCategorySource")
if source:
return str(source)
title = getattr(section, "title", None)
return str(title() if callable(title) else (title or ""))