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 +
toggle_switch
+
+
+ + +
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 +
toggle_switch
+
+
+ + +
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