wip line edit like optional completer for Editable Comboboxes

This commit is contained in:
2026-05-12 20:34:18 +02:00
parent b47ae8f917
commit 88f5bdfbea
6 changed files with 124 additions and 4 deletions
@@ -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):
@@ -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
@@ -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)
@@ -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()
@@ -44,6 +44,34 @@ 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):
+1
View File
@@ -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