mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-16 13:38:51 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae4b652a4 | ||
| 32fd959e67 | |||
|
|
73b1886bb8 | ||
| 9f853b0864 | |||
|
|
18636e723a | ||
| 594185dde9 | |||
| 46d7e3f517 | |||
| f9044996f6 | |||
|
|
03474cf7f7 | ||
| 9ef418bf55 | |||
| b3ce68070d | |||
|
|
784b54af6e | ||
| 3740ac8e32 | |||
| edfac87868 | |||
| 271116453d | |||
| 12f5233745 | |||
|
|
392ddf9d1a | ||
| 85705383e4 | |||
|
|
224863569f | ||
| 3e2544e52a |
15
.github/workflows/stale-issues.yml
vendored
Normal file
15
.github/workflows/stale-issues.yml
vendored
Normal 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
|
||||
81
CHANGELOG.md
81
CHANGELOG.md
@@ -1,6 +1,87 @@
|
||||
# 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
|
||||
|
||||
- **waveform**: Safeguard for history data access, closes #571; removed return values "none"
|
||||
([`8570538`](https://github.com/bec-project/bec_widgets/commit/85705383e4aff2f83f76d342db0a13380aeca42f))
|
||||
|
||||
|
||||
## v2.12.1 (2025-06-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **crosshair**: Emitted name from crosshair 2D is objectName of image or its id
|
||||
([`3e2544e`](https://github.com/bec-project/bec_widgets/commit/3e2544e52a84b30a5acb4a7874025fa359a3c58d))
|
||||
|
||||
|
||||
## v2.12.0 (2025-06-04)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -312,7 +312,7 @@ class Crosshair(QObject):
|
||||
y_values[name] = closest_y
|
||||
x_values[name] = closest_x
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
name = item.config.monitor or str(id(item))
|
||||
name = item.objectName() or str(id(item))
|
||||
image_2d = item.image
|
||||
if image_2d is None:
|
||||
continue
|
||||
@@ -400,7 +400,7 @@ class Crosshair(QObject):
|
||||
)
|
||||
self.coordinatesChanged1D.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
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 preview‑signal pseudo‑devices (labels like
|
||||
``"eiger_preview"``) are accepted as valid choices.
|
||||
|
||||
The validation run only on device not on the preview‑signal.
|
||||
|
||||
Args:
|
||||
device: The text currently entered/selected.
|
||||
|
||||
Returns:
|
||||
True if the device is a genuine BEC device *or* one of the
|
||||
whitelisted preview‑signal 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 preview‑signal 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
import lmfit
|
||||
import numpy as np
|
||||
@@ -163,7 +163,7 @@ class Waveform(PlotBase):
|
||||
self._async_curves = []
|
||||
self._slice_index = None
|
||||
self._dap_curves = []
|
||||
self._mode: Literal["none", "sync", "async", "mixed"] = "none"
|
||||
self._mode = None
|
||||
|
||||
# Scan data
|
||||
self._scan_done = True # means scan is not running
|
||||
@@ -1139,7 +1139,7 @@ class Waveform(PlotBase):
|
||||
QTimer.singleShot(100, self.update_sync_curves)
|
||||
QTimer.singleShot(300, self.update_sync_curves)
|
||||
|
||||
def _fetch_scan_data_and_access(self):
|
||||
def _fetch_scan_data_and_access(self) -> tuple[dict, str] | tuple[None, None]:
|
||||
"""
|
||||
Decide whether the widget is in live or historical mode
|
||||
and return the appropriate data dict and access key.
|
||||
@@ -1153,7 +1153,7 @@ class Waveform(PlotBase):
|
||||
self.update_with_scan_history(-1)
|
||||
if self.scan_item is None:
|
||||
logger.info("No scan executed so far; skipping device curves categorisation.")
|
||||
return "none", "none"
|
||||
return None, None
|
||||
|
||||
if hasattr(self.scan_item, "live_data"):
|
||||
# Live scan
|
||||
@@ -1169,7 +1169,7 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
if self.scan_item is None:
|
||||
logger.info("No scan executed so far; skipping device curves categorisation.")
|
||||
return "none"
|
||||
return
|
||||
data, access_key = self._fetch_scan_data_and_access()
|
||||
for curve in self._sync_curves:
|
||||
device_name = curve.config.signal.name
|
||||
@@ -1177,9 +1177,8 @@ class Waveform(PlotBase):
|
||||
if access_key == "val":
|
||||
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
|
||||
else:
|
||||
device_data = (
|
||||
data.get(device_name, {}).get(device_entry, {}).read().get("value", None)
|
||||
)
|
||||
entry_obj = data.get(device_name, {}).get(device_entry)
|
||||
device_data = entry_obj.read()["value"] if entry_obj else None
|
||||
x_data = self._get_x_data(device_name, device_entry)
|
||||
if x_data is not None:
|
||||
if len(x_data) == 1:
|
||||
@@ -1217,7 +1216,8 @@ class Waveform(PlotBase):
|
||||
if self._skip_large_dataset_check is False:
|
||||
if not self._check_dataset_size_and_confirm(dataset_obj, device_entry):
|
||||
continue # user declined to load; skip this curve
|
||||
device_data = dataset_obj.get(device_entry, {}).read().get("value", None)
|
||||
entry_obj = dataset_obj.get(device_entry, None)
|
||||
device_data = entry_obj.read()["value"] if entry_obj else None
|
||||
|
||||
# if shape is 2D cast it into 1D and take the last waveform
|
||||
if len(np.shape(device_data)) > 1:
|
||||
@@ -1549,15 +1549,21 @@ class Waveform(PlotBase):
|
||||
if access_key == "val": # live data
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, [0])
|
||||
else: # history data
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", [0])
|
||||
entry_obj = data.get(x_name, {}).get(x_entry)
|
||||
x_data = entry_obj.read()["value"] if entry_obj else [0]
|
||||
new_suffix = f" (custom: {x_name}-{x_entry})"
|
||||
|
||||
# 2 User wants timestamp
|
||||
if self.x_axis_mode["name"] == "timestamp":
|
||||
if access_key == "val": # live
|
||||
timestamps = data[device_name][device_entry].timestamps
|
||||
x_data = data.get(device_name, {}).get(device_entry, None)
|
||||
if x_data is None:
|
||||
return None
|
||||
else:
|
||||
timestamps = x_data.timestamps
|
||||
else: # history data
|
||||
timestamps = data[device_name][device_entry].read().get("timestamp", [0])
|
||||
entry_obj = data.get(device_name, {}).get(device_entry)
|
||||
timestamps = entry_obj.read()["timestamp"] if entry_obj else [0]
|
||||
x_data = timestamps
|
||||
new_suffix = " (timestamp)"
|
||||
|
||||
@@ -1584,7 +1590,8 @@ class Waveform(PlotBase):
|
||||
if access_key == "val":
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
|
||||
else:
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
|
||||
entry_obj = data.get(x_name, {}).get(x_entry)
|
||||
x_data = entry_obj.read()["value"] if entry_obj else None
|
||||
new_suffix = f" (auto: {x_name}-{x_entry})"
|
||||
self._update_x_label_suffix(new_suffix)
|
||||
return x_data
|
||||
@@ -1637,7 +1644,7 @@ class Waveform(PlotBase):
|
||||
self.update_with_scan_history(-1)
|
||||
if self.scan_item is None:
|
||||
logger.info("No scan executed so far; skipping device curves categorisation.")
|
||||
return "none"
|
||||
return None
|
||||
|
||||
if hasattr(self.scan_item, "live_data"):
|
||||
readout_priority = self.scan_item.status_message.info["readout_priority"] # live data
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.12.0"
|
||||
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",
|
||||
]
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ def image_widget_with_crosshair(qtbot):
|
||||
|
||||
image_item = pg.ImageItem()
|
||||
image_item.setImage(np.random.rand(100, 100))
|
||||
image_item.config = type("obj", (object,), {"monitor": "test"})
|
||||
|
||||
widget.addItem(image_item)
|
||||
plot_item = widget.getPlotItem()
|
||||
@@ -99,6 +98,7 @@ def test_mouse_moved_signals_outside(plot_widget_with_crosshair):
|
||||
|
||||
def test_mouse_moved_signals_2D(image_widget_with_crosshair):
|
||||
crosshair, plot_item = image_widget_with_crosshair
|
||||
image_item = plot_item.items[0]
|
||||
|
||||
emitted_values_2D = []
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_mouse_moved_signals_2D(image_widget_with_crosshair):
|
||||
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
assert emitted_values_2D == [("test", 21, 55)]
|
||||
assert emitted_values_2D == [(str(id(image_item)), 21, 55)]
|
||||
|
||||
|
||||
def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -113,6 +113,75 @@ def test_enable_colorbar_with_vrange(qtbot, mocked_client, colorbar_type):
|
||||
assert bec_image_view._color_bar is not None
|
||||
|
||||
|
||||
##############################################
|
||||
# Preview‑signal 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 1‑D PreviewSignal connects using the 1‑D 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 1‑D 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 2‑D PreviewSignal connects using the 2‑D 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 2‑D 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 2‑D 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 combo‑box 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 preview‑signal devices to the combo‑box
|
||||
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 set‑up:
|
||||
‑ fills the combo‑box 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() == ""
|
||||
|
||||
Reference in New Issue
Block a user