"""The repeated per-object settings, drawn as one row per question.
78 of Mask's 201 settings are the SAME twenty-odd questions asked once per
object type -- ``cell_diameter``, ``nucleus_diameter``,
``pathogen_diameter``, ``organelle_diameter`` -- and a form that lists them
flat asks 203 questions before anything is segmented. A table was chosen over
tabs and over leaving the names flat.
:mod:`spacr.object_settings_table` is the model and draws nothing; this is
the view over it. The split matters more than it looks: the stored keys never
change, so no settings file, notebook, tutorial or ``spacr-run`` invocation
migrates. What was wrong was the presentation, so only the presentation
changes.
WHY THIS SHAPE IS WHAT LETS AN ARBITRARY ORGANELLE COUNT LAND. The number of
organelles a run may declare is not fixed. In a flat vocabulary each new organelle is twenty new
settings that every tooltip table and translation catalog has to learn; here
it is one COLUMN, and the number of questions does not move.
:meth:`ObjectSettingsGrid.add_object` is that operation, and it starts a new
organelle from the first one's answers rather than from a global default
nobody chose.
TWO THINGS THIS VIEW IS CAREFUL ABOUT, both of which would corrupt a settings
file rather than merely look wrong:
* **A value keeps its type.** ``cell_diameter`` is an int, ``organelle_
cellprob_threshold`` is a float, and a cell edited in a table arrives as a
string. Writing ``"12"`` where ``12`` was is a settings file that has
quietly changed meaning, and the pipeline reading it either coerces
silently or fails a long way from here.
* **A question an object does not ask stays absent.** ``cytoplasm`` has no
channel, no diameter and no detection method -- it is DERIVED, cell minus
the rest, not found in a channel. Those cells are blank and not editable,
because writing a value there invents a key nothing reads.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Mapping, Optional, Tuple
from PySide6.QtCore import (QAbstractTableModel, QEvent, QModelIndex, QSize,
Qt, QTimer, Signal)
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QPushButton,
QSizePolicy,
QTableView,
QVBoxLayout,
QWidget,
)
LOG = logging.getLogger(__name__)
from ...object_roles import setting_label
from ...object_settings_table import (OBJECT_ORDER, _FILTER_PREFIX,
_SINGLE_OBJECT_QUESTIONS, _filter_text,
_parse_filter_text, _settings_key,
column_label, from_table, to_table,
widen)
from ...organelle_types import MAX_ORGANELLES, organelle_role
from ..theme import SPACING
from .sortable_table import install_sorting
__all__ = ["AUTO_TEXT", "OFF_TEXT", "ObjectSettingsGrid",
"ObjectSettingsModel"]
#: What an unset value reads as. ``None`` means "work it out" for most of
#: these -- a diameter of None is Cellpose estimating it -- and an empty cell
#: would read as "nobody has filled this in yet", which is a different claim.
AUTO_TEXT = "auto"
#: What an unset CHANNEL reads as, and it is not "auto".
#:
#: ``cell_channel = None`` does not mean spaCR picks a channel. It means no
#: cell masks, no cell table and no cell crops are produced -- the object is
#: not segmented at all. Drawn as "auto" it read as a promise to work
#: something out, which is the opposite of what it does.
OFF_TEXT = "off"
#: Questions whose ``None`` means OFF rather than AUTO.
_OFF_QUESTIONS = frozenset({"channel"})
#: The question whose cells get a model-zoo button, one per object column.
#:
#: A MODEL IS PER OBJECT. Cells and pathogens are not segmented by the same
#: checkpoint, so a single button for the row would be a button that has to
#: ask which column it meant. The button sits in the cell and already knows.
MODEL_QUESTION = "model_name"
def _unset_text(question: str) -> str:
"""How an unset value reads for ``question``."""
if question in _OFF_QUESTIONS or _is_filter(question):
return OFF_TEXT
return AUTO_TEXT
def _is_filter(question: str) -> bool:
"""Whether ``question`` is an object-filter row (``filter:area``)."""
return str(question).startswith(_FILTER_PREFIX)
def _cell_key(obj: str, question: str) -> str:
"""The settings key a cell edits; ``object_filters`` for a filter row."""
if _is_filter(question):
return "object_filters"
return _settings_key(obj, question)
#: The questions that say whether an object is in the run at all.
#:
#: 2026-09-29 (item 592): not drawn as a table row. A column is hidden while
#: its object's channel is unset, and a hidden column cannot hold the cell
#: that would bring it back, so the channel stays on the ordinary form.
_SWITCH_QUESTIONS = frozenset({"channel", "mask_dim"})
def _names_a_plane(value) -> bool:
"""Whether a channel value names a plane of the stack.
The form's rule (:func:`spacr.qt.screens.settings_model._names_a_plane`),
restated so the grid does not import the settings panel: ``None``,
``False``, blank and ``"none"`` name no plane; ``0`` is the first plane.
:param value: the channel setting's value.
"""
if value is None or isinstance(value, bool):
return False
if isinstance(value, (int, float)):
return True
text = str(value).strip()
if not text or text.lower() == "none":
return False
try:
float(text)
except ValueError:
return False
return True
def _object_is_in_the_run(obj: str, settings: Mapping[str, Any]) -> bool:
"""Whether ``obj`` gets a column: cell always, others once a channel is set.
2026-09-29 (item 592, "hide unset objects"). Cell is the reference
object and is never gated by its channel, the same rule the flat form
keeps. An object with no switch in ``settings`` at all (cytoplasm, which
is derived) cannot be switched off and keeps its column.
:param obj: an object column, e.g. ``"nucleus"`` or ``"organelleb"``.
:param settings: the flat settings the table was read from.
"""
if obj == "cell":
return True
switches = [f"{obj}_{question}" for question in sorted(_SWITCH_QUESTIONS)
if f"{obj}_{question}" in settings]
if not switches:
return True
return any(_names_a_plane(settings.get(key)) for key in switches)
def _filter_objects():
"""The objects an object filter may be set for, in table order.
Cytoplasm is left out: it is derived from the other masks at measure
time, and a Mask run filters only the objects it segments.
"""
return tuple(obj for obj in OBJECT_ORDER if obj != "cytoplasm")
def _question_help(question: str, obj: str) -> str:
"""The settings description behind one cell, or "" when there is none.
READ FROM :data:`spacr.settings.tooltips`, THE ONE THE FLAT FORM USES.
A table that wrote its own sentences would be a second set of
explanations to keep in step with the first, and the two would disagree
first where nobody was looking. ``descriptions`` is a different dict and
does not carry these keys -- reading it returned nothing for every row,
which is a tooltip that silently says the key back.
Falls back to another object's answer to the SAME question, because the
row is one question and the flat vocabulary spells it once per object:
``cell_channel`` is written up and ``organellec_channel`` is not.
"""
try:
from ...settings import tooltips
except Exception: # noqa: BLE001
return ""
return str(tooltips.get(_cell_key(obj, question), "") or "")
def _checked(value) -> bool:
"""Whether a check-state value from a view means checked.
:param value: a ``Qt.CheckState``, its integer, or a bool.
"""
if isinstance(value, bool):
return value
try:
return int(getattr(value, "value", value)) == int(
Qt.CheckState.Checked.value)
except (TypeError, ValueError):
return str(value).strip().lower() in ("1", "true", "yes", "on")
def _coerce(text: str, like: Any) -> Any:
"""Read ``text`` back as the type ``like`` already had.
THE WHOLE REASON THIS FUNCTION EXISTS is that a table hands back strings.
``cell_diameter`` is an int and ``organelle_cellprob_threshold`` is a
float; storing either as ``"12"`` is a settings file that has changed
meaning without anyone saying so.
:param text: what the user typed.
:param like: the value being replaced, whose type is the target. When it
is ``None`` there is no type to copy -- the caller passes a sibling
answer from the same row instead, because the same question about a
different object is the best evidence available about what this one
is.
"""
raw = str(text).strip()
if raw == "" or raw.lower() in (AUTO_TEXT, OFF_TEXT):
return None
if isinstance(like, bool):
return raw.lower() in ("1", "true", "yes", "on")
for kind in ((int, float) if isinstance(like, (int, float))
and not isinstance(like, bool) else ()):
try:
return kind(raw)
except (TypeError, ValueError):
continue
if like is None or isinstance(like, str):
for kind in (int, float):
try:
return kind(raw)
except (TypeError, ValueError):
continue
if raw.lower() in ("true", "false"):
return raw.lower() == "true"
return raw
[docs]
class ObjectSettingsModel(QAbstractTableModel):
"""One row per question, one column per object type.
A model rather than a widget full of cells because the table is 55 rows
by as many objects as the run has, and every one of those cells would
otherwise be a widget the form has to build, lay out and translate.
:param parent: parent widget.
"""
#: Emitted when a cell's value actually changed.
edited = Signal()
def __init__(self, parent=None):
"""Create the empty per-object settings table model.
:param parent: parent object, or ``None``.
"""
super().__init__(parent)
self._table: Dict[str, Dict[str, Any]] = {}
self._questions: Tuple[str, ...] = ()
self._objects: Tuple[str, ...] = ()
[docs]
def set_table(self, table: Mapping[str, Mapping[str, Any]]) -> None:
"""Show ``table``, as :func:`spacr.object_settings_table.to_table`
returns it.
:param table: ``{question: {object: value}}`` mapping, or ``None`` for
an empty table. Rows keep its order; columns follow the canonical
object order.
"""
self.beginResetModel()
self._table = {q: dict(row) for q, row in (table or {}).items()}
self._questions = tuple(self._table)
order = {name: index for index, name in enumerate(OBJECT_ORDER)}
present = {obj for row in self._table.values() for obj in row}
self._objects = tuple(sorted(
present, key=lambda o: order.get(o, len(order))))
self.endResetModel()
[docs]
def table(self) -> Dict[str, Dict[str, Any]]:
"""The table as it now stands, including every edit."""
return {q: dict(row) for q, row in self._table.items()}
[docs]
def objects(self) -> Tuple[str, ...]:
"""The object columns, in the order they are drawn."""
return self._objects
[docs]
def question_at(self, row: int) -> str:
"""The settings question one row asks, or ``''``.
:param row: zero-based table row; out of range gives ``''``.
"""
return self._questions[row] if 0 <= row < len(self._questions) else ""
[docs]
def value_at(self, question: str, obj: str) -> Any:
"""One cell's stored value. ``KeyError``-free: absent is ``None``.
:param question: settings question, i.e. the key suffix shared by every
object (``"min_area"`` for ``cell_min_area``).
:param obj: object name, the key prefix (``"cell"``, ``"nucleus"``, ...).
"""
return self._table.get(question, {}).get(obj)
[docs]
def asks(self, question: str, obj: str) -> bool:
"""Whether ``obj`` asks ``question`` at all.
Absence is a fact about the object, not a value it has yet to be
given: cytoplasm is derived and has no channel to be found in.
:param question: settings question, i.e. the key suffix shared by every
object (``"min_area"`` for ``cell_min_area``).
:param obj: object name, the key prefix (``"cell"``, ``"nucleus"``, ...).
"""
return obj in self._table.get(question, {})
[docs]
def rowCount(self, parent=QModelIndex()) -> int:
"""How many questions the table asks.
:param parent: unused; the model is flat.
:returns: the row count.
"""
return 0 if parent.isValid() else len(self._questions)
[docs]
def columnCount(self, parent=QModelIndex()) -> int:
"""How many objects the table has a column for.
:param parent: unused; the model is flat.
:returns: the column count.
"""
return 0 if parent.isValid() else len(self._objects)
[docs]
def flags(self, index):
"""Which cells are editable.
ONLY THE CELLS AN OBJECT ACTUALLY ASKS. A blank cell means that
object does not ask that question, and making it editable would
invite an answer to a question nobody posed.
:param index: the cell.
:returns: the Qt item flags.
"""
base = Qt.ItemIsEnabled | Qt.ItemIsSelectable
if not index.isValid():
return base
question = self.question_at(index.row())
obj = self._objects[index.column()]
if not self.asks(question, obj):
return Qt.ItemIsSelectable
if isinstance(self.value_at(question, obj), bool):
return base | Qt.ItemIsEditable | Qt.ItemIsUserCheckable
return base | Qt.ItemIsEditable
[docs]
def data(self, index, role=Qt.DisplayRole):
"""One cell of the table.
:param index: the cell.
:param role: the Qt display role.
:returns: the cell's value for that role, or None.
"""
if not index.isValid():
return None
question = self.question_at(index.row())
obj = self._objects[index.column()]
if not self.asks(question, obj):
if role == Qt.ToolTipRole:
return (f"{column_label(obj)} does not ask this. It is not a "
f"value waiting to be filled in.")
return None
value = self.value_at(question, obj)
unset = _unset_text(question)
if role == Qt.CheckStateRole and isinstance(value, bool):
return Qt.Checked if value else Qt.Unchecked
if role == Qt.DisplayRole:
return unset if value is None else str(value)
if role == Qt.EditRole:
return "" if value is None else str(value)
if role == Qt.ToolTipRole:
head = (f"{_cell_key(obj, question)} = "
f"{unset if value is None else value!r}")
if _is_filter(question):
head += (f"\n\nKeep only {column_label(obj)} objects whose "
f"{question[len(_FILTER_PREFIX):]} is within "
f"min \u2013 max. Type one number for a minimum "
f"alone, \u2013 then a number for a maximum alone, "
f"or clear the cell to switch the filter off.")
if question == MODEL_QUESTION:
head += (f"\n\nClick this cell to choose {column_label(obj)}'s "
f"model from the zoo. Double-click to type a path.")
help_text = _question_help(question, obj)
if question in _OFF_QUESTIONS and value is None:
head += (f"\n\n{column_label(obj)} is NOT SEGMENTED. "
f"Give it a channel number to turn it on.")
return f"{head}\n\n{help_text}" if help_text else head
return None
[docs]
def setData(self, index, value, role=Qt.EditRole) -> bool:
"""Write one cell back into the settings.
:param index: the cell.
:param value: what the user typed.
:param role: the Qt edit role.
:returns: True when the value was taken.
"""
if not index.isValid() or role not in (Qt.EditRole,
Qt.CheckStateRole):
return False
question = self.question_at(index.row())
obj = self._objects[index.column()]
if not self.asks(question, obj):
return False
row = self._table[question]
current = row.get(obj)
if _is_filter(question):
try:
low, high = _parse_filter_text(value)
except ValueError:
return False
new = _filter_text({"min": low, "max": high})
if new == current:
return False
row[obj] = new
self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole])
self.edited.emit()
return True
if role == Qt.CheckStateRole:
value = "true" if _checked(value) else "false"
like = current
if like is None:
like = next((v for o, v in row.items()
if o != obj and v is not None), None)
new = _coerce(value, like)
if new == current and type(new) is type(current):
return False
row[obj] = new
self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole])
self.edited.emit()
return True
class _GridHeightGrip(QFrame):
"""Thin drag handle along the per-object table's lower edge.
The table is one row of a scrolling settings form, so without this it
gets whatever height the form gives it and puts twenty-odd questions
behind an inner scrollbar inside an outer one. Dragging this sets the
height; double-clicking gives it back to the content.
:param grid: the :class:`ObjectSettingsGrid` this resizes. ALSO ITS
QWIDGET PARENT, so the grip is laid out under the table it drags and
cannot outlive it.
"""
HEIGHT = 7
def __init__(self, grid: "ObjectSettingsGrid"):
"""Build the handle and give it a vertical-resize cursor."""
super().__init__(grid)
self._grid = grid
self._press_y: Optional[float] = None
self._start_height = 0
self.setObjectName("ConsoleSectionResizeHandle")
self.setCursor(Qt.SizeVerCursor)
self.setFixedHeight(self.HEIGHT)
source = ("Drag to make the table taller or shorter. "
"Double-click to fit its rows.")
self.setProperty("_spacr_i18n_tooltip", source)
self.setToolTip(source)
def sizeHint(self) -> QSize:
"""Wide and thin -- the handle is an edge, not a bar."""
return QSize(80, self.HEIGHT)
def mousePressEvent(self, event) -> None: # noqa: N802
"""Remember where the drag started, and from what height."""
if event.button() == Qt.LeftButton:
self._press_y = event.globalPosition().y()
self._start_height = self._grid._table.height()
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event) -> None: # noqa: N802
"""Resize the table by how far the pointer has moved since the press.
Measured from the PRESS rather than the last move, so a drag that
outruns the redraw lands where the pointer is instead of accumulating
rounding.
"""
if self._press_y is not None and event.buttons() & Qt.LeftButton:
delta = event.globalPosition().y() - self._press_y
self._grid.set_user_height(self._start_height + int(delta))
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event) -> None: # noqa: N802
"""End the drag."""
self._press_y = None
super().mouseReleaseEvent(event)
def mouseDoubleClickEvent(self, event) -> None: # noqa: N802
"""Give the height back to the content."""
if event.button() == Qt.LeftButton:
self._grid.reset_user_height()
event.accept()
return
super().mouseDoubleClickEvent(event)
def _kind_of(obj: str) -> str:
"""The KIND of object ``obj`` is, collapsing every organelle into one.
`organelle_role_of` answers which SLOT a name is -- ``'organelleb'`` --
which is what the settings keys need and the wrong grain for asking
whether a question is per-object. Two organelles are one kind of thing
asked twice.
"""
from ...organelle_types import organelle_role_of
return "organelle" if organelle_role_of(obj) else obj
def _parsed_filters(raw) -> Dict[str, Any]:
"""``object_filters`` as a mapping, or empty when it cannot be read.
:param raw: the setting's value, a mapping or its JSON/literal text.
"""
if (raw is None or (isinstance(raw, dict) and not raw)
or (isinstance(raw, str) and raw.strip() in ("", "{}"))):
return {}
try:
from ..mask_engine import parse_object_filters
return parse_object_filters(raw)
except Exception: # noqa: BLE001
return {}
[docs]
class ObjectSettingsGrid(QWidget):
"""The per-object settings table, and the button that widens it.
:param parent: parent widget.
"""
#: Emitted when any cell changed, or a column was added.
settings_changed = Signal()
def __init__(self, parent=None):
"""Build the per-object settings grid.
Sorting is installed after the model is set, as the contract requires --
the view is wrapped in a proxy, so the selection model has to be taken
afterwards. Sorting the questions on screen reorders nothing on disk,
because the stored answers are read from the model rather than the view.
The table opens tall enough to show its rows and can be dragged from the
grip: inside a settings panel it is one row of a scrolling form, and a
plain ``QTableView`` default put twenty-odd questions behind an inner
scrollbar inside an outer one.
:param parent: parent widget, or ``None``.
"""
super().__init__(parent)
self._base: Dict[str, Any] = {}
self._model = ObjectSettingsModel(self)
self._model.edited.connect(self.settings_changed)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(SPACING["sm"])
from .hover_tooltip import (ANIMATION_MARK, API_MARK, PURPLE, TEAL,
_AnimationView, _LinkWord)
self._help_band = QWidget(self)
band = QHBoxLayout(self._help_band)
band.setContentsMargins(0, 0, 0, 0)
band.setSpacing(SPACING["sm"])
column = QVBoxLayout()
column.setContentsMargins(0, 0, 0, 0)
column.setSpacing(2)
self._help = QLabel("", self._help_band)
self._help.setObjectName("SubtitleSmall")
self._help.setWordWrap(True)
self._help.setTextFormat(Qt.TextFormat.RichText)
self._help.setAlignment(Qt.AlignmentFlag.AlignLeft
| Qt.AlignmentFlag.AlignTop)
self._help.setSizePolicy(QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed)
column.addWidget(self._help)
self._help_links = QWidget(self._help_band)
links = QHBoxLayout(self._help_links)
links.setContentsMargins(0, 0, 0, 0)
links.setSpacing(SPACING["sm"])
self._help_api = _LinkWord(API_MARK, "HoverTooltipApiLink",
self._help_links)
self._help_api.setAccessibleName("API")
self._help_api.setAccessibleDescription(
"Open spaCR API documentation for this setting.")
self._help_api.clicked.connect(self._open_help_api)
self._help_anim = _LinkWord(ANIMATION_MARK,
"HoverTooltipAnimationLink",
self._help_links)
self._help_anim.setAccessibleName("Animation")
self._help_anim.setAccessibleDescription(
"Show or hide this setting's animation.")
self._help_anim.clicked.connect(self._toggle_help_animation)
links.addWidget(self._help_api)
links.addWidget(self._help_anim)
links.addStretch(1)
self._help_links.setStyleSheet(
f"QLabel#HoverTooltipApiLink {{ color: {TEAL};"
f" text-decoration: none; }}"
f"QLabel#HoverTooltipAnimationLink {{ color: {PURPLE};"
f" text-decoration: none; }}")
column.addWidget(self._help_links)
column.addStretch(1)
band.addLayout(column, 1)
self._help_animation = _AnimationView(self.HELP_ANIMATION_PX,
self._help_band)
self._help_animation.hide()
band.addWidget(self._help_animation, 0,
Qt.AlignmentFlag.AlignTop)
outer.addWidget(self._help_band)
#: The animation offered for the hovered setting, and whether the
#: reader has asked to see it. Local to the band: pressing
#: **Animation** names ONE setting, exactly as the popup's does.
self._help_offered_animation = None
self._help_animation_shown = False
self._help_api_url = ""
self._table = QTableView(self)
self._table.setObjectName("Objects")
self._table.setModel(self._model)
install_sorting(self._table)
self._table.setSelectionBehavior(QAbstractItemView.SelectItems)
self._table.setAlternatingRowColors(True)
self._table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeToContents)
self._table.verticalHeader().setSectionResizeMode(
QHeaderView.ResizeToContents)
self._table.setSizePolicy(QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed)
self._table.clicked.connect(self._cell_clicked)
#: Which module's API the tooltips link to. Set by the screen that
#: mounts the grid -- the table itself has no way to know, and a
#: guess would send the reader to another module's page.
self._app_key = ""
self._hovered_key = ""
self._table.setMouseTracking(True)
self._table.viewport().setMouseTracking(True)
self._table.viewport().installEventFilter(self)
self._user_height: Optional[int] = None
outer.addWidget(self._table)
self._grip = _GridHeightGrip(self)
outer.addWidget(self._grip)
row = QHBoxLayout()
row.setSpacing(SPACING["sm"])
self._status = QLabel("", self)
self._status.setObjectName("Muted")
self._status.setWordWrap(True)
self._add = QPushButton("Add an organelle", self)
self._add.setToolTip(
"One more organelle is one more COLUMN. In the flat settings "
"vocabulary it was twenty new settings, which is why the count "
"could not be arbitrary before this table existed.")
self._add.clicked.connect(self.add_organelle)
self._add_filter = QPushButton("Add a filter", self)
self._add_filter.setObjectName("ObjectGridAddFilter")
self._add_filter.setToolTip(
"Add a row that keeps only the objects whose measurement -- "
"area, mean intensity, solidity or any other scalar region "
"property -- falls between a minimum and a maximum. Fill in the "
"column of every object it should apply to; a blank cell leaves "
"that object unfiltered. Default no filters.")
self._add_filter.clicked.connect(self._offer_filters)
row.addWidget(self._status, 1)
row.addWidget(self._add_filter)
row.addWidget(self._add)
outer.addLayout(row)
#: Filter rows the user added and has not filled in yet, which the
#: settings alone cannot show because an empty filter is no entry.
self._added_filters: list = []
#: The table with EVERY object's column, before the objects whose
#: channel is unset are hidden; what the grid claims from the form.
self._claimed: Dict[str, Dict[str, Any]] = {}
#: Fires once the pointer has rested on a cell long enough.
from ..tooltip_policy import HoverDelay
self._help_hover_delay = HoverDelay(self)
self._help_show_timer = self._help_hover_delay._timer
self._help_pending = ""
#: Fires after the pointer has left, unless it came back.
self._help_hide_timer = QTimer(self)
self._help_hide_timer.setSingleShot(True)
self._help_hide_timer.timeout.connect(lambda: self._write_help(""))
self._help_band.installEventFilter(self)
self._sync_help_height()
self._write_help("")
self.installEventFilter(self)
#: Lines reserved above the table for a setting's help.
#:
#: FIVE, and fixed. Three was the first value and it was not enough:
#: a setting's help is a paragraph, and the longer ones were being cut
#: off. The band has to be tall enough for the longest help a cell can
#: show WITHOUT the label growing when it arrives -- growing would push
#: the table down under the pointer mid-hover and move the cell out
#: from under it, which is the failure the fixed band exists to
#: prevent in the first place. So the cost of being too short is text
#: nobody can read, and the cost of being too tall is a little space:
#: the second is the mistake worth making.
HELP_LINES = 5
#: Side of the animation square in the band, in pixels.
#:
#: The popup uses 220. This is space reserved ABOVE the table for the
#: life of the panel, and the band may not grow when an animation
#: arrives -- growing would push the table down under the pointer that
#: asked for it. So the square is sized to what the band can afford
#: rather than the band to the square.
HELP_ANIMATION_PX = 132
#: How long the last help stays after the pointer leaves, in ms.
#:
#: The band carries an API link and an Animation word, and a reader
#: has to be able to reach them. Clearing on `Leave` put the words
#: under a pointer that was travelling towards them and then took them
#: away. The popup solves the same problem the same way; this is its
#: HIDE_DELAY_MS.
HELP_HIDE_DELAY_MS = 700
def _sync_help_height(self) -> None:
"""Reserve :data:`HELP_LINES` using the font Qt is actually painting.
Measured from the polished widget rather than from the theme's
point size, because a stylesheet or the platform can change what is
painted and a height computed from the wrong font reserves the
wrong number of lines.
"""
self._help.ensurePolished()
lines = self._help.fontMetrics().lineSpacing() * self.HELP_LINES
self._help.setFixedHeight(lines)
self._help_band.setFixedHeight(max(lines, self.HELP_ANIMATION_PX))
[docs]
def set_app_key(self, app_key: str) -> None:
"""Say which module's API documentation the tooltips should link to.
:param app_key: registry key of the module, e.g. ``"mask"``; ``None``
or empty clears it.
"""
self._app_key = str(app_key or "")
def _key_under(self, pos) -> str:
"""The settings key of the cell at ``pos``, or ``""``."""
index = self._table.indexAt(pos)
if not index.isValid():
return ""
mapper = getattr(self._table.model(), "mapToSource", None)
source = mapper(index) if mapper is not None else index
if not source.isValid():
return ""
question = self._model.question_at(source.row())
objects = self._model.objects()
if not question or source.column() >= len(objects):
return ""
obj = objects[source.column()]
return (_cell_key(obj, question) if self._model.asks(question, obj)
else "")
[docs]
def eventFilter(self, watched, event): # noqa: N802
"""Show the same sticky, linked tooltip the form shows.
THE SAME POPUP, NOT A SECOND ONE. The flat form puts rich help on a
widget and lets `HoverTooltip` draw it: a typed body, an API link,
and the setting's animation when it has one. A table has no widget
per cell, so the anchor is the view and the cell under the pointer
decides which setting it is speaking for.
The native tooltip is swallowed for the same reason the form
swallows it -- it disappears the moment the pointer moves toward the
API link, and that link is the point.
:param watched: the object the filter is installed on: this widget
(font changes), the help band (enter and leave) or the table's
viewport (tooltip, mouse move and leave).
:param event: the event; a tooltip event on the viewport is swallowed
and every other event is passed on to the base class.
"""
try:
kind = event.type()
if watched is self:
if kind == QEvent.Type.FontChange:
self._sync_help_height()
return super().eventFilter(watched, event)
if watched is self._help_band:
if kind == QEvent.Type.Enter:
self._help_hide_timer.stop()
elif kind == QEvent.Type.Leave:
self._help_hide_timer.start(self.HELP_HIDE_DELAY_MS)
return super().eventFilter(watched, event)
if kind == QEvent.Type.ToolTip:
return True
if kind == QEvent.Type.MouseMove:
self._offer_tooltip(event.position().toPoint())
elif kind == QEvent.Type.Leave:
self._hovered_key = ""
self._help_hover_delay.cancel()
self._help_hide_timer.start(self.HELP_HIDE_DELAY_MS)
except Exception: # noqa: BLE001
LOG.debug("the table could not offer its tooltip", exc_info=True)
return super().eventFilter(watched, event)
def _offer_tooltip(self, pos) -> None:
"""Show help for the cell under ``pos``, if it is a new cell.
RE-SHOWN ONLY ON A NEW CELL. `show_for` re-anchors and restarts the
popup, so calling it on every mouse-move would rebuild the tooltip
dozens of times a second and make its links unclickable.
"""
key = self._key_under(pos)
if key == self._hovered_key:
return
self._hovered_key = key
self._help_hide_timer.stop()
if not key:
self._help_hover_delay.cancel()
self._help_hide_timer.start(self.HELP_HIDE_DELAY_MS)
return
self._help_pending = key
self._help_hover_delay.schedule(
self._table.viewport(), self._show_pending_help)
def _show_pending_help(self) -> None:
"""Write the help for the cell the pointer settled on."""
key = self._help_pending
if not key:
return
try:
from ..screens.settings_model import format_tooltip, get_tooltips
body = str(get_tooltips().get(key) or "")
html = format_tooltip(body, self._app_key, key)
except Exception: # noqa: BLE001
LOG.debug("could not build the tooltip for %s", key, exc_info=True)
return
self._write_help(html, key=key)
def _open_help_api(self) -> None:
"""Open the documentation page the band's **API** word points at."""
if not self._help_api_url:
return
try:
from PySide6.QtGui import QDesktopServices
from PySide6.QtCore import QUrl
QDesktopServices.openUrl(QUrl(self._help_api_url))
except Exception: # noqa: BLE001
LOG.debug("could not open %s", self._help_api_url, exc_info=True)
def _toggle_help_animation(self) -> None:
"""Show or fold away the square beside the text.
Names ONE setting, like the popup's word: moving to another cell
falls back to the preference rather than carrying the reveal.
"""
self._help_animation_shown = not self._help_animation_shown
self._apply_help_animation()
def _apply_help_animation(self) -> None:
"""Draw, pause or drop the square for the offered animation."""
animation = self._help_offered_animation
showing = False
if animation is not None and self._help_animation_shown:
showing = bool(self._help_animation.load(animation))
else:
self._help_animation.clear_animation()
self._help_animation.setVisible(showing)
self._help_anim.setVisible(
animation is not None and (showing or not self._help_animation_shown))
self._help_links.setVisible(
self._help_api.isVisibleTo(self._help_links)
or self._help_anim.isVisibleTo(self._help_links))
def _write_help(self, html: str, key: str = "") -> None:
"""Put ``html`` in the band above the table, or the resting prompt.
The API link is kept. `format_tooltip` ends the body with an anchor
to the setting's documentation page, and the popup used to lift that
out into its own **API** word; here the band is a rich-text label
with external links enabled, so the anchor works where it already
is and there is nothing to lift.
The band does not go blank between cells. Moving the pointer across
a row would otherwise flicker it empty and back, and an empty band
is also what a reader sees before touching anything -- so the rest
state is a sentence saying what the band is for. That sentence is
the one the bottom-of-window strip already uses, reused rather than
written again: a new one would be a new user-facing string in ten
languages.
"""
from ..i18n import tr
from .hover_tooltip import split_api_link
text = str(html or "").strip()
if not text:
text = tr("Hover any setting for details and a link to its "
"documentation.")
body, url = text, ""
else:
body, url = split_api_link(text)
self._help.setText(body)
self._help_api_url = url
self._help_api.setVisible(bool(url))
animation = None
if key:
try:
from ...setting_animations import animation_for_setting
animation = animation_for_setting(key)
except Exception: # noqa: BLE001
LOG.debug("no animation lookup for %s", key, exc_info=True)
if animation is not self._help_offered_animation:
self._help_animation_shown = False
self._help_offered_animation = animation
self._apply_help_animation()
def _cell_clicked(self, index) -> None:
"""Open the model zoo when a model-name cell is clicked.
NO BUTTON, AND NOTHING DRAWN. A button small enough to fit in a table
cell has no room for a word, so the whole cell is the control: click
anywhere in the model row, under the object you mean, and the picker
opens for that object.
The cell is still editable by double-click, which is how a path that
is not in the zoo gets typed.
"""
try:
source = index
mapper = getattr(self._table.model(), "mapToSource", None)
if mapper is not None:
source = mapper(index)
if not source.isValid():
return
if self._model.question_at(source.row()) != MODEL_QUESTION:
return
obj = self._model.objects()[source.column()]
if self._model.asks(MODEL_QUESTION, obj):
self.choose_model_for(obj)
except Exception: # noqa: BLE001
LOG.debug("could not open the model zoo", exc_info=True)
#: Kinds the per-object Model cell offers.
#:
#: ``cellpose3`` is in the list because this cell writes
#: ``cell_model_name`` / ``nucleus_model_name`` / ``pathogen_model_name``,
#: and those are exactly the settings
#: :func:`spacr.settings._get_object_settings` reads when
#: ``segmentation_backend`` is ``'cellpose3'`` -- so cyto3, cyto2, cyto,
#: nuclei and any bioimage.io Cellpose 3 checkpoint belong here. Without
#: it the four stock Cellpose 3 models were listed nowhere a button opens
#: and had to be typed by hand. The Cellpose 4 preview boxes keep
#: ``("cellpose",)``: they load the checkpoint in spaCR's own process.
#: A Cellpose-DINO checkpoint runs in its own backend (item 525), which
#: Mask generation reaches, so ``cellpose_dino`` belongs here too, and
#: so do StarDist's, InstanSeg's and Omnipose's models, which the button
#: adds from :data:`spacr.model_zoo.PREFIXED_KINDS` (items 551-553).
MODEL_KINDS = ("cellpose", "cellpose3", "cellpose_dino")
[docs]
def choose_model_for(self, obj: str) -> bool:
"""Open the model zoo for one object and store what it returns.
:param obj: object name whose ``model_name`` cell receives the chosen
path, e.g. ``"cell"``.
:returns: True when a model was chosen. Cancelling leaves the cell
alone rather than clearing it -- a cancelled dialog is not an
instruction to forget the model already set.
"""
from ... import model_zoo
from .model_zoo_picker import choose_model
path = choose_model(self, kinds=self.MODEL_KINDS + tuple(
kind for kind in model_zoo.PREFIXED_KINDS
if kind not in self.MODEL_KINDS))
if not path:
return False
return self.set_value(MODEL_QUESTION, obj, path)
#: Never shorter than this, however few rows there are: a table that
#: collapses to its header is one the user cannot grab to make bigger.
MIN_TABLE_H = 90
#: How tall it opens at most. Past this the form is one long table and
#: the settings above and below it stop being findable -- the grip is
#: there for anyone who wants more.
AUTO_TABLE_H = 420
[docs]
def content_height(self) -> int:
"""The height that would show every row without an inner scrollbar."""
header = self._table.horizontalHeader().height()
rows = sum(self._table.rowHeight(r)
for r in range(self._model.rowCount()))
return header + rows + 2 * self._table.frameWidth()
[docs]
def set_user_height(self, height: int) -> None:
"""Fix the table at ``height`` px, clamped to at least MIN_TABLE_H.
:param height: wanted table height in pixels.
"""
self._user_height = max(self.MIN_TABLE_H, int(height))
self._apply_height()
[docs]
def reset_user_height(self) -> None:
"""Forget a dragged height and go back to fitting the rows."""
self._user_height = None
self._apply_height()
def _apply_height(self) -> None:
"""Put the chosen height on the table.
``setFixedHeight`` rather than a minimum, because the table sits in a
form that would otherwise stretch it: the point of the grip is that
the height is the USER's answer, and a layout free to grow it is a
layout that overrules them.
"""
if self._user_height is not None:
self._table.setFixedHeight(self._user_height)
return
fit = self.content_height()
self._table.setFixedHeight(
max(self.MIN_TABLE_H, min(self.AUTO_TABLE_H, fit)))
[docs]
def set_settings(self, settings: Mapping[str, Any]) -> None:
"""Show the per-object half of a flat settings dict.
The rest is KEPT, not dropped: :meth:`settings` returns it unchanged
beside the table's own keys, so this widget can edit a corner of a
settings file without holding the whole of it hostage.
:param settings: flat settings dict, or ``None``; its
``<object>_<question>`` keys become the table.
"""
self._base = dict(settings or {})
self._model.set_table(self._visible_table())
self._announce()
def _visible_table(self) -> Dict[str, Dict[str, Any]]:
"""The table as drawn: the claimed table less the objects the run lacks.
2026-09-29 (item 592, the decision "hide unset
objects"): a column is drawn only for an object whose channel names
a plane, and cell always (see :func:`_object_is_in_the_run`). The
channel row itself is not drawn -- a hidden object has no column to
hold it -- so every object's channel stays a row of the ordinary
form, where it can always be set. HIDDEN, NEVER DELETED: the hidden
columns' answers stay in ``self._base``, which :meth:`settings`
writes back unchanged, so setting the channel again brings the
column back with them.
"""
full = self._every_column_table()
self._claimed = full
shown = {obj for row in full.values() for obj in row
if _object_is_in_the_run(obj, self._base)}
table: Dict[str, Dict[str, Any]] = {}
for question, row in full.items():
if question in _SWITCH_QUESTIONS:
continue
kept = {obj: value for obj, value in row.items() if obj in shown}
if kept:
table[question] = kept
table.update(self._filter_rows(shown))
return table
def _claimed_objects(self) -> Tuple[str, ...]:
"""Every object column the table holds, drawn or hidden, in order."""
order = {name: index for index, name in enumerate(OBJECT_ORDER)}
present = {obj for row in self._claimed.values() for obj in row}
return tuple(sorted(present, key=lambda o: order.get(o, len(order))))
def _claimed_table(self) -> Dict[str, Dict[str, Any]]:
"""Every object's answers the table holds, drawn or hidden.
The columns of objects whose channel is unset are in here although
they are not on screen, and so is the channel row, which is never
drawn. The binding claims its keys from this, so a hidden object's
settings stay off the flat form too.
"""
return {q: dict(row) for q, row in self._claimed.items()}
def _shows_the_same_objects_as(self, settings: Mapping[str, Any]) -> bool:
"""Whether ``settings`` would draw the columns drawn now.
Cheap enough to ask on every change of a channel field: only the
channel values are read, and the table is rebuilt only when this
says no.
:param settings: the flat settings as the form now holds them.
"""
objects = {obj for row in self._claimed.values() for obj in row}
wanted = {obj for obj in objects
if _object_is_in_the_run(obj, settings)}
return wanted == set(self._model.objects())
def _keep_the_switches(self, settings: Mapping[str, Any]) -> None:
"""Hold the form's channel values without redrawing anything.
The channels are not cells, so a channel moved between two planes
changes no column; it is kept so that :meth:`settings` hands back
what the form holds rather than the channel of the last redraw.
:param settings: the flat settings as the form now holds them.
"""
for question in _SWITCH_QUESTIONS:
for obj in self._claimed.get(question, {}):
key = _settings_key(obj, question)
if key in settings:
self._base[key] = settings[key]
def _every_column_table(self) -> Dict[str, Dict[str, Any]]:
"""The table with the organelle slots the count does not ask for cut.
`number_of_organelles` IS THE SOURCE OF TRUTH FOR THE COLUMNS. The
settings dict keeps a typed placeholder for every slot up to the
maximum -- that is what makes lowering the count reversible -- so a
table built straight off the keys shows an Organelle 1 column at a
count of zero, which is a column for something the run will not
segment.
CUT FROM THE VIEW, NOT FROM THE SETTINGS. `settings()` writes the
table back over ``self._base``, so a hidden slot's keys are carried
through untouched and raising the count again brings its answers
back rather than a row of defaults.
"""
from ...organelle_types import active_organelle_roles
live = tuple(active_organelle_roles(self._base))
keep = set(live)
table = {
question: {
obj: value for obj, value in row.items()
if not obj.startswith("organelle") or obj in keep
}
for question, row in to_table(self._base).items()
}
for index, role in enumerate(live):
if any(role in row for row in table.values()):
continue
table = widen(table, role,
like=live[index - 1] if index else None)
return self._only_the_shared_questions(table)
def _filter_rows(self, objects) -> Dict[str, Dict[str, Any]]:
"""``object_filters`` as table rows, one per filtered property.
Rows appear for every property any shown object filters on, and for
each property added with :meth:`add_filter` and not yet filled in.
Every segmented object on screen has a cell in each row.
:param objects: the object columns the table shows.
"""
if "object_filters" not in self._base:
return {}
filters = _parsed_filters(self._base.get("object_filters"))
columns = [obj for obj in _filter_objects() if obj in objects]
order = []
for obj in columns:
for entry in filters.get(obj) or ():
name = str(entry.get("property", ""))
if name and name not in order:
order.append(name)
for name in self._added_filters:
if name not in order:
order.append(name)
rows: Dict[str, Dict[str, Any]] = {}
for name in order:
row = {}
for obj in columns:
cell = None
for entry in filters.get(obj) or ():
if str(entry.get("property", "")) == name:
cell = _filter_text(entry)
break
row[obj] = cell
rows[f"{_FILTER_PREFIX}{name}"] = row
return rows
@staticmethod
def _only_the_shared_questions(
table: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
"""Drop the questions only ONE object asks.
A TABLE IS A CLAIM THAT ITS ROWS AND COLUMNS ARE INDEPENDENT, and a
question a single object asks is not a row -- it is one setting, and
it belongs in the form beside that object's others. The organelle's
own detection settings are 37 of these: ridge filters, hysteresis,
LoG sigmas, which no cell, nucleus or pathogen has.
MORE THAN ONE, NOT ALL. This required EVERY object to ask, and that
was wrong in a way only a real settings dict shows: Measure asks
`mask_dim` of cell, nucleus, pathogen and organelle but not
cytoplasm, and `max_size` of three of its five objects -- so
requiring all five dropped EVERY per-object setting Measure has and
left the table empty. A blank cell already means "this object does
not ask", and the model draws it as not-editable, so a question
three objects share is a perfectly good row.
ROLES, NOT COLUMNS. Every organelle counts once between them. The
obvious reading -- more than one COLUMN asks -- makes the row set
depend on how many organelles there are, because a second organelle
is seeded from the first and so doubles every question only an
organelle asked. Adding a column would then silently add 34 rows.
A question is per-object because DIFFERENT KINDS of object ask it,
and two organelles are the same kind asked twice.
THEY ARE NOT LOST. The grid claims only the keys it shows, so
everything dropped here stays in the ordinary form under the
category it belongs to -- which is where a setting only one object
has belongs, next to the others that object has.
"""
return {
question: row for question, row in table.items()
if len({_kind_of(obj) for obj in row}) > 1
or question in _SINGLE_OBJECT_QUESTIONS
}
[docs]
def settings(self) -> Dict[str, Any]:
"""The whole settings dict, with the table's answers written back.
The filter rows are written back into ``object_filters``: each shown
object's list is rebuilt from its cells, in row order, and an object
the table does not show keeps the list it had.
"""
table = self._model.table()
plain = {q: row for q, row in table.items() if not _is_filter(q)}
out = from_table(plain, self._base)
if "object_filters" not in self._base:
return out
filters = {obj: [dict(entry) for entry in entries or ()]
for obj, entries in _parsed_filters(
self._base.get("object_filters")).items()}
shown = [obj for obj in _filter_objects()
if obj in set(self._model.objects())]
for obj in shown:
rows = []
for question, row in table.items():
if not _is_filter(question):
continue
low, high = _parse_filter_text(row.get(obj))
if low is None and high is None:
continue
rows.append({"property": question[len(_FILTER_PREFIX):],
"min": low, "max": high})
if rows:
filters[obj] = rows
else:
filters.pop(obj, None)
out["object_filters"] = filters
return out
[docs]
def filter_properties(self) -> Tuple[str, ...]:
"""The properties the table has a filter row for, in order."""
return tuple(q[len(_FILTER_PREFIX):] for q in self._model.table()
if _is_filter(q))
[docs]
def add_filter(self, name: str) -> bool:
"""Add a filter row for regionprop ``name``, every cell empty.
:param name: a scalar scikit-image regionprop, e.g. ``"area"`` or
``"intensity_mean"``; legacy spellings are accepted.
:returns: False when the name is not a filterable property or the row
is already there.
"""
try:
from ..mask_engine import canonical_property
name = canonical_property(name)
except Exception: # noqa: BLE001
self._status.setText(
f"'{name}' is not a region property an object filter can "
f"use.")
return False
if name in self.filter_properties():
return False
self._base = self.settings()
self._added_filters.append(name)
self._model.set_table(self._visible_table())
self._announce()
return True
[docs]
def set_filters(self, value) -> None:
"""Show ``object_filters`` as it now stands, e.g. after a file load.
:param value: the setting's value, a mapping or its text.
"""
self._base = self.settings()
self._base["object_filters"] = value
self._model.set_table(self._visible_table())
self._announce()
def _offer_filters(self) -> None:
"""Open the list of properties a new filter row can measure."""
from PySide6.QtWidgets import QMenu
try:
from ..mask_engine import filter_properties
names = filter_properties(intensity=True)
except Exception: # noqa: BLE001
LOG.debug("no filter catalogue", exc_info=True)
names = ("area", "intensity_mean")
menu = QMenu(self)
menu.setObjectName("ObjectGridFilterMenu")
first = [n for n in ("area", "intensity_mean") if n in names]
for name in first + [n for n in names if n not in first]:
action = menu.addAction(name)
action.triggered.connect(
lambda _checked=False, n=name: self._added(n))
menu.popup(self._add_filter.mapToGlobal(
self._add_filter.rect().bottomLeft()))
def _added(self, name: str) -> None:
"""Add the chosen filter row and tell the form."""
if self.add_filter(name):
self.settings_changed.emit()
[docs]
def table(self) -> Dict[str, Dict[str, Any]]:
"""The table itself, for a caller that wants the shape."""
return self._model.table()
[docs]
def objects(self) -> Tuple[str, ...]:
"""Which object columns are on screen."""
return self._model.objects()
[docs]
def questions(self) -> Tuple[str, ...]:
"""Which questions are on screen, in order."""
return tuple(self._model.table())
[docs]
def next_organelle(self) -> str:
"""The role the next organelle column would take, or ``''``.
Empty at the ceiling. Slot names are lettered -- an object type is
embedded in an underscore-separated object key, so a digit would be
ambiguous against the object LABEL -- and the lettering CARRIES past
``z``, so the ceiling is where two letters run out rather than one.
COUNTS UP FROM THE SLOTS IN USE rather than from one. Walking every
slot from the start was fine while there were 26; there are now 702,
and the caller that presses Add repeatedly turned an O(slots) scan
into an O(slots squared) one.
Hidden slots count as in use too.
"""
used = {obj for row in self._claimed.values() for obj in row
if obj.startswith("organelle")}
for number in range(len(used) + 1, MAX_ORGANELLES + 1):
role = organelle_role(number)
if role not in used:
return role
return ""
[docs]
def add_organelle(self) -> bool:
"""Add the next organelle column, seeded from the first one.
:returns: False when there is no slot left, with the reason on screen
rather than as an exception into a GUI slot.
A slot whose channel is unset is hidden.
"""
from ...organelle_types import NUMBER_OF_ORGANELLES, organelle_count
role = self.next_organelle()
if not role:
self._status.setText(
f"{MAX_ORGANELLES} organelles is the ceiling: the slots are "
f"lettered and carry past 'z', so that is where two "
f"letters run out.")
return False
self._base = self.settings()
self._base[NUMBER_OF_ORGANELLES] = organelle_count(self._base) + 1
full = self._every_column_table()
if not any(role in row for row in full.values()):
previous = [o for o in self._claimed_objects()
if o.startswith("organelle")]
full = widen(full, role, like=previous[-1] if previous else None)
self._base = from_table(full, self._base)
self._model.set_table(self._visible_table())
self._announce()
if role not in self.objects():
self._status.setText(
f"{column_label(role)} added. Give it a channel to show its "
f"column.")
self.settings_changed.emit()
return True
def _announce(self) -> None:
"""Say what the table is holding, and re-fit its height.
Called after every content change, which is why the height is
refreshed here rather than in each of the callers: adding an
organelle adds a column and a re-read replaces every row, and a
height computed before either is the height of the old table.
A height the user dragged is LEFT ALONE -- see
:meth:`_apply_height`.
"""
self._apply_height()
questions = len(self.questions())
objects = len(self.objects())
self._status.setText(
f"{questions} question(s) x {objects} object(s) = "
f"{sum(len(row) for row in self._model.table().values())} "
f"settings, asked once each.")
[docs]
def status_text(self) -> str:
"""What the line under the table says."""
return self._status.text()
[docs]
def set_value(self, question: str, obj: str, text: str) -> bool:
"""Type into one cell the way the editor would.
The screen's own edit path, exposed so a test drives the same code an
item delegate does rather than reaching into the model.
:param question: settings question, i.e. the key suffix shared by every
object (``"min_area"`` for ``cell_min_area``).
:param obj: object name, the key prefix (``"cell"``, ``"nucleus"``, ...).
:param text: what the user would type; converted to the type of the
cell's current value (or a sibling's). Returns ``False`` for an
unknown row or column, a cell the object does not ask, or no
change.
"""
objects = self.objects()
rows = self.questions()
if question not in rows or obj not in objects:
return False
index = self._model.index(rows.index(question), objects.index(obj))
return self._model.setData(index, text, Qt.EditRole)