mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-10 18:50:55 +02:00
Compare commits
12 Commits
feat/user_
...
v1.10.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b467b29f77 | ||
| 17a63e3b63 | |||
|
|
66fc5306d6 | ||
| 6563abfddc | |||
|
|
0d470ddf05 | ||
| 9b95b5d616 | |||
| c7d7c6d9ed | |||
|
|
4686a643f5 | ||
| 9370351abb | |||
| a55134c3bf | |||
| 5fdb2325ae | |||
| 6a36ca512d |
98
CHANGELOG.md
98
CHANGELOG.md
@@ -1,6 +1,54 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v1.10.0 (2024-12-10)
|
||||
|
||||
### Features
|
||||
|
||||
- **layout_manager**: Grid layout manager widget
|
||||
([`17a63e3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/17a63e3b639ecf6b41c379717d81339b04ef10f8))
|
||||
|
||||
|
||||
## v1.9.1 (2024-12-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **designer**: General way to find python lib on linux
|
||||
([`6563abf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6563abfddc9fc9baba6769022d6925545decdba9))
|
||||
|
||||
|
||||
## 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
|
||||
@@ -153,9 +201,6 @@ Depending on the test, auto-updates are enabled or not.
|
||||
- **crosshair**: Label of coordinates of TextItem is updated according to the current theme of qapp
|
||||
([`4f31ea6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4f31ea655cf6190e141e6a2720a2d6da517a2b5b))
|
||||
|
||||
- **crosshair**: Log is separately scaled for backend logic and for signal emit
|
||||
([`b2eb71a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b2eb71aae0b6a7c82158f2d150ae1e31411cfdeb))
|
||||
|
||||
### Features
|
||||
|
||||
- **crosshair**: Textitem to display crosshair coordinates
|
||||
@@ -165,50 +210,3 @@ Depending on the test, auto-updates are enabled or not.
|
||||
|
||||
- **crosshair**: Tests extended
|
||||
([`64df805`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/64df805a9ed92bb97e580ac3bc0a1bbd2b1cb81e))
|
||||
|
||||
|
||||
## v1.3.3 (2024-11-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scan_control**: Devicelineedit kwargs readings changed to get name of the positioner
|
||||
([`5fabd4b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5fabd4bea95bafd2352102686357cc1db80813fd))
|
||||
|
||||
### Documentation
|
||||
|
||||
- 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))
|
||||
|
||||
@@ -42,7 +42,6 @@ class Widgets(str, enum.Enum):
|
||||
SignalLineEdit = "SignalLineEdit"
|
||||
StopButton = "StopButton"
|
||||
TextBox = "TextBox"
|
||||
UserScriptWidget = "UserScriptWidget"
|
||||
VSCodeEditor = "VSCodeEditor"
|
||||
WebsiteWidget = "WebsiteWidget"
|
||||
|
||||
@@ -3689,9 +3688,6 @@ class TextBox(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class UserScriptWidget(RPCBase): ...
|
||||
|
||||
|
||||
class VSCodeEditor(RPCBase): ...
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
@@ -17,6 +18,7 @@ from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
from bec_widgets.widgets.containers.figure import BECFigure
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
|
||||
|
||||
@@ -50,11 +52,16 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"d1": self.d1,
|
||||
"d2": self.d2,
|
||||
"wave": self.wf,
|
||||
# "bar": self.bar,
|
||||
# "cm": self.colormap,
|
||||
"im": self.im,
|
||||
"mm": self.mm,
|
||||
"mw": self.mw,
|
||||
"lm": self.lm,
|
||||
"btn1": self.btn1,
|
||||
"btn2": self.btn2,
|
||||
"btn3": self.btn3,
|
||||
"btn4": self.btn4,
|
||||
"btn5": self.btn5,
|
||||
"btn6": self.btn6,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -79,11 +86,25 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
second_tab_layout.addWidget(self.figure)
|
||||
tab_widget.addTab(second_tab, "BEC Figure")
|
||||
|
||||
third_tab = QWidget()
|
||||
third_tab_layout = QVBoxLayout(third_tab)
|
||||
self.lm = LayoutManagerWidget()
|
||||
third_tab_layout.addWidget(self.lm)
|
||||
tab_widget.addTab(third_tab, "Layout Manager Widget")
|
||||
|
||||
group_box = QGroupBox("Jupyter Console", splitter)
|
||||
group_box_layout = QVBoxLayout(group_box)
|
||||
self.console = BECJupyterConsole(inprocess=True)
|
||||
group_box_layout.addWidget(self.console)
|
||||
|
||||
# Some buttons for layout testing
|
||||
self.btn1 = QPushButton("Button 1")
|
||||
self.btn2 = QPushButton("Button 2")
|
||||
self.btn3 = QPushButton("Button 3")
|
||||
self.btn4 = QPushButton("Button 4")
|
||||
self.btn5 = QPushButton("Button 5")
|
||||
self.btn6 = QPushButton("Button 6")
|
||||
|
||||
# add stuff to figure
|
||||
self._init_figure()
|
||||
|
||||
@@ -93,15 +114,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
self.setWindowTitle("Jupyter Console Window")
|
||||
|
||||
def _init_figure(self):
|
||||
self.w1 = self.figure.plot(
|
||||
x_name="samx",
|
||||
y_name="bpm4i",
|
||||
# title="Standard Plot with sync device, custom labels - w1",
|
||||
# x_label="Motor Position",
|
||||
# y_label="Intensity (A.U.)",
|
||||
row=0,
|
||||
col=0,
|
||||
)
|
||||
self.w1 = self.figure.plot(x_name="samx", y_name="bpm4i", row=0, col=0)
|
||||
self.w1.set(
|
||||
title="Standard Plot with sync device, custom labels - w1",
|
||||
x_label="Motor Position",
|
||||
@@ -169,14 +182,6 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
self.wf = self.d2.add_widget("BECFigure", row=0, col=0)
|
||||
|
||||
self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
|
||||
# self.wf.plot(x_name="samx", y_name="bpm3a")
|
||||
# self.wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
# self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
|
||||
# self.bar.set_diameter(200)
|
||||
|
||||
# self.d3 = self.dock.add_dock(name="dock_3", position="bottom")
|
||||
# self.colormap = pg.GradientWidget()
|
||||
# self.d3.add_widget(self.colormap, row=0, col=0)
|
||||
|
||||
self.dock.save_state()
|
||||
|
||||
|
||||
177
bec_widgets/qt_utils/round_frame.py
Normal file
177
bec_widgets/qt_utils/round_frame.py
Normal file
@@ -0,0 +1,177 @@
|
||||
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()
|
||||
386
bec_widgets/qt_utils/side_panel.py
Normal file
386
bec_widgets/qt_utils/side_panel.py
Normal file
@@ -0,0 +1,386 @@
|
||||
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())
|
||||
@@ -261,17 +261,31 @@ 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 (list[ToolBarAction], optional): A list of action creators to populate the toolbar. Defaults to None.
|
||||
actions (dict, optional): A dictionary 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):
|
||||
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)",
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
self.widgets = defaultdict(dict)
|
||||
self.set_background_color()
|
||||
self.background_color = background_color
|
||||
self.set_background_color(self.background_color)
|
||||
|
||||
# Set the initial orientation
|
||||
self.set_orientation(orientation)
|
||||
|
||||
if actions is not None and target_widget is not None:
|
||||
self.populate_toolbar(actions, target_widget)
|
||||
@@ -280,7 +294,7 @@ class ModularToolBar(QToolBar):
|
||||
"""Populates the toolbar with a set of actions.
|
||||
|
||||
Args:
|
||||
actions (list[ToolBarAction]): A list of action creators to populate the toolbar.
|
||||
actions (dict): A dictionary of action creators to populate the toolbar.
|
||||
target_widget (QWidget): The widget that the actions will target.
|
||||
"""
|
||||
self.clear()
|
||||
@@ -288,9 +302,83 @@ class ModularToolBar(QToolBar):
|
||||
action.add_to_toolbar(self, target_widget)
|
||||
self.widgets[action_id] = action
|
||||
|
||||
def set_background_color(self):
|
||||
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.
|
||||
"""
|
||||
self.setIconSize(QSize(20, 20))
|
||||
self.setMovable(False)
|
||||
self.setFloatable(False)
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self.setStyleSheet("QToolBar { background-color: rgba(0, 0, 0, 0); border: none; }")
|
||||
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)
|
||||
|
||||
@@ -93,17 +93,24 @@ def patch_designer(): # pragma: no cover
|
||||
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
|
||||
else:
|
||||
if sys.platform == "linux":
|
||||
suffix = f"{sys.abiflags}.so"
|
||||
env_var = "LD_PRELOAD"
|
||||
current_pid = os.getpid()
|
||||
with open(f"/proc/{current_pid}/maps", "rt") as f:
|
||||
for line in f:
|
||||
if "libpython" in line:
|
||||
lib_path = line.split()[-1]
|
||||
os.environ[env_var] = lib_path
|
||||
break
|
||||
|
||||
elif sys.platform == "darwin":
|
||||
suffix = ".dylib"
|
||||
env_var = "DYLD_INSERT_LIBRARIES"
|
||||
version = f"{major_version}.{minor_version}"
|
||||
library_name = f"libpython{version}{suffix}"
|
||||
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
|
||||
os.environ[env_var] = lib_path
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {sys.platform}")
|
||||
version = f"{major_version}.{minor_version}"
|
||||
library_name = f"libpython{version}{suffix}"
|
||||
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
|
||||
os.environ[env_var] = lib_path
|
||||
|
||||
if is_pyenv_python() or is_virtual_env():
|
||||
# append all editable packages to the PYTHONPATH
|
||||
|
||||
881
bec_widgets/widgets/containers/layout_manager/layout_manager.py
Normal file
881
bec_widgets/widgets/containers/layout_manager/layout_manager.py
Normal file
@@ -0,0 +1,881 @@
|
||||
import math
|
||||
import sys
|
||||
from typing import Dict, Literal, Optional, Set, Tuple, Union
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from typeguard import typechecked
|
||||
|
||||
from bec_widgets.cli.rpc_wigdet_handler import widget_handler
|
||||
|
||||
|
||||
class LayoutManagerWidget(QWidget):
|
||||
"""
|
||||
A robust layout manager that extends QGridLayout functionality, allowing
|
||||
users to add/remove widgets, access widgets by coordinates, shift widgets,
|
||||
and change the layout dynamically with automatic reindexing to keep the grid compact.
|
||||
|
||||
Supports adding widgets via QWidget instances or string identifiers referencing the widget handler.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, auto_reindex=True):
|
||||
super().__init__(parent)
|
||||
self.layout = QGridLayout(self)
|
||||
self.auto_reindex = auto_reindex
|
||||
|
||||
# Mapping from widget to its position (row, col, rowspan, colspan)
|
||||
self.widget_positions: Dict[QWidget, Tuple[int, int, int, int]] = {}
|
||||
|
||||
# Mapping from (row, col) to widget
|
||||
self.position_widgets: Dict[Tuple[int, int], QWidget] = {}
|
||||
|
||||
# Keep track of the current position for automatic placement
|
||||
self.current_row = 0
|
||||
self.current_col = 0
|
||||
|
||||
def add_widget(
|
||||
self,
|
||||
widget: QWidget | str,
|
||||
row: int | None = None,
|
||||
col: Optional[int] = None,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
shift_existing: bool = True,
|
||||
shift_direction: Literal["down", "up", "left", "right"] = "right",
|
||||
) -> QWidget:
|
||||
"""
|
||||
Add a widget to the grid with enhanced shifting capabilities.
|
||||
|
||||
Args:
|
||||
widget (QWidget | str): The widget to add. If str, it is used to create a widget via widget_handler.
|
||||
row (int, optional): The row to add the widget to. If None, the next available row is used.
|
||||
col (int, optional): The column to add the widget to. If None, the next available column is used.
|
||||
rowspan (int): Number of rows the widget spans. Default is 1.
|
||||
colspan (int): Number of columns the widget spans. Default is 1.
|
||||
shift_existing (bool): Whether to shift existing widgets if the target position is occupied. Default is True.
|
||||
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets. Default is "right".
|
||||
|
||||
Returns:
|
||||
QWidget: The widget that was added.
|
||||
"""
|
||||
# Handle widget creation if a BECWidget string identifier is provided
|
||||
if isinstance(widget, str):
|
||||
widget = widget_handler.create_widget(widget)
|
||||
|
||||
if row is None:
|
||||
row = self.current_row
|
||||
if col is None:
|
||||
col = self.current_col
|
||||
|
||||
if (row, col) in self.position_widgets:
|
||||
if shift_existing:
|
||||
# Attempt to shift the existing widget in the specified direction
|
||||
self.shift_widgets(direction=shift_direction, start_row=row, start_col=col)
|
||||
else:
|
||||
raise ValueError(f"Position ({row}, {col}) is already occupied.")
|
||||
|
||||
# Add the widget to the layout
|
||||
self.layout.addWidget(widget, row, col, rowspan, colspan)
|
||||
self.widget_positions[widget] = (row, col, rowspan, colspan)
|
||||
self.position_widgets[(row, col)] = widget
|
||||
|
||||
# Update current position for automatic placement
|
||||
self.current_col = col + colspan
|
||||
self.current_row = max(self.current_row, row)
|
||||
|
||||
if self.auto_reindex:
|
||||
self.reindex_grid()
|
||||
|
||||
return widget
|
||||
|
||||
def add_widget_relative(
|
||||
self,
|
||||
widget: QWidget | str,
|
||||
reference_widget: QWidget,
|
||||
position: Literal["left", "right", "top", "bottom"],
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
shift_existing: bool = True,
|
||||
shift_direction: Literal["down", "up", "left", "right"] = "right",
|
||||
) -> QWidget:
|
||||
"""
|
||||
Add a widget relative to an existing widget.
|
||||
|
||||
Args:
|
||||
widget (QWidget | str): The widget to add. If str, it is used to create a widget via widget_handler.
|
||||
reference_widget (QWidget): The widget relative to which the new widget will be placed.
|
||||
position (Literal["left", "right", "top", "bottom"]): Position relative to the reference widget.
|
||||
rowspan (int): Number of rows the widget spans. Default is 1.
|
||||
colspan (int): Number of columns the widget spans. Default is 1.
|
||||
shift_existing (bool): Whether to shift existing widgets if the target position is occupied.
|
||||
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
|
||||
|
||||
Returns:
|
||||
QWidget: The widget that was added.
|
||||
|
||||
Raises:
|
||||
ValueError: If the reference widget is not found.
|
||||
"""
|
||||
if reference_widget not in self.widget_positions:
|
||||
raise ValueError("Reference widget not found in layout.")
|
||||
|
||||
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
|
||||
|
||||
# Determine new widget position based on the specified relative position
|
||||
if position == "left":
|
||||
new_row = ref_row
|
||||
new_col = ref_col - 1
|
||||
elif position == "right":
|
||||
new_row = ref_row
|
||||
new_col = ref_col + ref_colspan
|
||||
elif position == "top":
|
||||
new_row = ref_row - 1
|
||||
new_col = ref_col
|
||||
elif position == "bottom":
|
||||
new_row = ref_row + ref_rowspan
|
||||
new_col = ref_col
|
||||
else:
|
||||
raise ValueError("Invalid position. Choose from 'left', 'right', 'top', 'bottom'.")
|
||||
|
||||
# Add the widget at the calculated position
|
||||
return self.add_widget(
|
||||
widget=widget,
|
||||
row=new_row,
|
||||
col=new_col,
|
||||
rowspan=rowspan,
|
||||
colspan=colspan,
|
||||
shift_existing=shift_existing,
|
||||
shift_direction=shift_direction,
|
||||
)
|
||||
|
||||
def move_widget_by_coords(
|
||||
self,
|
||||
current_row: int,
|
||||
current_col: int,
|
||||
new_row: int,
|
||||
new_col: int,
|
||||
shift: bool = True,
|
||||
shift_direction: Literal["down", "up", "left", "right"] = "right",
|
||||
) -> None:
|
||||
"""
|
||||
Move a widget from (current_row, current_col) to (new_row, new_col).
|
||||
|
||||
Args:
|
||||
current_row (int): Current row of the widget.
|
||||
current_col (int): Current column of the widget.
|
||||
new_row (int): Target row.
|
||||
new_col (int): Target column.
|
||||
shift (bool): Whether to shift existing widgets if the target position is occupied.
|
||||
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
|
||||
|
||||
Raises:
|
||||
ValueError: If the widget is not found or target position is invalid.
|
||||
"""
|
||||
self.move_widget(
|
||||
old_row=current_row,
|
||||
old_col=current_col,
|
||||
new_row=new_row,
|
||||
new_col=new_col,
|
||||
shift=shift,
|
||||
shift_direction=shift_direction,
|
||||
)
|
||||
|
||||
@typechecked
|
||||
def move_widget_by_object(
|
||||
self,
|
||||
widget: QWidget,
|
||||
new_row: int,
|
||||
new_col: int,
|
||||
shift: bool = True,
|
||||
shift_direction: Literal["down", "up", "left", "right"] = "right",
|
||||
) -> None:
|
||||
"""
|
||||
Move a widget to a new position using the widget object.
|
||||
|
||||
Args:
|
||||
widget (QWidget): The widget to move.
|
||||
new_row (int): Target row.
|
||||
new_col (int): Target column.
|
||||
shift (bool): Whether to shift existing widgets if the target position is occupied.
|
||||
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
|
||||
|
||||
Raises:
|
||||
ValueError: If the widget is not found or target position is invalid.
|
||||
"""
|
||||
if widget not in self.widget_positions:
|
||||
raise ValueError("Widget not found in layout.")
|
||||
|
||||
old_position = self.widget_positions[widget]
|
||||
old_row, old_col = old_position[0], old_position[1]
|
||||
|
||||
self.move_widget(
|
||||
old_row=old_row,
|
||||
old_col=old_col,
|
||||
new_row=new_row,
|
||||
new_col=new_col,
|
||||
shift=shift,
|
||||
shift_direction=shift_direction,
|
||||
)
|
||||
|
||||
@typechecked
|
||||
def move_widget(
|
||||
self,
|
||||
old_row: int | None = None,
|
||||
old_col: int | None = None,
|
||||
new_row: int | None = None,
|
||||
new_col: int | None = None,
|
||||
shift: bool = True,
|
||||
shift_direction: Literal["down", "up", "left", "right"] = "right",
|
||||
) -> None:
|
||||
"""
|
||||
Move a widget to a new position. If the new position is occupied and shift is True,
|
||||
shift the existing widget to the specified direction.
|
||||
|
||||
Args:
|
||||
old_row (int, optional): The current row of the widget.
|
||||
old_col (int, optional): The current column of the widget.
|
||||
new_row (int, optional): The target row to move the widget to.
|
||||
new_col (int, optional): The target column to move the widget to.
|
||||
shift (bool): Whether to shift existing widgets if the target position is occupied.
|
||||
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
|
||||
|
||||
Raises:
|
||||
ValueError: If the widget is not found or target position is invalid.
|
||||
"""
|
||||
if new_row is None or new_col is None:
|
||||
raise ValueError("Must provide both new_row and new_col to move a widget.")
|
||||
|
||||
if old_row is None and old_col is None:
|
||||
raise ValueError(f"No widget found at position ({old_row}, {old_col}).")
|
||||
widget = self.get_widget(old_row, old_col)
|
||||
|
||||
if (new_row, new_col) in self.position_widgets:
|
||||
if not shift:
|
||||
raise ValueError(f"Position ({new_row}, {new_col}) is already occupied.")
|
||||
# Shift the existing widget to make space
|
||||
self.shift_widgets(
|
||||
direction=shift_direction,
|
||||
start_row=new_row if shift_direction in ["down", "up"] else 0,
|
||||
start_col=new_col if shift_direction in ["left", "right"] else 0,
|
||||
)
|
||||
|
||||
# Proceed to move the widget
|
||||
self.layout.removeWidget(widget)
|
||||
old_position = self.widget_positions.pop(widget)
|
||||
self.position_widgets.pop((old_position[0], old_position[1]))
|
||||
|
||||
self.layout.addWidget(widget, new_row, new_col, old_position[2], old_position[3])
|
||||
self.widget_positions[widget] = (new_row, new_col, old_position[2], old_position[3])
|
||||
self.position_widgets[(new_row, new_col)] = widget
|
||||
|
||||
# Update current_row and current_col for automatic placement if needed
|
||||
self.current_row = max(self.current_row, new_row)
|
||||
self.current_col = max(self.current_col, new_col + old_position[3])
|
||||
|
||||
if self.auto_reindex:
|
||||
self.reindex_grid()
|
||||
|
||||
@typechecked
|
||||
def shift_widgets(
|
||||
self,
|
||||
direction: Literal["down", "up", "left", "right"],
|
||||
start_row: int = 0,
|
||||
start_col: int = 0,
|
||||
) -> None:
|
||||
"""
|
||||
Shift widgets in the grid in the specified direction starting from the given position.
|
||||
|
||||
Args:
|
||||
direction (Literal["down", "up", "left", "right"]): Direction to shift widgets.
|
||||
start_row (int): Starting row index.
|
||||
start_col (int): Starting column index.
|
||||
|
||||
Raises:
|
||||
ValueError: If shifting causes widgets to go out of grid boundaries.
|
||||
"""
|
||||
shifts = []
|
||||
positions_to_shift = [(start_row, start_col)]
|
||||
visited_positions = set()
|
||||
|
||||
while positions_to_shift:
|
||||
row, col = positions_to_shift.pop(0)
|
||||
if (row, col) in visited_positions:
|
||||
continue
|
||||
visited_positions.add((row, col))
|
||||
|
||||
widget = self.position_widgets.get((row, col))
|
||||
if widget is None:
|
||||
continue # No widget at this position
|
||||
|
||||
# Compute new position based on the direction
|
||||
if direction == "down":
|
||||
new_row = row + 1
|
||||
new_col = col
|
||||
elif direction == "up":
|
||||
new_row = row - 1
|
||||
new_col = col
|
||||
elif direction == "right":
|
||||
new_row = row
|
||||
new_col = col + 1
|
||||
elif direction == "left":
|
||||
new_row = row
|
||||
new_col = col - 1
|
||||
|
||||
# Check for negative indices
|
||||
if new_row < 0 or new_col < 0:
|
||||
raise ValueError("Shifting widgets out of grid boundaries.")
|
||||
|
||||
# If the new position is occupied, add it to the positions to shift
|
||||
if (new_row, new_col) in self.position_widgets:
|
||||
positions_to_shift.append((new_row, new_col))
|
||||
|
||||
shifts.append(
|
||||
(widget, (row, col), (new_row, new_col), self.widget_positions[widget][2:])
|
||||
)
|
||||
|
||||
# Remove all widgets from their old positions
|
||||
for widget, (old_row, old_col), _, _ in shifts:
|
||||
self.layout.removeWidget(widget)
|
||||
self.position_widgets.pop((old_row, old_col))
|
||||
|
||||
# Add widgets to their new positions
|
||||
for widget, _, (new_row, new_col), (rowspan, colspan) in shifts:
|
||||
self.layout.addWidget(widget, new_row, new_col, rowspan, colspan)
|
||||
self.widget_positions[widget] = (new_row, new_col, rowspan, colspan)
|
||||
self.position_widgets[(new_row, new_col)] = widget
|
||||
|
||||
# Update current_row and current_col if needed
|
||||
self.current_row = max(self.current_row, new_row)
|
||||
self.current_col = max(self.current_col, new_col + colspan)
|
||||
|
||||
def shift_all_widgets(self, direction: Literal["down", "up", "left", "right"]) -> None:
|
||||
"""
|
||||
Shift all widgets in the grid in the specified direction to make room and prevent negative indices.
|
||||
|
||||
Args:
|
||||
direction (Literal["down", "up", "left", "right"]): Direction to shift all widgets.
|
||||
"""
|
||||
# First, collect all the shifts to perform
|
||||
shifts = []
|
||||
for widget, (row, col, rowspan, colspan) in self.widget_positions.items():
|
||||
|
||||
if direction == "down":
|
||||
new_row = row + 1
|
||||
new_col = col
|
||||
elif direction == "up":
|
||||
new_row = row - 1
|
||||
new_col = col
|
||||
elif direction == "right":
|
||||
new_row = row
|
||||
new_col = col + 1
|
||||
elif direction == "left":
|
||||
new_row = row
|
||||
new_col = col - 1
|
||||
|
||||
# Check for negative indices
|
||||
if new_row < 0 or new_col < 0:
|
||||
raise ValueError("Shifting widgets out of grid boundaries.")
|
||||
|
||||
shifts.append((widget, (row, col), (new_row, new_col), (rowspan, colspan)))
|
||||
|
||||
# Now perform the shifts
|
||||
for widget, (old_row, old_col), (new_row, new_col), (rowspan, colspan) in shifts:
|
||||
self.layout.removeWidget(widget)
|
||||
self.position_widgets.pop((old_row, old_col))
|
||||
|
||||
for widget, (old_row, old_col), (new_row, new_col), (rowspan, colspan) in shifts:
|
||||
self.layout.addWidget(widget, new_row, new_col, rowspan, colspan)
|
||||
self.widget_positions[widget] = (new_row, new_col, rowspan, colspan)
|
||||
self.position_widgets[(new_row, new_col)] = widget
|
||||
|
||||
# Update current_row and current_col based on new widget positions
|
||||
self.current_row = max((pos[0] for pos in self.position_widgets.keys()), default=0)
|
||||
self.current_col = max((pos[1] for pos in self.position_widgets.keys()), default=0)
|
||||
|
||||
def remove(
|
||||
self,
|
||||
row: Optional[int] = None,
|
||||
col: Optional[int] = None,
|
||||
coordinates: Optional[Tuple[int, int]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Remove a widget from the layout. Can be removed by widget ID or by coordinates.
|
||||
|
||||
Args:
|
||||
row (int, optional): The row coordinate of the widget to remove.
|
||||
col (int, optional): The column coordinate of the widget to remove.
|
||||
coordinates (tuple[int, int], optional): The (row, col) coordinates of the widget to remove.
|
||||
|
||||
Raises:
|
||||
ValueError: If the widget to remove is not found.
|
||||
"""
|
||||
if coordinates:
|
||||
row, col = coordinates
|
||||
widget = self.get_widget(row, col)
|
||||
if widget is None:
|
||||
raise ValueError(f"No widget found at coordinates {coordinates}.")
|
||||
elif row is not None and col is not None:
|
||||
widget = self.get_widget(row, col)
|
||||
if widget is None:
|
||||
raise ValueError(f"No widget found at position ({row}, {col}).")
|
||||
else:
|
||||
raise ValueError(
|
||||
"Must provide either widget_id, coordinates, or both row and col for removal."
|
||||
)
|
||||
|
||||
self.remove_widget(widget)
|
||||
|
||||
def remove_widget(self, widget: QWidget) -> None:
|
||||
"""
|
||||
Remove a widget from the grid and reindex the grid to keep it compact.
|
||||
|
||||
Args:
|
||||
widget (QWidget): The widget to remove.
|
||||
|
||||
Raises:
|
||||
ValueError: If the widget is not found in the layout.
|
||||
"""
|
||||
if widget not in self.widget_positions:
|
||||
raise ValueError("Widget not found in layout.")
|
||||
|
||||
position = self.widget_positions.pop(widget)
|
||||
self.position_widgets.pop((position[0], position[1]))
|
||||
self.layout.removeWidget(widget)
|
||||
widget.setParent(None) # Remove widget from the parent
|
||||
widget.deleteLater()
|
||||
|
||||
# Reindex the grid to maintain compactness
|
||||
if self.auto_reindex:
|
||||
self.reindex_grid()
|
||||
|
||||
def get_widget(self, row: int, col: int) -> QWidget | None:
|
||||
"""
|
||||
Get the widget at the specified position.
|
||||
|
||||
Args:
|
||||
row (int): The row coordinate.
|
||||
col (int): The column coordinate.
|
||||
|
||||
Returns:
|
||||
QWidget | None: The widget at the specified position, or None if empty.
|
||||
"""
|
||||
return self.position_widgets.get((row, col))
|
||||
|
||||
def get_widget_position(self, widget: QWidget) -> Tuple[int, int, int, int] | None:
|
||||
"""
|
||||
Get the position of the specified widget.
|
||||
|
||||
Args:
|
||||
widget (QWidget): The widget to query.
|
||||
|
||||
Returns:
|
||||
Tuple[int, int, int, int] | None: The (row, col, rowspan, colspan) tuple, or None if not found.
|
||||
"""
|
||||
return self.widget_positions.get(widget)
|
||||
|
||||
def change_layout(self, num_rows: int | None = None, num_cols: int | None = None) -> None:
|
||||
"""
|
||||
Change the layout to have a certain number of rows and/or columns,
|
||||
rearranging the widgets accordingly.
|
||||
|
||||
If only one of num_rows or num_cols is provided, the other is calculated automatically
|
||||
based on the number of widgets and the provided constraint.
|
||||
|
||||
If both are provided, num_rows is calculated based on num_cols.
|
||||
|
||||
Args:
|
||||
num_rows (int | None): The new maximum number of rows.
|
||||
num_cols (int | None): The new maximum number of columns.
|
||||
"""
|
||||
if num_rows is None and num_cols is None:
|
||||
return # Nothing to change
|
||||
|
||||
total_widgets = len(self.widget_positions)
|
||||
|
||||
if num_cols is not None:
|
||||
# Calculate num_rows based on num_cols
|
||||
num_rows = math.ceil(total_widgets / num_cols)
|
||||
elif num_rows is not None:
|
||||
# Calculate num_cols based on num_rows
|
||||
num_cols = math.ceil(total_widgets / num_rows)
|
||||
|
||||
# Sort widgets by current position (row-major order)
|
||||
widgets_sorted = sorted(
|
||||
self.widget_positions.items(),
|
||||
key=lambda item: (item[1][0], item[1][1]), # Sort by row, then column
|
||||
)
|
||||
|
||||
# Clear the layout without deleting widgets
|
||||
for widget, _ in widgets_sorted:
|
||||
self.layout.removeWidget(widget)
|
||||
|
||||
# Reset position mappings
|
||||
self.widget_positions.clear()
|
||||
self.position_widgets.clear()
|
||||
|
||||
# Re-add widgets based on new layout constraints
|
||||
current_row, current_col = 0, 0
|
||||
for widget, _ in widgets_sorted:
|
||||
if current_col >= num_cols:
|
||||
current_col = 0
|
||||
current_row += 1
|
||||
self.layout.addWidget(widget, current_row, current_col, 1, 1)
|
||||
self.widget_positions[widget] = (current_row, current_col, 1, 1)
|
||||
self.position_widgets[(current_row, current_col)] = widget
|
||||
current_col += 1
|
||||
|
||||
# Update current_row and current_col for automatic placement
|
||||
self.current_row = current_row
|
||||
self.current_col = current_col
|
||||
|
||||
# Reindex the grid to ensure compactness
|
||||
self.reindex_grid()
|
||||
|
||||
def clear_layout(self) -> None:
|
||||
"""
|
||||
Remove all widgets from the layout without deleting them.
|
||||
"""
|
||||
for widget in list(self.widget_positions):
|
||||
self.layout.removeWidget(widget)
|
||||
self.position_widgets.pop(
|
||||
(self.widget_positions[widget][0], self.widget_positions[widget][1])
|
||||
)
|
||||
self.widget_positions.pop(widget)
|
||||
widget.setParent(None) # Optionally hide/remove the widget
|
||||
|
||||
self.current_row = 0
|
||||
self.current_col = 0
|
||||
|
||||
def reindex_grid(self) -> None:
|
||||
"""
|
||||
Reindex the grid to remove empty rows and columns, ensuring that
|
||||
widget coordinates are contiguous and start from (0, 0).
|
||||
"""
|
||||
# Step 1: Collect all occupied positions
|
||||
occupied_positions = sorted(self.position_widgets.keys())
|
||||
|
||||
if not occupied_positions:
|
||||
# No widgets to reindex
|
||||
self.clear_layout()
|
||||
return
|
||||
|
||||
# Step 2: Determine the new mapping by eliminating empty columns and rows
|
||||
# Find unique rows and columns
|
||||
unique_rows = sorted(set(pos[0] for pos in occupied_positions))
|
||||
unique_cols = sorted(set(pos[1] for pos in occupied_positions))
|
||||
|
||||
# Create mappings from old to new indices
|
||||
row_mapping = {old_row: new_row for new_row, old_row in enumerate(unique_rows)}
|
||||
col_mapping = {old_col: new_col for new_col, old_col in enumerate(unique_cols)}
|
||||
|
||||
# Step 3: Collect widgets with their new positions
|
||||
widgets_with_new_positions = []
|
||||
for widget, (row, col, rowspan, colspan) in self.widget_positions.items():
|
||||
new_row = row_mapping[row]
|
||||
new_col = col_mapping[col]
|
||||
widgets_with_new_positions.append((widget, new_row, new_col, rowspan, colspan))
|
||||
|
||||
# Step 4: Clear the layout and reset mappings
|
||||
self.clear_layout()
|
||||
|
||||
# Reset current_row and current_col
|
||||
self.current_row = 0
|
||||
self.current_col = 0
|
||||
|
||||
# Step 5: Re-add widgets with new positions
|
||||
for widget, new_row, new_col, rowspan, colspan in widgets_with_new_positions:
|
||||
self.layout.addWidget(widget, new_row, new_col, rowspan, colspan)
|
||||
self.widget_positions[widget] = (new_row, new_col, rowspan, colspan)
|
||||
self.position_widgets[(new_row, new_col)] = widget
|
||||
|
||||
# Update current position for automatic placement
|
||||
self.current_col = max(self.current_col, new_col + colspan)
|
||||
self.current_row = max(self.current_row, new_row)
|
||||
|
||||
def get_widgets_positions(self) -> Dict[QWidget, Tuple[int, int, int, int]]:
|
||||
"""
|
||||
Get the positions of all widgets in the layout.
|
||||
|
||||
Returns:
|
||||
Dict[QWidget, Tuple[int, int, int, int]]: Mapping of widgets to their (row, col, rowspan, colspan).
|
||||
"""
|
||||
return self.widget_positions.copy()
|
||||
|
||||
def print_all_button_text(self):
|
||||
"""Debug function to print the text of all QPushButton widgets."""
|
||||
print("Coordinates - Button Text")
|
||||
for coord, widget in self.position_widgets.items():
|
||||
if isinstance(widget, QPushButton):
|
||||
print(f"{coord} - {widget.text()}")
|
||||
|
||||
|
||||
####################################################################################################
|
||||
# The following code is for the GUI control panel to interact with the LayoutManagerWidget.
|
||||
# It is not covered by any tests as it serves only as an example for the LayoutManagerWidget class.
|
||||
####################################################################################################
|
||||
|
||||
|
||||
class ControlPanel(QWidget): # pragma: no cover
|
||||
def __init__(self, layout_manager: LayoutManagerWidget):
|
||||
super().__init__()
|
||||
self.layout_manager = layout_manager
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
main_layout = QVBoxLayout()
|
||||
|
||||
# Add Widget by Coordinates
|
||||
add_coord_group = QGroupBox("Add Widget by Coordinates")
|
||||
add_coord_layout = QGridLayout()
|
||||
|
||||
add_coord_layout.addWidget(QLabel("Text:"), 0, 0)
|
||||
self.text_input = QLineEdit()
|
||||
add_coord_layout.addWidget(self.text_input, 0, 1)
|
||||
|
||||
add_coord_layout.addWidget(QLabel("Row:"), 1, 0)
|
||||
self.row_input = QSpinBox()
|
||||
self.row_input.setMinimum(0)
|
||||
add_coord_layout.addWidget(self.row_input, 1, 1)
|
||||
|
||||
add_coord_layout.addWidget(QLabel("Column:"), 2, 0)
|
||||
self.col_input = QSpinBox()
|
||||
self.col_input.setMinimum(0)
|
||||
add_coord_layout.addWidget(self.col_input, 2, 1)
|
||||
|
||||
self.add_button = QPushButton("Add at Coordinates")
|
||||
self.add_button.clicked.connect(self.add_at_coordinates)
|
||||
add_coord_layout.addWidget(self.add_button, 3, 0, 1, 2)
|
||||
|
||||
add_coord_group.setLayout(add_coord_layout)
|
||||
main_layout.addWidget(add_coord_group)
|
||||
|
||||
# Add Widget Relative
|
||||
add_rel_group = QGroupBox("Add Widget Relative to Existing")
|
||||
add_rel_layout = QGridLayout()
|
||||
|
||||
add_rel_layout.addWidget(QLabel("Text:"), 0, 0)
|
||||
self.rel_text_input = QLineEdit()
|
||||
add_rel_layout.addWidget(self.rel_text_input, 0, 1)
|
||||
|
||||
add_rel_layout.addWidget(QLabel("Reference Widget:"), 1, 0)
|
||||
self.ref_widget_combo = QComboBox()
|
||||
add_rel_layout.addWidget(self.ref_widget_combo, 1, 1)
|
||||
|
||||
add_rel_layout.addWidget(QLabel("Position:"), 2, 0)
|
||||
self.position_combo = QComboBox()
|
||||
self.position_combo.addItems(["left", "right", "top", "bottom"])
|
||||
add_rel_layout.addWidget(self.position_combo, 2, 1)
|
||||
|
||||
self.add_rel_button = QPushButton("Add Relative")
|
||||
self.add_rel_button.clicked.connect(self.add_relative)
|
||||
add_rel_layout.addWidget(self.add_rel_button, 3, 0, 1, 2)
|
||||
|
||||
add_rel_group.setLayout(add_rel_layout)
|
||||
main_layout.addWidget(add_rel_group)
|
||||
|
||||
# Remove Widget
|
||||
remove_group = QGroupBox("Remove Widget")
|
||||
remove_layout = QGridLayout()
|
||||
|
||||
remove_layout.addWidget(QLabel("Row:"), 0, 0)
|
||||
self.remove_row_input = QSpinBox()
|
||||
self.remove_row_input.setMinimum(0)
|
||||
remove_layout.addWidget(self.remove_row_input, 0, 1)
|
||||
|
||||
remove_layout.addWidget(QLabel("Column:"), 1, 0)
|
||||
self.remove_col_input = QSpinBox()
|
||||
self.remove_col_input.setMinimum(0)
|
||||
remove_layout.addWidget(self.remove_col_input, 1, 1)
|
||||
|
||||
self.remove_button = QPushButton("Remove at Coordinates")
|
||||
self.remove_button.clicked.connect(self.remove_widget)
|
||||
remove_layout.addWidget(self.remove_button, 2, 0, 1, 2)
|
||||
|
||||
remove_group.setLayout(remove_layout)
|
||||
main_layout.addWidget(remove_group)
|
||||
|
||||
# Change Layout
|
||||
change_layout_group = QGroupBox("Change Layout")
|
||||
change_layout_layout = QGridLayout()
|
||||
|
||||
change_layout_layout.addWidget(QLabel("Number of Rows:"), 0, 0)
|
||||
self.change_rows_input = QSpinBox()
|
||||
self.change_rows_input.setMinimum(1)
|
||||
self.change_rows_input.setValue(1) # Default value
|
||||
change_layout_layout.addWidget(self.change_rows_input, 0, 1)
|
||||
|
||||
change_layout_layout.addWidget(QLabel("Number of Columns:"), 1, 0)
|
||||
self.change_cols_input = QSpinBox()
|
||||
self.change_cols_input.setMinimum(1)
|
||||
self.change_cols_input.setValue(1) # Default value
|
||||
change_layout_layout.addWidget(self.change_cols_input, 1, 1)
|
||||
|
||||
self.change_layout_button = QPushButton("Apply Layout Change")
|
||||
self.change_layout_button.clicked.connect(self.change_layout)
|
||||
change_layout_layout.addWidget(self.change_layout_button, 2, 0, 1, 2)
|
||||
|
||||
change_layout_group.setLayout(change_layout_layout)
|
||||
main_layout.addWidget(change_layout_group)
|
||||
|
||||
# Remove All Widgets
|
||||
self.clear_all_button = QPushButton("Clear All Widgets")
|
||||
self.clear_all_button.clicked.connect(self.clear_all_widgets)
|
||||
main_layout.addWidget(self.clear_all_button)
|
||||
|
||||
# Refresh Reference Widgets and Print Button
|
||||
self.refresh_button = QPushButton("Refresh Reference Widgets")
|
||||
self.refresh_button.clicked.connect(self.refresh_references)
|
||||
self.print_button = QPushButton("Print All Button Text")
|
||||
self.print_button.clicked.connect(self.layout_manager.print_all_button_text)
|
||||
main_layout.addWidget(self.refresh_button)
|
||||
main_layout.addWidget(self.print_button)
|
||||
|
||||
main_layout.addStretch()
|
||||
self.setLayout(main_layout)
|
||||
self.refresh_references()
|
||||
|
||||
def refresh_references(self):
|
||||
self.ref_widget_combo.clear()
|
||||
widgets = self.layout_manager.get_widgets_positions()
|
||||
for widget in widgets:
|
||||
if isinstance(widget, QPushButton):
|
||||
self.ref_widget_combo.addItem(widget.text(), widget)
|
||||
|
||||
def add_at_coordinates(self):
|
||||
text = self.text_input.text()
|
||||
row = self.row_input.value()
|
||||
col = self.col_input.value()
|
||||
|
||||
if not text:
|
||||
QMessageBox.warning(self, "Input Error", "Please enter text for the button.")
|
||||
return
|
||||
|
||||
button = QPushButton(text)
|
||||
try:
|
||||
self.layout_manager.add_widget(widget=button, row=row, col=col)
|
||||
self.refresh_references()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
def add_relative(self):
|
||||
text = self.rel_text_input.text()
|
||||
ref_index = self.ref_widget_combo.currentIndex()
|
||||
ref_widget = self.ref_widget_combo.itemData(ref_index)
|
||||
position = self.position_combo.currentText()
|
||||
|
||||
if not text:
|
||||
QMessageBox.warning(self, "Input Error", "Please enter text for the button.")
|
||||
return
|
||||
|
||||
if ref_widget is None:
|
||||
QMessageBox.warning(self, "Input Error", "Please select a reference widget.")
|
||||
return
|
||||
|
||||
button = QPushButton(text)
|
||||
try:
|
||||
self.layout_manager.add_widget_relative(
|
||||
widget=button, reference_widget=ref_widget, position=position
|
||||
)
|
||||
self.refresh_references()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
def remove_widget(self):
|
||||
row = self.remove_row_input.value()
|
||||
col = self.remove_col_input.value()
|
||||
|
||||
try:
|
||||
widget = self.layout_manager.get_widget(row, col)
|
||||
if widget is None:
|
||||
QMessageBox.warning(self, "Not Found", f"No widget found at ({row}, {col}).")
|
||||
return
|
||||
self.layout_manager.remove_widget(widget)
|
||||
self.refresh_references()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
def change_layout(self):
|
||||
num_rows = self.change_rows_input.value()
|
||||
num_cols = self.change_cols_input.value()
|
||||
|
||||
try:
|
||||
self.layout_manager.change_layout(num_rows=num_rows, num_cols=num_cols)
|
||||
self.refresh_references()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
def clear_all_widgets(self):
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirm Clear",
|
||||
"Are you sure you want to remove all widgets?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
try:
|
||||
self.layout_manager.clear_layout()
|
||||
self.refresh_references()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", str(e))
|
||||
|
||||
|
||||
class MainWindow(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Layout Manager Demo")
|
||||
self.resize(800, 600)
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
central_widget = QWidget()
|
||||
main_layout = QHBoxLayout()
|
||||
|
||||
# Layout Area GroupBox
|
||||
layout_group = QGroupBox("Layout Area")
|
||||
layout_group.setMinimumSize(400, 400)
|
||||
layout_layout = QVBoxLayout()
|
||||
|
||||
self.layout_manager = LayoutManagerWidget()
|
||||
layout_layout.addWidget(self.layout_manager)
|
||||
|
||||
layout_group.setLayout(layout_layout)
|
||||
|
||||
# Splitter
|
||||
splitter = QSplitter()
|
||||
splitter.addWidget(layout_group)
|
||||
|
||||
# Control Panel
|
||||
control_panel = ControlPanel(self.layout_manager)
|
||||
control_group = QGroupBox("Control Panel")
|
||||
control_layout = QVBoxLayout()
|
||||
control_layout.addWidget(control_panel)
|
||||
control_layout.addStretch()
|
||||
control_group.setLayout(control_layout)
|
||||
splitter.addWidget(control_group)
|
||||
|
||||
main_layout.addWidget(splitter)
|
||||
central_widget.setLayout(main_layout)
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -16,7 +16,6 @@ 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
|
||||
@@ -28,8 +27,6 @@ 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",
|
||||
@@ -364,24 +361,6 @@ 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)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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()
|
||||
@@ -1,569 +0,0 @@
|
||||
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_()
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['user_script.py']}
|
||||
@@ -1,54 +0,0 @@
|
||||
# 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()
|
||||
@@ -124,41 +124,16 @@ 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:
|
||||
|
||||
@@ -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 = "check_circle"
|
||||
RUNNING = "done_outline"
|
||||
BUSY = "progress_activity"
|
||||
IDLE = "progress_activity"
|
||||
ERROR = "emergency_home"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
@@ -1,35 +0,0 @@
|
||||
(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
|
||||
```
|
||||
````
|
||||
@@ -134,15 +134,6 @@ 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
|
||||
@@ -247,7 +238,6 @@ Display DAP summaries of LMFit models in a window.
|
||||
|
||||
Select DAP model from a list of DAP processes.
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
```{toctree}
|
||||
@@ -280,6 +270,5 @@ 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
|
||||
|
||||
```
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "1.7.0"
|
||||
version = "1.10.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
|
||||
BIN
tests/unit_tests/assets/BEC-Icon.png
Normal file
BIN
tests/unit_tests/assets/BEC-Icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
368
tests/unit_tests/test_layout_manager.py
Normal file
368
tests/unit_tests/test_layout_manager.py
Normal file
@@ -0,0 +1,368 @@
|
||||
from typing import Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from qtpy.QtWidgets import QLabel, QPushButton, QWidget
|
||||
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
|
||||
|
||||
class MockWidgetHandler:
|
||||
def create_widget(self, widget_type: str) -> Optional[QWidget]:
|
||||
if widget_type == "ButtonWidget":
|
||||
return QPushButton()
|
||||
elif widget_type == "LabelWidget":
|
||||
return QLabel()
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_widget_handler():
|
||||
handler = MockWidgetHandler()
|
||||
with patch(
|
||||
"bec_widgets.widgets.containers.layout_manager.layout_manager.widget_handler", handler
|
||||
):
|
||||
yield handler
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def layout_manager(qtbot, mock_widget_handler):
|
||||
widget = LayoutManagerWidget()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
def test_add_widget_empty_position(layout_manager):
|
||||
"""Test adding a widget to an empty position without shifting."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
layout_manager.add_widget(btn1, row=0, col=0)
|
||||
|
||||
assert layout_manager.get_widget(0, 0) == btn1
|
||||
assert layout_manager.widget_positions[btn1] == (0, 0, 1, 1)
|
||||
assert layout_manager.position_widgets[(0, 0)] == btn1
|
||||
|
||||
|
||||
def test_add_widget_occupied_position(layout_manager):
|
||||
"""Test adding a widget to an occupied position with shifting (default direction right)."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
layout_manager.add_widget(btn1, row=0, col=0)
|
||||
layout_manager.add_widget(btn2, row=0, col=0) # This should shift btn1 to the right
|
||||
|
||||
assert layout_manager.get_widget(0, 0) == btn2
|
||||
assert layout_manager.get_widget(0, 1) == btn1
|
||||
assert layout_manager.widget_positions[btn2] == (0, 0, 1, 1)
|
||||
assert layout_manager.widget_positions[btn1] == (0, 1, 1, 1)
|
||||
|
||||
|
||||
def test_add_widget_directional_shift_down(layout_manager):
|
||||
"""Test adding a widget to an occupied position but shifting down instead of right."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
btn3 = QPushButton("Button 3")
|
||||
layout_manager.add_widget(btn1, row=0, col=0)
|
||||
layout_manager.add_widget(btn2, row=0, col=0) # Shifts btn1 to the right by default
|
||||
|
||||
# Now add btn3 at (0,1) but shift direction is down, so it should push btn1 down.
|
||||
layout_manager.add_widget(btn3, row=0, col=1, shift_direction="down")
|
||||
|
||||
assert layout_manager.get_widget(0, 0) == btn2
|
||||
assert layout_manager.get_widget(0, 1) == btn3
|
||||
assert layout_manager.get_widget(1, 1) == btn1
|
||||
|
||||
|
||||
def test_remove_widget_by_position(layout_manager):
|
||||
"""Test removing a widget by specifying its row and column."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
layout_manager.add_widget(btn1, row=0, col=0)
|
||||
|
||||
layout_manager.remove(row=0, col=0)
|
||||
|
||||
assert layout_manager.get_widget(0, 0) is None
|
||||
assert btn1 not in layout_manager.widget_positions
|
||||
|
||||
|
||||
def test_move_widget_with_shift(layout_manager):
|
||||
"""Test moving a widget to an occupied position, triggering a shift."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
btn3 = QPushButton("Button 3")
|
||||
|
||||
layout_manager.add_widget(btn1, row=0, col=0)
|
||||
layout_manager.add_widget(btn2, row=0, col=1)
|
||||
layout_manager.add_widget(btn3, row=1, col=0)
|
||||
|
||||
layout_manager.move_widget(old_row=0, old_col=0, new_row=0, new_col=1, shift_direction="right")
|
||||
|
||||
assert layout_manager.get_widget(0, 1) == btn1
|
||||
assert layout_manager.get_widget(0, 2) == btn2
|
||||
assert layout_manager.get_widget(1, 0) == btn3
|
||||
|
||||
|
||||
def test_move_widget_without_shift(layout_manager):
|
||||
"""Test moving a widget to an occupied position without shifting."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
|
||||
layout_manager.add_widget(btn1, row=0, col=0)
|
||||
layout_manager.add_widget(btn2, row=0, col=1)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
layout_manager.move_widget(old_row=0, old_col=0, new_row=0, new_col=1, shift=False)
|
||||
|
||||
assert "Position (0, 1) is already occupied." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_change_layout_num_cols(layout_manager):
|
||||
"""Test changing the layout by specifying only the number of columns."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
btn3 = QPushButton("Button 3")
|
||||
btn4 = QPushButton("Button 4")
|
||||
|
||||
layout_manager.add_widget(btn1)
|
||||
layout_manager.add_widget(btn2)
|
||||
layout_manager.add_widget(btn3)
|
||||
layout_manager.add_widget(btn4)
|
||||
|
||||
layout_manager.change_layout(num_cols=2)
|
||||
|
||||
assert layout_manager.get_widget(0, 0) == btn1
|
||||
assert layout_manager.get_widget(0, 1) == btn2
|
||||
assert layout_manager.get_widget(1, 0) == btn3
|
||||
assert layout_manager.get_widget(1, 1) == btn4
|
||||
|
||||
|
||||
def test_change_layout_num_rows(layout_manager):
|
||||
"""Test changing the layout by specifying only the number of rows."""
|
||||
btn_list = [QPushButton(f"Button {i}") for i in range(1, 7)]
|
||||
for btn in btn_list:
|
||||
layout_manager.add_widget(btn)
|
||||
|
||||
layout_manager.change_layout(num_rows=3)
|
||||
|
||||
assert layout_manager.get_widget(0, 0) == btn_list[0]
|
||||
assert layout_manager.get_widget(0, 1) == btn_list[1]
|
||||
assert layout_manager.get_widget(1, 0) == btn_list[2]
|
||||
assert layout_manager.get_widget(1, 1) == btn_list[3]
|
||||
assert layout_manager.get_widget(2, 0) == btn_list[4]
|
||||
assert layout_manager.get_widget(2, 1) == btn_list[5]
|
||||
|
||||
|
||||
def test_shift_all_widgets(layout_manager):
|
||||
"""Test shifting all widgets down and then up."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
|
||||
layout_manager.add_widget(btn1, row=0, col=0)
|
||||
layout_manager.add_widget(btn2, row=0, col=1)
|
||||
|
||||
# Shift all down
|
||||
layout_manager.shift_all_widgets(direction="down")
|
||||
|
||||
assert layout_manager.get_widget(1, 0) == btn1
|
||||
assert layout_manager.get_widget(1, 1) == btn2
|
||||
|
||||
# Shift all up
|
||||
layout_manager.shift_all_widgets(direction="up")
|
||||
|
||||
assert layout_manager.get_widget(0, 0) == btn1
|
||||
assert layout_manager.get_widget(0, 1) == btn2
|
||||
|
||||
|
||||
def test_add_widget_auto_position(layout_manager):
|
||||
"""Test adding widgets without specifying row and column."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
|
||||
layout_manager.add_widget(btn1)
|
||||
layout_manager.add_widget(btn2)
|
||||
|
||||
assert layout_manager.get_widget(0, 0) == btn1
|
||||
assert layout_manager.get_widget(0, 1) == btn2
|
||||
|
||||
|
||||
def test_clear_layout(layout_manager):
|
||||
"""Test clearing the entire layout."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
layout_manager.add_widget(btn1)
|
||||
layout_manager.add_widget(btn2)
|
||||
|
||||
layout_manager.clear_layout()
|
||||
|
||||
assert layout_manager.get_widget(0, 0) is None
|
||||
assert layout_manager.get_widget(0, 1) is None
|
||||
assert len(layout_manager.widget_positions) == 0
|
||||
|
||||
|
||||
def test_add_widget_with_span(layout_manager):
|
||||
"""Test adding a widget with rowspan and colspan."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
layout_manager.add_widget(btn1, row=0, col=0, rowspan=2, colspan=2)
|
||||
|
||||
assert layout_manager.widget_positions[btn1] == (0, 0, 2, 2)
|
||||
|
||||
|
||||
def test_add_widget_overlap_with_span(layout_manager):
|
||||
"""
|
||||
Test adding a widget that overlaps with an existing widget's span.
|
||||
The code will attempt to shift widgets accordingly.
|
||||
"""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
|
||||
layout_manager.add_widget(btn1, row=0, col=0, rowspan=2, colspan=1)
|
||||
|
||||
layout_manager.add_widget(btn2, row=1, col=1, shift_direction="right")
|
||||
|
||||
assert layout_manager.get_widget(0, 0) == btn1
|
||||
assert layout_manager.widget_positions[btn1] == (0, 0, 2, 1)
|
||||
assert layout_manager.get_widget(1, 1) == btn2
|
||||
assert layout_manager.widget_positions[btn2] == (1, 1, 1, 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"position, btn3_coords",
|
||||
[("left", (1, 0)), ("right", (1, 2)), ("top", (0, 1)), ("bottom", (2, 1))],
|
||||
)
|
||||
def test_add_widget_relative(layout_manager, position, btn3_coords):
|
||||
"""Test adding a widget relative to an existing widget using parameterized data."""
|
||||
expected_row, expected_col = btn3_coords
|
||||
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
btn3 = QPushButton("Button 3")
|
||||
|
||||
layout_manager.add_widget(btn1, row=0, col=0)
|
||||
layout_manager.add_widget(btn2, row=1, col=1)
|
||||
|
||||
layout_manager.add_widget_relative(btn3, reference_widget=btn2, position=position)
|
||||
|
||||
assert layout_manager.get_widget(0, 0) == btn1
|
||||
assert layout_manager.get_widget(1, 1) == btn2
|
||||
assert layout_manager.get_widget(expected_row, expected_col) == btn3
|
||||
|
||||
|
||||
def test_add_widget_relative_invalid_position(layout_manager):
|
||||
"""Test adding a widget relative to an existing widget with an invalid position."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
|
||||
layout_manager.add_widget(btn1, row=1, col=1)
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
layout_manager.add_widget_relative(btn2, reference_widget=btn1, position="invalid_position")
|
||||
|
||||
assert "Invalid position. Choose from 'left', 'right', 'top', 'bottom'." in str(exc_info.value)
|
||||
btn2.deleteLater()
|
||||
|
||||
|
||||
def test_add_widget_relative_to_nonexistent_widget(layout_manager):
|
||||
"""Test adding a widget relative to a widget that does not exist in the layout."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
layout_manager.add_widget_relative(btn2, reference_widget=btn1, position="left")
|
||||
|
||||
assert "Reference widget not found in layout." in str(exc_info.value)
|
||||
btn1.deleteLater()
|
||||
btn2.deleteLater()
|
||||
|
||||
|
||||
def test_add_widget_relative_with_shift(layout_manager):
|
||||
"""Test adding a widget relative to an existing widget with shifting."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
btn3 = QPushButton("Button 3")
|
||||
|
||||
layout_manager.add_widget(btn1, row=1, col=1)
|
||||
layout_manager.add_widget(btn2, row=1, col=0)
|
||||
|
||||
layout_manager.add_widget_relative(
|
||||
btn3, reference_widget=btn1, position="left", shift_direction="right"
|
||||
)
|
||||
|
||||
assert layout_manager.get_widget(0, 0) == btn3
|
||||
assert layout_manager.get_widget(1, 1) == btn2
|
||||
assert layout_manager.get_widget(0, 1) == btn1
|
||||
|
||||
|
||||
def test_move_widget_by_object(layout_manager):
|
||||
"""Test moving a widget using the widget object."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
|
||||
layout_manager.add_widget(btn1)
|
||||
layout_manager.add_widget(btn2, row=0, col=1)
|
||||
|
||||
layout_manager.move_widget_by_object(btn1, new_row=1, new_col=1)
|
||||
|
||||
# the grid is reindex after each move, so the new positions are (0,0) and (1,0), because visually there is only one column
|
||||
assert layout_manager.get_widget(1, 0) == btn1
|
||||
assert layout_manager.get_widget(0, 0) == btn2
|
||||
|
||||
|
||||
def test_move_widget_by_coords(layout_manager):
|
||||
"""Test moving a widget using its current coordinates."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
|
||||
layout_manager.add_widget(btn1)
|
||||
layout_manager.add_widget(btn2, row=0, col=1)
|
||||
|
||||
layout_manager.move_widget_by_coords(0, 0, 1, 0, shift_direction="down")
|
||||
|
||||
assert layout_manager.get_widget(1, 0) == btn1
|
||||
assert layout_manager.get_widget(0, 1) == btn2
|
||||
|
||||
|
||||
def test_change_layout_no_arguments(layout_manager):
|
||||
"""Test changing the layout with no arguments (should do nothing)."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
layout_manager.add_widget(btn1, row=0, col=0)
|
||||
|
||||
layout_manager.change_layout()
|
||||
|
||||
assert layout_manager.get_widget(0, 0) == btn1
|
||||
assert len(layout_manager.widget_positions) == 1
|
||||
|
||||
|
||||
def test_remove_nonexistent_widget(layout_manager):
|
||||
"""Test removing a widget that doesn't exist in the layout."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
layout_manager.remove(row=0, col=0)
|
||||
|
||||
assert "No widget found at position (0, 0)." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_reindex_grid_after_removal(layout_manager):
|
||||
"""Test reindexing the grid after removing a widget."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
layout_manager.add_widget(btn1)
|
||||
layout_manager.add_widget(btn2, row=0, col=1)
|
||||
|
||||
layout_manager.remove_widget(btn1)
|
||||
layout_manager.reindex_grid()
|
||||
|
||||
# After removal and reindex, btn2 should shift to (0,0)
|
||||
assert layout_manager.get_widget(0, 0) == btn2
|
||||
assert layout_manager.widget_positions[btn2] == (0, 0, 1, 1)
|
||||
|
||||
|
||||
def test_shift_all_widgets_up_at_top_row(layout_manager):
|
||||
"""Test shifting all widgets up when they are already at the top row."""
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
|
||||
layout_manager.add_widget(btn1, row=0, col=0)
|
||||
layout_manager.add_widget(btn2, row=0, col=1)
|
||||
|
||||
# Shifting up should cause an error since widgets can't move above row 0
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
layout_manager.shift_all_widgets(direction="up")
|
||||
|
||||
assert "Shifting widgets out of grid boundaries." in str(exc_info.value)
|
||||
279
tests/unit_tests/test_modular_toolbar.py
Normal file
279
tests/unit_tests/test_modular_toolbar.py
Normal file
@@ -0,0 +1,279 @@
|
||||
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)
|
||||
64
tests/unit_tests/test_round_frame.py
Normal file
64
tests/unit_tests/test_round_frame.py
Normal file
@@ -0,0 +1,64 @@
|
||||
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)
|
||||
366
tests/unit_tests/test_side_menu.py
Normal file
366
tests/unit_tests/test_side_menu.py
Normal file
@@ -0,0 +1,366 @@
|
||||
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()
|
||||
@@ -1,119 +0,0 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user