1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-10 10:40:55 +02:00

Compare commits

...

25 Commits

Author SHA1 Message Date
semantic-release
d40075f85b 1.16.3
Automatically generated by python-semantic-release
2025-01-20 09:20:33 +00:00
dfa2908c3d test(error_popups): SafeSlot tests adjusted; tests extended to cover SafeProperty 2025-01-20 10:08:44 +01:00
02a4862afd fix(error_popups): logger message in SafeSlot for errors; identification in error log from which property or signal errors comes from 2025-01-20 10:08:44 +01:00
semantic-release
13438e22d3 1.16.2
Automatically generated by python-semantic-release
2025-01-20 09:06:13 +00:00
889ea8629f fix(widget_io): ToggleSwitchHandler added 2025-01-16 12:26:40 +01:00
semantic-release
0ef509e9ca 1.16.1
Automatically generated by python-semantic-release
2025-01-16 10:37:04 +00:00
b40d2c5f0b fix(error_popups): SafeProperty logger import fixed 2025-01-16 11:22:14 +01:00
semantic-release
6cd7ff6ef7 1.16.0
Automatically generated by python-semantic-release
2025-01-14 15:59:07 +00:00
0fd5dd5a26 fix(e2e): num of elements to wait for scan fixed to steps requested in the scan 2025-01-14 16:47:57 +01:00
508abfa8a5 fix(toolbar): adjusted to future plot base 2025-01-14 16:47:57 +01:00
001e6fc807 feat(modular_toolbar): context menu and action bundles 2025-01-14 13:53:08 +01:00
semantic-release
111dcef35a 1.15.1
Automatically generated by python-semantic-release
2025-01-13 13:41:49 +00:00
3b04b985b6 fix(error_popups): SafeProperty wrapper extended to catch more errors and not crash Designer 2025-01-13 11:25:25 +01:00
semantic-release
5944626d93 1.15.0
Automatically generated by python-semantic-release
2025-01-10 15:51:23 +00:00
a00d368c25 feat(widget_state_manager): example app added 2025-01-10 16:32:31 +01:00
01b4608331 feat(widget_state_manager): state manager for single widget 2025-01-10 16:32:31 +01:00
semantic-release
b7221d1151 1.14.1
Automatically generated by python-semantic-release
2025-01-10 14:34:09 +00:00
fa9ecaf433 fix: cast spinner widget angle to int when using for arc 2025-01-10 15:22:58 +01:00
semantic-release
c751d25f85 1.14.0
Automatically generated by python-semantic-release
2025-01-09 14:29:40 +00:00
e2c7dc98d2 docs: add docs for games/minesweeper 2025-01-09 15:24:00 +01:00
507d46f88b feat(widget): make Minesweeper into BEC widget 2025-01-09 15:24:00 +01:00
57dc1a3afc feat(widgets): added minesweeper widget 2025-01-09 15:24:00 +01:00
semantic-release
6a78da0e71 1.13.0
Automatically generated by python-semantic-release
2025-01-09 14:18:04 +00:00
fb545eebb3 tests(safeslot): wait for panels to be properly rendered 2025-01-09 14:55:31 +01:00
b4a240e463 tests(e2e): wait for the plotting to finish before checking the data 2025-01-09 14:38:58 +01:00
25 changed files with 7351 additions and 94 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,7 @@ class Widgets(str, enum.Enum):
DeviceComboBox = "DeviceComboBox"
DeviceLineEdit = "DeviceLineEdit"
LMFitDialog = "LMFitDialog"
Minesweeper = "Minesweeper"
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
PositionerControlLine = "PositionerControlLine"
@@ -3181,6 +3182,9 @@ class LMFitDialog(RPCBase):
"""
class Minesweeper(RPCBase): ...
class PositionIndicator(RPCBase):
@rpc_call
def set_value(self, position: float):

View File

@@ -2,42 +2,94 @@ import functools
import sys
import traceback
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
logger = bec_logger.logger
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, **prop_kwargs):
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs):
"""
Decorator to create a Qt Property with a safe setter that won't crash Designer on errors.
Behaves similarly to SafeSlot, but for properties.
Decorator to create a Qt Property with safe getter and setter so that
Qt Designer won't crash if an exception occurs in either method.
Args:
prop_type: The property type (e.g., str, bool, "QStringList", etc.)
popup_error (bool): If True, show popup on error, otherwise just handle it silently.
*prop_args, **prop_kwargs: Additional arguments and keyword arguments accepted by Property.
prop_type: The property type (e.g., str, bool, int, custom classes, etc.)
popup_error (bool): If True, show a popup for any error; otherwise, ignore or log silently.
default: Any default/fallback value to return if the getter raises an exception.
*prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor.
Usage:
@SafeProperty(int, default=-1)
def some_value(self) -> int:
# your getter logic
return ... # if an exception is raised, returns -1
@some_value.setter
def some_value(self, val: int):
# your setter logic
...
"""
def decorator(getter):
def decorator(py_getter):
"""Decorator for the user's property getter function."""
@functools.wraps(py_getter)
def safe_getter(self_):
try:
return py_getter(self_)
except Exception:
# Identify which property function triggered error
prop_name = f"{py_getter.__module__}.{py_getter.__qualname__}"
error_msg = traceback.format_exc()
if popup_error:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
else:
logger.error(f"SafeProperty error in GETTER of '{prop_name}':\n{error_msg}")
return default
class PropertyWrapper:
"""
Intermediate wrapper used so that the user can optionally chain .setter(...).
"""
def __init__(self, getter_func):
self.getter_func = getter_func
# We store only our safe_getter in the wrapper
self.getter_func = safe_getter
def setter(self, setter_func):
"""Wraps the user-defined setter to handle errors safely."""
@functools.wraps(setter_func)
def safe_setter(self_, value):
try:
return setter_func(self_, value)
except Exception:
prop_name = f"{setter_func.__module__}.{setter_func.__qualname__}"
error_msg = traceback.format_exc()
if popup_error:
ErrorPopupUtility().custom_exception_hook(
*sys.exc_info(), popup_error=True
)
else:
return
logger.error(
f"SafeProperty error in SETTER of '{prop_name}':\n{error_msg}"
)
return
# Return the full read/write Property
return Property(prop_type, self.getter_func, safe_setter, *prop_args, **prop_kwargs)
return PropertyWrapper(getter)
def __call__(self):
"""
If user never calls `.setter(...)`, produce a read-only property.
"""
return Property(prop_type, self.getter_func, None, *prop_args, **prop_kwargs)
return PropertyWrapper(py_getter)
return decorator
@@ -58,7 +110,14 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
try:
return method(*args, **kwargs)
except Exception:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=popup_error)
slot_name = f"{method.__module__}.{method.__qualname__}"
error_msg = traceback.format_exc()
if popup_error:
ErrorPopupUtility().custom_exception_hook(
*sys.exc_info(), popup_error=popup_error
)
else:
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
return wrapper

View File

@@ -2,17 +2,20 @@
from __future__ import annotations
import os
import sys
from abc import ABC, abstractmethod
from collections import defaultdict
from typing import Literal
from typing import Dict, List, Literal, Tuple
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QMainWindow,
QMenu,
QSizePolicy,
QToolBar,
@@ -31,7 +34,7 @@ class ToolBarAction(ABC):
Args:
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
tooltip (bool, optional): The tooltip for the action. Defaults to None.
tooltip (str, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
"""
@@ -81,15 +84,18 @@ class IconAction(ToolBarAction):
toolbar.addAction(self.action)
class MaterialIconAction:
class MaterialIconAction(ToolBarAction):
"""
Action with a Material icon for the toolbar.
Args:
icon_path (str, optional): The name of the Material icon. Defaults to None.
tooltip (bool, optional): The tooltip for the action. Defaults to None.
icon_name (str, optional): The name of the Material icon. Defaults to None.
tooltip (str, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
filled (bool, optional): Whether the icon is filled. Defaults to False.
color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon.
Defaults to None.
parent (QWidget or None, optional): Parent widget for the underlying QAction.
"""
def __init__(
@@ -99,30 +105,42 @@ class MaterialIconAction:
checkable: bool = False,
filled: bool = False,
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
parent=None,
):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.icon_name = icon_name
self.tooltip = tooltip
self.checkable = checkable
self.action = None
self.filled = filled
self.color = color
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
icon = self.get_icon()
self.action = QAction(icon, self.tooltip, target)
self.action.setCheckable(self.checkable)
toolbar.addAction(self.action)
def get_icon(self):
icon = material_icon(
# Generate the icon
self.icon = material_icon(
self.icon_name,
size=(20, 20),
convert_to_pixmap=False,
filled=self.filled,
color=self.color,
)
return icon
# Immediately create an QAction with the given parent
self.action = QAction(self.icon, self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the action to the toolbar.
Args:
toolbar(QToolBar): The toolbar to add the action to.
target(QWidget): The target widget for the action.
"""
toolbar.addAction(self.action)
def get_icon(self):
"""
Returns the icon for the action.
Returns:
QIcon: The icon for the action.
"""
return self.icon
class DeviceSelectionAction(ToolBarAction):
@@ -132,7 +150,6 @@ class DeviceSelectionAction(ToolBarAction):
Args:
label (str): The label for the combobox.
device_combobox (DeviceComboBox): The combobox for selecting the device.
"""
def __init__(self, label: str, device_combobox):
@@ -160,7 +177,6 @@ class WidgetAction(ToolBarAction):
Args:
label (str|None): The label for the widget.
widget (QWidget): The widget to be added to the toolbar.
"""
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
@@ -219,7 +235,6 @@ class ExpandableMenuAction(ToolBarAction):
label (str): The label for the menu.
actions (dict): A dictionary of actions to populate the menu.
icon_path (str, optional): The path to the icon file. Defaults to None.
"""
def __init__(self, label: str, actions: dict, icon_path: str = None):
@@ -259,6 +274,55 @@ class ExpandableMenuAction(ToolBarAction):
toolbar.addWidget(button)
class ToolbarBundle:
"""
Represents a bundle of toolbar actions, keyed by action_id.
Allows direct dictionary-like access: self.actions["some_id"] -> ToolBarAction object.
"""
def __init__(self, bundle_id: str = None, actions=None):
"""
Args:
bundle_id (str): Unique identifier for the bundle.
actions: Either None or a list of (action_id, ToolBarAction) tuples.
"""
self.bundle_id = bundle_id
self._actions: dict[str, ToolBarAction] = {}
# If you passed in a list of tuples, load them into the dictionary
if actions is not None:
for action_id, action in actions:
self._actions[action_id] = action
def add_action(self, action_id: str, action: ToolBarAction):
"""
Adds or replaces an action in the bundle.
Args:
action_id (str): Unique identifier for the action.
action (ToolBarAction): The action to add.
"""
self._actions[action_id] = action
def remove_action(self, action_id: str):
"""
Removes an action from the bundle by ID.
Ignores if not present.
Args:
action_id (str): Unique identifier for the action to remove.
"""
self._actions.pop(action_id, None)
@property
def actions(self) -> dict[str, ToolBarAction]:
"""
Return the internal dictionary of actions so that you can do
bundle.actions["drag_mode"] -> ToolBarAction instance.
"""
return self._actions
class ModularToolBar(QToolBar):
"""Modular toolbar with optional automatic initialization.
@@ -287,10 +351,14 @@ class ModularToolBar(QToolBar):
# Set the initial orientation
self.set_orientation(orientation)
# Initialize bundles
self.bundles = {}
self.toolbar_items = []
if actions is not None and target_widget is not None:
self.populate_toolbar(actions, target_widget)
def populate_toolbar(self, actions: dict, target_widget):
def populate_toolbar(self, actions: dict, target_widget: QWidget):
"""Populates the toolbar with a set of actions.
Args:
@@ -298,9 +366,12 @@ class ModularToolBar(QToolBar):
target_widget (QWidget): The widget that the actions will target.
"""
self.clear()
self.toolbar_items.clear() # Reset the order tracking
for action_id, action in actions.items():
action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action
self.toolbar_items.append(("action", action_id))
self.update_separators() # Ensure separators are updated after populating
def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"):
"""
@@ -345,7 +416,7 @@ class ModularToolBar(QToolBar):
def add_action(self, action_id: str, action: ToolBarAction, target_widget: QWidget):
"""
Adds a new action to the toolbar dynamically.
Adds a new standalone action to the toolbar dynamically.
Args:
action_id (str): Unique identifier for the action.
@@ -356,6 +427,8 @@ class ModularToolBar(QToolBar):
raise ValueError(f"Action with ID '{action_id}' already exists.")
action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action
self.toolbar_items.append(("action", action_id))
self.update_separators() # Update separators after adding the action
def hide_action(self, action_id: str):
"""
@@ -369,6 +442,7 @@ class ModularToolBar(QToolBar):
action = self.widgets[action_id]
if hasattr(action, "action") and isinstance(action.action, QAction):
action.action.setVisible(False)
self.update_separators() # Update separators after hiding the action
def show_action(self, action_id: str):
"""
@@ -382,3 +456,217 @@ class ModularToolBar(QToolBar):
action = self.widgets[action_id]
if hasattr(action, "action") and isinstance(action.action, QAction):
action.action.setVisible(True)
self.update_separators() # Update separators after showing the action
def add_bundle(self, bundle: ToolbarBundle, target_widget: QWidget):
"""
Adds a bundle of actions to the toolbar, separated by a separator.
Args:
bundle (ToolbarBundle): The bundle to add.
target_widget (QWidget): The target widget for the actions.
"""
if bundle.bundle_id in self.bundles:
raise ValueError(f"ToolbarBundle with ID '{bundle.bundle_id}' already exists.")
# Add a separator before the bundle (but not to first one)
if self.toolbar_items:
sep = SeparatorAction()
sep.add_to_toolbar(self, target_widget)
self.toolbar_items.append(("separator", None))
# Add each action in the bundle
for action_id, action_obj in bundle.actions.items():
action_obj.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action_obj
# Register the bundle
self.bundles[bundle.bundle_id] = list(bundle.actions.keys())
self.toolbar_items.append(("bundle", bundle.bundle_id))
self.update_separators() # Update separators after adding the bundle
def contextMenuEvent(self, event):
"""
Overrides the context menu event to show a list of toolbar actions with checkboxes and icons, including separators.
Args:
event(QContextMenuEvent): The context menu event.
"""
menu = QMenu(self)
# Iterate through the toolbar items in order
for item_type, identifier in self.toolbar_items:
if item_type == "separator":
menu.addSeparator()
elif item_type == "bundle":
self.handle_bundle_context_menu(menu, identifier)
elif item_type == "action":
self.handle_action_context_menu(menu, identifier)
# Connect the triggered signal after all actions are added
menu.triggered.connect(self.handle_menu_triggered)
menu.exec_(event.globalPos())
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
"""
Adds a set of bundle actions to the context menu.
Args:
menu (QMenu): The context menu to which the actions are added.
bundle_id (str): The identifier for the bundle.
"""
action_ids = self.bundles.get(bundle_id, [])
for act_id in action_ids:
toolbar_action = self.widgets.get(act_id)
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(
toolbar_action, "action"
):
continue
qaction = toolbar_action.action
if not isinstance(qaction, QAction):
continue
display_name = qaction.text() or toolbar_action.tooltip or act_id
menu_action = QAction(display_name, self)
menu_action.setCheckable(True)
menu_action.setChecked(qaction.isVisible())
menu_action.setData(act_id) # Store the action_id
# Set the icon if available
if qaction.icon() and not qaction.icon().isNull():
menu_action.setIcon(qaction.icon())
menu.addAction(menu_action)
def handle_action_context_menu(self, menu: QMenu, action_id: str):
"""
Adds a single toolbar action to the context menu.
Args:
menu (QMenu): The context menu to which the action is added.
action_id (str): Unique identifier for the action.
"""
toolbar_action = self.widgets.get(action_id)
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(toolbar_action, "action"):
return
qaction = toolbar_action.action
if not isinstance(qaction, QAction):
return
display_name = qaction.text() or toolbar_action.tooltip or action_id
menu_action = QAction(display_name, self)
menu_action.setCheckable(True)
menu_action.setChecked(qaction.isVisible())
menu_action.setData(action_id) # Store the action_id
# Set the icon if available
if qaction.icon() and not qaction.icon().isNull():
menu_action.setIcon(qaction.icon())
menu.addAction(menu_action)
def handle_menu_triggered(self, action):
"""Handles the toggling of toolbar actions from the context menu."""
action_id = action.data()
if action_id:
self.toggle_action_visibility(action_id, action.isChecked())
def toggle_action_visibility(self, action_id: str, visible: bool):
"""
Toggles the visibility of a specific action on the toolbar.
Args:
action_id(str): Unique identifier for the action to toggle.
visible(bool): Whether the action should be visible.
"""
if action_id not in self.widgets:
return
tool_action = self.widgets[action_id]
if hasattr(tool_action, "action") and isinstance(tool_action.action, QAction):
tool_action.action.setVisible(visible)
self.update_separators()
def update_separators(self):
"""
Hide separators that are adjacent to another separator or have no actions next to them.
"""
toolbar_actions = self.actions()
for i, action in enumerate(toolbar_actions):
if not action.isSeparator():
continue
# Find the previous visible action
prev_visible = None
for j in range(i - 1, -1, -1):
if toolbar_actions[j].isVisible():
prev_visible = toolbar_actions[j]
break
# Find the next visible action
next_visible = None
for j in range(i + 1, len(toolbar_actions)):
if toolbar_actions[j].isVisible():
next_visible = toolbar_actions[j]
break
# Determine if the separator should be hidden
# Hide if both previous and next visible actions are separators or non-existent
if (prev_visible is None or prev_visible.isSeparator()) and (
next_visible is None or next_visible.isSeparator()
):
action.setVisible(False)
else:
action.setVisible(True)
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
# Create a modular toolbar
self.toolbar = ModularToolBar(parent=self, target_widget=self)
self.addToolBar(self.toolbar)
# Example: Add a single bundle
home_action = MaterialIconAction(
icon_name="home", tooltip="Home", checkable=True, parent=self
)
settings_action = MaterialIconAction(
icon_name="settings", tooltip="Settings", checkable=True, parent=self
)
profile_action = MaterialIconAction(
icon_name="person", tooltip="Profile", checkable=True, parent=self
)
main_actions_bundle = ToolbarBundle(
bundle_id="main_actions",
actions=[
("home_action", home_action),
("settings_action", settings_action),
("profile_action", profile_action),
],
)
self.toolbar.add_bundle(main_actions_bundle, target_widget=self)
# Another bundle
search_action = MaterialIconAction(
icon_name="search", tooltip="Search", checkable=True, parent=self
)
help_action = MaterialIconAction(
icon_name="help", tooltip="Help", checkable=True, parent=self
)
second_bundle = ToolbarBundle(
bundle_id="secondary_actions",
actions=[("search_action", search_action), ("help_action", help_action)],
)
self.toolbar.add_bundle(second_bundle, target_widget=self)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
main_window = MainWindow()
main_window.show()
sys.exit(app.exec_())

View File

@@ -224,3 +224,11 @@ DEVICES = [
Positioner("test", limits=[-10, 10], read_value=2.0),
Device("test_device"),
]
def check_remote_data_size(widget, plot_name, num_elements):
"""
Check if the remote data has the correct number of elements.
Used in the qtbot.waitUntil function.
"""
return len(widget.get_all_data()[plot_name]["x"]) == num_elements

View File

@@ -15,6 +15,8 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
class WidgetHandler(ABC):
"""Abstract base class for all widget handlers."""
@@ -125,6 +127,19 @@ class CheckBoxHandler(WidgetHandler):
widget.toggled.connect(lambda val, w=widget: slot(w, val))
class ToggleSwitchHandler(WidgetHandler):
"""Handler for ToggleSwitch widgets."""
def get_value(self, widget, **kwargs):
return widget.checked
def set_value(self, widget, value):
widget.checked = value
def connect_change_signal(self, widget: ToggleSwitch, slot):
widget.enabled.connect(lambda val, w=widget: slot(w, val))
class LabelHandler(WidgetHandler):
"""Handler for QLabel widgets."""
@@ -149,6 +164,7 @@ class WidgetIO:
QDoubleSpinBox: SpinBoxHandler,
QCheckBox: CheckBoxHandler,
QLabel: LabelHandler,
ToggleSwitch: ToggleSwitchHandler,
}
@staticmethod

View File

@@ -0,0 +1,151 @@
from __future__ import annotations
from qtpy.QtCore import QSettings
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QFileDialog,
QHBoxLayout,
QLineEdit,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
class WidgetStateManager:
"""
A class to manage the state of a widget by saving and loading the state to and from a INI file.
Args:
widget(QWidget): The widget to manage the state for.
"""
def __init__(self, widget):
self.widget = widget
def save_state(self, filename: str = None):
"""
Save the state of the widget to a INI file.
Args:
filename(str): The filename to save the state to.
"""
if not filename:
filename, _ = QFileDialog.getSaveFileName(
self.widget, "Save Settings", "", "INI Files (*.ini)"
)
if filename:
settings = QSettings(filename, QSettings.IniFormat)
self._save_widget_state_qsettings(self.widget, settings)
def load_state(self, filename: str = None):
"""
Load the state of the widget from a INI file.
Args:
filename(str): The filename to load the state from.
"""
if not filename:
filename, _ = QFileDialog.getOpenFileName(
self.widget, "Load Settings", "", "INI Files (*.ini)"
)
if filename:
settings = QSettings(filename, QSettings.IniFormat)
self._load_widget_state_qsettings(self.widget, settings)
def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
"""
Save the state of the widget to QSettings.
Args:
widget(QWidget): The widget to save the state for.
settings(QSettings): The QSettings object to save the state to.
"""
meta = widget.metaObject()
settings.beginGroup(widget.objectName())
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
value = widget.property(name)
settings.setValue(name, value)
settings.endGroup()
# Recursively save child widgets
for child in widget.findChildren(QWidget):
if child.objectName():
self._save_widget_state_qsettings(child, settings)
def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
"""
Load the state of the widget from QSettings.
Args:
widget(QWidget): The widget to load the state for.
settings(QSettings): The QSettings object to load the state from.
"""
meta = widget.metaObject()
settings.beginGroup(widget.objectName())
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
if settings.contains(name):
value = settings.value(name)
widget.setProperty(name, value)
settings.endGroup()
# Recursively load child widgets
for child in widget.findChildren(QWidget):
if child.objectName():
self._load_widget_state_qsettings(child, settings)
class ExampleApp(QWidget): # pragma: no cover
def __init__(self):
super().__init__()
self.setObjectName("MainWindow")
self.setWindowTitle("State Manager Example")
layout = QVBoxLayout(self)
# A line edit to store some user text
self.line_edit = QLineEdit(self)
self.line_edit.setObjectName("MyLineEdit")
self.line_edit.setPlaceholderText("Enter some text here...")
layout.addWidget(self.line_edit)
# A spin box to hold a numeric value
self.spin_box = QSpinBox(self)
self.spin_box.setObjectName("MySpinBox")
self.spin_box.setRange(0, 100)
layout.addWidget(self.spin_box)
# A checkbox to hold a boolean value
self.check_box = QCheckBox("Enable feature?", self)
self.check_box.setObjectName("MyCheckBox")
layout.addWidget(self.check_box)
# Buttons to save and load state
button_layout = QHBoxLayout()
self.save_button = QPushButton("Save State", self)
self.load_button = QPushButton("Load State", self)
button_layout.addWidget(self.save_button)
button_layout.addWidget(self.load_button)
layout.addLayout(button_layout)
# Create the state manager
self.state_manager = WidgetStateManager(self)
# Connect buttons
self.save_button.clicked.connect(lambda: self.state_manager.save_state())
self.load_button.clicked.connect(lambda: self.state_manager.load_state())
if __name__ == "__main__": # pragma: no cover:
import sys
app = QApplication(sys.argv)
w = ExampleApp()
w.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,3 @@
from bec_widgets.widgets.games.minesweeper import Minesweeper
__ALL__ = ["Minesweeper"]

View File

@@ -0,0 +1,413 @@
import enum
import random
import time
from bec_qthemes import material_icon
from qtpy.QtCore import QSize, Qt, QTimer, Signal, Slot
from qtpy.QtGui import QBrush, QColor, QPainter, QPen
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
NUM_COLORS = {
1: QColor("#f44336"),
2: QColor("#9C27B0"),
3: QColor("#3F51B5"),
4: QColor("#03A9F4"),
5: QColor("#00BCD4"),
6: QColor("#4CAF50"),
7: QColor("#E91E63"),
8: QColor("#FF9800"),
}
LEVELS: dict[str, tuple[int, int]] = {"1": (8, 10), "2": (16, 40), "3": (24, 99)}
class GameStatus(enum.Enum):
READY = 0
PLAYING = 1
FAILED = 2
SUCCESS = 3
class Pos(QWidget):
expandable = Signal(int, int)
clicked = Signal()
ohno = Signal()
def __init__(self, x, y, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFixedSize(QSize(20, 20))
self.x = x
self.y = y
self.is_start = False
self.is_mine = False
self.adjacent_n = 0
self.is_revealed = False
self.is_flagged = False
def reset(self):
"""Restore the tile to its original state before mine status is assigned"""
self.is_start = False
self.is_mine = False
self.adjacent_n = 0
self.is_revealed = False
self.is_flagged = False
self.update()
def paintEvent(self, event):
p = QPainter(self)
r = event.rect()
if self.is_revealed:
color = self.palette().base().color()
outer, inner = color, color
else:
outer, inner = (self.palette().highlightedText().color(), self.palette().text().color())
p.fillRect(r, QBrush(inner))
pen = QPen(outer)
pen.setWidth(1)
p.setPen(pen)
p.drawRect(r)
if self.is_revealed:
if self.is_mine:
p.drawPixmap(r, material_icon("experiment", convert_to_pixmap=True, filled=True))
elif self.adjacent_n > 0:
pen = QPen(NUM_COLORS[self.adjacent_n])
p.setPen(pen)
f = p.font()
f.setBold(True)
p.setFont(f)
p.drawText(r, Qt.AlignHCenter | Qt.AlignVCenter, str(self.adjacent_n))
elif self.is_flagged:
p.drawPixmap(
r,
material_icon(
"flag",
size=(50, 50),
convert_to_pixmap=True,
filled=True,
color=self.palette().base().color(),
),
)
p.end()
def flag(self):
self.is_flagged = not self.is_flagged
self.update()
self.clicked.emit()
def reveal(self):
self.is_revealed = True
self.update()
def click(self):
if not self.is_revealed:
self.reveal()
if self.adjacent_n == 0:
self.expandable.emit(self.x, self.y)
self.clicked.emit()
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.RightButton and not self.is_revealed:
self.flag()
return
if event.button() == Qt.MouseButton.LeftButton:
self.click()
if self.is_mine:
self.ohno.emit()
class Minesweeper(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "videogame_asset"
USER_ACCESS = []
def __init__(self, parent=None, *args, **kwargs):
super().__init__(*args, **kwargs)
QWidget.__init__(self, parent=parent)
self._ui_initialised = False
self._timer_start_num_seconds = 0
self._set_level_params(LEVELS["1"])
self._init_ui()
self._init_map()
self.update_status(GameStatus.READY)
self.reset_map()
self.update_status(GameStatus.READY)
def _init_ui(self):
if self._ui_initialised:
return
self._ui_initialised = True
status_hb = QHBoxLayout()
self.mines = QLabel()
self.mines.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
f = self.mines.font()
f.setPointSize(24)
self.mines.setFont(f)
self.reset_button = QPushButton()
self.reset_button.setFixedSize(QSize(32, 32))
self.reset_button.setIconSize(QSize(32, 32))
self.reset_button.setFlat(True)
self.reset_button.pressed.connect(self.reset_button_pressed)
self.clock = QLabel()
self.clock.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
self.clock.setFont(f)
self._timer = QTimer()
self._timer.timeout.connect(self.update_timer)
self._timer.start(1000) # 1 second timer
self.mines.setText(f"{self.num_mines:03d}")
self.clock.setText("000")
status_hb.addWidget(self.mines)
status_hb.addWidget(self.reset_button)
status_hb.addWidget(self.clock)
level_hb = QHBoxLayout()
self.level_selector = QComboBox()
self.level_selector.addItems(list(LEVELS.keys()))
level_hb.addWidget(QLabel("Level: "))
level_hb.addWidget(self.level_selector)
self.level_selector.currentTextChanged.connect(self.change_level)
vb = QVBoxLayout()
vb.addLayout(level_hb)
vb.addLayout(status_hb)
self.grid = QGridLayout()
self.grid.setSpacing(5)
vb.addLayout(self.grid)
self.setLayout(vb)
def _init_map(self):
"""Redraw the grid of mines"""
# Remove any previous grid items and reset the grid
for i in reversed(range(self.grid.count())):
w: Pos = self.grid.itemAt(i).widget()
w.clicked.disconnect(self.on_click)
w.expandable.disconnect(self.expand_reveal)
w.ohno.disconnect(self.game_over)
w.setParent(None)
w.deleteLater()
# Add positions to the map
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = Pos(x, y)
self.grid.addWidget(w, y, x)
# Connect signal to handle expansion.
w.clicked.connect(self.on_click)
w.expandable.connect(self.expand_reveal)
w.ohno.connect(self.game_over)
def reset_map(self):
"""
Reset the map and add new mines.
"""
# Clear all mine positions
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = self.grid.itemAtPosition(y, x).widget()
w.reset()
# Add mines to the positions
positions = []
while len(positions) < self.num_mines:
x, y = (random.randint(0, self.b_size - 1), random.randint(0, self.b_size - 1))
if (x, y) not in positions:
w = self.grid.itemAtPosition(y, x).widget()
w.is_mine = True
positions.append((x, y))
def get_adjacency_n(x, y):
positions = self.get_surrounding(x, y)
num_mines = sum(1 if w.is_mine else 0 for w in positions)
return num_mines
# Add adjacencies to the positions
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = self.grid.itemAtPosition(y, x).widget()
w.adjacent_n = get_adjacency_n(x, y)
# Place starting marker
while True:
x, y = (random.randint(0, self.b_size - 1), random.randint(0, self.b_size - 1))
w = self.grid.itemAtPosition(y, x).widget()
# We don't want to start on a mine.
if (x, y) not in positions:
w = self.grid.itemAtPosition(y, x).widget()
w.is_start = True
# Reveal all positions around this, if they are not mines either.
for w in self.get_surrounding(x, y):
if not w.is_mine:
w.click()
break
def get_surrounding(self, x, y):
positions = []
for xi in range(max(0, x - 1), min(x + 2, self.b_size)):
for yi in range(max(0, y - 1), min(y + 2, self.b_size)):
positions.append(self.grid.itemAtPosition(yi, xi).widget())
return positions
def get_num_hidden(self) -> int:
"""
Get the number of hidden positions.
"""
return sum(
1
for x in range(0, self.b_size)
for y in range(0, self.b_size)
if not self.grid.itemAtPosition(y, x).widget().is_revealed
)
def get_num_remaining_flags(self) -> int:
"""
Get the number of remaining flags.
"""
return self.num_mines - sum(
1
for x in range(0, self.b_size)
for y in range(0, self.b_size)
if self.grid.itemAtPosition(y, x).widget().is_flagged
)
def reset_button_pressed(self):
match self.status:
case GameStatus.PLAYING:
self.game_over()
case GameStatus.FAILED | GameStatus.SUCCESS:
self.reset_map()
def reveal_map(self):
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = self.grid.itemAtPosition(y, x).widget()
w.reveal()
@Slot(str)
def change_level(self, level: str):
self._set_level_params(LEVELS[level])
self._init_map()
self.reset_map()
@Slot(int, int)
def expand_reveal(self, x, y):
"""
Expand the reveal to the surrounding
Args:
x (int): The x position.
y (int): The y position.
"""
for xi in range(max(0, x - 1), min(x + 2, self.b_size)):
for yi in range(max(0, y - 1), min(y + 2, self.b_size)):
w = self.grid.itemAtPosition(yi, xi).widget()
if not w.is_mine:
w.click()
@Slot()
def on_click(self):
"""
Handle the click event. If the game is not started, start the game.
"""
self.update_available_flags()
if self.status != GameStatus.PLAYING:
# First click.
self.update_status(GameStatus.PLAYING)
# Start timer.
self._timer_start_num_seconds = int(time.time())
return
self.check_win()
def update_available_flags(self):
"""
Update the number of available flags.
"""
self.mines.setText(f"{self.get_num_remaining_flags():03d}")
def check_win(self):
"""
Check if the game is won.
"""
if self.get_num_hidden() == self.num_mines:
self.update_status(GameStatus.SUCCESS)
def update_status(self, status: GameStatus):
"""
Update the status of the game.
Args:
status (GameStatus): The status of the game.
"""
self.status = status
match status:
case GameStatus.READY:
icon = material_icon(icon_name="add", convert_to_pixmap=False)
case GameStatus.PLAYING:
icon = material_icon(icon_name="smart_toy", convert_to_pixmap=False)
case GameStatus.FAILED:
icon = material_icon(icon_name="error", convert_to_pixmap=False)
case GameStatus.SUCCESS:
icon = material_icon(icon_name="celebration", convert_to_pixmap=False)
self.reset_button.setIcon(icon)
def update_timer(self):
"""
Update the timer.
"""
if self.status == GameStatus.PLAYING:
num_seconds = int(time.time()) - self._timer_start_num_seconds
self.clock.setText(f"{num_seconds:03d}")
def game_over(self):
"""Cause the game to end early"""
self.reveal_map()
self.update_status(GameStatus.FAILED)
def _set_level_params(self, level: tuple[int, int]):
self.b_size, self.num_mines = level
if __name__ == "__main__":
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("light")
widget = Minesweeper()
widget.show()
app.exec_()

View File

@@ -0,0 +1 @@
{'files': ['minesweeper.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.games.minesweeper import Minesweeper
DOM_XML = """
<ui language='c++'>
<widget class='Minesweeper' name='minesweeper'>
</widget>
</ui>
"""
class MinesweeperPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = Minesweeper(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Games"
def icon(self):
return designer_material_icon(Minesweeper.ICON_NAME)
def includeFile(self):
return "minesweeper"
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 "Minesweeper"
def toolTip(self):
return "Minesweeper"
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.games.minesweeper_plugin import MinesweeperPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(MinesweeperPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -75,7 +75,7 @@ class SpinnerWidget(QWidget):
proportion = 1 / 4
angle_span = int(proportion * 360 * 16)
angle_span += angle_span * ease_in_out_sine(self.time / self.duration)
painter.drawArc(adjusted_rect, self.angle * 16, int(angle_span))
painter.drawArc(adjusted_rect, int(self.angle * 16), int(angle_span))
painter.end()
def closeEvent(self, event):

View File

@@ -0,0 +1,11 @@
(user.widgets.games)=
# Game widgets
To provide some entertainment during long nights at the beamline, there are game widgets available. Well, only one, so far.
## Minesweeper
![Minesweeper](./minesweeper.png)
The classic game Minesweeper. You may select from three different levels. The game can be ended or reset by clicking on the icon in the top-centre (the robot in the screenshot).

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -270,5 +270,6 @@ signal_input/signal_input.md
position_indicator/position_indicator.md
lmfit_dialog/lmfit_dialog.md
dap_combo_box/dap_combo_box.md
games/games.md
```

View File

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

View File

@@ -4,7 +4,8 @@ import numpy as np
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.cli.client import BECDockArea, BECFigure, BECImageShow, BECMotorMap, BECWaveform
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
from bec_widgets.tests.utils import check_remote_data_size
from bec_widgets.utils import Colors
# pylint: disable=unused-argument
@@ -12,7 +13,7 @@ from bec_widgets.utils import Colors
# pylint: disable=too-many-locals
def test_rpc_add_dock_with_figure_e2e(bec_client_lib, connected_client_dock):
def test_rpc_add_dock_with_figure_e2e(qtbot, bec_client_lib, connected_client_dock):
# BEC client shortcuts
dock = connected_client_dock
client = bec_client_lib
@@ -88,14 +89,17 @@ def test_rpc_add_dock_with_figure_e2e(bec_client_lib, connected_client_dock):
# Try to make a scan
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
# wait for scan to finish
while not status.status == "COMPLETED":
time.sleep(0.2)
status.wait()
# plot
item = queue.scan_storage.storage[-1]
plt_last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
num_elements = 10
plot_name = "bpm4i-bpm4i"
qtbot.waitUntil(lambda: check_remote_data_size(plt, plot_name, num_elements))
plt_data = plt.get_all_data()
assert plt_data["bpm4i-bpm4i"]["x"] == plt_last_scan_data["samx"]["samx"].val
assert plt_data["bpm4i-bpm4i"]["y"] == plt_last_scan_data["bpm4i"]["bpm4i"].val
@@ -255,11 +259,17 @@ def test_auto_update(bec_client_lib, connected_client_dock_w_auto_updates, qtbot
# get data from curves
widgets = plt.widget_list
qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
plt_data = widgets[0].get_all_data()
item = queue.scan_storage.storage[-1]
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
num_elements = 10
plot_name = f"Scan {status.scan.scan_number} - {dock.selected_device}"
qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements))
plt_data = widgets[0].get_all_data()
# check plotted data
assert (
plt_data[f"Scan {status.scan.scan_number} - bpm4i"]["x"]
@@ -277,12 +287,18 @@ def test_auto_update(bec_client_lib, connected_client_dock_w_auto_updates, qtbot
plt = auto_updates.get_default_figure()
widgets = plt.widget_list
qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
plt_data = widgets[0].get_all_data()
item = queue.scan_storage.storage[-1]
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
plot_name = f"Scan {status.scan.scan_number} - bpm4i"
num_elements_bec = 25
qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements_bec))
plt_data = widgets[0].get_all_data()
# check plotted data
assert (
plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["x"]
@@ -355,6 +371,7 @@ def test_rpc_call_with_exception_in_safeslot_error_popup(connected_client_gui_ob
gui = connected_client_gui_obj
gui.main.add_dock("test")
qtbot.waitUntil(lambda: len(gui.main.panels) == 2) # default_figure + test
with pytest.raises(ValueError):
gui.main.add_dock("test")
# time.sleep(0.1)

View File

@@ -1,10 +1,10 @@
import time
import numpy as np
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
from bec_widgets.tests.utils import check_remote_data_size
def test_rpc_waveform1d_custom_curve(connected_client_figure):
@@ -78,7 +78,7 @@ def test_rpc_plotting_shortcuts_init_configs(connected_client_figure, qtbot):
}
def test_rpc_waveform_scan(connected_client_figure, bec_client_lib):
def test_rpc_waveform_scan(qtbot, connected_client_figure, bec_client_lib):
fig = BECFigure(connected_client_figure)
# add 3 different curves to track
@@ -97,6 +97,11 @@ def test_rpc_waveform_scan(connected_client_figure, bec_client_lib):
item = queue.scan_storage.storage[-1]
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
num_elements = 10
for plot_name in ["bpm4i-bpm4i", "bpm3a-bpm3a", "bpm4d-bpm4d"]:
qtbot.waitUntil(lambda: check_remote_data_size(plt, plot_name, num_elements))
# get data from curves
plt_data = plt.get_all_data()

View File

@@ -1,10 +1,33 @@
import sys
from unittest.mock import patch
import pytest
import pytestqt
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject
from qtpy.QtWidgets import QMessageBox
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility, ExampleWidget
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility, ExampleWidget, SafeProperty
class TestSafePropertyClass(QObject):
def __init__(self, parent=None):
super().__init__(parent)
self._my_value = 10 # internal store
@SafeProperty(int, default=-1)
def my_value(self) -> int:
# artificially raise if it's 999 for testing
if self._my_value == 999:
raise ValueError("Invalid internal state in getter!")
return self._my_value
@my_value.setter
def my_value(self, val: int):
# artificially raise if user sets -999 for testing
if val == -999:
raise ValueError("Invalid user input in setter!")
self._my_value = val
@pytest.fixture
@@ -18,46 +41,109 @@ def widget(qtbot):
@patch.object(QMessageBox, "exec_", return_value=QMessageBox.Ok)
def test_show_error_message_global(mock_exec, widget, qtbot):
"""
Test that an error popup is shown if global error popups are enabled
and the error_occurred signal is emitted manually.
"""
error_utility = ErrorPopupUtility()
error_utility.enable_global_error_popups(True)
with qtbot.waitSignal(error_utility.error_occurred, timeout=1000) as blocker:
error_utility.error_occurred.emit("Test Error", "This is a test error message.", widget)
assert mock_exec.called
assert blocker.signal_triggered
assert mock_exec.called
@pytest.mark.parametrize("global_pop", [False, True])
@patch.object(QMessageBox, "exec_", return_value=QMessageBox.Ok)
def test_slot_with_popup_on_error(mock_exec, widget, qtbot, global_pop):
"""
If the slot is decorated with @SafeSlot(popup_error=True),
we always expect a popup on error (and a signal) even if global popups are off.
"""
error_utility = ErrorPopupUtility()
error_utility.enable_global_error_popups(global_pop)
with qtbot.waitSignal(error_utility.error_occurred, timeout=200) as blocker:
with qtbot.waitSignal(error_utility.error_occurred, timeout=500) as blocker:
widget.method_with_error_handling()
assert blocker.signal_triggered
assert mock_exec.called
assert mock_exec.called # Because popup_error=True forces popup
@pytest.mark.parametrize("global_pop", [False, True])
@patch.object(bec_logger.logger, "error")
@patch.object(QMessageBox, "exec_", return_value=QMessageBox.Ok)
def test_slot_no_popup_by_default_on_error(mock_exec, widget, qtbot, capsys, global_pop):
def test_slot_no_popup_by_default_on_error(mock_exec, mock_log_error, widget, qtbot, global_pop):
"""
If the slot is decorated with @SafeSlot() (no popup_error=True),
we never show a popup, even if global popups are on,
because the code does not check 'enable_error_popup' for normal slots.
"""
error_utility = ErrorPopupUtility()
error_utility.enable_global_error_popups(global_pop)
try:
with qtbot.waitSignal(error_utility.error_occurred, timeout=200) as blocker:
widget.method_without_error_handling()
except pytestqt.exceptions.TimeoutError:
assert not global_pop
# We do NOT expect a popup or signal in either case, since code only logs
with qtbot.assertNotEmitted(error_utility.error_occurred):
widget.method_without_error_handling()
if global_pop:
assert blocker.signal_triggered
assert mock_exec.called
else:
assert not blocker.signal_triggered
assert not mock_exec.called
stdout, stderr = capsys.readouterr()
assert "ValueError" in stderr
assert not mock_exec.called
# Confirm logger.error(...) was called
mock_log_error.assert_called_once()
logged_msg = mock_log_error.call_args[0][0]
assert "ValueError" in logged_msg
assert "SafeSlot error in slot" in logged_msg
@pytest.mark.parametrize("global_pop", [False, True])
@patch.object(bec_logger.logger, "error")
@patch.object(QMessageBox, "exec_", return_value=QMessageBox.Ok)
def test_safe_property_getter_error(mock_exec, mock_log_error, qtbot, global_pop):
"""
If a property getter raises an error, we log it by default.
(No popup is shown unless code specifically calls it.)
"""
error_utility = ErrorPopupUtility()
error_utility.enable_global_error_popups(global_pop)
test_obj = TestSafePropertyClass()
test_obj._my_value = 999 # triggers ValueError in getter => logs => returns default (-1)
val = test_obj.my_value
assert val == -1
# No popup => mock_exec not called
assert not mock_exec.called
# logger.error(...) is called once
mock_log_error.assert_called_once()
logged_msg = mock_log_error.call_args[0][0]
assert "SafeProperty error in GETTER" in logged_msg
assert "ValueError" in logged_msg
@pytest.mark.parametrize("global_pop", [False, True])
@patch.object(bec_logger.logger, "error")
@patch.object(QMessageBox, "exec_", return_value=QMessageBox.Ok)
def test_safe_property_setter_error(mock_exec, mock_log_error, qtbot, global_pop):
"""
If a property setter raises an error, we log it by default.
(No popup is shown unless code specifically calls it.)
"""
error_utility = ErrorPopupUtility()
error_utility.enable_global_error_popups(global_pop)
test_obj = TestSafePropertyClass()
# Setting to -999 triggers setter error => logs => property returns None
test_obj.my_value = -999
# No popup => mock_exec not called
assert not mock_exec.called
# logger.error(...) is called once
mock_log_error.assert_called_once()
logged_msg = mock_log_error.call_args[0][0]
assert "SafeProperty error in SETTER" in logged_msg
assert "ValueError" in logged_msg

View File

@@ -0,0 +1,50 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import pytest
from qtpy.QtCore import Qt
from bec_widgets.widgets.games import Minesweeper
from bec_widgets.widgets.games.minesweeper import LEVELS, GameStatus, Pos
@pytest.fixture
def minesweeper(qtbot):
widget = Minesweeper()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_minesweeper_init(minesweeper: Minesweeper):
assert minesweeper.status == GameStatus.READY
def test_changing_level_updates_size_and_removes_old_grid_items(minesweeper: Minesweeper):
assert minesweeper.b_size == LEVELS["1"][0]
grid_items = [minesweeper.grid.itemAt(i).widget() for i in range(minesweeper.grid.count())]
for w in grid_items:
assert w.parent() is not None
minesweeper.change_level("2")
assert minesweeper.b_size == LEVELS["2"][0]
for w in grid_items:
assert w.parent() is None
def test_game_state_changes_to_failed_on_loss(qtbot, minesweeper: Minesweeper):
assert minesweeper.status == GameStatus.READY
grid_items: list[Pos] = [
minesweeper.grid.itemAt(i).widget() for i in range(minesweeper.grid.count())
]
mine = [p for p in grid_items if p.is_mine][0]
with qtbot.waitSignal(mine.ohno, timeout=1000):
qtbot.mouseRelease(mine, Qt.MouseButton.LeftButton)
assert minesweeper.status == GameStatus.FAILED
def test_game_resets_on_reset_click(minesweeper: Minesweeper):
assert minesweeper.status == GameStatus.READY
minesweeper.grid.itemAt(1).widget().ohno.emit()
assert minesweeper.status == GameStatus.FAILED
minesweeper.reset_button_pressed()
assert minesweeper.status == GameStatus.PLAYING

View File

@@ -1,8 +1,9 @@
from typing import Literal
import pytest
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QComboBox, QLabel, QToolButton, QWidget
from qtpy.QtCore import QPoint, Qt
from qtpy.QtGui import QContextMenuEvent
from qtpy.QtWidgets import QComboBox, QLabel, QMenu, QToolButton, QWidget
from bec_widgets.qt_utils.toolbar import (
DeviceSelectionAction,
@@ -11,8 +12,10 @@ from bec_widgets.qt_utils.toolbar import (
MaterialIconAction,
ModularToolBar,
SeparatorAction,
ToolbarBundle,
WidgetAction,
)
from tests.unit_tests.conftest import create_widget
@pytest.fixture
@@ -277,3 +280,117 @@ def test_show_action_nonexistent(toolbar_fixture):
with pytest.raises(ValueError) as excinfo:
toolbar.show_action("nonexistent_action")
assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value)
def test_add_bundle(toolbar_fixture, dummy_widget, icon_action, material_icon_action):
"""Test adding a bundle of actions to the toolbar."""
toolbar = toolbar_fixture
bundle = ToolbarBundle(
bundle_id="test_bundle",
actions=[
("icon_action_in_bundle", icon_action),
("material_icon_in_bundle", material_icon_action),
],
)
toolbar.add_bundle(bundle, dummy_widget)
assert "test_bundle" in toolbar.bundles
assert "icon_action_in_bundle" in toolbar.widgets
assert "material_icon_in_bundle" in toolbar.widgets
assert icon_action.action in toolbar.actions()
assert material_icon_action.action in toolbar.actions()
def test_invalid_orientation(dummy_widget):
"""Test that an invalid orientation raises a ValueError."""
toolbar = ModularToolBar(target_widget=dummy_widget, orientation="horizontal")
with pytest.raises(ValueError):
toolbar.set_orientation("diagonal")
def test_widget_action_calculate_minimum_width(qtbot):
"""Test calculate_minimum_width with various combo box items."""
combo = QComboBox()
combo.addItems(["Short", "Longer Item", "The Longest Item In Combo"])
widget_action = WidgetAction(label="Test", widget=combo)
width = widget_action.calculate_minimum_width(combo)
assert width > 0
# Width should be large enough to accommodate the longest item plus additional space
assert width > 100
# FIXME test is stucking CI, works locally
# def test_context_menu_contains_added_actions(
# qtbot, icon_action, material_icon_action, dummy_widget
# ):
# """
# Test that the toolbar's context menu lists all added toolbar actions.
# """
# toolbar = create_widget(
# qtbot, widget=ModularToolBar, target_widget=dummy_widget, orientation="horizontal"
# )
#
# # Add two different actions
# toolbar.add_action("icon_action", icon_action, dummy_widget)
# toolbar.add_action("material_icon_action", material_icon_action, dummy_widget)
#
# # Manually trigger the context menu event
# event = QContextMenuEvent(QContextMenuEvent.Mouse, QPoint(10, 10))
# toolbar.contextMenuEvent(event)
#
# # The QMenu is executed in contextMenuEvent, so we can fetch all possible actions
# # from the displayed menu by searching for QMenu in the immediate children of the toolbar.
# menus = toolbar.findChildren(QMenu)
# assert len(menus) > 0
# menu = menus[-1] # The most recently created menu
#
# menu_action_texts = [action.text() for action in menu.actions()]
# # Check if the menu contains entries for both added actions
# assert any(icon_action.tooltip in text or "icon_action" in text for text in menu_action_texts)
# assert any(
# material_icon_action.tooltip in text or "material_icon_action" in text
# for text in menu_action_texts
# )
# menu.actions()[0].trigger() # Trigger the first action to close the menu
# toolbar.close()
# FIXME test is stucking CI, works locally
# def test_context_menu_toggle_action_visibility(qtbot, icon_action, dummy_widget):
# """
# Test that toggling action visibility works correctly through the toolbar's context menu.
# """
# toolbar = create_widget(
# qtbot, widget=ModularToolBar, target_widget=dummy_widget, orientation="horizontal"
# )
# # Add an action
# toolbar.add_action("icon_action", icon_action, dummy_widget)
# assert icon_action.action.isVisible()
#
# # Manually trigger the context menu event
# event = QContextMenuEvent(QContextMenuEvent.Mouse, QPoint(10, 10))
# toolbar.contextMenuEvent(event)
#
# # Grab the menu that was created
# menus = toolbar.findChildren(QMenu)
# assert len(menus) > 0
# menu = menus[-1]
#
# # Locate the QAction in the menu
# matching_actions = [m for m in menu.actions() if m.text() == icon_action.tooltip]
# assert len(matching_actions) == 1
# action_in_menu = matching_actions[0]
#
# # Toggle it off (uncheck)
# action_in_menu.setChecked(False)
# menu.triggered.emit(action_in_menu)
# # The action on the toolbar should now be hidden
# assert not icon_action.action.isVisible()
#
# # Toggle it on (check)
# action_in_menu.setChecked(True)
# menu.triggered.emit(action_in_menu)
# # The action on the toolbar should be visible again
# assert icon_action.action.isVisible()
#
# menu.actions()[0].trigger() # Trigger the first action to close the menu
# toolbar.close()

View File

@@ -19,6 +19,12 @@ def test_spinner_widget_paint_event(spinner_widget, qtbot):
spinner_widget.paintEvent(None)
def test_spinnner_with_float_angle(spinner_widget, qtbot):
spinner_widget.start()
spinner_widget.angle = 0.123453453453453
spinner_widget.paintEvent(None)
def test_spinner_widget_rendered(spinner_widget, qtbot, tmpdir):
spinner_widget.update()
qtbot.wait(200)

View File

@@ -12,6 +12,7 @@ from qtpy.QtWidgets import (
)
from bec_widgets.utils.widget_io import WidgetHierarchy, WidgetIO
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
@pytest.fixture(scope="function")
@@ -23,10 +24,13 @@ def example_widget(qtbot):
combo_box = QComboBox(main_widget)
table_widget = QTableWidget(2, 2, main_widget)
spin_box = QSpinBox(main_widget)
toggle = ToggleSwitch(main_widget)
layout.addWidget(line_edit)
layout.addWidget(combo_box)
layout.addWidget(table_widget)
layout.addWidget(spin_box)
layout.addWidget(toggle)
# Add text items to the combo box
combo_box.addItems(["Option 1", "Option 2", "Option 3"])
@@ -60,44 +64,46 @@ def test_export_import_config(example_widget):
expected_full = {
"QWidget ()": {
"QVBoxLayout ()": {},
"QLineEdit ()": {"value": "New Text", "QObject ()": {}},
"QComboBox ()": {"value": 1, "QStandardItemModel ()": {}},
"QComboBox ()": {"QStandardItemModel ()": {}, "value": 1},
"QLineEdit ()": {"QObject ()": {}, "value": "New Text"},
"QSpinBox ()": {
"QLineEdit (qt_spinbox_lineedit)": {"QObject ()": {}, "value": "10"},
"QValidator (qt_spinboxvalidator)": {},
"value": 10,
},
"QTableWidget ()": {
"value": [["a", "b"], ["c", "d"]],
"QWidget (qt_scrollarea_viewport)": {},
"QStyledItemDelegate ()": {},
"QHeaderView ()": {
"QWidget (qt_scrollarea_viewport)": {},
"QWidget (qt_scrollarea_hcontainer)": {
"QScrollBar ()": {},
"QBoxLayout ()": {},
},
"QWidget (qt_scrollarea_vcontainer)": {
"QScrollBar ()": {},
"QBoxLayout ()": {},
},
"QItemSelectionModel ()": {},
},
"QAbstractButton ()": {},
"QAbstractTableModel ()": {},
"QHeaderView ()": {
"QItemSelectionModel ()": {},
"QWidget (qt_scrollarea_hcontainer)": {
"QBoxLayout ()": {},
"QScrollBar ()": {},
},
"QWidget (qt_scrollarea_vcontainer)": {
"QBoxLayout ()": {},
"QScrollBar ()": {},
},
"QWidget (qt_scrollarea_viewport)": {},
},
"QItemSelectionModel ()": {},
"QWidget (qt_scrollarea_hcontainer)": {"QScrollBar ()": {}, "QBoxLayout ()": {}},
"QWidget (qt_scrollarea_vcontainer)": {"QScrollBar ()": {}, "QBoxLayout ()": {}},
},
"QSpinBox ()": {
"value": 10,
"QLineEdit (qt_spinbox_lineedit)": {"value": "10", "QObject ()": {}},
"QValidator (qt_spinboxvalidator)": {},
"QStyledItemDelegate ()": {},
"QWidget (qt_scrollarea_hcontainer)": {"QBoxLayout ()": {}, "QScrollBar ()": {}},
"QWidget (qt_scrollarea_vcontainer)": {"QBoxLayout ()": {}, "QScrollBar ()": {}},
"QWidget (qt_scrollarea_viewport)": {},
"value": [["a", "b"], ["c", "d"]],
},
"QVBoxLayout ()": {},
"ToggleSwitch ()": {"value": True},
}
}
expected_reduced = {
"QWidget ()": {
"QLineEdit ()": {"value": "New Text"},
"QComboBox ()": {"value": 1},
"QLineEdit ()": {"value": "New Text"},
"QSpinBox ()": {"QLineEdit (qt_spinbox_lineedit)": {"value": "10"}, "value": 10},
"QTableWidget ()": {"value": [["a", "b"], ["c", "d"]]},
"QSpinBox ()": {"value": 10, "QLineEdit (qt_spinbox_lineedit)": {"value": "10"}},
"ToggleSwitch ()": {"value": True},
}
}
@@ -111,6 +117,7 @@ def test_widget_io_get_set_value(example_widget):
combo_box = example_widget.findChild(QComboBox)
table_widget = example_widget.findChild(QTableWidget)
spin_box = example_widget.findChild(QSpinBox)
toggle = example_widget.findChild(ToggleSwitch)
# Check initial values
assert WidgetIO.get_value(line_edit) == ""
@@ -120,18 +127,21 @@ def test_widget_io_get_set_value(example_widget):
["Initial C", "Initial D"],
]
assert WidgetIO.get_value(spin_box) == 0
assert WidgetIO.get_value(toggle) == True
# Set new values
WidgetIO.set_value(line_edit, "Hello")
WidgetIO.set_value(combo_box, "Option 2")
WidgetIO.set_value(table_widget, [["X", "Y"], ["Z", "W"]])
WidgetIO.set_value(spin_box, 5)
WidgetIO.set_value(toggle, False)
# Check updated values
assert WidgetIO.get_value(line_edit) == "Hello"
assert WidgetIO.get_value(combo_box, as_string=True) == "Option 2"
assert WidgetIO.get_value(table_widget) == [["X", "Y"], ["Z", "W"]]
assert WidgetIO.get_value(spin_box) == 5
assert WidgetIO.get_value(toggle) == False
def test_widget_io_signal(qtbot, example_widget):
@@ -140,6 +150,7 @@ def test_widget_io_signal(qtbot, example_widget):
combo_box = example_widget.findChild(QComboBox)
spin_box = example_widget.findChild(QSpinBox)
table_widget = example_widget.findChild(QTableWidget)
toggle = example_widget.findChild(ToggleSwitch)
# We'll store changes in a list to verify the slot is called
changes = []
@@ -152,6 +163,7 @@ def test_widget_io_signal(qtbot, example_widget):
WidgetIO.connect_widget_change_signal(combo_box, universal_slot)
WidgetIO.connect_widget_change_signal(spin_box, universal_slot)
WidgetIO.connect_widget_change_signal(table_widget, universal_slot)
WidgetIO.connect_widget_change_signal(toggle, universal_slot)
# Trigger changes
line_edit.setText("NewText")
@@ -173,3 +185,8 @@ def test_widget_io_signal(qtbot, example_widget):
qtbot.waitUntil(lambda: len(changes) > 3)
# The entire table value should be retrieved
assert changes[-1][1][0][0] == "ChangedCell"
# Test the toggle switch
toggle.checked = False
qtbot.waitUntil(lambda: len(changes) > 4)
assert changes[-1][1] == False

View File

@@ -0,0 +1,135 @@
import os
import tempfile
import pytest
from qtpy.QtCore import Property
from qtpy.QtWidgets import QLineEdit, QVBoxLayout, QWidget
from bec_widgets.utils.widget_state_manager import WidgetStateManager
class MyLineEdit(QLineEdit):
def __init__(self, parent=None):
super().__init__(parent)
# Internal attribute to hold the color string
self._customColor = ""
@Property(str)
def customColor(self):
return self._customColor
@customColor.setter
def customColor(self, color):
self._customColor = color
@pytest.fixture
def test_widget(qtbot):
w = QWidget()
w.setObjectName("MainWidget")
layout = QVBoxLayout(w)
child1 = MyLineEdit(w)
child1.setObjectName("ChildLineEdit1")
child1.setText("Hello")
child1.customColor = "red"
child2 = MyLineEdit(w)
child2.setObjectName("ChildLineEdit2")
child2.setText("World")
child2.customColor = "blue"
layout.addWidget(child1)
layout.addWidget(child2)
qtbot.addWidget(w)
qtbot.waitExposed(w)
return w
def test_save_load_widget_state(test_widget):
"""
Test saving and loading the state
"""
manager = WidgetStateManager(test_widget)
# Before saving, confirm initial properties
child1 = test_widget.findChild(MyLineEdit, "ChildLineEdit1")
child2 = test_widget.findChild(MyLineEdit, "ChildLineEdit2")
assert child1.text() == "Hello"
assert child1.customColor == "red"
assert child2.text() == "World"
assert child2.customColor == "blue"
# Create a temporary file to save settings
with tempfile.NamedTemporaryFile(delete=False, suffix=".ini") as tmp_file:
tmp_filename = tmp_file.name
# Save the current state
manager.save_state(tmp_filename)
# Modify the widget properties
child1.setText("Changed1")
child1.customColor = "green"
child2.setText("Changed2")
child2.customColor = "yellow"
assert child1.text() == "Changed1"
assert child1.customColor == "green"
assert child2.text() == "Changed2"
assert child2.customColor == "yellow"
# Load the previous state
manager.load_state(tmp_filename)
# Confirm that the state has been restored
assert child1.text() == "Hello"
assert child1.customColor == "red"
assert child2.text() == "World"
assert child2.customColor == "blue"
# Clean up temporary file
os.remove(tmp_filename)
def test_save_load_without_filename(test_widget, monkeypatch, qtbot):
"""
Test that the dialog would open if filename is not provided.
"""
manager = WidgetStateManager(test_widget)
# Mock QFileDialog.getSaveFileName to return a temporary filename
with tempfile.NamedTemporaryFile(delete=False, suffix=".ini") as tmp_file:
tmp_filename = tmp_file.name
def mock_getSaveFileName(*args, **kwargs):
return tmp_filename, "INI Files (*.ini)"
def mock_getOpenFileName(*args, **kwargs):
return tmp_filename, "INI Files (*.ini)"
from qtpy.QtWidgets import QFileDialog
monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_getSaveFileName)
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_getOpenFileName)
# Initial values
child1 = test_widget.findChild(MyLineEdit, "ChildLineEdit1")
assert child1.text() == "Hello"
# Save state without providing filename -> uses dialog mock
manager.save_state()
# Change property
child1.setText("Modified")
# Load state using dialog mock
manager.load_state()
# State should be restored
assert child1.text() == "Hello"
# Clean up
os.remove(tmp_filename)