diff --git a/bec_widgets/widgets/control/device_input/picker/__init__.py b/bec_widgets/widgets/control/device_input/picker/__init__.py new file mode 100644 index 00000000..7d9c101a --- /dev/null +++ b/bec_widgets/widgets/control/device_input/picker/__init__.py @@ -0,0 +1,7 @@ +"""Searchable device and signal pickers with a QWidget and a QML popup. + +The pickers keep the public API of :class:`DeviceComboBox` and :class:`SignalComboBox` (they are +subclasses) and replace the plain drop-down list with a popup that searches, groups and shows +the live value of the highlighted entry. :mod:`picker_model` holds the toolkit-independent +model and :mod:`picker_common` the combobox and popup base classes both versions share. +""" diff --git a/bec_widgets/widgets/control/device_input/picker/picker_common.py b/bec_widgets/widgets/control/device_input/picker/picker_common.py new file mode 100644 index 00000000..e373e772 --- /dev/null +++ b/bec_widgets/widgets/control/device_input/picker/picker_common.py @@ -0,0 +1,603 @@ +"""Combobox and popup base classes of the device and signal pickers. + +:class:`DevicePickerBase` and :class:`SignalPickerBase` subclass :class:`DeviceComboBox` and +:class:`SignalComboBox`, keep their API and add a faster refresh path and the wiring of the +searchable popup. The concrete pickers in :mod:`picker_qwidget` and :mod:`picker_qml` only choose +the popup. The toolkit-independent model lives in :mod:`picker_model`. +""" + +from __future__ import annotations + +import weakref + +from bec_lib.endpoints import MessageEndpoints +from qtpy.QtCore import QRectF, Qt +from qtpy.QtGui import QPainter, QPen +from qtpy.QtWidgets import QComboBox, QWidget + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.filter_io import get_bec_signals_for_classes +from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.picker.picker_model import ( + DEVICE_KIND_LABELS, + DeviceSnapshot, + PickerController, + PickerEntry, + device_snapshot, + invalidate_device_snapshot, + recent_picks, + remember_pick, +) +from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox + +# ---- combobox base classes ---------------------------------------------------------------------- + + +class PickerPopupMixin: + """Popup wiring and the cheaper validity styling shared by both picker comboboxes. + + The class using it must be a :class:`QComboBox` subclass and implement + :meth:`_picker_entries`, :meth:`_select_value` and :meth:`_create_popup`. + """ + + PICKER_NOUN = "items" + RECENT_KIND = "items" + + def _init_picker(self) -> None: + self._picker_popup = None + self._picker_controller: PickerController | None = None + self._picker_text_before = "" + self._live_topics: list = [] + self._live_entry: PickerEntry | None = None + self._show_invalid = False + self._last_valid_text = self.currentText() if self._is_valid_input else "" + self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) + self.setMinimumContentsLength(8) + line_edit = self.lineEdit() + if line_edit is not None: + line_edit.textEdited.connect(self._on_field_edited) + line_edit.setPlaceholderText(f"Select {self.PICKER_NOUN[:-1]}…") + # the validity of the current text may have been styled with the old stylesheet + self._update_validity_style(self._is_valid_input) + + # ---- styling ------------------------------------------------------------------------- + # The field keeps the application style; a per-widget stylesheet costs several + # milliseconds per picker to parse and polish, so the error state is painted instead. + + def _update_validity_style(self, is_valid: bool) -> None: + if not hasattr(self, "_picker_popup"): + # still inside the parent constructor; styled once in _init_picker + return + if is_valid and not self.is_popup_open(): + self._last_valid_text = self.currentText() + show = bool(self.currentText()) and not is_valid and self.isEnabled() + if show != self._show_invalid: + self._show_invalid = show + self.update() + + @property + def shows_invalid(self) -> bool: + """Whether the field is drawn with the error border.""" + return self._show_invalid + + def paintEvent(self, event): # pylint: disable=invalid-name + """Paint the field and, for invalid text, a danger-coloured border on top.""" + super().paintEvent(event) # type: ignore[misc] + if not self._show_invalid: + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + pen = QPen(ThemeTokens().danger, 2) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(QRectF(self.rect()).adjusted(1, 1, -1, -1), 4, 4) + painter.end() + + def apply_theme(self, theme: str): + """Re-style the popup for the new theme.""" + super().apply_theme(theme) # type: ignore[misc] + if self._picker_popup is not None: + self._picker_popup.refresh_theme() + self.update() + + # ---- popup ------------------------------------------------------------------------------- + + @property + def picker_controller(self) -> PickerController: + """Controller of the popup, created on first use.""" + if self._picker_controller is None: + self._picker_controller = PickerController( + self.PICKER_NOUN, self._read_live_value, self._watch_live_value, self + ) + self._picker_controller.picked.connect(self._on_picked) + self._picker_controller.dismissed.connect(self._on_dismissed) + return self._picker_controller + + @property + def picker_popup(self): + """The popup window, created on first use.""" + if self._picker_popup is None: + self._picker_popup = self._create_popup() + return self._picker_popup + + def is_popup_open(self) -> bool: + """Whether the picker popup is showing.""" + return self._picker_popup is not None and self._picker_popup.isVisible() + + def showPopup(self) -> None: # pylint: disable=invalid-name + """Open the searchable popup instead of the plain drop-down list.""" + self.open_picker() + + def hidePopup(self) -> None: # pylint: disable=invalid-name + """Close the popup.""" + if self.is_popup_open(): + self._picker_popup.hide() + super().hidePopup() # type: ignore[misc] + + def open_picker(self, query: str = "") -> None: + """Open the popup, optionally with a search text already entered. + + Args: + query(str): Initial search text. + """ + if not self.isEnabled(): + return + if not self.is_popup_open(): + # text typed to start a search is not a selection; cancelling reverts to the last + # valid one + self._picker_text_before = ( + self._last_valid_text + if query and query == self.currentText() + else self.currentText() + ) + controller = self.picker_controller + controller.open( + self._picker_entries(), self._picker_text_before, query, recent_picks(self.RECENT_KIND) + ) + popup = self.picker_popup + popup.show_below(self) + + @SafeSlot(str) + def _on_field_edited(self, text: str) -> None: + if text and not self.is_popup_open(): + self.open_picker(query=text) + + @SafeSlot(str) + def _on_picked(self, value: str) -> None: + self._picker_text_before = value + if self._picker_popup is not None: + self._picker_popup.hide() + remember_pick(self.RECENT_KIND, value) + self._select_value(value) + self.setFocus() + + @SafeSlot() + def _on_dismissed(self) -> None: + if self._picker_popup is not None: + self._picker_popup.hide() + self.setFocus() + + def _on_popup_hidden(self) -> None: + """Called by the popup window when it hides for any reason, e.g. a click outside. + + Text typed into the field to start a search is reverted unless an entry was picked. + """ + if self._picker_controller is not None: + self._picker_controller.close() + if self.currentText() != self._picker_text_before: + self.setCurrentText(self._picker_text_before) + + # ---- live value -------------------------------------------------------------------------- + + def _read_live_value(self, entry: PickerEntry) -> dict | None: + connector = getattr(self.client, "connector", None) + if connector is None or not entry.device: + return None + for endpoint in ( + MessageEndpoints.device_readback(entry.device), + MessageEndpoints.device_read_configuration(entry.device), + ): + msg = connector.get(endpoint) + signals = getattr(msg, "signals", None) or {} + reading = _pick_reading(signals, entry) + if reading is not None: + return reading + return None + + def _watch_live_value(self, entry: PickerEntry | None) -> None: + if self._live_topics: + self.bec_dispatcher.disconnect_slot(self._on_live_message, self._live_topics) + self._live_topics = [] + self._live_entry = entry + if entry is None or not entry.device: + return + self._live_topics = [ + MessageEndpoints.device_readback(entry.device), + MessageEndpoints.device_read_configuration(entry.device), + ] + self.bec_dispatcher.connect_slot(self._on_live_message, self._live_topics, owner=self) + + @SafeSlot(dict, dict) + def _on_live_message(self, content: dict, _metadata: dict) -> None: + entry = self._live_entry + if entry is None or self._picker_controller is None: + return + reading = _pick_reading(content.get("signals", {}) or {}, entry) + if reading is not None: + self._picker_controller.update_live_value(entry, reading) + + def cleanup(self): + """Close the popup and drop the live subscription.""" + if self._picker_controller is not None: + self._picker_controller.close() + if self._picker_popup is not None: + self._picker_popup.hide() + self._picker_popup.release() + self._picker_popup.deleteLater() + self._picker_popup = None + super().cleanup() # type: ignore[misc] + + # ---- to implement ------------------------------------------------------------------------ + + def _picker_entries(self) -> list[PickerEntry]: + raise NotImplementedError + + def _select_value(self, value: str) -> None: + raise NotImplementedError + + def _create_popup(self): + raise NotImplementedError + + +def _pick_reading(signals: dict, entry: PickerEntry) -> dict | None: + if not signals: + return None + key = entry.signal + if not key: + key = entry.device if entry.device in signals else next(iter(signals)) + reading = signals.get(key) + return reading if isinstance(reading, dict) else None + + +class DevicePickerBase(PickerPopupMixin, DeviceComboBox): # pylint: disable=abstract-method + """:class:`DeviceComboBox` with the searchable popup and a cheaper refresh. + + Differences to the base class, all invisible to callers: + + * enabling several filters at once (e.g. the five readout priorities in the constructor) + refreshes the list once instead of once per filter; + * the enabled devices are read from a snapshot shared by all pickers and only re-read when + the device configuration changes; + * an unchanged device list does not rebuild the items or re-emit the selection signals; + * the error state is a dynamic property, not a new stylesheet per validity check. + """ + + PLUGIN = False + RPC = False + PICKER_NOUN = "devices" + RECENT_KIND = "devices" + + _batching_filters = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.device_config_update.disconnect(self.update_devices_from_filters) + self.device_config_update.connect(self._refresh_after_config_update) + self._init_picker() + + # ---- faster refresh -------------------------------------------------------------------- + + def set_device_filter(self, filter_selection): + self._batching_filters = True + try: + super().set_device_filter(filter_selection) + finally: + self._batching_filters = False + self.update_devices_from_filters() + + def set_readout_priority_filter(self, filter_selection): + self._batching_filters = True + try: + super().set_readout_priority_filter(filter_selection) + finally: + self._batching_filters = False + self.update_devices_from_filters() + + @SafeSlot() + def update_devices_from_filters(self): + """Refresh the available device list from the current filters.""" + self._refresh(force=False) + + def on_device_update(self, action: str, content: dict) -> None: + """Invalidate the shared snapshot, then refresh like :class:`DeviceComboBox`.""" + invalidate_device_snapshot(self.client) + super().on_device_update(action, content) + + @SafeSlot() + def _refresh_after_config_update(self): + self._refresh(force=False) + + def _refresh(self, force: bool) -> None: + if self._batching_filters: + return + self.config.device_filter = [entry.value for entry in self.device_filter] + self.config.readout_filter = [entry.value for entry in self.readout_filter] + self.config.signal_class_filter = self.signal_class_filter + if not self.apply_filter: + return + snapshot = device_snapshot(self.client, force=force) + records = list(snapshot.records.values()) + if self.config.signal_class_filter: + allowed = self._devices_with_signal_classes(snapshot) + records = [record for record in records if record.name in allowed] + wanted = set(self.device_filter) + readout = self.readout_filter + names = [ + record.name + for record in records + if wanted <= record.filters and record.readout in readout + ] + if self.include_signals_with_write_access: + seen = set(names) + names.extend( + record.name + for record in snapshot.records.values() + if record.write_access and record.name not in seen + ) + self.devices = names + + def _devices_with_signal_classes(self, snapshot: DeviceSnapshot) -> set[str]: + key = tuple(self.config.signal_class_filter) + cached = snapshot.signal_classes.get(key) + if cached is None: + signals = get_bec_signals_for_classes(client=self.client, signal_class_filter=list(key)) + cached = snapshot.signal_classes[key] = [name for name, _, _ in signals] + return set(cached) + + def _replace_items(self, devices: list[str]): + items = [""] + list(devices) if self._set_first_element_as_empty else list(devices) + if self.count() == len(items) and all( + self.itemText(row) == text for row, text in enumerate(items) + ): + # nothing changed: keep the items, the completer and the emitted state + return + current = self.currentText() + self.blockSignals(True) + try: + self.clear() + self.addItems(items) + self.setCurrentText(current) + finally: + self.blockSignals(False) + self._completer_model.setStringList(list(devices)) + self.check_validity(self.currentText()) + + def validate_device(self, device: str | None) -> bool: + """Validate a device against the current filtered device selection.""" + if not device: + return False + device_name = self._device_name_from_text(device) + if device_name not in self.devices: + return False + return device_name in device_snapshot(self.client).records + + # ---- popup ------------------------------------------------------------------------------- + + def _picker_entries(self) -> list[PickerEntry]: + records = device_snapshot(self.client).records + entries = [] + for name in self.devices: + record = records.get(name) + if record is None: + entries.append(PickerEntry(value=name, title=name, device=name)) + continue + group, icon = DEVICE_KIND_LABELS[record.kind] + readout = getattr(record.readout, "value", record.readout) or "" + details = record.description or ", ".join(record.tags) + entries.append( + PickerEntry( + value=name, + title=name, + subtitle=record.device_class, + group=group, + badge=str(readout), + icon=icon, + device=name, + details=details, + ) + ) + return entries + + def _select_value(self, value: str) -> None: + self.setCurrentText(value) + + +SIGNAL_KIND_ICONS = {"hinted": "star", "normal": "sensors", "config": "tune"} + + +class SignalPickerBase(PickerPopupMixin, SignalComboBox): # pylint: disable=abstract-method + """:class:`SignalComboBox` with the searchable popup and cheaper validity styling. + + Signals of one device are grouped by kind (hinted, normal, config); signals listed by class + are grouped by their device. + """ + + PLUGIN = False + RPC = False + PICKER_NOUN = "signals" + RECENT_KIND = "signals" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._init_picker() + + def _on_device_update(self, action: str, content: dict) -> None: + invalidate_device_snapshot(self.client) + super()._on_device_update(action, content) + + def _replace_signal_items(self, items=None): + combo_items = self._signals if items is None else items + display_items = [""] + combo_items if self._set_first_element_as_empty else combo_items + current = self.currentText() + keep_text = bool(current) + self.blockSignals(True) + try: + self.clear() + plain = [entry for entry in display_items if isinstance(entry, str)] + if len(plain) == len(display_items): + self.addItems(plain) + else: + for entry in display_items: + if isinstance(entry, str): + self.addItem(entry) + else: + self.addItem(*entry) + if keep_text: + self.setCurrentText(current) + finally: + self.blockSignals(False) + self._completer_model.setStringList( + [entry if isinstance(entry, str) else entry[0] for entry in combo_items] + ) + + def _picker_entries(self) -> list[PickerEntry]: + entries: list[PickerEntry] = [] + if self._signal_class_filter: + device_names = sorted(device_snapshot(self.client).records, key=len, reverse=True) + for item in self._signals: + text, info = (item, {}) if isinstance(item, str) else item + obj_name = info.get("obj_name") or text + device = self._device or next( + (name for name in device_names if obj_name.startswith(name)), "" + ) + describe = info.get("describe", {}).get("signal_info", {}) or {} + ndim = describe.get("ndim") + subtitle = info.get("signal_class", "") + if ndim is not None: + subtitle = f"{subtitle} · {ndim}D" if subtitle else f"{ndim}D" + entries.append( + PickerEntry( + value=text, + title=text, + subtitle=subtitle, + group=device or "Signals", + badge=info.get("kind_str", ""), + icon=SIGNAL_KIND_ICONS.get(info.get("kind_str", ""), "sensors"), + device=device, + signal=obj_name, + details=info.get("storage_name", ""), + ) + ) + return entries + groups = ( + ("Hinted", "hinted", self._hinted_signals), + ("Normal", "normal", self._normal_signals), + ("Config", "config", self._config_signals), + ) + for group, kind, signals in groups: + for text, info in signals: + obj_name = info.get("obj_name") or text + component = info.get("component_name") or "" + subtitle = obj_name if obj_name != text else component + if subtitle == text: + subtitle = "" + entries.append( + PickerEntry( + value=text, + title=text, + subtitle=subtitle, + group=group, + badge=kind, + icon=SIGNAL_KIND_ICONS[kind], + device=self._device or "", + signal=obj_name, + details=info.get("signal_class", "") or info.get("doc", "") or "", + ) + ) + if not entries and self._signals: + # a plain signal device lists itself without signal info + for item in self._signals: + text = item if isinstance(item, str) else item[0] + entries.append( + PickerEntry(value=text, title=text, icon="sensors", device=self._device or text) + ) + return entries + + def _select_value(self, value: str) -> None: + index = self.findText(value) + if index >= 0: + self.setCurrentIndex(index) + else: + self.setCurrentText(value) + + +class PickerPopupBase(QWidget): + """Top-level popup window shared by the QWidget and QML popups. + + Subclasses fill the window; this class positions it under the field, follows the theme + and reports when it hides. + """ + + WIDTH = 380 + MAX_HEIGHT = 440 + MIN_HEIGHT = 240 + # search row, separators and footer around the list + CHROME_HEIGHT = 136 + ROW_HEIGHTS = {"header": 28, "empty": 96, "item": 34} + + def __init__(self, picker: PickerPopupMixin): + # parented so it is destroyed with the picker; the Popup flag keeps it a separate window + super().__init__(picker, Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint) + self._picker = weakref.ref(picker) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False) + self.setObjectName("pickerPopup") + + @property + def controller(self) -> PickerController: + """Controller of the picker this popup belongs to.""" + return self._picker().picker_controller + + def show_below(self, anchor: QWidget) -> None: + """Show under ``anchor``, flipping above it if there is no room below.""" + width = max(anchor.width(), self.WIDTH) + height = self.preferred_height() + origin = anchor.mapToGlobal(anchor.rect().bottomLeft()) + screen = anchor.screen().availableGeometry() if anchor.screen() is not None else None + x, y = origin.x(), origin.y() + 2 + if screen is not None: + if y + height > screen.bottom(): + y = max(screen.top(), anchor.mapToGlobal(anchor.rect().topLeft()).y() - height - 2) + x = max(screen.left(), min(x, screen.right() - width)) + self.setGeometry(x, y, width, height) + if not self.isVisible(): + self.show() + self.activateWindow() + self.focus_search() + + def preferred_height(self) -> int: + """Height that fits the current rows, between ``MIN_HEIGHT`` and ``MAX_HEIGHT``.""" + model = self.controller.model + rows = sum( + self.ROW_HEIGHTS.get(model.row(row).get("rowType"), 34) + for row in range(model.rowCount()) + ) + return max(self.MIN_HEIGHT, min(self.MAX_HEIGHT, rows + self.CHROME_HEIGHT + 8)) + + def focus_search(self) -> None: + """Move keyboard focus to the search field.""" + + def refresh_theme(self) -> None: + """Apply the current theme.""" + + def release(self) -> None: + """Release resources before deletion.""" + + def hideEvent(self, event): # pylint: disable=invalid-name + picker = self._picker() + if picker is not None: + picker._on_popup_hidden() # pylint: disable=protected-access + super().hideEvent(event) + + +__all__ = ["DevicePickerBase", "PickerPopupBase", "PickerPopupMixin", "SignalPickerBase"] diff --git a/bec_widgets/widgets/control/device_input/picker/picker_model.py b/bec_widgets/widgets/control/device_input/picker/picker_model.py new file mode 100644 index 00000000..b6a3bce6 --- /dev/null +++ b/bec_widgets/widgets/control/device_input/picker/picker_model.py @@ -0,0 +1,685 @@ +"""Toolkit-independent model of the device and signal pickers. + +* :func:`device_snapshot` reads the enabled devices once per device configuration and shares + the result between all pickers of a client, so a refresh no longer walks the device manager + for every combobox. +* :class:`PickerEntry`, :func:`rank_entry` and :class:`PickerController` hold the searchable, + grouped rows, the highlighted row and the details (with the live value) shown in the footer. + Both the QWidget and the QML popup are thin views over the controller. +""" + +from __future__ import annotations + +import html +import time +import weakref +from dataclasses import dataclass, field +from typing import Any, Callable, Sequence + +from bec_lib.device import ComputedSignal, Device, Positioner +from bec_lib.device import Signal as BECSignal +from bec_lib.logger import bec_logger +from qtpy.QtCore import QObject, Qt, QTimer, Signal + +from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.utils.quick.list_model import DictListModel +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import BECDeviceFilter + +logger = bec_logger.logger + +MAX_RECENT = 4 + +# ---- shared device snapshot ------------------------------------------------------------------ + + +@dataclass +class DeviceRecord: + """What the pickers need to know about one enabled device, read once per configuration.""" + + name: str + device: Any + kind: str + filters: frozenset + readout: Any + device_class: str + description: str + tags: tuple[str, ...] + write_access: bool + + +@dataclass +class DeviceSnapshot: + """All enabled devices of a client at one device configuration generation.""" + + generation: int + fingerprint: tuple = () + records: dict[str, DeviceRecord] = field(default_factory=dict) + signal_classes: dict[tuple, list] = field(default_factory=dict) + + +class _CatalogState: # pylint: disable=too-few-public-methods + """Generation counter bumped by device configuration updates of one device manager. + + The pickers bump it from their device update callbacks. Updates that happen while no picker + listens are caught by the fingerprint of the device container (see :func:`device_snapshot`). + """ + + def __init__(self): + self.generation = 0 + self.snapshot: DeviceSnapshot | None = None + + def bump(self, *_args, **_kwargs) -> None: + """Invalidate the snapshot; called from the BEC callback thread.""" + self.generation += 1 + + +_CATALOGS: "weakref.WeakKeyDictionary[Any, _CatalogState]" = weakref.WeakKeyDictionary() + +_KIND_ORDER = ( + (BECDeviceFilter.POSITIONER, "positioner"), + (BECDeviceFilter.COMPUTED_SIGNAL, "computed"), + (BECDeviceFilter.SIGNAL, "signal"), + (BECDeviceFilter.DEVICE, "device"), +) +_FILTER_CLASSES = { + BECDeviceFilter.DEVICE: Device, + BECDeviceFilter.POSITIONER: Positioner, + BECDeviceFilter.SIGNAL: BECSignal, + BECDeviceFilter.COMPUTED_SIGNAL: ComputedSignal, +} + +DEVICE_KIND_LABELS = { + "positioner": ("Positioners", "open_with"), + "computed": ("Computed signals", "functions"), + "signal": ("Signals", "sensors"), + "device": ("Devices", "memory"), +} + + +def _catalog_state(client) -> _CatalogState | None: + device_manager = getattr(client, "device_manager", None) + if device_manager is None: + return None + try: + state = _CATALOGS.get(device_manager) + except TypeError: + return None + if state is None: + state = _CatalogState() + try: + _CATALOGS[device_manager] = state + except TypeError: + return None + return state + + +def _record_for(device) -> DeviceRecord: + filters = frozenset(entry for entry, cls in _FILTER_CLASSES.items() if isinstance(device, cls)) + kind = next((label for entry, label in _KIND_ORDER if entry in filters), "device") + config = getattr(device, "_config", None) or {} + info = getattr(device, "_info", None) or {} + device_class = str(config.get("deviceClass") or "") + tags = config.get("deviceTags") or () + return DeviceRecord( + name=device.name, + device=device, + kind=kind, + filters=filters, + readout=getattr(device, "readout_priority", None), + device_class=device_class.rsplit(".", 1)[-1], + description=str(config.get("description") or ""), + tags=tuple(sorted(str(tag) for tag in tags)), + write_access=bool(info.get("write_access")), + ) + + +def _fingerprint(devices) -> tuple: + """Cheap identity of a device container: changes when devices are added, removed or reloaded.""" + if not isinstance(devices, dict): + return () + values = dict.values(devices) + return (len(devices), hash(tuple(id(device) for device in values))) + + +def device_snapshot(client, force: bool = False) -> DeviceSnapshot: + """Return the enabled devices of ``client``, shared by all pickers until the config changes. + + The snapshot is rebuilt when a picker saw a device configuration update, when devices were + added, removed or reloaded (the device objects change), or when ``force`` is set. + + Args: + client: BEC client. + force(bool): Re-read the device manager even if nothing seems to have changed. + + Returns: + DeviceSnapshot: Records of all enabled devices in device manager order. + """ + devices = getattr(getattr(client, "device_manager", None), "devices", None) + fingerprint = _fingerprint(devices) + state = _catalog_state(client) + if state is not None and not force: + snapshot = state.snapshot + if ( + snapshot is not None + and snapshot.generation == state.generation + and snapshot.fingerprint == fingerprint + ): + return snapshot + generation = state.generation if state is not None else -1 + snapshot = DeviceSnapshot(generation=generation, fingerprint=fingerprint) + for device in getattr(devices, "enabled_devices", None) or []: + snapshot.records[device.name] = _record_for(device) + if state is not None: + state.snapshot = snapshot + return snapshot + + +def invalidate_device_snapshot(client) -> None: + """Drop the shared snapshot of ``client``, e.g. after changing devices in a test.""" + state = _catalog_state(client) + if state is not None: + state.bump() + + +# ---- entries, search and rows ---------------------------------------------------------------- + + +@dataclass(frozen=True) +class PickerEntry: + """One selectable row of a picker popup. + + Attributes: + value: Text the combobox receives when the entry is picked. + title: Primary label. + subtitle: Muted secondary label, e.g. the device class. + group: Section the entry is listed under when nothing is searched. + badge: Short chip text, e.g. the readout priority or signal kind. + icon: Material icon name. + device: Device whose live value is shown for the entry. + signal: Signal object name of the live value; empty for the device's main signal. + details: Extra text for the footer, e.g. the description or tags. + """ + + value: str + title: str + subtitle: str = "" + group: str = "" + badge: str = "" + icon: str = "" + device: str = "" + signal: str = "" + details: str = "" + + +def _word_starts(text: str) -> list[int]: + starts = [0] + for index in range(1, len(text)): + if text[index - 1] in "_.-: " or (text[index].isupper() and text[index - 1].islower()): + starts.append(index) + return starts + + +def rank_entry( # pylint: disable=too-many-return-statements + query: str, entry: PickerEntry +) -> tuple[int, int, int] | None: + """Score how well ``entry`` matches ``query``; lower is better. + + Ranks: 0 title prefix, 1 prefix of a word in the title (``x`` matches ``sam_x``), + 2 substring of the title, 3 characters of the query in order (``smx`` matches ``samx``), + 4 substring of the subtitle, badge or details. + + Args: + query(str): Search text; matching ignores case and surrounding blanks. + entry(PickerEntry): Entry to score. + + Returns: + tuple[int, int, int] | None: ``(rank, match_start, match_length)`` in the title, or None + if the entry does not match. Ranks 3 and 4 report no title highlight (``-1, 0``). + """ + needle = query.strip().lower() + if not needle: + return (0, -1, 0) + title = entry.title.lower() + position = title.find(needle) + if position == 0: + return (0, 0, len(needle)) + if position > 0: + for start in _word_starts(entry.title): + if title.startswith(needle, start): + return (1, start, len(needle)) + return (2, position, len(needle)) + cursor = 0 + for char in needle: + cursor = title.find(char, cursor) + if cursor < 0: + break + cursor += 1 + else: + return (3, -1, 0) + haystack = f"{entry.subtitle} {entry.badge} {entry.details} {entry.signal}".lower() + if needle in haystack: + return (4, -1, 0) + return None + + +def rich_title(title: str, start: int, length: int, color: str) -> str: + """Return ``title`` as Qt rich text with the matched part bold and coloured.""" + if start < 0 or length <= 0: + return html.escape(title) + before, match, after = title[:start], title[start : start + length], title[start + length :] + return ( + f"{html.escape(before)}{html.escape(match)}" + f"{html.escape(after)}" + ) + + +ROW_ROLES = ( + "rowType", + "value", + "title", + "titleRich", + "subtitle", + "badge", + "icon", + "matchStart", + "matchLength", + "isCurrent", + "count", +) + + +class PickerRowsModel(DictListModel): + """Rows of a picker popup: ``header``, ``item`` and ``empty`` rows. + + The display role returns the title, so plain item views and tests can read the rows too. + """ + + def __init__(self, parent=None): + super().__init__(ROW_ROLES, parent) + + def data(self, index, role: int = Qt.ItemDataRole.DisplayRole): + if role == Qt.ItemDataRole.DisplayRole and index.isValid(): + role = self.role_id("title") + return super().data(index, role) + + def role_id(self, key: str) -> int: + """Qt role id of a row key.""" + return Qt.ItemDataRole.UserRole + 1 + ROW_ROLES.index(key) + + def row(self, row: int) -> dict: + """Row dictionary at ``row`` (not a copy; do not modify).""" + return self._items[row] # pylint: disable=protected-access + + +def _row(row_type: str, title: str, **values) -> dict: + """Row dictionary with every role of :data:`ROW_ROLES` set.""" + row = { + "rowType": row_type, + "value": "", + "title": title, + "titleRich": html.escape(title), + "subtitle": "", + "badge": "", + "icon": "", + "matchStart": -1, + "matchLength": 0, + "isCurrent": False, + "count": 0, + } + row.update(values) + return row + + +def format_value(value: Any) -> str: # pylint: disable=too-many-return-statements + """Short text for a reading shown in the footer.""" + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return f"{value:.6g}" + shape = getattr(value, "shape", None) + if shape is not None and len(shape) > 0: + return f"array {'x'.join(str(dim) for dim in shape)}" + if isinstance(value, (list, tuple)): + return f"list [{len(value)}]" + if isinstance(value, dict): + return "{…}" + text = str(value) + return text if len(text) <= 40 else f"{text[:39]}…" + + +class PickerController(QObject): + """Search, grouping, highlight and footer details of an open picker popup. + + Both popups are thin views over this object; all behaviour that the user can observe lives + here so the QWidget and QML versions behave identically. + + Args: + noun(str): Plural used in texts, e.g. ``"devices"``. + live_reader: Called with an entry, returns the cached reading dict (``value``, + ``timestamp``) or None. Runs debounced when the highlight settles. + live_watcher: Called with the highlighted entry (or None when the popup closes) so the + picker can subscribe to live updates of that device only. + parent: Parent QObject. + """ + + rows_changed = Signal() + highlight_changed = Signal() + detail_changed = Signal() + query_changed = Signal() + picked = Signal(str) + dismissed = Signal() + + LIVE_DELAY_MS = 120 + + def __init__( + self, + noun: str, + live_reader: Callable[[PickerEntry], dict | None] | None = None, + live_watcher: Callable[[PickerEntry | None], None] | None = None, + parent: QObject | None = None, + ): + super().__init__(parent) + self._noun = noun + self._live_reader = live_reader + self._live_watcher = live_watcher + self._entries: list[PickerEntry] = [] + self._recent: list[str] = [] + self._current = "" + self._query = "" + self._row_entries: list[PickerEntry | None] = [] + self._highlight = -1 + self._match_count = 0 + self._detail: dict = {} + self._accent = "" + self._watched: PickerEntry | None = None + self.model = PickerRowsModel(self) + self._live_timer = QTimer(self) + self._live_timer.setSingleShot(True) + self._live_timer.setInterval(self.LIVE_DELAY_MS) + self._live_timer.timeout.connect(self._load_live_value) + + # ---- state ----------------------------------------------------------------------------- + + def open( + self, + entries: Sequence[PickerEntry], + current: str = "", + query: str = "", + recent: Sequence[str] = (), + ) -> None: + """Load the entries shown by a popup that is about to open. + + Args: + entries: All selectable entries in display order. + current: Value of the current selection; it is marked and highlighted. + query: Initial search text, e.g. what the user started typing in the field. + recent: Recently picked values listed first while nothing is searched. + """ + self._entries = list(entries) + self._current = current or "" + self._recent = [value for value in recent if any(e.value == value for e in self._entries)] + self._query = query + self._rebuild() + self.query_changed.emit() + + def close(self) -> None: + """Stop live updates; called when the popup hides.""" + self._live_timer.stop() + if self._watched is not None and self._live_watcher is not None: + self._live_watcher(None) + self._watched = None + + def set_query(self, query: str) -> None: + """Filter the rows by ``query``.""" + if query == self._query: + return + self._query = query + self._rebuild() + self.query_changed.emit() + + def move_highlight(self, step: int) -> None: + """Move the highlight by ``step`` selectable rows, wrapping at the ends.""" + selectable = [row for row, entry in enumerate(self._row_entries) if entry is not None] + if not selectable: + return + if self._highlight not in selectable: + target = selectable[0] if step > 0 else selectable[-1] + else: + position = selectable.index(self._highlight) + step + target = selectable[position % len(selectable)] + self.set_highlight(target) + + def page_highlight(self, step: int, rows: int = 8) -> None: + """Move the highlight by a page of ``rows`` rows without wrapping.""" + selectable = [row for row, entry in enumerate(self._row_entries) if entry is not None] + if not selectable: + return + position = selectable.index(self._highlight) if self._highlight in selectable else 0 + position = max(0, min(len(selectable) - 1, position + step * rows)) + self.set_highlight(selectable[position]) + + def set_highlight(self, row: int) -> None: + """Highlight ``row`` if it is selectable.""" + if not 0 <= row < len(self._row_entries) or self._row_entries[row] is None: + return + if row == self._highlight: + return + self._highlight = row + self.highlight_changed.emit() + self._update_detail() + + def accept(self, row: int = -1) -> bool: + """Pick ``row``, or the highlighted row when ``row`` is -1. + + Returns: + bool: True if an entry was picked. + """ + row = self._highlight if row < 0 else row + if not 0 <= row < len(self._row_entries): + return False + entry = self._row_entries[row] + if entry is None: + return False + self.picked.emit(entry.value) + return True + + def dismiss(self) -> None: + """Close without picking.""" + self.dismissed.emit() + + # ---- read-only state used by the views --------------------------------------------------- + + @property + def query(self) -> str: + """Current search text.""" + return self._query + + @property + def highlight(self) -> int: + """Highlighted row, -1 if none.""" + return self._highlight + + @property + def detail(self) -> dict: + """Footer contents for the highlighted entry.""" + return self._detail + + @property + def summary(self) -> str: + """Count text shown next to the search field.""" + total = len(self._entries) + if not self._query.strip(): + return f"{total} {self._noun}" + return f"{self._match_count} of {total}" + + @property + def placeholder(self) -> str: + """Placeholder of the search field.""" + return f"Search {self._noun}…" + + def entry_at(self, row: int) -> PickerEntry | None: + """Entry shown in ``row`` (None for headers and the empty row).""" + if 0 <= row < len(self._row_entries): + return self._row_entries[row] + return None + + # ---- internals --------------------------------------------------------------------------- + + def _item_row(self, entry: PickerEntry, start: int = -1, length: int = 0) -> tuple: + accent = self._accent + return ( + _row( + "item", + entry.title, + value=entry.value, + titleRich=rich_title(entry.title, start, length, accent), + subtitle=entry.subtitle, + badge=entry.badge, + icon=entry.icon, + matchStart=start, + matchLength=length, + isCurrent=entry.value == self._current, + ), + entry, + ) + + def _ranked_rows(self, query: str) -> list[tuple]: + ranked = [] + for order, entry in enumerate(self._entries): + score = rank_entry(query, entry) + if score is not None: + ranked.append((score[0], order, entry, score)) + ranked.sort(key=lambda item: (item[0], item[1])) + self._match_count = len(ranked) + return [self._item_row(entry, score[1], score[2]) for _, _, entry, score in ranked] + + def _grouped_rows(self) -> list[tuple]: + self._match_count = len(self._entries) + rows = [] + if self._recent and len(self._entries) > MAX_RECENT * 2: + by_value = {entry.value: entry for entry in self._entries} + rows.append((_row("header", "Recent", icon="history", count=len(self._recent)), None)) + rows.extend(self._item_row(by_value[value]) for value in self._recent) + groups: dict[str, list[PickerEntry]] = {} + for entry in self._entries: + groups.setdefault(entry.group, []).append(entry) + show_headers = len(groups) > 1 or bool(rows) + for group, entries in groups.items(): + if show_headers and group: + rows.append((_row("header", group, count=len(entries)), None)) + rows.extend(self._item_row(entry) for entry in entries) + return rows + + def _rebuild(self) -> None: + self._accent = ThemeTokens().primary.name() + query = self._query.strip() + rows = self._ranked_rows(query) if query else self._grouped_rows() + if not any(entry is not None for _, entry in rows): + text = f"No {self._noun} match “{query}”" if query else f"No {self._noun} available" + rows = [(_row("empty", text, icon="search_off" if query else "block"), None)] + self._row_entries = [entry for _, entry in rows] + self.model.set_items([row for row, _ in rows]) + self.rows_changed.emit() + + selectable = [row for row, entry in enumerate(self._row_entries) if entry is not None] + wanted = None if query else self._current + target = next( + (row for row in selectable if self._row_entries[row].value == wanted), + selectable[0] if selectable else -1, + ) + self._highlight = target + self.highlight_changed.emit() + self._update_detail() + + def _update_detail(self) -> None: + entry = self.entry_at(self._highlight) + if entry is None: + self._detail = {} + self._live_timer.stop() + else: + self._detail = { + "title": entry.title, + "subtitle": entry.subtitle, + "details": entry.details, + "icon": entry.icon, + "badge": entry.badge, + "value": "", + "valueTime": "", + "valueState": "loading" if self._live_reader is not None else "none", + } + if self._live_reader is not None: + self._live_timer.start() + self.detail_changed.emit() + + def _load_live_value(self) -> None: + entry = self.entry_at(self._highlight) + if entry is None: + return + reading = None + try: + reading = self._live_reader(entry) if self._live_reader is not None else None + except Exception as exc: # pylint: disable=broad-except + logger.debug(f"Could not read cached value of {entry.device}: {exc}") + self.update_live_value(entry, reading) + if self._live_watcher is not None and entry != self._watched: + self._watched = entry + self._live_watcher(entry) + + def update_live_value(self, entry: PickerEntry, reading: dict | None) -> None: + """Show ``reading`` in the footer if ``entry`` is still highlighted. + + Args: + entry(PickerEntry): Entry the reading belongs to. + reading(dict | None): Reading with ``value`` and optional ``timestamp``. + """ + if self.entry_at(self._highlight) != entry or not self._detail: + return + detail = dict(self._detail) + if reading is None or "value" not in reading: + detail.update({"value": "", "valueTime": "", "valueState": "none"}) + else: + stamp = reading.get("timestamp") + detail.update( + { + "value": format_value(reading.get("value")), + "valueTime": ( + time.strftime("%H:%M:%S", time.localtime(stamp)) + if isinstance(stamp, (int, float)) and stamp > 0 + else "" + ), + "valueState": "live", + } + ) + self._detail = detail + self.detail_changed.emit() + + +# ---- recent picks ------------------------------------------------------------------------------ + +_RECENT: dict[str, list[str]] = {"devices": [], "signals": []} + + +def remember_pick(kind: str, value: str) -> None: + """Remember ``value`` as recently picked for pickers of ``kind`` in this session.""" + if not value: + return + recent = _RECENT.setdefault(kind, []) + if value in recent: + recent.remove(value) + recent.insert(0, value) + del recent[MAX_RECENT:] + + +def recent_picks(kind: str) -> list[str]: + """Recently picked values for pickers of ``kind``, newest first.""" + return list(_RECENT.get(kind, [])) + + +def clear_recent_picks() -> None: + """Forget the recent picks of all pickers.""" + for values in _RECENT.values(): + values.clear() diff --git a/bec_widgets/widgets/control/device_input/picker/picker_qml.py b/bec_widgets/widgets/control/device_input/picker/picker_qml.py new file mode 100644 index 00000000..b120ef9e --- /dev/null +++ b/bec_widgets/widgets/control/device_input/picker/picker_qml.py @@ -0,0 +1,133 @@ +"""Device and signal pickers whose popup is rendered with Qt Quick (QML). + +Same behaviour as the QWidget pickers in :mod:`picker_qwidget`; both popups are views over +:class:`~bec_widgets.widgets.control.device_input.picker.picker_common.PickerController`. +The field itself stays a :class:`QComboBox` so the pickers remain drop-in replacements for +:class:`DeviceComboBox` and :class:`SignalComboBox`; only the popup is QML. +""" + +from __future__ import annotations + +from pathlib import Path + +from qtpy.QtCore import Property, QObject, Qt, Signal, Slot +from qtpy.QtWidgets import QVBoxLayout + +from bec_widgets.utils.quick import create_quick_widget, release_quick_widget +from bec_widgets.widgets.control.device_input.picker.picker_common import ( + DevicePickerBase, + PickerPopupBase, + SignalPickerBase, +) +from bec_widgets.widgets.control.device_input.picker.picker_model import PickerController + +QML_FILE = Path(__file__).parent / "qml" / "PickerPopupView.qml" + + +class PickerBackend(QObject): + """Bridge between :class:`PickerController` and ``PickerPopupView.qml``.""" + + rowsChanged = Signal() # pylint: disable=invalid-name + highlightChanged = Signal() # pylint: disable=invalid-name + detailChanged = Signal() # pylint: disable=invalid-name + queryChanged = Signal() # pylint: disable=invalid-name + focusRequested = Signal() # pylint: disable=invalid-name + + def __init__(self, controller: PickerController, parent: QObject | None = None): + super().__init__(parent) + self._controller = controller + controller.rows_changed.connect(self.rowsChanged) + controller.highlight_changed.connect(self.highlightChanged) + controller.detail_changed.connect(self.detailChanged) + controller.query_changed.connect(self.queryChanged) + + # pylint: disable=invalid-name + @Slot(str) + def setQuery(self, text: str) -> None: + """Filter the rows.""" + self._controller.set_query(text) + + @Slot(int) + def move(self, step: int) -> None: + """Move the highlight.""" + self._controller.move_highlight(step) + + @Slot(int) + def page(self, step: int) -> None: + """Move the highlight by a page.""" + self._controller.page_highlight(step) + + @Slot(int) + def hover(self, row: int) -> None: + """Highlight the row under the mouse.""" + self._controller.set_highlight(row) + + @Slot(int) + def accept(self, row: int) -> None: + """Pick a row (-1 for the highlighted one).""" + self._controller.accept(row) + + @Slot() + def dismiss(self) -> None: + """Close without picking.""" + self._controller.dismiss() + + @property + def controller(self) -> PickerController: + """Controller this backend forwards.""" + return self._controller + + rows = Property(QObject, lambda self: self.controller.model, constant=True) + highlight = Property(int, lambda self: self.controller.highlight, notify=highlightChanged) + query = Property(str, lambda self: self.controller.query, notify=queryChanged) + placeholder = Property(str, lambda self: self.controller.placeholder, notify=queryChanged) + summary = Property(str, lambda self: self.controller.summary, notify=rowsChanged) + detail = Property("QVariantMap", lambda self: self.controller.detail, notify=detailChanged) + + +class PickerPopupQml(PickerPopupBase): + """Popup window that hosts ``PickerPopupView.qml``.""" + + def __init__(self, picker): + super().__init__(picker) + self.backend = PickerBackend(self.controller, self) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.view = create_quick_widget(self, QML_FILE, {"backend": self.backend}) + self.view.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + layout.addWidget(self.view) + self.refresh_theme() + + def focus_search(self) -> None: + self.view.setFocus() + self.backend.focusRequested.emit() + + def refresh_theme(self) -> None: + self.view.setClearColor(self.palette().window().color()) + + def release(self) -> None: + release_quick_widget(self.view) + + +class DevicePickerQML(DevicePickerBase): + """Device combobox with a searchable, grouped popup rendered in QML. + + Drop-in replacement for :class:`DeviceComboBox`; see :class:`DevicePickerBase`. + """ + + ICON_NAME = "manage_search" + + def _create_popup(self): + return PickerPopupQml(self) + + +class SignalPickerQML(SignalPickerBase): + """Signal combobox with a searchable, grouped popup rendered in QML. + + Drop-in replacement for :class:`SignalComboBox`; see :class:`SignalPickerBase`. + """ + + ICON_NAME = "manage_search" + + def _create_popup(self): + return PickerPopupQml(self) diff --git a/bec_widgets/widgets/control/device_input/picker/picker_qwidget.py b/bec_widgets/widgets/control/device_input/picker/picker_qwidget.py new file mode 100644 index 00000000..ea705d65 --- /dev/null +++ b/bec_widgets/widgets/control/device_input/picker/picker_qwidget.py @@ -0,0 +1,470 @@ +"""Device and signal pickers whose popup is built from QWidgets. + +Same behaviour as the QML pickers in :mod:`picker_qml`; both popups are views over +:class:`~bec_widgets.widgets.control.device_input.picker.picker_common.PickerController`. +""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import QEvent, QModelIndex, QRect, QRectF, QSize, Qt +from qtpy.QtGui import QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPixmap, QRegion +from qtpy.QtWidgets import ( + QAbstractItemView, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QListView, + QStyledItemDelegate, + QStyleOptionViewItem, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.utils.ux_kit import rgba +from bec_widgets.widgets.control.device_input.picker.picker_common import ( + DevicePickerBase, + PickerPopupBase, + SignalPickerBase, +) +from bec_widgets.widgets.control.device_input.picker.picker_model import PickerRowsModel + +ROW_HEIGHT = 34 +HEADER_HEIGHT = 28 +EMPTY_HEIGHT = 96 + +_ICON_CACHE: dict[tuple[str, str, int], QPixmap] = {} + + +def cached_icon(name: str, color: QColor, size: int = 16) -> QPixmap: + """Material icon pixmap, cached per name, colour and size.""" + key = (name, color.name(), size) + pixmap = _ICON_CACHE.get(key) + if pixmap is None: + pixmap = material_icon(name, size=(size * 2, size * 2), color=color) + pixmap.setDevicePixelRatio(2.0) + _ICON_CACHE[key] = pixmap + return pixmap + + +class PickerRowDelegate(QStyledItemDelegate): + """Paints header, item and empty rows like ``PickerPopupView.qml``.""" + + def __init__(self, popup: "PickerPopupWidget"): + super().__init__(popup) + self._popup = popup + self.tokens = ThemeTokens() + + def _row(self, index: QModelIndex) -> dict: + model = index.model() + if isinstance(model, PickerRowsModel): + return model.row(index.row()) + return {} + + def sizeHint(self, option, index): # pylint: disable=invalid-name + row_type = self._row(index).get("rowType") + height = {"header": HEADER_HEIGHT, "empty": EMPTY_HEIGHT}.get(row_type, ROW_HEIGHT) + return QSize(option.rect.width(), height) + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex): + row = self._row(index) + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = option.rect + row_type = row.get("rowType") + if row_type == "header": + self._paint_header(painter, rect, row) + elif row_type == "empty": + self._paint_empty(painter, rect, row) + else: + highlighted = index.row() == self._popup.controller.highlight + self._paint_item(painter, rect, row, highlighted) + painter.restore() + + def _paint_header(self, painter: QPainter, rect: QRect, row: dict) -> None: + tokens = self.tokens + font = QFont(painter.font()) + font.setPixelSize(11) + font.setWeight(QFont.Weight.DemiBold) + font.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 0.6) + painter.setFont(font) + x = rect.left() + 12 + if row.get("icon"): + painter.drawPixmap( + x, + rect.top() + (rect.height() - 14) // 2 + 2, + cached_icon(row["icon"], tokens.fg_subtle, 14), + ) + x += 20 + painter.setPen(tokens.fg_subtle) + title = row.get("title", "").upper() + text_rect = QRect(x, rect.top() + 4, rect.width() - x - 12, rect.height() - 4) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignVCenter, title) + width = QFontMetrics(font).horizontalAdvance(title) + count = row.get("count") or 0 + if count: + font.setWeight(QFont.Weight.Normal) + painter.setFont(font) + painter.drawText( + text_rect.adjusted(width + 8, 0, 0, 0), Qt.AlignmentFlag.AlignVCenter, str(count) + ) + + def _paint_empty(self, painter: QPainter, rect: QRect, row: dict) -> None: + tokens = self.tokens + icon = cached_icon(row.get("icon") or "search_off", tokens.fg_subtle, 24) + painter.drawPixmap(rect.center().x() - 12, rect.top() + 18, icon) + font = QFont(painter.font()) + font.setPixelSize(13) + painter.setFont(font) + painter.setPen(tokens.fg_muted) + painter.drawText( + rect.adjusted(12, 48, -12, -8), + Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, + row.get("title", ""), + ) + + def _paint_item(self, painter: QPainter, rect: QRect, row: dict, highlighted: bool) -> None: + tokens = self.tokens + card = QRectF(rect.adjusted(4, 1, -4, -1)) + painter.setPen(Qt.PenStyle.NoPen) + if highlighted: + painter.setBrush(tokens.hover) + painter.drawRoundedRect(card, 6, 6) + if row.get("isCurrent"): + painter.setBrush(tokens.primary) + painter.drawRoundedRect( + QRectF(card.left(), card.top() + 7, 3, card.height() - 14), 1.5, 1.5 + ) + left = int(card.left()) + 10 + if row.get("icon"): + color = tokens.primary if row.get("isCurrent") else tokens.fg_muted + painter.drawPixmap( + left, rect.top() + (rect.height() - 16) // 2, cached_icon(row["icon"], color) + ) + right = self._paint_badge(painter, rect, int(card.right()) - 8, row.get("badge") or "") + self._paint_title(painter, rect, left + 24, right, row) + + def _paint_badge(self, painter: QPainter, rect: QRect, right: int, badge: str) -> int: + """Paint the chip right-aligned at ``right``; return the x where the text must end.""" + if not badge: + return right + font = QFont(painter.font()) + font.setPixelSize(11) + font.setWeight(QFont.Weight.Medium) + width = QFontMetrics(font).horizontalAdvance(badge) + 14 + chip = QRectF(right - width, rect.top() + (rect.height() - 18) / 2, width, 18) + tone = _badge_tone(self.tokens, badge) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(self.tokens.soft(tone, 0.18)) + painter.drawRoundedRect(chip, 9, 9) + painter.setFont(font) + painter.setPen(tone) + painter.drawText(chip, Qt.AlignmentFlag.AlignCenter, badge) + return int(chip.left()) - 8 + + def _paint_title(self, painter: QPainter, rect: QRect, left: int, right: int, row: dict): + """Paint the title with the matched part highlighted, then the muted subtitle.""" + tokens = self.tokens + font = QFont(painter.font()) + font.setPixelSize(13) + font.setWeight(QFont.Weight.Medium if row.get("isCurrent") else QFont.Weight.Normal) + bold = QFont(font) + bold.setWeight(QFont.Weight.Bold) + title = row.get("title", "") + start, length = row.get("matchStart", -1), row.get("matchLength", 0) + segments = [(title, font, tokens.fg)] + if start >= 0 and length > 0: + segments = [ + (title[:start], font, tokens.fg), + (title[start : start + length], bold, tokens.primary), + (title[start + length :], font, tokens.fg), + ] + cursor = left + for text, segment_font, color in segments: + if not text: + continue + metrics = QFontMetrics(segment_font) + text = metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(0, right - cursor)) + painter.setFont(segment_font) + painter.setPen(color) + painter.drawText( + QRect(cursor, rect.top(), max(0, right - cursor), rect.height()), + Qt.AlignmentFlag.AlignVCenter, + text, + ) + cursor += metrics.horizontalAdvance(text) + subtitle = row.get("subtitle") or "" + if not subtitle or cursor + 38 >= right: + return + font.setPixelSize(12) + font.setWeight(QFont.Weight.Normal) + cursor += 8 + painter.setFont(font) + painter.setPen(tokens.fg_subtle) + painter.drawText( + QRect(cursor, rect.top(), right - cursor, rect.height()), + Qt.AlignmentFlag.AlignVCenter, + QFontMetrics(font).elidedText(subtitle, Qt.TextElideMode.ElideRight, right - cursor), + ) + + +def _badge_tone(tokens: ThemeTokens, badge: str) -> QColor: + """Chip colour of a readout priority or signal kind badge.""" + return { + "monitored": tokens.primary, + "hinted": tokens.primary, + "async": tokens.highlight, + "baseline": tokens.fg_muted, + "config": tokens.fg_muted, + "on_request": tokens.warning, + "continuous": tokens.success, + "normal": tokens.success, + }.get(badge, tokens.fg_muted) + + +class _SearchField(QLineEdit): + """Search input that forwards navigation keys to the controller.""" + + def __init__(self, popup: "PickerPopupWidget"): + super().__init__(popup) + self._popup = popup + + def keyPressEvent(self, event): # pylint: disable=invalid-name + controller = self._popup.controller + key = event.key() + if key == Qt.Key.Key_Down or (key == Qt.Key.Key_Tab and not event.modifiers()): + controller.move_highlight(1) + elif key in (Qt.Key.Key_Up, Qt.Key.Key_Backtab): + controller.move_highlight(-1) + elif key == Qt.Key.Key_PageDown: + controller.page_highlight(1) + elif key == Qt.Key.Key_PageUp: + controller.page_highlight(-1) + elif key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + controller.accept() + elif key == Qt.Key.Key_Escape: + controller.dismiss() + else: + super().keyPressEvent(event) + + +class PickerPopupWidget(PickerPopupBase): # pylint: disable=too-many-instance-attributes + """Popup with search field, grouped list and live-value footer, built from QWidgets.""" + + def __init__(self, picker): + super().__init__(picker) + self.card = QFrame(self) + self.card.setObjectName("pickerCard") + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.addWidget(self.card) + layout = QVBoxLayout(self.card) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addLayout(self._build_search()) + layout.addWidget(self._separator()) + layout.addWidget(self._build_list(), 1) + layout.addWidget(self._separator()) + layout.addWidget(self._build_footer()) + + self.search.textChanged.connect(self.controller.set_query) + self.controller.rows_changed.connect(self._sync_rows) + self.controller.highlight_changed.connect(self._sync_highlight) + self.controller.detail_changed.connect(self._sync_detail) + self.controller.query_changed.connect(self._sync_query) + self.refresh_theme() + self._sync_query() + self._sync_rows() + self._sync_highlight() + + def _build_search(self) -> QHBoxLayout: + header = QHBoxLayout() + header.setContentsMargins(12, 7, 12, 7) + header.setSpacing(8) + self.search_icon = QLabel(self.card) + self.search = _SearchField(self) + self.search.setObjectName("pickerSearch") + self.search.setClearButtonEnabled(True) + self.summary = QLabel(self.card) + self.summary.setObjectName("pickerSummary") + header.addWidget(self.search_icon) + header.addWidget(self.search, 1) + header.addWidget(self.summary) + return header + + def _build_list(self) -> QListView: + self.list = QListView(self.card) + self.list.setObjectName("pickerList") + self.list.setFrameShape(QFrame.Shape.NoFrame) + self.list.setMouseTracking(True) + self.list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.list.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.list.viewport().installEventFilter(self) + self.delegate = PickerRowDelegate(self) + self.list.setItemDelegate(self.delegate) + self.list.setModel(self.controller.model) + self.list.clicked.connect(lambda index: self.controller.accept(index.row())) + return self.list + + def _build_footer(self) -> QWidget: + self.footer = QWidget(self.card) + footer = QVBoxLayout(self.footer) + footer.setContentsMargins(12, 8, 12, 8) + footer.setSpacing(2) + top = QHBoxLayout() + top.setSpacing(8) + self.detail_icon = QLabel(self.footer) + self.detail_title = QLabel(self.footer) + self.detail_title.setObjectName("pickerDetailTitle") + self.detail_value = QLabel(self.footer) + self.detail_value.setObjectName("pickerDetailValue") + self.detail_value.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + top.addWidget(self.detail_icon) + top.addWidget(self.detail_title, 1) + top.addWidget(self.detail_value) + footer.addLayout(top) + self.detail_sub = QLabel(self.footer) + self.detail_sub.setObjectName("pickerDetailSub") + footer.addWidget(self.detail_sub) + self.hints = QLabel("↑↓ move ↵ select esc close", self.footer) + self.hints.setObjectName("pickerHints") + footer.addWidget(self.hints) + return self.footer + + def _separator(self) -> QFrame: + line = QFrame(self.card) + line.setObjectName("pickerSeparator") + line.setFixedHeight(1) + return line + + # ---- PickerPopupBase ----------------------------------------------------------------------- + + def focus_search(self) -> None: + self.search.setFocus() + self.search.end(False) + + def refresh_theme(self) -> None: + tokens = ThemeTokens() + self.delegate.tokens = tokens + self.search_icon.setPixmap(cached_icon("search", tokens.fg_muted, 18)) + self.setStyleSheet(f""" + QFrame#pickerCard {{ background: {tokens.card.name()}; + border: 1px solid {tokens.border.name()}; border-radius: 10px; }} + QFrame#pickerSeparator {{ background: {tokens.border.name()}; border: none; }} + QLineEdit#pickerSearch {{ background: transparent; border: none; color: {tokens.fg.name()}; + font-size: 14px; padding: 0px; min-height: 32px; max-height: 32px; + selection-background-color: {tokens.primary.name()}; }} + QLabel {{ background: transparent; }} + QLabel#pickerSummary {{ color: {tokens.fg_subtle.name()}; font-size: 12px; }} + QLabel#pickerDetailTitle {{ color: {tokens.fg.name()}; font-size: 13px; font-weight: 600; }} + QLabel#pickerDetailSub {{ color: {tokens.fg_muted.name()}; font-size: 12px; }} + QLabel#pickerHints {{ color: {tokens.fg_subtle.name()}; font-size: 11px; padding-top: 4px; }} + QListView#pickerList {{ background: transparent; border: none; outline: none; }} + QScrollBar:vertical {{ background: transparent; width: 8px; margin: 2px; }} + QScrollBar::handle:vertical {{ background: {rgba(tokens.fg, 0.25)}; border-radius: 3px; + min-height: 24px; }} + QScrollBar::add-line, QScrollBar::sub-line {{ height: 0px; }} + QScrollBar::add-page, QScrollBar::sub-page {{ background: none; }} + """) + self.search.setPlaceholderText(self.controller.placeholder) + self._sync_detail() + self.list.viewport().update() + + # ---- sync from the controller ---------------------------------------------------------------- + + def _sync_query(self) -> None: + if self.search.text() != self.controller.query: + self.search.blockSignals(True) + self.search.setText(self.controller.query) + self.search.blockSignals(False) + self.search.setPlaceholderText(self.controller.placeholder) + + def _sync_rows(self) -> None: + self.summary.setText(self.controller.summary) + self.list.viewport().update() + + def _sync_highlight(self) -> None: + row = self.controller.highlight + model = self.controller.model + if row >= 0: + # keep the section header of the highlighted row in view as well + if row > 0 and model.row(row - 1).get("rowType") == "header": + self.list.scrollTo(model.index(row - 1, 0)) + self.list.scrollTo(model.index(row, 0)) + self.list.viewport().update() + + def showEvent(self, event): # pylint: disable=invalid-name + super().showEvent(event) + self.list.doItemsLayout() + self._sync_highlight() + + def _sync_detail(self) -> None: + detail = self.controller.detail + tokens = self.delegate.tokens + self.footer.setVisible(bool(detail)) + if not detail: + return + self.detail_icon.setPixmap( + cached_icon(detail.get("icon") or "sensors", tokens.fg_muted, 16) + ) + self.detail_title.setText(detail.get("title", "")) + parts = [part for part in (detail.get("subtitle"), detail.get("details")) if part] + self.detail_sub.setText(" · ".join(parts)) + self.detail_sub.setVisible(bool(parts)) + state = detail.get("valueState") + if state == "live" and detail.get("value"): + stamp = detail.get("valueTime") + self.detail_value.setText(detail["value"] + (f" · {stamp}" if stamp else "")) + color = tokens.success + elif state == "loading": + self.detail_value.setText("reading…") + color = tokens.fg_subtle + else: + self.detail_value.setText("no value yet") + color = tokens.fg_subtle + self.detail_value.setStyleSheet( + f"color: {color.name()}; font-size: 12px; font-family: monospace; background: none;" + ) + + # ---- events ------------------------------------------------------------------------------- + + def eventFilter(self, watched, event): # pylint: disable=invalid-name + if watched is self.list.viewport() and event.type() == QEvent.Type.MouseMove: + index = self.list.indexAt(event.position().toPoint()) + if index.isValid(): + self.controller.set_highlight(index.row()) + return super().eventFilter(watched, event) + + def resizeEvent(self, event): # pylint: disable=invalid-name + super().resizeEvent(event) + path = QPainterPath() + path.addRoundedRect(QRectF(self.rect()), 10, 10) + self.setMask(QRegion(path.toFillPolygon().toPolygon())) + + +class DevicePicker(DevicePickerBase): + """Device combobox with a searchable, grouped popup built from QWidgets. + + Drop-in replacement for :class:`DeviceComboBox`; see :class:`DevicePickerBase`. + """ + + ICON_NAME = "manage_search" + + def _create_popup(self): + return PickerPopupWidget(self) + + +class SignalPicker(SignalPickerBase): + """Signal combobox with a searchable, grouped popup built from QWidgets. + + Drop-in replacement for :class:`SignalComboBox`; see :class:`SignalPickerBase`. + """ + + ICON_NAME = "manage_search" + + def _create_popup(self): + return PickerPopupWidget(self) diff --git a/bec_widgets/widgets/control/device_input/picker/qml/PickerPopupView.qml b/bec_widgets/widgets/control/device_input/picker/qml/PickerPopupView.qml new file mode 100644 index 00000000..ab123229 --- /dev/null +++ b/bec_widgets/widgets/control/device_input/picker/qml/PickerPopupView.qml @@ -0,0 +1,288 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import BecUi + +// Searchable, grouped device/signal list with a live-value footer. +// All state lives in the Python PickerController; `backend` forwards it. +Rectangle { + id: root + required property var backend + + color: theme.card + radius: 10 + border.color: theme.border + border.width: 1 + + function badgeTone(badge) { + switch (badge) { + case "monitored": + case "hinted": return theme.primary + case "async": return theme.highlight + case "on_request": return theme.warning + case "continuous": + case "normal": return theme.success + default: return theme.fgMuted + } + } + + Connections { + target: root.backend + function onFocusRequested() { search.forceActiveFocus(); search.cursorPosition = search.text.length } + function onHighlightChanged() { + const row = root.backend.highlight + if (row < 0) return + // keep the section header of the highlighted row in view as well + if (row > 0) list.positionViewAtIndex(row - 1, ListView.Contain) + list.positionViewAtIndex(row, ListView.Contain) + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 1 + spacing: 0 + + // ---- search ------------------------------------------------------------------- + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.topMargin: 7 + Layout.bottomMargin: 7 + spacing: 8 + Icon { name: "search"; size: 18; color: theme.fgMuted } + TextField { + id: search + Layout.fillWidth: true + text: root.backend.query + placeholderText: root.backend.placeholder + placeholderTextColor: theme.fgSubtle + color: theme.fg + selectionColor: theme.primary + selectedTextColor: theme.onPrimary + font.pixelSize: 14 + leftPadding: 0 + implicitHeight: 32 + verticalAlignment: TextInput.AlignVCenter + background: null + selectByMouse: true + onTextEdited: root.backend.setQuery(text) + Keys.onPressed: (event) => { + switch (event.key) { + case Qt.Key_Down: + case Qt.Key_Tab: root.backend.move(1); break + case Qt.Key_Up: + case Qt.Key_Backtab: root.backend.move(-1); break + case Qt.Key_PageDown: root.backend.page(1); break + case Qt.Key_PageUp: root.backend.page(-1); break + case Qt.Key_Return: + case Qt.Key_Enter: root.backend.accept(-1); break + case Qt.Key_Escape: root.backend.dismiss(); break + default: return + } + event.accepted = true + } + } + Text { + text: root.backend.summary + color: theme.fgSubtle + font.pixelSize: 12 + } + } + Rectangle { Layout.fillWidth: true; height: 1; color: theme.border } + + // ---- rows ----------------------------------------------------------------------- + ListView { + id: list + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.backend.rows + boundsBehavior: Flickable.StopAtBounds + reuseItems: true + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded; width: 8 } + + delegate: Item { + id: row + required property int index + required property string rowType + required property string titleRich + required property string title + required property string subtitle + required property string badge + required property string icon + required property bool isCurrent + required property int count + readonly property bool highlighted: index === root.backend.highlight + + width: ListView.view.width + height: rowType === "header" ? 28 : rowType === "empty" ? 96 : 34 + + // header + RowLayout { + visible: row.rowType === "header" + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.topMargin: 4 + spacing: 6 + Icon { visible: row.icon !== ""; name: row.icon; size: 14; color: theme.fgSubtle } + Text { + text: row.title.toUpperCase() + color: theme.fgSubtle + font.pixelSize: 11 + font.weight: Font.DemiBold + font.letterSpacing: 0.6 + } + Text { + visible: row.count > 0 + text: row.count + color: theme.fgSubtle + font.pixelSize: 11 + font.letterSpacing: 0.6 + } + Item { Layout.fillWidth: true } + } + + // empty state + Column { + visible: row.rowType === "empty" + anchors.centerIn: parent + spacing: 8 + Icon { anchors.horizontalCenter: parent.horizontalCenter; name: row.icon; size: 24; color: theme.fgSubtle } + Text { text: row.title; color: theme.fgMuted; font.pixelSize: 13 } + } + + // item + Rectangle { + visible: row.rowType === "item" + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + anchors.topMargin: 1 + anchors.bottomMargin: 1 + radius: 6 + color: row.highlighted ? theme.hover : "transparent" + + Rectangle { + visible: row.isCurrent + width: 3; radius: 1.5 + height: parent.height - 14 + anchors.verticalCenter: parent.verticalCenter + color: theme.primary + } + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 8 + spacing: 8 + Icon { + name: row.icon + size: 16 + color: row.isCurrent ? theme.primary : theme.fgMuted + } + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Text { + id: titleText + anchors.verticalCenter: parent.verticalCenter + width: Math.min(implicitWidth, parent.width) + text: row.titleRich + textFormat: Text.StyledText + color: theme.fg + font.pixelSize: 13 + font.weight: row.isCurrent ? Font.Medium : Font.Normal + elide: Text.ElideRight + } + Text { + anchors.verticalCenter: parent.verticalCenter + x: titleText.width + 8 + width: Math.max(0, parent.width - x) + visible: width > 24 + text: row.subtitle + color: theme.fgSubtle + font.pixelSize: 12 + elide: Text.ElideRight + } + } + Rectangle { + visible: row.badge !== "" + implicitWidth: badgeLabel.implicitWidth + 14 + implicitHeight: 18 + radius: 9 + readonly property color tone: root.badgeTone(row.badge) + color: Qt.rgba(tone.r, tone.g, tone.b, 0.18) + Text { + id: badgeLabel + anchors.centerIn: parent + text: row.badge + color: parent.tone + font.pixelSize: 11 + font.weight: Font.Medium + } + } + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.backend.hover(row.index) + onClicked: root.backend.accept(row.index) + } + } + } + } + + // ---- footer --------------------------------------------------------------------- + Rectangle { Layout.fillWidth: true; height: 1; color: theme.border; visible: footer.visible } + ColumnLayout { + id: footer + readonly property var detail: root.backend.detail + visible: detail.title !== undefined + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.topMargin: 8 + Layout.bottomMargin: 8 + spacing: 2 + RowLayout { + Layout.fillWidth: true + spacing: 8 + Icon { name: footer.detail.icon || "sensors"; size: 16; color: theme.fgMuted } + Text { + Layout.fillWidth: true + text: footer.detail.title || "" + color: theme.fg + font.pixelSize: 13 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Text { + readonly property bool live: footer.detail.valueState === "live" && footer.detail.value !== "" + text: live ? footer.detail.value + (footer.detail.valueTime ? " · " + footer.detail.valueTime : "") + : footer.detail.valueState === "loading" ? "reading…" : "no value yet" + color: live ? theme.success : theme.fgSubtle + font.pixelSize: 12 + font.family: "monospace" + } + } + Text { + readonly property string line: [footer.detail.subtitle, footer.detail.details].filter(p => p).join(" · ") + visible: line !== "" + Layout.fillWidth: true + text: line + color: theme.fgMuted + font.pixelSize: 12 + elide: Text.ElideRight + } + Text { + Layout.topMargin: 4 + text: "↑↓ move ↵ select esc close" + color: theme.fgSubtle + font.pixelSize: 11 + } + } + } +} diff --git a/tests/unit_tests/benchmarks/test_device_picker_benchmark.py b/tests/unit_tests/benchmarks/test_device_picker_benchmark.py new file mode 100644 index 00000000..ffd5f645 --- /dev/null +++ b/tests/unit_tests/benchmarks/test_device_picker_benchmark.py @@ -0,0 +1,76 @@ +"""Benchmarks of the device pickers against DeviceComboBox with a large device list.""" + +# pylint: disable=redefined-outer-name, missing-function-docstring +from __future__ import annotations + +import threading + +import pytest +from qtpy.QtWidgets import QApplication + +from bec_widgets.tests.client_mocks import mocked_client # noqa: F401 +from bec_widgets.tests.fake_devices import FakeDevice, FakePositioner +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.picker.picker_qml import DevicePickerQML +from bec_widgets.widgets.control.device_input.picker.picker_qwidget import DevicePicker + +WIDGETS = [DeviceComboBox, DevicePicker, DevicePickerQML] + + +@pytest.fixture +def many_devices(mocked_client): + devices = [] + for index in range(300): + devices.append(FakePositioner(f"mot{index:03d}", limits=[-1, 1], read_value=1.0)) + devices.append(FakeDevice(f"det{index:03d}")) + mocked_client.device_manager.add_devices(devices) + yield mocked_client + + +def _close(widgets): + for widget in widgets: + widget.close() + widget.deleteLater() + + +@pytest.mark.parametrize("widget_cls", WIDGETS, ids=lambda cls: cls.__name__) +def test_create_device_input(benchmark, qtbot, many_devices, widget_cls): + """Create one device input with about 600 devices.""" + created = [] + + def create(): + created.append(widget_cls(client=many_devices)) + + benchmark(create) + assert created[-1].devices + _close(created) + + +@pytest.mark.parametrize("widget_cls", WIDGETS, ids=lambda cls: cls.__name__) +def test_device_update_refreshes_twenty_inputs(benchmark, qtbot, many_devices, widget_cls): + """Deliver one device configuration update to 20 device inputs, as on a config reload.""" + widgets = [widget_cls(client=many_devices) for _ in range(20)] + + def update(): + thread = threading.Thread( + target=lambda: [widget.on_device_update("reload", {}) for widget in widgets] + ) + thread.start() + thread.join() + QApplication.processEvents() + + benchmark(update) + _close(widgets) + + +@pytest.mark.parametrize("widget_cls", [DevicePicker, DevicePickerQML], ids=lambda c: c.__name__) +def test_search_popup_keystroke(benchmark, qtbot, many_devices, widget_cls): + """Filter the open popup by one more character.""" + widget = widget_cls(client=many_devices) + widget.open_picker() + controller = widget.picker_controller + queries = iter(["m", "mo", "mot", "mot1", "mot12", ""] * 1000) + + benchmark(lambda: controller.set_query(next(queries))) + widget.hidePopup() + _close([widget]) diff --git a/tests/unit_tests/test_device_signal_pickers.py b/tests/unit_tests/test_device_signal_pickers.py new file mode 100644 index 00000000..3f6cb02a --- /dev/null +++ b/tests/unit_tests/test_device_signal_pickers.py @@ -0,0 +1,377 @@ +"""Tests for the searchable device and signal pickers (QWidget and QML popups).""" + +# pylint: disable=redefined-outer-name, protected-access, missing-function-docstring +import time +from unittest import mock + +import pytest +from bec_lib import messages +from bec_lib.device import ReadoutPriority +from bec_lib.endpoints import MessageEndpoints +from qtpy.QtWidgets import QApplication + +from bec_widgets.tests.fake_devices import FakeDevice +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import ( + BECDeviceFilter, + DeviceComboBox, +) +from bec_widgets.widgets.control.device_input.picker import picker_common, picker_model +from bec_widgets.widgets.control.device_input.picker.picker_model import ( + PickerController, + PickerEntry, + format_value, + rank_entry, +) +from bec_widgets.widgets.control.device_input.picker.picker_qml import ( + DevicePickerQML, + PickerPopupQml, + SignalPickerQML, +) +from bec_widgets.widgets.control.device_input.picker.picker_qwidget import ( + DevicePicker, + PickerPopupWidget, + SignalPicker, +) + +from .conftest import create_widget + + +@pytest.fixture(autouse=True) +def _clear_recent(): + picker_model.clear_recent_picks() + yield + picker_model.clear_recent_picks() + + +@pytest.fixture(params=[DevicePicker, DevicePickerQML]) +def device_picker(request, qtbot, mocked_client): + yield create_widget(qtbot, request.param, client=mocked_client) + + +@pytest.fixture(params=[SignalPicker, SignalPickerQML]) +def signal_picker(request, qtbot, mocked_client): + yield create_widget(qtbot, request.param, client=mocked_client, device="samx") + + +def _titles(controller: PickerController) -> list[tuple[str, str]]: + model = controller.model + return [(model.row(r)["rowType"], model.row(r)["title"]) for r in range(model.rowCount())] + + +def _wait_closed(qtbot, picker): + qtbot.waitUntil(lambda: not picker.is_popup_open()) + + +# ---- pure logic --------------------------------------------------------------------------------- + + +def test_rank_entry_orders_prefix_word_substring_fuzzy(): + def rank(query, title, **kwargs): + score = rank_entry(query, PickerEntry(value=title, title=title, **kwargs)) + return None if score is None else score[0] + + assert rank("sam", "samx") == 0 + assert rank("x", "slit1_xc") == 1 + assert rank("pm", "bpm4i") == 2 + assert rank("smx", "samx") == 3 + assert rank("motor", "samx", subtitle="EpicsMotor") == 4 + assert rank("zzz", "samx") is None + assert rank_entry("", PickerEntry(value="a", title="a")) == (0, -1, 0) + assert rank_entry("BPM", PickerEntry(value="x", title="gauss_bpm")) == (1, 6, 3) + + +def test_format_value(): + assert format_value(None) == "" + assert format_value(True) == "true" + assert format_value(3) == "3" + assert format_value(1.23456789) == "1.23457" + assert format_value([1, 2]) == "list [2]" + assert format_value("x" * 50).endswith("…") + + +def test_controller_groups_searches_and_navigates(qtbot): + entries = [ + PickerEntry(value="samx", title="samx", group="Positioners"), + PickerEntry(value="samy", title="samy", group="Positioners"), + PickerEntry(value="bpm4i", title="bpm4i", group="Devices"), + ] + controller = PickerController("devices") + controller.open(entries, current="samy") + assert _titles(controller) == [ + ("header", "Positioners"), + ("item", "samx"), + ("item", "samy"), + ("header", "Devices"), + ("item", "bpm4i"), + ] + # the current value is highlighted and marked + assert controller.highlight == 2 + assert controller.model.row(2)["isCurrent"] + assert controller.summary == "3 devices" + + # navigation skips headers and wraps around + controller.move_highlight(1) + assert controller.entry_at(controller.highlight).value == "bpm4i" + controller.move_highlight(1) + assert controller.entry_at(controller.highlight).value == "samx" + controller.move_highlight(-1) + assert controller.entry_at(controller.highlight).value == "bpm4i" + + controller.set_query("y") + assert _titles(controller) == [("item", "samy")] + assert controller.summary == "1 of 3" + assert "" in controller.model.row(0)["titleRich"] + + controller.set_query("nothing") + assert _titles(controller)[0][0] == "empty" + assert controller.entry_at(0) is None + assert not controller.accept() + + controller.set_query("bpm") + with qtbot.waitSignal(controller.picked) as blocker: + assert controller.accept() + assert blocker.args == ["bpm4i"] + controller.close() + + +def test_controller_lists_recent_first(): + entries = [PickerEntry(value=f"dev{i}", title=f"dev{i}", group="Devices") for i in range(12)] + controller = PickerController("devices") + controller.open(entries, recent=["dev7", "unknown"]) + titles = _titles(controller) + assert titles[:3] == [("header", "Recent"), ("item", "dev7"), ("header", "Devices")] + controller.close() + + +# ---- device picker ------------------------------------------------------------------------------- + + +def test_device_picker_lists_same_devices_as_combobox(qtbot, mocked_client, device_picker): + combo = create_widget(qtbot, DeviceComboBox, client=mocked_client) + assert device_picker.devices == combo.devices + + for widget in (combo, device_picker): + widget.set_device_filter(BECDeviceFilter.POSITIONER) + assert device_picker.devices == combo.devices + assert "samx" in device_picker.devices and "bpm4i" not in device_picker.devices + + for widget in (combo, device_picker): + widget.readout_monitored = False + assert device_picker.devices == combo.devices + + for widget in (combo, device_picker): + widget.set_available_devices(["samx", "samy"]) + assert device_picker.devices == combo.devices == ["samx", "samy"] + + +ASYNC_SIGNALS = [ + ( + "eiger", + "eiger", + { + "obj_name": "eiger", + "component_name": "eiger", + "kind_str": "hinted", + "signal_class": "AsyncSignal", + }, + ) +] + + +@pytest.fixture +def async_signals(mocked_client): + with mock.patch.object( + mocked_client.device_manager, "get_bec_signals", return_value=ASYNC_SIGNALS, create=True + ): + yield + + +def test_device_picker_signal_class_and_write_access_filters(qtbot, mocked_client, async_signals): + combo = create_widget( + qtbot, + DeviceComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + include_signals_with_write_access=True, + ) + picker = create_widget( + qtbot, + DevicePicker, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + include_signals_with_write_access=True, + ) + assert picker.devices == combo.devices + assert "eiger" in picker.devices + + +def test_readout_filters_refresh_once(qtbot, mocked_client): + with mock.patch.object( + picker_common, "device_snapshot", wraps=picker_common.device_snapshot + ) as snapshot: + picker = create_widget(qtbot, DevicePicker, client=mocked_client) + reads_in_init = [c for c in snapshot.call_args_list if c.kwargs.get("force") is not None] + assert len(reads_in_init) == 1 + snapshot.reset_mock() + picker.set_readout_priority_filter([ReadoutPriority.MONITORED, ReadoutPriority.BASELINE]) + assert len([c for c in snapshot.call_args_list if "force" in c.kwargs]) == 1 + + +def test_unchanged_device_list_does_not_reemit(qtbot, device_picker): + device_picker.set_device("samx") + with qtbot.assertNotEmitted(device_picker.device_selected): + device_picker.update_devices_from_filters() + + +def test_device_update_lists_new_device(qtbot, mocked_client, device_picker): + assert "new_det" not in device_picker.devices + mocked_client.device_manager.add_devices([FakeDevice("new_det")]) + device_picker.on_device_update("add", {}) + qtbot.waitUntil(lambda: "new_det" in device_picker.devices) + + +def test_invalid_text_is_painted_not_restyled(device_picker): + sheet = device_picker.styleSheet() + device_picker.setCurrentText("not_a_device") + assert not device_picker.is_valid_input + assert device_picker.shows_invalid + device_picker.setCurrentText("") + assert not device_picker.shows_invalid + device_picker.setCurrentText("samx") + assert device_picker.is_valid_input and not device_picker.shows_invalid + assert device_picker.styleSheet() == sheet + device_picker.setCurrentText("not_a_device") + device_picker.setEnabled(False) + assert not device_picker.shows_invalid + + +def test_pick_from_popup_selects_device(qtbot, device_picker): + device_picker.showPopup() + assert device_picker.is_popup_open() + popup_cls = PickerPopupQml if isinstance(device_picker, DevicePickerQML) else PickerPopupWidget + assert isinstance(device_picker.picker_popup, popup_cls) + controller = device_picker.picker_controller + assert ("header", "Positioners") in _titles(controller) + + controller.set_query("samy") + with qtbot.waitSignal(device_picker.device_selected) as blocker: + controller.accept() + assert blocker.args == ["samy"] + assert device_picker.currentText() == "samy" + _wait_closed(qtbot, device_picker) + assert picker_model.recent_picks("devices") == ["samy"] + + +def test_typing_opens_popup_with_query_and_escape_restores(qtbot, device_picker): + device_picker.set_device("samx") + device_picker.lineEdit().textEdited.emit("bpm") + assert device_picker.is_popup_open() + controller = device_picker.picker_controller + assert controller.query == "bpm" + assert all("bpm" in title for kind, title in _titles(controller) if kind == "item") + device_picker.setCurrentText("bpm") + controller.dismiss() + _wait_closed(qtbot, device_picker) + assert device_picker.currentText() == "samx" + + +def test_closing_popup_by_clicking_outside_reverts_typed_text(qtbot, device_picker): + device_picker.set_device("samx") + device_picker.setCurrentText("bp") + device_picker.lineEdit().textEdited.emit("bp") + assert device_picker.is_popup_open() + device_picker.picker_popup.hide() + assert device_picker.currentText() == "samx" + + +def test_footer_shows_cached_and_live_value(qtbot, mocked_client, device_picker): + now = time.time() + mocked_client.connector.set_and_publish( + MessageEndpoints.device_readback("samx"), + messages.DeviceMessage(signals={"samx": {"value": 2.5, "timestamp": now}}, metadata={}), + ) + device_picker.set_device("samx") + device_picker.showPopup() + controller = device_picker.picker_controller + assert controller.detail["title"] == "samx" + qtbot.waitUntil(lambda: controller.detail.get("valueState") == "live", timeout=2000) + assert controller.detail["value"] == "2.5" + assert device_picker._live_entry.device == "samx" + + device_picker._on_live_message({"signals": {"samx": {"value": 3.0, "timestamp": now}}}, {}) + assert controller.detail["value"] == "3" + + device_picker.hidePopup() + _wait_closed(qtbot, device_picker) + assert device_picker._live_entry is None + + +def test_popup_follows_theme(qtbot, device_picker): + from bec_widgets.utils.colors import apply_theme + + device_picker.showPopup() + apply_theme("dark") + QApplication.processEvents() + device_picker.hidePopup() + apply_theme("light") + + +def test_qml_popup_loads_without_errors(qtbot, mocked_client): + picker = create_widget(qtbot, DevicePickerQML, client=mocked_client) + picker.showPopup() + view = picker.picker_popup.view + assert view.errors() == [] + assert view.rootObject() is not None + picker.hidePopup() + + +def test_cleanup_releases_popup(qtbot, mocked_client): + picker = DevicePicker(client=mocked_client) + qtbot.addWidget(picker) + picker.showPopup() + popup = picker.picker_popup + picker.close() + assert picker._picker_popup is None + assert not popup.isVisible() + + +# ---- signal picker ------------------------------------------------------------------------------- + + +def test_signal_picker_groups_by_kind(signal_picker): + signal_picker.showPopup() + titles = _titles(signal_picker.picker_controller) + headers = [title for kind, title in titles if kind == "header"] + assert headers == ["Hinted", "Normal", "Config"] + assert ("item", "samx (readback)") in titles + signal_picker.hidePopup() + + +def test_signal_picker_pick_sets_index_and_config(qtbot, signal_picker): + signal_picker.showPopup() + controller = signal_picker.picker_controller + controller.set_query("velo") + with qtbot.waitSignal(signal_picker.device_signal_changed): + controller.accept() + assert signal_picker.currentText() == "velocity" + assert signal_picker.get_signal_name() == "samx_velocity" + assert signal_picker.get_signal_config()["obj_name"] == "samx_velocity" + _wait_closed(qtbot, signal_picker) + + +def test_signal_picker_by_class_groups_by_device(qtbot, mocked_client, async_signals): + picker = create_widget( + qtbot, SignalPicker, client=mocked_client, signal_class_filter=["AsyncSignal"] + ) + picker.showPopup() + titles = _titles(picker.picker_controller) + # a single group is listed without a header + assert titles == [("item", "eiger")] + entry = next( + picker.picker_controller.entry_at(row) + for row in range(len(titles)) + if titles[row] == ("item", "eiger") + ) + assert entry.device == "eiger" + assert entry.group == "eiger" + picker.hidePopup()