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

Compare commits

..

6 Commits

22 changed files with 901 additions and 1403 deletions

View File

@@ -1,38 +1,6 @@
# CHANGELOG
## v1.9.0 (2024-12-10)
### Features
- **side_menu**: Side menu with stack widget added
([`c7d7c6d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c7d7c6d9ed7c2dcc42b33fcd590f1f27499322c1))
### Testing
- **side_panel**: Tests added
([`9b95b5d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9b95b5d6164ff42673dbbc3031e5b1f45fbcde0a))
## v1.8.0 (2024-12-10)
### Features
- **modular_toolbar**: Material icons can be added/removed/hide/show/update dynamically
([`a55134c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a55134c3bfcbda6dc2d33a17cf5a83df8be3fa7f))
- **modular_toolbar**: Orientation setting
([`5fdb232`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5fdb2325ae970a7ecf4e2f4960710029891ab943))
- **round_frame**: Rounded frame for plot widgets and contrast adjustments
([`6a36ca5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6a36ca512d88f2b4fe916ac991e4f17ae0baffab))
### Testing
- **modular_toolbar**: Tests added
([`9370351`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9370351abbd7a151065ea9300c500d5bea8ee4f6))
## v1.7.0 (2024-12-02)
### Bug Fixes
@@ -210,3 +178,37 @@ Depending on the test, auto-updates are enabled or not.
- Update outdated text in docs
([`4f0693c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4f0693cae34b391d75884837e1ae6353a0501868))
## v1.3.2 (2024-11-05)
### Bug Fixes
- **plot_base**: Legend text color is changed when changing dark-light theme
([`2304c9f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2304c9f8497c1ab1492f3e6690bb79b0464c0df8))
### Build System
- Pyside6 version fixed 6.7.2
([`c6e48ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c6e48ec1fe5aaee6a7c7a6f930f1520cd439cdb2))
## v1.3.1 (2024-10-31)
### Bug Fixes
- **ophyd_kind_util**: Kind enums are imported from the bec widget util class
([`940ee65`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/940ee6552c1ee8d9b4e4a74c62351f2e133ab678))
## v1.3.0 (2024-10-30)
### Bug Fixes
- **colors**: Extend color map validation for matplotlib and colorcet maps (if available)
([`14dd8c5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/14dd8c5b2947c92f6643b888d71975e4e8d4ee88))
### Features
- **colormap_button**: Colormap button with menu to select colormap filtered by the colormap type
([`b039933`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b039933405e2fbe92bd81bd0748e79e8d443a741))

View File

@@ -42,6 +42,7 @@ class Widgets(str, enum.Enum):
SignalLineEdit = "SignalLineEdit"
StopButton = "StopButton"
TextBox = "TextBox"
UserScriptWidget = "UserScriptWidget"
VSCodeEditor = "VSCodeEditor"
WebsiteWidget = "WebsiteWidget"
@@ -3688,6 +3689,9 @@ class TextBox(RPCBase):
"""
class UserScriptWidget(RPCBase): ...
class VSCodeEditor(RPCBase): ...

View File

@@ -1,177 +0,0 @@
import pyqtgraph as pg
from qtpy.QtCore import Property
from qtpy.QtWidgets import QApplication, QFrame, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class RoundedFrame(BECWidget, QFrame):
"""
A custom QFrame with rounded corners and optional theme updates.
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
"""
def __init__(
self,
parent=None,
content_widget: QWidget = None,
background_color: str = None,
theme_update: bool = True,
radius: int = 10,
**kwargs,
):
super().__init__(**kwargs)
QFrame.__init__(self, parent)
self.background_color = background_color
self.theme_update = theme_update if background_color is None else False
self._radius = radius
# Apply rounded frame styling
self.setObjectName("roundedFrame")
self.update_style()
# Create a layout for the frame
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
# Add the content widget to the layout
if content_widget:
layout.addWidget(content_widget)
# Store reference to the content widget
self.content_widget = content_widget
# Automatically apply initial styles to the PlotWidget if applicable
if isinstance(content_widget, pg.PlotWidget):
self.apply_plot_widget_style()
self._connect_to_theme_change()
def apply_theme(self, theme: str):
"""
Apply the theme to the frame and its content if theme updates are enabled.
"""
if not self.theme_update:
return
# Update background color based on the theme
if theme == "light":
self.background_color = "#e9ecef" # Subtle contrast for light mode
else:
self.background_color = "#141414" # Dark mode
self.update_style()
# Update PlotWidget's background color and axis styles if applicable
if isinstance(self.content_widget, pg.PlotWidget):
self.apply_plot_widget_style()
@Property(int)
def radius(self):
"""Radius of the rounded corners."""
return self._radius
@radius.setter
def radius(self, value: int):
self._radius = value
self.update_style()
def update_style(self):
"""
Update the style of the frame based on the background color.
"""
if self.background_color:
self.setStyleSheet(
f"""
QFrame#roundedFrame {{
background-color: {self.background_color};
border-radius: {self._radius}; /* Rounded corners */
}}
"""
)
def apply_plot_widget_style(self, border: str = "none"):
"""
Automatically apply background, border, and axis styles to the PlotWidget.
Args:
border (str): Border style (e.g., 'none', '1px solid red').
"""
if isinstance(self.content_widget, pg.PlotWidget):
# Sync PlotWidget's background color with the RoundedFrame's background color
self.content_widget.setBackground(self.background_color)
# Calculate contrast-optimized axis and label colors
if self.background_color == "#e9ecef": # Light mode
label_color = "#000000"
axis_color = "#666666"
else: # Dark mode
label_color = "#FFFFFF"
axis_color = "#CCCCCC"
# Apply axis label and tick colors
plot_item = self.content_widget.getPlotItem()
plot_item.getAxis("left").setPen(pg.mkPen(color=axis_color))
plot_item.getAxis("bottom").setPen(pg.mkPen(color=axis_color))
plot_item.getAxis("left").setTextPen(pg.mkPen(color=label_color))
plot_item.getAxis("bottom").setTextPen(pg.mkPen(color=label_color))
# Apply border style via stylesheet
self.content_widget.setStyleSheet(
f"""
PlotWidget {{
border: {border}; /* Explicitly set the border */
}}
"""
)
class ExampleApp(QWidget): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Rounded Plots Example")
# Main layout
layout = QVBoxLayout(self)
dark_button = DarkModeButton()
# Create PlotWidgets
plot1 = pg.PlotWidget()
plot1.plot([1, 3, 2, 4, 6, 5], pen="r")
plot2 = pg.PlotWidget()
plot2.plot([1, 2, 4, 8, 16, 32], pen="r")
# Wrap PlotWidgets in RoundedFrame
rounded_plot1 = RoundedFrame(content_widget=plot1, theme_update=True)
rounded_plot2 = RoundedFrame(content_widget=plot2, theme_update=True)
round = RoundedFrame()
# Add to layout
layout.addWidget(dark_button)
layout.addWidget(rounded_plot1)
layout.addWidget(rounded_plot2)
layout.addWidget(round)
self.setLayout(layout)
# Simulate theme change after 2 seconds
from qtpy.QtCore import QTimer
def change_theme():
rounded_plot1.apply_theme("light")
rounded_plot2.apply_theme("dark")
QTimer.singleShot(100, change_theme)
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
window = ExampleApp()
window.show()
app.exec()

View File

@@ -1,386 +0,0 @@
import sys
from typing import Literal, Optional
from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation
from qtpy.QtGui import QAction
from qtpy.QtWidgets import (
QApplication,
QHBoxLayout,
QLabel,
QMainWindow,
QSizePolicy,
QSpacerItem,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
class SidePanel(QWidget):
"""
Side panel widget that can be placed on the left, right, top, or bottom of the main widget.
"""
def __init__(
self,
parent=None,
orientation: Literal["left", "right", "top", "bottom"] = "left",
panel_max_width: int = 200,
animation_duration: int = 200,
animations_enabled: bool = True,
):
super().__init__(parent=parent)
self._orientation = orientation
self._panel_max_width = panel_max_width
self._animation_duration = animation_duration
self._animations_enabled = animations_enabled
self._orientation = orientation
self._panel_width = 0
self._panel_height = 0
self.panel_visible = False
self.current_action: Optional[QAction] = None
self.current_index: Optional[int] = None
self.switching_actions = False
self._init_ui()
def _init_ui(self):
"""
Initialize the UI elements.
"""
if self._orientation in ("left", "right"):
self.main_layout = QHBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(target_widget=self, orientation="vertical")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
self.container.layout.setContentsMargins(0, 0, 0, 0)
self.container.layout.setSpacing(0)
self.stack_widget = QStackedWidget()
self.stack_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.stack_widget.setMinimumWidth(5)
if self._orientation == "left":
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
self.stack_widget.setMaximumWidth(self._panel_max_width)
else:
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
self.container.layout.setContentsMargins(0, 0, 0, 0)
self.container.layout.setSpacing(0)
self.stack_widget = QStackedWidget()
self.stack_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.stack_widget.setMinimumHeight(5)
if self._orientation == "top":
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
self.stack_widget.setMaximumHeight(self._panel_max_width)
if self._orientation in ("left", "right"):
self.menu_anim = QPropertyAnimation(self, b"panel_width")
else:
self.menu_anim = QPropertyAnimation(self, b"panel_height")
self.menu_anim.setDuration(self._animation_duration)
self.menu_anim.setEasingCurve(QEasingCurve.InOutQuad)
if self._orientation in ("left", "right"):
self.panel_width = 0
else:
self.panel_height = 0
@Property(int)
def panel_width(self):
"""
Get the panel width.
"""
return self._panel_width
@panel_width.setter
def panel_width(self, width: int):
"""
Set the panel width.
Args:
width(int): The width of the panel.
"""
self._panel_width = width
if self._orientation in ("left", "right"):
self.stack_widget.setFixedWidth(width)
@Property(int)
def panel_height(self):
"""
Get the panel height.
"""
return self._panel_height
@panel_height.setter
def panel_height(self, height: int):
"""
Set the panel height.
Args:
height(int): The height of the panel.
"""
self._panel_height = height
if self._orientation in ("top", "bottom"):
self.stack_widget.setFixedHeight(height)
@Property(int)
def panel_max_width(self):
"""
Get the maximum width of the panel.
"""
return self._panel_max_width
@panel_max_width.setter
def panel_max_width(self, size: int):
"""
Set the maximum width of the panel.
Args:
size(int): The maximum width of the panel.
"""
self._panel_max_width = size
if self._orientation in ("left", "right"):
self.stack_widget.setMaximumWidth(self._panel_max_width)
else:
self.stack_widget.setMaximumHeight(self._panel_max_width)
@Property(int)
def animation_duration(self):
"""
Get the duration of the animation.
"""
return self._animation_duration
@animation_duration.setter
def animation_duration(self, duration: int):
"""
Set the duration of the animation.
Args:
duration(int): The duration of the animation.
"""
self._animation_duration = duration
self.menu_anim.setDuration(duration)
@Property(bool)
def animations_enabled(self):
"""
Get the status of the animations.
"""
return self._animations_enabled
@animations_enabled.setter
def animations_enabled(self, enabled: bool):
"""
Set the status of the animations.
Args:
enabled(bool): The status of the animations.
"""
self._animations_enabled = enabled
def show_panel(self, idx: int):
"""
Show the side panel with animation and switch to idx.
Args:
idx(int): The index of the panel to show.
"""
self.stack_widget.setCurrentIndex(idx)
self.panel_visible = True
self.current_index = idx
if self._orientation in ("left", "right"):
start_val, end_val = 0, self._panel_max_width
else:
start_val, end_val = 0, self._panel_max_width
if self._animations_enabled:
self.menu_anim.stop()
self.menu_anim.setStartValue(start_val)
self.menu_anim.setEndValue(end_val)
self.menu_anim.start()
else:
if self._orientation in ("left", "right"):
self.panel_width = end_val
else:
self.panel_height = end_val
def hide_panel(self):
"""
Hide the side panel with animation.
"""
self.panel_visible = False
self.current_index = None
if self._orientation in ("left", "right"):
start_val, end_val = self._panel_max_width, 0
else:
start_val, end_val = self._panel_max_width, 0
if self._animations_enabled:
self.menu_anim.stop()
self.menu_anim.setStartValue(start_val)
self.menu_anim.setEndValue(end_val)
self.menu_anim.start()
else:
if self._orientation in ("left", "right"):
self.panel_width = end_val
else:
self.panel_height = end_val
def switch_to(self, idx: int):
"""
Switch to the specified index without animation.
Args:
idx(int): The index of the panel to switch to.
"""
if self.current_index != idx:
self.stack_widget.setCurrentIndex(idx)
self.current_index = idx
def add_menu(self, action_id: str, icon_name: str, tooltip: str, widget: QWidget, title: str):
"""
Add a menu to the side panel.
Args:
action_id(str): The ID of the action.
icon_name(str): The name of the icon.
tooltip(str): The tooltip for the action.
widget(QWidget): The widget to add to the panel.
title(str): The title of the panel.
"""
container_widget = QWidget()
container_layout = QVBoxLayout(container_widget)
container_widget.setStyleSheet("background-color: rgba(0,0,0,0);")
title_label = QLabel(f"<b>{title}</b>")
title_label.setStyleSheet("font-size: 16px;")
spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
container_layout.addWidget(title_label)
container_layout.addWidget(widget)
container_layout.addItem(spacer)
container_layout.setContentsMargins(5, 5, 5, 5)
container_layout.setSpacing(5)
index = self.stack_widget.count()
self.stack_widget.addWidget(container_widget)
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
def on_action_toggled(checked: bool):
if self.switching_actions:
return
if checked:
if self.current_action and self.current_action != action.action:
self.switching_actions = True
self.current_action.setChecked(False)
self.switching_actions = False
self.current_action = action.action
if not self.panel_visible:
self.show_panel(index)
else:
self.switch_to(index)
else:
if self.current_action == action.action:
self.current_action = None
self.hide_panel()
action.action.toggled.connect(on_action_toggled)
class ExampleApp(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Side Panel Example")
central_widget = QWidget()
self.setCentralWidget(central_widget)
self.side_panel = SidePanel(self, orientation="left")
self.layout = QHBoxLayout(central_widget)
self.layout.addWidget(self.side_panel)
self.plot = BECWaveformWidget()
self.layout.addWidget(self.plot)
self.add_side_menus()
def add_side_menus(self):
widget1 = QWidget()
widget1_layout = QVBoxLayout(widget1)
widget1_layout.addWidget(QLabel("This is Widget 1"))
self.side_panel.add_menu(
action_id="widget1",
icon_name="counter_1",
tooltip="Show Widget 1",
widget=widget1,
title="Widget 1 Panel",
)
widget2 = QWidget()
widget2_layout = QVBoxLayout(widget2)
widget2_layout.addWidget(QLabel("This is Widget 2"))
self.side_panel.add_menu(
action_id="widget2",
icon_name="counter_2",
tooltip="Show Widget 2",
widget=widget2,
title="Widget 2 Panel",
)
widget3 = QWidget()
widget3_layout = QVBoxLayout(widget3)
widget3_layout.addWidget(QLabel("This is Widget 3"))
self.side_panel.add_menu(
action_id="widget3",
icon_name="counter_3",
tooltip="Show Widget 3",
widget=widget3,
title="Widget 3 Panel",
)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = ExampleApp()
window.resize(800, 600)
window.show()
sys.exit(app.exec())

View File

@@ -261,31 +261,17 @@ class ExpandableMenuAction(ToolBarAction):
class ModularToolBar(QToolBar):
"""Modular toolbar with optional automatic initialization.
Args:
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
actions (dict, optional): A dictionary of action creators to populate the toolbar. Defaults to None.
actions (list[ToolBarAction], optional): A list of action creators to populate the toolbar. Defaults to None.
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
orientation (Literal["horizontal", "vertical"], optional): The initial orientation of the toolbar. Defaults to "horizontal".
background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)" - transparent background.
"""
def __init__(
self,
parent=None,
actions: dict | None = None,
target_widget=None,
orientation: Literal["horizontal", "vertical"] = "horizontal",
background_color: str = "rgba(0, 0, 0, 0)",
):
def __init__(self, parent=None, actions: dict | None = None, target_widget=None):
super().__init__(parent)
self.widgets = defaultdict(dict)
self.background_color = background_color
self.set_background_color(self.background_color)
# Set the initial orientation
self.set_orientation(orientation)
self.set_background_color()
if actions is not None and target_widget is not None:
self.populate_toolbar(actions, target_widget)
@@ -294,7 +280,7 @@ class ModularToolBar(QToolBar):
"""Populates the toolbar with a set of actions.
Args:
actions (dict): A dictionary of action creators to populate the toolbar.
actions (list[ToolBarAction]): A list of action creators to populate the toolbar.
target_widget (QWidget): The widget that the actions will target.
"""
self.clear()
@@ -302,83 +288,9 @@ class ModularToolBar(QToolBar):
action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action
def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"):
"""
Sets the background color and other appearance settings.
Args:
color(str): The background color of the toolbar.
"""
def set_background_color(self):
self.setIconSize(QSize(20, 20))
self.setMovable(False)
self.setFloatable(False)
self.setContentsMargins(0, 0, 0, 0)
self.background_color = color
self.setStyleSheet(f"QToolBar {{ background-color: {color}; border: none; }}")
def set_orientation(self, orientation: Literal["horizontal", "vertical"]):
"""Sets the orientation of the toolbar.
Args:
orientation (Literal["horizontal", "vertical"]): The desired orientation of the toolbar.
"""
if orientation == "horizontal":
self.setOrientation(Qt.Horizontal)
elif orientation == "vertical":
self.setOrientation(Qt.Vertical)
else:
raise ValueError("Orientation must be 'horizontal' or 'vertical'.")
def update_material_icon_colors(self, new_color: str | tuple | QColor):
"""
Updates the color of all MaterialIconAction icons in the toolbar.
Args:
new_color (str | tuple | QColor): The new color for the icons.
"""
for action in self.widgets.values():
if isinstance(action, MaterialIconAction):
action.color = new_color
# Refresh the icon
updated_icon = action.get_icon()
action.action.setIcon(updated_icon)
def add_action(self, action_id: str, action: ToolBarAction, target_widget: QWidget):
"""
Adds a new action to the toolbar dynamically.
Args:
action_id (str): Unique identifier for the action.
action (ToolBarAction): The action to add to the toolbar.
target_widget (QWidget): The target widget for the action.
"""
if action_id in self.widgets:
raise ValueError(f"Action with ID '{action_id}' already exists.")
action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action
def hide_action(self, action_id: str):
"""
Hides a specific action on the toolbar.
Args:
action_id (str): Unique identifier for the action to hide.
"""
if action_id not in self.widgets:
raise ValueError(f"Action with ID '{action_id}' does not exist.")
action = self.widgets[action_id]
if hasattr(action, "action") and isinstance(action.action, QAction):
action.action.setVisible(False)
def show_action(self, action_id: str):
"""
Shows a specific action on the toolbar.
Args:
action_id (str): Unique identifier for the action to show.
"""
if action_id not in self.widgets:
raise ValueError(f"Action with ID '{action_id}' does not exist.")
action = self.widgets[action_id]
if hasattr(action, "action") and isinstance(action.action, QAction):
action.action.setVisible(True)
self.setStyleSheet("QToolBar { background-color: rgba(0, 0, 0, 0); border: none; }")

View File

@@ -16,6 +16,7 @@ import sys
import time
import pyte
from bec_lib.logger import bec_logger
from pygments.token import Token
from pyte.screens import History
from qtpy import QtCore, QtGui, QtWidgets
@@ -27,6 +28,8 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
logger = bec_logger.logger
ansi_colors = {
"black": "#000000",
"red": "#CD0000",
@@ -361,6 +364,24 @@ class BECConsole(QtWidgets.QWidget):
def send_ctrl_c(self, timeout=None):
self.term.send_ctrl_c(timeout)
def cleanup(self):
"""Cleanup the terminal"""
self.execute_command("\x03") # Ctrl-C
self.execute_command("exit()")
timeout = 5
interval = 0.1
timer = 0
# os.close(self.term.fd)
while self.term.fd is not None:
time.sleep(interval)
timer += interval
if timer > 0.8 * timeout:
logger.warning(f"Terminal still cleaning up after {timer:.1f} seconds")
if timer > timeout:
logger.error(f"Terminal cleanup timed out after {timeout} seconds")
break
self.deleteLater()
cols = pyqtProperty(int, get_cols, set_cols)
rows = pyqtProperty(int, get_rows, set_rows)
bgcolor = pyqtProperty(QColor, get_bgcolor, set_bgcolor)

View File

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

View File

@@ -0,0 +1,569 @@
import glob
import importlib
import inspect
import os
import pathlib
from collections import defaultdict
from pathlib import Path
from typing import Literal
import bec_lib
from bec_qthemes import material_icon
from pydantic import BaseModel
from pygments.token import Token
from qtpy.QtCore import QSize, Signal, Slot
from qtpy.QtWidgets import (
QDialog,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QSpacerItem,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, set_theme
from bec_widgets.widgets.editors.console.console import BECConsole
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
logger = bec_lib.bec_logger.logger
class EnchancedQTreeWidget(QTreeWidget):
"""Thin wrapper around QTreeWidget to add some functionality for user scripting"""
play_button_clicked = Signal(str)
edit_button_clicked = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setColumnCount(2)
self.setHeaderHidden(True)
self.setObjectName(__class__.__name__)
self._update_style_sheet()
self._icon_size = QSize(24, 24)
self.setRootIsDecorated(False)
self.setUniformRowHeights(True)
self.setWordWrap(True)
self.setAnimated(True)
self.setIndentation(24)
self._adjust_size_policy()
def _adjust_size_policy(self):
"""Adjust the size policy"""
header = self.header()
header.setMinimumSectionSize(42)
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.Stretch)
def _update_style_sheet(self) -> None:
"""Update the style sheet"""
name = __class__.__name__
colors = get_accent_colors()
# pylint: disable=protected-access
color = colors._palette.midlight().color().name()
self.setStyleSheet(
f"""
{name}::item {{
border: none;
background: transparent;
}}
QTreeView::branch:hover {{
background: transparent;
color: {color};
}}
{name}::item:hover {{
background: {color};
}}
{name}::item:selected:hover {{
background: {color};
}}
"""
)
def add_top_item(self, label: str) -> QTreeWidgetItem:
"""Add a top item to the tree widget
Args:
label (str): The label for the top item
Returns:
QTreeWidgetItem: The top item
"""
top_item = QTreeWidgetItem(self, [label])
top_item.setExpanded(True)
top_item.setSelected(False)
self.resizeColumnToContents(0)
return top_item
def add_module_item(self, top_item: QTreeWidgetItem, mod_name: str) -> QTreeWidgetItem:
"""Add a top item to the tree widget together with an edit button in column 0 and label in 1
Args:
top_item (QTreeWidgetItem): The top item to add the child item to
mod_name (str): The label for the child item
Returns:
QTreeWidgetItem: The top item
"""
child_item = QTreeWidgetItem(top_item)
# Add label
label = QLabel(mod_name, parent=top_item.treeWidget())
# Add edit button with label as parent
edit_button = self._create_button(parent=label, button_type="edit")
edit_button.clicked.connect(self._handle_edit_button_clicked)
self.setItemWidget(child_item, 0, edit_button)
self.setItemWidget(child_item, 1, label)
self.resizeColumnToContents(0)
return child_item
def add_child_item(self, top_item: QTreeWidgetItem, label: str) -> None:
"""Add a child item to the top item together with a play button in column 1
Args:
top_item (QTreeWidgetItem): The top item to add the child item to
label (str): The label for the child item
Returns:
QTreeWidgetItem: The child item
"""
widget = QWidget(top_item.treeWidget())
label = QLabel(label)
spacer = QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum)
layout = QHBoxLayout(widget)
layout.addWidget(label)
layout.addItem(spacer)
layout.setSpacing(4)
layout.setContentsMargins(0, 0, 0, 0)
button = self._create_button(parent=top_item.treeWidget(), button_type="play")
button.clicked.connect(self._handle_play_button_clicked)
layout.addWidget(button)
child_item = QTreeWidgetItem(top_item)
self.setItemWidget(child_item, 1, widget)
return child_item
@Slot()
def _handle_edit_button_clicked(self):
"""Handle the click of the edit button"""
button = self.sender()
tree_widget_item = self.itemAt(button.pos())
text = self.itemWidget(tree_widget_item, 1).text()
self.edit_button_clicked.emit(text)
@Slot()
def _handle_play_button_clicked(self):
"""Handle the click of the play button"""
button = self.sender()
widget = button.parent()
text = widget.findChild(QLabel).text()
self.play_button_clicked.emit(text)
def _create_button(self, parent: QWidget, button_type: Literal["edit", "play"]) -> QToolButton:
"""Create a button for 'edit' or 'play'
Args:
button_type (Literal["edit", "play"]): The type of button to create
"""
colors = get_accent_colors()
if button_type == "edit":
color = colors.highlight
name = "edit_document"
elif button_type == "play":
color = colors.success
name = "play_arrow"
else:
raise ValueError("Invalid button type")
button = QToolButton(
parent=parent,
icon=material_icon(
name, filled=False, color=color, size=self._icon_size, convert_to_pixmap=False
),
)
button.setContentsMargins(0, 0, 0, 0)
button.setStyleSheet("QToolButton { border: none; }")
return button
def _hide_buttons(self, exclude_item: QWidget = None):
for button in self.viewport().findChildren(QToolButton):
if exclude_item is not None:
if button.parent() == exclude_item:
continue
button.setVisible(False)
class VSCodeDialog(QDialog):
"""Dialog for the VSCode editor"""
def __init__(self, parent=None, client=None, editor: VSCodeEditor = None):
super().__init__(parent=parent)
self.setWindowTitle("VSCode Editor")
self.setMinimumWidth(800)
self.setMinimumHeight(600)
self.layout = QVBoxLayout(self)
self.editor = editor
self.init_ui()
def init_ui(self):
"""Initialize the UI. Note: this makes the code easier to test."""
self.layout.addWidget(self.editor)
class InputDialog(QDialog):
"""Dialog for input
Args:
header (str): The header of the dialog
info (str): The information of the dialog
fields (dict): The fields of the dialog
parent (QWidget): The parent widget
"""
def __init__(self, header: str, info: str, fields: dict, parent=None):
super().__init__(parent=parent)
self.header = header
self.info = info
self.fields = fields
self._layout = QVBoxLayout(self)
self.button_ok = QPushButton(parent=self, text="OK")
self.button_cancel = QPushButton(parent=self, text="Cancel")
self._init_ui()
self.button_ok.clicked.connect(self.accept)
self.button_cancel.clicked.connect(self.reject)
def _init_ui(self):
"""Initialize the UI"""
self.setWindowTitle(f"{self.header}")
self.setMinimumWidth(200)
box = QGroupBox(self)
box.setTitle(self.info)
layout = QGridLayout(box)
layout.setSpacing(4)
layout.setContentsMargins(4, 30, 4, 30)
row = 0
for name, default in self.fields.items():
label = QLabel(parent=self, text=name)
line_input = QLineEdit(parent=self)
line_input.setObjectName(name)
if default is not None:
line_input.setText(f"{default}")
layout.addWidget(label, row, 0)
layout.addWidget(line_input, row, 1)
row += 1
self._layout.addWidget(box)
widget = QWidget(self)
sub_layout = QHBoxLayout(widget)
sub_layout.addWidget(self.button_ok)
sub_layout.addWidget(self.button_cancel)
self._layout.addWidget(widget)
self.setLayout(self._layout)
self.resize(self._layout.sizeHint() * 1.05)
def get_inputs(self):
"""Get the input from the dialog"""
out = {}
for name, _ in self.fields.items():
line_input = self.findChild(QLineEdit, name)
if line_input is not None:
out[name] = line_input.text()
return out
class ScriptBlock(BaseModel):
"""Model block for a script"""
location: Literal["BEC", "USER", "BL"]
fname: str
module_name: str
user_script_name: str | None = None
class UserScriptWidget(BECWidget, QWidget):
"""Dialog for displaying the fit summary and params for LMFit DAP processes."""
PLUGIN = True
USER_ACCESS = []
ICON_NAME = "manage_accounts"
def __init__(
self,
parent=None,
client=None,
config=None,
gui_id: str | None = None,
vs_code_editor=None,
bec_console=None,
):
"""
Initialize the widget
Args:
parent (QWidget): The parent widget
client (BECClient): The BEC client
config (dict): The configuration
gui_id (str): The GUI ID
vs_code_editor (VSCodeEditor): The VSCode editor, dep injection here makes makes testing easier, if None defaults to VSCodeEditor
bec_console (BECConsole): The BEC console, note this makes testing easier, if None defaults to BECConsole
"""
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True)
QWidget.__init__(self, parent=parent)
self.button_new_script = QPushButton(parent=self, text="New Script")
self.button_new_script.setObjectName("button_new_script")
if vs_code_editor is None:
vs_code_editor = VSCodeEditor(parent=self, client=self.client, gui_id=self.gui_id)
self._vscode_editor = vs_code_editor
if bec_console is None:
bec_console = BECConsole(parent=self)
self._console = bec_console
self.tree_widget = EnchancedQTreeWidget(parent=self)
self.layout = QVBoxLayout(self)
self.user_scripts = defaultdict(lambda: ScriptBlock)
self._base_path = os.path.join(str(Path.home()), "bec", "scripts")
self._icon_size = QSize(16, 16)
self._script_button_register = {}
self._code_dialog = None
self._script_dialog = None
self._new_script_dialog = None
self.init_ui()
self.button_new_script.clicked.connect(self.new_script)
self.tree_widget.edit_button_clicked.connect(self.handle_edit_button_clicked)
self.tree_widget.play_button_clicked.connect(self.handle_play_button_clicked)
def apply_theme(self, theme: str):
"""Apply the theme"""
self._update_button_ui()
self.update_user_scripts()
self.tree_widget._update_style_sheet()
super().apply_theme(theme)
def _setup_console(self):
"""Setup the console. Toents are needed to allow for the console to check for the prompt during shutdown."""
self._console.set_prompt_tokens(
(Token.OutPromptNum, ""),
(Token.Prompt, ""), # will match arbitrary string,
(Token.Prompt, " ["),
(Token.PromptNum, "3"),
(Token.Prompt, "/"),
(Token.PromptNum, "1"),
(Token.Prompt, "] "),
(Token.Prompt, ""),
)
self._console.start()
# Comment to not hide the console for debugging
self._console.hide()
def init_ui(self):
"""Initialize the UI"""
# Add buttons
widget = QWidget(self)
layout = QHBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
layout.addWidget(self.button_new_script)
self.layout.addWidget(widget)
self.layout.addWidget(self.tree_widget)
# Uncomment to show the console for debugging
# self.layout.addWidget(self._console)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._vscode_editor.hide()
self._update_button_ui()
self._setup_console()
self.update_user_scripts()
self._vscode_editor.file_saved.connect(self._handle_file_saved)
@Slot(str)
def _handle_file_saved(self, fname: str):
"""Handle the file saved signal"""
self.update_user_scripts()
def _update_button_ui(self):
"""Update the button UI"""
colors = get_accent_colors()
name = self.button_new_script.objectName()
self.button_new_script.setStyleSheet(
f"QWidget#{name} {{ color: {colors._palette.windowText().color().name()}; }}"
)
def save_script(self):
"""Save the script"""
self._vscode_editor.save_file()
self._vscode_editor.hide()
if self._code_dialog is not None:
self._code_dialog.hide()
self.update_user_scripts()
def open_script(self, fname: str):
"""Open a script
Args:
fname (str): The file name of the script
"""
if self._code_dialog is None:
self._code_dialog = VSCodeDialog(parent=self, editor=self._vscode_editor)
self._code_dialog.show()
self._vscode_editor.show()
# Only works after show was called for the first time
self._vscode_editor.zen_mode()
else:
self._code_dialog.show()
self._vscode_editor.show()
self._vscode_editor.open_file(fname)
@SafeSlot(popup_error=True)
def new_script(self, *args, **kwargs):
"""Create a new script"""
self._new_script_dialog = InputDialog(
header="New Script", info="Enter filename for new script", fields={"Filename": ""}
)
if self._new_script_dialog.exec_():
name = self._new_script_dialog.get_inputs()["Filename"]
check_name = name.replace("_", "").replace("-", "")
if not check_name.isalnum() or not check_name.isascii():
raise NameError(f"Invalid name {name}, must be alphanumeric and ascii")
if not name.endswith(".py"):
name = name + ".py"
fname = os.path.join(self._base_path, name)
# Check if file exists on disk
if os.path.exists(fname):
logger.error(f"File {fname} already exists")
raise FileExistsError(f"File {fname} already exists")
try:
os.makedirs(os.path.dirname(fname), exist_ok=True, mode=0o775)
with open(fname, "w", encoding="utf-8") as f:
f.write("# New BEC Script\n")
except Exception as e:
logger.error(f"Error creating new script: {e}")
raise e
self.open_script(fname)
def get_script_files(self) -> dict:
"""Get all script files in the base path"""
files = {"BEC": [], "USER": [], "BL": []}
# bec
bec_lib_path = pathlib.Path(bec_lib.__file__).parent.parent.resolve()
bec_scripts_dir = os.path.join(str(bec_lib_path), "scripts")
files["BEC"].extend(glob.glob(os.path.abspath(os.path.join(bec_scripts_dir, "*.py"))))
# user
user_scripts_dir = os.path.join(os.path.expanduser("~"), "bec", "scripts")
if os.path.exists(user_scripts_dir):
files["USER"].extend(glob.glob(os.path.abspath(os.path.join(user_scripts_dir, "*.py"))))
# load scripts from the beamline plugin
plugins = importlib.metadata.entry_points(group="bec")
for plugin in plugins:
if plugin.name == "plugin_bec":
plugin = plugin.load()
plugin_scripts_dir = os.path.join(plugin.__path__[0], "scripts")
if os.path.exists(plugin_scripts_dir):
files["BL"].extend(
glob.glob(os.path.abspath(os.path.join(plugin_scripts_dir, "*.py")))
)
return files
@SafeSlot()
def reload_user_scripts(self, *args, **kwargs):
"""Reload the user scripts"""
self.client.load_all_user_scripts()
@Slot()
def update_user_scripts(self) -> None:
"""Update the user scripts"""
self.user_scripts.clear()
self.tree_widget.clear()
script_files = self.get_script_files()
for key, files in script_files.items():
if len(files) == 0:
continue
top_item = self.tree_widget.add_top_item(key)
for fname in files:
mod_name = fname.split("/")[-1].strip(".py")
self.user_scripts[mod_name] = ScriptBlock(
fname=fname, module_name=mod_name, location=key
)
child_item = self.tree_widget.add_module_item(top_item, mod_name)
# pylint: disable=protected-access
self.reload_user_scripts(popup_error=True)
for user_script_name, info in self.client._scripts.items():
if info["fname"] == fname:
self.user_scripts[mod_name].user_script_name = user_script_name
_ = self.tree_widget.add_child_item(child_item, user_script_name)
self.tree_widget.expandAll()
@Slot(str)
def handle_edit_button_clicked(self, text: str):
"""Handle the click of the edit button"""
self.open_script(self.user_scripts[text].fname)
@Slot(str)
def handle_play_button_clicked(self, text: str):
"""Handle the click of the play button"""
self._console.execute_command("bec.load_all_user_scripts()")
info = self.client._scripts[text]
caller_args = inspect.getfullargspec(info["cls"])
args = caller_args.args + caller_args.kwonlyargs
if args:
self._handle_call_with_args(text, caller_args)
else:
self._console.execute_command(f"{text}()")
def _handle_call_with_args(self, text: str, caller_args: inspect.FullArgSpec) -> None:
"""Handle the call with arguments"""
defaults = []
args = caller_args.args + caller_args.kwonlyargs
for value in args:
if caller_args.kwonlydefaults is not None:
defaults.append(caller_args.kwonlydefaults.get(value, None))
fields = dict((arg, default) for arg, default in zip(args, defaults))
info = ", ".join([f"{k}={v}" for k, v in fields.items()]).replace("None", "")
info = f"Example: {text}({info})"
self._script_dialog = InputDialog(
parent=self, header="Script Arguments", info=info, fields=fields
)
if self._script_dialog.exec_():
args = self._script_dialog.get_inputs()
args = ", ".join([f"{k}={v}" for k, v in args.items()])
self._console.execute_command(f"{text}({args})")
self._script_dialog = None
def cleanup(self):
"""Cleanup the widget"""
self._vscode_editor.cleanup()
self._vscode_editor.deleteLater()
if self._code_dialog is not None:
self._code_dialog.deleteLater()
if self._script_dialog is not None:
self._script_dialog.deleteLater()
if self._new_script_dialog is not None:
self._new_script_dialog.deleteLater()
self.tree_widget.clear()
self._console.cleanup()
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QApplication([])
set_theme("dark")
w = QWidget()
layout = QVBoxLayout(w)
layout.addWidget(UserScriptWidget())
w.setFixedHeight(400)
w.setFixedWidth(400)
w.show()
app.exec_()

View File

@@ -0,0 +1 @@
{'files': ['user_script.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.editors.user_script.user_script import UserScriptWidget
DOM_XML = """
<ui language='c++'>
<widget class='UserScriptWidget' name='user_script_widget'>
</widget>
</ui>
"""
class UserScriptWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = UserScriptWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Services"
def icon(self):
return designer_material_icon(UserScriptWidget.ICON_NAME)
def includeFile(self):
return "user_script_widget"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "UserScriptWidget"
def toolTip(self):
return "Dialog for displaying the fit summary and params for LMFit DAP processes"
def whatsThis(self):
return self.toolTip()

View File

@@ -124,16 +124,41 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
self.tree = QTreeWidget(self)
self.tree.setHeaderHidden(True)
# TODO probably here is a problem still with setting the stylesheet
# self.tree.setStyleSheet(
# "QTreeWidget::item:!selected "
# "{ "
# "border: 1px solid gainsboro; "
# "border-left: none; "
# "border-top: none; "
# "}"
# "QTreeWidget::item:selected {}"
# )
self.tree.setStyleSheet(
"QTreeWidget::item:!selected "
"{ "
"QTreeWidget::item:!selected { "
"border: 1px solid gainsboro; "
"border-left: none; "
"border-top: none; "
"} "
"QTreeWidget::item:selected {} "
"QTreeView::branch { "
"border-image: none; "
"background: transparent; "
"} "
"QTreeView::branch:has-siblings:!adjoins-item { "
"border-image: none; "
"} "
"QTreeView::branch:has-children:!has-siblings:closed, "
"QTreeView::branch:closed:has-children:has-siblings { "
"border-image: none; "
"} "
"QTreeView::branch:open:has-children:!has-siblings, "
"QTreeView::branch:open:has-children:has-siblings { "
"border-image: none; "
"}"
"QTreeWidget::item:selected {}"
)
# self.tree.setRootIsDecorated(False)
def _create_status_widget(
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
) -> StatusItem:

View File

@@ -23,7 +23,7 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class IconsEnum(enum.Enum):
"""Enum class for icons in the status item widget."""
RUNNING = "done_outline"
RUNNING = "check_circle"
BUSY = "progress_activity"
IDLE = "progress_activity"
ERROR = "emergency_home"

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,35 @@
(user.widgets.user_script_widget)=
# User Script Widget
````{tab} Overview
The [`UserScriptWidget`] is designed to allow users to run their user-defined scripts directly from a BEC GUI. This widget lists all available user scripts and allows users to execute them with a single click. The widget also provides an interface to open a VSCode editor to modify the files hosting the user scripts. This widget is particularly useful to provide a user-friendly interface to run custom scripts to users without using the command line. We note that the scripts are executed in a BEC client that does not share the full namespace with the BEC IPython kernel.
## Key Features:
- **User Script Execution**: Run user-defined scripts directly from the BEC GUI.
- **VSCode Integration**: Open the VSCode editor to modify the files hosting the user scripts.
````{tab} Examples
The `UserScriptWidget` widget can be integrated within a [`BECDockArea`](user.widgets.bec_dock_area) or used as an individual component in your application through `BECDesigner`. Below are examples demonstrating how to create and use the `BECStatusBox` widget.
## Example 1 - Adding BEC Status Box to BECDockArea
In this example, we demonstrate how to add a `BECStatusBox` widget to a `BECDockArea`, allowing users to monitor the status of BEC processes directly from the GUI.
```python
# Add a new dock with a BECStatusBox widget
user_script = gui.add_dock().add_widget("UserScriptWidget")
```
```{hint}
The widget will automatically display the list of available user scripts. Users can click on the script name to execute it.
```
````
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.UserScriptWidget.rst
```
````

View File

@@ -134,6 +134,15 @@ Display status of BEC services.
Display current scan queue.
```
```{grid-item-card} User Script Widget
:link: user.widgets.user_script_widget
:link-type: ref
:img-top: /assets/widget_screenshots/user_script_widget.png
Run user-defined scripts directly from the BEC GUI.
```
````
## BEC Utility Widgets
@@ -238,6 +247,7 @@ Display DAP summaries of LMFit models in a window.
Select DAP model from a list of DAP processes.
```
````
```{toctree}
@@ -270,5 +280,6 @@ signal_input/signal_input.md
position_indicator/position_indicator.md
lmfit_dialog/lmfit_dialog.md
dap_combo_box/dap_combo_box.md
user_script_widget/user_script_widget.md
```

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -1,279 +0,0 @@
from typing import Literal
import pytest
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QComboBox, QLabel, QToolButton, QWidget
from bec_widgets.qt_utils.toolbar import (
DeviceSelectionAction,
ExpandableMenuAction,
IconAction,
MaterialIconAction,
ModularToolBar,
SeparatorAction,
WidgetAction,
)
@pytest.fixture
def dummy_widget(qtbot):
"""Fixture to create a simple widget to be used as target widget."""
widget = QWidget()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
@pytest.fixture(params=["horizontal", "vertical"])
def toolbar_fixture(qtbot, request, dummy_widget):
"""Parametrized fixture to create a ModularToolBar with different orientations."""
orientation: Literal["horizontal", "vertical"] = request.param
toolbar = ModularToolBar(
target_widget=dummy_widget,
orientation=orientation,
background_color="rgba(255, 255, 255, 255)", # White background for testing
)
qtbot.addWidget(toolbar)
qtbot.waitExposed(toolbar)
yield toolbar
toolbar.close()
@pytest.fixture
def separator_action():
"""Fixture to create a SeparatorAction."""
return SeparatorAction()
@pytest.fixture
def icon_action():
"""Fixture to create an IconAction."""
return IconAction(icon_path="assets/BEC-Icon.png", tooltip="Test Icon Action", checkable=True)
@pytest.fixture
def material_icon_action():
"""Fixture to create a MaterialIconAction."""
return MaterialIconAction(
icon_name="home", tooltip="Test Material Icon Action", checkable=False
)
@pytest.fixture
def device_selection_action():
"""Fixture to create a DeviceSelectionAction."""
device_combobox = QComboBox()
device_combobox.addItems(["Device 1", "Device 2", "Device 3"])
device_combobox.setCurrentIndex(0)
return DeviceSelectionAction(label="Select Device:", device_combobox=device_combobox)
@pytest.fixture
def widget_action():
"""Fixture to create a WidgetAction."""
sample_widget = QLabel("Sample Widget")
return WidgetAction(label="Sample Label:", widget=sample_widget)
@pytest.fixture
def expandable_menu_action():
"""Fixture to create an ExpandableMenuAction."""
action1 = MaterialIconAction(icon_name="counter_1", tooltip="Menu Action 1", checkable=False)
action2 = MaterialIconAction(icon_name="counter_2", tooltip="Menu Action 2", checkable=True)
actions = {"action1": action1, "action2": action2}
return ExpandableMenuAction(
label="Expandable Menu", actions=actions, icon_path="assets/BEC-Icon.png"
)
def test_initialization(toolbar_fixture):
"""Test that ModularToolBar initializes correctly with different orientations."""
toolbar = toolbar_fixture
if toolbar.orientation() == Qt.Horizontal:
assert toolbar.orientation() == Qt.Horizontal
elif toolbar.orientation() == Qt.Vertical:
assert toolbar.orientation() == Qt.Vertical
else:
pytest.fail("Toolbar orientation is neither horizontal nor vertical.")
assert toolbar.background_color == "rgba(255, 255, 255, 255)"
assert toolbar.widgets == {}
assert not toolbar.isMovable()
assert not toolbar.isFloatable()
def test_set_background_color(toolbar_fixture):
"""Test setting the background color of the toolbar."""
toolbar = toolbar_fixture
new_color = "rgba(0, 0, 0, 255)" # Black
toolbar.set_background_color(new_color)
assert toolbar.background_color == new_color
# Verify stylesheet
expected_style = f"QToolBar {{ background-color: {new_color}; border: none; }}"
assert toolbar.styleSheet() == expected_style
def test_set_orientation(toolbar_fixture, qtbot, dummy_widget):
"""Test changing the orientation of the toolbar."""
toolbar = toolbar_fixture
if toolbar.orientation() == Qt.Horizontal:
new_orientation = "vertical"
else:
new_orientation = "horizontal"
toolbar.set_orientation(new_orientation)
qtbot.wait(100)
if new_orientation == "horizontal":
assert toolbar.orientation() == Qt.Horizontal
else:
assert toolbar.orientation() == Qt.Vertical
def test_add_action(
toolbar_fixture, icon_action, separator_action, material_icon_action, dummy_widget
):
"""Test adding different types of actions to the toolbar."""
toolbar = toolbar_fixture
# Add IconAction
toolbar.add_action("icon_action", icon_action, dummy_widget)
assert "icon_action" in toolbar.widgets
assert toolbar.widgets["icon_action"] == icon_action
assert icon_action.action in toolbar.actions()
# Add SeparatorAction
toolbar.add_action("separator_action", separator_action, dummy_widget)
assert "separator_action" in toolbar.widgets
assert toolbar.widgets["separator_action"] == separator_action
# Add MaterialIconAction
toolbar.add_action("material_icon_action", material_icon_action, dummy_widget)
assert "material_icon_action" in toolbar.widgets
assert toolbar.widgets["material_icon_action"] == material_icon_action
assert material_icon_action.action in toolbar.actions()
def test_hide_show_action(toolbar_fixture, icon_action, qtbot, dummy_widget):
"""Test hiding and showing actions on the toolbar."""
toolbar = toolbar_fixture
# Add an action
toolbar.add_action("icon_action", icon_action, dummy_widget)
assert icon_action.action.isVisible()
# Hide the action
toolbar.hide_action("icon_action")
qtbot.wait(100)
assert not icon_action.action.isVisible()
# Show the action
toolbar.show_action("icon_action")
qtbot.wait(100)
assert icon_action.action.isVisible()
def test_add_duplicate_action(toolbar_fixture, icon_action, dummy_widget):
"""Test that adding an action with a duplicate action_id raises a ValueError."""
toolbar = toolbar_fixture
# Add an action
toolbar.add_action("icon_action", icon_action, dummy_widget)
assert "icon_action" in toolbar.widgets
# Attempt to add another action with the same ID
with pytest.raises(ValueError) as excinfo:
toolbar.add_action("icon_action", icon_action, dummy_widget)
assert "Action with ID 'icon_action' already exists." in str(excinfo.value)
def test_update_material_icon_colors(toolbar_fixture, material_icon_action, dummy_widget):
"""Test updating the color of MaterialIconAction icons."""
toolbar = toolbar_fixture
# Add MaterialIconAction
toolbar.add_action("material_icon_action", material_icon_action, dummy_widget)
assert material_icon_action.action is not None
# Initial icon
initial_icon = material_icon_action.action.icon()
# Update color
new_color = "#ff0000" # Red
toolbar.update_material_icon_colors(new_color)
# Updated icon
updated_icon = material_icon_action.action.icon()
# Assuming that the icon changes when color is updated
assert initial_icon != updated_icon
def test_device_selection_action(toolbar_fixture, device_selection_action, dummy_widget):
"""Test adding a DeviceSelectionAction to the toolbar."""
toolbar = toolbar_fixture
toolbar.add_action("device_selection", device_selection_action, dummy_widget)
assert "device_selection" in toolbar.widgets
# DeviceSelectionAction adds a QWidget, so it should be present in the toolbar's widgets
# Check if the widget is added
widget = device_selection_action.device_combobox.parentWidget()
assert widget in toolbar.findChildren(QWidget)
# Verify that the label is correct
label = widget.findChild(QLabel)
assert label.text() == "Select Device:"
def test_widget_action(toolbar_fixture, widget_action, dummy_widget):
"""Test adding a WidgetAction to the toolbar."""
toolbar = toolbar_fixture
toolbar.add_action("widget_action", widget_action, dummy_widget)
assert "widget_action" in toolbar.widgets
# WidgetAction adds a QWidget to the toolbar
container = widget_action.widget.parentWidget()
assert container in toolbar.findChildren(QWidget)
# Verify the label if present
label = container.findChild(QLabel)
assert label.text() == "Sample Label:"
def test_expandable_menu_action(toolbar_fixture, expandable_menu_action, dummy_widget):
"""Test adding an ExpandableMenuAction to the toolbar."""
toolbar = toolbar_fixture
toolbar.add_action("expandable_menu", expandable_menu_action, dummy_widget)
assert "expandable_menu" in toolbar.widgets
# ExpandableMenuAction adds a QToolButton with a QMenu
# Find the QToolButton
tool_buttons = toolbar.findChildren(QToolButton)
assert len(tool_buttons) > 0
button = tool_buttons[-1] # Assuming it's the last one added
menu = button.menu()
assert menu is not None
# Check that menu has the correct actions
for action_id, sub_action in expandable_menu_action.actions.items():
# Check if a sub-action with the correct tooltip exists
matched = False
for menu_action in menu.actions():
if menu_action.toolTip() == sub_action.tooltip:
matched = True
break
assert matched, f"Sub-action with tooltip '{sub_action.tooltip}' not found in menu."
def test_update_material_icon_colors_no_material_actions(toolbar_fixture, dummy_widget):
"""Test updating material icon colors when there are no MaterialIconActions."""
toolbar = toolbar_fixture
# Ensure there are no MaterialIconActions
toolbar.update_material_icon_colors("#00ff00")
def test_hide_action_nonexistent(toolbar_fixture):
"""Test hiding an action that does not exist raises a ValueError."""
toolbar = toolbar_fixture
with pytest.raises(ValueError) as excinfo:
toolbar.hide_action("nonexistent_action")
assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value)
def test_show_action_nonexistent(toolbar_fixture):
"""Test showing an action that does not exist raises a ValueError."""
toolbar = 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)

View File

@@ -1,64 +0,0 @@
import pyqtgraph as pg
import pytest
from bec_widgets.qt_utils.round_frame import RoundedFrame
def cleanup_pyqtgraph(plot_widget):
item = plot_widget.getPlotItem()
item.vb.menu.close()
item.vb.menu.deleteLater()
item.ctrlMenu.close()
item.ctrlMenu.deleteLater()
@pytest.fixture
def basic_rounded_frame(qtbot):
frame = RoundedFrame()
qtbot.addWidget(frame)
qtbot.waitExposed(frame)
yield frame
@pytest.fixture
def plot_rounded_frame(qtbot):
plot_widget = pg.PlotWidget()
plot_widget.plot([0, 1, 2], [2, 1, 0])
frame = RoundedFrame(content_widget=plot_widget, theme_update=True)
qtbot.addWidget(frame)
qtbot.waitExposed(frame)
yield frame
cleanup_pyqtgraph(plot_widget)
def test_basic_rounded_frame_initialization(basic_rounded_frame):
assert basic_rounded_frame.radius == 10
assert basic_rounded_frame.content_widget is None
assert basic_rounded_frame.background_color is None
assert basic_rounded_frame.theme_update is True
def test_set_radius(basic_rounded_frame):
basic_rounded_frame.radius = 20
assert basic_rounded_frame.radius == 20
def test_apply_theme_light(plot_rounded_frame):
plot_rounded_frame.apply_theme("light")
assert plot_rounded_frame.background_color == "#e9ecef"
def test_apply_theme_dark(plot_rounded_frame):
plot_rounded_frame.apply_theme("dark")
assert plot_rounded_frame.background_color == "#141414"
def test_apply_plot_widget_style(plot_rounded_frame):
# Verify that a PlotWidget can have its style applied
plot_rounded_frame.apply_plot_widget_style(border="1px solid red")
# Ensure style application did not break anything
assert plot_rounded_frame.content_widget is not None
assert isinstance(plot_rounded_frame.content_widget, pg.PlotWidget)

View File

@@ -1,366 +0,0 @@
from typing import Literal
import pytest
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
from bec_widgets.qt_utils.side_panel import SidePanel
@pytest.fixture(params=["left", "right", "top", "bottom"])
def side_panel_fixture(qtbot, request):
"""
Parametrized fixture to create SidePanel with different orientations.
Yields:
tuple: (SidePanel instance, orientation string)
"""
orientation: Literal["left", "right", "top", "bottom"] = request.param
panel = SidePanel(orientation=orientation)
qtbot.addWidget(panel)
qtbot.waitExposed(panel)
yield panel, orientation
@pytest.fixture
def menu_widget(qtbot):
"""Fixture to create a simple widget to add to the SidePanel."""
widget = QWidget()
layout = QVBoxLayout(widget)
label = QLabel("Test Widget")
layout.addWidget(label)
widget.setLayout(layout)
return widget
def test_initialization(side_panel_fixture):
"""Test that SidePanel initializes correctly with different orientations."""
panel, orientation = side_panel_fixture
assert panel._orientation == orientation
assert panel.panel_max_width == 200
assert panel.animation_duration == 200
assert panel.animations_enabled is True
assert panel.panel_visible is False
assert panel.current_action is None
assert panel.current_index is None
assert panel.switching_actions is False
if orientation in ("left", "right"):
assert panel.toolbar.orientation() == Qt.Vertical
assert isinstance(panel.main_layout, QHBoxLayout)
else:
assert panel.toolbar.orientation() == Qt.Horizontal
assert isinstance(panel.main_layout, QVBoxLayout)
def test_set_panel_max_width(side_panel_fixture, qtbot):
"""Test setting the panel_max_width property."""
panel, orientation = side_panel_fixture
new_max_width = 300
panel.panel_max_width = new_max_width
qtbot.wait(100)
assert panel.panel_max_width == new_max_width
if orientation in ("left", "right"):
assert panel.stack_widget.maximumWidth() == new_max_width
else:
assert panel.stack_widget.maximumHeight() == new_max_width
def test_set_animation_duration(side_panel_fixture, qtbot):
"""Test setting the animationDuration property."""
panel, _ = side_panel_fixture
new_duration = 500
panel.animation_duration = new_duration
qtbot.wait(100)
assert panel.animation_duration == new_duration
assert panel.menu_anim.duration() == new_duration
def test_set_animations_enabled(side_panel_fixture, qtbot):
"""Test setting the animationsEnabled property."""
panel, _ = side_panel_fixture
panel.animationsEnabled = False
qtbot.wait(100)
assert panel.animationsEnabled is False
panel.animationsEnabled = True
qtbot.wait(100)
assert panel.animationsEnabled is True
def test_show_hide_panel_with_animation(side_panel_fixture, qtbot):
"""Test showing and hiding the panel with animations enabled."""
panel, orientation = side_panel_fixture
panel.animationsEnabled = True
# Show panel
panel.show_panel(0)
qtbot.wait(panel.animation_duration + 100) # Wait for animation to complete
final_size = panel.panel_max_width
if orientation in ("left", "right"):
assert panel.panel_width == final_size
else:
assert panel.panel_height == final_size
assert panel.panel_visible is True
# Hide panel
panel.hide_panel()
qtbot.wait(panel.animation_duration + 100) # Wait for animation to complete
if orientation in ("left", "right"):
assert panel.panel_width == 0
else:
assert panel.panel_height == 0
assert panel.panel_visible is False
def test_add_menu(side_panel_fixture, menu_widget, qtbot):
"""Test adding a menu to the SidePanel."""
panel, _ = side_panel_fixture
initial_count = panel.stack_widget.count()
panel.add_menu(
action_id="test_action",
icon_name="counter_1",
tooltip="Test Tooltip",
widget=menu_widget,
title="Test Panel",
)
qtbot.wait(100)
assert panel.stack_widget.count() == initial_count + 1
# Verify the action is added to the toolbar
action = panel.toolbar.widgets.get("test_action")
assert action is not None
assert action.tooltip == "Test Tooltip"
assert action.action in panel.toolbar.actions()
def test_toggle_action_show_panel(side_panel_fixture, menu_widget, qtbot):
"""Test that toggling an action shows the corresponding panel."""
panel, _ = side_panel_fixture
panel.add_menu(
action_id="toggle_action",
icon_name="counter_1",
tooltip="Toggle Tooltip",
widget=menu_widget,
title="Toggle Panel",
)
qtbot.wait(100)
action = panel.toolbar.widgets.get("toggle_action")
assert action is not None
# Initially, panel should be hidden
assert panel.panel_visible is False
# Toggle the action to show the panel
action.action.trigger()
qtbot.wait(panel.animation_duration + 100)
assert panel.panel_visible is True
assert panel.current_action == action.action
assert panel.current_index == panel.stack_widget.count() - 1
# Toggle the action again to hide the panel
action.action.trigger()
qtbot.wait(panel.animation_duration + 100)
assert panel.panel_visible is False
assert panel.current_action is None
assert panel.current_index is None
def test_switch_actions(side_panel_fixture, menu_widget, qtbot):
"""Test switching between multiple actions and panels."""
panel, _ = side_panel_fixture
# Add two menus
panel.add_menu(
action_id="action1",
icon_name="counter_1",
tooltip="Tooltip1",
widget=menu_widget,
title="Panel 1",
)
panel.add_menu(
action_id="action2",
icon_name="counter_2",
tooltip="Tooltip2",
widget=menu_widget,
title="Panel 2",
)
qtbot.wait(100)
action1 = panel.toolbar.widgets.get("action1")
action2 = panel.toolbar.widgets.get("action2")
assert action1 is not None
assert action2 is not None
# Activate first action
action1.action.trigger()
qtbot.wait(panel.animation_duration + 100)
assert panel.panel_visible is True
assert panel.current_action == action1.action
assert panel.current_index == 0
# Activate second action
action2.action.trigger()
qtbot.wait(panel.animation_duration + 100)
assert panel.panel_visible is True
assert panel.current_action == action2.action
assert panel.current_index == 1
# Deactivate second action
action2.action.trigger()
qtbot.wait(panel.animation_duration + 100)
assert panel.panel_visible is False
assert panel.current_action is None
assert panel.current_index is None
def test_multiple_add_menu(side_panel_fixture, menu_widget, qtbot):
"""Test adding multiple menus and ensure they are all added correctly."""
panel, _ = side_panel_fixture
initial_count = panel.stack_widget.count()
for i in range(3):
panel.add_menu(
action_id=f"action{i}",
icon_name=f"counter_{i}",
tooltip=f"Tooltip{i}",
widget=menu_widget,
title=f"Panel {i}",
)
qtbot.wait(100)
assert panel.stack_widget.count() == initial_count + i + 1
action = panel.toolbar.widgets.get(f"action{i}")
assert action is not None
assert action.tooltip == f"Tooltip{i}"
assert action.action in panel.toolbar.actions()
def test_switch_to_method(side_panel_fixture, menu_widget, qtbot):
"""Test the switch_to method to change panels without animation."""
panel, _ = side_panel_fixture
# Add two menus
panel.add_menu(
action_id="action1",
icon_name="counter_1",
tooltip="Tooltip1",
widget=menu_widget,
title="Panel 1",
)
panel.add_menu(
action_id="action2",
icon_name="counter_2",
tooltip="Tooltip2",
widget=menu_widget,
title="Panel 2",
)
qtbot.wait(100)
# Show first panel
panel.show_panel(0)
qtbot.wait(panel.animation_duration + 100)
assert panel.current_index == 0
# Switch to second panel
panel.switch_to(1)
qtbot.wait(100)
assert panel.current_index == 1
def test_animation_enabled_parametrization(qtbot):
"""Test SidePanel with animations enabled and disabled."""
for animations_enabled in [True, False]:
panel = SidePanel(animations_enabled=animations_enabled)
qtbot.addWidget(panel)
qtbot.waitExposed(panel)
assert panel.animations_enabled == animations_enabled
panel.close()
def test_orientation_layouts(qtbot):
"""Test that the layouts are correctly set based on orientation."""
orientations = {
"left": ("horizontal", Qt.Vertical),
"right": ("horizontal", Qt.Vertical),
"top": ("vertical", Qt.Horizontal),
"bottom": ("vertical", Qt.Horizontal),
}
for orientation, (main_layout_dir, toolbar_orientation) in orientations.items():
panel = SidePanel(orientation=orientation)
qtbot.addWidget(panel)
qtbot.waitExposed(panel)
# Verify main layout direction
if main_layout_dir == "horizontal":
assert isinstance(panel.main_layout, QHBoxLayout)
else:
assert isinstance(panel.main_layout, QVBoxLayout)
# Verify toolbar orientation
bar_orientation = panel.toolbar.orientation()
assert bar_orientation == toolbar_orientation
panel.close()
def test_panel_width_height_properties(side_panel_fixture, qtbot):
"""Test that setting panel_width and panel_height works correctly."""
panel, orientation = side_panel_fixture
if orientation in ("left", "right"):
panel.panel_width = 150
qtbot.wait(100)
assert panel.panel_width == 150
assert panel.stack_widget.width() == 150
else:
panel.panel_height = 150
qtbot.wait(100)
assert panel.panel_height == 150
assert panel.stack_widget.height() == 150
def test_no_panel_initially(side_panel_fixture, qtbot):
"""Test that the panel is initially hidden."""
panel, orientation = side_panel_fixture
if orientation in ("left", "right"):
assert panel.panel_width == 0
else:
assert panel.panel_height == 0
assert panel.panel_visible is False
def test_add_multiple_menus(side_panel_fixture, menu_widget, qtbot):
"""Test adding multiple menus and ensure they are all added correctly."""
panel, _ = side_panel_fixture
initial_count = panel.stack_widget.count()
for i in range(3):
panel.add_menu(
action_id=f"action{i}",
icon_name=f"counter_{i}",
tooltip=f"Tooltip{i}",
widget=menu_widget,
title=f"Panel {i}",
)
qtbot.wait(100)
assert panel.stack_widget.count() == initial_count + i + 1
action = panel.toolbar.widgets.get(f"action{i}")
assert action is not None
assert action.tooltip == f"Tooltip{i}"
assert action.action in panel.toolbar.actions()

View File

@@ -0,0 +1,119 @@
import inspect
from unittest import mock
import pytest
from qtpy.QtWidgets import QLabel
from bec_widgets.widgets.editors.user_script.user_script import UserScriptWidget
from .client_mocks import mocked_client
def dummy_script():
pass
def dummy_script_with_args(arg1: str, arg2: int = 0):
pass
@pytest.fixture
def SCRIPTS(tmp_path):
"""Create dummy script files"""
home_script = f"{tmp_path}/dummy_path_home_scripts/home_testing.py"
bec_script = f"{tmp_path}/dummy_path_bec_lib_scripts/bec_testing.py"
rtr = {
"dummy_script": {"cls": dummy_script, "fname": home_script},
"dummy_script_with_args": {"cls": dummy_script_with_args, "fname": bec_script},
}
return rtr
@pytest.fixture
def user_script_widget(SCRIPTS, qtbot, mocked_client):
mocked_client._scripts = SCRIPTS
files = {
"USER": [SCRIPTS["dummy_script"]["fname"]],
"BEC": [SCRIPTS["dummy_script_with_args"]["fname"]],
}
mock_console = mock.MagicMock()
mock_vscode = mock.MagicMock()
with mock.patch(
"bec_widgets.widgets.editors.user_script.user_script.UserScriptWidget.get_script_files",
return_value=files,
):
with mock.patch("bec_widgets.widgets.editors.user_script.user_script.VSCodeDialog.init_ui"):
widget = UserScriptWidget(
client=mocked_client, vs_code_editor=mock_vscode, bec_console=mock_console
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_user_script_widget_start_up(SCRIPTS, user_script_widget):
"""Test init the user_script widget with dummy scripts from above"""
assert user_script_widget.tree_widget.columnCount() == 2
assert len(user_script_widget.tree_widget.children()[0].children()) == 6
assert user_script_widget.user_scripts["home_testing"].location == "USER"
assert user_script_widget.user_scripts["home_testing"].module_name == "home_testing"
assert user_script_widget.user_scripts["home_testing"].fname == SCRIPTS["dummy_script"]["fname"]
assert user_script_widget.user_scripts["home_testing"].user_script_name == dummy_script.__name__
assert user_script_widget.user_scripts["bec_testing"].location == "BEC"
assert user_script_widget.user_scripts["bec_testing"].module_name == "bec_testing"
assert (
user_script_widget.user_scripts["bec_testing"].fname
== SCRIPTS["dummy_script_with_args"]["fname"]
)
assert (
user_script_widget.user_scripts["bec_testing"].user_script_name
== dummy_script_with_args.__name__
)
for label in user_script_widget.tree_widget.children()[0].findChildren(QLabel):
assert label.text() in [
"home_testing",
"bec_testing",
"dummy_script",
"dummy_script_with_args",
]
def test_handle_open_script(SCRIPTS, user_script_widget):
"""Test handling open script"""
with mock.patch.object(user_script_widget, "open_script") as mock_open_script:
user_script_widget.handle_edit_button_clicked("home_testing")
fp = SCRIPTS["dummy_script"]["fname"]
mock_open_script.assert_called_once_with(fp)
def test_open_script(user_script_widget):
"""Test opening script"""
assert user_script_widget._code_dialog is None
# Override the _vscode_ed
with mock.patch.object(user_script_widget._vscode_editor, "show") as mock_show:
with mock.patch.object(user_script_widget._vscode_editor, "open_file") as mock_open_file:
with mock.patch.object(user_script_widget._vscode_editor, "zen_mode") as mock_zen_mode:
user_script_widget.open_script("/dummy_path_home_scripts/home_testing.py")
mock_show.assert_called_once()
mock_open_file.assert_called_once_with("/dummy_path_home_scripts/home_testing.py")
mock_zen_mode.assert_called_once()
assert user_script_widget._code_dialog is not None
def test_play_button(user_script_widget):
"""Test play button"""
with mock.patch.object(user_script_widget, "_console") as mock_console:
with mock.patch.object(user_script_widget, "_handle_call_with_args") as mock_handle_call:
# Test first with no args
user_script_widget.handle_play_button_clicked("dummy_script")
mock_console.execute_command.caller_args == [
mock.call("bec.load_all_user_scripts()"),
mock.call("dummy_script()"),
]
assert user_script_widget._script_dialog is None
# Test with args
user_script_widget.handle_play_button_clicked("dummy_script_with_args")
caller_args = inspect.getfullargspec(dummy_script_with_args)
assert mock_handle_call.call_args == mock.call("dummy_script_with_args", caller_args)