mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-10 10:40:55 +02:00
Compare commits
25 Commits
tomcat/pro
...
v1.16.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d40075f85b | ||
| dfa2908c3d | |||
| 02a4862afd | |||
|
|
13438e22d3 | ||
| 889ea8629f | |||
|
|
0ef509e9ca | ||
| b40d2c5f0b | |||
|
|
6cd7ff6ef7 | ||
| 0fd5dd5a26 | |||
| 508abfa8a5 | |||
| 001e6fc807 | |||
|
|
111dcef35a | ||
| 3b04b985b6 | |||
|
|
5944626d93 | ||
| a00d368c25 | |||
| 01b4608331 | |||
|
|
b7221d1151 | ||
| fa9ecaf433 | |||
|
|
c751d25f85 | ||
| e2c7dc98d2 | |||
| 507d46f88b | |||
| 57dc1a3afc | |||
|
|
6a78da0e71 | ||
| fb545eebb3 | |||
| b4a240e463 |
5800
CHANGELOG.md
5800
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
151
bec_widgets/utils/widget_state_manager.py
Normal file
151
bec_widgets/utils/widget_state_manager.py
Normal 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_())
|
||||
3
bec_widgets/widgets/games/__init__.py
Normal file
3
bec_widgets/widgets/games/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from bec_widgets.widgets.games.minesweeper import Minesweeper
|
||||
|
||||
__ALL__ = ["Minesweeper"]
|
||||
413
bec_widgets/widgets/games/minesweeper.py
Normal file
413
bec_widgets/widgets/games/minesweeper.py
Normal 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_()
|
||||
1
bec_widgets/widgets/games/minesweeper.pyproject
Normal file
1
bec_widgets/widgets/games/minesweeper.pyproject
Normal file
@@ -0,0 +1 @@
|
||||
{'files': ['minesweeper.py']}
|
||||
54
bec_widgets/widgets/games/minesweeper_plugin.py
Normal file
54
bec_widgets/widgets/games/minesweeper_plugin.py
Normal 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()
|
||||
15
bec_widgets/widgets/games/register_minesweeper.py
Normal file
15
bec_widgets/widgets/games/register_minesweeper.py
Normal 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()
|
||||
@@ -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):
|
||||
|
||||
11
docs/user/widgets/games/games.md
Normal file
11
docs/user/widgets/games/games.md
Normal 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
|
||||
|
||||

|
||||
|
||||
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).
|
||||
BIN
docs/user/widgets/games/minesweeper.png
Normal file
BIN
docs/user/widgets/games/minesweeper.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -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
|
||||
|
||||
```
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
50
tests/unit_tests/test_minesweeper.py
Normal file
50
tests/unit_tests/test_minesweeper.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
135
tests/unit_tests/test_widget_state_manager.py
Normal file
135
tests/unit_tests/test_widget_state_manager.py
Normal 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)
|
||||
Reference in New Issue
Block a user