mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-19 14:55:36 +02:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16bca25d9c | ||
| 130cc24b35 | |||
| 8b2d6052e8 | |||
| 530797a556 | |||
| c660e5141f | |||
| 900153bc0b | |||
| 8dc72656ef | |||
| 170be0c7d3 | |||
| 1925e6ac7f | |||
|
|
b6cef2d27b | ||
| a9fce175b7 | |||
| 783d042e8c | |||
|
|
319a4206f2 | ||
| 76439866c1 | |||
|
|
ca600b057e | ||
| 6c494258f8 | |||
| 63a8da680d |
63
CHANGELOG.md
63
CHANGELOG.md
@@ -1,6 +1,69 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.24.0 (2025-07-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Hide validity LED, show message as tooltip
|
||||
([`530797a`](https://github.com/bec-project/bec_widgets/commit/530797a5568957dde9f47f417310f5c4d2493906))
|
||||
|
||||
- Validate some config data
|
||||
([`c660e51`](https://github.com/bec-project/bec_widgets/commit/c660e5141f191a782c224ee1b83536793639eecb))
|
||||
|
||||
- **device_browser**: Un-nest exception
|
||||
([`8b2d605`](https://github.com/bec-project/bec_widgets/commit/8b2d6052e808f8b4063e5f45c40e4460524f044e))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Docstring for config dialog
|
||||
([`1925e6a`](https://github.com/bec-project/bec_widgets/commit/1925e6ac7f98875eb5980637ae3293e22b459e28))
|
||||
|
||||
### Features
|
||||
|
||||
- (#495) add devices through browser
|
||||
([`170be0c`](https://github.com/bec-project/bec_widgets/commit/170be0c7d3bb1f6e5f2305958909ef68cd987fbd))
|
||||
|
||||
- **#495**: Add validation against existing device names
|
||||
([`900153b`](https://github.com/bec-project/bec_widgets/commit/900153bc0b8cec7bad82e23b3772c66e84900a17))
|
||||
|
||||
- **device_browser**: Connect update to item refresh
|
||||
([`130cc24`](https://github.com/bec-project/bec_widgets/commit/130cc24b351684358558ab81c0111f10f9abb11f))
|
||||
|
||||
- **device_browser**: Device deletion from config
|
||||
([`8dc7265`](https://github.com/bec-project/bec_widgets/commit/8dc72656ef46ae7be886f9da59beb768f5381b4f))
|
||||
|
||||
|
||||
## v2.23.0 (2025-07-11)
|
||||
|
||||
### Features
|
||||
|
||||
- **widget_finder**: Widget to fetch any other widget by class from currently running app
|
||||
([`a9fce17`](https://github.com/bec-project/bec_widgets/commit/a9fce175b720ad85a5cefcab99d79fbcb971ff4a))
|
||||
|
||||
- **widget_io**: Utility function to find widget in the app by class
|
||||
([`783d042`](https://github.com/bec-project/bec_widgets/commit/783d042e8c469774fc8407921462a99c96f6d408))
|
||||
|
||||
|
||||
## v2.22.2 (2025-07-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **plot_base**: Autorange takes into account only visible curves
|
||||
([`7643986`](https://github.com/bec-project/bec_widgets/commit/76439866c1fd09cb7d9d48dfccdc7b1943bfbc0f))
|
||||
|
||||
|
||||
## v2.22.1 (2025-07-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **crosshair**: Crosshair mouse_moved can be set manually
|
||||
([`63a8da6`](https://github.com/bec-project/bec_widgets/commit/63a8da680d263a50102aacf463ec6f6252562f9d))
|
||||
|
||||
- **heatmap**: Fix pixel size calculation for arbitrary shapes
|
||||
([`6c49425`](https://github.com/bec-project/bec_widgets/commit/6c494258f82059a2472f43bb8287390ce1aba704))
|
||||
|
||||
|
||||
## v2.22.0 (2025-07-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -4554,6 +4554,15 @@ class Waveform(RPCBase):
|
||||
Set auto range for the y-axis.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, QPointF, Qt, Signal
|
||||
from qtpy.QtGui import QTransform
|
||||
from qtpy.QtGui import QCursor, QTransform
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
@@ -282,6 +282,34 @@ class Crosshair(QObject):
|
||||
self.marker_2d_col.skip_auto_range = True
|
||||
self.plot_item.addItem(self.marker_2d_col)
|
||||
|
||||
@SafeSlot()
|
||||
def update_markers_on_image_change(self):
|
||||
"""
|
||||
Update markers when the image changes, e.g. when the
|
||||
image shape or transformation changes.
|
||||
"""
|
||||
for item in self.items:
|
||||
if not isinstance(item, pg.ImageItem):
|
||||
continue
|
||||
if self.marker_2d_row is not None:
|
||||
self.marker_2d_row.setSize([item.image.shape[0], 1])
|
||||
self.marker_2d_row.setTransform(item.image_transform)
|
||||
if self.marker_2d_col is not None:
|
||||
self.marker_2d_col.setSize([1, item.image.shape[1]])
|
||||
self.marker_2d_col.setTransform(item.image_transform)
|
||||
# Get the current mouse position
|
||||
views = self.plot_item.vb.scene().views()
|
||||
if not views:
|
||||
return
|
||||
view = views[0]
|
||||
global_pos = QCursor.pos()
|
||||
view_pos = view.mapFromGlobal(global_pos)
|
||||
scene_pos = view.mapToScene(view_pos)
|
||||
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
|
||||
plot_pt = self.plot_item.vb.mapSceneToView(scene_pos)
|
||||
self.mouse_moved(manual_pos=(plot_pt.x(), plot_pt.y()))
|
||||
|
||||
def snap_to_data(
|
||||
self, x: float, y: float
|
||||
) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]:
|
||||
@@ -382,67 +410,74 @@ class Crosshair(QObject):
|
||||
|
||||
return list_x[original_index], list_y[original_index]
|
||||
|
||||
def mouse_moved(self, event):
|
||||
"""Handles the mouse moved event, updating the crosshair position and emitting signals.
|
||||
@SafeSlot(object, tuple)
|
||||
def mouse_moved(self, event=None, manual_pos=None):
|
||||
"""
|
||||
Handles the mouse moved event, updating the crosshair position and emitting signals.
|
||||
|
||||
Args:
|
||||
event: The mouse moved event
|
||||
event(object): The mouse moved event, which contains the scene position.
|
||||
manual_pos(tuple, optional): A tuple containing the (x, y) coordinates to manually set the crosshair position.
|
||||
"""
|
||||
pos = event[0]
|
||||
# Determine target (x, y) in *plot* coordinates
|
||||
if manual_pos is not None:
|
||||
x, y = manual_pos
|
||||
else:
|
||||
if event is None:
|
||||
return # nothing to do
|
||||
scene_pos = event[0] # SignalProxy bundle
|
||||
if not self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
|
||||
return
|
||||
view_pos = self.plot_item.vb.mapSceneToView(scene_pos)
|
||||
x, y = view_pos.x(), view_pos.y()
|
||||
|
||||
# Update cross‑hair visuals
|
||||
self.v_line.setPos(x)
|
||||
self.h_line.setPos(y)
|
||||
|
||||
self.update_markers()
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(pos):
|
||||
mouse_point = self.plot_item.vb.mapSceneToView(pos)
|
||||
x, y = mouse_point.x(), mouse_point.y()
|
||||
self.v_line.setPos(x)
|
||||
self.h_line.setPos(y)
|
||||
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
|
||||
self.crosshairChanged.emit((scaled_x, scaled_y))
|
||||
self.positionChanged.emit((x, y))
|
||||
scaled_x, scaled_y = self.scale_emitted_coordinates(x, y)
|
||||
self.crosshairChanged.emit((scaled_x, scaled_y))
|
||||
self.positionChanged.emit((x, y))
|
||||
|
||||
x_snap_values, y_snap_values = self.snap_to_data(x, y)
|
||||
if x_snap_values is None or y_snap_values is None:
|
||||
return
|
||||
if all(v is None for v in x_snap_values.values()) or all(
|
||||
v is None for v in y_snap_values.values()
|
||||
):
|
||||
# not sure how we got here, but just to be safe...
|
||||
return
|
||||
snap_x_vals, snap_y_vals = self.snap_to_data(x, y)
|
||||
if snap_x_vals is None or snap_y_vals is None:
|
||||
return
|
||||
if all(v is None for v in snap_x_vals.values()) or all(
|
||||
v is None for v in snap_y_vals.values()
|
||||
):
|
||||
return
|
||||
|
||||
precision = self._current_precision()
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_moved_1d[name].setData([x], [y])
|
||||
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
|
||||
coordinate_to_emit = (
|
||||
name,
|
||||
round(x_snapped_scaled, precision),
|
||||
round(y_snapped_scaled, precision),
|
||||
)
|
||||
self.coordinatesChanged1D.emit(coordinate_to_emit)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
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
|
||||
precision = self._current_precision()
|
||||
|
||||
# Compute offsets that respect the image's transform so the ROIs
|
||||
if isinstance(item, ImageItem) and item.image_transform is not None:
|
||||
row, col = self._get_transformed_position(x, y, item.image_transform)
|
||||
|
||||
self.marker_2d_row.setPos(row)
|
||||
self.marker_2d_col.setPos(col)
|
||||
else:
|
||||
self.marker_2d_row.setPos([0, y])
|
||||
self.marker_2d_col.setPos([x, 0])
|
||||
|
||||
coordinate_to_emit = (name, x, y)
|
||||
self.coordinatesChanged2D.emit(coordinate_to_emit)
|
||||
else:
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
sx, sy = snap_x_vals[name], snap_y_vals[name]
|
||||
if sx is None or sy is None:
|
||||
continue
|
||||
self.marker_moved_1d[name].setData([sx], [sy])
|
||||
sx_s, sy_s = self.scale_emitted_coordinates(sx, sy)
|
||||
self.coordinatesChanged1D.emit(
|
||||
(name, round(sx_s, precision), round(sy_s, precision))
|
||||
)
|
||||
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.objectName() or str(id(item))
|
||||
px, py = snap_x_vals[name], snap_y_vals[name]
|
||||
if px is None or py is None:
|
||||
continue
|
||||
|
||||
# Respect image transforms
|
||||
if isinstance(item, ImageItem) and item.image_transform is not None:
|
||||
row, col = self._get_transformed_position(px, py, item.image_transform)
|
||||
self.marker_2d_row.setPos(row)
|
||||
self.marker_2d_col.setPos(col)
|
||||
else:
|
||||
self.marker_2d_row.setPos([0, py])
|
||||
self.marker_2d_col.setPos([px, 0])
|
||||
|
||||
self.coordinatesChanged2D.emit((name, px, py))
|
||||
|
||||
def mouse_clicked(self, event):
|
||||
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
|
||||
|
||||
@@ -171,8 +171,9 @@ class TypedForm(BECWidget, QWidget):
|
||||
|
||||
|
||||
class PydanticModelForm(TypedForm):
|
||||
metadata_updated = Signal(dict)
|
||||
metadata_cleared = Signal(NoneType)
|
||||
form_data_updated = Signal(dict)
|
||||
form_data_cleared = Signal(NoneType)
|
||||
validity_proc = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -204,7 +205,7 @@ class PydanticModelForm(TypedForm):
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
self._validity.compact_view = True # type: ignore
|
||||
self._validity.label = "Metadata validity" # type: ignore
|
||||
self._validity.label = "Validity" # type: ignore
|
||||
self._validity.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
@@ -264,16 +265,18 @@ class PydanticModelForm(TypedForm):
|
||||
def validate_form(self, *_) -> bool:
|
||||
"""validate the currently entered metadata against the pydantic schema.
|
||||
If successful, returns on metadata_emitted and returns true.
|
||||
Otherwise, emits on metadata_cleared and returns false."""
|
||||
Otherwise, emits on form_data_cleared and returns false."""
|
||||
try:
|
||||
metadata_dict = self.get_form_data()
|
||||
self._md_schema.model_validate(metadata_dict)
|
||||
self._validity.set_global_state("success")
|
||||
self._validity_message.setText("No errors!")
|
||||
self.metadata_updated.emit(metadata_dict)
|
||||
self.form_data_updated.emit(metadata_dict)
|
||||
self.validity_proc.emit(True)
|
||||
return True
|
||||
except ValidationError as e:
|
||||
self._validity.set_global_state("emergency")
|
||||
self._validity_message.setText(str(e))
|
||||
self.metadata_cleared.emit(None)
|
||||
self.form_data_cleared.emit(None)
|
||||
self.validity_proc.emit(False)
|
||||
return False
|
||||
|
||||
@@ -390,17 +390,29 @@ class ListFormItem(DynamicFormItem):
|
||||
|
||||
def _add_buttons(self):
|
||||
self._button_holder = QWidget()
|
||||
self._button_holder.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self._buttons = QVBoxLayout()
|
||||
self._buttons.setContentsMargins(0, 0, 0, 0)
|
||||
self._button_holder.setLayout(self._buttons)
|
||||
self._layout.addWidget(self._button_holder)
|
||||
|
||||
self._add_remove_button_holder = QWidget()
|
||||
self._add_remove_button_layout = QHBoxLayout()
|
||||
self._add_remove_button_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._add_remove_button_holder.setLayout(self._add_remove_button_layout)
|
||||
|
||||
self._add_button = QPushButton("+")
|
||||
self._add_button.setMinimumHeight(15)
|
||||
self._add_button.setToolTip("add a new row")
|
||||
self._remove_button = QPushButton("-")
|
||||
self._remove_button.setMinimumHeight(15)
|
||||
self._remove_button.setToolTip("delete the focused row (if any)")
|
||||
self._add_button.clicked.connect(self._add_row)
|
||||
self._remove_button.clicked.connect(self._delete_row)
|
||||
self._buttons.addWidget(self._add_button)
|
||||
self._buttons.addWidget(self._remove_button)
|
||||
|
||||
self._buttons.addWidget(self._add_remove_button_holder)
|
||||
self._add_remove_button_layout.addWidget(self._add_button)
|
||||
self._add_remove_button_layout.addWidget(self._remove_button)
|
||||
|
||||
def _set_pretty_display(self):
|
||||
super()._set_pretty_display()
|
||||
|
||||
@@ -264,6 +264,48 @@ class WidgetIO:
|
||||
return WidgetIO._handlers[base]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def find_widgets(widget_class: QWidget | str, recursive: bool = True) -> list[QWidget]:
|
||||
"""
|
||||
Return widgets matching the given class (or class-name string).
|
||||
|
||||
Args:
|
||||
widget_class: Either a QWidget subclass or its class-name as a string.
|
||||
recursive: If True (default), traverse all top-level widgets and their children;
|
||||
if False, scan app.allWidgets() for a flat list.
|
||||
|
||||
Returns:
|
||||
List of QWidget instances matching the class or class-name.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
raise RuntimeError("No QApplication instance found.")
|
||||
|
||||
# Match by class-name string
|
||||
if isinstance(widget_class, str):
|
||||
name = widget_class
|
||||
if recursive:
|
||||
result: list[QWidget] = []
|
||||
for top in app.topLevelWidgets():
|
||||
if top.__class__.__name__ == name:
|
||||
result.append(top)
|
||||
result.extend(
|
||||
w for w in top.findChildren(QWidget) if w.__class__.__name__ == name
|
||||
)
|
||||
return result
|
||||
return [w for w in app.allWidgets() if w.__class__.__name__ == name]
|
||||
|
||||
# Match by actual class
|
||||
if recursive:
|
||||
result: list[QWidget] = []
|
||||
for top in app.topLevelWidgets():
|
||||
if isinstance(top, widget_class):
|
||||
result.append(top)
|
||||
result.extend(top.findChildren(widget_class))
|
||||
return result
|
||||
|
||||
return [w for w in app.allWidgets() if isinstance(w, widget_class)]
|
||||
|
||||
|
||||
################## for exporting and importing widget hierarchies ##################
|
||||
|
||||
|
||||
@@ -169,8 +169,8 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.layout.addWidget(self._metadata_form)
|
||||
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
|
||||
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
|
||||
self._metadata_form.metadata_updated.connect(self.update_scan_metadata)
|
||||
self._metadata_form.metadata_cleared.connect(self.update_scan_metadata)
|
||||
self._metadata_form.form_data_updated.connect(self.update_scan_metadata)
|
||||
self._metadata_form.form_data_cleared.connect(self.update_scan_metadata)
|
||||
self._metadata_form.validate_form()
|
||||
|
||||
def populate_scans(self):
|
||||
|
||||
@@ -431,6 +431,8 @@ class Heatmap(ImageBase):
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(False)
|
||||
self.image_updated.emit()
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.update_markers_on_image_change()
|
||||
|
||||
def get_image_data(
|
||||
self,
|
||||
@@ -457,14 +459,19 @@ class Heatmap(ImageBase):
|
||||
return None, None
|
||||
|
||||
if msg.scan_name == "grid_scan":
|
||||
return self.get_grid_scan_image(z_data, msg)
|
||||
if msg.scan_type == "step" and msg.info["positions"]:
|
||||
if len(z_data) < 4:
|
||||
# LinearNDInterpolator requires at least 4 points to interpolate
|
||||
return None, None
|
||||
return self.get_step_scan_image(x_data, y_data, z_data, msg)
|
||||
logger.warning(f"Scan type {msg.scan_name} not supported.")
|
||||
return None, None
|
||||
# We only support the grid scan mode if both scanning motors
|
||||
# are configured in the heatmap config.
|
||||
device_x = self._image_config.x_device.entry
|
||||
device_y = self._image_config.y_device.entry
|
||||
if (
|
||||
device_x in msg.request_inputs["arg_bundle"]
|
||||
and device_y in msg.request_inputs["arg_bundle"]
|
||||
):
|
||||
return self.get_grid_scan_image(z_data, msg)
|
||||
if len(z_data) < 4:
|
||||
# LinearNDInterpolator requires at least 4 points to interpolate
|
||||
return None, None
|
||||
return self.get_step_scan_image(x_data, y_data, z_data, msg)
|
||||
|
||||
def get_grid_scan_image(
|
||||
self, z_data: list[float], msg: messages.ScanStatusMessage
|
||||
@@ -560,17 +567,16 @@ class Heatmap(ImageBase):
|
||||
Returns:
|
||||
tuple[np.ndarray, QTransform]: The image data and the QTransform.
|
||||
"""
|
||||
|
||||
grid_x, grid_y, transform = self.get_image_grid(msg.scan_id)
|
||||
xy_data = np.column_stack((x_data, y_data))
|
||||
grid_x, grid_y, transform = self.get_image_grid(xy_data)
|
||||
|
||||
# Interpolate the z data onto the grid
|
||||
interp = LinearNDInterpolator(np.column_stack((x_data, y_data)), z_data)
|
||||
interp = LinearNDInterpolator(xy_data, z_data)
|
||||
grid_z = interp(grid_x, grid_y)
|
||||
|
||||
return grid_z, transform
|
||||
|
||||
@functools.lru_cache(maxsize=2)
|
||||
def get_image_grid(self, _scan_id) -> tuple[np.ndarray, np.ndarray, QTransform]:
|
||||
def get_image_grid(self, positions) -> tuple[np.ndarray, np.ndarray, QTransform]:
|
||||
"""
|
||||
LRU-cached calculation of the grid for the image. The lru cache is indexed by the scan_id
|
||||
to avoid recalculating the grid for the same scan.
|
||||
@@ -581,8 +587,6 @@ class Heatmap(ImageBase):
|
||||
Returns:
|
||||
tuple[np.ndarray, np.ndarray, QTransform]: The grid x and y coordinates and the QTransform.
|
||||
"""
|
||||
msg = self.status_message
|
||||
positions = np.asarray(msg.info["positions"])
|
||||
|
||||
width, height = self.estimate_image_resolution(positions)
|
||||
|
||||
@@ -608,7 +612,8 @@ class Heatmap(ImageBase):
|
||||
|
||||
return grid_x, grid_y, transform
|
||||
|
||||
def estimate_image_resolution(self, coords: np.ndarray) -> tuple[int, int]:
|
||||
@staticmethod
|
||||
def estimate_image_resolution(coords: np.ndarray) -> tuple[int, int]:
|
||||
"""
|
||||
Estimate the number of pixels needed for the image based on the coordinates.
|
||||
|
||||
|
||||
@@ -881,6 +881,38 @@ class PlotBase(BECWidget, QWidget):
|
||||
"""
|
||||
self.plot_item.enableAutoRange(y=value)
|
||||
|
||||
def auto_range(self, value: bool = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
if not value:
|
||||
self.plot_item.enableAutoRange(x=False, y=False)
|
||||
return
|
||||
self._apply_autorange_only_visible_curves()
|
||||
|
||||
def _fetch_visible_curves(self):
|
||||
"""
|
||||
Fetch all visible curves from the plot item.
|
||||
"""
|
||||
visible_curves = []
|
||||
for curve in self.plot_item.curves:
|
||||
if curve.isVisible():
|
||||
visible_curves.append(curve)
|
||||
return visible_curves
|
||||
|
||||
def _apply_autorange_only_visible_curves(self):
|
||||
"""
|
||||
Apply autorange to the plot item based on the provided curves.
|
||||
|
||||
Args:
|
||||
curves (list): List of curves to apply autorange to.
|
||||
"""
|
||||
visible_curves = self._fetch_visible_curves()
|
||||
self.plot_item.autoRange(items=visible_curves if visible_curves else None)
|
||||
|
||||
@SafeProperty(int, doc="The font size of the legend font.")
|
||||
def legend_label_size(self) -> int:
|
||||
"""
|
||||
|
||||
@@ -165,5 +165,4 @@ class MouseInteractionConnection(BundleConnection):
|
||||
Enable autorange on the plot widget.
|
||||
"""
|
||||
if self.target_widget:
|
||||
self.target_widget.auto_range_x = True
|
||||
self.target_widget.auto_range_y = True
|
||||
self.target_widget.auto_range()
|
||||
|
||||
@@ -96,6 +96,7 @@ class Waveform(PlotBase):
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"auto_range",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
@@ -1109,8 +1110,7 @@ class Waveform(PlotBase):
|
||||
self.reset()
|
||||
self.new_scan.emit()
|
||||
self.new_scan_id.emit(current_scan_id)
|
||||
self.auto_range_x = True
|
||||
self.auto_range_y = True
|
||||
self.auto_range(True)
|
||||
self.old_scan_id = self.scan_id
|
||||
self.scan_id = current_scan_id
|
||||
self.scan_item = self.queue.scan_storage.find_scan_by_ID(self.scan_id) # live scan
|
||||
|
||||
@@ -3,10 +3,12 @@ import re
|
||||
from functools import partial
|
||||
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ConfigAction
|
||||
from bec_qthemes import material_icon
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtCore import QSize, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
@@ -14,6 +16,9 @@ from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
DeviceConfigDialog,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -24,7 +29,8 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
DeviceBrowser is a widget that displays all available devices in the current BEC session.
|
||||
"""
|
||||
|
||||
device_update: Signal = Signal()
|
||||
devices_changed: Signal = Signal()
|
||||
device_update: Signal = Signal(str, dict)
|
||||
PLUGIN = True
|
||||
ICON_NAME = "lists"
|
||||
|
||||
@@ -38,6 +44,8 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
) -> None:
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self.get_bec_shortcuts()
|
||||
self._config_helper = ConfigHelper(self.client.connector, self.client._service_name)
|
||||
self._q_threadpool = QThreadPool()
|
||||
self.ui = None
|
||||
self.ini_ui()
|
||||
self.dev_list: QListWidget = self.ui.device_list
|
||||
@@ -48,7 +56,9 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.on_device_update
|
||||
)
|
||||
self.device_update.connect(self.update_device_list)
|
||||
self.devices_changed.connect(self.update_device_list)
|
||||
self.ui.add_button.clicked.connect(self._create_add_dialog)
|
||||
self.ui.add_button.setIcon(material_icon("add", size=(20, 20), convert_to_pixmap=False))
|
||||
|
||||
self.init_device_list()
|
||||
self.update_device_list()
|
||||
@@ -63,6 +73,10 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
layout.addWidget(self.ui)
|
||||
self.setLayout(layout)
|
||||
|
||||
def _create_add_dialog(self):
|
||||
dialog = DeviceConfigDialog(parent=self, device=None, action="add")
|
||||
dialog.open()
|
||||
|
||||
def on_device_update(self, action: ConfigAction, content: dict) -> None:
|
||||
"""
|
||||
Callback for device update events. Triggers the device_update signal.
|
||||
@@ -72,35 +86,49 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
content (dict): The content of the config update.
|
||||
"""
|
||||
if action in ["add", "remove", "reload"]:
|
||||
self.device_update.emit()
|
||||
self.devices_changed.emit()
|
||||
if action in ["update", "reload"]:
|
||||
self.device_update.emit(action, content)
|
||||
|
||||
def init_device_list(self):
|
||||
self.dev_list.clear()
|
||||
self._device_items: dict[str, QListWidgetItem] = {}
|
||||
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for device, device_obj in self.dev.items():
|
||||
self._add_item_to_list(device, device_obj)
|
||||
|
||||
def _add_item_to_list(self, device: str, device_obj):
|
||||
def _updatesize(item: QListWidgetItem, device_item: DeviceItem):
|
||||
device_item.adjustSize()
|
||||
item.setSizeHint(QSize(device_item.width(), device_item.height()))
|
||||
logger.debug(f"Adjusting {item} size to {device_item.width(), device_item.height()}")
|
||||
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for device, device_obj in self.dev.items():
|
||||
item = QListWidgetItem(self.dev_list)
|
||||
device_item = DeviceItem(
|
||||
parent=self,
|
||||
device=device,
|
||||
devices=self.dev,
|
||||
icon=map_device_type_to_icon(device_obj),
|
||||
)
|
||||
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
|
||||
tooltip = self.dev[device]._config.get("description", "")
|
||||
device_item.setToolTip(tooltip)
|
||||
device_item.broadcast_size_hint.connect(item.setSizeHint)
|
||||
item.setSizeHint(device_item.sizeHint())
|
||||
def _remove_item(item: QListWidgetItem):
|
||||
self.dev_list.takeItem(self.dev_list.row(item))
|
||||
del self._device_items[device]
|
||||
self.dev_list.sortItems()
|
||||
|
||||
self.dev_list.setItemWidget(item, device_item)
|
||||
self.dev_list.addItem(item)
|
||||
self._device_items[device] = item
|
||||
item = QListWidgetItem(self.dev_list)
|
||||
device_item = DeviceItem(
|
||||
parent=self,
|
||||
device=device,
|
||||
devices=self.dev,
|
||||
icon=map_device_type_to_icon(device_obj),
|
||||
config_helper=self._config_helper,
|
||||
q_threadpool=self._q_threadpool,
|
||||
)
|
||||
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
|
||||
device_item.imminent_deletion.connect(partial(_remove_item, item))
|
||||
self.device_update.connect(device_item.config_update)
|
||||
tooltip = self.dev[device]._config.get("description", "")
|
||||
device_item.setToolTip(tooltip)
|
||||
device_item.broadcast_size_hint.connect(item.setSizeHint)
|
||||
item.setSizeHint(device_item.sizeHint())
|
||||
|
||||
self.dev_list.setItemWidget(item, device_item)
|
||||
self.dev_list.addItem(item)
|
||||
self._device_items[device] = item
|
||||
|
||||
@SafeSlot()
|
||||
def reset_device_list(self) -> None:
|
||||
@@ -119,6 +147,10 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
Either way, the function will filter the devices based on the filter input text and update the device list.
|
||||
"""
|
||||
filter_text = self.ui.filter_input.text()
|
||||
for device in self.dev:
|
||||
if device not in self._device_items:
|
||||
# it is possible the device has just been added to the config
|
||||
self._add_item_to_list(device, self.dev[device])
|
||||
try:
|
||||
self.regex = re.compile(filter_text, re.IGNORECASE)
|
||||
except re.error:
|
||||
|
||||
@@ -29,6 +29,31 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="button_box">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QToolButton" name="add_button">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ConfigAction
|
||||
from qtpy.QtCore import QObject, QRunnable, Signal
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class _CommSignals(QObject):
|
||||
error = Signal(Exception)
|
||||
done = Signal()
|
||||
|
||||
|
||||
class CommunicateConfigAction(QRunnable):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_helper: ConfigHelper,
|
||||
device: str | None,
|
||||
config: dict | None,
|
||||
action: ConfigAction,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.config_helper = config_helper
|
||||
self.device = device
|
||||
self.config = config
|
||||
self.action = action
|
||||
self.signals = _CommSignals()
|
||||
|
||||
@SafeSlot()
|
||||
def run(self):
|
||||
try:
|
||||
if self.action in ["add", "update", "remove"]:
|
||||
if (dev_name := self.device or self.config.get("name")) is None:
|
||||
raise ValueError(
|
||||
"Must be updating a device or be supplied a name for a new device"
|
||||
)
|
||||
req_args = {
|
||||
"action": self.action,
|
||||
"config": {dev_name: self.config},
|
||||
"wait_for_response": False,
|
||||
}
|
||||
timeout = (
|
||||
self.config_helper.suggested_timeout_s(self.config)
|
||||
if self.config is not None
|
||||
else 20
|
||||
)
|
||||
RID = self.config_helper.send_config_request(**req_args)
|
||||
logger.info("Waiting for config reply")
|
||||
reply = self.config_helper.wait_for_config_reply(RID, timeout=timeout)
|
||||
self.config_helper.handle_update_reply(reply, RID, timeout)
|
||||
logger.info("Done updating config!")
|
||||
else:
|
||||
raise ValueError(f"action {self.action} is not supported")
|
||||
except Exception as e:
|
||||
self.signals.error.emit(e)
|
||||
else:
|
||||
self.signals.done.emit()
|
||||
@@ -1,10 +1,12 @@
|
||||
from ast import literal_eval
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal
|
||||
from pydantic import ValidationError, field_validator
|
||||
from qtpy.QtCore import QSize, Qt, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
@@ -17,6 +19,9 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
|
||||
DeviceConfigForm,
|
||||
)
|
||||
@@ -25,35 +30,13 @@ from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class _CommSignals(QObject):
|
||||
error = Signal(Exception)
|
||||
done = Signal()
|
||||
|
||||
|
||||
class _CommunicateUpdate(QRunnable):
|
||||
|
||||
def __init__(self, config_helper: ConfigHelper, device: str, config: dict) -> None:
|
||||
super().__init__()
|
||||
self.config_helper = config_helper
|
||||
self.device = device
|
||||
self.config = config
|
||||
self.signals = _CommSignals()
|
||||
|
||||
@SafeSlot()
|
||||
def run(self):
|
||||
try:
|
||||
timeout = self.config_helper.suggested_timeout_s(self.config)
|
||||
RID = self.config_helper.send_config_request(
|
||||
action="update", config={self.device: self.config}, wait_for_response=False
|
||||
)
|
||||
logger.info("Waiting for config reply")
|
||||
reply = self.config_helper.wait_for_config_reply(RID, timeout=timeout)
|
||||
self.config_helper.handle_update_reply(reply, RID, timeout)
|
||||
logger.info("Done updating config!")
|
||||
except Exception as e:
|
||||
self.signals.error.emit(e)
|
||||
finally:
|
||||
self.signals.done.emit()
|
||||
def _try_literal_eval(value: str):
|
||||
if value == "":
|
||||
return ""
|
||||
try:
|
||||
return literal_eval(value)
|
||||
except SyntaxError as e:
|
||||
raise ValueError(f"Entered config value {value} is not a valid python value!") from e
|
||||
|
||||
|
||||
class DeviceConfigDialog(BECWidget, QDialog):
|
||||
@@ -62,17 +45,31 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
parent=None,
|
||||
device: str | None = None,
|
||||
config_helper: ConfigHelper | None = None,
|
||||
action: Literal["update", "add"] = "update",
|
||||
threadpool: QThreadPool | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""A dialog to edit the configuration of a device in BEC. Generated from the pydantic model
|
||||
for device specification in bec_lib.atlas_models.
|
||||
|
||||
Args:
|
||||
parent (QObject): the parent QObject
|
||||
device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries.
|
||||
config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary.
|
||||
action (Literal["update", "add"]): the action which the form should perform on application or acceptance.
|
||||
"""
|
||||
self._initial_config = {}
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._config_helper = config_helper or ConfigHelper(
|
||||
self.client.connector, self.client._service_name
|
||||
)
|
||||
self.threadpool = QThreadPool()
|
||||
self._device = device
|
||||
self._action = action
|
||||
self._q_threadpool = threadpool or QThreadPool()
|
||||
self.setWindowTitle(f"Edit config for: {device}")
|
||||
self._container = QStackedLayout()
|
||||
self._container.setStackingMode(QStackedLayout.StackAll)
|
||||
@@ -85,13 +82,37 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
user_warning.setWordWrap(True)
|
||||
user_warning.setStyleSheet("QLabel { color: red; }")
|
||||
self._layout.addWidget(user_warning)
|
||||
self.get_bec_shortcuts()
|
||||
self._add_form()
|
||||
if self._action == "update":
|
||||
self._form._validity.setVisible(False)
|
||||
else:
|
||||
self._set_schema_to_check_devices()
|
||||
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
|
||||
# self._form._validity.setVisible(True)
|
||||
self._form.validity_proc.connect(self.enable_buttons_for_validity)
|
||||
self._add_overlay()
|
||||
self._add_buttons()
|
||||
|
||||
self.setLayout(self._container)
|
||||
self._form.validate_form()
|
||||
self._overlay_widget.setVisible(False)
|
||||
|
||||
def _set_schema_to_check_devices(self):
|
||||
class _NameValidatedConfigModel(DeviceConfigModel):
|
||||
@field_validator("name")
|
||||
@staticmethod
|
||||
def _validate_name(value: str, *_):
|
||||
if not value.isidentifier():
|
||||
raise ValueError(
|
||||
f"Invalid device name: {value}. Device names must be valid Python identifiers."
|
||||
)
|
||||
if value in self.dev:
|
||||
raise ValueError(f"A device with name {value} already exists!")
|
||||
return value
|
||||
|
||||
self._form.set_schema(_NameValidatedConfigModel)
|
||||
|
||||
def _add_form(self):
|
||||
self._form_widget = QWidget()
|
||||
self._form_widget.setLayout(self._layout)
|
||||
@@ -99,11 +120,15 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
self._layout.addWidget(self._form)
|
||||
|
||||
for row in self._form.enumerate_form_widgets():
|
||||
if row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE:
|
||||
if (
|
||||
row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE
|
||||
and self._action == "update"
|
||||
):
|
||||
row.widget._set_pretty_display()
|
||||
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
if self._action == "update" and self._device in self.dev:
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
self._container.addWidget(self._form_widget)
|
||||
|
||||
def _add_overlay(self):
|
||||
@@ -120,16 +145,15 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
self._container.addWidget(self._overlay_widget)
|
||||
|
||||
def _add_buttons(self):
|
||||
button_box = QDialogButtonBox(
|
||||
self.button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.Apply | QDialogButtonBox.Ok | QDialogButtonBox.Cancel
|
||||
)
|
||||
button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
self._layout.addWidget(button_box)
|
||||
self.button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply)
|
||||
self.button_box.accepted.connect(self.accept)
|
||||
self.button_box.rejected.connect(self.reject)
|
||||
self._layout.addWidget(self.button_box)
|
||||
|
||||
def _fetch_config(self):
|
||||
self._initial_config = {}
|
||||
if (
|
||||
self.client.device_manager is not None
|
||||
and self._device in self.client.device_manager.devices
|
||||
@@ -148,56 +172,70 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
# TODO: special cased in some parts of device manager but not others, should
|
||||
# be removed in config update as with below issue
|
||||
diff["deviceConfig"].pop("device_access", None)
|
||||
# TODO: replace when https://github.com/bec-project/bec/issues/528 is resolved
|
||||
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
|
||||
diff["deviceConfig"] = {
|
||||
k: literal_eval(str(v)) for k, v in diff["deviceConfig"].items()
|
||||
k: _try_literal_eval(str(v)) for k, v in diff["deviceConfig"].items() if k != ""
|
||||
}
|
||||
return diff
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def enable_buttons_for_validity(self, valid: bool):
|
||||
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
|
||||
for button in [
|
||||
self.button_box.button(b) for b in [QDialogButtonBox.Apply, QDialogButtonBox.Ok]
|
||||
]:
|
||||
button.setEnabled(valid)
|
||||
button.setToolTip(self._form._validity_message.text())
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def apply(self):
|
||||
self._process_update_action()
|
||||
self._process_action()
|
||||
self.applied.emit()
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(popup_error=True)
|
||||
def accept(self):
|
||||
self._process_update_action()
|
||||
self._process_action()
|
||||
return super().accept()
|
||||
|
||||
def _process_update_action(self):
|
||||
def _process_action(self):
|
||||
updated_config = self.updated_config()
|
||||
if (device_name := updated_config.get("name")) == "":
|
||||
logger.warning("Can't create a device with no name!")
|
||||
elif set(updated_config.keys()) & set(DEVICE_CONF_KEYS.NON_UPDATABLE):
|
||||
logger.info(
|
||||
f"Removing old device {self._device} and adding new device {device_name or self._device} with modified config: {updated_config}"
|
||||
)
|
||||
if self._action == "add":
|
||||
if (name := updated_config.get("name")) in self.dev:
|
||||
raise ValueError(
|
||||
f"Can't create a new device with the same name as already existing device {name}!"
|
||||
)
|
||||
self._proc_device_config_change(updated_config)
|
||||
else:
|
||||
self._update_device_config(updated_config)
|
||||
if updated_config == {}:
|
||||
logger.info("No changes made to device config")
|
||||
return
|
||||
self._proc_device_config_change(updated_config)
|
||||
|
||||
def _update_device_config(self, config: dict):
|
||||
if self._device is None:
|
||||
return
|
||||
if config == {}:
|
||||
logger.info("No changes made to device config")
|
||||
return
|
||||
def _proc_device_config_change(self, config: dict):
|
||||
logger.info(f"Sending request to update device config: {config}")
|
||||
|
||||
self._start_waiting_display()
|
||||
communicate_update = _CommunicateUpdate(self._config_helper, self._device, config)
|
||||
communicate_update = CommunicateConfigAction(
|
||||
self._config_helper, self._device, config, self._action
|
||||
)
|
||||
communicate_update.signals.error.connect(self.update_error)
|
||||
communicate_update.signals.done.connect(self.update_done)
|
||||
self.threadpool.start(communicate_update)
|
||||
self._q_threadpool.start(communicate_update)
|
||||
|
||||
@SafeSlot()
|
||||
def update_done(self):
|
||||
self._stop_waiting_display()
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
if self._action == "update":
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
|
||||
@SafeSlot(Exception, popup_error=True)
|
||||
def update_error(self, e: Exception):
|
||||
raise RuntimeError("Failed to update device configuration") from e
|
||||
self._stop_waiting_display()
|
||||
if self._action == "update":
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
raise e
|
||||
|
||||
def _start_waiting_display(self):
|
||||
self._overlay_widget.setVisible(True)
|
||||
@@ -238,7 +276,8 @@ def main(): # pragma: no cover
|
||||
def _show_dialog(*_):
|
||||
nonlocal dialog
|
||||
if dialog is None:
|
||||
dialog = DeviceConfigDialog(device=device.text())
|
||||
kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"}
|
||||
dialog = DeviceConfigDialog(**kwargs)
|
||||
dialog.accepted.connect(accept)
|
||||
dialog.rejected.connect(_destroy_dialog)
|
||||
dialog.open()
|
||||
|
||||
@@ -42,6 +42,7 @@ class DeviceConfigForm(PydanticModelForm):
|
||||
if theme is None:
|
||||
theme = get_theme_name()
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
self._validity.setVisible(False)
|
||||
|
||||
def get_form_data(self):
|
||||
"""Get the entered metadata as a dict."""
|
||||
@@ -54,7 +55,9 @@ class DeviceConfigForm(PydanticModelForm):
|
||||
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
|
||||
|
||||
def set_schema(self, schema: type[BaseModel]):
|
||||
raise TypeError("This class doesn't support changing the schema")
|
||||
if not issubclass(schema, DeviceConfigModel):
|
||||
raise TypeError("This class doesn't support changing the schema")
|
||||
super().set_schema(schema)
|
||||
|
||||
def set_data(self, data: DeviceConfigModel): # type: ignore # This class locks the type
|
||||
super().set_data(data)
|
||||
|
||||
@@ -3,15 +3,20 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.devicemanager import DeviceContainer
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ConfigAction
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QMimeData, QSize, Qt, Signal
|
||||
from qtpy.QtCore import QMimeData, QSize, Qt, QThreadPool, Signal
|
||||
from qtpy.QtGui import QDrag
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QTabWidget, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
DeviceConfigDialog,
|
||||
)
|
||||
@@ -31,10 +36,20 @@ logger = bec_logger.logger
|
||||
|
||||
class DeviceItem(ExpandableGroupFrame):
|
||||
broadcast_size_hint = Signal(QSize)
|
||||
imminent_deletion = Signal()
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent, device: str, devices: DeviceContainer, icon: str = "") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
parent,
|
||||
device: str,
|
||||
devices: DeviceContainer,
|
||||
icon: str = "",
|
||||
config_helper: ConfigHelper,
|
||||
q_threadpool: QThreadPool | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent, title=device, expanded=False, icon=icon)
|
||||
self.dev = devices
|
||||
self._drag_pos = None
|
||||
@@ -48,35 +63,64 @@ class DeviceItem(ExpandableGroupFrame):
|
||||
self._tab_widget.setDocumentMode(True)
|
||||
self._layout.addWidget(self._tab_widget)
|
||||
|
||||
self.set_layout(self._layout)
|
||||
|
||||
self._form_page = QWidget()
|
||||
self._form_page = QWidget(parent=self)
|
||||
self._form_page_layout = QVBoxLayout()
|
||||
self._form_page.setLayout(self._form_page_layout)
|
||||
|
||||
self._signal_page = QWidget()
|
||||
self._signal_page = QWidget(parent=self)
|
||||
self._signal_page_layout = QVBoxLayout()
|
||||
self._signal_page.setLayout(self._signal_page_layout)
|
||||
|
||||
self._tab_widget.addTab(self._form_page, "Configuration")
|
||||
self._tab_widget.addTab(self._signal_page, "Signals")
|
||||
self._config_helper = config_helper
|
||||
self._q_threadpool = q_threadpool or QThreadPool()
|
||||
|
||||
self.set_layout(self._layout)
|
||||
self.adjustSize()
|
||||
|
||||
def _create_title_layout(self, title: str, icon: str):
|
||||
super()._create_title_layout(title, icon)
|
||||
|
||||
self.edit_button = QToolButton()
|
||||
self.edit_button.setIcon(
|
||||
material_icon(icon_name="edit", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self.edit_button.setIcon(material_icon(icon_name="edit", size=(15, 15)))
|
||||
self._title_layout.insertWidget(self._title_layout.count() - 1, self.edit_button)
|
||||
self.edit_button.clicked.connect(self._create_edit_dialog)
|
||||
|
||||
self.delete_button = QToolButton()
|
||||
self.delete_button.setIcon(material_icon(icon_name="delete", size=(15, 15)))
|
||||
self._title_layout.insertWidget(self._title_layout.count() - 1, self.delete_button)
|
||||
self.delete_button.clicked.connect(self._delete_device)
|
||||
|
||||
@SafeSlot()
|
||||
def _create_edit_dialog(self):
|
||||
dialog = DeviceConfigDialog(parent=self, device=self.device)
|
||||
dialog = DeviceConfigDialog(
|
||||
parent=self,
|
||||
device=self.device,
|
||||
config_helper=self._config_helper,
|
||||
threadpool=self._q_threadpool,
|
||||
)
|
||||
dialog.accepted.connect(self._reload_config)
|
||||
dialog.applied.connect(self._reload_config)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot()
|
||||
def _delete_device(self):
|
||||
self.expanded = False
|
||||
deleter = CommunicateConfigAction(self._config_helper, self.device, None, "remove")
|
||||
deleter.signals.error.connect(self._deletion_error)
|
||||
deleter.signals.done.connect(self._deletion_done)
|
||||
self._q_threadpool.start(deleter)
|
||||
|
||||
@SafeSlot(Exception, popup_error=True)
|
||||
def _deletion_error(self, e: Exception):
|
||||
raise e
|
||||
|
||||
@SafeSlot()
|
||||
def _deletion_done(self):
|
||||
self.imminent_deletion.emit()
|
||||
self.deleteLater()
|
||||
|
||||
@SafeSlot()
|
||||
def switch_expanded_state(self):
|
||||
if not self.expanded and not self._expanded_first_time:
|
||||
@@ -96,6 +140,11 @@ class DeviceItem(ExpandableGroupFrame):
|
||||
self.adjustSize()
|
||||
self.broadcast_size_hint.emit(self.sizeHint())
|
||||
|
||||
@SafeSlot(str, dict)
|
||||
def config_update(self, action: ConfigAction, content: dict) -> None:
|
||||
if self.device in content:
|
||||
self._reload_config()
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def _reload_config(self, *_):
|
||||
self.set_display_config(self.dev[self.device]._config)
|
||||
@@ -157,7 +206,12 @@ if __name__ == "__main__": # pragma: no cover
|
||||
"deviceTags": {"tag1", "tag2", "tag3"},
|
||||
"userParameter": {"some_setting": "some_ value"},
|
||||
}
|
||||
item = DeviceItem(widget, "Device", {"Device": MagicMock(enabled=True, _config=mock_config)})
|
||||
item = DeviceItem(
|
||||
parent=widget,
|
||||
device="Device",
|
||||
devices={"Device": MagicMock(enabled=True, _config=mock_config)}, # type: ignore
|
||||
config_helper=ConfigHelper(MagicMock()),
|
||||
)
|
||||
layout.addWidget(DarkModeButton())
|
||||
layout.addWidget(item)
|
||||
widget.show()
|
||||
|
||||
244
bec_widgets/widgets/utility/widget_finder/widget_finder.py
Normal file
244
bec_widgets/widgets/utility/widget_finder/widget_finder.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QPropertyAnimation, QRect, QSequentialAnimationGroup, Qt, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import SafeProperty
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class WidgetFinderComboBox(QComboBox):
|
||||
|
||||
def __init__(self, parent=None, widget_class: type[QWidget] | str | None = None):
|
||||
super().__init__(parent)
|
||||
self.widget_class = widget_class
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
|
||||
self.setMinimumWidth(200)
|
||||
# find button inside combobox
|
||||
self.find_button = QToolButton(self)
|
||||
self.find_button.setIcon(material_icon("frame_inspect"))
|
||||
self.find_button.setCursor(Qt.PointingHandCursor)
|
||||
self.find_button.setFocusPolicy(Qt.NoFocus)
|
||||
self.find_button.setToolTip("Highlight selected widget")
|
||||
self.find_button.setStyleSheet("QToolButton { border: none; padding: 0px; }")
|
||||
self.find_button.clicked.connect(self.inspect_widget)
|
||||
|
||||
# refresh button inside combobox
|
||||
self.refresh_button = QToolButton(self)
|
||||
self.refresh_button.setIcon(material_icon("refresh"))
|
||||
self.refresh_button.setCursor(Qt.PointingHandCursor)
|
||||
self.refresh_button.setFocusPolicy(Qt.NoFocus)
|
||||
self.refresh_button.setToolTip("Refresh widget list")
|
||||
self.refresh_button.setStyleSheet("QToolButton { border: none; padding: 0px; }")
|
||||
self.refresh_button.clicked.connect(self.refresh_list)
|
||||
|
||||
# Purple Highlighter
|
||||
self.highlighter = None
|
||||
|
||||
# refresh items - delay to fetch widgets after UI is ready in next event loop
|
||||
QTimer.singleShot(0, self.refresh_list)
|
||||
|
||||
def _init_highlighter(self):
|
||||
"""
|
||||
Initialize the highlighter frame that will be used to highlight the inspected widget.
|
||||
"""
|
||||
self.highlighter = QFrame(self, Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||
self.highlighter.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||||
self.highlighter.setStyleSheet(
|
||||
"border: 2px solid #FF00FF; border-radius: 6px; background: transparent;"
|
||||
)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
btn_size = 16
|
||||
arrow_width = 24
|
||||
x = self.width() - arrow_width - btn_size - 2
|
||||
y = (self.height() - btn_size) // 2 - 2
|
||||
# position find_button first
|
||||
self.find_button.setFixedSize(btn_size, btn_size)
|
||||
self.find_button.move(x, y)
|
||||
# position refresh_button to the left of find_button
|
||||
refresh_x = x - btn_size - 2
|
||||
self.refresh_button.setFixedSize(btn_size, btn_size)
|
||||
self.refresh_button.move(refresh_x, y)
|
||||
|
||||
def refresh_list(self):
|
||||
"""
|
||||
Refresh the list of widgets in the combobox based on the specified widget class.
|
||||
"""
|
||||
self.clear()
|
||||
if self.widget_class is None:
|
||||
return
|
||||
widgets = WidgetIO.find_widgets(self.widget_class, recursive=True)
|
||||
# Build display names with counts for duplicates
|
||||
name_counts: dict[str, int] = {}
|
||||
for w in widgets:
|
||||
base_name = w.objectName() or w.__class__.__name__
|
||||
count = name_counts.get(base_name, 0) + 1
|
||||
name_counts[base_name] = count
|
||||
display_name = base_name if count == 1 else f"{base_name} ({count})"
|
||||
self.addItem(display_name, w)
|
||||
|
||||
def showPopup(self):
|
||||
"""
|
||||
Refresh list each time the popup opens to reflect dynamic widget changes.
|
||||
"""
|
||||
self.refresh_list()
|
||||
super().showPopup()
|
||||
|
||||
def inspect_widget(self):
|
||||
"""
|
||||
Inspect the currently selected widget in the combobox.
|
||||
"""
|
||||
target = self.currentData()
|
||||
if not target:
|
||||
return
|
||||
# ensure highlighter exists, avoid calling methods on deleted C++ object
|
||||
if not getattr(self, "highlighter", None):
|
||||
self._init_highlighter()
|
||||
else:
|
||||
self.highlighter.hide()
|
||||
# draw new
|
||||
geom = target.frameGeometry()
|
||||
pos = target.mapToGlobal(target.rect().topLeft())
|
||||
self.highlighter.setGeometry(pos.x(), pos.y(), geom.width(), geom.height())
|
||||
self.highlighter.show()
|
||||
# Pulse and fade animation to draw attention
|
||||
start_rect = QRect(pos.x() - 5, pos.y() - 5, geom.width() + 10, geom.height() + 10)
|
||||
pulse = QPropertyAnimation(self.highlighter, b"geometry")
|
||||
pulse.setDuration(300)
|
||||
pulse.setStartValue(start_rect)
|
||||
pulse.setEndValue(QRect(pos.x(), pos.y(), geom.width(), geom.height()))
|
||||
|
||||
fade = QPropertyAnimation(self.highlighter, b"windowOpacity")
|
||||
fade.setDuration(2000)
|
||||
fade.setStartValue(1.0)
|
||||
fade.setEndValue(0.0)
|
||||
fade.finished.connect(self.highlighter.hide)
|
||||
|
||||
group = QSequentialAnimationGroup(self)
|
||||
group.addAnimation(pulse)
|
||||
group.addAnimation(fade)
|
||||
group.start()
|
||||
|
||||
@SafeProperty(str)
|
||||
def widget_class_name(self) -> str:
|
||||
"""
|
||||
Get or set the target widget class by name.
|
||||
"""
|
||||
return (
|
||||
self.widget_class if isinstance(self.widget_class, str) else self.widget_class.__name__
|
||||
)
|
||||
|
||||
@widget_class_name.setter
|
||||
def widget_class_name(self, name: str):
|
||||
self.widget_class = name
|
||||
self.refresh_list()
|
||||
|
||||
@property
|
||||
def selected_widget(self):
|
||||
"""
|
||||
The currently selected QWidget instance (or None if not found).
|
||||
"""
|
||||
try:
|
||||
return self.currentData()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the highlighter frame when the combobox is deleted.
|
||||
"""
|
||||
if self.highlighter:
|
||||
self.highlighter.close()
|
||||
self.highlighter.deleteLater()
|
||||
self.highlighter = None
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""
|
||||
Override closeEvent to clean up the highlighter frame.
|
||||
"""
|
||||
self.cleanup()
|
||||
event.accept()
|
||||
|
||||
|
||||
class InspectorMainWindow(BECMainWindow): # pragma: no cover
|
||||
"""
|
||||
A main window that includes a widget finder combobox to inspect widgets.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Widget Inspector")
|
||||
self.setMinimumSize(800, 600)
|
||||
|
||||
self.central_widget = QWidget(self)
|
||||
self.setCentralWidget(self.central_widget)
|
||||
|
||||
self.central_widget.layout = QGridLayout(self.central_widget)
|
||||
|
||||
# Inspector box
|
||||
self.group_box_inspector = QGroupBox(self.central_widget)
|
||||
self.group_box_inspector.setTitle("Inspector")
|
||||
self.group_box_inspector.layout = QVBoxLayout(self.group_box_inspector)
|
||||
self.inspector_combobox = WidgetFinderComboBox(self.group_box_inspector, Waveform)
|
||||
self.switch_combobox = QComboBox(self.group_box_inspector)
|
||||
self.switch_combobox.addItems(["Waveform", "Image", "QPushButton"])
|
||||
self.switch_combobox.setToolTip("Switch the widget class to inspect")
|
||||
self.switch_combobox.currentTextChanged.connect(
|
||||
lambda text: setattr(self.inspector_combobox, "widget_class_name", text)
|
||||
)
|
||||
self.group_box_inspector.layout.addWidget(self.inspector_combobox)
|
||||
self.group_box_inspector.layout.addWidget(self.switch_combobox)
|
||||
|
||||
# Some bec widgets to inspect
|
||||
self.wf1 = Waveform(self.central_widget)
|
||||
self.wf2 = Waveform(self.central_widget)
|
||||
|
||||
self.im1 = Image(self.central_widget)
|
||||
self.im2 = Image(self.central_widget)
|
||||
|
||||
# Some normal widgets to inspect
|
||||
self.group_box_widgets = QGroupBox(self.central_widget)
|
||||
self.group_box_widgets.setTitle("Widgets ")
|
||||
self.group_box_widgets.layout = QVBoxLayout(self.group_box_widgets)
|
||||
self.btn1 = QPushButton("Button 1", self.group_box_widgets)
|
||||
self.btn1.setObjectName("btn1")
|
||||
self.btn2 = QPushButton("Button 2", self.group_box_widgets)
|
||||
self.btn2.setObjectName("btn1") # Same object name to test duplicate handling
|
||||
self.btn3 = QPushButton("Button 3", self.group_box_widgets)
|
||||
self.btn3.setObjectName("btn3")
|
||||
self.group_box_widgets.layout.addWidget(self.btn1)
|
||||
self.group_box_widgets.layout.addWidget(self.btn2)
|
||||
self.group_box_widgets.layout.addWidget(self.btn3)
|
||||
|
||||
self.central_widget.layout.addWidget(self.group_box_inspector, 0, 0)
|
||||
self.central_widget.layout.addWidget(self.group_box_widgets, 1, 0)
|
||||
self.central_widget.layout.addWidget(self.wf1, 0, 1)
|
||||
self.central_widget.layout.addWidget(self.wf2, 1, 1)
|
||||
self.central_widget.layout.addWidget(self.im1, 0, 2)
|
||||
self.central_widget.layout.addWidget(self.im2, 1, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
main_window = InspectorMainWindow()
|
||||
main_window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.22.0"
|
||||
version = "2.24.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
|
||||
28
tests/unit_tests/test_config_communicator.py
Normal file
28
tests/unit_tests/test_config_communicator.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from unittest.mock import ANY, MagicMock
|
||||
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
|
||||
|
||||
def test_must_have_a_name(qtbot):
|
||||
error_occurred = False
|
||||
|
||||
def oops():
|
||||
nonlocal error_occurred
|
||||
error_occurred = True
|
||||
|
||||
c = CommunicateConfigAction(ConfigHelper(MagicMock()), device=None, config={}, action="add")
|
||||
c.signals.error.connect(oops)
|
||||
c.run()
|
||||
qtbot.waitUntil(lambda: error_occurred, timeout=100)
|
||||
|
||||
|
||||
def test_wait_for_reply_on_RID():
|
||||
ch = MagicMock(spec=ConfigHelper)
|
||||
ch.send_config_request.return_value = "abcde"
|
||||
cca = CommunicateConfigAction(config_helper=ch, device="samx", config={}, action="update")
|
||||
cca.run()
|
||||
ch.wait_for_config_reply.assert_called_with("abcde", timeout=ANY)
|
||||
@@ -132,3 +132,13 @@ def test_device_item_double_click_event(device_browser, qtbot):
|
||||
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
|
||||
qtbot.mouseDClick(widget, Qt.LeftButton)
|
||||
|
||||
|
||||
def test_device_deletion(device_browser, qtbot):
|
||||
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
|
||||
widget._config_helper = mock.MagicMock()
|
||||
|
||||
assert widget.device in device_browser._device_items
|
||||
qtbot.mouseClick(widget.delete_button, Qt.LeftButton)
|
||||
qtbot.waitUntil(lambda: widget.device not in device_browser._device_items, timeout=10000)
|
||||
|
||||
@@ -2,9 +2,12 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
from qtpy.QtWidgets import QDialogButtonBox, QPushButton
|
||||
|
||||
from bec_widgets.utils.forms_from_types.items import StrFormItem
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
DeviceConfigDialog,
|
||||
_try_literal_eval,
|
||||
)
|
||||
|
||||
_BASIC_CONFIG = {
|
||||
@@ -16,83 +19,123 @@ _BASIC_CONFIG = {
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dialog(qtbot):
|
||||
"""Fixture to create a DeviceConfigDialog instance."""
|
||||
def mock_client():
|
||||
mock_device = MagicMock(_config=DeviceConfigModel.model_validate(_BASIC_CONFIG).model_dump())
|
||||
mock_client = MagicMock()
|
||||
mock_client.device_manager.devices = {"test_device": mock_device}
|
||||
dialog = DeviceConfigDialog(device="test_device", config_helper=MagicMock(), client=mock_client)
|
||||
qtbot.addWidget(dialog)
|
||||
return dialog
|
||||
return mock_client
|
||||
|
||||
|
||||
def test_initialization(dialog):
|
||||
assert dialog._device == "test_device"
|
||||
assert dialog._container.count() == 2
|
||||
@pytest.fixture
|
||||
def update_dialog(mock_client, qtbot):
|
||||
"""Fixture to create a DeviceConfigDialog instance."""
|
||||
update_dialog = DeviceConfigDialog(
|
||||
device="test_device", config_helper=MagicMock(), client=mock_client
|
||||
)
|
||||
qtbot.addWidget(update_dialog)
|
||||
return update_dialog
|
||||
|
||||
|
||||
def test_fill_form(dialog):
|
||||
with patch.object(dialog._form, "set_data") as mock_set_data:
|
||||
dialog._fill_form()
|
||||
@pytest.fixture
|
||||
def add_dialog(mock_client, qtbot):
|
||||
"""Fixture to create a DeviceConfigDialog instance."""
|
||||
add_dialog = DeviceConfigDialog(
|
||||
device=None, config_helper=MagicMock(), client=mock_client, action="add"
|
||||
)
|
||||
qtbot.addWidget(add_dialog)
|
||||
return add_dialog
|
||||
|
||||
|
||||
def test_initialization(update_dialog):
|
||||
assert update_dialog._device == "test_device"
|
||||
assert update_dialog._container.count() == 2
|
||||
|
||||
|
||||
def test_fill_form(update_dialog):
|
||||
with patch.object(update_dialog._form, "set_data") as mock_set_data:
|
||||
update_dialog._fill_form()
|
||||
mock_set_data.assert_called_once_with(DeviceConfigModel.model_validate(_BASIC_CONFIG))
|
||||
|
||||
|
||||
def test_updated_config(dialog):
|
||||
def test_updated_config(update_dialog):
|
||||
"""Test that updated_config returns the correct changes."""
|
||||
dialog._initial_config = {"key1": "value1", "key2": "value2"}
|
||||
update_dialog._initial_config = {"key1": "value1", "key2": "value2"}
|
||||
with patch.object(
|
||||
dialog._form, "get_form_data", return_value={"key1": "value1", "key2": "new_value"}
|
||||
update_dialog._form, "get_form_data", return_value={"key1": "value1", "key2": "new_value"}
|
||||
):
|
||||
updated = dialog.updated_config()
|
||||
updated = update_dialog.updated_config()
|
||||
assert updated == {"key2": "new_value"}
|
||||
|
||||
|
||||
def test_apply(dialog):
|
||||
with patch.object(dialog, "_process_update_action") as mock_process_update:
|
||||
dialog.apply()
|
||||
def test_apply(update_dialog):
|
||||
with patch.object(update_dialog, "_process_action") as mock_process_update:
|
||||
update_dialog.apply()
|
||||
mock_process_update.assert_called_once()
|
||||
|
||||
|
||||
def test_accept(dialog):
|
||||
def test_accept(update_dialog):
|
||||
with (
|
||||
patch.object(dialog, "_process_update_action") as mock_process_update,
|
||||
patch.object(update_dialog, "_process_action") as mock_process_update,
|
||||
patch("qtpy.QtWidgets.QDialog.accept") as mock_parent_accept,
|
||||
):
|
||||
dialog.accept()
|
||||
update_dialog.accept()
|
||||
mock_process_update.assert_called_once()
|
||||
mock_parent_accept.assert_called_once()
|
||||
|
||||
|
||||
def test_waiting_display(dialog, qtbot):
|
||||
def test_waiting_display(update_dialog, qtbot):
|
||||
with (
|
||||
patch.object(dialog._spinner, "start") as mock_spinner_start,
|
||||
patch.object(dialog._spinner, "stop") as mock_spinner_stop,
|
||||
patch.object(update_dialog._spinner, "start") as mock_spinner_start,
|
||||
patch.object(update_dialog._spinner, "stop") as mock_spinner_stop,
|
||||
):
|
||||
dialog.show()
|
||||
dialog._start_waiting_display()
|
||||
qtbot.waitUntil(dialog._overlay_widget.isVisible, timeout=100)
|
||||
update_dialog.show()
|
||||
update_dialog._start_waiting_display()
|
||||
qtbot.waitUntil(update_dialog._overlay_widget.isVisible, timeout=100)
|
||||
mock_spinner_start.assert_called_once()
|
||||
mock_spinner_stop.assert_not_called()
|
||||
dialog._stop_waiting_display()
|
||||
qtbot.waitUntil(lambda: not dialog._overlay_widget.isVisible(), timeout=100)
|
||||
update_dialog._stop_waiting_display()
|
||||
qtbot.waitUntil(lambda: not update_dialog._overlay_widget.isVisible(), timeout=100)
|
||||
mock_spinner_stop.assert_called_once()
|
||||
|
||||
|
||||
def test_update_cycle(dialog, qtbot):
|
||||
def test_update_cycle(update_dialog, qtbot):
|
||||
update = {"enabled": False, "readoutPriority": "baseline", "deviceTags": {"tag"}}
|
||||
|
||||
def _mock_send(action="update", config=None, wait_for_response=True, timeout_s=None):
|
||||
dialog.client.device_manager.devices["test_device"]._config = config["test_device"] # type: ignore
|
||||
update_dialog.client.device_manager.devices["test_device"]._config = config["test_device"] # type: ignore
|
||||
|
||||
dialog._config_helper.send_config_request = MagicMock(side_effect=_mock_send)
|
||||
for item in dialog._form.enumerate_form_widgets():
|
||||
update_dialog._config_helper.send_config_request = MagicMock(side_effect=_mock_send)
|
||||
for item in update_dialog._form.enumerate_form_widgets():
|
||||
if (val := update.get(item.label.property("_model_field_name"))) is not None:
|
||||
item.widget.setValue(val)
|
||||
|
||||
assert dialog.updated_config() == update
|
||||
dialog.apply()
|
||||
qtbot.waitUntil(lambda: dialog._config_helper.send_config_request.call_count == 1, timeout=100)
|
||||
assert update_dialog.updated_config() == update
|
||||
update_dialog.apply()
|
||||
qtbot.waitUntil(
|
||||
lambda: update_dialog._config_helper.send_config_request.call_count == 1, timeout=100
|
||||
)
|
||||
|
||||
dialog._config_helper.send_config_request.assert_called_with(
|
||||
update_dialog._config_helper.send_config_request.assert_called_with(
|
||||
action="update", config={"test_device": update}, wait_for_response=False
|
||||
)
|
||||
|
||||
|
||||
def test_add_form_init_without_name(add_dialog, qtbot):
|
||||
assert (name_widget := add_dialog._form.widget_dict.get("name")) is not None
|
||||
assert isinstance(name_widget, StrFormItem)
|
||||
assert name_widget.getValue() is None
|
||||
|
||||
|
||||
def test_add_form_validates_and_disables_on_init(add_dialog, qtbot):
|
||||
assert (ok_button := add_dialog.button_box.button(QDialogButtonBox.Ok)) is not None
|
||||
assert isinstance(ok_button, QPushButton)
|
||||
assert not ok_button.isEnabled()
|
||||
|
||||
|
||||
def test_try_literal_eval():
|
||||
assert _try_literal_eval("") == ""
|
||||
assert _try_literal_eval("[1, 2, 3]") == [1, 2, 3]
|
||||
assert _try_literal_eval('"[,,]"') == "[,,]"
|
||||
with pytest.raises(ValueError) as e:
|
||||
_try_literal_eval("[,,]")
|
||||
assert e.match("Entered config value [,,]")
|
||||
|
||||
@@ -62,8 +62,15 @@ def test_heatmap_get_image_data_missing_data(heatmap_widget):
|
||||
|
||||
def test_heatmap_get_image_data_grid_scan(heatmap_widget):
|
||||
scan_msg = messages.ScanStatusMessage(
|
||||
scan_id="123", status="open", scan_name="grid_scan", metadata={}, info={}
|
||||
scan_id="123",
|
||||
status="open",
|
||||
scan_name="grid_scan",
|
||||
metadata={},
|
||||
info={},
|
||||
request_inputs={"arg_bundle": ["samx", -5, 5, 10, "samy", -5, 5, 10], "kwargs": {}},
|
||||
)
|
||||
heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i")
|
||||
|
||||
heatmap_widget.status_message = scan_msg
|
||||
with mock.patch.object(heatmap_widget, "get_grid_scan_image") as mock_get_grid_scan_image:
|
||||
heatmap_widget.get_image_data(x_data=[1, 2], y_data=[3, 4], z_data=[5, 6])
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import numpy as np
|
||||
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
@@ -126,6 +128,31 @@ def test_auto_range_x_y(qtbot, mocked_client):
|
||||
assert pb.plot_item.vb.state["autoRange"][1] is False
|
||||
|
||||
|
||||
def test_autorange_respects_visibility(qtbot, mocked_client):
|
||||
"""
|
||||
Autorange must consider only the curves whose .isVisible() flag is True.
|
||||
"""
|
||||
|
||||
pb = create_widget(qtbot, PlotBase, client=mocked_client)
|
||||
|
||||
x = np.arange(10)
|
||||
small = pb.plot_item.plot(x, x, pen=(255, 0, 0)) # 0‒9
|
||||
medium = pb.plot_item.plot(x, x * 10, pen=(0, 255, 0)) # 0‒90
|
||||
large = pb.plot_item.plot(x, x * 100, pen=(0, 0, 255)) # 0‒900
|
||||
|
||||
pb.auto_range(True)
|
||||
qtbot.wait(200)
|
||||
yspan_full = pb.plot_item.vb.viewRange()[1]
|
||||
assert yspan_full[1] > 800, "Autorange must include the largest visible curve."
|
||||
|
||||
# Hide the largest curve, recompute autorange, and expect the span to shrink.
|
||||
large.setVisible(False)
|
||||
pb.auto_range(True)
|
||||
qtbot.wait(200)
|
||||
yspan_reduced = pb.plot_item.vb.viewRange()[1]
|
||||
assert yspan_reduced[1] < 200, "Hidden curves must be excluded from autorange."
|
||||
|
||||
|
||||
def test_x_log_y_log(qtbot, mocked_client):
|
||||
"""
|
||||
Test toggling log scale on x and y axes.
|
||||
|
||||
117
tests/unit_tests/test_widget_finder.py
Normal file
117
tests/unit_tests/test_widget_finder.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import pytest
|
||||
from qtpy.QtCore import QPoint, QSize, Qt
|
||||
from qtpy.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.widgets.utility.widget_finder.widget_finder import WidgetFinderComboBox
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finder_fixture(qtbot):
|
||||
central_widget = QWidget()
|
||||
central_widget.layout = QVBoxLayout(central_widget)
|
||||
|
||||
# Create some buttons and a label under parent
|
||||
btn1 = QPushButton("Button1", central_widget)
|
||||
btn1.setObjectName("btn1")
|
||||
btn2 = QPushButton("Button2", central_widget)
|
||||
btn2.setObjectName("btn2")
|
||||
lbl1 = QLabel("Label1", central_widget)
|
||||
lbl1.setObjectName("lbl1")
|
||||
|
||||
# Instantiate finder to look for QPushButton
|
||||
finder = WidgetFinderComboBox(central_widget, QPushButton)
|
||||
|
||||
# Add buttons and label to the layout
|
||||
central_widget.layout.addWidget(btn1)
|
||||
central_widget.layout.addWidget(btn2)
|
||||
central_widget.layout.addWidget(lbl1)
|
||||
central_widget.layout.addWidget(finder)
|
||||
|
||||
qtbot.addWidget(central_widget)
|
||||
qtbot.waitExposed(central_widget)
|
||||
|
||||
return finder, central_widget, btn1, btn2, lbl1
|
||||
|
||||
|
||||
def test_initial_list_contains_buttons_only(qtbot, finder_fixture):
|
||||
finder, parent, btn1, btn2, lbl1 = finder_fixture
|
||||
items = [finder.itemText(i) for i in range(finder.count())]
|
||||
assert "btn1" in items
|
||||
assert "btn2" in items
|
||||
assert "lbl1" not in items
|
||||
|
||||
|
||||
def test_refresh_and_show_popup_update_list(finder_fixture, qtbot):
|
||||
finder, parent, btn1, btn2, lbl1 = finder_fixture
|
||||
|
||||
# Dynamically add a third button
|
||||
btn3 = QPushButton("Button3", parent)
|
||||
btn3.setObjectName("btn3")
|
||||
|
||||
# Manual refresh
|
||||
qtbot.mouseClick(finder.refresh_button, Qt.LeftButton)
|
||||
items = [finder.itemText(i) for i in range(finder.count())]
|
||||
assert "btn3" in items
|
||||
|
||||
# And via showPopup
|
||||
btn4 = QPushButton("Button4", parent)
|
||||
btn4.setObjectName("btn4")
|
||||
finder.showPopup()
|
||||
items = [finder.itemText(i) for i in range(finder.count())]
|
||||
assert "btn4" in items
|
||||
|
||||
|
||||
def test_selected_widget_and_widget_class_name_setter(finder_fixture, qtbot):
|
||||
finder, parent, btn1, btn2, lbl1 = finder_fixture
|
||||
|
||||
# Select btn2
|
||||
idx = finder.findText("btn2")
|
||||
finder.setCurrentIndex(idx)
|
||||
qtbot.wait(200) # allow refresh_list to run
|
||||
selected_widget = finder.selected_widget
|
||||
assert selected_widget == btn2
|
||||
|
||||
# Now switch to QLabel via the property setter
|
||||
finder.widget_class_name = "QLabel"
|
||||
qtbot.wait(200) # allow refresh_list to run
|
||||
items = [finder.itemText(i) for i in range(finder.count())]
|
||||
assert "lbl1" in items
|
||||
assert "btn1" not in items
|
||||
|
||||
|
||||
def test_inspect_widget_highlights_button(qtbot, finder_fixture):
|
||||
finder, parent, btn1, btn2, lbl1 = finder_fixture
|
||||
|
||||
# Select btn1 and inspect
|
||||
idx = finder.findText("btn1")
|
||||
finder.setCurrentIndex(idx)
|
||||
finder.inspect_widget()
|
||||
qtbot.wait(100) # allow highlighter to show
|
||||
|
||||
highlighter = finder.highlighter
|
||||
assert highlighter.isVisible()
|
||||
qtbot.wait(500) # wait ≥ pulse duration
|
||||
# Highlighter should match the target widget size
|
||||
expected_size = btn1.frameGeometry().size()
|
||||
assert highlighter.geometry().size() == expected_size
|
||||
|
||||
|
||||
def test_inspect_widget_highlights_label(qtbot, finder_fixture):
|
||||
finder, parent, btn1, btn2, lbl1 = finder_fixture
|
||||
|
||||
# Switch to QLabel and inspect lbl1
|
||||
finder.widget_class_name = "QLabel"
|
||||
qtbot.wait(50) # allow refresh
|
||||
idx = finder.findText("lbl1")
|
||||
finder.setCurrentIndex(idx)
|
||||
finder.inspect_widget()
|
||||
qtbot.wait(100) # allow highlighter to show
|
||||
|
||||
highlighter = finder.highlighter
|
||||
assert highlighter.isVisible()
|
||||
|
||||
qtbot.wait(500) # wait ≥ pulse duration
|
||||
# Highlighter should match the target widget size
|
||||
expected_size = lbl1.frameGeometry().size()
|
||||
assert highlighter.geometry().size() == expected_size
|
||||
@@ -190,3 +190,23 @@ def test_widget_io_signal(qtbot, example_widget):
|
||||
toggle.checked = False
|
||||
qtbot.waitUntil(lambda: len(changes) > 4)
|
||||
assert changes[-1][1] == False
|
||||
|
||||
|
||||
def test_find_widgets(example_widget):
|
||||
# Test find_widgets by class type
|
||||
line_edits = WidgetIO.find_widgets(QLineEdit)
|
||||
assert len(line_edits) == 2 # one LineEdit and one in the SpinBox
|
||||
assert isinstance(line_edits[0], QLineEdit)
|
||||
|
||||
# Test find_widgets by class-name string
|
||||
combo_boxes = WidgetIO.find_widgets("QComboBox")
|
||||
assert len(combo_boxes) == 1
|
||||
assert isinstance(combo_boxes[0], QComboBox)
|
||||
|
||||
# Test non-recursive search returns the same widgets
|
||||
combo_boxes_flat = WidgetIO.find_widgets(QComboBox, recursive=False)
|
||||
assert combo_boxes_flat == combo_boxes
|
||||
|
||||
# Test search for non-existent widget returns empty list
|
||||
non_exist = WidgetIO.find_widgets("NonExistentWidget")
|
||||
assert non_exist == []
|
||||
|
||||
Reference in New Issue
Block a user