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

Compare commits

..

1 Commits

Author SHA1 Message Date
bdbc2b903d docs: add tutorial on how to add StartScan Button 2024-06-14 14:30:40 +02:00
616 changed files with 13943 additions and 61302 deletions

View File

@@ -5,16 +5,9 @@ image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
#commands to run in the Docker container before starting each job.
variables:
DOCKER_TLS_CERTDIR: ""
BEC_CORE_BRANCH:
description: bec branch
value: main
OPHYD_DEVICES_BRANCH:
description: ophyd_devices branch
value: main
BEC_CORE_BRANCH: "main"
OPHYD_DEVICES_BRANCH: "main"
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
CHECK_PKG_VERSIONS:
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
value: 0
workflow:
rules:
@@ -34,8 +27,8 @@ include:
inputs:
stage: test
path: "."
pytest_args: "-v,--random-order,tests/unit_tests"
pip_args: ".[dev]"
pytest_args: "-v --random-order tests/"
exclude_packages: ""
# different stages in the pipeline
stages:
@@ -50,21 +43,13 @@ stages:
- export QTWEBENGINE_DISABLE_SANDBOX=1
.clone-repos: &clone-repos
- echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
.install-repos: &install-repos
- pip install -e ./ophyd_devices
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
- pip install -e ./bec/pytest_bec_e2e
.install-os-packages: &install-os-packages
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- *install-qt-webengine-deps
before_script:
@@ -77,9 +62,9 @@ formatter:
stage: Formatter
needs: []
script:
- pip install -e ./[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"
@@ -88,7 +73,7 @@ pylint:
needs: []
before_script:
- pip install pylint pylint-exit anybadge
- pip install -e .[dev]
- pip install -e .[dev,pyqt6]
script:
- mkdir ./pylint
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
@@ -146,9 +131,10 @@ tests:
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyside6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
- pip install -e .[dev,pyqt6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
@@ -158,23 +144,6 @@ tests:
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths:
- tests/reference_failures/
when: always
generate-client-check:
stage: test
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyside6]
- bw-generate-cli --target bec_widgets
# if there are changes in the generated files, fail the job
- git diff --exit-code
test-matrix:
parallel:
@@ -185,6 +154,8 @@ test-matrix:
- "3.12"
QT_PCKG:
- "pyside6"
- "pyqt5"
- "pyqt6"
stage: AdditionalTests
needs: []
@@ -196,27 +167,23 @@ test-matrix:
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
- pip install -e .[dev,$QT_PCKG]
- pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
allow_failure: true
end-2-end-conda:
stage: End2End
needs: []
image: continuumio/miniconda3:25.1.1-2
image: continuumio/miniconda3
allow_failure: false
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- conda config --show-sources
- conda config --add channels conda-forge
- conda config --system --remove channels https://repo.anaconda.com/pkgs/main
- conda config --system --remove channels https://repo.anaconda.com/pkgs/r
- conda config --remove channels https://repo.anaconda.com/pkgs/main
- conda config --remove channels https://repo.anaconda.com/pkgs/r
- conda config --show-sources
- conda config --prepend channels conda-forge
- conda config --set channel_priority strict
- conda config --set always_yes yes --set changeps1 no
- conda create -q -n test-environment python=3.11
@@ -226,11 +193,13 @@ end-2-end-conda:
- cd ./bec
- source ./bin/install_bec_dev.sh -t
- cd ../
- pip install -e ./ophyd_devices
- pip install -e .[dev,pyside6]
- pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
- pip install -e ./bec_lib[dev]
- pip install -e ./bec_ipython_client[dev]
- cd ../
- pip install -e .[dev,pyqt6]
- cd ./tests/end-2-end
- pytest -v --start-servers --flush-redis --random-order
artifacts:
when: on_failure
@@ -245,7 +214,6 @@ end-2-end-conda:
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
- if: "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/"
semver:
stage: Deploy

View File

@@ -3,7 +3,7 @@
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=PyQt6, PySide6, pyqtgraph
extension-pkg-allow-list=PyQt5, pyqtgraph
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may

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 PyQt5 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[pyqt5]
```
## 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

@@ -1,4 +0,0 @@
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]

View File

@@ -1,23 +0,0 @@
from __future__ import annotations
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
def dock_area(object_name: str | None = None) -> BECDockArea:
_dock_area = BECDockArea(object_name=object_name, root_widget=True)
return _dock_area
def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates:
"""
Create a dock area with auto update enabled.
Args:
object_name(str): The name of the dock area.
Returns:
BECDockArea: The created dock area.
"""
_auto_update = AutoUpdates(object_name=object_name)
return _auto_update

View File

@@ -1,517 +0,0 @@
from __future__ import annotations
import os
import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING, Callable
from bec_lib.logger import bec_logger
from qtpy.QtCore import Qt, Signal # type: ignore
from qtpy.QtGui import QPainter, QPainterPath, QPixmap
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFileDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSpacerItem,
QWidget,
)
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, UILaunchWindow
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import QObject
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class LaunchTile(RoundedFrame):
open_signal = Signal()
def __init__(
self,
parent: QObject | None = None,
icon_path: str | None = None,
top_label: str | None = None,
main_label: str | None = None,
description: str | None = None,
show_selector: bool = False,
):
super().__init__(parent=parent, orientation="vertical")
self.icon_label = QLabel(parent=self)
self.icon_label.setFixedSize(100, 100)
self.icon_label.setScaledContents(True)
pixmap = QPixmap(icon_path)
if not pixmap.isNull():
size = 100
circular_pixmap = QPixmap(size, size)
circular_pixmap.fill(Qt.transparent)
painter = QPainter(circular_pixmap)
painter.setRenderHints(QPainter.Antialiasing, True)
path = QPainterPath()
path.addEllipse(0, 0, size, size)
painter.setClipPath(path)
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
painter.drawPixmap(0, 0, pixmap)
painter.end()
self.icon_label.setPixmap(circular_pixmap)
self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter)
# Top label
self.top_label = QLabel(top_label.upper())
font_top = self.top_label.font()
font_top.setPointSize(10)
self.top_label.setFont(font_top)
self.layout.addWidget(self.top_label, alignment=Qt.AlignCenter)
# Main label
self.main_label = QLabel(main_label)
font_main = self.main_label.font()
font_main.setPointSize(14)
font_main.setBold(True)
self.main_label.setFont(font_main)
self.main_label.setWordWrap(True)
self.main_label.setAlignment(Qt.AlignCenter)
self.layout.addWidget(self.main_label)
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
self.layout.addItem(self.spacer_top)
# Description
self.description_label = QLabel(description)
self.description_label.setWordWrap(True)
self.description_label.setAlignment(Qt.AlignCenter)
self.layout.addWidget(self.description_label)
# Selector
if show_selector:
self.selector = QComboBox(self)
self.layout.addWidget(self.selector)
else:
self.selector = None
self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding)
self.layout.addItem(self.spacer_bottom)
# Action button
self.action_button = QPushButton("Open")
self.action_button.setStyleSheet(
"""
QPushButton {
background-color: #007AFF;
border: none;
padding: 8px 16px;
color: white;
border-radius: 6px;
font-weight: bold;
}
QPushButton:hover {
background-color: #005BB5;
}
"""
)
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
class LaunchWindow(BECMainWindow):
RPC = True
TILE_SIZE = (250, 300)
USER_ACCESS = ["show_launcher", "hide_launcher"]
def __init__(
self, parent=None, gui_id: str = None, window_title="BEC Launcher", *args, **kwargs
):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.app = QApplication.instance()
self.tiles: dict[str, LaunchTile] = {}
# Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
self.spacer = QWidget(self)
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
# Main Widget
self.central_widget = QWidget(self)
self.central_widget.layout = QHBoxLayout(self.central_widget)
self.setCentralWidget(self.central_widget)
self.register_tile(
name="dock_area",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
top_label="Get started",
main_label="BEC Dock Area",
description="Highly flexible and customizable dock area application with modular widgets.",
action_button=lambda: self.launch("dock_area"),
show_selector=False,
)
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
self.register_tile(
name="auto_update",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
top_label="Get automated",
main_label="BEC Auto Update Dock Area",
description="Dock area with auto update functionality for BEC widgets plotting.",
action_button=self._open_auto_update,
show_selector=True,
selector_items=list(self.available_auto_updates.keys()) + ["Default"],
)
self.register_tile(
name="custom_ui_file",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
top_label="Get customized",
main_label="Launch Custom UI File",
description="GUI application with custom UI file.",
action_button=self._open_custom_ui_file,
show_selector=False,
)
# plugin widgets
self.available_widgets: dict[str, BECWidget] = get_all_plugin_widgets()
if self.available_widgets:
plugin_repo_name = next(iter(self.available_widgets.values())).__module__.split(".")[0]
plugin_repo_name = plugin_repo_name.removesuffix("_bec").upper()
self.register_tile(
name="widget",
icon_path=os.path.join(
MODULE_PATH, "assets", "app_icons", "widget_launch_tile.png"
),
top_label="Get quickly started",
main_label=f"Launch a {plugin_repo_name} Widget",
description=f"GUI application with one widget from the {plugin_repo_name} repository.",
action_button=self._open_widget,
show_selector=True,
selector_items=list(self.available_widgets.keys()),
)
self._update_theme()
self.register = RPCRegister()
self.register.callbacks.append(self._turn_off_the_lights)
self.register.broadcast()
def register_tile(
self,
name: str,
icon_path: str | None = None,
top_label: str | None = None,
main_label: str | None = None,
description: str | None = None,
action_button: Callable | None = None,
show_selector: bool = False,
selector_items: list[str] | None = None,
):
"""
Register a tile in the launcher window.
Args:
name(str): The name of the tile.
icon_path(str): The path to the icon.
top_label(str): The top label of the tile.
main_label(str): The main label of the tile.
description(str): The description of the tile.
action_button(callable): The action to be performed when the button is clicked.
show_selector(bool): Whether to show a selector or not.
selector_items(list[str]): The items to be shown in the selector.
"""
tile = LaunchTile(
icon_path=icon_path,
top_label=top_label,
main_label=main_label,
description=description,
show_selector=show_selector,
)
tile.setFixedSize(*self.TILE_SIZE)
if action_button:
tile.action_button.clicked.connect(action_button)
if show_selector and selector_items:
tile.selector.addItems(selector_items)
self.central_widget.layout.addWidget(tile)
self.tiles[name] = tile
def launch(
self,
launch_script: str,
name: str | None = None,
geometry: tuple[int, int, int, int] | None = None,
**kwargs,
) -> QWidget | None:
"""Launch the specified script. If the launch script creates a QWidget, it will be
embedded in a BECMainWindow. If the launch script creates a BECMainWindow, it will be shown
as a separate window.
Args:
launch_script(str): The name of the script to be launched.
name(str): The name of the dock area.
geometry(tuple): The geometry parameters to be passed to the dock area.
Returns:
QWidget: The created dock area.
"""
from bec_widgets.applications import bw_launch
with RPCRegister.delayed_broadcast() as rpc_register:
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
if name is not None:
if name in existing_dock_areas:
raise ValueError(
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
)
WidgetContainerUtils.raise_for_invalid_name(name)
else:
name = "dock_area"
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
if launch_script is None:
launch_script = "dock_area"
if not isinstance(launch_script, str):
raise ValueError(f"Launch script must be a string, but got {type(launch_script)}.")
if launch_script == "custom_ui_file":
ui_file = kwargs.pop("ui_file", None)
if not ui_file:
return None
return self._launch_custom_ui_file(ui_file)
if launch_script == "auto_update":
auto_update = kwargs.pop("auto_update", None)
return self._launch_auto_update(auto_update)
if launch_script == "widget":
widget = kwargs.pop("widget", None)
if widget is None:
raise ValueError("Widget name must be provided.")
return self._launch_widget(widget)
launch = getattr(bw_launch, launch_script, None)
if launch is None:
raise ValueError(f"Launch script {launch_script} not found.")
result_widget = launch(name)
result_widget.resize(result_widget.minimumSizeHint())
# TODO Should we simply use the specified name as title here?
result_widget.window().setWindowTitle(f"BEC - {name}")
logger.info(f"Created new dock area: {name}")
if geometry is not None:
result_widget.setGeometry(*geometry)
if isinstance(result_widget, BECMainWindow):
result_widget.show()
else:
window = BECMainWindow()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
window.show()
return result_widget
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
# Load the custom UI file
if ui_file is None:
raise ValueError("UI file must be provided for custom UI file launch.")
filename = os.path.basename(ui_file).split(".")[0]
WidgetContainerUtils.raise_for_invalid_name(filename)
tree = ET.parse(ui_file)
root = tree.getroot()
# Check if the top-level widget is a QMainWindow
widget = root.find("widget")
if widget is None:
raise ValueError("No widget found in the UI file.")
if widget.attrib.get("class") == "QMainWindow":
raise ValueError(
"Loading a QMainWindow from a UI file is currently not supported. "
"If you need this, please contact the BEC team or create a ticket on gitlab.psi.ch/bec/bec_widgets."
)
window = UILaunchWindow(object_name=filename)
QApplication.processEvents()
result_widget = UILoader(window).loader(ui_file)
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {window.object_name}")
window.show()
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}")
return window
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
if auto_update in self.available_auto_updates:
auto_update_cls = self.available_auto_updates[auto_update]
window = auto_update_cls()
else:
auto_update = "auto_updates"
window = AutoUpdates()
window.resize(window.minimumSizeHint())
QApplication.processEvents()
window.setWindowTitle(f"BEC - {window.objectName()}")
window.show()
return window
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
name = pascal_to_snake(widget.__name__)
WidgetContainerUtils.raise_for_invalid_name(name)
window = BECMainWindow()
widget_instance = widget(root_widget=True, object_name=name)
assert isinstance(widget_instance, QWidget)
QApplication.processEvents()
window.setCentralWidget(widget_instance)
window.resize(window.minimumSizeHint())
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
window.show()
return window
def apply_theme(self, theme: str):
"""
Change the theme of the application.
"""
for tile in self.tiles.values():
tile.apply_theme(theme)
super().apply_theme(theme)
def _open_auto_update(self):
"""
Open the auto update window.
"""
if self.tiles["auto_update"].selector is None:
auto_update = None
else:
auto_update = self.tiles["auto_update"].selector.currentText()
if auto_update == "Default":
auto_update = None
return self.launch("auto_update", auto_update=auto_update)
def _open_widget(self):
"""
Open a widget from the available widgets.
"""
if self.tiles["widget"].selector is None:
return
widget = self.tiles["widget"].selector.currentText()
if widget not in self.available_widgets:
raise ValueError(f"Widget {widget} not found in available widgets.")
return self.launch("widget", widget=self.available_widgets[widget])
@SafeSlot(popup_error=True)
def _open_custom_ui_file(self):
"""
Open a file dialog to select a custom UI file and launch it.
"""
ui_file, _ = QFileDialog.getOpenFileName(
self, "Select UI File", "", "UI Files (*.ui);;All Files (*)"
)
self.launch("custom_ui_file", ui_file=ui_file)
@staticmethod
def _update_available_auto_updates() -> dict[str, type[AutoUpdates]]:
"""
Load all available auto updates from the plugin repository.
"""
try:
auto_updates = get_plugin_auto_updates()
logger.info(f"Available auto updates: {auto_updates.keys()}")
except Exception as exc:
logger.error(f"Failed to load auto updates: {exc}")
return {}
return auto_updates
def show_launcher(self):
"""
Show the launcher window.
"""
self.show()
def hide_launcher(self):
"""
Hide the launcher window.
"""
self.hide()
def showEvent(self, event):
super().showEvent(event)
self.setFixedSize(self.size())
def _launcher_is_last_widget(self, connections: dict) -> bool:
"""
Check if the launcher is the last widget in the application.
"""
remaining_connections = [
connection for connection in connections.values() if connection.parent_id != self.gui_id
]
return len(remaining_connections) <= 1
def _turn_off_the_lights(self, connections: dict):
"""
If there is only one connection remaining, it is the launcher, so we show it.
Once the launcher is closed as the last window, we quit the application.
"""
if self._launcher_is_last_widget(connections):
self.show()
self.activateWindow()
self.raise_()
if self.app:
self.app.setQuitOnLastWindowClosed(True) # type: ignore
return
self.hide()
if self.app:
self.app.setQuitOnLastWindowClosed(False) # type: ignore
def closeEvent(self, event):
"""
Close the launcher window.
"""
connections = self.register.list_all_connections()
if self._launcher_is_last_widget(connections):
event.accept()
return
event.ignore()
self.hide()
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
launcher = LaunchWindow()
launcher.show()
sys.exit(app.exec())

Binary file not shown.

Before

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1 @@
from .client import *

View File

@@ -0,0 +1,153 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel
if TYPE_CHECKING:
from .client import BECDockArea, BECFigure
class ScanInfo(BaseModel):
scan_id: str
scan_number: int
scan_name: str
scan_report_devices: list
monitored_devices: list
status: str
model_config: dict = {"validate_assignment": True}
class AutoUpdates:
create_default_dock: bool = False
enabled: bool = False
dock_name: str = None
def __init__(self, gui: BECDockArea):
self.gui = gui
def start_default_dock(self):
"""
Create a default dock for the auto updates.
"""
dock = self.gui.add_dock("default_figure")
dock.add_widget("BECFigure")
self.dock_name = "default_figure"
@staticmethod
def get_scan_info(msg) -> ScanInfo:
"""
Update the script with the given data.
"""
info = msg.info
status = msg.status
scan_id = msg.scan_id
scan_number = info.get("scan_number", 0)
scan_name = info.get("scan_name", "Unknown")
scan_report_devices = info.get("scan_report_devices", [])
monitored_devices = info.get("readout_priority", {}).get("monitored", [])
monitored_devices = [dev for dev in monitored_devices if dev not in scan_report_devices]
return ScanInfo(
scan_id=scan_id,
scan_number=scan_number,
scan_name=scan_name,
scan_report_devices=scan_report_devices,
monitored_devices=monitored_devices,
status=status,
)
def get_default_figure(self) -> BECFigure | None:
"""
Get the default figure from the GUI.
"""
dock = self.gui.panels.get(self.dock_name, [])
if not dock:
return None
widgets = dock.widget_list
if not widgets:
return None
return widgets[0]
def run(self, msg):
"""
Run the update function if enabled.
"""
if not self.enabled:
return
if msg.status != "open":
return
info = self.get_scan_info(msg)
self.handler(info)
@staticmethod
def get_selected_device(monitored_devices, selected_device):
"""
Get the selected device for the plot. If no device is selected, the first
device in the monitored devices list is selected.
"""
if selected_device:
return selected_device
if len(monitored_devices) > 0:
sel_device = monitored_devices[0]
return sel_device
return None
def handler(self, info: ScanInfo) -> None:
"""
Default update function.
"""
if info.scan_name == "line_scan" and info.scan_report_devices:
self.simple_line_scan(info)
return
if info.scan_name == "grid_scan" and info.scan_report_devices:
self.simple_grid_scan(info)
return
if info.scan_report_devices:
self.best_effort(info)
return
def simple_line_scan(self, info: ScanInfo) -> None:
"""
Simple line scan.
"""
fig = self.get_default_figure()
if not fig:
return
dev_x = info.scan_report_devices[0]
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
if not dev_y:
return
fig.clear_all()
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def simple_grid_scan(self, info: ScanInfo) -> None:
"""
Simple grid scan.
"""
fig = self.get_default_figure()
if not fig:
return
dev_x = info.scan_report_devices[0]
dev_y = info.scan_report_devices[1]
dev_z = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
fig.clear_all()
plt = fig.plot(
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
)
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def best_effort(self, info: ScanInfo) -> None:
"""
Best effort scan.
"""
fig = self.get_default_figure()
if not fig:
return
dev_x = info.scan_report_devices[0]
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
if not dev_y:
return
fig.clear_all()
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)

File diff suppressed because it is too large Load Diff

View File

@@ -1,531 +1,339 @@
"""Client utilities for the BEC GUI."""
from __future__ import annotations
import json
import importlib
import importlib.metadata as imd
import os
import select
import subprocess
import sys
import threading
import time
from contextlib import contextmanager
from threading import Lock
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
import uuid
from functools import wraps
from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from rich.console import Console
from rich.table import Table
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
from qtpy.QtCore import QEventLoop, QSocketNotifier, QTimer
import bec_widgets.cli.client as client
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
from bec_widgets.utils.serialization import register_serializer_extension
from bec_widgets.cli.auto_updates import AutoUpdates
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import GUIRegistryStateMessage
else:
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
if TYPE_CHECKING:
from bec_lib.device import DeviceBase
logger = bec_logger.logger
from bec_widgets.cli.client import BECDockArea, BECFigure
IGNORE_WIDGETS = ["LaunchWindow"]
from bec_lib.serialization import MsgpackSerialization
RegistryState: TypeAlias = dict[
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
str | bool | dict,
]
# pylint: disable=redefined-outer-scope
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
def _filter_output(output: str) -> str:
def rpc_call(func):
"""
Filter out the output from the process.
A decorator for calling a function on the server.
Args:
func: The function to call.
Returns:
The result of the function call.
"""
if "IMKClient" in output:
# only relevant on macOS
# see https://discussions.apple.com/thread/255761734?sortBy=rank
return ""
return output
@wraps(func)
def wrapper(self, *args, **kwargs):
# we could rely on a strict type check here, but this is more flexible
# moreover, it would anyway crash for objects...
out = []
for arg in args:
if hasattr(arg, "name"):
arg = arg.name
out.append(arg)
args = tuple(out)
for key, val in kwargs.items():
if hasattr(val, "name"):
kwargs[key] = val.name
if not self.gui_is_alive():
raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs)
return wrapper
def _get_output(process, logger) -> None:
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
stream_buffer = {process.stdout: [], process.stderr: []}
def _get_output(process) -> None:
try:
os.set_blocking(process.stdout.fileno(), False)
os.set_blocking(process.stderr.fileno(), False)
while process.poll() is None:
readylist, _, _ = select.select([process.stdout, process.stderr], [], [], 1)
for stream in (process.stdout, process.stderr):
buf = stream_buffer[stream]
if stream in readylist:
buf.append(stream.read(4096))
output, _, remaining = "".join(buf).rpartition("\n")
output = _filter_output(output)
if process.stdout in readylist:
output = process.stdout.read(1024)
if output:
log_func[stream](output)
buf.clear()
buf.append(remaining)
print(output, end="")
if process.stderr in readylist:
error_output = process.stderr.read(1024)
if error_output:
print(error_output, end="", file=sys.stderr)
except Exception as e:
logger.error(f"Error reading process output: {str(e)}")
print(f"Error reading process output: {str(e)}")
def _start_plot_process(
gui_id: str,
gui_class_id: str,
config: dict | str,
gui_class: str = "dock_area",
logger=None, # FIXME change gui_class back to "launcher" later
) -> tuple[subprocess.Popen[str], threading.Thread | None]:
def _start_plot_process(gui_id, gui_class, config) -> None:
"""
Start the plot in a new process.
Logger must be a logger object with "debug" and "error" functions,
or it can be left to "None" as default. None means output from the
process will not be captured.
"""
# pylint: disable=subprocess-run-check
command = [
"bec-gui-server",
"--id",
gui_id,
"--config",
config,
"--gui_class",
gui_class,
"--gui_class_id",
gui_class_id,
"--hide",
gui_class.__name__,
]
if config:
if isinstance(config, dict):
config = json.dumps(config)
command.extend(["--config", str(config)])
env_dict = os.environ.copy()
env_dict["PYTHONUNBUFFERED"] = "1"
if logger is None:
stdout_redirect = subprocess.DEVNULL
stderr_redirect = subprocess.DEVNULL
else:
stdout_redirect = subprocess.PIPE
stderr_redirect = subprocess.PIPE
process = subprocess.Popen(
command,
text=True,
start_new_session=True,
stdout=stdout_redirect,
stderr=stderr_redirect,
env=env_dict,
command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env_dict
)
if logger is None:
process_output_processing_thread = None
else:
process_output_processing_thread = threading.Thread(
target=_get_output, args=(process, logger)
)
process_output_processing_thread.start()
process_output_processing_thread = threading.Thread(target=_get_output, args=(process,))
process_output_processing_thread.start()
return process, process_output_processing_thread
class RepeatTimer(threading.Timer):
"""RepeatTimer class."""
def run(self):
while not self.finished.wait(self.interval):
self.function(*self.args, **self.kwargs)
# pylint: disable=protected-access
@contextmanager
def wait_for_server(client: BECGuiClient):
"""Context manager to wait for the server to start."""
timeout = client._startup_timeout
if not timeout:
if client._gui_is_alive():
# there is hope, let's wait a bit
timeout = 1
else:
raise RuntimeError("GUI is not alive")
try:
if client._gui_started_event.wait(timeout=timeout):
client._gui_started_timer.cancel()
client._gui_started_timer.join()
else:
raise TimeoutError("Could not connect to GUI server")
finally:
# after initial waiting period, do not wait so much any more
# (only relevant if GUI didn't start)
client._startup_timeout = 0
yield
class WidgetNameSpace:
def __repr__(self):
console = Console()
table = Table(title="Available widgets for BEC CLI usage")
table.add_column("Widget Name", justify="left", style="magenta")
table.add_column("Description", justify="left")
for attr, value in self.__dict__.items():
docs = value.__doc__
docs = docs if docs else "No description available"
table.add_row(attr, docs)
console.print(table)
return ""
class AvailableWidgetsNamespace:
"""Namespace for available widgets in the BEC GUI."""
def __init__(self):
for widget in client.Widgets:
name = widget.value
if name in IGNORE_WIDGETS:
continue
setattr(self, name, name)
def __repr__(self):
console = Console()
table = Table(title="Available widgets for BEC CLI usage")
table.add_column("Widget Name", justify="left", style="magenta")
table.add_column("Description", justify="left")
for attr_name, _ in self.__dict__.items():
docs = getattr(client, attr_name).__doc__
docs = docs if docs else "No description available"
table.add_row(attr_name, docs if len(docs.strip()) > 0 else "No description available")
console.print(table)
return ""
class BECGuiClient(RPCBase):
"""BEC GUI client class. Container for GUI applications within Python."""
class BECGuiClientMixin:
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._lock = Lock()
self._anchor_widget = "launcher"
self._killed = False
self._top_level: dict[str, RPCReference] = {}
self._startup_timeout = 0
self._gui_started_timer = None
self._gui_started_event = threading.Event()
self._process = None
self._process_output_processing_thread = None
self._server_registry: dict[str, RegistryState] = {}
self._ipython_registry: dict[str, RPCReference] = {}
self.available_widgets = AvailableWidgetsNamespace()
register_serializer_extension()
self.auto_updates = self._get_update_script()
self._target_endpoint = MessageEndpoints.scan_status()
self._selected_device = None
self.stderr_output = []
####################
#### Client API ####
####################
@property
def launcher(self) -> RPCBase:
"""The launcher object."""
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
def connect_to_gui_server(self, gui_id: str) -> None:
"""Connect to a GUI server"""
# Unregister the old callback
self._client.connector.unregister(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
)
self._gui_id = gui_id
# reset the namespace
self._update_dynamic_namespace({})
self._server_registry = {}
self._top_level = {}
self._ipython_registry = {}
# Register the new callback
self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
from_start=True,
)
@property
def windows(self) -> dict:
"""Dictionary with dock areas in the GUI."""
return {widget.object_name: widget for widget in self._top_level.values()}
@property
def window_list(self) -> list:
"""List with dock areas in the GUI."""
return list(self._top_level.values())
def start(self, wait: bool = False) -> None:
"""Start the GUI server."""
return self._start(wait=wait)
def show(self):
"""Show the GUI window."""
if self._check_if_server_is_alive():
return self._show_all()
return self.start(wait=True)
def hide(self):
"""Hide the GUI window."""
return self._hide_all()
def new(
self,
name: str | None = None,
wait: bool = True,
geometry: tuple[int, int, int, int] | None = None,
launch_script: str = "dock_area",
**kwargs,
) -> client.BECDockArea:
"""Create a new top-level dock area.
Args:
name(str, optional): The name of the dock area. Defaults to None.
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h)
Returns:
client.BECDockArea: The new dock area.
"""
if not self._check_if_server_is_alive():
self.start(wait=True)
if wait:
with wait_for_server(self):
widget = self.launcher._run_rpc(
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
) # pylint: disable=protected-access
return widget
widget = self.launcher._run_rpc(
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
) # pylint: disable=protected-access
return widget
def delete(self, name: str) -> None:
"""Delete a dock area.
Args:
name(str): The name of the dock area.
"""
widget = self.windows.get(name)
if widget is None:
raise ValueError(f"Dock area {name} not found.")
widget._run_rpc("close") # pylint: disable=protected-access
def delete_all(self) -> None:
"""Delete all dock areas."""
for widget_name in self.windows:
self.delete(widget_name)
def kill_server(self) -> None:
"""Kill the GUI server."""
# Unregister the registry state
self._killed = True
if self._gui_started_timer is not None:
self._gui_started_timer.cancel()
self._gui_started_timer.join()
if self._process is None:
return
if self._process:
logger.success("Stopping GUI...")
self._process.terminate()
if self._process_output_processing_thread:
self._process_output_processing_thread.join()
self._process.wait()
self._process = None
# Unregister the registry state
self._client.connector.unregister(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
)
# Remove all reference from top level
self._top_level.clear()
self._server_registry.clear()
def close(self):
"""Deprecated. Use kill_server() instead."""
# FIXME, deprecated in favor of kill, will be removed in the future
self.kill_server()
#########################
#### Private methods ####
#########################
def _check_if_server_is_alive(self):
"""Checks if the process is alive"""
if self._process is None:
return False
if self._process.poll() is not None:
return False
return True
def _gui_post_startup(self):
timeout = 60
# Wait for 'bec' gui to be registered, this may take some time
# After 60s timeout. Should this raise an exception on timeout?
while time.time() < time.time() + timeout:
if len(list(self._server_registry.keys())) < 2 or not hasattr(
self, self._anchor_widget
):
time.sleep(0.1)
else:
break
self._gui_started_event.set()
def _start_server(self, wait: bool = False) -> None:
"""
Start the GUI server, and execute callback when it is launched
"""
if self._process is None or self._process.poll() is not None:
logger.success("GUI starting...")
self._startup_timeout = 5
self._gui_started_event.clear()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id,
gui_class_id="bec",
config=self._client._service_config.config, # pylint: disable=protected-access
logger=logger,
)
def gui_started_callback(callback):
def _get_update_script(self) -> AutoUpdates | None:
eps = imd.entry_points(group="bec.widgets.auto_updates")
for ep in eps:
if ep.name == "plugin_widgets_update":
try:
if callable(callback):
callback()
finally:
threading.current_thread().cancel() # type: ignore
return ep.load()(gui=self)
except Exception as e:
print(f"Error loading auto update script from plugin: {str(e)}")
return None
self._gui_started_timer = RepeatTimer(
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup)
)
self._gui_started_timer.start()
@property
def selected_device(self):
"""
Selected device for the plot.
"""
return self._selected_device
if wait:
self._gui_started_event.wait()
@selected_device.setter
def selected_device(self, device: str | DeviceBase):
if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
self._selected_device = device.name
elif isinstance(device, str):
self._selected_device = device
else:
raise ValueError("Device must be a string or a device object")
def _start(self, wait: bool = False) -> None:
self._killed = False
def _start_update_script(self) -> None:
self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
self._target_endpoint, cb=self._handle_msg_update, parent=self
)
return self._start_server(wait=wait)
@staticmethod
def _handle_registry_update(
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
) -> None:
# This was causing a deadlock during shutdown, not sure why.
# with self._lock:
self = parent
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
self._update_dynamic_namespace(self._server_registry)
def _handle_msg_update(msg: MessageObject, parent: BECGuiClientMixin) -> None:
if parent.auto_updates is not None:
# pylint: disable=protected-access
parent._update_script_msg_parser(msg.value)
def _do_show_all(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values():
window.show()
def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
if isinstance(msg, messages.ScanStatusMessage):
if not self.gui_is_alive():
return
self.auto_updates.run(msg)
def _show_all(self):
with wait_for_server(self):
return self._do_show_all()
def _hide_all(self):
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client._run_rpc("hide") # pylint: disable=protected-access
if not self._killed:
for window in self._top_level.values():
window.hide()
def _update_dynamic_namespace(self, server_registry: dict):
def show(self) -> None:
"""
Update the dynamic name space with the given server registry.
Setting the server registry to an empty dictionary will remove all widgets from the namespace.
Show the figure.
"""
if self._process is None or self._process.poll() is not None:
self._start_update_script()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id, self.__class__, self._client._service_config.redis
)
while not self.gui_is_alive():
print("Waiting for GUI to start...")
time.sleep(1)
def close(self) -> None:
"""
Close the figure.
"""
self._client.shutdown()
if self._process:
self._process.terminate()
self._process_output_processing_thread.join()
self._process = None
def print_log(self) -> None:
"""
Print the log of the plot process.
"""
if self._process is None:
return
print("".join(self.stderr_output))
# Flush list
self.stderr_output.clear()
class RPCResponseTimeoutError(Exception):
"""Exception raised when an RPC response is not received within the expected time."""
def __init__(self, request_id, timeout):
super().__init__(
f"RPC response not received within {timeout} seconds for request ID {request_id}"
)
class QtRedisMessageWaiter:
def __init__(self, redis_connector, message_to_wait):
self.ev_loop = QEventLoop()
self.response = None
self.connector = redis_connector
self.message_to_wait = message_to_wait
self.pubsub = redis_connector._redis_conn.pubsub()
self.pubsub.subscribe(self.message_to_wait.endpoint)
fd = self.pubsub.connection._sock.fileno()
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
self.notifier.activated.connect(self._pubsub_readable)
def _msg_received(self, msg_obj):
self.response = msg_obj.value
self.ev_loop.quit()
def wait(self, timeout=1):
timer = QTimer()
timer.singleShot(timeout * 1000, self.ev_loop.quit)
self.ev_loop.exec_()
timer.stop()
self.notifier.setEnabled(False)
self.pubsub.close()
return self.response
def _pubsub_readable(self, fd):
while True:
msg = self.pubsub.get_message()
if msg:
if msg["type"] == "subscribe":
# get_message buffers, so we may already have the answer
# let's check...
continue
else:
break
else:
return
channel = msg["channel"].decode()
msg = MessageObject(topic=channel, value=MsgpackSerialization.loads(msg["data"]))
self.connector._execute_callback(self._msg_received, msg, {})
class RPCBase:
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
self._client = BECDispatcher().client
self._config = config if config is not None else {}
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())
self._parent = parent
super().__init__()
# print(f"RPCBase: {self._gui_id}")
def __repr__(self):
type_ = type(self)
qualname = type_.__qualname__
return f"<{qualname} object at {hex(id(self))}>"
@property
def _root(self):
"""
Get the root widget. This is the BECFigure widget that holds
the anchor gui_id.
"""
parent = self
# pylint: disable=protected-access
while parent._parent is not None:
parent = parent._parent
return parent
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
"""
Run the RPC call.
Args:
server_registry (dict): The server registry
method: The method to call.
args: The arguments to pass to the method.
wait_for_rpc_response: Whether to wait for the RPC response.
kwargs: The keyword arguments to pass to the method.
Returns:
The result of the RPC call.
"""
top_level_widgets: dict[str, RPCReference] = {}
for gui_id, state in server_registry.items():
widget = self._add_widget(state, self)
if widget is None:
# ignore widgets that are not supported
continue
# get all top-level widgets. These are widgets that have no parent
if not state["config"].get("parent_id"):
top_level_widgets[gui_id] = widget
request_id = str(uuid.uuid4())
rpc_msg = messages.GUIInstructionMessage(
action=method,
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
metadata={"request_id": request_id},
)
remove_from_registry = []
for gui_id, widget in self._ipython_registry.items():
if gui_id not in server_registry:
remove_from_registry.append(gui_id)
for gui_id in remove_from_registry:
self._ipython_registry.pop(gui_id)
# pylint: disable=protected-access
receiver = self._root._gui_id
if wait_for_rpc_response:
redis_msg = QtRedisMessageWaiter(
self._client.connector, MessageEndpoints.gui_instruction_response(request_id)
)
removed_widgets = [
widget.object_name for widget in self._top_level.values() if widget._is_deleted()
]
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
for widget_name in removed_widgets:
# the check is not strictly necessary, but better safe
# than sorry; who knows what the user has done
if hasattr(self, widget_name):
delattr(self, widget_name)
if wait_for_rpc_response:
response = redis_msg.wait(timeout)
for gui_id, widget_ref in top_level_widgets.items():
setattr(self, widget_ref.object_name, widget_ref)
if response is None:
raise RPCResponseTimeoutError(request_id, timeout)
self._top_level = top_level_widgets
# get class name
if not response.accepted:
raise ValueError(response.message["error"])
msg_result = response.message.get("result")
return self._create_widget_from_msg_result(msg_result)
for widget in self._ipython_registry.values():
widget._refresh_references()
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
return None
if isinstance(msg_result, list):
return [self._create_widget_from_msg_result(res) for res in msg_result]
if isinstance(msg_result, dict):
if "__rpc__" not in msg_result:
return {
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
}
cls = msg_result.pop("widget_class", None)
msg_result.pop("__rpc__", None)
def _add_widget(self, state: dict, parent: object) -> RPCReference | None:
"""Add a widget to the namespace
if not cls:
return msg_result
Args:
state (dict): The state of the widget from the _server_registry.
parent (object): The parent object.
cls = getattr(client, cls)
# print(msg_result)
return cls(parent=self, **msg_result)
return msg_result
def gui_is_alive(self):
"""
object_name = state["object_name"]
gui_id = state["gui_id"]
if state["widget_class"] in IGNORE_WIDGETS:
return
widget_class = getattr(client, state["widget_class"], None)
if widget_class is None:
return
obj = self._ipython_registry.get(gui_id)
if obj is None:
widget = widget_class(gui_id=gui_id, object_name=object_name, parent=parent)
self._ipython_registry[gui_id] = widget
else:
widget = obj
obj = RPCReference(registry=self._ipython_registry, gui_id=gui_id)
return obj
if __name__ == "__main__": # pragma: no cover
from bec_lib.client import BECClient
from bec_lib.service_config import ServiceConfig
try:
config = ServiceConfig()
bec_client = BECClient(config)
bec_client.start()
# Test the client_utils.py module
gui = BECGuiClient()
gui.start(wait=True)
gui.new().new(widget="Waveform")
time.sleep(10)
finally:
gui.kill_server()
Check if the GUI is alive.
"""
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
return heart is not None

View File

@@ -6,17 +6,13 @@ import importlib
import inspect
import os
import sys
from pathlib import Path
from typing import Literal
import black
import isort
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property as QtProperty
from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator, plugin_filenames
from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
logger = bec_logger.logger
from bec_widgets.utils import BECConnector
if sys.version_info >= (3, 11):
from typing import get_overloads
@@ -34,56 +30,28 @@ else:
class ClientGenerator:
def __init__(self, base=False):
self._base = base
base_imports = (
"""import enum
import inspect
import traceback
from functools import reduce
from operator import add
from typing import Literal, Optional
"""
if self._base
else "\n"
)
self.header = f"""# This file was automatically generated by generate_cli.py
# type: ignore \n
from __future__ import annotations
{base_imports}
from bec_lib.logger import bec_logger
def __init__(self):
self.header = """# This file was automatically generated by generate_cli.py\n
import enum
from typing import Literal, Optional, overload
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger
from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
# pylint: skip-file"""
self.content = ""
def generate_client(self, class_container: BECClassContainer):
def generate_client(
self, published_classes: dict[Literal["connector_classes", "top_level_classes"], list[type]]
):
"""
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.
published_classes(dict): A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
# 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.sort(key=lambda x: x.__name__)
connector_classes = [
cls for cls in class_container.connector_classes if getattr(cls, "RPC", True)
]
connector_classes.sort(key=lambda x: x.__name__)
self.write_client_enum(rpc_top_level_classes)
for cls in connector_classes:
logger.debug(f"generating RPC client class for {cls.__name__}")
self.write_client_enum(published_classes["top_level_classes"])
for cls in published_classes["connector_classes"]:
self.content += "\n\n"
self.generate_content_for_class(cls)
@@ -91,50 +59,14 @@ logger = bec_logger.logger
"""
Write the client enum to the content.
"""
if self._base:
self.content += """
class _WidgetsEnumType(str, enum.Enum):
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
...
"""
self.content += """
_Widgets = {
class Widgets(str, enum.Enum):
\"\"\"
Enum for the available widgets.
\"\"\"
"""
for cls in published_classes:
self.content += f'"{cls.__name__}": "{cls.__name__}",\n '
self.content += """}
"""
if self._base:
self.content += """
try:
_plugin_widgets = get_all_plugin_widgets()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning(
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
)
continue
if plugin_name not in _overlap:
globals()[plugin_name] = plugin_class
except ImportError as e:
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
"""
self.content += f'{cls.__name__} = "{cls.__name__}"\n '
def generate_content_for_class(self, cls):
"""
@@ -146,52 +78,19 @@ except ImportError as e:
class_name = cls.__name__
if class_name == "BECDockArea":
# Generate the content
if cls.__name__ == "BECDockArea":
self.content += f"""
class {class_name}(RPCBase):"""
class {class_name}(RPCBase, BECGuiClientMixin):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
if cls.__doc__:
# We only want the first line of the docstring
# But skip the first line if it's a blank line
first_line = cls.__doc__.split("\n")[0]
if first_line:
class_docs = first_line
else:
class_docs = cls.__doc__.split("\n")[1]
self.content += f"""
\"\"\"{class_docs}\"\"\"
"""
if not cls.USER_ACCESS:
self.content += """...
"""
for method in cls.USER_ACCESS:
is_property_setter = False
obj = getattr(cls, method, None)
if obj is None:
obj = getattr(cls, method.split(".setter")[0], None)
is_property_setter = True
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."
)
if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties
if is_property_setter:
self.content += f"""
@{method}.setter
@rpc_call"""
else:
self.content += """
obj = getattr(cls, method)
if isinstance(obj, property):
self.content += """
@property
@rpc_call"""
sig = str(inspect.signature(obj.fget))
doc = inspect.getdoc(obj.fget)
else:
@@ -223,22 +122,66 @@ class {class_name}(RPCBase):"""
# Combine header and content, then format with black
full_content = self.header + "\n" + self.content
try:
formatted_content = black.format_str(full_content, mode=black.Mode(line_length=100))
formatted_content = black.format_str(full_content, mode=black.FileMode(line_length=100))
except black.NothingChanged:
formatted_content = full_content
config = isort.Config(
isort.Config(
profile="black",
line_length=100,
multi_line_output=3,
include_trailing_comma=False,
include_trailing_comma=True,
known_first_party=["bec_widgets"],
)
formatted_content = isort.code(formatted_content, config=config)
formatted_content = isort.code(formatted_content)
with open(file_name, "w", encoding="utf-8") as file:
file.write(formatted_content)
@staticmethod
def get_rpc_classes(
repo_name: str,
) -> dict[Literal["connector_classes", "top_level_classes"], list[type]]:
"""
Get all RPC-enabled classes in the specified repository.
Args:
repo_name(str): The name of the repository.
Returns:
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
connector_classes = []
top_level_classes = []
anchor_module = importlib.import_module(f"{repo_name}.widgets")
directory = os.path.dirname(anchor_module.__file__)
for root, _, files in sorted(os.walk(directory)):
for file in files:
if not file.endswith(".py") or file.startswith("__"):
continue
path = os.path.join(root, file)
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
if len(subs) == 1 and not subs[0]:
module_name = file.split(".")[0]
else:
module_name = ".".join(subs + [file.split(".")[0]])
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
for name in dir(module):
obj = getattr(module, name)
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue
if isinstance(obj, type) and issubclass(obj, BECConnector):
connector_classes.append(obj)
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
top_level_classes.append(obj)
return {"connector_classes": connector_classes, "top_level_classes": top_level_classes}
def main():
"""
@@ -246,78 +189,22 @@ def main():
"""
parser = argparse.ArgumentParser(description="Auto-generate the client for RPC widgets")
parser.add_argument(
"--target",
action="store",
type=str,
help="Which package to generate plugin files for. Should be installed in the local environment (example: my_plugin_repo)",
)
parser.add_argument("--core", action="store_true", help="Whether to generate the core client")
args = parser.parse_args()
if args.target is None:
logger.error(
"You must provide a target - for safety, the default of running this on bec_widgets core has been removed. To generate the client for bec_widgets, run `bw-generate-cli --target bec_widgets`"
)
return
logger.info(f"BEC Widget code generation tool started with args: {args}")
if args.core:
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
client_subdir = "cli" if args.target == "bec_widgets" else "widgets"
module_name = "bec_widgets" if args.target == "bec_widgets" else f"{args.target}.bec_widgets"
rpc_classes = ClientGenerator.get_rpc_classes("bec_widgets")
rpc_classes["connector_classes"].sort(key=lambda x: x.__name__)
try:
module = importlib.import_module(module_name)
assert module.__file__ is not None
module_file = Path(module.__file__)
module_dir = module_file.parent if module_file.is_file() else module_file
except Exception as e:
logger.error(f"Failed to load module {module_name} for code generation: {e}")
return
client_path = module_dir / client_subdir / "client.py"
rpc_classes = get_custom_classes(module_name)
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
generator = ClientGenerator(base=module_name == "bec_widgets")
logger.info(f"Generating client file at {client_path}")
generator.generate_client(rpc_classes)
generator.write(str(client_path))
if module_name != "bec_widgets":
non_overwrite_classes = list(clsinfo.name for clsinfo in get_custom_classes("bec_widgets"))
logger.info(
f"Not writing plugins which would conflict with builtin classes: {non_overwrite_classes}"
)
else:
non_overwrite_classes = []
for cls in rpc_classes.plugins:
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
if cls.__name__ in non_overwrite_classes:
logger.error(
f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists"
)
plugin = DesignerPluginGenerator(cls)
if not hasattr(plugin, "info"):
continue
def _exists(file: str):
return os.path.exists(os.path.join(plugin.info.base_path, file))
if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)):
logger.debug(
f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists."
)
continue
plugin.run()
generator = ClientGenerator()
generator.generate_client(rpc_classes)
generator.write(client_path)
if __name__ == "__main__": # pragma: no cover
import sys
sys.argv = ["bw-generate-cli", "--target", "bec_widgets"]
sys.argv = ["generate_cli.py", "--core"]
main()

View File

@@ -1,308 +0,0 @@
from __future__ import annotations
import inspect
import threading
import uuid
from functools import wraps
from typing import TYPE_CHECKING, Any, cast
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
if TYPE_CHECKING: # pragma: no cover
from bec_lib import messages
from bec_lib.connector import MessageObject
import bec_widgets.cli.client as client
from bec_widgets.cli.client_utils import BECGuiClient
else:
client = lazy_import("bec_widgets.cli.client") # avoid circular import
messages = lazy_import("bec_lib.messages")
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
# pylint: disable=protected-access
def rpc_call(func):
"""
A decorator for calling a function on the server.
Args:
func: The function to call.
Returns:
The result of the function call.
"""
@wraps(func)
def wrapper(self, *args, **kwargs):
# we could rely on a strict type check here, but this is more flexible
# moreover, it would anyway crash for objects...
caller_frame = inspect.currentframe().f_back # type: ignore
while caller_frame:
if "jedi" in caller_frame.f_globals:
# Jedi module is present, likely tab completion
# Do not run the RPC call
return None # func(*args, **kwargs)
caller_frame = caller_frame.f_back
out = []
for arg in args:
if hasattr(arg, "name"):
arg = arg.name
out.append(arg)
args = tuple(out)
for key, val in kwargs.items():
if hasattr(val, "name"):
kwargs[key] = val.name
if not self._root._gui_is_alive():
raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs)
return wrapper
class RPCResponseTimeoutError(Exception):
"""Exception raised when an RPC response is not received within the expected time."""
def __init__(self, request_id, timeout):
super().__init__(
f"RPC response not received within {timeout} seconds for request ID {request_id}"
)
class DeletedWidgetError(Exception): ...
def check_for_deleted_widget(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
if self._gui_id not in self._registry:
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
return func(self, *args, **kwargs)
return wrapper
class RPCReference:
def __init__(self, registry: dict, gui_id: str) -> None:
self._registry = registry
self._gui_id = gui_id
self.object_name = self._registry[self._gui_id].object_name
@check_for_deleted_widget
def __getattr__(self, name):
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
return super().__getattribute__(name)
return self._registry[self._gui_id].__getattribute__(name)
def __setattr__(self, name, value):
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
return super().__setattr__(name, value)
if self._gui_id not in self._registry:
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
self._registry[self._gui_id].__setattr__(name, value)
def __repr__(self):
if self._gui_id not in self._registry:
return f"<Deleted widget with gui_id {self._gui_id}>"
return self._registry[self._gui_id].__repr__()
def __str__(self):
if self._gui_id not in self._registry:
return f"<Deleted widget with gui_id {self._gui_id}>"
return self._registry[self._gui_id].__str__()
def __dir__(self):
if self._gui_id not in self._registry:
return []
return self._registry[self._gui_id].__dir__()
def _is_deleted(self) -> bool:
return self._gui_id not in self._registry
class RPCBase:
def __init__(
self,
gui_id: str | None = None,
config: dict | None = None,
object_name: str | None = None,
parent=None,
**kwargs,
) -> None:
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
self._config = config if config is not None else {}
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
self.object_name = object_name if object_name is not None else str(uuid.uuid4())[:5]
self._parent = parent
self._msg_wait_event = threading.Event()
self._rpc_response = None
super().__init__()
self._rpc_references: dict[str, str] = {}
def __repr__(self):
type_ = type(self)
qualname = type_.__qualname__
return f"<{qualname} with name: {self.object_name}>"
def remove(self):
"""
Remove the widget.
"""
obj = self._root._server_registry.get(self._gui_id)
if obj is None:
raise ValueError(f"Widget {self._gui_id} not found.")
if proxy := obj.get("container_proxy"):
assert isinstance(proxy, str)
self._run_rpc("remove", gui_id=proxy)
return
self._run_rpc("remove")
@property
def _root(self) -> BECGuiClient:
"""
Get the root widget. This is the BECFigure widget that holds
the anchor gui_id.
"""
parent = self
# pylint: disable=protected-access
while parent._parent is not None:
parent = parent._parent
return parent # type: ignore
def _run_rpc(
self,
method,
*args,
wait_for_rpc_response=True,
timeout=5,
gui_id: str | None = None,
**kwargs,
) -> Any:
"""
Run the RPC call.
Args:
method: The method to call.
args: The arguments to pass to the method.
wait_for_rpc_response: Whether to wait for the RPC response.
timeout: The timeout for the RPC response.
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
kwargs: The keyword arguments to pass to the method.
Returns:
The result of the RPC call.
"""
request_id = str(uuid.uuid4())
rpc_msg = messages.GUIInstructionMessage(
action=method,
parameter={"args": args, "kwargs": kwargs, "gui_id": gui_id or self._gui_id},
metadata={"request_id": request_id},
)
# pylint: disable=protected-access
receiver = self._root._gui_id
if wait_for_rpc_response:
self._rpc_response = None
self._msg_wait_event.clear()
self._client.connector.register(
MessageEndpoints.gui_instruction_response(request_id),
cb=self._on_rpc_response,
parent=self,
)
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
if wait_for_rpc_response:
try:
finished = self._msg_wait_event.wait(timeout)
if not finished:
raise RPCResponseTimeoutError(request_id, timeout)
finally:
self._msg_wait_event.clear()
self._client.connector.unregister(
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
# we can assume that the response is a RequestResponseMessage, updated by
# the _on_rpc_response method
assert isinstance(self._rpc_response, messages.RequestResponseMessage)
if not self._rpc_response.accepted:
raise ValueError(self._rpc_response.message["error"])
msg_result = self._rpc_response.message.get("result")
self._rpc_response = None
return self._create_widget_from_msg_result(msg_result)
@staticmethod
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
msg = cast(messages.RequestResponseMessage, msg_obj.value)
parent._rpc_response = msg
parent._msg_wait_event.set()
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
return None
if isinstance(msg_result, list):
return [self._create_widget_from_msg_result(res) for res in msg_result]
if isinstance(msg_result, dict):
if "__rpc__" not in msg_result:
return {
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
}
cls = msg_result.pop("widget_class", None)
msg_result.pop("__rpc__", None)
if not cls:
return msg_result
cls = getattr(client, cls)
# The namespace of the object will be updated dynamically on the client side
# Therefore it is important to check if the object is already in the registry
# If yes, we return the reference to the object, otherwise we create a new object
# pylint: disable=protected-access
if msg_result["gui_id"] in self._root._ipython_registry:
return RPCReference(self._root._ipython_registry, msg_result["gui_id"])
ret = cls(parent=self, **msg_result)
self._root._ipython_registry[ret._gui_id] = ret
self._refresh_references()
obj = RPCReference(self._root._ipython_registry, ret._gui_id)
return obj
# return ret
return msg_result
def _gui_is_alive(self):
"""
Check if the GUI is alive.
"""
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
if heart is None:
return False
if heart.status == messages.BECStatus.RUNNING:
return True
return False
def _refresh_references(self):
"""
Refresh the references.
"""
with self._root._lock:
references = {}
for key, val in self._root._server_registry.items():
parent_id = val["config"].get("parent_id")
if parent_id == self._gui_id:
references[key] = {
"gui_id": val["config"]["gui_id"],
"object_name": val["object_name"],
}
removed_references = set(self._rpc_references.keys()) - set(references.keys())
for key in removed_references:
delattr(self, self._rpc_references[key]["object_name"])
self._rpc_references = references
for key, val in references.items():
setattr(
self,
val["object_name"],
RPCReference(self._root._ipython_registry, val["gui_id"]),
)

View File

@@ -1,189 +0,0 @@
from __future__ import annotations
from functools import wraps
from threading import RLock
from typing import TYPE_CHECKING, Callable
from weakref import WeakValueDictionary
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
logger = bec_logger.logger
def broadcast_update(func):
"""
Decorator to broadcast updates to the RPCRegister whenever a new RPC object is added or removed.
If class attribute _skip_broadcast is set to True, the broadcast will be skipped
"""
@wraps(func)
def wrapper(self, *args, **kwargs):
result = func(self, *args, **kwargs)
self.broadcast()
return result
return wrapper
class RPCRegister:
"""
A singleton class that keeps track of all the RPC objects registered in the system for CLI usage.
"""
_instance = None
_initialized = False
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(RPCRegister, cls).__new__(cls)
cls._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._rpc_register = WeakValueDictionary()
self._broadcast_on_hold = RPCRegisterBroadcast(self)
self._lock = RLock()
self._skip_broadcast = False
self._initialized = True
self.callbacks = []
@classmethod
def delayed_broadcast(cls):
"""
Delay the broadcast of the update to all the callbacks.
"""
register = cls()
return register._broadcast_on_hold
@broadcast_update
def add_rpc(self, rpc: BECConnector):
"""
Add an RPC object to the register.
Args:
rpc(QObject): The RPC object to be added to the register.
"""
if not hasattr(rpc, "gui_id"):
raise ValueError("RPC object must have a 'gui_id' attribute.")
self._rpc_register[rpc.gui_id] = rpc
@broadcast_update
def remove_rpc(self, rpc: BECConnector):
"""
Remove an RPC object from the register.
Args:
rpc(str): The RPC object to be removed from the register.
"""
if not hasattr(rpc, "gui_id"):
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
self._rpc_register.pop(rpc.gui_id, None)
def get_rpc_by_id(self, gui_id: str) -> QObject | None:
"""
Get an RPC object by its ID.
Args:
gui_id(str): The ID of the RPC object to be retrieved.
Returns:
QObject | None: The RPC object with the given ID or None
"""
rpc_object = self._rpc_register.get(gui_id, None)
return rpc_object
def list_all_connections(self) -> dict:
"""
List all the registered RPC objects.
Returns:
dict: A dictionary containing all the registered RPC objects.
"""
with self._lock:
connections = dict(self._rpc_register)
return connections
def get_names_of_rpc_by_class_type(
self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea]
) -> list[str]:
"""Get all the names of the widgets.
Args:
cls(BECWidget | BECConnector): The class of the RPC object to be retrieved.
"""
# This retrieves any rpc objects that are subclass of BECWidget,
# i.e. curve and image items are excluded
widgets = [rpc for rpc in self._rpc_register.values() if isinstance(rpc, cls)]
return [widget.object_name for widget in widgets]
def broadcast(self):
"""
Broadcast the update to all the callbacks.
"""
if self._skip_broadcast:
return
connections = self.list_all_connections()
for callback in self.callbacks:
callback(connections)
def object_is_registered(self, obj: BECConnector) -> bool:
"""
Check if an object is registered in the RPC register.
Args:
obj(QObject): The object to check.
Returns:
bool: True if the object is registered, False otherwise.
"""
return obj.gui_id in self._rpc_register
def add_callback(self, callback: Callable[[dict], None]):
"""
Add a callback that will be called whenever the registry is updated.
Args:
callback(Callable[[dict], None]): The callback to be added. It should accept a dictionary of all the
registered RPC objects as an argument.
"""
self.callbacks.append(callback)
@classmethod
def reset_singleton(cls):
"""
Reset the singleton instance.
"""
cls._instance = None
cls._initialized = False
class RPCRegisterBroadcast:
"""Context manager for RPCRegister broadcast."""
def __init__(self, rpc_register: RPCRegister) -> None:
self.rpc_register = rpc_register
self._call_depth = 0
def __enter__(self):
"""Enter the context manager"""
self._call_depth += 1 # Needed for nested calls
self.rpc_register._skip_broadcast = True
return self.rpc_register
def __exit__(self, *exc):
"""Exit the context manager"""
self._call_depth -= 1 # Remove nested calls
if self._call_depth == 0: # The Last one to exit is responsible for broadcasting
self.rpc_register._skip_broadcast = False
self.rpc_register.broadcast()

View File

@@ -1,57 +0,0 @@
from __future__ import annotations
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.plugin_utils import get_custom_classes
class RPCWidgetHandler:
"""Handler class for creating widgets from RPC messages."""
def __init__(self):
self._widget_classes = None
@property
def widget_classes(self) -> dict[str, type[BECWidget]]:
"""
Get the available widget classes.
Returns:
dict: The available widget classes.
"""
if self._widget_classes is None:
self.update_available_widgets()
return self._widget_classes # type: ignore
def update_available_widgets(self):
"""
Update the available widgets.
Returns:
None
"""
clss = get_custom_classes("bec_widgets")
self._widget_classes = get_all_plugin_widgets() | {
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
}
def create_widget(self, widget_type, **kwargs) -> BECWidget:
"""
Create a widget from an RPC message.
Args:
widget_type(str): The type of the widget.
name (str): The name of the widget.
**kwargs: The keyword arguments for the widget.
Returns:
widget(BECWidget): The created widget.
"""
widget_class = self.widget_classes.get(widget_type) # type: ignore
if widget_class:
return widget_class(**kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")
widget_handler = RPCWidgetHandler()

View File

@@ -0,0 +1,80 @@
from threading import Lock
from weakref import WeakValueDictionary
from qtpy.QtCore import QObject
class RPCRegister:
"""
A singleton class that keeps track of all the RPC objects registered in the system for CLI usage.
"""
_instance = None
_initialized = False
_lock = Lock()
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(RPCRegister, cls).__new__(cls)
cls._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._rpc_register = WeakValueDictionary()
self._initialized = True
def add_rpc(self, rpc: QObject):
"""
Add an RPC object to the register.
Args:
rpc(QObject): The RPC object to be added to the register.
"""
if not hasattr(rpc, "gui_id"):
raise ValueError("RPC object must have a 'gui_id' attribute.")
self._rpc_register[rpc.gui_id] = rpc
def remove_rpc(self, rpc: str):
"""
Remove an RPC object from the register.
Args:
rpc(str): The RPC object to be removed from the register.
"""
if not hasattr(rpc, "gui_id"):
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
self._rpc_register.pop(rpc.gui_id, None)
def get_rpc_by_id(self, gui_id: str) -> QObject:
"""
Get an RPC object by its ID.
Args:
gui_id(str): The ID of the RPC object to be retrieved.
Returns:
QObject: The RPC object with the given ID.
"""
rpc_object = self._rpc_register.get(gui_id, None)
return rpc_object
def list_all_connections(self) -> dict:
"""
List all the registered RPC objects.
Returns:
dict: A dictionary containing all the registered RPC objects.
"""
with self._lock:
connections = dict(self._rpc_register)
return connections
@classmethod
def reset_singleton(cls):
"""
Reset the singleton instance.
"""
cls._instance = None
cls._initialized = False

View File

@@ -0,0 +1,33 @@
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.spiral_progress_bar.spiral_progress_bar import SpiralProgressBar
from bec_widgets.widgets.text_box.text_box import TextBox
from bec_widgets.widgets.website.website import WebsiteWidget
class RPCWidgetHandler:
"""Handler class for creating widgets from RPC messages."""
widget_classes = {
"BECFigure": BECFigure,
"SpiralProgressBar": SpiralProgressBar,
"Website": WebsiteWidget,
"TextBox": TextBox,
}
@staticmethod
def create_widget(widget_type, **kwargs) -> BECConnector:
"""
Create a widget from an RPC message.
Args:
widget_type(str): The type of the widget.
**kwargs: The keyword arguments for the widget.
Returns:
widget(BECConnector): The created widget.
"""
widget_class = RPCWidgetHandler.widget_classes.get(widget_type)
if widget_class:
return widget_class(**kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")

View File

@@ -1,178 +1,172 @@
from __future__ import annotations
import inspect
from typing import Union
import argparse
import json
import os
import signal
import sys
from contextlib import redirect_stderr, redirect_stdout
from typing import cast
from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import QTimer
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
import bec_widgets
from bec_widgets.applications.launch_window import LaunchWindow
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_dispatcher import BECDispatcher
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
messages = lazy_import("bec_lib.messages")
class SimpleFileLikeFromLogOutputFunc:
def __init__(self, log_func):
self._log_func = log_func
self._buffer = []
class BECWidgetsCLIServer:
def write(self, buffer):
self._buffer.append(buffer)
def __init__(
self,
gui_id: str = None,
dispatcher: BECDispatcher = None,
client=None,
config=None,
gui_class: Union["BECFigure", "BECDockArea"] = BECFigure,
) -> None:
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
self.gui_id = gui_id
self.gui = gui_class(gui_id=self.gui_id)
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self.gui)
def flush(self):
lines, _, remaining = "".join(self._buffer).rpartition("\n")
if lines:
self._log_func(lines)
self._buffer = [remaining]
def close(self):
return
class GUIServer:
"""
This class is used to start the BEC GUI and is the main entry point for launching BEC Widgets in a subprocess.
"""
def __init__(self, args):
self.config = args.config
self.gui_id = args.id
self.gui_class = args.gui_class
self.gui_class_id = args.gui_class_id
self.hide = args.hide
self.app: QApplication | None = None
self.launcher_window: LaunchWindow | None = None
self.dispatcher: BECDispatcher | None = None
def start(self):
"""
Start the GUI server.
"""
bec_logger.level = bec_logger.LOGLEVEL.INFO
if self.hide:
# pylint: disable=protected-access
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
bec_logger._update_sinks()
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)): # type: ignore
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)): # type: ignore
self._run()
def _get_service_config(self) -> ServiceConfig:
if self.config:
try:
config = json.loads(self.config)
service_config = ServiceConfig(config=config)
except (json.JSONDecodeError, TypeError):
service_config = ServiceConfig(config_path=config)
else:
# if no config is provided, use the default config
service_config = ServiceConfig()
return service_config
def _run(self):
"""
Run the GUI server.
"""
self.app = QApplication(sys.argv)
self.app.setApplicationName("BEC")
self.app.gui_id = self.gui_id # type: ignore
self.setup_bec_icon()
service_config = self._get_service_config()
self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id)
# self.dispatcher.start_cli_server(gui_id=self.gui_id)
self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher")
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
self.app.aboutToQuit.connect(self.shutdown)
self.app.setQuitOnLastWindowClosed(False)
if self.gui_class:
# If the server is started with a specific gui class, we launch it.
# This will automatically hide the launcher.
self.launcher_window.launch(self.gui_class, name=self.gui_class_id)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
print("Caught SIGINT, exiting")
# Widgets should be all closed.
with RPCRegister.delayed_broadcast():
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
widget.close()
if self.app:
self.app.quit()
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
sys.exit(self.app.exec())
def setup_bec_icon(self):
"""
Set the BEC icon for the application
"""
if self.app is None:
return
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
)
self.app.setWindowIcon(icon)
def shutdown(self):
"""
Shutdown the GUI server.
"""
if self.dispatcher:
self.dispatcher.stop_cli_server()
self.dispatcher.disconnect_all()
# Setup QTimer for heartbeat
self._shutdown_event = False
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200) # Emit heartbeat every 1 seconds
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
except Exception as e:
print(e)
self.send_response(request_id, False, {"error": str(e)})
else:
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set_and_publish(
MessageEndpoints.gui_instruction_response(request_id),
messages.RequestResponseMessage(accepted=accepted, message=msg),
expire=60,
)
def get_object_from_config(self, config: dict):
gui_id = config.get("gui_id")
obj = self.rpc_register.get_rpc_by_id(gui_id)
if obj is None:
raise ValueError(f"Object with gui_id {gui_id} not found")
return obj
def run_rpc(self, obj, method, args, kwargs):
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
res = method_obj
else:
sig = inspect.signature(method_obj)
if sig.parameters:
res = method_obj(*args, **kwargs)
else:
res = method_obj()
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
elif isinstance(res, dict):
res = {key: self.serialize_object(val) for key, val in res.items()}
else:
res = self.serialize_object(res)
return res
def serialize_object(self, obj):
if isinstance(obj, BECConnector):
return {
"gui_id": obj.gui_id,
"widget_class": obj.__class__.__name__,
"config": obj.config.model_dump(),
"__rpc__": True,
}
return obj
def emit_heartbeat(self):
if self._shutdown_event is False:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=1, info={}),
expire=10,
)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self._shutdown_event = True
self._heartbeat_timer.stop()
self.client.shutdown()
def main():
"""
Main entry point for subprocesses that start a GUI server.
"""
import argparse
import os
import sys
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QMainWindow
import bec_widgets
app = QApplication(sys.argv)
app.setApplicationName("BEC Figure")
module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon()
icon.addFile(os.path.join(module_path, "assets", "bec_widgets_icon.png"), size=QSize(48, 48))
app.setWindowIcon(icon)
win = QMainWindow()
win.setWindowTitle("BEC Widgets")
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
parser.add_argument("--id", type=str, default="test", help="The id of the server")
parser.add_argument("--id", type=str, help="The id of the server")
parser.add_argument(
"--gui_class",
type=str,
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
)
parser.add_argument(
"--gui_class_id",
type=str,
default="bec",
help="The id of the gui class that is added to the QApplication",
)
parser.add_argument("--config", type=str, help="Config file or config string.")
parser.add_argument("--hide", action="store_true", help="Hide on startup")
parser.add_argument("--config", type=str, help="Config to connect to redis.")
args = parser.parse_args()
server = GUIServer(args)
server.start()
if args.gui_class == "BECFigure":
gui_class = BECFigure
elif args.gui_class == "BECDockArea":
gui_class = BECDockArea
else:
print(
"Please specify a valid gui_class to run. Use -h for help."
"\n Starting with default gui_class BECFigure."
)
gui_class = BECFigure
server = BECWidgetsCLIServer(gui_id=args.id, config=args.config, gui_class=gui_class)
gui = server.gui
win.setCentralWidget(gui)
win.resize(800, 600)
win.show()
app.aboutToQuit.connect(server.shutdown)
sys.exit(app.exec())
if __name__ == "__main__":
# import sys
# sys.argv = ["bec_widgets", "--gui_class", "MainWindow"]
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,9 @@
from .motor_movement import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelAbsolute,
MotorControlPanelRelative,
MotorCoordinateTable,
MotorThread,
)

View File

@@ -1,92 +0,0 @@
import os
import sys
from qtpy.QtCore import QSize
from qtpy.QtGui import QActionGroup, QIcon
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
import bec_widgets
from bec_widgets.examples.general_app.web_links import BECWebLinksMixin
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 BECGeneralApp(QMainWindow):
def __init__(self, parent=None):
super(BECGeneralApp, self).__init__(parent)
ui_file_path = os.path.join(os.path.dirname(__file__), "general_app.ui")
self.load_ui(ui_file_path)
self.resize(1280, 720)
self.ini_ui()
def ini_ui(self):
self._setup_icons()
self._hook_menubar_docs()
self._hook_theme_bar()
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
def _hook_menubar_docs(self):
# BEC Docs
self.ui.action_BEC_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
# BEC Widgets Docs
self.ui.action_BEC_widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
# Bug report
self.ui.action_bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
def change_theme(self, theme):
apply_theme(theme)
def _setup_icons(self):
help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion)
bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation)
computer_icon = QIcon.fromTheme("computer")
widget_icon = QIcon(os.path.join(MODULE_PATH, "assets", "designer_icons", "dock_area.png"))
self.ui.action_BEC_docs.setIcon(help_icon)
self.ui.action_BEC_widgets_docs.setIcon(help_icon)
self.ui.action_bug_report.setIcon(bug_icon)
self.ui.central_tab.setTabIcon(0, widget_icon)
self.ui.central_tab.setTabIcon(1, computer_icon)
def _hook_theme_bar(self):
self.ui.action_light.setCheckable(True)
self.ui.action_dark.setCheckable(True)
# Create an action group to make sure only one can be checked at a time
theme_group = QActionGroup(self)
theme_group.addAction(self.ui.action_light)
theme_group.addAction(self.ui.action_dark)
theme_group.setExclusive(True)
# Connect the actions to the theme change method
self.ui.action_light.triggered.connect(lambda: self.change_theme("light"))
self.ui.action_dark.triggered.connect(lambda: self.change_theme("dark"))
self.ui.action_dark.trigger()
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)
main_window = BECGeneralApp()
main_window.show()
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,262 +0,0 @@
<?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>1718</width>
<height>1139</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<property name="tabShape">
<enum>QTabWidget::TabShape::Rounded</enum>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QTabWidget" name="central_tab">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="dock_area_tab">
<attribute name="title">
<string>Dock Area</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>1</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="BECDockArea" name="dock_area"/>
</item>
</layout>
</widget>
<widget class="QWidget" name="vscode_tab">
<attribute name="icon">
<iconset theme="QIcon::ThemeIcon::Computer"/>
</attribute>
<attribute name="title">
<string>Visual Studio Code</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>1</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="VSCodeEditor" name="vscode"/>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1718</width>
<height>31</height>
</rect>
</property>
<widget class="QMenu" name="menuHelp">
<property name="title">
<string>Help</string>
</property>
<addaction name="action_BEC_docs"/>
<addaction name="action_BEC_widgets_docs"/>
<addaction name="action_bug_report"/>
</widget>
<widget class="QMenu" name="menuTheme">
<property name="title">
<string>Theme</string>
</property>
<addaction name="action_light"/>
<addaction name="action_dark"/>
</widget>
<addaction name="menuTheme"/>
<addaction name="menuHelp"/>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<widget class="QDockWidget" name="dock_scan_control">
<property name="windowTitle">
<string>Scan Control</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_2">
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="ScanControl" name="scan_control"/>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="dock_status_2">
<property name="windowTitle">
<string>BEC Service Status</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_3">
<layout class="QVBoxLayout" name="verticalLayout_5">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="BECStatusBox" name="bec_status_box_2"/>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="dock_queue">
<property name="windowTitle">
<string>Scan Queue</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_4">
<layout class="QVBoxLayout" name="verticalLayout_6">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="BECQueue" name="bec_queue">
<row/>
<column/>
<column/>
<column/>
<item row="0" column="0"/>
<item row="0" column="1"/>
<item row="0" column="2"/>
</widget>
</item>
</layout>
</widget>
</widget>
<action name="action_BEC_docs">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
</property>
<property name="text">
<string>BEC Docs</string>
</property>
</action>
<action name="action_BEC_widgets_docs">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
</property>
<property name="text">
<string>BEC Widgets Docs</string>
</property>
</action>
<action name="action_bug_report">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogError"/>
</property>
<property name="text">
<string>Bug Report</string>
</property>
</action>
<action name="action_light">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Light</string>
</property>
</action>
<action name="action_dark">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Dark</string>
</property>
</action>
</widget>
<customwidgets>
<customwidget>
<class>WebsiteWidget</class>
<extends>QWebEngineView</extends>
<header>website_widget</header>
</customwidget>
<customwidget>
<class>BECQueue</class>
<extends>QTableWidget</extends>
<header>bec_queue</header>
</customwidget>
<customwidget>
<class>ScanControl</class>
<extends>QWidget</extends>
<header>scan_control</header>
</customwidget>
<customwidget>
<class>VSCodeEditor</class>
<extends>WebsiteWidget</extends>
<header>vs_code_editor</header>
</customwidget>
<customwidget>
<class>BECStatusBox</class>
<extends>QWidget</extends>
<header>bec_status_box</header>
</customwidget>
<customwidget>
<class>BECDockArea</class>
<extends>QWidget</extends>
<header>dock_area</header>
</customwidget>
<customwidget>
<class>QWebEngineView</class>
<extends></extends>
<header location="global">QtWebEngineWidgets/QWebEngineView</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,15 +0,0 @@
import webbrowser
class BECWebLinksMixin:
@staticmethod
def open_bec_docs():
webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/")
@staticmethod
def open_bec_widgets_docs():
webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/")
@staticmethod
def open_bec_bug_report():
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")

View File

@@ -2,29 +2,32 @@ import os
import numpy as np
import pyqtgraph as pg
from bec_qthemes import material_icon
from qtpy.QtWidgets import (
QApplication,
QGroupBox,
QHBoxLayout,
QPushButton,
QSplitter,
QTabWidget,
QVBoxLayout,
QWidget,
)
import qdarktheme
from qtconsole.inprocess import QtInProcessKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
from bec_widgets.widgets.containers.dock import BECDockArea
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.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.utils import BECDispatcher, UILoader
from bec_widgets.widgets import BECFigure
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.jupyter_console.jupyter_console import BECJupyterConsole
# class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:
# def __init__(self):
# super().__init__()
#
# self.kernel_manager = QtInProcessKernelManager()
# self.kernel_manager.start_kernel(show_banner=False)
# self.kernel_client = self.kernel_manager.client()
# self.kernel_client.start_channels()
#
# self.kernel_manager.kernel.shell.push({"np": np, "pg": pg})
#
# def shutdown_kernel(self):
# self.kernel_client.stop_channels()
# self.kernel_manager.shutdown_kernel()
class JupyterConsoleWindow(QWidget): # pragma: no cover:
@@ -33,131 +36,96 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
def __init__(self, parent=None):
super().__init__(parent)
current_path = os.path.dirname(__file__)
self.ui = UILoader().load_ui(os.path.join(current_path, "jupyter_console_window.ui"), self)
self._init_ui()
self.ui.splitter.setSizes([200, 100])
self.safe_close = False
# console push
if self.console.inprocess is True:
self.console.kernel_manager.kernel.shell.push(
{
"np": np,
"pg": pg,
"wh": wh,
"fig": self.figure,
"dock": self.dock,
# "im": self.im,
# "mi": self.mi,
# "mm": self.mm,
# "lm": self.lm,
# "btn1": self.btn1,
# "btn2": self.btn2,
# "btn3": self.btn3,
# "btn4": self.btn4,
# "btn5": self.btn5,
# "btn6": self.btn6,
# "pb": self.pb,
# "pi": self.pi,
# "wf": self.wf,
# "scatter": self.scatter,
# "scatter_mi": self.scatter,
# "mwf": self.mwf,
"w1": self.w1,
"w2": self.w2,
"w3": self.w3,
"d0": self.d0,
"d1": self.d1,
"d2": self.d2,
"fig0": self.fig0,
"fig1": self.fig1,
"fig2": self.fig2,
"bar": self.bar,
}
)
def _init_ui(self):
self.layout = QHBoxLayout(self)
# Plotting window
self.glw_1_layout = QVBoxLayout(self.ui.glw) # Create a new QVBoxLayout
self.figure = BECFigure(parent=self, gui_id="remote") # Create a new BECDeviceMonitor
self.glw_1_layout.addWidget(self.figure) # Add BECDeviceMonitor to the layout
# Horizontal splitter
splitter = QSplitter(self)
self.layout.addWidget(splitter)
self.dock_layout = QVBoxLayout(self.ui.dock_placeholder)
self.dock = BECDockArea(gui_id="remote")
self.dock_layout.addWidget(self.dock)
tab_widget = QTabWidget(splitter)
# add stuff to figure
self._init_figure()
first_tab = QWidget()
first_tab_layout = QVBoxLayout(first_tab)
self.dock = BECDockArea(gui_id="dock")
first_tab_layout.addWidget(self.dock)
tab_widget.addTab(first_tab, "Dock Area")
# init dock for testing
self._init_dock()
# third_tab = QWidget()
# third_tab_layout = QVBoxLayout(third_tab)
# self.lm = LayoutManagerWidget()
# third_tab_layout.addWidget(self.lm)
# tab_widget.addTab(third_tab, "Layout Manager Widget")
#
# 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_layout = QVBoxLayout(self.ui.widget_console)
self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console)
#
# # Some buttons for layout testing
# self.btn1 = QPushButton("Button 1")
# self.btn2 = QPushButton("Button 2")
# self.btn3 = QPushButton("Button 3")
# self.btn4 = QPushButton("Button 4")
# self.btn5 = QPushButton("Button 5")
# self.btn6 = QPushButton("Button 6")
#
# fifth_tab = QWidget()
# fifth_tab_layout = QVBoxLayout(fifth_tab)
# self.wf = Waveform()
# fifth_tab_layout.addWidget(self.wf)
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
# tab_widget.setCurrentIndex(4)
#
# sixth_tab = QWidget()
# sixth_tab_layout = QVBoxLayout(sixth_tab)
# self.im = Image()
# self.mi = self.im.main_image
# sixth_tab_layout.addWidget(self.im)
# tab_widget.addTab(sixth_tab, "Image Next Gen")
# tab_widget.setCurrentIndex(5)
#
# seventh_tab = QWidget()
# seventh_tab_layout = QVBoxLayout(seventh_tab)
# self.scatter = ScatterWaveform()
# self.scatter_mi = self.scatter.main_curve
# self.scatter.plot("samx", "samy", "bpm4i")
# seventh_tab_layout.addWidget(self.scatter)
# tab_widget.addTab(seventh_tab, "Scatter Waveform")
# tab_widget.setCurrentIndex(6)
#
# eighth_tab = QWidget()
# eighth_tab_layout = QVBoxLayout(eighth_tab)
# self.mm = MotorMap()
# eighth_tab_layout.addWidget(self.mm)
# tab_widget.addTab(eighth_tab, "Motor Map")
# tab_widget.setCurrentIndex(7)
#
# ninth_tab = QWidget()
# ninth_tab_layout = QVBoxLayout(ninth_tab)
# self.mwf = MultiWaveform()
# ninth_tab_layout.addWidget(self.mwf)
# tab_widget.addTab(ninth_tab, "MultiWaveform")
# tab_widget.setCurrentIndex(8)
#
# # add stuff to the new Waveform widget
# self._init_waveform()
#
# self.setWindowTitle("Jupyter Console Window")
self.console_layout.addWidget(self.console)
def _init_waveform(self):
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
def _init_figure(self):
self.figure.plot(x_name="samx", y_name="samy", z_name="bpm4i", color_map_z="cividis")
self.figure.motor_map("samx", "samy")
self.figure.image("eiger", color_map="viridis", vrange=(0, 100))
self.figure.add_plot(x_name="samx", y_name="samy", z_name="bpm4i", color_map_z="magma")
self.figure.change_layout(2, 2)
self.w1 = self.figure[0, 0]
self.w2 = self.figure[0, 1]
self.w3 = self.figure[1, 0]
# curves for w1
self.c1 = self.w1.get_config()
def _init_dock(self):
self.d0 = self.dock.add_dock(name="dock_0")
self.fig0 = self.d0.add_widget("BECFigure")
data = np.random.rand(10, 2)
self.fig0.plot(data, label="2d Data")
self.fig0.image("eiger", vrange=(0, 100))
self.d1 = self.dock.add_dock(name="dock_1", position="right")
self.fig1 = self.d1.add_widget("BECFigure")
self.fig1.plot(x_name="samx", y_name="bpm4i")
self.fig1.plot(x_name="samx", y_name="bpm3a")
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
self.fig2 = self.d2.add_widget("BECFigure", row=0, col=0)
self.fig2.plot(x_name="samx", y_name="bpm4i")
self.bar = self.d2.add_widget("SpiralProgressBar", row=0, col=1)
self.bar.set_diameter(200)
self.dock.save_state()
def closeEvent(self, event):
"""Override to handle things when main window is closed."""
self.dock.cleanup()
self.dock.close()
self.console.close()
self.figure.clear_all()
self.figure.client.shutdown()
super().closeEvent(event)
@@ -171,16 +139,17 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
qdarktheme.setup_theme("auto")
icon = QIcon()
icon.addFile(os.path.join(module_path, "assets", "terminal_icon.png"), size=QSize(48, 48))
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher(gui_id="jupyter_console")
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
win = JupyterConsoleWindow()
win.show()
win.resize(1500, 800)
app.aboutToQuit.connect(win.close)
sys.exit(app.exec_())

View File

@@ -0,0 +1,54 @@
<?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>2104</width>
<height>966</height>
</rect>
</property>
<property name="windowTitle">
<string>Plotting Console</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tab_1">
<attribute name="title">
<string>BECDock</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QWidget" name="dock_placeholder" native="true"/>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_2">
<attribute name="title">
<string>BECFigure</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QWidget" name="glw" native="true"/>
</item>
</layout>
</widget>
</widget>
<widget class="QWidget" name="widget_console" native="true"/>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,9 @@
from .motor_control_compilations import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelAbsolute,
MotorControlPanelRelative,
MotorCoordinateTable,
MotorThread,
)

View File

@@ -0,0 +1,250 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import qdarktheme
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QSplitter, QVBoxLayout, QWidget
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets.motor_control.motor_control import MotorThread
from bec_widgets.widgets.motor_control.motor_table.motor_table import MotorCoordinateTable
from bec_widgets.widgets.motor_control.movement_absolute.movement_absolute import (
MotorControlAbsolute,
)
from bec_widgets.widgets.motor_control.movement_relative.movement_relative import (
MotorControlRelative,
)
from bec_widgets.widgets.motor_control.selection.selection import MotorControlSelection
CONFIG_DEFAULT = {
"motor_control": {
"motor_x": "samx",
"motor_y": "samy",
"step_size_x": 3,
"step_size_y": 3,
"precision": 4,
"step_x_y_same": False,
"move_with_arrows": False,
},
"plot_settings": {
"colormap": "Greys",
"scatter_size": 5,
"max_points": 1000,
"num_dim_points": 100,
"precision": 2,
"num_columns": 1,
"background_value": 25,
},
"motors": [
{
"plot_name": "Motor Map",
"x_label": "Motor X",
"y_label": "Motor Y",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "samy", "entry": "samy"}],
},
}
],
}
class MotorControlApp(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
# Widgets
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
# Create MotorMap
# self.motion_map = MotorMap(client=self.client, config=self.config)
# Create MotorCoordinateTable
self.motor_table = MotorCoordinateTable(client=self.client, config=self.config)
# Create the splitter and add MotorMap and MotorControlPanel
splitter = QSplitter(Qt.Horizontal)
# splitter.addWidget(self.motion_map)
splitter.addWidget(self.motor_control_panel)
splitter.addWidget(self.motor_table)
# Set the main layout
layout = QVBoxLayout(self)
layout.addWidget(splitter)
self.setLayout(layout)
# Connecting signals and slots
# self.motor_control_panel.selection_widget.selected_motors_signal.connect(
# lambda x, y: self.motion_map.change_motors(x, y, 0)
# )
self.motor_control_panel.absolute_widget.coordinates_signal.connect(
self.motor_table.add_coordinate
)
self.motor_control_panel.relative_widget.precision_signal.connect(
self.motor_table.set_precision
)
self.motor_control_panel.relative_widget.precision_signal.connect(
self.motor_control_panel.absolute_widget.set_precision
)
# self.motor_table.plot_coordinates_signal.connect(self.motion_map.plot_saved_coordinates)
class MotorControlMap(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
# Widgets
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
# Create MotorMap
# self.motion_map = MotorMap(client=self.client, config=self.config)
# Create the splitter and add MotorMap and MotorControlPanel
splitter = QSplitter(Qt.Horizontal)
# splitter.addWidget(self.motion_map)
splitter.addWidget(self.motor_control_panel)
# Set the main layout
layout = QVBoxLayout(self)
layout.addWidget(splitter)
self.setLayout(layout)
# Connecting signals and slots
# self.motor_control_panel.selection_widget.selected_motors_signal.connect(
# lambda x, y: self.motion_map.change_motors(x, y, 0)
# )
class MotorControlPanel(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
self.motor_thread = MotorThread(client=self.client)
self.selection_widget = MotorControlSelection(
client=self.client, config=self.config, motor_thread=self.motor_thread
)
self.relative_widget = MotorControlRelative(
client=self.client, config=self.config, motor_thread=self.motor_thread
)
self.absolute_widget = MotorControlAbsolute(
client=self.client, config=self.config, motor_thread=self.motor_thread
)
layout = QVBoxLayout(self)
layout.addWidget(self.selection_widget)
layout.addWidget(self.relative_widget)
layout.addWidget(self.absolute_widget)
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
# Set the window to a fixed size based on its contents
# self.layout().setSizeConstraint(layout.SetFixedSize)
class MotorControlPanelAbsolute(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
self.motor_thread = MotorThread(client=self.client)
self.selection_widget = MotorControlSelection(
client=client, config=config, motor_thread=self.motor_thread
)
self.absolute_widget = MotorControlAbsolute(
client=client, config=config, motor_thread=self.motor_thread
)
layout = QVBoxLayout(self)
layout.addWidget(self.selection_widget)
layout.addWidget(self.absolute_widget)
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
class MotorControlPanelRelative(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
self.motor_thread = MotorThread(client=self.client)
self.selection_widget = MotorControlSelection(
client=client, config=config, motor_thread=self.motor_thread
)
self.relative_widget = MotorControlRelative(
client=client, config=config, motor_thread=self.motor_thread
)
layout = QVBoxLayout(self)
layout.addWidget(self.selection_widget)
layout.addWidget(self.relative_widget)
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
if __name__ == "__main__": # pragma: no cover
import argparse
import sys
parser = argparse.ArgumentParser(description="Run various Motor Control Widgets compositions.")
parser.add_argument(
"-v",
"--variant",
type=str,
choices=["app", "map", "panel", "panel_abs", "panel_rel"],
help="Select the variant of the motor control to run. "
"'app' for the full application, "
"'map' for MotorMap, "
"'panel' for the MotorControlPanel, "
"'panel_abs' for MotorControlPanel with absolute control, "
"'panel_rel' for MotorControlPanel with relative control.",
)
args = parser.parse_args()
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
app = QApplication([])
qdarktheme.setup_theme("auto")
if args.variant == "app":
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "map":
window = MotorControlMap(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "panel":
window = MotorControlPanel(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "panel_abs":
window = MotorControlPanelAbsolute(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "panel_rel":
window = MotorControlPanelRelative(client=client) # , config=CONFIG_DEFAULT)
else:
print("Please specify a valid variant to run. Use -h for help.")
print("Running the full application by default.")
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
window.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,926 @@
<?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>1561</width>
<height>748</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>1409</width>
<height>748</height>
</size>
</property>
<property name="windowTitle">
<string>Motor Controller</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout" stretch="8,5,8">
<item>
<widget class="GraphicsLayoutWidget" name="glw">
<property name="enabled">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QFrame" name="Controls">
<property name="minimumSize">
<size>
<width>221</width>
<height>471</height>
</size>
</property>
<layout class="QVBoxLayout" name="verticalLayout_6" stretch="1,1,1,0,1">
<property name="spacing">
<number>1</number>
</property>
<property name="sizeConstraint">
<enum>QLayout::SetMinimumSize</enum>
</property>
<item>
<widget class="QGroupBox" name="motorSelection">
<property name="minimumSize">
<size>
<width>261</width>
<height>145</height>
</size>
</property>
<property name="title">
<string>Motor Selection</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="2" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Motor Y</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="comboBox_motor_x"/>
</item>
<item row="2" column="1">
<widget class="QComboBox" name="comboBox_motor_y"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Motor X</string>
</property>
</widget>
</item>
<item row="3" column="0" colspan="2">
<widget class="QPushButton" name="pushButton_connecMotors">
<property name="text">
<string>Connect Motors</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_3">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Minimum</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>18</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QGroupBox" name="motorControl">
<property name="minimumSize">
<size>
<width>261</width>
<height>339</height>
</size>
</property>
<property name="title">
<string>Motor Relative</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_5">
<item>
<widget class="QCheckBox" name="checkBox_enableArrows">
<property name="text">
<string>Move with arrow keys</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkBox_same_xy">
<property name="text">
<string>Step [X] = Step [Y]</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="step_grid">
<item row="2" column="0">
<widget class="QLabel" name="label_step_y">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Step [Y]</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_2">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Decimal</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_step_x">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>99.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_step_x">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Step [X]</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QDoubleSpinBox" name="spinBox_step_y">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>99.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="spinBox_precision">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="maximum">
<number>8</number>
</property>
<property name="value">
<number>2</number>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QGridLayout" name="direction_grid">
<property name="sizeConstraint">
<enum>QLayout::SetDefaultConstraint</enum>
</property>
<item row="1" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
<widget class="QToolButton" name="toolButton_up">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::UpArrow</enum>
</property>
</widget>
</item>
<item row="2" column="4">
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
<widget class="QToolButton" name="toolButton_down">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::DownArrow</enum>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QToolButton" name="toolButton_left">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::LeftArrow</enum>
</property>
</widget>
</item>
<item row="0" column="2">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="3">
<widget class="QToolButton" name="toolButton_right">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::RightArrow</enum>
</property>
</widget>
</item>
<item row="2" column="0">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="2">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_4">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Minimum</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>18</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QGroupBox" name="motorControl_absolute">
<property name="minimumSize">
<size>
<width>261</width>
<height>195</height>
</size>
</property>
<property name="title">
<string>Move Absolute</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QCheckBox" name="checkBox_save_with_go">
<property name="text">
<string>Save position with Go</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout_3">
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_absolute_y">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-500.000000000000000</double>
</property>
<property name="maximum">
<double>500.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QDoubleSpinBox" name="spinBox_absolute_x">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-500.000000000000000</double>
</property>
<property name="maximum">
<double>500.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_7">
<property name="text">
<string>Y</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>X</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="pushButton_save">
<property name="text">
<string>Save</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_set">
<property name="text">
<string>Set</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_go_absolute">
<property name="text">
<string>Go</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QPushButton" name="pushButton_stop">
<property name="text">
<string>Stop Movement</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QTabWidget" name="tabWidget_tables">
<property name="enabled">
<bool>true</bool>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tab_coordinates">
<attribute name="title">
<string>Coordinates</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Entries Mode:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBox_mode">
<item>
<property name="text">
<string>Individual</string>
</property>
</item>
<item>
<property name="text">
<string>Start/Stop</string>
</property>
</item>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTableWidget" name="tableWidget_coordinates">
<property name="selectionMode">
<enum>QAbstractItemView::MultiSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<column>
<property name="text">
<string>Show</string>
</property>
</column>
<column>
<property name="text">
<string>Move</string>
</property>
</column>
<column>
<property name="text">
<string>Tag</string>
</property>
</column>
<column>
<property name="text">
<string>X</string>
</property>
</column>
<column>
<property name="text">
<string>Y</string>
</property>
</column>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="1">
<widget class="QPushButton" name="pushButton_resize_table">
<property name="text">
<string>Resize Table</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QCheckBox" name="checkBox_resize_auto">
<property name="text">
<string>Resize Auto</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QPushButton" name="pushButton_importCSV">
<property name="text">
<string>Import CSV</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="pushButton_exportCSV">
<property name="text">
<string>Export CSV</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QPushButton" name="pushButton_help">
<property name="text">
<string>Help</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="pushButton_duplicate">
<property name="text">
<string>Duplicate Last Entry</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_settings">
<attribute name="title">
<string>Settings</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QGroupBox" name="motorLimits">
<property name="enabled">
<bool>false</bool>
</property>
<property name="title">
<string>Motor Limits</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="2" column="1">
<widget class="QPushButton" name="pushButton_updateLimits">
<property name="text">
<string>Update</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_Y_max">
<property name="text">
<string>+ Y</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QLabel" name="label_Y_min">
<property name="text">
<string>- Y</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_X_min">
<property name="text">
<string>- X</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_X_max">
<property name="text">
<string>+ X</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_y_max">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QDoubleSpinBox" name="spinBox_y_min">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QDoubleSpinBox" name="spinBox_x_min">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="spinBox_x_max">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Plotting Options</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="0" column="1" colspan="2">
<widget class="QSpinBox" name="spinBox_max_points">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>100</number>
</property>
<property name="maximum">
<number>10000</number>
</property>
<property name="singleStep">
<number>100</number>
</property>
<property name="value">
<number>5000</number>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_15">
<property name="text">
<string>Max Points</string>
</property>
</widget>
</item>
<item row="2" column="1" colspan="2">
<widget class="QSpinBox" name="spinBox_scatter_size">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>15</number>
</property>
<property name="value">
<number>5</number>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_11">
<property name="text">
<string>Scatter Size</string>
</property>
</widget>
</item>
<item row="3" column="0" colspan="3">
<widget class="QPushButton" name="pushButton_update_config">
<property name="text">
<string>Update Settings</string>
</property>
</widget>
</item>
<item row="1" column="1" colspan="2">
<widget class="QSpinBox" name="spinBox_num_dim_points">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>10</number>
</property>
<property name="maximum">
<number>1000</number>
</property>
<property name="singleStep">
<number>10</number>
</property>
<property name="value">
<number>100</number>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_16">
<property name="text">
<string>N dim</string>
</property>
</widget>
</item>
<item row="4" column="0" colspan="3">
<widget class="QPushButton" name="pushButton_enableGUI">
<property name="text">
<string>Enable Control GUI</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_queue">
<attribute name="title">
<string>Queue</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QLabel" name="label_3">
<property name="text">
<string>Work in progress</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_5">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Reset Queue</string>
</property>
</widget>
</item>
<item>
<widget class="QTableWidget" name="tableWidget_2">
<property name="enabled">
<bool>false</bool>
</property>
<column>
<property name="text">
<string>queueID</string>
</property>
</column>
<column>
<property name="text">
<string>scan_id</string>
</property>
</column>
<column>
<property name="text">
<string>is_scan</string>
</property>
</column>
<column>
<property name="text">
<string>type</string>
</property>
</column>
<column>
<property name="text">
<string>scan_number</string>
</property>
</column>
<column>
<property name="text">
<string>IQ status</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>GraphicsLayoutWidget</class>
<extends>QGraphicsView</extends>
<header>pyqtgraph.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,18 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
"""PySide6 port of the Qt Designer taskmenuextension example from Qt v6.x"""
import sys
from bec_ipython_client.main import BECIPythonClient
from qtpy.QtWidgets import QApplication
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = TicTacToe()
window.state = "-X-XO----"
window.show()
sys.exit(app.exec())

View File

@@ -1,13 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
from bec_widgets.examples.plugin_example_pyside.tictactoeplugin import TicTacToePlugin
# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin
if __name__ == "__main__": # pragma: no cover
QPyDesignerCustomWidgetCollection.addCustomWidget(TicTacToePlugin())

View File

@@ -1,4 +0,0 @@
{
"files": ["tictactoe.py", "main.py", "registertictactoe.py", "tictactoeplugin.py",
"tictactoetaskmenu.py"]
}

View File

@@ -1,135 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtCore import Property, QPoint, QRect, QSize, Qt, Slot
from qtpy.QtGui import QPainter, QPen
from qtpy.QtWidgets import QWidget
EMPTY = "-"
CROSS = "X"
NOUGHT = "O"
DEFAULT_STATE = "---------"
class TicTacToe(QWidget): # pragma: no cover
def __init__(self, parent=None):
super().__init__(parent)
self._state = DEFAULT_STATE
self._turn_number = 0
def minimumSizeHint(self):
return QSize(200, 200)
def sizeHint(self):
return QSize(200, 200)
def setState(self, new_state):
self._turn_number = 0
self._state = DEFAULT_STATE
for position in range(min(9, len(new_state))):
mark = new_state[position]
if mark == CROSS or mark == NOUGHT:
self._turn_number += 1
self._change_state_at(position, mark)
position += 1
self.update()
def state(self):
return self._state
@Slot()
def clear_board(self):
self._state = DEFAULT_STATE
self._turn_number = 0
self.update()
def _change_state_at(self, pos, new_state):
self._state = self._state[:pos] + new_state + self._state[pos + 1 :]
def mousePressEvent(self, event):
if self._turn_number == 9:
self.clear_board()
return
for position in range(9):
cell = self._cell_rect(position)
if cell.contains(event.position().toPoint()):
if self._state[position] == EMPTY:
new_state = CROSS if self._turn_number % 2 == 0 else NOUGHT
self._change_state_at(position, new_state)
self._turn_number += 1
self.update()
def paintEvent(self, event):
with QPainter(self) as painter:
painter.setRenderHint(QPainter.Antialiasing)
painter.setPen(QPen(Qt.darkGreen, 1))
painter.drawLine(self._cell_width(), 0, self._cell_width(), self.height())
painter.drawLine(2 * self._cell_width(), 0, 2 * self._cell_width(), self.height())
painter.drawLine(0, self._cell_height(), self.width(), self._cell_height())
painter.drawLine(0, 2 * self._cell_height(), self.width(), 2 * self._cell_height())
painter.setPen(QPen(Qt.darkBlue, 2))
for position in range(9):
cell = self._cell_rect(position)
if self._state[position] == CROSS:
painter.drawLine(cell.topLeft(), cell.bottomRight())
painter.drawLine(cell.topRight(), cell.bottomLeft())
elif self._state[position] == NOUGHT:
painter.drawEllipse(cell)
painter.setPen(QPen(Qt.yellow, 3))
for position in range(0, 8, 3):
if (
self._state[position] != EMPTY
and self._state[position + 1] == self._state[position]
and self._state[position + 2] == self._state[position]
):
y = self._cell_rect(position).center().y()
painter.drawLine(0, y, self.width(), y)
self._turn_number = 9
for position in range(3):
if (
self._state[position] != EMPTY
and self._state[position + 3] == self._state[position]
and self._state[position + 6] == self._state[position]
):
x = self._cell_rect(position).center().x()
painter.drawLine(x, 0, x, self.height())
self._turn_number = 9
if (
self._state[0] != EMPTY
and self._state[4] == self._state[0]
and self._state[8] == self._state[0]
):
painter.drawLine(0, 0, self.width(), self.height())
self._turn_number = 9
if (
self._state[2] != EMPTY
and self._state[4] == self._state[2]
and self._state[6] == self._state[2]
):
painter.drawLine(0, self.height(), self.width(), 0)
self._turn_number = 9
def _cell_rect(self, position):
h_margin = self.width() / 30
v_margin = self.height() / 30
row = int(position / 3)
column = position - 3 * row
pos = QPoint(column * self._cell_width() + h_margin, row * self._cell_height() + v_margin)
size = QSize(self._cell_width() - 2 * h_margin, self._cell_height() - 2 * v_margin)
return QRect(pos, size)
def _cell_width(self):
return self.width() / 3
def _cell_height(self):
return self.height() / 3
state = Property(str, state, setState)

View File

@@ -1,73 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
from bec_widgets.examples.plugin_example_pyside.tictactoetaskmenu import TicTacToeTaskMenuFactory
from bec_widgets.utils.bec_designer import designer_material_icon
DOM_XML = """
<ui language='c++'>
<widget class='TicTacToe' name='ticTacToe'>
<property name='geometry'>
<rect>
<x>0</x>
<y>0</y>
<width>200</width>
<height>200</height>
</rect>
</property>
<property name='state'>
<string>-X-XO----</string>
</property>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class TicTacToePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = TicTacToe(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "Games"
def icon(self):
return designer_material_icon("sports_esports")
def includeFile(self):
return "tictactoe"
def initialize(self, form_editor):
self._form_editor = form_editor
manager = form_editor.extensionManager()
iid = TicTacToeTaskMenuFactory.task_menu_iid()
manager.registerExtensions(TicTacToeTaskMenuFactory(manager), iid)
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "TicTacToe"
def toolTip(self):
return "Tic Tac Toe Example, demonstrating class QDesignerTaskMenuExtension (Python)"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,68 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QExtensionFactory, QPyDesignerTaskMenuExtension
from qtpy.QtGui import QAction
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
from bec_widgets.utils.error_popups import SafeSlot as Slot
class TicTacToeDialog(QDialog): # pragma: no cover
def __init__(self, parent):
super().__init__(parent)
layout = QVBoxLayout(self)
self._ticTacToe = TicTacToe(self)
layout.addWidget(self._ticTacToe)
button_box = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Reset
)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
reset_button = button_box.button(QDialogButtonBox.Reset)
reset_button.clicked.connect(self._ticTacToe.clear_board)
layout.addWidget(button_box)
def set_state(self, new_state):
self._ticTacToe.setState(new_state)
def state(self):
return self._ticTacToe.state
class TicTacToeTaskMenu(QPyDesignerTaskMenuExtension):
def __init__(self, ticTacToe, parent):
super().__init__(parent)
self._ticTacToe = ticTacToe
self._edit_state_action = QAction("Edit State...", None)
self._edit_state_action.triggered.connect(self._edit_state)
def taskActions(self):
return [self._edit_state_action]
def preferredEditAction(self):
return self._edit_state_action
@Slot()
def _edit_state(self):
dialog = TicTacToeDialog(self._ticTacToe)
dialog.set_state(self._ticTacToe.state)
if dialog.exec() == QDialog.Accepted:
self._ticTacToe.state = dialog.state()
class TicTacToeTaskMenuFactory(QExtensionFactory):
def __init__(self, extension_manager):
super().__init__(extension_manager)
@staticmethod
def task_menu_iid():
return "org.qt-project.Qt.Designer.TaskMenu"
def createExtension(self, object, iid, parent):
if iid != TicTacToeTaskMenuFactory.task_menu_iid():
return None
if object.__class__.__name__ != "TicTacToe":
return None
return TicTacToeTaskMenu(object, parent)

View File

@@ -1,234 +0,0 @@
from unittest.mock import MagicMock
from bec_lib.device import Device as BECDevice
from bec_lib.device import Positioner as BECPositioner
from bec_lib.device import ReadoutPriority
from bec_lib.devicemanager import DeviceContainer
class FakeDevice(BECDevice):
"""Fake minimal positioner class for testing."""
def __init__(self, name, enabled=True, readout_priority=ReadoutPriority.MONITORED):
super().__init__(name=name)
self._enabled = enabled
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._readout_priority = readout_priority
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd.Device",
"deviceConfig": {},
"deviceTags": ["user device"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
@property
def readout_priority(self):
return self._readout_priority
@readout_priority.setter
def readout_priority(self, value):
self._readout_priority = value
@property
def limits(self) -> tuple[float, float]:
return self._limits
@limits.setter
def limits(self, value: tuple[float, float]):
self._limits = value
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.signals[self.name]["value"] = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
class FakePositioner(BECPositioner):
def __init__(
self,
name,
enabled=True,
limits=None,
read_value=1.0,
readout_priority=ReadoutPriority.MONITORED,
):
super().__init__(name=name)
# self.limits = limits if limits is not None else [0.0, 0.0]
self.read_value = read_value
self.setpoint_value = read_value
self.motor_is_moving_value = 0
self._enabled = enabled
self._limits = limits
self._readout_priority = readout_priority
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd_devices.SimPositioner",
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
"deviceTags": ["user motors"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
self._info = {
"signals": {
"readback": {"kind_str": "5"}, # hinted
"setpoint": {"kind_str": "1"}, # normal
"velocity": {"kind_str": "2"}, # config
}
}
self.signals = {
self.name: {"value": self.read_value},
f"{self.name}_setpoint": {"value": self.setpoint_value},
f"{self.name}_motor_is_moving": {"value": self.motor_is_moving_value},
}
@property
def readout_priority(self):
return self._readout_priority
@readout_priority.setter
def readout_priority(self, value):
self._readout_priority = value
@property
def enabled(self) -> bool:
return self._enabled
@enabled.setter
def enabled(self, value: bool):
self._enabled = value
@property
def limits(self) -> tuple[float, float]:
return self._limits
@limits.setter
def limits(self, value: tuple[float, float]):
self._limits = value
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.read_value = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
@property
def precision(self):
return 3
def set_read_value(self, value):
self.read_value = value
def read(self):
return self.signals
def set_limits(self, limits):
self.limits = limits
def move(self, value, relative=False):
"""Simulates moving the device to a new position."""
if relative:
self.read_value += value
else:
self.read_value = value
# Respect the limits
self.read_value = max(min(self.read_value, self.limits[1]), self.limits[0])
@property
def readback(self):
return MagicMock(get=MagicMock(return_value=self.read_value))
class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0):
super().__init__(name, limits, read_value)
class Device(FakeDevice):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name, enabled=True):
super().__init__(name, enabled)
class DMMock:
def __init__(self):
self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled]
def add_devives(self, devices: list):
for device in devices:
self.devices[device.name] = device
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
FakePositioner("samy", limits=[-5, 5], read_value=3.0),
FakePositioner("samz", limits=[-8, 8], read_value=4.0),
FakePositioner("aptrx", limits=None, read_value=4.0),
FakePositioner("aptry", limits=None, read_value=5.0),
FakeDevice("gauss_bpm"),
FakeDevice("gauss_adc1"),
FakeDevice("gauss_adc2"),
FakeDevice("gauss_adc3"),
FakeDevice("bpm4i"),
FakeDevice("bpm3a"),
FakeDevice("bpm3i"),
FakeDevice("eiger", readout_priority=ReadoutPriority.ASYNC),
FakeDevice("waveform1d"),
FakeDevice("async_device", readout_priority=ReadoutPriority.ASYNC),
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

@@ -1,5 +1,3 @@
from qtpy.QtWebEngineWidgets import QWebEngineView
from .bec_connector import BECConnector, ConnectionConfig
from .bec_dispatcher import BECDispatcher
from .bec_table import BECTable

View File

@@ -1,31 +1,16 @@
# pylint: disable = no-name-in-module,missing-module-docstring
from __future__ import annotations
import os
import time
import traceback
import uuid
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from typing import Optional, Type
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal
from qtpy.QtWidgets import QApplication
from qtpy.QtCore import Slot as pyqtSlot
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
from bec_widgets.cli.rpc_register import RPCRegister
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets.containers.dock import BECDock
else:
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
logger = bec_logger.logger
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
class ConnectionConfig(BaseModel):
@@ -43,301 +28,58 @@ class ConnectionConfig(BaseModel):
"""Generate a GUI ID if none is provided."""
if v is None:
widget_class = values.data["widget_class"]
v = f"{widget_class}_{datetime.now().strftime('%Y_%m_%d_%H_%M_%S_%f')}"
v = f"{widget_class}_{str(time.time())}"
return v
return v
class WorkerSignals(QObject):
progress = Signal(dict)
completed = Signal()
class Worker(QRunnable):
"""
Worker class to run a function in a separate thread.
"""
def __init__(self, func, *args, **kwargs):
super().__init__()
self.signals = WorkerSignals()
self.func = func
self.args = args
self.kwargs = kwargs
def run(self):
"""
Run the specified function in the thread.
"""
self.func(*self.args, **self.kwargs)
self.signals.completed.emit()
class BECConnector:
"""Connection mixin class to handle BEC client and device manager"""
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
EXIT_HANDLERS = {}
def __init__(
self,
client=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
object_name: str | None = None,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
root_widget: bool = False,
**kwargs,
):
"""
BECConnector mixin class to handle BEC client and device manager.
Args:
client(BECClient, optional): The BEC client.
config(ConnectionConfig, optional): The connection configuration with specific gui id.
gui_id(str, optional): The GUI ID.
object_name(str, optional): The object name.
parent_dock(BECDock, optional): The parent dock.# TODO should go away -> issue created #473
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
**kwargs:
"""
# Extract object_name from kwargs to not pass it to Qt class
object_name = object_name or kwargs.pop("objectName", None)
# Ensure the parent is always the first argument for QObject
parent = kwargs.pop("parent", None)
# This initializes the QObject or any qt related class BECConnector has to be used from this line down with QObject, otherwise hierarchy logic will not work
super().__init__(parent=parent, **kwargs)
assert isinstance(
self, QObject
), "BECConnector must be used with a QObject or any qt related class."
# flag to check if the object was destroyed and its cleanup was called
self._destroyed = False
USER_ACCESS = ["config_dict", "get_all_rpc"]
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
self._parent_dock = parent_dock # TODO also remove at some point -> issue created #473
self.rpc_register = RPCRegister()
if not self.client in BECConnector.EXIT_HANDLERS:
# register function to clean connections at exit;
# the function depends on BECClient, and BECDispatcher
@SafeSlot()
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
logger.info("Disconnecting", repr(dispatcher))
dispatcher.disconnect_all()
logger.info("Shutting down BEC Client", repr(client))
client.shutdown()
BECConnector.EXIT_HANDLERS[self.client] = terminate
QApplication.instance().aboutToQuit.connect(terminate)
if config:
self.config = config
self.config.widget_class = self.__class__.__name__
else:
logger.debug(
print(
f"No initial config found for {self.__class__.__name__}.\n"
f"Initializing with default config."
)
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
# If the gui_id is passed, it should be respected. However, this should be revisted since
# the gui_id has to be unique, and may no longer be.
if gui_id:
self.config.gui_id = gui_id
self.gui_id: str = gui_id # Keep namespace in sync
self.gui_id = gui_id
else:
self.gui_id: str = self.config.gui_id # type: ignore
self.gui_id = self.config.gui_id
if object_name is not None:
self.setObjectName(object_name)
# 1) If no objectName is set, set the initial name
if not self.objectName():
self.setObjectName(self.__class__.__name__)
self.object_name = self.objectName()
# 2) Enforce unique objectName among siblings with the same BECConnector parent
self.setParent(parent)
if isinstance(self.parent(), QObject) and hasattr(self, "cleanup"):
self.parent().destroyed.connect(self._run_cleanup_on_deleted_parent)
# Error popups
self.error_utility = ErrorPopupUtility()
self._thread_pool = QThreadPool.globalInstance()
# Store references to running workers so they're not garbage collected prematurely.
self._workers = []
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
self.root_widget = root_widget
QTimer.singleShot(0, self._update_object_name)
@property
def parent_id(self) -> str | None:
try:
if self.root_widget:
return None
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
return connector_parent.gui_id if connector_parent else None
except:
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
@SafeSlot()
def _run_cleanup_on_deleted_parent(self) -> None:
"""
Run cleanup on the deleted parent.
This method is called when the parent is deleted.
"""
if not hasattr(self, "cleanup"):
return
try:
if not self._destroyed:
self.cleanup()
self._destroyed = True
except Exception:
content = traceback.format_exc()
logger.info(
"Failed to run cleanup on deleted parent. "
f"This is not necessarily an error as the parent may be deleted before the child and includes already a cleanup. The following exception was raised:\n{content}"
)
def change_object_name(self, name: str) -> None:
"""
Change the object name of the widget. Unregister old name and register the new one.
Args:
name (str): The new object name.
"""
self.rpc_register.remove_rpc(self)
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
QTimer.singleShot(0, self._update_object_name)
def _update_object_name(self) -> None:
"""
Enforce a unique object name among siblings and register the object for RPC.
This method is called through a single shot timer kicked off in the constructor.
"""
# 1) Enforce unique objectName among siblings with the same BECConnector parent
self._enforce_unique_sibling_name()
# 2) Register the object for RPC
# register widget to rpc register
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
def _enforce_unique_sibling_name(self):
"""
Enforce that this BECConnector has a unique objectName among its siblings.
Sibling logic:
- If there's a nearest BECConnector parent, only compare with children of that parent.
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
"""
QApplication.processEvents()
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
if parent_bec:
# We have a parent => only compare with siblings under that parent
siblings = parent_bec.findChildren(BECConnector)
else:
# No parent => treat all top-level BECConnectors as siblings
# 1) Gather all BECConnectors from QApplication
all_widgets = QApplication.allWidgets()
all_bec = [w for w in all_widgets if isinstance(w, BECConnector)]
# 2) "Top-level" means closest BECConnector parent is None
top_level_bec = [
w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None
]
# 3) We are among these top-level siblings
siblings = top_level_bec
# Collect used names among siblings
used_names = {sib.objectName() for sib in siblings if sib is not self}
base_name = self.object_name
if base_name not in used_names:
# Name is already unique among siblings
return
# Need a suffix to avoid collision
counter = 0
while True:
trial_name = f"{base_name}_{counter}"
if trial_name not in used_names:
self.setObjectName(trial_name)
self.object_name = trial_name
break
counter += 1
# pylint: disable=invalid-name
def setObjectName(self, name: str) -> None:
"""
Set the object name of the widget.
Args:
name (str): The new object name.
"""
super().setObjectName(name)
self.object_name = name
if self.rpc_register.object_is_registered(self):
self.rpc_register.broadcast()
def submit_task(self, fn, *args, on_complete: SafeSlot = None, **kwargs) -> Worker:
"""
Submit a task to run in a separate thread. The task will run the specified
function with the provided arguments and emit the completed signal when done.
Use this method if you want to wait for a task to complete without blocking the
main thread.
Args:
fn: Function to run in a separate thread.
*args: Arguments for the function.
on_complete: Slot to run when the task is complete.
**kwargs: Keyword arguments for the function.
Returns:
worker: The worker object that will run the task.
Examples:
>>> def my_function(a, b):
>>> print(a + b)
>>> self.submit_task(my_function, 1, 2)
>>> def my_function(a, b):
>>> print(a + b)
>>> def on_complete():
>>> print("Task complete")
>>> self.submit_task(my_function, 1, 2, on_complete=on_complete)
"""
worker = Worker(fn, *args, **kwargs)
if on_complete:
worker.signals.completed.connect(on_complete)
# Keep a reference to the worker so it is not garbage collected.
self._workers.append(worker)
# When the worker is done, remove it from our list.
worker.signals.completed.connect(lambda: self._workers.remove(worker))
self._thread_pool.start(worker)
return worker
def _get_all_rpc(self) -> dict:
def get_all_rpc(self) -> dict:
"""Get all registered RPC objects."""
all_connections = self.rpc_register.list_all_connections()
return dict(all_connections)
@property
def _rpc_id(self) -> str:
def rpc_id(self) -> str:
"""Get the RPC ID of the widget."""
return self.gui_id
@_rpc_id.setter
def _rpc_id(self, rpc_id: str) -> None:
@rpc_id.setter
def rpc_id(self, rpc_id: str) -> None:
"""Set the RPC ID of the widget."""
self.gui_id = rpc_id
@property
def _config_dict(self) -> dict:
def config_dict(self) -> dict:
"""
Get the configuration of the widget.
@@ -346,78 +88,23 @@ class BECConnector:
"""
return self.config.model_dump()
@_config_dict.setter
def _config_dict(self, config: BaseModel) -> None:
@config_dict.setter
def config_dict(self, config: BaseModel) -> None:
"""
Set the configuration of the widget.
Get the configuration of the widget.
Args:
config (BaseModel): The new configuration model.
Returns:
dict: The configuration of the widget.
"""
self.config = config
# FIXME some thoughts are required to decide how thhis should work with rpc registry
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
"""
Apply the configuration to the widget.
Args:
config (dict): Configuration settings.
generate_new_id (bool): If True, generate a new GUI ID for the widget.
"""
self.config = ConnectionConfig(**config)
if generate_new_id is True:
gui_id = str(uuid.uuid4())
self.rpc_register.remove_rpc(self)
self._set_gui_id(gui_id)
self.rpc_register.add_rpc(self)
else:
self.gui_id = self.config.gui_id
# FIXME some thoughts are required to decide how thhis should work with rpc registry
def load_config(self, path: str | None = None, gui: bool = False):
"""
Load the configuration of the widget from YAML.
Args:
path (str | None): Path to the configuration file for non-GUI dialog mode.
gui (bool): If True, use the GUI dialog to load the configuration file.
"""
if gui is True:
config = load_yaml_gui(self)
else:
config = load_yaml(path)
if config is not None:
if config.get("widget_class") != self.__class__.__name__:
raise ValueError(
f"Configuration file is not for {self.__class__.__name__}. Got configuration for {config.get('widget_class')}."
)
self.apply_config(config)
def save_config(self, path: str | None = None, gui: bool = False):
"""
Save the configuration of the widget to YAML.
Args:
path (str | None): Path to save the configuration file for non-GUI dialog mode.
gui (bool): If True, use the GUI dialog to save the configuration file.
"""
if gui is True:
save_yaml_gui(self, self._config_dict)
else:
if path is None:
path = os.getcwd()
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
save_yaml(file_path, self._config_dict)
# @SafeSlot(str)
def _set_gui_id(self, gui_id: str) -> None:
@pyqtSlot(str)
def set_gui_id(self, gui_id: str) -> None:
"""
Set the GUI ID for the widget.
Args:
gui_id (str): GUI ID.
gui_id(str): GUI ID
"""
self.config.gui_id = gui_id
self.gui_id = gui_id
@@ -438,84 +125,48 @@ class BECConnector:
"""Update the client and device manager from BEC and create object for BEC shortcuts.
Args:
client: BEC client.
client: BEC client
"""
self.client = client
self.get_bec_shortcuts()
@SafeSlot(ConnectionConfig) # TODO can be also dict
@pyqtSlot(ConnectionConfig) # TODO can be also dict
def on_config_update(self, config: ConnectionConfig | dict) -> None:
"""
Update the configuration for the widget.
Args:
config (ConnectionConfig | dict): Configuration settings.
config(ConnectionConfig): Configuration settings.
"""
gui_id = getattr(config, "gui_id", None)
if isinstance(config, dict):
config = ConnectionConfig(**config)
self.config = config
if gui_id and config.gui_id != gui_id: # Recreating config should not overwrite the gui_id
self.config.gui_id = gui_id
# TODO add error handler
def remove(self):
"""Cleanup the BECConnector"""
# If the widget is attached to a dock, remove it from the dock.
# TODO this should be handled by dock and dock are not by BECConnector -> issue created #473
if self._parent_dock is not None:
self._parent_dock.delete(self.object_name)
# If the widget is from Qt, trigger its close method.
elif hasattr(self, "close"):
self.close()
# If the widget is neither from a Dock nor from Qt, remove it from the RPC registry.
# i.e. Curve Item from Waveform
else:
self.rpc_register.remove_rpc(self)
self.config = config
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
"""
Get the configuration of the widget.
Args:
dict_output (bool): If True, return the configuration as a dictionary.
If False, return the configuration as a pydantic model.
dict_output(bool): If True, return the configuration as a dictionary. If False, return the configuration as a pydantic model.
Returns:
dict | BaseModel: The configuration of the widget.
dict: The configuration of the plot widget.
"""
if dict_output:
return self.config.model_dump()
else:
return self.config
def cleanup(self):
"""Cleanup the widget."""
self.rpc_register.remove_rpc(self)
all_connections = self.rpc_register.list_all_connections()
if len(all_connections) == 0:
print("No more connections. Shutting down GUI BEC client.")
self.client.shutdown()
# --- Example usage of BECConnector: running a simple task ---
if __name__ == "__main__": # pragma: no cover
import sys
# Create a QApplication instance (required for QThreadPool)
app = QApplication(sys.argv)
connector = BECConnector()
def print_numbers():
"""
Task function that prints numbers 1 to 10 with a 0.5 second delay between each.
"""
for i in range(1, 11):
print(i)
time.sleep(0.5)
def task_complete():
"""
Called when the task is complete.
"""
print("Task complete")
# Exit the application after the task completes.
app.quit()
# Submit the task using the connector's submit_task method.
connector.submit_task(print_numbers, on_complete=task_complete)
# Start the Qt event loop.
sys.exit(app.exec_())
# def closeEvent(self, event):
# self.cleanup()
# super().closeEvent(event)

View File

@@ -1,171 +0,0 @@
import importlib.metadata
import json
import os
import site
import sys
import sysconfig
from pathlib import Path
from bec_qthemes import material_icon
from qtpy import PYSIDE6
from qtpy.QtGui import QIcon
from bec_widgets.utils.bec_plugin_helper import user_widget_plugin
if PYSIDE6:
from PySide6.scripts.pyside_tool import (
_extend_path_var,
init_virtual_env,
is_pyenv_python,
is_virtual_env,
qt_tool_wrapper,
ui_tool_binary,
)
import bec_widgets
def designer_material_icon(icon_name: str) -> QIcon:
"""
Create a QIcon for the BECDesigner with the given material icon name.
Args:
icon_name (str): The name of the material icon.
Returns:
QIcon: The QIcon for the material icon.
"""
return QIcon(material_icon(icon_name, filled=True, convert_to_pixmap=True))
def list_editable_packages() -> set[str]:
"""
List all editable packages in the environment.
Returns:
set: A set of paths to editable packages.
"""
editable_packages = set()
# Get site-packages directories
site_packages = site.getsitepackages()
if hasattr(site, "getusersitepackages"):
site_packages.append(site.getusersitepackages())
for dist in importlib.metadata.distributions():
location = dist.locate_file("").resolve()
is_editable = all(not str(location).startswith(site_pkg) for site_pkg in site_packages)
if is_editable:
editable_packages.add(str(location))
for packages in site_packages:
# all dist-info directories in site-packages that contain a direct_url.json file
dist_info_dirs = Path(packages).rglob("*.dist-info")
for dist_info_dir in dist_info_dirs:
direct_url = dist_info_dir / "direct_url.json"
if not direct_url.exists():
continue
# load the json file and get the path to the package
with open(direct_url, "r", encoding="utf-8") as f:
data = json.load(f)
path = data.get("url", "")
if path.startswith("file://"):
path = path[7:]
editable_packages.add(path)
return editable_packages
def patch_designer(cmd_args: list[str] = []): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
init_virtual_env()
major_version = sys.version_info[0]
minor_version = sys.version_info[1]
os.environ["PY_MAJOR_VERSION"] = str(major_version)
os.environ["PY_MINOR_VERSION"] = str(minor_version)
if sys.platform == "win32":
if is_virtual_env():
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
else:
if sys.platform == "linux":
env_var = "LD_PRELOAD"
current_pid = os.getpid()
with open(f"/proc/{current_pid}/maps", "rt") as f:
for line in f:
if "libpython" in line:
lib_path = line.split()[-1]
os.environ[env_var] = lib_path
break
elif sys.platform == "darwin":
suffix = ".dylib"
env_var = "DYLD_INSERT_LIBRARIES"
version = f"{major_version}.{minor_version}"
library_name = f"libpython{version}{suffix}"
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
os.environ[env_var] = lib_path
else:
raise RuntimeError(f"Unsupported platform: {sys.platform}")
if is_pyenv_python() or is_virtual_env():
# append all editable packages to the PYTHONPATH
editable_packages = list_editable_packages()
for pckg in editable_packages:
_extend_path_var("PYTHONPATH", pckg, True)
qt_tool_wrapper(ui_tool_binary("designer"), cmd_args)
def find_plugin_paths(base_path: Path):
"""
Recursively find all directories containing a .pyproject file.
"""
plugin_paths = []
for path in base_path.rglob("*.pyproject"):
plugin_paths.append(str(path.parent))
return plugin_paths
def set_plugin_environment_variable(plugin_paths):
"""
Set the PYSIDE_DESIGNER_PLUGINS environment variable with the given plugin paths.
"""
current_paths = os.environ.get("PYSIDE_DESIGNER_PLUGINS", "")
if current_paths:
current_paths = current_paths.split(os.pathsep)
else:
current_paths = []
current_paths.extend(plugin_paths)
os.environ["PYSIDE_DESIGNER_PLUGINS"] = os.pathsep.join(current_paths)
# Patch the designer function
def open_designer(cmd_args: list[str] = []): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Exiting...")
return
base_dir = Path(os.path.dirname(bec_widgets.__file__)).resolve()
plugin_paths = find_plugin_paths(base_dir)
if (plugin_repo := user_widget_plugin()) and isinstance(plugin_repo.__file__, str):
plugin_repo_dir = Path(os.path.dirname(plugin_repo.__file__)).resolve()
plugin_paths.extend(find_plugin_paths(plugin_repo_dir))
set_plugin_environment_variable(plugin_paths)
patch_designer(cmd_args)
def main():
open_designer(sys.argv[1:])
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,66 +1,37 @@
from __future__ import annotations
import argparse
import collections
import random
import string
from collections.abc import Callable
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
from typing import TYPE_CHECKING, Union
import louie
import redis
from bec_lib.client import BECClient
from bec_lib.logger import bec_logger
from bec_lib.redis_connector import MessageObject, RedisConnector
from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QObject
from qtpy.QtCore import QCoreApplication, QObject
from qtpy.QtCore import Signal as pyqtSignal
from bec_widgets.utils.serialization import register_serializer_extension
logger = bec_logger.logger
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from bec_lib.endpoints import EndpointInfo
from bec_widgets.utils.rpc_server import RPCServer
class QtThreadSafeCallback(QObject):
"""QtThreadSafeCallback is a wrapper around a callback function to make it thread-safe for Qt."""
cb_signal = pyqtSignal(dict, dict)
def __init__(self, cb: Callable, cb_info: dict | None = None):
"""
Initialize the QtThreadSafeCallback.
Args:
cb (Callable): The callback function to be wrapped.
cb_info (dict, optional): Additional information about the callback. Defaults to None.
"""
def __init__(self, cb):
super().__init__()
self.cb_info = cb_info
self.cb = cb
self.cb_ref = louie.saferef.safe_ref(cb)
self.cb_signal.connect(self.cb)
self.topics = set()
def __hash__(self):
# make 2 differents QtThreadSafeCallback to look
# identical when used as dictionary keys, if the
# callback is the same
return f"{id(self.cb_ref)}{self.cb_info}".__hash__()
def __eq__(self, other):
if not isinstance(other, QtThreadSafeCallback):
return False
return self.cb_ref == other.cb_ref and self.cb_info == other.cb_info
return id(self.cb)
def __call__(self, msg_content, metadata):
if self.cb_ref() is None:
# callback has been deleted
return
self.cb_signal.emit(msg_content, metadata)
@@ -100,182 +71,89 @@ class BECDispatcher:
_instance = None
_initialized = False
client: BECClient
cli_server: RPCServer | None = None
qapp = None
def __new__(
cls,
client=None,
config: str | ServiceConfig | None = None,
gui_id: str | None = None,
*args,
**kwargs,
):
def __new__(cls, client=None, config: str = None, *args, **kwargs):
if cls._instance is None:
cls._instance = super(BECDispatcher, cls).__new__(cls)
cls._initialized = False
return cls._instance
def __init__(self, client=None, config: str | ServiceConfig | None = None, gui_id: str = None):
def __init__(self, client=None, config: str = None):
if self._initialized:
return
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
collections.defaultdict()
)
if not QCoreApplication.instance():
BECDispatcher.qapp = QCoreApplication([])
self._slots = collections.defaultdict(set)
self.client = client
if self.client is None:
if config is not None:
if not isinstance(config, ServiceConfig):
# config is supposed to be a path
config = ServiceConfig(config)
self.client = BECClient(
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
)
host, port = config.split(":")
redis_config = {"host": host, "port": port}
self.client = BECClient(
config=ServiceConfig(redis=redis_config), connector_cls=QtRedisConnector
) # , forced=True)
else:
self.client = BECClient(connector_cls=QtRedisConnector) # , forced=True)
else:
if self.client.started:
# have to reinitialize client to use proper connector
logger.info("Shutting down BECClient to switch to QtRedisConnector")
self.client.shutdown()
self.client._BECClient__init_params["connector_cls"] = QtRedisConnector
try:
self.client.start()
except redis.exceptions.ConnectionError:
logger.warning("Could not connect to Redis, skipping start of BECClient.")
print("Could not connect to Redis, skipping start of BECClient.")
register_serializer_extension()
logger.success("Initialized BECDispatcher")
self.start_cli_server(gui_id=gui_id)
self._initialized = True
@classmethod
def reset_singleton(cls):
"""
Reset the singleton instance of the BECDispatcher.
"""
cls._instance = None
cls._initialized = False
def connect_slot(
self,
slot: Callable,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
cb_info: dict | None = None,
**kwargs,
self, slot: Callable, topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]]
) -> None:
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
"""Connect widget's pyqt slot, so that it is called on new pub/sub topic message.
Args:
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
the corresponding pub/sub message
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
"""
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
if qt_slot not in self._registered_slots:
self._registered_slots[qt_slot] = qt_slot
qt_slot = self._registered_slots[qt_slot]
self.client.connector.register(topics, cb=qt_slot, **kwargs)
slot = QtThreadSafeCallback(slot)
self.client.connector.register(topics, cb=slot)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
qt_slot.topics.update(set(topics_str))
self._slots[slot].update(set(topics_str))
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
"""
Disconnect a slot from a topic.
Args:
slot(Callable): The slot to disconnect
topics(Union[str, list]): The topic(s) to disconnect from
"""
# find the right slot to disconnect from ;
# slot callbacks are wrapped in QtThreadSafeCallback objects,
# but the slot we receive here is the original callable
for connected_slot in self._registered_slots.values():
for connected_slot in self._slots:
if connected_slot.cb == slot:
break
else:
return
self.client.connector.unregister(topics, cb=connected_slot)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._registered_slots[connected_slot].topics.difference_update(set(topics_str))
if not self._registered_slots[connected_slot].topics:
del self._registered_slots[connected_slot]
self._slots[slot].difference_update(set(topics_str))
if not self._slots[slot]:
del self._slots[slot]
def disconnect_topics(self, topics: Union[str, list]):
"""
Disconnect all slots from a topic.
Args:
topics(Union[str, list]): The topic(s) to disconnect from
"""
self.client.connector.unregister(topics)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
remove_slots = []
for connected_slot in self._registered_slots.values():
connected_slot.topics.difference_update(set(topics_str))
if not connected_slot.topics:
remove_slots.append(connected_slot)
for connected_slot in remove_slots:
self._registered_slots.pop(connected_slot, None)
for slot in list(self._slots.keys()):
slot_topics = self._slots[slot]
slot_topics.difference_update(set(topics_str))
if not slot_topics:
del self._slots[slot]
def disconnect_all(self, *args, **kwargs):
"""
Disconnect all slots from all topics.
Args:
*args: Arbitrary positional arguments
**kwargs: Arbitrary keyword arguments
"""
# pylint: disable=protected-access
self.disconnect_topics(self.client.connector._topics_cb)
def start_cli_server(self, gui_id: str | None = None):
"""
Start the CLI server.
Args:
gui_id(str, optional): The GUI ID. Defaults to None. If None, a unique identifier will be generated.
"""
# pylint: disable=import-outside-toplevel
from bec_widgets.utils.rpc_server import RPCServer
if gui_id is None:
gui_id = self.generate_unique_identifier()
if not self.client.started:
logger.error("Cannot start CLI server without a running client")
return
self.cli_server = RPCServer(gui_id, dispatcher=self, client=self.client)
logger.success(f"Started CLI server with gui_id: {gui_id}")
def stop_cli_server(self):
"""
Stop the CLI server.
"""
if self.cli_server is None:
logger.error("Cannot stop CLI server without starting it first")
return
self.cli_server.shutdown()
self.cli_server = None
logger.success("Stopped CLI server")
@staticmethod
def generate_unique_identifier(length: int = 4) -> str:
"""
Generate a unique identifier for the application.
Args:
length: The length of the identifier. Defaults to 4.
Returns:
str: The unique identifier.
"""
allowed_chars = string.ascii_lowercase + string.digits
return "".join(random.choices(allowed_chars, k=length))

View File

@@ -1,89 +0,0 @@
from __future__ import annotations
import importlib.metadata
import inspect
import pkgutil
from importlib import util as importlib_util
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
from types import ModuleType
from typing import Generator
from bec_widgets.utils.bec_widget import BECWidget
def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
"""Return specs for all submodules of the given module."""
return tuple(
module_info.module_finder.find_spec(module_info.name)
for module_info in pkgutil.iter_modules(module.__path__)
if isinstance(module_info.module_finder, FileFinder)
)
def _loaded_submodules_from_specs(
submodule_specs: tuple[ModuleSpec | None, ...],
) -> Generator[ModuleType, None, None]:
"""Load all submodules from the given specs."""
for submodule in (
importlib_util.module_from_spec(spec) for spec in submodule_specs if spec is not None
):
assert isinstance(
submodule.__loader__, SourceFileLoader
), "Module found from FileFinder should have SourceFileLoader!"
submodule.__loader__.exec_module(submodule)
yield submodule
def _submodule_by_name(module: ModuleType, name: str):
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
if submod.__name__ == name:
return submod
return None
def _get_widgets_from_module(module: ModuleType) -> dict[str, "type[BECWidget]"]:
"""Find any BECWidget subclasses in the given module and return them with their names."""
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
return dict(
inspect.getmembers(
module,
predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget)
and item is not BECWidget,
)
)
def _all_widgets_from_all_submods(module):
"""Recursively load submodules, find any BECWidgets, and return them all as a flat dict."""
widgets = _get_widgets_from_module(module)
if not hasattr(module, "__path__"):
return widgets
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
widgets.update(_all_widgets_from_all_submods(submod))
return widgets
def user_widget_plugin() -> ModuleType | None:
plugins = importlib.metadata.entry_points(group="bec.widgets.user_widgets") # type: ignore
return None if len(plugins) == 0 else tuple(plugins)[0].load()
def get_plugin_client_module() -> ModuleType | None:
"""If there is a plugin repository installed, return the client module."""
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
def get_all_plugin_widgets() -> dict[str, "type[BECWidget]"]:
"""If there is a plugin repository installed, load all widgets from it."""
if plugin := user_widget_plugin():
return _all_widgets_from_all_submods(plugin)
else:
return {}
if __name__ == "__main__": # pragma: no cover
# print(get_all_plugin_widgets())
client = get_plugin_client_module()
...

View File

@@ -1,90 +0,0 @@
"""This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
Unblocking the proxy needs to be done through the slot unblock_proxy. The most likely use case for this class is
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
analyse data. Requesting a new fit may lead to request piling up and an overall slow done of performance. This proxy
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.utils.error_popups import SafeSlot
class BECSignalProxy(SignalProxy):
"""
Thin wrapper around the SignalProxy class to allow signal calls to be blocked,
but arguments 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.
Example:
>>> proxy = BECSignalProxy(signal, rate_limit=25, slot=callback)
"""
is_blocked = Signal(bool)
def __init__(self, *args, rateLimit=25, timeout=10.0, **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"""
return self._blocking
@blocked.setter
def blocked(self, value: bool):
self._blocking = value
self.is_blocked.emit(value)
def signalReceived(self, *args):
"""Receive signal, store the args and call signalReceived from the parent class if not blocked"""
self.new_args = args
if self.blocked is True:
return
self.blocked = True
self.old_args = args
super().signalReceived(*args)
self._timer.start(int(self._timeout * 1000))
@SafeSlot()
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()
def cleanup(self):
"""
Cleanup the proxy by stopping the timer and disconnecting the timeout signal.
"""
self._timer.stop()
self._timer.timeout.disconnect(self._timeout_unblock)
self._timer.deleteLater()

View File

@@ -1,113 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import darkdetect
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject, Slot
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import set_theme
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.dock import BECDock
logger = bec_logger.logger
class BECWidget(BECConnector):
"""Mixin class for all BEC widgets, to handle cleanup"""
# The icon name is the name of the icon in the icon theme, typically a name taken
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
ICON_NAME = "widgets"
USER_ACCESS = ["remove"]
# pylint: disable=too-many-arguments
def __init__(
self,
client=None,
config: ConnectionConfig = None,
gui_id: str | None = None,
theme_update: bool = False,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
**kwargs,
):
"""
Base class for all BEC widgets. This class should be used as a mixin class for all BEC widgets, e.g.:
>>> class MyWidget(BECWidget, QWidget):
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
>>> super().__init__(client=client, config=config, gui_id=gui_id)
>>> QWidget.__init__(self, parent=parent)
Args:
client(BECClient, optional): The BEC client.
config(ConnectionConfig, optional): The connection configuration.
gui_id(str, optional): The GUI ID.
theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the
widget's apply_theme method will be called when the theme changes.
"""
super().__init__(
client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs
)
if not isinstance(self, QObject):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
app = QApplication.instance()
if not hasattr(app, "theme"):
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
# Instead, we will set the theme to the system setting on startup
if darkdetect.isDark():
set_theme("dark")
else:
set_theme("light")
if theme_update:
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
self._connect_to_theme_change()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
theme = qapp.theme.theme
else:
theme = "dark"
self.apply_theme(theme)
@Slot(str)
def apply_theme(self, theme: str):
"""
Apply the theme to the widget.
Args:
theme(str, optional): The theme to be applied.
"""
def cleanup(self):
"""Cleanup the widget."""
with RPCRegister.delayed_broadcast():
# All widgets need to call super().cleanup() in their cleanup method
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
self.rpc_register.remove_rpc(self)
def closeEvent(self, event):
"""Wrap the close even to ensure the rpc_register is cleaned up."""
try:
if not self._destroyed:
self.cleanup()
self._destroyed = True
finally:
super().closeEvent(event) # pylint: disable=no-member

View File

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

View File

@@ -1,137 +1,10 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Literal
from typing import Literal
import bec_qthemes
import numpy as np
import pyqtgraph as pg
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
from pydantic_core import PydanticCustomError
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
if TYPE_CHECKING: # pragma: no cover
from bec_qthemes._main import AccentColors
def get_theme_palette():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
theme = "dark"
else:
theme = QApplication.instance().theme.theme
return bec_qthemes.load_palette(theme)
def get_accent_colors() -> AccentColors | None:
"""
Get the accent colors for the current theme. These colors are extensions of the color palette
and are used to highlight specific elements in the UI.
"""
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
return None
return QApplication.instance().theme.accent_colors
def _theme_update_callback():
"""
Internal callback function to update the theme based on the system theme.
"""
app = QApplication.instance()
# pylint: disable=protected-access
app.theme.theme = app.os_listener._theme.lower()
app.theme_signal.theme_updated.emit(app.theme.theme)
apply_theme(app.os_listener._theme.lower())
def set_theme(theme: Literal["dark", "light", "auto"]):
"""
Set the theme for the application.
Args:
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
"""
app = QApplication.instance()
bec_qthemes.setup_theme(theme, install_event_filter=False)
app.theme_signal.theme_updated.emit(theme)
apply_theme(theme)
if theme != "auto":
return
if not hasattr(app, "os_listener") or app.os_listener is None:
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
app.installEventFilter(app.os_listener)
def apply_theme(theme: Literal["dark", "light"]):
"""
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
"""
app = QApplication.instance()
graphic_layouts = [
child
for top in app.topLevelWidgets()
for child in top.findChildren(pg.GraphicsLayoutWidget)
]
plot_items = [
item
for gl in graphic_layouts
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
if isinstance(item, pg.PlotItem)
]
histograms = [
item
for gl in graphic_layouts
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
if isinstance(item, pg.HistogramLUTItem)
]
# Update background color based on the theme
if theme == "light":
background_color = "#e9ecef" # Subtle contrast for light mode
foreground_color = "#141414"
label_color = "#000000"
axis_color = "#666666"
else:
background_color = "#141414" # Dark mode
foreground_color = "#e9ecef"
label_color = "#FFFFFF"
axis_color = "#CCCCCC"
# update GraphicsLayoutWidget
pg.setConfigOptions(foreground=foreground_color, background=background_color)
for pg_widget in graphic_layouts:
pg_widget.setBackground(background_color)
# update PlotItems
for plot_item in plot_items:
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)
# Change legend color
if hasattr(plot_item, "legend") and plot_item.legend is not None:
plot_item.legend.setLabelTextColor(label_color)
# if legend is in plot item and theme is changed, has to be like that because of pg opt logic
for sample, label in plot_item.legend.items:
label_text = label.text
label.setText(label_text, color=label_color)
# update HistogramLUTItem
for histogram in histograms:
histogram.axis.setPen(pg.mkPen(color=axis_color))
histogram.axis.setTextPen(pg.mkPen(color=label_color))
# now define stylesheet according to theme and apply it
style = bec_qthemes.load_stylesheet(theme)
app.setStyleSheet(style)
class Colors:
@@ -155,98 +28,9 @@ class Colors:
angles.append(angle)
return angles
@staticmethod
def set_theme_offset(theme: Literal["light", "dark"] | None = None, offset=0.2) -> tuple:
"""
Set the theme offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
Args:
theme(str): The theme to be applied.
offset(float): Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
Returns:
tuple: Tuple of min_pos and max_pos.
Raises:
ValueError: If theme_offset is not between 0 and 1.
"""
if offset < 0 or offset > 1:
raise ValueError("theme_offset must be between 0 and 1")
if theme is None:
app = QApplication.instance()
if hasattr(app, "theme"):
theme = app.theme.theme
if theme == "light":
min_pos = 0.0
max_pos = 1 - offset
else:
min_pos = 0.0 + offset
max_pos = 1.0
return min_pos, max_pos
@staticmethod
def evenly_spaced_colors(
colormap: str,
num: int,
format: Literal["QColor", "HEX", "RGB"] = "QColor",
theme_offset=0.2,
theme: Literal["light", "dark"] | None = None,
) -> list:
"""
Extract `num` colors from the specified colormap, evenly spaced along its range,
and return them in the specified format.
Args:
colormap (str): Name of the colormap.
num (int): Number of requested colors.
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
theme (Literal['light', 'dark'] | None): The theme to be applied. Overrides the QApplication theme if specified.
Returns:
list: List of colors in the specified format.
Raises:
ValueError: If theme_offset is not between 0 and 1.
"""
if theme_offset < 0 or theme_offset > 1:
raise ValueError("theme_offset must be between 0 and 1")
cmap = pg.colormap.get(colormap)
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
# Generate positions that are evenly spaced within the acceptable range
if num == 1:
positions = np.array([(min_pos + max_pos) / 2])
else:
positions = np.linspace(min_pos, max_pos, num)
# Sample colors from the colormap at the calculated positions
colors = cmap.map(positions, mode="float")
color_list = []
for color in colors:
if format.upper() == "HEX":
color_list.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
color_list.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR":
color_list.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
return color_list
@staticmethod
def golden_angle_color(
colormap: str,
num: int,
format: Literal["QColor", "HEX", "RGB"] = "QColor",
theme_offset=0.2,
theme: Literal["dark", "light"] | None = None,
colormap: str, num: int, format: Literal["QColor", "HEX", "RGB"] = "QColor"
) -> list:
"""
Extract num colors from the specified colormap following golden angle distribution and return them in the specified format.
@@ -255,77 +39,33 @@ class Colors:
colormap (str): Name of the colormap.
num (int): Number of requested colors.
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
Returns:
list: List of colors in the specified format.
Raises:
ValueError: If theme_offset is not between 0 and 1.
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
"""
cmap = pg.colormap.get(colormap)
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
# Generate positions within the acceptable range
positions = np.mod(np.arange(num) * golden_angle_conjugate, 1)
positions = min_pos + positions * (max_pos - min_pos)
# Sample colors from the colormap at the calculated positions
colors = cmap.map(positions, mode="float")
color_list = []
for color in colors:
cmap_colors = cmap.getColors(mode="float")
if num > len(cmap_colors):
raise ValueError(
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
)
angles = Colors.golden_ratio(len(cmap_colors))
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
colors = []
for ii in color_selection[:num]:
color = cmap_colors[int(ii)]
if format.upper() == "HEX":
color_list.append(QColor.fromRgbF(*color).name())
colors.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
color_list.append(tuple((np.array(color) * 255).astype(int)))
colors.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR":
color_list.append(QColor.fromRgbF(*color))
colors.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
return color_list
@staticmethod
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
"""
Convert HEX color to RGBA.
Args:
hex_color(str): HEX color string.
alpha(int): Alpha value (0-255). Default is 255 (opaque).
Returns:
tuple: RGBA color tuple (r, g, b, a).
"""
hex_color = hex_color.lstrip("#")
if len(hex_color) == 6:
r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
elif len(hex_color) == 8:
r, g, b, a = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4, 6))
return (r, g, b, a)
else:
raise ValueError("HEX color must be 6 or 8 characters long.")
return (r, g, b, alpha)
@staticmethod
def rgba_to_hex(r: int, g: int, b: int, a: int = 255) -> str:
"""
Convert RGBA color to HEX.
Args:
r(int): Red value (0-255).
g(int): Green value (0-255).
b(int): Blue value (0-255).
a(int): Alpha value (0-255). Default is 255 (opaque).
Returns:
hec_color(str): HEX color string.
"""
return "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, a)
return colors
@staticmethod
def validate_color(color: tuple | str) -> tuple | str:
@@ -516,7 +256,7 @@ class Colors:
return color
@staticmethod
def validate_color_map(color_map: str, return_error: bool = True) -> str | bool:
def validate_color_map(color_map: str) -> str:
"""
Validate the colormap input if it is supported by pyqtgraph. Can be used in any pydantic model as a field validator. If validation fails it prints all available colormaps from pyqtgraph instance.
@@ -524,24 +264,13 @@ class Colors:
color_map(str): The colormap to be validated.
Returns:
str: The validated colormap, if colormap is valid.
bool: False, if colormap is invalid.
Raises:
PydanticCustomError: If colormap is invalid.
str: The validated colormap.
"""
available_pg_maps = pg.colormap.listMaps()
available_mpl_maps = pg.colormap.listMaps("matplotlib")
available_mpl_colorcet = pg.colormap.listMaps("colorcet")
available_colormaps = available_pg_maps + available_mpl_maps + available_mpl_colorcet
available_colormaps = pg.colormap.listMaps()
if color_map not in available_colormaps:
if return_error:
raise PydanticCustomError(
"unsupported colormap",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
{"wrong_value": color_map},
)
else:
return False
raise PydanticCustomError(
"unsupported colormap",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
{"wrong_value": color_map},
)
return color_map

View File

@@ -1,270 +0,0 @@
import time
from types import SimpleNamespace
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Qt, Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSpacerItem,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.colors import get_accent_colors
class LedLabel(QLabel):
success_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.145, y1:0.16, x2:1, y2:1, stop:0 %s, stop:1 %s);"
emergency_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.145, y1:0.16, x2:0.92, y2:0.988636, stop:0 %s, stop:1 %s);"
warning_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.232, y1:0.272, x2:0.98, y2:0.959773, stop:0 %s, stop:1 %s);"
default_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.04, y1:0.0565909, x2:0.799, y2:0.795, stop:0 %s, stop:1 %s);"
def __init__(self, parent=None):
super().__init__(parent)
self.palette = get_accent_colors()
if self.palette is None:
# no theme!
self.palette = SimpleNamespace(
default=QColor("blue"),
success=QColor("green"),
warning=QColor("orange"),
emergency=QColor("red"),
)
self.setState("default")
self.setFixedSize(20, 20)
def setState(self, state: str):
match state:
case "success":
r, g, b, a = self.palette.success.getRgb()
self.setStyleSheet(
LedLabel.success_led
% (
f"rgba({r},{g},{b},{a})",
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
)
)
case "default":
r, g, b, a = self.palette.default.getRgb()
self.setStyleSheet(
LedLabel.default_led
% (
f"rgba({r},{g},{b},{a})",
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
)
)
case "warning":
r, g, b, a = self.palette.warning.getRgb()
self.setStyleSheet(
LedLabel.warning_led
% (
f"rgba({r},{g},{b},{a})",
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
)
)
case "emergency":
r, g, b, a = self.palette.emergency.getRgb()
self.setStyleSheet(
LedLabel.emergency_led
% (
f"rgba({r},{g},{b},{a})",
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
)
)
case unknown_state:
raise ValueError(
f"Unknown state {repr(unknown_state)}, must be one of default, success, warning or emergency"
)
class PopupDialog(QDialog):
def __init__(self, content_widget):
self.parent = content_widget.parent()
self.content_widget = content_widget
super().__init__(self.parent)
self.setAttribute(Qt.WA_DeleteOnClose)
self.content_widget.setParent(self)
QVBoxLayout(self)
self.layout().addWidget(self.content_widget)
self.content_widget.setVisible(True)
def closeEvent(self, event):
self.content_widget.setVisible(False)
self.content_widget.setParent(self.parent)
self.done(True)
class CompactPopupWidget(QWidget):
"""Container widget, that can display its content or have a compact form,
in this case clicking on a small button pops the contained widget up.
In the compact form, a LED-like indicator shows a status indicator.
"""
expand = Signal(bool)
def __init__(self, parent=None, layout=QVBoxLayout):
super().__init__(parent)
self._popup_window = None
self._expand_popup = True
QVBoxLayout(self)
self.compact_view_widget = QWidget(self)
self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
QHBoxLayout(self.compact_view_widget)
self.compact_view_widget.layout().setSpacing(0)
self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0)
self.compact_view_widget.layout().addSpacerItem(
QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)
)
self.compact_label = QLabel(self.compact_view_widget)
self.compact_status = LedLabel(self.compact_view_widget)
self.compact_show_popup = QPushButton(self.compact_view_widget)
self.compact_show_popup.setFlat(True)
self.compact_show_popup.setIcon(
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
)
self.compact_view_widget.layout().addWidget(self.compact_label)
self.compact_view_widget.layout().addWidget(self.compact_status)
self.compact_view_widget.layout().addWidget(self.compact_show_popup)
self.compact_view_widget.setVisible(False)
self.layout().addWidget(self.compact_view_widget)
self.container = QWidget(self)
self.layout().addWidget(self.container)
self.container.setVisible(True)
layout(self.container)
self.layout = self.container.layout()
self.compact_show_popup.clicked.connect(self.show_popup)
def set_global_state(self, state: str):
"""Set the LED-indicator state
The LED indicator represents the 'global' state. State can be one of the
following: "default", "success", "warning", "emergency"
"""
self.compact_status.setState(state)
def show_popup(self):
"""Display the contained widgets in a popup dialog"""
if self._expand_popup:
# show popup
self._popup_window = PopupDialog(self.container)
self._popup_window.show()
self._popup_window.finished.connect(lambda: self.expand.emit(False))
self.expand.emit(True)
else:
if self.compact_view:
# expand in place
self.compact_view = False
self.compact_view_widget.setVisible(True)
self.compact_label.setVisible(False)
self.compact_status.setVisible(False)
self.compact_show_popup.setIcon(
material_icon(
icon_name="collapse_content", size=(10, 10), convert_to_pixmap=False
)
)
self.expand.emit(True)
else:
# back to compact form
self.compact_label.setVisible(True)
self.compact_status.setVisible(True)
self.compact_show_popup.setIcon(
material_icon(
icon_name="expand_content", size=(10, 10), convert_to_pixmap=False
)
)
self.compact_view = True
self.expand.emit(False)
def setSizePolicy(self, size_policy1, size_policy2=None):
# setting size policy on the compact popup widget will set
# the policy for the container, and for itself
if size_policy2 is None:
# assuming first form: setSizePolicy(QSizePolicy)
self.container.setSizePolicy(size_policy1)
QWidget.setSizePolicy(self, size_policy1)
else:
self.container.setSizePolicy(size_policy1, size_policy2)
QWidget.setSizePolicy(self, size_policy1, size_policy2)
def addWidget(self, widget):
"""Add a widget to the popup container
The popup container corresponds to the "full view" (not compact)
The widget is reparented to the container, and added to the container layout
"""
widget.setParent(self.container)
self.container.layout().addWidget(widget)
@Property(bool)
def compact_view(self):
return self.compact_label.isVisible()
@compact_view.setter
def compact_view(self, set_compact: bool):
"""Sets the compact form
If set_compact is True, the compact view is displayed ; otherwise,
the full view is displayed. This is handled by toggling visibility of
the container widget or the compact view widget.
"""
if set_compact:
self.compact_view_widget.setVisible(True)
self.container.setVisible(False)
QWidget.setSizePolicy(self, QSizePolicy.Fixed, QSizePolicy.Fixed)
else:
self.compact_view_widget.setVisible(False)
self.container.setVisible(True)
QWidget.setSizePolicy(self, self.container.sizePolicy())
if self.parentWidget():
self.parentWidget().adjustSize()
else:
self.adjustSize()
@Property(str)
def label(self):
return self.compact_label.text()
@label.setter
def label(self, compact_label_text: str):
"""Set the label text associated to the compact view"""
self.compact_label.setText(compact_label_text)
@Property(str)
def tooltip(self):
return self.compact_label.toolTip()
@tooltip.setter
def tooltip(self, tooltip: str):
"""Set the tooltip text associated to the compact view"""
self.compact_label.setToolTip(tooltip)
self.compact_status.setToolTip(tooltip)
@Property(bool)
def expand_popup(self):
return self._expand_popup
@expand_popup.setter
def expand_popup(self, popup: bool):
self._expand_popup = popup
def closeEvent(self, event):
# Called by Qt, on closing - since the children widgets can be
# BECWidgets, it is good to explicitely call 'close' on them,
# to ensure proper resources cleanup
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
child.close()
super().closeEvent(event)

View File

@@ -1,54 +1,28 @@
from __future__ import annotations
from typing import Any, Type
import itertools
from typing import Type
from qtpy.QtWidgets import QWidget
from bec_widgets.cli.client_utils import BECGuiClient
class WidgetContainerUtils:
# We need one handler that checks if a WIDGET of a given name is already created for that DOCKAREA
# 1. If the name exists, then it depends whether the name was auto-generated -> add _1 to the name
# or alternatively raise an error that it can't be added again ( just raise an error)
# 2. Dock names in between docks should also be unique
@staticmethod
def has_name_valid_chars(name: str) -> bool:
"""Check if the name is valid.
def generate_unique_widget_id(container: dict, prefix: str = "widget") -> str:
"""
Generate a unique widget ID.
Args:
name(str): The name to be checked.
container(dict): The container of widgets.
prefix(str): The prefix of the widget ID.
Returns:
bool: True if the name is valid, False otherwise.
widget_id(str): The unique widget ID.
"""
if not name or len(name) > 256:
return False # Don't accept empty names or names longer than 256 characters
check_value = name.replace("_", "").replace("-", "")
if not check_value.isalnum() or not check_value.isascii():
return False
return True
@staticmethod
def generate_unique_name(name: str, list_of_names: list[str] | None = None) -> str:
"""Generate a unique ID.
Args:
name(str): The name of the widget.
Returns:
tuple (str): The unique name
"""
if list_of_names is None:
list_of_names = []
ii = 0
while ii < 1000: # 1000 is arbritrary!
name_candidate = f"{name}_{ii}"
if name_candidate not in list_of_names:
return name_candidate
ii += 1
raise ValueError("Could not generate a unique name after within 1000 attempts.")
existing_ids = set(container.keys())
for i in itertools.count(1):
widget_id = f"{prefix}_{i}"
if widget_id not in existing_ids:
return widget_id
@staticmethod
def find_first_widget_by_class(
@@ -72,36 +46,3 @@ class WidgetContainerUtils:
return None
else:
raise ValueError(f"No widget of class {widget_class} found.")
@staticmethod
def name_is_protected(name: str, container: Any = None) -> bool:
"""
Check if the name is not protected.
Args:
name(str): The name to be checked.
Returns:
bool: True if the name is not protected, False otherwise.
"""
if container is None:
container = BECGuiClient
gui_client_methods = set(filter(lambda x: not x.startswith("_"), dir(container)))
return name in gui_client_methods
@staticmethod
def raise_for_invalid_name(name: str, container: Any = None) -> None:
"""
Check if the name is valid. If not, raise a ValueError.
Args:
name(str): The name to be checked.
Raises:
ValueError: If the name is not valid.
"""
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(
f"Name '{name}' contains invalid characters. Only alphanumeric characters, underscores, and dashes are allowed."
)
if WidgetContainerUtils.name_is_protected(name, container):
raise ValueError(f"Name '{name}' is protected. Please choose another name.")

View File

@@ -1,38 +1,18 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication
class CrosshairScatterItem(pg.ScatterPlotItem):
def setDownsampling(self, ds=None, auto=None, method=None):
pass
def setClipToView(self, state):
pass
def setAlpha(self, *args, **kwargs):
pass
# from qtpy.QtCore import QObject, pyqtSignal
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
class Crosshair(QObject):
# QT Position of mouse cursor
positionChanged = Signal(tuple)
positionClicked = Signal(tuple)
# Plain crosshair position signals mapped to real coordinates
crosshairChanged = Signal(tuple)
crosshairClicked = Signal(tuple)
# Signal for 1D plot
coordinatesChanged1D = Signal(tuple)
coordinatesClicked1D = Signal(tuple)
coordinatesChanged1D = pyqtSignal(tuple)
coordinatesClicked1D = pyqtSignal(tuple)
# Signal for 2D plot
coordinatesChanged2D = Signal(tuple)
coordinatesClicked2D = Signal(tuple)
coordinatesChanged2D = pyqtSignal(tuple)
coordinatesClicked2D = pyqtSignal(tuple)
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
"""
@@ -46,187 +26,85 @@ class Crosshair(QObject):
super().__init__(parent)
self.is_log_y = None
self.is_log_x = None
self.is_derivative = None
self.plot_item = plot_item
self.precision = precision
self.v_line = pg.InfiniteLine(angle=90, movable=False)
self.v_line.skip_auto_range = True
self.h_line = pg.InfiniteLine(angle=0, movable=False)
self.h_line.skip_auto_range = True
# Add custom attribute to identify crosshair lines
self.v_line.is_crosshair = True
self.h_line.is_crosshair = True
self.plot_item.addItem(self.v_line, ignoreBounds=True)
self.plot_item.addItem(self.h_line, ignoreBounds=True)
# Initialize highlighted curve in a case of multiple curves
self.highlighted_curve_index = None
# Add TextItem to display coordinates
self.coord_label = pg.TextItem("", anchor=(1, 1), fill=(0, 0, 0, 100))
self.coord_label.setVisible(False) # Hide initially
self.coord_label.skip_auto_range = True
self.plot_item.addItem(self.coord_label)
# Signals to connect
self.proxy = pg.SignalProxy(
self.plot_item.scene().sigMouseMoved, rateLimit=60, slot=self.mouse_moved
)
self.positionChanged.connect(self.update_coord_label)
self.plot_item.scene().sigMouseClicked.connect(self.mouse_clicked)
# Connect signals from pyqtgraph right click menu
self.plot_item.ctrl.derivativeCheck.checkStateChanged.connect(self.check_derivatives)
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.downsampleSpin.valueChanged.connect(self.clear_markers)
# Initialize markers
self.items = []
self.marker_moved_1d = {}
self.marker_clicked_1d = {}
self.marker_moved_1d = []
self.marker_clicked_1d = []
self.marker_2d = None
self.update_markers()
self.check_log()
self.check_derivatives()
self._connect_to_theme_change()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
self._update_theme()
@Slot(str)
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
theme = qapp.theme.theme
else:
theme = "dark"
self.apply_theme(theme)
def apply_theme(self, theme: str):
"""Apply the theme to the plot."""
if theme == "dark":
text_color = "w"
label_bg_color = (50, 50, 50, 150)
elif theme == "light":
text_color = "k"
label_bg_color = (240, 240, 240, 150)
else:
text_color = "w"
label_bg_color = (50, 50, 50, 150)
self.coord_label.setColor(text_color)
self.coord_label.fill = pg.mkBrush(label_bg_color)
self.coord_label.border = pg.mkPen(None)
@Slot(int)
def update_highlighted_curve(self, curve_index: int):
"""
Update the highlighted curve in the case of multiple curves in a plot item.
Args:
curve_index(int): The index of curve to highlight
"""
self.highlighted_curve_index = curve_index
self.clear_markers()
self.update_markers()
def update_markers(self):
"""Update the markers for the crosshair, creating new ones if necessary."""
if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"):
# Focus on the highlighted curve only
self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]]
else:
# Handle all curves
self.items = self.plot_item.items
# Clear existing markers
for marker in self.marker_moved_1d + self.marker_clicked_1d:
self.plot_item.removeItem(marker)
if self.marker_2d:
self.plot_item.removeItem(self.marker_2d)
# Create or update markers
for item in self.items:
# Create new markers
self.marker_moved_1d = []
self.marker_clicked_1d = []
self.marker_2d = None
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem): # 1D plot
pen = item.opts["pen"]
color = pen.color() if hasattr(pen, "color") else pg.mkColor(pen)
name = item.name() or str(id(item))
if name in self.marker_moved_1d:
# Update existing markers
marker_moved = self.marker_moved_1d[name]
marker_moved.setPen(pg.mkPen(color))
# Update clicked markers' brushes
for marker_clicked in self.marker_clicked_1d[name]:
alpha = marker_clicked.opts["brush"].color().alpha()
marker_clicked.setBrush(
pg.mkBrush(color.red(), color.green(), color.blue(), alpha)
)
# Update z-values
marker_moved.setZValue(item.zValue() + 1)
for marker_clicked in self.marker_clicked_1d[name]:
marker_clicked.setZValue(item.zValue() + 1)
else:
# Create new markers
marker_moved = CrosshairScatterItem(
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
marker_moved = pg.ScatterPlotItem(
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
)
marker_clicked = pg.ScatterPlotItem(
size=10, pen=pg.mkPen(None), brush=pg.mkBrush(color)
)
self.marker_moved_1d.append(marker_moved)
self.plot_item.addItem(marker_moved)
# Create glowing effect markers for clicked events
marker_clicked_list = []
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
marker_clicked = pg.ScatterPlotItem(
size=size,
pen=pg.mkPen(None),
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
)
marker_moved.skip_auto_range = True
marker_moved.is_crosshair = True
self.marker_moved_1d[name] = marker_moved
self.plot_item.addItem(marker_moved)
# Set marker z-value higher than the curve
marker_moved.setZValue(item.zValue() + 1)
marker_clicked_list.append(marker_clicked)
self.plot_item.addItem(marker_clicked)
# Create glowing effect markers for clicked events
marker_clicked_list = []
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
marker_clicked = CrosshairScatterItem(
size=size,
pen=pg.mkPen(None),
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
)
marker_clicked.skip_auto_range = True
marker_clicked.is_crosshair = True
self.plot_item.addItem(marker_clicked)
marker_clicked.setZValue(item.zValue() + 1)
marker_clicked_list.append(marker_clicked)
self.marker_clicked_1d[name] = marker_clicked_list
self.marker_clicked_1d.append(marker_clicked_list)
elif isinstance(item, pg.ImageItem): # 2D plot
if self.marker_2d is not None:
continue
self.marker_2d = pg.ROI(
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
)
self.marker_2d.skip_auto_range = True
self.plot_item.addItem(self.marker_2d)
def snap_to_data(
self, x: float, y: float
) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]:
def snap_to_data(self, x, y) -> tuple:
"""
Finds the nearest data points to the given x and y coordinates.
Args:
x(float): The x-coordinate of the mouse cursor
y(float): The y-coordinate of the mouse cursor
x: The x-coordinate
y: The y-coordinate
Returns:
tuple: x and y values snapped to the nearest data
tuple: The nearest x and y values
"""
y_values = defaultdict(list)
x_values = defaultdict(list)
y_values_1d = []
x_values_1d = []
image_2d = None
# Iterate through items in the plot
for item in self.items:
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem): # 1D plot
name = item.name() or str(id(item))
plot_data = item._getDisplayDataset()
if plot_data is None:
continue
x_data, y_data = plot_data.x, plot_data.y
x_data, y_data = item.xData, item.yData
if x_data is not None and y_data is not None:
if self.is_log_x:
min_x_data = np.min(x_data[x_data > 0])
@@ -234,56 +112,43 @@ class Crosshair(QObject):
min_x_data = np.min(x_data)
max_x_data = np.max(x_data)
if x < min_x_data or x > max_x_data:
y_values[name] = None
x_values[name] = None
continue
return None, None
closest_x, closest_y = self.closest_x_y_value(x, x_data, y_data)
y_values[name] = closest_y
x_values[name] = closest_x
y_values_1d.append(closest_y)
x_values_1d.append(closest_x)
elif isinstance(item, pg.ImageItem): # 2D plot
name = item.config.monitor or str(id(item))
image_2d = item.image
# Clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
if x_values and y_values:
if all(v is None for v in x_values.values()) or all(
v is None for v in y_values.values()
):
# Handle 1D plot
if y_values_1d:
if all(v is None for v in x_values_1d) or all(v is None for v in y_values_1d):
return None, None
return x_values, y_values
closest_x = min(x_values_1d, key=lambda xi: abs(xi - x)) # Snap x to closest data point
return closest_x, y_values_1d
# Handle 2D plot
if image_2d is not None:
x_idx = int(np.clip(x, 0, image_2d.shape[0] - 1))
y_idx = int(np.clip(y, 0, image_2d.shape[1] - 1))
return x_idx, y_idx
return None, None
def closest_x_y_value(self, input_x: float, list_x: list, list_y: list) -> tuple:
def closest_x_y_value(self, input_value: float, list_x: list, list_y: list) -> tuple:
"""
Find the closest x and y value to the input value.
Args:
input_x (float): Input value
input_value (float): Input value
list_x (list): List of x values
list_y (list): List of y values
Returns:
tuple: Closest x and y value
"""
# Convert lists to NumPy arrays
arr_x = np.asarray(list_x)
# Get the indices where x is not NaN
valid_indices = ~np.isnan(arr_x)
# Filter x array to exclude NaN values
filtered_x = arr_x[valid_indices]
# Find the index of the closest value in the filtered x array
closest_index = np.abs(filtered_x - input_x).argmin()
# Map back to the original index in the list_x and list_y arrays
original_index = np.where(valid_indices)[0][closest_index]
return list_x[original_index], list_y[original_index]
arr = np.asarray(list_x)
i = (np.abs(arr - input_value)).argmin()
return list_x[i], list_y[i]
def mouse_moved(self, event):
"""Handles the mouse moved event, updating the crosshair position and emitting signals.
@@ -291,50 +156,39 @@ class Crosshair(QObject):
Args:
event: The mouse moved event
"""
self.check_log()
pos = event[0]
self.update_markers()
if self.plot_item.vb.sceneBoundingRect().contains(pos):
mouse_point = self.plot_item.vb.mapSceneToView(pos)
self.v_line.setPos(mouse_point.x())
self.h_line.setPos(mouse_point.y())
x, y = mouse_point.x(), mouse_point.y()
self.v_line.setPos(x)
self.h_line.setPos(y)
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
self.crosshairChanged.emit((scaled_x, scaled_y))
self.positionChanged.emit((x, y))
if self.is_log_x:
x = 10**x
if self.is_log_y:
y = 10**y
x, y_values = self.snap_to_data(x, y)
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
return
if all(v is None for v in x_snap_values.values()) or all(
v is None for v in y_snap_values.values()
):
# not sure how we got here, but just to be safe...
return
for item in self.items:
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_moved_1d[name].setData([x], [y])
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
if x is None or all(v is None for v in y_values):
return
coordinate_to_emit = (
name,
round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, self.precision),
round(x, self.precision),
[round(y_val, self.precision) for y_val in y_values],
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
for i, y_val in enumerate(y_values):
self.marker_moved_1d[i].setData(
[x if not self.is_log_x else np.log10(x)],
[y_val if not self.is_log_y else np.log10(y_val)],
)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_2d.setPos([x, y])
coordinate_to_emit = (name, x, y)
if x is None or y_values is None:
return
coordinate_to_emit = (x, y_values)
self.coordinatesChanged2D.emit(coordinate_to_emit)
else:
continue
def mouse_clicked(self, event):
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
@@ -342,119 +196,40 @@ class Crosshair(QObject):
Args:
event: The mouse clicked event
"""
# we only accept left mouse clicks
if event.button() != Qt.MouseButton.LeftButton:
return
self.update_markers()
self.check_log()
if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos):
mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos)
x, y = mouse_point.x(), mouse_point.y()
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
self.crosshairClicked.emit((scaled_x, scaled_y))
self.positionClicked.emit((x, y))
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if self.is_log_x:
x = 10**x
if self.is_log_y:
y = 10**y
x, y_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
return
if all(v is None for v in x_snap_values.values()) or all(
v is None for v in y_snap_values.values()
):
# not sure how we got here, but just to be safe...
return
for item in self.items:
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
for marker_clicked in self.marker_clicked_1d[name]:
marker_clicked.setData([x], [y])
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
if x is None or all(v is None for v in y_values):
return
coordinate_to_emit = (
name,
round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, self.precision),
round(x, self.precision),
[round(y_val, self.precision) for y_val in y_values],
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
for i, y_val in enumerate(y_values):
for marker in self.marker_clicked_1d[i]:
marker.setData(
[x if not self.is_log_x else np.log10(x)],
[y_val if not self.is_log_y else np.log10(y_val)],
)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_2d.setPos([x, y])
coordinate_to_emit = (name, x, y)
if x is None or y_values is None:
return
coordinate_to_emit = (x, y_values)
self.coordinatesClicked2D.emit(coordinate_to_emit)
else:
continue
def clear_markers(self):
"""Clears the markers from the plot."""
for marker in self.marker_moved_1d.values():
self.plot_item.removeItem(marker)
for markers in self.marker_clicked_1d.values():
for marker in markers:
self.plot_item.removeItem(marker)
self.marker_moved_1d.clear()
self.marker_clicked_1d.clear()
def scale_emitted_coordinates(self, x, y):
"""Scales the emitted coordinates if the axes are in log scale.
Args:
x (float): The x-coordinate
y (float): The y-coordinate
Returns:
tuple: The scaled x and y coordinates
"""
if self.is_log_x:
x = 10**x
if self.is_log_y:
y = 10**y
return x, y
def update_coord_label(self, pos: tuple):
"""Updates the coordinate label based on the crosshair position and axis scales.
Args:
pos (tuple): The (x, y) position of the crosshair.
"""
x, y = pos
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})"
for item in self.items:
if isinstance(item, pg.ImageItem):
image = item.image
ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1))
intensity = image[ix, iy]
text += f"\nIntensity: {intensity:.{self.precision}g}"
break
# Update coordinate label
self.coord_label.setText(text)
self.coord_label.setPos(x, y)
self.coord_label.setVisible(True)
self.marker_2d.setPos([x, y_values])
def check_log(self):
"""Checks if the x or y axis is in log scale and updates the internal state accordingly."""
self.is_log_x = self.plot_item.axes["bottom"]["item"].logMode
self.is_log_y = self.plot_item.axes["left"]["item"].logMode
self.clear_markers()
def check_derivatives(self):
"""Checks if the derivatives are enabled and updates the internal state accordingly."""
self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked()
self.clear_markers()
def cleanup(self):
if self.marker_2d is not None:
self.plot_item.removeItem(self.marker_2d)
self.marker_2d = None
self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label)
self.clear_markers()
self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked()
self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked()

View File

@@ -19,12 +19,10 @@ class EntryValidator:
device = self.devices[name]
description = device.describe()
if entry is None or entry == "":
if entry is None:
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description:
raise ValueError(
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
)
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
return entry

View File

@@ -1,344 +0,0 @@
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):
"""
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.
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
...
"""
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
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
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
# 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 decorator
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
to the passed function, to display errors instead of potentially raising an exception
'popup_error' keyword argument can be passed with boolean value if a dialog should pop up,
otherwise error display is left to the original exception hook
'verify_sender' keyword argument can be passed with boolean value if the sender should be verified
before executing the slot. If True, the slot will only execute if the sender is a QObject. This is
useful to prevent function calls from already deleted objects.
'raise_error' keyword argument can be passed with boolean value if the error should be raised
after the error is displayed. This is useful to propagate the error to the caller but should be used
with great care to avoid segfaults.
The keywords above are stored in a container which can be overridden by passing
'_override_slot_params' keyword argument with a dictionary containing the keywords to override.
This is useful to override the default behavior of the decorator for a specific function call.
"""
_slot_params = {
"popup_error": bool(slot_kwargs.pop("popup_error", False)),
"verify_sender": bool(slot_kwargs.pop("verify_sender", False)),
"raise_error": bool(slot_kwargs.pop("raise_error", False)),
}
def error_managed(method):
@Slot(*slot_args, **slot_kwargs)
@functools.wraps(method)
def wrapper(*args, **kwargs):
_override_slot_params = kwargs.pop("_override_slot_params", {})
_slot_params.update(_override_slot_params)
try:
if not _slot_params["verify_sender"] or len(args) == 0:
return method(*args, **kwargs)
_instance = args[0]
if not isinstance(_instance, QObject):
return method(*args, **kwargs)
sender = _instance.sender()
if sender is None:
logger.info(
f"Sender is None for {method.__module__}.{method.__qualname__}, "
"skipping method call."
)
return
return method(*args, **kwargs)
except Exception:
slot_name = f"{method.__module__}.{method.__qualname__}"
error_msg = traceback.format_exc()
if _slot_params["popup_error"]:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
if _slot_params["raise_error"]:
raise
return wrapper
return error_managed
class WarningPopupUtility(QObject):
"""
Utility class to show warning popups in the application.
"""
@SafeSlot(str, str, str, QWidget)
def show_warning_message(self, title, message, detailed_text, widget):
msg = QMessageBox(widget)
msg.setIcon(QMessageBox.Warning)
msg.setWindowTitle(title)
msg.setText(message)
msg.setStandardButtons(QMessageBox.Ok)
msg.setDetailedText(detailed_text)
msg.exec_()
def show_warning(self, title: str, message: str, detailed_text: str, widget: QWidget = None):
"""
Show a warning message with the given title, message, and detailed text.
Args:
title (str): The title of the warning message.
message (str): The main text of the warning message.
detailed_text (str): The detailed text to show when the user expands the message.
widget (QWidget): The parent widget for the message box.
"""
self.show_warning_message(title, message, detailed_text, widget)
_popup_utility_instance = None
class _ErrorPopupUtility(QObject):
"""
Utility class to manage error popups in the application to show error messages to the users.
This class is singleton and the error popup can be enabled or disabled globally or attach to widget methods with decorator @error_managed.
"""
error_occurred = Signal(str, str, QWidget)
def __init__(self, parent=None):
super().__init__(parent=parent)
self.error_occurred.connect(self.show_error_message)
self.enable_error_popup = False
self._initialized = True
sys.excepthook = self.custom_exception_hook
@SafeSlot(str, str, QWidget)
def show_error_message(self, title, message, widget):
detailed_text = self.format_traceback(message)
error_message = self.parse_error_message(detailed_text)
msg = QMessageBox(widget)
msg.setIcon(QMessageBox.Critical)
msg.setWindowTitle(title)
msg.setText(error_message)
msg.setStandardButtons(QMessageBox.Ok)
msg.setDetailedText(detailed_text)
msg.setTextInteractionFlags(Qt.TextSelectableByMouse)
msg.setMinimumWidth(600)
msg.setMinimumHeight(400)
msg.exec_()
def show_property_error(self, title, message, widget):
"""
Show a property-specific error message.
"""
self.error_occurred.emit(title, message, widget)
def format_traceback(self, traceback_message: str) -> str:
"""
Format the traceback message to be displayed in the error popup by adding indentation to each line.
Args:
traceback_message(str): The traceback message to be formatted.
Returns:
str: The formatted traceback message.
"""
formatted_lines = []
lines = traceback_message.split("\n")
for line in lines:
formatted_lines.append(" " + line) # Add indentation to each line
return "\n".join(formatted_lines)
def parse_error_message(self, traceback_message):
lines = traceback_message.split("\n")
error_message = "Error occurred. See details."
capture = False
captured_message = []
for line in lines:
if "raise" in line:
capture = True
continue
if capture:
if line.strip() and not line.startswith(" File "):
captured_message.append(line.strip())
else:
break
if captured_message:
error_message = " ".join(captured_message)
return error_message
def get_error_message(self, exctype, value, tb):
return "".join(traceback.format_exception(exctype, value, tb))
def custom_exception_hook(self, exctype, value, tb, popup_error=False):
if popup_error or self.enable_error_popup:
self.error_occurred.emit(
"Method error" if popup_error else "Application Error",
self.get_error_message(exctype, value, tb),
self.parent(),
)
else:
sys.__excepthook__(exctype, value, tb) # Call the original excepthook
def enable_global_error_popups(self, state: bool):
"""
Enable or disable global error popups for all applications.
Args:
state(bool): True to enable error popups, False to disable error popups.
"""
self.enable_error_popup = bool(state)
def ErrorPopupUtility():
global _popup_utility_instance
if not _popup_utility_instance:
_popup_utility_instance = _ErrorPopupUtility()
return _popup_utility_instance
class ExampleWidget(QWidget): # pragma: no cover
"""
Example widget to demonstrate error handling with the ErrorPopupUtility.
Warnings -> This example works properly only with PySide6, PyQt6 has a bug with the error handling.
"""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.init_ui()
self.warning_utility = WarningPopupUtility(self)
def init_ui(self):
self.layout = QVBoxLayout(self)
# Button to trigger method with error handling
self.error_button = QPushButton("Trigger Handled Error", self)
self.error_button.clicked.connect(self.method_with_error_handling)
self.layout.addWidget(self.error_button)
# Button to trigger method without error handling
self.normal_button = QPushButton("Trigger Normal Error", self)
self.normal_button.clicked.connect(self.method_without_error_handling)
self.layout.addWidget(self.normal_button)
# Button to trigger warning popup
self.warning_button = QPushButton("Trigger Warning", self)
self.warning_button.clicked.connect(self.trigger_warning)
self.layout.addWidget(self.warning_button)
@SafeSlot(popup_error=True)
def method_with_error_handling(self):
"""This method raises an error and the exception is handled by the decorator."""
raise ValueError("This is a handled error.")
@SafeSlot()
def method_without_error_handling(self):
"""This method raises an error and the exception is not handled here."""
raise ValueError("This is an unhandled error.")
@SafeSlot()
def trigger_warning(self):
"""Trigger a warning using the WarningPopupUtility."""
self.warning_utility.show_warning(
title="Warning",
message="This is a warning message.",
detailed_text="This is the detailed text of the warning message.",
widget=self,
)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
widget = ExampleWidget()
widget.show()
sys.exit(app.exec_())

View File

@@ -1,72 +0,0 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QLayout,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class ExpandableGroupFrame(QFrame):
EXPANDED_ICON_NAME: str = "collapse_all"
COLLAPSED_ICON_NAME: str = "expand_all"
def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None:
super().__init__(parent=parent)
self._expanded = expanded
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
self._expansion_button = QToolButton()
self._update_icon()
self._title = QLabel(f"<b>{title}</b>")
self._title_layout.addWidget(self._expansion_button)
self._title_layout.addWidget(self._title)
self._contents = QWidget(self)
self._layout.addWidget(self._contents)
self._expansion_button.clicked.connect(self.switch_expanded_state)
self.expanded = self._expanded # type: ignore
def set_layout(self, layout: QLayout) -> None:
self._contents.setLayout(layout)
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
@SafeSlot()
def switch_expanded_state(self):
self.expanded = not self.expanded # type: ignore
self._update_icon()
@SafeProperty(bool)
def expanded(self): # type: ignore
return self._expanded
@expanded.setter
def expanded(self, expanded: bool):
self._expanded = expanded
self._contents.setVisible(expanded)
self.updateGeometry()
def _update_icon(self):
self._expansion_button.setIcon(
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
if self.expanded
else material_icon(
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
)
)

View File

@@ -1,156 +0,0 @@
"""Module for handling filter I/O operations in BEC Widgets for input fields.
These operations include filtering device/signal names and/or device types.
"""
from abc import ABC, abstractmethod
from bec_lib.logger import bec_logger
from qtpy.QtCore import QStringListModel
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
logger = bec_logger.logger
class WidgetFilterHandler(ABC):
"""Abstract base class for widget filter handlers"""
@abstractmethod
def set_selection(self, widget, selection: list) -> None:
"""Set the filtered_selection for the widget
Args:
selection (list): Filtered selection of items
"""
@abstractmethod
def check_input(self, widget, text: str) -> bool:
"""Check if the input text is in the filtered selection
Args:
widget: Widget instance
text (str): Input text
Returns:
bool: True if the input text is in the filtered selection
"""
class LineEditFilterHandler(WidgetFilterHandler):
"""Handler for QLineEdit widget"""
def set_selection(self, widget: QLineEdit, selection: list) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QLineEdit): The QLineEdit widget
selection (list): Filtered selection of items
"""
if not isinstance(widget.completer, QCompleter):
completer = QCompleter(widget)
widget.setCompleter(completer)
widget.completer.setModel(QStringListModel(selection, widget))
def check_input(self, widget: QLineEdit, text: str) -> bool:
"""Check if the input text is in the filtered selection
Args:
widget (QLineEdit): The QLineEdit widget
text (str): Input text
Returns:
bool: True if the input text is in the filtered selection
"""
model = widget.completer.model()
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
return text in model_data
class ComboBoxFilterHandler(WidgetFilterHandler):
"""Handler for QComboBox widget"""
def set_selection(self, widget: QComboBox, selection: list) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QComboBox): The QComboBox widget
selection (list): Filtered selection of items
"""
widget.clear()
widget.addItems(selection)
def check_input(self, widget: QComboBox, text: str) -> bool:
"""Check if the input text is in the filtered selection
Args:
widget (QComboBox): The QComboBox widget
text (str): Input text
Returns:
bool: True if the input text is in the filtered selection
"""
return text in [widget.itemText(i) for i in range(widget.count())]
class FilterIO:
"""Public interface to set filters for input widgets.
It supports the list of widgets stored in class attribute _handlers.
"""
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
@staticmethod
def set_selection(widget, selection: list, ignore_errors=True):
"""
Retrieve value from the widget instance.
Args:
widget: Widget instance.
selection(list): List of filtered selection items.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().set_selection(widget=widget, selection=selection)
if not ignore_errors:
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
return None
@staticmethod
def check_input(widget, text: str, ignore_errors=True):
"""
Check if the input text is in the filtered selection.
Args:
widget: Widget instance.
text(str): Input text.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
Returns:
bool: True if the input text is in the filtered selection.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().check_input(widget=widget, text=text)
if not ignore_errors:
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
return None
@staticmethod
def _find_handler(widget):
"""
Find the appropriate handler for the widget by checking its base classes.
Args:
widget: Widget instance.
Returns:
handler_class: The handler class if found, otherwise None.
"""
for base in type(widget).__mro__:
if base in FilterIO._handlers:
return FilterIO._handlers[base]
return None

View File

@@ -1,182 +0,0 @@
from __future__ import annotations
from decimal import Decimal
from types import NoneType
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ValidationError
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.forms_from_types.items import FormItemSpec, widget_from_type
logger = bec_logger.logger
class TypedForm(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "list_alt"
value_changed = Signal()
RPC = False
def __init__(
self,
parent=None,
items: list[tuple[str, type]] | None = None,
form_item_specs: list[FormItemSpec] | None = None,
client=None,
**kwargs,
):
"""Widget with a list of form items based on a list of types.
Args:
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
Should be a type supported by the logic in items.py
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be
supplied.
"""
if (items is not None and form_item_specs is not None) or (
items is None and form_item_specs is None
):
raise ValueError("Must specify one and only one of items and form_item_specs")
super().__init__(parent=parent, client=client, **kwargs)
self._items = (
form_item_specs
if form_item_specs is not None
else [
FormItemSpec(name=name, item_type=item_type)
for name, item_type in items # type: ignore
]
)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._form_grid_container = QWidget(parent=self)
self._form_grid = QWidget(parent=self._form_grid_container)
self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout())
self.populate()
def populate(self):
self._clear_grid()
for r, item in enumerate(self._items):
self._add_griditem(item, r)
def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout()
label = QLabel(item.name)
label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0)
widget = widget_from_type(item.item_type)(parent=self, spec=item)
widget.valueChanged.connect(self.value_changed)
grid.addWidget(widget, row, 1)
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
grid: QGridLayout = self._form_grid.layout() # type: ignore
return {
grid.itemAtPosition(i, 0)
.widget()
.property("_model_field_name"): grid.itemAtPosition(i, 1)
.widget()
.getValue() # type: ignore # we only add 'DynamicFormItem's here
for i in range(grid.rowCount())
}
def _clear_grid(self):
if (old_layout := self._form_grid.layout()) is not None:
while old_layout.count():
item = old_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
old_layout.deleteLater()
self._form_grid.deleteLater()
self._form_grid = QWidget()
self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid)
self._form_grid.adjustSize()
self._form_grid_container.adjustSize()
self.adjustSize()
def _new_grid_layout(self):
new_grid = QGridLayout()
new_grid.setContentsMargins(0, 0, 0, 0)
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
return new_grid
class PydanticModelForm(TypedForm):
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
def __init__(self, parent=None, metadata_model: type[BaseModel] = None, client=None, **kwargs):
"""
A form generated from a pydantic model.
Args:
metadata_model (type[BaseModel]): the model class for which to generate a form.
"""
self._md_schema = metadata_model
super().__init__(parent=parent, form_item_specs=self._form_item_specs(), client=client)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "Metadata validity" # type: ignore
self._validity.compact_show_popup.setIcon(
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
)
self._validity_message = QLabel("Not yet validated")
self._validity.addWidget(self._validity_message)
self._layout.addWidget(self._validity)
self.value_changed.connect(self.validate_form)
def set_schema(self, schema: type[BaseModel]):
self._md_schema = schema
self.populate()
def _form_item_specs(self):
return [
FormItemSpec(name=name, info=info, item_type=info.annotation)
for name, info in self._md_schema.model_fields.items()
]
def update_items_from_schema(self):
self._items = self._form_item_specs()
def populate(self):
self.update_items_from_schema()
super().populate()
def get_form_data(self):
"""Get the entered metadata as a dict."""
return self._dict_from_grid()
def validate_form(self, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true.
Otherwise, emits on metadata_cleared and returns false."""
try:
metadata_dict = self.get_form_data()
self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
self.metadata_updated.emit(metadata_dict)
return True
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
self.metadata_cleared.emit(None)
return False

View File

@@ -1,286 +0,0 @@
from __future__ import annotations
from abc import abstractmethod
from decimal import Decimal
from types import UnionType
from typing import Callable, Protocol
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ConfigDict, Field
from pydantic.fields import FieldInfo
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QButtonGroup,
QCheckBox,
QDoubleSpinBox,
QGridLayout,
QHBoxLayout,
QLabel,
QLayout,
QLineEdit,
QRadioButton,
QSpinBox,
QToolButton,
QWidget,
)
from bec_widgets.widgets.editors.scan_metadata._util import (
clearable_required,
field_default,
field_limits,
field_maxlen,
field_minlen,
field_precision,
)
logger = bec_logger.logger
class FormItemSpec(BaseModel):
"""
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
to store most annotation info, since one of the main purposes is to store data for
forms genrated from pydantic models, but can also be composed from other sources or by hand.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType
name: str
info: FieldInfo = FieldInfo()
class ClearableBoolEntry(QWidget):
stateChanged = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._layout = QHBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
self._entry = QButtonGroup()
self._true = QRadioButton("true", parent=self)
self._false = QRadioButton("false", parent=self)
for button in [self._true, self._false]:
self._layout.addWidget(button)
self._entry.addButton(button)
button.toggled.connect(self.stateChanged)
def clear(self):
self._entry.setExclusive(False)
self._true.setChecked(False)
self._false.setChecked(False)
self._entry.setExclusive(True)
def isChecked(self) -> bool | None:
if not self._true.isChecked() and not self._false.isChecked():
return None
return self._true.isChecked()
def setChecked(self, value: bool | None):
if value is None:
self.clear()
elif value:
self._true.setChecked(True)
self._false.setChecked(False)
else:
self._true.setChecked(False)
self._false.setChecked(True)
def setToolTip(self, tooltip: str):
self._true.setToolTip(tooltip)
self._false.setToolTip(tooltip)
class DynamicFormItem(QWidget):
valueChanged = Signal()
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent)
self._spec = spec
self._layout = QHBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize)
self._default = field_default(self._spec.info)
self._desc = self._spec.info.description
self.setLayout(self._layout)
self._add_main_widget()
if clearable_required(spec.info):
self._add_clear_button()
@abstractmethod
def getValue(self): ...
@abstractmethod
def setValue(self, value): ...
@abstractmethod
def _add_main_widget(self) -> None:
"""Add the main data entry widget to self._main_widget and appply any
constraints from the field info"""
def _describe(self, pad=" "):
return pad + (self._desc if self._desc else "")
def _add_clear_button(self):
self._clear_button = QToolButton()
self._clear_button.setIcon(
material_icon(icon_name="close", size=(10, 10), convert_to_pixmap=False)
)
self._layout.addWidget(self._clear_button)
# the widget added in _add_main_widget must implement .clear() if value is not required
self._clear_button.setToolTip("Clear value or reset to default.")
self._clear_button.clicked.connect(self._main_widget.clear) # type: ignore
def _value_changed(self, *_, **__):
self.valueChanged.emit()
class StrMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QLineEdit()
self._layout.addWidget(self._main_widget)
min_length, max_length = (field_minlen(self._spec.info), field_maxlen(self._spec.info))
if max_length:
self._main_widget.setMaxLength(max_length)
self._main_widget.setToolTip(
f"(length min: {min_length} max: {max_length}){self._describe()}"
)
if self._default:
self._main_widget.setText(self._default)
self._add_clear_button()
def getValue(self):
if self._main_widget.text() == "":
return self._default
return self._main_widget.text()
def setValue(self, value: str):
if value is None:
self._main_widget.setText("")
self._main_widget.setText(value)
class IntMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QSpinBox()
self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._spec.info, int)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}")
if self._default is not None:
self._main_widget.setValue(self._default)
self._add_clear_button()
else:
self._main_widget.clear()
def getValue(self):
if self._main_widget.text() == "":
return self._default
return self._main_widget.value()
def setValue(self, value: int):
if value is None:
self._main_widget.clear()
self._main_widget.setValue(value)
class FloatDecimalMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QDoubleSpinBox()
self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._spec.info, int)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
precision = field_precision(self._spec.info)
if precision:
self._main_widget.setDecimals(precision)
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
maxstr = f"{float(max_):.3f}" if abs(max_) <= 1000 else f"{float(max_):.3e}"
self._main_widget.setToolTip(f"(range {minstr} to {maxstr}){self._describe()}")
if self._default is not None:
self._main_widget.setValue(self._default)
self._add_clear_button()
else:
self._main_widget.clear()
def getValue(self):
if self._main_widget.text() == "":
return self._default
return self._main_widget.value()
def setValue(self, value: float):
if value is None:
self._main_widget.clear()
self._main_widget.setValue(value)
class BoolMetadataField(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.stateChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
if clearable_required(self._spec.info):
self._main_widget = ClearableBoolEntry()
else:
self._main_widget = QCheckBox()
self._layout.addWidget(self._main_widget)
self._main_widget.setToolTip(self._describe(""))
self._main_widget.setChecked(self._default) # type: ignore # if there is no default then it will be ClearableBoolEntry and can be set with None
def getValue(self):
return self._main_widget.isChecked()
def setValue(self, value):
self._main_widget.setChecked(value)
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
if annotation in [str, str | None]:
return StrMetadataField
if annotation in [int, int | None]:
return IntMetadataField
if annotation in [float, float | None, Decimal, Decimal | None]:
return FloatDecimalMetadataField
if annotation in [bool, bool | None]:
return BoolMetadataField
else:
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
return StrMetadataField
if __name__ == "__main__": # pragma: no cover
class TestModel(BaseModel):
value1: str | None = Field(None)
value2: bool | None = Field(None)
value3: bool = Field(True)
value4: int = Field(123)
value5: int | None = Field()
app = QApplication([])
w = QWidget()
layout = QGridLayout()
w.setLayout(layout)
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
layout.addWidget(QLabel(field_name), i, 0)
layout.addWidget(widget_from_type(info.annotation)(info), i, 1)
w.show()
app.exec()

View File

@@ -1,84 +0,0 @@
"""
This module provides a utility class for counting and reporting frames per second (FPS) in a PyQtGraph application.
Classes:
FPSCounter: A class that monitors the paint events of a `ViewBox` to calculate and emit FPS values.
Usage:
The `FPSCounter` class can be used to monitor the rendering performance of a `ViewBox` in a PyQtGraph application.
It connects to the `ViewBox`'s paint event and calculates the FPS over a specified interval, emitting the FPS value
at regular intervals.
Example:
from qtpy import QtWidgets, QtCore
import pyqtgraph as pg
from fps_counter import FPSCounter
app = pg.mkQApp("FPS Counter Example")
win = pg.GraphicsLayoutWidget()
win.show()
vb = pg.ViewBox()
plot_item = pg.PlotItem(viewBox=vb)
win.addItem(plot_item)
fps_counter = FPSCounter(vb)
fps_counter.sigFpsUpdate.connect(lambda fps: print(f"FPS: {fps:.2f}"))
sys.exit(app.exec_())
"""
from time import perf_counter
import pyqtgraph as pg
from qtpy import QtCore
class FPSCounter(QtCore.QObject):
"""
A utility class for counting and reporting frames per second (FPS).
This class connects to a `ViewBox`'s paint event to count the number of
frames rendered and calculates the FPS over a specified interval. It emits
a signal with the FPS value at regular intervals.
Attributes:
sigFpsUpdate (QtCore.Signal): Signal emitted with the FPS value.
view_box (pg.ViewBox): The `ViewBox` instance to monitor.
"""
sigFpsUpdate = QtCore.Signal(float)
def __init__(self, view_box):
super().__init__()
self.view_box = view_box
self.view_box.sigPaint.connect(self.increment_count)
self.count = 0
self.last_update = perf_counter()
self.timer = QtCore.QTimer()
self.timer.timeout.connect(self.calculate_fps)
self.timer.start(1000)
def increment_count(self):
"""
Increment the frame count when the `ViewBox` is painted.
"""
self.count += 1
def calculate_fps(self):
"""
Calculate the frames per second (FPS) based on the number of frames
"""
now = perf_counter()
elapsed = now - self.last_update
fps = self.count / elapsed if elapsed > 0 else 0.0
self.last_update = now
self.count = 0
self.sigFpsUpdate.emit(fps)
def cleanup(self):
"""
Clean up the FPS counter by stopping the timer and disconnecting the signal.
"""
self.timer.stop()
self.timer.timeout.disconnect(self.calculate_fps)

View File

@@ -1,153 +0,0 @@
import inspect
import os
import re
from typing import NamedTuple
from qtpy.QtCore import QObject
from bec_widgets.utils.name_utils import pascal_to_snake
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
class PluginFilenames(NamedTuple):
register: str
plugin: str
pyproj: str
def plugin_filenames(name: str) -> PluginFilenames:
return PluginFilenames(f"register_{name}.py", f"{name}_plugin.py", f"{name}.pyproject")
class DesignerPluginInfo:
def __init__(self, plugin_class):
self.plugin_class = plugin_class
self.plugin_name_pascal = plugin_class.__name__
self.plugin_name_snake = pascal_to_snake(self.plugin_name_pascal)
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
plugin_module = (
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
)
self.plugin_import = f"from {plugin_module} import {self.plugin_name_pascal}Plugin"
# first sentence / line of the docstring is used as tooltip
self.plugin_tooltip = (
plugin_class.__doc__.split("\n")[0].strip().replace('"', "'")
if plugin_class.__doc__
else self.plugin_name_pascal
)
self.base_path = os.path.dirname(inspect.getfile(plugin_class))
class DesignerPluginGenerator:
def __init__(self, widget: type):
self._excluded = False
self.widget = widget
self.info = DesignerPluginInfo(widget)
if widget.__name__ in EXCLUDED_PLUGINS:
self._excluded = True
return
self.templates: dict[str, str] = {}
self.template_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "plugin_templates"
)
@property
def filenames(self):
return plugin_filenames(self.info.plugin_name_snake)
def run(self, validate=True):
if self._excluded:
print(f"Plugin {self.widget.__name__} is excluded from generation.")
return
if validate:
self._check_class_validity()
self._load_templates()
self._write_templates()
def _check_class_validity(self):
# Check if the widget is a QWidget subclass
if not issubclass(self.widget, QObject):
return
# Check if the widget class has parent as the first argument. This is a strict requirement of Qt!
signature = list(inspect.signature(self.widget.__init__).parameters.values())
if len(signature) == 1 or signature[1].name != "parent":
raise ValueError(
f"Widget class {self.widget.__name__} must have parent as the first argument."
)
base_cls = [val for val in self.widget.__bases__ if issubclass(val, QObject)]
if not base_cls:
raise ValueError(
f"Widget class {self.widget.__name__} must inherit from a QObject subclass."
)
# Check if the widget class calls the super constructor with parent argument
init_source = inspect.getsource(self.widget.__init__)
cls_init_found = (
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
)
super_init_found = (
bool(
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
)
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
super_init_found = (
bool(init_source.find("super().__init__(parent=parent") > 0)
or bool(init_source.find("super().__init__(parent,") > 0)
or bool(init_source.find("super().__init__(parent)") > 0)
)
# for the new style classes, we only have one super call. We can therefore check if the
# number of __init__ calls is 2 (the class itself and the super class)
num_inits = re.findall(r"__init__", init_source)
if len(num_inits) == 2 and not super_init_found:
super_init_found = bool(
init_source.find("super().__init__(parent=parent") > 0
or init_source.find("super().__init__(parent,") > 0
or init_source.find("super().__init__(parent)") > 0
)
if not cls_init_found and not super_init_found:
raise ValueError(
f"Widget class {self.widget.__name__} must call the super constructor with parent."
)
def _write_file(self, name: str, contents: str):
with open(os.path.join(self.info.base_path, name), "w", encoding="utf-8") as f:
f.write(contents)
def _format(self, name: str):
return self.templates[name].format(**self.info.__dict__)
def _write_templates(self):
self._write_file(self.filenames.register, self._format("register"))
self._write_file(self.filenames.plugin, self._format("plugin"))
pyproj = str({"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]})
self._write_file(self.filenames.pyproj, pyproj)
def _load_templates(self):
for file in os.listdir(self.template_path):
if not file.endswith(".template"):
continue
with open(os.path.join(self.template_path, file), "r", encoding="utf-8") as f:
self.templates[file.split(".")[0]] = f.read()
if __name__ == "__main__": # pragma: no cover
# from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.utility.spinner import SpinnerWidget
generator = DesignerPluginGenerator(SpinnerWidget)
generator.run(validate=False)

View File

@@ -1,86 +0,0 @@
"""Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
The class is mainly designed for usage with the BECWaveform and 1D plots."""
from __future__ import annotations
import pyqtgraph as pg
from qtpy.QtCore import QObject, Signal, Slot
from qtpy.QtGui import QColor
class LinearRegionWrapper(QObject):
"""Wrapper class for the LinearRegionItem in pyqtgraph for 1D plots (BECWaveform)
Args:
plot_item (pg.PlotItem): The plot item to add the region selector to.
parent (QObject): The parent object.
color (QColor): The color of the region selector.
hover_color (QColor): The color of the region selector when the mouse is over it.
"""
# Signal with the region tuble (start, end)
region_changed = Signal(tuple)
def __init__(
self, plot_item: pg.PlotItem, color: QColor = None, hover_color: QColor = None, parent=None
):
super().__init__(parent)
self.is_log_x = None
self._edge_width = 2
self.plot_item = plot_item
self.linear_region_selector = pg.LinearRegionItem()
self.proxy = None
self.change_roi_color((color, hover_color))
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
# Slot for changing the color of the region selector (edge and fill)
@Slot(tuple)
def change_roi_color(self, colors: tuple[QColor | str | tuple, QColor | str | tuple]):
"""Change the color and hover color of the region selector.
Hover color means the color when the mouse is over the region.
Args:
colors (tuple): Tuple with the color and hover color
"""
color, hover_color = colors
if color is not None:
self.linear_region_selector.setBrush(pg.mkBrush(color))
if hover_color is not None:
self.linear_region_selector.setHoverBrush(pg.mkBrush(hover_color))
@Slot()
def add_region_selector(self):
"""Add the region selector to the plot item"""
self.plot_item.addItem(self.linear_region_selector)
# Use proxy to limit the update rate of the region change signal to 10Hz
self.proxy = pg.SignalProxy(
self.linear_region_selector.sigRegionChanged,
rateLimit=10,
slot=self._region_change_proxy,
)
@Slot()
def remove_region_selector(self):
"""Remove the region selector from the plot item"""
self.proxy.disconnect()
self.proxy = None
self.plot_item.removeItem(self.linear_region_selector)
def _region_change_proxy(self):
"""Emit the region change signal. If the plot is in log mode, convert the region to log."""
x_low, x_high = self.linear_region_selector.getRegion()
if self.is_log_x:
x_low = 10**x_low
x_high = 10**x_high
self.region_changed.emit((x_low, x_high))
@Slot()
def check_log(self):
"""Check if the plot is in log mode."""
self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked()
self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked()
def cleanup(self):
"""Cleanup the widget."""
self.remove_region_selector()

View File

@@ -1,16 +0,0 @@
import re
def pascal_to_snake(name: str) -> str:
"""
Convert PascalCase to snake_case.
Args:
name (str): The name to be converted.
Returns:
str: The converted name.
"""
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
return s2.lower()

View File

@@ -1,26 +0,0 @@
from enum import IntFlag
try:
from enum import KEEP
class IFBase(IntFlag, boundary=KEEP): ...
except ImportError:
IFBase = IntFlag
class Kind(IFBase):
"""
This is used in the .kind attribute of all OphydObj (Signals, Devices).
A Device examines its components' .kind atttribute to decide whether to
traverse it in read(), read_configuration(), or neither. Additionally, if
decides whether to include its name in `hints['fields']`.
"""
omitted = 0b000
normal = 0b001
config = 0b010
hinted = 0b101 # Notice that bool(hinted & normal) is True.

View File

@@ -1,183 +0,0 @@
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QApplication,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class PaletteViewer(BECWidget, QWidget):
"""
This class is a widget that displays current palette colors.
"""
ICON_NAME = "palette"
RPC = False
def __init__(self, *args, parent=None, **kwargs):
super().__init__(parent=parent, theme_update=True, **kwargs)
self.setFixedSize(400, 600)
layout = QVBoxLayout(self)
dark_mode_button = DarkModeButton(self)
layout.addWidget(dark_mode_button)
# Create a scroll area to hold the color boxes
scroll_area = QScrollArea(self)
scroll_area.setWidgetResizable(True)
scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Create a frame to hold the color boxes
self.frame = QFrame(self)
self.frame_layout = QGridLayout(self.frame)
self.frame_layout.setSpacing(0)
self.frame_layout.setContentsMargins(0, 0, 0, 0)
scroll_area.setWidget(self.frame)
layout.addWidget(scroll_area)
self.setLayout(layout)
self.update_palette()
def apply_theme(self, theme) -> None:
"""
Apply the theme to the widget.
Args:
theme (str): The theme to apply.
"""
self.update_palette()
def clear_palette(self) -> None:
"""
Clear the palette colors from the frame.
Recursively removes all widgets and layouts in the frame layout.
"""
# Iterate over all items in the layout in reverse to safely remove them
for i in reversed(range(self.frame_layout.count())):
item = self.frame_layout.itemAt(i)
# If the item is a layout, clear its contents
if isinstance(item, QHBoxLayout):
# Recursively remove all widgets from the layout
for j in reversed(range(item.count())):
widget = item.itemAt(j).widget()
if widget:
item.removeWidget(widget)
widget.deleteLater()
self.frame_layout.removeItem(item)
# If the item is a widget, remove and delete it
elif item.widget():
widget = item.widget()
self.frame_layout.removeWidget(widget)
widget.deleteLater()
def update_palette(self) -> None:
"""
Update the palette colors in the frame.
"""
self.clear_palette()
palette_label = QLabel("Palette Colors (e.g. palette.windowText().color())")
palette_label.setStyleSheet("font-weight: bold;")
self.frame_layout.addWidget(palette_label, 0, 0)
palette = get_theme_palette()
# Add the palette colors (roles) to the frame
palette_roles = [
palette.windowText,
palette.toolTipText,
palette.placeholderText,
palette.text,
palette.buttonText,
palette.highlight,
palette.link,
palette.light,
palette.midlight,
palette.mid,
palette.shadow,
palette.button,
palette.brightText,
palette.toolTipBase,
palette.alternateBase,
palette.dark,
palette.base,
palette.window,
palette.highlightedText,
palette.linkVisited,
]
offset = 1
for i, pal in enumerate(palette_roles):
i += offset
color = pal().color()
label_layout = QHBoxLayout()
color_label = QLabel(f"{pal().color().name()} ({pal.__name__})")
background_label = self.background_label_with_clipboard(color)
label_layout.addWidget(color_label)
label_layout.addWidget(background_label)
self.frame_layout.addLayout(label_layout, i, 0)
# add a horizontal spacer
spacer = QLabel()
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
self.frame_layout.addWidget(spacer, i + 1, 0)
accent_colors_label = QLabel("Accent Colors (e.g. accent_colors.default)")
accent_colors_label.setStyleSheet("font-weight: bold;")
self.frame_layout.addWidget(accent_colors_label, i + 2, 0)
accent_colors = get_accent_colors()
items = [
(accent_colors.default, "default"),
(accent_colors.success, "success"),
(accent_colors.warning, "warning"),
(accent_colors.emergency, "emergency"),
(accent_colors.highlight, "highlight"),
]
offset = len(palette_roles) + 2
for i, (color, name) in enumerate(items):
i += offset
label_layout = QHBoxLayout()
color_label = QLabel(f"{color.name()} ({name})")
background_label = self.background_label_with_clipboard(color)
label_layout.addWidget(color_label)
label_layout.addWidget(background_label)
self.frame_layout.addLayout(label_layout, i + 2, 0)
def background_label_with_clipboard(self, color) -> QLabel:
"""
Create a label with a background color that copies the color to the clipboard when clicked.
Args:
color (QColor): The color to display in the background.
Returns:
QLabel: The label with the background color.
"""
button = QLabel()
button.setStyleSheet(f"QLabel {{ background-color: {color.name()}; }}")
button.setToolTip("Click to copy color to clipboard")
button.setCursor(Qt.PointingHandCursor)
button.mousePressEvent = lambda event: QApplication.clipboard().setText(color.name())
return button
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
viewer = PaletteViewer()
viewer.show()
sys.exit(app.exec_())

View File

@@ -1,244 +0,0 @@
"""Module to create an arrow item for a pyqtgraph plot"""
import numpy as np
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject, QPointF, Signal, Slot
from bec_widgets.utils.colors import get_accent_colors
logger = bec_logger.logger
class BECIndicatorItem(QObject):
def __init__(self, plot_item: pg.PlotItem = None, parent=None):
super().__init__(parent=parent)
self.accent_colors = get_accent_colors()
self.plot_item = plot_item
self._item_on_plot = False
self._pos = None
self.is_log_x = False
self.is_log_y = False
@property
def item_on_plot(self) -> bool:
"""Returns if the item is on the plot"""
return self._item_on_plot
@item_on_plot.setter
def item_on_plot(self, value: bool) -> None:
self._item_on_plot = value
def add_to_plot(self) -> None:
"""Add the item to the plot"""
raise NotImplementedError("Method add_to_plot not implemented")
def remove_from_plot(self) -> None:
"""Remove the item from the plot"""
raise NotImplementedError("Method remove_from_plot not implemented")
def set_position(self, pos) -> None:
"""This method should implement the logic to set the position of the
item on the plot. Depending on the child class, the position can be
a tuple (x,y) or a single value, i.e. x position where y position is fixed.
"""
raise NotImplementedError("Method set_position not implemented")
def check_log(self):
"""Checks if the x or y axis is in log scale and updates the internal state accordingly."""
self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked()
self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked()
self.set_position(self._pos)
class BECTickItem(BECIndicatorItem):
"""Class to create a tick item which can be added to a pyqtgraph plot.
The tick item will be added to the layout of the plot item and can be used to indicate
a position"""
position_changed = Signal(float)
position_changed_str = Signal(str)
def __init__(self, plot_item: pg.PlotItem = None, parent=None):
super().__init__(plot_item=plot_item, parent=parent)
self.tick_item = pg.TickSliderItem(
parent=parent, allowAdd=False, allowRemove=False, orientation="bottom"
)
self.tick_item.skip_auto_range = True
self.tick = None
self._pos = 0.0
self._range = [0, 1]
@Slot(float)
def set_position(self, pos: float) -> None:
"""Set the x position of the tick item
Args:
pos (float): The position of the tick item.
"""
if self.is_log_x is True:
pos = pos if pos > 0 else 1e-10
pos = np.log10(pos)
self._pos = pos
view_box = self.plot_item.getViewBox() # Ensure you're accessing the correct view box
view_range = view_box.viewRange()[0]
self.update_range(self.plot_item.vb, view_range)
self.position_changed.emit(pos)
self.position_changed_str.emit(str(pos))
@Slot()
def update_range(self, _, view_range: tuple[float, float]) -> None:
"""Update the range of the tick item
Args:
vb (pg.ViewBox): The view box.
viewRange (tuple): The view range.
"""
if self._pos < view_range[0] or self._pos > view_range[1]:
self.tick_item.setVisible(False)
else:
self.tick_item.setVisible(True)
if self.tick_item.isVisible():
origin = self.tick_item.tickSize / 2.0
length = self.tick_item.length
length_with_padding = length + self.tick_item.tickSize + 2
self._range = view_range
tick_with_padding = (self._pos - view_range[0]) / (view_range[1] - view_range[0])
tick_value = (tick_with_padding * length_with_padding - origin) / length
self.tick_item.setTickValue(self.tick, tick_value)
def add_to_plot(self):
"""Add the tick item to the view box or plot item."""
if self.plot_item is None:
return
self.plot_item.layout.addItem(self.tick_item, 2, 1)
self.tick_item.setOrientation("top")
self.tick = self.tick_item.addTick(0, movable=False, color=self.accent_colors.highlight)
self.update_tick_pos_y()
self.plot_item.vb.sigXRangeChanged.connect(self.update_range)
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
self.plot_item.vb.geometryChanged.connect(self.update_tick_pos_y)
self.item_on_plot = True
@Slot()
def update_tick_pos_y(self):
"""Update tick position, while respecting the tick_item coordinates"""
pos = self.tick.pos()
pos = self.tick_item.mapToParent(pos)
new_pos = self.plot_item.vb.geometry().bottom()
new_pos = self.tick_item.mapFromParent(QPointF(pos.x(), new_pos))
self.tick.setPos(new_pos)
def remove_from_plot(self):
"""Remove the tick item from the view box or plot item."""
if self.plot_item is not None and self.item_on_plot is True:
self.plot_item.vb.sigXRangeChanged.disconnect(self.update_range)
self.plot_item.ctrl.logXCheck.checkStateChanged.disconnect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.disconnect(self.check_log)
if self.plot_item.layout is not None:
self.plot_item.layout.removeItem(self.tick_item)
self.item_on_plot = False
def cleanup(self) -> None:
"""Cleanup the item"""
self.remove_from_plot()
self.tick_item = None
class BECArrowItem(BECIndicatorItem):
"""Class to create an arrow item which can be added to a pyqtgraph plot.
It can be either added directly to a view box or a plot item.
To add the arrow item to a view box or plot item, use the add_to_plot method.
Args:
view_box (pg.ViewBox | pg.PlotItem): The view box or plot item to which the arrow item should be added.
parent (QObject): The parent object.
Signals:
position_changed (tuple[float, float]): Signal emitted when the position of the arrow item has changed.
position_changed_str (tuple[str, str]): Signal emitted when the position of the arrow item has changed.
"""
# Signal to emit if the position of the arrow item has changed
position_changed = Signal(tuple)
position_changed_str = Signal(tuple)
def __init__(self, plot_item: pg.PlotItem = None, parent=None):
super().__init__(plot_item=plot_item, parent=parent)
self.arrow_item = pg.ArrowItem()
self.arrow_item.skip_auto_range = True
self._pos = (0, 0)
self.arrow_item.setVisible(False)
@Slot(dict)
def set_style(self, style: dict) -> None:
"""Set the style of the arrow item
Args:
style (dict): The style of the arrow item. Dictionary with key,
value pairs which are accepted from the pg.ArrowItem.setStyle method.
"""
self.arrow_item.setStyle(**style)
@Slot(tuple)
def set_position(self, pos: tuple[float, float]) -> None:
"""Set the position of the arrow item
Args:
pos (tuple): The position of the arrow item as a tuple (x, y).
"""
self._pos = pos
pos_x = pos[0]
pos_y = pos[1]
if self.is_log_x is True:
pos_x = np.log10(pos_x) if pos_x > 0 else 1e-10
view_box = self.plot_item.getViewBox() # Ensure you're accessing the correct view box
view_range = view_box.viewRange()[0]
# Avoid values outside the view range in the negative direction. Otherwise, there is
# a buggy behaviour of the arrow item and it appears at the wrong position.
if pos_x < view_range[0]:
pos_x = view_range[0]
if self.is_log_y is True:
pos_y = np.log10(pos_y) if pos_y > 0 else 1e-10
self.arrow_item.setPos(pos_x, pos_y)
self.position_changed.emit(self._pos)
self.position_changed_str.emit((str(self._pos[0]), str(self._pos[1])))
def add_to_plot(self):
"""Add the arrow item to the view box or plot item."""
if not self.arrow_item:
logger.warning(f"Arrow item was already destroyed, cannot be created")
return
self.arrow_item.setStyle(
angle=-90,
pen=pg.mkPen(self.accent_colors.emergency, width=1),
brush=pg.mkBrush(self.accent_colors.highlight),
headLen=20,
)
self.arrow_item.setVisible(True)
if self.plot_item is not None:
self.plot_item.addItem(self.arrow_item)
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
self.item_on_plot = True
def remove_from_plot(self):
"""Remove the arrow item from the view box or plot item."""
if self.plot_item is not None and self.item_on_plot is True:
self.plot_item.ctrl.logXCheck.checkStateChanged.disconnect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.disconnect(self.check_log)
self.plot_item.removeItem(self.arrow_item)
self.item_on_plot = False
def cleanup(self) -> None:
"""Cleanup the item"""
self.remove_from_plot()
self.arrow_item = None

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
{widget_import}
DOM_XML = """
<ui language='c++'>
<widget class='{plugin_name_pascal}' name='{plugin_name_snake}'>
</widget>
</ui>
"""
class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = {plugin_name_pascal}(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon({plugin_name_pascal}.ICON_NAME)
def includeFile(self):
return "{plugin_name_snake}"
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 "{plugin_name_pascal}"
def toolTip(self):
return "{plugin_tooltip}"
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
{plugin_import}
QPyDesignerCustomWidgetCollection.addCustomWidget({plugin_name_pascal}Plugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,19 +1,8 @@
from __future__ import annotations
import importlib
import inspect
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
def get_plugin_widgets() -> dict[str, BECConnector]:
@@ -49,165 +38,3 @@ def get_plugin_widgets() -> dict[str, BECConnector]:
def _filter_plugins(obj):
return inspect.isclass(obj) and issubclass(obj, BECConnector)
def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
"""
Get all available auto update classes from the plugin directory. AutoUpdates must inherit from AutoUpdate and be
placed in the plugin repository's bec_widgets/auto_updates directory. The entry point for the auto updates is
specified in the respective pyproject.toml file using the following key:
[project.entry-points."bec.widgets.auto_updates"]
plugin_widgets_update = "<beamline_name>.bec_widgets.auto_updates"
e.g.
[project.entry-points."bec.widgets.auto_updates"]
plugin_widgets_update = "pxiii_bec.bec_widgets.auto_updates"
Returns:
dict[str, AutoUpdates]: A dictionary of widget names and their respective classes.
"""
modules = _get_available_plugins("bec.widgets.auto_updates")
loaded_plugins = {}
for module in modules:
mods = inspect.getmembers(module, predicate=_filter_auto_updates)
for name, mod_cls in mods:
if name in loaded_plugins:
print(f"Duplicated auto update {name}.")
loaded_plugins[name] = mod_cls
return loaded_plugins
def _filter_auto_updates(obj):
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
return (
inspect.isclass(obj) and issubclass(obj, AutoUpdates) and not obj.__name__ == "AutoUpdates"
)
@dataclass
class BECClassInfo:
name: str
module: str
file: str
obj: type
is_connector: bool = False
is_widget: bool = False
is_plugin: bool = False
class BECClassContainer:
def __init__(self):
self._collection: list[BECClassInfo] = []
def __repr__(self):
return str(list(cl.name for cl in self.collection))
def __iter__(self):
return self._collection.__iter__()
def add_class(self, class_info: BECClassInfo):
"""
Add a class to the collection.
Args:
class_info(BECClassInfo): The class information
"""
self.collection.append(class_info)
@property
def collection(self):
"""
Get the collection of classes.
"""
return self._collection
@property
def connector_classes(self):
"""
Get all connector classes.
"""
return [info.obj for info in self.collection if info.is_connector]
@property
def top_level_classes(self):
"""
Get all top-level classes.
"""
return [info.obj for info in self.collection if info.is_plugin]
@property
def plugins(self):
"""
Get all plugins. These are all classes that are on the top level and are widgets.
"""
return [info.obj for info in self.collection if info.is_widget and info.is_plugin]
@property
def widgets(self):
"""
Get all widgets. These are all classes inheriting from BECWidget.
"""
return [info.obj for info in self.collection if info.is_widget]
@property
def rpc_top_level_classes(self):
"""
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
"""
return [info.obj for info in self.collection if info.is_plugin and info.is_connector]
@property
def classes(self):
"""
Get all classes.
"""
return [info.obj for info in self.collection]
def get_custom_classes(repo_name: str) -> BECClassContainer:
"""
Get all RPC-enabled classes in the specified repository.
Args:
repo_name(str): The name of the repository.
Returns:
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
collection = BECClassContainer()
anchor_module = importlib.import_module(f"{repo_name}.widgets")
directory = os.path.dirname(anchor_module.__file__)
for root, _, files in sorted(os.walk(directory)):
for file in files:
if not file.endswith(".py") or file.startswith("__"):
continue
path = os.path.join(root, file)
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
if len(subs) == 1 and not subs[0]:
module_name = file.split(".")[0]
else:
module_name = ".".join(subs + [file.split(".")[0]])
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
for name in dir(module):
obj = getattr(module, name)
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue
if isinstance(obj, type):
class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
if issubclass(obj, BECConnector):
class_info.is_connector = True
if issubclass(obj, BECWidget):
class_info.is_widget = True
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
class_info.is_top_level = True
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
class_info.is_plugin = True
collection.add_class(class_info)
return collection

View File

@@ -1,47 +0,0 @@
from bec_lib.serialization import MsgpackSerialization
from bec_lib.utils import lazy_import_from
from qtpy.QtCore import QEventLoop, QSocketNotifier, QTimer
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
class QtRedisMessageWaiter:
def __init__(self, redis_connector, message_to_wait):
self.ev_loop = QEventLoop()
self.response = None
self.connector = redis_connector
self.message_to_wait = message_to_wait
self.pubsub = redis_connector._redis_conn.pubsub()
self.pubsub.subscribe(self.message_to_wait.endpoint)
fd = self.pubsub.connection._sock.fileno()
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
self.notifier.activated.connect(self._pubsub_readable)
def _msg_received(self, msg_obj):
self.response = msg_obj.value
self.ev_loop.quit()
def wait(self, timeout=1):
timer = QTimer()
timer.singleShot(timeout * 1000, self.ev_loop.quit)
self.ev_loop.exec_()
timer.stop()
self.notifier.setEnabled(False)
self.pubsub.close()
return self.response
def _pubsub_readable(self, fd):
while True:
msg = self.pubsub.get_message()
if msg:
if msg["type"] == "subscribe":
# get_message buffers, so we may already have the answer
# let's check...
continue
else:
break
else:
return
channel = msg["channel"].decode()
msg = MessageObject(topic=channel, value=MsgpackSerialization.loads(msg["data"]))
self.connector._execute_callback(self._msg_received, msg, {})

View File

@@ -1,92 +0,0 @@
import os
import sys
from PIL import Image, ImageChops
from qtpy.QtGui import QPixmap
import bec_widgets
REFERENCE_DIR = os.path.join(
os.path.dirname(os.path.dirname(bec_widgets.__file__)), "tests/references"
)
REFERENCE_DIR_FAILURES = os.path.join(
os.path.dirname(os.path.dirname(bec_widgets.__file__)), "tests/reference_failures"
)
def compare_images(image1_path: str, reference_image_path: str):
"""
Load two images and compare them pixel by pixel
Args:
image1_path(str): The path to the first image
reference_image_path(str): The path to the reference image
Raises:
ValueError: If the images are different
"""
image1 = Image.open(image1_path)
image2 = Image.open(reference_image_path)
if image1.size != image2.size:
raise ValueError("Image size has changed")
diff = ImageChops.difference(image1, image2)
if diff.getbbox():
# copy image1 to the reference directory to upload as artifact
os.makedirs(REFERENCE_DIR_FAILURES, exist_ok=True)
image_name = os.path.join(REFERENCE_DIR_FAILURES, os.path.basename(image1_path))
image1.save(image_name)
print(f"Image saved to {image_name}")
raise ValueError("Images are different")
def snap_and_compare(widget: any, output_directory: str, suffix: str = ""):
"""
Save a rendering of a widget and compare it to a reference image
Args:
widget(any): The widget to render
output_directory(str): The directory to save the image to
suffix(str): A suffix to append to the image name
Raises:
ValueError: If the images are different
Examples:
snap_and_compare(widget, tmpdir, suffix="started")
"""
if not isinstance(output_directory, str):
output_directory = str(output_directory)
os_suffix = sys.platform
name = (
f"{widget.__class__.__name__}_{suffix}_{os_suffix}.png"
if suffix
else f"{widget.__class__.__name__}_{os_suffix}.png"
)
# Save the widget to a pixmap
test_image_path = os.path.join(output_directory, name)
pixmap = QPixmap(widget.size())
widget.render(pixmap)
pixmap.save(test_image_path)
try:
reference_path = os.path.join(REFERENCE_DIR, f"{widget.__class__.__name__}")
reference_image_path = os.path.join(reference_path, name)
if not os.path.exists(reference_image_path):
raise ValueError(f"Reference image not found: {reference_image_path}")
compare_images(test_image_path, reference_image_path)
except ValueError:
image = Image.open(test_image_path)
os.makedirs(REFERENCE_DIR_FAILURES, exist_ok=True)
image_name = os.path.join(REFERENCE_DIR_FAILURES, name)
image.save(image_name)
print(f"Image saved to {image_name}")
raise

View File

@@ -1,157 +0,0 @@
import pyqtgraph as pg
from qtpy.QtCore import Property
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class RoundedFrame(QFrame):
"""
A custom QFrame with rounded corners and optional theme updates.
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
"""
def __init__(
self,
parent=None,
content_widget: QWidget = None,
background_color: str = None,
orientation: str = "horizontal",
radius: int = 10,
):
QFrame.__init__(self, parent)
self.background_color = background_color
self._radius = radius
# Apply rounded frame styling
self.setProperty("skip_settings", True)
self.setObjectName("roundedFrame")
# Create a layout for the frame
if orientation == "vertical":
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5)
else:
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
# Add the content widget to the layout
if content_widget:
self.layout.addWidget(content_widget)
# Store reference to the content widget
self.content_widget = content_widget
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
self.apply_plot_widget_style()
def apply_theme(self, theme: str):
"""
Apply the theme to the frame and its content if theme updates are enabled.
"""
if self.content_widget is not None and isinstance(
self.content_widget, pg.GraphicsLayoutWidget
):
self.content_widget.setBackground(self.background_color)
# Update background color based on the theme
if theme == "light":
self.background_color = "#e9ecef" # Subtle contrast for light mode
else:
self.background_color = "#141414" # Dark mode
self.update_style()
@Property(int)
def radius(self):
"""Radius of the rounded corners."""
return self._radius
@radius.setter
def radius(self, value: int):
self._radius = value
self.update_style()
def update_style(self):
"""
Update the style of the frame based on the background color.
"""
if self.background_color:
self.setStyleSheet(
f"""
QFrame#roundedFrame {{
background-color: {self.background_color};
border-radius: {self._radius}; /* Rounded corners */
}}
"""
)
self.apply_plot_widget_style()
def apply_plot_widget_style(self, border: str = "none"):
"""
Automatically apply background, border, and axis styles to the PlotWidget.
Args:
border (str): Border style (e.g., 'none', '1px solid red').
"""
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
# Apply border style via stylesheet
self.content_widget.setStyleSheet(
f"""
GraphicsLayoutWidget {{
border: {border}; /* Explicitly set the border */
}}
"""
)
self.content_widget.setBackground(self.background_color)
class ExampleApp(QWidget): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Rounded Plots Example")
# Main layout
layout = QVBoxLayout(self)
dark_button = DarkModeButton()
# Create PlotWidgets
plot1 = pg.GraphicsLayoutWidget()
plot_item_1 = pg.PlotItem()
plot_item_1.plot([1, 3, 2, 4, 6, 5], pen="r")
plot1.plot_item = plot_item_1
plot2 = pg.GraphicsLayoutWidget()
plot_item_2 = pg.PlotItem()
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
plot2.plot_item = plot_item_2
# Wrap PlotWidgets in RoundedFrame
rounded_plot1 = RoundedFrame(parent=self, content_widget=plot1)
rounded_plot2 = RoundedFrame(parent=self, content_widget=plot2)
# Add to layout
layout.addWidget(dark_button)
layout.addWidget(rounded_plot1)
layout.addWidget(rounded_plot2)
self.setLayout(layout)
from qtpy.QtCore import QTimer
def change_theme():
rounded_plot1.apply_theme("light")
rounded_plot2.apply_theme("dark")
QTimer.singleShot(100, change_theme)
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
window = ExampleApp()
window.show()
app.exec()

View File

@@ -1,277 +0,0 @@
from __future__ import annotations
import functools
import traceback
import types
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable, TypeVar
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication
from redis.exceptions import RedisError
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
if TYPE_CHECKING: # pragma: no cover
from bec_lib import messages
from qtpy.QtCore import QObject
else:
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
T = TypeVar("T")
@contextmanager
def rpc_exception_hook(err_func):
"""This context replaces the popup message box for error display with a specific hook"""
# get error popup utility singleton
popup = ErrorPopupUtility()
# save current setting
old_exception_hook = popup.custom_exception_hook
# install err_func, if it is a callable
# IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook
# of the ErrorPopupUtility (popup instance) class.
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
err_func({"error": popup.get_error_message(exc_type, value, tb)})
popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup)
try:
yield popup
finally:
# restore state of error popup utility singleton
popup.custom_exception_hook = old_exception_hook
class RPCServer:
client: BECClient
def __init__(
self,
gui_id: str,
dispatcher: BECDispatcher | None = None,
client: BECClient | None = None,
config=None,
gui_class_id: str = "bec",
) -> None:
self.status = messages.BECStatus.BUSY
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
self.gui_id = gui_id
# register broadcast callback
self.rpc_register = RPCRegister()
self.rpc_register.add_callback(self.broadcast_registry_update)
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
)
# Setup QTimer for heartbeat
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200)
self._registry_update_callbacks = []
self._broadcasted_data = {}
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
if request_id is None:
logger.error("Received RPC instruction without request_id")
return
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
except Exception:
content = traceback.format_exc()
logger.error(f"Error while executing RPC instruction: {content}")
self.send_response(request_id, False, {"error": content})
else:
logger.debug(f"RPC instruction executed successfully: {res}")
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set_and_publish(
MessageEndpoints.gui_instruction_response(request_id),
messages.RequestResponseMessage(accepted=accepted, message=msg),
expire=60,
)
def get_object_from_config(self, config: dict):
gui_id = config.get("gui_id")
obj = self.rpc_register.get_rpc_by_id(gui_id)
if obj is None:
raise ValueError(f"Object with gui_id {gui_id} not found")
return obj
def run_rpc(self, obj, method, args, kwargs):
# Run with rpc registry broadcast, but only once
with RPCRegister.delayed_broadcast():
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
if not args:
res = method_obj
else:
setattr(obj, method, args[0])
res = None
else:
res = method_obj(*args, **kwargs)
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
elif isinstance(res, dict):
res = {key: self.serialize_object(val) for key, val in res.items()}
else:
res = self.serialize_object(res)
return res
def serialize_object(self, obj: T) -> None | dict | T:
"""
Serialize all BECConnector objects.
Args:
obj: The object to be serialized.
Returns:
None | dict | T: The serialized object or None if the object is not a BECConnector.
"""
if not isinstance(obj, BECConnector):
return obj
# Respect RPC = False
if getattr(obj, "RPC", True) is False:
return None
return self._serialize_bec_connector(obj, wait=True)
def emit_heartbeat(self) -> None:
"""
Emit a heartbeat message to the GUI server.
This method is called periodically to indicate that the server is still running.
"""
logger.trace(f"Emitting heartbeat for {self.gui_id}")
try:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
except RedisError as exc:
logger.error(f"Error while emitting heartbeat: {exc}")
def broadcast_registry_update(self, connections: dict) -> None:
"""
Broadcast the registry update to all the callbacks.
This method is called whenever the registry is updated.
"""
data = {}
for key, val in connections.items():
if not isinstance(val, BECConnector):
continue
if not getattr(val, "RPC", True):
continue
data[key] = self._serialize_bec_connector(val)
if self._broadcasted_data == data:
return
self._broadcasted_data = data
logger.info(f"Broadcasting registry update: {data} for {self.gui_id}")
self.client.connector.xadd(
MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
max_size=1,
)
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:
"""
Create the serialization dict for a single BECConnector.
Args:
connector (BECConnector): The BECConnector to serialize.
wait (bool): If True, wait until the object is registered in the RPC register.
Returns:
dict: The serialized BECConnector object.
"""
config_dict = connector.config.model_dump()
config_dict["parent_id"] = getattr(connector, "parent_id", None)
try:
parent = connector.parent()
if isinstance(parent, BECMainWindow):
container_proxy = parent.gui_id
else:
container_proxy = None
except Exception:
container_proxy = None
if wait:
while not self.rpc_register.object_is_registered(connector):
QApplication.processEvents()
widget_class = getattr(connector, "rpc_widget_class", None)
if not widget_class:
widget_class = connector.__class__.__name__
return {
"gui_id": connector.gui_id,
"object_name": connector.object_name or connector.__class__.__name__,
"widget_class": widget_class,
"config": config_dict,
"container_proxy": container_proxy,
"__rpc__": True,
}
@staticmethod
def _get_becwidget_ancestor(widget: QObject) -> BECConnector | None:
"""
Traverse up the parent chain to find the nearest BECConnector.
Returns None if none is found.
"""
parent = widget.parent()
while parent is not None:
if isinstance(parent, BECConnector):
return parent
parent = parent.parent()
return None
# Suppose clients register callbacks to receive updates
def add_registry_update_callback(self, cb: Callable) -> None:
"""
Add a callback to be called whenever the registry is updated.
The specified callback is called whenever the registry is updated.
Args:
cb (Callable): The callback to be added. It should accept a dictionary of all the
registered RPC objects as an argument.
"""
self._registry_update_callbacks.append(cb)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
logger.info("Succeded in shutting down CLI server")
self.client.shutdown()

View File

@@ -1,44 +0,0 @@
from bec_lib.serialization import msgpack
from qtpy.QtCore import QPointF
def register_serializer_extension():
"""
Register the serializer extension for the BECConnector.
"""
if not module_is_registered("bec_widgets.utils.serialization"):
msgpack.register_object_hook(encode_qpointf, decode_qpointf)
def module_is_registered(module_name: str) -> bool:
"""
Check if the module is registered in the encoder.
Args:
module_name (str): The name of the module to check.
Returns:
bool: True if the module is registered, False otherwise.
"""
# pylint: disable=protected-access
for enc in msgpack._encoder:
if enc[0].__module__ == module_name:
return True
return False
def encode_qpointf(obj):
"""
Encode a QPointF object to a list of floats. As this is mostly used for sending
data to the client, it is not necessary to convert it back to a QPointF object.
"""
if isinstance(obj, QPointF):
return [obj.x(), obj.y()]
return obj
def decode_qpointf(obj):
"""
no-op function since QPointF is encoded as a list of floats.
"""
return obj

View File

@@ -1,145 +0,0 @@
from bec_lib.logger import bec_logger
from PySide6.QtGui import QCloseEvent
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class SettingWidget(QWidget):
"""
Abstract class for a settings widget to enforce the implementation of the accept_changes and display_current_settings.
Can be used for toolbar actions to display the settings of a widget.
Args:
target_widget (QWidget): The widget that the settings will be taken from and applied to.
"""
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self.target_widget = None
def set_target_widget(self, target_widget: QWidget):
self.target_widget = target_widget
@SafeSlot()
def accept_changes(self):
"""
Accepts the changes made in the settings widget and applies them to the target widget.
"""
pass
@SafeSlot(dict)
def display_current_settings(self, config_dict: dict):
"""
Displays the current settings of the target widget in the settings widget.
Args:
config_dict(dict): The current settings of the target widget.
"""
pass
def cleanup(self):
"""
Cleanup the settings widget.
"""
def closeEvent(self, event: QCloseEvent) -> None:
self.cleanup()
return super().closeEvent(event)
class SettingsDialog(QDialog):
"""
Dialog to display and edit the settings of a widget with accept and cancel buttons.
Args:
parent (QWidget): The parent widget of the dialog.
target_widget (QWidget): The widget that the settings will be taken from and applied to.
settings_widget (SettingWidget): The widget that will display the settings.
"""
def __init__(
self,
parent=None,
settings_widget: SettingWidget = None,
window_title: str = "Settings",
config: dict = None,
modal: bool = False,
*args,
**kwargs,
):
super().__init__(parent, *args, **kwargs)
self.setModal(modal)
self.setWindowTitle(window_title)
self.widget = settings_widget
self.widget.set_target_widget(parent)
if config is None:
config = parent.get_config()
self.widget.display_current_settings(config)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.apply_button = QPushButton("Apply")
button_layout = QHBoxLayout()
button_layout.addWidget(self.button_box.button(QDialogButtonBox.Cancel))
button_layout.addWidget(self.apply_button)
button_layout.addWidget(self.button_box.button(QDialogButtonBox.Ok))
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
self.apply_button.clicked.connect(self.apply_changes)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5)
self.layout.addWidget(self.widget)
self.layout.addLayout(button_layout)
ok_button = self.button_box.button(QDialogButtonBox.Ok)
ok_button.setDefault(True)
ok_button.setAutoDefault(True)
@SafeSlot()
def accept(self):
"""
Accept the changes made in the settings widget and close the dialog.
"""
self.widget.accept_changes()
self.cleanup()
super().accept()
@SafeSlot()
def reject(self):
"""
Reject the changes made in the settings widget and close the dialog.
"""
self.cleanup()
super().reject()
@SafeSlot()
def apply_changes(self):
"""
Apply the changes made in the settings widget without closing the dialog.
"""
self.widget.accept_changes()
def cleanup(self):
"""
Cleanup the dialog.
"""
self.button_box.close()
self.button_box.deleteLater()
self.widget.close()
self.widget.deleteLater()
def closeEvent(self, event):
logger.info("Closing settings dialog")
self.cleanup()
super().closeEvent(event)

View File

@@ -1,376 +0,0 @@
import sys
from typing import Literal, Optional
from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation
from qtpy.QtGui import QAction
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QScrollArea,
QSizePolicy,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
class SidePanel(QWidget):
"""
Side panel widget that can be placed on the left, right, top, or bottom of the main widget.
"""
def __init__(
self,
parent=None,
orientation: Literal["left", "right", "top", "bottom"] = "left",
panel_max_width: int = 200,
animation_duration: int = 200,
animations_enabled: bool = True,
):
super().__init__(parent=parent)
self.setProperty("skip_settings", True)
self._orientation = orientation
self._panel_max_width = panel_max_width
self._animation_duration = animation_duration
self._animations_enabled = animations_enabled
self._panel_width = 0
self._panel_height = 0
self.panel_visible = False
self.current_action: Optional[QAction] = None
self.current_index: Optional[int] = None
self.switching_actions = False
self._init_ui()
def _init_ui(self):
"""
Initialize the UI elements.
"""
if self._orientation in ("left", "right"):
self.main_layout = QHBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="vertical")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
self.container.layout.setContentsMargins(0, 0, 0, 0)
self.container.layout.setSpacing(0)
self.stack_widget = QStackedWidget()
self.stack_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.stack_widget.setMinimumWidth(5)
self.stack_widget.setMaximumWidth(self._panel_max_width)
if self._orientation == "left":
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
self.menu_anim = QPropertyAnimation(self, b"panel_width")
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.panel_width = 0 # start hidden
else:
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
self.container.layout.setContentsMargins(0, 0, 0, 0)
self.container.layout.setSpacing(0)
self.stack_widget = QStackedWidget()
self.stack_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.stack_widget.setMinimumHeight(5)
self.stack_widget.setMaximumHeight(self._panel_max_width)
if self._orientation == "top":
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
self.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)
@Property(int)
def panel_width(self):
"""Get the panel width."""
return self._panel_width
@panel_width.setter
def panel_width(self, width: int):
"""Set the panel width."""
self._panel_width = width
if self._orientation in ("left", "right"):
self.stack_widget.setFixedWidth(width)
@Property(int)
def panel_height(self):
"""Get the panel height."""
return self._panel_height
@panel_height.setter
def panel_height(self, height: int):
"""Set the panel height."""
self._panel_height = height
if self._orientation in ("top", "bottom"):
self.stack_widget.setFixedHeight(height)
@Property(int)
def panel_max_width(self):
"""Get the maximum width of the panel."""
return self._panel_max_width
@panel_max_width.setter
def panel_max_width(self, size: int):
"""Set the maximum width of the panel."""
self._panel_max_width = size
if self._orientation in ("left", "right"):
self.stack_widget.setMaximumWidth(self._panel_max_width)
else:
self.stack_widget.setMaximumHeight(self._panel_max_width)
@Property(int)
def animation_duration(self):
"""Get the duration of the animation."""
return self._animation_duration
@animation_duration.setter
def animation_duration(self, duration: int):
"""Set the duration of the animation."""
self._animation_duration = duration
self.menu_anim.setDuration(duration)
@Property(bool)
def animations_enabled(self):
"""Get the status of the animations."""
return self._animations_enabled
@animations_enabled.setter
def animations_enabled(self, enabled: bool):
"""Set the status of the animations."""
self._animations_enabled = enabled
def show_panel(self, idx: int):
"""
Show the side panel with animation and switch to idx.
"""
self.stack_widget.setCurrentIndex(idx)
self.panel_visible = True
self.current_index = idx
if self._orientation in ("left", "right"):
start_val, end_val = 0, self._panel_max_width
else:
start_val, end_val = 0, self._panel_max_width
if self._animations_enabled:
self.menu_anim.stop()
self.menu_anim.setStartValue(start_val)
self.menu_anim.setEndValue(end_val)
self.menu_anim.start()
else:
if self._orientation in ("left", "right"):
self.panel_width = end_val
else:
self.panel_height = end_val
def hide_panel(self):
"""
Hide the side panel with animation.
"""
self.panel_visible = False
self.current_index = None
if self._orientation in ("left", "right"):
start_val, end_val = self._panel_max_width, 0
else:
start_val, end_val = self._panel_max_width, 0
if self._animations_enabled:
self.menu_anim.stop()
self.menu_anim.setStartValue(start_val)
self.menu_anim.setEndValue(end_val)
self.menu_anim.start()
else:
if self._orientation in ("left", "right"):
self.panel_width = end_val
else:
self.panel_height = end_val
def switch_to(self, idx: int):
"""
Switch to the specified index without animation.
"""
if self.current_index != idx:
self.stack_widget.setCurrentIndex(idx)
self.current_index = idx
def add_menu(
self,
action_id: str,
icon_name: str,
tooltip: str,
widget: QWidget,
title: str | None = None,
):
"""
Add a menu to the side panel.
Args:
action_id(str): The ID of the action.
icon_name(str): The name of the icon.
tooltip(str): The tooltip for the action.
widget(QWidget): The widget to add to the panel.
title(str): The title of the panel.
"""
# container_widget: 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)
if title is not None:
title_label = QLabel(f"<b>{title}</b>")
title_label.setStyleSheet("font-size: 16px;")
container_layout.addWidget(title_label)
# Create a QScrollArea for the actual widget to ensure scrolling if the widget inside is too large
scroll_area = QScrollArea()
scroll_area.setFrameShape(QFrame.NoFrame)
scroll_area.setWidgetResizable(True)
# Let the scroll area expand in both directions if there's room
scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
scroll_area.setWidget(widget)
# Put the scroll area in the container layout
container_layout.addWidget(scroll_area)
# Optionally stretch the scroll area to fill vertical space
container_layout.setStretchFactor(scroll_area, 1)
# Add container_widget to the stacked widget
index = self.stack_widget.count()
self.stack_widget.addWidget(container_widget)
# Add an action to the toolbar
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
def on_action_toggled(checked: bool):
if self.switching_actions:
return
if checked:
if self.current_action and self.current_action != action.action:
self.switching_actions = True
self.current_action.setChecked(False)
self.switching_actions = False
self.current_action = action.action
if not self.panel_visible:
self.show_panel(index)
else:
self.switch_to(index)
else:
if self.current_action == action.action:
self.current_action = None
self.hide_panel()
action.action.toggled.connect(on_action_toggled)
############################################
# DEMO APPLICATION
############################################
class ExampleApp(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Side Panel Example")
central_widget = QWidget()
self.setCentralWidget(central_widget)
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 import Waveform
self.plot = Waveform()
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}"))
self.side_panel.add_menu(
action_id="widget1",
icon_name="counter_1",
tooltip="Show Widget 1",
widget=widget1,
title="Widget 1 Panel",
)
widget2 = QWidget()
layout2 = QVBoxLayout(widget2)
layout2.addWidget(QLabel("Short widget 2 content"))
self.side_panel.add_menu(
action_id="widget2",
icon_name="counter_2",
tooltip="Show Widget 2",
widget=widget2,
title="Widget 2 Panel",
)
widget3 = QWidget()
layout3 = QVBoxLayout(widget3)
for i in range(10):
layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
self.side_panel.add_menu(
action_id="widget3",
icon_name="counter_3",
tooltip="Show Widget 3",
widget=widget3,
title="Widget 3 Panel",
)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = ExampleApp()
window.resize(1000, 700)
window.show()
sys.exit(app.exec())

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +1,27 @@
from bec_lib.logger import bec_logger
from qtpy import PYQT6, PYSIDE6
from qtpy import QT_VERSION
from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger
if PYSIDE6:
from PySide6.QtUiTools import QUiLoader
class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance, custom_widgets: dict | None = None):
super().__init__(baseinstance)
self.custom_widgets = custom_widgets or {}
self.baseinstance = baseinstance
def createWidget(self, class_name, parent=None, name=""):
if class_name in self.custom_widgets:
widget = self.custom_widgets[class_name](self.baseinstance)
return widget
return super().createWidget(class_name, self.baseinstance, name)
class UILoader:
"""Universal UI loader for PyQt6 and PySide6."""
"""Universal UI loader for PyQt5, PyQt6, PySide2, and PySide6."""
def __init__(self, parent=None):
self.parent = parent
if QT_VERSION.startswith("5"):
# PyQt5 or PySide2
from qtpy import uic
widgets = get_custom_classes("bec_widgets").classes
self.loader = uic.loadUi
elif QT_VERSION.startswith("6"):
# PyQt6 or PySide6
try:
from PySide6.QtUiTools import QUiLoader
self.custom_widgets = {widget.__name__: widget for widget in widgets}
self.loader = self.load_ui_pyside6
except ImportError:
from PyQt6.uic import loadUi
if PYSIDE6:
self.loader = self.load_ui_pyside6
elif PYQT6:
self.loader = self.load_ui_pyqt6
else:
raise ImportError("No compatible Qt bindings found.")
self.loader = loadUi
def load_ui_pyside6(self, ui_file, parent=None):
"""
@@ -51,8 +33,9 @@ class UILoader:
Returns:
QWidget: The loaded widget.
"""
parent = parent or self.parent
loader = CustomUiLoader(parent, self.custom_widgets)
from PySide6.QtUiTools import QUiLoader
loader = QUiLoader(parent)
file = QFile(ui_file)
if not file.open(QIODevice.ReadOnly):
raise IOError(f"Cannot open file: {ui_file}")
@@ -60,71 +43,6 @@ class UILoader:
file.close()
return widget
def load_ui_pyqt6(self, ui_file, parent=None):
"""
Specific loader for PyQt6 using loadUi.
Args:
ui_file(str): Path to the .ui file.
parent(QWidget): Parent widget.
Returns:
QWidget: The loaded widget.
"""
from PyQt6.uic.Loader.loader import DynamicUILoader
class CustomDynamicUILoader(DynamicUILoader):
def __init__(self, package, custom_widgets: dict = None):
super().__init__(package)
self.custom_widgets = custom_widgets or {}
def _handle_custom_widgets(self, el):
"""Handle the <customwidgets> element."""
def header2module(header):
"""header2module(header) -> string
Convert paths to C++ header files to according Python modules
>>> header2module("foo/bar/baz.h")
'foo.bar.baz'
"""
if header.endswith(".h"):
header = header[:-2]
mpath = []
for part in header.split("/"):
# Ignore any empty parts or those that refer to the current
# directory.
if part not in ("", "."):
if part == "..":
# We should allow this for Python3.
raise SyntaxError(
"custom widget header file name may not contain '..'."
)
mpath.append(part)
return ".".join(mpath)
for custom_widget in el:
classname = custom_widget.findtext("class")
header = custom_widget.findtext("header")
if header:
header = self._translate_bec_widgets_header(header)
self.factory.addCustomWidget(
classname,
custom_widget.findtext("extends") or "QWidget",
header2module(header),
)
def _translate_bec_widgets_header(self, header):
for name, value in self.custom_widgets.items():
if header == DesignerPluginInfo.pascal_to_snake(name):
return value.__module__
return header
return CustomDynamicUILoader("", self.custom_widgets).loadUi(ui_file, parent)
def load_ui(self, ui_file, parent=None):
"""
Universal UI loader method.

View File

@@ -1,9 +1,6 @@
# pylint: disable=no-name-in-module
from __future__ import annotations
from abc import ABC, abstractmethod
import shiboken6 as shb
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
@@ -11,7 +8,6 @@ from qtpy.QtWidgets import (
QDoubleSpinBox,
QLabel,
QLineEdit,
QSlider,
QSpinBox,
QTableWidget,
QTableWidgetItem,
@@ -19,67 +15,43 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
class WidgetHandler(ABC):
"""Abstract base class for all widget handlers."""
@abstractmethod
def get_value(self, widget: QWidget, **kwargs):
def get_value(self, widget: QWidget):
"""Retrieve value from the widget instance."""
@abstractmethod
def set_value(self, widget: QWidget, value):
"""Set a value on the widget instance."""
def connect_change_signal(self, widget: QWidget, slot):
"""
Connect a change signal from this widget to the given slot.
If the widget type doesn't have a known "value changed" signal, do nothing.
slot: a function accepting two arguments (widget, value)
"""
pass
class LineEditHandler(WidgetHandler):
"""Handler for QLineEdit widgets."""
def get_value(self, widget: QLineEdit, **kwargs) -> str:
def get_value(self, widget: QLineEdit) -> str:
return widget.text()
def set_value(self, widget: QLineEdit, value: str) -> None:
widget.setText(value)
def connect_change_signal(self, widget: QLineEdit, slot):
widget.textChanged.connect(lambda text, w=widget: slot(w, text))
class ComboBoxHandler(WidgetHandler):
"""Handler for QComboBox widgets."""
def get_value(self, widget: QComboBox, as_string: bool = False, **kwargs) -> int | str:
if as_string is True:
return widget.currentText()
def get_value(self, widget: QComboBox) -> int:
return widget.currentIndex()
def set_value(self, widget: QComboBox, value: int | str) -> None:
if isinstance(value, str):
value = widget.findText(value)
if isinstance(value, int):
widget.setCurrentIndex(value)
def connect_change_signal(self, widget: QComboBox, slot):
# currentIndexChanged(int) or currentIndexChanged(str) both possible.
# We use currentIndexChanged(int) for a consistent behavior.
widget.currentIndexChanged.connect(lambda idx, w=widget: slot(w, self.get_value(w)))
def set_value(self, widget: QComboBox, value: int) -> None:
widget.setCurrentIndex(value)
class TableWidgetHandler(WidgetHandler):
"""Handler for QTableWidget widgets."""
def get_value(self, widget: QTableWidget, **kwargs) -> list:
def get_value(self, widget: QTableWidget) -> list:
return [
[
widget.item(row, col).text() if widget.item(row, col) else ""
@@ -94,84 +66,39 @@ class TableWidgetHandler(WidgetHandler):
item = QTableWidgetItem(str(cell_value))
widget.setItem(row, col, item)
def connect_change_signal(self, widget: QTableWidget, slot):
# If desired, we could connect cellChanged(row, col) and then fetch all data.
# This might be noisy if table is large.
# For demonstration, connect cellChanged to update entire table value.
def on_cell_changed(row, col, w=widget):
val = self.get_value(w)
slot(w, val)
widget.cellChanged.connect(on_cell_changed)
class SpinBoxHandler(WidgetHandler):
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
def get_value(self, widget: QSpinBox | QDoubleSpinBox, **kwargs):
def get_value(self, widget):
return widget.value()
def set_value(self, widget: QSpinBox | QDoubleSpinBox, value):
def set_value(self, widget, value):
widget.setValue(value)
def connect_change_signal(self, widget: QSpinBox | QDoubleSpinBox, slot):
widget.valueChanged.connect(lambda val, w=widget: slot(w, val))
class CheckBoxHandler(WidgetHandler):
"""Handler for QCheckBox widgets."""
def get_value(self, widget: QCheckBox, **kwargs):
def get_value(self, widget):
return widget.isChecked()
def set_value(self, widget: QCheckBox, value):
def set_value(self, widget, value):
widget.setChecked(value)
def connect_change_signal(self, widget: QCheckBox, slot):
widget.toggled.connect(lambda val, w=widget: slot(w, val))
class SlideHandler(WidgetHandler):
"""Handler for QCheckBox widgets."""
def get_value(self, widget: QSlider, **kwargs):
return widget.value()
def set_value(self, widget: QSlider, value):
widget.setValue(value)
def connect_change_signal(self, widget: QSlider, slot):
widget.valueChanged.connect(lambda val, w=widget: slot(w, val))
class ToggleSwitchHandler(WidgetHandler):
"""Handler for ToggleSwitch widgets."""
def get_value(self, widget: ToggleSwitch, **kwargs):
return widget.checked
def set_value(self, widget: ToggleSwitch, 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."""
def get_value(self, widget: QLabel, **kwargs):
def get_value(self, widget):
return widget.text()
def set_value(self, widget: QLabel, value):
def set_value(self, widget, value):
widget.setText(value)
# QLabel typically doesn't have user-editable changes. No signal to connect.
# If needed, this can remain empty.
class WidgetIO:
"""Public interface for getting, setting values and connecting signals using handler mapping"""
"""Public interface for getting and setting values using handler mapping"""
_handlers = {
QLineEdit: LineEditHandler,
@@ -181,12 +108,10 @@ class WidgetIO:
QDoubleSpinBox: SpinBoxHandler,
QCheckBox: CheckBoxHandler,
QLabel: LabelHandler,
ToggleSwitch: ToggleSwitchHandler,
QSlider: SlideHandler,
}
@staticmethod
def get_value(widget, ignore_errors=False, **kwargs):
def get_value(widget, ignore_errors=False):
"""
Retrieve value from the widget instance.
@@ -194,9 +119,9 @@ class WidgetIO:
widget: Widget instance.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = WidgetIO._find_handler(widget)
handler_class = WidgetIO._handlers.get(type(widget))
if handler_class:
return handler_class().get_value(widget, **kwargs) # Instantiate the handler
return handler_class().get_value(widget) # Instantiate the handler
if not ignore_errors:
raise ValueError(f"No handler for widget type: {type(widget)}")
return None
@@ -211,59 +136,12 @@ class WidgetIO:
value: Value to set.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = WidgetIO._find_handler(widget)
handler_class = WidgetIO._handlers.get(type(widget))
if handler_class:
handler_class().set_value(widget, value) # Instantiate the handler
elif not ignore_errors:
raise ValueError(f"No handler for widget type: {type(widget)}")
@staticmethod
def connect_widget_change_signal(widget, slot):
"""
Connect the widget's value-changed signal to a generic slot function (widget, value).
This now delegates the logic to the widget's handler.
"""
handler_class = WidgetIO._find_handler(widget)
if handler_class:
handler = handler_class()
handler.connect_change_signal(widget, slot)
@staticmethod
def check_and_adjust_limits(spin_box: QDoubleSpinBox, number: float):
"""
Check if the new limits are within the current limits, if not adjust the limits.
Args:
number(float): The new value to check against the limits.
"""
min_value = spin_box.minimum()
max_value = spin_box.maximum()
# Calculate the new limits
new_limit = number + 5 * number
if number < min_value:
spin_box.setMinimum(new_limit)
elif number > max_value:
spin_box.setMaximum(new_limit)
@staticmethod
def _find_handler(widget):
"""
Find the appropriate handler for the widget by checking its base classes.
Args:
widget: Widget instance.
Returns:
handler_class: The handler class if found, otherwise None.
"""
for base in type(widget).__mro__:
if base in WidgetIO._handlers:
return WidgetIO._handlers[base]
return None
################## for exporting and importing widget hierarchies ##################
@@ -276,162 +154,39 @@ class WidgetHierarchy:
grab_values: bool = False,
prefix: str = "",
exclude_internal_widgets: bool = True,
only_bec_widgets: bool = False,
show_parent: bool = True,
) -> None:
"""
Print the widget hierarchy to the console.
Args:
widget: Widget to print the hierarchy of.
widget: Widget to print the hierarchy of
indent(int, optional): Level of indentation.
grab_values(bool,optional): Whether to grab the values of the widgets.
prefix(str,optional): Custom string prefix for indentation.
prefix(stc,optional): Custom string prefix for indentation.
exclude_internal_widgets(bool,optional): Whether to exclude internal widgets (e.g. QComboBox in PyQt6).
only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget.
show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget.
"""
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.plots.waveform.waveform import Waveform
widget_info = f"{widget.__class__.__name__} ({widget.objectName()})"
if grab_values:
value = WidgetIO.get_value(widget, ignore_errors=True)
value_str = f" [value: {value}]" if value is not None else ""
widget_info += value_str
# 1) Filter out widgets that are not BECConnectors (if 'only_bec_widgets' is True)
is_bec = isinstance(widget, BECConnector)
if only_bec_widgets and not is_bec:
return
# 2) Determine and print the parent's info (closest BECConnector)
parent_info = ""
if show_parent and is_bec:
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
if ancestor:
parent_label = ancestor.objectName() or ancestor.__class__.__name__
parent_info = f" parent={parent_label}"
else:
parent_info = " parent=None"
widget_info = f"{widget.__class__.__name__} ({widget.objectName()}){parent_info}"
print(prefix + widget_info)
# 3) If it's a Waveform, explicitly print the curves
if isinstance(widget, Waveform):
for curve in widget.curves:
curve_prefix = prefix + " └─ "
print(
f"{curve_prefix}{curve.__class__.__name__} ({curve.objectName()}) "
f"parent={widget.objectName()}"
)
# 4) Recursively handle each child if:
# - It's a QWidget
# - It is a BECConnector (or we don't care about filtering)
# - Its closest BECConnector parent is the current widget
for child in widget.findChildren(QWidget):
if only_bec_widgets and not isinstance(child, BECConnector):
children = widget.children()
for child in children:
if (
exclude_internal_widgets
and isinstance(widget, QComboBox)
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
):
continue
# if WidgetHierarchy._get_becwidget_ancestor(child) == widget:
child_prefix = prefix + " └─ "
child_prefix = prefix + " "
arrow = "├─ " if child != children[-1] else "└─ "
WidgetHierarchy.print_widget_hierarchy(
child,
indent=indent + 1,
grab_values=grab_values,
prefix=child_prefix,
exclude_internal_widgets=exclude_internal_widgets,
only_bec_widgets=only_bec_widgets,
show_parent=show_parent,
child, indent + 1, grab_values, prefix=child_prefix + arrow
)
@staticmethod
def print_becconnector_hierarchy_from_app():
"""
Enumerate ALL BECConnector objects in the QApplication.
Also detect if a widget is a PlotBase, and add any data items
(PlotDataItem-like) that are also BECConnector objects.
Build a parent->children graph where each child's 'parent'
is its closest BECConnector ancestor. Print the entire hierarchy
from the root(s).
The result is a single, consolidated tree for your entire
running GUI, including PlotBase data items that are BECConnector.
"""
import sys
from collections import defaultdict
from qtpy.QtWidgets import QApplication
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.plots.plot_base import PlotBase
# 1) Gather ALL QWidget-based BECConnector objects
all_qwidgets = QApplication.allWidgets()
bec_widgets = set(w for w in all_qwidgets if isinstance(w, BECConnector))
# 2) Also gather any BECConnector-based data items from PlotBase widgets
for w in all_qwidgets:
if isinstance(w, PlotBase) and hasattr(w, "plot_item"):
plot_item = w.plot_item
if hasattr(plot_item, "listDataItems"):
for data_item in plot_item.listDataItems():
if isinstance(data_item, BECConnector):
bec_widgets.add(data_item)
# 3) Build a map of (closest BECConnector parent) -> list of children
parent_map = defaultdict(list)
for w in bec_widgets:
parent_bec = WidgetHierarchy._get_becwidget_ancestor(w)
parent_map[parent_bec].append(w)
# 4) Define a recursive printer to show each object's children
def print_tree(parent, prefix=""):
children = parent_map[parent]
for i, child in enumerate(children):
connector_class = child.__class__.__name__
connector_name = child.objectName() or connector_class
if parent is None:
parent_label = "None"
else:
parent_label = parent.objectName() or parent.__class__.__name__
line = f"{connector_class} ({connector_name}) parent={parent_label}"
# Determine tree-branch symbols
is_last = i == len(children) - 1
branch_str = "└─ " if is_last else "├─ "
print(prefix + branch_str + line)
# Recurse deeper
next_prefix = prefix + (" " if is_last else "")
print_tree(child, prefix=next_prefix)
# 5) Print top-level items (roots) whose BECConnector parent is None
roots = parent_map[None]
for r_i, root in enumerate(roots):
root_class = root.__class__.__name__
root_name = root.objectName() or root_class
line = f"{root_class} ({root_name}) parent=None"
is_last_root = r_i == len(roots) - 1
print(line)
# Recurse into its children
print_tree(root, prefix=" ")
@staticmethod
def _get_becwidget_ancestor(widget):
"""
Traverse up the parent chain to find the nearest BECConnector.
Returns None if none is found.
"""
from bec_widgets.utils import BECConnector
if not shb.isValid(widget):
return None
parent = widget.parent()
while parent is not None:
if isinstance(parent, BECConnector):
return parent
parent = parent.parent()
return None
@staticmethod
def export_config_to_dict(
widget: QWidget,
@@ -512,8 +267,8 @@ class WidgetHierarchy:
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
# Example usage
def hierarchy_example(): # pragma: no cover
# Example application to demonstrate the usage of the functions
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
# Create instance of WidgetHierarchy
@@ -568,37 +323,3 @@ def hierarchy_example(): # pragma: no cover
print(f"Config dict new REDUCED: {config_dict_new_reduced}")
app.exec()
def widget_io_signal_example(): # pragma: no cover
app = QApplication([])
main_widget = QWidget()
layout = QVBoxLayout(main_widget)
line_edit = QLineEdit(main_widget)
combo_box = QComboBox(main_widget)
spin_box = QSpinBox(main_widget)
combo_box.addItems(["Option 1", "Option 2", "Option 3"])
layout.addWidget(line_edit)
layout.addWidget(combo_box)
layout.addWidget(spin_box)
main_widget.show()
def universal_slot(w, val):
print(f"Widget {w.objectName() or w} changed, new value: {val}")
# Connect all supported widgets through their handlers
WidgetIO.connect_widget_change_signal(line_edit, universal_slot)
WidgetIO.connect_widget_change_signal(combo_box, universal_slot)
WidgetIO.connect_widget_change_signal(spin_box, universal_slot)
app.exec_()
if __name__ == "__main__": # pragma: no cover
# Change example function to test different scenarios
# hierarchy_example()
widget_io_signal_example()

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

@@ -6,7 +6,7 @@ import yaml
from qtpy.QtWidgets import QFileDialog
def load_yaml_gui(instance) -> Union[dict, None]:
def load_yaml(instance) -> Union[dict, None]:
"""
Load YAML file from disk.
@@ -20,25 +20,12 @@ def load_yaml_gui(instance) -> Union[dict, None]:
file_path, _ = QFileDialog.getOpenFileName(
instance, "Load Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
)
config = load_yaml(file_path)
return config
def load_yaml(file_path: str) -> Union[dict, None]:
"""
Load YAML file from disk.
Args:
file_path(str): Path to the YAML file.
Returns:
dict: Configuration data loaded from the YAML file.
"""
if not file_path:
return None
try:
with open(file_path, "r") as file:
config = yaml.load(file, Loader=yaml.FullLoader)
config = yaml.safe_load(file)
return config
except FileNotFoundError:
@@ -51,7 +38,7 @@ def load_yaml(file_path: str) -> Union[dict, None]:
print(f"An error occurred while loading the settings from {file_path}: {e}")
def save_yaml_gui(instance, config: dict) -> None:
def save_yaml(instance, config: dict) -> None:
"""
Save YAML file to disk.
@@ -64,17 +51,6 @@ def save_yaml_gui(instance, config: dict) -> None:
instance, "Save Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
)
save_yaml(file_path, config)
def save_yaml(file_path: str, config: dict) -> None:
"""
Save YAML file to disk.
Args:
file_path(str): Path to the YAML file.
config(dict): Configuration data to be saved.
"""
if not file_path:
return
try:

View File

@@ -0,0 +1,5 @@
from .buttons import StopButton
from .dock import BECDock, BECDockArea
from .figure import BECFigure, FigureConfig
from .scan_control import ScanControl
from .spiral_progress_bar import SpiralProgressBar

View File

@@ -0,0 +1 @@
from .stop_button.stop_button import StopButton

View File

@@ -0,0 +1,32 @@
from qtpy.QtWidgets import QPushButton
from bec_widgets.utils import BECConnector
class StopButton(BECConnector, QPushButton):
"""A button that stops the current scan."""
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
QPushButton.__init__(self, parent=parent)
self.get_bec_shortcuts()
self.setText("Stop")
self.setStyleSheet("background-color: #cc181e; color: white")
self.clicked.connect(self.stop_scan)
def stop_scan(self):
"""Stop the scan."""
self.queue.request_scan_abortion()
self.queue.request_queue_reset()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = StopButton()
widget.show()
sys.exit(app.exec_())

View File

@@ -1,364 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import ScanStatusMessage
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
logger = bec_logger.logger
class AutoUpdates(BECMainWindow):
_default_dock: BECDock
USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"]
RPC = True
# enforce that subclasses have the same rpc widget class
rpc_widget_class = "AutoUpdates"
def __init__(
self, parent=None, gui_id: str = None, window_title="Auto Update", *args, **kwargs
):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.dock_area = BECDockArea(parent=self, object_name="dock_area")
self.setCentralWidget(self.dock_area)
self._auto_update_selected_device: str | None = None
self._default_dock = None # type:ignore
self.current_widget: BECWidget | None = None
self.dock_name = None
self._enabled = True
self.start_auto_update()
def start_auto_update(self):
"""
Establish all connections for the auto updates.
"""
self.bec_dispatcher.connect_slot(self._on_scan_status, MessageEndpoints.scan_status())
def stop_auto_update(self):
"""
Disconnect all connections for the auto updates.
"""
self.bec_dispatcher.disconnect_slot(
self._on_scan_status, MessageEndpoints.scan_status() # type:ignore
)
@property
def selected_device(self) -> str | None:
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
return self._auto_update_selected_device
@selected_device.setter
def selected_device(self, value: str | None) -> None:
"""
Set the selected device in the auto update config.
Args:
value(str): The selected device.
"""
self._auto_update_selected_device = value
@SafeSlot()
def _on_scan_status(self, content: dict, metadata: dict) -> None:
"""
Callback for scan status messages.
"""
msg = ScanStatusMessage(**content, metadata=metadata)
if not self.enabled:
return
self.enable_gui_highlights(True)
match msg.status:
case "open":
self.on_scan_open(msg)
case "closed":
self.on_scan_closed(msg)
case ["aborted", "halted"]:
self.on_scan_abort(msg)
case _:
pass
def start_default_dock(self):
"""
Create a default dock for the auto updates.
"""
self.dock_name = "update_dock"
self._default_dock = self.dock_area.new(self.dock_name)
self.current_widget = self._default_dock.new("Waveform")
@overload
def set_dock_to_widget(self, widget: Literal["Waveform"]) -> Waveform: ...
@overload
def set_dock_to_widget(self, widget: Literal["Image"]) -> Image: ...
@overload
def set_dock_to_widget(self, widget: Literal["ScatterWaveform"]) -> ScatterWaveform: ...
@overload
def set_dock_to_widget(self, widget: Literal["MotorMap"]) -> MotorMap: ...
@overload
def set_dock_to_widget(self, widget: Literal["MultiWaveform"]) -> MultiWaveform: ...
def set_dock_to_widget(
self,
widget: Literal["Waveform", "Image", "ScatterWaveform", "MotorMap", "MultiWaveForm"] | str,
) -> BECWidget:
"""
Set the dock to the widget.
Args:
widget (str): The widget to set the dock to. Must be the name of a valid widget class.
Returns:
BECWidget: The widget that was set.
"""
if self._default_dock is None or self.current_widget is None:
logger.warning(
f"Auto Updates: No default dock found. Creating a new one with name {self.dock_name}"
)
self.start_default_dock()
assert self.current_widget is not None
if not self.current_widget.__class__.__name__ == widget:
self._default_dock.delete(self.current_widget.object_name)
self.current_widget = self._default_dock.new(widget)
return self.current_widget
def get_selected_device(
self, monitored_devices, selected_device: str | None = None
) -> str | None:
"""
Get the selected device for the plot. If no device is selected, the first
device in the monitored devices list is selected.
"""
if selected_device is None:
selected_device = self.selected_device
if selected_device:
return selected_device
if len(monitored_devices) > 0:
sel_device = monitored_devices[0]
return sel_device
return None
def enable_gui_highlights(self, enable: bool) -> None:
"""
Enable or disable GUI highlights.
Args:
enable (bool): Whether to enable or disable the highlights.
"""
if enable:
title = self.dock_area.window().windowTitle()
if " [Auto Updates]" in title:
return
self.dock_area.window().setWindowTitle(f"{title} [Auto Updates]")
else:
title = self.dock_area.window().windowTitle()
self.dock_area.window().setWindowTitle(title.replace(" [Auto Updates]", ""))
@property
def enabled(self) -> bool:
"""
Get the enabled status of the auto updates.
"""
return self._enabled
@enabled.setter
def enabled(self, value: bool) -> None:
"""
Set the enabled status of the auto updates.
"""
if self._enabled == value:
return
self._enabled = value
if value:
self.start_auto_update()
self.enable_gui_highlights(True)
self.on_start()
else:
self.stop_auto_update()
self.enable_gui_highlights(False)
self.on_stop()
def cleanup(self) -> None:
"""
Cleanup procedure to run when the auto updates are disabled.
"""
self.enabled = False
self.stop_auto_update()
self.dock_area.close()
self.dock_area.deleteLater()
self.dock_area = None
super().cleanup()
########################################################################
################# Update Functions #####################################
########################################################################
def simple_line_scan(self, info: ScanStatusMessage) -> None:
"""
Simple line scan.
Args:
info (ScanStatusMessage): The scan status message.
"""
# Set the dock to the waveform widget
wf = self.set_dock_to_widget("Waveform")
# Get the scan report devices reported by the scan
dev_x = info.scan_report_devices[0] # type:ignore
# For the y axis, get the selected device
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
if not dev_y:
return
# Clear the waveform widget and plot the data
# with the scan number and device names
# as the label and title
wf.clear_all()
wf.plot(
x_name=dev_x,
y_name=dev_y,
label=f"Scan {info.scan_number} - {dev_y}",
title=f"Scan {info.scan_number}",
x_label=dev_x,
y_label=dev_y,
)
logger.info(
f"Auto Update [simple_line_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}"
)
def simple_grid_scan(self, info: ScanStatusMessage) -> None:
"""
Simple grid scan.
Args:
info (ScanStatusMessage): The scan status message.
"""
# Set the dock to the scatter waveform widget
scatter = self.set_dock_to_widget("ScatterWaveform")
# Get the scan report devices reported by the scan
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type:ignore
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
if None in (dev_x, dev_y, dev_z):
return
# Clear the scatter waveform widget and plot the data
scatter.clear_all()
scatter.plot(
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
)
logger.info(
f"Auto Update [simple_grid_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}, z_name={dev_z}"
)
def best_effort(self, info: ScanStatusMessage) -> None:
"""
Best effort scan.
Args:
info (ScanStatusMessage): The scan status message.
"""
# If the scan report devices are empty, there is nothing we can do
if not info.scan_report_devices:
return
dev_x = info.scan_report_devices[0] # type:ignore
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
if not dev_y:
return
# Set the dock to the waveform widget
wf = self.set_dock_to_widget("Waveform")
# Clear the waveform widget and plot the data
wf.clear_all()
wf.plot(
x_name=dev_x,
y_name=dev_y,
label=f"Scan {info.scan_number} - {dev_y}",
title=f"Scan {info.scan_number}",
x_label=dev_x,
y_label=dev_y,
)
logger.info(f"Auto Update [best_effort]: Started plot with: x_name={dev_x}, y_name={dev_y}")
#######################################################################
################# GUI Callbacks #######################################
#######################################################################
def on_start(self) -> None:
"""
Procedure to run when the auto updates are enabled.
"""
self.start_default_dock()
def on_stop(self) -> None:
"""
Procedure to run when the auto updates are disabled.
"""
def on_scan_open(self, msg: ScanStatusMessage) -> None:
"""
Procedure to run when a scan starts.
Args:
msg (ScanStatusMessage): The scan status message.
"""
if msg.scan_name == "line_scan" and msg.scan_report_devices:
return self.simple_line_scan(msg)
if msg.scan_name == "grid_scan" and msg.scan_report_devices:
return self.simple_grid_scan(msg)
if msg.scan_report_devices:
return self.best_effort(msg)
return None
def on_scan_closed(self, msg: ScanStatusMessage) -> None:
"""
Procedure to run when a scan ends.
Args:
msg (ScanStatusMessage): The scan status message.
"""
def on_scan_abort(self, msg: ScanStatusMessage) -> None:
"""
Procedure to run when a scan is aborted.
Args:
msg (ScanStatusMessage): The scan status message.
"""

View File

@@ -1,439 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
from bec_lib.logger import bec_logger
from pydantic import Field
from pyqtgraph.dockarea import Dock, DockLabel
from qtpy import QtCore, QtGui
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
class DockConfig(ConnectionConfig):
widgets: dict[str, Any] = Field({}, description="The widgets in the dock.")
position: Literal["bottom", "top", "left", "right", "above", "below"] = Field(
"bottom", description="The position of the dock."
)
parent_dock_area: Optional[str] | None = Field(
None, description="The GUI ID of parent dock area of the dock."
)
class CustomDockLabel(DockLabel):
def __init__(self, text: str, closable: bool = True):
super().__init__(text, closable)
if closable:
red_icon = QtGui.QIcon()
pixmap = QtGui.QPixmap(32, 32)
pixmap.fill(QtCore.Qt.GlobalColor.red)
painter = QtGui.QPainter(pixmap)
pen = QtGui.QPen(QtCore.Qt.GlobalColor.white)
pen.setWidth(2)
painter.setPen(pen)
painter.drawLine(8, 8, 24, 24)
painter.drawLine(24, 8, 8, 24)
painter.end()
red_icon.addPixmap(pixmap)
self.closeButton.setIcon(red_icon)
def updateStyle(self):
r = "3px"
if self.dim:
fg = "#aaa"
bg = "#44a"
border = "#339"
else:
fg = "#fff"
bg = "#3f4042"
border = "#3f4042"
if self.orientation == "vertical":
self.vStyle = """DockLabel {
background-color : %s;
color : %s;
border-top-right-radius: 0px;
border-top-left-radius: %s;
border-bottom-right-radius: 0px;
border-bottom-left-radius: %s;
border-width: 0px;
border-right: 2px solid %s;
padding-top: 3px;
padding-bottom: 3px;
font-size: %s;
}""" % (
bg,
fg,
r,
r,
border,
self.fontSize,
)
self.setStyleSheet(self.vStyle)
else:
self.hStyle = """DockLabel {
background-color : %s;
color : %s;
border-top-right-radius: %s;
border-top-left-radius: %s;
border-bottom-right-radius: 0px;
border-bottom-left-radius: 0px;
border-width: 0px;
border-bottom: 2px solid %s;
padding-left: 3px;
padding-right: 3px;
font-size: %s;
}""" % (
bg,
fg,
r,
r,
border,
self.fontSize,
)
self.setStyleSheet(self.hStyle)
class BECDock(BECWidget, Dock):
ICON_NAME = "widgets"
USER_ACCESS = [
"_config_dict",
"element_list",
"elements",
"new",
"show",
"hide",
"show_title_bar",
"set_title",
"hide_title_bar",
"available_widgets",
"delete",
"delete_all",
"remove",
"attach",
"detach",
]
def __init__(
self,
parent: QWidget | None = None,
parent_dock_area: BECDockArea | None = None,
config: DockConfig | None = None,
name: str | None = None,
object_name: str | None = None,
client=None,
gui_id: str | None = None,
closable: bool = True,
**kwargs,
) -> None:
if config is None:
config = DockConfig(
widget_class=self.__class__.__name__,
parent_dock_area=parent_dock_area.gui_id if parent_dock_area else None,
)
else:
if isinstance(config, dict):
config = DockConfig(**config)
self.config = config
label = CustomDockLabel(text=name, closable=closable)
super().__init__(
parent=parent_dock_area,
name=name,
object_name=object_name,
client=client,
gui_id=gui_id,
config=config,
label=label,
**kwargs,
)
self.parent_dock_area = parent_dock_area
# Layout Manager
self.layout_manager = GridLayoutManager(self.layout)
def dropEvent(self, event):
source = event.source()
old_area = source.area
self.setOrientation("horizontal", force=True)
super().dropEvent(event)
if old_area in self.orig_area.tempAreas and old_area != self.orig_area:
self.orig_area.removeTempArea(old_area)
old_area.window().deleteLater()
def float(self):
"""
Float the dock.
Overwrites the default pyqtgraph dock float.
"""
# need to check if the dock is temporary and if it is the only dock in the area
# fixes bug in pyqtgraph detaching
if self.area.temporary == True and len(self.area.docks) <= 1:
return
elif self.area.temporary == True and len(self.area.docks) > 1:
self.area.docks.pop(self.name(), None)
super().float()
else:
super().float()
@property
def elements(self) -> dict[str, BECWidget]:
"""
Get the widgets in the dock.
Returns:
widgets(dict): The widgets in the dock.
"""
# pylint: disable=protected-access
return dict((widget.object_name, widget) for widget in self.element_list)
@property
def element_list(self) -> list[BECWidget]:
"""
Get the widgets in the dock.
Returns:
widgets(list): The widgets in the dock.
"""
return self.widgets
def hide_title_bar(self):
"""
Hide the title bar of the dock.
"""
# self.hideTitleBar() #TODO pyqtgraph looks bugged ATM, doing my implementation
self.label.hide()
self.labelHidden = True
def show(self):
"""
Show the dock.
"""
super().show()
self.show_title_bar()
def hide(self):
"""
Hide the dock.
"""
self.hide_title_bar()
super().hide()
def show_title_bar(self):
"""
Hide the title bar of the dock.
"""
# self.showTitleBar() #TODO pyqtgraph looks bugged ATM, doing my implementation
self.label.show()
self.labelHidden = False
def set_title(self, title: str):
"""
Set the title of the dock.
Args:
title(str): The title of the dock.
"""
self.orig_area.docks[title] = self.orig_area.docks.pop(self.name())
self.setTitle(title)
def get_widgets_positions(self) -> dict:
"""
Get the positions of the widgets in the dock.
Returns:
dict: The positions of the widgets in the dock as dict -> {(row, col, rowspan, colspan):widget}
"""
return self.layout_manager.get_widgets_positions()
def available_widgets(
self,
) -> list: # TODO can be moved to some util mixin like container class for rpc widgets
"""
List all widgets that can be added to the dock.
Returns:
list: The list of eligible widgets.
"""
return list(widget_handler.widget_classes.keys())
def _get_list_of_widget_name_of_parent_dock_area(self) -> list[str]:
if (docks := self.parent_dock_area.panel_list) is None:
return []
widgets = []
for dock in docks:
widgets.extend(dock.elements.keys())
return widgets
@SafeSlot(popup_error=True)
def new(
self,
widget: BECWidget | str,
name: str | None = None,
row: int | None = None,
col: int = 0,
rowspan: int = 1,
colspan: int = 1,
shift: Literal["down", "up", "left", "right"] = "down",
) -> BECWidget:
"""
Add a widget to the dock.
Args:
widget(QWidget): The widget to add. It can not be BECDock or BECDockArea.
name(str): The name of the widget.
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
col(int): The column to add the widget to.
rowspan(int): The number of rows the widget should span.
colspan(int): The number of columns the widget should span.
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
"""
if name is not None:
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
if row is None:
row = self.layout.rowCount()
if self.layout_manager.is_position_occupied(row, col):
self.layout_manager.shift_widgets(shift, start_row=row)
# Check that Widget is not BECDock or BECDockArea
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
if widget_class_name in IGNORE_WIDGETS:
raise ValueError(f"Widget {widget} can not be added to dock.")
if isinstance(widget, str):
widget = cast(
BECWidget,
widget_handler.create_widget(
widget_type=widget, object_name=name, parent_dock=self, parent=self
),
)
else:
widget.object_name = name
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
if hasattr(widget, "config"):
widget.config.gui_id = widget.gui_id
self.config.widgets[widget.object_name] = widget.config
return widget
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
"""
Move a widget to a new position in the layout.
Args:
widget(QWidget): The widget to move.
new_row(int): The new row to move the widget to.
new_col(int): The new column to move the widget to.
"""
self.layout_manager.move_widget(widget, new_row, new_col)
def attach(self):
"""
Attach the dock to the parent dock area.
"""
self.parent_dock_area.remove_temp_area(self.area)
def detach(self):
"""
Detach the dock from the parent dock area.
"""
self.float()
def remove(self):
"""
Remove the dock from the parent dock area.
"""
self.parent_dock_area.delete(self.object_name)
def delete(self, widget_name: str) -> None:
"""
Remove a widget from the dock.
Args:
widget_name(str): Delete the widget with the given name.
"""
# pylint: disable=protected-access
widgets = [widget for widget in self.widgets if widget.object_name == widget_name]
if len(widgets) == 0:
logger.warning(
f"Widget with name {widget_name} not found in dock {self.name()}. "
f"Checking if gui_id was passed as widget_name."
)
# Try to find the widget in the RPC register, maybe the gui_id was passed as widget_name
widget = self.rpc_register.get_rpc_by_id(widget_name)
if widget is None:
logger.warning(
f"Widget not found for name or gui_id: {widget_name} in dock {self.name()}"
)
return
else:
widget = widgets[0]
self.layout.removeWidget(widget)
self.config.widgets.pop(widget.object_name, None)
if widget in self.widgets:
self.widgets.remove(widget)
widget.close()
def delete_all(self):
"""
Remove all widgets from the dock.
"""
for widget in self.widgets:
self.delete(widget.object_name)
def cleanup(self):
"""
Clean up the dock, including all its widgets.
"""
# # FIXME Cleanup might be called twice
try:
logger.info(f"Cleaning up dock {self.name()}")
self.label.close()
self.label.deleteLater()
except Exception as e:
logger.error(f"Error while closing dock label: {e}")
# Remove the dock from the parent dock area
if self.parent_dock_area:
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
self.parent_dock_area.config.docks.pop(self.name(), None)
self.delete_all()
self.widgets.clear()
super().cleanup()
self.deleteLater()
def close(self):
"""
Close the dock area and cleanup.
Has to be implemented to overwrite pyqtgraph event accept in Container close.
"""
self.cleanup()
super().close()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication([])
dock = BECDock(name="dock")
dock.show()
app.exec_()
sys.exit(app.exec_())

View File

@@ -1,531 +0,0 @@
from __future__ import annotations
from typing import Literal, Optional
from weakref import WeakValueDictionary
from bec_lib.logger import bec_logger
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 QApplication, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.toolbar import (
ExpandableMenuAction,
MaterialIconAction,
ModularToolBar,
SeparatorAction,
)
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.control.device_control.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 import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.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
logger = bec_logger.logger
class DockAreaConfig(ConnectionConfig):
docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.")
docks_state: Optional[dict] = Field(
None, description="The state of the docks in the dock area."
)
class BECDockArea(BECWidget, QWidget):
"""
Container for other widgets. Widgets can be added to the dock area and arranged in a grid layout.
"""
PLUGIN = True
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"_get_all_rpc",
"new",
"show",
"hide",
"panels",
"panel_list",
"delete",
"delete_all",
"remove",
"detach_dock",
"attach_all",
"save_state",
"restore_state",
]
def __init__(
self,
parent: QWidget | None = None,
config: DockAreaConfig | None = None,
client=None,
gui_id: str = None,
object_name: str = None,
**kwargs,
) -> None:
if config is None:
config = DockAreaConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DockAreaConfig(**config)
self.config = config
super().__init__(
parent=parent,
object_name=object_name,
client=client,
gui_id=gui_id,
config=config,
**kwargs,
)
self._parent = parent # TODO probably not needed
self.layout = QVBoxLayout(self)
self.layout.setSpacing(5)
self.layout.setContentsMargins(0, 0, 0, 0)
self._instructions_visible = True
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.dock_area = DockArea(parent=self)
self.toolbar = ModularToolBar(
parent=self,
actions={
"menu_plots": ExpandableMenuAction(
label="Add Plot ",
actions={
"waveform": MaterialIconAction(
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
),
"scatter_waveform": MaterialIconAction(
icon_name=ScatterWaveform.ICON_NAME,
tooltip="Add Scatter Waveform",
filled=True,
),
"multi_waveform": MaterialIconAction(
icon_name=MultiWaveform.ICON_NAME,
tooltip="Add Multi Waveform",
filled=True,
),
"image": MaterialIconAction(
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True
),
"motor_map": MaterialIconAction(
icon_name=MotorMap.ICON_NAME, tooltip="Add Motor Map", filled=True
),
},
),
"separator_0": SeparatorAction(),
"menu_devices": ExpandableMenuAction(
label="Add Device Control ",
actions={
"scan_control": MaterialIconAction(
icon_name=ScanControl.ICON_NAME, tooltip="Add Scan Control", filled=True
),
"positioner_box": MaterialIconAction(
icon_name=PositionerBox.ICON_NAME, tooltip="Add Device Box", filled=True
),
},
),
"separator_1": SeparatorAction(),
"menu_utils": ExpandableMenuAction(
label="Add Utils ",
actions={
"queue": MaterialIconAction(
icon_name=BECQueue.ICON_NAME, tooltip="Add Scan Queue", filled=True
),
"vs_code": MaterialIconAction(
icon_name=VSCodeEditor.ICON_NAME, tooltip="Add VS Code", filled=True
),
"status": MaterialIconAction(
icon_name=BECStatusBox.ICON_NAME,
tooltip="Add BEC Status Box",
filled=True,
),
"progress_bar": MaterialIconAction(
icon_name=RingProgressBar.ICON_NAME,
tooltip="Add Circular ProgressBar",
filled=True,
),
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME, tooltip="Add LogPanel", filled=True
),
},
),
"separator_2": SeparatorAction(),
"attach_all": MaterialIconAction(
icon_name="zoom_in_map", tooltip="Attach all floating docks"
),
"save_state": MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State"),
"restore_state": MaterialIconAction(
icon_name="frame_reload", tooltip="Restore Dock State"
),
},
target_widget=self,
)
self.layout.addWidget(self.toolbar)
self.layout.addWidget(self.dock_area)
self.spacer = QWidget(parent=self)
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
self._hook_toolbar()
def minimumSizeHint(self):
return QSize(800, 600)
def _hook_toolbar(self):
# Menu Plot
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
)
self.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform")
)
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform")
)
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Image")
)
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
)
# Menu Devices
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
)
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
)
# Menu Utils
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
)
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
)
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
)
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
)
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
)
# Icons
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
@SafeSlot()
def _create_widget_from_toolbar(self, widget_name: str) -> None:
# Run with RPC broadcast to namespace of all widgets
with RPCRegister.delayed_broadcast():
name = pascal_to_snake(widget_name)
dock_name = WidgetContainerUtils.generate_unique_name(name, self.panels.keys())
self.new(name=dock_name, widget=widget_name)
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
super().paintEvent(event)
if self._instructions_visible:
painter = QPainter(self)
painter.drawText(
self.rect(),
Qt.AlignCenter,
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
)
@property
def panels(self) -> dict[str, BECDock]:
"""
Get the docks in the dock area.
Returns:
dock_dict(dict): The docks in the dock area.
"""
return dict(self.dock_area.docks)
@panels.setter
def panels(self, value: dict[str, BECDock]):
self.dock_area.docks = WeakValueDictionary(value) # This can not work can it?
@property
def panel_list(self) -> list[BECDock]:
"""
Get the docks in the dock area.
Returns:
list: The docks in the dock area.
"""
return list(self.dock_area.docks.values())
@property
def temp_areas(self) -> list:
"""
Get the temporary areas in the dock area.
Returns:
list: The temporary areas in the dock area.
"""
return list(map(str, self.dock_area.tempAreas))
@temp_areas.setter
def temp_areas(self, value: list):
self.dock_area.tempAreas = list(map(str, value))
@SafeSlot()
def restore_state(
self, state: dict = None, missing: Literal["ignore", "error"] = "ignore", extra="bottom"
):
"""
Restore the state of the dock area. If no state is provided, the last state is restored.
Args:
state(dict): The state to restore.
missing(Literal['ignore','error']): What to do if a dock is missing.
extra(str): Extra docks that are in the dockarea but that are not mentioned in state will be added to the bottom of the dockarea, unless otherwise specified by the extra argument.
"""
if state is None:
state = self.config.docks_state
if state is None:
return
self.dock_area.restoreState(state, missing=missing, extra=extra)
@SafeSlot()
def save_state(self) -> dict:
"""
Save the state of the dock area.
Returns:
dict: The state of the dock area.
"""
last_state = self.dock_area.saveState()
self.config.docks_state = last_state
return last_state
@SafeSlot(popup_error=True)
def new(
self,
name: str | None = None,
widget: str | QWidget | None = None,
widget_name: str | None = None,
position: Literal["bottom", "top", "left", "right", "above", "below"] = "bottom",
relative_to: BECDock | None = None,
closable: bool = True,
floating: bool = False,
row: int | None = None,
col: int = 0,
rowspan: int = 1,
colspan: int = 1,
) -> BECDock:
"""
Add a dock to the dock area. Dock has QGridLayout as layout manager by default.
Args:
name(str): The name of the dock to be displayed and for further references. Has to be unique.
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
relative_to(BECDock): The dock to which the new dock should be added relative to.
closable(bool): Whether the dock is closable.
floating(bool): Whether the dock is detached after creating.
row(int): The row of the added widget.
col(int): The column of the added widget.
rowspan(int): The rowspan of the added widget.
colspan(int): The colspan of the added widget.
Returns:
BECDock: The created dock.
"""
dock_names = [
dock.object_name for dock in self.panel_list
] # pylint: disable=protected-access
if name is not None: # Name is provided
if name in dock_names:
raise ValueError(
f"Name {name} must be unique for docks, but already exists in DockArea "
f"with name: {self.object_name} and id {self.gui_id}."
)
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
else: # Name is not provided
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
dock = BECDock(
parent=self,
name=name, # this is dock name pyqtgraph property, this is displayed on label
object_name=name, # this is a real qt object name passed to BECConnector
parent_dock_area=self,
closable=closable,
)
dock.config.position = position
self.config.docks[dock.name()] = dock.config
# The dock.name is equal to the name passed to BECDock
self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to)
if len(self.dock_area.docks) <= 1:
dock.hide_title_bar()
elif len(self.dock_area.docks) > 1:
for dock in self.dock_area.docks.values():
dock.show_title_bar()
if widget is not None:
# Check if widget name exists.
dock.new(
widget=widget, name=widget_name, row=row, col=col, rowspan=rowspan, colspan=colspan
)
if (
self._instructions_visible
): # TODO still decide how initial instructions should be handled
self._instructions_visible = False
self.update()
if floating:
dock.detach()
return dock
def detach_dock(self, dock_name: str) -> BECDock:
"""
Undock a dock from the dock area.
Args:
dock_name(str): The dock to undock.
Returns:
BECDock: The undocked dock.
"""
dock = self.dock_area.docks[dock_name]
dock.detach()
return dock
@SafeSlot()
def attach_all(self):
"""
Return all floating docks to the dock area.
"""
while self.dock_area.tempAreas:
for temp_area in self.dock_area.tempAreas:
self.remove_temp_area(temp_area)
def remove_temp_area(self, area):
"""
Remove a temporary area from the dock area.
This is a patched method of pyqtgraph's removeTempArea
"""
if area not in self.dock_area.tempAreas:
# FIXME add some context for the logging, I am not sure which object is passed.
# It looks like a pyqtgraph.DockArea
logger.info(f"Attempted to remove dock_area, but was not floating.")
return
self.dock_area.tempAreas.remove(area)
area.window().close()
area.window().deleteLater()
def cleanup(self):
"""
Cleanup the dock area.
"""
self.delete_all()
self.dark_mode_button.close()
self.dark_mode_button.deleteLater()
super().cleanup()
def show(self):
"""Show all windows including floating docks."""
super().show()
for docks in self.panels.values():
if docks.window() is self:
# avoid recursion
continue
docks.window().show()
def hide(self):
"""Hide all windows including floating docks."""
super().hide()
for docks in self.panels.values():
if docks.window() is self:
# avoid recursion
continue
docks.window().hide()
def delete_all(self) -> None:
"""
Delete all docks.
"""
self.attach_all()
for dock_name in self.panels.keys():
self.delete(dock_name)
def delete(self, dock_name: str):
"""
Delete a dock by name.
Args:
dock_name(str): The name of the dock to delete.
"""
dock = self.dock_area.docks.pop(dock_name, None)
self.config.docks.pop(dock_name, None)
if dock:
dock.close()
dock.deleteLater()
if len(self.dock_area.docks) <= 1:
for dock in self.dock_area.docks.values():
dock.hide_title_bar()
else:
raise ValueError(f"Dock with name {dock_name} does not exist.")
# self._broadcast_update()
def remove(self) -> None:
"""
Remove the dock area. If the dock area is embedded in a BECMainWindow and
is set as the central widget, the main window will be closed.
"""
parent = self.parent()
if isinstance(parent, BECMainWindow):
central_widget = parent.centralWidget()
if central_widget is self:
# Closing the parent will also close the dock area
parent.close()
return
self.close()
if __name__ == "__main__": # pragma: no cover
import sys
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("auto")
dock_area = BECDockArea()
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
dock_1.new(widget="DarkModeButton")
# dock_1 = dock_area.new(name="dock_0", widget="Waveform")
dock_area.new(widget="DarkModeButton")
dock_area.show()
dock_area.setGeometry(100, 100, 800, 600)
app.topLevelWidgets()
WidgetHierarchy.print_becconnector_hierarchy_from_app()
app.exec_()
sys.exit(app.exec_())

View File

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

View File

@@ -1,58 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.containers.dock import BECDockArea
DOM_XML = """
<ui language='c++'>
<widget class='BECDockArea' name='dock_area'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECDockArea(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Plots"
def icon(self):
return designer_material_icon(BECDockArea.ICON_NAME)
def includeFile(self):
return "dock_area"
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 "BECDockArea"
def toolTip(self):
return "BECDockArea"
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.containers.dock.dock_area_plugin import BECDockAreaPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,883 +0,0 @@
from __future__ import annotations
import math
import sys
from typing import Dict, Literal, Optional, Set, Tuple, Union
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPushButton,
QSpinBox,
QSplitter,
QVBoxLayout,
QWidget,
)
from typeguard import typechecked
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
class LayoutManagerWidget(QWidget):
"""
A robust layout manager that extends QGridLayout functionality, allowing
users to add/remove widgets, access widgets by coordinates, shift widgets,
and change the layout dynamically with automatic reindexing to keep the grid compact.
Supports adding widgets via QWidget instances or string identifiers referencing the widget handler.
"""
def __init__(self, parent=None, auto_reindex=True):
super().__init__(parent)
self.layout = QGridLayout(self)
self.auto_reindex = auto_reindex
# Mapping from widget to its position (row, col, rowspan, colspan)
self.widget_positions: Dict[QWidget, Tuple[int, int, int, int]] = {}
# Mapping from (row, col) to widget
self.position_widgets: Dict[Tuple[int, int], QWidget] = {}
# Keep track of the current position for automatic placement
self.current_row = 0
self.current_col = 0
def add_widget(
self,
widget: QWidget | str,
row: int | None = None,
col: Optional[int] = None,
rowspan: int = 1,
colspan: int = 1,
shift_existing: bool = True,
shift_direction: Literal["down", "up", "left", "right"] = "right",
) -> QWidget:
"""
Add a widget to the grid with enhanced shifting capabilities.
Args:
widget (QWidget | str): The widget to add. If str, it is used to create a widget via widget_handler.
row (int, optional): The row to add the widget to. If None, the next available row is used.
col (int, optional): The column to add the widget to. If None, the next available column is used.
rowspan (int): Number of rows the widget spans. Default is 1.
colspan (int): Number of columns the widget spans. Default is 1.
shift_existing (bool): Whether to shift existing widgets if the target position is occupied. Default is True.
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets. Default is "right".
Returns:
QWidget: The widget that was added.
"""
# Handle widget creation if a BECWidget string identifier is provided
if isinstance(widget, str):
widget = widget_handler.create_widget(widget)
if row is None:
row = self.current_row
if col is None:
col = self.current_col
if (row, col) in self.position_widgets:
if shift_existing:
# Attempt to shift the existing widget in the specified direction
self.shift_widgets(direction=shift_direction, start_row=row, start_col=col)
else:
raise ValueError(f"Position ({row}, {col}) is already occupied.")
# Add the widget to the layout
self.layout.addWidget(widget, row, col, rowspan, colspan)
self.widget_positions[widget] = (row, col, rowspan, colspan)
self.position_widgets[(row, col)] = widget
# Update current position for automatic placement
self.current_col = col + colspan
self.current_row = max(self.current_row, row)
if self.auto_reindex:
self.reindex_grid()
return widget
def add_widget_relative(
self,
widget: QWidget | str,
reference_widget: QWidget,
position: Literal["left", "right", "top", "bottom"],
rowspan: int = 1,
colspan: int = 1,
shift_existing: bool = True,
shift_direction: Literal["down", "up", "left", "right"] = "right",
) -> QWidget:
"""
Add a widget relative to an existing widget.
Args:
widget (QWidget | str): The widget to add. If str, it is used to create a widget via widget_handler.
reference_widget (QWidget): The widget relative to which the new widget will be placed.
position (Literal["left", "right", "top", "bottom"]): Position relative to the reference widget.
rowspan (int): Number of rows the widget spans. Default is 1.
colspan (int): Number of columns the widget spans. Default is 1.
shift_existing (bool): Whether to shift existing widgets if the target position is occupied.
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
Returns:
QWidget: The widget that was added.
Raises:
ValueError: If the reference widget is not found.
"""
if reference_widget not in self.widget_positions:
raise ValueError("Reference widget not found in layout.")
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
# Determine new widget position based on the specified relative position
if position == "left":
new_row = ref_row
new_col = ref_col - 1
elif position == "right":
new_row = ref_row
new_col = ref_col + ref_colspan
elif position == "top":
new_row = ref_row - 1
new_col = ref_col
elif position == "bottom":
new_row = ref_row + ref_rowspan
new_col = ref_col
else:
raise ValueError("Invalid position. Choose from 'left', 'right', 'top', 'bottom'.")
# Add the widget at the calculated position
return self.add_widget(
widget=widget,
row=new_row,
col=new_col,
rowspan=rowspan,
colspan=colspan,
shift_existing=shift_existing,
shift_direction=shift_direction,
)
def move_widget_by_coords(
self,
current_row: int,
current_col: int,
new_row: int,
new_col: int,
shift: bool = True,
shift_direction: Literal["down", "up", "left", "right"] = "right",
) -> None:
"""
Move a widget from (current_row, current_col) to (new_row, new_col).
Args:
current_row (int): Current row of the widget.
current_col (int): Current column of the widget.
new_row (int): Target row.
new_col (int): Target column.
shift (bool): Whether to shift existing widgets if the target position is occupied.
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
Raises:
ValueError: If the widget is not found or target position is invalid.
"""
self.move_widget(
old_row=current_row,
old_col=current_col,
new_row=new_row,
new_col=new_col,
shift=shift,
shift_direction=shift_direction,
)
@typechecked
def move_widget_by_object(
self,
widget: QWidget,
new_row: int,
new_col: int,
shift: bool = True,
shift_direction: Literal["down", "up", "left", "right"] = "right",
) -> None:
"""
Move a widget to a new position using the widget object.
Args:
widget (QWidget): The widget to move.
new_row (int): Target row.
new_col (int): Target column.
shift (bool): Whether to shift existing widgets if the target position is occupied.
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
Raises:
ValueError: If the widget is not found or target position is invalid.
"""
if widget not in self.widget_positions:
raise ValueError("Widget not found in layout.")
old_position = self.widget_positions[widget]
old_row, old_col = old_position[0], old_position[1]
self.move_widget(
old_row=old_row,
old_col=old_col,
new_row=new_row,
new_col=new_col,
shift=shift,
shift_direction=shift_direction,
)
@typechecked
def move_widget(
self,
old_row: int | None = None,
old_col: int | None = None,
new_row: int | None = None,
new_col: int | None = None,
shift: bool = True,
shift_direction: Literal["down", "up", "left", "right"] = "right",
) -> None:
"""
Move a widget to a new position. If the new position is occupied and shift is True,
shift the existing widget to the specified direction.
Args:
old_row (int, optional): The current row of the widget.
old_col (int, optional): The current column of the widget.
new_row (int, optional): The target row to move the widget to.
new_col (int, optional): The target column to move the widget to.
shift (bool): Whether to shift existing widgets if the target position is occupied.
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
Raises:
ValueError: If the widget is not found or target position is invalid.
"""
if new_row is None or new_col is None:
raise ValueError("Must provide both new_row and new_col to move a widget.")
if old_row is None and old_col is None:
raise ValueError(f"No widget found at position ({old_row}, {old_col}).")
widget = self.get_widget(old_row, old_col)
if (new_row, new_col) in self.position_widgets:
if not shift:
raise ValueError(f"Position ({new_row}, {new_col}) is already occupied.")
# Shift the existing widget to make space
self.shift_widgets(
direction=shift_direction,
start_row=new_row if shift_direction in ["down", "up"] else 0,
start_col=new_col if shift_direction in ["left", "right"] else 0,
)
# Proceed to move the widget
self.layout.removeWidget(widget)
old_position = self.widget_positions.pop(widget)
self.position_widgets.pop((old_position[0], old_position[1]))
self.layout.addWidget(widget, new_row, new_col, old_position[2], old_position[3])
self.widget_positions[widget] = (new_row, new_col, old_position[2], old_position[3])
self.position_widgets[(new_row, new_col)] = widget
# Update current_row and current_col for automatic placement if needed
self.current_row = max(self.current_row, new_row)
self.current_col = max(self.current_col, new_col + old_position[3])
if self.auto_reindex:
self.reindex_grid()
@typechecked
def shift_widgets(
self,
direction: Literal["down", "up", "left", "right"],
start_row: int = 0,
start_col: int = 0,
) -> None:
"""
Shift widgets in the grid in the specified direction starting from the given position.
Args:
direction (Literal["down", "up", "left", "right"]): Direction to shift widgets.
start_row (int): Starting row index.
start_col (int): Starting column index.
Raises:
ValueError: If shifting causes widgets to go out of grid boundaries.
"""
shifts = []
positions_to_shift = [(start_row, start_col)]
visited_positions = set()
while positions_to_shift:
row, col = positions_to_shift.pop(0)
if (row, col) in visited_positions:
continue
visited_positions.add((row, col))
widget = self.position_widgets.get((row, col))
if widget is None:
continue # No widget at this position
# Compute new position based on the direction
if direction == "down":
new_row = row + 1
new_col = col
elif direction == "up":
new_row = row - 1
new_col = col
elif direction == "right":
new_row = row
new_col = col + 1
elif direction == "left":
new_row = row
new_col = col - 1
# Check for negative indices
if new_row < 0 or new_col < 0:
raise ValueError("Shifting widgets out of grid boundaries.")
# If the new position is occupied, add it to the positions to shift
if (new_row, new_col) in self.position_widgets:
positions_to_shift.append((new_row, new_col))
shifts.append(
(widget, (row, col), (new_row, new_col), self.widget_positions[widget][2:])
)
# Remove all widgets from their old positions
for widget, (old_row, old_col), _, _ in shifts:
self.layout.removeWidget(widget)
self.position_widgets.pop((old_row, old_col))
# Add widgets to their new positions
for widget, _, (new_row, new_col), (rowspan, colspan) in shifts:
self.layout.addWidget(widget, new_row, new_col, rowspan, colspan)
self.widget_positions[widget] = (new_row, new_col, rowspan, colspan)
self.position_widgets[(new_row, new_col)] = widget
# Update current_row and current_col if needed
self.current_row = max(self.current_row, new_row)
self.current_col = max(self.current_col, new_col + colspan)
def shift_all_widgets(self, direction: Literal["down", "up", "left", "right"]) -> None:
"""
Shift all widgets in the grid in the specified direction to make room and prevent negative indices.
Args:
direction (Literal["down", "up", "left", "right"]): Direction to shift all widgets.
"""
# First, collect all the shifts to perform
shifts = []
for widget, (row, col, rowspan, colspan) in self.widget_positions.items():
if direction == "down":
new_row = row + 1
new_col = col
elif direction == "up":
new_row = row - 1
new_col = col
elif direction == "right":
new_row = row
new_col = col + 1
elif direction == "left":
new_row = row
new_col = col - 1
# Check for negative indices
if new_row < 0 or new_col < 0:
raise ValueError("Shifting widgets out of grid boundaries.")
shifts.append((widget, (row, col), (new_row, new_col), (rowspan, colspan)))
# Now perform the shifts
for widget, (old_row, old_col), (new_row, new_col), (rowspan, colspan) in shifts:
self.layout.removeWidget(widget)
self.position_widgets.pop((old_row, old_col))
for widget, (old_row, old_col), (new_row, new_col), (rowspan, colspan) in shifts:
self.layout.addWidget(widget, new_row, new_col, rowspan, colspan)
self.widget_positions[widget] = (new_row, new_col, rowspan, colspan)
self.position_widgets[(new_row, new_col)] = widget
# Update current_row and current_col based on new widget positions
self.current_row = max((pos[0] for pos in self.position_widgets.keys()), default=0)
self.current_col = max((pos[1] for pos in self.position_widgets.keys()), default=0)
def remove(
self,
row: int | None = None,
col: int | None = None,
coordinates: Tuple[int, int] | None = None,
) -> None:
"""
Remove a widget from the layout. Can be removed by widget ID or by coordinates.
Args:
row (int, optional): The row coordinate of the widget to remove.
col (int, optional): The column coordinate of the widget to remove.
coordinates (tuple[int, int], optional): The (row, col) coordinates of the widget to remove.
Raises:
ValueError: If the widget to remove is not found.
"""
if coordinates:
row, col = coordinates
widget = self.get_widget(row, col)
if widget is None:
raise ValueError(f"No widget found at coordinates {coordinates}.")
elif row is not None and col is not None:
widget = self.get_widget(row, col)
if widget is None:
raise ValueError(f"No widget found at position ({row}, {col}).")
else:
raise ValueError(
"Must provide either widget_id, coordinates, or both row and col for removal."
)
self.remove_widget(widget)
def remove_widget(self, widget: QWidget) -> None:
"""
Remove a widget from the grid and reindex the grid to keep it compact.
Args:
widget (QWidget): The widget to remove.
Raises:
ValueError: If the widget is not found in the layout.
"""
if widget not in self.widget_positions:
raise ValueError("Widget not found in layout.")
position = self.widget_positions.pop(widget)
self.position_widgets.pop((position[0], position[1]))
self.layout.removeWidget(widget)
widget.setParent(None) # Remove widget from the parent
widget.deleteLater()
# Reindex the grid to maintain compactness
if self.auto_reindex:
self.reindex_grid()
def get_widget(self, row: int, col: int) -> QWidget | None:
"""
Get the widget at the specified position.
Args:
row (int): The row coordinate.
col (int): The column coordinate.
Returns:
QWidget | None: The widget at the specified position, or None if empty.
"""
return self.position_widgets.get((row, col))
def get_widget_position(self, widget: QWidget) -> Tuple[int, int, int, int] | None:
"""
Get the position of the specified widget.
Args:
widget (QWidget): The widget to query.
Returns:
Tuple[int, int, int, int] | None: The (row, col, rowspan, colspan) tuple, or None if not found.
"""
return self.widget_positions.get(widget)
def change_layout(self, num_rows: int | None = None, num_cols: int | None = None) -> None:
"""
Change the layout to have a certain number of rows and/or columns,
rearranging the widgets accordingly.
If only one of num_rows or num_cols is provided, the other is calculated automatically
based on the number of widgets and the provided constraint.
If both are provided, num_rows is calculated based on num_cols.
Args:
num_rows (int | None): The new maximum number of rows.
num_cols (int | None): The new maximum number of columns.
"""
if num_rows is None and num_cols is None:
return # Nothing to change
total_widgets = len(self.widget_positions)
if num_cols is not None:
# Calculate num_rows based on num_cols
num_rows = math.ceil(total_widgets / num_cols)
elif num_rows is not None:
# Calculate num_cols based on num_rows
num_cols = math.ceil(total_widgets / num_rows)
# Sort widgets by current position (row-major order)
widgets_sorted = sorted(
self.widget_positions.items(),
key=lambda item: (item[1][0], item[1][1]), # Sort by row, then column
)
# Clear the layout without deleting widgets
for widget, _ in widgets_sorted:
self.layout.removeWidget(widget)
# Reset position mappings
self.widget_positions.clear()
self.position_widgets.clear()
# Re-add widgets based on new layout constraints
current_row, current_col = 0, 0
for widget, _ in widgets_sorted:
if current_col >= num_cols:
current_col = 0
current_row += 1
self.layout.addWidget(widget, current_row, current_col, 1, 1)
self.widget_positions[widget] = (current_row, current_col, 1, 1)
self.position_widgets[(current_row, current_col)] = widget
current_col += 1
# Update current_row and current_col for automatic placement
self.current_row = current_row
self.current_col = current_col
# Reindex the grid to ensure compactness
self.reindex_grid()
def clear_layout(self) -> None:
"""
Remove all widgets from the layout without deleting them.
"""
for widget in list(self.widget_positions):
self.layout.removeWidget(widget)
self.position_widgets.pop(
(self.widget_positions[widget][0], self.widget_positions[widget][1])
)
self.widget_positions.pop(widget)
widget.setParent(None) # Optionally hide/remove the widget
self.current_row = 0
self.current_col = 0
def reindex_grid(self) -> None:
"""
Reindex the grid to remove empty rows and columns, ensuring that
widget coordinates are contiguous and start from (0, 0).
"""
# Step 1: Collect all occupied positions
occupied_positions = sorted(self.position_widgets.keys())
if not occupied_positions:
# No widgets to reindex
self.clear_layout()
return
# Step 2: Determine the new mapping by eliminating empty columns and rows
# Find unique rows and columns
unique_rows = sorted(set(pos[0] for pos in occupied_positions))
unique_cols = sorted(set(pos[1] for pos in occupied_positions))
# Create mappings from old to new indices
row_mapping = {old_row: new_row for new_row, old_row in enumerate(unique_rows)}
col_mapping = {old_col: new_col for new_col, old_col in enumerate(unique_cols)}
# Step 3: Collect widgets with their new positions
widgets_with_new_positions = []
for widget, (row, col, rowspan, colspan) in self.widget_positions.items():
new_row = row_mapping[row]
new_col = col_mapping[col]
widgets_with_new_positions.append((widget, new_row, new_col, rowspan, colspan))
# Step 4: Clear the layout and reset mappings
self.clear_layout()
# Reset current_row and current_col
self.current_row = 0
self.current_col = 0
# Step 5: Re-add widgets with new positions
for widget, new_row, new_col, rowspan, colspan in widgets_with_new_positions:
self.layout.addWidget(widget, new_row, new_col, rowspan, colspan)
self.widget_positions[widget] = (new_row, new_col, rowspan, colspan)
self.position_widgets[(new_row, new_col)] = widget
# Update current position for automatic placement
self.current_col = max(self.current_col, new_col + colspan)
self.current_row = max(self.current_row, new_row)
def get_widgets_positions(self) -> Dict[QWidget, Tuple[int, int, int, int]]:
"""
Get the positions of all widgets in the layout.
Returns:
Dict[QWidget, Tuple[int, int, int, int]]: Mapping of widgets to their (row, col, rowspan, colspan).
"""
return self.widget_positions.copy()
def print_all_button_text(self):
"""Debug function to print the text of all QPushButton widgets."""
print("Coordinates - Button Text")
for coord, widget in self.position_widgets.items():
if isinstance(widget, QPushButton):
print(f"{coord} - {widget.text()}")
####################################################################################################
# The following code is for the GUI control panel to interact with the LayoutManagerWidget.
# It is not covered by any tests as it serves only as an example for the LayoutManagerWidget class.
####################################################################################################
class ControlPanel(QWidget): # pragma: no cover
def __init__(self, layout_manager: LayoutManagerWidget):
super().__init__()
self.layout_manager = layout_manager
self.init_ui()
def init_ui(self):
main_layout = QVBoxLayout()
# Add Widget by Coordinates
add_coord_group = QGroupBox("Add Widget by Coordinates")
add_coord_layout = QGridLayout()
add_coord_layout.addWidget(QLabel("Text:"), 0, 0)
self.text_input = QLineEdit()
add_coord_layout.addWidget(self.text_input, 0, 1)
add_coord_layout.addWidget(QLabel("Row:"), 1, 0)
self.row_input = QSpinBox()
self.row_input.setMinimum(0)
add_coord_layout.addWidget(self.row_input, 1, 1)
add_coord_layout.addWidget(QLabel("Column:"), 2, 0)
self.col_input = QSpinBox()
self.col_input.setMinimum(0)
add_coord_layout.addWidget(self.col_input, 2, 1)
self.add_button = QPushButton("Add at Coordinates")
self.add_button.clicked.connect(self.add_at_coordinates)
add_coord_layout.addWidget(self.add_button, 3, 0, 1, 2)
add_coord_group.setLayout(add_coord_layout)
main_layout.addWidget(add_coord_group)
# Add Widget Relative
add_rel_group = QGroupBox("Add Widget Relative to Existing")
add_rel_layout = QGridLayout()
add_rel_layout.addWidget(QLabel("Text:"), 0, 0)
self.rel_text_input = QLineEdit()
add_rel_layout.addWidget(self.rel_text_input, 0, 1)
add_rel_layout.addWidget(QLabel("Reference Widget:"), 1, 0)
self.ref_widget_combo = QComboBox()
add_rel_layout.addWidget(self.ref_widget_combo, 1, 1)
add_rel_layout.addWidget(QLabel("Position:"), 2, 0)
self.position_combo = QComboBox()
self.position_combo.addItems(["left", "right", "top", "bottom"])
add_rel_layout.addWidget(self.position_combo, 2, 1)
self.add_rel_button = QPushButton("Add Relative")
self.add_rel_button.clicked.connect(self.add_relative)
add_rel_layout.addWidget(self.add_rel_button, 3, 0, 1, 2)
add_rel_group.setLayout(add_rel_layout)
main_layout.addWidget(add_rel_group)
# Remove Widget
remove_group = QGroupBox("Remove Widget")
remove_layout = QGridLayout()
remove_layout.addWidget(QLabel("Row:"), 0, 0)
self.remove_row_input = QSpinBox()
self.remove_row_input.setMinimum(0)
remove_layout.addWidget(self.remove_row_input, 0, 1)
remove_layout.addWidget(QLabel("Column:"), 1, 0)
self.remove_col_input = QSpinBox()
self.remove_col_input.setMinimum(0)
remove_layout.addWidget(self.remove_col_input, 1, 1)
self.remove_button = QPushButton("Remove at Coordinates")
self.remove_button.clicked.connect(self.remove_widget)
remove_layout.addWidget(self.remove_button, 2, 0, 1, 2)
remove_group.setLayout(remove_layout)
main_layout.addWidget(remove_group)
# Change Layout
change_layout_group = QGroupBox("Change Layout")
change_layout_layout = QGridLayout()
change_layout_layout.addWidget(QLabel("Number of Rows:"), 0, 0)
self.change_rows_input = QSpinBox()
self.change_rows_input.setMinimum(1)
self.change_rows_input.setValue(1) # Default value
change_layout_layout.addWidget(self.change_rows_input, 0, 1)
change_layout_layout.addWidget(QLabel("Number of Columns:"), 1, 0)
self.change_cols_input = QSpinBox()
self.change_cols_input.setMinimum(1)
self.change_cols_input.setValue(1) # Default value
change_layout_layout.addWidget(self.change_cols_input, 1, 1)
self.change_layout_button = QPushButton("Apply Layout Change")
self.change_layout_button.clicked.connect(self.change_layout)
change_layout_layout.addWidget(self.change_layout_button, 2, 0, 1, 2)
change_layout_group.setLayout(change_layout_layout)
main_layout.addWidget(change_layout_group)
# Remove All Widgets
self.clear_all_button = QPushButton("Clear All Widgets")
self.clear_all_button.clicked.connect(self.clear_all_widgets)
main_layout.addWidget(self.clear_all_button)
# Refresh Reference Widgets and Print Button
self.refresh_button = QPushButton("Refresh Reference Widgets")
self.refresh_button.clicked.connect(self.refresh_references)
self.print_button = QPushButton("Print All Button Text")
self.print_button.clicked.connect(self.layout_manager.print_all_button_text)
main_layout.addWidget(self.refresh_button)
main_layout.addWidget(self.print_button)
main_layout.addStretch()
self.setLayout(main_layout)
self.refresh_references()
def refresh_references(self):
self.ref_widget_combo.clear()
widgets = self.layout_manager.get_widgets_positions()
for widget in widgets:
if isinstance(widget, QPushButton):
self.ref_widget_combo.addItem(widget.text(), widget)
def add_at_coordinates(self):
text = self.text_input.text()
row = self.row_input.value()
col = self.col_input.value()
if not text:
QMessageBox.warning(self, "Input Error", "Please enter text for the button.")
return
button = QPushButton(text)
try:
self.layout_manager.add_widget(widget=button, row=row, col=col)
self.refresh_references()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def add_relative(self):
text = self.rel_text_input.text()
ref_index = self.ref_widget_combo.currentIndex()
ref_widget = self.ref_widget_combo.itemData(ref_index)
position = self.position_combo.currentText()
if not text:
QMessageBox.warning(self, "Input Error", "Please enter text for the button.")
return
if ref_widget is None:
QMessageBox.warning(self, "Input Error", "Please select a reference widget.")
return
button = QPushButton(text)
try:
self.layout_manager.add_widget_relative(
widget=button, reference_widget=ref_widget, position=position
)
self.refresh_references()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def remove_widget(self):
row = self.remove_row_input.value()
col = self.remove_col_input.value()
try:
widget = self.layout_manager.get_widget(row, col)
if widget is None:
QMessageBox.warning(self, "Not Found", f"No widget found at ({row}, {col}).")
return
self.layout_manager.remove_widget(widget)
self.refresh_references()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def change_layout(self):
num_rows = self.change_rows_input.value()
num_cols = self.change_cols_input.value()
try:
self.layout_manager.change_layout(num_rows=num_rows, num_cols=num_cols)
self.refresh_references()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def clear_all_widgets(self):
reply = QMessageBox.question(
self,
"Confirm Clear",
"Are you sure you want to remove all widgets?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
try:
self.layout_manager.clear_layout()
self.refresh_references()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Layout Manager Demo")
self.resize(800, 600)
self.init_ui()
def init_ui(self):
central_widget = QWidget()
main_layout = QHBoxLayout()
# Layout Area GroupBox
layout_group = QGroupBox("Layout Area")
layout_group.setMinimumSize(400, 400)
layout_layout = QVBoxLayout()
self.layout_manager = LayoutManagerWidget()
layout_layout.addWidget(self.layout_manager)
layout_group.setLayout(layout_layout)
# Splitter
splitter = QSplitter()
splitter.addWidget(layout_group)
# Control Panel
control_panel = ControlPanel(self.layout_manager)
control_group = QGroupBox("Control Panel")
control_layout = QVBoxLayout()
control_layout.addWidget(control_panel)
control_layout.addStretch()
control_group.setLayout(control_layout)
splitter.addWidget(control_group)
main_layout.addWidget(splitter)
central_widget.setLayout(main_layout)
self.setCentralWidget(central_widget)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())

View File

@@ -1,15 +0,0 @@
import webbrowser
class BECWebLinksMixin:
@staticmethod
def open_bec_docs():
webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/")
@staticmethod
def open_bec_widgets_docs():
webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/")
@staticmethod
def open_bec_bug_report():
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")

View File

@@ -1,189 +0,0 @@
import os
from qtpy.QtCore import QSize
from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
import bec_widgets
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECMainWindow(BECWidget, QMainWindow):
RPC = False
PLUGIN = False
def __init__(
self,
parent=None,
gui_id: str = None,
client=None,
window_title: str = "BEC",
*args,
**kwargs,
):
super().__init__(parent=parent, gui_id=gui_id, **kwargs)
self.app = QApplication.instance()
self.setWindowTitle(window_title)
self._init_ui()
self._connect_to_theme_change()
def _init_ui(self):
# Set the icon
self._init_bec_icon()
# Set Menu and Status bar
self._setup_menu_bar()
# BEC Specific UI
self.display_app_id()
def _init_bec_icon(self):
icon = self.app.windowIcon()
if icon.isNull():
print("No icon is set, setting default icon")
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.app.setWindowIcon(icon)
else:
print("An icon is set")
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
def display_app_id(self):
"""
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
else:
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self.statusBar().showMessage(status_message)
def _fetch_theme(self) -> str:
return self.app.theme.theme
def _get_launcher_from_qapp(self):
"""
Get the launcher from the QApplication instance.
"""
from bec_widgets.applications.launch_window import LaunchWindow
qapp = QApplication.instance()
widgets = qapp.topLevelWidgets()
widgets = [w for w in widgets if isinstance(w, LaunchWindow)]
if widgets:
return widgets[0]
return None
def _show_launcher(self):
"""
Show the launcher if it exists.
"""
launcher = self._get_launcher_from_qapp()
if launcher:
launcher.show()
launcher.activateWindow()
launcher.raise_()
def _setup_menu_bar(self):
"""
Setup the menu bar for the main window.
"""
menu_bar = self.menuBar()
##########################################
# Launch menu
launch_menu = menu_bar.addMenu("New")
open_launcher_action = QAction("Open Launcher", self)
launch_menu.addAction(open_launcher_action)
open_launcher_action.triggered.connect(self._show_launcher)
########################################
# Theme menu
theme_menu = menu_bar.addMenu("Theme")
theme_group = QActionGroup(self)
light_theme_action = QAction("Light Theme", self, checkable=True)
dark_theme_action = QAction("Dark Theme", self, checkable=True)
theme_group.addAction(light_theme_action)
theme_group.addAction(dark_theme_action)
theme_group.setExclusive(True)
theme_menu.addAction(light_theme_action)
theme_menu.addAction(dark_theme_action)
# Connect theme actions
light_theme_action.triggered.connect(lambda: self.change_theme("light"))
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
# Set the default theme
theme = self.app.theme.theme
if theme == "light":
light_theme_action.setChecked(True)
elif theme == "dark":
dark_theme_action.setChecked(True)
########################################
# Help menu
help_menu = menu_bar.addMenu("Help")
help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion)
bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation)
bec_docs = QAction("BEC Docs", self)
bec_docs.setIcon(help_icon)
widgets_docs = QAction("BEC Widgets Docs", self)
widgets_docs.setIcon(help_icon)
bug_report = QAction("Bug Report", self)
bug_report.setIcon(bug_icon)
bec_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
help_menu.addAction(bec_docs)
help_menu.addAction(widgets_docs)
help_menu.addAction(bug_report)
@SafeSlot(str)
def change_theme(self, theme: str):
apply_theme(theme)
def cleanup(self):
central_widget = self.centralWidget()
central_widget.close()
central_widget.deleteLater()
if not isinstance(central_widget, BECWidget):
# if the central widget is not a BECWidget, we need to call the cleanup method
# of all widgets whose parent is the current BECMainWindow
children = self.findChildren(BECWidget)
for child in children:
ancestor = WidgetHierarchy._get_becwidget_ancestor(child)
if ancestor is self:
child.cleanup()
child.close()
child.deleteLater()
super().cleanup()
class UILaunchWindow(BECMainWindow):
RPC = True

View File

@@ -1 +0,0 @@
{'files': ['button_abort.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.control.buttons.button_abort.button_abort import AbortButton
DOM_XML = """
<ui language='c++'>
<widget class='AbortButton' name='abort_button'>
</widget>
</ui>
"""
class AbortButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = AbortButton(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
return designer_material_icon(AbortButton.ICON_NAME)
def includeFile(self):
return "abort_button"
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 "AbortButton"
def toolTip(self):
return "A button that abort the scan."
def whatsThis(self):
return self.toolTip()

View File

@@ -1,64 +0,0 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
class AbortButton(BECWidget, QWidget):
"""A button that abort the scan."""
PLUGIN = True
ICON_NAME = "cancel"
RPC = True
def __init__(
self,
parent=None,
client=None,
config=None,
gui_id=None,
toolbar=False,
scan_id=None,
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self.layout = QHBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
if toolbar:
icon = material_icon("cancel", color="#666666", filled=True)
self.button = QToolButton(icon=icon)
self.button.setToolTip("Abort the scan")
else:
self.button = QPushButton()
self.button.setText("Abort")
self.button.setStyleSheet(
"background-color: #666666; color: white; font-weight: bold; font-size: 12px;"
)
self.button.clicked.connect(self.abort_scan)
self.layout.addWidget(self.button)
self.scan_id = scan_id
@SafeSlot()
def abort_scan(
self,
): # , scan_id: str | None = None): #FIXME scan_id will be added when combining with Queue widget
"""
Abort the scan.
Args:
scan_id(str|None): The scan id to abort. If None, the current scan will be aborted.
"""
if self.scan_id is not None:
print(f"Aborting scan with scan_id: {self.scan_id}")
self.queue.request_scan_abortion(scan_id=self.scan_id)
else:
self.queue.request_scan_abortion()

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.buttons.button_abort.abort_button_plugin import (
AbortButtonPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(AbortButtonPlugin())
if __name__ == "__main__": # pragma: no cover
main()

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