From d3e5eea1b215ce5afdc71823da5a54b76ae1e542 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 12 May 2026 20:34:18 +0200 Subject: [PATCH] feat(device_input): comboboxes can have line edit like autocomplete --- .../device_combobox/device_combobox.py | 37 +++++++++++++++- .../signal_combobox/signal_combobox.py | 42 ++++++++++++++++++- .../control/scan_control/scan_group_box.py | 1 + tests/unit_tests/test_device_input_widgets.py | 19 +++++++++ tests/unit_tests/test_device_signal_input.py | 21 ++++++++++ tests/unit_tests/test_scan_control.py | 1 + 6 files changed, 117 insertions(+), 4 deletions(-) diff --git a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py index 62f5ec86..268ddbb7 100644 --- a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +++ b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py @@ -7,8 +7,8 @@ from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority from bec_lib.device import Signal as BECSignal from bec_lib.logger import bec_logger from pydantic import Field, field_validator -from qtpy.QtCore import QSize, Signal, Slot -from qtpy.QtWidgets import QComboBox, QSizePolicy +from qtpy.QtCore import QSize, QStringListModel, Signal, Slot +from qtpy.QtWidgets import QComboBox, QCompleter, QSizePolicy from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget @@ -36,6 +36,7 @@ class DeviceInputConfig(ConnectionConfig): arg_name: str | None = None apply_filter: bool = True signal_class_filter: list[str] = Field(default_factory=list) + autocomplete: bool = False @field_validator("device_filter") @classmethod @@ -104,6 +105,7 @@ class DeviceComboBox(BECWidget, QComboBox): default: str | None = None, arg_name: str | None = None, signal_class_filter: list[str] | None = None, + autocomplete: bool | None = None, **kwargs, ): self.config = self._process_config(config) @@ -124,6 +126,7 @@ class DeviceComboBox(BECWidget, QComboBox): self._is_valid_input = False self._accent_colors = get_accent_colors() self._set_first_element_as_empty = False + self._completer_model = QStringListModel(self) self.setEditable(True) self.setInsertPolicy(QComboBox.NoInsert) @@ -144,6 +147,10 @@ class DeviceComboBox(BECWidget, QComboBox): signal_class_filter = self.config.signal_class_filter if default is None and self.config.default: default = self.config.default + if autocomplete is not None: + self.config.autocomplete = autocomplete + if self.config.autocomplete: + self.autocomplete = True if available_devices is not None: self.set_available_devices(available_devices) @@ -358,6 +365,20 @@ class DeviceComboBox(BECWidget, QComboBox): if not current_text: self.setCurrentText("") + @SafeProperty(bool) + def autocomplete(self) -> bool: + """Whether autocomplete suggestions are enabled while editing.""" + return self.config.autocomplete + + @autocomplete.setter + def autocomplete(self, value: bool) -> None: + self.config.autocomplete = value + if value: + completer = QCompleter(self._completer_model, self) + self.setCompleter(completer) + else: + self._restore_default_completer() + @property def device_filter(self) -> list[BECDeviceFilter]: """Device class filters.""" @@ -508,10 +529,22 @@ class DeviceComboBox(BECWidget, QComboBox): def _replace_items(self, devices: list[str]): current_text = self.currentText() replace_combobox_items(self, devices) + self._update_completer_model(devices) if self._set_first_element_as_empty: self.insertItem(0, "") self.setCurrentText(current_text) + def _update_completer_model(self, items: list[str]) -> None: + self._completer_model.setStringList(items) + + def _restore_default_completer(self) -> None: + if self.completer() is not None and self.completer().model() == self.model(): + return + current_text = self.currentText() + self.setEditable(False) + self.setEditable(True) + self.setCurrentText(current_text) + def _device_name_from_text(self, text: str) -> str: index = self.findText(text) if index >= 0 and isinstance(self.itemData(index), tuple): diff --git a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py index 39041a7a..6af2946d 100644 --- a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py +++ b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py @@ -3,8 +3,8 @@ from __future__ import annotations from bec_lib.callback_handler import EventType from bec_lib.device import Signal as BECSignal from bec_lib.logger import bec_logger -from qtpy.QtCore import Property, QSize, Qt, Signal, Slot -from qtpy.QtWidgets import QComboBox, QSizePolicy +from qtpy.QtCore import Property, QSize, QStringListModel, Qt, Signal, Slot +from qtpy.QtWidgets import QComboBox, QCompleter, QSizePolicy from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget @@ -29,6 +29,7 @@ class SignalComboBoxConfig(ConnectionConfig): arg_name: str | None = None device: str | None = None signals: list[str] | None = None + autocomplete: bool = False class SignalComboBox(BECWidget, QComboBox): @@ -73,6 +74,7 @@ class SignalComboBox(BECWidget, QComboBox): arg_name: str | None = None, store_signal_config: bool = True, require_device: bool = False, + autocomplete: bool | None = None, **kwargs, ): self.config = self._process_config(config) @@ -90,6 +92,7 @@ class SignalComboBox(BECWidget, QComboBox): self._store_signal_config = store_signal_config self._require_device = require_device self._is_valid_input = False + self._completer_model = QStringListModel(self) if arg_name is not None: self.config.arg_name = arg_name @@ -105,12 +108,16 @@ class SignalComboBox(BECWidget, QComboBox): device = self.config.device if default is None and self.config.default: default = self.config.default + if autocomplete is not None: + self.config.autocomplete = autocomplete self.config.ndim_filter = ndim_filter self.setEditable(True) self.setInsertPolicy(QComboBox.NoInsert) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.setMinimumSize(QSize(100, 0)) + if self.config.autocomplete: + self.autocomplete = True self._device_update_register = self.bec_dispatcher.client.callbacks.register( EventType.DEVICE_UPDATE, self.update_signals_from_filters @@ -281,6 +288,20 @@ class SignalComboBox(BECWidget, QComboBox): self._require_device = value self.update_signals_from_filters() + @SafeProperty(bool) + def autocomplete(self) -> bool: + """Whether autocomplete suggestions are enabled while editing.""" + return self.config.autocomplete + + @autocomplete.setter + def autocomplete(self, value: bool) -> None: + self.config.autocomplete = value + if value: + completer = QCompleter(self._completer_model, self) + self.setCompleter(completer) + else: + self._restore_default_completer() + @property def signals(self) -> list[str | tuple[str, dict]]: """Available signals after filtering.""" @@ -427,6 +448,7 @@ class SignalComboBox(BECWidget, QComboBox): self.config.signals = [ entry if isinstance(entry, str) else entry[0] for entry in self._signals ] + self._update_completer_model(self.config.signals) if self._set_first_element_as_empty and self.count() > 0 and self.itemText(0) != "": self.insertItem(0, "") @@ -494,6 +516,7 @@ class SignalComboBox(BECWidget, QComboBox): def _replace_signal_items(self): replace_combobox_items(self, self._signals) + self._update_completer_model(self._signal_display_texts(self._signals)) if self._set_first_element_as_empty and self.count() > 0 and self.itemText(0) != "": self.insertItem(0, "") @@ -543,6 +566,21 @@ class SignalComboBox(BECWidget, QComboBox): return item_index return -1 + @staticmethod + def _signal_display_texts(signals: list[str | tuple[str, dict]]) -> list[str]: + return [entry[0] if isinstance(entry, tuple) else entry for entry in signals] + + def _update_completer_model(self, items: list[str]) -> None: + self._completer_model.setStringList(items) + + def _restore_default_completer(self) -> None: + if self.completer() is not None and self.completer().model() == self.model(): + return + current_text = self.currentText() + self.setEditable(False) + self.setEditable(True) + self.setCurrentText(current_text) + if __name__ == "__main__": # pragma: no cover from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget diff --git a/bec_widgets/widgets/control/scan_control/scan_group_box.py b/bec_widgets/widgets/control/scan_control/scan_group_box.py index 09218ac2..c690a399 100644 --- a/bec_widgets/widgets/control/scan_control/scan_group_box.py +++ b/bec_widgets/widgets/control/scan_control/scan_group_box.py @@ -277,6 +277,7 @@ class ScanGroupBox(QGroupBox): arg_name=arg_name, default=default, device_filter=BECDeviceFilter.DEVICE, + autocomplete=True, ) else: widget = widget_class(parent=self.parent(), arg_name=arg_name, default=default) diff --git a/tests/unit_tests/test_device_input_widgets.py b/tests/unit_tests/test_device_input_widgets.py index bb4ff07d..2a7e8c31 100644 --- a/tests/unit_tests/test_device_input_widgets.py +++ b/tests/unit_tests/test_device_input_widgets.py @@ -48,6 +48,9 @@ def test_device_input_combobox_init(device_input_combobox): ReadoutPriority.ON_REQUEST.value, ] assert device_input_combobox.config.default is None + assert device_input_combobox.autocomplete is False + assert device_input_combobox.completer() is not None + assert device_input_combobox.completer().model() == device_input_combobox.model() assert device_input_combobox.devices == [ "samx", "samy", @@ -76,6 +79,22 @@ def test_device_input_combobox_init_with_kwargs(device_input_combobox_with_kwarg assert device_input_combobox_with_kwargs.config.arg_name == "test_arg_name" +def test_device_input_combobox_autocomplete(qtbot, mocked_client): + widget = DeviceComboBox(client=mocked_client, autocomplete=True) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + assert widget.autocomplete is True + assert widget.completer() is not None + assert widget.completer().model().stringList() == widget.devices + assert widget.completer().model() != widget.model() + + widget.autocomplete = False + + assert widget.completer() is not None + assert widget.completer().model() == widget.model() + + def test_get_device_from_input_combobox_init(device_input_combobox): device_input_combobox.setCurrentIndex(0) device_text = device_input_combobox.currentText() diff --git a/tests/unit_tests/test_device_signal_input.py b/tests/unit_tests/test_device_signal_input.py index e7a50b43..904b600e 100644 --- a/tests/unit_tests/test_device_signal_input.py +++ b/tests/unit_tests/test_device_signal_input.py @@ -44,6 +44,27 @@ def test_signal_combobox_init(device_signal_combobox): assert device_signal_combobox._hinted_signals == [] assert device_signal_combobox._normal_signals == [] assert device_signal_combobox._config_signals == [] + assert device_signal_combobox.autocomplete is False + assert device_signal_combobox.completer() is not None + assert device_signal_combobox.completer().model() == device_signal_combobox.model() + + +def test_signal_combobox_autocomplete(qtbot, mocked_client): + widget = create_widget( + qtbot=qtbot, widget=SignalComboBox, client=mocked_client, autocomplete=True + ) + + widget.set_device("samx") + + assert widget.autocomplete is True + assert widget.completer() is not None + assert widget.completer().model().stringList() == ["samx (readback)", "setpoint", "velocity"] + assert widget.completer().model() != widget.model() + + widget.autocomplete = False + + assert widget.completer() is not None + assert widget.completer().model() == widget.model() def test_signal_combobox_qproperties(device_signal_combobox): diff --git a/tests/unit_tests/test_scan_control.py b/tests/unit_tests/test_scan_control.py index cae4811c..34e4622d 100644 --- a/tests/unit_tests/test_scan_control.py +++ b/tests/unit_tests/test_scan_control.py @@ -307,6 +307,7 @@ def test_on_scan_selected(scan_control, scan_name): assert isinstance(widget, expected_widget_type) # Confirm the widget type matches if isinstance(widget, DeviceComboBox): assert widget.currentText() == "" + assert widget.autocomplete is True assert "samx" in widget.devices assert ( "async_device" in widget.devices