Source code for spacr.qt.widgets.picture_settings_dialog

"""Picture-rendering controls shared by cell and image views.

Defaults come from :func:`spacr.settings.set_annotate_default_settings`, and
mode applicability comes from :mod:`spacr.picture_settings`. The dialog thus
uses the same values and availability rules as non-GUI callers.
"""
from __future__ import annotations

from typing import Any, Dict, Optional

from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
    QCheckBox,
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QDoubleSpinBox,
    QFormLayout,
    QLabel,
    QLineEdit,
    QSpinBox,
    QTabWidget,
    QVBoxLayout,
    QWidget,
)

from ...crops import LOAD_IMAGES
from ...picture_settings import ALL_KEYS, applies_to, categories, why_not

__all__ = ["PictureSettingsDialog", "picture_defaults"]


[docs] def picture_defaults() -> Dict[str, Any]: """Return typed defaults for every setting offered by the dialog.""" from ...settings import set_annotate_default_settings try: filled = set_annotate_default_settings({}) except Exception: # noqa: BLE001 filled = {} if not isinstance(filled, dict): filled = {} from ...picture_settings import OWN_DEFAULTS out = {} for key in ALL_KEYS: if key in OWN_DEFAULTS: out[key] = OWN_DEFAULTS[key] else: out[key] = filled.get(key) return out
#: Settings that name channels rather than choosing an item from a list. #: ``outline`` uses the same red/green/blue vocabulary and parsing rules as #: ``normalize_channels``, so the dialog presents them with the same control. #: ``_as_channel_list`` parses the #: comma-separated string either control produces. Only the widget differed. CHANNEL_KEYS = ("channels", "normalize_channels", "outline") #: Settings that are a PAIR of numbers rather than one value. #: #: A window is two numbers, so it is asked for as two numbers. As one text #: box it was a parsing problem handed to the user -- `[1, 99]` and `[1 99]` #: are one intent, and only one of them survived the trip to the renderer. PAIR_KEYS = ("percentiles",) def _editor(value: Any, parent: Optional[QWidget] = None, choices: Any = ()) -> QWidget: """A control suited to ``value``'s type. Deliberately small: a float gets a step that follows its magnitude, for the reason the settings panel had to be taught the same thing -- a spin box left at Qt's default step of 1.0 turns 0.05 into -0.95 on one wheel tick. """ if choices: combo = QComboBox(parent) for option in choices: if isinstance(option, tuple) and len(option) == 2: stored, label = option else: stored = label = option combo.addItem(str(label), stored) current = combo.findData(value) if current < 0: current = combo.findText(str(value)) combo.setCurrentIndex(max(current, 0)) return combo if isinstance(value, bool): box = QCheckBox(parent) box.setChecked(value) return box if isinstance(value, int): spin = QSpinBox(parent) spin.setRange(0, 1_000_000) spin.setValue(int(value)) return spin if isinstance(value, float): spin = QDoubleSpinBox(parent) spin.setDecimals(4) spin.setRange(-1e6, 1e6) spin.setSingleStep(0.01 if abs(value) < 1 else 0.1) spin.setValue(float(value)) return spin edit = QLineEdit(parent) edit.setText("" if value is None else str(value)) return edit def _value_of(widget: QWidget) -> Any: """Read one settings control's value. THE PAIR STAYS A PAIR. Read through ``text()`` like any other unfamiliar editor, a percentile pair would come back as the string ``"2, 98"``, and every settings file already on disk holds a two-element list. A combo returns its stored data, falling back to its caption when it carries none -- the data is what the setting means and the caption is only what it says. :param widget: the control. :returns: its value, in the form a settings file holds. """ from .percentile_pair import PercentilePair if isinstance(widget, PercentilePair): return widget.value() if isinstance(widget, QComboBox): data = widget.currentData() return widget.currentText() if data is None else data if isinstance(widget, QCheckBox): return widget.isChecked() if isinstance(widget, (QSpinBox, QDoubleSpinBox)): return widget.value() return widget.text() def _attach_picking_help(editor, key: str) -> None: """Give each annotation method's dropdown entry its own explanation. A no-op for every other setting and for a control that is not a combo, so the caller does not have to know which is which. """ if str(key) != "cell_picking": return from PySide6.QtWidgets import QComboBox if not isinstance(editor, QComboBox): return from ...picture_settings import PICKING_HELP for index in range(editor.count()): value = editor.itemData(index) if value is None: value = editor.itemText(index).split(" ")[0] help_text = PICKING_HELP.get(str(value)) if help_text: editor.setItemData(index, help_text, Qt.ToolTipRole)
[docs] class PictureSettingsDialog(QDialog): """Edit picture settings while retaining mode-inapplicable values. :param values: the settings to open with. Keys outside the picture vocabulary are IGNORED rather than carried, and keys the dialog knows but the current mode does not apply are kept untouched -- which is what "retaining" in the summary above means, and why editing PNG settings does not silently drop the array ones. :param mode: which crop SOURCE the pictures come from. It decides which settings apply -- a plane index means something to the array route and nothing to the database route, which finds its rows by coordinate columns -- so an inapplicable setting is greyed with a reason rather than hidden. :param parent: parent widget. :param source: where the pictures come from, for the previews. :param objects: the objects available to crop, for the previews. """ def __init__(self, values: Optional[Dict[str, Any]] = None, mode: str = "png", parent: Optional[QWidget] = None, *, source: Any = None, objects: Any = None): """Build the dialog controlling how object crops are drawn. The cap label's help is kept without its cost sentence, so re-stating the cost replaces it rather than stacking another copy on the end. :param values: the settings to open with; missing keys fall back to the shipped picture defaults. :param mode: which output the settings are for. :param parent: parent widget, or ``None``. :param source: the data source, used to gate what this mode can offer. :param objects: the object types available, for the per-object controls. """ super().__init__(parent) self.setWindowTitle("Picture settings") self._mode = str(mode or "png") self._editors: Dict[str, QWidget] = {} self._labels: Dict[str, QLabel] = {} #: The cap label's help WITHOUT the cost sentence, so re-stating the #: cost replaces it instead of stacking another copy on the end. self._cap_help: Optional[str] = None from ...picture_settings import drop_retired values, _notes = drop_retired(values) start = dict(picture_defaults()) start.update({k: v for k, v in values.items() if k in ALL_KEYS}) self._tabs = QTabWidget(self) self._tab_of: Dict[str, str] = {} layout = QVBoxLayout(self) from ...picture_settings import offered_values from .channel_picker import ChannelPicker from .percentile_pair import PercentilePair for title, keys in categories(): page = QWidget(self._tabs) form = QFormLayout(page) form.setLabelAlignment(Qt.AlignRight) for key in keys: value = start.get(key) if key == "cap" and isinstance(value, float): value = int(value) if key in CHANNEL_KEYS: editor = ChannelPicker( value, page, allow_none=(key != "channels")) elif key in PAIR_KEYS: editor = PercentilePair(value, page) else: editor = _editor(value, page, choices=offered_values(key, source=source, frame=objects)) label = QLabel(key.replace("_", " "), page) _attach_picking_help(editor, key) self._editors[key] = editor self._labels[key] = label self._tab_of[key] = title form.addRow(label, editor) self._tabs.addTab(page, title) layout.addWidget(self._tabs) cap = self._editors.get("cap") if isinstance(cap, QSpinBox): cap.valueChanged.connect( lambda _v: self._say_what_the_cap_costs()) source_editor = self._editors.get("crop_source") if isinstance(source_editor, QComboBox): source_editor.currentIndexChanged.connect( lambda _i: self.set_mode( str(source_editor.currentData() or source_editor.currentText() or LOAD_IMAGES))) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=self) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) try: from ..screens.settings_model import install_api_tooltips install_api_tooltips( self, "annotate", {editor: key for key, editor in self._editors.items()}) except Exception: # noqa: BLE001 pass self.set_mode(self._mode) from ..screens.settings_model import retarget_field_tooltips retarget_field_tooltips(self)
[docs] def set_mode(self, mode: str) -> None: """Update control availability for an image-source mode. Inapplicable controls remain visible and explain why they are disabled, so switching modes does not hide or discard configured values. :param mode: image-source mode; each control is enabled or disabled by :func:`spacr.picture_settings.applies_to` for it, and an empty value means ``"png"``. """ self._mode = str(mode or "png") from ...settings import tooltips for key, editor in self._editors.items(): usable = applies_to(key, self._mode) editor.setEnabled(usable) label = self._labels[key] label.setEnabled(usable) if not usable: label.setToolTip(why_not(key, self._mode)) continue rich = str(label.property("apiTooltipHtml") or "") label.setToolTip(rich or str(tooltips.get(key, "") or "")) if key == "cap": self._cap_help = str(label.toolTip() or "") self._say_which_tabs_this_mode_uses() self._say_what_the_cap_costs()
def _say_what_the_cap_costs(self) -> None: """Put the measured cost of the chosen cap beside the cap itself. A CAP IS A DECISION ABOUT WHERE A LIMIT SITS, and the numbers that decide it -- how many pages a reader has to walk, how much memory the tab holds while they do, and how long the cut takes -- are on screen nowhere else. Raising it without them is raising it blind. """ from ...picture_settings import montage_cap_cost label = self._labels.get("cap") editor = self._editors.get("cap") if label is None or editor is None: return base = self._cap_help if base is None: base = str(label.toolTip() or "") self._cap_help = base cost = montage_cap_cost(_value_of(editor)) if not cost: label.setToolTip(base) return joiner = "<p>{0}</p>" if base.lstrip().startswith("<") else "\n\n{0}" label.setToolTip(base + joiner.format(cost) if base else cost) def _say_which_tabs_this_mode_uses(self) -> None: """Put the greyed count for each tab on the tab itself. A GREYED CONTROL IS ONLY AN EXPLANATION IF IT IS FOUND. Behind a tab the reason a control cannot be touched is a hover the reader has to go looking for, so the tab says how many of its settings this mode does not use -- and it stays selectable, because the reason lives on the labels inside it. """ for index in range(self._tabs.count()): title = self._tabs.tabText(index) keys = [k for k, tab in self._tab_of.items() if tab == title] greyed = [k for k in keys if not applies_to(k, self._mode)] if not greyed: self._tabs.setTabToolTip(index, "") continue self._tabs.setTabToolTip( index, f"{len(greyed)} of {len(keys)} settings here are not used by " f"the chosen image source. They stay on the tab, and each " f"one's label says why.")
[docs] def mode(self) -> str: """Which picture mode the user chose. :returns: the mode's name. """ return self._mode
[docs] def tab_titles(self) -> tuple: """The tabs, in the order they are shown.""" return tuple(self._tabs.tabText(i) for i in range(self._tabs.count()))
[docs] def tab_of(self, key: str) -> str: """Which tab ``key``'s control is on, or ``""`` if it has none. :param key: picture-setting key; converted with ``str()`` and stripped, and None counts as empty. """ return self._tab_of.get(str(key or "").strip(), "")
[docs] def show_tab(self, title: str) -> bool: """Bring the tab named ``title`` to the front. False if there is none. A caller that wants one question answered can open the panel on it rather than on whichever tab happened to be first. :param title: tab title, converted with ``str()`` and matched exactly. """ for index in range(self._tabs.count()): if self._tabs.tabText(index) == str(title): self._tabs.setCurrentIndex(index) return True return False
[docs] def values(self) -> Dict[str, Any]: """Return every configured value, including disabled controls. Values for the current mode's disabled controls are preserved so that switching away from a mode and back restores the prior configuration. """ return {key: _value_of(editor) for key, editor in self._editors.items()}
[docs] def applied_values(self) -> Dict[str, Any]: """Only the settings the current mode actually uses.""" return {key: value for key, value in self.values().items() if applies_to(key, self._mode)}