From d716aa30c5d02f8333880221f9a10fe40866a677 Mon Sep 17 00:00:00 2001 From: JoschD <26184899+JoschD@users.noreply.github.com> Date: Fri, 17 Nov 2023 12:54:12 +0100 Subject: [PATCH] doc doc doc --- omc3_gui/segment_by_segment/controller.py | 6 +- .../segment_by_segment/measurement_model.py | 2 +- .../segment_by_segment/measurement_view.py | 30 +- omc3_gui/utils/dataclass_ui.py | 263 ++++++++++++------ 4 files changed, 195 insertions(+), 106 deletions(-) diff --git a/omc3_gui/segment_by_segment/controller.py b/omc3_gui/segment_by_segment/controller.py index 6f31032..5198576 100644 --- a/omc3_gui/segment_by_segment/controller.py +++ b/omc3_gui/segment_by_segment/controller.py @@ -36,7 +36,7 @@ def connect_signals(self): @Slot() def open_measurements(self): - LOGGER.debug("OpenButton Clicked. Asking for folder paths.") + LOGGER.debug("Opening new optics measurement. Asking for folder paths.") filenames = OpenDirectoriesDialog( parent=self._view, caption="Select Optics Folders", @@ -48,7 +48,7 @@ def open_measurements(self): LOGGER.debug(f"User selected {len(filenames)} files.") for filename in filenames: self._last_selected_optics_path = filename.parent - LOGGER.debug(f"User selected: {filename}") + LOGGER.debug(f"adding: {filename}") optics_measurement = OpticsMeasurement.from_path(filename) try: loaded_measurements.add_item(optics_measurement) @@ -57,7 +57,7 @@ def open_measurements(self): @Slot(OpticsMeasurement) def edit_measurement(self, measurement: OpticsMeasurement): - LOGGER.debug("EditButton Clicked. Opening edit dialog.") + LOGGER.debug(f"Opening edit dialog for {measurement.display()}.") dialog = OpticsMeasurementDialog( parent=self._view, optics_measurement=measurement, diff --git a/omc3_gui/segment_by_segment/measurement_model.py b/omc3_gui/segment_by_segment/measurement_model.py index 3e2eaa4..96ec8f2 100644 --- a/omc3_gui/segment_by_segment/measurement_model.py +++ b/omc3_gui/segment_by_segment/measurement_model.py @@ -43,7 +43,7 @@ def display(self) -> str: @classmethod def get_label(cls, name: str) -> str: try: - return cls.__dataclass_fields__[name].metadata["display"] + return cls.__dataclass_fields__[name].metadata["label"] except KeyError: return name diff --git a/omc3_gui/segment_by_segment/measurement_view.py b/omc3_gui/segment_by_segment/measurement_view.py index e78c7af..5d8a8be 100644 --- a/omc3_gui/segment_by_segment/measurement_view.py +++ b/omc3_gui/segment_by_segment/measurement_view.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets from omc3_gui.segment_by_segment.measurement_model import OpticsMeasurement -from omc3_gui.utils.dataclass_ui import DataClassUI, FieldUIDef, dataclass_ui_builder +from omc3_gui.utils.dataclass_ui import DataClassUI, FieldUIDef TO_BE_DEFINED = Path("to_be_defined") @@ -33,19 +33,19 @@ def __init__(self, parent=None, optics_measurement: OpticsMeasurement = None): def _set_size(self, width: int = -1, height: int = -1): # Set position to the center of the parent parent = self.parent() - if parent is not None: - parent_geo = parent.geometry() - parent_pos = parent.mapToGlobal(parent.pos()) # multiscreen support - if width >= 0: - x = parent_pos.x() + parent_geo.width() / 2 - else: - x = parent_pos.x() + (parent_geo.width() - width) / 2 - - if height >=0 : - y = parent_pos.y() + parent_geo.height() / 2 - else: - y = parent_pos.y() + (parent_geo.height() - height) / 2 - self.move(x, y) + # if parent is not None: + # parent_geo = parent.geometry() + # parent_pos = parent.mapToGlobal(parent.pos()) # multiscreen support + # if width >= 0: + # x = parent_pos.x() + parent_geo.width() / 2 + # else: + # x = parent_pos.x() + (parent_geo.width() - width) / 2 + + # if height >=0 : + # y = parent_pos.y() + parent_geo.height() / 2 + # else: + # y = parent_pos.y() + (parent_geo.height() - height) / 2 + # self.move(x, y) # Set size self.resize(width, height) @@ -58,7 +58,7 @@ def _build_gui(self): # A little bit of coupling to the model here, # so if field-names change or are added this needs to be adjusted. # But it makes more sense to have this list here than in the model. - self._dataclass_ui = dataclass_ui_builder( + self._dataclass_ui = DataClassUI.build_dataclass_ui( field_def=[ FieldUIDef(name="measurement_dir", editable=False), *(FieldUIDef(name) for name in ("output_dir", "accel", "beam", "year", "ring")) diff --git a/omc3_gui/utils/dataclass_ui.py b/omc3_gui/utils/dataclass_ui.py index 923405b..b1623aa 100644 --- a/omc3_gui/utils/dataclass_ui.py +++ b/omc3_gui/utils/dataclass_ui.py @@ -3,31 +3,79 @@ from dataclasses import MISSING, Field, dataclass, field, fields from pathlib import Path from typing import Callable, Dict, Optional, Sequence, Tuple, Union +from PyQt5.QtWidgets import QWidget from qtpy import QtWidgets from omc3_gui.utils.widgets import HorizontalSeparator +import logging +LOGGER = logging.getLogger(__name__) + +# Helper for the dataclass definitions ----------------------------------------- @dataclass -class FieldUIDef: - name: str +class MetaData: label: Optional[str] = None - type: Optional[str] = None comment: Optional[str] = None - editable: Optional[bool] = True + choices: Optional[Sequence] = None + + def __getitem__(self, key): + return getattr(self, key) + + def get(self, key, default=None): + return getattr(self, key, default) + + +def metafield(label: str, comment: str, default=MISSING, choices: Sequence = None) -> Field: + """ Convenience function to create a dataclass-field with metadata. """ + return field(default=default, metadata=MetaData(label=label, comment=comment, choices=choices)) + + +class FilePath(Path): + """ Convenience Class to indicate that the Path should lead to a file. """ + pass + + +class DirectoryPath(Path): + """ Convenience Class to indicate that the Path should lead to a directory. """ + pass + +# DataClass UI Building -------------------------------------------------------- + +@dataclass +class FieldUIDef: + """ Definition of an FieldUI to be generated by + :func:`omc3_gui.utils.dataclass_ui.dataclass_ui_builder`. + This defines how the label and edit-field widgets should look like + for a field in the dataclass to be UI'ed. + Missing information is parsed in from the dataclass + isetlf if possible, but values given here take precedence. + """ + name: str # name of the field in the dataclass + label: Optional[str] = None # label of the field + type: Optional[str] = None # type of the field's data, neds to be instanciable + comment: Optional[str] = None # comment for the field, e.g. used for tooltips + editable: Optional[bool] = True # sets field to be editable + @dataclass class FieldUI: - widget: QtWidgets.QWidget - label: QtWidgets.QLabel - get_value: Callable - set_value: Callable - text_color: Optional[str] = "black" + """ UI-container for a dataclass field, + as generated by :func:`omc3_gui.utils.dataclass_ui.dataclass_ui_builder` + and attached to :class:`omc3_gui.utils.dataclass_ui.DataClassUI`. + """ + widget: QtWidgets.QWidget # edit-widget to edit field value + label: QtWidgets.QLabel # label-widget of the field + get_value: Callable # getter for the widget value, returns the value as appropriate type for the dataclass + set_value: Callable # setter for the widget value + text_color: Optional[str] = "black" # default text-color for both widget and label + modified: bool = False # flag indicating if the widget-content has been modified by the user def __post_init__(self): + """ Connects the widget to the label and sets the text-color. """ try: self.widget.textChanged.connect(self.has_changed) except AttributeError: @@ -36,11 +84,17 @@ def __post_init__(self): self.label.setStyleSheet(f"QLabel {{color: {self.text_color}}};") def has_changed(self): + """ Triggered when the widget has been modified. + Sets then the label font to italic. + """ + self.modified = True font = self.label.font() font.setItalic(True) self.label.setFont(font) def reset(self): + """ Reset the label font to normal and clear the modified flag. """ + self.modified = False font = self.label.font() font.setItalic(False) self.label.setFont(font) @@ -48,44 +102,131 @@ def reset(self): @dataclass class DataClassUI: - layout: QtWidgets.QGridLayout # layout containing all elements - model: object = None # dataclass instance - fields: Dict[str, FieldUI] = field(default_factory=dict) + """ Controller for the UI representation of a dataclass. + It's contains a grid-layout that can be added to any QWidget/QLayout. + """ + layout: QtWidgets.QGridLayout # final layout of the UI for dataclass + model: object = None # dataclass instance + fields: Dict[str, FieldUI] = field(default_factory=dict) # stored field UI-elements def reset_labels(self): + """ Resets all labels to indicate that the field shows the currently set value in the dataclass.""" for name in self.fields.keys(): self.fields[name].reset() def update_widget_from_model(self, name: str): + """ Updates the edit-widget of the given field from the dataclass values. + + Args: + name (str): name of the field in the dataclass + """ value = getattr(self.model, name) if value is not None: self.fields[name].set_value(value) def update_model_from_widget(self, name: str): - value = self.fields[name].get_value() + """ Updates the dataclass value of the given field from the edit-widget. + + Args: + name (str): name of the field in the dataclass + """ + field: FieldUI = self.fields[name] + + if not field.modified: # avoid replacing 'None' with widget defaults + LOGGER.debug(f"Field {name} was not modified.") + return + + value = field.get_value() setattr(self.model, name, value) def update_ui(self): + """ Updates all edit-widgets from the dataclass values. """ for name in self.fields.keys(): self.update_widget_from_model(name) def update_model(self): + """ Updates all dataclass fields from the current edit-widget values. """ for name in self.fields.keys(): self.update_model_from_widget(name) - - - -class FilePath(Path): - pass - - -class DirectoryPath(Path): - pass - - -WIDGET_TYPE_MAP = { - int: QtWidgets.QSpinBox, + @classmethod + def build_dataclass_ui(cls, + field_def: Sequence[Union[FieldUIDef, str]], dclass: Union[type, object] = None) -> 'DataClassUI': + """ Builds a DataClassUI from a list of field definitions. + NOTE: `dclass` is not automatically attached to the resulting class, + as this function works with classes and instances, but the attached object needs to be the instance. + + Args: + field_def (Sequence[Union[FieldUIDef, str]]): list of field definitions + dclass (Union[type, object], optional): dataclass type or instance. Defaults to None. + + Returns: + DataClassUI: A grid-layout containing edit-widgets and labels. + """ + field_instances = {} + if dclass is not None: + field_instances = {field.name: field for field in fields(dclass)} + + layout = QtWidgets.QGridLayout() + dataclass_ui = cls(layout) + + for idx_row, field in enumerate(field_def): + if field is None: + layout.addWidget(HorizontalSeparator(), idx_row, 0, 1, 3) + continue + + if isinstance(field, str): + layout.addWidget(QtWidgets.QLabel(field), idx_row, 0) + continue + + if field.name not in field_instances: + raise ValueError(f"Field {field.name} not found in dataclass {dclass}") + field_inst = field_instances[field.name] + + # Label --- + qlabel = QtWidgets.QLabel(field.label or field_inst.metadata.get("label", field.name)) + qlabel.setToolTip(field.comment or field_inst.metadata.get("comment", "")) + layout.addWidget(qlabel, idx_row, 0) + + # User input --- + # If field.type is not given, use evaluate from dataclass. + # Check __args__ in case of Union/Optional and use first one. + # The type needs to be instanciable! + field_type = field.type or getattr(field_inst.type, "__args__", [field_inst.type])[0] + + widget = TYPE_TO_WIDGET_MAP.get(field_type, QtWidgets.QLineEdit)() + + try: + widget.setReadOnly(not field.editable) + except AttributeError: + widget.setEnabled(field.editable) + + layout.addWidget(widget, idx_row, 1) + + get_value, set_value = build_getter_setter(widget, field_type) + dataclass_ui.fields[field.name] = FieldUI( + widget=widget, + label=qlabel, + set_value=set_value, + get_value=get_value, + text_color="#000000" if field.editable else "#bbbbbb" + ) + + # TODO: add path button + return dataclass_ui + + +# Type-to-Widget Helpers ---------------------------------------------------------------- + +class QFullIntSpinBox(QtWidgets.QSpinBox): + + def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: + super().__init__(parent) + self.setRange(-2**31, 2**31 - 1) + + +TYPE_TO_WIDGET_MAP = { + int: QFullIntSpinBox, # Maybe just use QLineEdit as well? float: QtWidgets.QLineEdit, str: QtWidgets.QLineEdit, Path: QtWidgets.QLineEdit, @@ -93,8 +234,13 @@ class DirectoryPath(Path): } -def build_getter_setter(widget, field_type) -> Tuple[Callable, Callable]: - """ Getter/Setter Factory.""" +def build_getter_setter(widget: QtWidgets.QWidget, field_type: type) -> Tuple[Callable, Callable]: + """ Getter/Setter Factory for widgets. + + Args: + widget (QtWidgets.QWidget): The widget to get/set the value of. + field_type (type): The type of the dataclass field. + """ if isinstance(widget, QtWidgets.QCheckBox): def get_value() -> bool: return widget.isChecked() @@ -109,7 +255,7 @@ def get_value(): def set_value(value): widget.setValue(value) - else: + else: # Any kind of widget should be able to handle strings. def get_value(): return field_type(widget.text()) @@ -118,63 +264,8 @@ def set_value(value): return get_value, set_value -def metafield(label: str, comment: str, default=MISSING) -> Field: - return field(default=default, metadata={"label": label, "comment": comment}) - - -def dataclass_ui_builder(field_def: Sequence[Union[FieldUIDef, str]], dclass: Union[type, object] = None) -> DataClassUI: - field_instances = {} - if dclass is not None: - field_instances = {field.name: field for field in fields(dclass)} - - layout = QtWidgets.QGridLayout() - - dataclass_ui = DataClassUI(layout) - for idx_row, field in enumerate(field_def): - if field is None: - layout.addWidget(HorizontalSeparator(), idx_row, 0, 1, 3) - continue - - if isinstance(field, str): - layout.addWidget(QtWidgets.QLabel(field), idx_row, 0) - continue - - if field.name not in field_instances: - raise ValueError(f"Field {field.name} not found in dataclass {dclass}") - field_inst = field_instances[field.name] - - # Label --- - qlabel = QtWidgets.QLabel(field.label or field_inst.metadata.get("label", field.name)) - qlabel.setToolTip(field.comment or field_inst.metadata.get("comment", "")) - layout.addWidget(qlabel, idx_row, 0) - - # User input --- - # If field.type is not given, use evaluate from dataclass. - # Check __args__ in case of Union/Optional and use first one. - # The type needs to be instanciable! - field_type = field.type or getattr(field_inst.type, "__args__", [field_inst.type])[0] - - widget = WIDGET_TYPE_MAP.get(field_type, QtWidgets.QLineEdit)() - - try: - widget.setReadOnly(not field.editable) - except AttributeError: - widget.setEnabled(field.editable) - - layout.addWidget(widget, idx_row, 1) - - get_value, set_value = build_getter_setter(widget, field_type) - dataclass_ui.fields[field.name] = FieldUI( - widget=widget, - label=qlabel, - set_value=set_value, - get_value=get_value, - text_color="#000000" if field.editable else "#bbbbbb" - ) - - # TODO: add path button - return dataclass_ui +# Other ------------------------------------------------------------------------ def get_field_inline_comments(dclass: type) -> Dict[str, str]: """ @@ -202,5 +293,3 @@ def get_field_inline_comments(dclass: type) -> Dict[str, str]: found_fields[match.group('field')] = match.group('comment') return found_fields - -