mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-14 11:41:49 +02:00
feat(widget_io): general change signal for supported widgets
This commit is contained in:
@ -1,6 +1,5 @@
|
|||||||
# pylint: disable=no-name-in-module
|
# pylint: disable=no-name-in-module
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
@ -28,6 +27,15 @@ class WidgetHandler(ABC):
|
|||||||
def set_value(self, widget: QWidget, value):
|
def set_value(self, widget: QWidget, value):
|
||||||
"""Set a value on the widget instance."""
|
"""Set a value on the widget instance."""
|
||||||
|
|
||||||
|
def connect_change_signal(self, widget: QWidget, slot):
|
||||||
|
"""
|
||||||
|
Connect a change signal from this widget to the given slot.
|
||||||
|
If the widget type doesn't have a known "value changed" signal, do nothing.
|
||||||
|
|
||||||
|
slot: a function accepting two arguments (widget, value)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class LineEditHandler(WidgetHandler):
|
class LineEditHandler(WidgetHandler):
|
||||||
"""Handler for QLineEdit widgets."""
|
"""Handler for QLineEdit widgets."""
|
||||||
@ -38,6 +46,9 @@ class LineEditHandler(WidgetHandler):
|
|||||||
def set_value(self, widget: QLineEdit, value: str) -> None:
|
def set_value(self, widget: QLineEdit, value: str) -> None:
|
||||||
widget.setText(value)
|
widget.setText(value)
|
||||||
|
|
||||||
|
def connect_change_signal(self, widget: QLineEdit, slot):
|
||||||
|
widget.textChanged.connect(lambda text, w=widget: slot(w, text))
|
||||||
|
|
||||||
|
|
||||||
class ComboBoxHandler(WidgetHandler):
|
class ComboBoxHandler(WidgetHandler):
|
||||||
"""Handler for QComboBox widgets."""
|
"""Handler for QComboBox widgets."""
|
||||||
@ -53,6 +64,11 @@ class ComboBoxHandler(WidgetHandler):
|
|||||||
if isinstance(value, int):
|
if isinstance(value, int):
|
||||||
widget.setCurrentIndex(value)
|
widget.setCurrentIndex(value)
|
||||||
|
|
||||||
|
def connect_change_signal(self, widget: QComboBox, slot):
|
||||||
|
# currentIndexChanged(int) or currentIndexChanged(str) both possible.
|
||||||
|
# We use currentIndexChanged(int) for a consistent behavior.
|
||||||
|
widget.currentIndexChanged.connect(lambda idx, w=widget: slot(w, self.get_value(w)))
|
||||||
|
|
||||||
|
|
||||||
class TableWidgetHandler(WidgetHandler):
|
class TableWidgetHandler(WidgetHandler):
|
||||||
"""Handler for QTableWidget widgets."""
|
"""Handler for QTableWidget widgets."""
|
||||||
@ -72,6 +88,16 @@ class TableWidgetHandler(WidgetHandler):
|
|||||||
item = QTableWidgetItem(str(cell_value))
|
item = QTableWidgetItem(str(cell_value))
|
||||||
widget.setItem(row, col, item)
|
widget.setItem(row, col, item)
|
||||||
|
|
||||||
|
def connect_change_signal(self, widget: QTableWidget, slot):
|
||||||
|
# If desired, we could connect cellChanged(row, col) and then fetch all data.
|
||||||
|
# This might be noisy if table is large.
|
||||||
|
# For demonstration, connect cellChanged to update entire table value.
|
||||||
|
def on_cell_changed(row, col, w=widget):
|
||||||
|
val = self.get_value(w)
|
||||||
|
slot(w, val)
|
||||||
|
|
||||||
|
widget.cellChanged.connect(on_cell_changed)
|
||||||
|
|
||||||
|
|
||||||
class SpinBoxHandler(WidgetHandler):
|
class SpinBoxHandler(WidgetHandler):
|
||||||
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
|
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
|
||||||
@ -82,6 +108,9 @@ class SpinBoxHandler(WidgetHandler):
|
|||||||
def set_value(self, widget, value):
|
def set_value(self, widget, value):
|
||||||
widget.setValue(value)
|
widget.setValue(value)
|
||||||
|
|
||||||
|
def connect_change_signal(self, widget: QSpinBox | QDoubleSpinBox, slot):
|
||||||
|
widget.valueChanged.connect(lambda val, w=widget: slot(w, val))
|
||||||
|
|
||||||
|
|
||||||
class CheckBoxHandler(WidgetHandler):
|
class CheckBoxHandler(WidgetHandler):
|
||||||
"""Handler for QCheckBox widgets."""
|
"""Handler for QCheckBox widgets."""
|
||||||
@ -92,6 +121,9 @@ class CheckBoxHandler(WidgetHandler):
|
|||||||
def set_value(self, widget, value):
|
def set_value(self, widget, value):
|
||||||
widget.setChecked(value)
|
widget.setChecked(value)
|
||||||
|
|
||||||
|
def connect_change_signal(self, widget: QCheckBox, slot):
|
||||||
|
widget.toggled.connect(lambda val, w=widget: slot(w, val))
|
||||||
|
|
||||||
|
|
||||||
class LabelHandler(WidgetHandler):
|
class LabelHandler(WidgetHandler):
|
||||||
"""Handler for QLabel widgets."""
|
"""Handler for QLabel widgets."""
|
||||||
@ -99,12 +131,15 @@ class LabelHandler(WidgetHandler):
|
|||||||
def get_value(self, widget, **kwargs):
|
def get_value(self, widget, **kwargs):
|
||||||
return widget.text()
|
return widget.text()
|
||||||
|
|
||||||
def set_value(self, widget, value):
|
def set_value(self, widget: QLabel, value):
|
||||||
widget.setText(value)
|
widget.setText(value)
|
||||||
|
|
||||||
|
# QLabel typically doesn't have user-editable changes. No signal to connect.
|
||||||
|
# If needed, this can remain empty.
|
||||||
|
|
||||||
|
|
||||||
class WidgetIO:
|
class WidgetIO:
|
||||||
"""Public interface for getting and setting values using handler mapping"""
|
"""Public interface for getting, setting values and connecting signals using handler mapping"""
|
||||||
|
|
||||||
_handlers = {
|
_handlers = {
|
||||||
QLineEdit: LineEditHandler,
|
QLineEdit: LineEditHandler,
|
||||||
@ -148,6 +183,17 @@ class WidgetIO:
|
|||||||
elif not ignore_errors:
|
elif not ignore_errors:
|
||||||
raise ValueError(f"No handler for widget type: {type(widget)}")
|
raise ValueError(f"No handler for widget type: {type(widget)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def connect_widget_change_signal(widget, slot):
|
||||||
|
"""
|
||||||
|
Connect the widget's value-changed signal to a generic slot function (widget, value).
|
||||||
|
This now delegates the logic to the widget's handler.
|
||||||
|
"""
|
||||||
|
handler_class = WidgetIO._find_handler(widget)
|
||||||
|
if handler_class:
|
||||||
|
handler = handler_class()
|
||||||
|
handler.connect_change_signal(widget, slot)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_and_adjust_limits(spin_box: QDoubleSpinBox, number: float):
|
def check_and_adjust_limits(spin_box: QDoubleSpinBox, number: float):
|
||||||
"""
|
"""
|
||||||
@ -309,8 +355,8 @@ class WidgetHierarchy:
|
|||||||
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
|
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
|
||||||
|
|
||||||
|
|
||||||
# Example application to demonstrate the usage of the functions
|
# Example usage
|
||||||
if __name__ == "__main__": # pragma: no cover
|
def hierarchy_example(): # pragma: no cover
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
|
|
||||||
# Create instance of WidgetHierarchy
|
# Create instance of WidgetHierarchy
|
||||||
@ -365,3 +411,37 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
print(f"Config dict new REDUCED: {config_dict_new_reduced}")
|
print(f"Config dict new REDUCED: {config_dict_new_reduced}")
|
||||||
|
|
||||||
app.exec()
|
app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
def widget_io_signal_example(): # pragma: no cover
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
main_widget = QWidget()
|
||||||
|
layout = QVBoxLayout(main_widget)
|
||||||
|
line_edit = QLineEdit(main_widget)
|
||||||
|
combo_box = QComboBox(main_widget)
|
||||||
|
spin_box = QSpinBox(main_widget)
|
||||||
|
combo_box.addItems(["Option 1", "Option 2", "Option 3"])
|
||||||
|
|
||||||
|
layout.addWidget(line_edit)
|
||||||
|
layout.addWidget(combo_box)
|
||||||
|
layout.addWidget(spin_box)
|
||||||
|
|
||||||
|
main_widget.show()
|
||||||
|
|
||||||
|
def universal_slot(w, val):
|
||||||
|
print(f"Widget {w.objectName() or w} changed, new value: {val}")
|
||||||
|
|
||||||
|
# Connect all supported widgets through their handlers
|
||||||
|
WidgetIO.connect_widget_change_signal(line_edit, universal_slot)
|
||||||
|
WidgetIO.connect_widget_change_signal(combo_box, universal_slot)
|
||||||
|
WidgetIO.connect_widget_change_signal(spin_box, universal_slot)
|
||||||
|
|
||||||
|
app.exec_()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
# Change example function to test different scenarios
|
||||||
|
|
||||||
|
# hierarchy_example()
|
||||||
|
widget_io_signal_example()
|
||||||
|
@ -1,8 +1,17 @@
|
|||||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||||
import pytest
|
import pytest
|
||||||
from qtpy.QtWidgets import QComboBox, QLineEdit, QSpinBox, QTableWidget, QVBoxLayout, QWidget
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
|
QLineEdit,
|
||||||
|
QSpinBox,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
from bec_widgets.utils.widget_io import WidgetHierarchy, WidgetIO
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
@ -22,6 +31,12 @@ def example_widget(qtbot):
|
|||||||
# Add text items to the combo box
|
# Add text items to the combo box
|
||||||
combo_box.addItems(["Option 1", "Option 2", "Option 3"])
|
combo_box.addItems(["Option 1", "Option 2", "Option 3"])
|
||||||
|
|
||||||
|
# Populate the table widget
|
||||||
|
table_widget.setItem(0, 0, QTableWidgetItem("Initial A"))
|
||||||
|
table_widget.setItem(0, 1, QTableWidgetItem("Initial B"))
|
||||||
|
table_widget.setItem(1, 0, QTableWidgetItem("Initial C"))
|
||||||
|
table_widget.setItem(1, 1, QTableWidgetItem("Initial D"))
|
||||||
|
|
||||||
qtbot.addWidget(main_widget)
|
qtbot.addWidget(main_widget)
|
||||||
qtbot.waitExposed(main_widget)
|
qtbot.waitExposed(main_widget)
|
||||||
yield main_widget
|
yield main_widget
|
||||||
@ -88,3 +103,73 @@ def test_export_import_config(example_widget):
|
|||||||
|
|
||||||
assert exported_config_full == expected_full
|
assert exported_config_full == expected_full
|
||||||
assert exported_config_reduced == expected_reduced
|
assert exported_config_reduced == expected_reduced
|
||||||
|
|
||||||
|
|
||||||
|
def test_widget_io_get_set_value(example_widget):
|
||||||
|
# Extract widgets
|
||||||
|
line_edit = example_widget.findChild(QLineEdit)
|
||||||
|
combo_box = example_widget.findChild(QComboBox)
|
||||||
|
table_widget = example_widget.findChild(QTableWidget)
|
||||||
|
spin_box = example_widget.findChild(QSpinBox)
|
||||||
|
|
||||||
|
# Check initial values
|
||||||
|
assert WidgetIO.get_value(line_edit) == ""
|
||||||
|
assert WidgetIO.get_value(combo_box) == 0 # first index
|
||||||
|
assert WidgetIO.get_value(table_widget) == [
|
||||||
|
["Initial A", "Initial B"],
|
||||||
|
["Initial C", "Initial D"],
|
||||||
|
]
|
||||||
|
assert WidgetIO.get_value(spin_box) == 0
|
||||||
|
|
||||||
|
# Set new values
|
||||||
|
WidgetIO.set_value(line_edit, "Hello")
|
||||||
|
WidgetIO.set_value(combo_box, "Option 2")
|
||||||
|
WidgetIO.set_value(table_widget, [["X", "Y"], ["Z", "W"]])
|
||||||
|
WidgetIO.set_value(spin_box, 5)
|
||||||
|
|
||||||
|
# Check updated values
|
||||||
|
assert WidgetIO.get_value(line_edit) == "Hello"
|
||||||
|
assert WidgetIO.get_value(combo_box, as_string=True) == "Option 2"
|
||||||
|
assert WidgetIO.get_value(table_widget) == [["X", "Y"], ["Z", "W"]]
|
||||||
|
assert WidgetIO.get_value(spin_box) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_widget_io_signal(qtbot, example_widget):
|
||||||
|
# Extract widgets
|
||||||
|
line_edit = example_widget.findChild(QLineEdit)
|
||||||
|
combo_box = example_widget.findChild(QComboBox)
|
||||||
|
spin_box = example_widget.findChild(QSpinBox)
|
||||||
|
table_widget = example_widget.findChild(QTableWidget)
|
||||||
|
|
||||||
|
# We'll store changes in a list to verify the slot is called
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
def universal_slot(w, val):
|
||||||
|
changes.append((w, val))
|
||||||
|
|
||||||
|
# Connect signals
|
||||||
|
WidgetIO.connect_widget_change_signal(line_edit, universal_slot)
|
||||||
|
WidgetIO.connect_widget_change_signal(combo_box, universal_slot)
|
||||||
|
WidgetIO.connect_widget_change_signal(spin_box, universal_slot)
|
||||||
|
WidgetIO.connect_widget_change_signal(table_widget, universal_slot)
|
||||||
|
|
||||||
|
# Trigger changes
|
||||||
|
line_edit.setText("NewText")
|
||||||
|
qtbot.waitUntil(lambda: len(changes) > 0)
|
||||||
|
assert changes[-1][1] == "NewText"
|
||||||
|
|
||||||
|
combo_box.setCurrentIndex(2)
|
||||||
|
qtbot.waitUntil(lambda: len(changes) > 1)
|
||||||
|
# combo_box change should give the current index or value
|
||||||
|
# We set "Option 3" is index 2
|
||||||
|
assert changes[-1][1] == 2 or changes[-1][1] == "Option 3"
|
||||||
|
|
||||||
|
spin_box.setValue(42)
|
||||||
|
qtbot.waitUntil(lambda: len(changes) > 2)
|
||||||
|
assert changes[-1][1] == 42
|
||||||
|
|
||||||
|
# For the table widget, changing a cell triggers cellChanged
|
||||||
|
table_widget.setItem(0, 0, QTableWidgetItem("ChangedCell"))
|
||||||
|
qtbot.waitUntil(lambda: len(changes) > 3)
|
||||||
|
# The entire table value should be retrieved
|
||||||
|
assert changes[-1][1][0][0] == "ChangedCell"
|
||||||
|
Reference in New Issue
Block a user