From ebf9033f1f49e238cdf58fde9ca97993e98e7241 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 19 Mar 2026 11:14:14 +0100 Subject: [PATCH 1/2] fix(dap_combobox): added safeguard for no DAP models --- bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py | 7 +++++-- tests/unit_tests/test_dap_combobox.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py b/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py index 5931d6726..10979628e 100644 --- a/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py +++ b/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py @@ -65,8 +65,10 @@ def select_default_fit(self, default_fit: str | None): """ if self._validate_dap_model(default_fit): self.select_fit_model(default_fit) - else: + elif self._validate_dap_model("GaussianModel"): self.select_fit_model("GaussianModel") + elif self.available_models: + self.select_fit_model(self.available_models[0]) @property def available_models(self): @@ -154,7 +156,8 @@ def select_fit_model(self, fit_name: str | None): def populate_fit_model_combobox(self): """Populate the fit_model_combobox with the devices.""" # pylint: disable=protected-access - self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()] + available_plugins = getattr(getattr(self.client, "dap", None), "_available_dap_plugins", {}) + self.available_models = [model for model in available_plugins.keys()] self.fit_model_combobox.clear() self.fit_model_combobox.addItems(self.available_models) diff --git a/tests/unit_tests/test_dap_combobox.py b/tests/unit_tests/test_dap_combobox.py index 93cdfca0f..b4854ff3f 100644 --- a/tests/unit_tests/test_dap_combobox.py +++ b/tests/unit_tests/test_dap_combobox.py @@ -71,3 +71,13 @@ def my_callback(msg: str): dap_combobox.fit_model_updated.connect(my_callback) dap_combobox.fit_model_combobox.setCurrentText("SineModel") assert container[0] == "SineModel" + + +def test_dap_combobox_init_without_available_models(qtbot, mocked_client): + mocked_client.dap._available_dap_plugins = {} + + widget = create_widget(qtbot, DapComboBox, client=mocked_client) + + assert widget.available_models == [] + assert widget.fit_model_combobox.count() == 0 + assert widget.fit_model_combobox.currentText() == "" From b435f247386341e77d4daa8fde3e50ebd0c83915 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 19 Mar 2026 11:28:48 +0100 Subject: [PATCH 2/2] fix(dap_combobox): rewritten as proper combobox --- bec_widgets/cli/client.py | 4 +- .../dap/dap_combo_box/dap_combo_box.py | 100 +++++++++++------- tests/unit_tests/test_dap_combobox.py | 14 +++ 3 files changed, 78 insertions(+), 40 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index b5fa38aee..86c3510e7 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -985,7 +985,7 @@ def dap_oversample(self): class DapComboBox(RPCBase): - """The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.""" + """Editable combobox listing the available DAP models.""" @rpc_call def select_y_axis(self, y_axis: str): @@ -1011,7 +1011,7 @@ def select_fit_model(self, fit_name: str | None): Slot to update the fit model. Args: - default_device(str): Default device name. + fit_name(str): Fit model name. """ diff --git a/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py b/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py index 10979628e..5b6eec8d8 100644 --- a/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py +++ b/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py @@ -2,22 +2,19 @@ from bec_lib.logger import bec_logger from qtpy.QtCore import Property, Signal, Slot -from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget +from qtpy.QtWidgets import QComboBox from bec_widgets.utils.bec_widget import BECWidget logger = bec_logger.logger -class DapComboBox(BECWidget, QWidget): +class DapComboBox(BECWidget, QComboBox): """ - The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC. + Editable combobox listing the available DAP models. - Args: - parent: Parent widget. - client: BEC client object. - gui_id: GUI ID. - default: Default device name. + The widget behaves as a plain QComboBox and keeps ``fit_model_combobox`` as an alias to itself + for backwards compatibility with older call sites. """ ICON_NAME = "data_exploration" @@ -45,19 +42,20 @@ def __init__( **kwargs, ): super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs) - self.layout = QVBoxLayout(self) - self.fit_model_combobox = QComboBox(self) - self.layout.addWidget(self.fit_model_combobox) - self.layout.setContentsMargins(0, 0, 0, 0) - self._available_models = None + self.fit_model_combobox = self # Just for backwards compatibility with older call sites, the widget itself is the combobox + self._available_models: list[str] = [] self._x_axis = None self._y_axis = None + self._is_valid_input = False + + self.setEditable(True) + self.populate_fit_model_combobox() - self.fit_model_combobox.currentTextChanged.connect(self._update_current_fit) - # Set default fit model + self.currentTextChanged.connect(self._on_text_changed) self.select_default_fit(default_fit) + self.check_validity(self.currentText()) - def select_default_fit(self, default_fit: str | None): + def select_default_fit(self, default_fit: str | None = "GaussianModel"): """Set the default fit model. Args: @@ -65,8 +63,6 @@ def select_default_fit(self, default_fit: str | None): """ if self._validate_dap_model(default_fit): self.select_fit_model(default_fit) - elif self._validate_dap_model("GaussianModel"): - self.select_fit_model("GaussianModel") elif self.available_models: self.select_fit_model(self.available_models[0]) @@ -116,12 +112,40 @@ def y_axis(self, y_axis: str): self._y_axis = y_axis self.y_axis_updated.emit(y_axis) - def _update_current_fit(self, fit_name: str): - """Update the current fit.""" + @Slot(str) + def _on_text_changed(self, fit_name: str): + """ + Validate and emit updates for the current text. + + Args: + fit_name(str): The current text in the combobox, representing the selected fit model. + """ + self.check_validity(fit_name) + if not self._is_valid_input: + return + self.fit_model_updated.emit(fit_name) if self.x_axis is not None and self.y_axis is not None: self.new_dap_config.emit(self._x_axis, self._y_axis, fit_name) + @Slot(str) + def check_validity(self, fit_name: str): + """ + Highlight invalid manual entries similarly to DeviceComboBox. + + Args: + fit_name(str): The current text in the combobox, representing the selected fit model. + """ + if self._validate_dap_model(fit_name): + self._is_valid_input = True + self.setStyleSheet("border: 1px solid transparent;") + else: + self._is_valid_input = False + if self.isEnabled(): + self.setStyleSheet("border: 1px solid red;") + else: + self.setStyleSheet("border: 1px solid transparent;") + @Slot(str) def select_x_axis(self, x_axis: str): """Slot to update the x axis. @@ -130,7 +154,7 @@ def select_x_axis(self, x_axis: str): x_axis(str): X axis. """ self.x_axis = x_axis - self._update_current_fit(self.fit_model_combobox.currentText()) + self._on_text_changed(self.currentText()) @Slot(str) def select_y_axis(self, y_axis: str): @@ -140,26 +164,26 @@ def select_y_axis(self, y_axis: str): y_axis(str): Y axis. """ self.y_axis = y_axis - self._update_current_fit(self.fit_model_combobox.currentText()) + self._on_text_changed(self.currentText()) @Slot(str) def select_fit_model(self, fit_name: str | None): """Slot to update the fit model. Args: - default_device(str): Default device name. + fit_name(str): Fit model name. """ if not self._validate_dap_model(fit_name): raise ValueError(f"Fit {fit_name} is not valid.") - self.fit_model_combobox.setCurrentText(fit_name) + self.setCurrentText(fit_name) def populate_fit_model_combobox(self): """Populate the fit_model_combobox with the devices.""" # pylint: disable=protected-access available_plugins = getattr(getattr(self.client, "dap", None), "_available_dap_plugins", {}) self.available_models = [model for model in available_plugins.keys()] - self.fit_model_combobox.clear() - self.fit_model_combobox.addItems(self.available_models) + self.clear() + self.addItems(self.available_models) def _validate_dap_model(self, model: str | None) -> bool: """Validate the DAP model. @@ -169,23 +193,23 @@ def _validate_dap_model(self, model: str | None) -> bool: """ if model is None: return False - if model not in self.available_models: - return False - return True + return model in self.available_models + + @property + def is_valid_input(self) -> bool: + """Whether the current text matches an available DAP model.""" + return self._is_valid_input if __name__ == "__main__": # pragma: no cover - # pylint: disable=import-outside-toplevel + import sys + from qtpy.QtWidgets import QApplication from bec_widgets.utils.colors import apply_theme - app = QApplication([]) + app = QApplication(sys.argv) apply_theme("dark") - widget = QWidget() - widget.setFixedSize(200, 200) - layout = QVBoxLayout() - widget.setLayout(layout) - layout.addWidget(DapComboBox()) - widget.show() - app.exec_() + dialog = DapComboBox() + dialog.show() + sys.exit(app.exec_()) diff --git a/tests/unit_tests/test_dap_combobox.py b/tests/unit_tests/test_dap_combobox.py index b4854ff3f..5e58cf54d 100644 --- a/tests/unit_tests/test_dap_combobox.py +++ b/tests/unit_tests/test_dap_combobox.py @@ -17,6 +17,8 @@ def dap_combobox(qtbot, mocked_client): def test_dap_combobox_init(dap_combobox): """Test DapComboBox init.""" + assert dap_combobox.fit_model_combobox is dap_combobox + assert dap_combobox.isEditable() is True assert dap_combobox.fit_model_combobox.currentText() == "GaussianModel" assert dap_combobox.available_models == ["GaussianModel", "LorentzModel", "SineModel"] assert dap_combobox._validate_dap_model("GaussianModel") is True @@ -81,3 +83,15 @@ def test_dap_combobox_init_without_available_models(qtbot, mocked_client): assert widget.available_models == [] assert widget.fit_model_combobox.count() == 0 assert widget.fit_model_combobox.currentText() == "" + + +def test_dap_combobox_invalid_manual_entry_highlighted(dap_combobox): + dap_combobox.setCurrentText("not-a-model") + + assert dap_combobox.is_valid_input is False + assert "red" in dap_combobox.styleSheet() + + dap_combobox.setCurrentText("GaussianModel") + + assert dap_combobox.is_valid_input is True + assert "transparent" in dap_combobox.styleSheet()