1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-16 05:30:54 +02:00

Compare commits

...

16 Commits

Author SHA1 Message Date
semantic-release
0ae4b652a4 2.13.2
Automatically generated by python-semantic-release
2025-06-13 16:17:37 +00:00
32fd959e67 fix: allow sets in generated form types 2025-06-13 18:16:56 +02:00
semantic-release
73b1886bb8 2.13.1
Automatically generated by python-semantic-release
2025-06-12 12:51:59 +00:00
9f853b0864 fix(main_window): event filter applied on QEvent.Type.StatusTip; closes #698 2025-06-12 14:51:14 +02:00
semantic-release
18636e723a 2.13.0
Automatically generated by python-semantic-release
2025-06-10 15:18:29 +00:00
594185dde9 feat(image_roi_tree): lock/unlock rois possible through the ROIPropertyTree 2025-06-10 17:17:31 +02:00
46d7e3f517 feat(roi): rois can be lock to be not moved by mouse 2025-06-10 17:17:31 +02:00
f9044996f6 fix(roi): removed roi handle adding/removing inconsistencies 2025-06-10 17:17:31 +02:00
semantic-release
03474cf7f7 2.12.4
Automatically generated by python-semantic-release
2025-06-10 14:42:40 +00:00
9ef418bf55 fix(image_roi): coordinates are emitted correctly when handles are inverted; closes #672 2025-06-10 16:41:59 +02:00
b3ce68070d ci: add stale issue job 2025-06-06 14:48:10 +02:00
semantic-release
784b54af6e 2.12.3
Automatically generated by python-semantic-release
2025-06-05 19:07:20 +00:00
3740ac8e32 build: update min dependency of bec to 3.38 2025-06-05 21:06:32 +02:00
edfac87868 fix(crosshair): use objectName instead of config for retrieving the monitor name 2025-06-05 21:06:32 +02:00
271116453d fix(image): preview signals can be used in Image widget; update logic adjusted; closes #683 2025-06-05 21:06:32 +02:00
12f5233745 fix(device_combobox): tuple entries of preview signals are checked in DeviceComboBoxes just for the relevant device 2025-06-05 21:06:32 +02:00
16 changed files with 758 additions and 98 deletions

15
.github/workflows/stale-issues.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: 'Close stale issues and PRs'
on:
schedule:
- cron: '00 10 * * *'
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
days-before-stale: 60
days-before-close: 7

View File

@@ -1,6 +1,71 @@
# CHANGELOG
## v2.13.2 (2025-06-13)
### Bug Fixes
- Allow sets in generated form types
([`32fd959`](https://github.com/bec-project/bec_widgets/commit/32fd959e675108265f35139b44d02ba966bd37e2))
## v2.13.1 (2025-06-12)
### Bug Fixes
- **main_window**: Event filter applied on QEvent.Type.StatusTip; closes #698
([`9f853b0`](https://github.com/bec-project/bec_widgets/commit/9f853b08640f0ffff9f5b59c6d5e0dd3e210d4f6))
## v2.13.0 (2025-06-10)
### Bug Fixes
- **roi**: Removed roi handle adding/removing inconsistencies
([`f904499`](https://github.com/bec-project/bec_widgets/commit/f9044996f6d62cdbb693149934b09625fb39fd55))
### Features
- **image_roi_tree**: Lock/unlock rois possible through the ROIPropertyTree
([`594185d`](https://github.com/bec-project/bec_widgets/commit/594185dde9c73991489f2154507f1c3d3822c5b4))
- **roi**: Rois can be lock to be not moved by mouse
([`46d7e3f`](https://github.com/bec-project/bec_widgets/commit/46d7e3f5170a5f8b444043bc49651921816f7003))
## v2.12.4 (2025-06-10)
### Bug Fixes
- **image_roi**: Coordinates are emitted correctly when handles are inverted; closes #672
([`9ef418b`](https://github.com/bec-project/bec_widgets/commit/9ef418bf5597d4be77adc3c0c88c1c1619c9aa2f))
### Continuous Integration
- Add stale issue job
([`b3ce680`](https://github.com/bec-project/bec_widgets/commit/b3ce68070d58cdd76559cbd7db04cdbcc6c1f075))
## v2.12.3 (2025-06-05)
### Bug Fixes
- **crosshair**: Use objectName instead of config for retrieving the monitor name
([`edfac87`](https://github.com/bec-project/bec_widgets/commit/edfac87868605b4b755f7732b2841673de53bc3f))
- **device_combobox**: Tuple entries of preview signals are checked in DeviceComboBoxes just for the
relevant device
([`12f5233`](https://github.com/bec-project/bec_widgets/commit/12f523374586d55499f80baf56a50b6ef486cd43))
- **image**: Preview signals can be used in Image widget; update logic adjusted; closes #683
([`2711164`](https://github.com/bec-project/bec_widgets/commit/271116453d1ef5316b19457d04613b6ddc939cdb))
### Build System
- Update min dependency of bec to 3.38
([`3740ac8`](https://github.com/bec-project/bec_widgets/commit/3740ac8e325a489d59faca648896ffcea29e1a02))
## v2.12.2 (2025-06-05)
### Bug Fixes

View File

@@ -530,6 +530,26 @@ class BaseROI(RPCBase):
str: The current name of the ROI.
"""
@property
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@movable.setter
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@property
@rpc_call
def line_color(self) -> "str":
@@ -639,6 +659,26 @@ class CircularROI(RPCBase):
str: The current name of the ROI.
"""
@property
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@movable.setter
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@property
@rpc_call
def line_color(self) -> "str":
@@ -1459,12 +1499,12 @@ class Image(RPCBase):
@rpc_call
def image(
self,
monitor: "str | None" = None,
monitor: "str | tuple | None" = None,
monitor_type: "Literal['auto', '1d', '2d']" = "auto",
color_map: "str | None" = None,
color_bar: "Literal['simple', 'full'] | None" = None,
vrange: "tuple[int, int] | None" = None,
) -> "ImageItem":
) -> "ImageItem | None":
"""
Set the image source and update the image.
@@ -1494,6 +1534,7 @@ class Image(RPCBase):
line_width: "int | None" = 5,
pos: "tuple[float, float] | None" = (10, 10),
size: "tuple[float, float] | None" = (50, 50),
movable: "bool" = True,
**pg_kwargs,
) -> "RectangularROI | CircularROI":
"""
@@ -1505,6 +1546,7 @@ class Image(RPCBase):
line_width(int): The line width of the ROI.
pos(tuple): The position of the ROI.
size(tuple): The size of the ROI.
movable(bool): Whether the ROI is movable.
**pg_kwargs: Additional arguments for the ROI.
Returns:
@@ -2664,6 +2706,26 @@ class RectangularROI(RPCBase):
str: The current name of the ROI.
"""
@property
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@movable.setter
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@property
@rpc_call
def line_color(self) -> "str":

View File

@@ -458,7 +458,7 @@ class Crosshair(QObject):
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor or str(id(item))
name = item.objectName() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue

View File

@@ -64,12 +64,12 @@ class FormItemSpec(BaseModel):
if isinstance(v, (type, UnionType)):
return v
if isinstance(v, GenericAlias):
if v.__origin__ in [list, dict] and all(
if v.__origin__ in [list, dict, set] and all(
arg in allowed_primitives for arg in v.__args__
):
return v
raise ValueError(
f"Generics of type {v} are not supported - only lists and dicts of primitive types {allowed_primitives}"
f"Generics of type {v} are not supported - only lists, dicts and sets of primitive types {allowed_primitives}"
)
if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing
arg_types = set(type(arg) for arg in v.__args__)

View File

@@ -1,6 +1,6 @@
import os
from qtpy.QtCore import QSize
from qtpy.QtCore import QEvent, QSize
from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
@@ -168,6 +168,11 @@ class BECMainWindow(BECWidget, QMainWindow):
def change_theme(self, theme: str):
apply_theme(theme)
def event(self, event):
if event.type() == QEvent.Type.StatusTip:
return True
return super().event(event)
def cleanup(self):
central_widget = self.centralWidget()
central_widget.close()

View File

@@ -149,6 +149,25 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self._is_valid_input = False
self.update()
def validate_device(self, device: str) -> bool: # type: ignore[override]
"""
Extend validation so that previewsignal pseudodevices (labels like
``"eiger_preview"``) are accepted as valid choices.
The validation run only on device not on the previewsignal.
Args:
device: The text currently entered/selected.
Returns:
True if the device is a genuine BEC device *or* one of the
whitelisted previewsignal entries.
"""
idx = self.findText(device)
if idx >= 0 and isinstance(self.itemData(idx), tuple):
device = self.itemData(idx)[0] # type: ignore[assignment]
return super().validate_device(device)
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel

View File

@@ -35,7 +35,7 @@ class ImageConfig(ConnectionConfig):
class ImageLayerConfig(BaseModel):
monitor: str | None = Field(None, description="The name of the monitor.")
monitor: str | tuple | None = Field(None, description="The name of the monitor.")
monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.")
source: Literal["device_monitor_1d", "device_monitor_2d", "auto"] = Field(
"auto", description="The source of the image data."
@@ -179,12 +179,12 @@ class Image(ImageBase):
@SafeSlot(popup_error=True)
def image(
self,
monitor: str | None = None,
monitor: str | tuple | None = None,
monitor_type: Literal["auto", "1d", "2d"] = "auto",
color_map: str | None = None,
color_bar: Literal["simple", "full"] | None = None,
vrange: tuple[int, int] | None = None,
) -> ImageItem:
) -> ImageItem | None:
"""
Set the image source and update the image.
@@ -201,21 +201,13 @@ class Image(ImageBase):
if self.subscriptions["main"].monitor:
self.disconnect_monitor(self.subscriptions["main"].monitor)
self.entry_validator.validate_monitor(monitor)
self.subscriptions["main"].monitor = monitor
if monitor_type == "1d":
self.subscriptions["main"].source = "device_monitor_1d"
self.subscriptions["main"].monitor_type = "1d"
elif monitor_type == "2d":
self.subscriptions["main"].source = "device_monitor_2d"
self.subscriptions["main"].monitor_type = "2d"
elif monitor_type == "auto":
self.subscriptions["main"].source = "auto"
logger.warning(
f"Updates for '{monitor}' will be fetch from both 1D and 2D monitor endpoints."
)
self.subscriptions["main"].monitor_type = "auto"
if monitor is None or monitor == "":
logger.warning(f"No monitor specified, cannot set image, old monitor is unsubscribed")
return None
if isinstance(monitor, tuple):
self.entry_validator.validate_monitor(monitor[0])
else:
self.entry_validator.validate_monitor(monitor)
self.set_image_update(monitor=monitor, type=monitor_type)
if color_map is not None:
@@ -240,7 +232,12 @@ class Image(ImageBase):
self.selection_bundle.dim_combo_box,
):
combo.blockSignals(True)
self.selection_bundle.device_combo_box.set_device(config.monitor)
if isinstance(config.monitor, tuple):
self.selection_bundle.device_combo_box.setCurrentText(
f"{config.monitor[0]}_{config.monitor[1]}"
)
else:
self.selection_bundle.device_combo_box.setCurrentText(config.monitor)
self.selection_bundle.dim_combo_box.setCurrentText(config.monitor_type)
for combo in (
self.selection_bundle.device_combo_box,
@@ -340,7 +337,8 @@ class Image(ImageBase):
########################################
# Connections
def set_image_update(self, monitor: str, type: Literal["1d", "2d", "auto"]):
@SafeSlot()
def set_image_update(self, monitor: str | tuple, type: Literal["1d", "2d", "auto"]):
"""
Set the image update method for the given monitor.
@@ -350,37 +348,95 @@ class Image(ImageBase):
"""
# TODO consider moving connecting and disconnecting logic to Image itself if multiple images
if type == "1d":
self.bec_dispatcher.connect_slot(
self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor)
)
elif type == "2d":
self.bec_dispatcher.connect_slot(
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
)
elif type == "auto":
self.bec_dispatcher.connect_slot(
self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor)
)
self.bec_dispatcher.connect_slot(
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
)
if isinstance(monitor, tuple):
device = self.dev[monitor[0]]
signal = monitor[1]
if len(monitor) == 3:
signal_config = monitor[2]
else:
signal_config = device._info["signals"][signal]
signal_class = signal_config.get("signal_class", None)
if signal_class != "PreviewSignal":
logger.warning(f"Signal '{monitor}' is not a PreviewSignal.")
return
ndim = signal_config.get("describe", None).get("signal_info", None).get("ndim", None)
if ndim is None:
logger.warning(
f"Signal '{monitor}' does not have a valid 'ndim' in its signal_info."
)
return
if ndim == 1:
self.bec_dispatcher.connect_slot(
self.on_image_update_1d, MessageEndpoints.device_preview(device.name, signal)
)
self.subscriptions["main"].source = "device_monitor_1d"
self.subscriptions["main"].monitor_type = "1d"
elif ndim == 2:
self.bec_dispatcher.connect_slot(
self.on_image_update_2d, MessageEndpoints.device_preview(device.name, signal)
)
self.subscriptions["main"].source = "device_monitor_2d"
self.subscriptions["main"].monitor_type = "2d"
else: # FIXME old monitor 1d/2d endpoint handling, present for backwards compatibility, will be removed in future versions
if type == "1d":
self.bec_dispatcher.connect_slot(
self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor)
)
self.subscriptions["main"].source = "device_monitor_1d"
self.subscriptions["main"].monitor_type = "1d"
elif type == "2d":
self.bec_dispatcher.connect_slot(
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
)
self.subscriptions["main"].source = "device_monitor_2d"
self.subscriptions["main"].monitor_type = "2d"
elif type == "auto":
self.bec_dispatcher.connect_slot(
self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor)
)
self.bec_dispatcher.connect_slot(
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
)
self.subscriptions["main"].source = "auto"
logger.warning(
f"Updates for '{monitor}' will be fetch from both 1D and 2D monitor endpoints."
)
self.subscriptions["main"].monitor_type = "auto"
logger.info(f"Connected to {monitor} with type {type}")
self.subscriptions["main"].monitor = monitor
def disconnect_monitor(self, monitor: str):
def disconnect_monitor(self, monitor: str | tuple):
"""
Disconnect the monitor from the image update signals, both 1D and 2D.
Args:
monitor(str): The name of the monitor to disconnect.
monitor(str|tuple): The name of the monitor to disconnect, or a tuple of (device, signal) for preview signals.
"""
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor)
)
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
)
if isinstance(monitor, tuple):
if self.subscriptions["main"].source == "device_monitor_1d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d, MessageEndpoints.device_preview(monitor[0], monitor[1])
)
elif self.subscriptions["main"].source == "device_monitor_2d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d, MessageEndpoints.device_preview(monitor[0], monitor[1])
)
else:
logger.warning(
f"Cannot disconnect monitor {monitor} with source {self.subscriptions['main'].source}"
)
return
else: # FIXME old monitor 1d/2d endpoint handling, present for backwards compatibility, will be removed in future versions
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor)
)
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
)
self.subscriptions["main"].monitor = None
self._sync_device_selection()

View File

@@ -559,6 +559,7 @@ class ImageBase(PlotBase):
line_width: int | None = 5,
pos: tuple[float, float] | None = (10, 10),
size: tuple[float, float] | None = (50, 50),
movable: bool = True,
**pg_kwargs,
) -> RectangularROI | CircularROI:
"""
@@ -570,6 +571,7 @@ class ImageBase(PlotBase):
line_width(int): The line width of the ROI.
pos(tuple): The position of the ROI.
size(tuple): The size of the ROI.
movable(bool): Whether the ROI is movable.
**pg_kwargs: Additional arguments for the ROI.
Returns:
@@ -584,6 +586,7 @@ class ImageBase(PlotBase):
parent_image=self,
line_width=line_width,
label=name,
movable=movable,
**pg_kwargs,
)
elif kind == "circle":
@@ -593,6 +596,7 @@ class ImageBase(PlotBase):
parent_image=self,
line_width=line_width,
label=name,
movable=movable,
**pg_kwargs,
)
else:
@@ -601,7 +605,6 @@ class ImageBase(PlotBase):
# Add to plot and controller (controller assigns color)
self.plot_item.addItem(roi)
self.roi_controller.add_roi(roi)
roi.add_scale_handle()
return roi
def remove_roi(self, roi: int | str):

View File

@@ -8,6 +8,7 @@ from qtpy.QtCore import QEvent, Qt
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QColorDialog,
QHBoxLayout,
QHeaderView,
QSpinBox,
QToolButton,
@@ -35,6 +36,28 @@ if TYPE_CHECKING:
from bec_widgets.widgets.plots.image.image import Image
class ROILockButton(QToolButton):
"""Keeps its icon and checked state in sync with a single ROI."""
def __init__(self, roi: BaseROI, parent=None):
super().__init__(parent)
self.setCheckable(True)
self._roi = roi
self.clicked.connect(self._toggle)
roi.movableChanged.connect(lambda _: self._sync())
self._sync()
def _toggle(self):
# checked -> locked -> movable = False
self._roi.movable = not self.isChecked()
def _sync(self):
movable = self._roi.movable
self.setChecked(not movable)
icon = "lock_open_right" if movable else "lock"
self.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
class ROIPropertyTree(BECWidget, QWidget):
"""
Two-column tree: [ROI] [Properties]
@@ -124,6 +147,24 @@ class ROIPropertyTree(BECWidget, QWidget):
self.expand_toggle.action.toggled.connect(_exp_toggled)
self.expand_toggle.action.setChecked(False)
# Lock/Unlock all ROIs
self.lock_all_action = MaterialIconAction(
"lock_open_right", "Lock/Unlock all ROIs", checkable=True, parent=self
)
tb.add_action("Lock/Unlock all ROIs", self.lock_all_action, self)
def _lock_all(checked: bool):
# checked -> everything locked (movable = False)
for r in self.controller.rois:
r.movable = not checked
new_icon = material_icon(
"lock" if checked else "lock_open_right", size=(20, 20), convert_to_pixmap=False
)
self.lock_all_action.action.setIcon(new_icon)
self.lock_all_action.action.toggled.connect(_lock_all)
# colormap widget
self.cmap = BECColorMapWidget(cmap=self.controller.colormap)
tb.addWidget(QWidget()) # spacer
@@ -235,18 +276,30 @@ class ROIPropertyTree(BECWidget, QWidget):
self._temp_roi = None
self._set_roi_draw_mode(None)
# register via controller
final_roi.add_scale_handle()
self.controller.add_roi(final_roi)
return True
return super().eventFilter(obj, event)
# --------------------------------------------------------- controller slots
def _on_roi_added(self, roi: BaseROI):
# check the global setting from the toolbar
if self.lock_all_action.action.isChecked():
roi.movable = False
# parent row with blank action column, name in ROI column
parent = QTreeWidgetItem(self.tree, ["", "", ""])
parent.setText(self.COL_ROI, roi.label)
parent.setFlags(parent.flags() | Qt.ItemIsEditable)
# --- delete button in actions column ---
# --- actions widget (lock/unlock + delete) ---
actions_widget = QWidget()
actions_layout = QHBoxLayout(actions_widget)
actions_layout.setContentsMargins(0, 0, 0, 0)
actions_layout.setSpacing(3)
# lock / unlock toggle
lock_btn = ROILockButton(roi, parent=self)
actions_layout.addWidget(lock_btn)
# delete button
del_btn = QToolButton()
delete_icon = material_icon(
"delete",
@@ -256,8 +309,11 @@ class ROIPropertyTree(BECWidget, QWidget):
color=self.DELETE_BUTTON_COLOR,
)
del_btn.setIcon(delete_icon)
self.tree.setItemWidget(parent, self.COL_ACTION, del_btn)
del_btn.clicked.connect(lambda _=None, r=roi: self._delete_roi(r))
actions_layout.addWidget(del_btn)
# install composite widget into the tree
self.tree.setItemWidget(parent, self.COL_ACTION, actions_widget)
# color button
color_btn = ColorButtonNative(parent=self, color=roi.line_color)
self.tree.setItemWidget(parent, self.COL_PROPS, color_btn)
@@ -309,6 +365,12 @@ class ROIPropertyTree(BECWidget, QWidget):
for c in range(3):
self.tree.resizeColumnToContents(c)
def _toggle_movable(self, roi: BaseROI):
"""
Toggle the `movable` property of the given ROI.
"""
roi.movable = not roi.movable
def _on_roi_removed(self, roi: BaseROI):
item = self.roi_items.pop(roi, None)
if item:
@@ -345,7 +407,7 @@ if __name__ == "__main__": # pragma: no cover
import sys
import numpy as np
from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.plots.image.image import Image

View File

@@ -1,5 +1,5 @@
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate
from bec_widgets.utils.error_popups import SafeSlot
@@ -50,11 +50,58 @@ class MonitorSelectionToolbarBundle(ToolbarBundle):
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False))
# Connect slots, a device will be connected upon change of any combobox
self.device_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
self.dim_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
self.device_combo_box.currentTextChanged.connect(self.connect_monitor)
self.dim_combo_box.currentTextChanged.connect(self.connect_monitor)
QTimer.singleShot(0, self._adjust_and_connect)
def _adjust_and_connect(self):
"""
Adjust the size of the device combo box and populate it with preview signals.
Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing.
"""
self._populate_preview_signals()
self._reverse_device_items()
self.device_combo_box.setCurrentText("") # set again default to empty string
def _populate_preview_signals(self) -> None:
"""
Populate the device combo box with previewsignal devices in the
format '<device>_<signal>' and store the tuple(device, signal) in
the item's userData for later use.
"""
preview_signals = self.target_widget.client.device_manager.get_bec_signals("PreviewSignal")
for device, signal, signal_config in preview_signals:
label = signal_config.get("obj_name", f"{device}_{signal}")
self.device_combo_box.addItem(label, (device, signal, signal_config))
def _reverse_device_items(self) -> None:
"""
Reverse the current order of items in the device combo box while
keeping their userData and restoring the previous selection.
"""
current_text = self.device_combo_box.currentText()
items = [
(self.device_combo_box.itemText(i), self.device_combo_box.itemData(i))
for i in range(self.device_combo_box.count())
]
self.device_combo_box.clear()
for text, data in reversed(items):
self.device_combo_box.addItem(text, data)
if current_text:
self.device_combo_box.setCurrentText(current_text)
@SafeSlot()
def connect_monitor(self):
def connect_monitor(self, *args, **kwargs):
"""
Connect the target widget to the selected monitor based on the current device and dimension.
If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor.
"""
dim = self.dim_combo_box.currentText()
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)
data = self.device_combo_box.currentData()
if isinstance(data, tuple):
self.target_widget.image(monitor=data, monitor_type="auto")
else:
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)

View File

@@ -104,9 +104,12 @@ class BaseROI(BECConnector):
nameChanged = Signal(str)
penChanged = Signal()
movableChanged = Signal(bool)
USER_ACCESS = [
"label",
"label.setter",
"movable",
"movable.setter",
"line_color",
"line_color.setter",
"line_width",
@@ -127,6 +130,7 @@ class BaseROI(BECConnector):
label: str | None = None,
line_color: str | None = None,
line_width: int = 5,
movable: bool = True,
# all remaining pg.*ROI kwargs (pos, size, pen, …)
**pg_kwargs,
):
@@ -155,6 +159,7 @@ class BaseROI(BECConnector):
gui_id=gui_id,
removable=True,
invertible=True,
movable=movable,
**pg_kwargs,
)
@@ -162,8 +167,14 @@ class BaseROI(BECConnector):
self._line_color = line_color or "#ffffff"
self._line_width = line_width
self._description = True
self._movable = movable
self.setPen(mkPen(self._line_color, width=self._line_width))
# Reset Handles to avoid inherited handles from pyqtgraph
self.remove_scale_handles() # remove any existing handles from pyqtgraph.RectROI
if movable:
self.add_scale_handle() # add custom scale handles
def set_parent(self, parent: Image):
"""
Sets the parent image for this ROI.
@@ -182,6 +193,40 @@ class BaseROI(BECConnector):
"""
return self.parent_image
@property
def movable(self) -> bool:
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
return self._movable
@movable.setter
def movable(self, value: bool):
"""
Sets whether this ROI is movable.
If the new value is different from the current value, this method updates
the internal state and emits the penChanged signal.
Args:
value (bool): True to make the ROI movable, False to make it fixed.
"""
if value != self._movable:
self._movable = value
# All relevant properties from pyqtgraph to block movement
self.translatable = value
self.rotatable = value
self.resizable = value
self.removable = value
if value:
self.add_scale_handle() # add custom scale handles
else:
self.remove_scale_handles() # remove custom scale handles
self.movableChanged.emit(value)
@property
def label(self) -> str:
"""
@@ -337,8 +382,18 @@ class BaseROI(BECConnector):
)
def add_scale_handle(self):
"""Add scale handles to the ROI."""
return
def remove_scale_handles(self):
"""Remove all scale handles from the ROI."""
handles = self.handles
for i in range(len(handles)):
try:
self.removeHandle(0)
except IndexError:
continue
def set_position(self, x: float, y: float):
"""
Sets the position of the ROI.
@@ -355,12 +410,7 @@ class BaseROI(BECConnector):
if controller and self in controller.rois:
controller.remove_roi(self)
return # controller will call back into this method once deregistered
handles = self.handles
for i in range(len(handles)):
try:
self.removeHandle(0)
except IndexError:
continue
self.remove_scale_handles()
self.rpc_register.remove_rpc(self)
self.parent_image.plot_item.removeItem(self)
viewBox = self.parent_plot_item.vb
@@ -399,6 +449,7 @@ class RectangularROI(BaseROI, pg.RectROI):
label: str | None = None,
line_color: str | None = None,
line_width: int = 5,
movable: bool = True,
resize_handles: bool = True,
**extra_pg,
):
@@ -429,6 +480,7 @@ class RectangularROI(BaseROI, pg.RectROI):
pos=pos,
size=size,
pen=pen,
movable=movable,
**extra_pg,
)
@@ -437,6 +489,23 @@ class RectangularROI(BaseROI, pg.RectROI):
self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
self.handleHoverPen = fn.mkPen("lime", width=4)
def _normalized_edges(self) -> tuple[float, float, float, float]:
"""
Return rectangle edges as (left, bottom, right, top) with consistent
ordering even when the ROI has been inverted by its scale handles.
Returns:
tuple: A tuple containing the left, bottom, right, and top edges
of the ROI rectangle in normalized coordinates.
"""
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
x_left = min(x0, x0 + w)
x_right = max(x0, x0 + w)
y_bottom = min(y0, y0 + h)
y_top = max(y0, y0 + h)
return x_left, y_bottom, x_right, y_top
def add_scale_handle(self):
"""
Add scale handles at every corner and edge of the ROI.
@@ -458,24 +527,17 @@ class RectangularROI(BaseROI, pg.RectROI):
self.addScaleHandle([0, 0.5], [1, 0.5]) # left edge
self.addScaleHandle([1, 0.5], [0, 0.5]) # right edge
self.handlePen = fn.mkPen("#ffff00", width=5) # bright yellow outline
self.handleHoverPen = fn.mkPen("#00ffff", width=4) # cyan, thicker when hovered
self.handleBrush = (200, 200, 0, 120) # semi-transparent fill
self.handleHoverBrush = (0, 255, 255, 160)
def _on_region_changed(self):
"""
Handles ROI region change events.
Handles changes to the ROI's region.
This method is called whenever the ROI's position or size changes.
It calculates the new corner coordinates and emits the edgesChanged signal
with the updated coordinates.
"""
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
self.edgesChanged.emit(x0, y0, x0 + w, y0 + h)
viewBox = self.parent_plot_item.vb
viewBox.update()
x_left, y_bottom, x_right, y_top = self._normalized_edges()
self.edgesChanged.emit(x_left, y_bottom, x_right, y_top)
self.parent_plot_item.vb.update()
def mouseDragEvent(self, ev):
"""
@@ -489,9 +551,8 @@ class RectangularROI(BaseROI, pg.RectROI):
"""
super().mouseDragEvent(ev)
if ev.isFinish():
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
self.edgesReleased.emit(x0, y0, x0 + w, y0 + h)
x_left, y_bottom, x_right, y_top = self._normalized_edges()
self.edgesReleased.emit(x_left, y_bottom, x_right, y_top)
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
"""
@@ -510,17 +571,16 @@ class RectangularROI(BaseROI, pg.RectROI):
if typed is None:
typed = self.description
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
x1, y1 = x0 + w, y0 + h
x_left, y_bottom, x_right, y_top = self._normalized_edges()
if typed:
return {
"bottom_left": (x0, y0),
"bottom_right": (x1, y0),
"top_left": (x0, y1),
"top_right": (x1, y1),
"bottom_left": (x_left, y_bottom),
"bottom_right": (x_right, y_bottom),
"top_left": (x_left, y_top),
"top_right": (x_right, y_top),
}
return ((x0, y0), (x1, y0), (x0, y1), (x1, y1))
return (x_left, y_bottom), (x_right, y_bottom), (x_left, y_top), (x_right, y_top)
def _lookup_scene_image(self):
"""
@@ -568,6 +628,7 @@ class CircularROI(BaseROI, pg.CircleROI):
label: str | None = None,
line_color: str | None = None,
line_width: int = 5,
movable: bool = True,
**extra_pg,
):
"""
@@ -599,10 +660,19 @@ class CircularROI(BaseROI, pg.CircleROI):
pos=pos,
size=size,
pen=pen,
movable=movable,
**extra_pg,
)
self.sigRegionChanged.connect(self._on_region_changed)
self._adorner = LabelAdorner(self)
self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
self.handleHoverPen = fn.mkPen("lime", width=4)
def add_scale_handle(self):
"""
Adds scale handles to the circular ROI.
"""
self._addHandles() # wrapper around pg.CircleROI._addHandles
def _on_region_changed(self):
"""
@@ -654,7 +724,7 @@ class CircularROI(BaseROI, pg.CircleROI):
if typed is None:
typed = self.description
d = self.state["size"][0]
d = abs(self.state["size"][0])
cx = self.pos().x() + d / 2
cy = self.pos().y() + d / 2

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.12.2"
version = "2.13.2"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -13,15 +13,15 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
"bec_ipython_client>=2.21.4, <=4.0", # needed for jupyter console
"bec_lib>=3.29, <=4.0",
"bec_ipython_client>=3.38, <=4.0", # needed for jupyter console
"bec_lib>=3.38, <=4.0",
"bec_qthemes~=0.7, >=0.7",
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"pydantic~=2.0",
"pyqtgraph~=0.13",
"PySide6~=6.8.2",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
]

View File

@@ -148,10 +148,10 @@ def test_delete_roi_button(roi_tree, image_widget, qtbot):
roi = image_widget.add_roi(kind="rect", name="to_delete")
item = roi_tree.roi_items[roi]
# Get the delete button
del_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
action_widget = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
layout = action_widget.layout()
# Click the delete button
del_btn = layout.itemAt(1).widget()
del_btn.click()
qtbot.wait(200)
@@ -331,3 +331,67 @@ def test_add_roi_from_toolbar(qtbot, mocked_client):
# Verify it's a circle ROI
assert isinstance(new_roi, CircularROI)
def test_roi_lock_button(roi_tree, image_widget, qtbot):
"""Verify the individual lock button toggles ROI.movable."""
roi = image_widget.add_roi(kind="rect", name="lock_test")
item = roi_tree.roi_items[roi]
# Lock button is the first widget in the Actions layout
action_widget = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
lock_btn = action_widget.layout().itemAt(0).widget()
# Initially unlocked
assert roi.movable
assert not lock_btn.isChecked()
# Lock it
lock_btn.click()
qtbot.wait(200)
assert not roi.movable
assert lock_btn.isChecked()
# Unlock again
lock_btn.click()
qtbot.wait(200)
assert roi.movable
assert not lock_btn.isChecked()
def test_global_lock_all_button(roi_tree, image_widget, qtbot):
"""Verify the toolbar lock-all action locks/unlocks every ROI."""
roi1 = image_widget.add_roi(kind="rect", name="g1")
roi2 = image_widget.add_roi(kind="circle", name="g2")
lock_all = roi_tree.lock_all_action.action
# Start unlocked
assert roi1.movable and roi2.movable
assert not lock_all.isChecked()
# Toggle → lock everything
lock_all.trigger()
qtbot.wait(200)
assert lock_all.isChecked()
assert not roi1.movable and not roi2.movable
# Toggle again → unlock everything
lock_all.trigger()
qtbot.wait(200)
assert not lock_all.isChecked()
assert roi1.movable and roi2.movable
def test_new_roi_respects_global_lock(roi_tree, image_widget, qtbot):
"""When the global lock-all toggle is active, newly added ROIs start locked."""
# Enable global lock
roi_tree.lock_all_action.action.setChecked(True)
qtbot.wait(100)
# Add ROI after lock enabled
roi = image_widget.add_roi(kind="rect", name="new_locked")
assert not roi.movable
# Disable global lock again
roi_tree.lock_all_action.action.setChecked(False)

View File

@@ -205,3 +205,29 @@ def test_roi_set_position(bec_image_widget_with_roi):
pos = roi.pos()
assert int(pos.x()) == 10
assert int(pos.y()) == 15
def test_roi_movable_property(bec_image_widget_with_roi, qtbot):
"""Verify BaseROI.movable toggles flags, handles, and emits a signal."""
_widget, roi, _ = bec_image_widget_with_roi
# defaults ROI is movable
assert roi.movable
assert roi.translatable and roi.rotatable and roi.resizable and roi.removable
assert len(roi.handles) > 0
# lock it
with qtbot.waitSignal(roi.movableChanged) as blocker:
roi.movable = False
assert blocker.args == [False]
assert not roi.movable
assert not (roi.translatable or roi.rotatable or roi.resizable or roi.removable)
assert len(roi.handles) == 0
# unlock again
with qtbot.waitSignal(roi.movableChanged) as blocker:
roi.movable = True
assert blocker.args == [True]
assert roi.movable
assert roi.translatable and roi.rotatable and roi.resizable and roi.removable
assert len(roi.handles) > 0

View File

@@ -113,6 +113,75 @@ def test_enable_colorbar_with_vrange(qtbot, mocked_client, colorbar_type):
assert bec_image_view._color_bar is not None
##############################################
# Previewsignal update mechanism
def test_image_setup_preview_signal_1d(qtbot, mocked_client, monkeypatch):
"""
Ensure that calling .image() with a (device, signal, config) tuple representing
a 1D PreviewSignal connects using the 1D path and updates correctly.
"""
import numpy as np
view = create_widget(qtbot, Image, client=mocked_client)
signal_config = {
"obj_name": "waveform1d_img",
"signal_class": "PreviewSignal",
"describe": {"signal_info": {"ndim": 1}},
}
# Set the image monitor to the preview signal
view.image(monitor=("waveform1d", "img", signal_config))
# Subscriptions should indicate 1D preview connection
sub = view.subscriptions["main"]
assert sub.source == "device_monitor_1d"
assert sub.monitor_type == "1d"
assert sub.monitor == ("waveform1d", "img", signal_config)
# Simulate a waveform update from the dispatcher
waveform = np.arange(25, dtype=float)
view.on_image_update_1d({"data": waveform}, {"scan_id": "scan_test"})
assert view.main_image.raw_data.shape == (1, 25)
np.testing.assert_array_equal(view.main_image.raw_data[0], waveform)
def test_image_setup_preview_signal_2d(qtbot, mocked_client, monkeypatch):
"""
Ensure that calling .image() with a (device, signal, config) tuple representing
a 2D PreviewSignal connects using the 2D path and updates correctly.
"""
import numpy as np
view = create_widget(qtbot, Image, client=mocked_client)
signal_config = {
"obj_name": "eiger_img2d",
"signal_class": "PreviewSignal",
"describe": {"signal_info": {"ndim": 2}},
}
# Set the image monitor to the preview signal
view.image(monitor=("eiger", "img2d", signal_config))
# Subscriptions should indicate 2D preview connection
sub = view.subscriptions["main"]
assert sub.source == "device_monitor_2d"
assert sub.monitor_type == "2d"
assert sub.monitor == ("eiger", "img2d", signal_config)
# Simulate a 2D image update
test_data = np.arange(16, dtype=float).reshape(4, 4)
view.on_image_update_2d({"data": test_data}, {})
np.testing.assert_array_equal(view.main_image.image, test_data)
##############################################
# Device monitor endpoint update mechanism
def test_image_setup_image_2d(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.image(monitor="eiger", monitor_type="2d")
@@ -167,6 +236,10 @@ def test_image_data_update_1d(qtbot, mocked_client):
assert bec_image_view.main_image.raw_data.shape == (2, 60)
##############################################
# Toolbar and Actions Tests
def test_toolbar_actions_presence(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
assert "autorange_image" in bec_image_view.toolbar.widgets
@@ -484,3 +557,96 @@ def test_roi_plot_data_from_image(qtbot, mocked_client):
# Horizontal slice (row)
h_slice, _ = y_items[0].getData()
np.testing.assert_array_equal(h_slice, test_data[2])
##############################################
# MonitorSelectionToolbarBundle specific tests
##############################################
def test_monitor_selection_reverse_device_items(qtbot, mocked_client):
"""
Verify that _reverse_device_items correctly reverses the order of items in the
device combobox while preserving the current selection.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
combo = bundle.device_combo_box
# Replace existing items with a deterministic list
combo.clear()
combo.addItem("samx", 1)
combo.addItem("samy", 2)
combo.addItem("samz", 3)
combo.setCurrentText("samy")
# Reverse the items
bundle._reverse_device_items()
# Order should be reversed and selection preserved
assert [combo.itemText(i) for i in range(combo.count())] == ["samz", "samy", "samx"]
assert combo.currentText() == "samy"
def test_monitor_selection_populate_preview_signals(qtbot, mocked_client, monkeypatch):
"""
Verify that _populate_preview_signals adds previewsignal devices to the combobox
with the correct userData.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
# Provide a deterministic fake device_manager with get_bec_signals
class _FakeDM:
def get_bec_signals(self, _filter):
return [
("eiger", "img", {"obj_name": "eiger_img"}),
("async_device", "img2", {"obj_name": "async_device_img2"}),
]
monkeypatch.setattr(view.client, "device_manager", _FakeDM())
initial_count = bundle.device_combo_box.count()
bundle._populate_preview_signals()
# Two new entries should have been added
assert bundle.device_combo_box.count() == initial_count + 2
# The first newly added item should carry tuple userData describing the device/signal
data = bundle.device_combo_box.itemData(initial_count)
assert isinstance(data, tuple) and data[0] == "eiger"
def test_monitor_selection_adjust_and_connect(qtbot, mocked_client, monkeypatch):
"""
Verify that _adjust_and_connect performs the full setup:
fills the combobox with preview signals,
reverses their order,
and resets the currentText to an empty string.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
# Deterministic fake device_manager
class _FakeDM:
def get_bec_signals(self, _filter):
return [("eiger", "img", {"obj_name": "eiger_img"})]
monkeypatch.setattr(view.client, "device_manager", _FakeDM())
combo = bundle.device_combo_box
# Start from a clean state
combo.clear()
combo.addItem("", None)
combo.setCurrentText("")
# Execute the method under test
bundle._adjust_and_connect()
# Expect exactly two items: preview label followed by the empty default
assert combo.count() == 2
# Because of the reversal, the preview label comes first
assert combo.itemText(0) == "eiger_img"
# Current selection remains empty
assert combo.currentText() == ""