diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/__init__.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/__init__.py new file mode 100644 index 00000000..3e8ca033 --- /dev/null +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/__init__.py @@ -0,0 +1,4 @@ +"""Modernized positioner boxes in QML and QWidget, for side-by-side comparison.""" + +from .positioner_box_qml import PositionerBoxQML +from .positioner_box_widgets import PositionerBoxWidgets diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/compare.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/compare.py new file mode 100644 index 00000000..18e89261 --- /dev/null +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/compare.py @@ -0,0 +1,36 @@ +"""Show the current, QML and QWidget positioner boxes side by side against a running BEC. + +Usage: python -m bec_widgets.widgets.control.device_control.positioner_box.positioner_box_modern.compare samx [dark|light] +""" + +import sys + +from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QWidget + +from bec_widgets.utils.colors import apply_theme +from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox +from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_modern import ( + PositionerBoxQML, + PositionerBoxWidgets, +) + + +def main(): # pragma: no cover + """Open the comparison window.""" + app = QApplication(sys.argv) + device = sys.argv[1] if len(sys.argv) > 1 else "samx" + apply_theme(sys.argv[2] if len(sys.argv) > 2 else "dark") + window = QWidget() + window.setWindowTitle(f"Positioner box comparison: {device}") + grid = QGridLayout(window) + for column, (title, cls) in enumerate( + (("Current", PositionerBox), ("QML", PositionerBoxQML), ("QWidget", PositionerBoxWidgets)) + ): + grid.addWidget(QLabel(title), 0, column) + grid.addWidget(cls(device=device), 1, column) + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/positioner_box_modern_base.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/positioner_box_modern_base.py new file mode 100644 index 00000000..1e29a182 --- /dev/null +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/positioner_box_modern_base.py @@ -0,0 +1,514 @@ +"""Shared logic for the modernized positioner boxes (QML and QWidget versions). + +Both views render the same state and call the same actions, so the BEC handling lives here once +and the comparison between the two versions is only about the view layer. +""" + +from __future__ import annotations + +import math + +from bec_lib.device import Positioner +from bec_lib.endpoints import MessageEndpoints +from bec_lib.logger import bec_logger +from qtpy.QtCore import Property, QObject, QPoint, Qt, Signal, Slot +from qtpy.QtGui import QCursor +from qtpy.QtWidgets import QFrame, QLineEdit, QListWidget, QVBoxLayout, QWidget + +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import ( + PositionerBoxBase, +) + +logger = bec_logger.logger + +MOVING_UNKNOWN = -1 +MOVING_IDLE = 0 +MOVING_ACTIVE = 1 + + +def _format(value: float | None, precision: int) -> str: + if value is None: + return "—" + return f"{value:.{precision}f}" + + +class PositionerState(QObject): + """Read-only view state of a positioner box, exposed to QML as ``box``. + + Every property shares the single ``changed`` signal, so a readback update costs one + notification regardless of how many fields changed. + """ + + changed = Signal() + + def __init__(self, box: "ModernPositionerBoxBase"): + super().__init__(box) + self._box = box + + def _prop(name, type_, notify=changed): # pylint: disable=no-self-argument + # pylint: disable=protected-access + return Property(type_, lambda self: getattr(self._box, name), notify=notify) + + device = _prop("_device", str) + units = _prop("_units", str) + readbackText = _prop("readback_text", str) + targetText = _prop("target_text", str) + hasTarget = _prop("has_target", bool) + moving = _prop("_moving", int) + hasLimits = _prop("has_limits", bool) + lowText = _prop("low_text", str) + highText = _prop("high_text", str) + position = _prop("position_fraction", float) + targetPosition = _prop("target_fraction", float) + outOfLimits = _prop("out_of_limits", bool) + stepText = _prop("step_text", str) + deviceSelectable = _prop("device_selectable", bool) + ready = _prop("ready", bool) + + # pylint: disable=invalid-name + @Slot(str, result=str) + def validate(self, text: str) -> str: + """Return an error message for a target text, or an empty string if it is valid.""" + return self._box.validate_target(text) + + @Slot(str, result=bool) + def moveTo(self, text: str) -> bool: + """Move to the typed target. Returns True if a move was sent.""" + return self._box.move_to_text(text) + + @Slot(int) + def tweak(self, direction: int): + """Tweak by one step in the given direction (+1 or -1).""" + self._box.tweak(direction) + + @Slot() + def stop(self): + """Stop the device.""" + self._box.on_stop() + + @Slot(str) + def setStepText(self, text: str): + """Set the step size from text; invalid text is ignored.""" + self._box.set_step_text(text) + + @Slot(float) + def scaleStep(self, factor: float): + """Multiply the step size, e.g. by 10 or 0.1.""" + self._box.scale_step(factor) + + @Slot() + def pickDevice(self): + """Open the device picker at the mouse position.""" + self._box.open_device_picker(QCursor.pos()) + + +class DevicePickerPopup(QFrame): + """Searchable popup list of positioners. Click or Enter picks, Esc closes.""" + + def __init__(self, names: list[str], current: str, on_pick, parent: QWidget | None = None): + super().__init__(parent, Qt.WindowType.Popup) + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self.setFrameShape(QFrame.Shape.StyledPanel) + self._on_pick = on_pick + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(4) + self.search = QLineEdit(self) + self.search.setPlaceholderText("Search positioners") + self.search.setClearButtonEnabled(True) + self.list = QListWidget(self) + self.list.addItems(names) + layout.addWidget(self.search) + layout.addWidget(self.list) + self.setFixedSize(220, min(320, 60 + 24 * max(len(names), 3))) + matches = self.list.findItems(current, Qt.MatchFlag.MatchExactly) + if matches: + self.list.setCurrentItem(matches[0]) + self.search.textChanged.connect(self._filter) + self.search.returnPressed.connect(self._pick_current) + self.list.itemClicked.connect(lambda item: self._pick(item.text())) + self.list.itemActivated.connect(lambda item: self._pick(item.text())) + self.search.setFocus() + + def _filter(self, text: str): + first = None + for row in range(self.list.count()): + item = self.list.item(row) + hidden = text.lower() not in item.text().lower() + item.setHidden(hidden) + if not hidden and first is None: + first = item + if first is not None: + self.list.setCurrentItem(first) + + def _pick_current(self): + item = self.list.currentItem() + if item is not None and not item.isHidden(): + self._pick(item.text()) + + def _pick(self, name: str): + self.close() + self._on_pick(name) + + +class ModernPositionerBoxBase(PositionerBoxBase): + """Positioner box with the modernized UX. Subclasses only build the view. + + Keeps the public API of :class:`PositionerBox`: ``device``, ``hide_device_selection``, + ``set_positioner``, ``show_device_selection``, ``step_size``, ``on_stop``, + ``force_update_readback`` and the ``device_changed`` and ``position_update`` signals. + """ + + PLUGIN = False + RPC = False + USER_ACCESS = ["set_positioner", "attach", "detach", "screenshot"] + + device_changed = Signal(str, str) + position_update = Signal(float) + + def __init__(self, parent=None, device: Positioner | str | None = None, **kwargs): + super().__init__(parent=parent, **kwargs) + self._device = "" + self._units = "" + self._precision = 8 + self._readback: float | None = None + self._setpoint: float | None = None + self._moving = MOVING_UNKNOWN + self._limits: tuple[float, float] | None = None + self._step = 0.1 + self._hide_device_selection = False + self.state = PositionerState(self) + self._picker: DevicePickerPopup | None = None + self.init_view() + if isinstance(device, Positioner): + device = device.name + self.device = device + + def init_view(self): + """Build the view and add it to ``self.main_layout``.""" + raise NotImplementedError + + def _device_ui_components(self, device: str): # pragma: no cover - not used by these views + return {} + + ########################################## + # Public API, same as PositionerBox + ########################################## + + @SafeProperty(str) + def device(self): + """Name of the controlled positioner.""" + return self._device + + @device.setter + def device(self, value: str): + if not value or not isinstance(value, str): + return + if not self._check_device_is_valid(value): + return + old_device = self._device + if old_device: + self.bec_dispatcher.disconnect_slot( + self.on_device_readback, MessageEndpoints.device_readback(old_device) + ) + self._device = value + self._load_device_info() + self.bec_dispatcher.connect_slot( + self.on_device_readback, MessageEndpoints.device_readback(value) + ) + self.force_update_readback() + self.device_changed.emit(old_device, value) + + @SafeProperty(bool) + def hide_device_selection(self): + """Hide the device picker.""" + return self._hide_device_selection + + @hide_device_selection.setter + def hide_device_selection(self, value: bool): + self._hide_device_selection = value + self.state.changed.emit() + + @SafeSlot(bool) + def show_device_selection(self, value: bool): + """Show the device picker. + + Args: + value (bool): Show the device picker. + """ + self.hide_device_selection = not value + + @SafeSlot(str) + def set_positioner(self, positioner: str | Positioner): + """Set the device. + + Args: + positioner (Positioner | str): Positioner to set, accepts str or the device. + """ + if isinstance(positioner, Positioner): + positioner = positioner.name + self.device = positioner + + @property + def step_size(self) -> float: + """Step size for tweak.""" + return self._step + + def force_update_readback(self): + """Read the cached device values and refresh the view.""" + if not self._device: + return + data = self.dev[self._device].read(cached=True) + self.on_device_readback({"signals": data}, {}) + + @SafeSlot() + def on_stop(self): + """Stop the device.""" + self._stop_device(self._device) + + ########################################## + # Readback + ########################################## + + def _load_device_info(self): + device = self.dev[self._device] + precision = getattr(device, "precision", 8) + try: + self._precision = int(precision) + except (TypeError, ValueError): + self._precision = 8 + self._step = 10**-self._precision * 10 + try: + self._units = str(device.egu() or "") + except Exception: # pylint: disable=broad-except + self._units = "" + self._readback = None + self._setpoint = None + self._moving = MOVING_UNKNOWN + + # pylint: disable=unused-argument + @SafeSlot(dict, dict) + def on_device_readback(self, msg_content: dict, metadata: dict): + """Callback for device readback. + + Args: + msg_content (dict): The message content. + metadata (dict): The message metadata. + """ + if not self._device: + return + device = self._device + signals = msg_content.get("signals", {}) + # pylint: disable=protected-access + hinted_signals = self.dev[device]._hints + if len(hinted_signals) == 1: + value = signals.get(hinted_signals[0], {}).get("value") + if value is not None: + self._readback = float(value) + for name in ("setpoint", "user_setpoint"): + value = signals.get(f"{device}_{name}", {}).get("value") + if value is not None: + self._setpoint = float(value) + break + if f"{device}_motor_done_move" in signals: + self._moving = int(not signals[f"{device}_motor_done_move"].get("value")) + elif f"{device}_motor_is_moving" in signals: + self._moving = int(bool(signals[f"{device}_motor_is_moving"].get("value"))) + limits = self.dev[device].limits + self._limits = tuple(limits) if limits is not None and limits[0] != limits[1] else None + self.state.changed.emit() + if self._readback is not None: + self.position_update.emit(self._readback) + + ########################################## + # Derived view state + ########################################## + + @property + def ready(self) -> bool: + return bool(self._device) + + @property + def device_selectable(self) -> bool: + return not self._hide_device_selection + + @property + def readback_text(self) -> str: + return _format(self._readback, self._precision) + + @property + def target_text(self) -> str: + return _format(self._setpoint, self._precision) + + @property + def has_target(self) -> bool: + """True while the setpoint differs from the readback by more than the precision.""" + if self._setpoint is None or self._readback is None: + return False + return abs(self._setpoint - self._readback) >= 10**-self._precision + + @property + def has_limits(self) -> bool: + return self._limits is not None + + @property + def low_text(self) -> str: + return _format(self._limits[0], self._precision) if self._limits else "" + + @property + def high_text(self) -> str: + return _format(self._limits[1], self._precision) if self._limits else "" + + def _fraction(self, value: float | None) -> float: + if self._limits is None or value is None: + return -1.0 + low, high = self._limits + return min(max((value - low) / (high - low), 0.0), 1.0) + + @property + def position_fraction(self) -> float: + return self._fraction(self._readback) + + @property + def target_fraction(self) -> float: + return self._fraction(self._setpoint) if self.has_target else -1.0 + + @property + def out_of_limits(self) -> bool: + if self._limits is None or self._readback is None: + return False + return not self._limits[0] <= self._readback <= self._limits[1] + + @property + def step_text(self) -> str: + return f"{self._step:g}" + + ########################################## + # Actions + ########################################## + + def validate_target(self, text: str) -> str: + """Check a typed target. + + Args: + text (str): The typed target. + + Returns: + str: An error message, or an empty string if the target can be sent. + """ + text = text.strip() + if not text: + return "" + try: + value = float(text) + except ValueError: + return "Enter a number" + if not math.isfinite(value): + return "Enter a number" + if self._limits is not None and not self._limits[0] <= value <= self._limits[1]: + return f"Outside limits {self.low_text} … {self.high_text}" + return "" + + def move_to_text(self, text: str) -> bool: + """Move to a typed target if it is valid. + + Args: + text (str): The typed target. + + Returns: + bool: True if the move was sent. + """ + if not self._device or not text.strip() or self.validate_target(text): + return False + self.move_to(float(text)) + return True + + @SafeSlot(float) + def move_to(self, value: float): + """Move the device to an absolute position. + + Args: + value (float): Target position. + """ + self.scans.mv(self.dev[self._device], value, relative=False) + + @SafeSlot(int) + def tweak(self, direction: int): + """Move by one step from the current setpoint. + + Args: + direction (int): +1 or -1. + """ + if not self._device: + return + step = self._step if direction > 0 else -self._step + setpoint = self._get_setpoint() + if setpoint is None: + self.scans.mv(self.dev[self._device], step, relative=True) + return + self.scans.mv(self.dev[self._device], setpoint + step, relative=False) + + def _get_setpoint(self) -> float | None: + device = self.dev[self._device] + setpoint = getattr(device, "setpoint", None) or getattr(device, "user_setpoint", None) + if not setpoint: + return None + try: + return float(setpoint.get()) + except Exception: # pylint: disable=broad-except + return None + + def set_step_text(self, text: str): + """Set the tweak step from text. Non-positive or invalid values are ignored. + + Args: + text (str): Step size as text. + """ + try: + value = float(text) + except ValueError: + return + if math.isfinite(value) and value > 0: + self._step = value + self.state.changed.emit() + + def scale_step(self, factor: float): + """Multiply the tweak step. + + Args: + factor (float): Factor, e.g. 10 or 0.1. + """ + self._step = float(f"{self._step * factor:.12g}") + self.state.changed.emit() + + def positioner_names(self) -> list[str]: + """Names of all enabled positioners, sorted.""" + return sorted( + name + for name, dev in self.dev.items() + if isinstance(dev, Positioner) and getattr(dev, "enabled", True) + ) + + def open_device_picker(self, global_pos: QPoint): + """Open the searchable device picker. + + Args: + global_pos (QPoint): Screen position of the popup's top-left corner. + """ + if self._hide_device_selection: + return + self._picker = DevicePickerPopup( + self.positioner_names(), self._device, self.set_positioner, self + ) + self._picker.destroyed.connect(self._on_picker_destroyed) + self._picker.move(global_pos) + self._picker.show() + + def _on_picker_destroyed(self): + self._picker = None + + def cleanup(self): + if self._picker is not None: + self._picker.close() + super().cleanup() diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/positioner_box_qml.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/positioner_box_qml.py new file mode 100644 index 00000000..c5aa1fd4 --- /dev/null +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/positioner_box_qml.py @@ -0,0 +1,127 @@ +"""Positioner box rendered with QML inside a QQuickWidget.""" + +from __future__ import annotations + +from pathlib import Path + +from bec_qthemes import material_icon +from qtpy.QtCore import Property, QObject, QSize, QUrl, Signal +from qtpy.QtGui import QColor, QPalette +from qtpy.QtQuick import QQuickImageProvider +from qtpy.QtQuickWidgets import QQuickWidget +from qtpy.QtWidgets import QApplication + +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_modern.positioner_box_modern_base import ( + ModernPositionerBoxBase, +) + +QML_FILE = Path(__file__).parent / "qml" / "PositionerBox.qml" + + +class QmlTheme(QObject): + """Theme colors for QML, taken from the application palette and BEC accent colors.""" + + changed = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._colors: dict[str, QColor] = {} + self.refresh() + + def refresh(self): + """Re-read the colors after a theme change.""" + palette = QApplication.palette() + accents = get_accent_colors() + window = palette.color(QPalette.ColorRole.Window) + self._colors = { + "window": window, + "base": palette.color(QPalette.ColorRole.Base), + "button": palette.color(QPalette.ColorRole.Button), + "border": palette.color(QPalette.ColorRole.Mid), + "text": palette.color(QPalette.ColorRole.WindowText), + "muted": palette.color(QPalette.ColorRole.PlaceholderText), + "accent": palette.color(QPalette.ColorRole.Highlight), + "onAccent": palette.color(QPalette.ColorRole.HighlightedText), + "success": accents.success, + "warning": accents.warning, + "danger": accents.emergency, + } + self.changed.emit() + + def color(self, name: str) -> QColor: + """Return a theme color by name, e.g. ``"window"``.""" + return self._colors[name] + + def _color(name, notify=changed): # pylint: disable=no-self-argument + return Property(QColor, lambda self: self.color(name), notify=notify) + + window = _color("window") + base = _color("base") + button = _color("button") + border = _color("border") + text = _color("text") + muted = _color("muted") + accent = _color("accent") + onAccent = _color("onAccent") + success = _color("success") + warning = _color("warning") + danger = _color("danger") + + +class MaterialIconProvider(QQuickImageProvider): + """Serves ``image://material/?`` from bec_qthemes Material icons.""" + + def __init__(self): + super().__init__(QQuickImageProvider.ImageType.Pixmap) + + def requestPixmap(self, icon_id: str, size: QSize, requested_size: QSize): + name, _, color = icon_id.partition("?") + side = requested_size.width() if requested_size.width() > 0 else 24 + pixmap = material_icon(name, size=(side, side), color=color or None, filled=True) + if size is not None: + size.setWidth(pixmap.width()) + size.setHeight(pixmap.height()) + return pixmap + + +class PositionerBoxQML(ModernPositionerBoxBase): + """Positioner box with the modernized UX, drawn in QML.""" + + def init_view(self): + self.theme = QmlTheme(self) + self.view = QQuickWidget(self) + self.view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) + engine = self.view.engine() + engine.addImageProvider("material", MaterialIconProvider()) + context = self.view.rootContext() + context.setContextProperty("box", self.state) + context.setContextProperty("theme", self.theme) + self.view.setClearColor(self.theme.color("window")) + self.view.setSource(QUrl.fromLocalFile(str(QML_FILE))) + for error in self.view.errors(): + raise RuntimeError(f"PositionerBox.qml: {error.toString()}") + self.view.setMinimumSize(240, 250) + self.main_layout.addWidget(self.view) + + @SafeSlot(str) + def apply_theme(self, theme: str): + self.theme.refresh() + self.view.setClearColor(self.theme.color("window")) + + def cleanup(self): + self.view.setSource(QUrl()) + super().cleanup() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from bec_widgets.utils.colors import apply_theme + + app = QApplication(sys.argv) + apply_theme("dark") + widget = PositionerBoxQML(device="samx") + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/positioner_box_widgets.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/positioner_box_widgets.py new file mode 100644 index 00000000..8ea93530 --- /dev/null +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/positioner_box_widgets.py @@ -0,0 +1,431 @@ +"""Positioner box with the modernized UX, built from QWidgets.""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import QEvent, QPointF, QRectF, Qt, QVariantAnimation +from qtpy.QtGui import QColor, QDoubleValidator, QFont, QIcon, QPainter, QPalette, QPen +from qtpy.QtWidgets import ( + QApplication, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.colors import get_accent_colors, rgba +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_modern.positioner_box_modern_base import ( + MOVING_ACTIVE, + MOVING_UNKNOWN, + ModernPositionerBoxBase, +) + + +def _colors() -> dict[str, QColor]: + palette = QApplication.palette() + accents = get_accent_colors() + return { + "window": palette.color(QPalette.ColorRole.Window), + "base": palette.color(QPalette.ColorRole.Base), + "button": palette.color(QPalette.ColorRole.Button), + "border": palette.color(QPalette.ColorRole.Mid), + "text": palette.color(QPalette.ColorRole.WindowText), + "muted": palette.color(QPalette.ColorRole.PlaceholderText), + "accent": palette.color(QPalette.ColorRole.Highlight), + "onAccent": palette.color(QPalette.ColorRole.HighlightedText), + "success": accents.success, + "warning": accents.warning, + "danger": accents.emergency, + } + + +class StatusPill(QWidget): + """Rounded "Moving" / "Idle" chip with a pulsing dot while moving.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._moving = False + self._dot_alpha = 1.0 + self._colors = _colors() + self._pulse = QVariantAnimation(self, startValue=1.0, endValue=0.25, duration=500) + self._pulse.setLoopCount(-1) + self._pulse.setKeyValueAt(0.5, 0.25) + self._pulse.setEndValue(1.0) + self._pulse.valueChanged.connect(self._on_pulse) + font = self.font() + font.setPixelSize(12) + font.setWeight(QFont.Weight.DemiBold) + self.setFont(font) + self.setFixedHeight(22) + + def set_colors(self, colors: dict[str, QColor]): + self._colors = colors + self.update() + + def set_moving(self, moving: bool): + if moving == self._moving: + return + self._moving = moving + if moving: + self._pulse.start() + else: + self._pulse.stop() + self._dot_alpha = 1.0 + self.setFixedWidth(self.sizeHint().width()) + self.update() + + def _on_pulse(self, value): + self._dot_alpha = float(value) + self.update() + + def _text(self) -> str: + return "Moving" if self._moving else "Idle" + + def sizeHint(self): + hint = super().sizeHint() + hint.setWidth(self.fontMetrics().horizontalAdvance(self._text()) + 30) + return hint + + def paintEvent(self, _event): + tone = QColor(self._colors["warning" if self._moving else "success"]) + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + bg = QColor(tone) + bg.setAlphaF(0.16) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(bg) + painter.drawRoundedRect(QRectF(self.rect()), 11, 11) + dot = QColor(tone) + dot.setAlphaF(self._dot_alpha) + painter.setBrush(dot) + painter.drawEllipse(QPointF(12, self.height() / 2), 4, 4) + painter.setPen(self._colors["text"]) + painter.drawText( + self.rect().adjusted(22, 0, 0, 0), Qt.AlignmentFlag.AlignVCenter, self._text() + ) + + +class LimitBar(QWidget): + """Track between the limits with a readback dot and a hollow target ring.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._position = -1.0 + self._target = -1.0 + self._low = "" + self._high = "" + self._out = False + self._colors = _colors() + self._anim = QVariantAnimation(self, duration=120) + self._anim.valueChanged.connect(self._on_anim) + self.setFixedHeight(30) + font = self.font() + font.setPixelSize(11) + self.setFont(font) + + def set_colors(self, colors: dict[str, QColor]): + self._colors = colors + self.update() + + def set_state(self, position: float, target: float, low: str, high: str, out: bool): + self._target, self._low, self._high, self._out = target, low, high, out + if position >= 0 and self._position >= 0 and position != self._position: + self._anim.stop() + self._anim.setStartValue(self._position) + self._anim.setEndValue(position) + self._anim.start() + else: + self._position = position + self.update() + + def _on_anim(self, value): + self._position = float(value) + self.update() + + def paintEvent(self, _event): + colors = self._colors + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + width = self.width() + mid_y = 7 + track = QColor(colors["border"]) + track.setAlphaF(0.6) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(track) + painter.drawRoundedRect(QRectF(0, mid_y - 3, width, 6), 3, 3) + tone = QColor(colors["danger" if self._out else "accent"]) + if self._position >= 0: + fill = QColor(tone) + fill.setAlphaF(0.55) + painter.setBrush(fill) + painter.drawRoundedRect(QRectF(0, mid_y - 3, width * self._position, 6), 3, 3) + if self._target >= 0: + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(colors["accent"], 2)) + painter.drawEllipse(QPointF(width * self._target, mid_y), 5, 5) + if self._position >= 0: + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(tone) + painter.drawEllipse(QPointF(width * self._position, mid_y), 6, 6) + painter.setPen(colors["muted"]) + text_rect = self.rect().adjusted(0, 14, 0, 0) + painter.drawText( + text_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom, self._low + ) + painter.drawText( + text_rect, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignBottom, self._high + ) + + +class PositionerBoxWidgets(ModernPositionerBoxBase): + """Positioner box with the modernized UX, built from QWidgets.""" + + def init_view(self): + self._colors = _colors() + self._icons: dict[tuple[str, str, int], QIcon] = {} + root = QWidget(self) + root.setObjectName("positioner_root") + layout = QVBoxLayout(root) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(8) + + # Header: device picker and motion status + header = QHBoxLayout() + self.device_button = QToolButton(root) + self.device_button.setObjectName("device_button") + self.device_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.device_button.setLayoutDirection(Qt.LayoutDirection.RightToLeft) + self.device_button.setToolTip("Change positioner") + self.device_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.device_button.clicked.connect(self._on_device_button) + self.status_pill = StatusPill(root) + header.addWidget(self.device_button) + header.addStretch() + header.addWidget(self.status_pill) + layout.addLayout(header) + + # Readback and target + readback_row = QHBoxLayout() + readback_row.setSpacing(6) + self.readback = QLabel(root) + self.readback.setObjectName("readback") + self.units = QLabel(root) + self.units.setObjectName("units") + readback_row.addWidget(self.readback) + readback_row.addWidget(self.units, 0, Qt.AlignmentFlag.AlignBaseline) + readback_row.addStretch() + layout.addLayout(readback_row) + self.target = QLabel(root) + self.target.setObjectName("target") + self.target.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.target) + + # Limit track + self.limit_bar = LimitBar(root) + self.no_limits = QLabel("No limits set", root) + self.no_limits.setObjectName("hint") + layout.addWidget(self.limit_bar) + layout.addWidget(self.no_limits) + + # Move to + move_row = QHBoxLayout() + move_row.setSpacing(6) + self.setpoint = QLineEdit(root) + self.setpoint.setPlaceholderText("Move to…") + self.setpoint.textChanged.connect(self._on_target_text) + self.setpoint.returnPressed.connect(self.on_go) + self.setpoint.installEventFilter(self) + self.go = QPushButton("Go", root) + self.go.setObjectName("go") + self.go.setFixedWidth(52) + self.go.clicked.connect(self.on_go) + move_row.addWidget(self.setpoint, 1) + move_row.addWidget(self.go) + layout.addLayout(move_row) + self.move_error = QLabel(root) + self.move_error.setObjectName("error") + self.move_error.setVisible(False) + layout.addWidget(self.move_error) + + # Tweak + tweak_row = QHBoxLayout() + tweak_row.setSpacing(6) + self.tweak_left = QPushButton(root) + self.tweak_left.clicked.connect(lambda: self.tweak(-1)) + self.step = QLineEdit(root) + self.step.setObjectName("step") + self.step.setFixedWidth(72) + self.step.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.step.setValidator(QDoubleValidator(0, float("inf"), 12, self.step)) + self.step.setToolTip("Step size. Scroll or ↑/↓ to change by ×10") + self.step.editingFinished.connect(lambda: self.set_step_text(self.step.text())) + self.step.installEventFilter(self) + self.tweak_right = QPushButton(root) + self.tweak_right.clicked.connect(lambda: self.tweak(1)) + tweak_row.addWidget(self.tweak_left, 1) + tweak_row.addWidget(self.step) + tweak_row.addWidget(self.tweak_right, 1) + layout.addLayout(tweak_row) + + layout.addStretch() + self.stop = QPushButton("Stop", root) + self.stop.setObjectName("stop") + self.stop.setMinimumHeight(34) + self.stop.clicked.connect(self.on_stop) + layout.addWidget(self.stop) + + root.setMinimumSize(240, 250) + self.main_layout.addWidget(root) + self._root = root + self.state.changed.connect(self._refresh) + self.apply_theme("") + + def eventFilter(self, obj, event): + if obj is self.step: + if event.type() == QEvent.Type.Wheel: + self.scale_step(10 if event.angleDelta().y() > 0 else 0.1) + return True + if event.type() == QEvent.Type.KeyPress and event.key() in ( + Qt.Key.Key_Up, + Qt.Key.Key_Down, + ): + self.scale_step(10 if event.key() == Qt.Key.Key_Up else 0.1) + return True + if obj is self.setpoint and event.type() == QEvent.Type.KeyPress: + if event.key() == Qt.Key.Key_Escape: + self.setpoint.clear() + self.setpoint.clearFocus() + return True + return super().eventFilter(obj, event) + + @SafeSlot() + def _on_device_button(self): + self.open_device_picker( + self.device_button.mapToGlobal(self.device_button.rect().bottomLeft()) + ) + + @staticmethod + def _set_style_flag(widget: QWidget, name: str, value: bool): + """Set a QSS dynamic property and re-polish only when it changes.""" + if widget.property(name) == value: + return + widget.setProperty(name, value) + widget.style().unpolish(widget) + widget.style().polish(widget) + + def _icon(self, name: str, color: QColor | str, size: int) -> QIcon: + key = (name, QColor(color).name(), size) + if key not in self._icons: + self._icons[key] = QIcon( + material_icon(name, size=(size, size), color=key[1], filled=True) + ) + return self._icons[key] + + @SafeSlot(str) + def _on_target_text(self, text: str): + error = self.validate_target(text) + self.move_error.setText(error) + self.move_error.setVisible(bool(error)) + self._set_style_flag(self.setpoint, "invalid", bool(error)) + self.go.setEnabled(bool(text.strip()) and not error) + + @SafeSlot() + def on_go(self): + """Send the typed target.""" + if self.move_to_text(self.setpoint.text()): + self.setpoint.clear() + self.setpoint.clearFocus() + + @SafeSlot() + def _refresh(self): + moving = self._moving == MOVING_ACTIVE + self._root.setEnabled(self.ready) + self.device_button.setText(self._device or "No device") + self.device_button.setEnabled(self.device_selectable) + self.device_button.setIcon( + self._icon("expand_more", self._colors["muted"], 18) + if self.device_selectable + else QIcon() + ) + self.status_pill.setVisible(self._moving != MOVING_UNKNOWN) + self.status_pill.set_moving(moving) + self.readback.setText(self.readback_text) + self._set_style_flag(self.readback, "out", self.out_of_limits) + self.units.setText(self._units) + self.units.setVisible(bool(self._units)) + self.target.setText(f"→ target {self.target_text}" if self.has_target else "at target") + self._set_style_flag(self.target, "active", self.has_target) + self.limit_bar.setVisible(self.has_limits) + self.no_limits.setVisible(not self.has_limits and self.ready) + self.limit_bar.set_state( + self.position_fraction, + self.target_fraction, + self.low_text, + self.high_text, + self.out_of_limits, + ) + step = self.step_text + self.tweak_left.setText(f"− {step}") + self.tweak_right.setText(f"+ {step}") + self.tweak_left.setToolTip(f"Move by −{step} {self._units}") + self.tweak_right.setToolTip(f"Move by +{step} {self._units}") + if not self.step.hasFocus(): + self.step.setText(step) + self._set_style_flag(self.stop, "moving", moving) + self.stop.setIcon(self._icon("stop", "#ffffff" if moving else self._colors["danger"], 16)) + self._on_target_text(self.setpoint.text()) + + @SafeSlot(str) + def apply_theme(self, theme: str): + c = self._colors = _colors() + self.status_pill.set_colors(c) + self.limit_bar.set_colors(c) + name = lambda key: c[key].name() # pylint: disable=unnecessary-lambda-assignment + self._root.setStyleSheet(f""" + #positioner_root {{ background: {name('window')}; }} + #device_button {{ border: none; border-radius: 6px; padding: 3px 6px; + font-size: 15px; font-weight: 600; color: {name('text')}; + background: transparent; }} + #device_button:hover {{ background: {rgba(c['accent'], 38)}; }} + #readback {{ font-family: monospace; font-size: 26px; font-weight: 500; + color: {name('text')}; }} + #readback[out="true"] {{ color: {name('danger')}; }} + #units {{ font-size: 14px; color: {name('muted')}; }} + #target, #hint {{ font-size: 12px; color: {name('muted')}; }} + #hint {{ font-size: 11px; }} + #target[active="true"] {{ color: {name('accent')}; }} + #error {{ font-size: 11px; color: {name('danger')}; }} + QLineEdit {{ min-height: 26px; border-radius: 6px; padding: 0 6px; font-size: 13px; + background: {name('base')}; color: {name('text')}; + border: 1px solid {name('border')}; }} + QLineEdit:focus {{ border: 2px solid {name('accent')}; }} + QLineEdit[invalid="true"] {{ border: 2px solid {name('danger')}; }} + QPushButton {{ min-height: 28px; border-radius: 6px; font-size: 13px; + color: {name('text')}; background: {rgba(c['button'], 255)}; + border: 1px solid {rgba(c['border'], 160)}; padding: 0 8px; }} + QPushButton:hover {{ background: {rgba(c['accent'], 40)}; }} + QPushButton:pressed {{ background: {rgba(c['accent'], 80)}; }} + #go {{ background: {name('accent')}; color: {name('onAccent')}; border: none; }} + #go:disabled {{ background: {rgba(c['accent'], 40)}; color: {name('muted')}; }} + #stop {{ min-height: 32px; font-weight: 600; color: {name('danger')}; + background: {rgba(c['danger'], 30)}; border: 1px solid {rgba(c['danger'], 128)}; }} + #stop:hover {{ background: {rgba(c['danger'], 56)}; }} + #stop[moving="true"] {{ background: {name('danger')}; color: white; border: none; }} + """) + self._refresh() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from bec_widgets.utils.colors import apply_theme + + app = QApplication(sys.argv) + apply_theme("dark") + widget = PositionerBoxWidgets(device="samx") + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/qml/PositionerBox.qml b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/qml/PositionerBox.qml new file mode 100644 index 00000000..c562c661 --- /dev/null +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_modern/qml/PositionerBox.qml @@ -0,0 +1,324 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +// Positioner box: device header with status, large readback with target, +// limit track, move-to field with inline validation, tweak row and stop. +Rectangle { + id: root + color: theme.window + implicitWidth: 260 + implicitHeight: 280 + + readonly property bool isMoving: box.moving === 1 + readonly property string moveError: box.validate(targetField.text) + + component FlatButton: Button { + id: control + property color tint: theme.button + property color ink: theme.text + property bool solid: false + property bool neutral: true + implicitHeight: 30 + font.pixelSize: 13 + hoverEnabled: true + background: Rectangle { + radius: 6 + color: !control.enabled ? Qt.alpha(control.tint, 0.15) + : control.solid ? (control.down ? Qt.darker(control.tint, 1.2) : control.hovered ? Qt.lighter(control.tint, 1.1) : control.tint) + : control.neutral ? (control.down ? Qt.alpha(theme.accent, 0.32) : control.hovered ? Qt.alpha(theme.accent, 0.16) : theme.button) + : (control.down ? Qt.alpha(control.tint, 0.35) : control.hovered ? Qt.alpha(control.tint, 0.22) : Qt.alpha(control.tint, 0.12)) + border.width: control.solid ? 0 : 1 + border.color: control.neutral ? Qt.alpha(theme.border, 0.63) : Qt.alpha(control.tint, 0.5) + } + contentItem: Row { + spacing: 6 + anchors.centerIn: parent + Image { + visible: control.icon.name !== "" + source: visible ? "image://material/" + control.icon.name + "?" + control.ink : "" + sourceSize: Qt.size(16, 16) + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: control.text + font: control.font + color: control.enabled ? control.ink : theme.muted + anchors.verticalCenter: parent.verticalCenter + } + } + } + + component Field: TextField { + id: field + property bool invalid: false + implicitHeight: 30 + font.pixelSize: 13 + color: theme.text + placeholderTextColor: theme.muted + selectByMouse: true + background: Rectangle { + radius: 6 + color: theme.base + border.width: field.activeFocus || field.invalid ? 2 : 1 + border.color: field.invalid ? theme.danger : field.activeFocus ? theme.accent : theme.border + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + enabled: box.ready + + // Header: device picker and motion status + RowLayout { + Layout.fillWidth: true + spacing: 6 + Rectangle { + id: deviceChip + Layout.preferredHeight: 26 + Layout.preferredWidth: deviceRow.implicitWidth + 16 + Layout.maximumWidth: root.width - 110 + radius: 6 + color: deviceMouse.containsMouse && box.deviceSelectable ? Qt.alpha(theme.accent, 0.15) : "transparent" + Row { + id: deviceRow + anchors.centerIn: parent + spacing: 2 + Text { + text: box.device || "No device" + color: theme.text + font.pixelSize: 15 + font.weight: Font.DemiBold + elide: Text.ElideRight + width: Math.min(implicitWidth, deviceChip.Layout.maximumWidth - 30) + } + Image { + visible: box.deviceSelectable + source: "image://material/expand_more?" + theme.muted + sourceSize: Qt.size(18, 18) + anchors.verticalCenter: parent.verticalCenter + } + } + MouseArea { + id: deviceMouse + anchors.fill: parent + hoverEnabled: true + enabled: box.deviceSelectable + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: box.pickDevice() + ToolTip.visible: containsMouse && enabled + ToolTip.text: "Change positioner" + ToolTip.delay: 600 + } + } + Item { Layout.fillWidth: true } + Rectangle { + visible: box.moving !== -1 + Layout.preferredHeight: 22 + Layout.preferredWidth: statusRow.implicitWidth + 16 + radius: 11 + readonly property color tone: root.isMoving ? theme.warning : theme.success + color: Qt.alpha(tone, 0.16) + Row { + id: statusRow + anchors.centerIn: parent + spacing: 6 + Rectangle { + width: 8; height: 8; radius: 4 + anchors.verticalCenter: parent.verticalCenter + color: parent.parent.tone + SequentialAnimation on opacity { + running: root.isMoving + loops: Animation.Infinite + onRunningChanged: if (!running) parent.opacity = 1 + NumberAnimation { to: 0.25; duration: 500 } + NumberAnimation { to: 1; duration: 500 } + } + } + Text { + text: root.isMoving ? "Moving" : "Idle" + color: theme.text + font.pixelSize: 12 + font.weight: Font.DemiBold + } + } + } + } + + // Readback and target + RowLayout { + Layout.fillWidth: true + spacing: 6 + Text { + text: box.readbackText + color: box.outOfLimits ? theme.danger : theme.text + font.pixelSize: 26 + font.weight: Font.Medium + font.family: "monospace" + Layout.maximumWidth: root.width - 60 + elide: Text.ElideRight + } + Text { + visible: box.units !== "" + text: box.units + color: theme.muted + font.pixelSize: 14 + Layout.alignment: Qt.AlignBaseline + } + Item { Layout.fillWidth: true } + } + Text { + Layout.topMargin: -8 + text: box.hasTarget ? "→ target " + box.targetText : "at target" + color: box.hasTarget ? theme.accent : theme.muted + font.pixelSize: 12 + } + + // Limit track + Item { + Layout.fillWidth: true + Layout.preferredHeight: 30 + visible: box.hasLimits + Rectangle { + id: track + y: 4 + width: parent.width + height: 6 + radius: 3 + color: Qt.alpha(theme.border, 0.6) + Rectangle { + visible: box.position >= 0 + width: track.width * Math.max(box.position, 0) + height: parent.height + radius: 3 + color: Qt.alpha(box.outOfLimits ? theme.danger : theme.accent, 0.55) + } + } + Rectangle { + visible: box.targetPosition >= 0 + width: 12; height: 12; radius: 6 + x: track.width * box.targetPosition - width / 2 + y: track.y + track.height / 2 - height / 2 + color: "transparent" + border.width: 2 + border.color: theme.accent + } + Rectangle { + visible: box.position >= 0 + width: 12; height: 12; radius: 6 + x: track.width * box.position - width / 2 + y: track.y + track.height / 2 - height / 2 + color: box.outOfLimits ? theme.danger : theme.accent + Behavior on x { NumberAnimation { duration: 120 } } + } + Text { + anchors.left: parent.left; anchors.bottom: parent.bottom + text: box.lowText; color: theme.muted; font.pixelSize: 11 + } + Text { + anchors.right: parent.right; anchors.bottom: parent.bottom + text: box.highText; color: theme.muted; font.pixelSize: 11 + } + } + Text { + visible: !box.hasLimits && box.ready + text: "No limits set" + color: theme.muted + font.pixelSize: 11 + } + + // Move to + RowLayout { + Layout.fillWidth: true + spacing: 6 + Field { + id: targetField + objectName: "targetField" + Layout.fillWidth: true + placeholderText: "Move to…" + invalid: root.moveError !== "" + onAccepted: root.go() + Keys.onEscapePressed: { text = ""; focus = false } + } + FlatButton { + text: "Go" + tint: theme.accent + neutral: false + ink: theme.onAccent + solid: true + enabled: targetField.text.trim() !== "" && root.moveError === "" + Layout.preferredWidth: 52 + onClicked: root.go() + } + } + Text { + Layout.topMargin: -4 + visible: root.moveError !== "" + text: root.moveError + color: theme.danger + font.pixelSize: 11 + } + + // Tweak + RowLayout { + Layout.fillWidth: true + spacing: 6 + FlatButton { + text: "− " + box.stepText + Layout.fillWidth: true + onClicked: box.tweak(-1) + ToolTip.visible: hovered + ToolTip.text: "Move by −" + box.stepText + " " + box.units + ToolTip.delay: 600 + } + Field { + id: stepField + Layout.preferredWidth: 72 + horizontalAlignment: Text.AlignHCenter + text: box.stepText + validator: DoubleValidator { bottom: 0 } + onEditingFinished: box.setStepText(text) + WheelHandler { + onWheel: (event) => box.scaleStep(event.angleDelta.y > 0 ? 10 : 0.1) + } + Keys.onUpPressed: box.scaleStep(10) + Keys.onDownPressed: box.scaleStep(0.1) + ToolTip.visible: hovered + ToolTip.text: "Step size. Scroll or ↑/↓ to change by ×10" + ToolTip.delay: 600 + } + FlatButton { + text: "+ " + box.stepText + Layout.fillWidth: true + onClicked: box.tweak(1) + ToolTip.visible: hovered + ToolTip.text: "Move by +" + box.stepText + " " + box.units + ToolTip.delay: 600 + } + } + + Item { Layout.fillHeight: true } + + FlatButton { + Layout.fillWidth: true + Layout.preferredHeight: 34 + text: "Stop" + icon.name: "stop" + tint: theme.danger + neutral: false + ink: root.isMoving ? "white" : theme.danger + solid: root.isMoving + font.weight: Font.DemiBold + onClicked: box.stop() + } + } + + function go() { + if (box.moveTo(targetField.text)) { + targetField.text = "" + targetField.focus = false + } + } +} diff --git a/tests/unit_tests/test_positioner_box_modern.py b/tests/unit_tests/test_positioner_box_modern.py new file mode 100644 index 00000000..909ed4f0 --- /dev/null +++ b/tests/unit_tests/test_positioner_box_modern.py @@ -0,0 +1,197 @@ +from unittest import mock + +import pytest +from bec_lib.endpoints import MessageEndpoints +from bec_lib.messages import VariableMessage + +from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_modern import ( + PositionerBoxQML, + PositionerBoxWidgets, +) +from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_modern.positioner_box_modern_base import ( + MOVING_ACTIVE, + MOVING_IDLE, +) + +from .conftest import create_widget + + +@pytest.fixture(params=[PositionerBoxQML, PositionerBoxWidgets], ids=["qml", "qwidget"]) +def box(request, qtbot, mocked_client): + yield create_widget(qtbot, request.param, device="samx", client=mocked_client) + + +def _readback(box, value, setpoint, moving): + box.on_device_readback( + { + "signals": { + "samx": {"value": value}, + "samx_setpoint": {"value": setpoint}, + "samx_motor_is_moving": {"value": moving}, + } + }, + {}, + ) + + +def test_init_reads_cached_values(box): + data = box.dev["samx"].read(cached=True) + precision = box.dev["samx"].precision + assert box.device == "samx" + assert box.readback_text == f"{data['samx']['value']:.{precision}f}" + assert box.step_size == pytest.approx(10**-precision * 10) + assert box.has_limits + assert box.low_text == f"{-10:.{precision}f}" + + +def test_readback_updates_state_and_emits_position(box, qtbot): + with qtbot.waitSignal(box.position_update) as blocker: + _readback(box, 2.0, 5.5, True) + assert blocker.args == [2.0] + assert box._moving == MOVING_ACTIVE + assert box.has_target + assert box.position_fraction == pytest.approx(0.6) + assert box.target_fraction == pytest.approx(0.775) + + _readback(box, 5.5, 5.5, False) + assert box._moving == MOVING_IDLE + assert not box.has_target + assert box.target_fraction == -1 + + +def test_readback_outside_limits_is_flagged(box): + _readback(box, 12.0, 12.0, False) + assert box.out_of_limits + assert box.position_fraction == 1.0 + + +@pytest.mark.parametrize( + "text, error", + [("", ""), ("3", ""), ("abc", "Enter a number"), ("nan", "Enter a number"), ("100", "Outside")], +) +def test_validate_target(box, text, error): + result = box.validate_target(text) + assert result.startswith(error) if error else result == "" + + +def test_move_to_text_only_sends_valid_targets(box): + with mock.patch.object(box.scans, "mv") as mock_mv: + assert not box.move_to_text("100") + assert not box.move_to_text("abc") + assert not box.move_to_text(" ") + mock_mv.assert_not_called() + assert box.move_to_text("3.5") + mock_mv.assert_called_once_with(box.dev["samx"], 3.5, relative=False) + + +@pytest.mark.parametrize("direction", [1, -1]) +def test_tweak(box, direction): + box.set_step_text("0.5") + with mock.patch.object(box.scans, "mv") as mock_mv: + with mock.patch.object(box, "_get_setpoint", return_value=None): + box.tweak(direction) + mock_mv.assert_called_once_with(box.dev["samx"], 0.5 * direction, relative=True) + mock_mv.reset_mock() + with mock.patch.object(box, "_get_setpoint", return_value=2.0): + box.tweak(direction) + mock_mv.assert_called_once_with(box.dev["samx"], 2.0 + 0.5 * direction, relative=False) + + +def test_step_size_editing(box): + box.set_step_text("0.5") + assert box.step_size == 0.5 + box.set_step_text("-1") + box.set_step_text("abc") + assert box.step_size == 0.5 + box.scale_step(10) + assert box.step_text == "5" + box.scale_step(0.1) + box.scale_step(0.1) + assert box.step_text == "0.05" + + +def test_stop(box): + with mock.patch.object(box.client.connector, "send") as mock_send: + box.on_stop() + mock_send.assert_called_once_with( + MessageEndpoints.stop_devices(), VariableMessage(value=["samx"]) + ) + + +def test_set_positioner_switches_subscription(box, qtbot): + with ( + mock.patch.object(box.bec_dispatcher, "connect_slot") as connect, + mock.patch.object(box.bec_dispatcher, "disconnect_slot") as disconnect, + ): + with qtbot.waitSignal(box.device_changed) as blocker: + box.set_positioner("samy") + assert blocker.args == ["samx", "samy"] + disconnect.assert_called_once_with( + box.on_device_readback, MessageEndpoints.device_readback("samx") + ) + connect.assert_called_once_with( + box.on_device_readback, MessageEndpoints.device_readback("samy") + ) + assert box.high_text == f"{5:.3f}" + + +def test_set_positioner_rejects_non_positioner(box): + box.set_positioner("bpm4i") + assert box.device == "samx" + + +def test_no_limits(box): + box.set_positioner("aptrx") + assert not box.has_limits + assert box.position_fraction == -1 + assert box.validate_target("1e6") == "" + + +def test_device_picker_filters_and_picks(box): + assert "samx" in box.positioner_names() + assert "bpm4i" not in box.positioner_names() + box.open_device_picker(box.mapToGlobal(box.rect().topLeft())) + picker = box._picker + picker.search.setText("samy") + visible = [ + picker.list.item(i).text() + for i in range(picker.list.count()) + if not picker.list.item(i).isHidden() + ] + assert visible == ["samy"] + picker.search.returnPressed.emit() + assert box.device == "samy" + + +def test_hide_device_selection(box): + box.hide_device_selection = True + assert not box.device_selectable + box.open_device_picker(box.mapToGlobal(box.rect().topLeft())) + assert box._picker is None + box.show_device_selection(True) + assert box.device_selectable + + +def test_qwidget_view_shows_inline_error(qtbot, mocked_client): + box = create_widget(qtbot, PositionerBoxWidgets, device="samx", client=mocked_client) + box.setpoint.setText("100") + assert not box.go.isEnabled() + assert box.move_error.text().startswith("Outside limits") + box.setpoint.setText("4") + assert box.go.isEnabled() + assert box.move_error.text() == "" + with mock.patch.object(box.scans, "mv") as mock_mv: + box.go.click() + mock_mv.assert_called_once_with(box.dev["samx"], 4.0, relative=False) + assert box.setpoint.text() == "" + + +def test_qml_view_binds_to_state(qtbot, mocked_client): + box = create_widget(qtbot, PositionerBoxQML, device="samx", client=mocked_client) + root = box.view.rootObject() + assert root is not None + field = root.findChild(object, "targetField") + field.setProperty("text", "100") + assert root.property("moveError").startswith("Outside limits") + _readback(box, 2.0, 5.5, True) + assert root.property("isMoving")