1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-17 14:05:35 +02:00

Compare commits

...

12 Commits

Author SHA1 Message Date
semantic-release
86b60b4aed 1.3.0
Automatically generated by python-semantic-release
2024-10-30 13:19:18 +00:00
14dd8c5b29 fix(colors): extend color map validation for matplotlib and colorcet maps (if available) 2024-10-28 17:17:03 +01:00
b039933405 feat(colormap_button): colormap button with menu to select colormap filtered by the colormap type 2024-10-28 13:48:56 +01:00
semantic-release
d8c80293c7 1.2.0
Automatically generated by python-semantic-release
2024-10-25 17:17:49 +00:00
40c9fea35f feat(colors): evenly spaced color generation + new golden ratio calculation 2024-10-25 19:08:13 +02:00
5d4b86e1c6 refactor: add bec_lib version to statusbox 2024-10-25 16:12:06 +02:00
semantic-release
5681c0cbd1 1.1.0
Automatically generated by python-semantic-release
2024-10-25 08:19:34 +00:00
91959e82de refactor: do not flush selection upon receiving config update; allow widgetIO to receive kwargs to be able to use get_value to receive string instead of int for QComboBox 2024-10-24 18:09:18 +02:00
5eb15b785f refactor: allow to set selection in DeviceInput; automatic update of selection on device config update; cleanup 2024-10-24 13:38:26 +02:00
6fb20552ff refactor: cleanup, added device_signal for signal inputs 2024-10-24 09:21:32 +02:00
0350833f36 feat: add filter i/o utility class 2024-10-22 16:56:16 +02:00
acb79020d4 test(scan_control): tests added for grid_scan to ensure scan_args signal validity 2024-10-22 16:05:14 +02:00
58 changed files with 2811 additions and 539 deletions

View File

@@ -1,6 +1,47 @@
# CHANGELOG
## v1.3.0 (2024-10-30)
### Bug Fixes
* fix(colors): extend color map validation for matplotlib and colorcet maps (if available) ([`14dd8c5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/14dd8c5b2947c92f6643b888d71975e4e8d4ee88))
### Features
* feat(colormap_button): colormap button with menu to select colormap filtered by the colormap type ([`b039933`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b039933405e2fbe92bd81bd0748e79e8d443a741))
## v1.2.0 (2024-10-25)
### Features
* feat(colors): evenly spaced color generation + new golden ratio calculation ([`40c9fea`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/40c9fea35f869ef52e05948dd1989bcd99f602e0))
### Refactoring
* refactor: add bec_lib version to statusbox ([`5d4b86e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5d4b86e1c6e1800051afce4f991153e370767fa6))
## v1.1.0 (2024-10-25)
### Features
* feat: add filter i/o utility class ([`0350833`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0350833f36e0a7cadce4173f9b1d1fbfdf985375))
### Refactoring
* refactor: do not flush selection upon receiving config update; allow widgetIO to receive kwargs to be able to use get_value to receive string instead of int for QComboBox ([`91959e8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/91959e82de8586934af3ebb5aaa0923930effc51))
* refactor: allow to set selection in DeviceInput; automatic update of selection on device config update; cleanup ([`5eb15b7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5eb15b785f12e30eb8ccbc56d4ad9e759a4cf5eb))
* refactor: cleanup, added device_signal for signal inputs ([`6fb2055`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6fb20552ff57978f4aeb79fd7f062f8d6b5581e7))
### Testing
* test(scan_control): tests added for grid_scan to ensure scan_args signal validity ([`acb7902`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/acb79020d4be546efc001ff47b6f5cdba2ee9375))
## v1.0.2 (2024-10-22)
### Bug Fixes
@@ -128,49 +169,3 @@ complete UI ([`49268e3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/49268e38
### Features
* feat: add bec-app script to launch applications ([`8bf4842`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8bf48427884338672a8e3de3deb20439b0bfdf99))
## v0.114.0 (2024-10-02)
### Bug Fixes
* fix: prevent exception when empty string updates are coming from widget ([`04cfb1e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/04cfb1edf19437d54f07b868bcf3cfc2a35fd3bc))
* fix: use new 'scan_axis' signal, to set_x and select x axis on waveform
Fixes #361, do not try to change x axis when not permitted ([`efa2763`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/efa276358b0f5a45cce9fa84fa5f9aafaf4284f7))
### Features
* feat: new 'scan_axis' signal
Signal is emitted before "scan_started", to inform about scan positioner
and (start, stop) positions. In case of multiple bundles, the signal
is emitted multiple times. ([`f084e25`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f084e2514bc9459cccaa951b79044bc25884e738))
## v0.113.0 (2024-10-02)
### Bug Fixes
* fix: add is_log checks and functionality to plot_indicator_items ([`0f9953e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0f9953e8fdcf3f9b5a09f994c69edb6b34756df9))
### Features
* feat: add first draft for alignment_1d GUI ([`63c24f9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/63c24f97a355edaa928b6e222909252b276bcada))
* feat: add move to position button to lmfit dialog ([`281cb27`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/281cb27d8b5433e27a7ba0ca0a19e4b45b9c544f))
### Refactoring
* refactor: various minor improvements for the alignment gui ([`f554f3c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f554f3c1672c4fe32968a5991dc98802556a6f3b))
* refactor: allow hiding of arg/kwarg boxes ([`efe90eb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/efe90eb163e2123a5b4d0bb59f66025a569336ad))
### Testing
* test: add tests for scan_status_callback ([`dc0c825`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/dc0c825fd594c093a24543ff803d6c6564010e92))
### Unknown
* feat : Add bec_signal_proxy to handle signals with option to unblock them manually. ([`1dcfeb6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1dcfeb6cfce3c69f0c5401731d4d3f9a1981b22e))

View File

@@ -16,6 +16,7 @@ class Widgets(str, enum.Enum):
"""
AbortButton = "AbortButton"
BECColorMapWidget = "BECColorMapWidget"
BECDock = "BECDock"
BECDockArea = "BECDockArea"
BECFigure = "BECFigure"
@@ -34,10 +35,13 @@ class Widgets(str, enum.Enum):
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
PositionerControlLine = "PositionerControlLine"
PositionerGroup = "PositionerGroup"
ResetButton = "ResetButton"
ResumeButton = "ResumeButton"
RingProgressBar = "RingProgressBar"
ScanControl = "ScanControl"
SignalComboBox = "SignalComboBox"
SignalLineEdit = "SignalLineEdit"
StopButton = "StopButton"
TextBox = "TextBox"
VSCodeEditor = "VSCodeEditor"
@@ -62,6 +66,15 @@ class AbortButton(RPCBase):
"""
class BECColorMapWidget(RPCBase):
@property
@rpc_call
def colormap(self):
"""
Get the current colormap name.
"""
class BECCurve(RPCBase):
@rpc_call
def remove(self):
@@ -2591,6 +2604,24 @@ class DeviceLineEdit(RPCBase):
"""
class DeviceSignalInputBase(RPCBase):
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class LMFitDialog(RPCBase):
@property
@rpc_call
@@ -2670,6 +2701,16 @@ class PositionerControlLine(RPCBase):
"""
class PositionerGroup(RPCBase):
@rpc_call
def set_positioners(self, device_names: "str"):
"""
Redraw grid with positioners from device_names string
Device names must be separated by space
"""
class ResetButton(RPCBase):
@property
@rpc_call
@@ -3003,6 +3044,42 @@ class ScanControl(RPCBase):
"""
class SignalComboBox(RPCBase):
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class SignalLineEdit(RPCBase):
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class StopButton(RPCBase):
@property
@rpc_call

View File

226
bec_widgets/tests/utils.py Normal file
View File

@@ -0,0 +1,226 @@
from unittest.mock import MagicMock
from bec_lib.device import Device as BECDevice
from bec_lib.device import Positioner as BECPositioner
from bec_lib.device import ReadoutPriority
from bec_lib.devicemanager import DeviceContainer
class FakeDevice(BECDevice):
"""Fake minimal positioner class for testing."""
def __init__(self, name, enabled=True, readout_priority=ReadoutPriority.MONITORED):
super().__init__(name=name)
self._enabled = enabled
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._readout_priority = readout_priority
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd.Device",
"deviceConfig": {},
"deviceTags": ["user device"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
@property
def readout_priority(self):
return self._readout_priority
@readout_priority.setter
def readout_priority(self, value):
self._readout_priority = value
@property
def limits(self) -> tuple[float, float]:
return self._limits
@limits.setter
def limits(self, value: tuple[float, float]):
self._limits = value
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.signals[self.name]["value"] = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
class FakePositioner(BECPositioner):
def __init__(
self,
name,
enabled=True,
limits=None,
read_value=1.0,
readout_priority=ReadoutPriority.MONITORED,
):
super().__init__(name=name)
# self.limits = limits if limits is not None else [0.0, 0.0]
self.read_value = read_value
self.setpoint_value = read_value
self.motor_is_moving_value = 0
self._enabled = enabled
self._limits = limits
self._readout_priority = readout_priority
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd_devices.SimPositioner",
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
"deviceTags": ["user motors"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
self._info = {
"signals": {
"readback": {"kind_str": "5"}, # hinted
"setpoint": {"kind_str": "1"}, # normal
"velocity": {"kind_str": "2"}, # config
}
}
self.signals = {
self.name: {"value": self.read_value},
f"{self.name}_setpoint": {"value": self.setpoint_value},
f"{self.name}_motor_is_moving": {"value": self.motor_is_moving_value},
}
@property
def readout_priority(self):
return self._readout_priority
@readout_priority.setter
def readout_priority(self, value):
self._readout_priority = value
@property
def enabled(self) -> bool:
return self._enabled
@enabled.setter
def enabled(self, value: bool):
self._enabled = value
@property
def limits(self) -> tuple[float, float]:
return self._limits
@limits.setter
def limits(self, value: tuple[float, float]):
self._limits = value
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.read_value = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
@property
def precision(self):
return 3
def set_read_value(self, value):
self.read_value = value
def read(self):
return self.signals
def set_limits(self, limits):
self.limits = limits
def move(self, value, relative=False):
"""Simulates moving the device to a new position."""
if relative:
self.read_value += value
else:
self.read_value = value
# Respect the limits
self.read_value = max(min(self.read_value, self.limits[1]), self.limits[0])
@property
def readback(self):
return MagicMock(get=MagicMock(return_value=self.read_value))
class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0):
super().__init__(name, limits, read_value)
class Device(FakeDevice):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name, enabled=True):
super().__init__(name, enabled)
class DMMock:
def __init__(self):
self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled]
def add_devives(self, devices: list):
for device in devices:
self.devices[device.name] = device
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
FakePositioner("samy", limits=[-5, 5], read_value=3.0),
FakePositioner("samz", limits=[-8, 8], read_value=4.0),
FakePositioner("aptrx", limits=None, read_value=4.0),
FakePositioner("aptry", limits=None, read_value=5.0),
FakeDevice("gauss_bpm"),
FakeDevice("gauss_adc1"),
FakeDevice("gauss_adc2"),
FakeDevice("gauss_adc3"),
FakeDevice("bpm4i"),
FakeDevice("bpm3a"),
FakeDevice("bpm3i"),
FakeDevice("eiger", readout_priority=ReadoutPriority.ASYNC),
FakeDevice("waveform1d"),
FakeDevice("async_device", readout_priority=ReadoutPriority.ASYNC),
Positioner("test", limits=[-10, 10], read_value=2.0),
Device("test_device"),
]

View File

@@ -107,9 +107,98 @@ class Colors:
angles.append(angle)
return angles
@staticmethod
def set_theme_offset(theme: Literal["light", "dark"] | None = None, offset=0.2) -> tuple:
"""
Set the theme offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
Args:
theme(str): The theme to be applied.
offset(float): Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
Returns:
tuple: Tuple of min_pos and max_pos.
Raises:
ValueError: If theme_offset is not between 0 and 1.
"""
if offset < 0 or offset > 1:
raise ValueError("theme_offset must be between 0 and 1")
if theme is None:
app = QApplication.instance()
if hasattr(app, "theme"):
theme = app.theme.theme
if theme == "light":
min_pos = 0.0
max_pos = 1 - offset
else:
min_pos = 0.0 + offset
max_pos = 1.0
return min_pos, max_pos
@staticmethod
def evenly_spaced_colors(
colormap: str,
num: int,
format: Literal["QColor", "HEX", "RGB"] = "QColor",
theme_offset=0.2,
theme: Literal["light", "dark"] | None = None,
) -> list:
"""
Extract `num` colors from the specified colormap, evenly spaced along its range,
and return them in the specified format.
Args:
colormap (str): Name of the colormap.
num (int): Number of requested colors.
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
theme (Literal['light', 'dark'] | None): The theme to be applied. Overrides the QApplication theme if specified.
Returns:
list: List of colors in the specified format.
Raises:
ValueError: If theme_offset is not between 0 and 1.
"""
if theme_offset < 0 or theme_offset > 1:
raise ValueError("theme_offset must be between 0 and 1")
cmap = pg.colormap.get(colormap)
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
# Generate positions that are evenly spaced within the acceptable range
if num == 1:
positions = np.array([(min_pos + max_pos) / 2])
else:
positions = np.linspace(min_pos, max_pos, num)
# Sample colors from the colormap at the calculated positions
colors = cmap.map(positions, mode="float")
color_list = []
for color in colors:
if format.upper() == "HEX":
color_list.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
color_list.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR":
color_list.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
return color_list
@staticmethod
def golden_angle_color(
colormap: str, num: int, format: Literal["QColor", "HEX", "RGB"] = "QColor"
colormap: str,
num: int,
format: Literal["QColor", "HEX", "RGB"] = "QColor",
theme_offset=0.2,
theme: Literal["dark", "light"] | None = None,
) -> list:
"""
Extract num colors from the specified colormap following golden angle distribution and return them in the specified format.
@@ -118,45 +207,39 @@ class Colors:
colormap (str): Name of the colormap.
num (int): Number of requested colors.
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
Returns:
list: List of colors in the specified format.
Raises:
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
ValueError: If theme_offset is not between 0 and 1.
"""
cmap = pg.colormap.get(colormap)
cmap_colors = cmap.getColors(mode="float")
if num > len(cmap_colors):
raise ValueError(
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
)
angles = Colors.golden_ratio(len(cmap_colors))
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
colors = []
ii = 0
while len(colors) < num:
color_index = int(color_selection[ii])
color = cmap_colors[color_index]
app = QApplication.instance()
if hasattr(app, "theme") and app.theme.theme == "light":
background = 255
else:
background = 0
if np.abs(np.mean(color[:3] * 255) - background) < 50:
ii += 1
continue
cmap = pg.colormap.get(colormap)
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
# Generate positions within the acceptable range
positions = np.mod(np.arange(num) * golden_angle_conjugate, 1)
positions = min_pos + positions * (max_pos - min_pos)
# Sample colors from the colormap at the calculated positions
colors = cmap.map(positions, mode="float")
color_list = []
for color in colors:
if format.upper() == "HEX":
colors.append(QColor.fromRgbF(*color).name())
color_list.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
colors.append(tuple((np.array(color) * 255).astype(int)))
color_list.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR":
colors.append(QColor.fromRgbF(*color))
color_list.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
ii += 1
return colors
return color_list
@staticmethod
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
@@ -385,7 +468,7 @@ class Colors:
return color
@staticmethod
def validate_color_map(color_map: str) -> str:
def validate_color_map(color_map: str, return_error: bool = True) -> str | bool:
"""
Validate the colormap input if it is supported by pyqtgraph. Can be used in any pydantic model as a field validator. If validation fails it prints all available colormaps from pyqtgraph instance.
@@ -393,13 +476,24 @@ class Colors:
color_map(str): The colormap to be validated.
Returns:
str: The validated colormap.
str: The validated colormap, if colormap is valid.
bool: False, if colormap is invalid.
Raises:
PydanticCustomError: If colormap is invalid.
"""
available_colormaps = pg.colormap.listMaps()
available_pg_maps = pg.colormap.listMaps()
available_mpl_maps = pg.colormap.listMaps("matplotlib")
available_mpl_colorcet = pg.colormap.listMaps("colorcet")
available_colormaps = available_pg_maps + available_mpl_maps + available_mpl_colorcet
if color_map not in available_colormaps:
raise PydanticCustomError(
"unsupported colormap",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
{"wrong_value": color_map},
)
if return_error:
raise PydanticCustomError(
"unsupported colormap",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
{"wrong_value": color_map},
)
else:
return False
return color_map

View File

@@ -0,0 +1,156 @@
"""Module for handling filter I/O operations in BEC Widgets for input fields.
These operations include filtering device/signal names and/or device types.
"""
from abc import ABC, abstractmethod
from bec_lib.logger import bec_logger
from qtpy.QtCore import QStringListModel
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
logger = bec_logger.logger
class WidgetFilterHandler(ABC):
"""Abstract base class for widget filter handlers"""
@abstractmethod
def set_selection(self, widget, selection: list) -> None:
"""Set the filtered_selection for the widget
Args:
selection (list): Filtered selection of items
"""
@abstractmethod
def check_input(self, widget, text: str) -> bool:
"""Check if the input text is in the filtered selection
Args:
widget: Widget instance
text (str): Input text
Returns:
bool: True if the input text is in the filtered selection
"""
class LineEditFilterHandler(WidgetFilterHandler):
"""Handler for QLineEdit widget"""
def set_selection(self, widget: QLineEdit, selection: list) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QLineEdit): The QLineEdit widget
selection (list): Filtered selection of items
"""
if not isinstance(widget.completer, QCompleter):
completer = QCompleter(widget)
widget.setCompleter(completer)
widget.completer.setModel(QStringListModel(selection, widget))
def check_input(self, widget: QLineEdit, text: str) -> bool:
"""Check if the input text is in the filtered selection
Args:
widget (QLineEdit): The QLineEdit widget
text (str): Input text
Returns:
bool: True if the input text is in the filtered selection
"""
model = widget.completer.model()
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
return text in model_data
class ComboBoxFilterHandler(WidgetFilterHandler):
"""Handler for QComboBox widget"""
def set_selection(self, widget: QComboBox, selection: list) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QComboBox): The QComboBox widget
selection (list): Filtered selection of items
"""
widget.clear()
widget.addItems(selection)
def check_input(self, widget: QComboBox, text: str) -> bool:
"""Check if the input text is in the filtered selection
Args:
widget (QComboBox): The QComboBox widget
text (str): Input text
Returns:
bool: True if the input text is in the filtered selection
"""
return text in [widget.itemText(i) for i in range(widget.count())]
class FilterIO:
"""Public interface to set filters for input widgets.
It supports the list of widgets stored in class attribute _handlers.
"""
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
@staticmethod
def set_selection(widget, selection: list, ignore_errors=True):
"""
Retrieve value from the widget instance.
Args:
widget: Widget instance.
selection(list): List of filtered selection items.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().set_selection(widget=widget, selection=selection)
if not ignore_errors:
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
return None
@staticmethod
def check_input(widget, text: str, ignore_errors=True):
"""
Check if the input text is in the filtered selection.
Args:
widget: Widget instance.
text(str): Input text.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
Returns:
bool: True if the input text is in the filtered selection.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().check_input(widget=widget, text=text)
if not ignore_errors:
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
return None
@staticmethod
def _find_handler(widget):
"""
Find the appropriate handler for the widget by checking its base classes.
Args:
widget: Widget instance.
Returns:
handler_class: The handler class if found, otherwise None.
"""
for base in type(widget).__mro__:
if base in FilterIO._handlers:
return FilterIO._handlers[base]
return None

View File

@@ -1,5 +1,6 @@
# pylint: disable=no-name-in-module
from abc import ABC, abstractmethod
from typing import Literal
from qtpy.QtWidgets import (
QApplication,
@@ -20,7 +21,7 @@ class WidgetHandler(ABC):
"""Abstract base class for all widget handlers."""
@abstractmethod
def get_value(self, widget: QWidget):
def get_value(self, widget: QWidget, **kwargs):
"""Retrieve value from the widget instance."""
@abstractmethod
@@ -31,7 +32,7 @@ class WidgetHandler(ABC):
class LineEditHandler(WidgetHandler):
"""Handler for QLineEdit widgets."""
def get_value(self, widget: QLineEdit) -> str:
def get_value(self, widget: QLineEdit, **kwargs) -> str:
return widget.text()
def set_value(self, widget: QLineEdit, value: str) -> None:
@@ -41,7 +42,9 @@ class LineEditHandler(WidgetHandler):
class ComboBoxHandler(WidgetHandler):
"""Handler for QComboBox widgets."""
def get_value(self, widget: QComboBox) -> int:
def get_value(self, widget: QComboBox, as_string: bool = False, **kwargs) -> int | str:
if as_string is True:
return widget.currentText()
return widget.currentIndex()
def set_value(self, widget: QComboBox, value: int | str) -> None:
@@ -54,7 +57,7 @@ class ComboBoxHandler(WidgetHandler):
class TableWidgetHandler(WidgetHandler):
"""Handler for QTableWidget widgets."""
def get_value(self, widget: QTableWidget) -> list:
def get_value(self, widget: QTableWidget, **kwargs) -> list:
return [
[
widget.item(row, col).text() if widget.item(row, col) else ""
@@ -73,7 +76,7 @@ class TableWidgetHandler(WidgetHandler):
class SpinBoxHandler(WidgetHandler):
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
def get_value(self, widget):
def get_value(self, widget, **kwargs):
return widget.value()
def set_value(self, widget, value):
@@ -83,7 +86,7 @@ class SpinBoxHandler(WidgetHandler):
class CheckBoxHandler(WidgetHandler):
"""Handler for QCheckBox widgets."""
def get_value(self, widget):
def get_value(self, widget, **kwargs):
return widget.isChecked()
def set_value(self, widget, value):
@@ -93,7 +96,7 @@ class CheckBoxHandler(WidgetHandler):
class LabelHandler(WidgetHandler):
"""Handler for QLabel widgets."""
def get_value(self, widget):
def get_value(self, widget, **kwargs):
return widget.text()
def set_value(self, widget, value):
@@ -114,7 +117,7 @@ class WidgetIO:
}
@staticmethod
def get_value(widget, ignore_errors=False):
def get_value(widget, ignore_errors=False, **kwargs):
"""
Retrieve value from the widget instance.
@@ -124,7 +127,7 @@ class WidgetIO:
"""
handler_class = WidgetIO._find_handler(widget)
if handler_class:
return handler_class().get_value(widget) # Instantiate the handler
return handler_class().get_value(widget, **kwargs) # Instantiate the handler
if not ignore_errors:
raise ValueError(f"No handler for widget type: {type(widget)}")
return None

View File

@@ -1,38 +1,133 @@
from __future__ import annotations
import enum
from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.widget_io import WidgetIO
logger = bec_logger.logger
class BECDeviceFilter(enum.Enum):
"""Filter for the device classes."""
DEVICE = "Device"
POSITIONER = "Positioner"
SIGNAL = "Signal"
COMPUTED_SIGNAL = "ComputedSignal"
class DeviceInputConfig(ConnectionConfig):
device_filter: str | list[str] | None = None
device_filter: list[BECDeviceFilter] = []
readout_filter: list[ReadoutPriority] = []
devices: list[str] = []
default: str | None = None
arg_name: str | None = None
apply_filter: bool = True
class DeviceInputBase(BECWidget):
"""
Mixin class for device input widgets. This class provides methods to get the device list and device object based
on the current text of the widget.
Mixin base class for device input widgets.
It allows to filter devices from BEC based on
device class and readout priority.
"""
def __init__(self, client=None, config=None, gui_id=None):
_device_handler = {
BECDeviceFilter.DEVICE: Device,
BECDeviceFilter.POSITIONER: Positioner,
BECDeviceFilter.SIGNAL: BECSignal,
BECDeviceFilter.COMPUTED_SIGNAL: ComputedSignal,
}
_filter_handler = {
BECDeviceFilter.DEVICE: "filter_to_device",
BECDeviceFilter.POSITIONER: "filter_to_positioner",
BECDeviceFilter.SIGNAL: "filter_to_signal",
BECDeviceFilter.COMPUTED_SIGNAL: "filter_to_computed_signal",
ReadoutPriority.MONITORED: "readout_monitored",
ReadoutPriority.BASELINE: "readout_baseline",
ReadoutPriority.ASYNC: "readout_async",
ReadoutPriority.CONTINUOUS: "readout_continuous",
ReadoutPriority.ON_REQUEST: "readout_on_request",
}
def __init__(self, client=None, config=None, gui_id: str = None):
if config is None:
config = DeviceInputConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceInputConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True)
self.get_bec_shortcuts()
self._device_filter = None
self._device_filter = []
self._readout_filter = []
self._devices = []
@property
### QtSlots ###
@Slot(str)
def set_device(self, device: str):
"""
Set the device.
Args:
device (str): Default name.
"""
if self.validate_device(device) is True:
WidgetIO.set_value(widget=self, value=device)
self.config.default = device
else:
logger.warning(f"Device {device} is not in the filtered selection.")
@Slot()
def update_devices_from_filters(self):
"""Update the devices based on the current filter selection
in self.device_filter and self.readout_filter. If apply_filter is False,
it will not apply the filters, store the filter settings and return.
"""
current_device = WidgetIO.get_value(widget=self, as_string=True)
self.config.device_filter = self.device_filter
self.config.readout_filter = self.readout_filter
if self.apply_filter is False:
return
all_dev = self.dev.enabled_devices
# Filter based on device class
devs = [dev for dev in all_dev if self._check_device_filter(dev)]
# Filter based on readout priority
devs = [dev for dev in devs if self._check_readout_filter(dev)]
self.devices = [device.name for device in devs]
self.set_device(current_device)
@Slot(list)
def set_available_devices(self, devices: list[str]):
"""
Set the devices. If a device in the list is not valid, it will not be considered.
Args:
devices (list[str]): List of devices.
"""
self.apply_filter = False
self.devices = devices
### QtProperties ###
@Property(
"QStringList",
doc="List of devices. If updated, it will disable the apply filters property.",
)
def devices(self) -> list[str]:
"""
Get the list of devices.
Get the list of devices for the applied filters.
Returns:
list[str]: List of devices.
@@ -40,84 +135,258 @@ class DeviceInputBase(BECWidget):
return self._devices
@devices.setter
def devices(self, value: list[str]):
"""
Set the list of devices.
Args:
value: List of devices.
"""
def devices(self, value: list):
self._devices = value
self.config.devices = value
FilterIO.set_selection(widget=self, selection=value)
def set_device_filter(self, device_filter: str | list[str]):
@Property(str)
def default(self):
"""Get the default device name. If set through this property, it will update only if the device is within the filtered selection."""
return self.config.default
@default.setter
def default(self, value: str):
if self.validate_device(value) is False:
return
self.config.default = value
WidgetIO.set_value(widget=self, value=value)
@Property(bool)
def apply_filter(self):
"""Apply the filters on the devices."""
return self.config.apply_filter
@apply_filter.setter
def apply_filter(self, value: bool):
self.config.apply_filter = value
self.update_devices_from_filters()
@Property(bool)
def filter_to_device(self):
"""Include devices in filters."""
return BECDeviceFilter.DEVICE in self.device_filter
@filter_to_device.setter
def filter_to_device(self, value: bool):
if value is True and BECDeviceFilter.DEVICE not in self.device_filter:
self._device_filter.append(BECDeviceFilter.DEVICE)
if value is False and BECDeviceFilter.DEVICE in self.device_filter:
self._device_filter.remove(BECDeviceFilter.DEVICE)
self.update_devices_from_filters()
@Property(bool)
def filter_to_positioner(self):
"""Include devices of type Positioner in filters."""
return BECDeviceFilter.POSITIONER in self.device_filter
@filter_to_positioner.setter
def filter_to_positioner(self, value: bool):
if value is True and BECDeviceFilter.POSITIONER not in self.device_filter:
self._device_filter.append(BECDeviceFilter.POSITIONER)
if value is False and BECDeviceFilter.POSITIONER in self.device_filter:
self._device_filter.remove(BECDeviceFilter.POSITIONER)
self.update_devices_from_filters()
@Property(bool)
def filter_to_signal(self):
"""Include devices of type Signal in filters."""
return BECDeviceFilter.SIGNAL in self.device_filter
@filter_to_signal.setter
def filter_to_signal(self, value: bool):
if value is True and BECDeviceFilter.SIGNAL not in self.device_filter:
self._device_filter.append(BECDeviceFilter.SIGNAL)
if value is False and BECDeviceFilter.SIGNAL in self.device_filter:
self._device_filter.remove(BECDeviceFilter.SIGNAL)
self.update_devices_from_filters()
@Property(bool)
def filter_to_computed_signal(self):
"""Include devices of type ComputedSignal in filters."""
return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter
@filter_to_computed_signal.setter
def filter_to_computed_signal(self, value: bool):
if value is True and BECDeviceFilter.COMPUTED_SIGNAL not in self.device_filter:
self._device_filter.append(BECDeviceFilter.COMPUTED_SIGNAL)
if value is False and BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter:
self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL)
self.update_devices_from_filters()
@Property(bool)
def readout_monitored(self):
"""Include devices with readout priority Monitored in filters."""
return ReadoutPriority.MONITORED in self.readout_filter
@readout_monitored.setter
def readout_monitored(self, value: bool):
if value is True and ReadoutPriority.MONITORED not in self.readout_filter:
self._readout_filter.append(ReadoutPriority.MONITORED)
if value is False and ReadoutPriority.MONITORED in self.readout_filter:
self._readout_filter.remove(ReadoutPriority.MONITORED)
self.update_devices_from_filters()
@Property(bool)
def readout_baseline(self):
"""Include devices with readout priority Baseline in filters."""
return ReadoutPriority.BASELINE in self.readout_filter
@readout_baseline.setter
def readout_baseline(self, value: bool):
if value is True and ReadoutPriority.BASELINE not in self.readout_filter:
self._readout_filter.append(ReadoutPriority.BASELINE)
if value is False and ReadoutPriority.BASELINE in self.readout_filter:
self._readout_filter.remove(ReadoutPriority.BASELINE)
self.update_devices_from_filters()
@Property(bool)
def readout_async(self):
"""Include devices with readout priority Async in filters."""
return ReadoutPriority.ASYNC in self.readout_filter
@readout_async.setter
def readout_async(self, value: bool):
if value is True and ReadoutPriority.ASYNC not in self.readout_filter:
self._readout_filter.append(ReadoutPriority.ASYNC)
if value is False and ReadoutPriority.ASYNC in self.readout_filter:
self._readout_filter.remove(ReadoutPriority.ASYNC)
self.update_devices_from_filters()
@Property(bool)
def readout_continuous(self):
"""Include devices with readout priority continuous in filters."""
return ReadoutPriority.CONTINUOUS in self.readout_filter
@readout_continuous.setter
def readout_continuous(self, value: bool):
if value is True and ReadoutPriority.CONTINUOUS not in self.readout_filter:
self._readout_filter.append(ReadoutPriority.CONTINUOUS)
if value is False and ReadoutPriority.CONTINUOUS in self.readout_filter:
self._readout_filter.remove(ReadoutPriority.CONTINUOUS)
self.update_devices_from_filters()
@Property(bool)
def readout_on_request(self):
"""Include devices with readout priority OnRequest in filters."""
return ReadoutPriority.ON_REQUEST in self.readout_filter
@readout_on_request.setter
def readout_on_request(self, value: bool):
if value is True and ReadoutPriority.ON_REQUEST not in self.readout_filter:
self._readout_filter.append(ReadoutPriority.ON_REQUEST)
if value is False and ReadoutPriority.ON_REQUEST in self.readout_filter:
self._readout_filter.remove(ReadoutPriority.ON_REQUEST)
self.update_devices_from_filters()
### Python Methods and Properties ###
@property
def device_filter(self) -> list[object]:
"""Get the list of filters to apply on the devices."""
return self._device_filter
@property
def readout_filter(self) -> list[str]:
"""Get the list of filters to apply on the devices"""
return self._readout_filter
def get_available_filters(self) -> list:
"""Get the available filters."""
return [entry for entry in BECDeviceFilter]
def get_readout_priority_filters(self) -> list:
"""Get the available readout priority filters."""
return [entry for entry in ReadoutPriority]
def set_device_filter(
self, filter_selection: str | BECDeviceFilter | list[str] | list[BECDeviceFilter]
):
"""
Set the device filter.
Set the device filter. If None is passed, no filters are applied and all devices included.
Args:
device_filter(str): Device filter, name of the device class.
filter_selection (str | list[str]): Device filters. It is recommended to make an enum for the filters.
"""
self.validate_device_filter(device_filter)
self.config.device_filter = device_filter
self._device_filter = device_filter
filters = None
if isinstance(filter_selection, list):
filters = [self._filter_handler.get(entry) for entry in filter_selection]
if isinstance(filter_selection, str) or isinstance(filter_selection, BECDeviceFilter):
filters = [self._filter_handler.get(filter_selection)]
if filters is None or any([entry is None for entry in filters]):
logger.warning(f"Device filter {filter_selection} is not in the device filter list.")
return
for entry in filters:
setattr(self, entry, True)
def set_default_device(self, default_device: str):
def set_readout_priority_filter(
self, filter_selection: str | ReadoutPriority | list[str] | list[ReadoutPriority]
):
"""
Set the default device.
Set the readout priority filter. If None is passed, all filters are included.
Args:
default_device(str): Default device name.
filter_selection (str | list[str]): Readout priority filters.
"""
self.validate_device(default_device)
self.config.default = default_device
filters = None
if isinstance(filter_selection, list):
filters = [self._filter_handler.get(entry) for entry in filter_selection]
if isinstance(filter_selection, str) or isinstance(filter_selection, ReadoutPriority):
filters = [self._filter_handler.get(filter_selection)]
if filters is None or any([entry is None for entry in filters]):
logger.warning(
f"Readout priority filter {filter_selection} is not in the readout priority list."
)
return
for entry in filters:
setattr(self, entry, True)
def get_device_list(self, filter: str | list[str] | None = None) -> list[str]:
"""
Get the list of device names based on the filter of current BEC client.
def _check_device_filter(
self, device: Device | BECSignal | ComputedSignal | Positioner
) -> bool:
"""Check if filter for device type is applied or not.
Args:
filter(str|None): Class name filter to apply on the device list.
device(Device | Signal | ComputedSignal | Positioner): Device object.
"""
return all(isinstance(device, self._device_handler[entry]) for entry in self.device_filter)
def _check_readout_filter(
self, device: Device | BECSignal | ComputedSignal | Positioner
) -> bool:
"""Check if filter for readout priority is applied or not.
Args:
device(Device | Signal | ComputedSignal | Positioner): Device object.
"""
return device.readout_priority in self.readout_filter
def get_device_object(self, device: str) -> object:
"""
Get the device object based on the device name.
Args:
device(str): Device name.
Returns:
devices(list[str]): List of device names.
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
all_devices = self.dev.enabled_devices
if filter is not None:
self.validate_device_filter(filter)
if isinstance(filter, str):
filter = [filter]
devices = [device.name for device in all_devices if device.__class__.__name__ in filter]
else:
devices = [device.name for device in all_devices]
return devices
self.validate_device(device)
dev = getattr(self.dev, device.lower(), None)
if dev is None:
raise ValueError(
f"Device {device} is not found in the device manager {self.dev} as enabled device."
)
return dev
def get_available_filters(self):
def validate_device(self, device: str) -> bool:
"""
Get the available device classes which can be used as filters.
"""
all_devices = self.dev.enabled_devices
filters = {device.__class__.__name__ for device in all_devices}
return filters
def validate_device_filter(self, filter: str | list[str]) -> None:
"""
Validate the device filter if the class name is present in the current BEC instance.
Args:
filter(str|list[str]): Class name to use as a device filter.
"""
if isinstance(filter, str):
filter = [filter]
available_filters = self.get_available_filters()
for f in filter:
if f not in available_filters:
raise ValueError(f"Device filter {f} is not valid.")
def validate_device(self, device: str) -> None:
"""
Validate the device if it is present in current BEC instance.
Validate the device if it is present in the filtered device selection.
Args:
device(str): Device to validate.
"""
if device not in self.get_device_list(self.config.device_filter):
raise ValueError(f"Device {device} is not valid.")
all_devs = [dev.name for dev in self.dev.enabled_devices]
if device in self.devices and device in all_devs:
return True
return False

View File

@@ -0,0 +1,280 @@
from bec_lib.callback_handler import EventType
from bec_lib.device import Signal
from bec_lib.logger import bec_logger
from ophyd import Kind
from qtpy.QtCore import Property, Slot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.widget_io import WidgetIO
logger = bec_logger.logger
class DeviceSignalInputBaseConfig(ConnectionConfig):
"""Configuration class for DeviceSignalInputBase."""
signal_filter: str | list[str] | None = None
default: str | None = None
arg_name: str | None = None
device: str | None = None
signals: list[str] | None = None
class DeviceSignalInputBase(BECWidget):
"""
Mixin base class for device signal input widgets.
Mixin class for device signal input widgets. This class provides methods to get the device signal list and device
signal object based on the current text of the widget.
"""
_filter_handler = {
Kind.hinted: "include_hinted_signals",
Kind.normal: "include_normal_signals",
Kind.config: "include_config_signals",
}
def __init__(self, client=None, config=None, gui_id: str = None):
if config is None:
config = DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceSignalInputBaseConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
self._device = None
self.get_bec_shortcuts()
self._signal_filter = []
self._signals = []
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.update_signals_from_filters
)
### Qt Slots ###
@Slot(str)
def set_signal(self, signal: str):
"""
Set the signal.
Args:
signal (str): signal name.
"""
if self.validate_signal(signal) is True:
WidgetIO.set_value(widget=self, value=signal)
self.config.default = signal
else:
logger.warning(
f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}."
)
@Slot(str)
def set_device(self, device: str | None):
"""
Set the device. If device is not valid, device will be set to None which happpens
Args:
device(str): device name.
"""
if self.validate_device(device) is False:
self._device = None
else:
self._device = device
self.update_signals_from_filters()
@Slot(dict, dict)
@Slot()
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
"""Update the filters for the device signals based on list in self.signal_filter.
In addition, store the hinted, normal and config signals in separate lists to allow
customisation within QLineEdit.
Note:
Signal and ComputedSignals have no signals. The naming convention follows the device name.
"""
self.config.signal_filter = self.signal_filter
# pylint: disable=protected-access
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
if self.validate_device(self._device) is False:
self._device = None
self.config.device = self._device
return
device = self.get_device_object(self._device)
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [self._device]
FilterIO.set_selection(widget=self, selection=[self._device])
return
device_info = device._info["signals"]
if Kind.hinted in self.signal_filter:
hinted_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.hinted.value))
]
self._hinted_signals = hinted_signals
if Kind.normal in self.signal_filter:
normal_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.normal.value))
]
self._normal_signals = normal_signals
if Kind.config in self.signal_filter:
config_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.config.value))
]
self._config_signals = config_signals
self._signals = self._hinted_signals + self._normal_signals + self._config_signals
FilterIO.set_selection(widget=self, selection=self.signals)
### Qt Properties ###
@Property(str)
def device(self) -> str:
"""Get the selected device."""
if self._device is None:
return ""
return self._device
@device.setter
def device(self, value: str):
"""Set the device and update the filters, only allow devices present in the devicemanager."""
self._device = value
self.config.device = value
self.update_signals_from_filters()
@Property(bool)
def include_hinted_signals(self):
"""Include hinted signals in filters."""
return Kind.hinted in self.signal_filter
@include_hinted_signals.setter
def include_hinted_signals(self, value: bool):
if value:
self._signal_filter.append(Kind.hinted)
else:
self._signal_filter.remove(Kind.hinted)
self.update_signals_from_filters()
@Property(bool)
def include_normal_signals(self):
"""Include normal signals in filters."""
return Kind.normal in self.signal_filter
@include_normal_signals.setter
def include_normal_signals(self, value: bool):
if value:
self._signal_filter.append(Kind.normal)
else:
self._signal_filter.remove(Kind.normal)
self.update_signals_from_filters()
@Property(bool)
def include_config_signals(self):
"""Include config signals in filters."""
return Kind.config in self.signal_filter
@include_config_signals.setter
def include_config_signals(self, value: bool):
if value:
self._signal_filter.append(Kind.config)
else:
self._signal_filter.remove(Kind.config)
self.update_signals_from_filters()
### Properties and Methods ###
@property
def signals(self) -> list[str]:
"""
Get the list of device signals for the applied filters.
Returns:
list[str]: List of device signals.
"""
return self._signals
@signals.setter
def signals(self, value: list[str]):
self._signals = value
self.config.signals = value
FilterIO.set_selection(widget=self, selection=value)
@property
def signal_filter(self) -> list[str]:
"""Get the list of filters to apply on the device signals."""
return self._signal_filter
def get_available_filters(self) -> list[str]:
"""Get the available filters."""
return [entry for entry in self._filter_handler]
def set_filter(self, filter_selection: str | list[str]):
"""
Set the device filter. If None, all devices are included.
Args:
filter_selection (str | list[str]): Device filters from BECDeviceFilter and BECReadoutPriority.
"""
filters = None
if isinstance(filter_selection, list):
filters = [self._filter_handler.get(entry) for entry in filter_selection]
if isinstance(filter_selection, str):
filters = [self._filter_handler.get(filter_selection)]
if filters is None:
return
for entry in filters:
setattr(self, entry, True)
def get_device_object(self, device: str) -> object | None:
"""
Get the device object based on the device name.
Args:
device(str): Device name.
Returns:
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
self.validate_device(device)
dev = getattr(self.dev, device.lower(), None)
if dev is None:
logger.warning(f"Device {device} not found in devicemanager.")
return None
return dev
def validate_device(self, device: str | None, raise_on_false: bool = False) -> bool:
"""
Validate the device if it is present in current BEC instance.
Args:
device(str): Device to validate.
"""
if device in self.dev:
return True
if raise_on_false is True:
raise ValueError(f"Device {device} not found in devicemanager.")
return False
def validate_signal(self, signal: str) -> bool:
"""
Validate the signal if it is present in the device signals.
Args:
signal(str): Signal to validate.
"""
if signal in self.signals:
return True
return False

View File

@@ -141,6 +141,7 @@ class StatusItem(QWidget):
metrics_text = (
f"<b>SERVICE:</b> {self.config.service_name}<br><b>STATUS:</b> {self.config.status}<br>"
)
metrics_text += f"<b>BEC_LIB VERSION:</b> {self.config.info['version']}<br>"
if self.config.metrics:
for key, value in self.config.metrics.items():
if key == "create_time":

View File

@@ -0,0 +1 @@
{'files': ['colormap_widget.py']}

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.colormap_widget.colormap_widget import BECColorMapWidget
DOM_XML = """
<ui language='c++'>
<widget class='BECColorMapWidget' name='bec_color_map_widget'>
</widget>
</ui>
"""
class BECColorMapWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECColorMapWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
return designer_material_icon(BECColorMapWidget.ICON_NAME)
def includeFile(self):
return "bec_color_map_widget"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECColorMapWidget"
def toolTip(self):
return "BECColorMapWidget"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,73 @@
from pyqtgraph.widgets.ColorMapButton import ColorMapButton
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils import Colors
from bec_widgets.utils.bec_widget import BECWidget
class BECColorMapWidget(BECWidget, QWidget):
colormap_changed_signal = Signal(str)
ICON_NAME = "palette"
USER_ACCESS = ["colormap"]
def __init__(self, parent=None, cmap: str = "magma"):
super().__init__()
QWidget.__init__(self, parent=parent)
# Create the ColorMapButton
self.button = ColorMapButton()
# Set the size policy and minimum width
size_policy = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)
self.button.setSizePolicy(size_policy)
self.button.setMinimumWidth(100)
self.button.setMinimumHeight(30)
# Create the layout
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.button)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
# Set the initial colormap
self.button.setColorMap(cmap)
self._cmap = cmap
# Connect the signal
self.button.sigColorMapChanged.connect(self.colormap_changed)
@Property(str)
def colormap(self):
"""Get the current colormap name."""
return self._cmap
@colormap.setter
def colormap(self, name):
"""Set the colormap by name."""
if self._cmap != name:
if Colors.validate_color_map(name, return_error=False) is False:
return
self.button.setColorMap(name)
self._cmap = name
self.colormap_changed_signal.emit(name)
@Slot()
def colormap_changed(self):
"""
Emit the colormap changed signal with the current colormap selected in the button.
"""
cmap = self.button.colorMap().name
self._cmap = cmap
self.colormap_changed_signal.emit(cmap)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
window = BECColorMapWidget()
window.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,17 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.colormap_widget.bec_color_map_widget_plugin import (
BECColorMapWidgetPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(BECColorMapWidgetPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -27,7 +27,7 @@ class DapComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Selection Widgets"
return "BEC Input Widgets"
def icon(self):
return designer_material_icon(DapComboBox.ICON_NAME)

View File

@@ -31,7 +31,7 @@ class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "Device Control"
return "BEC Input Widgets"
def icon(self):
return designer_material_icon(DeviceComboBox.ICON_NAME)

View File

@@ -1,86 +1,163 @@
from typing import TYPE_CHECKING
from bec_lib.callback_handler import EventType
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import QSize, Signal, Slot
from qtpy.QtGui import QPainter, QPaintEvent, QPen
from qtpy.QtWidgets import QComboBox, QSizePolicy
from qtpy.QtWidgets import QComboBox
from bec_widgets.widgets.base_classes.device_input_base import DeviceInputBase, DeviceInputConfig
if TYPE_CHECKING:
from bec_widgets.widgets.base_classes.device_input_base import DeviceInputConfig
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.base_classes.device_input_base import (
BECDeviceFilter,
DeviceInputBase,
DeviceInputConfig,
)
class DeviceComboBox(DeviceInputBase, QComboBox):
"""
Line edit widget for device input with autocomplete for device names.
Combobox widget for device input with autocomplete for device names.
Args:
parent: Parent widget.
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class.
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
ICON_NAME = "list_alt"
device_selected = Signal(str)
device_config_update = Signal()
def __init__(
self,
parent=None,
client=None,
config: DeviceInputConfig = None,
gui_id: str | None = None,
device_filter: str | None = None,
device_filter: BECDeviceFilter | list[BECDeviceFilter] | None = None,
readout_priority_filter: (
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
) = None,
available_devices: list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
):
super().__init__(client=client, config=config, gui_id=gui_id)
QComboBox.__init__(self, parent=parent)
self.setMinimumSize(125, 26)
self.populate_combobox()
if arg_name is not None:
self.config.arg_name = arg_name
self.arg_name = arg_name
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setMinimumSize(QSize(100, 0))
self._callback_id = None
self._is_valid_input = False
self._accent_colors = get_accent_colors()
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
# Set available devices if passed
if available_devices is not None:
self.set_available_devices(available_devices)
# Set readout priority filter default is all
if readout_priority_filter is not None:
self.set_readout_priority_filter(readout_priority_filter)
else:
self.set_readout_priority_filter(
[
ReadoutPriority.MONITORED,
ReadoutPriority.BASELINE,
ReadoutPriority.ASYNC,
ReadoutPriority.CONTINUOUS,
ReadoutPriority.ON_REQUEST,
]
)
# Device filter default is None
if device_filter is not None:
self.set_device_filter(device_filter)
# Set default device if passed
if default is not None:
self.set_default_device(default)
self.set_device(default)
self._callback_id = self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.on_device_update
)
self.device_config_update.connect(self.update_devices_from_filters)
self.currentTextChanged.connect(self.check_validity)
self.check_validity(self.currentText())
def set_device_filter(self, device_filter: str):
def on_device_update(self, action: str, content: dict) -> None:
"""
Set the device filter.
Callback for device update events. Triggers the device_update signal.
Args:
device_filter(str): Device filter, name of the device class.
action (str): The action that triggered the event.
content (dict): The content of the config update.
"""
super().set_device_filter(device_filter)
self.populate_combobox()
if action in ["add", "remove", "reload"]:
self.device_config_update.emit()
def set_default_device(self, default_device: str):
def cleanup(self):
"""Cleanup the widget."""
if self._callback_id is not None:
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
def get_current_device(self) -> object:
"""
Set the default device.
Args:
default_device(str): Default device name.
"""
super().set_default_device(default_device)
self.setCurrentText(default_device)
def populate_combobox(self):
"""Populate the combobox with the devices."""
self.devices = self.get_device_list(self.config.device_filter)
self.clear()
self.addItems(self.devices)
def get_device(self) -> object:
"""
Get the selected device object.
Get the current device object based on the current value.
Returns:
object: Device object.
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
device_name = self.currentText()
device_obj = getattr(self.dev, device_name.lower(), None)
if device_obj is None:
raise ValueError(f"Device {device_name} is not found.")
return device_obj
dev_name = self.currentText()
return self.get_device_object(dev_name)
def paintEvent(self, event: QPaintEvent) -> None:
"""Extend the paint event to set the border color based on the validity of the input.
Args:
event (PySide6.QtGui.QPaintEvent) : Paint event.
"""
# logger.info(f"Received paint event: {event} in {self.__class__}")
super().paintEvent(event)
if self._is_valid_input is False and self.isEnabled() is True:
painter = QPainter(self)
pen = QPen()
pen.setWidth(2)
pen.setColor(self._accent_colors.emergency)
painter.setPen(pen)
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
painter.end()
@Slot(str)
def check_validity(self, input_text: str) -> None:
"""
Check if the current value is a valid device name.
"""
if self.validate_device(input_text) is True:
self._is_valid_input = True
self.device_selected.emit(input_text.lower())
else:
self._is_valid_input = False
self.update()
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
combo = DeviceComboBox()
combo.devices = ["samx", "dev1", "dev2", "dev3", "dev4"]
layout.addWidget(combo)
widget.show()
app.exec_()

View File

@@ -1,13 +1,18 @@
from typing import TYPE_CHECKING
from bec_lib.callback_handler import EventType
from bec_lib.device import ReadoutPriority
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Signal, Slot
from qtpy.QtWidgets import QCompleter, QLineEdit, QSizePolicy
from qtpy.QtGui import QPainter, QPaintEvent, QPen
from qtpy.QtWidgets import QApplication, QCompleter, QLineEdit, QSizePolicy
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.base_classes.device_input_base import DeviceInputBase, DeviceInputConfig
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.base_classes.device_input_base import (
BECDeviceFilter,
DeviceInputBase,
DeviceInputConfig,
)
if TYPE_CHECKING:
from bec_widgets.widgets.base_classes.device_input_base import DeviceInputConfig
logger = bec_logger.logger
class DeviceLineEdit(DeviceInputBase, QLineEdit):
@@ -19,12 +24,13 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class.
device_filter: Device filter, name of the device class from BECDeviceFilter and ReadoutPriority. Check DeviceInputBase for more details.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
device_selected = Signal(str)
device_config_update = Signal()
ICON_NAME = "edit_note"
@@ -34,80 +40,137 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
client=None,
config: DeviceInputConfig = None,
gui_id: str | None = None,
device_filter: str | list[str] | None = None,
device_filter: BECDeviceFilter | list[BECDeviceFilter] | None = None,
readout_priority_filter: (
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
) = None,
available_devices: list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
):
self._callback_id = None
self._is_valid_input = False
self._accent_colors = get_accent_colors()
super().__init__(client=client, config=config, gui_id=gui_id)
QLineEdit.__init__(self, parent=parent)
self.completer = QCompleter(self)
self.setCompleter(self.completer)
self.populate_completer()
if arg_name is not None:
self.config.arg_name = arg_name
self.arg_name = arg_name
if device_filter is not None:
self.set_device_filter(device_filter)
if default is not None:
self.set_default_device(default)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setMinimumSize(QSize(100, 0))
self.editingFinished.connect(self.emit_device_selected)
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
# Set available devices if passed
if available_devices is not None:
self.set_available_devices(available_devices)
# Set readout priority filter default is all
if readout_priority_filter is not None:
self.set_readout_priority_filter(readout_priority_filter)
else:
self.set_readout_priority_filter(
[
ReadoutPriority.MONITORED,
ReadoutPriority.BASELINE,
ReadoutPriority.ASYNC,
ReadoutPriority.CONTINUOUS,
ReadoutPriority.ON_REQUEST,
]
)
# Device filter default is None
if device_filter is not None:
self.set_device_filter(device_filter)
# Set default device if passed
if default is not None:
self.set_device(default)
self._callback_id = self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.on_device_update
)
self.device_config_update.connect(self.update_devices_from_filters)
self.textChanged.connect(self.check_validity)
self.check_validity(self.text())
@Slot()
def emit_device_selected(self):
def on_device_update(self, action: str, content: dict) -> None:
"""
Editing finished, let's see which device is selected and emit signal
"""
device_name = self.text().lower()
device_obj = getattr(self.dev, device_name, None)
if device_obj is not None:
self.device_selected.emit(device_name)
def set_device_filter(self, device_filter: str | list[str]):
"""
Set the device filter.
Callback for device update events. Triggers the device_update signal.
Args:
device_filter (str | list[str]): Device filter, name of the device class.
action (str): The action that triggered the event.
content (dict): The content of the config update.
"""
super().set_device_filter(device_filter)
self.populate_completer()
if action in ["add", "remove", "reload"]:
self.device_config_update.emit()
def set_default_device(self, default_device: str):
def cleanup(self):
"""Cleanup the widget."""
if self._callback_id is not None:
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
def get_current_device(self) -> object:
"""
Set the default device.
Args:
default_device (str): Default device name.
"""
super().set_default_device(default_device)
self.setText(default_device)
def populate_completer(self):
"""Populate the completer with the devices."""
self.devices = self.get_device_list(self.config.device_filter)
self.completer.setModel(self.create_completer_model(self.devices))
def create_completer_model(self, devices: list[str]):
"""Create a model for the completer."""
from qtpy.QtCore import QStringListModel
return QStringListModel(devices, self)
def get_device(self) -> object:
"""
Get the selected device object.
Get the current device object based on the current value.
Returns:
object: Device object.
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
device_name = self.text()
device_obj = getattr(self.dev, device_name.lower(), None)
if device_obj is None:
raise ValueError(f"Device {device_name} is not found.")
return device_obj
dev_name = self.text()
return self.get_device_object(dev_name)
def paintEvent(self, event: QPaintEvent) -> None:
"""Extend the paint event to set the border color based on the validity of the input.
Args:
event (PySide6.QtGui.QPaintEvent) : Paint event.
"""
# logger.info(f"Received paint event: {event} in {self.__class__}")
super().paintEvent(event)
if self._is_valid_input is False and self.isEnabled() is True:
painter = QPainter(self)
pen = QPen()
pen.setWidth(2)
pen.setColor(self._accent_colors.emergency)
painter.setPen(pen)
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
painter.end()
@Slot(str)
def check_validity(self, input_text: str) -> None:
"""
Check if the current value is a valid device name.
"""
if self.validate_device(input_text) is True:
self._is_valid_input = True
self.device_selected.emit(input_text.lower())
else:
self._is_valid_input = False
self.update()
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.signal_combobox.signal_combobox import SignalComboBox
app = QApplication([])
set_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
line_edit = DeviceLineEdit()
line_edit.filter_to_positioner = True
signal_line_edit = SignalComboBox()
line_edit.textChanged.connect(signal_line_edit.set_device)
line_edit.set_available_devices(["samx", "samy", "samz"])
line_edit.set_device("samx")
layout.addWidget(line_edit)
layout.addWidget(signal_line_edit)
widget.show()
app.exec_()

View File

@@ -31,7 +31,7 @@ class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "Device Control"
return "BEC Input Widgets"
def icon(self):
return designer_material_icon(DeviceLineEdit.ICON_NAME)

View File

@@ -4,6 +4,7 @@ import sys
from typing import Literal, Optional
import pyqtgraph as pg
from bec_lib.device import ReadoutPriority
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility
@@ -16,6 +17,7 @@ from bec_widgets.qt_utils.toolbar import (
WidgetAction,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.figure.plots.axis_settings import AxisSettings
@@ -69,7 +71,11 @@ class BECImageWidget(BECWidget, QWidget):
self.toolbar = ModularToolBar(
actions={
"monitor": DeviceSelectionAction(
"Monitor:", DeviceComboBox(device_filter="Device")
"Monitor:",
DeviceComboBox(
device_filter=BECDeviceFilter.DEVICE,
readout_priority_filter=[ReadoutPriority.ASYNC],
),
),
"monitor_type": WidgetAction(widget=self.dim_combo_box),
"connect": MaterialIconAction(icon_name="link", tooltip="Connect Device"),

View File

@@ -2,11 +2,13 @@ from __future__ import annotations
import sys
from bec_lib.device import ReadoutPriority
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
from bec_widgets.qt_utils.toolbar import DeviceSelectionAction, MaterialIconAction, ModularToolBar
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.figure.plots.motor_map.motor_map import MotorMapConfig
@@ -50,10 +52,10 @@ class BECMotorMapWidget(BECWidget, QWidget):
self.toolbar = ModularToolBar(
actions={
"motor_x": DeviceSelectionAction(
"Motor X:", DeviceComboBox(device_filter="Positioner")
"Motor X:", DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
),
"motor_y": DeviceSelectionAction(
"Motor Y:", DeviceComboBox(device_filter="Positioner")
"Motor Y:", DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
),
"connect": MaterialIconAction(icon_name="link", tooltip="Connect Motors"),
"history": MaterialIconAction(icon_name="history", tooltip="Reset Trace History"),

View File

@@ -18,6 +18,7 @@ from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, set_theme
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.device_line_edit.device_line_edit import DeviceLineEdit
logger = bec_logger.logger
@@ -97,7 +98,9 @@ class PositionerBox(BECWidget, CompactPopupWidget):
self._dialog = QDialog(self)
self._dialog.setWindowTitle("Positioner Selection")
layout = QVBoxLayout()
line_edit = DeviceLineEdit(self, client=self.client, device_filter="Positioner")
line_edit = DeviceLineEdit(
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
)
line_edit.textChanged.connect(self.set_positioner)
layout.addWidget(line_edit)
close_button = QPushButton("Close")

View File

@@ -21,6 +21,7 @@ from qtpy.QtWidgets import (
)
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.device_line_edit.device_line_edit import DeviceLineEdit
logger = bec_logger.logger
@@ -233,6 +234,7 @@ class ScanGroupBox(QGroupBox):
default = None
widget = widget_class(arg_name=arg_name, default=default)
if isinstance(widget, DeviceLineEdit):
widget.set_device_filter(BECDeviceFilter.DEVICE)
self.selected_devices[widget] = ""
widget.device_selected.connect(self.emit_device_selected)
tooltip = item.get("tooltip", None)
@@ -306,7 +308,7 @@ class ScanGroupBox(QGroupBox):
try: # In case that the bundle size changes
widget = self.layout.itemAtPosition(i, j).widget()
if isinstance(widget, DeviceLineEdit) and device_object:
value = widget.get_device()
value = widget.get_current_device()
else:
value = WidgetIO.get_value(widget)
args.append(value)

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.signal_combobox.signal_combobox_plugin import SignalComboBoxPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(SignalComboBoxPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,115 @@
from bec_lib.device import Positioner
from ophyd import Kind
from qtpy.QtCore import QSize, Signal, Slot
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
from bec_widgets.widgets.base_classes.device_signal_input_base import DeviceSignalInputBase
class SignalComboBox(DeviceSignalInputBase, QComboBox):
"""
Line edit widget for device input with autocomplete for device names.
Args:
parent: Parent widget.
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
ICON_NAME = "list_alt"
device_signal_changed = Signal(str)
def __init__(
self,
parent=None,
client=None,
config: DeviceSignalInputBase = None,
gui_id: str | None = None,
device: str | None = None,
signal_filter: str | list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
):
super().__init__(client=client, config=config, gui_id=gui_id)
QComboBox.__init__(self, parent=parent)
if arg_name is not None:
self.config.arg_name = arg_name
self.arg_name = arg_name
if default is not None:
self.set_device(default)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setMinimumSize(QSize(100, 0))
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
self.currentTextChanged.connect(self.on_text_changed)
if signal_filter is not None:
self.set_filter(signal_filter)
else:
self.set_filter([Kind.hinted, Kind.normal, Kind.config])
if device is not None:
self.set_device(device)
if default is not None:
self.set_signal(default)
def update_signals_from_filters(self):
"""Update the filters for the combobox"""
super().update_signals_from_filters()
# pylint: disable=protected-access
if FilterIO._find_handler(self) is ComboBoxFilterHandler:
if len(self._config_signals) > 0:
self.insertItem(
len(self._hinted_signals) + len(self._normal_signals), "Config Signals"
)
self.model().item(len(self._hinted_signals) + len(self._normal_signals)).setEnabled(
False
)
if len(self._normal_signals) > 0:
self.insertItem(len(self._hinted_signals), "Normal Signals")
self.model().item(len(self._hinted_signals)).setEnabled(False)
if len(self._hinted_signals) > 0:
self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False)
@Slot(str)
def on_text_changed(self, text: str):
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
For a positioner, the readback value has to be renamed to the device name.
Args:
text (str): Text in the combobox.
"""
if self.validate_device(self.device) is False:
return
if self.validate_signal(text) is False:
return
if text == "readback" and isinstance(self.get_device_object(self.device), Positioner):
device_signal = self.device
else:
device_signal = f"{self.device}_{text}"
self.device_signal_changed.emit(device_signal)
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
box = SignalComboBox(device="samx")
layout.addWidget(box)
widget.show()
app.exec_()

View File

@@ -0,0 +1 @@
{'files': ['signal_combobox.py']}

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.signal_combobox.signal_combobox import SignalComboBox
DOM_XML = """
<ui language='c++'>
<widget class='SignalComboBox' name='signal_combobox'>
</widget>
</ui>
"""
class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = SignalComboBox(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Input Widgets"
def icon(self):
return designer_material_icon(SignalComboBox.ICON_NAME)
def includeFile(self):
return "signal_combobox"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "SignalComboBox"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.signal_line_edit.signal_line_edit_plugin import SignalLineEditPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(SignalLineEditPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,140 @@
from bec_lib.device import Positioner
from ophyd import Kind
from qtpy.QtCore import QSize, Signal, Slot
from qtpy.QtGui import QPainter, QPaintEvent, QPen
from qtpy.QtWidgets import QCompleter, QLineEdit, QSizePolicy
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.base_classes.device_signal_input_base import DeviceSignalInputBase
class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
"""
Line edit widget for device input with autocomplete for device names.
Args:
parent: Parent widget.
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
device_signal_changed = Signal(str)
ICON_NAME = "vital_signs"
def __init__(
self,
parent=None,
client=None,
config: DeviceSignalInputBase = None,
gui_id: str | None = None,
device: str | None = None,
signal_filter: str | list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
):
self._is_valid_input = False
super().__init__(client=client, config=config, gui_id=gui_id)
QLineEdit.__init__(self, parent=parent)
self._accent_colors = get_accent_colors()
self.completer = QCompleter(self)
self.setCompleter(self.completer)
if arg_name is not None:
self.config.arg_name = arg_name
self.arg_name = arg_name
if default is not None:
self.set_device(default)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setMinimumSize(QSize(100, 0))
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
if signal_filter is not None:
self.set_filter(signal_filter)
else:
self.set_filter([Kind.hinted, Kind.normal, Kind.config])
if device is not None:
self.set_device(device)
if default is not None:
self.set_signal(default)
self.textChanged.connect(self.validate_device)
self.validate_device(self.text())
def get_current_device(self) -> object:
"""
Get the current device object based on the current value.
Returns:
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
dev_name = self.text()
return self.get_device_object(dev_name)
def paintEvent(self, event: QPaintEvent) -> None:
"""Extend the paint event to set the border color based on the validity of the input.
Args:
event (PySide6.QtGui.QPaintEvent) : Paint event.
"""
super().paintEvent(event)
painter = QPainter(self)
pen = QPen()
pen.setWidth(2)
if self._is_valid_input is False and self.isEnabled() is True:
pen.setColor(self._accent_colors.emergency)
painter.setPen(pen)
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
@Slot(str)
def check_validity(self, input_text: str) -> None:
"""
Check if the current value is a valid device name.
"""
if self.validate_signal(input_text) is True:
self._is_valid_input = True
self.on_text_changed(input_text)
else:
self._is_valid_input = False
self.update()
@Slot(str)
def on_text_changed(self, text: str):
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
For a positioner, the readback value has to be renamed to the device name.
Args:
text (str): Text in the combobox.
"""
print("test")
if self.validate_device(self.device) is False:
return
if self.validate_signal(text) is False:
return
if text == "readback" and isinstance(self.get_device_object(self.device), Positioner):
device_signal = self.device
else:
device_signal = f"{self.device}_{text}"
self.device_signal_changed.emit(device_signal)
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
layout.addWidget(SignalLineEdit(device="samx"))
widget.show()
app.exec_()

View File

@@ -0,0 +1 @@
{'files': ['signal_line_edit.py']}

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.signal_line_edit.signal_line_edit import SignalLineEdit
DOM_XML = """
<ui language='c++'>
<widget class='SignalLineEdit' name='signal_line_edit'>
</widget>
</ui>
"""
class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = SignalLineEdit(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Input Widgets"
def icon(self):
return designer_material_icon(SignalLineEdit.ICON_NAME)
def includeFile(self):
return "signal_line_edit"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "SignalLineEdit"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -53,7 +53,7 @@ class CurveSettings(SettingWidget):
x_entry = self.target_widget.waveform._x_axis_mode["entry"]
self._enable_ui_elements(x_name, x_entry)
cm = self.target_widget.config.color_palette
self.ui.color_map_selector_scan.combo.setCurrentText(cm)
self.ui.color_map_selector_scan.colormap = cm
# Scan Curve Table
for source in ["scan_segment", "async"]:
@@ -115,10 +115,10 @@ class CurveSettings(SettingWidget):
@Slot()
def change_colormap(self, target: Literal["scan", "dap"]):
if target == "scan":
cm = self.ui.color_map_selector_scan.combo.currentText()
cm = self.ui.color_map_selector_scan.colormap
table = self.ui.scan_table
if target == "dap":
cm = self.ui.color_map_selector_dap.combo.currentText()
cm = self.ui.color_map_selector_dap.colormap
table = self.ui.dap_table
rows = table.rowCount()
colors = Colors.golden_angle_color(colormap=cm, num=max(10, rows + 1), format="HEX")

View File

@@ -231,7 +231,14 @@
</widget>
</item>
<item row="0" column="3">
<widget class="ColormapSelector" name="color_map_selector_scan"/>
<widget class="BECColorMapWidget" name="color_map_selector_scan">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
@@ -330,7 +337,14 @@
</widget>
</item>
<item row="0" column="3">
<widget class="ColormapSelector" name="color_map_selector_dap"/>
<widget class="BECColorMapWidget" name="color_map_selector_dap">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
@@ -348,9 +362,9 @@
<header>device_line_edit</header>
</customwidget>
<customwidget>
<class>ColormapSelector</class>
<class>BECColorMapWidget</class>
<extends>QWidget</extends>
<header>colormap_selector</header>
<header>bec_color_map_widget</header>
</customwidget>
</customwidgets>
<resources/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -39,6 +39,17 @@ The `Colormap Selector` is a specialized combobox that allows users to select a
**Key Features:**
- **Colormap Selection**: Provides a dropdown to select from all available colormaps in `pyqtgraph`.
- **Visual Preview**: Displays a small preview of the colormap next to its name, enhancing usability.
## Colormap Button
The `Colormap Button` is a custom widget that displays the current colormap and, upon clicking, shows a nested menu for selecting a different colormap. It integrates the `ColorMapMenu` from `pyqtgraph`, providing an intuitive and interactive way for users to choose colormaps within the GUI.
**Key Features:**
- **Current Colormap Display**: Shows the name and a gradient icon of the current colormap directly on the button.
- **Nested Menu Selection**: Offers a nested menu with categorized colormaps, making it easy to find and select the desired colormap.
- **Signal Emission**: Emits a signal when the colormap changes, providing the new colormap name as a string.
- **Qt Designer Integration**: Exposes properties and signals to be used within Qt Designer, allowing for customization within the designer interface.
- **Resizable and Styled**: Features adjustable size policies and styles to match the look and feel of standard `QPushButton` widgets, including rounded edges.
`````
````{tab} Examples
@@ -104,6 +115,33 @@ class MyGui(QWidget):
my_gui = MyGui()
my_gui.show()
```
## Example 4 - Adding a Colormap Button
```python
from qtpy.QtWidgets import QWidget, QVBoxLayout
from bec_widgets.widgets.buttons import ColormapButton
class MyGui(QWidget):
def __init__(self):
super().__init__()
self.setLayout(QVBoxLayout(self))
# Create and add the ColormapButton to the layout
self.colormap_button = ColormapButton()
self.layout().addWidget(self.colormap_button)
# Connect the signal to handle colormap changes
self.colormap_button.colormap_changed_signal.connect(self.on_colormap_changed)
def on_colormap_changed(self, colormap_name):
print(f"Selected colormap: {colormap_name}")
# Example of how this custom GUI might be used:
my_gui = MyGui()
my_gui.show()
```
````
````{tab} API

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -13,9 +13,11 @@ The `DeviceLineEdit` widget provides a line edit interface with autocomplete fun
The `DeviceComboBox` widget offers a dropdown interface for device selection, providing a more visual way to browse through available devices.
## Key Features:
- **Device Filtering**: Both widgets allow users to filter devices by their class names, ensuring that only relevant devices are shown.
- **Device Filtering**: Both widgets allow users to filter devices by device type and readout priority, ensuring that only relevant devices are shown.
- **Default Device Setting**: Users can set a default device to be pre-selected when the widget is initialized.
- **Set Device Selection**: Both widgets allow users to set the available devices to be displayed independent of the applied filters.
- **Real-Time Autocomplete (LineEdit)**: The `DeviceLineEdit` widget supports real-time autocomplete, helping users find devices faster.
- **Real-Time Input Validation (LineEdit)**: User input is validated in real-time with a red border around the `DeviceLineEdit` indicating an invalid input.
- **Dropdown Selection (ComboBox)**: The `DeviceComboBox` widget displays devices in a dropdown list, making selection straightforward.
- **QtDesigner Integration**: Both widgets can be added as custom widgets in `QtDesigner` or instantiated directly in code.
@@ -28,11 +30,15 @@ Both `DeviceLineEdit` and `DeviceComboBox` can be integrated within a GUI applic
## Example 1 - Creating a DeviceLineEdit in Code
In this example, we demonstrate how to create a `DeviceLineEdit` widget in code and customize its behavior.
In this example, we demonstrate how to create a `DeviceLineEdit` widget in code and customize its behavior.
We filter down to Positioners with readout_priority Baseline.
Note, if we do not specify a device_filter or readout_filter, all enabled devices will be included.
```python
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.widgets.device_line_edit import DeviceLineEdit
from bec_widgets.widgets.device_line_edit.device_line_edit import DeviceLineEdit
from bec_lib.device import ReadoutPriority
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
class MyGui(QWidget):
def __init__(self):
@@ -40,7 +46,7 @@ class MyGui(QWidget):
self.setLayout(QVBoxLayout(self)) # Initialize the layout for the widget
# Create and add the DeviceLineEdit to the layout
self.device_line_edit = DeviceLineEdit(device_filter="Motor")
self.device_line_edit = DeviceLineEdit(device_filter=BECDeviceFilter.POSITIONER, readout_priority_filter=ReadoutPriority.BASELINE)
self.layout().addWidget(self.device_line_edit)
# Example of how this custom GUI might be used:
@@ -56,7 +62,9 @@ Similarly, here is an example of creating a `DeviceComboBox` widget in code and
```python
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.widgets.device_combo_box import DeviceComboBox
from bec_widgets.widgets.device_combobox.device_combobox import DeviceComboBox
from bec_lib.device import ReadoutPriority
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
class MyGui(QWidget):
def __init__(self):
@@ -64,8 +72,8 @@ class MyGui(QWidget):
self.setLayout(QVBoxLayout(self)) # Initialize the layout for the widget
# Create and add the DeviceComboBox to the layout
self.device_combo_box = DeviceComboBox(device_filter="Motor")
self.layout().addWidget(self.device_combo_box)
self.device_combobox = DeviceComboBox(device_filter=BECDeviceFilter.POSITIONER, readout_priority_filter=ReadoutPriority.BASELINE)
self.layout().addWidget(self.device_combobox)
# Example of how this custom GUI might be used:
app = QApplication([])
@@ -80,11 +88,24 @@ Both `DeviceLineEdit` and `DeviceComboBox` allow you to set a default device tha
```python
# Set default device for DeviceLineEdit
self.device_line_edit.set_default_device("motor1")
self.device_line_edit.set_device("motor1")
# Set default device for DeviceComboBox
self.device_combo_box.set_default_device("motor2")
self.device_combo_box.set_device("motor2")
# Set the available devices to be displayed independent of the applied filters
self.device_combo_box.set_available_devices(["motor1", "motor2", "motor3"])
```
````
````{tab} BEC Designer
Both widgets are also available as plugins for the BEC Designer. We have included Qt properties for both widgets, allowing customization of filtering and default device settings directly from the designer. In addition to the common signals and slots for `DeviceLineEdit` and `DeviceComboBox`, the following slots are available:
- `set_device(str)` to set the default device
- `update_devices()` to refresh the devices list
The following Qt properties are also included:
```{figure} ./QProperties_DeviceInput.png
```
````
````{tab} API - ComboBox

View File

@@ -0,0 +1,114 @@
(user.widgets.signal_input)=
# Signal Input Widgets
````{tab} Overview
The `Signal Input Widgets` consist of two primary widgets: `SignalLineEdit` and `SignalComboBox`. Both widgets are designed to facilitate the selection of the available signals for a selected device within the current BEC session. These widgets allow users to filter, search, and select signals dynamically. The widgets can either be integrated into a GUI through direct code instantiation or by using `QtDesigner`.
## SignalLineEdit
The `SignalLineEdit` widget provides a line edit interface with autocomplete functionality for the available of signals associated with the selected device. This widget is ideal for users who prefer to type in the signal name directly. If no device is selected, the autocomplete will be empty. In addition, the widget will display a red border around the line edit if the input signal is invalid.
## SignalComboBox
The `SignalComboBox` widget offers a dropdown interface for choosing a signal from the available signals of a device. It will further categorise the signals according to its `kind`: `hinted`, `normal` and `config`. For more information about `kind`, please check the [ophyd documentation](https://nsls-ii.github.io/ophyd/signals.html#kind). This widget is ideal for users who prefer to select signals from a list.
## Key Features:
- **Signal Filtering**: Both widgets allow users to filter devices by signal types(`kind`). No selected filter will show all signals.
- **Real-Time Autocomplete (LineEdit)**: The `SignalLineEdit` widget supports real-time autocomplete, helping users find devices faster.
- **Real-Time Input Validation (LineEdit)**: User input is validated in real-time with a red border around the `SignalLineEdit` indicating an invalid input.
- **Dropdown Selection (SignalComboBox)**: The `SignalComboBox` widget displays the sorted signals of the device
- **QtDesigner Integration**: Both widgets can be added as custom widgets in `QtDesigner` or instantiated directly in code.
````
````{tab} Examples
Both `SignalLineEdit` and `SignalComboBox` can be integrated within a GUI application through direct code instantiation or by using `QtDesigner`. Below are examples demonstrating how to create and use these widgets.
## Example 1 - Creating a SignalLineEdit in Code
In this example, we demonstrate how to create a `SignalLineEdit` widget in code and customize its behavior.
We will select `samx`, which is a motor in the BEC simulation device config, and filter the signals to `normal` and `hinted`.
Note, not specifying signal_filter will include all signals.
```python
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.widgets.signal_line_edit.signal_line_edit import SignalLineEdit
from bec_widgets.widgets.base_classes.device_signal_input_base import BECSignalFilter
class MyGui(QWidget):
def __init__(self):
super().__init__()
self.setLayout(QVBoxLayout(self)) # Initialize the layout for the widget
# Create and add the DeviceLineEdit to the layout
self.signal_line_edit = SignalLineEdit(device="samx", signal_filter=[BECSignalFilter.NORMAL, BECSignalFilter.HINTED])
self.layout().addWidget(self.signal_line_edit)
# Example of how this custom GUI might be used:
app = QApplication([])
my_gui = MyGui()
my_gui.show()
app.exec_()
```
## Example 2 - Creating a DeviceComboBox in Code
Similarly, here is an example of creating a `DeviceComboBox` widget in code and customizing its behavior.
```python
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.widgets.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.base_classes.device_signal_input_base import BECSignalFilter
class MyGui(QWidget):
def __init__(self):
super().__init__()
self.setLayout(QVBoxLayout(self)) # Initialize the layout for the widget
# Create and add the DeviceLineEdit to the layout
self.signal_combobox = SignalComboBox(device="samx", signal_filter=[BECSignalFilter.NORMAL, BECSignalFilter.HINTED])
self.layout().addWidget(self.signal_combobox)
# Example of how this custom GUI might be used:
app = QApplication([])
my_gui = MyGui()
my_gui.show()
app.exec_()
```
## Example 3 - Setting Default Device
Both `SignalLineEdit` and `SignalComboBox` allow you to set a default device that will be selected when the widget is initialized.
```python
# Set default device for DeviceLineEdit
self.signal_line_edit.set_device("motor1")
# Set default device for DeviceComboBox
self.signal_combobox.set_device("motor2")
```
````
````{tab} BEC Designer
Both widgets are also available as plugins for the BEC Designer. We have included Qt properties for both widgets, allowing customization of filtering and default device settings directly from the designer. In addition to the common signals and slots for `SignalLineEdit` and `SignalComboBox`, the following slots are available:
- `set_device(str)` to set the default device
- `set_signal(str)` to set the default signal
- `update_signals_from_filters()` to refresh the devices list based on the current filters
The following Qt properties are also included:
```{figure} ./signal_input_qproperties.png
```
````
````{tab} API - ComboBox
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.SignalComboBox.rst
```
````
````{tab} API - LineEdit
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.SignalLineEdit.rst
```
````

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -159,6 +159,14 @@ Various buttons which manage the control of the BEC Queue.
Choose individual device from current session.
```
```{grid-item-card} Signal Input Widgets
:link: user.widgets.signal_input
:link-type: ref
:img-top: /assets/widget_screenshots/signal_inputs.png
Choose individual signals available for a selected device.
```
```{grid-item-card} Text Box Widget
:link: user.widgets.text_box
:link-type: ref

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "1.0.2"
version = "1.3.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [

View File

@@ -170,12 +170,17 @@ def test_ring_bar(rpc_server_dock):
bar_config = bar._config_dict
expected_colors = [list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB")]
expected_colors_light = [
list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB", theme="light")
]
expected_colors_dark = [
list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB", theme="dark")
]
bar_colors = [ring._config_dict["color"] for ring in bar.rings]
bar_values = [ring._config_dict["value"] for ring in bar.rings]
assert bar_config["num_bars"] == 5
assert bar_values == [10, 20, 30, 40, 50]
assert bar_colors == expected_colors
assert bar_colors == expected_colors_light or bar_colors == expected_colors_dark
def test_ring_bar_scan_update(bec_client_lib, rpc_server_dock):

View File

@@ -3,147 +3,9 @@ from unittest.mock import MagicMock, patch
import fakeredis
import pytest
from bec_lib.client import BECClient
from bec_lib.device import Positioner, ReadoutPriority
from bec_lib.devicemanager import DeviceContainer
from bec_lib.redis_connector import RedisConnector
class FakeDevice:
"""Fake minimal positioner class for testing."""
def __init__(self, name, enabled=True, readout_priority=ReadoutPriority.MONITORED):
self.name = name
self.enabled = enabled
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self.readout_priority = readout_priority
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd_devices.SimPositioner",
"deviceConfig": {
"delay": 1,
"limits": [-50, 50],
"tolerance": 0.01,
"update_frequency": 400,
},
"deviceTags": ["user motors"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.signals[self.name]["value"] = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
class FakePositioner(FakeDevice):
def __init__(
self,
name,
enabled=True,
limits=None,
read_value=1.0,
readout_priority=ReadoutPriority.MONITORED,
):
super().__init__(name, enabled, readout_priority)
self.limits = limits if limits is not None else [0, 0]
self.read_value = read_value
self.name = name
@property
def precision(self):
return 3
def set_read_value(self, value):
self.read_value = value
def read(self):
return {
self.name: {"value": self.read_value},
f"{self.name}_setpoint": {"value": self.read_value},
f"{self.name}_motor_is_moving": {"value": 0},
}
def set_limits(self, limits):
self.limits = limits
def move(self, value, relative=False):
"""Simulates moving the device to a new position."""
if relative:
self.read_value += value
else:
self.read_value = value
# Respect the limits
self.read_value = max(min(self.read_value, self.limits[1]), self.limits[0])
@property
def readback(self):
return MagicMock(get=MagicMock(return_value=self.read_value))
class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0):
super().__init__(name, limits, read_value)
class Device(FakeDevice):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name, enabled=True):
super().__init__(name, enabled)
class DMMock:
def __init__(self):
self.devices = DeviceContainer()
def add_devives(self, devices: list):
for device in devices:
self.devices[device.name] = device
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
FakePositioner("samy", limits=[-5, 5], read_value=3.0),
FakePositioner("samz", limits=[-8, 8], read_value=4.0),
FakePositioner("aptrx", limits=None, read_value=4.0),
FakePositioner("aptry", limits=None, read_value=5.0),
FakeDevice("gauss_bpm"),
FakeDevice("gauss_adc1"),
FakeDevice("gauss_adc2"),
FakeDevice("gauss_adc3"),
FakeDevice("bpm4i"),
FakeDevice("bpm3a"),
FakeDevice("bpm3i"),
FakeDevice("eiger"),
FakeDevice("waveform1d"),
FakeDevice("async_device", readout_priority=ReadoutPriority.ASYNC),
Positioner("test", limits=[-10, 10], read_value=2.0),
Device("test_device"),
]
from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner
def fake_redis_server(host, port):

View File

@@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch
import pyqtgraph as pg
import pytest
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.image.image_widget import BECImageWidget
from .client_mocks import mocked_client
@@ -11,7 +12,6 @@ from .client_mocks import mocked_client
@pytest.fixture
def image_widget(qtbot, mocked_client):
widget = BECImageWidget(client=mocked_client())
widget.toolbar.widgets["monitor"].device_combobox.set_device_filter("FakeDevice")
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@@ -32,7 +32,8 @@ def test_image_widget_init(image_widget):
assert image_widget._image is not None
assert (
image_widget.toolbar.widgets["monitor"].device_combobox.config.device_filter == "FakeDevice"
BECDeviceFilter.DEVICE
in image_widget.toolbar.widgets["monitor"].device_combobox.config.device_filter
)
assert image_widget.toolbar.widgets["drag_mode"].action.isChecked() == True
assert image_widget.toolbar.widgets["rectangle_mode"].action.isChecked() == False

View File

@@ -4,8 +4,7 @@ import pytest
from bec_widgets.cli.client import BECFigure
from bec_widgets.cli.client_utils import BECGuiClientMixin, _start_plot_process
from .client_mocks import FakeDevice
from bec_widgets.tests.utils import FakeDevice
@pytest.fixture

View File

@@ -1,5 +1,6 @@
import pytest
from pydantic import ValidationError
from qtpy.QtGui import QColor
from bec_widgets.utils import Colors
from bec_widgets.widgets.figure.plots.waveform.waveform_curve import CurveConfig
@@ -73,3 +74,39 @@ def test_rgba_to_hex():
assert Colors.rgba_to_hex(255, 87, 51, 255) == "#FF5733FF"
assert Colors.rgba_to_hex(255, 87, 51, 128) == "#FF573380"
assert Colors.rgba_to_hex(255, 87, 51) == "#FF5733FF"
@pytest.mark.parametrize("num", [10, 100, 400])
def test_evenly_spaced_colors(num):
colors_qcolor = Colors.evenly_spaced_colors(colormap="magma", num=num, format="QColor")
colors_hex = Colors.evenly_spaced_colors(colormap="magma", num=num, format="HEX")
colors_rgb = Colors.evenly_spaced_colors(colormap="magma", num=num, format="RGB")
assert len(colors_qcolor) == num
assert len(colors_hex) == num
assert len(colors_rgb) == num
assert all(isinstance(color, QColor) for color in colors_qcolor)
assert all(isinstance(color, str) for color in colors_hex)
assert all(isinstance(color, tuple) for color in colors_rgb)
assert all(color.isValid() for color in colors_qcolor)
assert all(color.startswith("#") for color in colors_hex)
@pytest.mark.parametrize("num", [10, 100, 400])
def test_golder_angle_colors(num):
colors_qcolor = Colors.golden_angle_color(colormap="magma", num=num, format="QColor")
colors_hex = Colors.golden_angle_color(colormap="magma", num=num, format="HEX")
colors_rgb = Colors.golden_angle_color(colormap="magma", num=num, format="RGB")
assert len(colors_qcolor) == num
assert len(colors_hex) == num
assert len(colors_rgb) == num
assert all(isinstance(color, QColor) for color in colors_qcolor)
assert all(isinstance(color, str) for color in colors_hex)
assert all(isinstance(color, tuple) for color in colors_rgb)
assert all(color.isValid() for color in colors_qcolor)
assert all(color.startswith("#") for color in colors_hex)

View File

@@ -0,0 +1,69 @@
import pytest
from pyqtgraph.widgets.ColorMapButton import ColorMapButton
from bec_widgets.widgets.colormap_widget.colormap_widget import BECColorMapWidget
@pytest.fixture
def color_map_widget(qtbot):
widget = BECColorMapWidget()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_color_map_widget_init(color_map_widget):
"""Test that the widget initializes correctly."""
assert color_map_widget is not None
assert isinstance(color_map_widget, BECColorMapWidget)
assert color_map_widget.colormap == "magma"
assert isinstance(color_map_widget.button, ColorMapButton)
# Check that the button has the correct initial colormap
assert color_map_widget.button.colorMap().name == "magma"
def test_color_map_widget_set_valid_colormap(color_map_widget):
"""
Test setting a valid colormap.
"""
new_cmap = "viridis"
color_map_widget.colormap = new_cmap
assert color_map_widget.colormap == new_cmap
assert color_map_widget.button.colorMap().name == new_cmap
def test_color_map_widget_set_invalid_colormap(color_map_widget):
"""Test setting an invalid colormap."""
invalid_cmap = "invalid_colormap_name"
old_cmap = color_map_widget.colormap
color_map_widget.colormap = invalid_cmap
# Since invalid, the colormap should not change
assert color_map_widget.colormap == old_cmap
assert color_map_widget.button.colorMap().name == old_cmap
def test_color_map_widget_signal_emitted(color_map_widget, qtbot):
"""Test that the signal is emitted when the colormap changes."""
new_cmap = "plasma"
with qtbot.waitSignal(color_map_widget.colormap_changed_signal, timeout=1000) as blocker:
color_map_widget.colormap = new_cmap
assert blocker.signal_triggered
assert blocker.args == [new_cmap]
assert color_map_widget.colormap == new_cmap
def test_color_map_widget_signal_not_emitted_for_invalid_colormap(color_map_widget, qtbot):
"""Test that the signal is not emitted when an invalid colormap is set."""
invalid_cmap = "invalid_colormap_name"
with qtbot.assertNotEmitted(color_map_widget.colormap_changed_signal):
color_map_widget.colormap = invalid_cmap
# The colormap should remain unchanged
assert color_map_widget.colormap == "magma"
def test_color_map_widget_resize(color_map_widget):
"""Test that the widget resizes properly."""
width, height = 200, 50
color_map_widget.resize(width, height)
assert color_map_widget.width() == width
assert color_map_widget.height() == height

View File

@@ -1,13 +1,20 @@
from unittest import mock
import pytest
from bec_lib.device import ReadoutPriority
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.base_classes.device_input_base import DeviceInputBase
from bec_widgets.tests.utils import FakePositioner
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter, DeviceInputBase
from .client_mocks import mocked_client
from .conftest import create_widget
# DeviceInputBase is meant to be mixed in a QWidget
class DeviceInputWidget(DeviceInputBase, QWidget):
"""Thin wrapper around DeviceInputBase to make it a QWidget"""
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
@@ -15,62 +22,113 @@ class DeviceInputWidget(DeviceInputBase, QWidget):
@pytest.fixture
def device_input_base(qtbot, mocked_client):
widget = DeviceInputWidget(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
"""Fixture with mocked FilterIO and WidgetIO"""
with mock.patch("bec_widgets.utils.filter_io.FilterIO.set_selection"):
with mock.patch("bec_widgets.utils.widget_io.WidgetIO.set_value"):
with mock.patch("bec_widgets.utils.widget_io.WidgetIO.get_value"):
widget = create_widget(qtbot=qtbot, widget=DeviceInputWidget, client=mocked_client)
yield widget
def test_device_input_base_init(device_input_base):
"""Test init"""
assert device_input_base is not None
assert device_input_base.client is not None
assert isinstance(device_input_base, DeviceInputBase)
assert device_input_base.config.widget_class == "DeviceInputWidget"
assert device_input_base.config.device_filter is None
assert device_input_base.config.device_filter == []
assert device_input_base.config.default is None
assert device_input_base.devices == []
def test_device_input_base_init_with_config(mocked_client):
"""Test init with Config"""
config = {
"widget_class": "DeviceInputWidget",
"gui_id": "test_gui_id",
"device_filter": "FakePositioner",
"device_filter": [BECDeviceFilter.POSITIONER],
"default": "samx",
}
widget = DeviceInputWidget(client=mocked_client, config=config)
assert widget.config.gui_id == "test_gui_id"
assert widget.config.device_filter == "FakePositioner"
assert widget.config.device_filter == [BECDeviceFilter.POSITIONER]
assert widget.config.default == "samx"
def test_device_input_base_set_device_filter(device_input_base):
device_input_base.set_device_filter("FakePositioner")
assert device_input_base.config.device_filter == "FakePositioner"
"""Test device filter setter."""
device_input_base.set_device_filter(BECDeviceFilter.POSITIONER)
assert device_input_base.config.device_filter == [BECDeviceFilter.POSITIONER]
def test_device_input_base_set_device_filter_error(device_input_base):
with pytest.raises(ValueError) as excinfo:
device_input_base.set_device_filter("NonExistingClass")
assert "Device filter NonExistingClass is not in the device list." in str(excinfo.value)
"""Test set_device_filter with Noneexisting class. This should not raise. It writes a log message entry."""
device_input_base.set_device_filter("NonExistingClass")
assert device_input_base.device_filter == []
def test_device_input_base_set_default_device(device_input_base):
device_input_base.set_default_device("samx")
"""Test setting the default device. Also tests the update_devices method."""
device_input_base.set_device("samx")
assert device_input_base.config.default == None
device_input_base.set_device_filter(BECDeviceFilter.POSITIONER)
device_input_base.set_readout_priority_filter(ReadoutPriority.MONITORED)
device_input_base.set_device("samx")
assert device_input_base.config.default == "samx"
def test_device_input_base_set_default_device_error(device_input_base):
with pytest.raises(ValueError) as excinfo:
device_input_base.set_default_device("NonExistingDevice")
assert "Default device NonExistingDevice is not in the device list." in str(excinfo.value)
def test_device_input_base_get_device_list(device_input_base):
devices = device_input_base.get_device_list("FakePositioner")
assert devices == ["samx", "samy", "samz", "aptrx", "aptry"]
def test_device_input_base_get_filters(device_input_base):
"""Test getting the available filters."""
filters = device_input_base.get_available_filters()
assert filters == {"FakePositioner", "FakeDevice", "Positioner", "Device"}
selection = [
BECDeviceFilter.POSITIONER,
BECDeviceFilter.DEVICE,
BECDeviceFilter.COMPUTED_SIGNAL,
BECDeviceFilter.SIGNAL,
] + [
ReadoutPriority.MONITORED,
ReadoutPriority.BASELINE,
ReadoutPriority.ASYNC,
ReadoutPriority.ON_REQUEST,
]
assert [entry for entry in filters if entry in selection]
def test_device_input_base_properties(device_input_base):
"""Test setting the properties of the device input base."""
assert device_input_base.device_filter == []
device_input_base.filter_to_device = True
assert device_input_base.device_filter == [BECDeviceFilter.DEVICE]
device_input_base.filter_to_positioner = True
assert device_input_base.device_filter == [BECDeviceFilter.DEVICE, BECDeviceFilter.POSITIONER]
device_input_base.filter_to_computed_signal = True
assert device_input_base.device_filter == [
BECDeviceFilter.DEVICE,
BECDeviceFilter.POSITIONER,
BECDeviceFilter.COMPUTED_SIGNAL,
]
device_input_base.filter_to_signal = True
assert device_input_base.device_filter == [
BECDeviceFilter.DEVICE,
BECDeviceFilter.POSITIONER,
BECDeviceFilter.COMPUTED_SIGNAL,
BECDeviceFilter.SIGNAL,
]
assert device_input_base.readout_filter == []
device_input_base.readout_async = True
assert device_input_base.readout_filter == [ReadoutPriority.ASYNC]
device_input_base.readout_baseline = True
assert device_input_base.readout_filter == [ReadoutPriority.ASYNC, ReadoutPriority.BASELINE]
device_input_base.readout_monitored = True
assert device_input_base.readout_filter == [
ReadoutPriority.ASYNC,
ReadoutPriority.BASELINE,
ReadoutPriority.MONITORED,
]
device_input_base.readout_on_request = True
assert device_input_base.readout_filter == [
ReadoutPriority.ASYNC,
ReadoutPriority.BASELINE,
ReadoutPriority.MONITORED,
ReadoutPriority.ON_REQUEST,
]

View File

@@ -1,5 +1,7 @@
import pytest
from bec_lib.device import ReadoutPriority
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.device_line_edit.device_line_edit import DeviceLineEdit
@@ -14,27 +16,12 @@ def device_input_combobox(qtbot, mocked_client):
yield widget
@pytest.fixture
def device_input_combobox_with_config(qtbot, mocked_client):
config = {
"widget_class": "DeviceComboBox",
"gui_id": "test_gui_id",
"device_filter": "FakePositioner",
"default": "samx",
"arg_name": "test_arg_name",
}
widget = DeviceComboBox(client=mocked_client, config=config)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def device_input_combobox_with_kwargs(qtbot, mocked_client):
widget = DeviceComboBox(
client=mocked_client,
gui_id="test_gui_id",
device_filter="FakePositioner",
device_filter=[BECDeviceFilter.POSITIONER],
default="samx",
arg_name="test_arg_name",
)
@@ -48,8 +35,6 @@ def test_device_input_combobox_init(device_input_combobox):
assert device_input_combobox.client is not None
assert isinstance(device_input_combobox, DeviceComboBox)
assert device_input_combobox.config.widget_class == "DeviceComboBox"
assert device_input_combobox.config.device_filter is None
assert device_input_combobox.config.default is None
assert device_input_combobox.devices == [
"samx",
"samy",
@@ -71,16 +56,9 @@ def test_device_input_combobox_init(device_input_combobox):
]
def test_device_input_combobox_init_with_config(device_input_combobox_with_config):
assert device_input_combobox_with_config.config.gui_id == "test_gui_id"
assert device_input_combobox_with_config.config.device_filter == "FakePositioner"
assert device_input_combobox_with_config.config.default == "samx"
assert device_input_combobox_with_config.config.arg_name == "test_arg_name"
def test_device_input_combobox_init_with_kwargs(device_input_combobox_with_kwargs):
assert device_input_combobox_with_kwargs.config.gui_id == "test_gui_id"
assert device_input_combobox_with_kwargs.config.device_filter == "FakePositioner"
assert device_input_combobox_with_kwargs.config.device_filter == [BECDeviceFilter.POSITIONER]
assert device_input_combobox_with_kwargs.config.default == "samx"
assert device_input_combobox_with_kwargs.config.arg_name == "test_arg_name"
@@ -88,7 +66,7 @@ def test_device_input_combobox_init_with_kwargs(device_input_combobox_with_kwarg
def test_get_device_from_input_combobox_init(device_input_combobox):
device_input_combobox.setCurrentIndex(0)
device_text = device_input_combobox.currentText()
current_device = device_input_combobox.get_device()
current_device = device_input_combobox.get_current_device()
assert current_device.name == device_text
@@ -101,27 +79,12 @@ def device_input_line_edit(qtbot, mocked_client):
yield widget
@pytest.fixture
def device_input_line_edit_with_config(qtbot, mocked_client):
config = {
"widget_class": "DeviceLineEdit",
"gui_id": "test_gui_id",
"device_filter": "FakePositioner",
"default": "samx",
"arg_name": "test_arg_name",
}
widget = DeviceLineEdit(client=mocked_client, config=config)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def device_input_line_edit_with_kwargs(qtbot, mocked_client):
widget = DeviceLineEdit(
client=mocked_client,
gui_id="test_gui_id",
device_filter="FakePositioner",
device_filter=[BECDeviceFilter.POSITIONER],
default="samx",
arg_name="test_arg_name",
)
@@ -135,7 +98,14 @@ def test_device_input_line_edit_init(device_input_line_edit):
assert device_input_line_edit.client is not None
assert isinstance(device_input_line_edit, DeviceLineEdit)
assert device_input_line_edit.config.widget_class == "DeviceLineEdit"
assert device_input_line_edit.config.device_filter is None
assert device_input_line_edit.config.device_filter == []
assert device_input_line_edit.config.readout_filter == [
ReadoutPriority.MONITORED,
ReadoutPriority.BASELINE,
ReadoutPriority.ASYNC,
ReadoutPriority.CONTINUOUS,
ReadoutPriority.ON_REQUEST,
]
assert device_input_line_edit.config.default is None
assert device_input_line_edit.devices == [
"samx",
@@ -158,16 +128,9 @@ def test_device_input_line_edit_init(device_input_line_edit):
]
def test_device_input_line_edit_init_with_config(device_input_line_edit_with_config):
assert device_input_line_edit_with_config.config.gui_id == "test_gui_id"
assert device_input_line_edit_with_config.config.device_filter == "FakePositioner"
assert device_input_line_edit_with_config.config.default == "samx"
assert device_input_line_edit_with_config.config.arg_name == "test_arg_name"
def test_device_input_line_edit_init_with_kwargs(device_input_line_edit_with_kwargs):
assert device_input_line_edit_with_kwargs.config.gui_id == "test_gui_id"
assert device_input_line_edit_with_kwargs.config.device_filter == "FakePositioner"
assert device_input_line_edit_with_kwargs.config.device_filter == [BECDeviceFilter.POSITIONER]
assert device_input_line_edit_with_kwargs.config.default == "samx"
assert device_input_line_edit_with_kwargs.config.arg_name == "test_arg_name"
@@ -175,6 +138,6 @@ def test_device_input_line_edit_init_with_kwargs(device_input_line_edit_with_kwa
def test_get_device_from_input_line_edit_init(device_input_line_edit):
device_input_line_edit.setText("samx")
device_text = device_input_line_edit.text()
current_device = device_input_line_edit.get_device()
current_device = device_input_line_edit.get_current_device()
assert current_device.name == device_text

View File

@@ -0,0 +1,121 @@
from unittest import mock
import pytest
from ophyd import Kind
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.base_classes.device_signal_input_base import DeviceSignalInputBase
from bec_widgets.widgets.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.signal_line_edit.signal_line_edit import SignalLineEdit
from .client_mocks import mocked_client
from .conftest import create_widget
class DeviceInputWidget(DeviceSignalInputBase, QWidget):
"""Thin wrapper around DeviceInputBase to make it a QWidget"""
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
@pytest.fixture
def device_signal_base(qtbot, mocked_client):
"""Fixture with mocked FilterIO and WidgetIO"""
with mock.patch("bec_widgets.utils.filter_io.FilterIO.set_selection"):
with mock.patch("bec_widgets.utils.widget_io.WidgetIO.set_value"):
widget = create_widget(qtbot=qtbot, widget=DeviceInputWidget, client=mocked_client)
yield widget
@pytest.fixture
def device_signal_combobox(qtbot, mocked_client):
"""Fixture with mocked FilterIO and WidgetIO"""
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
yield widget
@pytest.fixture
def device_signal_line_edit(qtbot, mocked_client):
"""Fixture with mocked FilterIO and WidgetIO"""
widget = create_widget(qtbot=qtbot, widget=SignalLineEdit, client=mocked_client)
yield widget
@pytest.fixture
def test_device_signal_combo(qtbot, mocked_client):
"""Fixture to create a SignalComboBox widget and a DeviceInputWidget widget"""
input = create_widget(
qtbot=qtbot,
widget=DeviceComboBox,
client=mocked_client,
device_filter=[BECDeviceFilter.POSITIONER],
)
signal = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
yield input, signal
def test_device_signal_base_init(device_signal_base):
"""Test if the DeviceSignalInputBase is initialized correctly"""
assert device_signal_base._device is None
assert device_signal_base._signal_filter == []
assert device_signal_base._signals == []
assert device_signal_base._hinted_signals == []
assert device_signal_base._normal_signals == []
assert device_signal_base._config_signals == []
def test_device_signal_qproperties(device_signal_base):
"""Test if the DeviceSignalInputBase has the correct QProperties"""
device_signal_base.include_config_signals = True
assert device_signal_base._signal_filter == [Kind.config]
device_signal_base.include_normal_signals = True
assert device_signal_base._signal_filter == [Kind.config, Kind.normal]
device_signal_base.include_hinted_signals = True
assert device_signal_base._signal_filter == [Kind.config, Kind.normal, Kind.hinted]
def test_device_signal_set_device(device_signal_base):
"""Test if the set_device method works correctly"""
device_signal_base.include_hinted_signals = True
device_signal_base.set_device("samx")
assert device_signal_base.device == "samx"
assert device_signal_base.signals == ["readback"]
device_signal_base.include_normal_signals = True
assert device_signal_base.signals == ["readback", "setpoint"]
device_signal_base.include_config_signals = True
assert device_signal_base.signals == ["readback", "setpoint", "velocity"]
def test_signal_combobox(qtbot, device_signal_combobox):
"""Test the signal_combobox"""
container = []
def test_cb(input):
container.append(input)
device_signal_combobox.device_signal_changed.connect(test_cb)
assert device_signal_combobox._signals == []
device_signal_combobox.include_normal_signals = True
device_signal_combobox.include_hinted_signals = True
device_signal_combobox.include_config_signals = True
assert device_signal_combobox.signals == []
device_signal_combobox.set_device("samx")
assert device_signal_combobox.signals == ["readback", "setpoint", "velocity"]
qtbot.wait(100)
assert container == ["samx"]
def test_signal_lineeidt(device_signal_line_edit):
"""Test the signal_combobox"""
assert device_signal_line_edit._signals == []
device_signal_line_edit.include_normal_signals = True
device_signal_line_edit.include_hinted_signals = True
device_signal_line_edit.include_config_signals = True
assert device_signal_line_edit.signals == []
device_signal_line_edit.set_device("samx")
assert device_signal_line_edit.signals == ["readback", "setpoint", "velocity"]

View File

@@ -0,0 +1,45 @@
import pytest
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.widgets.dap_combo_box.dap_combo_box import DapComboBox
from bec_widgets.widgets.device_line_edit.device_line_edit import DeviceLineEdit
from .client_mocks import mocked_client
from .conftest import create_widget
@pytest.fixture(scope="function")
def dap_mock(qtbot, mocked_client):
"""Fixture for QLineEdit widget"""
models = ["GaussianModel", "LorentzModel", "SineModel"]
mocked_client.dap._available_dap_plugins.keys.return_value = models
widget = create_widget(qtbot, DapComboBox, client=mocked_client)
return widget
@pytest.fixture(scope="function")
def line_edit_mock(qtbot, mocked_client):
"""Fixture for QLineEdit widget"""
widget = create_widget(qtbot, DeviceLineEdit, client=mocked_client)
return widget
def test_set_selection_combo_box(dap_mock):
"""Test set selection for QComboBox using DapComboBox"""
assert dap_mock.fit_model_combobox.count() == 3
FilterIO.set_selection(dap_mock.fit_model_combobox, selection=["testA", "testB"])
assert dap_mock.fit_model_combobox.count() == 2
assert FilterIO.check_input(widget=dap_mock.fit_model_combobox, text="testA") is True
def test_set_selection_line_edit(line_edit_mock):
"""Test set selection for QComboBox using DapComboBox"""
FilterIO.set_selection(line_edit_mock, selection=["testA", "testB"])
assert line_edit_mock.completer.model().rowCount() == 2
model = line_edit_mock.completer.model()
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
assert model_data == ["testA", "testB"]
assert FilterIO.check_input(widget=line_edit_mock, text="testA") is True
FilterIO.set_selection(line_edit_mock, selection=["testC"])
assert FilterIO.check_input(widget=line_edit_mock, text="testA") is False
assert FilterIO.check_input(widget=line_edit_mock, text="testC") is True

View File

@@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch
import pytest
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.motor_map.motor_map_dialog.motor_map_settings import MotorMapSettings
from bec_widgets.widgets.motor_map.motor_map_widget import BECMotorMapWidget
@@ -11,8 +12,8 @@ from .client_mocks import mocked_client
@pytest.fixture
def motor_map_widget(qtbot, mocked_client):
widget = BECMotorMapWidget(client=mocked_client())
widget.toolbar.widgets["motor_x"].device_combobox.set_device_filter("FakePositioner")
widget.toolbar.widgets["motor_y"].device_combobox.set_device_filter("FakePositioner")
widget.toolbar.widgets["motor_x"].device_combobox.set_device_filter(BECDeviceFilter.POSITIONER)
widget.toolbar.widgets["motor_y"].device_combobox.set_device_filter(BECDeviceFilter.POSITIONER)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@@ -35,14 +36,12 @@ def test_motor_map_widget_init(motor_map_widget):
assert motor_map_widget.toolbar.widgets["connect"].action.isEnabled() == True
assert motor_map_widget.toolbar.widgets["config"].action.isEnabled() == False
assert motor_map_widget.toolbar.widgets["history"].action.isEnabled() == False
assert (
motor_map_widget.toolbar.widgets["motor_x"].device_combobox.config.device_filter
== "FakePositioner"
)
assert (
motor_map_widget.toolbar.widgets["motor_y"].device_combobox.config.device_filter
== "FakePositioner"
)
assert motor_map_widget.toolbar.widgets["motor_x"].device_combobox.config.device_filter == [
BECDeviceFilter.POSITIONER
]
assert motor_map_widget.toolbar.widgets["motor_y"].device_combobox.config.device_filter == [
BECDeviceFilter.POSITIONER
]
assert motor_map_widget.map.motor_x is None
assert motor_map_widget.map.motor_y is None

View File

@@ -372,22 +372,21 @@ def test_run_line_scan_with_parameters(scan_control, mocked_client):
scan_name = "line_scan"
kwargs = {"exp_time": 0.1, "steps": 10, "relative": True, "burst_at_each_point": 1}
args = {"device": "samx", "start": -5, "stop": 5}
mock_slot = MagicMock()
scan_control.scan_args.connect(mock_slot)
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
# Set kwargs in the UI
for kwarg_box in scan_control.kwarg_boxes:
for widget in kwarg_box.widgets:
for key, value in kwargs.items():
if widget.arg_name == key:
WidgetIO.set_value(widget, value)
break
if widget.arg_name in kwargs:
WidgetIO.set_value(widget, kwargs[widget.arg_name])
# Set args in the UI
for widget in scan_control.arg_box.widgets:
for key, value in args.items():
if widget.arg_name == key:
WidgetIO.set_value(widget, value)
break
if widget.arg_name in args:
WidgetIO.set_value(widget, args[widget.arg_name])
# Mock the scan function
mocked_scan_function = MagicMock()
@@ -405,6 +404,88 @@ def test_run_line_scan_with_parameters(scan_control, mocked_client):
assert called_args == tuple(expected_args_list)
assert called_kwargs == kwargs
# Check the emitted signal
mock_slot.assert_called_once()
emitted_args_list = mock_slot.call_args[0][0]
assert len(emitted_args_list) == 3 # Expected 3 arguments for line_scan
assert emitted_args_list == [expected_device, -5.0, 5.0]
def test_run_grid_scan_with_parameters(scan_control, mocked_client):
scan_name = "grid_scan"
kwargs = {"exp_time": 0.2, "settling_time": 0.1, "relative": False, "burst_at_each_point": 2}
args_row1 = {"device": "samx", "start": -10, "stop": 10, "steps": 20}
args_row2 = {"device": "samy", "start": -5, "stop": 5, "steps": 10}
mock_slot = MagicMock()
scan_control.scan_args.connect(mock_slot)
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
# Ensure there are two rows in the arg_box
current_rows = scan_control.arg_box.count_arg_rows()
required_rows = 2
while current_rows < required_rows:
scan_control.arg_box.add_widget_bundle()
current_rows += 1
# Set kwargs in the UI
for kwarg_box in scan_control.kwarg_boxes:
for widget in kwarg_box.widgets:
if widget.arg_name in kwargs:
WidgetIO.set_value(widget, kwargs[widget.arg_name])
# Set args in the UI for both rows
arg_widgets = scan_control.arg_box.widgets # This is a flat list of widgets
num_columns = len(scan_control.arg_box.inputs)
num_rows = int(len(arg_widgets) / num_columns)
assert num_rows == required_rows # We expect 2 rows for grid_scan
# Set values for first row
for i in range(num_columns):
widget = arg_widgets[i]
arg_name = widget.arg_name
if arg_name in args_row1:
WidgetIO.set_value(widget, args_row1[arg_name])
# Set values for second row
for i in range(num_columns):
widget = arg_widgets[num_columns + i] # Next row
arg_name = widget.arg_name
if arg_name in args_row2:
WidgetIO.set_value(widget, args_row2[arg_name])
# Mock the scan function
mocked_scan_function = MagicMock()
setattr(mocked_client.scans, scan_name, mocked_scan_function)
# Run the scan
scan_control.button_run_scan.click()
# Retrieve the actual arguments passed to the mock
called_args, called_kwargs = mocked_scan_function.call_args
# Check if the scan function was called correctly
expected_device1 = mocked_client.device_manager.devices.samx
expected_device2 = mocked_client.device_manager.devices.samy
expected_args_list = [
expected_device1,
args_row1["start"],
args_row1["stop"],
args_row1["steps"],
expected_device2,
args_row2["start"],
args_row2["stop"],
args_row2["steps"],
]
assert called_args == tuple(expected_args_list)
assert called_kwargs == kwargs
# Check the emitted signal
mock_slot.assert_called_once()
emitted_args_list = mock_slot.call_args[0][0]
assert len(emitted_args_list) == 8 # Expected 8 arguments for grid_scan
assert emitted_args_list == expected_args_list
def test_changing_scans_remember_parameters(scan_control, mocked_client):
scan_name = "line_scan"

View File

@@ -152,12 +152,30 @@ def test_getting_curve(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
c1 = w1.add_curve_bec(x_name="samx", y_name="bpm4i", gui_id="test_curve")
c1_expected_config = CurveConfig(
c1_expected_config_dark = CurveConfig(
widget_class="BECCurve",
gui_id="test_curve",
parent_id=w1.gui_id,
label="bpm4i-bpm4i",
color="#b73779",
color="#3b0f70",
symbol="o",
symbol_color=None,
symbol_size=7,
pen_width=4,
pen_style="solid",
source="scan_segment",
signals=Signal(
source="scan_segment",
x=SignalData(name="samx", entry="samx", unit=None, modifier=None),
y=SignalData(name="bpm4i", entry="bpm4i", unit=None, modifier=None),
),
)
c1_expected_config_light = CurveConfig(
widget_class="BECCurve",
gui_id="test_curve",
parent_id=w1.gui_id,
label="bpm4i-bpm4i",
color="#000004",
symbol="o",
symbol_color=None,
symbol_size=7,
@@ -171,14 +189,39 @@ def test_getting_curve(qtbot, mocked_client):
),
)
assert w1.curves[0].config == c1_expected_config
assert w1._curves_data["scan_segment"]["bpm4i-bpm4i"].config == c1_expected_config
assert w1.get_curve(0).config == c1_expected_config
assert w1.get_curve_config("bpm4i-bpm4i", dict_output=True) == c1_expected_config.model_dump()
assert w1.get_curve_config("bpm4i-bpm4i", dict_output=False) == c1_expected_config
assert w1.get_curve("bpm4i-bpm4i").config == c1_expected_config
assert c1.get_config(False) == c1_expected_config
assert c1.get_config() == c1_expected_config.model_dump()
assert (
w1.curves[0].config == c1_expected_config_dark
or w1.curves[0].config == c1_expected_config_light
)
assert (
w1._curves_data["scan_segment"]["bpm4i-bpm4i"].config == c1_expected_config_dark
or w1._curves_data["scan_segment"]["bpm4i-bpm4i"].config == c1_expected_config_light
)
assert (
w1.get_curve(0).config == c1_expected_config_dark
or w1.get_curve(0).config == c1_expected_config_light
)
assert (
w1.get_curve_config("bpm4i-bpm4i", dict_output=True) == c1_expected_config_dark.model_dump()
or w1.get_curve_config("bpm4i-bpm4i", dict_output=True)
== c1_expected_config_light.model_dump()
)
assert (
w1.get_curve_config("bpm4i-bpm4i", dict_output=False) == c1_expected_config_dark
or w1.get_curve_config("bpm4i-bpm4i", dict_output=False) == c1_expected_config_light
)
assert (
w1.get_curve("bpm4i-bpm4i").config == c1_expected_config_dark
or w1.get_curve("bpm4i-bpm4i").config == c1_expected_config_light
)
assert (
c1.get_config(False) == c1_expected_config_dark
or c1.get_config(False) == c1_expected_config_light
)
assert (
c1.get_config() == c1_expected_config_dark.model_dump()
or c1.get_config() == c1_expected_config_light.model_dump()
)
def test_getting_curve_errors(qtbot, mocked_client):