diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py
index 3af90274..98e94bf8 100644
--- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py
+++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py
@@ -20,6 +20,7 @@ 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
+from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
class JupyterConsoleWindow(QWidget): # pragma: no cover:
@@ -62,6 +63,8 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"btn4": self.btn4,
"btn5": self.btn5,
"btn6": self.btn6,
+ "pb": self.pb,
+ "pi": self.pi,
}
)
@@ -92,6 +95,15 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
third_tab_layout.addWidget(self.lm)
tab_widget.addTab(third_tab, "Layout Manager Widget")
+ fourth_tab = QWidget()
+ fourth_tab_layout = QVBoxLayout(fourth_tab)
+ self.pb = PlotBase()
+ self.pi = self.pb.plot_item
+ fourth_tab_layout.addWidget(self.pb)
+ tab_widget.addTab(fourth_tab, "PltoBase")
+
+ tab_widget.setCurrentIndex(3)
+
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
diff --git a/bec_widgets/qt_utils/round_frame.py b/bec_widgets/qt_utils/round_frame.py
index 0a0d265e..3715791e 100644
--- a/bec_widgets/qt_utils/round_frame.py
+++ b/bec_widgets/qt_utils/round_frame.py
@@ -114,10 +114,12 @@ class RoundedFrame(BECWidget, QFrame):
# 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))
+ for axis in ["left", "right", "top", "bottom"]:
+ plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
+ plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
+
+ # Change title color
+ plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
# Apply border style via stylesheet
self.content_widget.setStyleSheet(
diff --git a/bec_widgets/qt_utils/side_panel.py b/bec_widgets/qt_utils/side_panel.py
index f69a2a53..5f855569 100644
--- a/bec_widgets/qt_utils/side_panel.py
+++ b/bec_widgets/qt_utils/side_panel.py
@@ -5,18 +5,18 @@ from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation
from qtpy.QtGui import QAction
from qtpy.QtWidgets import (
QApplication,
+ QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
+ QScrollArea,
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):
@@ -41,7 +41,6 @@ class SidePanel(QWidget):
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
@@ -71,6 +70,7 @@ class SidePanel(QWidget):
self.stack_widget = QStackedWidget()
self.stack_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.stack_widget.setMinimumWidth(5)
+ self.stack_widget.setMaximumWidth(self._panel_max_width)
if self._orientation == "left":
self.main_layout.addWidget(self.toolbar)
@@ -80,7 +80,10 @@ class SidePanel(QWidget):
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
- self.stack_widget.setMaximumWidth(self._panel_max_width)
+
+ self.menu_anim = QPropertyAnimation(self, b"panel_width")
+ self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
+ self.panel_width = 0 # start hidden
else:
self.main_layout = QVBoxLayout(self)
@@ -97,6 +100,7 @@ class SidePanel(QWidget):
self.stack_widget = QStackedWidget()
self.stack_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.stack_widget.setMinimumHeight(5)
+ self.stack_widget.setMaximumHeight(self._panel_max_width)
if self._orientation == "top":
self.main_layout.addWidget(self.toolbar)
@@ -106,74 +110,46 @@ class SidePanel(QWidget):
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.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
+ self.panel_height = 0 # start hidden
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.
- """
+ """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.
- """
+ """Set the panel width."""
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.
- """
+ """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.
- """
+ """Set the panel height."""
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.
- """
+ """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.
- """
+ """Set 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)
@@ -182,45 +158,28 @@ class SidePanel(QWidget):
@Property(int)
def animation_duration(self):
- """
- Get the duration of the animation.
- """
+ """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.
- """
+ """Set 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.
- """
+ """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.
- """
+ """Set 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
@@ -268,9 +227,6 @@ class SidePanel(QWidget):
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)
@@ -287,20 +243,35 @@ class SidePanel(QWidget):
widget(QWidget): The widget to add to the panel.
title(str): The title of the panel.
"""
+ # container_widget: top-level container for the stacked page
container_widget = QWidget()
container_layout = QVBoxLayout(container_widget)
- title_label = QLabel(f"{title}")
- 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.setContentsMargins(0, 0, 0, 0)
container_layout.setSpacing(5)
+ title_label = QLabel(f"{title}")
+ title_label.setStyleSheet("font-size: 16px;")
+ container_layout.addWidget(title_label)
+
+ # Create a QScrollArea for the actual widget to ensure scrolling if the widget inside is too large
+ scroll_area = QScrollArea()
+ scroll_area.setFrameShape(QFrame.NoFrame)
+ scroll_area.setWidgetResizable(True)
+ # Let the scroll area expand in both directions if there's room
+ scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+ scroll_area.setWidget(widget)
+
+ # Put the scroll area in the container layout
+ container_layout.addWidget(scroll_area)
+
+ # Optionally stretch the scroll area to fill vertical space
+ container_layout.setStretchFactor(scroll_area, 1)
+
+ # Add container_widget to the stacked widget
index = self.stack_widget.count()
self.stack_widget.addWidget(container_widget)
+ # Add an action to the toolbar
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
@@ -328,6 +299,11 @@ class SidePanel(QWidget):
action.action.toggled.connect(on_action_toggled)
+############################################
+# DEMO APPLICATION
+############################################
+
+
class ExampleApp(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
@@ -335,20 +311,24 @@ class ExampleApp(QMainWindow): # pragma: no cover
central_widget = QWidget()
self.setCentralWidget(central_widget)
-
- self.side_panel = SidePanel(self, orientation="left")
-
self.layout = QHBoxLayout(central_widget)
+ # Create side panel
+ self.side_panel = SidePanel(self, orientation="left", panel_max_width=250)
self.layout.addWidget(self.side_panel)
+
+ from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
+
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"))
+ layout1 = QVBoxLayout(widget1)
+ for i in range(15):
+ layout1.addWidget(QLabel(f"Widget 1 label row {i}"))
self.side_panel.add_menu(
action_id="widget1",
icon_name="counter_1",
@@ -358,8 +338,8 @@ class ExampleApp(QMainWindow): # pragma: no cover
)
widget2 = QWidget()
- widget2_layout = QVBoxLayout(widget2)
- widget2_layout.addWidget(QLabel("This is Widget 2"))
+ layout2 = QVBoxLayout(widget2)
+ layout2.addWidget(QLabel("Short widget 2 content"))
self.side_panel.add_menu(
action_id="widget2",
icon_name="counter_2",
@@ -369,8 +349,9 @@ class ExampleApp(QMainWindow): # pragma: no cover
)
widget3 = QWidget()
- widget3_layout = QVBoxLayout(widget3)
- widget3_layout.addWidget(QLabel("This is Widget 3"))
+ layout3 = QVBoxLayout(widget3)
+ for i in range(10):
+ layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
self.side_panel.add_menu(
action_id="widget3",
icon_name="counter_3",
@@ -383,6 +364,6 @@ class ExampleApp(QMainWindow): # pragma: no cover
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = ExampleApp()
- window.resize(800, 600)
+ window.resize(1000, 700)
window.show()
sys.exit(app.exec())
diff --git a/bec_widgets/widgets/plots_next_gen/plot_base.py b/bec_widgets/widgets/plots_next_gen/plot_base.py
new file mode 100644
index 00000000..3b7ca802
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/plot_base.py
@@ -0,0 +1,571 @@
+from __future__ import annotations
+
+import pyqtgraph as pg
+from bec_lib import bec_logger
+from qtpy.QtCore import QPoint, QPointF, Qt, Signal
+from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
+
+from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
+from bec_widgets.qt_utils.round_frame import RoundedFrame
+from bec_widgets.qt_utils.side_panel import SidePanel
+from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar, SeparatorAction
+from bec_widgets.utils import ConnectionConfig, Crosshair, EntryValidator
+from bec_widgets.utils.bec_widget import BECWidget
+from bec_widgets.utils.colors import set_theme
+from bec_widgets.utils.fps_counter import FPSCounter
+from bec_widgets.utils.widget_state_manager import WidgetStateManager
+from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
+from bec_widgets.widgets.plots_next_gen.setting_menus.axis_settings import AxisSettings
+from bec_widgets.widgets.plots_next_gen.toolbar_bundles.mouse_interactions import (
+ MouseInteractionToolbarBundle,
+)
+from bec_widgets.widgets.plots_next_gen.toolbar_bundles.plot_export import PlotExportBundle
+from bec_widgets.widgets.plots_next_gen.toolbar_bundles.save_state import SaveStateBundle
+from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
+
+logger = bec_logger.logger
+
+
+class BECViewBox(pg.ViewBox):
+ sigPaint = Signal()
+
+ def paint(self, painter, opt, widget):
+ super().paint(painter, opt, widget)
+ self.sigPaint.emit()
+
+ def itemBoundsChanged(self, item):
+ self._itemBoundsCache.pop(item, None)
+ if (self.state["autoRange"][0] is not False) or (self.state["autoRange"][1] is not False):
+ # check if the call is coming from a mouse-move event
+ if hasattr(item, "skip_auto_range") and item.skip_auto_range:
+ return
+ self._autoRangeNeedsUpdate = True
+ self.update()
+
+
+class PlotBase(BECWidget, QWidget):
+ PLUGIN = False
+ RPC = False
+
+ # Custom Signals
+ property_changed = Signal(str, object)
+ crosshair_position_changed = Signal(tuple)
+ crosshair_position_clicked = Signal(tuple)
+ crosshair_coordinates_changed = Signal(tuple)
+ crosshair_coordinates_clicked = Signal(tuple)
+
+ def __init__(
+ self,
+ parent: QWidget | None = None,
+ config: ConnectionConfig | None = None,
+ client=None,
+ gui_id: str | None = None,
+ ) -> None:
+ if config is None:
+ config = ConnectionConfig(widget_class=self.__class__.__name__)
+ super().__init__(client=client, gui_id=gui_id, config=config)
+ QWidget.__init__(self, parent=parent)
+
+ # For PropertyManager identification
+ self.setObjectName("PlotBase")
+ self.get_bec_shortcuts()
+
+ # Layout Management
+ self.layout = QVBoxLayout(self)
+ self.layout.setContentsMargins(0, 0, 0, 0)
+ self.layout.setSpacing(0)
+ self.layout_manager = LayoutManagerWidget(parent=self)
+
+ # Property Manager
+ self.state_manager = WidgetStateManager(self)
+
+ # Entry Validator
+ self.entry_validator = EntryValidator(self.dev)
+
+ # Base widgets elements
+ self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
+ self.plot_widget = pg.PlotWidget(plotItem=self.plot_item)
+ self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
+ self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
+ self.init_toolbar()
+
+ # PlotItem Addons
+ self.plot_item.addLegend()
+ self.crosshair = None
+ self.fps_monitor = None
+ self.fps_label = QLabel(alignment=Qt.AlignmentFlag.AlignRight)
+
+ self._init_ui()
+
+ def _init_ui(self):
+ self.layout.addWidget(self.layout_manager)
+ self.round_plot_widget = RoundedFrame(content_widget=self.plot_widget, theme_update=True)
+ self.round_plot_widget.apply_theme("dark")
+
+ self.layout_manager.add_widget(self.round_plot_widget)
+ self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top")
+ self.fps_label.hide()
+ self.layout_manager.add_widget_relative(self.side_panel, self.round_plot_widget, "left")
+ self.layout_manager.add_widget_relative(self.toolbar, self.fps_label, "top")
+
+ self.add_side_menus()
+
+ # PlotItem ViewBox Signals
+ self.plot_item.vb.sigStateChanged.connect(self.viewbox_state_changed)
+
+ def init_toolbar(self):
+
+ self.plot_export_bundle = PlotExportBundle("plot_export", target_widget=self)
+ self.mouse_bundle = MouseInteractionToolbarBundle("mouse_interaction", target_widget=self)
+ self.state_export_bundle = SaveStateBundle("state_export", target_widget=self)
+
+ # Add elements to toolbar
+ self.toolbar.add_bundle(self.plot_export_bundle, target_widget=self)
+ self.toolbar.add_bundle(self.state_export_bundle, target_widget=self)
+ self.toolbar.add_bundle(self.mouse_bundle, target_widget=self)
+
+ self.toolbar.add_action("separator_0", SeparatorAction(), target_widget=self)
+ self.toolbar.add_action(
+ "crosshair",
+ MaterialIconAction(icon_name="point_scan", tooltip="Show Crosshair", checkable=True),
+ target_widget=self,
+ )
+ self.toolbar.add_action("separator_1", SeparatorAction(), target_widget=self)
+ self.toolbar.add_action(
+ "fps_monitor",
+ MaterialIconAction(icon_name="speed", tooltip="Show FPS Monitor", checkable=True),
+ target_widget=self,
+ )
+ self.toolbar.addWidget(DarkModeButton(toolbar=True))
+
+ self.toolbar.widgets["fps_monitor"].action.toggled.connect(
+ lambda checked: setattr(self, "enable_fps_monitor", checked)
+ )
+ self.toolbar.widgets["crosshair"].action.toggled.connect(self.toggle_crosshair)
+
+ def add_side_menus(self):
+ """Adds multiple menus to the side panel."""
+ # Setting Axis Widget
+ axis_setting = AxisSettings(target_widget=self)
+ self.side_panel.add_menu(
+ action_id="axis",
+ icon_name="settings",
+ tooltip="Show Axis Settings",
+ widget=axis_setting,
+ title="Axis Settings",
+ )
+
+ ################################################################################
+ # Toggle UI Elements
+ ################################################################################
+
+ @SafeProperty(bool, doc="Show Toolbar")
+ def enable_toolbar(self) -> bool:
+ return self.toolbar.isVisible()
+
+ @enable_toolbar.setter
+ def enable_toolbar(self, value: bool):
+ self.toolbar.setVisible(value)
+
+ @SafeProperty(bool, doc="Show Side Panel")
+ def enable_side_panel(self) -> bool:
+ return self.side_panel.isVisible()
+
+ @enable_side_panel.setter
+ def enable_side_panel(self, value: bool):
+ self.side_panel.setVisible(value)
+
+ @SafeProperty(bool, doc="Enable the FPS monitor.")
+ def enable_fps_monitor(self) -> bool:
+ return self.fps_label.isVisible()
+
+ @enable_fps_monitor.setter
+ def enable_fps_monitor(self, value: bool):
+ if value and self.fps_monitor is None:
+ self.hook_fps_monitor()
+ elif not value and self.fps_monitor is not None:
+ self.unhook_fps_monitor()
+
+ ################################################################################
+ # ViewBox State Signals
+ ################################################################################
+
+ def viewbox_state_changed(self):
+ """
+ Emit a signal when the state of the viewbox has changed.
+ Merges the default pyqtgraphs signal states and also CTRL menu toggles.
+ """
+
+ viewbox_state = self.plot_item.vb.getState()
+ # Range Limits
+ x_min, x_max = viewbox_state["targetRange"][0]
+ y_min, y_max = viewbox_state["targetRange"][1]
+ self.property_changed.emit("x_min", x_min)
+ self.property_changed.emit("x_max", x_max)
+ self.property_changed.emit("y_min", y_min)
+ self.property_changed.emit("y_max", y_max)
+
+ # Grid Toggles
+
+ ################################################################################
+ # Plot Properties
+ ################################################################################
+
+ def set(self, **kwargs):
+ """
+ Set the properties of the plot widget.
+
+ Args:
+ **kwargs: Keyword arguments for the properties to be set.
+
+ Possible properties:
+
+ """
+ property_map = {
+ "title": self.title,
+ "x_label": self.x_label,
+ "y_label": self.y_label,
+ "x_limits": self.x_limits,
+ "y_limits": self.y_limits,
+ "x_grid": self.x_grid,
+ "y_grid": self.y_grid,
+ "inner_axes": self.inner_axes,
+ "outer_axes": self.outer_axes,
+ "lock_aspect_ratio": self.lock_aspect_ratio,
+ "auto_range_x": self.auto_range_x,
+ "auto_range_y": self.auto_range_y,
+ "x_log": self.x_log,
+ "y_log": self.y_log,
+ "legend_label_size": self.legend_label_size,
+ }
+
+ for key, value in kwargs.items():
+ if key in property_map:
+ setattr(self, key, value)
+ else:
+ logger.warning(f"Property {key} not found.")
+
+ @SafeProperty(str, doc="The title of the axes.")
+ def title(self) -> str:
+ return self.plot_item.titleLabel.text
+
+ @title.setter
+ def title(self, value: str):
+ self.plot_item.setTitle(value)
+ self.property_changed.emit("title", value)
+
+ @SafeProperty(str, doc="The text of the x label")
+ def x_label(self) -> str:
+ return self.plot_item.getAxis("bottom").labelText
+
+ @x_label.setter
+ def x_label(self, value: str):
+ self.plot_item.setLabel("bottom", text=value)
+ self.property_changed.emit("x_label", value)
+
+ @SafeProperty(str, doc="The text of the y label")
+ def y_label(self) -> str:
+ return self.plot_item.getAxis("left").labelText
+
+ @y_label.setter
+ def y_label(self, value: str):
+ self.plot_item.setLabel("left", text=value)
+ self.property_changed.emit("y_label", value)
+
+ def _tuple_to_qpointf(self, tuple: tuple | list):
+ """
+ Helper function to convert a tuple to a QPointF.
+
+ Args:
+ tuple(tuple|list): Tuple or list of two numbers.
+
+ Returns:
+ QPointF: The tuple converted to a QPointF.
+ """
+ if len(tuple) != 2:
+ raise ValueError("Limits must be a tuple or list of two numbers.")
+ min_val, max_val = tuple
+ if not isinstance(min_val, (int, float)) or not isinstance(max_val, (int, float)):
+ raise TypeError("Limits must be numbers.")
+ if min_val > max_val:
+ raise ValueError("Minimum limit cannot be greater than maximum limit.")
+ return QPoint(*tuple)
+
+ ################################################################################
+ # X limits, has to be SaveProperty("QPointF") because of the tuple conversion for designer,
+ # the python properties are used for CLI and API for context dialog settings.
+
+ @SafeProperty("QPointF")
+ def x_limits(self) -> QPointF:
+ current_lim = self.plot_item.vb.viewRange()[0]
+ return QPointF(current_lim[0], current_lim[1])
+
+ @x_limits.setter
+ def x_limits(self, value):
+ if isinstance(value, (tuple, list)):
+ value = self._tuple_to_qpointf(value)
+ self.plot_item.vb.setXRange(value.x(), value.y(), padding=0)
+
+ @property
+ def x_lim(self) -> tuple:
+ return (self.x_limits.x(), self.x_limits.y())
+
+ @x_lim.setter
+ def x_lim(self, value):
+ self.x_limits = value
+
+ @property
+ def x_min(self) -> float:
+ return self.x_limits.x()
+
+ @x_min.setter
+ def x_min(self, value: float):
+ self.x_limits = (value, self.x_lim[1])
+
+ @property
+ def x_max(self) -> float:
+ return self.x_limits.y()
+
+ @x_max.setter
+ def x_max(self, value: float):
+ self.x_limits = (self.x_lim[0], value)
+
+ ################################################################################
+ # Y limits, has to be SaveProperty("QPointF") because of the tuple conversion for designer,
+ # the python properties are used for CLI and API for context dialog settings.
+
+ @SafeProperty("QPointF")
+ def y_limits(self) -> QPointF:
+ current_lim = self.plot_item.vb.viewRange()[1]
+ return QPointF(current_lim[0], current_lim[1])
+
+ @y_limits.setter
+ def y_limits(self, value):
+ if isinstance(value, (tuple, list)):
+ value = self._tuple_to_qpointf(value)
+ self.plot_item.vb.setYRange(value.x(), value.y(), padding=0)
+
+ @property
+ def y_lim(self) -> tuple:
+ return (self.y_limits.x(), self.y_limits.y())
+
+ @y_lim.setter
+ def y_lim(self, value):
+ self.y_limits = value
+
+ @property
+ def y_min(self) -> float:
+ return self.y_limits.x()
+
+ @y_min.setter
+ def y_min(self, value: float):
+ self.y_limits = (value, self.y_lim[1])
+
+ @property
+ def y_max(self) -> float:
+ return self.y_limits.y()
+
+ @y_max.setter
+ def y_max(self, value: float):
+ self.y_limits = (self.y_lim[0], value)
+
+ @SafeProperty(bool, doc="Show grid on the x-axis.")
+ def x_grid(self) -> bool:
+ return self.plot_item.ctrl.xGridCheck.isChecked()
+
+ @x_grid.setter
+ def x_grid(self, value: bool):
+ self.plot_item.showGrid(x=value)
+ self.property_changed.emit("x_grid", value)
+
+ @SafeProperty(bool, doc="Show grid on the y-axis.")
+ def y_grid(self) -> bool:
+ return self.plot_item.ctrl.yGridCheck.isChecked()
+
+ @y_grid.setter
+ def y_grid(self, value: bool):
+ self.plot_item.showGrid(y=value)
+ self.property_changed.emit("y_grid", value)
+
+ @SafeProperty(bool, doc="Set X-axis to log scale if True, linear if False.")
+ def x_log(self) -> bool:
+ return bool(self.plot_item.vb.state.get("logMode", [False, False])[0])
+
+ @x_log.setter
+ def x_log(self, value: bool):
+ self.plot_item.setLogMode(x=value)
+ self.property_changed.emit("x_log", value)
+
+ @SafeProperty(bool, doc="Set Y-axis to log scale if True, linear if False.")
+ def y_log(self) -> bool:
+ return bool(self.plot_item.vb.state.get("logMode", [False, False])[1])
+
+ @y_log.setter
+ def y_log(self, value: bool):
+ self.plot_item.setLogMode(y=value)
+ self.property_changed.emit("y_log", value)
+
+ @SafeProperty(bool, doc="Show the outer axes of the plot widget.")
+ def outer_axes(self) -> bool:
+ return self.plot_item.getAxis("top").isVisible()
+
+ @outer_axes.setter
+ def outer_axes(self, value: bool):
+ self.plot_item.showAxis("top", value)
+ self.plot_item.showAxis("right", value)
+ self.property_changed.emit("outer_axes", value)
+
+ @SafeProperty(bool, doc="Show inner axes of the plot widget.")
+ def inner_axes(self) -> bool:
+ return self.plot_item.getAxis("bottom").isVisible()
+
+ @inner_axes.setter
+ def inner_axes(self, value: bool):
+ self.plot_item.showAxis("bottom", value)
+ self.plot_item.showAxis("left", value)
+ self.property_changed.emit("inner_axes", value)
+
+ @SafeProperty(bool, doc="Lock aspect ratio of the plot widget.")
+ def lock_aspect_ratio(self) -> bool:
+ return bool(self.plot_item.vb.getState()["aspectLocked"])
+
+ @lock_aspect_ratio.setter
+ def lock_aspect_ratio(self, value: bool):
+ self.plot_item.setAspectLocked(value)
+
+ @SafeProperty(bool, doc="Set auto range for the x-axis.")
+ def auto_range_x(self) -> bool:
+ return bool(self.plot_item.vb.getState()["autoRange"][0])
+
+ @auto_range_x.setter
+ def auto_range_x(self, value: bool):
+ self.plot_item.enableAutoRange(x=value)
+
+ @SafeProperty(bool, doc="Set auto range for the y-axis.")
+ def auto_range_y(self) -> bool:
+ return bool(self.plot_item.vb.getState()["autoRange"][1])
+
+ @auto_range_y.setter
+ def auto_range_y(self, value: bool):
+ self.plot_item.enableAutoRange(y=value)
+
+ @SafeProperty(int, doc="The font size of the legend font.")
+ def legend_label_size(self) -> int:
+ if not self.plot_item.legend:
+ return
+ scale = self.plot_item.legend.scale() * 9
+ return scale
+
+ @legend_label_size.setter
+ def legend_label_size(self, value: int):
+ if not self.plot_item.legend:
+ return
+ scale = (
+ value / 9
+ ) # 9 is the default font size of the legend, so we always scale it against 9
+ self.plot_item.legend.setScale(scale)
+
+ ################################################################################
+ # FPS Counter
+ ################################################################################
+
+ def update_fps_label(self, fps: float) -> None:
+ """
+ Update the FPS label.
+
+ Args:
+ fps(float): The frames per second.
+ """
+ if self.fps_label:
+ self.fps_label.setText(f"FPS: {fps:.2f}")
+
+ def hook_fps_monitor(self):
+ """Hook the FPS monitor to the plot."""
+ if self.fps_monitor is None:
+ self.fps_monitor = FPSCounter(self.plot_item.vb)
+ self.fps_label.show()
+
+ self.fps_monitor.sigFpsUpdate.connect(self.update_fps_label)
+ self.update_fps_label(0)
+
+ def unhook_fps_monitor(self, delete_label=True):
+ """Unhook the FPS monitor from the plot."""
+ if self.fps_monitor is not None and delete_label:
+ # Remove Monitor
+ self.fps_monitor.cleanup()
+ self.fps_monitor.deleteLater()
+ self.fps_monitor = None
+ if self.fps_label is not None:
+ # Hide Label
+ self.fps_label.hide()
+
+ ################################################################################
+ # Crosshair
+ ################################################################################
+
+ def hook_crosshair(self) -> None:
+ """Hook the crosshair to all plots."""
+ if self.crosshair is None:
+ self.crosshair = Crosshair(self.plot_item, precision=3)
+ self.crosshair.crosshairChanged.connect(self.crosshair_position_changed)
+ self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked)
+ self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed)
+ self.crosshair.coordinatesClicked1D.connect(self.crosshair_coordinates_clicked)
+ self.crosshair.coordinatesChanged2D.connect(self.crosshair_coordinates_changed)
+ self.crosshair.coordinatesClicked2D.connect(self.crosshair_coordinates_clicked)
+
+ def unhook_crosshair(self) -> None:
+ """Unhook the crosshair from all plots."""
+ if self.crosshair is not None:
+ self.crosshair.crosshairChanged.disconnect(self.crosshair_position_changed)
+ self.crosshair.crosshairClicked.disconnect(self.crosshair_position_clicked)
+ self.crosshair.coordinatesChanged1D.disconnect(self.crosshair_coordinates_changed)
+ self.crosshair.coordinatesClicked1D.disconnect(self.crosshair_coordinates_clicked)
+ self.crosshair.coordinatesChanged2D.disconnect(self.crosshair_coordinates_changed)
+ self.crosshair.coordinatesClicked2D.disconnect(self.crosshair_coordinates_clicked)
+ self.crosshair.cleanup()
+ self.crosshair.deleteLater()
+ self.crosshair = None
+
+ def toggle_crosshair(self) -> None:
+ """Toggle the crosshair on all plots."""
+ if self.crosshair is None:
+ return self.hook_crosshair()
+
+ self.unhook_crosshair()
+
+ @SafeSlot()
+ def reset(self) -> None:
+ """Reset the plot widget."""
+ if self.crosshair is not None:
+ self.crosshair.clear_markers()
+ self.crosshair.update_markers()
+
+ def cleanup(self):
+ self.unhook_crosshair()
+ self.unhook_fps_monitor(delete_label=True)
+ self.cleanup_pyqtgraph()
+
+ def cleanup_pyqtgraph(self):
+ """Cleanup pyqtgraph items."""
+ item = self.plot_item
+ item.vb.menu.close()
+ item.vb.menu.deleteLater()
+ item.ctrlMenu.close()
+ item.ctrlMenu.deleteLater()
+
+
+if __name__ == "__main__": # pragma: no cover:
+ import sys
+
+ from qtpy.QtWidgets import QApplication
+
+ app = QApplication(sys.argv)
+ set_theme("dark")
+ widget = PlotBase()
+ widget.show()
+ # Just some example data and parameters to test
+ widget.y_grid = True
+ widget.plot_item.plot([1, 2, 3, 4, 5], [1, 2, 3, 4, 5])
+
+ sys.exit(app.exec_())
diff --git a/bec_widgets/widgets/plots_next_gen/setting_menus/__init__.py b/bec_widgets/widgets/plots_next_gen/setting_menus/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bec_widgets/widgets/plots_next_gen/setting_menus/axis_settings.py b/bec_widgets/widgets/plots_next_gen/setting_menus/axis_settings.py
new file mode 100644
index 00000000..6fe2cb84
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/setting_menus/axis_settings.py
@@ -0,0 +1,95 @@
+import os
+
+from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
+
+from bec_widgets.qt_utils.error_popups import SafeSlot
+from bec_widgets.qt_utils.settings_dialog import SettingWidget
+from bec_widgets.utils import UILoader
+from bec_widgets.utils.widget_io import WidgetIO
+
+
+class AxisSettings(SettingWidget):
+ def __init__(self, parent=None, target_widget=None, *args, **kwargs):
+ super().__init__(parent=parent, *args, **kwargs)
+
+ # This is a settings widget that depends on the target widget
+ # and should mirror what is in the target widget.
+ # Saving settings for this widget could result in recursively setting the target widget.
+ self.setProperty("skip_settings", True)
+ self.setObjectName("AxisSettings")
+ current_path = os.path.dirname(__file__)
+ form = UILoader().load_ui(os.path.join(current_path, "axis_settings_vertical.ui"), self)
+
+ self.target_widget = target_widget
+
+ # # Scroll area
+ self.scroll_area = QScrollArea(self)
+ self.scroll_area.setWidgetResizable(True)
+ self.scroll_area.setFrameShape(QFrame.NoFrame)
+ self.scroll_area.setWidget(form)
+
+ self.layout = QVBoxLayout(self)
+ self.layout.setContentsMargins(0, 0, 0, 0)
+ self.layout.addWidget(self.scroll_area)
+ # self.layout.addWidget(self.ui)
+ self.ui = form
+
+ self.connect_all_signals()
+ if self.target_widget is not None:
+ self.target_widget.property_changed.connect(self.update_property)
+
+ def connect_all_signals(self):
+ for widget in [
+ self.ui.title,
+ self.ui.inner_axes,
+ self.ui.outer_axes,
+ self.ui.x_label,
+ self.ui.x_min,
+ self.ui.x_max,
+ self.ui.x_log,
+ self.ui.x_grid,
+ self.ui.y_label,
+ self.ui.y_min,
+ self.ui.y_max,
+ self.ui.y_log,
+ self.ui.y_grid,
+ ]:
+ WidgetIO.connect_widget_change_signal(widget, self.set_property)
+
+ @SafeSlot()
+ def set_property(self, widget: QWidget, value):
+ """
+ Set property of the target widget based on the widget that emitted the signal.
+ The name of the property has to be the same as the objectName of the widget
+ and compatible with WidgetIO.
+
+ Args:
+ widget(QWidget): The widget that emitted the signal.
+ value(): The value to set the property to.
+ """
+
+ try: # to avoid crashing when the widget is not found in Designer
+ property_name = widget.objectName()
+ setattr(self.target_widget, property_name, value)
+ except RuntimeError:
+ return
+
+ @SafeSlot()
+ def update_property(self, property_name: str, value):
+ """
+ Update the value of the widget based on the property name and value.
+ The name of the property has to be the same as the objectName of the widget
+ and compatible with WidgetIO.
+
+ Args:
+ property_name(str): The name of the property to update.
+ value: The value to set the property to.
+ """
+ try: # to avoid crashing when the widget is not found in Designer
+ widget_to_set = self.ui.findChild(QWidget, property_name)
+ except RuntimeError:
+ return
+ # Block signals to avoid triggering set_property again
+ was_blocked = widget_to_set.blockSignals(True)
+ WidgetIO.set_value(widget_to_set, value)
+ widget_to_set.blockSignals(was_blocked)
diff --git a/bec_widgets/widgets/plots_next_gen/setting_menus/axis_settings_horizontal.ui b/bec_widgets/widgets/plots_next_gen/setting_menus/axis_settings_horizontal.ui
new file mode 100644
index 00000000..dae3a82a
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/setting_menus/axis_settings_horizontal.ui
@@ -0,0 +1,256 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 427
+ 270
+
+
+
+
+ 0
+ 250
+
+
+
+
+ 16777215
+ 278
+
+
+
+ Form
+
+
+ -
+
+
-
+
+
+ Plot Title
+
+
+
+ -
+
+
+
+
+ -
+
+
+ Outer Axes
+
+
+
+ -
+
+
+ Y Axis
+
+
+
-
+
+
-
+
+ linear
+
+
+ -
+
+ log
+
+
+
+
+ -
+
+
+ Qt::AlignmentFlag::AlignCenter
+
+
+ -9999.000000000000000
+
+
+ 9999.000000000000000
+
+
+
+ -
+
+
+ Min
+
+
+
+ -
+
+
+ Qt::AlignmentFlag::AlignCenter
+
+
+ -9999.000000000000000
+
+
+ 9999.000000000000000
+
+
+
+ -
+
+
+ -
+
+
+ Scale
+
+
+
+ -
+
+
+ Label
+
+
+
+ -
+
+
+ Max
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+ Grid
+
+
+
+
+
+
+ -
+
+
+ X Axis
+
+
+
-
+
+
+ Scale
+
+
+
+ -
+
+
+ Qt::AlignmentFlag::AlignCenter
+
+
+ -9999.000000000000000
+
+
+ 9999.000000000000000
+
+
+
+ -
+
+
+ Min
+
+
+
+ -
+
+
+ Qt::AlignmentFlag::AlignCenter
+
+
+ -9999.000000000000000
+
+
+ 9999.000000000000000
+
+
+
+ -
+
+
-
+
+ linear
+
+
+ -
+
+ log
+
+
+
+
+ -
+
+
+ Max
+
+
+
+ -
+
+
+ -
+
+
+ Label
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+ Grid
+
+
+
+
+
+
+ -
+
+
+ false
+
+
+
+
+
+
+
+ ToggleSwitch
+ QWidget
+
+
+
+
+
+
diff --git a/bec_widgets/widgets/plots_next_gen/setting_menus/axis_settings_vertical.ui b/bec_widgets/widgets/plots_next_gen/setting_menus/axis_settings_vertical.ui
new file mode 100644
index 00000000..28c61a63
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/setting_menus/axis_settings_vertical.ui
@@ -0,0 +1,240 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 241
+ 526
+
+
+
+ Form
+
+
+ -
+
+
+ X Axis
+
+
+
-
+
+
+ Qt::AlignmentFlag::AlignCenter
+
+
+ -9999.000000000000000
+
+
+ 9999.000000000000000
+
+
+
+ -
+
+
+ Log
+
+
+
+ -
+
+
+ -
+
+
+ Max
+
+
+
+ -
+
+
+ false
+
+
+
+ -
+
+
+ Grid
+
+
+
+ -
+
+
+ Qt::AlignmentFlag::AlignCenter
+
+
+ -9999.000000000000000
+
+
+ 9999.000000000000000
+
+
+
+ -
+
+
+ Min
+
+
+
+ -
+
+
+ Label
+
+
+
+ -
+
+
+ false
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Plot Title
+
+
+
+ -
+
+
+
+
+ -
+
+
+ Outer Axes
+
+
+
+ -
+
+
+ Y Axis
+
+
+
-
+
+
+ Qt::AlignmentFlag::AlignCenter
+
+
+ -9999.000000000000000
+
+
+ 9999.000000000000000
+
+
+
+ -
+
+
+ Min
+
+
+
+ -
+
+
+ Qt::AlignmentFlag::AlignCenter
+
+
+ -9999.000000000000000
+
+
+ 9999.000000000000000
+
+
+
+ -
+
+
+ -
+
+
+ Log
+
+
+
+ -
+
+
+ Label
+
+
+
+ -
+
+
+ Max
+
+
+
+ -
+
+
+ Grid
+
+
+
+ -
+
+
+ false
+
+
+
+ -
+
+
+ false
+
+
+
+
+
+
+ -
+
+
+ false
+
+
+
+ -
+
+
+ Inner Axes
+
+
+
+ -
+
+
+
+
+
+
+ ToggleSwitch
+ QWidget
+
+
+
+
+
+
diff --git a/bec_widgets/widgets/plots_next_gen/toolbar_bundles/__init__.py b/bec_widgets/widgets/plots_next_gen/toolbar_bundles/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bec_widgets/widgets/plots_next_gen/toolbar_bundles/mouse_interactions.py b/bec_widgets/widgets/plots_next_gen/toolbar_bundles/mouse_interactions.py
new file mode 100644
index 00000000..03c72c67
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/toolbar_bundles/mouse_interactions.py
@@ -0,0 +1,88 @@
+import pyqtgraph as pg
+
+from bec_widgets.qt_utils.error_popups import SafeSlot
+from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle
+
+
+class MouseInteractionToolbarBundle(ToolbarBundle):
+ """
+ A bundle of actions that are hooked in this constructor itself,
+ so that you can immediately connect the signals and toggle states.
+
+ This bundle is for a toolbar that controls mouse interactions on a plot.
+ """
+
+ def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
+ super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
+ self.target_widget = target_widget
+
+ # Create each MaterialIconAction with a parent
+ # so the signals can fire even if the toolbar isn't added yet.
+ drag = MaterialIconAction(
+ icon_name="drag_pan",
+ tooltip="Drag Mouse Mode",
+ checkable=True,
+ parent=self.target_widget, # or any valid parent
+ )
+ rect = MaterialIconAction(
+ icon_name="frame_inspect",
+ tooltip="Rectangle Zoom Mode",
+ checkable=True,
+ parent=self.target_widget,
+ )
+ auto = MaterialIconAction(
+ icon_name="open_in_full",
+ tooltip="Autorange Plot",
+ checkable=False,
+ parent=self.target_widget,
+ )
+ aspect_ratio = MaterialIconAction(
+ icon_name="aspect_ratio",
+ tooltip="Lock image aspect ratio",
+ checkable=True,
+ parent=self.target_widget,
+ )
+
+ # Add them to the bundle
+ self.add_action("drag_mode", drag)
+ self.add_action("rectangle_mode", rect)
+ self.add_action("auto_range", auto)
+ self.add_action("aspect_ratio", aspect_ratio)
+
+ # Immediately connect signals
+ drag.action.toggled.connect(self.enable_mouse_pan_mode)
+ rect.action.toggled.connect(self.enable_mouse_rectangle_mode)
+ auto.action.triggered.connect(self.autorange_plot)
+ aspect_ratio.action.toggled.connect(self.lock_aspect_ratio)
+
+ @SafeSlot(bool)
+ def enable_mouse_rectangle_mode(self, checked: bool):
+ """
+ Enable the rectangle zoom mode on the plot widget.
+ """
+ self.actions["drag_mode"].action.setChecked(not checked)
+ if self.target_widget and checked:
+ self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
+
+ @SafeSlot(bool)
+ def enable_mouse_pan_mode(self, checked: bool):
+ """
+ Enable the pan mode on the plot widget.
+ """
+ self.actions["rectangle_mode"].action.setChecked(not checked)
+ if self.target_widget and checked:
+ self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
+
+ @SafeSlot()
+ def autorange_plot(self):
+ """
+ Enable autorange on the plot widget.
+ """
+ if self.target_widget:
+ self.target_widget.auto_range_x = True
+ self.target_widget.auto_range_y = True
+
+ @SafeSlot(bool)
+ def lock_aspect_ratio(self, checked: bool):
+ if self.target_widget:
+ self.target_widget.lock_aspect_ratio = checked
diff --git a/bec_widgets/widgets/plots_next_gen/toolbar_bundles/plot_export.py b/bec_widgets/widgets/plots_next_gen/toolbar_bundles/plot_export.py
new file mode 100644
index 00000000..887a4317
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/toolbar_bundles/plot_export.py
@@ -0,0 +1,63 @@
+from pyqtgraph.exporters import MatplotlibExporter
+
+from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility
+from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle
+
+
+class PlotExportBundle(ToolbarBundle):
+ """
+ A bundle of actions that are hooked in this constructor itself,
+ so that you can immediately connect the signals and toggle states.
+
+ This bundle is for a toolbar that controls exporting a plot.
+ """
+
+ def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
+ super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
+ self.target_widget = target_widget
+
+ # Create each MaterialIconAction with a parent
+ # so the signals can fire even if the toolbar isn't added yet.
+ save = MaterialIconAction(
+ icon_name="save", tooltip="Open Export Dialog", parent=self.target_widget
+ )
+ matplotlib = MaterialIconAction(
+ icon_name="photo_library", tooltip="Open Matplotlib Dialog", parent=self.target_widget
+ )
+
+ # Add them to the bundle
+ self.add_action("save", save)
+ self.add_action("matplotlib", matplotlib)
+
+ # Immediately connect signals
+ save.action.triggered.connect(self.export_dialog)
+ matplotlib.action.triggered.connect(self.matplotlib_dialog)
+
+ @SafeSlot()
+ def export_dialog(self):
+ """
+ Open the export dialog for the plot widget.
+ """
+ if self.target_widget:
+ scene = self.target_widget.plot_item.scene()
+ scene.contextMenuItem = self.target_widget.plot_item
+ scene.showExportDialog()
+
+ @SafeSlot()
+ def matplotlib_dialog(self):
+ """
+ Export the plot widget to Matplotlib.
+ """
+ if self.target_widget:
+ try:
+ import matplotlib as mpl
+
+ MatplotlibExporter(self.target_widget.plot_item).export()
+ except:
+ warning_util = WarningPopupUtility()
+ warning_util.show_warning(
+ title="Matplotlib not installed",
+ message="Matplotlib is required for this feature.",
+ detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.",
+ )
+ return
diff --git a/bec_widgets/widgets/plots_next_gen/toolbar_bundles/save_state.py b/bec_widgets/widgets/plots_next_gen/toolbar_bundles/save_state.py
new file mode 100644
index 00000000..6a7bbe7a
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/toolbar_bundles/save_state.py
@@ -0,0 +1,48 @@
+from bec_widgets.qt_utils.error_popups import SafeSlot
+from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle
+
+
+class SaveStateBundle(ToolbarBundle):
+ """
+ A bundle of actions that are hooked in this constructor itself,
+ so that you can immediately connect the signals and toggle states.
+
+ This bundle is for a toolbar that controls saving the state of the widget.
+ """
+
+ def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
+ super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
+ self.target_widget = target_widget
+
+ # Create each MaterialIconAction with a parent
+ # so the signals can fire even if the toolbar isn't added yet.
+ save_state = MaterialIconAction(
+ icon_name="download", tooltip="Save Widget State", parent=self.target_widget
+ )
+ load_state = MaterialIconAction(
+ icon_name="upload", tooltip="Load Widget State", parent=self.target_widget
+ )
+
+ # Add them to the bundle
+ self.add_action("save", save_state)
+ self.add_action("matplotlib", load_state)
+
+ # Immediately connect signals
+ save_state.action.triggered.connect(self.save_state_dialog)
+ load_state.action.triggered.connect(self.load_state_dialog)
+
+ @SafeSlot()
+ def save_state_dialog(self):
+ """
+ Open the export dialog to save a state of the widget.
+ """
+ if self.target_widget:
+ self.target_widget.state_manager.save_state()
+
+ @SafeSlot()
+ def load_state_dialog(self):
+ """
+ Load a saved state of the widget.
+ """
+ if self.target_widget:
+ self.target_widget.state_manager.load_state()
diff --git a/tests/unit_tests/test_axis_settings.py b/tests/unit_tests/test_axis_settings.py
new file mode 100644
index 00000000..7cd0d170
--- /dev/null
+++ b/tests/unit_tests/test_axis_settings.py
@@ -0,0 +1,105 @@
+import pytest
+from qtpy.QtWidgets import QDoubleSpinBox, QLineEdit
+
+from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
+from bec_widgets.widgets.plots_next_gen.setting_menus.axis_settings import AxisSettings
+from tests.unit_tests.client_mocks import mocked_client
+from tests.unit_tests.conftest import create_widget
+
+
+@pytest.fixture
+def axis_settings_fixture(qtbot, mocked_client):
+ """
+ Creates an AxisSettings widget, targeting the real PlotBase widget.
+ """
+
+ plot_base = create_widget(qtbot, PlotBase, client=mocked_client)
+ axis_settings = create_widget(qtbot, AxisSettings, parent=None, target_widget=plot_base)
+ return axis_settings, plot_base
+
+
+def test_axis_settings_init(axis_settings_fixture):
+ """
+ Ensure AxisSettings constructs properly with a real PlotBase target.
+ """
+ axis_settings, plot_base = axis_settings_fixture
+ # Verify the UI was loaded and placed in a scroll area
+ assert axis_settings.ui is not None
+ assert axis_settings.scroll_area is not None
+ assert axis_settings.layout.count() == 1 # scroll area
+ # Check the target
+ assert axis_settings.target_widget == plot_base
+
+
+def test_change_ui_updates_plot_base(axis_settings_fixture, qtbot):
+ """
+ When user edits AxisSettings UI fields, verify that PlotBase's properties update.
+ """
+ axis_settings, plot_base = axis_settings_fixture
+
+ # 1) Set the 'title'
+ title_edit = axis_settings.ui.title
+ assert isinstance(title_edit, QLineEdit)
+ with qtbot.waitSignal(plot_base.property_changed, timeout=500) as signal:
+ title_edit.setText("New Plot Title")
+
+ assert signal.args == ["title", "New Plot Title"]
+ assert plot_base.title == "New Plot Title"
+
+ # 2) Set x_min spinbox
+ x_max_spin = axis_settings.ui.x_max
+ assert isinstance(x_max_spin, QDoubleSpinBox)
+ with qtbot.waitSignal(plot_base.property_changed, timeout=500) as signal2:
+ x_max_spin.setValue(123)
+ assert plot_base.x_max == 123
+
+ # # 3) Toggle grid
+ x_log_toggle = axis_settings.ui.x_log
+ x_log_toggle.checked = True
+ with qtbot.waitSignal(plot_base.property_changed, timeout=500) as signal3:
+ x_log_toggle.checked = True
+
+ assert plot_base.x_log is True
+
+
+def test_plot_base_updates_ui(axis_settings_fixture, qtbot):
+ """
+ When PlotBase properties change (on the Python side), AxisSettings UI should update.
+ We do this by simulating that PlotBase sets properties and emits property_changed.
+ (In real usage, PlotBase calls .property_changed.emit(...) in its setters.)
+ """
+ axis_settings, plot_base = axis_settings_fixture
+
+ # 1) Set plot_base.title
+ plot_base.title = "Plot Title from Code"
+ assert axis_settings.ui.title.text() == "Plot Title from Code"
+
+ # 2) Set x_max
+ plot_base.x_max = 100
+ qtbot.wait(50)
+ assert axis_settings.ui.x_max.value() == 100
+
+ # 3) Set x_log
+ plot_base.x_log = True
+ qtbot.wait(50)
+ assert axis_settings.ui.x_log.checked is True
+
+
+def test_no_crash_no_target(qtbot):
+ """
+ AxisSettings can be created with target_widget=None. It won't update anything,
+ but it shouldn't crash on UI changes.
+ """
+ axis_settings = create_widget(qtbot, AxisSettings, parent=None, target_widget=None)
+
+ axis_settings.ui.title.setText("No target")
+ assert axis_settings.ui.title.text() == "No target"
+
+
+def test_scroll_area_behavior(axis_settings_fixture, qtbot):
+ """
+ Optional: Check that the QScrollArea is set up in a resizable manner.
+ """
+ axis_settings, plot_base = axis_settings_fixture
+ scroll_area = axis_settings.scroll_area
+ assert scroll_area.widgetResizable() is True
diff --git a/tests/unit_tests/test_plot_base_next_gen.py b/tests/unit_tests/test_plot_base_next_gen.py
new file mode 100644
index 00000000..cac364bd
--- /dev/null
+++ b/tests/unit_tests/test_plot_base_next_gen.py
@@ -0,0 +1,249 @@
+from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
+
+from .client_mocks import mocked_client
+from .conftest import create_widget
+
+
+def test_init_plot_base(qtbot, mocked_client):
+ """
+ Test that PlotBase initializes without error and has expected default states.
+ """
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+ assert pb.objectName() == "PlotBase"
+ # The default title/labels should be empty
+ assert pb.title == ""
+ assert pb.x_label == ""
+ assert pb.y_label == ""
+ # By default, no crosshair or FPS monitor
+ assert pb.crosshair is None
+ assert pb.fps_monitor is None
+ # The side panel was created
+ assert pb.side_panel is not None
+ # The toolbar was created
+ assert pb.toolbar is not None
+
+
+def test_set_title_emits_signal(qtbot, mocked_client):
+ """
+ Test that setting the title updates the plot and emits a property_changed signal.
+ """
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as signal:
+ pb.title = "My Plot Title"
+ # The signal should carry ("title", "My Plot Title")
+ assert signal.args == ["title", "My Plot Title"]
+ assert pb.plot_item.titleLabel.text == "My Plot Title"
+
+ # Get the property back from the object
+ assert pb.title == "My Plot Title"
+
+
+def test_set_x_label_emits_signal(qtbot, mocked_client):
+ """
+ Test setting x_label updates the plot and emits a property_changed signal.
+ """
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as signal:
+ pb.x_label = "Voltage (V)"
+ assert signal.args == ["x_label", "Voltage (V)"]
+ assert pb.x_label == "Voltage (V)"
+ assert pb.plot_item.getAxis("bottom").labelText == "Voltage (V)"
+
+
+def test_set_y_label_emits_signal(qtbot, mocked_client):
+ """
+ Test setting y_label updates the plot and emits a property_changed signal.
+ """
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as signal:
+ pb.y_label = "Current (A)"
+ assert signal.args == ["y_label", "Current (A)"]
+ assert pb.y_label == "Current (A)"
+ assert pb.plot_item.getAxis("left").labelText == "Current (A)"
+
+
+def test_set_x_min_max(qtbot, mocked_client):
+ """
+ Test setting x_min, x_max changes the actual X-range of the plot
+ and emits signals.
+ """
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+ # Set x_max
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as sig_max:
+ pb.x_max = 50
+ assert pb.x_max == 50.0
+
+ # Set x_min
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as sig_min:
+ pb.x_min = 5
+ assert pb.x_min == 5.0
+
+ # Confirm the actual ViewBox range in pyqtgraph
+ assert pb.plot_item.vb.viewRange()[0] == [5.0, 50.0]
+
+
+def test_set_y_min_max(qtbot, mocked_client):
+ """
+ Test setting y_min, y_max changes the actual Y-range of the plot
+ """
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as sig_max:
+ pb.y_max = 100
+ assert pb.y_max == 100.0
+
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as sig_min:
+ pb.y_min = 10
+ assert pb.y_min == 10.0
+
+ # Confirm the actual ViewBox range
+ assert pb.plot_item.vb.viewRange()[1] == [10.0, 100.0]
+
+
+def test_auto_range_x_y(qtbot, mocked_client):
+ """
+ Test enabling and disabling autoRange for x and y axes.
+ """
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+ # auto_range_x = True
+ pb.auto_range_x = True
+ assert pb.plot_item.vb.state["autoRange"][0] is True
+ pb.auto_range_y = True
+ assert pb.plot_item.vb.state["autoRange"][1] is True
+ # Turn off
+ pb.auto_range_x = False
+ assert pb.plot_item.vb.state["autoRange"][0] is False
+ pb.auto_range_y = False
+ assert pb.plot_item.vb.state["autoRange"][1] is False
+
+
+def test_x_log_y_log(qtbot, mocked_client):
+ """
+ Test toggling log scale on x and y axes.
+ """
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as sig1:
+ pb.x_log = True
+ assert pb.plot_item.vb.state["logMode"][0] is True
+
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as sig2:
+ pb.x_log = False
+ assert pb.plot_item.vb.state["logMode"][0] is False
+
+ # Y log
+ pb.y_log = True
+ assert pb.plot_item.vb.state["logMode"][1] is True
+ pb.y_log = False
+ assert pb.plot_item.vb.state["logMode"][1] is False
+
+
+def test_grid(qtbot, mocked_client):
+ """
+ Test x_grid and y_grid toggles.
+ """
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+ # By default, might be off
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as sigx:
+ pb.x_grid = True
+ assert sigx.args == ["x_grid", True]
+ # Confirm in pyqtgraph
+ assert pb.plot_item.ctrl.xGridCheck.isChecked() is True
+
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as sigy:
+ pb.y_grid = True
+ assert sigy.args == ["y_grid", True]
+ assert pb.plot_item.ctrl.yGridCheck.isChecked() is True
+
+
+def test_lock_aspect_ratio(qtbot, mocked_client):
+ """
+ Test locking and unlocking the aspect ratio.
+ """
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+ # default is unlocked
+ assert bool(pb.plot_item.vb.getState()["aspectLocked"]) is False
+
+ pb.lock_aspect_ratio = True
+ assert bool(pb.plot_item.vb.getState()["aspectLocked"]) is True
+
+ pb.lock_aspect_ratio = False
+ assert bool(pb.plot_item.vb.getState()["aspectLocked"]) is False
+
+
+def test_inner_axes_toggle(qtbot, mocked_client):
+ """
+ Test the 'inner_axes' property, which shows/hides bottom and left axes.
+ """
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as sig_off:
+ pb.inner_axes = False
+ assert sig_off.args == ["inner_axes", False]
+ assert pb.plot_item.getAxis("bottom").isVisible() is False
+ assert pb.plot_item.getAxis("left").isVisible() is False
+
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as sig_on:
+ pb.inner_axes = True
+ assert sig_on.args == ["inner_axes", True]
+ assert pb.plot_item.getAxis("bottom").isVisible() is True
+ assert pb.plot_item.getAxis("left").isVisible() is True
+
+
+def test_outer_axes_toggle(qtbot, mocked_client):
+ """
+ Test the 'outer_axes' property, which shows/hides top and right axes.
+ """
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as sig_on:
+ pb.outer_axes = True
+ assert sig_on.args == ["outer_axes", True]
+ assert pb.plot_item.getAxis("top").isVisible() is True
+ assert pb.plot_item.getAxis("right").isVisible() is True
+
+ with qtbot.waitSignal(pb.property_changed, timeout=500) as sig_off:
+ pb.outer_axes = False
+ assert sig_off.args == ["outer_axes", False]
+ assert pb.plot_item.getAxis("top").isVisible() is False
+ assert pb.plot_item.getAxis("right").isVisible() is False
+
+
+def test_crosshair_hook_unhook(qtbot, mocked_client):
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+ assert pb.crosshair is None
+ # Hook
+ pb.hook_crosshair()
+ assert pb.crosshair is not None
+ # Unhook
+ pb.unhook_crosshair()
+ assert pb.crosshair is None
+
+ # toggle
+ pb.toggle_crosshair()
+ assert pb.crosshair is not None
+ pb.toggle_crosshair()
+ assert pb.crosshair is None
+
+
+def test_set_method(qtbot, mocked_client):
+ """
+ Test using the set(...) convenience method to update multiple properties at once.
+ """
+ pb = create_widget(qtbot, PlotBase, client=mocked_client)
+ pb.set(
+ title="Multi Set Title",
+ x_label="Voltage",
+ y_label="Current",
+ x_grid=True,
+ y_grid=True,
+ x_log=True,
+ outer_axes=True,
+ )
+
+ assert pb.title == "Multi Set Title"
+ assert pb.x_label == "Voltage"
+ assert pb.y_label == "Current"
+ assert pb.x_grid is True
+ assert pb.y_grid is True
+ assert pb.x_log is True
+ assert pb.outer_axes is True