Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactored validated input widgets to be more extensible #42

Merged
merged 3 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions rascal2/dialogs/settings_dialog.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from PyQt6 import QtCore, QtWidgets

from rascal2.core.settings import Settings, SettingsGroups, delete_local_settings
from rascal2.widgets.inputs import ValidatedInputWidget
from rascal2.widgets.inputs import get_validated_input


class SettingsDialog(QtWidgets.QDialog):
Expand Down Expand Up @@ -84,7 +84,7 @@ def __init__(self, parent: SettingsDialog, group: SettingsGroups):
for i, setting in enumerate(group_settings):
label_text = setting.replace("_", " ").title()
tab_layout.addWidget(QtWidgets.QLabel(label_text), i, 0)
self.widgets[setting] = ValidatedInputWidget(field_info[setting])
self.widgets[setting] = get_validated_input(field_info[setting])
try:
self.widgets[setting].set_data(getattr(self.settings, setting))
except TypeError:
Expand Down
4 changes: 2 additions & 2 deletions rascal2/widgets/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from rascal2.widgets.controls import ControlsWidget
from rascal2.widgets.inputs import AdaptiveDoubleSpinBox, ValidatedInputWidget
from rascal2.widgets.inputs import AdaptiveDoubleSpinBox, get_validated_input
from rascal2.widgets.terminal import TerminalWidget

__all__ = ["ControlsWidget", "TerminalWidget", "ValidatedInputWidget", "AdaptiveDoubleSpinBox"]
__all__ = ["ControlsWidget", "AdaptiveDoubleSpinBox", "get_validated_input", "TerminalWidget"]
4 changes: 2 additions & 2 deletions rascal2/widgets/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from RATapi.utils.enums import Procedures

from rascal2.config import path_for
from rascal2.widgets.inputs import ValidatedInputWidget
from rascal2.widgets.inputs import get_validated_input


class ControlsWidget(QtWidgets.QWidget):
Expand Down Expand Up @@ -196,7 +196,7 @@ def __init__(self, parent, settings, presenter):
controls_fields = self.get_controls_attribute("model_fields")
for i, setting in enumerate(settings):
field_info = controls_fields[setting]
self.rows[setting] = ValidatedInputWidget(field_info)
self.rows[setting] = get_validated_input(field_info)
self.datasetter[setting] = self.create_model_data_setter(setting)
self.rows[setting].edited_signal.connect(self.datasetter[setting])
label = QtWidgets.QLabel(setting)
Expand Down
186 changes: 132 additions & 54 deletions rascal2/widgets/inputs.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Widget for validated user inputs."""
"""Widgets for validated user inputs."""

from enum import Enum
from math import floor, log10
Expand All @@ -8,76 +8,154 @@
from PyQt6 import QtCore, QtGui, QtWidgets


class ValidatedInputWidget(QtWidgets.QWidget):
"""Value input generated from Pydantic field info.
def get_validated_input(field_info: FieldInfo) -> QtWidgets.QWidget:
"""Get a validated input widget from Pydantic field info.

Parameters
----------
field_info: FieldInfo
field_info : FieldInfo
The Pydantic field info for the field.

Returns
-------
QtWidgets.QWidget
The validated input widget for the field.

"""
class_widgets = {
bool: BoolInputWidget,
int: IntInputWidget,
float: FloatInputWidget,
Enum: EnumInputWidget,
}

for type, widget in class_widgets.items():
if issubclass(field_info.annotation, type):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The names type and input shadow built-in globals while I don't think anyone will every use input in this project, I advise changing these names to avoid conflict or bugs in future. Might be good to add this to our linter if its easy to do

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good spot - i've added the flake8-naming ruleset to the linting and fixed a couple other places where it was broken.

return widget(field_info)

return BaseInputWidget(field_info)


class BaseInputWidget(QtWidgets.QWidget):
"""Base class for input generated from Pydantic field info.

This base class is used for unrecognised types.

Parameters
----------
field_info : FieldInfo
The Pydantic field info for the input.
parent: QWidget or None, default None
parent : QWidget or None, default None
The parent widget of this widget.

"""

data_getter = "text"
data_setter = "setText"
edit_signal = "textChanged"

def __init__(self, field_info: FieldInfo, parent=None):
super().__init__(parent=parent)
layout = QtWidgets.QVBoxLayout()
# editor_data and change_editor_data are set to the getter and setter
# methods for the actual editor inside the widget
self.get_data: Callable
self.set_data: Callable
self.edited_signal: QtCore.pyqtSignal

# widget, getter, setter and change signal for different datatypes
editor_types = {
int: (QtWidgets.QSpinBox, "value", "setValue", "editingFinished"),
float: (AdaptiveDoubleSpinBox, "value", "setValue", "editingFinished"),
bool: (QtWidgets.QCheckBox, "isChecked", "setChecked", "checkStateChanged"),
}
defaults = (QtWidgets.QLineEdit, "text", "setText", "textChanged")

if issubclass(field_info.annotation, Enum):
self.editor = QtWidgets.QComboBox(self)
self.editor.addItems(str(e) for e in field_info.annotation)
self.get_data = self.editor.currentText
self.set_data = self.editor.setCurrentText
self.edited_signal = self.editor.currentTextChanged
else:
editor, getter, setter, signal = editor_types.get(field_info.annotation, defaults)
self.editor = editor(self)
self.get_data = getattr(self.editor, getter)
self.set_data = getattr(self.editor, setter)
self.edited_signal = getattr(self.editor, signal)
if isinstance(self.editor, QtWidgets.QSpinBox):
for item in field_info.metadata:
if hasattr(item, "ge"):
self.editor.setMinimum(item.ge)
if hasattr(item, "gt"):
self.editor.setMinimum(item.gt + 1)
if hasattr(item, "le"):
self.editor.setMaximum(item.le)
if hasattr(item, "lt"):
self.editor.setMaximum(item.lt - 1)
elif isinstance(self.editor, AdaptiveDoubleSpinBox):
for item in field_info.metadata:
for attr in ["ge", "gt"]:
if hasattr(item, attr):
self.editor.setMinimum(getattr(item, attr))
for attr in ["le", "lt"]:
if hasattr(item, attr):
self.editor.setMaximum(getattr(item, attr))
# if no default exists, field_info.default is PydanticUndefined not a nonexistent attribute
if isinstance(field_info.default, (int, float)) and field_info.default > 0:
# set default decimals to order of magnitude of default value
self.editor.setDecimals(-floor(log10(abs(field_info.default))))

self.editor: QtWidgets.QWidget = self.create_editor(field_info)
self.get_data: Callable = getattr(self.editor, self.data_getter)
self.set_data: Callable = getattr(self.editor, self.data_setter)
self.edited_signal: QtCore.pyqtSignal = getattr(self.editor, self.edit_signal)

layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.editor)
layout.setContentsMargins(5, 0, 0, 0)

self.setLayout(layout)
self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed)

def create_editor(self, field_info: FieldInfo) -> QtWidgets.QWidget:
"""Create the relevant editor for the field information.

Parameters
----------
field_info : FieldInfo
The Pydantic field information for the input.

Returns
-------
QtWidgets.QWidget
A widget which allows restricted input based on the field information.

"""
return QtWidgets.QLineEdit(self)


class IntInputWidget(BaseInputWidget):
"""Input widget for integer data with optional minimum and maximum values."""

data_getter = "value"
data_setter = "setValue"
edit_signal = "editingFinished"

def create_editor(self, field_info: FieldInfo) -> QtWidgets.QWidget:
editor = QtWidgets.QSpinBox(self)
for item in field_info.metadata:
if hasattr(item, "ge"):
editor.setMinimum(item.ge)
if hasattr(item, "gt"):
editor.setMinimum(item.gt + 1)
if hasattr(item, "le"):
editor.setMaximum(item.le)
if hasattr(item, "lt"):
editor.setMaximum(item.lt - 1)

return editor


class FloatInputWidget(BaseInputWidget):
"""Input widget for float data with optional minimum and maximum values."""

data_getter = "value"
data_setter = "setValue"
edit_signal = "editingFinished"

def create_editor(self, field_info: FieldInfo) -> QtWidgets.QWidget:
editor = AdaptiveDoubleSpinBox(self)
for item in field_info.metadata:
for attr in ["ge", "gt"]:
if hasattr(item, attr):
editor.setMinimum(getattr(item, attr))
for attr in ["le", "lt"]:
if hasattr(item, attr):
editor.setMaximum(getattr(item, attr))
# if no default exists, field_info.default is PydanticUndefined not a nonexistent attribute
if isinstance(field_info.default, (int, float)) and field_info.default > 0:
# set default decimals to order of magnitude of default value
editor.setDecimals(-floor(log10(abs(field_info.default))))

return editor


class BoolInputWidget(BaseInputWidget):
"""Input widget for boolean data."""

data_getter = "isChecked"
data_setter = "setChecked"
edit_signal = "checkStateChanged"

def create_editor(self, field_info: FieldInfo) -> QtWidgets.QWidget:
return QtWidgets.QCheckBox(self)


class EnumInputWidget(BaseInputWidget):
"""Input widget for Enums."""

data_getter = "currentText"
data_setter = "setCurrentText"
edit_signal = "currentTextChanged"

def create_editor(self, field_info: FieldInfo) -> QtWidgets.QWidget:
editor = QtWidgets.QComboBox(self)
editor.addItems(str(e) for e in field_info.annotation)

return editor


class AdaptiveDoubleSpinBox(QtWidgets.QDoubleSpinBox):
"""A double spinbox which adapts to given numbers of decimals."""
Expand Down
4 changes: 2 additions & 2 deletions tests/test_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pydantic.fields import FieldInfo
from PyQt6 import QtWidgets

from rascal2.widgets import AdaptiveDoubleSpinBox, ValidatedInputWidget
from rascal2.widgets import AdaptiveDoubleSpinBox, get_validated_input


class MyEnum(StrEnum):
Expand All @@ -28,7 +28,7 @@ class MyEnum(StrEnum):
def test_editor_type(field_info, expected_type, example_data):
"""Test that the editor type is as expected, and can be read and written."""

widget = ValidatedInputWidget(field_info)
widget = get_validated_input(field_info)
assert isinstance(widget.editor, expected_type)
widget.set_data(example_data)
assert widget.get_data() == example_data
Expand Down
Loading