feat(positioner_box): add QML and QWidget positioner boxes with modernized UX for comparison

This commit is contained in:
Claude committed 2026-10-09 20:30:00 +00:00
1 parent 703513b175
commit 1e65640c60
7 files changed
+1633

No files matched your search

@@ -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
@@ -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()
@@ -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()
@@ -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/<name>?<color>`` 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())
@@ -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())
@@ -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
}
}
}
@@ -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")