mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-11 15:41:01 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19c6648763 |
@@ -1,85 +1,6 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v3.13.3 (2026-05-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **tests**: Rename description attribute to _description in FakeDevice
|
||||
([`668b1bd`](https://github.com/bec-project/bec_widgets/commit/668b1bd9cd158fc12cff2c340d7317f30a212121))
|
||||
|
||||
|
||||
## v3.13.2 (2026-05-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **tests**: Rename description attribute to _description in FakePositioner
|
||||
([`c346bd0`](https://github.com/bec-project/bec_widgets/commit/c346bd0f18ce873ff5ca6c59150c9581c9edca8d))
|
||||
|
||||
|
||||
## v3.13.1 (2026-05-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use .show instead of .start
|
||||
([`b4beb27`](https://github.com/bec-project/bec_widgets/commit/b4beb274da745da618f9b37ec241cd0109c088f1))
|
||||
|
||||
- **gui**: Replace window.show() with window.raise_window() and add hide() method
|
||||
([`f7a48b5`](https://github.com/bec-project/bec_widgets/commit/f7a48b5f6a51d391dca26ca42d03bad4f278ff22))
|
||||
|
||||
|
||||
## v3.13.0 (2026-05-21)
|
||||
|
||||
### Features
|
||||
|
||||
- **rpc-base**: Set default RPC timeout and allow customization
|
||||
([`f03a5d9`](https://github.com/bec-project/bec_widgets/commit/f03a5d9e853bd62b8ec1bad1c1e112fe01befe70))
|
||||
|
||||
|
||||
## v3.12.2 (2026-05-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **toggle**: Disable styling implemented
|
||||
([`9eb0541`](https://github.com/bec-project/bec_widgets/commit/9eb05416ab68dcb88732dca8974c665030d34e0b))
|
||||
|
||||
|
||||
## v3.12.1 (2026-05-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **device_input**: Correct cleanup unsubscribe
|
||||
([`56427a7`](https://github.com/bec-project/bec_widgets/commit/56427a7f0c3a89fe847d415c8b45212e663434c4))
|
||||
|
||||
- **device_input**: Ensure callback is removed after cleanup
|
||||
([`d99db7d`](https://github.com/bec-project/bec_widgets/commit/d99db7d04208945b86a39d65022b211ba093caed))
|
||||
|
||||
- **signal_combobox**: Signature matched for update_signals_from_filters
|
||||
([`a976837`](https://github.com/bec-project/bec_widgets/commit/a976837cff612349f2a3f17900903c203bc3d250))
|
||||
|
||||
|
||||
## v3.12.0 (2026-05-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scan-control**: Filter out private scans from allowed scans
|
||||
([`2dc0227`](https://github.com/bec-project/bec_widgets/commit/2dc0227d38f0e217e252a5e5751bafd60363a5a4))
|
||||
|
||||
- **scan-control**: Hide hidden scan arguments
|
||||
([`2d8e1ee`](https://github.com/bec-project/bec_widgets/commit/2d8e1eed4d6503c42a38c8de910ddaa54132405d))
|
||||
|
||||
- **scan-control**: Reject unsupported scan input types
|
||||
([`3b579e7`](https://github.com/bec-project/bec_widgets/commit/3b579e740f36c60c3635681a9b2c35b518498f58))
|
||||
|
||||
- **scan-control**: Skip duplicate visible scan kwargs
|
||||
([`b8740c9`](https://github.com/bec-project/bec_widgets/commit/b8740c95941d36102f07a51d74a50e6f262a6646))
|
||||
|
||||
### Features
|
||||
|
||||
- Add support for new scan signatures including units
|
||||
([`d5bf10e`](https://github.com/bec-project/bec_widgets/commit/d5bf10e21682ae8270078c7858a036bafbabf10e))
|
||||
|
||||
|
||||
## v3.11.1 (2026-05-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -222,7 +222,6 @@ class BECGuiClient(RPCBase):
|
||||
self._ipython_registry: dict[str, RPCReference] = {}
|
||||
self.available_widgets = AvailableWidgetsNamespace()
|
||||
register_serializer_extension()
|
||||
self._rpc_timeout = 5
|
||||
|
||||
####################
|
||||
#### Client API ####
|
||||
@@ -233,16 +232,6 @@ class BECGuiClient(RPCBase):
|
||||
"""The launcher object."""
|
||||
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
|
||||
|
||||
def set_rpc_timeout(self, timeout: float):
|
||||
"""Set the timeout for RPC calls to the GUI server.
|
||||
|
||||
Args:
|
||||
timeout(float): The timeout in seconds.
|
||||
"""
|
||||
if not isinstance(timeout, (int, float)) or timeout < 0:
|
||||
raise ValueError("Timeout must be a non-negative number.")
|
||||
self._rpc_timeout = timeout
|
||||
|
||||
def _safe_register_stream(self, endpoint: EndpointInfo, cb: Callable, **kwargs):
|
||||
"""Check if already registered for registration in idempotent functions."""
|
||||
if not self._client.connector.any_stream_is_registered(endpoint, cb=cb):
|
||||
@@ -369,7 +358,7 @@ class BECGuiClient(RPCBase):
|
||||
)
|
||||
|
||||
if not self._check_if_server_is_alive():
|
||||
self.show(wait=True)
|
||||
self.start(wait=True)
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
return self._new_impl(
|
||||
@@ -561,7 +550,7 @@ class BECGuiClient(RPCBase):
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.raise_window()
|
||||
window.show()
|
||||
|
||||
def _show_all(self):
|
||||
with wait_for_server(self):
|
||||
@@ -580,7 +569,7 @@ class BECGuiClient(RPCBase):
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("raise") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.raise_window()
|
||||
window._run_rpc("raise") # type: ignore[attr-defined]
|
||||
|
||||
def _raise_all(self):
|
||||
with wait_for_server(self):
|
||||
|
||||
@@ -24,8 +24,6 @@ else:
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
_DEFAULT_RPC_TIMEOUT = object()
|
||||
|
||||
|
||||
def _name_arg(arg):
|
||||
if isinstance(arg, DeviceBaseWithConfig):
|
||||
@@ -156,7 +154,6 @@ class RPCReference:
|
||||
|
||||
|
||||
class RPCBase:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str | None = None,
|
||||
@@ -210,16 +207,12 @@ class RPCBase:
|
||||
# Use explicit call to ensure action name is 'raise' (not 'raise_')
|
||||
return self._run_rpc("raise")
|
||||
|
||||
def hide(self):
|
||||
"""Hide this widget (or its container)."""
|
||||
return self._run_rpc("hide")
|
||||
|
||||
def _run_rpc(
|
||||
self,
|
||||
method,
|
||||
*args,
|
||||
wait_for_rpc_response: bool = True,
|
||||
timeout: float | None | object = _DEFAULT_RPC_TIMEOUT,
|
||||
wait_for_rpc_response=True,
|
||||
timeout=5,
|
||||
gui_id: str | None = None,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
@@ -230,16 +223,13 @@ class RPCBase:
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
timeout: The timeout for the RPC response. If omitted, the client's default RPC
|
||||
timeout is used. If explicitly set to None, wait indefinitely.
|
||||
timeout: The timeout for the RPC response.
|
||||
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
if timeout is _DEFAULT_RPC_TIMEOUT:
|
||||
timeout = self._root._rpc_timeout
|
||||
if method in ["show", "hide", "raise"] and gui_id is None:
|
||||
obj = self._root._server_registry.get(self._gui_id)
|
||||
if obj is None:
|
||||
|
||||
@@ -15,7 +15,7 @@ class FakeDevice(BECDevice):
|
||||
super().__init__(name=name)
|
||||
self._enabled = enabled
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self._description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._readout_priority = readout_priority
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
@@ -74,7 +74,7 @@ class FakeDevice(BECDevice):
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self._description
|
||||
return self.description
|
||||
|
||||
|
||||
class FakePositioner(BECPositioner):
|
||||
@@ -96,7 +96,7 @@ class FakePositioner(BECPositioner):
|
||||
self._limits = limits
|
||||
self._readout_priority = readout_priority
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self._description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd_devices.SimPositioner",
|
||||
@@ -176,7 +176,7 @@ class FakePositioner(BECPositioner):
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self._description
|
||||
return self.description
|
||||
|
||||
@property
|
||||
def precision(self):
|
||||
|
||||
@@ -429,10 +429,10 @@ class Crosshair(QObject):
|
||||
if event is None:
|
||||
return # nothing to do
|
||||
scene_pos = event[0] # SignalProxy bundle
|
||||
if not self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
|
||||
return
|
||||
view_pos = self.plot_item.vb.mapSceneToView(scene_pos)
|
||||
x, y = view_pos.x(), view_pos.y()
|
||||
if not self._is_within_view_range(x, y):
|
||||
return
|
||||
|
||||
# Update cross‑hair visuals
|
||||
self.v_line.setPos(x)
|
||||
@@ -493,12 +493,8 @@ class Crosshair(QObject):
|
||||
if event.button() != Qt.MouseButton.LeftButton:
|
||||
return
|
||||
self.update_markers()
|
||||
scene_pos_getter = getattr(event, "scenePos", None)
|
||||
if not callable(scene_pos_getter):
|
||||
return
|
||||
scene_pos = scene_pos_getter()
|
||||
mouse_point = self.plot_item.vb.mapSceneToView(scene_pos)
|
||||
if self._is_within_view_range(mouse_point.x(), mouse_point.y()):
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos):
|
||||
mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos)
|
||||
x, y = mouse_point.x(), mouse_point.y()
|
||||
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
|
||||
self.crosshairClicked.emit((scaled_x, scaled_y))
|
||||
@@ -549,10 +545,6 @@ class Crosshair(QObject):
|
||||
else:
|
||||
continue
|
||||
|
||||
def _is_within_view_range(self, x: float, y: float) -> bool:
|
||||
x_range, y_range = self.plot_item.vb.viewRange()
|
||||
return min(x_range) <= x <= max(x_range) and min(y_range) <= y <= max(y_range)
|
||||
|
||||
def _get_transformed_position(
|
||||
self, x: float, y: float, transform: QTransform
|
||||
) -> tuple[QPointF, QPointF]:
|
||||
|
||||
@@ -9,7 +9,7 @@ from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
|
||||
from bec_lib.device import Signal as BECSignal
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field, field_validator
|
||||
from qtpy.QtCore import QSize, QStringListModel, Signal, Slot
|
||||
from qtpy.QtCore import QSize, QStringListModel, Qt, Signal, Slot
|
||||
from qtpy.QtWidgets import QComboBox, QCompleter, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
@@ -219,7 +219,9 @@ class DeviceComboBox(BECWidget, QComboBox):
|
||||
self._callback_id = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.on_device_update
|
||||
)
|
||||
self.device_config_update.connect(self.update_devices_from_filters)
|
||||
self.device_config_update.connect(
|
||||
self.update_devices_from_filters, Qt.ConnectionType.QueuedConnection
|
||||
)
|
||||
self.currentTextChanged.connect(self.check_validity)
|
||||
self.check_validity(self.currentText())
|
||||
|
||||
@@ -255,6 +257,9 @@ class DeviceComboBox(BECWidget, QComboBox):
|
||||
@SafeSlot()
|
||||
def update_devices_from_filters(self):
|
||||
"""Refresh the available device list from current device/readout/signal filters."""
|
||||
if getattr(self, "_destroyed", False):
|
||||
return
|
||||
|
||||
self.config.device_filter = [entry.value for entry in self.device_filter]
|
||||
self.config.readout_filter = [entry.value for entry in self.readout_filter]
|
||||
self.config.signal_class_filter = self.signal_class_filter
|
||||
@@ -489,7 +494,7 @@ class DeviceComboBox(BECWidget, QComboBox):
|
||||
action: Device update action emitted by BEC.
|
||||
content: Device update payload. Currently unused.
|
||||
"""
|
||||
if self._callback_id is None or getattr(self, "_destroyed", False):
|
||||
if getattr(self, "_destroyed", False):
|
||||
return
|
||||
if action in ["add", "remove", "reload"]:
|
||||
self.device_config_update.emit()
|
||||
@@ -497,9 +502,8 @@ class DeviceComboBox(BECWidget, QComboBox):
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
if self._callback_id is not None:
|
||||
callback_id = self._callback_id
|
||||
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
|
||||
self._callback_id = None
|
||||
self.bec_dispatcher.client.callbacks.remove(callback_id)
|
||||
super().cleanup()
|
||||
|
||||
def get_current_device(self) -> object:
|
||||
|
||||
@@ -77,6 +77,7 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
|
||||
device_signal_changed = Signal(str)
|
||||
signal_reset = Signal()
|
||||
device_config_update = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -138,7 +139,10 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
self.autocomplete = True
|
||||
|
||||
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.update_signals_from_filters
|
||||
EventType.DEVICE_UPDATE, self.on_device_update
|
||||
)
|
||||
self.device_config_update.connect(
|
||||
self.update_signals_from_filters, Qt.ConnectionType.QueuedConnection
|
||||
)
|
||||
self.currentTextChanged.connect(self.on_text_changed)
|
||||
|
||||
@@ -197,19 +201,17 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str, dict)
|
||||
def update_signals_from_filters(self, action: str | None = None, content: dict | None = None):
|
||||
@SafeSlot(dict, dict)
|
||||
def update_signals_from_filters(
|
||||
self, content: dict | None = None, metadata: dict | None = None
|
||||
):
|
||||
"""Refresh available signals from the current device and filters.
|
||||
|
||||
Args:
|
||||
action: Optional BEC device update action. If provided, only device list changing
|
||||
actions trigger a refresh.
|
||||
content: Optional callback payload from BEC device updates. Currently unused.
|
||||
metadata: Optional callback metadata from BEC device updates. Currently unused.
|
||||
"""
|
||||
if self._device_update_register is None or getattr(self, "_destroyed", False):
|
||||
return
|
||||
|
||||
if action is not None and action not in ["add", "remove", "reload"]:
|
||||
if getattr(self, "_destroyed", False):
|
||||
return
|
||||
|
||||
self.config.signal_filter = [kind.name for kind in self.signal_filter]
|
||||
@@ -252,6 +254,13 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
),
|
||||
)
|
||||
|
||||
def on_device_update(self, action: str, content: dict) -> None:
|
||||
"""Refresh filters when BEC reports device configuration changes."""
|
||||
if getattr(self, "_destroyed", False):
|
||||
return
|
||||
if action in ["add", "remove", "reload"]:
|
||||
self.device_config_update.emit()
|
||||
|
||||
@Property(str)
|
||||
def device(self) -> str:
|
||||
"""Selected device."""
|
||||
@@ -594,9 +603,8 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
if self._device_update_register is not None:
|
||||
callback_id = self._device_update_register
|
||||
self.bec_dispatcher.client.callbacks.remove(self._device_update_register)
|
||||
self._device_update_register = None
|
||||
self.bec_dispatcher.client.callbacks.remove(callback_id)
|
||||
super().cleanup()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -14,6 +14,7 @@ from qtpy.QtWidgets import (
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -24,7 +25,6 @@ from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
|
||||
from bec_widgets.widgets.control.scan_control.scan_info_adapter import ScanInfoAdapter
|
||||
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ class ScanControl(BECWidget, QWidget):
|
||||
self._hide_scan_control_buttons = False
|
||||
self._hide_metadata = False
|
||||
self._hide_scan_selection_combobox = False
|
||||
self._scan_info_adapter = ScanInfoAdapter()
|
||||
|
||||
# Create and set main layout
|
||||
self._init_UI()
|
||||
@@ -185,17 +184,12 @@ class ScanControl(BECWidget, QWidget):
|
||||
MessageEndpoints.available_scans()
|
||||
).resource
|
||||
if self.config.allowed_scans is None:
|
||||
supported_scans = ["ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase", "ScanBaseV4"]
|
||||
|
||||
def _is_scan_supported(scan_name):
|
||||
scan_info = self.available_scans[scan_name]
|
||||
return (
|
||||
scan_info.get("base_class") in supported_scans
|
||||
and self._scan_info_adapter.has_scan_ui_config(scan_info)
|
||||
and not scan_name.startswith("_")
|
||||
)
|
||||
|
||||
allowed_scans = filter(_is_scan_supported, self.available_scans.keys())
|
||||
supported_scans = ["ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase"]
|
||||
allowed_scans = [
|
||||
scan_name
|
||||
for scan_name, scan_info in self.available_scans.items()
|
||||
if scan_info["base_class"] in supported_scans and len(scan_info["gui_config"]) > 0
|
||||
]
|
||||
|
||||
else:
|
||||
allowed_scans = self.config.allowed_scans
|
||||
@@ -382,14 +376,14 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.reset_layout()
|
||||
selected_scan_info = self.available_scans.get(scan_name, {})
|
||||
|
||||
gui_config = self._scan_info_adapter.build_scan_ui_config(selected_scan_info)
|
||||
arg_group = gui_config.get("arg_group", None)
|
||||
kwarg_groups = gui_config.get("kwarg_groups", [])
|
||||
gui_config = selected_scan_info.get("gui_config", {})
|
||||
self.arg_group = gui_config.get("arg_group", None)
|
||||
self.kwarg_groups = gui_config.get("kwarg_groups", None)
|
||||
|
||||
if arg_group and bool(arg_group.get("arg_inputs")):
|
||||
self.add_arg_group(arg_group)
|
||||
if kwarg_groups:
|
||||
self.add_kwargs_boxes(kwarg_groups)
|
||||
if bool(self.arg_group["arg_inputs"]):
|
||||
self.add_arg_group(self.arg_group)
|
||||
if len(self.kwarg_groups) > 0:
|
||||
self.add_kwargs_boxes(self.kwarg_groups)
|
||||
|
||||
self.update()
|
||||
self.adjustSize()
|
||||
@@ -420,7 +414,6 @@ class ScanControl(BECWidget, QWidget):
|
||||
position = self.ARG_BOX_POSITION + (1 if self.arg_box is not None else 0)
|
||||
for group in groups:
|
||||
box = ScanGroupBox(box_type="kwargs", config=group)
|
||||
box.reference_units_changed.connect(self._apply_reference_units_to_other_boxes)
|
||||
box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
self.layout.insertWidget(position + len(self.kwarg_boxes), box)
|
||||
self.kwarg_boxes.append(box)
|
||||
@@ -434,30 +427,11 @@ class ScanControl(BECWidget, QWidget):
|
||||
"""
|
||||
self.arg_box = ScanGroupBox(box_type="args", config=group)
|
||||
self.arg_box.device_selected.connect(self.emit_device_selected)
|
||||
self.arg_box.reference_units_changed.connect(self._apply_reference_units_to_other_boxes)
|
||||
self.arg_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons
|
||||
self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box)
|
||||
self.arg_box.setVisible(not self._hide_arg_box)
|
||||
|
||||
def _scan_group_boxes(self) -> list[ScanGroupBox]:
|
||||
boxes = []
|
||||
if self.arg_box is not None:
|
||||
boxes.append(self.arg_box)
|
||||
boxes.extend(self.kwarg_boxes)
|
||||
return boxes
|
||||
|
||||
def _apply_reference_units_to_other_boxes(
|
||||
self, source_box: ScanGroupBox, reference_name: str, units: str | None
|
||||
) -> None:
|
||||
"""
|
||||
Propagate device-derived units to scan fields that reference a device in another group.
|
||||
"""
|
||||
for box in self._scan_group_boxes():
|
||||
if box is source_box:
|
||||
continue
|
||||
box.apply_reference_units(reference_name, units)
|
||||
|
||||
@SafeSlot(str)
|
||||
def emit_device_selected(self, dev_names):
|
||||
"""
|
||||
|
||||
@@ -174,7 +174,6 @@ class ScanGroupBox(QGroupBox):
|
||||
}
|
||||
|
||||
device_selected = Signal(str)
|
||||
reference_units_changed = Signal(object, str, object)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -210,8 +209,6 @@ class ScanGroupBox(QGroupBox):
|
||||
|
||||
self.labels = []
|
||||
self.widgets = []
|
||||
self._widget_configs = {}
|
||||
self._column_labels = {}
|
||||
self.selected_devices = {}
|
||||
|
||||
self.init_box(self.config)
|
||||
@@ -250,7 +247,6 @@ class ScanGroupBox(QGroupBox):
|
||||
label = QLabel(text=display_name)
|
||||
self.layout.addWidget(label, row, column_index)
|
||||
self.labels.append(label)
|
||||
self._column_labels[column_index] = label
|
||||
|
||||
def add_input_widgets(self, group_inputs: dict, row) -> None:
|
||||
"""
|
||||
@@ -285,31 +281,20 @@ class ScanGroupBox(QGroupBox):
|
||||
)
|
||||
else:
|
||||
widget = widget_class(parent=self.parent(), arg_name=arg_name, default=default)
|
||||
self._apply_numeric_precision(widget, item)
|
||||
self._apply_numeric_limits(widget, item)
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
self.selected_devices[widget] = ""
|
||||
widget.device_selected.connect(self.emit_device_selected)
|
||||
widget.currentTextChanged.connect(
|
||||
lambda text, device_widget=widget: self._handle_device_text_changed(
|
||||
device_widget, text
|
||||
)
|
||||
)
|
||||
if isinstance(widget, ScanLiteralsComboBox):
|
||||
widget.set_literals(item["type"].get("Literal", []))
|
||||
self._widget_configs[widget] = item
|
||||
self._apply_unit_metadata(widget, item)
|
||||
tooltip = item.get("tooltip", None)
|
||||
if tooltip is not None:
|
||||
widget.setToolTip(item["tooltip"])
|
||||
self.layout.addWidget(widget, row, column_index)
|
||||
self.widgets.append(widget)
|
||||
|
||||
@Slot(str)
|
||||
def emit_device_selected(self, device_name):
|
||||
sender = self.sender()
|
||||
self.selected_devices[sender] = device_name.strip()
|
||||
if isinstance(sender, DeviceComboBox):
|
||||
units = self._device_units(sender.get_current_device())
|
||||
self._update_reference_units(sender, units)
|
||||
self._emit_reference_units_changed(sender, units)
|
||||
self.selected_devices[self.sender()] = device_name.strip()
|
||||
selected_devices_str = " ".join(self.selected_devices.values())
|
||||
self.device_selected.emit(selected_devices_str)
|
||||
|
||||
@@ -336,7 +321,6 @@ class ScanGroupBox(QGroupBox):
|
||||
for widget in self.widgets[-len(self.inputs) :]:
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
self.selected_devices[widget] = ""
|
||||
self._widget_configs.pop(widget, None)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
self.widgets = self.widgets[: -len(self.inputs)]
|
||||
@@ -349,7 +333,6 @@ class ScanGroupBox(QGroupBox):
|
||||
for widget in list(self.widgets):
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
self.selected_devices.pop(widget, None)
|
||||
self._widget_configs.pop(widget, None)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
self.layout.removeWidget(widget)
|
||||
@@ -452,159 +435,3 @@ class ScanGroupBox(QGroupBox):
|
||||
if widget.arg_name == key:
|
||||
WidgetIO.set_value(widget, value)
|
||||
break
|
||||
|
||||
@staticmethod
|
||||
def _unit_tooltip(item: dict, units: str | None = None) -> str | None:
|
||||
tooltip = item.get("tooltip", None)
|
||||
reference_units = item.get("reference_units", None)
|
||||
units = units or item.get("units", None)
|
||||
tooltip_parts = [tooltip] if tooltip else []
|
||||
if units:
|
||||
tooltip_parts.append(f"Units: {units}")
|
||||
elif reference_units:
|
||||
tooltip_parts.append(f"Units from: {reference_units}")
|
||||
if tooltip_parts:
|
||||
return "\n".join(tooltip_parts)
|
||||
return None
|
||||
|
||||
def _apply_unit_metadata(self, widget, item: dict, units: str | None = None) -> None:
|
||||
units = units or item.get("units", None)
|
||||
tooltip = self._unit_tooltip(item, units)
|
||||
existing_tooltip = widget.toolTip()
|
||||
|
||||
if existing_tooltip:
|
||||
# strip the existing unit info from the tooltip if it exists
|
||||
# to avoid tooltip bloat on multiple updates
|
||||
existing_tooltip = "\n".join(
|
||||
line
|
||||
for line in existing_tooltip.splitlines()
|
||||
if not (line.startswith("Units:") or line.startswith("Units from:"))
|
||||
).strip()
|
||||
if tooltip:
|
||||
if existing_tooltip:
|
||||
widget.setToolTip(f"{existing_tooltip}\n{tooltip}")
|
||||
else:
|
||||
widget.setToolTip(tooltip)
|
||||
if hasattr(widget, "setSuffix"):
|
||||
widget.setSuffix(f" {units}" if units else "")
|
||||
|
||||
def _refresh_column_label(self, column: int, item: dict) -> None:
|
||||
if column not in self._column_labels:
|
||||
return
|
||||
self._column_labels[column].setText(item.get("display_name", item.get("name", None)))
|
||||
|
||||
@staticmethod
|
||||
def _device_units(device) -> str | None:
|
||||
egu = getattr(device, "egu", None)
|
||||
if not callable(egu):
|
||||
return None
|
||||
try:
|
||||
return egu()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch engineering units from device %s", device)
|
||||
return None
|
||||
|
||||
def _widget_position(self, widget) -> tuple[int, int] | None:
|
||||
for row in range(self.layout.rowCount()):
|
||||
for column in range(self.layout.columnCount()):
|
||||
item = self.layout.itemAtPosition(row, column)
|
||||
if item is not None and item.widget() is widget:
|
||||
return row, column
|
||||
return None
|
||||
|
||||
def _update_reference_units(self, device_widget: DeviceComboBox, units: str | None) -> None:
|
||||
position = self._widget_position(device_widget)
|
||||
if position is None:
|
||||
return
|
||||
source_row, _ = position
|
||||
source_name = device_widget.arg_name
|
||||
|
||||
for widget in self.widgets:
|
||||
item = self._widget_configs.get(widget, {})
|
||||
if item.get("reference_units") != source_name:
|
||||
continue
|
||||
widget_position = self._widget_position(widget)
|
||||
if widget_position is None:
|
||||
continue
|
||||
row, column = widget_position
|
||||
if self.box_type == "args" and row != source_row:
|
||||
continue
|
||||
self._apply_unit_metadata(widget, item, units)
|
||||
self._refresh_column_label(column, item)
|
||||
|
||||
def apply_reference_units(self, reference_name: str, units: str | None) -> None:
|
||||
"""
|
||||
Apply units to widgets that reference an argument owned by another group box.
|
||||
|
||||
Cross-box references only have one widget row, so row scoping is intentionally handled by
|
||||
the source group before this method is called.
|
||||
"""
|
||||
for widget in self.widgets:
|
||||
item = self._widget_configs.get(widget, {})
|
||||
if item.get("reference_units") != reference_name:
|
||||
continue
|
||||
self._apply_unit_metadata(widget, item, units)
|
||||
position = self._widget_position(widget)
|
||||
if position is not None:
|
||||
_, column = position
|
||||
self._refresh_column_label(column, item)
|
||||
|
||||
def _emit_reference_units_changed(
|
||||
self, device_widget: DeviceComboBox, units: str | None
|
||||
) -> None:
|
||||
reference_name = getattr(device_widget, "arg_name", None)
|
||||
if not reference_name:
|
||||
return
|
||||
self.reference_units_changed.emit(self, reference_name, units)
|
||||
|
||||
def _handle_device_text_changed(self, device_widget: DeviceComboBox, device_name: str) -> None:
|
||||
if not device_widget.validate_device(device_name):
|
||||
self.selected_devices[device_widget] = ""
|
||||
self._update_reference_units(device_widget, None)
|
||||
self._emit_reference_units_changed(device_widget, None)
|
||||
|
||||
@staticmethod
|
||||
def _apply_numeric_precision(widget: ScanDoubleSpinBox, item: dict) -> None:
|
||||
if not isinstance(widget, ScanDoubleSpinBox):
|
||||
return
|
||||
|
||||
precision = item.get("precision")
|
||||
if precision is None:
|
||||
return
|
||||
|
||||
try:
|
||||
widget.setDecimals(max(0, int(precision)))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Ignoring invalid precision %r for parameter %s", precision, item.get("name")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _apply_numeric_limits(widget: ScanDoubleSpinBox | ScanSpinBox, item: dict) -> None:
|
||||
if isinstance(widget, ScanSpinBox):
|
||||
minimum = -2147483647 # largest int which qt allows
|
||||
maximum = 2147483647
|
||||
if item.get("ge") is not None:
|
||||
minimum = int(item["ge"])
|
||||
if item.get("gt") is not None:
|
||||
minimum = int(item["gt"]) + 1
|
||||
if item.get("le") is not None:
|
||||
maximum = int(item["le"])
|
||||
if item.get("lt") is not None:
|
||||
maximum = int(item["lt"]) - 1
|
||||
widget.setRange(minimum, maximum)
|
||||
return
|
||||
|
||||
if isinstance(widget, ScanDoubleSpinBox):
|
||||
minimum = -float("inf")
|
||||
maximum = float("inf")
|
||||
step = 10 ** (-widget.decimals())
|
||||
if item.get("ge") is not None:
|
||||
minimum = float(item["ge"])
|
||||
if item.get("gt") is not None:
|
||||
minimum = float(item["gt"]) + step
|
||||
if item.get("le") is not None:
|
||||
maximum = float(item["le"])
|
||||
if item.get("lt") is not None:
|
||||
maximum = float(item["lt"]) - step
|
||||
widget.setRange(minimum, maximum)
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
"""Helpers for translating BEC scan metadata into ScanControl UI configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
AnnotationValue = str | dict[str, Any] | list[Any] | None
|
||||
ScanArgumentMetadata = dict[str, Any]
|
||||
SignatureEntry = dict[str, Any]
|
||||
ScanInputConfig = dict[str, Any]
|
||||
ScanInfo = dict[str, Any]
|
||||
ScanUIConfig = dict[str, Any]
|
||||
|
||||
SUPPORTED_SCAN_INPUT_TYPES = {"device", "DeviceBase", "float", "int", "bool", "str"}
|
||||
|
||||
|
||||
class ScanInfoAdapter:
|
||||
"""Normalize available-scan payloads into the structure consumed by ``ScanControl``."""
|
||||
|
||||
@staticmethod
|
||||
def has_scan_ui_config(scan_info: ScanInfo) -> bool:
|
||||
"""Check whether a scan exposes enough metadata to build a UI.
|
||||
|
||||
Args:
|
||||
scan_info (ScanInfo): Available-scan payload for one scan.
|
||||
|
||||
Returns:
|
||||
bool: ``True`` when a supported GUI metadata field is present.
|
||||
"""
|
||||
if not (
|
||||
scan_info.get("gui_visibility")
|
||||
or scan_info.get("gui_config")
|
||||
or scan_info.get("gui_visualization")
|
||||
or scan_info.get("signature")
|
||||
):
|
||||
return False
|
||||
|
||||
gui_config = ScanInfoAdapter().build_scan_ui_config(scan_info)
|
||||
return not ScanInfoAdapter.unsupported_inputs(gui_config)
|
||||
|
||||
@staticmethod
|
||||
def is_supported_input_type(input_type: AnnotationValue) -> bool:
|
||||
"""Return whether ``ScanGroupBox`` has a widget for this serialized type."""
|
||||
return (
|
||||
isinstance(input_type, str)
|
||||
and input_type in SUPPORTED_SCAN_INPUT_TYPES
|
||||
or isinstance(input_type, dict)
|
||||
and "Literal" in input_type
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def unsupported_inputs(gui_config: ScanUIConfig) -> list[ScanInputConfig]:
|
||||
"""Return input configs that cannot be rendered by ``ScanGroupBox``."""
|
||||
inputs = []
|
||||
arg_group = gui_config.get("arg_group")
|
||||
if arg_group:
|
||||
inputs.extend(arg_group.get("inputs", []))
|
||||
for group in gui_config.get("kwarg_groups", []):
|
||||
inputs.extend(group.get("inputs", []))
|
||||
return [
|
||||
input_config
|
||||
for input_config in inputs
|
||||
if not ScanInfoAdapter.is_supported_input_type(input_config.get("type"))
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def format_display_name(name: str) -> str:
|
||||
"""Convert a parameter name into a user-facing label.
|
||||
|
||||
Args:
|
||||
name (str): Raw parameter name.
|
||||
|
||||
Returns:
|
||||
str: Formatted display label such as ``Exp Time``.
|
||||
"""
|
||||
parts = re.split(r"(_|\d+)", name)
|
||||
return " ".join(part.capitalize() for part in parts if part.isalnum()).strip()
|
||||
|
||||
@staticmethod
|
||||
def resolve_tooltip(scan_argument: ScanArgumentMetadata) -> str | None:
|
||||
"""Resolve the tooltip text from parsed ``ScanArgument`` metadata.
|
||||
|
||||
Args:
|
||||
scan_argument (ScanArgumentMetadata): Parsed ``ScanArgument`` metadata.
|
||||
|
||||
Returns:
|
||||
str | None: Explicit tooltip text if provided, otherwise the description fallback.
|
||||
"""
|
||||
return scan_argument.get("tooltip") or scan_argument.get("description")
|
||||
|
||||
@staticmethod
|
||||
def parse_annotation(
|
||||
annotation: AnnotationValue,
|
||||
) -> tuple[AnnotationValue, ScanArgumentMetadata]:
|
||||
"""Extract the serialized base annotation and ``ScanArgument`` metadata.
|
||||
|
||||
Args:
|
||||
annotation (AnnotationValue): Serialized annotation payload from BEC.
|
||||
|
||||
Returns:
|
||||
tuple[AnnotationValue, ScanArgumentMetadata]: The unwrapped annotation and parsed
|
||||
``ScanArgument`` metadata.
|
||||
"""
|
||||
scan_argument: ScanArgumentMetadata = {}
|
||||
if isinstance(annotation, list):
|
||||
annotation = next(
|
||||
(entry for entry in annotation if entry != "NoneType"),
|
||||
annotation[0] if annotation else "_empty",
|
||||
)
|
||||
if isinstance(annotation, dict) and "Annotated" in annotation:
|
||||
annotated = annotation["Annotated"]
|
||||
annotation = annotated.get("type", "_empty")
|
||||
scan_argument = annotated.get("metadata", {}).get("ScanArgument", {}) or {}
|
||||
return annotation, scan_argument
|
||||
|
||||
@staticmethod
|
||||
def scan_arg_type_from_annotation(annotation: AnnotationValue) -> AnnotationValue:
|
||||
"""Normalize an annotation value to the widget type expected by ``ScanControl``.
|
||||
|
||||
Args:
|
||||
annotation (AnnotationValue): Serialized or parsed annotation value.
|
||||
|
||||
Returns:
|
||||
AnnotationValue: The normalized type identifier used by the widget layer.
|
||||
"""
|
||||
if isinstance(annotation, dict):
|
||||
return annotation
|
||||
if annotation in ("_empty", None):
|
||||
return "str"
|
||||
return annotation
|
||||
|
||||
def scan_input_from_signature(
|
||||
self, param: SignatureEntry, arg: bool = False
|
||||
) -> ScanInputConfig:
|
||||
"""Build one ScanControl input description from a signature entry.
|
||||
|
||||
Args:
|
||||
param (SignatureEntry): Serialized signature entry.
|
||||
arg (bool): Whether the parameter belongs to the positional arg bundle.
|
||||
|
||||
Returns:
|
||||
ScanInputConfig: Normalized input configuration for ``ScanControl``.
|
||||
"""
|
||||
annotation, scan_argument = self.parse_annotation(param.get("annotation"))
|
||||
return self._build_scan_input(
|
||||
name=param["name"],
|
||||
annotation=annotation,
|
||||
scan_argument=scan_argument,
|
||||
arg=arg,
|
||||
default=None if arg else param.get("default", None),
|
||||
)
|
||||
|
||||
def scan_input_from_arg_input(
|
||||
self, name: str, item_type: AnnotationValue, signature_by_name: dict[str, SignatureEntry]
|
||||
) -> ScanInputConfig:
|
||||
"""Build one arg-bundle input description from ``arg_input`` metadata.
|
||||
|
||||
Args:
|
||||
name (str): Argument name from ``arg_input``.
|
||||
item_type (AnnotationValue): Serialized argument type from ``arg_input``.
|
||||
signature_by_name (dict[str, SignatureEntry]): Signature entries indexed by
|
||||
parameter name.
|
||||
|
||||
Returns:
|
||||
ScanInputConfig: Normalized input configuration for one arg-bundle field.
|
||||
"""
|
||||
if name in signature_by_name:
|
||||
scan_input = self.scan_input_from_signature(signature_by_name[name], arg=True)
|
||||
scan_input["type"] = self.scan_arg_type_from_annotation(
|
||||
self.parse_annotation(signature_by_name[name].get("annotation"))[0]
|
||||
)
|
||||
else:
|
||||
annotation, scan_argument = self.parse_annotation(item_type)
|
||||
scan_input = self._build_scan_input(
|
||||
name=name,
|
||||
annotation=annotation,
|
||||
scan_argument=scan_argument,
|
||||
arg=True,
|
||||
default=None,
|
||||
)
|
||||
if scan_input["type"] in ("_empty", None):
|
||||
scan_input["type"] = item_type
|
||||
return scan_input
|
||||
|
||||
def _build_scan_input(
|
||||
self,
|
||||
name: str,
|
||||
annotation: AnnotationValue,
|
||||
scan_argument: ScanArgumentMetadata,
|
||||
*,
|
||||
arg: bool,
|
||||
default: Any,
|
||||
) -> ScanInputConfig:
|
||||
"""Build one normalized ScanControl input configuration.
|
||||
|
||||
Args:
|
||||
name (str): Parameter name.
|
||||
annotation (AnnotationValue): Parsed annotation value.
|
||||
scan_argument (ScanArgumentMetadata): Parsed ``ScanArgument`` metadata.
|
||||
arg (bool): Whether the parameter belongs to the positional arg bundle.
|
||||
default (Any): Default value for the parameter.
|
||||
|
||||
Returns:
|
||||
ScanInputConfig: Normalized input configuration.
|
||||
"""
|
||||
return {
|
||||
"arg": arg,
|
||||
"name": name,
|
||||
"type": self.scan_arg_type_from_annotation(annotation),
|
||||
"display_name": scan_argument.get("display_name") or self.format_display_name(name),
|
||||
"tooltip": self.resolve_tooltip(scan_argument),
|
||||
"default": default,
|
||||
"expert": scan_argument.get("expert", False),
|
||||
"hidden": scan_argument.get("hidden", False),
|
||||
"precision": scan_argument.get("precision"),
|
||||
"units": scan_argument.get("units"),
|
||||
"reference_units": scan_argument.get("reference_units"),
|
||||
"gt": scan_argument.get("gt"),
|
||||
"ge": scan_argument.get("ge"),
|
||||
"lt": scan_argument.get("lt"),
|
||||
"le": scan_argument.get("le"),
|
||||
"alternative_group": scan_argument.get("alternative_group"),
|
||||
}
|
||||
|
||||
def build_scan_ui_config(self, scan_info: ScanInfo) -> ScanUIConfig:
|
||||
"""Normalize one available-scan entry into the widget UI configuration.
|
||||
|
||||
Args:
|
||||
scan_info (ScanInfo): Available-scan payload for one scan.
|
||||
|
||||
Returns:
|
||||
ScanUIConfig: Legacy group structure consumed by ``ScanControl`` and
|
||||
``ScanGroupBox``.
|
||||
"""
|
||||
gui_visualization = (
|
||||
scan_info.get("gui_visualization") or scan_info.get("gui_visibility") or {}
|
||||
)
|
||||
if not gui_visualization and scan_info.get("gui_config"):
|
||||
return scan_info["gui_config"]
|
||||
|
||||
signature = scan_info.get("signature", [])
|
||||
signature_by_name = {entry["name"]: entry for entry in signature}
|
||||
|
||||
arg_group = None
|
||||
arg_input = scan_info.get("arg_input", {})
|
||||
if isinstance(arg_input, dict) and arg_input:
|
||||
bundle_size = scan_info.get("arg_bundle_size", {})
|
||||
inputs = [
|
||||
self.scan_input_from_arg_input(name, item_type, signature_by_name)
|
||||
for name, item_type in arg_input.items()
|
||||
]
|
||||
arg_group = {
|
||||
"name": "Scan Arguments",
|
||||
"bundle": bundle_size.get("bundle"),
|
||||
"arg_inputs": arg_input,
|
||||
"inputs": inputs,
|
||||
"min": bundle_size.get("min"),
|
||||
"max": bundle_size.get("max"),
|
||||
}
|
||||
|
||||
kwarg_groups = []
|
||||
arg_names = set(arg_input) if isinstance(arg_input, dict) else set()
|
||||
visible_kwarg_names = set()
|
||||
for group_name, input_names in gui_visualization.items():
|
||||
inputs = []
|
||||
for input_name in input_names:
|
||||
if input_name in arg_names or input_name not in signature_by_name:
|
||||
continue
|
||||
if input_name in visible_kwarg_names:
|
||||
continue
|
||||
param = signature_by_name[input_name]
|
||||
if param.get("kind") in ("VAR_POSITIONAL", "VAR_KEYWORD"):
|
||||
continue
|
||||
scan_input = self.scan_input_from_signature(param)
|
||||
if scan_input.get("hidden"):
|
||||
continue
|
||||
inputs.append(scan_input)
|
||||
visible_kwarg_names.add(input_name)
|
||||
if inputs:
|
||||
kwarg_groups.append({"name": group_name, "inputs": inputs})
|
||||
|
||||
return {
|
||||
"scan_class_name": scan_info.get("class"),
|
||||
"arg_group": arg_group,
|
||||
"kwarg_groups": kwarg_groups,
|
||||
}
|
||||
@@ -907,7 +907,14 @@ class Image(ImageBase):
|
||||
async_signal_name=config.async_signal_name,
|
||||
)
|
||||
|
||||
self.subscriptions["main"].async_signal_name = None
|
||||
config.async_signal_name = None
|
||||
if target_device == self._config.device and target_entry == self._config.signal:
|
||||
config.device = ""
|
||||
config.signal = ""
|
||||
config.source = None
|
||||
config.monitor_type = None
|
||||
self._signal_configs.pop("main", None)
|
||||
self._set_connection_status("disconnected")
|
||||
self.async_update = False
|
||||
self._sync_device_selection()
|
||||
|
||||
|
||||
@@ -217,14 +217,14 @@ class Ring(BECWidget, QWidget):
|
||||
|
||||
match mode:
|
||||
case "manual":
|
||||
if self.config.mode == "manual":
|
||||
if self.config.mode == "manual" and self.registered_slot is None:
|
||||
return
|
||||
if self.registered_slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
|
||||
self.config.mode = "manual"
|
||||
self.registered_slot = None
|
||||
case "scan":
|
||||
if self.config.mode == "scan":
|
||||
if self.config.mode == "scan" and self.registered_slot is not None:
|
||||
return
|
||||
if self.registered_slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
|
||||
@@ -383,9 +383,9 @@ class Ring(BECWidget, QWidget):
|
||||
"""
|
||||
current_RID = meta.get("RID", None)
|
||||
if current_RID != self.RID:
|
||||
self.RID = current_RID
|
||||
self.set_min_max_values(0, msg.get("max_value", 100))
|
||||
self.set_value(msg.get("value", 0))
|
||||
self.update()
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback(self, msg, meta):
|
||||
@@ -404,7 +404,6 @@ class Ring(BECWidget, QWidget):
|
||||
if value is None:
|
||||
return
|
||||
self.set_value(value)
|
||||
self.update()
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_progress(self, msg, meta):
|
||||
@@ -424,7 +423,6 @@ class Ring(BECWidget, QWidget):
|
||||
if msg.get("done"):
|
||||
value = max_val
|
||||
self.set_value(value)
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
if not self.progress_container:
|
||||
|
||||
@@ -103,7 +103,6 @@ class RingProgressContainerWidget(QWidget):
|
||||
self._hovered_ring = None
|
||||
self._last_hover_global_pos = None
|
||||
self._hover_tooltip.hide()
|
||||
ring.cleanup()
|
||||
ring.close()
|
||||
ring.deleteLater()
|
||||
self.rings.pop(index)
|
||||
@@ -373,7 +372,7 @@ class RingProgressContainerWidget(QWidget):
|
||||
self._hovered_ring = None
|
||||
self._last_hover_global_pos = None
|
||||
self._hover_tooltip.hide()
|
||||
for ring in self.rings:
|
||||
for ring in list(self.rings):
|
||||
ring.close()
|
||||
ring.deleteLater()
|
||||
self.rings = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import sys
|
||||
|
||||
from qtpy.QtCore import Property, QEasingCurve, QEvent, QPointF, QPropertyAnimation, Qt, Signal
|
||||
from qtpy.QtCore import Property, QEasingCurve, QPointF, QPropertyAnimation, Qt, Signal
|
||||
from qtpy.QtGui import QColor, QPainter
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
@@ -41,22 +41,10 @@ class ToggleSwitch(QWidget):
|
||||
theme = getattr(QApplication.instance(), "theme", None)
|
||||
colors = theme.colors if theme else {}
|
||||
|
||||
self._active_track_color = self._theme_color(colors, "PRIMARY", QColor(33, 150, 243))
|
||||
self._active_thumb_color = self._theme_color(colors, "ON_PRIMARY", QColor(255, 255, 255))
|
||||
self._inactive_track_color = self._theme_color(colors, "SEPARATOR", QColor(200, 200, 200))
|
||||
self._inactive_thumb_color = self._theme_color(colors, "ON_PRIMARY", QColor(255, 255, 255))
|
||||
self._disabled_track_color = self._theme_color(colors, "DISABLED_BG", QColor(220, 220, 220))
|
||||
self._disabled_thumb_color = self._theme_color(colors, "DISABLED_FG", QColor(150, 150, 150))
|
||||
self._disabled_border_color = self._theme_color(
|
||||
colors, "DISABLED_BORDER", QColor(170, 170, 170)
|
||||
)
|
||||
if hasattr(self, "_checked"):
|
||||
self.update_colors()
|
||||
|
||||
@staticmethod
|
||||
def _theme_color(colors: dict, key: str, fallback: QColor) -> QColor:
|
||||
color = colors.get(key, fallback)
|
||||
return color if isinstance(color, QColor) else QColor(color)
|
||||
self._active_track_color = colors.get("PRIMARY", QColor(33, 150, 243))
|
||||
self._active_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255))
|
||||
self._inactive_track_color = colors.get("SEPARATOR", QColor(200, 200, 200))
|
||||
self._inactive_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255))
|
||||
|
||||
@Property(bool)
|
||||
def checked(self):
|
||||
@@ -131,40 +119,29 @@ class ToggleSwitch(QWidget):
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
# Draw track
|
||||
painter.setBrush(self._track_color)
|
||||
painter.setPen(self._disabled_border_color if not self.isEnabled() else Qt.PenStyle.NoPen)
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.drawRoundedRect(
|
||||
0, 0, self.width(), self.height(), self.height() / 2, self.height() / 2
|
||||
)
|
||||
|
||||
# Draw thumb
|
||||
painter.setBrush(self._thumb_color)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
diameter = int(self.height() * 0.8)
|
||||
painter.drawEllipse(int(self._thumb_pos.x()), int(self._thumb_pos.y()), diameter, diameter)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if self.isEnabled() and event.button() == Qt.MouseButton.LeftButton:
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.checked = not self.checked
|
||||
|
||||
def update_colors(self):
|
||||
if not self.isEnabled():
|
||||
self._thumb_color = self._disabled_thumb_color
|
||||
self._track_color = self._disabled_track_color
|
||||
return
|
||||
|
||||
self._thumb_color = self.active_thumb_color if self._checked else self.inactive_thumb_color
|
||||
self._track_color = self.active_track_color if self._checked else self.inactive_track_color
|
||||
|
||||
def changeEvent(self, event):
|
||||
if event.type() == QEvent.Type.EnabledChange:
|
||||
self.update_colors()
|
||||
self.update()
|
||||
super().changeEvent(event)
|
||||
|
||||
def get_thumb_pos(self, checked):
|
||||
return QPointF(self.width() - self.height() + 3, 2) if checked else QPointF(3, 2)
|
||||
|
||||
@@ -190,7 +167,7 @@ class ToggleSwitch(QWidget):
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QHBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
@@ -200,12 +177,9 @@ if __name__ == "__main__": # pragma: no cover
|
||||
widget = QWidget()
|
||||
layout = QHBoxLayout(widget)
|
||||
toggle = ToggleSwitch()
|
||||
toggle_disabled = ToggleSwitch()
|
||||
dark_mode_btn = DarkModeButton()
|
||||
layout.addWidget(toggle)
|
||||
layout.addWidget(toggle_disabled)
|
||||
layout.addWidget(dark_mode_btn)
|
||||
toggle_disabled.setEnabled(False)
|
||||
window = QWidget()
|
||||
window.setLayout(layout)
|
||||
window.show()
|
||||
|
||||
+2
-9
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "3.13.3"
|
||||
version = "3.11.1"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
@@ -23,7 +23,7 @@ dependencies = [
|
||||
"ophyd_devices~=1.29, >=1.29.1",
|
||||
"pydantic~=2.0",
|
||||
"pylsp-bec~=1.2",
|
||||
"pyqtgraph~=0.14.0",
|
||||
"pyqtgraph==0.13.7",
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtmonaco~=0.8, >=0.8.1",
|
||||
"qtpy~=2.4",
|
||||
@@ -64,13 +64,6 @@ qtermwidget = ["pyside6_qtermwidget"]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
@@ -45,7 +45,7 @@ def connected_client_gui_obj(qtbot, gui_id, bec_client_lib):
|
||||
"""
|
||||
gui = BECGuiClient(gui_id=gui_id)
|
||||
try:
|
||||
gui.show(wait=True)
|
||||
gui.start(wait=True)
|
||||
qtbot.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000)
|
||||
gui.bec.delete_all() # ensure clean state
|
||||
qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
|
||||
|
||||
@@ -143,11 +143,11 @@ def test_rpc_gui_obj(connected_client_gui_obj: BECGuiClient, qtbot):
|
||||
qtbot.wait(500)
|
||||
gui.kill_server()
|
||||
assert not gui._gui_is_alive()
|
||||
gui.show(wait=True)
|
||||
gui.start(wait=True)
|
||||
assert gui._gui_is_alive()
|
||||
# calling show multiple times should not change anything
|
||||
gui.show(wait=True)
|
||||
gui.show(wait=True)
|
||||
# calling start multiple times should not change anything
|
||||
gui.start(wait=True)
|
||||
gui.start(wait=True)
|
||||
|
||||
def wait_for_gui_started():
|
||||
return "bec" in gui.windows
|
||||
|
||||
@@ -75,7 +75,7 @@ def connected_client_gui_obj(qtbot_scope_module, gui_id, bec_client_lib):
|
||||
"""
|
||||
gui = BECGuiClient(gui_id=gui_id)
|
||||
try:
|
||||
gui.show(wait=True)
|
||||
gui.start(wait=True)
|
||||
qtbot_scope_module.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000)
|
||||
gui.bec.delete_all() # ensure clean state
|
||||
qtbot_scope_module.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
|
||||
|
||||
@@ -5,7 +5,6 @@ import pytest
|
||||
|
||||
from bec_widgets.cli.client import BECDockArea
|
||||
from bec_widgets.cli.client_utils import BECGuiClient, _start_plot_process
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCResponseTimeoutError, rpc_timeout
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -221,7 +220,7 @@ def test_client_utils_new_starts_server_when_not_alive():
|
||||
with mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server):
|
||||
with (
|
||||
mock.patch.object(gui, "_check_if_server_is_alive", return_value=False),
|
||||
mock.patch.object(gui, "show") as mock_start,
|
||||
mock.patch.object(gui, "start") as mock_start,
|
||||
):
|
||||
gui.new(wait=False, startup_profile=None)
|
||||
|
||||
@@ -258,11 +257,3 @@ def test_client_utils_delete_falls_back_to_direct_close():
|
||||
gui.delete("dock")
|
||||
|
||||
widget._run_rpc.assert_called_once_with("close")
|
||||
|
||||
|
||||
def test_client_utils_gui_client_set_rpc_timeout():
|
||||
gui = BECGuiClient()
|
||||
assert gui._rpc_timeout == 5
|
||||
|
||||
gui.set_rpc_timeout(10)
|
||||
assert gui._rpc_timeout == 10
|
||||
|
||||
@@ -14,18 +14,6 @@ from .conftest import create_widget
|
||||
# pylint: disable = redefined-outer-name
|
||||
|
||||
|
||||
class _FakeMouseClickEvent:
|
||||
def __init__(self, scene_pos: QPointF, button: Qt.MouseButton = Qt.MouseButton.LeftButton):
|
||||
self._scene_pos = scene_pos
|
||||
self._button = button
|
||||
|
||||
def button(self):
|
||||
return self._button
|
||||
|
||||
def scenePos(self):
|
||||
return self._scene_pos
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plot_widget_with_crosshair(qtbot):
|
||||
widget = pg.PlotWidget()
|
||||
@@ -34,7 +22,6 @@ def plot_widget_with_crosshair(qtbot):
|
||||
|
||||
widget.plot(x=[1, 2, 3], y=[4, 5, 6], name="Curve 1")
|
||||
plot_item = widget.getPlotItem()
|
||||
plot_item.vb.setRange(xRange=(0, 4), yRange=(0, 10), padding=0)
|
||||
crosshair = Crosshair(plot_item=plot_item, precision=3)
|
||||
|
||||
yield crosshair, plot_item
|
||||
@@ -51,17 +38,20 @@ def image_widget_with_crosshair(qtbot):
|
||||
|
||||
widget.addItem(image_item)
|
||||
plot_item = widget.getPlotItem()
|
||||
plot_item.vb.setRange(xRange=(0, 100), yRange=(0, 100), padding=0)
|
||||
crosshair = Crosshair(plot_item=plot_item, precision=3)
|
||||
|
||||
yield crosshair, plot_item
|
||||
|
||||
|
||||
def test_mouse_moved_lines(plot_widget_with_crosshair):
|
||||
crosshair, _ = plot_widget_with_crosshair
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
|
||||
pos_in_view = QPointF(2, 5)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
# Simulate mouse movement
|
||||
crosshair.mouse_moved(manual_pos=(2, 5))
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
# Check that the vertical line is indeed at x=2
|
||||
assert np.isclose(crosshair.v_line.pos().x(), 2)
|
||||
@@ -69,7 +59,7 @@ def test_mouse_moved_lines(plot_widget_with_crosshair):
|
||||
|
||||
|
||||
def test_mouse_moved_signals(plot_widget_with_crosshair):
|
||||
crosshair, _ = plot_widget_with_crosshair
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
|
||||
emitted_values_1D = []
|
||||
|
||||
@@ -78,40 +68,43 @@ def test_mouse_moved_signals(plot_widget_with_crosshair):
|
||||
|
||||
crosshair.coordinatesChanged1D.connect(slot)
|
||||
|
||||
crosshair.mouse_moved(manual_pos=(2, 5))
|
||||
pos_in_view = QPointF(2, 5)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
# Assert the expected behavior
|
||||
assert emitted_values_1D == [("Curve 1", 2, 5)]
|
||||
|
||||
|
||||
def test_mouse_moved_signals_outside(plot_widget_with_crosshair):
|
||||
crosshair, _ = plot_widget_with_crosshair
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
|
||||
# Create a slot that will store the emitted values as tuples
|
||||
emitted_values_1D = []
|
||||
emitted_positions = []
|
||||
|
||||
def slot(coordinates):
|
||||
emitted_values_1D.append(coordinates)
|
||||
|
||||
# Connect the signal to the custom slot
|
||||
crosshair.coordinatesChanged1D.connect(slot)
|
||||
crosshair.crosshairChanged.connect(emitted_positions.append)
|
||||
|
||||
crosshair.mouse_moved(manual_pos=(2, 5))
|
||||
emitted_positions.clear()
|
||||
emitted_values_1D.clear()
|
||||
crosshair.mouse_moved(manual_pos=(22, 55))
|
||||
# Simulate a mouse moved event at a specific position
|
||||
pos_in_view = QPointF(22, 55)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
# Call the mouse_moved method
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
# Assert the expected behavior
|
||||
assert emitted_values_1D == []
|
||||
assert emitted_positions == []
|
||||
assert np.isclose(crosshair.v_line.pos().x(), 2)
|
||||
assert np.isclose(crosshair.h_line.pos().y(), 5)
|
||||
|
||||
|
||||
def test_mouse_moved_signals_2D(image_widget_with_crosshair):
|
||||
crosshair, _ = image_widget_with_crosshair
|
||||
crosshair, plot_item = image_widget_with_crosshair
|
||||
image_item = plot_item.items[0]
|
||||
|
||||
emitted_values_2D = []
|
||||
|
||||
@@ -120,16 +113,17 @@ def test_mouse_moved_signals_2D(image_widget_with_crosshair):
|
||||
|
||||
crosshair.coordinatesChanged2D.connect(slot)
|
||||
|
||||
crosshair.mouse_moved(manual_pos=(21.0, 55.0))
|
||||
pos_in_view = QPointF(21.0, 55.0)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
assert emitted_values_2D == [("ImageItem", 21, 55)]
|
||||
|
||||
|
||||
def test_mouse_moved_signals_2D_outside_image_bounds_clamps_inside_view_range(
|
||||
image_widget_with_crosshair,
|
||||
):
|
||||
def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair):
|
||||
crosshair, plot_item = image_widget_with_crosshair
|
||||
plot_item.vb.setRange(xRange=(0, 300), yRange=(0, 600), padding=0)
|
||||
|
||||
emitted_values_2D = []
|
||||
|
||||
@@ -138,34 +132,23 @@ def test_mouse_moved_signals_2D_outside_image_bounds_clamps_inside_view_range(
|
||||
|
||||
crosshair.coordinatesChanged2D.connect(slot)
|
||||
|
||||
crosshair.mouse_moved(manual_pos=(220.0, 555.0))
|
||||
pos_in_view = QPointF(220.0, 555.0)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
assert emitted_values_2D == [("ImageItem", 99, 99)]
|
||||
|
||||
|
||||
def test_mouse_moved_signals_2D_outside_view_range_ignored(image_widget_with_crosshair):
|
||||
crosshair, _ = image_widget_with_crosshair
|
||||
|
||||
emitted_values_2D = []
|
||||
emitted_positions = []
|
||||
|
||||
crosshair.coordinatesChanged2D.connect(emitted_values_2D.append)
|
||||
crosshair.crosshairChanged.connect(emitted_positions.append)
|
||||
|
||||
crosshair.mouse_moved(manual_pos=(21.0, 55.0))
|
||||
emitted_positions.clear()
|
||||
emitted_values_2D.clear()
|
||||
crosshair.mouse_moved(manual_pos=(220.0, 555.0))
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
assert emitted_values_2D == []
|
||||
assert emitted_positions == []
|
||||
assert np.isclose(crosshair.v_line.pos().x(), 21.0)
|
||||
assert np.isclose(crosshair.h_line.pos().y(), 55.0)
|
||||
|
||||
|
||||
def test_marker_positions_after_mouse_move(plot_widget_with_crosshair):
|
||||
crosshair, _ = plot_widget_with_crosshair
|
||||
crosshair.mouse_moved(manual_pos=(2, 5))
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
|
||||
pos_in_view = QPointF(2, 5)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
marker = crosshair.marker_moved_1d["Curve 1"]
|
||||
marker_x, marker_y = marker.getData()
|
||||
@@ -189,7 +172,7 @@ def test_scale_emitted_coordinates(plot_widget_with_crosshair):
|
||||
|
||||
|
||||
def test_crosshair_changed_signal(plot_widget_with_crosshair):
|
||||
crosshair, _ = plot_widget_with_crosshair
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
|
||||
emitted_positions = []
|
||||
|
||||
@@ -198,7 +181,11 @@ def test_crosshair_changed_signal(plot_widget_with_crosshair):
|
||||
|
||||
crosshair.crosshairChanged.connect(slot)
|
||||
|
||||
crosshair.mouse_moved(manual_pos=(2, 5))
|
||||
pos_in_view = QPointF(2, 5)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
x, y = emitted_positions[0]
|
||||
|
||||
@@ -206,33 +193,33 @@ def test_crosshair_changed_signal(plot_widget_with_crosshair):
|
||||
assert np.isclose(y, 5)
|
||||
|
||||
|
||||
def test_crosshair_clicked_signal(plot_widget_with_crosshair):
|
||||
def test_crosshair_clicked_signal(qtbot, plot_widget_with_crosshair):
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
|
||||
emitted_positions = []
|
||||
emitted_view_positions = []
|
||||
|
||||
def slot(position):
|
||||
emitted_positions.append(position)
|
||||
|
||||
crosshair.crosshairClicked.connect(slot)
|
||||
crosshair.positionClicked.connect(emitted_view_positions.append)
|
||||
|
||||
crosshair.is_log_x = True
|
||||
crosshair.is_log_y = True
|
||||
plot_item.vb.setRange(xRange=(0, 1), yRange=(0, 1), padding=0)
|
||||
x_data = 2
|
||||
y_data = 5
|
||||
|
||||
known_view_point = QPointF(np.log10(2), np.log10(5))
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(known_view_point)
|
||||
crosshair.mouse_clicked(_FakeMouseClickEvent(pos_in_scene))
|
||||
# Map data coordinates to scene coordinates
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(QPointF(x_data, y_data))
|
||||
# Map scene coordinates to widget coordinates
|
||||
graphics_view = plot_item.vb.scene().views()[0]
|
||||
qtbot.waitExposed(graphics_view)
|
||||
pos_in_widget = graphics_view.mapFromScene(pos_in_scene)
|
||||
|
||||
# Simulate mouse click
|
||||
qtbot.mouseClick(graphics_view.viewport(), Qt.LeftButton, pos=pos_in_widget)
|
||||
|
||||
x, y = emitted_positions[0]
|
||||
view_x, view_y = emitted_view_positions[0]
|
||||
|
||||
assert np.isclose(x, 2)
|
||||
assert np.isclose(y, 5)
|
||||
assert np.isclose(view_x, known_view_point.x())
|
||||
assert np.isclose(view_y, known_view_point.y())
|
||||
assert np.isclose(round(x, 1), 2)
|
||||
assert np.isclose(round(y, 1), 5)
|
||||
|
||||
|
||||
def test_update_coord_label_1D(plot_widget_with_crosshair):
|
||||
@@ -372,17 +359,20 @@ def test_ignore_invisible_curves_on_move(qtbot, mocked_client):
|
||||
c0 = wf.plot(x=[1, 2, 3], y=[1, 4, 9], name="Curve_0")
|
||||
c1 = wf.plot(x=[1, 2, 3], y=[2, 5, 10], name="Curve_1")
|
||||
wf.hook_crosshair()
|
||||
wf.crosshair.plot_item.vb.setRange(xRange=(0, 4), yRange=(0, 10), padding=0)
|
||||
|
||||
# # Simulate a mouse move at (2,5)
|
||||
pos_in_view = QPointF(2, 5)
|
||||
pos_in_scene = wf.plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
# 1) Both curves visible: expect markers for both
|
||||
wf.crosshair.clear_markers()
|
||||
wf.crosshair.mouse_moved(manual_pos=(2, 5))
|
||||
wf.crosshair.mouse_moved(event_mock)
|
||||
assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0", "Curve_1"}
|
||||
|
||||
# 2) Hide Curve B and repeat: only Curve_0 should remain
|
||||
c1.setVisible(False)
|
||||
wf.crosshair.clear_markers()
|
||||
wf.crosshair.mouse_moved(manual_pos=(2, 5))
|
||||
wf.crosshair.mouse_moved(event_mock)
|
||||
qtbot.wait(200)
|
||||
assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0"}
|
||||
|
||||
@@ -139,23 +139,6 @@ def test_device_input_combobox_cleanup_unregisters_callback(qtbot, mocked_client
|
||||
assert widget._callback_id is None
|
||||
|
||||
|
||||
def test_device_input_combobox_cleanup_clears_callback_before_unregister(qtbot, mocked_client):
|
||||
widget = DeviceComboBox(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
callback_id = widget._callback_id
|
||||
|
||||
def assert_callback_cleared(removed_callback_id):
|
||||
assert removed_callback_id == callback_id
|
||||
assert widget._callback_id is None
|
||||
|
||||
with mock.patch.object(
|
||||
mocked_client.callbacks, "remove", side_effect=assert_callback_cleared
|
||||
) as remove_mock:
|
||||
widget.cleanup()
|
||||
|
||||
remove_mock.assert_called_once_with(callback_id)
|
||||
|
||||
|
||||
def test_get_device_from_input_combobox_init(device_input_combobox):
|
||||
device_input_combobox.setCurrentIndex(0)
|
||||
device_text = device_input_combobox.currentText()
|
||||
|
||||
@@ -196,50 +196,6 @@ def test_device_signal_input_base_cleanup(qtbot, mocked_client):
|
||||
assert widget._device_update_register is None
|
||||
|
||||
|
||||
def test_signal_combobox_cleanup_clears_callback_before_unregister(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
|
||||
callback_id = widget._device_update_register
|
||||
|
||||
def assert_callback_cleared(removed_callback_id):
|
||||
assert removed_callback_id == callback_id
|
||||
assert widget._device_update_register is None
|
||||
|
||||
with mock.patch.object(
|
||||
mocked_client.callbacks, "remove", side_effect=assert_callback_cleared
|
||||
) as remove_mock:
|
||||
widget.cleanup()
|
||||
|
||||
remove_mock.assert_called_once_with(callback_id)
|
||||
|
||||
|
||||
def test_signal_combobox_cleanup_blocks_in_flight_device_update(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
|
||||
callback_id = widget._device_update_register
|
||||
|
||||
def trigger_in_flight_update(_):
|
||||
widget.update_signals_from_filters("reload", {})
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
mocked_client.callbacks, "remove", side_effect=trigger_in_flight_update
|
||||
) as remove_mock,
|
||||
mock.patch.object(widget, "_set_signal_groups") as set_signal_groups,
|
||||
):
|
||||
widget.cleanup()
|
||||
|
||||
remove_mock.assert_called_once_with(callback_id)
|
||||
set_signal_groups.assert_not_called()
|
||||
|
||||
|
||||
def test_signal_combobox_device_update_ignores_update_action(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
|
||||
|
||||
with mock.patch.object(widget, "_set_signal_groups") as set_signal_groups:
|
||||
widget.update_signals_from_filters("update", {})
|
||||
|
||||
set_signal_groups.assert_not_called()
|
||||
|
||||
|
||||
def test_signal_combobox_get_signal_name_with_item_data(qtbot, device_signal_combobox):
|
||||
"""Test get_signal_name returns obj_name from item data when available."""
|
||||
device_signal_combobox.include_normal_signals = True
|
||||
|
||||
@@ -464,6 +464,10 @@ def test_disconnect_clears_async_state(qtbot, mocked_client, monkeypatch):
|
||||
|
||||
assert view.subscriptions["main"].async_signal_name is None
|
||||
assert view.async_update is False
|
||||
assert view.device == ""
|
||||
assert view.signal == ""
|
||||
assert view.subscriptions["main"].source is None
|
||||
assert view.subscriptions["main"].monitor_type is None
|
||||
|
||||
|
||||
##############################################
|
||||
|
||||
@@ -11,7 +11,6 @@ from bec_widgets.utils.forms_from_types.items import StrFormItem
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.control.scan_control.scan_info_adapter import ScanInfoAdapter
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
@@ -281,359 +280,6 @@ def test_populate_scans(scan_control, mocked_client):
|
||||
assert sorted(items) == sorted(expected_scans)
|
||||
|
||||
|
||||
def test_scan_control_uses_gui_visibility_and_signature(qtbot, mocked_client):
|
||||
scan_info = {
|
||||
"class": "AnnotatedScan",
|
||||
"base_class": "ScanBase",
|
||||
"arg_input": {
|
||||
"device": "DeviceBase",
|
||||
"start": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Start Position",
|
||||
"description": "Start position",
|
||||
"tooltip": "Custom start tooltip",
|
||||
"expert": False,
|
||||
"alternative_group": None,
|
||||
"units": None,
|
||||
"reference_units": "device",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"stop": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": None,
|
||||
"description": "Stop position",
|
||||
"tooltip": None,
|
||||
"expert": False,
|
||||
"alternative_group": None,
|
||||
"units": None,
|
||||
"reference_units": "device",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"arg_bundle_size": {"bundle": 3, "min": 1, "max": None},
|
||||
"gui_visibility": {
|
||||
"Movement Parameters": ["steps", "step_size"],
|
||||
"Acquisition Parameters": ["exp_time", "relative"],
|
||||
},
|
||||
"required_kwargs": [],
|
||||
"signature": [
|
||||
{"name": "args", "kind": "VAR_POSITIONAL", "default": "_empty", "annotation": "_empty"},
|
||||
{"name": "steps", "kind": "KEYWORD_ONLY", "default": 10, "annotation": "int"},
|
||||
{
|
||||
"name": "step_size",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Step Size Custom",
|
||||
"description": "Step size",
|
||||
"tooltip": "Custom step tooltip",
|
||||
"expert": False,
|
||||
"alternative_group": "scan_resolution",
|
||||
"units": "mm",
|
||||
"reference_units": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "exp_time",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": 0,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": None,
|
||||
"description": None,
|
||||
"tooltip": "Exposure time",
|
||||
"expert": False,
|
||||
"alternative_group": None,
|
||||
"units": "s",
|
||||
"reference_units": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{"name": "relative", "kind": "KEYWORD_ONLY", "default": False, "annotation": "bool"},
|
||||
{"name": "kwargs", "kind": "VAR_KEYWORD", "default": "_empty", "annotation": "_empty"},
|
||||
],
|
||||
}
|
||||
mocked_client.connector.set_and_publish(
|
||||
MessageEndpoints.available_scans(),
|
||||
AvailableResourceMessage(resource={"annotated_scan": scan_info}),
|
||||
)
|
||||
|
||||
widget = ScanControl(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
widget.comboBox_scan_selection.setCurrentText("annotated_scan")
|
||||
|
||||
assert widget.comboBox_scan_selection.count() == 1
|
||||
assert widget.arg_box.layout.itemAtPosition(0, 1).widget().text() == "Start Position"
|
||||
assert "Custom start tooltip\nUnits from: device" in widget.arg_box.widgets[1].toolTip()
|
||||
with patch.object(mocked_client.device_manager.devices.samx, "egu", return_value="mm"):
|
||||
WidgetIO.set_value(widget.arg_box.widgets[0], "samx")
|
||||
assert widget.arg_box.layout.itemAtPosition(0, 1).widget().text() == "Start Position"
|
||||
assert widget.arg_box.widgets[1].suffix() == " mm"
|
||||
assert "Custom start tooltip\nUnits: mm" in widget.arg_box.widgets[1].toolTip()
|
||||
widget.arg_box.widgets[0].setCurrentText("not_a_device")
|
||||
assert widget.arg_box.layout.itemAtPosition(0, 1).widget().text() == "Start Position"
|
||||
assert widget.arg_box.widgets[1].suffix() == ""
|
||||
assert "Custom start tooltip\nUnits from: device" in widget.arg_box.widgets[1].toolTip()
|
||||
assert [box.title() for box in widget.kwarg_boxes] == [
|
||||
"Movement Parameters",
|
||||
"Acquisition Parameters",
|
||||
]
|
||||
assert widget.kwarg_boxes[0].layout.itemAtPosition(0, 1).widget().text() == "Step Size Custom"
|
||||
assert widget.kwarg_boxes[0].widgets[1].suffix() == " mm"
|
||||
assert "Custom step tooltip\nUnits: mm" in widget.kwarg_boxes[0].widgets[1].toolTip()
|
||||
assert widget.kwarg_boxes[1].layout.itemAtPosition(0, 0).widget().text() == "Exp Time"
|
||||
assert "Exposure time\nUnits: s" in widget.kwarg_boxes[1].widgets[0].toolTip()
|
||||
|
||||
|
||||
def test_scan_info_adapter_skips_duplicate_visible_kwargs():
|
||||
scan_info = {
|
||||
"class": "DuplicateScan",
|
||||
"base_class": "ScanBaseV4",
|
||||
"arg_input": {},
|
||||
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
|
||||
"gui_visibility": {
|
||||
"Scan Parameters": ["relative", "burst_at_each_point"],
|
||||
"Acquisition Parameters": ["exp_time", "burst_at_each_point"],
|
||||
},
|
||||
"signature": [
|
||||
{"name": "relative", "kind": "KEYWORD_ONLY", "default": False, "annotation": "bool"},
|
||||
{
|
||||
"name": "burst_at_each_point",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": 1,
|
||||
"annotation": "int",
|
||||
},
|
||||
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
|
||||
],
|
||||
}
|
||||
|
||||
gui_config = ScanInfoAdapter().build_scan_ui_config(scan_info)
|
||||
groups = {
|
||||
group["name"]: [input_spec["name"] for input_spec in group["inputs"]]
|
||||
for group in gui_config["kwarg_groups"]
|
||||
}
|
||||
|
||||
assert groups == {
|
||||
"Scan Parameters": ["relative", "burst_at_each_point"],
|
||||
"Acquisition Parameters": ["exp_time"],
|
||||
}
|
||||
|
||||
|
||||
def test_scan_info_adapter_rejects_unsupported_visible_inputs():
|
||||
scan_info = {
|
||||
"class": "UnsupportedScan",
|
||||
"base_class": "ScanBaseV4",
|
||||
"arg_input": {},
|
||||
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
|
||||
"gui_visibility": {"Regions": ["regions"]},
|
||||
"signature": [
|
||||
{
|
||||
"name": "regions",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": "_empty",
|
||||
"annotation": {
|
||||
"Generic": {
|
||||
"origin": "list",
|
||||
"args": [
|
||||
{"Generic": {"origin": "tuple", "args": ["float", "float", "int"]}}
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
gui_config = ScanInfoAdapter().build_scan_ui_config(scan_info)
|
||||
unsupported_inputs = ScanInfoAdapter.unsupported_inputs(gui_config)
|
||||
|
||||
assert [input_spec["name"] for input_spec in unsupported_inputs] == ["regions"]
|
||||
assert ScanInfoAdapter.has_scan_ui_config(scan_info) is False
|
||||
|
||||
|
||||
def test_scan_info_adapter_skips_hidden_visible_kwargs():
|
||||
scan_info = {
|
||||
"class": "HiddenScan",
|
||||
"base_class": "ScanBaseV4",
|
||||
"arg_input": {},
|
||||
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
|
||||
"gui_visibility": {"Acquisition": ["exp_time", "internal_token"]},
|
||||
"signature": [
|
||||
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
|
||||
{
|
||||
"name": "internal_token",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "str",
|
||||
"metadata": {
|
||||
"ScanArgument": {"display_name": "Internal Token", "hidden": True}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
gui_config = ScanInfoAdapter().build_scan_ui_config(scan_info)
|
||||
|
||||
assert [input_spec["name"] for input_spec in gui_config["kwarg_groups"][0]["inputs"]] == [
|
||||
"exp_time"
|
||||
]
|
||||
|
||||
|
||||
def test_scan_control_propagates_reference_units_across_kwarg_groups(qtbot, mocked_client):
|
||||
scan_info = {
|
||||
"class": "RoundScan",
|
||||
"base_class": "ScanBaseV4",
|
||||
"arg_input": {},
|
||||
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
|
||||
"gui_visibility": {
|
||||
"Motors": ["motor_1", "motor_2"],
|
||||
"Ring Parameters": ["inner_radius", "outer_radius", "center_1", "center_2"],
|
||||
},
|
||||
"required_kwargs": [],
|
||||
"signature": [
|
||||
{
|
||||
"name": "motor_1",
|
||||
"kind": "POSITIONAL_OR_KEYWORD",
|
||||
"default": "_empty",
|
||||
"annotation": "DeviceBase",
|
||||
},
|
||||
{
|
||||
"name": "motor_2",
|
||||
"kind": "POSITIONAL_OR_KEYWORD",
|
||||
"default": "_empty",
|
||||
"annotation": "DeviceBase",
|
||||
},
|
||||
{
|
||||
"name": "inner_radius",
|
||||
"kind": "POSITIONAL_OR_KEYWORD",
|
||||
"default": "_empty",
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Inner Radius",
|
||||
"units": None,
|
||||
"reference_units": "motor_1",
|
||||
"ge": 0,
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "outer_radius",
|
||||
"kind": "POSITIONAL_OR_KEYWORD",
|
||||
"default": "_empty",
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Outer Radius",
|
||||
"units": None,
|
||||
"reference_units": "motor_1",
|
||||
"ge": 0,
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "center_1",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": 0,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Center Motor 1",
|
||||
"units": None,
|
||||
"reference_units": "motor_1",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "center_2",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": 0,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Center Motor 2",
|
||||
"units": None,
|
||||
"reference_units": "motor_2",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
mocked_client.connector.set_and_publish(
|
||||
MessageEndpoints.available_scans(),
|
||||
AvailableResourceMessage(resource={"round_scan": scan_info}),
|
||||
)
|
||||
|
||||
widget = ScanControl(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
widget.comboBox_scan_selection.setCurrentText("round_scan")
|
||||
|
||||
motor_box = widget.kwarg_boxes[0]
|
||||
ring_box = widget.kwarg_boxes[1]
|
||||
|
||||
assert "Units from: motor_1" in ring_box.widgets[0].toolTip()
|
||||
assert ring_box.widgets[0].suffix() == ""
|
||||
|
||||
with patch.object(mocked_client.device_manager.devices.samx, "egu", return_value="mm"):
|
||||
WidgetIO.set_value(motor_box.widgets[0], "samx")
|
||||
|
||||
assert ring_box.widgets[0].suffix() == " mm"
|
||||
assert ring_box.widgets[1].suffix() == " mm"
|
||||
assert ring_box.widgets[2].suffix() == " mm"
|
||||
assert ring_box.widgets[3].suffix() == ""
|
||||
assert "Units: mm" in ring_box.widgets[0].toolTip()
|
||||
|
||||
motor_box.widgets[0].setCurrentText("not_a_device")
|
||||
|
||||
assert ring_box.widgets[0].suffix() == ""
|
||||
assert ring_box.widgets[1].suffix() == ""
|
||||
assert ring_box.widgets[2].suffix() == ""
|
||||
assert "Units from: motor_1" in ring_box.widgets[0].toolTip()
|
||||
|
||||
|
||||
def test_current_scan(scan_control, mocked_client):
|
||||
current_scan = scan_control.current_scan
|
||||
wrong_scan = "error_scan"
|
||||
|
||||
@@ -67,28 +67,28 @@ def test_kwarg_box(qtbot):
|
||||
assert kwarg_box.widgets[0].__class__.__name__ == "ScanDoubleSpinBox"
|
||||
assert kwarg_box.widgets[0].arg_name == "exp_time"
|
||||
assert WidgetIO.get_value(kwarg_box.widgets[0]) == 0
|
||||
assert "Exposure time in seconds" in kwarg_box.widgets[0].toolTip()
|
||||
assert kwarg_box.widgets[0].toolTip() == "Exposure time in seconds"
|
||||
|
||||
# Widget 1
|
||||
assert kwarg_box.widgets[1].__class__.__name__ == "ScanSpinBox"
|
||||
assert kwarg_box.widgets[1].arg_name == "num_points"
|
||||
assert WidgetIO.get_value(kwarg_box.widgets[1]) == 1
|
||||
assert "Number of points" in kwarg_box.widgets[1].toolTip()
|
||||
assert kwarg_box.widgets[1].toolTip() == "Number of points"
|
||||
|
||||
# Widget 2
|
||||
assert kwarg_box.widgets[2].__class__.__name__ == "ScanCheckBox"
|
||||
assert kwarg_box.widgets[2].arg_name == "relative"
|
||||
assert WidgetIO.get_value(kwarg_box.widgets[2]) == False
|
||||
assert (
|
||||
"If True, the motors will be moved relative to their current position"
|
||||
in kwarg_box.widgets[2].toolTip()
|
||||
kwarg_box.widgets[2].toolTip()
|
||||
== "If True, the motors will be moved relative to their current position"
|
||||
)
|
||||
|
||||
# Widget 3
|
||||
assert kwarg_box.widgets[3].__class__.__name__ == "ScanLineEdit"
|
||||
assert kwarg_box.widgets[3].arg_name == "scan_type"
|
||||
assert WidgetIO.get_value(kwarg_box.widgets[3]) == "line"
|
||||
assert "Type of scan" in kwarg_box.widgets[3].toolTip()
|
||||
assert kwarg_box.widgets[3].toolTip() == "Type of scan"
|
||||
|
||||
parameters = kwarg_box.get_parameters()
|
||||
assert parameters == {"exp_time": 0, "num_points": 1, "relative": False, "scan_type": "line"}
|
||||
@@ -146,92 +146,14 @@ def test_arg_box(qtbot):
|
||||
assert arg_box.widgets[0].__class__.__name__ == "ScanLineEdit"
|
||||
assert arg_box.widgets[0].arg_name == "device"
|
||||
assert WidgetIO.get_value(arg_box.widgets[0]) == "samx"
|
||||
assert "Device to scan" in arg_box.widgets[0].toolTip()
|
||||
assert arg_box.widgets[0].toolTip() == "Device to scan"
|
||||
|
||||
# Widget 1
|
||||
assert arg_box.widgets[1].__class__.__name__ == "ScanDoubleSpinBox"
|
||||
assert arg_box.widgets[1].arg_name == "start"
|
||||
assert WidgetIO.get_value(arg_box.widgets[1]) == 0
|
||||
assert "Start position" in arg_box.widgets[1].toolTip()
|
||||
assert arg_box.widgets[1].toolTip() == "Start position"
|
||||
|
||||
# Widget 2
|
||||
assert arg_box.widgets[2].__class__.__name__ == "ScanSpinBox"
|
||||
assert arg_box.widgets[2].arg_name
|
||||
|
||||
|
||||
def test_spinbox_limits_from_scan_info(qtbot):
|
||||
group_input = {
|
||||
"name": "Kwarg Test",
|
||||
"inputs": [
|
||||
{
|
||||
"arg": False,
|
||||
"name": "exp_time",
|
||||
"type": "float",
|
||||
"display_name": "Exp Time",
|
||||
"tooltip": "Exposure time in seconds",
|
||||
"default": 2.0,
|
||||
"expert": False,
|
||||
"precision": 3,
|
||||
"gt": 1.5,
|
||||
"ge": None,
|
||||
"lt": 5.0,
|
||||
"le": None,
|
||||
},
|
||||
{
|
||||
"arg": False,
|
||||
"name": "num_points",
|
||||
"type": "int",
|
||||
"display_name": "Num Points",
|
||||
"tooltip": "Number of points",
|
||||
"default": 4,
|
||||
"expert": False,
|
||||
"gt": None,
|
||||
"ge": 3,
|
||||
"lt": 9,
|
||||
"le": None,
|
||||
},
|
||||
{
|
||||
"arg": False,
|
||||
"name": "settling_time",
|
||||
"type": "float",
|
||||
"display_name": "Settling Time",
|
||||
"tooltip": "Settling time in seconds",
|
||||
"default": 0.5,
|
||||
"expert": False,
|
||||
"gt": None,
|
||||
"ge": 0.2,
|
||||
"lt": None,
|
||||
"le": 3.5,
|
||||
},
|
||||
{
|
||||
"arg": False,
|
||||
"name": "steps",
|
||||
"type": "int",
|
||||
"display_name": "Steps",
|
||||
"tooltip": "Number of steps",
|
||||
"default": 4,
|
||||
"expert": False,
|
||||
"gt": 0,
|
||||
"ge": None,
|
||||
"lt": None,
|
||||
"le": 10,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
kwarg_box = ScanGroupBox(box_type="kwargs", config=group_input)
|
||||
|
||||
exp_time = kwarg_box.widgets[0]
|
||||
num_points = kwarg_box.widgets[1]
|
||||
settling_time = kwarg_box.widgets[2]
|
||||
steps = kwarg_box.widgets[3]
|
||||
|
||||
assert exp_time.decimals() == 3
|
||||
assert exp_time.minimum() == 1.501
|
||||
assert exp_time.maximum() == 4.999
|
||||
assert num_points.minimum() == 3
|
||||
assert num_points.maximum() == 8
|
||||
assert settling_time.minimum() == 0.2
|
||||
assert settling_time.maximum() == 3.5
|
||||
assert steps.minimum() == 1
|
||||
assert steps.maximum() == 10
|
||||
|
||||
@@ -36,26 +36,3 @@ def test_toggle_click(qtbot, toggle):
|
||||
qtbot.mouseClick(toggle, Qt.LeftButton)
|
||||
toggle.paintEvent(None)
|
||||
assert toggle.checked is not init_state
|
||||
|
||||
|
||||
def test_toggle_disabled_state_blocks_clicks_and_restores_colors(qtbot, toggle):
|
||||
toggle.checked = True
|
||||
assert toggle._track_color == toggle.active_track_color
|
||||
assert toggle._thumb_color == toggle.active_thumb_color
|
||||
|
||||
toggle.setEnabled(False)
|
||||
|
||||
assert toggle._track_color == toggle._disabled_track_color
|
||||
assert toggle._thumb_color == toggle._disabled_thumb_color
|
||||
|
||||
qtbot.mouseClick(toggle, Qt.LeftButton)
|
||||
|
||||
assert toggle.checked is True
|
||||
assert toggle._track_color == toggle._disabled_track_color
|
||||
assert toggle._thumb_color == toggle._disabled_thumb_color
|
||||
|
||||
toggle.setEnabled(True)
|
||||
|
||||
assert toggle.checked is True
|
||||
assert toggle._track_color == toggle.active_track_color
|
||||
assert toggle._thumb_color == toggle.active_thumb_color
|
||||
|
||||
Reference in New Issue
Block a user