mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-29 00:11:28 +01:00
feat(positioner_box): add QML and QWidget positioner boxes with modernized UX for comparison
This commit is contained in:
7 files changed
+1633
No files matched your search
+4
@@ -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
|
||||
+36
@@ -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()
|
||||
+514
@@ -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()
|
||||
+127
@@ -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())
|
||||
+431
@@ -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())
|
||||
+324
@@ -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")
|
||||
Reference in new issue
Block a user