From 54e64c9f10155c9cb6c77b6c18d45f65bac09f1e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 17 Dec 2024 19:16:06 +0100 Subject: [PATCH] feat(widget_io): general change signal for supported widgets --- bec_widgets/utils/widget_io.py | 90 ++++++++++++++++++++++++++++-- tests/unit_tests/test_widget_io.py | 89 ++++++++++++++++++++++++++++- 2 files changed, 172 insertions(+), 7 deletions(-) diff --git a/bec_widgets/utils/widget_io.py b/bec_widgets/utils/widget_io.py index 111ef9f6..4a81ae99 100644 --- a/bec_widgets/utils/widget_io.py +++ b/bec_widgets/utils/widget_io.py @@ -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() diff --git a/tests/unit_tests/test_widget_io.py b/tests/unit_tests/test_widget_io.py index 6e32a3b8..7d8e9a21 100644 --- a/tests/unit_tests/test_widget_io.py +++ b/tests/unit_tests/test_widget_io.py @@ -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"