Source code for dawiq.datawidget
"""
Data widget
===========
:mod:`dawiq.datawidget` provides :class:`DataWidget` to represent the data
structure established by the dataclass.
"""
from .qt_compat import QtCore, QtWidgets
from .fieldwidgets import (
BoolCheckBox,
IntLineEdit,
FloatLineEdit,
StrLineEdit,
EnumComboBox,
TupleGroupBox,
)
import dataclasses
from enum import Enum
from typing import Optional, Any, Union, Type, Callable, Dict, get_type_hints
from .typing import FieldWidgetProtocol, DataclassProtocol
__all__ = [
"DataWidget",
"type2Widget",
"dataclass2Widget",
]
[docs]class DataWidget(QtWidgets.QGroupBox):
"""
Group box for structured data.
This group box contains the field widgets as subwidgets. :meth:`dataValue`
returns the dictionary of field values and :meth:`setDataValue` sets the
values to the subwidgets.
Whenever the data value changes, :attr:`dataValueChanged` signal is emitted.
When any field is edited by the user, :attr:`dataEdited` signal is emitted.
This widget is also used to represent the nested dataclass field, therefore
it follows :class:`FieldWidgetProtocol`.
Notes
=====
This class can be constructed from :func:`dataclass2Widget`, but the widget
does not store the dataclass type. To associate the data widget to the
dataclass use :class:`DataclassDelegate`.
"""
dataValueChanged = QtCore.Signal(dict)
dataEdited = QtCore.Signal()
fieldValueChanged = dataValueChanged
fieldEdited = dataEdited
def __init__(
self,
orientation: QtCore.Qt.Orientation = QtCore.Qt.Orientation.Vertical,
parent=None,
):
super().__init__(parent)
self._orientation = orientation
if orientation == QtCore.Qt.Orientation.Vertical:
layout = QtWidgets.QVBoxLayout()
elif orientation == QtCore.Qt.Orientation.Horizontal:
layout = QtWidgets.QHBoxLayout()
else:
raise TypeError(f"Invalid orientation: {orientation}")
self.setLayout(layout)
[docs] def orientation(self) -> QtCore.Qt.Orientation:
"""Orientation to stack the subwidgets."""
return self._orientation
[docs] def widget(self, index: int) -> Optional[FieldWidgetProtocol]:
"""
Returns the subwidget at the given index, or None for invalid index.
"""
item = self.layout().itemAt(index)
if item is not None:
item = item.widget()
return item
[docs] def insertWidget(
self,
index: int,
widget: FieldWidgetProtocol,
stretch: int = 0,
alignment: QtCore.Qt.AlignmentFlag = QtCore.Qt.AlignmentFlag(0),
):
"""Insert the widget to layout and connect the signals."""
for i in range(self.count()):
w = self.widget(i)
if w is None:
break
if widget.fieldName() == w.fieldName():
raise KeyError(f"Data name '{widget.fieldName()}' is duplicate")
widget.fieldValueChanged.connect(self._onSubfieldValueChange)
widget.fieldEdited.connect(self.dataEdited)
self.layout().insertWidget(index, widget, stretch, alignment)
[docs] def addWidget(
self,
widget: FieldWidgetProtocol,
stretch: int = 0,
alignment: QtCore.Qt.AlignmentFlag = QtCore.Qt.AlignmentFlag(0),
):
"""Add the widget to layout and connect the signals."""
for i in range(self.count()):
w = self.widget(i)
if w is None:
break
if widget.fieldName() == w.fieldName():
raise KeyError(f"Data name '{widget.fieldName()}' is duplicate")
widget.fieldValueChanged.connect(self._onSubfieldValueChange)
widget.fieldEdited.connect(self.dataEdited)
self.layout().addWidget(widget, stretch, alignment)
[docs] def removeWidget(self, widget: FieldWidgetProtocol):
"""Remove the widget from layout and disconnect the signals."""
for i in range(self.count()):
w = self.widget(i)
if w is None:
break
if w == widget:
widget.fieldValueChanged.disconnect(self._onSubfieldValueChange)
widget.fieldEdited.disconnect(self.dataEdited)
break
self.layout().removeWidget(widget)
def dataValue(self) -> Dict[str, Any]:
ret = {}
for i in range(self.count()):
w = self.widget(i)
if w is None:
break
ret[w.fieldName()] = w.fieldValue()
return ret
fieldValue = dataValue
def setDataValue(self, data: Optional[Dict[str, Any]]):
if data is None:
data = {}
for i in range(self.count()):
w = self.widget(i)
if w is None:
break
val = data.get(w.fieldName(), None) # type: ignore[union-attr]
w.fieldValueChanged.disconnect(self._onSubfieldValueChange)
try:
w.setFieldValue(val)
except TypeError:
w.setFieldValue(None)
w.fieldValueChanged.connect(self._onSubfieldValueChange)
self.dataValueChanged.emit(data)
setFieldValue = setDataValue
def _onSubfieldValueChange(self, value: Any):
self.dataValueChanged.emit(self.dataValue())
def fieldName(self) -> str:
return self.title()
def setFieldName(self, name: str):
self.setTitle(name)
[docs] def setRequired(self, required: bool):
"""Recursively set *required* to all subwidgets."""
for i in range(self.count()):
widget = self.widget(i)
if widget is None:
continue
widget.setRequired(required)
[docs]def type2Widget(t: Any) -> FieldWidgetProtocol:
"""
Construct the widget for given type annotation *t*.
The following types are supported. Dataclass type is not converted here but
by :func:`dataclass2Widget`.
* :class:`enum.Enum` -> :class:`.EnumComboBox`
* :class:`bool` -> :class:`.BoolCheckBox`
* :obj:`Optional[bool]` -> :class:`.BoolCheckBox` with tristate
* :class:`int` or :obj:`Optional[int]` -> :class:`.IntLineEdit`
* :class:`float` or :obj:`Optional[float]` -> :class:`.FloatLineEdit`
* :class:`str` or :obj:`Optional[str]` -> :class:`.StrLineEdit`
* :obj:`Tuple` -> :class:`.TupleGroupBox` with nested field widgets
For :obj:`Tuple`, its length must be finite (no :class:`Ellipsis` in args)
and item types must be the supported type.
"""
# When new type is supported, update intro.rst as well
if isinstance(t, type) and issubclass(t, Enum):
return EnumComboBox.fromEnum(t)
if t is bool:
return BoolCheckBox()
if t is int:
return IntLineEdit()
if t is float:
return FloatLineEdit()
if t is str:
return StrLineEdit()
origin = getattr(t, "__origin__", None) # t is tuple
if origin is tuple:
args = getattr(t, "__args__", None)
if args is None:
raise TypeError("%s does not have argument type" % t)
if Ellipsis in args:
txt = "Number of arguments of %s not fixed" % t
raise TypeError(txt)
subwidgets = [type2Widget(arg) for arg in args]
tupwidget = TupleGroupBox()
for w in subwidgets:
tupwidget.addWidget(w)
return tupwidget
if origin is Union:
args = [a for a in getattr(t, "__args__") if not isinstance(None, a)]
if len(args) > 1:
msg = f"Cannot convert Union with multiple types: {t}"
raise TypeError(msg)
# t is Optional[...]
widget = type2Widget(args[0])
if isinstance(widget, BoolCheckBox):
widget.setTristate(True)
return widget
raise TypeError("Unknown type or annotation: %s" % t)
[docs]def dataclass2Widget(
dcls: Type[DataclassProtocol],
field_converter: Callable[[Any], FieldWidgetProtocol] = type2Widget,
orientation: QtCore.Qt.Orientation = QtCore.Qt.Orientation.Vertical,
globalns: Optional[Dict] = None,
localns: Optional[Dict] = None,
include_extras: bool = False,
) -> DataWidget:
"""
Construct :class:`DataWidget` from *dcls*.
Each field of *dcls* is converted to field widget by :func:`type2Widget`
with the type hint of the field. If the field has ``Qt_typehint`` metadata,
its value is used instead of the type hint.
If the field type is dataclass, construction is recursively done with same
parameters.
Parameters
==========
dcls
Dataclass type which will be converted to widget.
field_converter
Callable to convert non-dataclass fields to widgets. Default is
:class:`type2Widget`.
orientation
Argument for :class:`DataWidget`.
globalns, localns, include_extras
Arguments for :func:`get_type_hints` to resolve the forward-referenced
type annotations.
"""
widget = DataWidget(orientation)
fields = dataclasses.fields(dcls)
annots = get_type_hints(dcls, globalns, localns, include_extras)
for f in fields:
typehint = f.metadata.get("Qt_typehint", annots[f.name])
if dataclasses.is_dataclass(typehint):
field_w = dataclass2Widget(
typehint,
field_converter,
orientation,
globalns,
localns,
include_extras,
)
else:
field_w = field_converter(typehint) # type: ignore[assignment]
field_w.setFieldName(f.name)
widget.addWidget(field_w)
return widget