from __future__ import annotations from pathlib import Path from typing import Any from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPlainTextEdit, QPushButton, QScrollArea, QSlider, QSpinBox, QVBoxLayout, QWidget, ) from PySide6.QtCore import Qt, Signal class SettingsForm(QWidget): """Builds editable PySide controls from a model plugin setting schema.""" changed = Signal() def __init__( self, schema: dict[str, dict[str, Any]] | None = None, parent: QWidget | None = None, ) -> None: super().__init__(parent) self.schema: dict[str, dict[str, Any]] = {} self.widgets: dict[str, QWidget] = {} self.rows: dict[str, tuple[QLabel, QWidget]] = {} self.show_advanced = QCheckBox("Show advanced settings") self.show_advanced.toggled.connect(self._advanced_changed) layout = QVBoxLayout(self) layout.setContentsMargins(0, 4, 0, 0) layout.setSpacing(10) layout.addWidget(self.show_advanced) self.scroll = QScrollArea() self.scroll.setWidgetResizable(True) self.scroll.setFrameShape(QFrame.NoFrame) self.scroll.setMinimumHeight(180) self.scroll.setMaximumHeight(390) self.content = QWidget() self.content_layout = QVBoxLayout(self.content) self.content_layout.setContentsMargins(4, 4, 4, 4) self.content_layout.setSpacing(10) self.scroll.setWidget(self.content) layout.addWidget(self.scroll) if schema: self.set_schema(schema) def set_schema(self, schema: dict[str, dict[str, Any]]) -> None: self.schema = {str(key): dict(value) for key, value in schema.items()} self.widgets.clear() self.rows.clear() while self.content_layout.count(): item = self.content_layout.takeAt(0) if item.widget(): item.widget().deleteLater() grouped: dict[str, list[tuple[str, dict[str, Any]]]] = {} for key, spec in self.schema.items(): grouped.setdefault(str(spec.get("group", "Basic")), []).append((key, spec)) has_advanced = any(bool(spec.get("advanced")) for spec in self.schema.values()) self.show_advanced.setVisible(has_advanced) for group, items in grouped.items(): box = QGroupBox() grid = QGridLayout(box) grid.setContentsMargins(12, 12, 12, 12) grid.setHorizontalSpacing(12) grid.setVerticalSpacing(8) grid.setColumnStretch(0, 0) grid.setColumnStretch(1, 1) group_title = QLabel(group) group_title.setProperty("sectionTitle", True) grid.addWidget(group_title, 0, 0, 1, 2) for row, (key, spec) in enumerate(items): required = " *" if spec.get("required") else "" label = QLabel(str(spec.get("label", key)) + required) label.setMinimumWidth(128) label.setMaximumWidth(210) label.setWordWrap(True) widget = self._build_widget(key, spec) tooltip = self._tooltip_for(key, spec) if tooltip: label.setToolTip(tooltip) widget.setToolTip(tooltip) if str(spec.get("type", "text")) not in {"path", "folder", "multiline_text"}: widget.setMaximumWidth(420) grid.addWidget(label, row + 1, 0, Qt.AlignLeft | Qt.AlignVCenter) grid.addWidget(widget, row + 1, 1, Qt.AlignLeft | Qt.AlignVCenter) self.widgets[key] = widget self.rows[key] = (label, widget) self.content_layout.addWidget(box) self.content_layout.addStretch() self._advanced_changed(self.show_advanced.isChecked()) def _build_widget(self, key: str, spec: dict[str, Any]) -> QWidget: kind = str(spec.get("type", "text")) default = spec.get("default", "") if kind == "bool": widget = QCheckBox() widget.setChecked(bool(default)) widget.setMaximumWidth(420) widget.toggled.connect(self.changed) return widget if kind == "choice": widget = QComboBox() option_labels = spec.get("option_labels", {}) for option in spec.get("options", []): label = option_labels.get(str(option), str(option)) if isinstance(option_labels, dict) else str(option) widget.addItem(str(label), option) index = widget.findData(default) if index < 0: index = widget.findText(str(default)) widget.setCurrentIndex(max(0, index)) widget.currentIndexChanged.connect(self.changed) widget.setMinimumWidth(170) return widget if kind in {"int", "slider"}: if kind == "slider": container = QWidget() row = QHBoxLayout(container) row.setContentsMargins(0, 0, 0, 0) slider = QSlider(Qt.Horizontal) slider.setRange(int(spec.get("min", 0)), int(spec.get("max", 100))) value = QSpinBox() value.setRange(slider.minimum(), slider.maximum()) slider.setValue(int(default or slider.minimum())) value.setValue(slider.value()) slider.valueChanged.connect(value.setValue) value.valueChanged.connect(slider.setValue) slider.valueChanged.connect(self.changed) row.addWidget(slider, 1) row.addWidget(value) container.setProperty("value_widget", value) container.setMaximumWidth(420) return container widget = QSpinBox() widget.setRange(int(spec.get("min", -2_147_000_000)), int(spec.get("max", 2_147_000_000))) widget.setValue(int(default or 0)) widget.valueChanged.connect(self.changed) widget.setMinimumWidth(120) return widget if kind == "float": widget = QDoubleSpinBox() widget.setDecimals(int(spec.get("decimals", 7))) widget.setRange(float(spec.get("min", -1_000_000.0)), float(spec.get("max", 1_000_000.0))) widget.setSingleStep(float(spec.get("step", 0.0001))) widget.setValue(float(default or 0.0)) widget.valueChanged.connect(self.changed) widget.setMinimumWidth(140) return widget if kind == "multiline_text": widget = QPlainTextEdit() widget.setMaximumHeight(int(spec.get("height", 76))) widget.setPlainText(str(default or "")) widget.textChanged.connect(self.changed) widget.setMinimumWidth(260) return widget if kind in {"path", "folder"}: container = QWidget() row = QHBoxLayout(container) row.setContentsMargins(0, 0, 0, 0) edit = QLineEdit(str(default or "")) button = QPushButton("Browse") button.setFixedWidth(82) button.clicked.connect(lambda _checked=False, e=edit, k=kind: self._browse(e, k)) edit.editingFinished.connect(self.changed) row.addWidget(edit, 1) row.addWidget(button) container.setProperty("value_widget", edit) container.setMinimumWidth(260) return container widget = QLineEdit(str(default or "")) widget.editingFinished.connect(self.changed) widget.setMinimumWidth(180) return widget @staticmethod def _tooltip_for(key: str, spec: dict[str, Any]) -> str: parts = [] description = str(spec.get("description", "")).strip() if description: parts.append(description) if spec.get("required"): parts.append("Required.") if "min" in spec or "max" in spec: low = spec.get("min", "—") high = spec.get("max", "—") parts.append(f"Range: {low} to {high}.") if str(spec.get("type", "")) == "choice" and spec.get("options"): parts.append("Choices: " + ", ".join(str(option) for option in spec.get("options", [])) + ".") if spec.get("advanced"): parts.append("Advanced setting.") if not parts: parts.append(key.replace("_", " ").title()) return "\n".join(parts) def _browse(self, edit: QLineEdit, kind: str) -> None: current = Path(edit.text()).expanduser() if edit.text().strip() else Path.home() if kind == "folder": selected = QFileDialog.getExistingDirectory(self, "Select folder", str(current)) else: selected, _filter = QFileDialog.getOpenFileName(self, "Select file", str(current)) if selected: edit.setText(selected) self.changed.emit() def _advanced_changed(self, enabled: bool) -> None: for key, spec in self.schema.items(): label, widget = self.rows[key] visible = enabled or not bool(spec.get("advanced")) label.setVisible(visible) widget.setVisible(visible) def values(self) -> dict[str, Any]: return {key: self.value(key) for key in self.schema} def value(self, key: str) -> Any: widget = self.widgets[key] spec = self.schema[key] kind = str(spec.get("type", "text")) value_widget = widget.property("value_widget") if value_widget: widget = value_widget if kind == "bool": return bool(widget.isChecked()) if kind == "choice": data = widget.currentData() return data if data is not None else widget.currentText() if kind in {"int", "slider"}: return int(widget.value()) if kind == "float": return float(widget.value()) if kind == "multiline_text": return widget.toPlainText() return widget.text() def set_values(self, values: dict[str, Any]) -> None: for key, value in values.items(): if key not in self.widgets: continue self._set_value(key, value) def _set_value(self, key: str, value: Any) -> None: widget = self.widgets[key] spec = self.schema[key] kind = str(spec.get("type", "text")) value_widget = widget.property("value_widget") if value_widget: widget = value_widget try: if kind == "bool": widget.setChecked(bool(value)) elif kind == "choice": index = widget.findData(value) if index < 0: index = widget.findText(str(value)) if index >= 0: widget.setCurrentIndex(index) elif kind in {"int", "slider"}: widget.setValue(int(value)) elif kind == "float": widget.setValue(float(value)) elif kind == "multiline_text": widget.setPlainText(str(value)) else: widget.setText(str(value)) except (TypeError, ValueError): return