Source code for spacr.qt.screens.settings_model
"""
Bridge between spacr's plain-python default settings and Qt form widgets.
The existing spacr GUI expresses settings as `{name: (widget_type, options,
default)}` triples via `spacr.gui_utils.convert_settings_dict_for_gui`.
Here we consume the same conversion output and materialize each entry as
a real Qt widget grouped into logical Section boxes based on
`spacr.settings.categories`.
"""
from __future__ import annotations
import ast
from typing import Any, Dict, List, Optional, Tuple
from PySide6.QtCore import Qt
from PySide6.QtGui import QIntValidator, QDoubleValidator
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QLineEdit,
QSpinBox,
QDoubleSpinBox,
QVBoxLayout,
QWidget,
QHBoxLayout,
QLabel,
)
# ---------------------------------------------------------------------------
# Settings resolvers per app_key
# ---------------------------------------------------------------------------
[docs]
def resolve_default_settings(app_key: str) -> Dict[str, Any]:
"""Return a fresh defaults dict for an app key, mirroring the Tk GUI
dispatch in gui_core.setup_settings_panel."""
from spacr.settings import (
get_identify_masks_finetune_default_settings,
set_default_analyze_screen,
set_default_settings_preprocess_generate_masks,
get_automated_motility_assay_default_settings,
get_measure_crop_settings,
deep_spacr_defaults,
set_default_generate_barecode_mapping,
set_default_umap_image_settings,
get_map_barcodes_default_settings,
get_analyze_recruitment_default_settings,
get_check_cellpose_models_default_settings,
get_analyze_plaque_settings,
get_perform_regression_default_settings,
get_train_cellpose_default_settings,
get_default_generate_activation_map_settings,
)
if app_key == "mask":
s = set_default_settings_preprocess_generate_masks(settings={})
s = get_automated_motility_assay_default_settings(s)
return s
if app_key == "measure":
return get_measure_crop_settings(settings={})
if app_key == "classify":
return deep_spacr_defaults(settings={})
if app_key == "umap":
return set_default_umap_image_settings(settings={})
if app_key == "train_cellpose":
return get_train_cellpose_default_settings(settings={})
if app_key == "ml_analyze":
return set_default_analyze_screen(settings={})
if app_key == "cellpose_masks":
return get_identify_masks_finetune_default_settings(settings={})
if app_key == "cellpose_all":
return get_check_cellpose_models_default_settings(settings={})
if app_key == "map_barcodes":
return set_default_generate_barecode_mapping(settings={})
if app_key == "regression":
return get_perform_regression_default_settings(settings={})
if app_key == "recruitment":
return get_analyze_recruitment_default_settings(settings={})
if app_key == "activation":
return get_default_generate_activation_map_settings(settings={})
if app_key == "analyze_plaques":
return get_analyze_plaque_settings(settings={})
if app_key in ("annotate", "make_masks"):
# These are interactive apps; return minimal placeholder.
return {"src": "path to images"}
return {"src": "path"}
[docs]
def get_categories() -> Dict[str, List[str]]:
"""Return the {category_name: [setting keys]} mapping."""
from spacr.settings import categories
return categories
[docs]
def get_tooltips() -> Dict[str, str]:
"""Return per-key tooltip text (spacr.settings.descriptions and .tooltips)."""
tips: Dict[str, str] = {}
try:
from spacr.settings import descriptions, tooltips
except Exception:
return tips
tips.update({k: v for k, v in descriptions.items() if isinstance(v, str)})
tips.update({k: v for k, v in tooltips.items() if isinstance(v, str)})
return tips
# ---------------------------------------------------------------------------
# API doc link per app
# ---------------------------------------------------------------------------
[docs]
def api_docs_url(app_key: str) -> str:
"""Return the spacr documentation URL for a given app.
The published docs don't yet split into per-function anchors, so
we point every setting at the docs landing page. Users can search
from there and don't hit 404s.
"""
return DOCS_BASE
[docs]
def format_tooltip(text: str, app_key: str) -> str:
"""Return an HTML tooltip body: description + a docs footer link.
Kept minimal — Qt auto-detects rich text from any HTML tag, but
complex containers/styles trip up some builds and the tip renders
empty. Plain <br> line breaks and a plain <a> footer are the
format that renders reliably on every platform we've tested.
Also normalises whitespace so multi-line descriptions from
spacr.settings.descriptions collapse into a single paragraph.
"""
body = " ".join((text or "").split())
url = api_docs_url(app_key)
if body:
return f'{body}<br><a href="{url}">Docs</a>'
return f'<a href="{url}">Docs</a>'
[docs]
def plain_tooltip(text: str, app_key: str) -> str:
"""Same content as `format_tooltip` but plain text — used by the
hover-follows footer at the bottom of each AppScreen."""
body = " ".join((text or "").split())
url = api_docs_url(app_key)
if body:
return f"{body} · {url}"
return url
# ---------------------------------------------------------------------------
# Widget factory
# ---------------------------------------------------------------------------
class _ListEdit(QLineEdit):
"""A QLineEdit that round-trips a Python list via repr()."""
def get_value(self) -> Any:
"""Return the field parsed as a Python literal (or raw text on failure)."""
text = self.text().strip()
if not text:
return None
try:
return ast.literal_eval(text)
except Exception:
return text
def set_value(self, v: Any) -> None:
"""Render ``v`` into the field via ``repr``; ``None`` clears the field."""
self.setText(repr(v) if v is not None else "")
class _ScalarEdit(QLineEdit):
"""A plain QLineEdit that returns None for empty text."""
def get_value(self) -> Optional[str]:
"""Return the current text, or ``None`` when the field is empty."""
return self.text() or None
def set_value(self, v: Any) -> None:
"""Set the field text; ``None`` clears the field."""
self.setText("" if v is None else str(v))
[docs]
class SettingsWidgets:
"""Container for the Qt widgets bound to a settings dict.
Instantiate with an `app_key`; call `.build_sections()` to get a list
of (section_title, list_of_(label, widget)) tuples to feed into the
Section widgets on a screen. `.collect()` returns the current settings
dict after user edits."""
def __init__(self, app_key: str, parent: Optional[QWidget] = None):
"""Load the app's default settings dict and prepare an empty widget map.
:param app_key: id of the app whose settings are being edited.
:param parent: optional Qt parent for created widgets.
"""
self._parent = parent
self._defaults = resolve_default_settings(app_key)
self._widgets: Dict[str, QWidget] = {}
self._tooltips = get_tooltips()
[docs]
def build_sections(self) -> List[Tuple[str, List[Tuple[str, QWidget]]]]:
"""Group the settings by category and return one (title, rows)
tuple per non-empty category, plus a trailing 'Other' section
for anything not categorized."""
from spacr.gui_utils import convert_settings_dict_for_gui
variables = convert_settings_dict_for_gui(self._defaults)
# Materialize a widget per key; attach a rich HTML tooltip that
# ends with an "API docs →" link to the spacr docs.
for key, meta in variables.items():
kind, options, default = meta
widget = self._widget_for(kind, options, default, key)
if widget is not None:
tip = format_tooltip(self._tooltips.get(key, ""), self.app_key)
widget.setToolTip(tip)
widget.setToolTipDuration(-1) # respect system default (persistent)
self._widgets[key] = widget
# Bucket into sections.
cats = get_categories()
used_keys = set()
sections: List[Tuple[str, List[Tuple[str, QWidget]]]] = []
for cat_name, keys in cats.items():
rows: List[Tuple[str, QWidget]] = []
for k in keys:
if k in self._widgets and k not in used_keys:
rows.append((self._label_for(k), self._widgets[k]))
used_keys.add(k)
if rows:
sections.append((cat_name, rows))
# Trailing 'Other' for anything not in a category.
remaining = [(self._label_for(k), self._widgets[k])
for k in self._widgets if k not in used_keys]
if remaining:
sections.append(("Other", remaining))
return sections
[docs]
def tooltip_for(self, key: str) -> str:
"""Return the HTML-formatted tooltip for a given setting key."""
return format_tooltip(self._tooltips.get(key, ""), self.app_key)
[docs]
def plain_tooltip_for(self, key: str) -> str:
"""Return the plain-text hint (description + docs URL) for a setting."""
return plain_tooltip(self._tooltips.get(key, ""), self.app_key)
def _label_for(self, key: str) -> str:
return key.replace("_", " ").capitalize()
def _widget_for(self, kind: str, options: Any, default: Any,
key: str) -> Optional[QWidget]:
parent = self._parent
if kind == "check":
w = QCheckBox()
w.setChecked(bool(default))
return w
if kind == "combo":
w = QComboBox()
for opt in (options or []):
w.addItem("None" if opt is None else str(opt),
userData=opt)
# Try to pre-select default
for i in range(w.count()):
if w.itemData(i) == default or w.itemText(i) == str(default):
w.setCurrentIndex(i)
break
return w
if kind == "entry":
# Choose widget by inferred type from the DEFAULT value
if isinstance(default, bool):
w = QCheckBox()
w.setChecked(default)
return w
if isinstance(default, int):
w = QSpinBox()
w.setRange(-1_000_000, 1_000_000)
w.setValue(default)
return w
if isinstance(default, float):
w = QDoubleSpinBox()
w.setRange(-1e12, 1e12)
w.setDecimals(6)
w.setValue(default)
return w
if isinstance(default, list):
w = _ListEdit()
w.set_value(default)
return w
# Fallback — string or None
w = _ScalarEdit()
w.set_value(default)
return w
return None
[docs]
def collect(self) -> Dict[str, Any]:
"""Read all widgets and return the current settings dict."""
out: Dict[str, Any] = {}
for key, w in self._widgets.items():
out[key] = self._read_widget(w)
# Also carry over any defaults we didn't render (e.g. things not
# in the categories map that convert_settings_dict_for_gui also
# skipped).
for k, v in self._defaults.items():
out.setdefault(k, v)
return out
def _read_widget(self, w: QWidget) -> Any:
if isinstance(w, QCheckBox):
return bool(w.isChecked())
if isinstance(w, QSpinBox):
return int(w.value())
if isinstance(w, QDoubleSpinBox):
return float(w.value())
if isinstance(w, QComboBox):
return w.currentData() if w.currentData() is not None else w.currentText()
if isinstance(w, _ListEdit):
return w.get_value()
if isinstance(w, _ScalarEdit):
return w.get_value()
if isinstance(w, QLineEdit):
return w.text() or None
return None