1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-18 22:35:38 +02:00

Compare commits

..

3 Commits

140 changed files with 1053 additions and 15817 deletions

View File

@@ -78,9 +78,9 @@ formatter:
stage: Formatter
needs: []
script:
- pip install bec_lib[dev]
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
- pip install black isort
- isort --check --diff ./
- black --check --diff --color ./
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
@@ -148,7 +148,7 @@ tests:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyside6]
- pip install -e .[dev,pyqt6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
@@ -172,6 +172,7 @@ test-matrix:
- "3.12"
QT_PCKG:
- "pyside6"
- "pyqt6"
stage: AdditionalTests
needs: []
@@ -210,7 +211,7 @@ end-2-end-conda:
- cd ../
- pip install -e ./ophyd_devices
- pip install -e .[dev,pyside6]
- pip install -e .[dev,pyqt6]
- cd ./tests/end-2-end
- pytest -v --start-servers --flush-redis --random-order

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,12 @@
# BEC Widgets
**⚠️ Important Notice:**
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
## Installation
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
```bash
pip install bec_widgets[pyside6]
pip install bec_widgets PyQt6
```
For development purposes, you can clone the repository and install the package locally in editable mode:
@@ -19,12 +14,22 @@ For development purposes, you can clone the repository and install the package l
```bash
git clone https://gitlab.psi.ch/bec/bec-widgets
cd bec_widgets
pip install -e .[dev,pyside6]
pip install -e .[dev,pyqt6]
```
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
specified.
BEC Widgets currently supports both Pyside6 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
Python Qt distributions manually.
To select a specific Python Qt distribution, install the package with an additional tag:
```bash
pip install bec_widgets[pyqt6]
```
or
```bash
pip install bec_widgets[pyside6]
```
## Documentation
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
@@ -34,7 +39,7 @@ Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs
All commits should use the Angular commit scheme:
> #### <a name="commit-header"></a>Angular Commit Message Header
>
>
> ```
> <type>(<scope>): <short summary>
> │ │ │
@@ -48,13 +53,13 @@ All commits should use the Angular commit scheme:
>
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
> ```
>
>
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
> ##### Type
>
>
> Must be one of the following:
>
>
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
> * **docs**: Documentation only changes
@@ -66,5 +71,4 @@ All commits should use the Angular commit scheme:
## License
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)

View File

@@ -0,0 +1,100 @@
import os
import numpy as np
import pyqtgraph as pg
import requests
import sys
from PySide6.QtCore import Signal, Slot
from qtpy.QtCore import QSize
from qtpy.QtGui import QActionGroup, QIcon
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
import bec_widgets
from bec_lib.client import BECClient
from bec_lib.service_config import ServiceConfig
from bec_widgets.examples.general_app.web_links import BECWebLinksMixin
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.ui_loader import UILoader
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class TomcatApp(QMainWindow, BECWidget):
select_slice = Signal()
def __init__(self, parent=None, client=None, gui_id=None):
super(TomcatApp, self).__init__(parent)
BECWidget.__init__(self, client=client, gui_id=gui_id)
ui_file_path = os.path.join(os.path.dirname(__file__), "tomcat_app.ui")
self.load_ui(ui_file_path)
self.resize(1280, 720)
self.get_bec_shortcuts()
self.bec_dispatcher.connect_slot(self.test_connection, "GPU Fastapi message")
self.bec_dispatcher.connect_slot(self.status_update, "GPU Fastapi message")
self.ui.slider_select.valueChanged.connect(self.select_slice_from_slider)
self.proxy_slider = pg.SignalProxy(self.select_slice, rateLimit=2, slot=self.send_slice)
self.image_widget = self.ui.image_widget
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
def status_update(self, msg):
status = msg["data"]["GPU SVC Status"]
if status == "Running":
self.ui.radio_io.setChecked(True)
else:
self.ui.radio_io.setChecked(False)
# @SafeSlot(dict, dict)
def test_connection(self, msg):
print("Test Connection")
print(msg)
# print(metadata)
def select_slice_from_slider(self, value):
print(value)
self.select_slice.emit()
@Slot()
def send_slice(self):
value = self.ui.slider_select.value()
requests.post(
"http://ra-gpu-006:8000/api/v1/reco/single_slice",
json={"slice": value, "rot_center": 0},
)
print(f"Sending slice {value}")
def main(): # pragma: no cover
app = QApplication(sys.argv)
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"), size=QSize(48, 48)
)
app.setWindowIcon(icon)
config = ServiceConfig(redis={"host": "ra-gpu-006", "port": 6379})
client = BECClient(config=config, connector_cls=QtRedisConnector)
main_window = TomcatApp(client=client)
main_window.show()
main_window.image_widget.image("RecoPreview")
# custom_data = np.random.rand(100, 100)
# main_window.image_widget._image.add_custom_image("custom", custom_data)
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,278 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>631</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="BECImageWidget" name="image_widget"/>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>29</height>
</rect>
</property>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<widget class="QDockWidget" name="status_dock">
<property name="windowTitle">
<string>Status</string>
</property>
<attribute name="dockWidgetArea">
<number>1</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents">
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label_7">
<property name="text">
<string>Dataset loaded</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QRadioButton" name="radio_dataset">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>I/O Service</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QRadioButton" name="radio_io">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QLineEdit" name="lineEdit_2"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_10">
<property name="text">
<string>Reco Service</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QRadioButton" name="radioButton_4">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QLabel" name="label_11">
<property name="text">
<string>reco-adress</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_9">
<property name="text">
<string>Streaming Service</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QRadioButton" name="radioButton_3">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QLabel" name="label_12">
<property name="text">
<string>stream-adress</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="dock_data">
<property name="windowTitle">
<string>Dataset</string>
</property>
<attribute name="dockWidgetArea">
<number>1</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_2">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Input Dataset (full HDF5 path)</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="line_edit_dataset"/>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QPushButton" name="button_load">
<property name="text">
<string>Load Dataset</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="button_close">
<property name="text">
<string>Close Dataset</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Single Slice Live Preview</string>
</property>
</widget>
</item>
<item>
<widget class="ToggleSwitch" name="toggle_preview"/>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="label_3">
<property name="text">
<string>Select Slice</string>
</property>
</widget>
</item>
<item>
<widget class="QSlider" name="slider_select">
<property name="maximum">
<number>500</number>
</property>
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_4">
<property name="text">
<string>Current Index</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_5">
<property name="text">
<string>Rotation center (offset from center)</string>
</property>
</widget>
</item>
<item>
<widget class="QSlider" name="slider_rotation">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_6">
<property name="text">
<string>Rotation Index</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QPushButton" name="button_reconstruct">
<property name="text">
<string>Reconstruct</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="button_single">
<property name="text">
<string>Single Slice</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QPushButton" name="button_async">
<property name="text">
<string>Sample Async task</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</widget>
<customwidgets>
<customwidget>
<class>BECImageWidget</class>
<extends>QWidget</extends>
<header>bec_image_widget</header>
</customwidget>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>slider_select</sender>
<signal>valueChanged(int)</signal>
<receiver>label_4</receiver>
<slot>setNum(int)</slot>
<hints>
<hint type="sourcelabel">
<x>61</x>
<y>396</y>
</hint>
<hint type="destinationlabel">
<x>73</x>
<y>425</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -31,11 +31,8 @@ class Widgets(str, enum.Enum):
DeviceComboBox = "DeviceComboBox"
DeviceLineEdit = "DeviceLineEdit"
LMFitDialog = "LMFitDialog"
LogPanel = "LogPanel"
Minesweeper = "Minesweeper"
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
PositionerBox2D = "PositionerBox2D"
PositionerControlLine = "PositionerControlLine"
ResetButton = "ResetButton"
ResumeButton = "ResumeButton"
@@ -2996,140 +2993,6 @@ class BECWaveformWidget(RPCBase):
"""
class Curve(RPCBase):
@rpc_call
def remove(self):
"""
Remove the curve from the plot.
"""
@property
@rpc_call
def dap_params(self):
"""
None
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def set(self, **kwargs):
"""
Set the properties of the curve.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- color: str
- symbol: str
- symbol_color: str
- symbol_size: int
- pen_width: int
- pen_style: Literal["solid", "dash", "dot", "dashdot"]
"""
@rpc_call
def set_data(self, x, y):
"""
None
"""
@rpc_call
def set_color(self, color: "str", symbol_color: "Optional[str]" = None):
"""
Change the color of the curve.
Args:
color(str): Color of the curve.
symbol_color(str, optional): Color of the symbol. Defaults to None.
"""
@rpc_call
def set_color_map_z(self, colormap: "str"):
"""
Set the colormap for the scatter plot z gradient.
Args:
colormap(str): Colormap for the scatter plot.
"""
@rpc_call
def set_symbol(self, symbol: "str"):
"""
Change the symbol of the curve.
Args:
symbol(str): Symbol of the curve.
"""
@rpc_call
def set_symbol_color(self, symbol_color: "str"):
"""
Change the symbol color of the curve.
Args:
symbol_color(str): Color of the symbol.
"""
@rpc_call
def set_symbol_size(self, symbol_size: "int"):
"""
Change the symbol size of the curve.
Args:
symbol_size(int): Size of the symbol.
"""
@rpc_call
def set_pen_width(self, pen_width: "int"):
"""
Change the pen width of the curve.
Args:
pen_width(int): Width of the pen.
"""
@rpc_call
def set_pen_style(self, pen_style: "Literal['solid', 'dash', 'dot', 'dashdot']"):
"""
Change the pen style of the curve.
Args:
pen_style(Literal["solid", "dash", "dot", "dashdot"]): Style of the pen.
"""
@rpc_call
def get_data(self) -> "tuple[np.ndarray, np.ndarray]":
"""
Get the data of the curve.
Returns:
tuple[np.ndarray,np.ndarray]: X and Y data of the curve.
"""
@property
@rpc_call
def dap_params(self):
"""
None
"""
class DapComboBox(RPCBase):
@rpc_call
def select_y_axis(self, y_axis: str):
@@ -3318,29 +3181,6 @@ class LMFitDialog(RPCBase):
"""
class LogPanel(RPCBase):
@rpc_call
def set_plain_text(self, text: str) -> None:
"""
Set the plain text of the widget.
Args:
text (str): The text to set.
"""
@rpc_call
def set_html_text(self, text: str) -> None:
"""
Set the HTML text of the widget.
Args:
text (str): The text to set.
"""
class Minesweeper(RPCBase): ...
class PositionIndicator(RPCBase):
@rpc_call
def set_value(self, position: float):
@@ -3391,51 +3231,6 @@ class PositionerBox(RPCBase):
"""
class PositionerBox2D(RPCBase):
@rpc_call
def set_positioner_hor(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
@rpc_call
def set_positioner_ver(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
class PositionerBoxBase(RPCBase):
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class PositionerControlLine(RPCBase):
@rpc_call
def set_positioner(self, positioner: "str | Positioner"):

View File

@@ -43,21 +43,14 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
def generate_client(self, class_container: BECClassContainer):
"""
Generate the client for the published classes, skipping any classes
that have `RPC = False`.
Generate the client for the published classes.
Args:
class_container: The class container with the classes to generate the client for.
"""
# Filter out classes that explicitly have RPC=False
rpc_top_level_classes = [
cls for cls in class_container.rpc_top_level_classes if getattr(cls, "RPC", True)
]
rpc_top_level_classes = class_container.rpc_top_level_classes
rpc_top_level_classes.sort(key=lambda x: x.__name__)
connector_classes = [
cls for cls in class_container.connector_classes if getattr(cls, "RPC", True)
]
connector_classes = class_container.connector_classes
connector_classes.sort(key=lambda x: x.__name__)
self.write_client_enum(rpc_top_level_classes)
@@ -88,13 +81,13 @@ class Widgets(str, enum.Enum):
class_name = cls.__name__
if class_name == "BECDockArea":
# Generate the content
if cls.__name__ == "BECDockArea":
self.content += f"""
class {class_name}(RPCBase):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
if not cls.USER_ACCESS:
self.content += """...
"""
@@ -107,10 +100,8 @@ class {class_name}(RPCBase):"""
method = method.split(".setter")[0]
if obj is None:
raise AttributeError(
f"Method {method} not found in class {cls.__name__}. "
f"Please check the USER_ACCESS list."
f"Method {method} not found in class {cls.__name__}. Please check the USER_ACCESS list."
)
if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties
if is_property_setter:

View File

@@ -218,11 +218,15 @@ def main():
args = parser.parse_args()
bec_logger.level = bec_logger.LOGLEVEL.INFO
if args.hide:
# if we start hidden, it means we are under control of the client
# -> set the log level to critical to not see all the messages
# pylint: disable=protected-access
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
bec_logger._update_sinks()
# bec_logger._stderr_log_level = bec_logger.LOGLEVEL.CRITICAL
bec_logger.level = bec_logger.LOGLEVEL.CRITICAL
else:
# verbose log
bec_logger.level = bec_logger.LOGLEVEL.DEBUG
if args.gui_class == "BECDockArea":
gui_class = BECDockArea

View File

@@ -17,12 +17,9 @@ from qtpy.QtWidgets import (
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.expantion_panel.expansion_panel import ExpansionPanel
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
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
class JupyterConsoleWindow(QWidget): # pragma: no cover:
@@ -65,10 +62,6 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"btn4": self.btn4,
"btn5": self.btn5,
"btn6": self.btn6,
"pb": self.pb,
"pi": self.pi,
"wfng": self.wfng,
"ep": self.ep,
}
)
@@ -99,15 +92,6 @@ 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, "PlotBase")
tab_widget.setCurrentIndex(3)
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
@@ -121,26 +105,6 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.btn5 = QPushButton("Button 5")
self.btn6 = QPushButton("Button 6")
fifth_tab = QWidget()
fifth_tab_layout = QVBoxLayout(fifth_tab)
self.wfng = Waveform()
fifth_tab_layout.addWidget(self.wfng)
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
tab_widget.setCurrentIndex(4)
# add stuff to the new Waveform widget
self._init_waveform()
six_tab = QWidget()
six_tab_layout = QVBoxLayout(six_tab)
self.ep = ExpansionPanel()
self.ep.content_layout.addWidget(self.btn1)
self.ep.content_layout.addWidget(self.btn2)
self.ep.content_layout.addWidget(self.btn3)
self.ep.content_layout.addWidget(self.btn4)
six_tab_layout.addWidget(self.ep)
tab_widget.addTab(six_tab, "Exp Panel")
tab_widget.setCurrentIndex(5)
# add stuff to figure
self._init_figure()
@@ -149,13 +113,6 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.setWindowTitle("Jupyter Console Window")
def _init_waveform(self):
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve1")
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve2")
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve3")
self.wfng.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
self.wfng.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
def _init_figure(self):
self.w1 = self.figure.plot(x_name="samx", y_name="bpm4i", row=0, col=0)
self.w1.set(
@@ -222,11 +179,9 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.im.image("waveform", "1d")
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
self.wf = self.d2.add_widget("BECWaveformWidget", row=0, col=0)
self.wf.plot("bpm4i")
self.wf.plot("bpm3a")
self.wf = self.d2.add_widget("BECFigure", row=0, col=0)
self.mw = None # self.wf.multi_waveform(monitor="waveform") # , config=config)
self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
self.dock.save_state()
@@ -252,7 +207,7 @@ if __name__ == "__main__": # pragma: no cover
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
apply_theme("dark")
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
icon = material_icon("terminal", color="#434343", filled=True)
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher()

View File

@@ -2,90 +2,42 @@ import functools
import sys
import traceback
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
logger = bec_logger.logger
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs):
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, **prop_kwargs):
"""
Decorator to create a Qt Property with safe getter and setter so that
Qt Designer won't crash if an exception occurs in either method.
Decorator to create a Qt Property with a safe setter that won't crash Designer on errors.
Behaves similarly to SafeSlot, but for properties.
Args:
prop_type: The property type (e.g., str, bool, int, custom classes, etc.)
popup_error (bool): If True, show a popup for any error; otherwise, ignore or log silently.
default: Any default/fallback value to return if the getter raises an exception.
*prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor.
Usage:
@SafeProperty(int, default=-1)
def some_value(self) -> int:
# your getter logic
return ... # if an exception is raised, returns -1
@some_value.setter
def some_value(self, val: int):
# your setter logic
...
prop_type: The property type (e.g., str, bool, "QStringList", etc.)
popup_error (bool): If True, show popup on error, otherwise just handle it silently.
*prop_args, **prop_kwargs: Additional arguments and keyword arguments accepted by Property.
"""
def decorator(py_getter):
"""Decorator for the user's property getter function."""
@functools.wraps(py_getter)
def safe_getter(self_):
try:
return py_getter(self_)
except Exception:
# Identify which property function triggered error
prop_name = f"{py_getter.__module__}.{py_getter.__qualname__}"
error_msg = traceback.format_exc()
if popup_error:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
logger.error(f"SafeProperty error in GETTER of '{prop_name}':\n{error_msg}")
return default
def decorator(getter):
class PropertyWrapper:
"""
Intermediate wrapper used so that the user can optionally chain .setter(...).
"""
def __init__(self, getter_func):
# We store only our safe_getter in the wrapper
self.getter_func = safe_getter
self.getter_func = getter_func
def setter(self, setter_func):
"""Wraps the user-defined setter to handle errors safely."""
@functools.wraps(setter_func)
def safe_setter(self_, value):
try:
return setter_func(self_, value)
except Exception:
prop_name = f"{setter_func.__module__}.{setter_func.__qualname__}"
error_msg = traceback.format_exc()
if popup_error:
ErrorPopupUtility().custom_exception_hook(
*sys.exc_info(), popup_error=True
)
logger.error(f"SafeProperty error in SETTER of '{prop_name}':\n{error_msg}")
return
else:
return
# Return the full read/write Property
return Property(prop_type, self.getter_func, safe_setter, *prop_args, **prop_kwargs)
def __call__(self):
"""
If user never calls `.setter(...)`, produce a read-only property.
"""
return Property(prop_type, self.getter_func, None, *prop_args, **prop_kwargs)
return PropertyWrapper(py_getter)
return PropertyWrapper(getter)
return decorator
@@ -106,13 +58,7 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
try:
return method(*args, **kwargs)
except Exception:
slot_name = f"{method.__module__}.{method.__qualname__}"
error_msg = traceback.format_exc()
if popup_error:
ErrorPopupUtility().custom_exception_hook(
*sys.exc_info(), popup_error=popup_error
)
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=popup_error)
return wrapper

View File

@@ -29,7 +29,6 @@ class RoundedFrame(BECWidget, QFrame):
self._radius = radius
# Apply rounded frame styling
self.setProperty("skip_settings", True)
self.setObjectName("roundedFrame")
self.update_style()
@@ -114,12 +113,10 @@ class RoundedFrame(BECWidget, QFrame):
# Apply axis label and tick colors
plot_item = self.content_widget.getPlotItem()
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)
plot_item.getAxis("left").setPen(pg.mkPen(color=axis_color))
plot_item.getAxis("bottom").setPen(pg.mkPen(color=axis_color))
plot_item.getAxis("left").setTextPen(pg.mkPen(color=label_color))
plot_item.getAxis("bottom").setTextPen(pg.mkPen(color=label_color))
# Apply border style via stylesheet
self.content_widget.setStyleSheet(

View File

@@ -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):
@@ -34,13 +34,11 @@ class SidePanel(QWidget):
):
super().__init__(parent=parent)
self.setProperty("skip_settings", True)
self.setObjectName("SidePanel")
self._orientation = orientation
self._panel_max_width = panel_max_width
self._animation_duration = animation_duration
self._animations_enabled = animations_enabled
self._orientation = orientation
self._panel_width = 0
self._panel_height = 0
@@ -70,7 +68,6 @@ 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,10 +77,7 @@ class SidePanel(QWidget):
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
self.menu_anim = QPropertyAnimation(self, b"panel_width")
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.panel_width = 0 # start hidden
self.stack_widget.setMaximumWidth(self._panel_max_width)
else:
self.main_layout = QVBoxLayout(self)
@@ -100,7 +94,6 @@ 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)
@@ -110,46 +103,74 @@ 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."""
"""
Set the panel width.
Args:
width(int): The width of the panel.
"""
self._panel_width = width
if self._orientation in ("left", "right"):
self.stack_widget.setFixedWidth(width)
@Property(int)
def panel_height(self):
"""Get the panel height."""
"""
Get the panel height.
"""
return self._panel_height
@panel_height.setter
def panel_height(self, height: int):
"""Set the panel height."""
"""
Set the panel height.
Args:
height(int): The height of the panel.
"""
self._panel_height = height
if self._orientation in ("top", "bottom"):
self.stack_widget.setFixedHeight(height)
@Property(int)
def panel_max_width(self):
"""Get the maximum width of the panel."""
"""
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."""
"""
Set the maximum width of the panel.
Args:
size(int): The maximum width of the panel.
"""
self._panel_max_width = size
if self._orientation in ("left", "right"):
self.stack_widget.setMaximumWidth(self._panel_max_width)
@@ -158,28 +179,45 @@ 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."""
"""
Set the duration of the animation.
Args:
duration(int): The duration of the animation.
"""
self._animation_duration = duration
self.menu_anim.setDuration(duration)
@Property(bool)
def animations_enabled(self):
"""Get the status of the animations."""
"""
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."""
"""
Set the status of the animations.
Args:
enabled(bool): The status of the animations.
"""
self._animations_enabled = enabled
def show_panel(self, idx: int):
"""
Show the side panel with animation and switch to idx.
Args:
idx(int): The index of the panel to show.
"""
self.stack_widget.setCurrentIndex(idx)
self.panel_visible = True
@@ -227,6 +265,9 @@ 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)
@@ -243,35 +284,21 @@ 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)
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.setSpacing(5)
container_widget.setStyleSheet("background-color: rgba(0,0,0,0);")
title_label = QLabel(f"<b>{title}</b>")
title_label.setStyleSheet("font-size: 16px;")
spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
container_layout.addWidget(title_label)
container_layout.addWidget(widget)
container_layout.addItem(spacer)
container_layout.setContentsMargins(5, 5, 5, 5)
container_layout.setSpacing(5)
# 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)
@@ -299,11 +326,6 @@ class SidePanel(QWidget):
action.action.toggled.connect(on_action_toggled)
############################################
# DEMO APPLICATION
############################################
class ExampleApp(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
@@ -311,24 +333,20 @@ 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()
layout1 = QVBoxLayout(widget1)
for i in range(15):
layout1.addWidget(QLabel(f"Widget 1 label row {i}"))
widget1_layout = QVBoxLayout(widget1)
widget1_layout.addWidget(QLabel("This is Widget 1"))
self.side_panel.add_menu(
action_id="widget1",
icon_name="counter_1",
@@ -338,8 +356,8 @@ class ExampleApp(QMainWindow): # pragma: no cover
)
widget2 = QWidget()
layout2 = QVBoxLayout(widget2)
layout2.addWidget(QLabel("Short widget 2 content"))
widget2_layout = QVBoxLayout(widget2)
widget2_layout.addWidget(QLabel("This is Widget 2"))
self.side_panel.add_menu(
action_id="widget2",
icon_name="counter_2",
@@ -349,9 +367,8 @@ class ExampleApp(QMainWindow): # pragma: no cover
)
widget3 = QWidget()
layout3 = QVBoxLayout(widget3)
for i in range(10):
layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
widget3_layout = QVBoxLayout(widget3)
widget3_layout.addWidget(QLabel("This is Widget 3"))
self.side_panel.add_menu(
action_id="widget3",
icon_name="counter_3",
@@ -364,6 +381,6 @@ class ExampleApp(QMainWindow): # pragma: no cover
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = ExampleApp()
window.resize(1000, 700)
window.resize(800, 600)
window.show()
sys.exit(app.exec())

View File

@@ -2,20 +2,17 @@
from __future__ import annotations
import os
import sys
from abc import ABC, abstractmethod
from collections import defaultdict
from typing import Dict, List, Literal, Tuple
from typing import Literal
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QMainWindow,
QMenu,
QSizePolicy,
QToolBar,
@@ -34,7 +31,7 @@ class ToolBarAction(ABC):
Args:
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
tooltip (str, optional): The tooltip for the action. Defaults to None.
tooltip (bool, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
"""
@@ -84,18 +81,15 @@ class IconAction(ToolBarAction):
toolbar.addAction(self.action)
class MaterialIconAction(ToolBarAction):
class MaterialIconAction:
"""
Action with a Material icon for the toolbar.
Args:
icon_name (str, optional): The name of the Material icon. Defaults to None.
tooltip (str, optional): The tooltip for the action. Defaults to None.
icon_path (str, optional): The name of the Material icon. Defaults to None.
tooltip (bool, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
filled (bool, optional): Whether the icon is filled. Defaults to False.
color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon.
Defaults to None.
parent (QWidget or None, optional): Parent widget for the underlying QAction.
"""
def __init__(
@@ -105,42 +99,30 @@ class MaterialIconAction(ToolBarAction):
checkable: bool = False,
filled: bool = False,
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
parent=None,
):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.icon_name = icon_name
self.tooltip = tooltip
self.checkable = checkable
self.action = None
self.filled = filled
self.color = color
# Generate the icon
self.icon = material_icon(
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
icon = self.get_icon()
self.action = QAction(icon, self.tooltip, target)
self.action.setCheckable(self.checkable)
toolbar.addAction(self.action)
def get_icon(self):
icon = material_icon(
self.icon_name,
size=(20, 20),
convert_to_pixmap=False,
filled=self.filled,
color=self.color,
)
# Immediately create an QAction with the given parent
self.action = QAction(self.icon, self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the action to the toolbar.
Args:
toolbar(QToolBar): The toolbar to add the action to.
target(QWidget): The target widget for the action.
"""
toolbar.addAction(self.action)
def get_icon(self):
"""
Returns the icon for the action.
Returns:
QIcon: The icon for the action.
"""
return self.icon
return icon
class DeviceSelectionAction(ToolBarAction):
@@ -150,6 +132,7 @@ class DeviceSelectionAction(ToolBarAction):
Args:
label (str): The label for the combobox.
device_combobox (DeviceComboBox): The combobox for selecting the device.
"""
def __init__(self, label: str, device_combobox):
@@ -177,6 +160,7 @@ class WidgetAction(ToolBarAction):
Args:
label (str|None): The label for the widget.
widget (QWidget): The widget to be added to the toolbar.
"""
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
@@ -235,6 +219,7 @@ class ExpandableMenuAction(ToolBarAction):
label (str): The label for the menu.
actions (dict): A dictionary of actions to populate the menu.
icon_path (str, optional): The path to the icon file. Defaults to None.
"""
def __init__(self, label: str, actions: dict, icon_path: str = None):
@@ -274,55 +259,6 @@ class ExpandableMenuAction(ToolBarAction):
toolbar.addWidget(button)
class ToolbarBundle:
"""
Represents a bundle of toolbar actions, keyed by action_id.
Allows direct dictionary-like access: self.actions["some_id"] -> ToolBarAction object.
"""
def __init__(self, bundle_id: str = None, actions=None):
"""
Args:
bundle_id (str): Unique identifier for the bundle.
actions: Either None or a list of (action_id, ToolBarAction) tuples.
"""
self.bundle_id = bundle_id
self._actions: dict[str, ToolBarAction] = {}
# If you passed in a list of tuples, load them into the dictionary
if actions is not None:
for action_id, action in actions:
self._actions[action_id] = action
def add_action(self, action_id: str, action: ToolBarAction):
"""
Adds or replaces an action in the bundle.
Args:
action_id (str): Unique identifier for the action.
action (ToolBarAction): The action to add.
"""
self._actions[action_id] = action
def remove_action(self, action_id: str):
"""
Removes an action from the bundle by ID.
Ignores if not present.
Args:
action_id (str): Unique identifier for the action to remove.
"""
self._actions.pop(action_id, None)
@property
def actions(self) -> dict[str, ToolBarAction]:
"""
Return the internal dictionary of actions so that you can do
bundle.actions["drag_mode"] -> ToolBarAction instance.
"""
return self._actions
class ModularToolBar(QToolBar):
"""Modular toolbar with optional automatic initialization.
@@ -351,14 +287,10 @@ class ModularToolBar(QToolBar):
# Set the initial orientation
self.set_orientation(orientation)
# Initialize bundles
self.bundles = {}
self.toolbar_items = []
if actions is not None and target_widget is not None:
self.populate_toolbar(actions, target_widget)
def populate_toolbar(self, actions: dict, target_widget: QWidget):
def populate_toolbar(self, actions: dict, target_widget):
"""Populates the toolbar with a set of actions.
Args:
@@ -366,12 +298,9 @@ class ModularToolBar(QToolBar):
target_widget (QWidget): The widget that the actions will target.
"""
self.clear()
self.toolbar_items.clear() # Reset the order tracking
for action_id, action in actions.items():
action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action
self.toolbar_items.append(("action", action_id))
self.update_separators() # Ensure separators are updated after populating
def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"):
"""
@@ -416,7 +345,7 @@ class ModularToolBar(QToolBar):
def add_action(self, action_id: str, action: ToolBarAction, target_widget: QWidget):
"""
Adds a new standalone action to the toolbar dynamically.
Adds a new action to the toolbar dynamically.
Args:
action_id (str): Unique identifier for the action.
@@ -427,8 +356,6 @@ class ModularToolBar(QToolBar):
raise ValueError(f"Action with ID '{action_id}' already exists.")
action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action
self.toolbar_items.append(("action", action_id))
self.update_separators() # Update separators after adding the action
def hide_action(self, action_id: str):
"""
@@ -442,7 +369,6 @@ class ModularToolBar(QToolBar):
action = self.widgets[action_id]
if hasattr(action, "action") and isinstance(action.action, QAction):
action.action.setVisible(False)
self.update_separators() # Update separators after hiding the action
def show_action(self, action_id: str):
"""
@@ -456,217 +382,3 @@ class ModularToolBar(QToolBar):
action = self.widgets[action_id]
if hasattr(action, "action") and isinstance(action.action, QAction):
action.action.setVisible(True)
self.update_separators() # Update separators after showing the action
def add_bundle(self, bundle: ToolbarBundle, target_widget: QWidget):
"""
Adds a bundle of actions to the toolbar, separated by a separator.
Args:
bundle (ToolbarBundle): The bundle to add.
target_widget (QWidget): The target widget for the actions.
"""
if bundle.bundle_id in self.bundles:
raise ValueError(f"ToolbarBundle with ID '{bundle.bundle_id}' already exists.")
# Add a separator before the bundle (but not to first one)
if self.toolbar_items:
sep = SeparatorAction()
sep.add_to_toolbar(self, target_widget)
self.toolbar_items.append(("separator", None))
# Add each action in the bundle
for action_id, action_obj in bundle.actions.items():
action_obj.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action_obj
# Register the bundle
self.bundles[bundle.bundle_id] = list(bundle.actions.keys())
self.toolbar_items.append(("bundle", bundle.bundle_id))
self.update_separators() # Update separators after adding the bundle
def contextMenuEvent(self, event):
"""
Overrides the context menu event to show a list of toolbar actions with checkboxes and icons, including separators.
Args:
event(QContextMenuEvent): The context menu event.
"""
menu = QMenu(self)
# Iterate through the toolbar items in order
for item_type, identifier in self.toolbar_items:
if item_type == "separator":
menu.addSeparator()
elif item_type == "bundle":
self.handle_bundle_context_menu(menu, identifier)
elif item_type == "action":
self.handle_action_context_menu(menu, identifier)
# Connect the triggered signal after all actions are added
menu.triggered.connect(self.handle_menu_triggered)
menu.exec_(event.globalPos())
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
"""
Adds a set of bundle actions to the context menu.
Args:
menu (QMenu): The context menu to which the actions are added.
bundle_id (str): The identifier for the bundle.
"""
action_ids = self.bundles.get(bundle_id, [])
for act_id in action_ids:
toolbar_action = self.widgets.get(act_id)
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(
toolbar_action, "action"
):
continue
qaction = toolbar_action.action
if not isinstance(qaction, QAction):
continue
display_name = qaction.text() or toolbar_action.tooltip or act_id
menu_action = QAction(display_name, self)
menu_action.setCheckable(True)
menu_action.setChecked(qaction.isVisible())
menu_action.setData(act_id) # Store the action_id
# Set the icon if available
if qaction.icon() and not qaction.icon().isNull():
menu_action.setIcon(qaction.icon())
menu.addAction(menu_action)
def handle_action_context_menu(self, menu: QMenu, action_id: str):
"""
Adds a single toolbar action to the context menu.
Args:
menu (QMenu): The context menu to which the action is added.
action_id (str): Unique identifier for the action.
"""
toolbar_action = self.widgets.get(action_id)
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(toolbar_action, "action"):
return
qaction = toolbar_action.action
if not isinstance(qaction, QAction):
return
display_name = qaction.text() or toolbar_action.tooltip or action_id
menu_action = QAction(display_name, self)
menu_action.setCheckable(True)
menu_action.setChecked(qaction.isVisible())
menu_action.setData(action_id) # Store the action_id
# Set the icon if available
if qaction.icon() and not qaction.icon().isNull():
menu_action.setIcon(qaction.icon())
menu.addAction(menu_action)
def handle_menu_triggered(self, action):
"""Handles the toggling of toolbar actions from the context menu."""
action_id = action.data()
if action_id:
self.toggle_action_visibility(action_id, action.isChecked())
def toggle_action_visibility(self, action_id: str, visible: bool):
"""
Toggles the visibility of a specific action on the toolbar.
Args:
action_id(str): Unique identifier for the action to toggle.
visible(bool): Whether the action should be visible.
"""
if action_id not in self.widgets:
return
tool_action = self.widgets[action_id]
if hasattr(tool_action, "action") and isinstance(tool_action.action, QAction):
tool_action.action.setVisible(visible)
self.update_separators()
def update_separators(self):
"""
Hide separators that are adjacent to another separator or have no actions next to them.
"""
toolbar_actions = self.actions()
for i, action in enumerate(toolbar_actions):
if not action.isSeparator():
continue
# Find the previous visible action
prev_visible = None
for j in range(i - 1, -1, -1):
if toolbar_actions[j].isVisible():
prev_visible = toolbar_actions[j]
break
# Find the next visible action
next_visible = None
for j in range(i + 1, len(toolbar_actions)):
if toolbar_actions[j].isVisible():
next_visible = toolbar_actions[j]
break
# Determine if the separator should be hidden
# Hide if both previous and next visible actions are separators or non-existent
if (prev_visible is None or prev_visible.isSeparator()) and (
next_visible is None or next_visible.isSeparator()
):
action.setVisible(False)
else:
action.setVisible(True)
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
# Create a modular toolbar
self.toolbar = ModularToolBar(parent=self, target_widget=self)
self.addToolBar(self.toolbar)
# Example: Add a single bundle
home_action = MaterialIconAction(
icon_name="home", tooltip="Home", checkable=True, parent=self
)
settings_action = MaterialIconAction(
icon_name="settings", tooltip="Settings", checkable=True, parent=self
)
profile_action = MaterialIconAction(
icon_name="person", tooltip="Profile", checkable=True, parent=self
)
main_actions_bundle = ToolbarBundle(
bundle_id="main_actions",
actions=[
("home_action", home_action),
("settings_action", settings_action),
("profile_action", profile_action),
],
)
self.toolbar.add_bundle(main_actions_bundle, target_widget=self)
# Another bundle
search_action = MaterialIconAction(
icon_name="search", tooltip="Search", checkable=True, parent=self
)
help_action = MaterialIconAction(
icon_name="help", tooltip="Help", checkable=True, parent=self
)
second_bundle = ToolbarBundle(
bundle_id="secondary_actions",
actions=[("search_action", search_action), ("help_action", help_action)],
)
self.toolbar.add_bundle(second_bundle, target_widget=self)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
main_window = MainWindow()
main_window.show()
sys.exit(app.exec_())

View File

@@ -224,11 +224,3 @@ DEVICES = [
Positioner("test", limits=[-10, 10], read_value=2.0),
Device("test_device"),
]
def check_remote_data_size(widget, plot_name, num_elements):
"""
Check if the remote data has the correct number of elements.
Used in the qtbot.waitUntil function.
"""
return len(widget.get_all_data()[plot_name]["x"]) == num_elements

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import os
import time
import uuid
from typing import TYPE_CHECKING, Optional
from typing import Optional
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
@@ -17,9 +17,6 @@ from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
if TYPE_CHECKING:
from bec_widgets.utils.bec_dispatcher import BECDispatcher
logger = bec_logger.logger
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))

View File

@@ -5,43 +5,28 @@ analyse data. Requesting a new fit may lead to request piling up and an overall
will allow you to decide by yourself when to unblock and execute the callback again."""
from pyqtgraph import SignalProxy
from qtpy.QtCore import QTimer, Signal
from bec_widgets.qt_utils.error_popups import SafeSlot
from qtpy.QtCore import Signal, Slot
class BECSignalProxy(SignalProxy):
"""
Thin wrapper around the SignalProxy class to allow signal calls to be blocked,
but arguments still being stored.
"""Thin wrapper around the SignalProxy class to allow signal calls to be blocked, but args still being stored
Args:
*args: Arguments to pass to the SignalProxy class.
rateLimit (int): The rateLimit of the proxy.
timeout (float): The number of seconds after which the proxy automatically
unblocks if still blocked. Default is 10.0 seconds.
**kwargs: Keyword arguments to pass to the SignalProxy class.
*args: Arguments to pass to the SignalProxy class
rateLimit (int): The rateLimit of the proxy
**kwargs: Keyword arguments to pass to the SignalProxy class
Example:
>>> proxy = BECSignalProxy(signal, rate_limit=25, slot=callback)
"""
>>> proxy = BECSignalProxy(signal, rate_limit=25, slot=callback)"""
is_blocked = Signal(bool)
def __init__(self, *args, rateLimit=25, timeout=10.0, **kwargs):
def __init__(self, *args, rateLimit=25, **kwargs):
super().__init__(*args, rateLimit=rateLimit, **kwargs)
self._blocking = False
self.old_args = None
self.new_args = None
# Store timeout value (in seconds)
self._timeout = timeout
# Create a single-shot timer for auto-unblocking
self._timer = QTimer()
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._timeout_unblock)
@property
def blocked(self):
"""Returns if the proxy is blocked"""
@@ -61,22 +46,9 @@ class BECSignalProxy(SignalProxy):
self.old_args = args
super().signalReceived(*args)
self._timer.start(int(self._timeout * 1000))
@SafeSlot()
@Slot()
def unblock_proxy(self):
"""Unblock the proxy, and call the signalReceived method in case there was an update of the args."""
if self.blocked:
self._timer.stop()
self.blocked = False
if self.new_args != self.old_args:
self.signalReceived(*self.new_args)
@SafeSlot()
def _timeout_unblock(self):
"""
Internal method called by the QTimer upon timeout. Unblocks the proxy
automatically if it is still blocked.
"""
if self.blocked:
self.unblock_proxy()
self.blocked = False
if self.new_args != self.old_args:
self.signalReceived(*self.new_args)

View File

@@ -15,8 +15,6 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
class WidgetHandler(ABC):
"""Abstract base class for all widget handlers."""
@@ -127,19 +125,6 @@ class CheckBoxHandler(WidgetHandler):
widget.toggled.connect(lambda val, w=widget: slot(w, val))
class ToggleSwitchHandler(WidgetHandler):
"""Handler for ToggleSwitch widgets."""
def get_value(self, widget, **kwargs):
return widget.checked
def set_value(self, widget, value):
widget.checked = value
def connect_change_signal(self, widget: ToggleSwitch, slot):
widget.enabled.connect(lambda val, w=widget: slot(w, val))
class LabelHandler(WidgetHandler):
"""Handler for QLabel widgets."""
@@ -164,7 +149,6 @@ class WidgetIO:
QDoubleSpinBox: SpinBoxHandler,
QCheckBox: CheckBoxHandler,
QLabel: LabelHandler,
ToggleSwitch: ToggleSwitchHandler,
}
@staticmethod

View File

@@ -1,223 +0,0 @@
from __future__ import annotations
from bec_lib import bec_logger
from qtpy.QtCore import QSettings
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
logger = bec_logger.logger
class WidgetStateManager:
"""
A class to manage the state of a widget by saving and loading the state to and from a INI file.
Args:
widget(QWidget): The widget to manage the state for.
"""
def __init__(self, widget):
self.widget = widget
def save_state(self, filename: str = None):
"""
Save the state of the widget to an INI file.
Args:
filename(str): The filename to save the state to.
"""
if not filename:
filename, _ = QFileDialog.getSaveFileName(
self.widget, "Save Settings", "", "INI Files (*.ini)"
)
if filename:
settings = QSettings(filename, QSettings.IniFormat)
self._save_widget_state_qsettings(self.widget, settings)
def load_state(self, filename: str = None):
"""
Load the state of the widget from an INI file.
Args:
filename(str): The filename to load the state from.
"""
if not filename:
filename, _ = QFileDialog.getOpenFileName(
self.widget, "Load Settings", "", "INI Files (*.ini)"
)
if filename:
settings = QSettings(filename, QSettings.IniFormat)
self._load_widget_state_qsettings(self.widget, settings)
def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
"""
Save the state of the widget to QSettings.
Args:
widget(QWidget): The widget to save the state for.
settings(QSettings): The QSettings object to save the state to.
"""
if widget.property("skip_settings") is True:
return
meta = widget.metaObject()
widget_name = self._get_full_widget_name(widget)
settings.beginGroup(widget_name)
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
if (
name == "objectName"
or not prop.isReadable()
or not prop.isWritable()
or not prop.isStored() # can be extended to fine filter
):
continue
value = widget.property(name)
settings.setValue(name, value)
settings.endGroup()
# Recursively process children (only if they aren't skipped)
for child in widget.children():
if (
child.objectName()
and child.property("skip_settings") is not True
and not isinstance(child, QLabel)
):
self._save_widget_state_qsettings(child, settings)
def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
"""
Load the state of the widget from QSettings.
Args:
widget(QWidget): The widget to load the state for.
settings(QSettings): The QSettings object to load the state from.
"""
if widget.property("skip_settings") is True:
return
meta = widget.metaObject()
widget_name = self._get_full_widget_name(widget)
settings.beginGroup(widget_name)
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
if settings.contains(name):
value = settings.value(name)
widget.setProperty(name, value)
settings.endGroup()
# Recursively process children (only if they aren't skipped)
for child in widget.children():
if (
child.objectName()
and child.property("skip_settings") is not True
and not isinstance(child, QLabel)
):
self._load_widget_state_qsettings(child, settings)
def _get_full_widget_name(self, widget: QWidget):
"""
Get the full name of the widget including its parent names.
Args:
widget(QWidget): The widget to get the full name for.
Returns:
str: The full name of the widget.
"""
name = widget.objectName()
parent = widget.parent()
while parent:
obj_name = parent.objectName() or parent.metaObject().className()
name = obj_name + "." + name
parent = parent.parent()
return name
class ExampleApp(QWidget): # pragma: no cover:
def __init__(self):
super().__init__()
self.setObjectName("MainWindow")
self.setWindowTitle("State Manager Example")
layout = QVBoxLayout(self)
# A line edit to store some user text
self.line_edit = QLineEdit(self)
self.line_edit.setObjectName("MyLineEdit")
self.line_edit.setPlaceholderText("Enter some text here...")
layout.addWidget(self.line_edit)
# A spin box to hold a numeric value
self.spin_box = QSpinBox(self)
self.spin_box.setObjectName("MySpinBox")
self.spin_box.setRange(0, 100)
layout.addWidget(self.spin_box)
# A checkbox to hold a boolean value
self.check_box = QCheckBox("Enable feature?", self)
self.check_box.setObjectName("MyCheckBox")
layout.addWidget(self.check_box)
# A checkbox that we want to skip
self.check_box_skip = QCheckBox("Enable feature - skip save?", self)
self.check_box_skip.setProperty("skip_state", True)
self.check_box_skip.setObjectName("MyCheckBoxSkip")
layout.addWidget(self.check_box_skip)
# CREATE A "SIDE PANEL" with nested structure and skip all what is inside
self.side_panel = QWidget(self)
self.side_panel.setObjectName("SidePanel")
self.side_panel.setProperty("skip_settings", True) # skip the ENTIRE panel
layout.addWidget(self.side_panel)
# Put some sub-widgets inside side_panel
panel_layout = QVBoxLayout(self.side_panel)
self.panel_label = QLabel("Label in side panel", self.side_panel)
self.panel_label.setObjectName("PanelLabel")
panel_layout.addWidget(self.panel_label)
self.panel_edit = QLineEdit(self.side_panel)
self.panel_edit.setObjectName("PanelLineEdit")
self.panel_edit.setPlaceholderText("I am inside side panel")
panel_layout.addWidget(self.panel_edit)
self.panel_checkbox = QCheckBox("Enable feature in side panel?", self.side_panel)
self.panel_checkbox.setObjectName("PanelCheckBox")
panel_layout.addWidget(self.panel_checkbox)
# Save/Load buttons
button_layout = QHBoxLayout()
self.save_button = QPushButton("Save State", self)
self.load_button = QPushButton("Load State", self)
button_layout.addWidget(self.save_button)
button_layout.addWidget(self.load_button)
layout.addLayout(button_layout)
# Create the state manager
self.state_manager = WidgetStateManager(self)
# Connect buttons
self.save_button.clicked.connect(lambda: self.state_manager.save_state())
self.load_button.clicked.connect(lambda: self.state_manager.load_state())
if __name__ == "__main__": # pragma: no cover:
import sys
app = QApplication(sys.argv)
w = ExampleApp()
w.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1 @@

View File

@@ -8,7 +8,7 @@ from pydantic import Field
from pyqtgraph.dockarea.DockArea import DockArea
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QPainter, QPaintEvent
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
from qtpy.QtWidgets import QApplication, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.toolbar import (
@@ -20,18 +20,16 @@ from bec_widgets.qt_utils.toolbar import (
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
from bec_widgets.widgets.plots.motor_map.motor_map_widget import BECMotorMapWidget
from bec_widgets.widgets.plots.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
@@ -107,13 +105,6 @@ class BECDockArea(BECWidget, QWidget):
tooltip="Add Motor Map",
filled=True,
),
"separator_next_gen": SeparatorAction(),
"waveform_ng": MaterialIconAction(
icon_name=Waveform.ICON_NAME,
color="#FFD700",
tooltip="Add Waveform Next Gen",
filled=True,
),
},
),
"separator_0": SeparatorAction(),
@@ -148,9 +139,6 @@ class BECDockArea(BECWidget, QWidget):
tooltip="Add Circular ProgressBar",
filled=True,
),
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME, tooltip="Add LogPanel", filled=True
),
},
),
"separator_2": SeparatorAction(),
@@ -190,9 +178,6 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
lambda: self.add_dock(widget="BECMotorMapWidget", prefix="motor_map")
)
self.toolbar.widgets["menu_plots"].widgets["waveform_ng"].triggered.connect(
lambda: self.add_dock(widget="Waveform", prefix="waveform_ng")
)
# Menu Devices
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
@@ -215,9 +200,6 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self.add_dock(widget="RingProgressBar", prefix="progress_bar")
)
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
lambda: self.add_dock(widget="LogPanel", prefix="log_panel")
)
# Icons
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)

View File

@@ -1,229 +0,0 @@
import sys
from qtpy.QtCore import QEvent, Qt
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
class ExpansionPanel(BECWidget, QWidget):
"""
A collapsible container widget for Qt Designer.
Key improvements in this version:
- A "title_color" property (type "QColor") that accepts either
a QColor or a string (hex/rgb/named color).
- The label text color is updated accordingly.
"""
PLUGIN = True
RPC = False
def __init__(
self,
parent: QWidget | None = None,
config: ConnectionConfig | None = None,
client=None,
gui_id: str | None = None,
title="Panel",
expanded=False,
) -> 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)
self.setObjectName("ExpansionPanel")
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# Properties
self._expanded = expanded
self._title_color = QColor("black") # Default text color
self.header_frame = None
self.content_frame = None
# Main layout
self._main_layout = QVBoxLayout(self)
self._main_layout.setContentsMargins(0, 0, 0, 0)
self._main_layout.setSpacing(0)
# Create header / content
self._init_header(title)
self._init_content()
# Visible or hidden
self.content_frame.setVisible(expanded)
# Defer event filter after constructing frames
self.installEventFilter(self)
def _init_header(self, title):
"""
Create the header frame with arrow button and label.
"""
self.header_frame = QFrame(self)
self.header_frame.setObjectName("headerFrame")
self.header_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.header_frame.setStyleSheet(
"""
#headerFrame {
background-color: "#141414";
border-radius: 4px;
}
"""
)
self._header_layout = QHBoxLayout(self.header_frame)
self._header_layout.setContentsMargins(3, 2, 3, 2)
self._header_layout.setSpacing(5)
self.btn_toggle = QPushButton("" if self._expanded else "", self.header_frame)
self.btn_toggle.setFixedSize(25, 25)
self.btn_toggle.setStyleSheet("border: none; font-weight: bold;")
self.btn_toggle.clicked.connect(self.toggle)
self._header_layout.addWidget(self.btn_toggle, alignment=Qt.AlignVCenter)
self.label_title = QLabel(title, self.header_frame)
self.label_title.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
self._header_layout.addWidget(self.label_title, alignment=Qt.AlignVCenter | Qt.AlignLeft)
self._header_layout.addStretch()
self._main_layout.addWidget(self.header_frame)
def _init_content(self):
"""Create the collapsible content frame (with its own layout)."""
self.content_frame = QFrame(self)
self.content_frame.setObjectName("ContentFrame")
self.content_frame.setStyleSheet(
"""
#ContentFrame {
border-radius: 4px;
}
"""
)
self.content_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
self._content_layout = QVBoxLayout(self.content_frame)
self._content_layout.setContentsMargins(5, 5, 5, 5)
self._content_layout.setSpacing(5)
self._main_layout.addWidget(self.content_frame)
@SafeSlot()
def toggle(self):
"""Collapse or expand the content area."""
self._expanded = not self._expanded
if self.content_frame:
self.content_frame.setVisible(self._expanded)
self.btn_toggle.setText("" if self._expanded else "")
@SafeProperty(bool)
def expanded(self) -> bool:
return self._expanded
@expanded.setter
def expanded(self, value: bool):
if value != self._expanded:
self.toggle()
@SafeProperty(str)
def title(self):
"""Property for the header text."""
if self.label_title:
return self.label_title.text()
return ""
@title.setter
def title(self, value: str):
if self.label_title:
self.label_title.setText(value)
@SafeProperty("QColor")
def title_color(self):
"""
A 'QColor' property recognized by Designer. We also accept
strings for convenience (e.g. "#FF0000", "rgb(0,255,0)", "blue").
"""
return self._title_color
@title_color.setter
def title_color(self, color):
# TODO set bec widget color validator here
# If user passes a string instead of a QColor, parse it
if isinstance(color, str):
new_col = QColor(color)
if not new_col.isValid():
return # ignore invalid color string
self._title_color = new_col
elif isinstance(color, QColor):
self._title_color = color
else:
# unknown type, ignore
return
# Update label's style
color_hex = self._title_color.name() # e.g. "#RRGGBB"
self.label_title.setStyleSheet(f"color: {color_hex};")
@property
def content_layout(self) -> QVBoxLayout:
"""Layout of the content frame for programmatic additions."""
return self._content_layout
@property
def header_layout(self) -> QHBoxLayout:
"""Layout of the content frame for programmatic additions."""
return self._header_layout
def event(self, e):
"""
If Designer adds child widgets, re-parent them into _content_layout
unless they're our known frames.
"""
if e.type() == QEvent.ChildAdded:
child_obj = e.child()
if child_obj is not None and isinstance(child_obj, QWidget):
if self.header_frame is not None and self.content_frame is not None:
if child_obj not in (self.header_frame, self.content_frame):
self._content_layout.addWidget(child_obj)
return super().event(e)
if __name__ == "__main__":
# Quick test if not using Designer
from qtpy.QtWidgets import QApplication, QPushButton, QVBoxLayout
app = QApplication(sys.argv)
panel = ExpansionPanel(title="Test Panel", expanded=False)
panel2 = ExpansionPanel(title="Test Panel 2", expanded=False)
# You can set the color via hex string
panel.title_color = "#FF00FF"
panel.content_layout.addWidget(QPushButton("Test Button 1"))
panel.content_layout.addWidget(QPushButton("Test Button 2"))
panel2.content_layout.addWidget(QPushButton("Test Button 1"))
panel2.content_layout.addWidget(QPushButton("Test Button 2"))
container = QWidget()
lay = QVBoxLayout(container)
lay.addWidget(panel)
lay.addWidget(panel2)
container.resize(400, 300)
container.show()
sys.exit(app.exec_())

View File

@@ -1 +0,0 @@
{'files': ['expansion_panel.py']}

View File

@@ -1,53 +0,0 @@
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
# Make sure the path below is correct
from bec_widgets.widgets.containers.expantion_panel.expansion_panel import ExpansionPanel
DOM_XML = """
<ui language='c++'>
<widget class='ExpansionPanel' name='expansion_panel'/>
</ui>
"""
class ExpansionPanelPlugin(QDesignerCustomWidgetInterface):
def __init__(self):
super().__init__()
self.initialized = False
def createWidget(self, parent):
return ExpansionPanel(parent=parent)
def domXml(self):
return DOM_XML
def group(self):
return "BEC Widgets"
def icon(self):
return designer_material_icon(ExpansionPanel.ICON_NAME)
def includeFile(self):
return "bec_widgets.widgets.containers.expantion_panel.expansion_panel"
def initialize(self, form_editor):
if self.initialized:
return
self.initialized = True
def isContainer(self):
return True # crucial for Designer to allow dropping child widgets
def isInitialized(self):
return self.initialized
def name(self):
return "ExpansionPanel"
def toolTip(self):
return "A collapsible panel container widget"
def whatsThis(self):
return self.toolTip()

View File

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

View File

@@ -634,7 +634,7 @@ class BECImageShow(BECPlotBase):
)
image_item.connected = False
if monitor and image_item.connected is False:
self.entry_validator.validate_monitor(monitor)
# self.entry_validator.validate_monitor(monitor)
if self.image_type == "device_monitor_1d":
self.bec_dispatcher.connect_slot(
self.on_image_update, MessageEndpoints.device_monitor_1d(monitor)

View File

@@ -34,7 +34,6 @@ class LayoutManagerWidget(QWidget):
def __init__(self, parent=None, auto_reindex=True):
super().__init__(parent)
self.setObjectName("LayoutManagerWidget")
self.layout = QGridLayout(self)
self.auto_reindex = auto_reindex

View File

@@ -1,11 +0,0 @@
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box import (
PositionerBox,
)
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import (
PositionerBox2D,
)
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
PositionerControlLine,
)
__ALL__ = ["PositionerBox", "PositionerControlLine", "PositionerBox2D"]

View File

@@ -1,3 +0,0 @@
from .positioner_box_base import PositionerBoxBase
__ALL__ = ["PositionerBoxBase"]

View File

@@ -1,243 +0,0 @@
import uuid
from abc import abstractmethod
from ast import Tuple
from typing import Callable, TypedDict
from bec_lib.device import Positioner
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import ScanQueueMessage
from qtpy.QtWidgets import (
QDialog,
QDoubleSpinBox,
QGroupBox,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
)
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
PositionIndicator,
)
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
logger = bec_logger.logger
class DeviceUpdateUIComponents(TypedDict):
spinner: SpinnerWidget
setpoint: QLineEdit
readback: QLabel
position_indicator: PositionIndicator
step_size: QDoubleSpinBox
device_box: QGroupBox
stop: QPushButton
tweak_increase: QPushButton
tweak_decrease: QPushButton
class PositionerBoxBase(BECWidget, CompactPopupWidget):
"""Contains some core logic for positioner box widgets"""
current_path = ""
ICON_NAME = "switch_right"
def __init__(self, parent=None, **kwargs):
"""Initialize the PositionerBox widget.
Args:
parent: The parent widget.
device (Positioner): The device to control.
"""
super().__init__(**kwargs)
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
self._dialog = None
self.get_bec_shortcuts()
def _check_device_is_valid(self, device: str):
"""Check if the device is a positioner
Args:
device (str): The device name
"""
if device not in self.dev:
logger.info(f"Device {device} not found in the device list")
return False
if not isinstance(self.dev[device], Positioner):
logger.info(f"Device {device} is not a positioner")
return False
return True
@abstractmethod
def _device_ui_components(self, device: str) -> DeviceUpdateUIComponents: ...
def _init_device(
self,
device: str,
position_emit: Callable[[float], None],
limit_update: Callable[[tuple[float, float]], None],
):
"""Init the device view and readback"""
if self._check_device_is_valid(device):
data = self.dev[device].read()
self._on_device_readback(
device,
self._device_ui_components(device),
{"signals": data},
{},
position_emit,
limit_update,
)
def _stop_device(self, device: str):
"""Stop call"""
request_id = str(uuid.uuid4())
params = {"device": device, "rpc_id": request_id, "func": "stop", "args": [], "kwargs": {}}
msg = ScanQueueMessage(
scan_type="device_rpc",
parameter=params,
queue="emergency",
metadata={"RID": request_id, "response": False},
)
self.client.connector.send(MessageEndpoints.scan_queue_request(), msg)
# pylint: disable=unused-argument
def _on_device_readback(
self,
device: str,
ui_components: DeviceUpdateUIComponents,
msg_content: dict,
metadata: dict,
position_emit: Callable[[float], None],
limit_update: Callable[[tuple[float, float]], None],
):
signals = msg_content.get("signals", {})
# pylint: disable=protected-access
hinted_signals = self.dev[device]._hints
precision = self.dev[device].precision
spinner = ui_components["spinner"]
position_indicator = ui_components["position_indicator"]
readback = ui_components["readback"]
setpoint = ui_components["setpoint"]
readback_val = None
setpoint_val = None
if len(hinted_signals) == 1:
signal = hinted_signals[0]
readback_val = signals.get(signal, {}).get("value")
for setpoint_signal in ["setpoint", "user_setpoint"]:
setpoint_val = signals.get(f"{device}_{setpoint_signal}", {}).get("value")
if setpoint_val is not None:
break
for moving_signal in ["motor_done_move", "motor_is_moving"]:
is_moving = signals.get(f"{device}_{moving_signal}", {}).get("value")
if is_moving is not None:
break
if is_moving is not None:
spinner.setVisible(True)
if is_moving:
spinner.start()
spinner.setToolTip("Device is moving")
self.set_global_state("warning")
else:
spinner.stop()
spinner.setToolTip("Device is idle")
self.set_global_state("success")
else:
spinner.setVisible(False)
if readback_val is not None:
readback.setText(f"{readback_val:.{precision}f}")
position_emit(readback_val)
if setpoint_val is not None:
setpoint.setText(f"{setpoint_val:.{precision}f}")
limits = self.dev[device].limits
limit_update(limits)
if limits is not None and readback_val is not None and limits[0] != limits[1]:
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
position_indicator.set_value(pos)
def _update_limits_ui(
self, limits: tuple[float, float], position_indicator, setpoint_validator
):
if limits is not None and limits[0] != limits[1]:
position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
setpoint_validator.setRange(limits[0], limits[1])
else:
position_indicator.setToolTip("No limits set")
setpoint_validator.setRange(float("-inf"), float("inf"))
def _update_device_ui(self, device: str, ui: DeviceUpdateUIComponents):
ui["device_box"].setTitle(device)
ui["readback"].setToolTip(f"{device} readback")
ui["setpoint"].setToolTip(f"{device} setpoint")
ui["step_size"].setToolTip(f"Step size for {device}")
precision = self.dev[device].precision
if precision is not None:
ui["step_size"].setDecimals(precision)
ui["step_size"].setValue(10**-precision * 10)
def _swap_readback_signal_connection(self, slot, old_device, new_device):
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
"""Toogle enable/disable on available buttons
Args:
enable (bool): Enable buttons
"""
ui["tweak_increase"].setEnabled(enable)
ui["tweak_decrease"].setEnabled(enable)
ui["stop"].setEnabled(enable)
ui["setpoint"].setEnabled(enable)
ui["step_size"].setEnabled(enable)
def _on_device_change(
self,
old_device: str,
new_device: str,
position_emit: Callable[[float], None],
limit_update: Callable[[tuple[float, float]], None],
on_device_readback: Callable,
ui: DeviceUpdateUIComponents,
):
logger.info(f"Device changed from {old_device} to {new_device}")
self._toggle_enable_buttons(ui, True)
self._init_device(new_device, position_emit, limit_update)
self._swap_readback_signal_connection(on_device_readback, old_device, new_device)
self._update_device_ui(new_device, ui)
def _open_dialog_selection(self, set_positioner: Callable):
def _ods():
"""Open dialog window for positioner selection"""
self._dialog = QDialog(self)
self._dialog.setWindowTitle("Positioner Selection")
layout = QVBoxLayout()
line_edit = DeviceLineEdit(
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
)
line_edit.textChanged.connect(set_positioner)
layout.addWidget(line_edit)
close_button = QPushButton("Close")
close_button.clicked.connect(self._dialog.accept)
layout.addWidget(close_button)
self._dialog.setLayout(layout)
self._dialog.exec()
self._dialog = None
return _ods

View File

@@ -0,0 +1,352 @@
""" Module for a PositionerBox widget to control a positioner device."""
from __future__ import annotations
import os
import uuid
from bec_lib.device import Positioner
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import ScanQueueMessage
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDialog, QDoubleSpinBox, QPushButton, QVBoxLayout
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, set_theme
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerBox(BECWidget, CompactPopupWidget):
"""Simple Widget to control a positioner in box form"""
ui_file = "positioner_box.ui"
dimensions = (234, 224)
PLUGIN = True
ICON_NAME = "switch_right"
USER_ACCESS = ["set_positioner"]
device_changed = Signal(str, str)
# Signal emitted to inform listeners about a position update
position_update = Signal(float)
def __init__(self, parent=None, device: Positioner = None, **kwargs):
"""Initialize the PositionerBox widget.
Args:
parent: The parent widget.
device (Positioner): The device to control.
"""
super().__init__(**kwargs)
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
self.get_bec_shortcuts()
self._device = ""
self._limits = None
self._dialog = None
self.init_ui()
if device is not None:
self.device = device
self.init_device()
def init_ui(self):
"""Init the ui"""
self.device_changed.connect(self.on_device_change)
current_path = os.path.dirname(__file__)
self.ui = UILoader(self).loader(os.path.join(current_path, self.ui_file))
self.addWidget(self.ui)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
# fix the size of the device box
db = self.ui.device_box
db.setFixedHeight(self.dimensions[0])
db.setFixedWidth(self.dimensions[1])
self.ui.step_size.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType)
self.ui.stop.clicked.connect(self.on_stop)
self.ui.stop.setToolTip("Stop")
self.ui.stop.setStyleSheet(
f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}"
)
self.ui.tweak_right.clicked.connect(self.on_tweak_right)
self.ui.tweak_right.setToolTip("Tweak right")
self.ui.tweak_left.clicked.connect(self.on_tweak_left)
self.ui.tweak_left.setToolTip("Tweak left")
self.ui.setpoint.returnPressed.connect(self.on_setpoint_change)
self.setpoint_validator = QDoubleValidator()
self.ui.setpoint.setValidator(self.setpoint_validator)
self.ui.spinner_widget.start()
self.ui.tool_button.clicked.connect(self._open_dialog_selection)
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
self.ui.tool_button.setIcon(icon)
def _open_dialog_selection(self):
"""Open dialog window for positioner selection"""
self._dialog = QDialog(self)
self._dialog.setWindowTitle("Positioner Selection")
layout = QVBoxLayout()
line_edit = DeviceLineEdit(
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
)
line_edit.textChanged.connect(self.set_positioner)
layout.addWidget(line_edit)
close_button = QPushButton("Close")
close_button.clicked.connect(self._dialog.accept)
layout.addWidget(close_button)
self._dialog.setLayout(layout)
self._dialog.exec()
self._dialog = None
def init_device(self):
"""Init the device view and readback"""
if self._check_device_is_valid(self.device):
data = self.dev[self.device].read()
self.on_device_readback({"signals": data}, {})
def _toogle_enable_buttons(self, enable: bool) -> None:
"""Toogle enable/disable on available buttons
Args:
enable (bool): Enable buttons
"""
self.ui.tweak_left.setEnabled(enable)
self.ui.tweak_right.setEnabled(enable)
self.ui.stop.setEnabled(enable)
self.ui.setpoint.setEnabled(enable)
self.ui.step_size.setEnabled(enable)
@Property(str)
def device(self):
"""Property to set the device"""
return self._device
@device.setter
def device(self, value: str):
"""Setter, checks if device is a string"""
if not value or not isinstance(value, str):
return
if not self._check_device_is_valid(value):
return
old_device = self._device
self._device = value
if not self.label:
self.label = value
self.device_changed.emit(old_device, value)
@Property(bool)
def hide_device_selection(self):
"""Hide the device selection"""
return not self.ui.tool_button.isVisible()
@hide_device_selection.setter
def hide_device_selection(self, value: bool):
"""Set the device selection visibility"""
self.ui.tool_button.setVisible(not value)
@Slot(bool)
def show_device_selection(self, value: bool):
"""Show the device selection
Args:
value (bool): Show the device selection
"""
self.hide_device_selection = not value
@Slot(str)
def set_positioner(self, positioner: str | Positioner):
"""Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
if isinstance(positioner, Positioner):
positioner = positioner.name
self.device = positioner
def _check_device_is_valid(self, device: str):
"""Check if the device is a positioner
Args:
device (str): The device name
"""
if device not in self.dev:
logger.info(f"Device {device} not found in the device list")
return False
if not isinstance(self.dev[device], Positioner):
logger.info(f"Device {device} is not a positioner")
return False
return True
@Slot(str, str)
def on_device_change(self, old_device: str, new_device: str):
"""Upon changing the device, a check will be performed if the device is a Positioner.
Args:
old_device (str): The old device name.
new_device (str): The new device name.
"""
if not self._check_device_is_valid(new_device):
return
logger.info(f"Device changed from {old_device} to {new_device}")
self._toogle_enable_buttons(True)
self.init_device()
self.bec_dispatcher.disconnect_slot(
self.on_device_readback, MessageEndpoints.device_readback(old_device)
)
self.bec_dispatcher.connect_slot(
self.on_device_readback, MessageEndpoints.device_readback(new_device)
)
self.ui.device_box.setTitle(new_device)
self.ui.readback.setToolTip(f"{self.device} readback")
self.ui.setpoint.setToolTip(f"{self.device} setpoint")
self.ui.step_size.setToolTip(f"Step size for {new_device}")
precision = self.dev[new_device].precision
if precision is not None:
self.ui.step_size.setDecimals(precision)
self.ui.step_size.setValue(10**-precision * 10)
# pylint: disable=unused-argument
@Slot(dict, dict)
def on_device_readback(self, msg_content: dict, metadata: dict):
"""Callback for device readback.
Args:
msg_content (dict): The message content.
metadata (dict): The message metadata.
"""
signals = msg_content.get("signals", {})
# pylint: disable=protected-access
hinted_signals = self.dev[self.device]._hints
precision = self.dev[self.device].precision
readback_val = None
setpoint_val = None
if len(hinted_signals) == 1:
signal = hinted_signals[0]
readback_val = signals.get(signal, {}).get("value")
for setpoint_signal in ["setpoint", "user_setpoint"]:
setpoint_val = signals.get(f"{self.device}_{setpoint_signal}", {}).get("value")
if setpoint_val is not None:
break
for moving_signal in ["motor_done_move", "motor_is_moving"]:
is_moving = signals.get(f"{self.device}_{moving_signal}", {}).get("value")
if is_moving is not None:
break
if is_moving is not None:
self.ui.spinner_widget.setVisible(True)
if is_moving:
self.ui.spinner_widget.start()
self.ui.spinner_widget.setToolTip("Device is moving")
self.set_global_state("warning")
else:
self.ui.spinner_widget.stop()
self.ui.spinner_widget.setToolTip("Device is idle")
self.set_global_state("success")
else:
self.ui.spinner_widget.setVisible(False)
if readback_val is not None:
self.ui.readback.setText(f"{readback_val:.{precision}f}")
self.position_update.emit(readback_val)
if setpoint_val is not None:
self.ui.setpoint.setText(f"{setpoint_val:.{precision}f}")
limits = self.dev[self.device].limits
self.update_limits(limits)
if limits is not None and readback_val is not None and limits[0] != limits[1]:
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
self.ui.position_indicator.set_value(pos)
def update_limits(self, limits: tuple):
"""Update limits
Args:
limits (tuple): Limits of the positioner
"""
if limits == self._limits:
return
self._limits = limits
if limits is not None and limits[0] != limits[1]:
self.ui.position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
self.setpoint_validator.setRange(limits[0], limits[1])
else:
self.ui.position_indicator.setToolTip("No limits set")
self.setpoint_validator.setRange(float("-inf"), float("inf"))
@Slot()
def on_stop(self):
"""Stop call"""
request_id = str(uuid.uuid4())
params = {
"device": self.device,
"rpc_id": request_id,
"func": "stop",
"args": [],
"kwargs": {},
}
msg = ScanQueueMessage(
scan_type="device_rpc",
parameter=params,
queue="emergency",
metadata={"RID": request_id, "response": False},
)
self.client.connector.send(MessageEndpoints.scan_queue_request(), msg)
@property
def step_size(self):
"""Step size for tweak"""
return self.ui.step_size.value()
@Slot()
def on_tweak_right(self):
"""Tweak motor right"""
self.dev[self.device].move(self.step_size, relative=True)
@Slot()
def on_tweak_left(self):
"""Tweak motor left"""
self.dev[self.device].move(-self.step_size, relative=True)
@Slot()
def on_setpoint_change(self):
"""Change the setpoint for the motor"""
self.ui.setpoint.clearFocus()
setpoint = self.ui.setpoint.text()
self.dev[self.device].move(float(setpoint), relative=False)
self.ui.tweak_left.setToolTip(f"Tweak left by {self.step_size}")
self.ui.tweak_right.setToolTip(f"Tweak right by {self.step_size}")
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
set_theme("dark")
widget = PositionerBox(device="bpm4i")
widget.show()
sys.exit(app.exec_())

View File

@@ -1,242 +0,0 @@
""" Module for a PositionerBox widget to control a positioner device."""
from __future__ import annotations
import os
from bec_lib.device import Positioner
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import get_accent_colors, set_theme
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
DeviceUpdateUIComponents,
)
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerBox(PositionerBoxBase):
"""Simple Widget to control a positioner in box form"""
ui_file = "positioner_box.ui"
dimensions = (234, 224)
PLUGIN = True
USER_ACCESS = ["set_positioner"]
device_changed = Signal(str, str)
# Signal emitted to inform listeners about a position update
position_update = Signal(float)
def __init__(self, parent=None, device: Positioner | str | None = None, **kwargs):
"""Initialize the PositionerBox widget.
Args:
parent: The parent widget.
device (Positioner): The device to control.
"""
super().__init__(parent=parent, **kwargs)
self._device = ""
self._limits = None
if self.current_path == "":
self.current_path = os.path.dirname(__file__)
self.init_ui()
self.device = device
self._init_device(self.device, self.position_update.emit, self.update_limits)
def init_ui(self):
"""Init the ui"""
self.device_changed.connect(self.on_device_change)
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
self.addWidget(self.ui)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
# fix the size of the device box
db = self.ui.device_box
db.setFixedHeight(self.dimensions[0])
db.setFixedWidth(self.dimensions[1])
self.ui.step_size.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType)
self.ui.stop.clicked.connect(self.on_stop)
self.ui.stop.setToolTip("Stop")
self.ui.stop.setStyleSheet(
f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}"
)
self.ui.tweak_right.clicked.connect(self.on_tweak_right)
self.ui.tweak_right.setToolTip("Tweak right")
self.ui.tweak_left.clicked.connect(self.on_tweak_left)
self.ui.tweak_left.setToolTip("Tweak left")
self.ui.setpoint.returnPressed.connect(self.on_setpoint_change)
self.setpoint_validator = QDoubleValidator()
self.ui.setpoint.setValidator(self.setpoint_validator)
self.ui.spinner_widget.start()
self.ui.tool_button.clicked.connect(self._open_dialog_selection(self.set_positioner))
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
self.ui.tool_button.setIcon(icon)
def force_update_readback(self):
self._init_device(self.device, self.position_update.emit, self.update_limits)
@SafeProperty(str)
def device(self):
"""Property to set the device"""
return self._device
@device.setter
def device(self, value: str):
"""Setter, checks if device is a string"""
if not value or not isinstance(value, str):
return
if not self._check_device_is_valid(value):
return
old_device = self._device
self._device = value
if not self.label:
self.label = value
self.device_changed.emit(old_device, value)
@SafeProperty(bool)
def hide_device_selection(self):
"""Hide the device selection"""
return not self.ui.tool_button.isVisible()
@hide_device_selection.setter
def hide_device_selection(self, value: bool):
"""Set the device selection visibility"""
self.ui.tool_button.setVisible(not value)
@SafeSlot(bool)
def show_device_selection(self, value: bool):
"""Show the device selection
Args:
value (bool): Show the device selection
"""
self.hide_device_selection = not value
@SafeSlot(str)
def set_positioner(self, positioner: str | Positioner):
"""Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
if isinstance(positioner, Positioner):
positioner = positioner.name
self.device = positioner
@SafeSlot(str, str)
def on_device_change(self, old_device: str, new_device: str):
"""Upon changing the device, a check will be performed if the device is a Positioner.
Args:
old_device (str): The old device name.
new_device (str): The new device name.
"""
if not self._check_device_is_valid(new_device):
return
self._on_device_change(
old_device,
new_device,
self.position_update.emit,
self.update_limits,
self.on_device_readback,
self._device_ui_components(new_device),
)
def _device_ui_components(self, device: str) -> DeviceUpdateUIComponents:
return {
"spinner": self.ui.spinner_widget,
"position_indicator": self.ui.position_indicator,
"readback": self.ui.readback,
"setpoint": self.ui.setpoint,
"step_size": self.ui.step_size,
"device_box": self.ui.device_box,
"stop": self.ui.stop,
"tweak_increase": self.ui.tweak_right,
"tweak_decrease": self.ui.tweak_left,
}
@SafeSlot(dict, dict)
def on_device_readback(self, msg_content: dict, metadata: dict):
"""Callback for device readback.
Args:
msg_content (dict): The message content.
metadata (dict): The message metadata.
"""
self._on_device_readback(
self.device,
self._device_ui_components(self.device),
msg_content,
metadata,
self.position_update.emit,
self.update_limits,
)
def update_limits(self, limits: tuple):
"""Update limits
Args:
limits (tuple): Limits of the positioner
"""
if limits == self._limits:
return
self._limits = limits
self._update_limits_ui(limits, self.ui.position_indicator, self.setpoint_validator)
@SafeSlot()
def on_stop(self):
self._stop_device(self.device)
@property
def step_size(self):
"""Step size for tweak"""
return self.ui.step_size.value()
@SafeSlot()
def on_tweak_right(self):
"""Tweak motor right"""
self.dev[self.device].move(self.step_size, relative=True)
@SafeSlot()
def on_tweak_left(self):
"""Tweak motor left"""
self.dev[self.device].move(-self.step_size, relative=True)
@SafeSlot()
def on_setpoint_change(self):
"""Change the setpoint for the motor"""
self.ui.setpoint.clearFocus()
setpoint = self.ui.setpoint.text()
self.dev[self.device].move(float(setpoint), relative=False)
self.ui.tweak_left.setToolTip(f"Tweak left by {self.step_size}")
self.ui.tweak_right.setToolTip(f"Tweak right by {self.step_size}")
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
set_theme("dark")
widget = PositionerBox(device="bpm4i")
widget.show()
sys.exit(app.exec_())

View File

@@ -1,56 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import (
PositionerBox2D,
)
DOM_XML = """
<ui language='c++'>
<widget class='PositionerBox2D' name='positioner_box2_d'>
</widget>
</ui>
"""
class PositionerBox2DPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = PositionerBox2D(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "Device Control"
def icon(self):
return designer_material_icon(PositionerBox2D.ICON_NAME)
def includeFile(self):
return "positioner_box2_d"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "PositionerBox2D"
def toolTip(self):
return "Simple Widget to control two positioners in box form"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,482 +0,0 @@
""" Module for a PositionerBox2D widget to control two positioner devices."""
from __future__ import annotations
import os
from typing import Literal
from bec_lib.device import Positioner
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
DeviceUpdateUIComponents,
)
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
DeviceId = Literal["horizontal", "vertical"]
class PositionerBox2D(PositionerBoxBase):
"""Simple Widget to control two positioners in box form"""
ui_file = "positioner_box_2d.ui"
PLUGIN = True
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver"]
device_changed_hor = Signal(str, str)
device_changed_ver = Signal(str, str)
# Signals emitted to inform listeners about a position update
position_update_hor = Signal(float)
position_update_ver = Signal(float)
def __init__(
self,
parent=None,
device_hor: Positioner | str | None = None,
device_ver: Positioner | str | None = None,
**kwargs,
):
"""Initialize the PositionerBox widget.
Args:
parent: The parent widget.
device_hor (Positioner | str): The first device to control - assigned the horizontal axis.
device_ver (Positioner | str): The second device to control - assigned the vertical axis.
"""
super().__init__(parent=parent, **kwargs)
self._device_hor = ""
self._device_ver = ""
self._limits_hor = None
self._limits_ver = None
self._dialog = None
if self.current_path == "":
self.current_path = os.path.dirname(__file__)
self.init_ui()
self.device_hor = device_hor
self.device_ver = device_ver
self.connect_ui()
def init_ui(self):
"""Init the ui"""
self.device_changed_hor.connect(self.on_device_change_hor)
self.device_changed_ver.connect(self.on_device_change_ver)
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
self.setpoint_validator_hor = QDoubleValidator()
self.setpoint_validator_ver = QDoubleValidator()
def connect_ui(self):
"""Connect the UI components to signals, data, or routines"""
self.addWidget(self.ui)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
def _init_ui(val: QDoubleValidator, device_id: DeviceId):
ui = self._device_ui_components_hv(device_id)
tweak_inc = (
self.on_tweak_inc_hor if device_id == "horizontal" else self.on_tweak_inc_ver
)
tweak_dec = (
self.on_tweak_dec_hor if device_id == "horizontal" else self.on_tweak_dec_ver
)
ui["setpoint"].setValidator(val)
ui["setpoint"].returnPressed.connect(
self.on_setpoint_change_hor
if device_id == "horizontal"
else self.on_setpoint_change_ver
)
ui["stop"].setToolTip("Stop")
ui["step_size"].setStepType(QDoubleSpinBox.StepType.AdaptiveDecimalStepType)
ui["tweak_increase"].clicked.connect(tweak_inc)
ui["tweak_decrease"].clicked.connect(tweak_dec)
_init_ui(self.setpoint_validator_hor, "horizontal")
_init_ui(self.setpoint_validator_ver, "vertical")
self.ui.stop_button.button.clicked.connect(self.on_stop)
self.ui.step_decrease_hor.clicked.connect(self.on_step_dec_hor)
self.ui.step_decrease_ver.clicked.connect(self.on_step_dec_ver)
self.ui.step_increase_hor.clicked.connect(self.on_step_inc_hor)
self.ui.step_increase_ver.clicked.connect(self.on_step_inc_ver)
self.ui.tool_button_hor.clicked.connect(
self._open_dialog_selection(self.set_positioner_hor)
)
self.ui.tool_button_ver.clicked.connect(
self._open_dialog_selection(self.set_positioner_ver)
)
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
self.ui.tool_button_hor.setIcon(icon)
self.ui.tool_button_ver.setIcon(icon)
step_tooltip = "Step by the step size"
tweak_tooltip = "Tweak by 1/10th the step size"
for b in [
self.ui.step_increase_hor,
self.ui.step_increase_ver,
self.ui.step_decrease_hor,
self.ui.step_decrease_ver,
]:
b.setToolTip(step_tooltip)
for b in [
self.ui.tweak_increase_hor,
self.ui.tweak_increase_ver,
self.ui.tweak_decrease_hor,
self.ui.tweak_decrease_ver,
]:
b.setToolTip(tweak_tooltip)
icon_options = {"size": (16, 16), "convert_to_pixmap": False}
self.ui.tweak_increase_hor.setIcon(
material_icon(icon_name="keyboard_arrow_right", **icon_options)
)
self.ui.step_increase_hor.setIcon(
material_icon(icon_name="keyboard_double_arrow_right", **icon_options)
)
self.ui.tweak_decrease_hor.setIcon(
material_icon(icon_name="keyboard_arrow_left", **icon_options)
)
self.ui.step_decrease_hor.setIcon(
material_icon(icon_name="keyboard_double_arrow_left", **icon_options)
)
self.ui.tweak_increase_ver.setIcon(
material_icon(icon_name="keyboard_arrow_up", **icon_options)
)
self.ui.step_increase_ver.setIcon(
material_icon(icon_name="keyboard_double_arrow_up", **icon_options)
)
self.ui.tweak_decrease_ver.setIcon(
material_icon(icon_name="keyboard_arrow_down", **icon_options)
)
self.ui.step_decrease_ver.setIcon(
material_icon(icon_name="keyboard_double_arrow_down", **icon_options)
)
@SafeProperty(str)
def device_hor(self):
"""SafeProperty to set the device"""
return self._device_hor
@device_hor.setter
def device_hor(self, value: str):
"""Setter, checks if device is a string"""
if not value or not isinstance(value, str):
return
if not self._check_device_is_valid(value):
return
if value == self.device_ver:
return
old_device = self._device_hor
self._device_hor = value
self.label = f"{self._device_hor}, {self._device_ver}"
self.device_changed_hor.emit(old_device, value)
self._init_device(self.device_hor, self.position_update_hor.emit, self.update_limits_hor)
@SafeProperty(str)
def device_ver(self):
"""SafeProperty to set the device"""
return self._device_ver
@device_ver.setter
def device_ver(self, value: str):
"""Setter, checks if device is a string"""
if not value or not isinstance(value, str):
return
if not self._check_device_is_valid(value):
return
if value == self.device_hor:
return
old_device = self._device_ver
self._device_ver = value
self.label = f"{self._device_hor}, {self._device_ver}"
self.device_changed_ver.emit(old_device, value)
self._init_device(self.device_ver, self.position_update_ver.emit, self.update_limits_ver)
@SafeProperty(bool)
def hide_device_selection(self):
"""Hide the device selection"""
return not self.ui.tool_button_hor.isVisible()
@hide_device_selection.setter
def hide_device_selection(self, value: bool):
"""Set the device selection visibility"""
self.ui.tool_button_hor.setVisible(not value)
self.ui.tool_button_ver.setVisible(not value)
@SafeProperty(bool)
def hide_device_boxes(self):
"""Hide the device selection"""
return not self.ui.device_box_hor.isVisible()
@hide_device_boxes.setter
def hide_device_boxes(self, value: bool):
"""Set the device selection visibility"""
self.ui.device_box_hor.setVisible(not value)
self.ui.device_box_ver.setVisible(not value)
@SafeSlot(bool)
def show_device_selection(self, value: bool):
"""Show the device selection
Args:
value (bool): Show the device selection
"""
self.hide_device_selection = not value
@SafeSlot(str)
def set_positioner_hor(self, positioner: str | Positioner):
"""Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
if isinstance(positioner, Positioner):
positioner = positioner.name
self.device_hor = positioner
@SafeSlot(str)
def set_positioner_ver(self, positioner: str | Positioner):
"""Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
if isinstance(positioner, Positioner):
positioner = positioner.name
self.device_ver = positioner
@SafeSlot(str, str)
def on_device_change_hor(self, old_device: str, new_device: str):
"""Upon changing the device, a check will be performed if the device is a Positioner.
Args:
old_device (str): The old device name.
new_device (str): The new device name.
"""
if not self._check_device_is_valid(new_device):
return
self._on_device_change(
old_device,
new_device,
self.position_update_hor.emit,
self.update_limits_hor,
self.on_device_readback_hor,
self._device_ui_components_hv("horizontal"),
)
@SafeSlot(str, str)
def on_device_change_ver(self, old_device: str, new_device: str):
"""Upon changing the device, a check will be performed if the device is a Positioner.
Args:
old_device (str): The old device name.
new_device (str): The new device name.
"""
if not self._check_device_is_valid(new_device):
return
self._on_device_change(
old_device,
new_device,
self.position_update_ver.emit,
self.update_limits_ver,
self.on_device_readback_ver,
self._device_ui_components_hv("vertical"),
)
def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents:
if device == "horizontal":
return {
"spinner": self.ui.spinner_widget_hor,
"position_indicator": self.ui.position_indicator_hor,
"readback": self.ui.readback_hor,
"setpoint": self.ui.setpoint_hor,
"step_size": self.ui.step_size_hor,
"device_box": self.ui.device_box_hor,
"stop": self.ui.stop_button,
"tweak_increase": self.ui.tweak_increase_hor,
"tweak_decrease": self.ui.tweak_decrease_hor,
}
elif device == "vertical":
return {
"spinner": self.ui.spinner_widget_ver,
"position_indicator": self.ui.position_indicator_ver,
"readback": self.ui.readback_ver,
"setpoint": self.ui.setpoint_ver,
"step_size": self.ui.step_size_ver,
"device_box": self.ui.device_box_ver,
"stop": self.ui.stop_button,
"tweak_increase": self.ui.tweak_increase_ver,
"tweak_decrease": self.ui.tweak_decrease_ver,
}
else:
raise ValueError(f"Device {device} is not represented by this UI")
def _device_ui_components(self, device: str):
if device == self.device_hor:
return self._device_ui_components_hv("horizontal")
if device == self.device_ver:
return self._device_ui_components_hv("vertical")
@SafeSlot(dict, dict)
def on_device_readback_hor(self, msg_content: dict, metadata: dict):
"""Callback for device readback.
Args:
msg_content (dict): The message content.
metadata (dict): The message metadata.
"""
self._on_device_readback(
self.device_hor,
self._device_ui_components_hv("horizontal"),
msg_content,
metadata,
self.position_update_hor.emit,
self.update_limits_hor,
)
@SafeSlot(dict, dict)
def on_device_readback_ver(self, msg_content: dict, metadata: dict):
"""Callback for device readback.
Args:
msg_content (dict): The message content.
metadata (dict): The message metadata.
"""
self._on_device_readback(
self.device_ver,
self._device_ui_components_hv("vertical"),
msg_content,
metadata,
self.position_update_ver.emit,
self.update_limits_ver,
)
def update_limits_hor(self, limits: tuple):
"""Update limits
Args:
limits (tuple): Limits of the positioner
"""
if limits == self._limits_hor:
return
self._limits_hor = limits
self._update_limits_ui(limits, self.ui.position_indicator_hor, self.setpoint_validator_hor)
def update_limits_ver(self, limits: tuple):
"""Update limits
Args:
limits (tuple): Limits of the positioner
"""
if limits == self._limits_ver:
return
self._limits_ver = limits
self._update_limits_ui(limits, self.ui.position_indicator_ver, self.setpoint_validator_ver)
@SafeSlot()
def on_stop(self):
self._stop_device(f"{self.device_hor} or {self.device_ver}")
@SafeProperty(float)
def step_size_hor(self):
"""Step size for tweak"""
return self.ui.step_size_hor.value()
@step_size_hor.setter
def step_size_hor(self, val: float):
"""Step size for tweak"""
self.ui.step_size_hor.setValue(val)
@SafeProperty(float)
def step_size_ver(self):
"""Step size for tweak"""
return self.ui.step_size_ver.value()
@step_size_ver.setter
def step_size_ver(self, val: float):
"""Step size for tweak"""
self.ui.step_size_ver.setValue(val)
@SafeSlot()
def on_tweak_inc_hor(self):
"""Tweak device a up"""
self.dev[self.device_hor].move(self.step_size_hor / 10, relative=True)
@SafeSlot()
def on_tweak_dec_hor(self):
"""Tweak device a down"""
self.dev[self.device_hor].move(-self.step_size_hor / 10, relative=True)
@SafeSlot()
def on_step_inc_hor(self):
"""Tweak device a up"""
self.dev[self.device_hor].move(self.step_size_hor, relative=True)
@SafeSlot()
def on_step_dec_hor(self):
"""Tweak device a down"""
self.dev[self.device_hor].move(-self.step_size_hor, relative=True)
@SafeSlot()
def on_tweak_inc_ver(self):
"""Tweak device a up"""
self.dev[self.device_ver].move(self.step_size_ver / 10, relative=True)
@SafeSlot()
def on_tweak_dec_ver(self):
"""Tweak device b down"""
self.dev[self.device_ver].move(-self.step_size_ver / 10, relative=True)
@SafeSlot()
def on_step_inc_ver(self):
"""Tweak device b up"""
self.dev[self.device_ver].move(self.step_size_ver, relative=True)
@SafeSlot()
def on_step_dec_ver(self):
"""Tweak device a down"""
self.dev[self.device_ver].move(-self.step_size_ver, relative=True)
@SafeSlot()
def on_setpoint_change_hor(self):
"""Change the setpoint for device a"""
self.ui.setpoint_hor.clearFocus()
setpoint = self.ui.setpoint_hor.text()
self.dev[self.device_hor].move(float(setpoint), relative=False)
@SafeSlot()
def on_setpoint_change_ver(self):
"""Change the setpoint for device b"""
self.ui.setpoint_ver.clearFocus()
setpoint = self.ui.setpoint_ver.text()
self.dev[self.device_ver].move(float(setpoint), relative=False)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
set_theme("dark")
widget = PositionerBox2D()
widget.show()
sys.exit(app.exec_())

View File

@@ -1,562 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>326</width>
<height>323</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="1">
<layout class="QGridLayout" name="gridLayout_4">
<item row="0" column="1">
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="2">
<widget class="QGroupBox" name="device_box_ver">
<property name="title">
<string>No positioner selected</string>
</property>
<layout class="QGridLayout" name="gridLayout_6" rowstretch="0,0,0,0,0,0">
<property name="topMargin">
<number>0</number>
</property>
<item row="1" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_5">
<item>
<widget class="QToolButton" name="tool_button_ver">
<property name="focusPolicy">
<enum>Qt::FocusPolicy::NoFocus</enum>
</property>
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="readback_ver">
<property name="text">
<string>Position</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="SpinnerWidget" name="spinner_widget_ver">
<property name="minimumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLineEdit" name="setpoint_ver">
<property name="enabled">
<bool>false</bool>
</property>
<property name="focusPolicy">
<enum>Qt::FocusPolicy::StrongFocus</enum>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="5" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item>
<widget class="QDoubleSpinBox" name="step_size_ver">
<property name="enabled">
<bool>false</bool>
</property>
<property name="focusPolicy">
<enum>Qt::FocusPolicy::StrongFocus</enum>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item row="0" column="1">
<widget class="QGroupBox" name="device_box_hor">
<property name="title">
<string>No positioner selected</string>
</property>
<layout class="QGridLayout" name="gridLayout_5" rowstretch="0,0,0,0,0,0,0,0,0,0,0">
<property name="topMargin">
<number>0</number>
</property>
<item row="9" column="0">
<widget class="QLineEdit" name="setpoint_hor">
<property name="enabled">
<bool>false</bool>
</property>
<property name="focusPolicy">
<enum>Qt::FocusPolicy::StrongFocus</enum>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item>
<widget class="QToolButton" name="tool_button_hor">
<property name="focusPolicy">
<enum>Qt::FocusPolicy::NoFocus</enum>
</property>
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="readback_hor">
<property name="text">
<string>Position</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="SpinnerWidget" name="spinner_widget_hor">
<property name="minimumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item row="10" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_7">
<item>
<widget class="QDoubleSpinBox" name="step_size_hor">
<property name="enabled">
<bool>false</bool>
</property>
<property name="focusPolicy">
<enum>Qt::FocusPolicy::StrongFocus</enum>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item row="4" column="0">
<widget class="PositionIndicator" name="position_indicator_ver">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximum" stdset="0">
<double>1.000000000000000</double>
</property>
<property name="vertical" stdset="0">
<bool>true</bool>
</property>
<property name="value" stdset="0">
<double>0.500000000000000</double>
</property>
<property name="indicator_width" stdset="0">
<number>4</number>
</property>
<property name="rounded_corners" stdset="0">
<number>4</number>
</property>
</widget>
</item>
<item row="4" column="1">
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="3">
<widget class="QPushButton" name="step_increase_ver">
<property name="focusPolicy">
<enum>Qt::FocusPolicy::NoFocus</enum>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="2" column="5">
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="6" column="3">
<widget class="QPushButton" name="step_decrease_ver">
<property name="focusPolicy">
<enum>Qt::FocusPolicy::NoFocus</enum>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="5">
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="6" column="2">
<spacer name="horizontalSpacer_16">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>10</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="1">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="2">
<spacer name="horizontalSpacer_14">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>10</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="1">
<spacer name="horizontalSpacer_17">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="2">
<spacer name="horizontalSpacer_7">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>10</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="6" column="1">
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="3">
<spacer name="horizontalSpacer_6">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="5">
<widget class="QPushButton" name="step_increase_hor">
<property name="focusPolicy">
<enum>Qt::FocusPolicy::NoFocus</enum>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="6" column="4">
<spacer name="horizontalSpacer_10">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="2">
<widget class="QPushButton" name="tweak_decrease_hor">
<property name="focusPolicy">
<enum>Qt::FocusPolicy::NoFocus</enum>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="2">
<spacer name="horizontalSpacer_15">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>10</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="1">
<spacer name="horizontalSpacer_35">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="3">
<widget class="QPushButton" name="tweak_decrease_ver">
<property name="focusPolicy">
<enum>Qt::FocusPolicy::NoFocus</enum>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QPushButton" name="step_decrease_hor">
<property name="focusPolicy">
<enum>Qt::FocusPolicy::NoFocus</enum>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="3" column="4">
<widget class="QPushButton" name="tweak_increase_hor">
<property name="focusPolicy">
<enum>Qt::FocusPolicy::NoFocus</enum>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="2" column="3">
<widget class="QPushButton" name="tweak_increase_ver">
<property name="focusPolicy">
<enum>Qt::FocusPolicy::NoFocus</enum>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="2" column="4">
<spacer name="horizontalSpacer_12">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="4">
<spacer name="horizontalSpacer_8">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="4">
<spacer name="horizontalSpacer_11">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="5">
<spacer name="horizontalSpacer_5">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="6" column="5">
<widget class="StopButton" name="stop_button"/>
</item>
</layout>
</item>
<item row="2" column="1">
<widget class="PositionIndicator" name="position_indicator_hor">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximum" stdset="0">
<double>1.000000000000000</double>
</property>
<property name="value" stdset="0">
<double>0.500000000000000</double>
</property>
<property name="indicator_width" stdset="0">
<number>4</number>
</property>
<property name="rounded_corners" stdset="0">
<number>4</number>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>StopButton</class>
<extends>QWidget</extends>
<header>stop_button</header>
</customwidget>
<customwidget>
<class>PositionIndicator</class>
<extends>QWidget</extends>
<header>position_indicator</header>
</customwidget>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<header>spinner_widget</header>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>tool_button_hor</tabstop>
<tabstop>tool_button_ver</tabstop>
<tabstop>setpoint_hor</tabstop>
<tabstop>setpoint_ver</tabstop>
<tabstop>step_size_hor</tabstop>
<tabstop>step_size_ver</tabstop>
<tabstop>tweak_decrease_hor</tabstop>
<tabstop>tweak_increase_ver</tabstop>
<tabstop>tweak_increase_hor</tabstop>
<tabstop>tweak_decrease_ver</tabstop>
<tabstop>step_decrease_hor</tabstop>
<tabstop>step_increase_ver</tabstop>
<tabstop>step_increase_hor</tabstop>
<tabstop>step_decrease_ver</tabstop>
</tabstops>
<resources/>
<connections/>
</ui>

View File

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

View File

@@ -6,7 +6,7 @@ import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
DOM_XML = """
<ui language='c++'>

View File

@@ -1,8 +1,6 @@
import os
from bec_lib.device import Positioner
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
class PositionerControlLine(PositionerBox):
@@ -14,14 +12,13 @@ class PositionerControlLine(PositionerBox):
PLUGIN = True
ICON_NAME = "switch_left"
def __init__(self, parent=None, device: Positioner | str | None = None, *args, **kwargs):
def __init__(self, parent=None, device: Positioner = None, *args, **kwargs):
"""Initialize the DeviceControlLine.
Args:
parent: The parent widget.
device (Positioner): The device to control.
"""
self.current_path = os.path.dirname(__file__)
super().__init__(parent=parent, device=device, *args, **kwargs)

View File

@@ -6,7 +6,9 @@ import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_box import PositionerControlLine
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line import (
PositionerControlLine,
)
DOM_XML = """
<ui language='c++'>

View File

@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box_plugin import (
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_plugin import (
PositionerBoxPlugin,
)

View File

@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line_plugin import (
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line_plugin import (
PositionerControlLinePlugin,
)

View File

@@ -4,12 +4,11 @@ from __future__ import annotations
from bec_lib.device import Positioner
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Signal
from qtpy.QtCore import Property, QSize, Signal, Slot
from qtpy.QtWidgets import QGridLayout, QGroupBox, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
logger = bec_logger.logger
@@ -33,7 +32,7 @@ class PositionerGroupBox(QGroupBox):
self.widget.position_update.connect(self._on_position_update)
self.widget.expand.connect(self._on_expand)
self.setTitle(self.device_name)
self.widget.force_update_readback()
self.widget.init_device() # force readback
def _on_expand(self, expand):
if expand:
@@ -83,7 +82,7 @@ class PositionerGroup(BECWidget, QWidget):
def minimumSizeHint(self):
return QSize(300, 30)
@SafeSlot(str)
@Slot(str)
def set_positioners(self, device_names: str):
"""Redraw grid with positioners from device_names string
@@ -131,7 +130,7 @@ class PositionerGroup(BECWidget, QWidget):
widget = self.sender()
self.device_position_update.emit(widget.title(), pos)
@SafeProperty(str)
@Property(str)
def devices_list(self):
"""Device names string separated by space"""
return " ".join(self._device_widgets)
@@ -145,7 +144,7 @@ class PositionerGroup(BECWidget, QWidget):
return
self.set_positioners(device_names)
@SafeProperty(int)
@Property(int)
def grid_max_cols(self):
"""Max number of columns for widgets grid"""
return self._grid_ncols

View File

@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combo_box_plugin import (
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox_plugin import (
SignalComboBoxPlugin,
)

View File

@@ -8,7 +8,7 @@ from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox im
DOM_XML = """
<ui language='c++'>
<widget class='SignalComboBox' name='signal_combo_box'>
<widget class='SignalComboBox' name='signal_combobox'>
</widget>
</ui>
"""
@@ -33,7 +33,7 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return designer_material_icon(SignalComboBox.ICON_NAME)
def includeFile(self):
return "signal_combo_box"
return "signal_combobox"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -48,7 +48,7 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "SignalComboBox"
def toolTip(self):
return "Signal ComboBox Example for BEC Widgets with autocomplete."
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -50,7 +50,7 @@ class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "SignalLineEdit"
def toolTip(self):
return "Signal LineEdit Example for BEC Widgets with autocomplete."
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -1,16 +1,15 @@
""" Module for DapComboBox widget class to select a DAP model from a combobox. """
from bec_lib.logger import bec_logger
from PySide6.QtWidgets import QSizePolicy
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QComboBox
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
class DapComboBox(BECWidget, QComboBox):
class DapComboBox(BECWidget, QWidget):
"""
The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.
@@ -40,13 +39,17 @@ class DapComboBox(BECWidget, QComboBox):
def __init__(
self, parent=None, client=None, gui_id: str | None = None, default_fit: str | None = None
):
BECWidget.__init__(self, client=client, gui_id=gui_id)
QComboBox.__init__(self, parent=parent)
super().__init__(client=client, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.layout = QVBoxLayout(self)
self.fit_model_combobox = QComboBox(self)
self.layout.addWidget(self.fit_model_combobox)
self.layout.setContentsMargins(0, 0, 0, 0)
self._available_models = None
self._x_axis = None
self._y_axis = None
self.populate_fit_model_combobox()
self.currentTextChanged.connect(self._update_current_fit)
self.fit_model_combobox.currentTextChanged.connect(self._update_current_fit)
# Set default fit model
self.select_default_fit(default_fit)
@@ -121,7 +124,7 @@ class DapComboBox(BECWidget, QComboBox):
x_axis(str): X axis.
"""
self.x_axis = x_axis
self._update_current_fit(self.currentText())
self._update_current_fit(self.fit_model_combobox.currentText())
@Slot(str)
def select_y_axis(self, y_axis: str):
@@ -131,7 +134,7 @@ class DapComboBox(BECWidget, QComboBox):
y_axis(str): Y axis.
"""
self.y_axis = y_axis
self._update_current_fit(self.currentText())
self._update_current_fit(self.fit_model_combobox.currentText())
@Slot(str)
def select_fit_model(self, fit_name: str | None):
@@ -142,14 +145,14 @@ class DapComboBox(BECWidget, QComboBox):
"""
if not self._validate_dap_model(fit_name):
raise ValueError(f"Fit {fit_name} is not valid.")
self.setCurrentText(fit_name)
self.fit_model_combobox.setCurrentText(fit_name)
def populate_fit_model_combobox(self):
"""Populate the fit_model_combobox with the devices."""
# pylint: disable=protected-access
self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()]
self.clear()
self.addItems(self.available_models)
self.fit_model_combobox.clear()
self.fit_model_combobox.addItems(self.available_models)
def _validate_dap_model(self, model: str | None) -> bool:
"""Validate the DAP model.
@@ -166,14 +169,14 @@ class DapComboBox(BECWidget, QComboBox):
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("dark")
widget = QWidget()
# widget.setFixedSize(200, 200)
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
layout.addWidget(DapComboBox())

View File

@@ -1,10 +1,9 @@
import os
from bec_lib.logger import bec_logger
from qtpy.QtCore import Signal
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QPushButton, QTreeWidgetItem, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
@@ -44,8 +43,6 @@ class LMFitDialog(BECWidget, QWidget):
"""
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.setProperty("skip_settings", True)
self.setObjectName("LMFitDialog")
self._ui_file = ui_file
self.target_widget = target_widget
@@ -68,7 +65,7 @@ class LMFitDialog(BECWidget, QWidget):
@property
def enable_actions(self) -> bool:
"""SafeProperty to enable the move to buttons."""
"""Property to enable the move to buttons."""
return self._enable_actions
@enable_actions.setter
@@ -77,37 +74,37 @@ class LMFitDialog(BECWidget, QWidget):
for button in self.action_buttons.values():
button.setEnabled(enable)
@SafeProperty(list)
@Property(list)
def active_action_list(self) -> list[str]:
"""SafeProperty to list the names of the fit parameters for which actions should be enabled."""
"""Property to list the names of the fit parameters for which actions should be enabled."""
return self._active_actions
@active_action_list.setter
def active_action_list(self, actions: list[str]):
self._active_actions = actions
# This SafeSlot needed?
@SafeSlot(bool)
# This slot needed?
@Slot(bool)
def set_actions_enabled(self, enable: bool) -> bool:
"""SafeSlot to enable the move to buttons.
"""Slot to enable the move to buttons.
Args:
enable (bool): Whether to enable the action buttons.
"""
self.enable_actions = enable
@SafeProperty(bool)
@Property(bool)
def always_show_latest(self):
"""SafeProperty to indicate if always the latest DAP update is displayed."""
"""Property to indicate if always the latest DAP update is displayed."""
return self._always_show_latest
@always_show_latest.setter
def always_show_latest(self, show: bool):
self._always_show_latest = show
@SafeProperty(bool)
@Property(bool)
def hide_curve_selection(self):
"""SafeProperty for showing the curve selection."""
"""Property for showing the curve selection."""
return not self.ui.group_curve_selection.isVisible()
@hide_curve_selection.setter
@@ -119,9 +116,9 @@ class LMFitDialog(BECWidget, QWidget):
"""
self.ui.group_curve_selection.setVisible(not show)
@SafeProperty(bool)
@Property(bool)
def hide_summary(self) -> bool:
"""SafeProperty for showing the summary."""
"""Property for showing the summary."""
return not self.ui.group_summary.isVisible()
@hide_summary.setter
@@ -133,9 +130,9 @@ class LMFitDialog(BECWidget, QWidget):
"""
self.ui.group_summary.setVisible(not show)
@SafeProperty(bool)
@Property(bool)
def hide_parameters(self) -> bool:
"""SafeProperty for showing the parameters."""
"""Property for showing the parameters."""
return not self.ui.group_parameters.isVisible()
@hide_parameters.setter
@@ -149,7 +146,7 @@ class LMFitDialog(BECWidget, QWidget):
@property
def fit_curve_id(self) -> str:
"""SafeProperty for the currently displayed fit curve_id."""
"""Property for the currently displayed fit curve_id."""
return self._fit_curve_id
@fit_curve_id.setter
@@ -162,7 +159,7 @@ class LMFitDialog(BECWidget, QWidget):
self._fit_curve_id = curve_id
self.selected_fit.emit(curve_id)
@SafeSlot(str)
@Slot(str)
def remove_dap_data(self, curve_id: str):
"""Remove the DAP data for the given curve_id.
@@ -172,7 +169,7 @@ class LMFitDialog(BECWidget, QWidget):
self.summary_data.pop(curve_id, None)
self.refresh_curve_list()
@SafeSlot(str)
@Slot(str)
def select_curve(self, curve_id: str):
"""Select active curve_id in the curve list.
@@ -181,7 +178,7 @@ class LMFitDialog(BECWidget, QWidget):
"""
self.fit_curve_id = curve_id
@SafeSlot(dict, dict)
@Slot(dict, dict)
def update_summary_tree(self, data: dict, metadata: dict):
"""Update the summary tree with the given data.

View File

@@ -6,12 +6,12 @@
<rect>
<x>0</x>
<y>0</y>
<width>337</width>
<height>552</height>
<width>303</width>
<height>457</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
@@ -35,17 +35,11 @@
<item>
<widget class="QGroupBox" name="group_curve_selection">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>100</height>
</size>
</property>
<property name="title">
<string>Select Curve</string>
</property>
@@ -66,7 +60,7 @@
<item>
<widget class="QGroupBox" name="group_summary">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
@@ -74,7 +68,7 @@
<property name="minimumSize">
<size>
<width>0</width>
<height>200</height>
<height>0</height>
</size>
</property>
<property name="title">
@@ -119,7 +113,7 @@
<item>
<widget class="QGroupBox" name="group_parameters">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
@@ -127,7 +121,7 @@
<property name="minimumSize">
<size>
<width>0</width>
<height>200</height>
<height>0</height>
</size>
</property>
<property name="title">

View File

@@ -5,9 +5,9 @@ from html.parser import HTMLParser
from bec_lib.logger import bec_logger
from pydantic import Field
from qtpy.QtCore import Property, Slot
from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
@@ -66,7 +66,7 @@ class TextBox(BECWidget, QWidget):
else:
self.set_html_text(DEFAULT_TEXT)
@SafeSlot(str)
@Slot(str)
def set_plain_text(self, text: str) -> None:
"""Set the plain text of the widget.
@@ -77,7 +77,7 @@ class TextBox(BECWidget, QWidget):
self.config.text = text
self.config.is_html = False
@SafeSlot(str)
@Slot(str)
def set_html_text(self, text: str) -> None:
"""Set the HTML text of the widget.
@@ -88,7 +88,7 @@ class TextBox(BECWidget, QWidget):
self.config.text = text
self.config.is_html = True
@SafeProperty(str)
@Property(str)
def plain_text(self) -> str:
"""Get the text of the widget.
@@ -106,7 +106,7 @@ class TextBox(BECWidget, QWidget):
"""
self.set_plain_text(text)
@SafeProperty(str)
@Property(str)
def html_text(self) -> str:
"""Get the HTML text of the widget.

View File

@@ -1,3 +0,0 @@
from bec_widgets.widgets.games.minesweeper import Minesweeper
__ALL__ = ["Minesweeper"]

View File

@@ -1,413 +0,0 @@
import enum
import random
import time
from bec_qthemes import material_icon
from qtpy.QtCore import QSize, Qt, QTimer, Signal, Slot
from qtpy.QtGui import QBrush, QColor, QPainter, QPen
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
NUM_COLORS = {
1: QColor("#f44336"),
2: QColor("#9C27B0"),
3: QColor("#3F51B5"),
4: QColor("#03A9F4"),
5: QColor("#00BCD4"),
6: QColor("#4CAF50"),
7: QColor("#E91E63"),
8: QColor("#FF9800"),
}
LEVELS: dict[str, tuple[int, int]] = {"1": (8, 10), "2": (16, 40), "3": (24, 99)}
class GameStatus(enum.Enum):
READY = 0
PLAYING = 1
FAILED = 2
SUCCESS = 3
class Pos(QWidget):
expandable = Signal(int, int)
clicked = Signal()
ohno = Signal()
def __init__(self, x, y, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFixedSize(QSize(20, 20))
self.x = x
self.y = y
self.is_start = False
self.is_mine = False
self.adjacent_n = 0
self.is_revealed = False
self.is_flagged = False
def reset(self):
"""Restore the tile to its original state before mine status is assigned"""
self.is_start = False
self.is_mine = False
self.adjacent_n = 0
self.is_revealed = False
self.is_flagged = False
self.update()
def paintEvent(self, event):
p = QPainter(self)
r = event.rect()
if self.is_revealed:
color = self.palette().base().color()
outer, inner = color, color
else:
outer, inner = (self.palette().highlightedText().color(), self.palette().text().color())
p.fillRect(r, QBrush(inner))
pen = QPen(outer)
pen.setWidth(1)
p.setPen(pen)
p.drawRect(r)
if self.is_revealed:
if self.is_mine:
p.drawPixmap(r, material_icon("experiment", convert_to_pixmap=True, filled=True))
elif self.adjacent_n > 0:
pen = QPen(NUM_COLORS[self.adjacent_n])
p.setPen(pen)
f = p.font()
f.setBold(True)
p.setFont(f)
p.drawText(r, Qt.AlignHCenter | Qt.AlignVCenter, str(self.adjacent_n))
elif self.is_flagged:
p.drawPixmap(
r,
material_icon(
"flag",
size=(50, 50),
convert_to_pixmap=True,
filled=True,
color=self.palette().base().color(),
),
)
p.end()
def flag(self):
self.is_flagged = not self.is_flagged
self.update()
self.clicked.emit()
def reveal(self):
self.is_revealed = True
self.update()
def click(self):
if not self.is_revealed:
self.reveal()
if self.adjacent_n == 0:
self.expandable.emit(self.x, self.y)
self.clicked.emit()
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.RightButton and not self.is_revealed:
self.flag()
return
if event.button() == Qt.MouseButton.LeftButton:
self.click()
if self.is_mine:
self.ohno.emit()
class Minesweeper(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "videogame_asset"
USER_ACCESS = []
def __init__(self, parent=None, *args, **kwargs):
super().__init__(*args, **kwargs)
QWidget.__init__(self, parent=parent)
self._ui_initialised = False
self._timer_start_num_seconds = 0
self._set_level_params(LEVELS["1"])
self._init_ui()
self._init_map()
self.update_status(GameStatus.READY)
self.reset_map()
self.update_status(GameStatus.READY)
def _init_ui(self):
if self._ui_initialised:
return
self._ui_initialised = True
status_hb = QHBoxLayout()
self.mines = QLabel()
self.mines.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
f = self.mines.font()
f.setPointSize(24)
self.mines.setFont(f)
self.reset_button = QPushButton()
self.reset_button.setFixedSize(QSize(32, 32))
self.reset_button.setIconSize(QSize(32, 32))
self.reset_button.setFlat(True)
self.reset_button.pressed.connect(self.reset_button_pressed)
self.clock = QLabel()
self.clock.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
self.clock.setFont(f)
self._timer = QTimer()
self._timer.timeout.connect(self.update_timer)
self._timer.start(1000) # 1 second timer
self.mines.setText(f"{self.num_mines:03d}")
self.clock.setText("000")
status_hb.addWidget(self.mines)
status_hb.addWidget(self.reset_button)
status_hb.addWidget(self.clock)
level_hb = QHBoxLayout()
self.level_selector = QComboBox()
self.level_selector.addItems(list(LEVELS.keys()))
level_hb.addWidget(QLabel("Level: "))
level_hb.addWidget(self.level_selector)
self.level_selector.currentTextChanged.connect(self.change_level)
vb = QVBoxLayout()
vb.addLayout(level_hb)
vb.addLayout(status_hb)
self.grid = QGridLayout()
self.grid.setSpacing(5)
vb.addLayout(self.grid)
self.setLayout(vb)
def _init_map(self):
"""Redraw the grid of mines"""
# Remove any previous grid items and reset the grid
for i in reversed(range(self.grid.count())):
w: Pos = self.grid.itemAt(i).widget()
w.clicked.disconnect(self.on_click)
w.expandable.disconnect(self.expand_reveal)
w.ohno.disconnect(self.game_over)
w.setParent(None)
w.deleteLater()
# Add positions to the map
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = Pos(x, y)
self.grid.addWidget(w, y, x)
# Connect signal to handle expansion.
w.clicked.connect(self.on_click)
w.expandable.connect(self.expand_reveal)
w.ohno.connect(self.game_over)
def reset_map(self):
"""
Reset the map and add new mines.
"""
# Clear all mine positions
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = self.grid.itemAtPosition(y, x).widget()
w.reset()
# Add mines to the positions
positions = []
while len(positions) < self.num_mines:
x, y = (random.randint(0, self.b_size - 1), random.randint(0, self.b_size - 1))
if (x, y) not in positions:
w = self.grid.itemAtPosition(y, x).widget()
w.is_mine = True
positions.append((x, y))
def get_adjacency_n(x, y):
positions = self.get_surrounding(x, y)
num_mines = sum(1 if w.is_mine else 0 for w in positions)
return num_mines
# Add adjacencies to the positions
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = self.grid.itemAtPosition(y, x).widget()
w.adjacent_n = get_adjacency_n(x, y)
# Place starting marker
while True:
x, y = (random.randint(0, self.b_size - 1), random.randint(0, self.b_size - 1))
w = self.grid.itemAtPosition(y, x).widget()
# We don't want to start on a mine.
if (x, y) not in positions:
w = self.grid.itemAtPosition(y, x).widget()
w.is_start = True
# Reveal all positions around this, if they are not mines either.
for w in self.get_surrounding(x, y):
if not w.is_mine:
w.click()
break
def get_surrounding(self, x, y):
positions = []
for xi in range(max(0, x - 1), min(x + 2, self.b_size)):
for yi in range(max(0, y - 1), min(y + 2, self.b_size)):
positions.append(self.grid.itemAtPosition(yi, xi).widget())
return positions
def get_num_hidden(self) -> int:
"""
Get the number of hidden positions.
"""
return sum(
1
for x in range(0, self.b_size)
for y in range(0, self.b_size)
if not self.grid.itemAtPosition(y, x).widget().is_revealed
)
def get_num_remaining_flags(self) -> int:
"""
Get the number of remaining flags.
"""
return self.num_mines - sum(
1
for x in range(0, self.b_size)
for y in range(0, self.b_size)
if self.grid.itemAtPosition(y, x).widget().is_flagged
)
def reset_button_pressed(self):
match self.status:
case GameStatus.PLAYING:
self.game_over()
case GameStatus.FAILED | GameStatus.SUCCESS:
self.reset_map()
def reveal_map(self):
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = self.grid.itemAtPosition(y, x).widget()
w.reveal()
@Slot(str)
def change_level(self, level: str):
self._set_level_params(LEVELS[level])
self._init_map()
self.reset_map()
@Slot(int, int)
def expand_reveal(self, x, y):
"""
Expand the reveal to the surrounding
Args:
x (int): The x position.
y (int): The y position.
"""
for xi in range(max(0, x - 1), min(x + 2, self.b_size)):
for yi in range(max(0, y - 1), min(y + 2, self.b_size)):
w = self.grid.itemAtPosition(yi, xi).widget()
if not w.is_mine:
w.click()
@Slot()
def on_click(self):
"""
Handle the click event. If the game is not started, start the game.
"""
self.update_available_flags()
if self.status != GameStatus.PLAYING:
# First click.
self.update_status(GameStatus.PLAYING)
# Start timer.
self._timer_start_num_seconds = int(time.time())
return
self.check_win()
def update_available_flags(self):
"""
Update the number of available flags.
"""
self.mines.setText(f"{self.get_num_remaining_flags():03d}")
def check_win(self):
"""
Check if the game is won.
"""
if self.get_num_hidden() == self.num_mines:
self.update_status(GameStatus.SUCCESS)
def update_status(self, status: GameStatus):
"""
Update the status of the game.
Args:
status (GameStatus): The status of the game.
"""
self.status = status
match status:
case GameStatus.READY:
icon = material_icon(icon_name="add", convert_to_pixmap=False)
case GameStatus.PLAYING:
icon = material_icon(icon_name="smart_toy", convert_to_pixmap=False)
case GameStatus.FAILED:
icon = material_icon(icon_name="error", convert_to_pixmap=False)
case GameStatus.SUCCESS:
icon = material_icon(icon_name="celebration", convert_to_pixmap=False)
self.reset_button.setIcon(icon)
def update_timer(self):
"""
Update the timer.
"""
if self.status == GameStatus.PLAYING:
num_seconds = int(time.time()) - self._timer_start_num_seconds
self.clock.setText(f"{num_seconds:03d}")
def game_over(self):
"""Cause the game to end early"""
self.reveal_map()
self.update_status(GameStatus.FAILED)
def _set_level_params(self, level: tuple[int, int]):
self.b_size, self.num_mines = level
if __name__ == "__main__":
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("light")
widget = Minesweeper()
widget.show()
app.exec_()

View File

@@ -1 +0,0 @@
{'files': ['minesweeper.py']}

View File

@@ -1,54 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.games.minesweeper import Minesweeper
DOM_XML = """
<ui language='c++'>
<widget class='Minesweeper' name='minesweeper'>
</widget>
</ui>
"""
class MinesweeperPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = Minesweeper(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Games"
def icon(self):
return designer_material_icon(Minesweeper.ICON_NAME)
def includeFile(self):
return "minesweeper"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "Minesweeper"
def toolTip(self):
return "Minesweeper"
def whatsThis(self):
return self.toolTip()

View File

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

View File

@@ -190,7 +190,7 @@ class BECImageWidget(BECWidget, QWidget):
###################################
# User Access Methods from image
###################################
@SafeSlot(popup_error=True)
@SafeSlot(popup_error=False)
def image(
self,
monitor: str,

View File

@@ -1,602 +0,0 @@
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.roi_bundle import ROIBundle
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._user_x_label = ""
self._x_label_suffix = ""
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)
self.roi_bundle = ROIBundle("roi", 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_bundle(self.roi_bundle, 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)
)
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._user_x_label
@x_label.setter
def x_label(self, value: str):
self._user_x_label = value
self._apply_x_label()
self.property_changed.emit("x_label", self._user_x_label)
@property
def x_label_suffix(self) -> str:
"""
A read-only (or internal) suffix automatically appended to the user label.
Not settable by the user directly from the UI.
"""
return self._x_label_suffix
def set_x_label_suffix(self, suffix: str):
"""
Public or protected method to update the suffix.
The user code or subclass (Waveform) can call this
when x_mode changes, but the AxisSettings won't show it.
"""
self._x_label_suffix = suffix
self._apply_x_label()
@property
def x_label_combined(self) -> str:
"""
The final label shown on the axis = user portion + suffix.
"""
return self._user_x_label + self._x_label_suffix
def _apply_x_label(self):
"""
Actually updates the pyqtgraph axis label text to
the combined label. Called whenever user label or suffix changes.
"""
final_label = self.x_label_combined
self.plot_item.setLabel("bottom", text=final_label)
@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_())

View File

@@ -1,95 +0,0 @@
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)

View File

@@ -1,256 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>427</width>
<height>270</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>250</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>278</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="plot_title"/>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
<string>Y Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="3" column="2">
<widget class="QComboBox" name="y_scale">
<item>
<property name="text">
<string>linear</string>
</property>
</item>
<item>
<property name="text">
<string>log</string>
</property>
</item>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="y_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="y_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="y_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="y_label"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="y_scale_label">
<property name="text">
<string>Scale</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="y_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="y_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QCheckBox" name="y_grid">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="y_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="2" column="0">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Scale</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="x_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="x_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QComboBox" name="x_scale">
<item>
<property name="text">
<string>linear</string>
</property>
</item>
<item>
<property name="text">
<string>log</string>
</property>
</item>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QCheckBox" name="x_grid">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="switch_outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,245 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>218</width>
<height>561</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>General</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item row="0" column="1" colspan="2">
<widget class="QLineEdit" name="title"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
<item row="2" column="0" colspan="2">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="x_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Log</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="ToggleSwitch" name="x_log">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="x_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="ToggleSwitch" name="x_grid">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
<string>Y Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="y_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="y_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="y_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="y_label"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="y_scale_label">
<property name="text">
<string>Log</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="y_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="y_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="y_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="ToggleSwitch" name="y_log">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="ToggleSwitch" name="y_grid">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,88 +0,0 @@
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

View File

@@ -1,63 +0,0 @@
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

View File

@@ -1,32 +0,0 @@
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle
class ROIBundle(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 crosshair and ROI interaction.
"""
def __init__(self, bundle_id="roi", 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.
crosshair = MaterialIconAction(
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
)
roi = MaterialIconAction(
icon_name="align_justify_space_between",
tooltip="Add ROI region for DAP",
checkable=True,
)
# Add them to the bundle
self.add_action("crosshair", crosshair)
self.add_action("roi_linear", roi)
# Immediately connect signals
crosshair.action.toggled.connect(self.target_widget.toggle_crosshair)

View File

@@ -1,48 +0,0 @@
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()

View File

@@ -1,268 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from pydantic import BaseModel, Field, field_validator
from qtpy import QtCore
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
if TYPE_CHECKING:
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
logger = bec_logger.logger
# noinspection PyDataclass
class DeviceSignal(BaseModel):
"""The configuration of a signal in the 1D waveform widget."""
name: str
entry: str
dap: Optional[str] = None # TODO utilize differently than in past
model_config: dict = {"validate_assignment": True}
# noinspection PyDataclass
class CurveConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent plot of the curve.")
label: Optional[str] = Field(None, description="The label of the curve.")
color: Optional[str | tuple] = Field(None, description="The color of the curve.")
symbol: Optional[str | None] = Field("o", description="The symbol of the curve.")
symbol_color: Optional[str | tuple] = Field(
None, description="The color of the symbol of the curve."
)
symbol_size: Optional[int] = Field(7, description="The size of the symbol of the curve.")
pen_width: Optional[int] = Field(4, description="The width of the pen of the curve.")
pen_style: Optional[Literal["solid", "dash", "dot", "dashdot"]] = Field(
"solid", description="The style of the pen of the curve."
)
source: Literal["device", "dap", "custom"] = Field(
"custom", description="The source of the curve."
)
signal: Optional[DeviceSignal] = Field(None, description="The signal of the curve.")
# TODO do validator for parent_label
parent_label: Optional[str] = Field(
None, description="The label of the parent plot, only relevant for dap curves."
)
model_config: dict = {"validate_assignment": True}
_validate_color = field_validator("color")(Colors.validate_color)
_validate_symbol_color = field_validator("symbol_color")(Colors.validate_color)
class Curve(BECConnector, pg.PlotDataItem):
USER_ACCESS = [
"remove",
"dap_params",
"_rpc_id",
"_config_dict",
"set",
"set_data",
"set_color",
"set_color_map_z",
"set_symbol",
"set_symbol_color",
"set_symbol_size",
"set_pen_width",
"set_pen_style",
"get_data",
"dap_params",
]
def __init__(
self,
name: Optional[str] = None,
config: Optional[CurveConfig] = None,
gui_id: Optional[str] = None,
parent_item: Optional[Waveform] = None,
**kwargs,
):
if config is None:
config = CurveConfig(label=name, widget_class=self.__class__.__name__)
self.config = config
else:
self.config = config
# config.widget_class = self.__class__.__name__
super().__init__(config=config, gui_id=gui_id)
pg.PlotDataItem.__init__(self, name=name)
self.parent_item = parent_item
self.apply_config()
self.dap_params = None
self.dap_summary = None
if kwargs:
self.set(**kwargs)
def apply_config(self):
pen_style_map = {
"solid": QtCore.Qt.SolidLine,
"dash": QtCore.Qt.DashLine,
"dot": QtCore.Qt.DotLine,
"dashdot": QtCore.Qt.DashDotLine,
}
pen_style = pen_style_map.get(self.config.pen_style, QtCore.Qt.SolidLine)
pen = pg.mkPen(color=self.config.color, width=self.config.pen_width, style=pen_style)
self.setPen(pen)
if self.config.symbol:
symbol_color = self.config.symbol_color or self.config.color
brush = pg.mkBrush(color=symbol_color)
self.setSymbolBrush(brush)
self.setSymbolSize(self.config.symbol_size)
self.setSymbol(self.config.symbol)
@property
def dap_params(self):
return self._dap_params
@dap_params.setter
def dap_params(self, value):
self._dap_params = value
@property
def dap_summary(self):
return self._dap_report
@dap_summary.setter
def dap_summary(self, value):
self._dap_report = value
def set_data(self, x, y):
if self.config.source == "custom":
self.setData(x, y)
else:
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")
def set(self, **kwargs):
"""
Set the properties of the curve.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- color: str
- symbol: str
- symbol_color: str
- symbol_size: int
- pen_width: int
- pen_style: Literal["solid", "dash", "dot", "dashdot"]
"""
# Mapping of keywords to setter methods
method_map = {
"color": self.set_color,
"color_map_z": self.set_color_map_z,
"symbol": self.set_symbol,
"symbol_color": self.set_symbol_color,
"symbol_size": self.set_symbol_size,
"pen_width": self.set_pen_width,
"pen_style": self.set_pen_style,
}
for key, value in kwargs.items():
if key in method_map:
method_map[key](value)
else:
logger.warning(f"Warning: '{key}' is not a recognized property.")
def set_color(self, color: str, symbol_color: Optional[str] = None):
"""
Change the color of the curve.
Args:
color(str): Color of the curve.
symbol_color(str, optional): Color of the symbol. Defaults to None.
"""
self.config.color = color
self.config.symbol_color = symbol_color or color
self.apply_config()
def set_symbol(self, symbol: str):
"""
Change the symbol of the curve.
Args:
symbol(str): Symbol of the curve.
"""
self.config.symbol = symbol
self.setSymbol(symbol)
self.updateItems()
def set_symbol_color(self, symbol_color: str):
"""
Change the symbol color of the curve.
Args:
symbol_color(str): Color of the symbol.
"""
self.config.symbol_color = symbol_color
self.apply_config()
def set_symbol_size(self, symbol_size: int):
"""
Change the symbol size of the curve.
Args:
symbol_size(int): Size of the symbol.
"""
self.config.symbol_size = symbol_size
self.apply_config()
def set_pen_width(self, pen_width: int):
"""
Change the pen width of the curve.
Args:
pen_width(int): Width of the pen.
"""
self.config.pen_width = pen_width
self.apply_config()
def set_pen_style(self, pen_style: Literal["solid", "dash", "dot", "dashdot"]):
"""
Change the pen style of the curve.
Args:
pen_style(Literal["solid", "dash", "dot", "dashdot"]): Style of the pen.
"""
self.config.pen_style = pen_style
self.apply_config()
def set_color_map_z(self, colormap: str):
"""
Set the colormap for the scatter plot z gradient.
Args:
colormap(str): Colormap for the scatter plot.
"""
self.config.color_map_z = colormap
self.apply_config()
self.parent_item.scan_history(-1)
def get_data(self) -> tuple[np.ndarray, np.ndarray]:
"""
Get the data of the curve.
Returns:
tuple[np.ndarray,np.ndarray]: X and Y data of the curve.
"""
try:
x_data, y_data = self.getData()
except TypeError:
x_data, y_data = np.array([]), np.array([])
return x_data, y_data
def clear_data(self):
self.setData([], [])
def remove(self):
"""Remove the curve from the plot."""
# self.parent_item.removeItem(self)
self.parent_item.remove_curve(self.name())
self.rpc_register.remove_rpc(self)

View File

@@ -1,30 +0,0 @@
import os
from PySide6.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
class CurveItem(QWidget):
"""
#TODO change this nonsense docstring
Widget that lets a user set up curves for the Waveform widget.
It allows:
- Selecting color palette for the entire widget
- Choosing x-axis mode
- Selecting device and signal
- Adding a new curve
- Viewing existing curves in a QTreeWidget
"""
def __init__(self, parent=None, target_widget=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self.setObjectName("CurveSettings")
current_path = os.path.dirname(__file__)
self.ui = UILoader().load_ui(os.path.join(current_path, "curve_settings.ui"), self)
self.target_widget = target_widget
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.ui)

View File

@@ -1,75 +0,0 @@
import os
from bec_qthemes import material_icon
from pyqtgraph import ColorMapWidget
from PySide6.QtWidgets import QComboBox, QPushButton, QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.widgets.containers.expantion_panel.expansion_panel import ExpansionPanel
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit import (
SignalLineEdit,
)
class CurveSettingWidget(QWidget):
"""
Widget that lets a user set up curves for the Waveform widget.
It allows:
- Selecting color palette for the entire widget
- Choosing x-axis mode
- Selecting device and signal
- Adding a new curve
- Viewing existing curves in a QTreeWidget
"""
def __init__(self, parent=None, target_widget=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self.setObjectName("CurveSettings")
self.current_path = os.path.dirname(__file__)
self.main_setting_ui = UILoader().load_ui(
os.path.join(self.current_path, "main_settings.ui"), self
)
self.target_widget = target_widget
self.layout = QVBoxLayout(self)
# self.layout.addWidget(self.ui)
self.main_setting = ExpansionPanel(title="Main Settings", expanded=True)
self.curve_setting_1 = ExpansionPanel(title="Curve 1", expanded=False)
self.curve_setting_2 = ExpansionPanel(title="Curve 2", expanded=False)
self.curve_setting_3 = ExpansionPanel(title="Curve 3", expanded=False)
self.layout.addWidget(self.main_setting)
for cs in [self.curve_setting_1, self.curve_setting_2, self.curve_setting_3]:
self.layout.addWidget(cs)
self._init_curve(cs)
self._init_main_settings()
# add spacer
self.layout.addStretch()
def _init_main_settings(self):
self.main_setting.content_layout.addWidget(self.main_setting_ui)
def _init_curve(self, curve_setting):
icon = material_icon("delete", color=(255, 0, 0, 255))
delete_button = QPushButton()
delete_button.setIcon(icon)
curve_ui = UILoader().load_ui(os.path.join(self.current_path, "curve_3.ui"), self)
curve_setting.header_layout.addWidget(delete_button)
curve_setting.content_layout.addWidget(curve_ui)
if __name__ == "__main__":
import sys
from PySide6.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = CurveSettingWidget()
widget.show()
sys.exit(app.exec_())

View File

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

View File

@@ -1,127 +0,0 @@
"""
waveform_plot.py
A minimal demonstration widget with multiple properties:
- deviceName (str)
- curvesJson (str)
- someFlag (bool)
It uses pyqtgraph to show dummy curves from 'curvesJson'.
"""
import json
import pyqtgraph as pg
from qtpy.QtCore import Property, QPointF
from qtpy.QtWidgets import QVBoxLayout, QWidget
class WaveformPlot(QWidget):
"""
Minimal demonstration of a multi-property widget:
- deviceName (string)
- curvesJson (string containing JSON)
- someFlag (boolean)
"""
ICON_NAME = "multiline_chart" # For a designer icon, if desired
def __init__(self, parent=None):
super().__init__(parent)
self._device_name = "MyDevice"
self._curves_json = "[]"
self._some_flag = False
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.plot_item = pg.PlotItem()
self.plot_widget = pg.PlotWidget(plotItem=self.plot_item)
layout.addWidget(self.plot_widget)
self._plot_curves = []
# ------------------------------------------------------------------------
# Property #1: deviceName
# ------------------------------------------------------------------------
def getDeviceName(self) -> str:
return self._device_name
def setDeviceName(self, val: str):
if self._device_name != val:
self._device_name = val
# You might do something in your real code
# e.g. re-subscribe to a device, etc.
deviceName = Property(str, fget=getDeviceName, fset=setDeviceName)
# ------------------------------------------------------------------------
# Property #2: curvesJson
# ------------------------------------------------------------------------
def getCurvesJson(self) -> str:
return self._curves_json
def setCurvesJson(self, new_json: str):
if self._curves_json != new_json:
self._curves_json = new_json
self._rebuild_curves()
curvesJson = Property(str, fget=getCurvesJson, fset=setCurvesJson, designable=False)
# ------------------------------------------------------------------------
# Property #3: someFlag
# ------------------------------------------------------------------------
def getSomeFlag(self) -> bool:
return self._some_flag
def setSomeFlag(self, val: bool):
if self._some_flag != val:
self._some_flag = val
# React to the flag in your real code if needed
someFlag = Property(bool, fget=getSomeFlag, fset=setSomeFlag)
# ------------------------------------------------------------------------
# Re-build the curves from the JSON
# ------------------------------------------------------------------------
def _rebuild_curves(self):
# Remove existing PlotDataItems
for c in self._plot_curves:
self.plot_item.removeItem(c)
self._plot_curves.clear()
# Try parse JSON
try:
arr = json.loads(self._curves_json)
if not isinstance(arr, list):
raise ValueError("curvesJson must be a JSON list.")
except Exception:
# If parsing fails, do nothing
return
# Create new PlotDataItems from the JSON
for idx, cdef in enumerate(arr):
label = cdef.get("label", f"Curve{idx + 1}")
color = cdef.get("color", "blue")
x = [0, 1, 2, 3, 4]
y = [val + idx for val in x]
item = pg.PlotDataItem(x, y, pen=color, name=label)
self.plot_item.addItem(item)
self._plot_curves.append(item)
# Optional standalone test
if __name__ == "__main__":
import json
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
w = WaveformPlot()
w.deviceName = "TestDevice"
w.curvesJson = json.dumps([{"label": "A", "color": "red"}, {"label": "B", "color": "green"}])
w.someFlag = True
w.show()
sys.exit(app.exec_())

View File

@@ -1 +0,0 @@
{'files': ['waveform_demo.py']}

View File

@@ -1,178 +0,0 @@
"""
waveform_plot_config_dialog.py
A single dialog that configures a WaveformPlot's properties:
- deviceName
- someFlag
- curvesJson (with add/remove curve, color picking, etc.)
You can call this dialog in normal code:
dlg = WaveformPlotConfigDialog(myWaveformPlot)
if dlg.exec_() == QDialog.Accepted:
# properties updated
Or from a QDesignerTaskMenu (see waveform_plot_taskmenu.py).
"""
import json
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QCheckBox,
QColorDialog,
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
class WaveformPlotConfigDialog(QDialog):
"""
Edits three properties of a WaveformPlot:
- deviceName (string)
- someFlag (bool)
- curvesJson (JSON array of {label, color})
In real usage, you might add more fields (pen widths, device signals, etc.).
"""
def __init__(self, waveform_plot, parent=None):
super().__init__(parent, Qt.WindowTitleHint | Qt.WindowSystemMenuHint)
self.setWindowTitle("WaveformPlot Configuration")
self._wp = waveform_plot # We'll read and write properties on this widget
main_layout = QVBoxLayout(self)
self.setLayout(main_layout)
# ---------------------------
# Row 1: deviceName
# ---------------------------
row1 = QHBoxLayout()
row1.addWidget(QLabel("Device Name:"))
self._device_name_edit = QLineEdit(self)
self._device_name_edit.setText(self._wp.deviceName)
row1.addWidget(self._device_name_edit)
main_layout.addLayout(row1)
# ---------------------------
# Row 2: someFlag (bool)
# ---------------------------
row2 = QHBoxLayout()
self._flag_checkbox = QCheckBox("someFlag", self)
self._flag_checkbox.setChecked(self._wp.someFlag)
row2.addWidget(self._flag_checkbox)
row2.addStretch()
main_layout.addLayout(row2)
# ---------------------------
# The curves config area
# We'll store an internal list of curves
# so we can load them from curvesJson
# and then re-serialize after changes.
# ---------------------------
self._curves_data = []
try:
arr = json.loads(self._wp.curvesJson)
if isinstance(arr, list):
self._curves_data = arr
except:
pass
self._curves_layout = QVBoxLayout()
main_layout.addLayout(self._curves_layout)
add_curve_btn = QPushButton("Add Curve")
add_curve_btn.clicked.connect(self._on_add_curve)
main_layout.addWidget(add_curve_btn)
# OK / Cancel
box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
box.accepted.connect(self.accept)
box.rejected.connect(self.reject)
main_layout.addWidget(box)
self._refresh_curves_rows()
def _refresh_curves_rows(self):
# Clear old row widgets
while True:
item = self._curves_layout.takeAt(0)
if not item:
break
w = item.widget()
if w:
w.deleteLater()
# Create row per curve
for idx, cinfo in enumerate(self._curves_data):
row_widget = self._create_curve_row(idx, cinfo)
self._curves_layout.addWidget(row_widget)
def _create_curve_row(self, idx, cinfo):
container = QWidget(self)
hl = QHBoxLayout(container)
label_edit = QLineEdit(cinfo.get("label", ""), container)
label_edit.setPlaceholderText("Label")
label_edit.textChanged.connect(lambda txt, i=idx: self._on_label_changed(i, txt))
hl.addWidget(label_edit)
color_btn = QPushButton(cinfo.get("color", "Pick Color"), container)
color_btn.clicked.connect(lambda _=None, i=idx: self._pick_color(i))
hl.addWidget(color_btn)
rm_btn = QPushButton("X", container)
rm_btn.clicked.connect(lambda _=None, i=idx: self._on_remove_curve(i))
hl.addWidget(rm_btn)
return container
def _on_add_curve(self):
self._curves_data.append({"label": "NewCurve", "color": "blue"})
self._refresh_curves_rows()
def _on_remove_curve(self, idx: int):
if 0 <= idx < len(self._curves_data):
self._curves_data.pop(idx)
self._refresh_curves_rows()
def _on_label_changed(self, idx: int, new_label: str):
if 0 <= idx < len(self._curves_data):
self._curves_data[idx]["label"] = new_label
def _pick_color(self, idx: int):
if 0 <= idx < len(self._curves_data):
dlg = QColorDialog(self)
if dlg.exec_() == QDialog.Accepted:
c = dlg.selectedColor().name()
self._curves_data[idx]["color"] = c
self._refresh_curves_rows()
def accept(self):
"""
If user presses OK, update the widget's properties:
deviceName
someFlag
curvesJson
"""
# 1) deviceName
self._wp.deviceName = self._device_name_edit.text().strip()
# 2) someFlag
self._wp.someFlag = self._flag_checkbox.isChecked()
# 3) curvesJson
new_json = json.dumps(self._curves_data, indent=2)
self._wp.curvesJson = new_json
super().accept()
# For standalone usage, you can do:
# dlg = WaveformPlotConfigDialog(wp)
# if dlg.exec_() == QDialog.Accepted:
# # properties were updated

View File

@@ -1,90 +0,0 @@
"""
waveform_plot_plugin.py
Registers WaveformPlot with Qt Designer,
including the WaveformPlotTaskMenu extension.
"""
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from .waveform_demo import WaveformPlot
from .waveform_plot_taskmenu import WaveformPlotTaskMenuFactory
DOM_XML = """
<ui language='c++'>
<widget class='WaveformPlot' name='waveformPlot'>
<property name='geometry'>
<rect>
<x>0</x>
<y>0</y>
<width>300</width>
<height>200</height>
</rect>
</property>
<property name='deviceName'>
<string>MyDevice</string>
</property>
<property name='someFlag'>
<bool>false</bool>
</property>
<property name='curvesJson'>
<string>[{"label": "DefaultCurve", "color": "red"}]</string>
</property>
</widget>
</ui>
"""
class WaveformPlotPlugin(QDesignerCustomWidgetInterface):
"""
Exposes WaveformPlot to Designer, plus sets up the Task Menu extension
for "Edit Configuration..." popup.
"""
def __init__(self):
super().__init__()
self._initialized = False
def initialize(self, form_editor):
if self._initialized:
return
self._initialized = True
# Register the TaskMenu extension
manager = form_editor.extensionManager()
if manager:
factory = WaveformPlotTaskMenuFactory(manager)
manager.registerExtensions(factory, "org.qt-project.Qt.Designer.TaskMenu")
def isInitialized(self):
return self._initialized
def createWidget(self, parent):
return WaveformPlot(parent)
def name(self):
return "WaveformPlot"
def group(self):
return "Waveform Widgets"
def icon(self):
# If you have a real icon, load it here
return QIcon()
def toolTip(self):
return "A multi-property WaveformPlot example"
def whatsThis(self):
return self.toolTip()
def isContainer(self):
return False
def domXml(self):
return DOM_XML
def includeFile(self):
# The Python import path for your waveforms
# E.g. "my_widgets.waveform.waveform_plot"
return __name__

View File

@@ -1,48 +0,0 @@
"""
waveform_plot_taskmenu.py
Task Menu extension for Qt Designer.
It attaches "Edit Configuration..." to the WaveformPlot,
launching WaveformPlotConfigDialog.
"""
from qtpy.QtCore import Slot
from qtpy.QtDesigner import QExtensionFactory, QPyDesignerTaskMenuExtension
from qtpy.QtGui import QAction
from bec_widgets.widgets.plots_next_gen.waveform.demo.waveform_demo import WaveformPlot
from bec_widgets.widgets.plots_next_gen.waveform.demo.waveform_plot_config_dialog import (
WaveformPlotConfigDialog,
)
class WaveformPlotTaskMenu(QPyDesignerTaskMenuExtension):
def __init__(self, widget: WaveformPlot, parent=None):
super().__init__(parent)
self._widget = widget
self._edit_action = QAction("Edit Configuration...", self)
self._edit_action.triggered.connect(self._on_edit)
def taskActions(self):
return [self._edit_action]
def preferredEditAction(self):
# Double-click in Designer might open this
return self._edit_action
@Slot()
def _on_edit(self):
# Show the same config dialog we can use in normal code
dlg = WaveformPlotConfigDialog(self._widget)
dlg.exec_() # If user presses OK, the widget's properties are updated
class WaveformPlotTaskMenuFactory(QExtensionFactory):
"""
Creates a WaveformPlotTaskMenu if the widget is an instance of WaveformPlot
and the requested extension is 'TaskMenu'.
"""
def createExtension(self, obj, iid, parent):
if iid == "org.qt-project.Qt.Designer.TaskMenu" and isinstance(obj, WaveformPlot):
return WaveformPlotTaskMenu(obj, parent)
return None

View File

@@ -1,55 +0,0 @@
from typing import Literal, Optional
import pyqtgraph as pg
from pydantic import BaseModel, Field
from qtpy.QtCore import Qt
class CurveConfig(BaseModel):
label: str = Field(..., description="Label/ID of this curve")
color: str = Field("blue", description="Curve color")
symbol: Optional[str] = Field(None, description="Symbol e.g. 'o', 'x'")
pen_width: int = Field(2, description="Pen width in px")
pen_style: Literal["solid", "dash", "dot", "dashdot"] = "solid"
# You can add device/signal if desired:
# signals: Optional[Signal] = None
# etc.
class Config:
model_config = {"validate_assignment": True}
pen_style_map = {
"solid": Qt.SolidLine,
"dash": Qt.DashLine,
"dot": Qt.DotLine,
"dashdot": Qt.DashDotLine,
}
class BECCurve(pg.PlotDataItem):
"""
A custom PlotDataItem that holds a reference to a Pydantic-based CurveConfig.
"""
def __init__(self, config: CurveConfig, parent=None):
super().__init__(name=config.label) # set the PlotDataItem name
self.config = config
self._parent = parent # optional reference to the WaveformPlot
# now apply config to actual PlotDataItem
self.apply_config()
def apply_config(self):
style = pen_style_map.get(self.config.pen_style, Qt.SolidLine)
pen = pg.mkPen(color=self.config.color, width=self.config.pen_width, style=style)
self.setPen(pen)
if self.config.symbol is not None:
self.setSymbol(self.config.symbol)
else:
self.setSymbol(None)
def set_data_custom(self, x, y):
# If you only want to allow custom data if config.source == "custom", etc.
self.setData(x, y)

View File

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

View File

@@ -1,144 +0,0 @@
import json
from typing import List
import pyqtgraph as pg
from qtpy.QtCore import Property
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.widgets.plots_next_gen.waveform.demo_2.demo_curve import BECCurve, CurveConfig
class WaveformPlotDemo2(QWidget):
"""
A Plot widget that stores multiple curves in a single JSON property (`curvesJson`).
Internally, we keep a list of (CurveConfig, BECCurve).
"""
def __init__(self, parent=None):
super().__init__(parent)
self._curves_json = "[]"
self._curves: List[BECCurve] = [] # the actual PlotDataItems
self._curve_configs: List[CurveConfig] = []
layout = QVBoxLayout(self)
self.plot_item = pg.PlotItem()
self.plot_widget = pg.PlotWidget(plotItem=self.plot_item)
layout.addWidget(self.plot_widget)
self.plot_item.addLegend()
# ------------------------------------------------------------------------
# QProperty: curvesJson
# ------------------------------------------------------------------------
def getCurvesJson(self) -> str:
return self._curves_json
def setCurvesJson(self, val: str):
if self._curves_json != val:
self._curves_json = val
self._rebuild_curves_from_json()
curvesJson = Property(str, fget=getCurvesJson, fset=setCurvesJson)
# ------------------------------------------------------------------------
# Internal method: parse JSON -> create/update BECCurve objects
# ------------------------------------------------------------------------
def _rebuild_curves_from_json(self):
# 1) Remove existing items from the plot
for c in self._curves:
self.plot_item.removeItem(c)
self._curves.clear()
self._curve_configs.clear()
# 2) Parse JSON
try:
raw_list = json.loads(self._curves_json)
if not isinstance(raw_list, list):
raise ValueError("curvesJson must be a JSON list.")
except Exception:
raw_list = []
# 3) Convert each raw dict -> CurveConfig -> BECCurve
for entry in raw_list:
try:
cfg = CurveConfig(**entry)
except Exception:
# fallback or skip
continue
curve_obj = BECCurve(config=cfg, parent=self)
# For demonstration, set some dummy data
xdata = [0, 1, 2, 3, 4]
ydata = [val + hash(cfg.label) % 3 for val in xdata]
curve_obj.setData(xdata, ydata)
self.plot_item.addItem(curve_obj)
self._curves.append(curve_obj)
self._curve_configs.append(cfg)
# ------------------------------------------------------------------------
# CLI / dynamic methods to add, remove, or modify curves at runtime
# ------------------------------------------------------------------------
def list_curve_labels(self) -> list[str]:
return [cfg.label for cfg in self._curve_configs]
def get_curve(self, label: str) -> BECCurve:
# Return the actual BECCurve object (or a config, or both)
for c in self._curves:
if c.config.label == label:
return c
raise ValueError(f"No curve with label='{label}'")
def add_curve(self, cfg: CurveConfig):
"""
Add a new curve from code. We just insert the new config
into the list, then re-serialize to JSON => triggers rebuild
"""
# insert new config to the internal list
self._curve_configs.append(cfg)
self._sync_json_from_configs()
def remove_curve(self, label: str):
for i, c in enumerate(self._curve_configs):
if c.label == label:
self._curve_configs.pop(i)
break
else:
raise ValueError(f"No curve with label='{label}' found to remove.")
self._sync_json_from_configs()
def set_curve_property(self, label: str, **kwargs):
"""
For example, set_curve_property("Curve1", color="red", pen_width=4)
We'll update the pydantic model, then re-sync to JSON, rebuild.
"""
c = self._find_config(label)
for k, v in kwargs.items():
setattr(c, k, v) # pydantic assignment
self._sync_json_from_configs()
def _find_config(self, label: str) -> CurveConfig:
for cfg in self._curve_configs:
if cfg.label == label:
return cfg
raise ValueError(f"No config with label='{label}' found.")
def _sync_json_from_configs(self):
"""
Re-serialize our internal curve configs -> JSON string,
call setCurvesJson(...) => triggers the rebuild in the same widget
so the user and Designer stay in sync
"""
raw_list = [cfg.dict() for cfg in self._curve_configs]
new_json = json.dumps(raw_list, indent=2)
self.setCurvesJson(new_json)
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
app = QApplication([])
w = WaveformPlotDemo2()
w.show()
w.add_curve(CurveConfig(label="Curve1", color="red"))
w.add_curve(CurveConfig(label="Curve2", color="blue"))
app.exec_()

View File

@@ -1 +0,0 @@
{'files': ['waveform_demo2.py']}

View File

@@ -1,81 +0,0 @@
"""
waveform_plot_plugin.py
Registers WaveformPlotDemo2 with Qt Designer,
including the WaveformPlotDemo2TaskMenu extension.
"""
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from bec_widgets.widgets.plots_next_gen.waveform.demo_2.waveform_demo2 import WaveformPlotDemo2
DOM_XML = """
<ui language='c++'>
<widget class='WaveformPlotDemo2' name='WaveformPlotDemo2'>
<property name='geometry'>
<rect>
<x>0</x>
<y>0</y>
<width>300</width>
<height>200</height>
</rect>
</property>
<property name='deviceName'>
<string>MyDevice</string>
</property>
<property name='someFlag'>
<bool>false</bool>
</property>
<property name='curvesJson'>
<string>[{"label": "DefaultCurve", "color": "red"}]</string>
</property>
</widget>
</ui>
"""
class WaveformPlotDemo2Plugin(QDesignerCustomWidgetInterface):
"""
Exposes WaveformPlotDemo2 to Designer, plus sets up the Task Menu extension
for "Edit Configuration..." popup.
"""
def __init__(self):
super().__init__()
self._initialized = False
def initialize(self, form_editor):
self._form_editor = form_editor
def isInitialized(self):
return self._initialized
def createWidget(self, parent):
return WaveformPlotDemo2(parent)
def name(self):
return "WaveformPlotDemo2"
def group(self):
return "Waveform Widgets"
def icon(self):
# If you have a real icon, load it here
return QIcon()
def toolTip(self):
return "A multi-property WaveformPlotDemo2 example"
def whatsThis(self):
return self.toolTip()
def isContainer(self):
return False
def domXml(self):
return DOM_XML
def includeFile(self):
# The Python import path for your waveforms
# E.g. "my_widgets.waveform.waveform_plot"
return __name__

View File

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

View File

@@ -1,183 +0,0 @@
import os
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QTreeWidgetItem
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils import UILoader
from bec_widgets.utils.widget_io import WidgetIO
class CurveSettingWidgetOld(QWidget):
"""
Widget that lets a user set up curves for the Waveform widget.
It allows:
- Selecting color palette for the entire widget
- Choosing x-axis mode
- Selecting device and signal
- Adding a new curve
- Viewing existing curves in a QTreeWidget
"""
def __init__(self, parent=None, target_widget=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self.setObjectName("CurveSettings")
current_path = os.path.dirname(__file__)
self.ui = UILoader().load_ui(os.path.join(current_path, "curve_settings.ui"), self)
self.target_widget = target_widget
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.ui)
self.connect_all_signals()
self.refresh_tree_from_waveform() # TODO implement
def connect_all_signals(self):
self.ui.pushButton.clicked.connect(self.on_apply_color_palette)
self.ui.x_mode.currentTextChanged.connect(self.enable_ui_elements_x_mode)
self.ui.x_mode.currentTextChanged.connect(self.on_x_mode_changed)
self.enable_ui_elements_x_mode() # Enable or disable the x-axis mode elements based on the x-axis mode
self.ui.add_curve.clicked.connect(self.add_curve_from_ui)
# TODO: Implement this method
# General property forwarding for the target widget
# for widget in [self.ui.x_mode]:
# WidgetIO.connect_widget_change_signal(widget, self.set_property)
def enable_ui_elements_x_mode(self):
"""
Enable or disable the x-axis mode elements based on the x-axis mode.
"""
combo_box_mode = self.ui.x_mode.currentText()
if combo_box_mode == "device":
self.ui.device_line_edit.setEnabled(True)
self.ui.signal_line_edit.setEnabled(True)
else:
self.ui.device_line_edit.setEnabled(False)
self.ui.signal_line_edit.setEnabled(False)
@SafeSlot("QString")
def on_x_mode_changed(self, text):
"""
Update the x-axis mode of the target widget.
"""
if not self.target_widget:
return
self.target_widget.x_mode = text
if text == "device":
self.target_widget.device = self.ui.device_line_edit.text()
self.target_widget.signal = self.ui.signal_line_edit.text()
self.refresh_tree_from_waveform() # TODO implement
@SafeSlot()
def on_apply_color_palette(self):
"""
Apply the selected color palette to the target widget.
"""
if not self.target_widget:
return
color_map = getattr(self.ui.bec_color_map_widget, "colormap", None)
self.target_widget.color_palette = color_map
self.refresh_tree_from_waveform() # TODO implement
def add_curve_from_ui(self):
"""
Add a curve to the target widget based on the UI elements.
"""
if not self.target_widget:
return
def refresh_tree_from_waveform(self):
"""
Clears the treeWidget and repopulates it with the current curves
from the target_widgets curve_json.
"""
self.ui.treeWidget.clear()
if not self.target_widget:
return
# The Waveform has a SafeProperty 'curve_json' that returns JSON for all device curves
# or you can iterate over target_widget.curves and build your own representation.
# For a simpler approach, well just iterate curves directly:
for curve in self.target_widget.curves:
# Make a top-level item in the tree for each curve
top_item = QTreeWidgetItem(self.ui.treeWidget)
top_item.setText(0, "CURVE")
top_item.setText(1, curve.name()) # e.g. "myDevice-myEntry"
# Child: device name
dev_item = QTreeWidgetItem(top_item)
dev_item.setText(0, "device")
if curve.config.signal:
dev_item.setText(1, curve.config.signal.name)
# Child: entry
entry_item = QTreeWidgetItem(top_item)
entry_item.setText(0, "signal")
if curve.config.signal:
entry_item.setText(1, curve.config.signal.entry)
# Child: color
color_item = QTreeWidgetItem(top_item)
color_item.setText(0, "color")
if curve.config.color:
color_item.setText(1, str(curve.config.color))
# Child: source (custom/device/dap)
source_item = QTreeWidgetItem(top_item)
source_item.setText(0, "source")
source_item.setText(1, curve.config.source)
# Expand the top-level item
self.ui.treeWidget.addTopLevelItem(top_item)
top_item.setExpanded(True)
# Optionally, resize columns
# self.ui.treeWidget.header().resizeSections(Qt.ResizeToContents)
@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.
"""
property_name = widget.objectName()
setattr(self.target_widget, property_name, value)
@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)

View File

@@ -1,189 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>256</width>
<height>563</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Color Palette</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QPushButton" name="pushButton">
<property name="text">
<string>Apply</string>
</property>
</widget>
</item>
<item>
<widget class="BECColorMapWidget" name="bec_color_map_widget"/>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox">
<property name="minimumSize">
<size>
<width>0</width>
<height>151</height>
</size>
</property>
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Mode</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Device</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="DeviceLineEdit" name="device_line_edit"/>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="x_mode">
<item>
<property name="text">
<string>auto</string>
</property>
</item>
<item>
<property name="text">
<string>index</string>
</property>
</item>
<item>
<property name="text">
<string>timestamp</string>
</property>
</item>
<item>
<property name="text">
<string>device</string>
</property>
</item>
</widget>
</item>
<item row="2" column="1">
<widget class="SignalLineEdit" name="signal_line_edit"/>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QPushButton" name="add_curve">
<property name="text">
<string>Add Curve</string>
</property>
</widget>
</item>
<item>
<widget class="QTreeWidget" name="treeWidget">
<column>
<property name="text">
<string>Property</string>
</property>
</column>
<column>
<property name="text">
<string>Value</string>
</property>
</column>
<item>
<property name="text">
<string>DEVICE</string>
</property>
<item>
<property name="text">
<string>device</string>
</property>
</item>
<item>
<property name="text">
<string>name</string>
</property>
</item>
<item>
<property name="text">
<string>color</string>
</property>
</item>
<item>
<property name="text">
<string>style</string>
</property>
<property name="text">
<string/>
</property>
</item>
</item>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>DeviceLineEdit</class>
<extends>QLineEdit</extends>
<header>device_line_edit</header>
</customwidget>
<customwidget>
<class>SignalLineEdit</class>
<extends>QLineEdit</extends>
<header>signal_line_edit</header>
</customwidget>
<customwidget>
<class>BECColorMapWidget</class>
<extends>QWidget</extends>
<header>bec_color_map_widget</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>device_line_edit</sender>
<signal>device_selected(QString)</signal>
<receiver>signal_line_edit</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>158</x>
<y>174</y>
</hint>
<hint type="destinationlabel">
<x>165</x>
<y>222</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -1,84 +0,0 @@
import pyqtgraph as pg
from qtpy.QtCore import QObject, Signal, Slot
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.linear_region_selector import LinearRegionWrapper
class WaveformROIManager(QObject):
"""
A reusable helper class that manages a single linear ROI region on a given plot item.
It provides signals to notify about region changes and active state.
"""
roi_changed = Signal(tuple) # Emitted when the ROI (left, right) changes
roi_active = Signal(bool) # Emitted when ROI is enabled or disabled
def __init__(self, plot_item: pg.PlotItem, parent=None):
super().__init__(parent)
self._plot_item = plot_item
self._roi_wrapper: LinearRegionWrapper | None = None
self._roi_region: tuple[float, float] | None = None
self._accent_colors = get_accent_colors()
@property
def roi_region(self) -> tuple[float, float] | None:
return self._roi_region
@roi_region.setter
def roi_region(self, value: tuple[float, float] | None):
self._roi_region = value
if self._roi_wrapper is not None and value is not None:
self._roi_wrapper.linear_region_selector.setRegion(value)
@Slot(bool)
def toggle_roi(self, enabled: bool) -> None:
if enabled:
self._enable_roi()
else:
self._disable_roi()
@Slot(tuple)
def select_roi(self, region: tuple[float, float]):
# If ROI not present, enabling it
if self._roi_wrapper is None:
self.toggle_roi(True)
self.roi_region = region
def _enable_roi(self):
if self._roi_wrapper is not None:
# Already enabled
return
color = self._accent_colors.default
color.setAlpha(int(0.2 * 255))
hover_color = self._accent_colors.default
hover_color.setAlpha(int(0.35 * 255))
self._roi_wrapper = LinearRegionWrapper(
self._plot_item, color=color, hover_color=hover_color, parent=self
)
self._roi_wrapper.add_region_selector()
self._roi_wrapper.region_changed.connect(self._on_region_changed)
# If we already had a region, apply it
if self._roi_region is not None:
self._roi_wrapper.linear_region_selector.setRegion(self._roi_region)
else:
self._roi_region = self._roi_wrapper.linear_region_selector.getRegion()
self.roi_active.emit(True)
def _disable_roi(self):
if self._roi_wrapper is not None:
self._roi_wrapper.region_changed.disconnect(self._on_region_changed)
self._roi_wrapper.cleanup()
self._roi_wrapper.deleteLater()
self._roi_wrapper = None
self._roi_region = None
self.roi_active.emit(False)
@Slot(tuple)
def _on_region_changed(self, region: tuple[float, float]):
self._roi_region = region
self.roi_changed.emit(region)

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
{'files': ['waveform.py']}

Some files were not shown because too many files have changed in this diff Show More