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

Compare commits

...

15 Commits

Author SHA1 Message Date
semantic-release
ce11d1382c 1.11.0
Automatically generated by python-semantic-release
2024-12-11 16:19:34 +00:00
ff654b56ae test(collapsible_panel_manager): fixture changed to not use .show() 2024-12-11 15:24:59 +01:00
a434d3ee57 feat(collapsible_panel_manager): panel manager to handle collapsing and expanding widgets from the main widget added 2024-12-11 15:18:43 +01:00
semantic-release
b467b29f77 1.10.0
Automatically generated by python-semantic-release
2024-12-10 19:59:55 +00:00
17a63e3b63 feat(layout_manager): grid layout manager widget 2024-12-10 20:49:19 +01:00
semantic-release
66fc5306d6 1.9.1
Automatically generated by python-semantic-release
2024-12-10 19:34:00 +00:00
6563abfddc fix(designer): general way to find python lib on linux 2024-12-10 19:12:21 +01:00
semantic-release
0d470ddf05 1.9.0
Automatically generated by python-semantic-release
2024-12-10 10:53:44 +00:00
9b95b5d616 test(side_panel): tests added 2024-12-10 11:42:46 +01:00
c7d7c6d9ed feat(side_menu): side menu with stack widget added 2024-12-10 11:42:46 +01:00
semantic-release
4686a643f5 1.8.0
Automatically generated by python-semantic-release
2024-12-10 10:08:47 +00:00
9370351abb test(modular_toolbar): tests added 2024-12-09 21:10:18 +01:00
a55134c3bf feat(modular_toolbar): material icons can be added/removed/hide/show/update dynamically 2024-12-09 20:56:03 +01:00
5fdb2325ae feat(modular_toolbar): orientation setting 2024-12-09 15:04:59 +01:00
6a36ca512d feat(round_frame): rounded frame for plot widgets and contrast adjustments 2024-12-09 15:01:09 +01:00
17 changed files with 3409 additions and 95 deletions

View File

@@ -1,6 +1,68 @@
# CHANGELOG
## v1.11.0 (2024-12-11)
### Features
- **collapsible_panel_manager**: Panel manager to handle collapsing and expanding widgets from the
main widget added
([`a434d3e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a434d3ee574081356c32c096d2fd61f641e04542))
### Testing
- **collapsible_panel_manager**: Fixture changed to not use .show()
([`ff654b5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ff654b56ae98388a2b707c040d51220be6cbce13))
## 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
@@ -149,66 +211,3 @@ Depending on the test, auto-updates are enabled or not.
- **crosshair**: Label of coordinates of TextItem displays numbers in general format
([`11e5937`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/11e5937ae0f3c1413acd4e66878a692ebe4ef7d0))
- **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
([`035136d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/035136d5171ec5f4311d15a9aa5bad2bdbc1f6cb))
### Testing
- **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))

View File

@@ -19,7 +19,6 @@ class Widgets(str, enum.Enum):
BECColorMapWidget = "BECColorMapWidget"
BECDockArea = "BECDockArea"
BECImageWidget = "BECImageWidget"
BECMainWindow = "BECMainWindow"
BECMotorMapWidget = "BECMotorMapWidget"
BECMultiWaveformWidget = "BECMultiWaveformWidget"
BECProgressBar = "BECProgressBar"
@@ -64,6 +63,13 @@ class AbortButton(RPCBase):
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class BECColorMapWidget(RPCBase):
@property

View File

@@ -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()

View File

@@ -0,0 +1,380 @@
from __future__ import annotations
import sys
from typing import Literal
import pyqtgraph as pg
from qtpy.QtCore import Property, QEasingCurve, QObject, QPropertyAnimation
from qtpy.QtWidgets import (
QApplication,
QHBoxLayout,
QMainWindow,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from typeguard import typechecked
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
class DimensionAnimator(QObject):
"""
Helper class to animate the size of a panel widget.
"""
def __init__(self, panel_widget: QWidget, direction: str):
super().__init__()
self.panel_widget = panel_widget
self.direction = direction
self._size = 0
@Property(int)
def panel_width(self):
"""
Returns the current width of the panel widget.
"""
return self._size
@panel_width.setter
def panel_width(self, val: int):
"""
Set the width of the panel widget.
Args:
val(int): The width to set.
"""
self._size = val
self.panel_widget.setFixedWidth(val)
@Property(int)
def panel_height(self):
"""
Returns the current height of the panel widget.
"""
return self._size
@panel_height.setter
def panel_height(self, val: int):
"""
Set the height of the panel widget.
Args:
val(int): The height to set.
"""
self._size = val
self.panel_widget.setFixedHeight(val)
class CollapsiblePanelManager(QObject):
"""
Manager class to handle collapsible panels from a main widget using LayoutManagerWidget.
"""
def __init__(self, layout_manager: LayoutManagerWidget, reference_widget: QWidget, parent=None):
super().__init__(parent)
self.layout_manager = layout_manager
self.reference_widget = reference_widget
self.animations = {}
self.panels = {}
self.direction_settings = {
"left": {"property": b"maximumWidth", "default_size": 200},
"right": {"property": b"maximumWidth", "default_size": 200},
"top": {"property": b"maximumHeight", "default_size": 150},
"bottom": {"property": b"maximumHeight", "default_size": 150},
}
def add_panel(
self,
direction: Literal["left", "right", "top", "bottom"],
panel_widget: QWidget,
target_size: int | None = None,
duration: int = 300,
):
"""
Add a panel widget to the layout manager.
Args:
direction(Literal["left", "right", "top", "bottom"]): Direction of the panel.
panel_widget(QWidget): The panel widget to add.
target_size(int, optional): The target size of the panel. Defaults to None.
duration(int): The duration of the animation in milliseconds. Defaults to 300.
"""
if direction not in self.direction_settings:
raise ValueError("Direction must be one of 'left', 'right', 'top', 'bottom'.")
if target_size is None:
target_size = self.direction_settings[direction]["default_size"]
self.layout_manager.add_widget_relative(
widget=panel_widget, reference_widget=self.reference_widget, position=direction
)
panel_widget.setVisible(False)
# Set initial constraints as flexible
if direction in ["left", "right"]:
panel_widget.setMaximumWidth(0)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
else:
panel_widget.setMaximumHeight(0)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.panels[direction] = {
"widget": panel_widget,
"direction": direction,
"target_size": target_size,
"duration": duration,
"animator": None,
}
def toggle_panel(
self,
direction: Literal["left", "right", "top", "bottom"],
target_size: int | None = None,
duration: int | None = None,
easing_curve: QEasingCurve = QEasingCurve.InOutQuad,
ensure_max: bool = False,
scale: float | None = None,
animation: bool = True,
):
"""
Toggle the specified panel.
Parameters:
direction (Literal["left", "right", "top", "bottom"]): Direction of the panel to toggle.
target_size (int, optional): Override target size for this toggle.
duration (int, optional): Override the animation duration.
easing_curve (QEasingCurve): Animation easing curve.
ensure_max (bool): If True, animate as a fixed-size panel.
scale (float, optional): If provided, calculate target_size from main widget size.
animation (bool): If False, no animation is performed; panel instantly toggles.
"""
if direction not in self.panels:
raise ValueError(f"No panel found in direction '{direction}'.")
panel_info = self.panels[direction]
panel_widget = panel_info["widget"]
dir_settings = self.direction_settings[direction]
# Determine final target size
if scale is not None:
main_rect = self.reference_widget.geometry()
if direction in ["left", "right"]:
computed_target = int(main_rect.width() * scale)
else:
computed_target = int(main_rect.height() * scale)
final_target_size = computed_target
else:
if target_size is None:
final_target_size = panel_info["target_size"]
else:
final_target_size = target_size
if duration is None:
duration = panel_info["duration"]
expanding_property = dir_settings["property"]
currently_visible = panel_widget.isVisible()
if ensure_max:
if panel_info["animator"] is None:
panel_info["animator"] = DimensionAnimator(panel_widget, direction)
animator = panel_info["animator"]
if direction in ["left", "right"]:
prop_name = b"panel_width"
else:
prop_name = b"panel_height"
else:
animator = None
prop_name = expanding_property
if currently_visible:
# Hide the panel
if ensure_max:
start_value = final_target_size
end_value = 0
finish_callback = lambda w=panel_widget, d=direction: self._after_hide_reset(w, d)
else:
start_value = (
panel_widget.width()
if direction in ["left", "right"]
else panel_widget.height()
)
end_value = 0
finish_callback = lambda w=panel_widget: w.setVisible(False)
else:
# Show the panel
start_value = 0
end_value = final_target_size
finish_callback = None
if ensure_max:
# Fix panel exactly
if direction in ["left", "right"]:
panel_widget.setMinimumWidth(0)
panel_widget.setMaximumWidth(final_target_size)
panel_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
else:
panel_widget.setMinimumHeight(0)
panel_widget.setMaximumHeight(final_target_size)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
else:
# Flexible mode
if direction in ["left", "right"]:
panel_widget.setMinimumWidth(0)
panel_widget.setMaximumWidth(final_target_size)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
else:
panel_widget.setMinimumHeight(0)
panel_widget.setMaximumHeight(final_target_size)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
panel_widget.setVisible(True)
if not animation:
# No animation: instantly set final state
if end_value == 0:
# Hiding
if ensure_max:
# Reset after hide
self._after_hide_reset(panel_widget, direction)
else:
panel_widget.setVisible(False)
else:
# Showing
if ensure_max:
# Already set fixed size
if direction in ["left", "right"]:
panel_widget.setFixedWidth(end_value)
else:
panel_widget.setFixedHeight(end_value)
else:
# Just set maximum dimension
if direction in ["left", "right"]:
panel_widget.setMaximumWidth(end_value)
else:
panel_widget.setMaximumHeight(end_value)
return
# With animation
animation = QPropertyAnimation(animator if ensure_max else panel_widget, prop_name)
animation.setDuration(duration)
animation.setStartValue(start_value)
animation.setEndValue(end_value)
animation.setEasingCurve(easing_curve)
if end_value == 0 and finish_callback:
animation.finished.connect(finish_callback)
elif end_value == 0 and not finish_callback:
animation.finished.connect(lambda w=panel_widget: w.setVisible(False))
animation.start()
self.animations[panel_widget] = animation
@typechecked
def _after_hide_reset(
self, panel_widget: QWidget, direction: Literal["left", "right", "top", "bottom"]
):
"""
Reset the panel widget after hiding it in ensure_max mode.
Args:
panel_widget(QWidget): The panel widget to reset.
direction(Literal["left", "right", "top", "bottom"]): The direction of the panel.
"""
# Called after hiding a panel in ensure_max mode
panel_widget.setVisible(False)
if direction in ["left", "right"]:
panel_widget.setMinimumWidth(0)
panel_widget.setMaximumWidth(0)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
else:
panel_widget.setMinimumHeight(0)
panel_widget.setMaximumHeight(16777215)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
####################################################################################################
# The following code is for the GUI control panel to interact with the CollapsiblePanelManager.
# It is not covered by any tests as it serves only as an example for the CollapsiblePanelManager class.
####################################################################################################
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Panels with ensure_max, scale, and animation toggle")
self.resize(800, 600)
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(10)
# Buttons
buttons_layout = QHBoxLayout()
self.btn_left = QPushButton("Toggle Left (ensure_max=True)")
self.btn_top = QPushButton("Toggle Top (scale=0.5, no animation)")
self.btn_right = QPushButton("Toggle Right (ensure_max=True, scale=0.3)")
self.btn_bottom = QPushButton("Toggle Bottom (no animation)")
buttons_layout.addWidget(self.btn_left)
buttons_layout.addWidget(self.btn_top)
buttons_layout.addWidget(self.btn_right)
buttons_layout.addWidget(self.btn_bottom)
main_layout.addLayout(buttons_layout)
self.layout_manager = LayoutManagerWidget()
main_layout.addWidget(self.layout_manager)
# Main widget
self.main_plot = pg.PlotWidget()
self.main_plot.plot([1, 2, 3, 4], [4, 3, 2, 1])
self.layout_manager.add_widget(self.main_plot, 0, 0)
self.panel_manager = CollapsiblePanelManager(self.layout_manager, self.main_plot)
# Panels
self.left_panel = pg.PlotWidget()
self.left_panel.plot([1, 2, 3], [3, 2, 1])
self.panel_manager.add_panel("left", self.left_panel, target_size=200)
self.right_panel = pg.PlotWidget()
self.right_panel.plot([10, 20, 30], [1, 10, 1])
self.panel_manager.add_panel("right", self.right_panel, target_size=200)
self.top_panel = pg.PlotWidget()
self.top_panel.plot([1, 2, 3], [1, 2, 3])
self.panel_manager.add_panel("top", self.top_panel, target_size=150)
self.bottom_panel = pg.PlotWidget()
self.bottom_panel.plot([2, 4, 6], [10, 5, 10])
self.panel_manager.add_panel("bottom", self.bottom_panel, target_size=150)
# Connect buttons
# Left with ensure_max
self.btn_left.clicked.connect(
lambda: self.panel_manager.toggle_panel("left", ensure_max=True)
)
# Top with scale=0.5 and no animation
self.btn_top.clicked.connect(
lambda: self.panel_manager.toggle_panel("top", scale=0.5, animation=False)
)
# Right with ensure_max, scale=0.3
self.btn_right.clicked.connect(
lambda: self.panel_manager.toggle_panel("right", ensure_max=True, scale=0.3)
)
# Bottom no animation
self.btn_bottom.clicked.connect(
lambda: self.panel_manager.toggle_panel("bottom", target_size=100, animation=False)
)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec())

View 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()

View 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())

View File

@@ -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)

View File

@@ -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

View 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: int | None = None,
col: int | None = None,
coordinates: Tuple[int, int] | None = 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_())

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,308 @@
import pytest
from qtpy.QtCore import QEasingCurve
from qtpy.QtWidgets import QPushButton, QVBoxLayout, QWidget
from bec_widgets.qt_utils.collapsible_panel_manager import (
CollapsiblePanelManager,
DimensionAnimator,
)
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
@pytest.fixture
def reference_widget(qtbot):
widget = QWidget()
layout = QVBoxLayout(widget)
btn = QPushButton("Reference")
layout.addWidget(btn)
qtbot.addWidget(widget)
widget.setVisible(True)
qtbot.waitExposed(widget)
return widget
@pytest.fixture
def layout_manager(qtbot, reference_widget):
manager = LayoutManagerWidget()
qtbot.addWidget(manager)
manager.add_widget(reference_widget, row=0, col=0)
manager.setVisible(True)
qtbot.waitExposed(manager)
return manager
@pytest.fixture
def panel_manager(layout_manager, reference_widget):
manager = CollapsiblePanelManager(layout_manager, reference_widget)
return manager
@pytest.fixture
def test_panel_widget(qtbot):
widget = QWidget()
qtbot.addWidget(widget)
return widget
def test_dimension_animator_width_setting(qtbot, test_panel_widget):
animator = DimensionAnimator(test_panel_widget, "left")
animator.panel_width = 100
assert animator.panel_width == 100
assert test_panel_widget.width() == 100
def test_dimension_animator_height_setting(qtbot, test_panel_widget):
animator = DimensionAnimator(test_panel_widget, "top")
animator.panel_height = 150
assert animator.panel_height == 150
assert test_panel_widget.height() == 150
def test_add_panel(panel_manager, test_panel_widget):
panel_manager.add_panel("left", test_panel_widget, target_size=200)
assert panel_manager.panels["left"]["widget"] == test_panel_widget
# Initially hidden
assert not test_panel_widget.isVisible()
assert test_panel_widget.maximumWidth() == 0
def test_add_panel_no_target_size(panel_manager, test_panel_widget):
panel_manager.add_panel("top", test_panel_widget)
assert panel_manager.panels["top"]["target_size"] == 150
assert not test_panel_widget.isVisible()
def test_add_panel_invalid_direction(panel_manager, test_panel_widget):
with pytest.raises(ValueError) as exc_info:
panel_manager.add_panel("invalid", test_panel_widget)
assert "Direction must be one of 'left', 'right', 'top', 'bottom'." in str(exc_info.value)
def test_toggle_panel_show(panel_manager, test_panel_widget):
panel_manager.add_panel("left", test_panel_widget, target_size=200)
assert not test_panel_widget.isVisible()
panel_manager.toggle_panel("left", animation=False)
assert test_panel_widget.isVisible()
def test_toggle_panel_hide(panel_manager, test_panel_widget):
panel_manager.add_panel("left", test_panel_widget, target_size=200)
panel_manager.toggle_panel("left", animation=False)
assert test_panel_widget.isVisible()
panel_manager.toggle_panel("left", animation=False)
assert not test_panel_widget.isVisible()
def test_toggle_panel_scale(panel_manager, test_panel_widget, reference_widget):
reference_widget.resize(800, 600)
panel_manager.add_panel("right", test_panel_widget)
panel_manager.toggle_panel("right", scale=0.25, animation=False)
assert test_panel_widget.isVisible()
assert test_panel_widget.maximumWidth() == 200
def test_toggle_panel_ensure_max(panel_manager, test_panel_widget):
panel_manager.add_panel("bottom", test_panel_widget, target_size=150)
panel_manager.toggle_panel("bottom", ensure_max=True, animation=False)
assert test_panel_widget.isVisible()
assert test_panel_widget.maximumHeight() == 150
panel_manager.toggle_panel("bottom", ensure_max=True, animation=False)
assert not test_panel_widget.isVisible()
assert test_panel_widget.maximumHeight() == 16777215
def test_toggle_panel_easing_curve(panel_manager, test_panel_widget):
panel_manager.add_panel("top", test_panel_widget, target_size=100, duration=500)
panel_manager.toggle_panel("top", easing_curve=QEasingCurve.OutBounce, animation=True)
assert panel_manager.animations.get(test_panel_widget) is not None
def test_toggle_nonexistent_panel(panel_manager):
with pytest.raises(ValueError) as exc_info:
panel_manager.toggle_panel("invalid")
assert "No panel found in direction 'invalid'." in str(exc_info.value)
def test_toggle_panel_without_animation(panel_manager, test_panel_widget):
panel_manager.add_panel("left", test_panel_widget, target_size=200)
panel_manager.toggle_panel("left", animation=False)
assert test_panel_widget.isVisible()
assert test_panel_widget.maximumWidth() == 200
panel_manager.toggle_panel("left", animation=False)
assert not test_panel_widget.isVisible()
def test_after_hide_reset(panel_manager, test_panel_widget):
panel_manager.add_panel("left", test_panel_widget, target_size=200)
panel_manager.toggle_panel("left", ensure_max=True, animation=False)
assert test_panel_widget.isVisible()
panel_manager.toggle_panel("left", ensure_max=True, animation=False)
assert not test_panel_widget.isVisible()
assert test_panel_widget.minimumWidth() == 0
assert test_panel_widget.maximumWidth() == 0
def test_toggle_panel_repeated(panel_manager, test_panel_widget):
panel_manager.add_panel("right", test_panel_widget, target_size=200)
panel_manager.toggle_panel("right", animation=False)
assert test_panel_widget.isVisible()
panel_manager.toggle_panel("right", animation=False)
assert not test_panel_widget.isVisible()
panel_manager.toggle_panel("right", animation=False)
assert test_panel_widget.isVisible()
def test_toggle_panel_with_custom_duration(panel_manager, test_panel_widget):
panel_manager.add_panel("bottom", test_panel_widget, target_size=150, duration=1000)
panel_manager.toggle_panel("bottom", duration=2000, animation=True)
animation = panel_manager.animations.get(test_panel_widget)
assert animation is not None
assert animation.duration() == 2000
def test_toggle_panel_ensure_max_scale(panel_manager, test_panel_widget, reference_widget):
reference_widget.resize(1000, 800)
panel_manager.add_panel("top", test_panel_widget)
panel_manager.toggle_panel("top", ensure_max=True, scale=0.5, animation=False)
assert test_panel_widget.isVisible()
assert test_panel_widget.maximumHeight() == 400
def test_no_animation_mode(panel_manager, test_panel_widget):
panel_manager.add_panel("left", test_panel_widget, target_size=200)
panel_manager.toggle_panel("left", animation=False)
assert test_panel_widget.isVisible()
panel_manager.toggle_panel("left", animation=False)
assert not test_panel_widget.isVisible()
def test_toggle_panel_nondefault_easing(panel_manager, test_panel_widget):
panel_manager.add_panel("right", test_panel_widget, target_size=200)
panel_manager.toggle_panel("right", easing_curve=QEasingCurve.InCurve, animation=True)
animation = panel_manager.animations.get(test_panel_widget)
assert animation is not None
assert animation.easingCurve() == QEasingCurve.InCurve
def test_toggle_panel_ensure_max_no_animation(panel_manager, test_panel_widget):
panel_manager.add_panel("bottom", test_panel_widget, target_size=150)
panel_manager.toggle_panel("bottom", ensure_max=True, animation=False)
assert test_panel_widget.isVisible()
assert test_panel_widget.maximumHeight() == 150
panel_manager.toggle_panel("bottom", ensure_max=True, animation=False)
assert not test_panel_widget.isVisible()
assert test_panel_widget.maximumHeight() == 16777215
def test_toggle_panel_new_target_size(panel_manager, test_panel_widget):
panel_manager.add_panel("left", test_panel_widget, target_size=200)
panel_manager.toggle_panel("left", target_size=300, animation=False)
assert test_panel_widget.isVisible()
assert test_panel_widget.maximumWidth() == 300
panel_manager.toggle_panel("left", animation=False)
assert not test_panel_widget.isVisible()
def test_toggle_panel_new_duration(panel_manager, test_panel_widget):
panel_manager.add_panel("left", test_panel_widget, target_size=200, duration=300)
panel_manager.toggle_panel("left", duration=1000, animation=True)
animation = panel_manager.animations.get(test_panel_widget)
assert animation.duration() == 1000
def test_toggle_panel_wrong_direction(panel_manager):
with pytest.raises(ValueError) as exc:
panel_manager.toggle_panel("unknown_direction")
assert "No panel found in direction 'unknown_direction'." in str(exc.value)
def test_toggle_panel_no_panels(panel_manager):
with pytest.raises(ValueError) as exc:
panel_manager.toggle_panel("top")
assert "No panel found in direction 'top'." in str(exc.value)
def test_multiple_panels_interaction(panel_manager):
widget_left = QWidget()
widget_right = QWidget()
panel_manager.add_panel("left", widget_left, target_size=200)
panel_manager.add_panel("right", widget_right, target_size=300)
panel_manager.toggle_panel("left", animation=False)
assert widget_left.isVisible()
panel_manager.toggle_panel("right", animation=False)
assert widget_right.isVisible()
panel_manager.toggle_panel("left", animation=False)
assert not widget_left.isVisible()
assert widget_right.isVisible()
panel_manager.toggle_panel("right", animation=False)
assert not widget_right.isVisible()
def test_panel_manager_custom_easing(panel_manager, test_panel_widget):
panel_manager.add_panel("top", test_panel_widget, target_size=150)
panel_manager.toggle_panel("top", easing_curve=QEasingCurve.InQuad, animation=True)
animation = panel_manager.animations.get(test_panel_widget)
assert animation is not None
assert animation.easingCurve() == QEasingCurve.InQuad
def test_toggle_panel_scale_no_animation(panel_manager, test_panel_widget, reference_widget):
reference_widget.resize(400, 300)
panel_manager.add_panel("bottom", test_panel_widget)
panel_manager.toggle_panel("bottom", scale=0.5, animation=False)
assert test_panel_widget.isVisible()
assert test_panel_widget.maximumHeight() == 150
panel_manager.toggle_panel("bottom", animation=False)
assert not test_panel_widget.isVisible()
def test_after_hide_reset_properties(panel_manager, test_panel_widget):
panel_manager.add_panel("left", test_panel_widget, target_size=200)
panel_manager.toggle_panel("left", ensure_max=True, animation=False)
panel_manager.toggle_panel("left", ensure_max=True, animation=False)
assert not test_panel_widget.isVisible()
assert test_panel_widget.minimumWidth() == 0
assert test_panel_widget.maximumWidth() == 0
def test_toggle_panel_no_animation_show_only(panel_manager, test_panel_widget):
panel_manager.add_panel("right", test_panel_widget, target_size=100)
panel_manager.toggle_panel("right", animation=False)
assert test_panel_widget.isVisible()
assert test_panel_widget.maximumWidth() == 100
def test_toggle_panel_no_animation_hide_only(panel_manager, test_panel_widget):
panel_manager.add_panel("left", test_panel_widget, target_size=100)
panel_manager.toggle_panel("left", animation=False)
assert test_panel_widget.isVisible()
panel_manager.toggle_panel("left", animation=False)
assert not test_panel_widget.isVisible()
def test_toggle_panel_easing_inout(panel_manager, test_panel_widget):
panel_manager.add_panel("top", test_panel_widget, target_size=120)
panel_manager.toggle_panel("top", easing_curve=QEasingCurve.InOutQuad, animation=True)
animation = panel_manager.animations.get(test_panel_widget)
assert animation is not None
assert animation.easingCurve() == QEasingCurve.InOutQuad
def test_toggle_panel_ensure_max_width(panel_manager, test_panel_widget):
panel_manager.add_panel("right", test_panel_widget, target_size=200)
panel_manager.toggle_panel("right", ensure_max=True, animation=False)
assert test_panel_widget.isVisible()
assert test_panel_widget.maximumWidth() == 200
def test_toggle_panel_invalid_direction_twice(panel_manager, test_panel_widget):
panel_manager.add_panel("left", test_panel_widget, target_size=200)
with pytest.raises(ValueError) as exc_info:
panel_manager.toggle_panel("invalid_direction")
assert "No panel found in direction 'invalid_direction'." in str(exc_info.value)

View 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)

View 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)

View 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)

View 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()