0
0
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:
2024-12-17 19:16:06 +01:00
committed by wyzula_j
parent 1c8b06cbe6
commit 54e64c9f10
2 changed files with 172 additions and 7 deletions

View File

@ -1,6 +1,5 @@
# pylint: disable=no-name-in-module
from abc import ABC, abstractmethod
from typing import Literal
from qtpy.QtWidgets import (
QApplication,
@ -28,6 +27,15 @@ class WidgetHandler(ABC):
def set_value(self, widget: QWidget, value):
"""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):
"""Handler for QLineEdit widgets."""
@ -38,6 +46,9 @@ class LineEditHandler(WidgetHandler):
def set_value(self, widget: QLineEdit, value: str) -> None:
widget.setText(value)
def connect_change_signal(self, widget: QLineEdit, slot):
widget.textChanged.connect(lambda text, w=widget: slot(w, text))
class ComboBoxHandler(WidgetHandler):
"""Handler for QComboBox widgets."""
@ -53,6 +64,11 @@ class ComboBoxHandler(WidgetHandler):
if isinstance(value, int):
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):
"""Handler for QTableWidget widgets."""
@ -72,6 +88,16 @@ class TableWidgetHandler(WidgetHandler):
item = QTableWidgetItem(str(cell_value))
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):
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
@ -82,6 +108,9 @@ class SpinBoxHandler(WidgetHandler):
def set_value(self, widget, 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):
"""Handler for QCheckBox widgets."""
@ -92,6 +121,9 @@ class CheckBoxHandler(WidgetHandler):
def set_value(self, widget, 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):
"""Handler for QLabel widgets."""
@ -99,12 +131,15 @@ class LabelHandler(WidgetHandler):
def get_value(self, widget, **kwargs):
return widget.text()
def set_value(self, widget, value):
def set_value(self, widget: QLabel, value):
widget.setText(value)
# QLabel typically doesn't have user-editable changes. No signal to connect.
# If needed, this can remain empty.
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 = {
QLineEdit: LineEditHandler,
@ -148,6 +183,17 @@ class WidgetIO:
elif not ignore_errors:
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
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)
# Example application to demonstrate the usage of the functions
if __name__ == "__main__": # pragma: no cover
# Example usage
def hierarchy_example(): # pragma: no cover
app = QApplication([])
# Create instance of WidgetHierarchy
@ -365,3 +411,37 @@ if __name__ == "__main__": # pragma: no cover
print(f"Config dict new REDUCED: {config_dict_new_reduced}")
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()

View File

@ -1,8 +1,17 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
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")
@ -22,6 +31,12 @@ def example_widget(qtbot):
# Add text items to the combo box
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.waitExposed(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_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"