Source code for ezmsg.qt.settings_form
from __future__ import annotations
import types
import typing
from dataclasses import dataclass
from enum import Enum
from typing import Any
from typing import get_args
from typing import get_origin
from typing import get_type_hints
from qtpy import QtCore
from qtpy import QtWidgets
[docs]
@dataclass(frozen=True)
class SettingsFieldSpec:
name: str
typ: type[Any]
optional: bool
default: Any
def _unwrap_optional(typ: Any) -> tuple[bool, Any]:
origin = get_origin(typ)
if origin is None:
return False, typ
if origin not in (typing.Union, types.UnionType):
return False, typ
args = get_args(typ)
non_none = [a for a in args if a is not type(None)]
if len(non_none) == 1 and len(args) == 2:
return True, non_none[0]
return False, typ
def _iter_settings_fields(settings_type: type[Any]) -> list[SettingsFieldSpec]:
hints = get_type_hints(settings_type, include_extras=True)
specs: list[SettingsFieldSpec] = []
for name, hint in hints.items():
is_opt, inner = _unwrap_optional(hint)
default = getattr(settings_type, name, None)
specs.append(
SettingsFieldSpec(
name=name,
typ=inner if isinstance(inner, type) else object,
optional=is_opt,
default=default,
)
)
return specs
def _make_widget_for_type(typ: type[Any]) -> QtWidgets.QWidget:
if typ is bool:
return QtWidgets.QCheckBox()
if typ is int:
w = QtWidgets.QSpinBox()
w.setRange(-2147483648, 2147483647)
return w
if typ is float:
w = QtWidgets.QDoubleSpinBox()
w.setDecimals(6)
w.setRange(-1.0e12, 1.0e12)
w.setSingleStep(0.1)
return w
if typ is str:
return QtWidgets.QLineEdit()
if isinstance(typ, type) and issubclass(typ, Enum):
return QtWidgets.QComboBox()
raise TypeError(f"Unsupported field type for settings form: {typ!r}")
def _get_widget_value(widget: QtWidgets.QWidget, typ: type[Any]) -> Any:
if typ is bool:
assert isinstance(widget, QtWidgets.QCheckBox)
return widget.isChecked()
if typ is int:
assert isinstance(widget, QtWidgets.QSpinBox)
return int(widget.value())
if typ is float:
assert isinstance(widget, QtWidgets.QDoubleSpinBox)
return float(widget.value())
if typ is str:
assert isinstance(widget, QtWidgets.QLineEdit)
return widget.text()
if isinstance(typ, type) and issubclass(typ, Enum):
assert isinstance(widget, QtWidgets.QComboBox)
data = widget.currentData()
if isinstance(data, typ):
return data
return typ[widget.currentText()]
raise TypeError(f"Unsupported field type for settings form: {typ!r}")
def _set_widget_value(widget: QtWidgets.QWidget, typ: type[Any], value: Any) -> None:
if typ is bool:
assert isinstance(widget, QtWidgets.QCheckBox)
widget.setChecked(bool(value))
return
if typ is int:
assert isinstance(widget, QtWidgets.QSpinBox)
widget.setValue(int(value))
return
if typ is float:
assert isinstance(widget, QtWidgets.QDoubleSpinBox)
widget.setValue(float(value))
return
if typ is str:
assert isinstance(widget, QtWidgets.QLineEdit)
widget.setText("" if value is None else str(value))
return
if isinstance(typ, type) and issubclass(typ, Enum):
assert isinstance(widget, QtWidgets.QComboBox)
if isinstance(value, typ):
idx = widget.findData(value)
if idx >= 0:
widget.setCurrentIndex(idx)
return
widget.setCurrentText(value.name)
return
if isinstance(value, str):
widget.setCurrentText(value)
return
raise TypeError(f"Enum field expects {typ.__name__} or str, got {type(value)}")
raise TypeError(f"Unsupported field type for settings form: {typ!r}")
[docs]
class SettingsForm(QtWidgets.QWidget):
settings_changed = QtCore.Signal() # pyright: ignore[reportPrivateImportUsage]
[docs]
def __init__(
self,
settings_type: type[Any],
initial: Any | None = None,
parent: QtWidgets.QWidget | None = None,
):
super().__init__(parent)
self._settings_type = settings_type
self._specs = _iter_settings_fields(settings_type)
self._widgets: dict[str, QtWidgets.QWidget] = {}
self._updating = False
self.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Maximum,
QtWidgets.QSizePolicy.Policy.Maximum,
)
layout = QtWidgets.QFormLayout()
layout.setFieldGrowthPolicy(
QtWidgets.QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint
)
layout.setFormAlignment(
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop
)
layout.setLabelAlignment(
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.setLayout(layout)
for spec in self._specs:
widget = _make_widget_for_type(spec.typ)
if isinstance(spec.typ, type) and issubclass(spec.typ, Enum):
assert isinstance(widget, QtWidgets.QComboBox)
for member in spec.typ:
widget.addItem(member.name, member)
widget.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Maximum,
QtWidgets.QSizePolicy.Policy.Fixed,
)
self._widgets[spec.name] = widget
layout.addRow(spec.name, widget)
self._updating = True
if initial is not None:
self.set_settings(initial)
else:
for spec in self._specs:
if spec.optional and spec.default is None:
continue
try:
_set_widget_value(self._widgets[spec.name], spec.typ, spec.default)
except Exception:
pass
self._updating = False
for spec in self._specs:
self._connect_widget(spec, self._widgets[spec.name])
@property
def settings_type(self) -> type[Any]:
return self._settings_type
[docs]
def get_settings(self) -> Any:
values: dict[str, Any] = {}
for spec in self._specs:
widget = self._widgets[spec.name]
value = _get_widget_value(widget, spec.typ)
values[spec.name] = value
return self._settings_type(**values)
[docs]
def set_settings(self, settings: Any) -> None:
self._updating = True
for spec in self._specs:
if not hasattr(settings, spec.name):
continue
value = getattr(settings, spec.name)
_set_widget_value(self._widgets[spec.name], spec.typ, value)
self._updating = False
def _connect_widget(
self,
spec: SettingsFieldSpec,
widget: QtWidgets.QWidget,
) -> None:
if spec.typ is bool:
assert isinstance(widget, QtWidgets.QCheckBox)
widget.toggled.connect(self._emit_settings_changed)
return
if spec.typ is int:
assert isinstance(widget, QtWidgets.QSpinBox)
widget.valueChanged.connect(self._emit_settings_changed)
return
if spec.typ is float:
assert isinstance(widget, QtWidgets.QDoubleSpinBox)
widget.valueChanged.connect(self._emit_settings_changed)
return
if spec.typ is str:
assert isinstance(widget, QtWidgets.QLineEdit)
widget.textChanged.connect(self._emit_settings_changed)
return
if isinstance(spec.typ, type) and issubclass(spec.typ, Enum):
assert isinstance(widget, QtWidgets.QComboBox)
widget.currentIndexChanged.connect(self._emit_settings_changed)
def _emit_settings_changed(self, *_args: Any) -> None:
if not self._updating:
self.settings_changed.emit()