mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-09 10:10:55 +02:00
Compare commits
6 Commits
fix/exclud
...
feat/user_
| Author | SHA1 | Date | |
|---|---|---|---|
| 504794f86a | |||
| 2e5ee7c5bd | |||
| b87cab6744 | |||
| 9ac4ce73ff | |||
| 710d7229a7 | |||
| 9402ba82ff |
@@ -61,7 +61,6 @@ stages:
|
||||
- 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
|
||||
@@ -78,9 +77,9 @@ formatter:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
script:
|
||||
- pip install bec_lib[dev]
|
||||
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
|
||||
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
|
||||
- pip install black isort
|
||||
- isort --check --diff ./
|
||||
- black --check --diff --color ./
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
@@ -148,7 +147,7 @@ tests:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,pyside6]
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
|
||||
- coverage report
|
||||
- coverage xml
|
||||
@@ -172,6 +171,7 @@ test-matrix:
|
||||
- "3.12"
|
||||
QT_PCKG:
|
||||
- "pyside6"
|
||||
- "pyqt6"
|
||||
|
||||
stage: AdditionalTests
|
||||
needs: []
|
||||
@@ -197,13 +197,7 @@ end-2-end-conda:
|
||||
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
|
||||
@@ -216,8 +210,9 @@ end-2-end-conda:
|
||||
- cd ../
|
||||
- pip install -e ./ophyd_devices
|
||||
|
||||
- pip install -e .[dev,pyside6]
|
||||
- pytest -v --files-path ./ --start-servers --flush-redis --random-order ./tests/end-2-end
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- cd ./tests/end-2-end
|
||||
- pytest -v --start-servers --flush-redis --random-order
|
||||
|
||||
artifacts:
|
||||
when: on_failure
|
||||
@@ -232,7 +227,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
|
||||
|
||||
6158
CHANGELOG.md
6158
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
34
README.md
34
README.md
@@ -1,17 +1,12 @@
|
||||
# BEC Widgets
|
||||
|
||||
**⚠️ Important Notice:**
|
||||
|
||||
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
|
||||
|
||||
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
|
||||
|
||||
## Installation
|
||||
|
||||
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyside6]
|
||||
pip install bec_widgets PyQt6
|
||||
```
|
||||
|
||||
For development purposes, you can clone the repository and install the package locally in editable mode:
|
||||
@@ -19,12 +14,22 @@ For development purposes, you can clone the repository and install the package l
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
cd bec_widgets
|
||||
pip install -e .[dev,pyside6]
|
||||
pip install -e .[dev,pyqt6]
|
||||
```
|
||||
|
||||
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
|
||||
specified.
|
||||
BEC Widgets currently supports both Pyside6 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
|
||||
Python Qt distributions manually.
|
||||
|
||||
To select a specific Python Qt distribution, install the package with an additional tag:
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyqt6]
|
||||
```
|
||||
or
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyside6]
|
||||
```
|
||||
## Documentation
|
||||
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
@@ -34,7 +39,7 @@ Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs
|
||||
All commits should use the Angular commit scheme:
|
||||
|
||||
> #### <a name="commit-header"></a>Angular Commit Message Header
|
||||
>
|
||||
>
|
||||
> ```
|
||||
> <type>(<scope>): <short summary>
|
||||
> │ │ │
|
||||
@@ -48,13 +53,13 @@ All commits should use the Angular commit scheme:
|
||||
> │
|
||||
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
|
||||
> ```
|
||||
>
|
||||
>
|
||||
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
|
||||
|
||||
> ##### Type
|
||||
>
|
||||
>
|
||||
> Must be one of the following:
|
||||
>
|
||||
>
|
||||
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
|
||||
> * **docs**: Documentation only changes
|
||||
@@ -66,5 +71,4 @@ All commits should use the Angular commit scheme:
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
@@ -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"]
|
||||
|
||||
@@ -13,10 +13,10 @@ from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot as Slot
|
||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||
from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import (
|
||||
PositionerGroup,
|
||||
@@ -29,7 +29,6 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# FIXME BECWaveFormWidget is gone, this app will not work until adapted to new Waveform
|
||||
class Alignment1D:
|
||||
"""Alignment GUI to perform 1D scans"""
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .client import *
|
||||
|
||||
@@ -1,169 +1,184 @@
|
||||
# TODO autoupdate disabled
|
||||
# from __future__ import annotations
|
||||
#
|
||||
# import threading
|
||||
# from queue import Queue
|
||||
# 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
|
||||
# self._default_dock = None
|
||||
# self._default_fig = None
|
||||
#
|
||||
# def start_default_dock(self):
|
||||
# """
|
||||
# Create a default dock for the auto updates.
|
||||
# """
|
||||
# self.dock_name = "default_figure"
|
||||
# self._default_dock = self.gui.new(self.dock_name)
|
||||
# self._default_dock.new("BECFigure")
|
||||
# self._default_fig = self._default_dock.elements_list[0]
|
||||
#
|
||||
# @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.
|
||||
# """
|
||||
# return self._default_fig
|
||||
#
|
||||
# def do_update(self, msg):
|
||||
# """
|
||||
# Run the update function if enabled.
|
||||
# """
|
||||
# if not self.enabled:
|
||||
# return
|
||||
# if msg.status != "open":
|
||||
# return
|
||||
# info = self.get_scan_info(msg)
|
||||
# return self.handler(info)
|
||||
#
|
||||
# def get_selected_device(self, 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:
|
||||
# return self.simple_line_scan(info)
|
||||
# if info.scan_name == "grid_scan" and info.scan_report_devices:
|
||||
# return self.simple_grid_scan(info)
|
||||
# if info.scan_report_devices:
|
||||
# return self.best_effort(info)
|
||||
#
|
||||
# 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]
|
||||
# selected_device = yield self.gui.selected_device
|
||||
# dev_y = self.get_selected_device(info.monitored_devices, selected_device)
|
||||
# if not dev_y:
|
||||
# return
|
||||
# yield fig.clear_all()
|
||||
# yield fig.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,
|
||||
# )
|
||||
#
|
||||
# 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]
|
||||
# selected_device = yield self.gui.selected_device
|
||||
# dev_z = self.get_selected_device(info.monitored_devices, selected_device)
|
||||
# yield fig.clear_all()
|
||||
# yield fig.plot(
|
||||
# x_name=dev_x,
|
||||
# y_name=dev_y,
|
||||
# z_name=dev_z,
|
||||
# label=f"Scan {info.scan_number} - {dev_z}",
|
||||
# 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]
|
||||
# selected_device = yield self.gui.selected_device
|
||||
# dev_y = self.get_selected_device(info.monitored_devices, selected_device)
|
||||
# if not dev_y:
|
||||
# return
|
||||
# yield fig.clear_all()
|
||||
# yield fig.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,
|
||||
# )
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from queue import Queue
|
||||
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
|
||||
self.msg_queue = Queue()
|
||||
self.auto_update_thread = None
|
||||
self._shutdown_sentinel = object()
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the auto update thread.
|
||||
"""
|
||||
self.auto_update_thread = threading.Thread(target=self.process_queue)
|
||||
self.auto_update_thread.start()
|
||||
|
||||
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)
|
||||
|
||||
def process_queue(self):
|
||||
"""
|
||||
Process the message queue.
|
||||
"""
|
||||
while True:
|
||||
msg = self.msg_queue.get()
|
||||
if msg is self._shutdown_sentinel:
|
||||
break
|
||||
self.run(msg)
|
||||
|
||||
@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)
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Shutdown the auto update thread.
|
||||
"""
|
||||
self.msg_queue.put(self._shutdown_sentinel)
|
||||
if self.auto_update_thread:
|
||||
self.auto_update_thread.join()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,47 +1,65 @@
|
||||
"""Client utilities for the BEC GUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.metadata as imd
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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, 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
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
|
||||
from bec_widgets.cli.auto_updates import AutoUpdates
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.redis_connector import StreamMessage
|
||||
else:
|
||||
StreamMessage = lazy_import_from("bec_lib.redis_connector", ("StreamMessage",))
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.device import DeviceBase
|
||||
|
||||
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",))
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
IGNORE_WIDGETS = ["BECDockArea", "BECDock"]
|
||||
|
||||
# pylint: disable=redefined-outer-scope
|
||||
|
||||
|
||||
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:
|
||||
@@ -57,7 +75,6 @@ def _get_output(process, logger) -> None:
|
||||
if stream in readylist:
|
||||
buf.append(stream.read(4096))
|
||||
output, _, remaining = "".join(buf).rpartition("\n")
|
||||
output = _filter_output(output)
|
||||
if output:
|
||||
log_func[stream](output)
|
||||
buf.clear()
|
||||
@@ -66,9 +83,7 @@ def _get_output(process, logger) -> None:
|
||||
logger.error(f"Error reading process output: {str(e)}")
|
||||
|
||||
|
||||
def _start_plot_process(
|
||||
gui_id: str, gui_class: type, gui_class_id: str, config: dict | str, logger=None
|
||||
) -> tuple[subprocess.Popen[str], threading.Thread | None]:
|
||||
def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger=None) -> None:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
|
||||
@@ -77,16 +92,7 @@ def _start_plot_process(
|
||||
process will not be captured.
|
||||
"""
|
||||
# pylint: disable=subprocess-run-check
|
||||
command = [
|
||||
"bec-gui-server",
|
||||
"--id",
|
||||
gui_id,
|
||||
"--gui_class",
|
||||
gui_class.__name__,
|
||||
"--gui_class_id",
|
||||
gui_class_id,
|
||||
"--hide",
|
||||
]
|
||||
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__, "--hide"]
|
||||
if config:
|
||||
if isinstance(config, dict):
|
||||
config = json.dumps(config)
|
||||
@@ -121,192 +127,139 @@ def _start_plot_process(
|
||||
|
||||
|
||||
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._default_dock_name = "bec"
|
||||
self._auto_updates_enabled = True
|
||||
self._auto_updates = None
|
||||
self._killed = False
|
||||
self._top_level: dict[str, client.BECDockArea] = {}
|
||||
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._exposed_widgets = []
|
||||
self._server_registry = {}
|
||||
self._ipython_registry = {}
|
||||
self.available_widgets = AvailableWidgetsNamespace()
|
||||
self._target_endpoint = MessageEndpoints.scan_status()
|
||||
self._selected_device = None
|
||||
|
||||
####################
|
||||
#### Client API ####
|
||||
####################
|
||||
@property
|
||||
def auto_updates(self):
|
||||
if self._auto_updates_enabled:
|
||||
self._gui_started_event.wait()
|
||||
return self._auto_updates
|
||||
|
||||
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
|
||||
# Get the registry state
|
||||
msgs = self._client.connector.xread(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), count=1
|
||||
)
|
||||
if msgs:
|
||||
self._handle_registry_update(msgs[0])
|
||||
# Register the new callback
|
||||
def shutdown_auto_updates(self):
|
||||
if self._auto_updates_enabled:
|
||||
if self._auto_updates is not None:
|
||||
self._auto_updates.shutdown()
|
||||
self._auto_updates = None
|
||||
|
||||
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:
|
||||
spec = importlib.util.find_spec(ep.module)
|
||||
# if the module is not found, we skip it
|
||||
if spec is None:
|
||||
continue
|
||||
return ep.load()(gui=self)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading auto update script from plugin: {str(e)}")
|
||||
return None
|
||||
|
||||
@property
|
||||
def selected_device(self):
|
||||
"""
|
||||
Selected device for the plot.
|
||||
"""
|
||||
return self._selected_device
|
||||
|
||||
@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_update_script(self) -> None:
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
self._target_endpoint, cb=self._handle_msg_update, parent=self
|
||||
)
|
||||
|
||||
@property
|
||||
def windows(self) -> dict:
|
||||
"""Dictionary with dock areas in the GUI."""
|
||||
return self._top_level
|
||||
@staticmethod
|
||||
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)
|
||||
|
||||
@property
|
||||
def window_list(self) -> list:
|
||||
"""List with dock areas in the GUI."""
|
||||
return list(self._top_level.values())
|
||||
def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
|
||||
if isinstance(msg, messages.ScanStatusMessage):
|
||||
if not self.gui_is_alive():
|
||||
return
|
||||
if self._auto_updates_enabled:
|
||||
self.auto_updates.msg_queue.put(msg)
|
||||
|
||||
def start(self, wait: bool = False) -> None:
|
||||
"""Start the GUI server."""
|
||||
return self._start(wait=wait)
|
||||
def _gui_post_startup(self):
|
||||
if self._auto_updates_enabled:
|
||||
if self._auto_updates is None:
|
||||
auto_updates = self._get_update_script()
|
||||
if auto_updates is None:
|
||||
AutoUpdates.create_default_dock = True
|
||||
AutoUpdates.enabled = True
|
||||
auto_updates = AutoUpdates(gui=self)
|
||||
if auto_updates.create_default_dock:
|
||||
auto_updates.start_default_dock()
|
||||
# fig = auto_updates.get_default_figure()
|
||||
self._auto_updates = auto_updates
|
||||
self._gui_started_event.set()
|
||||
self.show_all()
|
||||
|
||||
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,
|
||||
) -> 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.
|
||||
def start_server(self, wait=False) -> None:
|
||||
"""
|
||||
if not self._check_if_server_is_alive():
|
||||
self.start(wait=True)
|
||||
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._gui_started_event.clear()
|
||||
self._start_update_script()
|
||||
self._process, self._process_output_processing_thread = _start_plot_process(
|
||||
self._gui_id, self.__class__, self._client._service_config.config, logger=logger
|
||||
)
|
||||
|
||||
def gui_started_callback(callback):
|
||||
try:
|
||||
if callable(callback):
|
||||
callback()
|
||||
finally:
|
||||
threading.current_thread().cancel()
|
||||
|
||||
self._gui_started_timer = RepeatTimer(
|
||||
1, lambda: self.gui_is_alive() and gui_started_callback(self._gui_post_startup)
|
||||
)
|
||||
self._gui_started_timer.start()
|
||||
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
widget = rpc_client._run_rpc(
|
||||
"new_dock_area", name, geometry
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
self._gui_started_event.wait()
|
||||
|
||||
def show_all(self):
|
||||
self._gui_started_event.wait()
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
widget = rpc_client._run_rpc(
|
||||
"new_dock_area", name, geometry
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
rpc_client._run_rpc("show")
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
"""Delete a dock area.
|
||||
def hide_all(self):
|
||||
self._gui_started_event.wait()
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
rpc_client._run_rpc("hide")
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock area.
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close the gui window.
|
||||
"""
|
||||
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()
|
||||
@@ -321,248 +274,130 @@ class BECGuiClient(RPCBase):
|
||||
self._process_output_processing_thread.join()
|
||||
self._process.wait()
|
||||
self._process = None
|
||||
self.shutdown_auto_updates()
|
||||
|
||||
# Unregister the registry state
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
|
||||
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}"
|
||||
)
|
||||
# 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 ####
|
||||
#########################
|
||||
class RPCBase:
|
||||
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> 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._parent = parent
|
||||
self._msg_wait_event = threading.Event()
|
||||
self._rpc_response = None
|
||||
super().__init__()
|
||||
# print(f"RPCBase: {self._gui_id}")
|
||||
|
||||
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 __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} object at {hex(id(self))}>"
|
||||
|
||||
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())) == 0:
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
break
|
||||
self._do_show_all()
|
||||
self._gui_started_event.set()
|
||||
|
||||
def _start_server(self, wait: bool = False) -> None:
|
||||
@property
|
||||
def _root(self):
|
||||
"""
|
||||
Start the GUI server, and execute callback when it is launched
|
||||
Get the root widget. This is the BECFigure widget that holds
|
||||
the anchor gui_id.
|
||||
"""
|
||||
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,
|
||||
self.__class__,
|
||||
gui_class_id=self._default_dock_name,
|
||||
config=self._client._service_config.config, # pylint: disable=protected-access
|
||||
logger=logger,
|
||||
)
|
||||
parent = self
|
||||
# pylint: disable=protected-access
|
||||
while parent._parent is not None:
|
||||
parent = parent._parent
|
||||
return parent
|
||||
|
||||
def gui_started_callback(callback):
|
||||
try:
|
||||
if callable(callback):
|
||||
callback()
|
||||
finally:
|
||||
threading.current_thread().cancel()
|
||||
|
||||
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()
|
||||
|
||||
if wait:
|
||||
self._gui_started_event.wait()
|
||||
|
||||
def _dump(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
return rpc_client._run_rpc("_dump")
|
||||
|
||||
def _start(self, wait: bool = False) -> None:
|
||||
self._killed = False
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
)
|
||||
return self._start_server(wait=wait)
|
||||
|
||||
def _handle_registry_update(self, msg: StreamMessage) -> None:
|
||||
# This was causing a deadlock during shutdown, not sure why.
|
||||
# with self._lock:
|
||||
self._server_registry = msg["data"].state
|
||||
self._update_dynamic_namespace()
|
||||
|
||||
def _do_show_all(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
rpc_client._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.show()
|
||||
|
||||
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}:window", 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):
|
||||
"""Update the dynamic name space"""
|
||||
# Clear the top level
|
||||
self._top_level.clear()
|
||||
# First we update the name space based on the new registry state
|
||||
self._add_registry_to_namespace()
|
||||
# Then we clear the ipython registry from old objects
|
||||
self._cleanup_ipython_registry()
|
||||
|
||||
def _cleanup_ipython_registry(self):
|
||||
"""Cleanup the ipython registry"""
|
||||
names_in_registry = list(self._ipython_registry.keys())
|
||||
names_in_server_state = list(self._server_registry.keys())
|
||||
remove_ids = list(set(names_in_registry) - set(names_in_server_state))
|
||||
for widget_id in remove_ids:
|
||||
self._ipython_registry.pop(widget_id)
|
||||
self._cleanup_rpc_references_on_rpc_base(remove_ids)
|
||||
# Clear the exposed widgets
|
||||
self._exposed_widgets.clear() # No longer needed I think
|
||||
|
||||
def _cleanup_rpc_references_on_rpc_base(self, remove_ids: list[str]) -> None:
|
||||
"""Cleanup the rpc references on the RPCBase object"""
|
||||
if not remove_ids:
|
||||
return
|
||||
for widget in self._ipython_registry.values():
|
||||
to_delete = []
|
||||
for attr_name, gui_id in widget._rpc_references.items():
|
||||
if gui_id in remove_ids:
|
||||
to_delete.append(attr_name)
|
||||
for attr_name in to_delete:
|
||||
if hasattr(widget, attr_name):
|
||||
delattr(widget, attr_name)
|
||||
if attr_name.startswith("elements."):
|
||||
delattr(widget.elements, attr_name.split(".")[1])
|
||||
widget._rpc_references.pop(attr_name)
|
||||
|
||||
def _set_dynamic_attributes(self, obj: object, name: str, value: Any) -> None:
|
||||
"""Add an object to the namespace"""
|
||||
setattr(obj, name, value)
|
||||
|
||||
def _update_rpc_references(self, widget: RPCBase, name: str, gui_id: str) -> None:
|
||||
"""Update the RPC references"""
|
||||
widget._rpc_references[name] = gui_id
|
||||
|
||||
def _add_registry_to_namespace(self) -> None:
|
||||
"""Add registry to namespace"""
|
||||
# Add dock areas
|
||||
dock_area_states = [
|
||||
state
|
||||
for state in self._server_registry.values()
|
||||
if state["widget_class"] == "BECDockArea"
|
||||
]
|
||||
for state in dock_area_states:
|
||||
dock_area_ref = self._add_widget(state, self)
|
||||
dock_area = self._ipython_registry.get(dock_area_ref._gui_id)
|
||||
if not hasattr(dock_area, "elements"):
|
||||
self._set_dynamic_attributes(dock_area, "elements", WidgetNameSpace())
|
||||
self._set_dynamic_attributes(self, dock_area.widget_name, dock_area_ref)
|
||||
# Keep track of rpc references on RPCBase object
|
||||
self._update_rpc_references(self, dock_area.widget_name, dock_area_ref._gui_id)
|
||||
# Add dock_area to the top level
|
||||
self._top_level[dock_area_ref.widget_name] = dock_area_ref
|
||||
self._exposed_widgets.append(dock_area_ref._gui_id)
|
||||
|
||||
# Add docks
|
||||
dock_states = [
|
||||
state
|
||||
for state in self._server_registry.values()
|
||||
if state["config"].get("parent_id", "") == dock_area_ref._gui_id
|
||||
]
|
||||
for state in dock_states:
|
||||
dock_ref = self._add_widget(state, dock_area)
|
||||
dock = self._ipython_registry.get(dock_ref._gui_id)
|
||||
self._set_dynamic_attributes(dock_area, dock_ref.widget_name, dock_ref)
|
||||
# Keep track of rpc references on RPCBase object
|
||||
self._update_rpc_references(dock_area, dock_ref.widget_name, dock_ref._gui_id)
|
||||
# Keep track of exposed docks
|
||||
self._exposed_widgets.append(dock_ref._gui_id)
|
||||
|
||||
# Add widgets
|
||||
widget_states = [
|
||||
state
|
||||
for state in self._server_registry.values()
|
||||
if state["config"].get("parent_id", "") == dock_ref._gui_id
|
||||
]
|
||||
for state in widget_states:
|
||||
widget_ref = self._add_widget(state, dock)
|
||||
self._set_dynamic_attributes(dock, widget_ref.widget_name, widget_ref)
|
||||
self._set_dynamic_attributes(
|
||||
dock_area.elements, widget_ref.widget_name, widget_ref
|
||||
)
|
||||
# Keep track of rpc references on RPCBase object
|
||||
self._update_rpc_references(
|
||||
dock_area, f"elements.{widget_ref.widget_name}", widget_ref._gui_id
|
||||
)
|
||||
self._update_rpc_references(dock, widget_ref.widget_name, widget_ref._gui_id)
|
||||
# Keep track of exposed widgets
|
||||
self._exposed_widgets.append(widget_ref._gui_id)
|
||||
|
||||
def _add_widget(self, state: dict, parent: object) -> RPCReference:
|
||||
"""Add a widget to the namespace
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
Args:
|
||||
state (dict): The state of the widget from the _server_registry.
|
||||
parent (object): The parent object.
|
||||
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.
|
||||
"""
|
||||
name = state["name"]
|
||||
gui_id = state["gui_id"]
|
||||
try:
|
||||
widget_class = getattr(client, state["widget_class"])
|
||||
except AttributeError as e:
|
||||
raise AttributeError(
|
||||
f"Failed to find user widget {state['widget_class']} in the client - did you run bw-generate-cli to generate the plugin files?"
|
||||
) from e
|
||||
obj = self._ipython_registry.get(gui_id)
|
||||
if obj is None:
|
||||
widget = widget_class(gui_id=gui_id, name=name, parent=parent)
|
||||
self._ipython_registry[gui_id] = widget
|
||||
else:
|
||||
widget = obj
|
||||
obj = RPCReference(registry=self._ipython_registry, gui_id=gui_id)
|
||||
return obj
|
||||
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},
|
||||
)
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
try:
|
||||
config = ServiceConfig()
|
||||
bec_client = BECClient(config)
|
||||
bec_client.start()
|
||||
if wait_for_rpc_response:
|
||||
try:
|
||||
finished = self._msg_wait_event.wait(10)
|
||||
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
|
||||
)
|
||||
# get class name
|
||||
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)
|
||||
|
||||
# Test the client_utils.py module
|
||||
gui = BECGuiClient()
|
||||
@staticmethod
|
||||
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
|
||||
msg = msg.value
|
||||
parent._msg_wait_event.set()
|
||||
parent._rpc_response = msg
|
||||
|
||||
gui.start(wait=True)
|
||||
gui.new().new(widget="Waveform")
|
||||
time.sleep(10)
|
||||
finally:
|
||||
gui.kill_server()
|
||||
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)
|
||||
# print(msg_result)
|
||||
return cls(parent=self, **msg_result)
|
||||
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
|
||||
|
||||
@@ -2,22 +2,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import black
|
||||
import isort
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property as QtProperty
|
||||
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator, plugin_filenames
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import get_overloads
|
||||
else:
|
||||
@@ -34,25 +29,13 @@ else:
|
||||
|
||||
|
||||
class ClientGenerator:
|
||||
def __init__(self, base=False):
|
||||
self._base = base
|
||||
base_imports = (
|
||||
"""import enum
|
||||
import inspect
|
||||
from typing import Literal, Optional
|
||||
"""
|
||||
if self._base
|
||||
else "\n"
|
||||
)
|
||||
self.header = f"""# This file was automatically generated by generate_cli.py\n
|
||||
def __init__(self):
|
||||
self.header = """# This file was automatically generated by generate_cli.py\n
|
||||
from __future__ import annotations
|
||||
{base_imports}
|
||||
from bec_lib.logger import bec_logger
|
||||
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"""
|
||||
|
||||
@@ -60,26 +43,18 @@ logger = bec_logger.logger
|
||||
|
||||
def generate_client(self, class_container: BECClassContainer):
|
||||
"""
|
||||
Generate the client for the published classes, skipping any classes
|
||||
that have `RPC = False`.
|
||||
Generate the client for the published classes.
|
||||
|
||||
Args:
|
||||
class_container: The class container with the classes to generate the client for.
|
||||
"""
|
||||
# Filter out classes that explicitly have RPC=False
|
||||
rpc_top_level_classes = [
|
||||
cls for cls in class_container.rpc_top_level_classes if getattr(cls, "RPC", True)
|
||||
]
|
||||
rpc_top_level_classes = class_container.rpc_top_level_classes
|
||||
rpc_top_level_classes.sort(key=lambda x: x.__name__)
|
||||
|
||||
connector_classes = [
|
||||
cls for cls in class_container.connector_classes if getattr(cls, "RPC", True)
|
||||
]
|
||||
connector_classes = class_container.connector_classes
|
||||
connector_classes.sort(key=lambda x: x.__name__)
|
||||
|
||||
self.write_client_enum(rpc_top_level_classes)
|
||||
for cls in connector_classes:
|
||||
logger.debug(f"generating RPC client class for {cls.__name__}")
|
||||
self.content += "\n\n"
|
||||
self.generate_content_for_class(cls)
|
||||
|
||||
@@ -87,48 +62,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 += """
|
||||
|
||||
_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
|
||||
|
||||
"""
|
||||
self.content += f'{cls.__name__} = "{cls.__name__}"\n '
|
||||
|
||||
def generate_content_for_class(self, cls):
|
||||
"""
|
||||
@@ -140,28 +81,16 @@ for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.iscla
|
||||
|
||||
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)
|
||||
@@ -171,10 +100,8 @@ class {class_name}(RPCBase):"""
|
||||
method = method.split(".setter")[0]
|
||||
if obj is None:
|
||||
raise AttributeError(
|
||||
f"Method {method} not found in class {cls.__name__}. "
|
||||
f"Please check the USER_ACCESS list."
|
||||
f"Method {method} not found in class {cls.__name__}. Please check the USER_ACCESS list."
|
||||
)
|
||||
|
||||
if isinstance(obj, (property, QtProperty)):
|
||||
# for the cli, we can map qt properties to regular properties
|
||||
if is_property_setter:
|
||||
@@ -240,61 +167,41 @@ 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 = get_custom_classes("bec_widgets")
|
||||
|
||||
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
|
||||
generator = ClientGenerator()
|
||||
generator.generate_client(rpc_classes)
|
||||
generator.write(client_path)
|
||||
|
||||
client_path = module_dir / client_subdir / "client.py"
|
||||
for cls in rpc_classes.plugins:
|
||||
plugin = DesignerPluginGenerator(cls)
|
||||
if not hasattr(plugin, "info"):
|
||||
continue
|
||||
|
||||
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))
|
||||
|
||||
for cls in rpc_classes.plugins:
|
||||
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
|
||||
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()
|
||||
# if the class directory already has a register, plugin and pyproject file, skip
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"register_{plugin.info.plugin_name_snake}.py")
|
||||
):
|
||||
continue
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}_plugin.py")
|
||||
):
|
||||
continue
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}.pyproject")
|
||||
):
|
||||
continue
|
||||
plugin.run()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.argv = ["generate_cli.py", "--core"]
|
||||
main()
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import threading
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@check_for_deleted_widget
|
||||
def __getattr__(self, name):
|
||||
if name in ["_registry", "_gui_id"]:
|
||||
return super().__getattribute__(name)
|
||||
return self._registry[self._gui_id].__getattribute__(name)
|
||||
|
||||
@check_for_deleted_widget
|
||||
def __getitem__(self, key):
|
||||
return self._registry[self._gui_id].__getitem__(key)
|
||||
|
||||
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__()
|
||||
|
||||
|
||||
class RPCBase:
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str | None = None,
|
||||
config: dict | None = None,
|
||||
name: str | None = None,
|
||||
parent=None,
|
||||
) -> 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._name = name if 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.widget_name}>"
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the widget.
|
||||
"""
|
||||
self._run_rpc("remove")
|
||||
|
||||
@property
|
||||
def widget_name(self):
|
||||
"""
|
||||
Get the widget name.
|
||||
"""
|
||||
return self._name
|
||||
|
||||
@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) -> 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.
|
||||
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": 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
|
||||
)
|
||||
# get class name
|
||||
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: MessageObject, parent: RPCBase) -> None:
|
||||
msg = msg.value
|
||||
parent._msg_wait_event.set()
|
||||
parent._rpc_response = msg
|
||||
|
||||
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
|
||||
# Therefor 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
|
||||
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
|
||||
@@ -1,176 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from threading import Lock, 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: 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
|
||||
|
||||
@broadcast_update
|
||||
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 | 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: BECWidget | BECConnector | BECDock | 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._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 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: # Last one to exit is repsonsible for broadcasting
|
||||
self.rpc_register._skip_broadcast = False
|
||||
self.rpc_register.broadcast()
|
||||
82
bec_widgets/cli/rpc_register.py
Normal file
82
bec_widgets/cli/rpc_register.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
@@ -1,9 +1,6 @@
|
||||
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
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
|
||||
class RPCWidgetHandler:
|
||||
@@ -13,7 +10,7 @@ class RPCWidgetHandler:
|
||||
self._widget_classes = None
|
||||
|
||||
@property
|
||||
def widget_classes(self) -> dict[str, type[BECWidget]]:
|
||||
def widget_classes(self):
|
||||
"""
|
||||
Get the available widget classes.
|
||||
|
||||
@@ -22,7 +19,7 @@ class RPCWidgetHandler:
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
return self._widget_classes # type: ignore
|
||||
return self._widget_classes
|
||||
|
||||
def update_available_widgets(self):
|
||||
"""
|
||||
@@ -31,26 +28,27 @@ class RPCWidgetHandler:
|
||||
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
|
||||
}
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
def create_widget(self, widget_type, name: str | None = None, **kwargs) -> BECWidget:
|
||||
clss = get_custom_classes("bec_widgets")
|
||||
self._widget_classes = {cls.__name__: cls for cls in clss.widgets}
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECConnector:
|
||||
"""
|
||||
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(BECConnector): The created widget.
|
||||
"""
|
||||
widget_class = self.widget_classes.get(widget_type) # type: ignore
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
widget_class = self._widget_classes.get(widget_type)
|
||||
if widget_class:
|
||||
return widget_class(name=name, **kwargs)
|
||||
return widget_class(**kwargs)
|
||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager, redirect_stderr, redirect_stdout
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import Union
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
@@ -14,42 +11,18 @@ from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
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.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
from bec_widgets.widgets.containers.figure import BECFigure
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@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 BECWidgetsCLIServer:
|
||||
|
||||
def __init__(
|
||||
@@ -58,17 +31,16 @@ class BECWidgetsCLIServer:
|
||||
dispatcher: BECDispatcher = None,
|
||||
client=None,
|
||||
config=None,
|
||||
gui_class: type[BECDockArea] = BECDockArea,
|
||||
gui_class_id: str = "bec",
|
||||
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
|
||||
) -> 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.gui = gui_class(gui_id=self.gui_id)
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_callback(self.broadcast_registry_update)
|
||||
self.rpc_register.add_rpc(self.gui)
|
||||
|
||||
self.dispatcher.connect_slot(
|
||||
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
|
||||
@@ -80,27 +52,23 @@ class BECWidgetsCLIServer:
|
||||
self._heartbeat_timer.start(200)
|
||||
|
||||
self.status = messages.BECStatus.RUNNING
|
||||
with RPCRegister.delayed_broadcast():
|
||||
self.gui = gui_class(parent=None, name=gui_class_id, gui_id=gui_class_id)
|
||||
logger.success(f"Server started with gui_id: {self.gui_id}")
|
||||
# Create initial object -> BECFigure or BECDockArea
|
||||
|
||||
def on_rpc_update(self, msg: dict, metadata: dict):
|
||||
request_id = metadata.get("request_id")
|
||||
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 as e:
|
||||
logger.error(f"Error while executing RPC instruction: {traceback.format_exc()}")
|
||||
self.send_response(request_id, False, {"error": str(e)})
|
||||
else:
|
||||
logger.debug(f"RPC instruction executed successfully: {res}")
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
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:
|
||||
logger.error(f"Error while executing RPC instruction: {e}")
|
||||
self.send_response(request_id, False, {"error": str(e)})
|
||||
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(
|
||||
@@ -117,72 +85,50 @@ class BECWidgetsCLIServer:
|
||||
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
|
||||
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:
|
||||
res = method_obj(*args, **kwargs)
|
||||
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
|
||||
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):
|
||||
config = obj.config.model_dump()
|
||||
config["parent_id"] = obj.parent_id # add parent_id to config
|
||||
return {
|
||||
"gui_id": obj.gui_id,
|
||||
"name": (
|
||||
obj._name if hasattr(obj, "_name") else obj.__class__.__name__
|
||||
), # pylint: disable=protected-access
|
||||
"widget_class": obj.__class__.__name__,
|
||||
"config": config,
|
||||
"config": obj.config.model_dump(),
|
||||
"__rpc__": True,
|
||||
}
|
||||
return obj
|
||||
|
||||
def emit_heartbeat(self):
|
||||
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):
|
||||
"""
|
||||
Broadcast the updated registry to all clients.
|
||||
"""
|
||||
|
||||
# We only need to broadcast the dock areas
|
||||
data = {key: self.serialize_object(val) for key, val in connections.items()}
|
||||
self.client.connector.xadd(
|
||||
MessageEndpoints.gui_registry_state(self.gui_id),
|
||||
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
|
||||
max_size=1, # only single message in stream
|
||||
self.client.connector.set(
|
||||
MessageEndpoints.gui_heartbeat(self.gui_id),
|
||||
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
|
||||
expire=10,
|
||||
)
|
||||
|
||||
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
|
||||
logger.info(f"Shutting down server with gui_id: {self.gui_id}")
|
||||
self.status = messages.BECStatus.IDLE
|
||||
self._heartbeat_timer.stop()
|
||||
self.emit_heartbeat()
|
||||
logger.info("Succeded in shutting down gui")
|
||||
self.gui.close()
|
||||
self.client.shutdown()
|
||||
|
||||
|
||||
@@ -204,9 +150,7 @@ class SimpleFileLikeFromLogOutputFunc:
|
||||
return
|
||||
|
||||
|
||||
def _start_server(
|
||||
gui_id: str, gui_class: BECDockArea, gui_class_id: str = "bec", config: str | None = None
|
||||
):
|
||||
def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config: str | None = None):
|
||||
if config:
|
||||
try:
|
||||
config = json.loads(config)
|
||||
@@ -223,9 +167,7 @@ def _start_server(
|
||||
# service_name="BECWidgetsCLIServer",
|
||||
# service_config=service_config.service_config,
|
||||
# )
|
||||
server = BECWidgetsCLIServer(
|
||||
gui_id=gui_id, config=service_config, gui_class=gui_class, gui_class_id=gui_class_id
|
||||
)
|
||||
server = BECWidgetsCLIServer(gui_id=gui_id, config=service_config, gui_class=gui_class)
|
||||
return server
|
||||
|
||||
|
||||
@@ -239,31 +181,27 @@ def main():
|
||||
|
||||
import bec_widgets
|
||||
|
||||
bec_logger.level = bec_logger.LOGLEVEL.DEBUG
|
||||
if __name__ != "__main__":
|
||||
# if not running as main, set the log level to critical
|
||||
# pylint: disable=protected-access
|
||||
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.CRITICAL
|
||||
|
||||
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")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
bec_logger.level = bec_logger.LOGLEVEL.INFO
|
||||
if args.hide:
|
||||
# pylint: disable=protected-access
|
||||
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
|
||||
bec_logger._update_sinks()
|
||||
|
||||
if args.gui_class == "BECDockArea":
|
||||
if args.gui_class == "BECFigure":
|
||||
gui_class = BECFigure
|
||||
elif args.gui_class == "BECDockArea":
|
||||
gui_class = BECDockArea
|
||||
else:
|
||||
print(
|
||||
@@ -275,10 +213,8 @@ def main():
|
||||
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
|
||||
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
|
||||
app = QApplication(sys.argv)
|
||||
# set close on last window, only if not under control of client ;
|
||||
# indeed, Qt considers a hidden window a closed window, so if all windows
|
||||
# are hidden by default it exits
|
||||
app.setQuitOnLastWindowClosed(not args.hide)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
app.setApplicationName("BEC Figure")
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
@@ -286,19 +222,18 @@ def main():
|
||||
size=QSize(48, 48),
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
# store gui id within QApplication object, to make it available to all widgets
|
||||
app.gui_id = args.id
|
||||
|
||||
# args.id = "abff6"
|
||||
server = _start_server(args.id, gui_class, args.gui_class_id, args.config)
|
||||
server = _start_server(args.id, gui_class, args.config)
|
||||
|
||||
win = BECMainWindow(gui_id=f"{server.gui_id}:window")
|
||||
win.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
win.setWindowTitle("BEC")
|
||||
win.setWindowTitle("BEC Widgets")
|
||||
|
||||
RPCRegister().add_rpc(win)
|
||||
|
||||
gui = server.gui
|
||||
win.setCentralWidget(gui)
|
||||
win.resize(800, 600)
|
||||
if not args.hide:
|
||||
win.show()
|
||||
|
||||
@@ -307,19 +242,14 @@ def main():
|
||||
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():
|
||||
widget.close()
|
||||
app.quit()
|
||||
|
||||
# gui.bec.close()
|
||||
# win.shutdown()
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
signal.signal(signal.SIGTERM, sigint_handler)
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.argv = ["bec_widgets.cli.server", "--id", "e2860", "--gui_class", "BECDockArea"]
|
||||
main()
|
||||
|
||||
@@ -7,7 +7,6 @@ from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
@@ -15,16 +14,10 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
from bec_widgets.widgets.containers.figure import BECFigure
|
||||
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
|
||||
|
||||
|
||||
class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
@@ -41,24 +34,27 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
{
|
||||
"np": np,
|
||||
"pg": pg,
|
||||
"wh": wh,
|
||||
"fig": self.figure,
|
||||
"dock": self.dock,
|
||||
"w1": self.w1,
|
||||
"w2": self.w2,
|
||||
"w3": self.w3,
|
||||
"w4": self.w4,
|
||||
"w5": self.w5,
|
||||
"w6": self.w6,
|
||||
"w7": self.w7,
|
||||
"w8": self.w8,
|
||||
"w9": self.w9,
|
||||
"w10": self.w10,
|
||||
"d0": self.d0,
|
||||
"d1": self.d1,
|
||||
"d2": self.d2,
|
||||
"wave": self.wf,
|
||||
# "bar": self.bar,
|
||||
# "cm": self.colormap,
|
||||
"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,
|
||||
"mw": self.mw,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -77,85 +73,119 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
first_tab_layout.addWidget(self.dock)
|
||||
tab_widget.addTab(first_tab, "Dock Area")
|
||||
|
||||
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)
|
||||
second_tab = QWidget()
|
||||
second_tab_layout = QVBoxLayout(second_tab)
|
||||
self.figure = BECFigure(parent=self, gui_id="figure")
|
||||
second_tab_layout.addWidget(self.figure)
|
||||
tab_widget.addTab(second_tab, "BEC Figure")
|
||||
|
||||
group_box = QGroupBox("Jupyter Console", splitter)
|
||||
group_box_layout = QVBoxLayout(group_box)
|
||||
self.console = BECJupyterConsole(inprocess=True)
|
||||
group_box_layout.addWidget(self.console)
|
||||
|
||||
# Some buttons for layout testing
|
||||
self.btn1 = QPushButton("Button 1")
|
||||
self.btn2 = QPushButton("Button 2")
|
||||
self.btn3 = QPushButton("Button 3")
|
||||
self.btn4 = QPushButton("Button 4")
|
||||
self.btn5 = QPushButton("Button 5")
|
||||
self.btn6 = QPushButton("Button 6")
|
||||
# add stuff to figure
|
||||
self._init_figure()
|
||||
|
||||
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()
|
||||
# init dock for testing
|
||||
self._init_dock()
|
||||
|
||||
self.setWindowTitle("Jupyter Console Window")
|
||||
|
||||
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.w1 = self.figure.plot(
|
||||
x_name="samx",
|
||||
y_name="bpm4i",
|
||||
# title="Standard Plot with sync device, custom labels - w1",
|
||||
# x_label="Motor Position",
|
||||
# y_label="Intensity (A.U.)",
|
||||
row=0,
|
||||
col=0,
|
||||
)
|
||||
self.w1.set(
|
||||
title="Standard Plot with sync device, custom labels - w1",
|
||||
x_label="Motor Position",
|
||||
y_label="Intensity (A.U.)",
|
||||
)
|
||||
self.w2 = self.figure.motor_map("samx", "samy", row=0, col=1)
|
||||
self.w3 = self.figure.image(
|
||||
"eiger", color_map="viridis", vrange=(0, 100), title="Eiger Image - w3", row=0, col=2
|
||||
)
|
||||
self.w4 = self.figure.plot(
|
||||
x_name="samx",
|
||||
y_name="samy",
|
||||
z_name="bpm4i",
|
||||
color_map_z="magma",
|
||||
new=True,
|
||||
title="2D scatter plot - w4",
|
||||
row=0,
|
||||
col=3,
|
||||
)
|
||||
self.w5 = self.figure.plot(
|
||||
y_name="bpm4i",
|
||||
new=True,
|
||||
title="Best Effort Plot - w5",
|
||||
dap="GaussianModel",
|
||||
row=1,
|
||||
col=0,
|
||||
)
|
||||
self.w6 = self.figure.plot(
|
||||
x_name="timestamp", y_name="bpm4i", new=True, title="Timestamp Plot - w6", row=1, col=1
|
||||
)
|
||||
self.w7 = self.figure.plot(
|
||||
x_name="index", y_name="bpm4i", new=True, title="Index Plot - w7", row=1, col=2
|
||||
)
|
||||
self.w8 = self.figure.plot(
|
||||
y_name="monitor_async", new=True, title="Async Plot - Best Effort - w8", row=2, col=0
|
||||
)
|
||||
self.w9 = self.figure.plot(
|
||||
x_name="timestamp",
|
||||
y_name="monitor_async",
|
||||
new=True,
|
||||
title="Async Plot - timestamp - w9",
|
||||
row=2,
|
||||
col=1,
|
||||
)
|
||||
self.w10 = self.figure.plot(
|
||||
x_name="index",
|
||||
y_name="monitor_async",
|
||||
new=True,
|
||||
title="Async Plot - index - w10",
|
||||
row=2,
|
||||
col=2,
|
||||
)
|
||||
|
||||
def _init_dock(self):
|
||||
|
||||
self.d0 = self.dock.add_dock(name="dock_0")
|
||||
self.mm = self.d0.add_widget("BECMotorMapWidget")
|
||||
self.mm.change_motors("samx", "samy")
|
||||
|
||||
self.d1 = self.dock.add_dock(name="dock_1", position="right")
|
||||
self.im = self.d1.add_widget("BECImageWidget")
|
||||
self.im.image("waveform", "1d")
|
||||
|
||||
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
|
||||
self.wf = self.d2.add_widget("BECFigure", row=0, col=0)
|
||||
|
||||
self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
|
||||
# self.wf.plot(x_name="samx", y_name="bpm3a")
|
||||
# self.wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
# self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
|
||||
# self.bar.set_diameter(200)
|
||||
|
||||
# self.d3 = self.dock.add_dock(name="dock_3", position="bottom")
|
||||
# self.colormap = pg.GradientWidget()
|
||||
# self.d3.add_widget(self.colormap, row=0, col=0)
|
||||
|
||||
self.dock.save_state()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Override to handle things when main window is closed."""
|
||||
self.dock.cleanup()
|
||||
self.dock.close()
|
||||
self.figure.cleanup()
|
||||
self.figure.close()
|
||||
self.console.close()
|
||||
|
||||
super().closeEvent(event)
|
||||
@@ -171,7 +201,8 @@ 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)
|
||||
apply_theme("dark")
|
||||
icon = material_icon("terminal", color="#434343", filled=True)
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
@@ -180,7 +211,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
win = JupyterConsoleWindow()
|
||||
win.show()
|
||||
win.resize(1500, 800)
|
||||
win.resize(1200, 800)
|
||||
|
||||
app.aboutToQuit.connect(win.close)
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -6,7 +6,7 @@ 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
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
|
||||
|
||||
class TicTacToeDialog(QDialog): # pragma: no cover
|
||||
|
||||
@@ -2,93 +2,9 @@ import functools
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
|
||||
from qtpy.QtCore import 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
|
||||
@@ -106,13 +22,7 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
try:
|
||||
return method(*args, **kwargs)
|
||||
except Exception:
|
||||
slot_name = f"{method.__module__}.{method.__qualname__}"
|
||||
error_msg = traceback.format_exc()
|
||||
if popup_error:
|
||||
ErrorPopupUtility().custom_exception_hook(
|
||||
*sys.exc_info(), popup_error=popup_error
|
||||
)
|
||||
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
|
||||
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=popup_error)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -181,12 +91,6 @@ class _ErrorPopupUtility(QObject):
|
||||
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.
|
||||
@@ -223,14 +127,12 @@ class _ErrorPopupUtility(QObject):
|
||||
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:
|
||||
error_message = traceback.format_exception(exctype, value, tb)
|
||||
self.error_occurred.emit(
|
||||
"Method error" if popup_error else "Application Error",
|
||||
self.get_error_message(exctype, value, tb),
|
||||
"".join(error_message),
|
||||
self.parent(),
|
||||
)
|
||||
else:
|
||||
@@ -1,6 +1,6 @@
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
|
||||
|
||||
class SettingWidget(QWidget):
|
||||
@@ -20,14 +20,14 @@ class SettingWidget(QWidget):
|
||||
def set_target_widget(self, target_widget: QWidget):
|
||||
self.target_widget = target_widget
|
||||
|
||||
@SafeSlot()
|
||||
@Slot()
|
||||
def accept_changes(self):
|
||||
"""
|
||||
Accepts the changes made in the settings widget and applies them to the target widget.
|
||||
"""
|
||||
pass
|
||||
|
||||
@SafeSlot(dict)
|
||||
@Slot(dict)
|
||||
def display_current_settings(self, config_dict: dict):
|
||||
"""
|
||||
Displays the current settings of the target widget in the settings widget.
|
||||
@@ -54,13 +54,12 @@ class SettingsDialog(QDialog):
|
||||
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.setModal(False)
|
||||
|
||||
self.setWindowTitle(window_title)
|
||||
|
||||
@@ -93,7 +92,7 @@ class SettingsDialog(QDialog):
|
||||
ok_button.setDefault(True)
|
||||
ok_button.setAutoDefault(True)
|
||||
|
||||
@SafeSlot()
|
||||
@Slot()
|
||||
def accept(self):
|
||||
"""
|
||||
Accept the changes made in the settings widget and close the dialog.
|
||||
@@ -101,7 +100,7 @@ class SettingsDialog(QDialog):
|
||||
self.widget.accept_changes()
|
||||
super().accept()
|
||||
|
||||
@SafeSlot()
|
||||
@Slot()
|
||||
def apply_changes(self):
|
||||
"""
|
||||
Apply the changes made in the settings widget without closing the dialog.
|
||||
296
bec_widgets/qt_utils/toolbar.py
Normal file
296
bec_widgets/qt_utils/toolbar.py
Normal file
@@ -0,0 +1,296 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from typing import Literal
|
||||
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QAction, QColor, QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QSizePolicy,
|
||||
QToolBar,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class ToolBarAction(ABC):
|
||||
"""
|
||||
Abstract base class for toolbar actions.
|
||||
|
||||
Args:
|
||||
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
|
||||
tooltip (bool, optional): The tooltip for the action. Defaults to None.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||
"""
|
||||
|
||||
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
|
||||
self.icon_path = (
|
||||
os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None
|
||||
)
|
||||
self.tooltip = tooltip
|
||||
self.checkable = checkable
|
||||
self.action = None
|
||||
|
||||
@abstractmethod
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""Adds an action or widget to a toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the action or widget to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
|
||||
|
||||
class SeparatorAction(ToolBarAction):
|
||||
"""Separator action for the toolbar."""
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
toolbar.addSeparator()
|
||||
|
||||
|
||||
class IconAction(ToolBarAction):
|
||||
"""
|
||||
Action with an icon for the toolbar.
|
||||
|
||||
Args:
|
||||
icon_path (str): The path to the icon file.
|
||||
tooltip (str): The tooltip for the action.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||
"""
|
||||
|
||||
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
|
||||
super().__init__(icon_path, tooltip, checkable)
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
icon = QIcon()
|
||||
icon.addFile(self.icon_path, size=QSize(20, 20))
|
||||
self.action = QAction(icon, self.tooltip, target)
|
||||
self.action.setCheckable(self.checkable)
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
|
||||
class MaterialIconAction:
|
||||
"""
|
||||
Action with a Material icon for the toolbar.
|
||||
|
||||
Args:
|
||||
icon_path (str, optional): The name of the Material icon. Defaults to None.
|
||||
tooltip (bool, optional): The tooltip for the action. Defaults to None.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||
filled (bool, optional): Whether the icon is filled. Defaults to False.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
icon_name: str = None,
|
||||
tooltip: str = None,
|
||||
checkable: bool = False,
|
||||
filled: bool = False,
|
||||
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
|
||||
):
|
||||
self.icon_name = icon_name
|
||||
self.tooltip = tooltip
|
||||
self.checkable = checkable
|
||||
self.action = None
|
||||
self.filled = filled
|
||||
self.color = color
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
icon = self.get_icon()
|
||||
self.action = QAction(icon, self.tooltip, target)
|
||||
self.action.setCheckable(self.checkable)
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
|
||||
icon = material_icon(
|
||||
self.icon_name,
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
filled=self.filled,
|
||||
color=self.color,
|
||||
)
|
||||
return icon
|
||||
|
||||
|
||||
class DeviceSelectionAction(ToolBarAction):
|
||||
"""
|
||||
Action for selecting a device in a combobox.
|
||||
|
||||
Args:
|
||||
label (str): The label for the combobox.
|
||||
device_combobox (DeviceComboBox): The combobox for selecting the device.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, label: str, device_combobox):
|
||||
super().__init__()
|
||||
self.label = label
|
||||
self.device_combobox = device_combobox
|
||||
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
|
||||
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
widget = QWidget()
|
||||
layout = QHBoxLayout(widget)
|
||||
label = QLabel(f"{self.label}")
|
||||
layout.addWidget(label)
|
||||
layout.addWidget(self.device_combobox)
|
||||
toolbar.addWidget(widget)
|
||||
|
||||
def set_combobox_style(self, color: str):
|
||||
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
|
||||
|
||||
|
||||
class WidgetAction(ToolBarAction):
|
||||
"""
|
||||
Action for adding any widget to the toolbar.
|
||||
|
||||
Args:
|
||||
label (str|None): The label for the widget.
|
||||
widget (QWidget): The widget to be added to the toolbar.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.label = label
|
||||
self.widget = widget
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
container = QWidget()
|
||||
layout = QHBoxLayout(container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(5)
|
||||
|
||||
if self.label is not None:
|
||||
label_widget = QLabel(f"{self.label}")
|
||||
label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
|
||||
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
|
||||
layout.addWidget(label_widget)
|
||||
|
||||
if isinstance(self.widget, QComboBox):
|
||||
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||
|
||||
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.widget.setSizePolicy(size_policy)
|
||||
|
||||
self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget))
|
||||
|
||||
else:
|
||||
self.widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
layout.addWidget(self.widget)
|
||||
|
||||
toolbar.addWidget(container)
|
||||
|
||||
@staticmethod
|
||||
def calculate_minimum_width(combo_box: QComboBox) -> int:
|
||||
"""
|
||||
Calculate the minimum width required to display the longest item in the combo box.
|
||||
|
||||
Args:
|
||||
combo_box (QComboBox): The combo box to calculate the width for.
|
||||
|
||||
Returns:
|
||||
int: The calculated minimum width in pixels.
|
||||
"""
|
||||
font_metrics = combo_box.fontMetrics()
|
||||
max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count()))
|
||||
return max_width + 60
|
||||
|
||||
|
||||
class ExpandableMenuAction(ToolBarAction):
|
||||
"""
|
||||
Action for an expandable menu in the toolbar.
|
||||
|
||||
Args:
|
||||
label (str): The label for the menu.
|
||||
actions (dict): A dictionary of actions to populate the menu.
|
||||
icon_path (str, optional): The path to the icon file. Defaults to None.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, label: str, actions: dict, icon_path: str = None):
|
||||
super().__init__(icon_path, label)
|
||||
self.actions = actions
|
||||
self.widgets = defaultdict(dict)
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
button = QToolButton(toolbar)
|
||||
if self.icon_path:
|
||||
button.setIcon(QIcon(self.icon_path))
|
||||
button.setText(self.tooltip)
|
||||
button.setPopupMode(QToolButton.InstantPopup)
|
||||
button.setStyleSheet(
|
||||
"""
|
||||
QToolButton {
|
||||
font-size: 14px;
|
||||
}
|
||||
QMenu {
|
||||
font-size: 14px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
menu = QMenu(button)
|
||||
for action_id, action in self.actions.items():
|
||||
sub_action = QAction(action.tooltip, target)
|
||||
if hasattr(action, "icon_path"):
|
||||
icon = QIcon()
|
||||
icon.addFile(action.icon_path, size=QSize(20, 20))
|
||||
sub_action.setIcon(icon)
|
||||
elif hasattr(action, "get_icon"):
|
||||
sub_action.setIcon(action.get_icon())
|
||||
sub_action.setCheckable(action.checkable)
|
||||
menu.addAction(sub_action)
|
||||
self.widgets[action_id] = sub_action
|
||||
button.setMenu(menu)
|
||||
toolbar.addWidget(button)
|
||||
|
||||
|
||||
class ModularToolBar(QToolBar):
|
||||
"""Modular toolbar with optional automatic initialization.
|
||||
Args:
|
||||
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
|
||||
actions (list[ToolBarAction], optional): A list of action creators to populate the toolbar. Defaults to None.
|
||||
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, actions: dict | None = None, target_widget=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.widgets = defaultdict(dict)
|
||||
self.set_background_color()
|
||||
|
||||
if actions is not None and target_widget is not None:
|
||||
self.populate_toolbar(actions, target_widget)
|
||||
|
||||
def populate_toolbar(self, actions: dict, target_widget):
|
||||
"""Populates the toolbar with a set of actions.
|
||||
|
||||
Args:
|
||||
actions (list[ToolBarAction]): A list of action creators to populate the toolbar.
|
||||
target_widget (QWidget): The widget that the actions will target.
|
||||
"""
|
||||
self.clear()
|
||||
for action_id, action in actions.items():
|
||||
action.add_to_toolbar(self, target_widget)
|
||||
self.widgets[action_id] = action
|
||||
|
||||
def set_background_color(self):
|
||||
self.setIconSize(QSize(20, 20))
|
||||
self.setMovable(False)
|
||||
self.setFloatable(False)
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self.setStyleSheet("QToolBar { background-color: rgba(0, 0, 0, 0); border: none; }")
|
||||
@@ -224,11 +224,3 @@ DEVICES = [
|
||||
Positioner("test", limits=[-10, 10], read_value=2.0),
|
||||
Device("test_device"),
|
||||
]
|
||||
|
||||
|
||||
def check_remote_data_size(widget, plot_name, num_elements):
|
||||
"""
|
||||
Check if the remote data has the correct number of elements.
|
||||
Used in the qtbot.waitUntil function.
|
||||
"""
|
||||
return len(widget.get_all_data()[plot_name]["x"]) == num_elements
|
||||
|
||||
@@ -4,8 +4,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import Optional
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
@@ -13,16 +12,11 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.utils.error_popups import SafeSlot as pyqtSlot
|
||||
from bec_widgets.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
|
||||
logger = bec_logger.logger
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
@@ -42,7 +36,8 @@ 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
|
||||
|
||||
|
||||
@@ -77,19 +72,10 @@ class BECConnector:
|
||||
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,
|
||||
name: str | None = None,
|
||||
parent_dock: BECDock | None = None,
|
||||
parent_id: str | None = None,
|
||||
):
|
||||
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
|
||||
|
||||
if not self.client in BECConnector.EXIT_HANDLERS:
|
||||
# register function to clean connections at exit;
|
||||
@@ -114,20 +100,15 @@ class BECConnector:
|
||||
)
|
||||
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
|
||||
self.parent_id = parent_id
|
||||
# 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
|
||||
if name is None:
|
||||
name = self.__class__.__name__
|
||||
else:
|
||||
if not WidgetContainerUtils.has_name_valid_chars(name):
|
||||
raise ValueError(f"Name {name} contains invalid characters.")
|
||||
self._name = name if name else self.__class__.__name__
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
# register widget to rpc register
|
||||
# be careful: when registering, and the object is not a BECWidget,
|
||||
# cleanup has to called manually since there is no 'closeEvent'
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_rpc(self)
|
||||
|
||||
@@ -135,8 +116,6 @@ class BECConnector:
|
||||
self.error_utility = ErrorPopupUtility()
|
||||
|
||||
self._thread_pool = QThreadPool.globalInstance()
|
||||
# Store references to running workers so they're not garbage collected prematurely.
|
||||
self._workers = []
|
||||
|
||||
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
|
||||
"""
|
||||
@@ -165,14 +144,11 @@ class BECConnector:
|
||||
>>> 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
|
||||
|
||||
@@ -204,39 +180,37 @@ class BECConnector:
|
||||
@_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.
|
||||
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.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.
|
||||
path(str): 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)
|
||||
@@ -255,8 +229,8 @@ class BECConnector:
|
||||
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.
|
||||
path(str): 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)
|
||||
@@ -264,15 +238,16 @@ class BECConnector:
|
||||
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)
|
||||
|
||||
# @pyqtSlot(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
|
||||
@@ -293,7 +268,7 @@ 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()
|
||||
@@ -304,72 +279,25 @@ class BECConnector:
|
||||
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.
|
||||
if self._parent_dock is not None:
|
||||
self._parent_dock.delete(self._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
|
||||
|
||||
|
||||
# --- 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_())
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import ast
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import inspect
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import site
|
||||
@@ -10,14 +6,9 @@ import sys
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import PYSIDE6
|
||||
from qtpy.QtGui import QIcon
|
||||
from zmq import PLAIN
|
||||
|
||||
from bec_widgets.utils.bec_plugin_helper import user_widget_plugin
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
if PYSIDE6:
|
||||
from PySide6.scripts.pyside_tool import (
|
||||
@@ -31,8 +22,6 @@ if PYSIDE6:
|
||||
|
||||
import bec_widgets
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def designer_material_icon(icon_name: str) -> QIcon:
|
||||
"""
|
||||
@@ -104,24 +93,17 @@ def patch_designer(): # pragma: no cover
|
||||
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
|
||||
else:
|
||||
if sys.platform == "linux":
|
||||
suffix = f"{sys.abiflags}.so"
|
||||
env_var = "LD_PRELOAD"
|
||||
current_pid = os.getpid()
|
||||
with open(f"/proc/{current_pid}/maps", "rt") as f:
|
||||
for line in f:
|
||||
if "libpython" in line:
|
||||
lib_path = line.split()[-1]
|
||||
os.environ[env_var] = lib_path
|
||||
break
|
||||
|
||||
elif sys.platform == "darwin":
|
||||
suffix = ".dylib"
|
||||
env_var = "DYLD_INSERT_LIBRARIES"
|
||||
version = f"{major_version}.{minor_version}"
|
||||
library_name = f"libpython{version}{suffix}"
|
||||
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
|
||||
os.environ[env_var] = lib_path
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {sys.platform}")
|
||||
version = f"{major_version}.{minor_version}"
|
||||
library_name = f"libpython{version}{suffix}"
|
||||
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
|
||||
os.environ[env_var] = lib_path
|
||||
|
||||
if is_pyenv_python() or is_virtual_env():
|
||||
# append all editable packages to the PYTHONPATH
|
||||
@@ -131,43 +113,14 @@ def patch_designer(): # pragma: no cover
|
||||
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
|
||||
|
||||
|
||||
def _plugin_classes_for_python_file(file: Path):
|
||||
logger.debug(f"getting plugin classes for {file}")
|
||||
if not str(file).endswith(".py"):
|
||||
raise ValueError("Please pass a python file")
|
||||
spec = importlib.util.spec_from_file_location("_temp", file)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["_temp"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
plugin_widgets = list(
|
||||
mem[0]
|
||||
for mem in inspect.getmembers(mod, inspect.isclass)
|
||||
if issubclass(mem[1], BECWidget) and hasattr(mem[1], "PLUGIN") and mem[1].PLUGIN is True
|
||||
)
|
||||
logger.debug(f"Found: {plugin_widgets}")
|
||||
return plugin_widgets
|
||||
|
||||
|
||||
def _plugin_classes_for_pyproject(path: Path):
|
||||
if not str(path).endswith(".pyproject"):
|
||||
raise ValueError("Please pass the path of the designer pyproject file")
|
||||
with open(path) as pyproject:
|
||||
plugin_filenames = ast.literal_eval(pyproject.read())["files"]
|
||||
plugin_files = (path.parent / file for file in plugin_filenames)
|
||||
return itertools.chain(*(_plugin_classes_for_python_file(f) for f in plugin_files))
|
||||
|
||||
|
||||
def find_plugin_paths(base_path: Path) -> dict[str, list[str]]:
|
||||
def find_plugin_paths(base_path: Path):
|
||||
"""
|
||||
Recursively find all directories containing a .pyproject file. Returns a dictionary with keys of
|
||||
such paths, and values of the names of the classes contained in them if those classes are
|
||||
desginer plugins.
|
||||
Recursively find all directories containing a .pyproject file.
|
||||
"""
|
||||
return {
|
||||
str(path.parent): list(_plugin_classes_for_pyproject(path))
|
||||
for path in base_path.rglob("*.pyproject")
|
||||
}
|
||||
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):
|
||||
@@ -184,33 +137,14 @@ def set_plugin_environment_variable(plugin_paths):
|
||||
os.environ["PYSIDE_DESIGNER_PLUGINS"] = os.pathsep.join(current_paths)
|
||||
|
||||
|
||||
def _extend_plugin_paths(plugin_paths: dict[str, list[str]], plugin_repo_dir: Path):
|
||||
plugin_plugin_paths = find_plugin_paths(plugin_repo_dir)
|
||||
builtin_plugin_names = list(itertools.chain(*plugin_paths.values()))
|
||||
for plugin_file, plugin_classes in plugin_plugin_paths.items():
|
||||
logger.info(f"{plugin_classes} {builtin_plugin_names}")
|
||||
if any(name in builtin_plugin_names for name in plugin_classes):
|
||||
logger.warning(
|
||||
f"Ignoring plugin {plugin_file} because it contains widgets {plugin_classes} which include duplicates of built-in widgets!"
|
||||
)
|
||||
else:
|
||||
plugin_paths[plugin_file] = plugin_classes
|
||||
|
||||
|
||||
# Patch the designer function
|
||||
def main(): # 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()
|
||||
_extend_plugin_paths(plugin_paths, plugin_repo_dir)
|
||||
|
||||
set_plugin_environment_variable(plugin_paths.keys())
|
||||
set_plugin_environment_variable(plugin_paths)
|
||||
|
||||
patch_designer()
|
||||
|
||||
|
||||
@@ -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()
|
||||
...
|
||||
@@ -5,43 +5,28 @@ analyse data. Requesting a new fit may lead to request piling up and an overall
|
||||
will allow you to decide by yourself when to unblock and execute the callback again."""
|
||||
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from qtpy.QtCore import Signal, Slot
|
||||
|
||||
|
||||
class BECSignalProxy(SignalProxy):
|
||||
"""
|
||||
Thin wrapper around the SignalProxy class to allow signal calls to be blocked,
|
||||
but arguments still being stored.
|
||||
"""Thin wrapper around the SignalProxy class to allow signal calls to be blocked, but args still being stored
|
||||
|
||||
Args:
|
||||
*args: Arguments to pass to the SignalProxy class.
|
||||
rateLimit (int): The rateLimit of the proxy.
|
||||
timeout (float): The number of seconds after which the proxy automatically
|
||||
unblocks if still blocked. Default is 10.0 seconds.
|
||||
**kwargs: Keyword arguments to pass to the SignalProxy class.
|
||||
*args: Arguments to pass to the SignalProxy class
|
||||
rateLimit (int): The rateLimit of the proxy
|
||||
**kwargs: Keyword arguments to pass to the SignalProxy class
|
||||
|
||||
Example:
|
||||
>>> proxy = BECSignalProxy(signal, rate_limit=25, slot=callback)
|
||||
"""
|
||||
>>> proxy = BECSignalProxy(signal, rate_limit=25, slot=callback)"""
|
||||
|
||||
is_blocked = Signal(bool)
|
||||
|
||||
def __init__(self, *args, rateLimit=25, timeout=10.0, **kwargs):
|
||||
def __init__(self, *args, rateLimit=25, **kwargs):
|
||||
super().__init__(*args, rateLimit=rateLimit, **kwargs)
|
||||
self._blocking = False
|
||||
self.old_args = None
|
||||
self.new_args = None
|
||||
|
||||
# Store timeout value (in seconds)
|
||||
self._timeout = timeout
|
||||
|
||||
# Create a single-shot timer for auto-unblocking
|
||||
self._timer = QTimer()
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._timeout_unblock)
|
||||
|
||||
@property
|
||||
def blocked(self):
|
||||
"""Returns if the proxy is blocked"""
|
||||
@@ -61,30 +46,9 @@ class BECSignalProxy(SignalProxy):
|
||||
self.old_args = args
|
||||
super().signalReceived(*args)
|
||||
|
||||
self._timer.start(int(self._timeout * 1000))
|
||||
|
||||
@SafeSlot()
|
||||
@Slot()
|
||||
def unblock_proxy(self):
|
||||
"""Unblock the proxy, and call the signalReceived method in case there was an update of the args."""
|
||||
if self.blocked:
|
||||
self._timer.stop()
|
||||
self.blocked = False
|
||||
if self.new_args != self.old_args:
|
||||
self.signalReceived(*self.new_args)
|
||||
|
||||
@SafeSlot()
|
||||
def _timeout_unblock(self):
|
||||
"""
|
||||
Internal method called by the QTimer upon timeout. Unblocks the proxy
|
||||
automatically if it is still blocked.
|
||||
"""
|
||||
if self.blocked:
|
||||
self.unblock_proxy()
|
||||
|
||||
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()
|
||||
self.blocked = False
|
||||
if self.new_args != self.old_args:
|
||||
self.signalReceived(*self.new_args)
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import darkdetect
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Slot
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -23,19 +17,13 @@ class BECWidget(BECConnector):
|
||||
# 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,
|
||||
gui_id: str = None,
|
||||
theme_update: bool = False,
|
||||
name: str | None = None,
|
||||
parent_dock: BECDock | None = None,
|
||||
parent_id: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Base class for all BEC widgets. This class should be used as a mixin class for all BEC widgets, e.g.:
|
||||
@@ -56,15 +44,9 @@ class BECWidget(BECConnector):
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
|
||||
super().__init__(
|
||||
client=client,
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
name=name,
|
||||
parent_dock=parent_dock,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
# Set the theme to auto if it is not set yet
|
||||
app = QApplication.instance()
|
||||
if not hasattr(app, "theme"):
|
||||
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
|
||||
@@ -84,7 +66,7 @@ class BECWidget(BECConnector):
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self._update_theme)
|
||||
|
||||
def _update_theme(self, theme: str | None = None):
|
||||
def _update_theme(self, theme: str):
|
||||
"""Update the theme."""
|
||||
if theme is None:
|
||||
qapp = QApplication.instance()
|
||||
@@ -105,13 +87,10 @@ class BECWidget(BECConnector):
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
with RPCRegister.delayed_broadcast():
|
||||
# All widgets need to call super().cleanup() in their cleanup method
|
||||
self.rpc_register.remove_rpc(self)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
self.rpc_register.remove_rpc(self)
|
||||
try:
|
||||
self.cleanup()
|
||||
finally:
|
||||
super().closeEvent(event) # pylint: disable=no-member
|
||||
super().closeEvent(event)
|
||||
|
||||
@@ -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())
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
@@ -70,64 +71,15 @@ 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))
|
||||
# go through all pyqtgraph widgets and set background
|
||||
children = itertools.chain.from_iterable(
|
||||
top.findChildren(pg.GraphicsLayoutWidget) for top in app.topLevelWidgets()
|
||||
)
|
||||
pg.setConfigOptions(
|
||||
foreground="d" if theme == "dark" else "k", background="k" if theme == "dark" else "w"
|
||||
)
|
||||
for pg_widget in children:
|
||||
pg_widget.setBackground("k" if theme == "dark" else "w")
|
||||
|
||||
# now define stylesheet according to theme and apply it
|
||||
style = bec_qthemes.load_stylesheet(theme)
|
||||
|
||||
@@ -1,55 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from typing import Literal, Type
|
||||
from typing import Type
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
|
||||
|
||||
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(
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
@@ -200,18 +197,15 @@ class Crosshair(QObject):
|
||||
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[defaultdict[list], defaultdict[list]]:
|
||||
"""
|
||||
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 of the mouse cursor
|
||||
y: The y-coordinate of the mouse cursor
|
||||
|
||||
Returns:
|
||||
tuple: x and y values snapped to the nearest data
|
||||
@@ -241,7 +235,7 @@ class Crosshair(QObject):
|
||||
y_values[name] = closest_y
|
||||
x_values[name] = closest_x
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
name = item.config.monitor or str(id(item))
|
||||
name = item.config.monitor
|
||||
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))
|
||||
@@ -326,7 +320,7 @@ class Crosshair(QObject):
|
||||
)
|
||||
self.coordinatesChanged1D.emit(coordinate_to_emit)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.config.monitor or str(id(item))
|
||||
name = item.config.monitor
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
@@ -380,7 +374,7 @@ class Crosshair(QObject):
|
||||
)
|
||||
self.coordinatesClicked1D.emit(coordinate_to_emit)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.config.monitor or str(id(item))
|
||||
name = item.config.monitor
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
@@ -424,17 +418,9 @@ class Crosshair(QObject):
|
||||
"""
|
||||
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.setText(f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})")
|
||||
self.coord_label.setPos(x, y)
|
||||
self.coord_label.setVisible(True)
|
||||
|
||||
@@ -450,9 +436,6 @@ class Crosshair(QObject):
|
||||
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)
|
||||
|
||||
@@ -22,9 +22,7 @@ class EntryValidator:
|
||||
if entry is None or entry == "":
|
||||
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
|
||||
|
||||
|
||||
@@ -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._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
|
||||
)
|
||||
)
|
||||
@@ -1,23 +1,12 @@
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
from typing import NamedTuple
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
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
|
||||
@@ -64,15 +53,11 @@ class DesignerPluginGenerator:
|
||||
self._excluded = True
|
||||
return
|
||||
|
||||
self.templates: dict[str, str] = {}
|
||||
self.templates = {}
|
||||
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.")
|
||||
@@ -127,18 +112,26 @@ class DesignerPluginGenerator:
|
||||
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)
|
||||
self._write_register()
|
||||
self._write_plugin()
|
||||
self._write_pyproject()
|
||||
|
||||
def _write_register(self):
|
||||
file_path = os.path.join(self.info.base_path, f"register_{self.info.plugin_name_snake}.py")
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(self.templates["register"].format(**self.info.__dict__))
|
||||
|
||||
def _write_plugin(self):
|
||||
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}_plugin.py")
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(self.templates["plugin"].format(**self.info.__dict__))
|
||||
|
||||
def _write_pyproject(self):
|
||||
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}.pyproject")
|
||||
out = {"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]}
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(str(out))
|
||||
|
||||
def _load_templates(self):
|
||||
for file in os.listdir(self.template_path):
|
||||
|
||||
@@ -148,7 +148,10 @@ class BECTickItem(BECIndicatorItem):
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup the item"""
|
||||
self.remove_from_plot()
|
||||
self.tick_item = None
|
||||
if self.tick_item is not None:
|
||||
self.tick_item.close()
|
||||
self.tick_item.deleteLater()
|
||||
self.tick_item = None
|
||||
|
||||
|
||||
class BECArrowItem(BECIndicatorItem):
|
||||
@@ -171,7 +174,7 @@ class BECArrowItem(BECIndicatorItem):
|
||||
|
||||
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 = pg.ArrowItem(parent=parent)
|
||||
self.arrow_item.skip_auto_range = True
|
||||
self._pos = (0, 0)
|
||||
self.arrow_item.setVisible(False)
|
||||
|
||||
@@ -58,10 +58,7 @@ class BECClassInfo:
|
||||
|
||||
class BECClassContainer:
|
||||
def __init__(self):
|
||||
self._collection: list[BECClassInfo] = []
|
||||
|
||||
def __repr__(self):
|
||||
return str(list(cl.name for cl in self.collection))
|
||||
self._collection = []
|
||||
|
||||
def add_class(self, class_info: BECClassInfo):
|
||||
"""
|
||||
|
||||
@@ -1,152 +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,
|
||||
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
|
||||
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()
|
||||
@@ -1,377 +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.setObjectName("SidePanel")
|
||||
|
||||
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(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(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
@@ -1,7 +1,6 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -10,7 +9,6 @@ from qtpy.QtWidgets import (
|
||||
QDoubleSpinBox,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
@@ -18,8 +16,6 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
|
||||
class WidgetHandler(ABC):
|
||||
"""Abstract base class for all widget handlers."""
|
||||
@@ -32,15 +28,6 @@ class WidgetHandler(ABC):
|
||||
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."""
|
||||
@@ -51,9 +38,6 @@ class LineEditHandler(WidgetHandler):
|
||||
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."""
|
||||
@@ -69,11 +53,6 @@ class ComboBoxHandler(WidgetHandler):
|
||||
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)))
|
||||
|
||||
|
||||
class TableWidgetHandler(WidgetHandler):
|
||||
"""Handler for QTableWidget widgets."""
|
||||
@@ -93,84 +72,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, **kwargs):
|
||||
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, **kwargs):
|
||||
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, **kwargs):
|
||||
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,
|
||||
@@ -180,8 +114,6 @@ class WidgetIO:
|
||||
QDoubleSpinBox: SpinBoxHandler,
|
||||
QCheckBox: CheckBoxHandler,
|
||||
QLabel: LabelHandler,
|
||||
ToggleSwitch: ToggleSwitchHandler,
|
||||
QSlider: SlideHandler,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -216,17 +148,6 @@ class WidgetIO:
|
||||
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):
|
||||
"""
|
||||
@@ -388,8 +309,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
|
||||
@@ -444,37 +365,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()
|
||||
|
||||
@@ -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_())
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
|
||||
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.cli.rpc_wigdet_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
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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(
|
||||
parent_dock_area: Optional[str] = Field(
|
||||
None, description="The GUI ID of parent dock area of the dock."
|
||||
)
|
||||
|
||||
@@ -110,17 +103,16 @@ class BECDock(BECWidget, Dock):
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = [
|
||||
"_config_dict",
|
||||
"element_list",
|
||||
"elements",
|
||||
"new",
|
||||
"show",
|
||||
"hide",
|
||||
"_rpc_id",
|
||||
"widget_list",
|
||||
"show_title_bar",
|
||||
"set_title",
|
||||
"hide_title_bar",
|
||||
"available_widgets",
|
||||
"delete",
|
||||
"delete_all",
|
||||
"get_widgets_positions",
|
||||
"set_title",
|
||||
"add_widget",
|
||||
"list_eligible_widgets",
|
||||
"move_widget",
|
||||
"remove_widget",
|
||||
"remove",
|
||||
"attach",
|
||||
"detach",
|
||||
@@ -129,8 +121,7 @@ class BECDock(BECWidget, Dock):
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
parent_dock_area: BECDockArea | None = None,
|
||||
parent_id: str | None = None,
|
||||
parent_dock_area: QWidget | None = None,
|
||||
config: DockConfig | None = None,
|
||||
name: str | None = None,
|
||||
client=None,
|
||||
@@ -138,24 +129,21 @@ class BECDock(BECWidget, Dock):
|
||||
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,
|
||||
widget_class=self.__class__.__name__, parent_dock_area=parent_dock_area.gui_id
|
||||
)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DockConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(
|
||||
client=client, config=config, gui_id=gui_id, name=name, parent_id=parent_id
|
||||
) # Name was checked and created in BEC Widget
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
label = CustomDockLabel(text=name, closable=closable)
|
||||
Dock.__init__(self, name=name, label=label, parent=self, **kwargs)
|
||||
Dock.__init__(self, name=name, label=label, **kwargs)
|
||||
# Dock.__init__(self, name=name, **kwargs)
|
||||
|
||||
self.parent_dock_area = parent_dock_area
|
||||
|
||||
# Layout Manager
|
||||
self.layout_manager = GridLayoutManager(self.layout)
|
||||
|
||||
@@ -185,18 +173,7 @@ class BECDock(BECWidget, Dock):
|
||||
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._name, widget) for widget in self.element_list)
|
||||
|
||||
@property
|
||||
def element_list(self) -> list[BECWidget]:
|
||||
def widget_list(self) -> list[BECWidget]:
|
||||
"""
|
||||
Get the widgets in the dock.
|
||||
|
||||
@@ -205,6 +182,10 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
return self.widgets
|
||||
|
||||
@widget_list.setter
|
||||
def widget_list(self, value: list[BECWidget]):
|
||||
self.widgets = value
|
||||
|
||||
def hide_title_bar(self):
|
||||
"""
|
||||
Hide the title bar of the dock.
|
||||
@@ -213,20 +194,6 @@ class BECDock(BECWidget, Dock):
|
||||
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.
|
||||
@@ -244,6 +211,7 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
self.orig_area.docks[title] = self.orig_area.docks.pop(self.name())
|
||||
self.setTitle(title)
|
||||
self._name = title
|
||||
|
||||
def get_widgets_positions(self) -> dict:
|
||||
"""
|
||||
@@ -254,7 +222,7 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
return self.layout_manager.get_widgets_positions()
|
||||
|
||||
def available_widgets(
|
||||
def list_eligible_widgets(
|
||||
self,
|
||||
) -> list: # TODO can be moved to some util mixin like container class for rpc widgets
|
||||
"""
|
||||
@@ -265,30 +233,20 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
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
|
||||
|
||||
def new(
|
||||
def add_widget(
|
||||
self,
|
||||
widget: BECWidget | str,
|
||||
name: str | None = None,
|
||||
row: int | None = None,
|
||||
col: int = 0,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
row=None,
|
||||
col=0,
|
||||
rowspan=1,
|
||||
colspan=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.
|
||||
widget(QWidget): The widget to add.
|
||||
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.
|
||||
@@ -296,46 +254,21 @@ class BECDock(BECWidget, Dock):
|
||||
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
|
||||
"""
|
||||
if row is None:
|
||||
# row = cast(int, self.layout.rowCount()) # type:ignore
|
||||
row = self.layout.rowCount()
|
||||
# row = cast(int, row)
|
||||
|
||||
if self.layout_manager.is_position_occupied(row, col):
|
||||
self.layout_manager.shift_widgets(shift, start_row=row)
|
||||
|
||||
existing_widgets_parent_dock = self._get_list_of_widget_name_of_parent_dock_area()
|
||||
|
||||
if name is not None: # Name is provided
|
||||
if name in existing_widgets_parent_dock:
|
||||
# pylint: disable=protected-access
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for widgets, but already exists in DockArea "
|
||||
f"with name: {self.parent_dock_area._name} and id {self.parent_dock_area.gui_id}."
|
||||
)
|
||||
else: # Name is not provided
|
||||
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
|
||||
name = WidgetContainerUtils.generate_unique_name(
|
||||
name=widget_class_name, list_of_names=existing_widgets_parent_dock
|
||||
)
|
||||
# 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, name=name, parent_dock=self, parent_id=self.gui_id
|
||||
),
|
||||
)
|
||||
widget = widget_handler.create_widget(widget)
|
||||
else:
|
||||
widget._name = name # pylint: disable=protected-access
|
||||
widget = widget
|
||||
|
||||
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._name] = widget.config # pylint: disable=protected-access
|
||||
self.config.widgets[widget.gui_id] = widget.config
|
||||
|
||||
return widget
|
||||
|
||||
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
|
||||
@@ -361,66 +294,35 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
self.float()
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the dock from the parent dock area.
|
||||
"""
|
||||
self.parent_dock_area.delete(self._name)
|
||||
|
||||
def delete(self, widget_name: str) -> None:
|
||||
def remove_widget(self, widget_rpc_id: str):
|
||||
"""
|
||||
Remove a widget from the dock.
|
||||
|
||||
Args:
|
||||
widget_name(str): Delete the widget with the given name.
|
||||
widget_rpc_id(str): The ID of the widget to remove.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
widgets = [widget for widget in self.widgets if widget._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]
|
||||
widget = self.rpc_register.get_rpc_by_id(widget_rpc_id)
|
||||
self.layout.removeWidget(widget)
|
||||
self.config.widgets.pop(widget._name, None)
|
||||
if widget in self.widgets:
|
||||
self.widgets.remove(widget)
|
||||
self.config.widgets.pop(widget_rpc_id, None)
|
||||
widget.close()
|
||||
|
||||
def delete_all(self):
|
||||
def remove(self):
|
||||
"""
|
||||
Remove all widgets from the dock.
|
||||
Remove the dock from the parent dock area.
|
||||
"""
|
||||
for widget in self.widgets:
|
||||
self.delete(widget._name) # pylint: disable=protected-access
|
||||
# self.cleanup()
|
||||
self.parent_dock_area.remove_dock(self.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()
|
||||
for widget in self.widgets:
|
||||
if hasattr(widget, "cleanup"):
|
||||
widget.cleanup()
|
||||
self.widgets.clear()
|
||||
self.label.close()
|
||||
self.label.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
def close(self):
|
||||
@@ -430,15 +332,4 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
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_())
|
||||
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
|
||||
|
||||
@@ -3,41 +3,34 @@ from __future__ import annotations
|
||||
from typing import Literal, Optional
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
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.QtCore import Qt
|
||||
from qtpy.QtGui import QPainter, QPaintEvent
|
||||
from qtpy.QtWidgets import QApplication, QSizePolicy, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import 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.toolbar import (
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.toolbar import (
|
||||
ExpandableMenuAction,
|
||||
MaterialIconAction,
|
||||
ModularToolBar,
|
||||
SeparatorAction,
|
||||
)
|
||||
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
from bec_widgets.widgets.plots.image.image 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.plots.image.image_widget import BECImageWidget
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map_widget import BECMotorMapWidget
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
from bec_widgets.widgets.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.")
|
||||
@@ -49,22 +42,19 @@ class DockAreaConfig(ConnectionConfig):
|
||||
class BECDockArea(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"_get_all_rpc",
|
||||
"new",
|
||||
"show",
|
||||
"hide",
|
||||
"panels",
|
||||
"panel_list",
|
||||
"delete",
|
||||
"delete_all",
|
||||
"remove",
|
||||
"save_state",
|
||||
"remove_dock",
|
||||
"restore_state",
|
||||
"add_dock",
|
||||
"clear_all",
|
||||
"detach_dock",
|
||||
"attach_all",
|
||||
"selected_device",
|
||||
"save_state",
|
||||
"restore_state",
|
||||
"_get_all_rpc",
|
||||
"temp_areas",
|
||||
"show",
|
||||
"hide",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
@@ -73,8 +63,6 @@ class BECDockArea(BECWidget, QWidget):
|
||||
config: DockAreaConfig | None = None,
|
||||
client=None,
|
||||
gui_id: str = None,
|
||||
name: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = DockAreaConfig(widget_class=self.__class__.__name__)
|
||||
@@ -82,9 +70,8 @@ class BECDockArea(BECWidget, QWidget):
|
||||
if isinstance(config, dict):
|
||||
config = DockAreaConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, name=name, **kwargs)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
self._parent = parent
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setSpacing(5)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -98,23 +85,22 @@ class BECDockArea(BECWidget, QWidget):
|
||||
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",
|
||||
icon_name=BECWaveformWidget.ICON_NAME,
|
||||
tooltip="Add Waveform",
|
||||
filled=True,
|
||||
),
|
||||
"multi_waveform": MaterialIconAction(
|
||||
icon_name=MultiWaveform.ICON_NAME,
|
||||
icon_name=BECMultiWaveformWidget.ICON_NAME,
|
||||
tooltip="Add Multi Waveform",
|
||||
filled=True,
|
||||
),
|
||||
"image": MaterialIconAction(
|
||||
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True
|
||||
icon_name=BECImageWidget.ICON_NAME, tooltip="Add Image", filled=True
|
||||
),
|
||||
"motor_map": MaterialIconAction(
|
||||
icon_name=MotorMap.ICON_NAME, tooltip="Add Motor Map", filled=True
|
||||
icon_name=BECMotorMapWidget.ICON_NAME,
|
||||
tooltip="Add Motor Map",
|
||||
filled=True,
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -150,9 +136,6 @@ class BECDockArea(BECWidget, QWidget):
|
||||
tooltip="Add Circular ProgressBar",
|
||||
filled=True,
|
||||
),
|
||||
"log_panel": MaterialIconAction(
|
||||
icon_name=LogPanel.ICON_NAME, tooltip="Add LogPanel", filled=True
|
||||
),
|
||||
},
|
||||
),
|
||||
"separator_2": SeparatorAction(),
|
||||
@@ -175,50 +158,41 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.toolbar.addWidget(DarkModeButton(toolbar=True))
|
||||
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")
|
||||
lambda: self.add_dock(widget="BECWaveformWidget", prefix="waveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform")
|
||||
lambda: self.add_dock(widget="BECMultiWaveformWidget", prefix="multi_waveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Image")
|
||||
lambda: self.add_dock(widget="BECImageWidget", prefix="image")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
|
||||
lambda: self.add_dock(widget="BECMotorMapWidget", prefix="motor_map")
|
||||
)
|
||||
|
||||
# Menu Devices
|
||||
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
|
||||
lambda: self.add_dock(widget="ScanControl", prefix="scan_control")
|
||||
)
|
||||
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
|
||||
lambda: self.add_dock(widget="PositionerBox", prefix="positioner_box")
|
||||
)
|
||||
|
||||
# Menu Utils
|
||||
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
|
||||
lambda: self.add_dock(widget="BECQueue", prefix="queue")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
|
||||
lambda: self.add_dock(widget="BECStatusBox", prefix="status")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
|
||||
lambda: self.add_dock(widget="VSCodeEditor", prefix="vs_code")
|
||||
)
|
||||
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")
|
||||
lambda: self.add_dock(widget="RingProgressBar", prefix="progress_bar")
|
||||
)
|
||||
|
||||
# Icons
|
||||
@@ -226,13 +200,6 @@ class BECDockArea(BECWidget, QWidget):
|
||||
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():
|
||||
dock_name = WidgetContainerUtils.generate_unique_name(widget_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:
|
||||
@@ -240,20 +207,9 @@ class BECDockArea(BECWidget, QWidget):
|
||||
painter.drawText(
|
||||
self.rect(),
|
||||
Qt.AlignCenter,
|
||||
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
|
||||
"Add docks using 'add_dock' method from CLI\n or \n Add widget docks using the toolbar",
|
||||
)
|
||||
|
||||
@property
|
||||
def selected_device(self) -> str:
|
||||
gui_id = QApplication.instance().gui_id
|
||||
auto_update_config = self.client.connector.get(
|
||||
MessageEndpoints.gui_auto_update_config(gui_id)
|
||||
)
|
||||
try:
|
||||
return auto_update_config.selected_device
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def panels(self) -> dict[str, BECDock]:
|
||||
"""
|
||||
@@ -265,17 +221,7 @@ class BECDockArea(BECWidget, QWidget):
|
||||
|
||||
@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())
|
||||
self.dock_area.docks = WeakValueDictionary(value)
|
||||
|
||||
@property
|
||||
def temp_areas(self) -> list:
|
||||
@@ -319,17 +265,36 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.config.docks_state = last_state
|
||||
return last_state
|
||||
|
||||
def remove_dock(self, name: str):
|
||||
"""
|
||||
Remove a dock by name and ensure it is properly closed and cleaned up.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock to remove.
|
||||
"""
|
||||
dock = self.dock_area.docks.pop(name, None)
|
||||
self.config.docks.pop(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 {name} does not exist.")
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def new(
|
||||
def add_dock(
|
||||
self,
|
||||
name: str | None = None,
|
||||
widget: str | QWidget | None = None,
|
||||
widget_name: str | None = None,
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = "bottom",
|
||||
name: str = None,
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = None,
|
||||
relative_to: BECDock | None = None,
|
||||
closable: bool = True,
|
||||
floating: bool = False,
|
||||
row: int | None = None,
|
||||
prefix: str = "dock",
|
||||
widget: str | QWidget | None = None,
|
||||
row: int = None,
|
||||
col: int = 0,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
@@ -339,11 +304,12 @@ class BECDockArea(BECWidget, QWidget):
|
||||
|
||||
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.
|
||||
prefix(str): The prefix for the dock name if no name is provided.
|
||||
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
|
||||
row(int): The row of the added widget.
|
||||
col(int): The column of the added widget.
|
||||
rowspan(int): The rowspan of the added widget.
|
||||
@@ -352,20 +318,21 @@ class BECDockArea(BECWidget, QWidget):
|
||||
Returns:
|
||||
BECDock: The created dock.
|
||||
"""
|
||||
dock_names = [dock._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._name} and id {self.gui_id}."
|
||||
)
|
||||
else: # Name is not provided
|
||||
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
|
||||
if name is None:
|
||||
name = WidgetContainerUtils.generate_unique_widget_id(
|
||||
container=self.dock_area.docks, prefix=prefix
|
||||
)
|
||||
|
||||
dock = BECDock(name=name, parent_dock_area=self, parent_id=self.gui_id, closable=closable)
|
||||
if name in set(self.dock_area.docks.keys()):
|
||||
raise ValueError(f"Dock with name {name} already exists.")
|
||||
|
||||
if position is None:
|
||||
position = "bottom"
|
||||
|
||||
dock = BECDock(name=name, 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.config.docks[name] = dock.config
|
||||
|
||||
self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to)
|
||||
|
||||
if len(self.dock_area.docks) <= 1:
|
||||
@@ -374,11 +341,10 @@ class BECDockArea(BECWidget, QWidget):
|
||||
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 widget is not None and isinstance(widget, str):
|
||||
dock.add_widget(widget=widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
|
||||
elif widget is not None and isinstance(widget, QWidget):
|
||||
dock.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
|
||||
if (
|
||||
self._instructions_visible
|
||||
): # TODO still decide how initial instructions should be handled
|
||||
@@ -416,90 +382,58 @@ class BECDockArea(BECWidget, QWidget):
|
||||
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 clear_all(self):
|
||||
"""
|
||||
Close all docks and remove all temp areas.
|
||||
"""
|
||||
self.attach_all()
|
||||
for dock in dict(self.dock_area.docks).values():
|
||||
dock.remove()
|
||||
self.dock_area.docks.clear()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the dock area.
|
||||
"""
|
||||
self.delete_all()
|
||||
self.clear_all()
|
||||
self.toolbar.close()
|
||||
self.toolbar.deleteLater()
|
||||
self.dock_area.close()
|
||||
self.dock_area.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
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()
|
||||
|
||||
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."""
|
||||
self.close()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
import sys
|
||||
if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
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="Waveform")
|
||||
# dock_1 = dock_area.new(name="dock_0", widget="Waveform")
|
||||
dock_area.new(widget="Waveform")
|
||||
dock_area.show()
|
||||
dock_area.setGeometry(100, 100, 800, 600)
|
||||
app.topLevelWidgets()
|
||||
app.exec_()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
1
bec_widgets/widgets/containers/figure/__init__.py
Normal file
1
bec_widgets/widgets/containers/figure/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .figure import BECFigure, FigureConfig
|
||||
797
bec_widgets/widgets/containers/figure/figure.py
Normal file
797
bec_widgets/widgets/containers/figure/figure.py
Normal file
@@ -0,0 +1,797 @@
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from typing import Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from typeguard import typechecked
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.figure.plots.image.image import BECImageShow, ImageConfig
|
||||
from bec_widgets.widgets.containers.figure.plots.motor_map.motor_map import (
|
||||
BECMotorMap,
|
||||
MotorMapConfig,
|
||||
)
|
||||
from bec_widgets.widgets.containers.figure.plots.multi_waveform.multi_waveform import (
|
||||
BECMultiWaveform,
|
||||
BECMultiWaveformConfig,
|
||||
)
|
||||
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
|
||||
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import (
|
||||
BECWaveform,
|
||||
Waveform1DConfig,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class FigureConfig(ConnectionConfig):
|
||||
"""Configuration for BECFigure. Inheriting from ConnectionConfig widget_class and gui_id"""
|
||||
|
||||
theme: Literal["dark", "light"] = Field("dark", description="The theme of the figure widget.")
|
||||
num_cols: int = Field(1, description="The number of columns in the figure widget.")
|
||||
num_rows: int = Field(1, description="The number of rows in the figure widget.")
|
||||
widgets: dict[str, Waveform1DConfig | ImageConfig | MotorMapConfig | SubplotConfig] = Field(
|
||||
{}, description="The list of widgets to be added to the figure widget."
|
||||
)
|
||||
|
||||
@field_validator("widgets", mode="before")
|
||||
@classmethod
|
||||
def validate_widgets(cls, v):
|
||||
"""Validate the widgets configuration."""
|
||||
widget_class_map = {
|
||||
"BECWaveform": Waveform1DConfig,
|
||||
"BECImageShow": ImageConfig,
|
||||
"BECMotorMap": MotorMapConfig,
|
||||
}
|
||||
validated_widgets = {}
|
||||
for key, widget_config in v.items():
|
||||
if "widget_class" not in widget_config:
|
||||
raise ValueError(f"Widget config for {key} does not contain 'widget_class'.")
|
||||
widget_class = widget_config["widget_class"]
|
||||
if widget_class not in widget_class_map:
|
||||
raise ValueError(f"Unknown widget_class '{widget_class}' for widget '{key}'.")
|
||||
config_class = widget_class_map[widget_class]
|
||||
validated_widgets[key] = config_class(**widget_config)
|
||||
return validated_widgets
|
||||
|
||||
|
||||
class WidgetHandler:
|
||||
"""Factory for creating and configuring BEC widgets for BECFigure."""
|
||||
|
||||
def __init__(self):
|
||||
self.widget_factory = {
|
||||
"BECPlotBase": (BECPlotBase, SubplotConfig),
|
||||
"BECWaveform": (BECWaveform, Waveform1DConfig),
|
||||
"BECImageShow": (BECImageShow, ImageConfig),
|
||||
"BECMotorMap": (BECMotorMap, MotorMapConfig),
|
||||
"BECMultiWaveform": (BECMultiWaveform, BECMultiWaveformConfig),
|
||||
}
|
||||
|
||||
def create_widget(
|
||||
self,
|
||||
widget_type: str,
|
||||
widget_id: str,
|
||||
parent_figure,
|
||||
parent_id: str,
|
||||
config: dict = None,
|
||||
**axis_kwargs,
|
||||
) -> BECPlotBase:
|
||||
"""
|
||||
Create and configure a widget based on its type.
|
||||
|
||||
Args:
|
||||
widget_type (str): The type of the widget to create.
|
||||
widget_id (str): Unique identifier for the widget.
|
||||
parent_id (str): Identifier of the parent figure.
|
||||
config (dict, optional): Additional configuration for the widget.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECPlotBase: The created and configured widget instance.
|
||||
"""
|
||||
entry = self.widget_factory.get(widget_type)
|
||||
if not entry:
|
||||
raise ValueError(f"Unsupported widget type: {widget_type}")
|
||||
|
||||
widget_class, config_class = entry
|
||||
if config is not None and isinstance(config, config_class):
|
||||
config = config.model_dump()
|
||||
widget_config_dict = {
|
||||
"widget_class": widget_class.__name__,
|
||||
"parent_id": parent_id,
|
||||
"gui_id": widget_id,
|
||||
**(config if config is not None else {}),
|
||||
}
|
||||
widget_config = config_class(**widget_config_dict)
|
||||
widget = widget_class(
|
||||
config=widget_config, parent_figure=parent_figure, client=parent_figure.client
|
||||
)
|
||||
|
||||
if axis_kwargs:
|
||||
widget.set(**axis_kwargs)
|
||||
|
||||
return widget
|
||||
|
||||
|
||||
class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"_get_all_rpc",
|
||||
"axes",
|
||||
"widgets",
|
||||
"plot",
|
||||
"image",
|
||||
"motor_map",
|
||||
"remove",
|
||||
"change_layout",
|
||||
"change_theme",
|
||||
"export",
|
||||
"clear_all",
|
||||
"widget_list",
|
||||
]
|
||||
subplot_map = {
|
||||
"PlotBase": BECPlotBase,
|
||||
"BECWaveform": BECWaveform,
|
||||
"BECImageShow": BECImageShow,
|
||||
"BECMotorMap": BECMotorMap,
|
||||
"BECMultiWaveform": BECMultiWaveform,
|
||||
}
|
||||
widget_method_map = {
|
||||
"BECWaveform": "plot",
|
||||
"BECImageShow": "image",
|
||||
"BECMotorMap": "motor_map",
|
||||
"BECMultiWaveform": "multi_waveform",
|
||||
}
|
||||
|
||||
clean_signal = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
config: Optional[FigureConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = FigureConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = FigureConfig(**config)
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
pg.GraphicsLayoutWidget.__init__(self, parent)
|
||||
|
||||
self.widget_handler = WidgetHandler()
|
||||
|
||||
# Widget container to reference widgets by 'widget_id'
|
||||
self._widgets = defaultdict(dict)
|
||||
|
||||
# Container to keep track of the grid
|
||||
self.grid = []
|
||||
# Create config and apply it
|
||||
self.apply_config(config)
|
||||
|
||||
def __getitem__(self, key: tuple | str):
|
||||
if isinstance(key, tuple) and len(key) == 2:
|
||||
return self.axes(*key)
|
||||
if isinstance(key, str):
|
||||
widget = self._widgets.get(key)
|
||||
if widget is None:
|
||||
raise KeyError(f"No widget with ID {key}")
|
||||
return self._widgets.get(key)
|
||||
else:
|
||||
raise TypeError(
|
||||
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
|
||||
)
|
||||
|
||||
def apply_config(self, config: dict | FigureConfig): # ,generate_new_id: bool = False):
|
||||
if isinstance(config, dict):
|
||||
try:
|
||||
config = FigureConfig(**config)
|
||||
except ValidationError as e:
|
||||
logger.error(f"Error in applying config: {e}")
|
||||
return
|
||||
self.config = config
|
||||
|
||||
# widget_config has to be reset for not have each widget config twice when added to the figure
|
||||
widget_configs = list(self.config.widgets.values())
|
||||
self.config.widgets = {}
|
||||
for widget_config in widget_configs:
|
||||
getattr(self, self.widget_method_map[widget_config.widget_class])(
|
||||
config=widget_config.model_dump(), row=widget_config.row, col=widget_config.col
|
||||
)
|
||||
|
||||
@property
|
||||
def widget_list(self) -> list[BECPlotBase]:
|
||||
"""
|
||||
Access all widget in BECFigure as a list
|
||||
Returns:
|
||||
list[BECPlotBase]: List of all widgets in the figure.
|
||||
"""
|
||||
axes = [value for value in self._widgets.values() if isinstance(value, BECPlotBase)]
|
||||
return axes
|
||||
|
||||
@widget_list.setter
|
||||
def widget_list(self, value: list[BECPlotBase]):
|
||||
"""
|
||||
Access all widget in BECFigure as a list
|
||||
Returns:
|
||||
list[BECPlotBase]: List of all widgets in the figure.
|
||||
"""
|
||||
self._axes = value
|
||||
|
||||
@property
|
||||
def widgets(self) -> dict:
|
||||
"""
|
||||
All widgets within the figure with gui ids as keys.
|
||||
Returns:
|
||||
dict: All widgets within the figure.
|
||||
"""
|
||||
return self._widgets
|
||||
|
||||
@widgets.setter
|
||||
def widgets(self, value: dict):
|
||||
"""
|
||||
All widgets within the figure with gui ids as keys.
|
||||
Returns:
|
||||
dict: All widgets within the figure.
|
||||
"""
|
||||
self._widgets = value
|
||||
|
||||
def export(self):
|
||||
"""Export the plot widget."""
|
||||
try:
|
||||
plot_item = self.widget_list[0]
|
||||
except Exception as exc:
|
||||
raise ValueError("No plot widget available to export.") from exc
|
||||
|
||||
scene = plot_item.scene()
|
||||
scene.contextMenuItem = plot_item
|
||||
scene.showExportDialog()
|
||||
|
||||
@typechecked
|
||||
def plot(
|
||||
self,
|
||||
arg1: list | np.ndarray | str | None = None,
|
||||
y: list | np.ndarray | None = None,
|
||||
x: list | np.ndarray | None = None,
|
||||
x_name: str | None = None,
|
||||
y_name: str | None = None,
|
||||
z_name: str | None = None,
|
||||
x_entry: str | None = None,
|
||||
y_entry: str | None = None,
|
||||
z_entry: str | None = None,
|
||||
color: str | None = None,
|
||||
color_map_z: str | None = "magma",
|
||||
label: str | None = None,
|
||||
validate: bool = True,
|
||||
new: bool = False,
|
||||
row: int | None = None,
|
||||
col: int | None = None,
|
||||
dap: str | None = None,
|
||||
config: dict | None = None, # TODO make logic more transparent
|
||||
**axis_kwargs,
|
||||
) -> BECWaveform:
|
||||
"""
|
||||
Add a 1D waveform plot to the figure. Always access the first waveform widget in the figure.
|
||||
|
||||
Args:
|
||||
arg1(list | np.ndarray | str | None): First argument which can be x data, y data, or y_name.
|
||||
y(list | np.ndarray): Custom y data to plot.
|
||||
x(list | np.ndarray): Custom x data to plot.
|
||||
x_name(str): The name of the device for the x-axis.
|
||||
y_name(str): The name of the device for the y-axis.
|
||||
z_name(str): The name of the device for the z-axis.
|
||||
x_entry(str): The name of the entry for the x-axis.
|
||||
y_entry(str): The name of the entry for the y-axis.
|
||||
z_entry(str): The name of the entry for the z-axis.
|
||||
color(str): The color of the curve.
|
||||
color_map_z(str): The color map to use for the z-axis.
|
||||
label(str): The label of the curve.
|
||||
validate(bool): If True, validate the device names and entries.
|
||||
new(bool): If True, create a new plot instead of using the first plot.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
dap(str): The DAP model to use for the curve.
|
||||
config(dict): Recreates the whole BECWaveform widget from provided configuration.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECWaveform: The waveform plot widget.
|
||||
"""
|
||||
waveform = self.subplot_factory(
|
||||
widget_type="BECWaveform", config=config, row=row, col=col, new=new, **axis_kwargs
|
||||
)
|
||||
if config is not None:
|
||||
return waveform
|
||||
|
||||
if arg1 is not None or y_name is not None or (y is not None and x is not None):
|
||||
waveform.plot(
|
||||
arg1=arg1,
|
||||
y=y,
|
||||
x=x,
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
color=color,
|
||||
color_map_z=color_map_z,
|
||||
label=label,
|
||||
validate=validate,
|
||||
dap=dap,
|
||||
)
|
||||
return waveform
|
||||
|
||||
def _init_image(
|
||||
self,
|
||||
image,
|
||||
monitor: str = None,
|
||||
monitor_type: Literal["1d", "2d"] = "2d",
|
||||
color_bar: Literal["simple", "full"] = "full",
|
||||
color_map: str = "magma",
|
||||
data: np.ndarray = None,
|
||||
vrange: tuple[float, float] = None,
|
||||
) -> BECImageShow:
|
||||
"""
|
||||
Configure the image based on the provided parameters.
|
||||
|
||||
Args:
|
||||
image (BECImageShow): The image to configure.
|
||||
monitor (str): The name of the monitor to display.
|
||||
color_bar (Literal["simple","full"]): The type of color bar to display.
|
||||
color_map (str): The color map to use for the image.
|
||||
data (np.ndarray): Custom data to display.
|
||||
"""
|
||||
if monitor is not None and data is None:
|
||||
image.image(
|
||||
monitor=monitor,
|
||||
monitor_type=monitor_type,
|
||||
color_map=color_map,
|
||||
vrange=vrange,
|
||||
color_bar=color_bar,
|
||||
)
|
||||
elif data is not None and monitor is None:
|
||||
image.add_custom_image(
|
||||
name="custom", data=data, color_map=color_map, vrange=vrange, color_bar=color_bar
|
||||
)
|
||||
elif data is None and monitor is None:
|
||||
# Setting appearance
|
||||
if vrange is not None:
|
||||
image.set_vrange(vmin=vrange[0], vmax=vrange[1])
|
||||
if color_map is not None:
|
||||
image.set_color_map(color_map)
|
||||
else:
|
||||
raise ValueError("Invalid input. Provide either monitor name or custom data.")
|
||||
return image
|
||||
|
||||
def image(
|
||||
self,
|
||||
monitor: str = None,
|
||||
monitor_type: Literal["1d", "2d"] = "2d",
|
||||
color_bar: Literal["simple", "full"] = "full",
|
||||
color_map: str = "magma",
|
||||
data: np.ndarray = None,
|
||||
vrange: tuple[float, float] = None,
|
||||
new: bool = False,
|
||||
row: int | None = None,
|
||||
col: int | None = None,
|
||||
config: dict | None = None,
|
||||
**axis_kwargs,
|
||||
) -> BECImageShow:
|
||||
"""
|
||||
Add an image to the figure. Always access the first image widget in the figure.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor to display.
|
||||
color_bar(Literal["simple","full"]): The type of color bar to display.
|
||||
color_map(str): The color map to use for the image.
|
||||
data(np.ndarray): Custom data to display.
|
||||
vrange(tuple[float, float]): The range of values to display.
|
||||
new(bool): If True, create a new plot instead of using the first plot.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Recreates the whole BECImageShow widget from provided configuration.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECImageShow: The image widget.
|
||||
"""
|
||||
|
||||
image = self.subplot_factory(
|
||||
widget_type="BECImageShow", config=config, row=row, col=col, new=new, **axis_kwargs
|
||||
)
|
||||
if config is not None:
|
||||
return image
|
||||
|
||||
image = self._init_image(
|
||||
image=image,
|
||||
monitor=monitor,
|
||||
monitor_type=monitor_type,
|
||||
color_bar=color_bar,
|
||||
color_map=color_map,
|
||||
data=data,
|
||||
vrange=vrange,
|
||||
)
|
||||
return image
|
||||
|
||||
def motor_map(
|
||||
self,
|
||||
motor_x: str = None,
|
||||
motor_y: str = None,
|
||||
new: bool = False,
|
||||
row: int | None = None,
|
||||
col: int | None = None,
|
||||
config: dict | None = None,
|
||||
**axis_kwargs,
|
||||
) -> BECMotorMap:
|
||||
"""
|
||||
Add a motor map to the figure. Always access the first motor map widget in the figure.
|
||||
|
||||
Args:
|
||||
motor_x(str): The name of the motor for the X axis.
|
||||
motor_y(str): The name of the motor for the Y axis.
|
||||
new(bool): If True, create a new plot instead of using the first plot.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Recreates the whole BECImageShow widget from provided configuration.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECMotorMap: The motor map widget.
|
||||
"""
|
||||
motor_map = self.subplot_factory(
|
||||
widget_type="BECMotorMap", config=config, row=row, col=col, new=new, **axis_kwargs
|
||||
)
|
||||
if config is not None:
|
||||
return motor_map
|
||||
|
||||
if motor_x is not None and motor_y is not None:
|
||||
motor_map.change_motors(motor_x, motor_y)
|
||||
|
||||
return motor_map
|
||||
|
||||
def multi_waveform(
|
||||
self,
|
||||
monitor: str = None,
|
||||
new: bool = False,
|
||||
row: int | None = None,
|
||||
col: int | None = None,
|
||||
config: dict | None = None,
|
||||
**axis_kwargs,
|
||||
):
|
||||
multi_waveform = self.subplot_factory(
|
||||
widget_type="BECMultiWaveform", config=config, row=row, col=col, new=new, **axis_kwargs
|
||||
)
|
||||
if config is not None:
|
||||
return multi_waveform
|
||||
multi_waveform.set_monitor(monitor)
|
||||
return multi_waveform
|
||||
|
||||
def subplot_factory(
|
||||
self,
|
||||
widget_type: Literal[
|
||||
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
|
||||
] = "BECPlotBase",
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
config=None,
|
||||
new: bool = False,
|
||||
**axis_kwargs,
|
||||
) -> BECPlotBase:
|
||||
# Case 1 - config provided, new plot, possible to define coordinates
|
||||
if config is not None:
|
||||
widget_cls = config["widget_class"]
|
||||
if widget_cls != widget_type:
|
||||
raise ValueError(
|
||||
f"Widget type '{widget_type}' does not match the provided configuration ({widget_cls})."
|
||||
)
|
||||
widget = self.add_widget(
|
||||
widget_type=widget_type, config=config, row=row, col=col, **axis_kwargs
|
||||
)
|
||||
return widget
|
||||
|
||||
# Case 2 - find first plot or create first plot if no plot available, no config provided, no coordinates
|
||||
if new is False and (row is None or col is None):
|
||||
widget = WidgetContainerUtils.find_first_widget_by_class(
|
||||
self._widgets, self.subplot_map[widget_type], can_fail=True
|
||||
)
|
||||
if widget is not None:
|
||||
if axis_kwargs:
|
||||
widget.set(**axis_kwargs)
|
||||
else:
|
||||
widget = self.add_widget(widget_type=widget_type, **axis_kwargs)
|
||||
return widget
|
||||
|
||||
# Case 3 - modifying existing plot wit coordinates provided
|
||||
if new is False and (row is not None and col is not None):
|
||||
try:
|
||||
widget = self.axes(row, col)
|
||||
except ValueError:
|
||||
widget = None
|
||||
if widget is not None:
|
||||
if axis_kwargs:
|
||||
widget.set(**axis_kwargs)
|
||||
else:
|
||||
widget = self.add_widget(widget_type=widget_type, row=row, col=col, **axis_kwargs)
|
||||
return widget
|
||||
|
||||
# Case 4 - no previous plot or new plot, no config provided, possible to define coordinates
|
||||
widget = self.add_widget(widget_type=widget_type, row=row, col=col, **axis_kwargs)
|
||||
return widget
|
||||
|
||||
def add_widget(
|
||||
self,
|
||||
widget_type: Literal[
|
||||
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
|
||||
] = "BECPlotBase",
|
||||
widget_id: str = None,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
config=None,
|
||||
**axis_kwargs,
|
||||
) -> BECPlotBase:
|
||||
"""
|
||||
Add a widget to the figure at the specified position.
|
||||
|
||||
Args:
|
||||
widget_type(Literal["PlotBase","Waveform1D"]): The type of the widget to add.
|
||||
widget_id(str): The unique identifier of the widget. If not provided, a unique ID will be generated.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Additional configuration for the widget.
|
||||
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
|
||||
"""
|
||||
if not widget_id:
|
||||
widget_id = str(uuid.uuid4())
|
||||
if widget_id in self._widgets:
|
||||
raise ValueError(f"Widget with ID '{widget_id}' already exists.")
|
||||
|
||||
# Check if position is occupied
|
||||
if row is not None and col is not None:
|
||||
if self.getItem(row, col):
|
||||
raise ValueError(f"Position at row {row} and column {col} is already occupied.")
|
||||
else:
|
||||
row, col = self._find_next_empty_position()
|
||||
|
||||
widget = self.widget_handler.create_widget(
|
||||
widget_type=widget_type,
|
||||
widget_id=widget_id,
|
||||
parent_figure=self,
|
||||
parent_id=self.gui_id,
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
# has to be changed manually to ensure unique id, if config is copied from existing widget, the id could be
|
||||
# used otherwise multiple times
|
||||
widget.set_gui_id(widget_id)
|
||||
|
||||
widget.config.row = row
|
||||
widget.config.col = col
|
||||
|
||||
# Add widget to the figure
|
||||
self.addItem(widget, row=row, col=col)
|
||||
|
||||
# Update num_cols and num_rows based on the added widget
|
||||
self.config.num_rows = max(self.config.num_rows, row + 1)
|
||||
self.config.num_cols = max(self.config.num_cols, col + 1)
|
||||
|
||||
# Saving config for future referencing
|
||||
self.config.widgets[widget_id] = widget.config
|
||||
self._widgets[widget_id] = widget
|
||||
|
||||
# Reflect the grid coordinates
|
||||
self._change_grid(widget_id, row, col)
|
||||
|
||||
return widget
|
||||
|
||||
def remove(
|
||||
self,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
widget_id: str = None,
|
||||
coordinates: tuple[int, int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Remove a widget from the figure. Can be removed by its unique identifier or by its coordinates.
|
||||
|
||||
Args:
|
||||
row(int): The row coordinate of the widget to remove.
|
||||
col(int): The column coordinate of the widget to remove.
|
||||
widget_id(str): The unique identifier of the widget to remove.
|
||||
coordinates(tuple[int, int], optional): The coordinates of the widget to remove.
|
||||
"""
|
||||
if widget_id:
|
||||
self._remove_by_id(widget_id)
|
||||
elif row is not None and col is not None:
|
||||
self._remove_by_coordinates(row, col)
|
||||
elif coordinates:
|
||||
self._remove_by_coordinates(*coordinates)
|
||||
else:
|
||||
raise ValueError("Must provide either widget_id or coordinates for removal.")
|
||||
|
||||
def change_theme(self, theme: Literal["dark", "light"]) -> None:
|
||||
"""
|
||||
Change the theme of the figure widget.
|
||||
|
||||
Args:
|
||||
theme(Literal["dark","light"]): The theme to set for the figure widget.
|
||||
"""
|
||||
self.config.theme = theme
|
||||
apply_theme(theme)
|
||||
for plot in self.widget_list:
|
||||
plot.set_x_label(plot.plot_item.getAxis("bottom").label.toPlainText())
|
||||
plot.set_y_label(plot.plot_item.getAxis("left").label.toPlainText())
|
||||
if plot.plot_item.titleLabel.text:
|
||||
plot.set_title(plot.plot_item.titleLabel.text)
|
||||
plot.set_legend_label_size()
|
||||
|
||||
def _remove_by_coordinates(self, row: int, col: int) -> None:
|
||||
"""
|
||||
Remove a widget from the figure by its coordinates.
|
||||
|
||||
Args:
|
||||
row(int): The row coordinate of the widget to remove.
|
||||
col(int): The column coordinate of the widget to remove.
|
||||
"""
|
||||
widget = self.axes(row, col)
|
||||
if widget:
|
||||
widget_id = widget.config.gui_id
|
||||
if widget_id in self._widgets:
|
||||
self._remove_by_id(widget_id)
|
||||
|
||||
def _remove_by_id(self, widget_id: str) -> None:
|
||||
"""
|
||||
Remove a widget from the figure by its unique identifier.
|
||||
|
||||
Args:
|
||||
widget_id(str): The unique identifier of the widget to remove.
|
||||
"""
|
||||
if widget_id in self._widgets:
|
||||
widget = self._widgets.pop(widget_id)
|
||||
widget.cleanup_pyqtgraph()
|
||||
widget.cleanup()
|
||||
self.removeItem(widget)
|
||||
self.grid[widget.config.row][widget.config.col] = None
|
||||
self._reindex_grid()
|
||||
if widget_id in self.config.widgets:
|
||||
self.config.widgets.pop(widget_id)
|
||||
widget.deleteLater()
|
||||
else:
|
||||
raise ValueError(f"Widget with ID '{widget_id}' does not exist.")
|
||||
|
||||
def axes(self, row: int, col: int) -> BECPlotBase:
|
||||
"""
|
||||
Get widget by its coordinates in the figure.
|
||||
|
||||
Args:
|
||||
row(int): the row coordinate
|
||||
col(int): the column coordinate
|
||||
|
||||
Returns:
|
||||
BECPlotBase: the widget at the given coordinates
|
||||
"""
|
||||
widget = self.getItem(row, col)
|
||||
if widget is None:
|
||||
raise ValueError(f"No widget at coordinates ({row}, {col})")
|
||||
return widget
|
||||
|
||||
def _find_next_empty_position(self):
|
||||
"""Find the next empty position (new row) in the figure."""
|
||||
row, col = 0, 0
|
||||
while self.getItem(row, col):
|
||||
row += 1
|
||||
return row, col
|
||||
|
||||
def _change_grid(self, widget_id: str, row: int, col: int):
|
||||
"""
|
||||
Change the grid to reflect the new position of the widget.
|
||||
|
||||
Args:
|
||||
widget_id(str): The unique identifier of the widget.
|
||||
row(int): The new row coordinate of the widget in the figure.
|
||||
col(int): The new column coordinate of the widget in the figure.
|
||||
"""
|
||||
while len(self.grid) <= row:
|
||||
self.grid.append([])
|
||||
row = self.grid[row]
|
||||
while len(row) <= col:
|
||||
row.append(None)
|
||||
row[col] = widget_id
|
||||
|
||||
def _reindex_grid(self):
|
||||
"""Reindex the grid to remove empty rows and columns."""
|
||||
new_grid = []
|
||||
for row in self.grid:
|
||||
new_row = [widget for widget in row if widget is not None]
|
||||
if new_row:
|
||||
new_grid.append(new_row)
|
||||
#
|
||||
# Update the config of each object to reflect its new position
|
||||
for row_idx, row in enumerate(new_grid):
|
||||
for col_idx, widget in enumerate(row):
|
||||
self._widgets[widget].config.row, self._widgets[widget].config.col = (
|
||||
row_idx,
|
||||
col_idx,
|
||||
)
|
||||
|
||||
self.grid = new_grid
|
||||
self._replot_layout()
|
||||
|
||||
def _replot_layout(self):
|
||||
"""Replot the layout based on the current grid configuration."""
|
||||
self.clear()
|
||||
for row_idx, row in enumerate(self.grid):
|
||||
for col_idx, widget in enumerate(row):
|
||||
self.addItem(self._widgets[widget], row=row_idx, col=col_idx)
|
||||
|
||||
def change_layout(self, max_columns=None, max_rows=None):
|
||||
"""
|
||||
Reshuffle the layout of the figure to adjust to a new number of max_columns or max_rows.
|
||||
If both max_columns and max_rows are provided, max_rows is ignored.
|
||||
|
||||
Args:
|
||||
max_columns (Optional[int]): The new maximum number of columns in the figure.
|
||||
max_rows (Optional[int]): The new maximum number of rows in the figure.
|
||||
"""
|
||||
# Calculate total number of widgets
|
||||
total_widgets = len(self._widgets)
|
||||
|
||||
if max_columns:
|
||||
# Calculate the required number of rows based on max_columns
|
||||
required_rows = (total_widgets + max_columns - 1) // max_columns
|
||||
new_grid = [[None for _ in range(max_columns)] for _ in range(required_rows)]
|
||||
elif max_rows:
|
||||
# Calculate the required number of columns based on max_rows
|
||||
required_columns = (total_widgets + max_rows - 1) // max_rows
|
||||
new_grid = [[None for _ in range(required_columns)] for _ in range(max_rows)]
|
||||
else:
|
||||
# If neither max_columns nor max_rows is specified, just return without changing the layout
|
||||
return
|
||||
|
||||
# Populate the new grid with widgets' IDs
|
||||
current_idx = 0
|
||||
for widget_id in self._widgets:
|
||||
row = current_idx // len(new_grid[0])
|
||||
col = current_idx % len(new_grid[0])
|
||||
new_grid[row][col] = widget_id
|
||||
current_idx += 1
|
||||
|
||||
self.config.num_rows = row
|
||||
self.config.num_cols = col
|
||||
|
||||
# Update widgets' positions and replot them according to the new grid
|
||||
self.grid = new_grid
|
||||
self._reindex_grid() # This method should be updated to handle reshuffling correctly
|
||||
self._replot_layout() # Assumes this method re-adds widgets to the layout based on self.grid
|
||||
|
||||
def clear_all(self):
|
||||
"""Clear all widgets from the figure and reset to default state"""
|
||||
for widget in list(self._widgets.values()):
|
||||
widget.remove()
|
||||
self._widgets.clear()
|
||||
self.grid = []
|
||||
theme = self.config.theme
|
||||
self.config = FigureConfig(
|
||||
widget_class=self.__class__.__name__, gui_id=self.gui_id, theme=theme
|
||||
)
|
||||
|
||||
def cleanup_pyqtgraph_all_widgets(self):
|
||||
"""Clean up the pyqtgraph widget."""
|
||||
for widget in self.widget_list:
|
||||
widget.cleanup_pyqtgraph()
|
||||
|
||||
def cleanup(self):
|
||||
"""Close the figure widget."""
|
||||
self.cleanup_pyqtgraph_all_widgets()
|
||||
91
bec_widgets/widgets/containers/figure/plots/axis_settings.py
Normal file
91
bec_widgets/widgets/containers/figure/plots/axis_settings.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import os
|
||||
|
||||
from qtpy.QtWidgets import QVBoxLayout
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
from bec_widgets.qt_utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
class AxisSettings(SettingWidget):
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
self.ui = UILoader().load_ui(os.path.join(current_path, "axis_settings.ui"), self)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.addWidget(self.ui)
|
||||
|
||||
# Hardcoded values for best appearance
|
||||
self.setMinimumHeight(280)
|
||||
self.setMaximumHeight(280)
|
||||
self.resize(380, 280)
|
||||
|
||||
@Slot(dict)
|
||||
def display_current_settings(self, axis_config: dict):
|
||||
|
||||
if axis_config == {}:
|
||||
return
|
||||
|
||||
# Top Box
|
||||
WidgetIO.set_value(self.ui.plot_title, axis_config["title"])
|
||||
self.ui.switch_outer_axes.checked = axis_config["outer_axes"]
|
||||
|
||||
# X Axis Box
|
||||
WidgetIO.set_value(self.ui.x_label, axis_config["x_label"])
|
||||
WidgetIO.set_value(self.ui.x_scale, axis_config["x_scale"])
|
||||
WidgetIO.set_value(self.ui.x_grid, axis_config["x_grid"])
|
||||
if axis_config["x_lim"] is not None:
|
||||
WidgetIO.check_and_adjust_limits(self.ui.x_min, axis_config["x_lim"][0])
|
||||
WidgetIO.check_and_adjust_limits(self.ui.x_max, axis_config["x_lim"][1])
|
||||
WidgetIO.set_value(self.ui.x_min, axis_config["x_lim"][0])
|
||||
WidgetIO.set_value(self.ui.x_max, axis_config["x_lim"][1])
|
||||
if axis_config["x_lim"] is None:
|
||||
x_range = self.target_widget.fig.widget_list[0].plot_item.viewRange()[0]
|
||||
WidgetIO.set_value(self.ui.x_min, x_range[0])
|
||||
WidgetIO.set_value(self.ui.x_max, x_range[1])
|
||||
|
||||
# Y Axis Box
|
||||
WidgetIO.set_value(self.ui.y_label, axis_config["y_label"])
|
||||
WidgetIO.set_value(self.ui.y_scale, axis_config["y_scale"])
|
||||
WidgetIO.set_value(self.ui.y_grid, axis_config["y_grid"])
|
||||
if axis_config["y_lim"] is not None:
|
||||
WidgetIO.check_and_adjust_limits(self.ui.y_min, axis_config["y_lim"][0])
|
||||
WidgetIO.check_and_adjust_limits(self.ui.y_max, axis_config["y_lim"][1])
|
||||
WidgetIO.set_value(self.ui.y_min, axis_config["y_lim"][0])
|
||||
WidgetIO.set_value(self.ui.y_max, axis_config["y_lim"][1])
|
||||
if axis_config["y_lim"] is None:
|
||||
y_range = self.target_widget.fig.widget_list[0].plot_item.viewRange()[1]
|
||||
WidgetIO.set_value(self.ui.y_min, y_range[0])
|
||||
WidgetIO.set_value(self.ui.y_max, y_range[1])
|
||||
|
||||
@Slot()
|
||||
def accept_changes(self):
|
||||
title = WidgetIO.get_value(self.ui.plot_title)
|
||||
outer_axes = self.ui.switch_outer_axes.checked
|
||||
|
||||
# X Axis
|
||||
x_label = WidgetIO.get_value(self.ui.x_label)
|
||||
x_scale = self.ui.x_scale.currentText()
|
||||
x_grid = WidgetIO.get_value(self.ui.x_grid)
|
||||
x_lim = (WidgetIO.get_value(self.ui.x_min), WidgetIO.get_value(self.ui.x_max))
|
||||
|
||||
# Y Axis
|
||||
y_label = WidgetIO.get_value(self.ui.y_label)
|
||||
y_scale = self.ui.y_scale.currentText()
|
||||
y_grid = WidgetIO.get_value(self.ui.y_grid)
|
||||
y_lim = (WidgetIO.get_value(self.ui.y_min), WidgetIO.get_value(self.ui.y_max))
|
||||
|
||||
self.target_widget.set(
|
||||
title=title,
|
||||
x_label=x_label,
|
||||
x_scale=x_scale,
|
||||
x_lim=x_lim,
|
||||
y_label=y_label,
|
||||
y_scale=y_scale,
|
||||
y_lim=y_lim,
|
||||
)
|
||||
self.target_widget.set_grid(x_grid, y_grid)
|
||||
self.target_widget.set_outer_axes(outer_axes)
|
||||
@@ -6,111 +6,80 @@
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>486</width>
|
||||
<height>300</height>
|
||||
<width>427</width>
|
||||
<height>270</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>250</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>278</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0" colspan="2">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="plot_title_label">
|
||||
<property name="text">
|
||||
<string>Plot Title</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="plot_title"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Inner Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="ToggleSwitch" name="inner_axes"/>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLabel" name="label_outer_axes">
|
||||
<property name="text">
|
||||
<string>Outer Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="ToggleSwitch" name="outer_axes">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0" colspan="2">
|
||||
<widget class="QGroupBox" name="x_axis_box">
|
||||
<property name="title">
|
||||
<string>X Axis</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="5" column="0">
|
||||
<widget class="QLabel" name="x_grid_label">
|
||||
<property name="text">
|
||||
<string>Grid</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="ToggleSwitch" name="x_log">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="x_max_label">
|
||||
<property name="text">
|
||||
<string>Max</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="2">
|
||||
<widget class="ToggleSwitch" name="x_grid">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="x_scale_label">
|
||||
<property name="text">
|
||||
<string>Log</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="2">
|
||||
<widget class="QLabel" name="x_min_label">
|
||||
<property name="text">
|
||||
<string>Min</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="x_label_label">
|
||||
<property name="text">
|
||||
<string>Label</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLineEdit" name="x_label"/>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="BECSpinBox" name="x_min"/>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="BECSpinBox" name="x_max"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2" colspan="2">
|
||||
<item row="2" column="1">
|
||||
<widget class="QGroupBox" name="y_axis_box">
|
||||
<property name="title">
|
||||
<string>Y Axis</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_5">
|
||||
<item row="3" column="2">
|
||||
<widget class="QComboBox" name="y_scale">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>linear</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>log</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QDoubleSpinBox" name="y_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-9999.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>9999.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="2">
|
||||
<widget class="QLabel" name="y_min_label">
|
||||
<property name="text">
|
||||
@@ -118,13 +87,26 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QDoubleSpinBox" name="y_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-9999.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>9999.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLineEdit" name="y_label"/>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="y_scale_label">
|
||||
<property name="text">
|
||||
<string>Log</string>
|
||||
<string>Scale</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
@@ -142,6 +124,13 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<widget class="QCheckBox" name="y_grid">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="y_grid_label">
|
||||
<property name="text">
|
||||
@@ -149,58 +138,113 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="ToggleSwitch" name="y_log">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<widget class="ToggleSwitch" name="y_grid">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QGroupBox" name="x_axis_box">
|
||||
<property name="title">
|
||||
<string>X Axis</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="x_scale_label">
|
||||
<property name="text">
|
||||
<string>Scale</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="BECSpinBox" name="y_min">
|
||||
<widget class="QDoubleSpinBox" name="x_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="buttonSymbols">
|
||||
<enum>QAbstractSpinBox::ButtonSymbols::UpDownArrows</enum>
|
||||
<property name="minimum">
|
||||
<double>-9999.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>9999.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="2">
|
||||
<widget class="QLabel" name="x_min_label">
|
||||
<property name="text">
|
||||
<string>Min</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="BECSpinBox" name="y_max"/>
|
||||
<widget class="QDoubleSpinBox" name="x_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-9999.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>9999.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QComboBox" name="x_scale">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>linear</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>log</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="x_max_label">
|
||||
<property name="text">
|
||||
<string>Max</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLineEdit" name="x_label"/>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="x_label_label">
|
||||
<property name="text">
|
||||
<string>Label</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<widget class="QCheckBox" name="x_grid">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="x_grid_label">
|
||||
<property name="text">
|
||||
<string>Grid</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0" colspan="4">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="plot_title_label">
|
||||
<property name="text">
|
||||
<string>Plot Title</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="title"/>
|
||||
</item>
|
||||
</layout>
|
||||
<item row="1" column="1">
|
||||
<widget class="ToggleSwitch" name="switch_outer_axes">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>BECSpinBox</class>
|
||||
<extends>QDoubleSpinBox</extends>
|
||||
<header>bec_spin_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
<extends>QWidget</extends>
|
||||
772
bec_widgets/widgets/containers/figure/plots/image/image.py
Normal file
772
bec_widgets/widgets/containers/figure/plots/image/image.py
Normal file
@@ -0,0 +1,772 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field, ValidationError
|
||||
from qtpy.QtCore import QThread, Slot
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
# from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
from bec_widgets.utils import EntryValidator
|
||||
from bec_widgets.widgets.containers.figure.plots.image.image_item import (
|
||||
BECImageItem,
|
||||
ImageItemConfig,
|
||||
)
|
||||
from bec_widgets.widgets.containers.figure.plots.image.image_processor import (
|
||||
ImageProcessor,
|
||||
ImageStats,
|
||||
ProcessorWorker,
|
||||
)
|
||||
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ImageConfig(SubplotConfig):
|
||||
images: dict[str, ImageItemConfig] = Field(
|
||||
{},
|
||||
description="The configuration of the images. The key is the name of the image (source).",
|
||||
)
|
||||
|
||||
|
||||
class BECImageShow(BECPlotBase):
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"add_image_by_config",
|
||||
"image",
|
||||
"add_custom_image",
|
||||
"set_vrange",
|
||||
"set_color_map",
|
||||
"set_autorange",
|
||||
"set_autorange_mode",
|
||||
"set_monitor",
|
||||
"set_processing",
|
||||
"set_image_properties",
|
||||
"set_fft",
|
||||
"set_log",
|
||||
"set_rotation",
|
||||
"set_transpose",
|
||||
"set",
|
||||
"set_title",
|
||||
"set_x_label",
|
||||
"set_y_label",
|
||||
"set_x_scale",
|
||||
"set_y_scale",
|
||||
"set_x_lim",
|
||||
"set_y_lim",
|
||||
"set_grid",
|
||||
"enable_fps_monitor",
|
||||
"lock_aspect_ratio",
|
||||
"export",
|
||||
"remove",
|
||||
"images",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent_figure=None,
|
||||
config: Optional[ImageConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
single_image: bool = True,
|
||||
):
|
||||
if config is None:
|
||||
config = ImageConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(
|
||||
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
|
||||
)
|
||||
# Get bec shortcuts dev, scans, queue, scan_storage, dap
|
||||
self.single_image = single_image
|
||||
self.image_type = "device_monitor_2d"
|
||||
self.scan_id = None
|
||||
self.get_bec_shortcuts()
|
||||
self.entry_validator = EntryValidator(self.dev)
|
||||
self._images = defaultdict(dict)
|
||||
self.apply_config(self.config)
|
||||
self.processor = ImageProcessor()
|
||||
self.use_threading = False # TODO WILL be moved to the init method and to figure method
|
||||
|
||||
def _create_thread_worker(self, device: str, image: np.ndarray):
|
||||
thread = QThread()
|
||||
worker = ProcessorWorker(self.processor)
|
||||
worker.moveToThread(thread)
|
||||
|
||||
# Connect signals and slots
|
||||
thread.started.connect(lambda: worker.process_image(device, image))
|
||||
worker.processed.connect(self.update_image)
|
||||
worker.stats.connect(self.update_vrange)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.finished.connect(thread.wait)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
thread.start()
|
||||
|
||||
def find_image_by_monitor(self, item_id: str) -> BECImageItem:
|
||||
"""
|
||||
Find the image item by its gui_id.
|
||||
|
||||
Args:
|
||||
item_id(str): The gui_id of the widget.
|
||||
|
||||
Returns:
|
||||
BECImageItem: The widget with the given gui_id.
|
||||
"""
|
||||
for source, images in self._images.items():
|
||||
for key, value in images.items():
|
||||
if key == item_id and isinstance(value, BECImageItem):
|
||||
return value
|
||||
elif isinstance(value, dict):
|
||||
result = self.find_image_by_monitor(item_id)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
def apply_config(self, config: dict | SubplotConfig):
|
||||
"""
|
||||
Apply the configuration to the 1D waveform widget.
|
||||
|
||||
Args:
|
||||
config(dict|SubplotConfig): Configuration settings.
|
||||
replot_last_scan(bool, optional): If True, replot the last scan. Defaults to False.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
try:
|
||||
config = ImageConfig(**config)
|
||||
except ValidationError as e:
|
||||
logger.error(f"Validation error when applying config to BECImageShow: {e}")
|
||||
return
|
||||
self.config = config
|
||||
self.plot_item.clear()
|
||||
|
||||
self.apply_axis_config()
|
||||
self._images = defaultdict(dict)
|
||||
|
||||
for image_id, image_config in config.images.items():
|
||||
self.add_image_by_config(image_config)
|
||||
|
||||
def change_gui_id(self, new_gui_id: str):
|
||||
"""
|
||||
Change the GUI ID of the image widget and update the parent_id in all associated curves.
|
||||
|
||||
Args:
|
||||
new_gui_id (str): The new GUI ID to be set for the image widget.
|
||||
"""
|
||||
self.gui_id = new_gui_id
|
||||
self.config.gui_id = new_gui_id
|
||||
|
||||
for source, images in self._images.items():
|
||||
for id, image_item in images.items():
|
||||
image_item.config.parent_id = new_gui_id
|
||||
|
||||
def add_image_by_config(self, config: ImageItemConfig | dict) -> BECImageItem:
|
||||
"""
|
||||
Add an image to the widget by configuration.
|
||||
|
||||
Args:
|
||||
config(ImageItemConfig|dict): The configuration of the image.
|
||||
|
||||
Returns:
|
||||
BECImageItem: The image object.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
config = ImageItemConfig(**config)
|
||||
config.parent_id = self.gui_id
|
||||
name = config.monitor if config.monitor is not None else config.gui_id
|
||||
image = self._add_image_object(source=config.source, name=name, config=config)
|
||||
return image
|
||||
|
||||
def get_image_config(self, image_id, dict_output: bool = True) -> ImageItemConfig | dict:
|
||||
"""
|
||||
Get the configuration of the image.
|
||||
|
||||
Args:
|
||||
image_id(str): The ID of the image.
|
||||
dict_output(bool): Whether to return the configuration as a dictionary. Defaults to True.
|
||||
|
||||
Returns:
|
||||
ImageItemConfig|dict: The configuration of the image.
|
||||
"""
|
||||
for source, images in self._images.items():
|
||||
for id, image in images.items():
|
||||
if id == image_id:
|
||||
if dict_output:
|
||||
return image.config.dict()
|
||||
else:
|
||||
return image.config # TODO check if this works
|
||||
|
||||
@property
|
||||
def images(self) -> list[BECImageItem]:
|
||||
"""
|
||||
Get the list of images.
|
||||
Returns:
|
||||
list[BECImageItem]: The list of images.
|
||||
"""
|
||||
images = []
|
||||
for source, images_dict in self._images.items():
|
||||
for id, image in images_dict.items():
|
||||
images.append(image)
|
||||
return images
|
||||
|
||||
@images.setter
|
||||
def images(self, value: dict[str, dict[str, BECImageItem]]):
|
||||
"""
|
||||
Set the images from a dictionary.
|
||||
|
||||
Args:
|
||||
value (dict[str, dict[str, BECImageItem]]): The images to set, organized by source and id.
|
||||
"""
|
||||
self._images = value
|
||||
|
||||
def get_image_dict(self) -> dict[str, dict[str, BECImageItem]]:
|
||||
"""
|
||||
Get all images.
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, BECImageItem]]: The dictionary of images.
|
||||
"""
|
||||
return self._images
|
||||
|
||||
def image(
|
||||
self,
|
||||
monitor: str,
|
||||
monitor_type: Literal["1d", "2d"] = "2d",
|
||||
color_map: Optional[str] = "magma",
|
||||
color_bar: Optional[Literal["simple", "full"]] = "full",
|
||||
downsample: Optional[bool] = True,
|
||||
opacity: Optional[float] = 1.0,
|
||||
vrange: Optional[tuple[int, int]] = None,
|
||||
# post_processing: Optional[PostProcessingConfig] = None,
|
||||
**kwargs,
|
||||
) -> BECImageItem:
|
||||
"""
|
||||
Add an image to the figure. Always access the first image widget in the figure.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor to display.
|
||||
monitor_type(Literal["1d","2d"]): The type of monitor to display.
|
||||
color_bar(Literal["simple","full"]): The type of color bar to display.
|
||||
color_map(str): The color map to use for the image.
|
||||
data(np.ndarray): Custom data to display.
|
||||
vrange(tuple[float, float]): The range of values to display.
|
||||
|
||||
Returns:
|
||||
BECImageItem: The image item.
|
||||
"""
|
||||
if monitor_type == "1d":
|
||||
image_source = "device_monitor_1d"
|
||||
self.image_type = "device_monitor_1d"
|
||||
elif monitor_type == "2d":
|
||||
image_source = "device_monitor_2d"
|
||||
self.image_type = "device_monitor_2d"
|
||||
|
||||
image_exits = self._check_image_id(monitor, self._images)
|
||||
if image_exits:
|
||||
# raise ValueError(
|
||||
# f"Monitor with ID '{monitor}' already exists in widget '{self.gui_id}'."
|
||||
# )
|
||||
return
|
||||
|
||||
# monitor = self.entry_validator.validate_monitor(monitor)
|
||||
|
||||
image_config = ImageItemConfig(
|
||||
widget_class="BECImageItem",
|
||||
parent_id=self.gui_id,
|
||||
color_map=color_map,
|
||||
color_bar=color_bar,
|
||||
downsample=downsample,
|
||||
opacity=opacity,
|
||||
vrange=vrange,
|
||||
source=image_source,
|
||||
monitor=monitor,
|
||||
# post_processing=post_processing,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
image = self._add_image_object(source=image_source, name=monitor, config=image_config)
|
||||
return image
|
||||
|
||||
def add_custom_image(
|
||||
self,
|
||||
name: str,
|
||||
data: Optional[np.ndarray] = None,
|
||||
color_map: Optional[str] = "magma",
|
||||
color_bar: Optional[Literal["simple", "full"]] = "full",
|
||||
downsample: Optional[bool] = True,
|
||||
opacity: Optional[float] = 1.0,
|
||||
vrange: Optional[tuple[int, int]] = None,
|
||||
# post_processing: Optional[PostProcessingConfig] = None,
|
||||
**kwargs,
|
||||
):
|
||||
image_source = "custom"
|
||||
|
||||
image_exits = self._check_image_id(name, self._images)
|
||||
if image_exits:
|
||||
raise ValueError(f"Monitor with ID '{name}' already exists in widget '{self.gui_id}'.")
|
||||
|
||||
image_config = ImageItemConfig(
|
||||
widget_class="BECImageItem",
|
||||
parent_id=self.gui_id,
|
||||
monitor=name,
|
||||
color_map=color_map,
|
||||
color_bar=color_bar,
|
||||
downsample=downsample,
|
||||
opacity=opacity,
|
||||
vrange=vrange,
|
||||
# post_processing=post_processing,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
image = self._add_image_object(
|
||||
source=image_source, name=name, config=image_config, data=data
|
||||
)
|
||||
return image
|
||||
|
||||
def apply_setting_to_images(
|
||||
self, setting_method_name: str, args: list, kwargs: dict, image_id: str = None
|
||||
):
|
||||
"""
|
||||
Apply a setting to all images or a specific image by its ID.
|
||||
|
||||
Args:
|
||||
setting_method_name (str): The name of the method to apply (e.g., 'set_color_map').
|
||||
args (list): Positional arguments for the setting method.
|
||||
kwargs (dict): Keyword arguments for the setting method.
|
||||
image_id (str, optional): The ID of the specific image to apply the setting to. If None, applies to all images.
|
||||
"""
|
||||
if image_id:
|
||||
image = self.find_image_by_monitor(image_id)
|
||||
if image:
|
||||
getattr(image, setting_method_name)(*args, **kwargs)
|
||||
else:
|
||||
for source, images in self._images.items():
|
||||
for _, image in images.items():
|
||||
getattr(image, setting_method_name)(*args, **kwargs)
|
||||
self.refresh_image()
|
||||
|
||||
def set_vrange(self, vmin: float, vmax: float, name: str = None):
|
||||
"""
|
||||
Set the range of the color bar.
|
||||
If name is not specified, then set vrange for all images.
|
||||
|
||||
Args:
|
||||
vmin(float): Minimum value of the color bar.
|
||||
vmax(float): Maximum value of the color bar.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_vrange", args=[vmin, vmax], kwargs={}, image_id=name)
|
||||
|
||||
def set_color_map(self, cmap: str, name: str = None):
|
||||
"""
|
||||
Set the color map of the image.
|
||||
If name is not specified, then set color map for all images.
|
||||
|
||||
Args:
|
||||
cmap(str): The color map of the image.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_color_map", args=[cmap], kwargs={}, image_id=name)
|
||||
|
||||
def set_autorange(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the autoscale of the image.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to autoscale the color bar.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_autorange", args=[enable], kwargs={}, image_id=name)
|
||||
|
||||
def set_autorange_mode(self, mode: Literal["max", "mean"], name: str = None):
|
||||
"""
|
||||
Set the autoscale mode of the image, that decides how the vrange of the color bar is scaled.
|
||||
Choose betwen 'max' -> min/max of the data, 'mean' -> mean +/- fudge_factor*std of the data (fudge_factor~2).
|
||||
|
||||
Args:
|
||||
mode(str): The autoscale mode of the image.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_autorange_mode", args=[mode], kwargs={}, image_id=name)
|
||||
|
||||
def set_monitor(self, monitor: str, name: str = None):
|
||||
"""
|
||||
Set the monitor of the image.
|
||||
If name is not specified, then set monitor for all images.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_monitor", args=[monitor], kwargs={}, image_id=name)
|
||||
|
||||
def set_processing(self, name: str = None, **kwargs):
|
||||
"""
|
||||
Set the post processing of the image.
|
||||
If name is not specified, then set post processing for all images.
|
||||
|
||||
Args:
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
Possible properties:
|
||||
- fft: bool
|
||||
- log: bool
|
||||
- rot: int
|
||||
- transpose: bool
|
||||
"""
|
||||
self.apply_setting_to_images("set", args=[], kwargs=kwargs, image_id=name)
|
||||
|
||||
def set_image_properties(self, name: str = None, **kwargs):
|
||||
"""
|
||||
Set the properties of the image.
|
||||
|
||||
Args:
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
Possible properties:
|
||||
- downsample: bool
|
||||
- color_map: str
|
||||
- monitor: str
|
||||
- opacity: float
|
||||
- vrange: tuple[int,int]
|
||||
- fft: bool
|
||||
- log: bool
|
||||
- rot: int
|
||||
- transpose: bool
|
||||
"""
|
||||
self.apply_setting_to_images("set", args=[], kwargs=kwargs, image_id=name)
|
||||
|
||||
def set_fft(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the FFT of the image.
|
||||
If name is not specified, then set FFT for all images.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to perform FFT on the monitor data.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_fft", args=[enable], kwargs={}, image_id=name)
|
||||
|
||||
def set_log(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the log of the image.
|
||||
If name is not specified, then set log for all images.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to perform log on the monitor data.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_log", args=[enable], kwargs={}, image_id=name)
|
||||
|
||||
def set_rotation(self, deg_90: int = 0, name: str = None):
|
||||
"""
|
||||
Set the rotation of the image.
|
||||
If name is not specified, then set rotation for all images.
|
||||
|
||||
Args:
|
||||
deg_90(int): The rotation angle of the monitor data before displaying.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_rotation", args=[deg_90], kwargs={}, image_id=name)
|
||||
|
||||
def set_transpose(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the transpose of the image.
|
||||
If name is not specified, then set transpose for all images.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to transpose the monitor data before displaying.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_transpose", args=[enable], kwargs={}, image_id=name)
|
||||
|
||||
def toggle_threading(self, use_threading: bool):
|
||||
"""
|
||||
Toggle threading for the widgets postprocessing and updating.
|
||||
|
||||
Args:
|
||||
use_threading(bool): Whether to use threading.
|
||||
"""
|
||||
self.use_threading = use_threading
|
||||
if self.use_threading is False and self.thread.isRunning():
|
||||
self.cleanup()
|
||||
|
||||
def process_image(self, device: str, image: BECImageItem, data: np.ndarray):
|
||||
"""
|
||||
Process the image data.
|
||||
|
||||
Args:
|
||||
device(str): The name of the device - image_id of image.
|
||||
image(np.ndarray): The image data to be processed.
|
||||
data(np.ndarray): The image data to be processed.
|
||||
|
||||
Returns:
|
||||
np.ndarray: The processed image data.
|
||||
"""
|
||||
processing_config = image.config.processing
|
||||
self.processor.set_config(processing_config)
|
||||
if self.use_threading:
|
||||
self._create_thread_worker(device, data)
|
||||
else:
|
||||
data = self.processor.process_image(data)
|
||||
self.update_image(device, data)
|
||||
self.update_vrange(device, self.processor.config.stats)
|
||||
|
||||
@Slot(dict, dict)
|
||||
def on_image_update(self, msg: dict, metadata: dict):
|
||||
"""
|
||||
Update the image of the device monitor from bec.
|
||||
|
||||
Args:
|
||||
msg(dict): The message from bec.
|
||||
metadata(dict): The metadata of the message.
|
||||
"""
|
||||
data = msg["data"]
|
||||
device = msg["device"]
|
||||
if self.image_type == "device_monitor_1d":
|
||||
image = self._images["device_monitor_1d"][device]
|
||||
current_scan_id = metadata.get("scan_id", None)
|
||||
if current_scan_id is None:
|
||||
return
|
||||
if current_scan_id != self.scan_id:
|
||||
self.reset()
|
||||
self.scan_id = current_scan_id
|
||||
image.image_buffer_list = []
|
||||
image.max_len = 0
|
||||
image_buffer = self.adjust_image_buffer(image, data)
|
||||
image.raw_data = image_buffer
|
||||
self.process_image(device, image, image_buffer)
|
||||
elif self.image_type == "device_monitor_2d":
|
||||
image = self._images["device_monitor_2d"][device]
|
||||
image.raw_data = data
|
||||
self.process_image(device, image, data)
|
||||
|
||||
def adjust_image_buffer(self, image: BECImageItem, new_data: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Adjusts the image buffer to accommodate the new data, ensuring that all rows have the same length.
|
||||
|
||||
Args:
|
||||
image: The image object (used to store buffer list and max_len).
|
||||
new_data (np.ndarray): The new incoming 1D waveform data.
|
||||
|
||||
Returns:
|
||||
np.ndarray: The updated image buffer with adjusted shapes.
|
||||
"""
|
||||
new_len = new_data.shape[0]
|
||||
if not hasattr(image, "image_buffer_list"):
|
||||
image.image_buffer_list = []
|
||||
image.max_len = 0
|
||||
|
||||
if new_len > image.max_len:
|
||||
image.max_len = new_len
|
||||
for i in range(len(image.image_buffer_list)):
|
||||
wf = image.image_buffer_list[i]
|
||||
pad_width = image.max_len - wf.shape[0]
|
||||
if pad_width > 0:
|
||||
image.image_buffer_list[i] = np.pad(
|
||||
wf, (0, pad_width), mode="constant", constant_values=0
|
||||
)
|
||||
image.image_buffer_list.append(new_data)
|
||||
else:
|
||||
pad_width = image.max_len - new_len
|
||||
if pad_width > 0:
|
||||
new_data = np.pad(new_data, (0, pad_width), mode="constant", constant_values=0)
|
||||
image.image_buffer_list.append(new_data)
|
||||
|
||||
image_buffer = np.array(image.image_buffer_list)
|
||||
return image_buffer
|
||||
|
||||
@Slot(str, np.ndarray)
|
||||
def update_image(self, device: str, data: np.ndarray):
|
||||
"""
|
||||
Update the image of the device monitor.
|
||||
|
||||
Args:
|
||||
device(str): The name of the device.
|
||||
data(np.ndarray): The data to be updated.
|
||||
"""
|
||||
image_to_update = self._images[self.image_type][device]
|
||||
image_to_update.updateImage(data, autoLevels=image_to_update.config.autorange)
|
||||
|
||||
@Slot(str, ImageStats)
|
||||
def update_vrange(self, device: str, stats: ImageStats):
|
||||
"""
|
||||
Update the scaling of the image.
|
||||
|
||||
Args:
|
||||
stats(ImageStats): The statistics of the image.
|
||||
"""
|
||||
image_to_update = self._images[self.image_type][device]
|
||||
if image_to_update.config.autorange:
|
||||
image_to_update.auto_update_vrange(stats)
|
||||
|
||||
def refresh_image(self):
|
||||
"""
|
||||
Refresh the image.
|
||||
"""
|
||||
for source, images in self._images.items():
|
||||
for image_id, image in images.items():
|
||||
data = image.raw_data
|
||||
self.process_image(image_id, image, data)
|
||||
|
||||
def _connect_device_monitor(self, monitor: str):
|
||||
"""
|
||||
Connect to the device monitor.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor.
|
||||
"""
|
||||
image_item = self.find_image_by_monitor(monitor)
|
||||
try:
|
||||
previous_monitor = image_item.config.monitor
|
||||
except AttributeError:
|
||||
previous_monitor = None
|
||||
if previous_monitor and image_item.connected is True:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_1d(previous_monitor)
|
||||
)
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_2d(previous_monitor)
|
||||
)
|
||||
image_item.connected = False
|
||||
if monitor and image_item.connected is False:
|
||||
self.entry_validator.validate_monitor(monitor)
|
||||
if self.image_type == "device_monitor_1d":
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_1d(monitor)
|
||||
)
|
||||
elif self.image_type == "device_monitor_2d":
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_2d(monitor)
|
||||
)
|
||||
image_item.set_monitor(monitor)
|
||||
image_item.connected = True
|
||||
|
||||
def _add_image_object(
|
||||
self, source: str, name: str, config: ImageItemConfig, data=None
|
||||
) -> BECImageItem:
|
||||
config.parent_id = self.gui_id
|
||||
if self.single_image is True and len(self.images) > 0:
|
||||
self.remove_image(0)
|
||||
image = BECImageItem(config=config, parent_image=self)
|
||||
self.plot_item.addItem(image)
|
||||
self._images[source][name] = image
|
||||
self._connect_device_monitor(config.monitor)
|
||||
self.config.images[name] = config
|
||||
if data is not None:
|
||||
image.setImage(data)
|
||||
return image
|
||||
|
||||
def _check_image_id(self, val: Any, dict_to_check: dict) -> bool:
|
||||
"""
|
||||
Check if val is in the values of the dict_to_check or in the values of the nested dictionaries.
|
||||
|
||||
Args:
|
||||
val(Any): Value to check.
|
||||
dict_to_check(dict): Dictionary to check.
|
||||
|
||||
Returns:
|
||||
bool: True if val is in the values of the dict_to_check or in the values of the nested dictionaries, False otherwise.
|
||||
"""
|
||||
if val in dict_to_check.keys():
|
||||
return True
|
||||
for key in dict_to_check:
|
||||
if isinstance(dict_to_check[key], dict):
|
||||
if self._check_image_id(val, dict_to_check[key]):
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_image(self, *identifiers):
|
||||
"""
|
||||
Remove an image from the plot widget.
|
||||
|
||||
Args:
|
||||
*identifiers: Identifier of the image to be removed. Can be either an integer (index) or a string (image_id).
|
||||
"""
|
||||
for identifier in identifiers:
|
||||
if isinstance(identifier, int):
|
||||
self._remove_image_by_order(identifier)
|
||||
elif isinstance(identifier, str):
|
||||
self._remove_image_by_id(identifier)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Each identifier must be either an integer (index) or a string (image_id)."
|
||||
)
|
||||
|
||||
def _remove_image_by_id(self, image_id):
|
||||
for source, images in self._images.items():
|
||||
if image_id in images:
|
||||
self._disconnect_monitor(image_id)
|
||||
image = images.pop(image_id)
|
||||
self.removeItem(image.color_bar)
|
||||
self.plot_item.removeItem(image)
|
||||
del self.config.images[image_id]
|
||||
if image in self.images:
|
||||
self.images.remove(image)
|
||||
return
|
||||
raise KeyError(f"Image with ID '{image_id}' not found.")
|
||||
|
||||
def _remove_image_by_order(self, N):
|
||||
"""
|
||||
Remove an image by its order from the plot widget.
|
||||
|
||||
Args:
|
||||
N(int): Order of the image to be removed.
|
||||
"""
|
||||
if N < len(self.images):
|
||||
image = self.images[N]
|
||||
image_id = image.config.monitor
|
||||
self._disconnect_monitor(image_id)
|
||||
self.removeItem(image.color_bar)
|
||||
self.plot_item.removeItem(image)
|
||||
del self.config.images[image_id]
|
||||
for source, images in self._images.items():
|
||||
if image_id in images:
|
||||
del images[image_id]
|
||||
break
|
||||
else:
|
||||
raise IndexError(f"Image order {N} out of range.")
|
||||
|
||||
def _disconnect_monitor(self, image_id):
|
||||
"""
|
||||
Disconnect the monitor from the device.
|
||||
|
||||
Args:
|
||||
image_id(str): The ID of the monitor.
|
||||
"""
|
||||
image = self.find_image_by_monitor(image_id)
|
||||
if image:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_1d(image.config.monitor)
|
||||
)
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_2d(image.config.monitor)
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the widget.
|
||||
"""
|
||||
for monitor in self._images[self.image_type]:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_1d(monitor)
|
||||
)
|
||||
self.images.clear()
|
||||
|
||||
def cleanup_pyqtgraph(self):
|
||||
"""Cleanup pyqtgraph items."""
|
||||
super().cleanup_pyqtgraph()
|
||||
item = self.plot_item
|
||||
if not item.items:
|
||||
return
|
||||
cbar = item.items[0].color_bar
|
||||
cbar.vb.menu.close()
|
||||
cbar.vb.menu.deleteLater()
|
||||
cbar.gradient.menu.close()
|
||||
cbar.gradient.menu.deleteLater()
|
||||
cbar.gradient.colorDialog.close()
|
||||
cbar.gradient.colorDialog.deleteLater()
|
||||
337
bec_widgets/widgets/containers/figure/plots/image/image_item.py
Normal file
337
bec_widgets/widgets/containers/figure/plots/image/image_item.py
Normal file
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field
|
||||
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
from bec_widgets.widgets.containers.figure.plots.image.image_processor import (
|
||||
ImageStats,
|
||||
ProcessingConfig,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.containers.figure.plots.image.image import BECImageShow
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ImageItemConfig(ConnectionConfig):
|
||||
parent_id: Optional[str] = Field(None, description="The parent plot of the image.")
|
||||
monitor: Optional[str] = Field(None, description="The name of the monitor.")
|
||||
source: Optional[str] = Field(None, description="The source of the curve.")
|
||||
color_map: Optional[str] = Field("magma", description="The color map of the image.")
|
||||
downsample: Optional[bool] = Field(True, description="Whether to downsample the image.")
|
||||
opacity: Optional[float] = Field(1.0, description="The opacity of the image.")
|
||||
vrange: Optional[tuple[float | int, float | int]] = Field(
|
||||
None, description="The range of the color bar. If None, the range is automatically set."
|
||||
)
|
||||
color_bar: Optional[Literal["simple", "full"]] = Field(
|
||||
"simple", description="The type of the color bar."
|
||||
)
|
||||
autorange: Optional[bool] = Field(True, description="Whether to autorange the color bar.")
|
||||
autorange_mode: Optional[Literal["max", "mean"]] = Field(
|
||||
"mean", description="Whether to use the mean of the image for autoscaling."
|
||||
)
|
||||
processing: ProcessingConfig = Field(
|
||||
default_factory=ProcessingConfig, description="The post processing of the image."
|
||||
)
|
||||
|
||||
|
||||
class BECImageItem(BECConnector, pg.ImageItem):
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"set",
|
||||
"set_fft",
|
||||
"set_log",
|
||||
"set_rotation",
|
||||
"set_transpose",
|
||||
"set_opacity",
|
||||
"set_autorange",
|
||||
"set_autorange_mode",
|
||||
"set_color_map",
|
||||
"set_auto_downsample",
|
||||
"set_monitor",
|
||||
"set_vrange",
|
||||
"get_data",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[ImageItemConfig] = None,
|
||||
gui_id: Optional[str] = None,
|
||||
parent_image: Optional[BECImageShow] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if config is None:
|
||||
config = ImageItemConfig(widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
else:
|
||||
self.config = config
|
||||
super().__init__(config=config, gui_id=gui_id)
|
||||
pg.ImageItem.__init__(self)
|
||||
|
||||
self.parent_image = parent_image
|
||||
self.colorbar_bar = None
|
||||
self._raw_data = None
|
||||
|
||||
self._add_color_bar(
|
||||
self.config.color_bar, self.config.vrange
|
||||
) # TODO can also support None to not have any colorbar
|
||||
self.apply_config()
|
||||
if kwargs:
|
||||
self.set(**kwargs)
|
||||
self.connected = False
|
||||
|
||||
@property
|
||||
def raw_data(self) -> np.ndarray:
|
||||
return self._raw_data
|
||||
|
||||
@raw_data.setter
|
||||
def raw_data(self, data: np.ndarray):
|
||||
self._raw_data = data
|
||||
|
||||
def apply_config(self):
|
||||
"""
|
||||
Apply current configuration.
|
||||
"""
|
||||
self.set_color_map(self.config.color_map)
|
||||
self.set_auto_downsample(self.config.downsample)
|
||||
if self.config.vrange is not None:
|
||||
self.set_vrange(vrange=self.config.vrange)
|
||||
|
||||
def set(self, **kwargs):
|
||||
"""
|
||||
Set the properties of the image.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
|
||||
Possible properties:
|
||||
- downsample
|
||||
- color_map
|
||||
- monitor
|
||||
- opacity
|
||||
- vrange
|
||||
- fft
|
||||
- log
|
||||
- rot
|
||||
- transpose
|
||||
- autorange_mode
|
||||
"""
|
||||
method_map = {
|
||||
"downsample": self.set_auto_downsample,
|
||||
"color_map": self.set_color_map,
|
||||
"monitor": self.set_monitor,
|
||||
"opacity": self.set_opacity,
|
||||
"vrange": self.set_vrange,
|
||||
"fft": self.set_fft,
|
||||
"log": self.set_log,
|
||||
"rot": self.set_rotation,
|
||||
"transpose": self.set_transpose,
|
||||
"autorange_mode": self.set_autorange_mode,
|
||||
}
|
||||
for key, value in kwargs.items():
|
||||
if key in method_map:
|
||||
method_map[key](value)
|
||||
else:
|
||||
logger.warning(f"Warning: '{key}' is not a recognized property.")
|
||||
|
||||
def set_fft(self, enable: bool = False):
|
||||
"""
|
||||
Set the FFT of the image.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to perform FFT on the monitor data.
|
||||
"""
|
||||
self.config.processing.fft = enable
|
||||
|
||||
def set_log(self, enable: bool = False):
|
||||
"""
|
||||
Set the log of the image.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to perform log on the monitor data.
|
||||
"""
|
||||
self.config.processing.log = enable
|
||||
if enable and self.color_bar and self.config.color_bar == "full":
|
||||
self.color_bar.autoHistogramRange()
|
||||
|
||||
def set_rotation(self, deg_90: int = 0):
|
||||
"""
|
||||
Set the rotation of the image.
|
||||
|
||||
Args:
|
||||
deg_90(int): The rotation angle of the monitor data before displaying.
|
||||
"""
|
||||
self.config.processing.rotation = deg_90
|
||||
|
||||
def set_transpose(self, enable: bool = False):
|
||||
"""
|
||||
Set the transpose of the image.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to transpose the image.
|
||||
"""
|
||||
self.config.processing.transpose = enable
|
||||
|
||||
def set_opacity(self, opacity: float = 1.0):
|
||||
"""
|
||||
Set the opacity of the image.
|
||||
|
||||
Args:
|
||||
opacity(float): The opacity of the image.
|
||||
"""
|
||||
self.setOpacity(opacity)
|
||||
self.config.opacity = opacity
|
||||
|
||||
def set_autorange(self, autorange: bool = False):
|
||||
"""
|
||||
Set the autorange of the color bar.
|
||||
|
||||
Args:
|
||||
autorange(bool): Whether to autorange the color bar.
|
||||
"""
|
||||
self.config.autorange = autorange
|
||||
if self.color_bar and autorange:
|
||||
self.color_bar.autoHistogramRange()
|
||||
|
||||
def set_autorange_mode(self, mode: Literal["max", "mean"] = "mean"):
|
||||
"""
|
||||
Set the autorange mode to scale the vrange of the color bar. Choose between min/max or mean +/- std.
|
||||
|
||||
Args:
|
||||
mode(Literal["max","mean"]): Max for min/max or mean for mean +/- std.
|
||||
"""
|
||||
self.config.autorange_mode = mode
|
||||
|
||||
def set_color_map(self, cmap: str = "magma"):
|
||||
"""
|
||||
Set the color map of the image.
|
||||
|
||||
Args:
|
||||
cmap(str): The color map of the image.
|
||||
"""
|
||||
self.setColorMap(cmap)
|
||||
if self.color_bar is not None:
|
||||
if self.config.color_bar == "simple":
|
||||
self.color_bar.setColorMap(cmap)
|
||||
elif self.config.color_bar == "full":
|
||||
self.color_bar.gradient.loadPreset(cmap)
|
||||
self.config.color_map = cmap
|
||||
|
||||
def set_auto_downsample(self, auto: bool = True):
|
||||
"""
|
||||
Set the auto downsample of the image.
|
||||
|
||||
Args:
|
||||
auto(bool): Whether to downsample the image.
|
||||
"""
|
||||
self.setAutoDownsample(auto)
|
||||
self.config.downsample = auto
|
||||
|
||||
def set_monitor(self, monitor: str):
|
||||
"""
|
||||
Set the monitor of the image.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor.
|
||||
"""
|
||||
self.config.monitor = monitor
|
||||
|
||||
def auto_update_vrange(self, stats: ImageStats) -> None:
|
||||
"""Auto update of the vrange base on the stats of the image.
|
||||
|
||||
Args:
|
||||
stats(ImageStats): The stats of the image.
|
||||
"""
|
||||
fumble_factor = 2
|
||||
if self.config.autorange_mode == "mean":
|
||||
vmin = max(stats.mean - fumble_factor * stats.std, 0)
|
||||
vmax = stats.mean + fumble_factor * stats.std
|
||||
self.set_vrange(vmin, vmax, change_autorange=False)
|
||||
return
|
||||
if self.config.autorange_mode == "max":
|
||||
self.set_vrange(max(stats.minimum, 0), stats.maximum, change_autorange=False)
|
||||
return
|
||||
|
||||
def set_vrange(
|
||||
self,
|
||||
vmin: float = None,
|
||||
vmax: float = None,
|
||||
vrange: tuple[float, float] = None,
|
||||
change_autorange: bool = True,
|
||||
):
|
||||
"""
|
||||
Set the range of the color bar.
|
||||
|
||||
Args:
|
||||
vmin(float): Minimum value of the color bar.
|
||||
vmax(float): Maximum value of the color bar.
|
||||
"""
|
||||
if vrange is not None:
|
||||
vmin, vmax = vrange
|
||||
self.setLevels([vmin, vmax])
|
||||
self.config.vrange = (vmin, vmax)
|
||||
if change_autorange:
|
||||
self.config.autorange = False
|
||||
if self.color_bar is not None:
|
||||
if self.config.color_bar == "simple":
|
||||
self.color_bar.setLevels(low=vmin, high=vmax)
|
||||
elif self.config.color_bar == "full":
|
||||
# pylint: disable=unexpected-keyword-arg
|
||||
self.color_bar.setLevels(min=vmin, max=vmax)
|
||||
self.color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
|
||||
|
||||
def get_data(self) -> np.ndarray:
|
||||
"""
|
||||
Get the data of the image.
|
||||
Returns:
|
||||
np.ndarray: The data of the image.
|
||||
"""
|
||||
return self.image
|
||||
|
||||
def _add_color_bar(
|
||||
self, color_bar_style: str = "simple", vrange: Optional[tuple[int, int]] = None
|
||||
):
|
||||
"""
|
||||
Add color bar to the layout.
|
||||
|
||||
Args:
|
||||
style(Literal["simple,full"]): The style of the color bar.
|
||||
vrange(tuple[int,int]): The range of the color bar.
|
||||
"""
|
||||
if color_bar_style == "simple":
|
||||
self.color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
|
||||
if vrange is not None:
|
||||
self.color_bar.setLevels(low=vrange[0], high=vrange[1])
|
||||
self.color_bar.setImageItem(self)
|
||||
self.parent_image.addItem(self.color_bar, row=1, col=1)
|
||||
self.config.color_bar = "simple"
|
||||
elif color_bar_style == "full":
|
||||
# Setting histogram
|
||||
self.color_bar = pg.HistogramLUTItem()
|
||||
self.color_bar.setImageItem(self)
|
||||
self.color_bar.gradient.loadPreset(self.config.color_map)
|
||||
if vrange is not None:
|
||||
self.color_bar.setLevels(min=vrange[0], max=vrange[1])
|
||||
self.color_bar.setHistogramRange(
|
||||
vrange[0] - 0.1 * vrange[0], vrange[1] + 0.1 * vrange[1]
|
||||
)
|
||||
|
||||
# Adding histogram to the layout
|
||||
self.parent_image.addItem(self.color_bar, row=1, col=1)
|
||||
|
||||
# save settings
|
||||
self.config.color_bar = "full"
|
||||
else:
|
||||
raise ValueError("style should be 'simple' or 'full'")
|
||||
|
||||
def remove(self):
|
||||
"""Remove the curve from the plot."""
|
||||
self.parent_image.remove_image(self.config.monitor)
|
||||
self.rpc_register.remove_rpc(self)
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtCore import QObject, Signal
|
||||
from qtpy.QtCore import QObject, Signal, Slot
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -16,51 +17,35 @@ class ImageStats:
|
||||
mean: float
|
||||
std: float
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: np.ndarray) -> ImageStats:
|
||||
"""
|
||||
Get the statistics of the image data.
|
||||
|
||||
Args:
|
||||
data(np.ndarray): The image data.
|
||||
|
||||
Returns:
|
||||
ImageStats: The statistics of the image data.
|
||||
"""
|
||||
return cls(maximum=np.max(data), minimum=np.min(data), mean=np.mean(data), std=np.std(data))
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class ProcessingConfig(BaseModel):
|
||||
fft: bool = Field(False, description="Whether to perform FFT on the monitor data.")
|
||||
log: bool = Field(False, description="Whether to perform log on the monitor data.")
|
||||
transpose: bool = Field(
|
||||
fft: Optional[bool] = Field(False, description="Whether to perform FFT on the monitor data.")
|
||||
log: Optional[bool] = Field(False, description="Whether to perform log on the monitor data.")
|
||||
center_of_mass: Optional[bool] = Field(
|
||||
False, description="Whether to calculate the center of mass of the monitor data."
|
||||
)
|
||||
transpose: Optional[bool] = Field(
|
||||
False, description="Whether to transpose the monitor data before displaying."
|
||||
)
|
||||
rotation: int = Field(
|
||||
0, description="The rotation angle of the monitor data before displaying."
|
||||
rotation: Optional[int] = Field(
|
||||
None, description="The rotation angle of the monitor data before displaying."
|
||||
)
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
stats: ImageStats = Field(
|
||||
ImageStats(maximum=0, minimum=0, mean=0, std=0),
|
||||
description="The statistics of the image data.",
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
class ImageProcessor(QObject):
|
||||
class ImageProcessor:
|
||||
"""
|
||||
Class for processing the image data.
|
||||
"""
|
||||
|
||||
image_processed = Signal(np.ndarray)
|
||||
|
||||
def __init__(self, parent=None, config: ProcessingConfig = None):
|
||||
super().__init__(parent=parent)
|
||||
def __init__(self, config: ProcessingConfig = None):
|
||||
if config is None:
|
||||
config = ProcessingConfig()
|
||||
self.config = config
|
||||
self._current_thread = None
|
||||
|
||||
def set_config(self, config: ProcessingConfig):
|
||||
"""
|
||||
@@ -124,6 +109,9 @@ class ImageProcessor(QObject):
|
||||
data_offset = data + offset
|
||||
return np.log10(data_offset)
|
||||
|
||||
# def center_of_mass(self, data: np.ndarray) -> tuple: # TODO check functionality
|
||||
# return np.unravel_index(np.argmax(data), data.shape)
|
||||
|
||||
def update_image_stats(self, data: np.ndarray) -> None:
|
||||
"""Get the statistics of the image data.
|
||||
|
||||
@@ -137,7 +125,15 @@ class ImageProcessor(QObject):
|
||||
self.config.stats.std = np.std(data)
|
||||
|
||||
def process_image(self, data: np.ndarray) -> np.ndarray:
|
||||
"""Core processing logic without threading overhead."""
|
||||
"""
|
||||
Process the data according to the configuration.
|
||||
|
||||
Args:
|
||||
data(np.ndarray): The data to be processed.
|
||||
|
||||
Returns:
|
||||
np.ndarray: The processed data.
|
||||
"""
|
||||
if self.config.fft:
|
||||
data = self.FFT(data)
|
||||
if self.config.rotation is not None:
|
||||
@@ -148,3 +144,40 @@ class ImageProcessor(QObject):
|
||||
data = self.log(data)
|
||||
self.update_image_stats(data)
|
||||
return data
|
||||
|
||||
|
||||
class ProcessorWorker(QObject):
|
||||
"""
|
||||
Worker for processing the image data.
|
||||
"""
|
||||
|
||||
processed = Signal(str, np.ndarray)
|
||||
stats = Signal(str, ImageStats)
|
||||
stopRequested = Signal()
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self, processor):
|
||||
super().__init__()
|
||||
self.processor = processor
|
||||
self._isRunning = False
|
||||
self.stopRequested.connect(self.stop)
|
||||
|
||||
@Slot(str, np.ndarray)
|
||||
def process_image(self, device: str, image: np.ndarray):
|
||||
"""
|
||||
Process the image data.
|
||||
|
||||
Args:
|
||||
device(str): The name of the device.
|
||||
image(np.ndarray): The image data.
|
||||
"""
|
||||
self._isRunning = True
|
||||
processed_image = self.processor.process_image(image)
|
||||
self._isRunning = False
|
||||
if not self._isRunning:
|
||||
self.processed.emit(device, processed_image)
|
||||
self.stats.emit(self.processor.config.stats)
|
||||
self.finished.emit()
|
||||
|
||||
def stop(self):
|
||||
self._isRunning = False
|
||||
@@ -0,0 +1,525 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy import QtCore, QtGui
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
from bec_widgets.utils import Colors, EntryValidator
|
||||
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
|
||||
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import Signal, SignalData
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class MotorMapConfig(SubplotConfig):
|
||||
signals: Optional[Signal] = Field(None, description="Signals of the motor map")
|
||||
color: Optional[str | tuple] = Field(
|
||||
(255, 255, 255, 255), description="The color of the last point of current position."
|
||||
)
|
||||
scatter_size: Optional[int] = Field(5, description="Size of the scatter points.")
|
||||
max_points: Optional[int] = Field(5000, description="Maximum number of points to display.")
|
||||
num_dim_points: Optional[int] = Field(
|
||||
100,
|
||||
description="Number of points to dim before the color remains same for older recorded position.",
|
||||
)
|
||||
precision: Optional[int] = Field(2, description="Decimal precision of the motor position.")
|
||||
background_value: Optional[int] = Field(
|
||||
25, description="Background value of the motor map. Has to be between 0 and 255."
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
_validate_color = field_validator("color")(Colors.validate_color)
|
||||
|
||||
@field_validator("background_value")
|
||||
def validate_background_value(cls, value):
|
||||
if not 0 <= value <= 255:
|
||||
raise PydanticCustomError(
|
||||
"wrong_value", f"'{value}' hs to be between 0 and 255.", {"wrong_value": value}
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class BECMotorMap(BECPlotBase):
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"change_motors",
|
||||
"set_max_points",
|
||||
"set_precision",
|
||||
"set_num_dim_points",
|
||||
"set_background_value",
|
||||
"set_scatter_size",
|
||||
"get_data",
|
||||
"export",
|
||||
"remove",
|
||||
"reset_history",
|
||||
]
|
||||
|
||||
# QT Signals
|
||||
update_signal = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent_figure=None,
|
||||
config: Optional[MotorMapConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
):
|
||||
if config is None:
|
||||
config = MotorMapConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(
|
||||
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
|
||||
)
|
||||
|
||||
# Get bec shortcuts dev, scans, queue, scan_storage, dap
|
||||
self.get_bec_shortcuts()
|
||||
self.entry_validator = EntryValidator(self.dev)
|
||||
|
||||
# connect update signal to update plot
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self._update_plot
|
||||
)
|
||||
self.apply_config(self.config)
|
||||
|
||||
def apply_config(self, config: dict | MotorMapConfig):
|
||||
"""
|
||||
Apply the config to the motor map.
|
||||
|
||||
Args:
|
||||
config(dict|MotorMapConfig): Config to be applied.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
try:
|
||||
config = MotorMapConfig(**config)
|
||||
except ValidationError as e:
|
||||
logger.error(f"Error in applying config: {e}")
|
||||
return
|
||||
|
||||
self.config = config
|
||||
self.plot_item.clear()
|
||||
|
||||
self.motor_x = None
|
||||
self.motor_y = None
|
||||
self.database_buffer = {"x": [], "y": []}
|
||||
self.plot_components = defaultdict(dict) # container for plot components
|
||||
|
||||
self.apply_axis_config()
|
||||
|
||||
if self.config.signals is not None:
|
||||
self.change_motors(
|
||||
motor_x=self.config.signals.x.name,
|
||||
motor_y=self.config.signals.y.name,
|
||||
motor_x_entry=self.config.signals.x.entry,
|
||||
motor_y_entry=self.config.signals.y.entry,
|
||||
)
|
||||
|
||||
@Slot(str, str, str, str, bool)
|
||||
def change_motors(
|
||||
self,
|
||||
motor_x: str,
|
||||
motor_y: str,
|
||||
motor_x_entry: str = None,
|
||||
motor_y_entry: str = None,
|
||||
validate_bec: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Change the active motors for the plot.
|
||||
|
||||
Args:
|
||||
motor_x(str): Motor name for the X axis.
|
||||
motor_y(str): Motor name for the Y axis.
|
||||
motor_x_entry(str): Motor entry for the X axis.
|
||||
motor_y_entry(str): Motor entry for the Y axis.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
"""
|
||||
self.plot_item.clear()
|
||||
|
||||
motor_x_entry, motor_y_entry = self._validate_signal_entries(
|
||||
motor_x, motor_y, motor_x_entry, motor_y_entry, validate_bec
|
||||
)
|
||||
|
||||
motor_x_limit = self._get_motor_limit(motor_x)
|
||||
motor_y_limit = self._get_motor_limit(motor_y)
|
||||
|
||||
signal = Signal(
|
||||
source="device_readback",
|
||||
x=SignalData(name=motor_x, entry=motor_x_entry, limits=motor_x_limit),
|
||||
y=SignalData(name=motor_y, entry=motor_y_entry, limits=motor_y_limit),
|
||||
)
|
||||
self.config.signals = signal
|
||||
|
||||
# reconnect the signals
|
||||
self._connect_motor_to_slots()
|
||||
|
||||
self.database_buffer = {"x": [], "y": []}
|
||||
|
||||
# Redraw the motor map
|
||||
self._make_motor_map()
|
||||
|
||||
def get_data(self) -> dict:
|
||||
"""
|
||||
Get the data of the motor map.
|
||||
|
||||
Returns:
|
||||
dict: Data of the motor map.
|
||||
"""
|
||||
data = {"x": self.database_buffer["x"], "y": self.database_buffer["y"]}
|
||||
return data
|
||||
|
||||
def reset_history(self):
|
||||
"""
|
||||
Reset the history of the motor map.
|
||||
"""
|
||||
self.database_buffer["x"] = [self.database_buffer["x"][-1]]
|
||||
self.database_buffer["y"] = [self.database_buffer["y"][-1]]
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_color(self, color: str | tuple):
|
||||
"""
|
||||
Set color of the motor trace.
|
||||
|
||||
Args:
|
||||
color(str|tuple): Color of the motor trace. Can be HEX(str) or RGBA(tuple).
|
||||
"""
|
||||
if isinstance(color, str):
|
||||
color = Colors.validate_color(color)
|
||||
color = Colors.hex_to_rgba(color, 255)
|
||||
self.config.color = color
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_max_points(self, max_points: int) -> None:
|
||||
"""
|
||||
Set the maximum number of points to display.
|
||||
|
||||
Args:
|
||||
max_points(int): Maximum number of points to display.
|
||||
"""
|
||||
self.config.max_points = max_points
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_precision(self, precision: int) -> None:
|
||||
"""
|
||||
Set the decimal precision of the motor position.
|
||||
|
||||
Args:
|
||||
precision(int): Decimal precision of the motor position.
|
||||
"""
|
||||
self.config.precision = precision
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_num_dim_points(self, num_dim_points: int) -> None:
|
||||
"""
|
||||
Set the number of dim points for the motor map.
|
||||
|
||||
Args:
|
||||
num_dim_points(int): Number of dim points.
|
||||
"""
|
||||
self.config.num_dim_points = num_dim_points
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_background_value(self, background_value: int) -> None:
|
||||
"""
|
||||
Set the background value of the motor map.
|
||||
|
||||
Args:
|
||||
background_value(int): Background value of the motor map.
|
||||
"""
|
||||
self.config.background_value = background_value
|
||||
self._swap_limit_map()
|
||||
|
||||
def set_scatter_size(self, scatter_size: int) -> None:
|
||||
"""
|
||||
Set the scatter size of the motor map plot.
|
||||
|
||||
Args:
|
||||
scatter_size(int): Size of the scatter points.
|
||||
"""
|
||||
self.config.scatter_size = scatter_size
|
||||
self.update_signal.emit()
|
||||
|
||||
def _disconnect_current_motors(self):
|
||||
"""Disconnect the current motors from the slots."""
|
||||
if self.motor_x is not None and self.motor_y is not None:
|
||||
endpoints = [
|
||||
MessageEndpoints.device_readback(self.motor_x),
|
||||
MessageEndpoints.device_readback(self.motor_y),
|
||||
]
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_readback, endpoints)
|
||||
|
||||
def _connect_motor_to_slots(self):
|
||||
"""Connect motors to slots."""
|
||||
self._disconnect_current_motors()
|
||||
|
||||
self.motor_x = self.config.signals.x.name
|
||||
self.motor_y = self.config.signals.y.name
|
||||
|
||||
endpoints = [
|
||||
MessageEndpoints.device_readback(self.motor_x),
|
||||
MessageEndpoints.device_readback(self.motor_y),
|
||||
]
|
||||
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoints)
|
||||
|
||||
def _swap_limit_map(self):
|
||||
"""Swap the limit map."""
|
||||
self.plot_item.removeItem(self.plot_components["limit_map"])
|
||||
if self.config.signals.x.limits is not None and self.config.signals.y.limits is not None:
|
||||
self.plot_components["limit_map"] = self._make_limit_map(
|
||||
self.config.signals.x.limits, self.config.signals.y.limits
|
||||
)
|
||||
self.plot_components["limit_map"].setZValue(-1)
|
||||
self.plot_item.addItem(self.plot_components["limit_map"])
|
||||
|
||||
def _make_motor_map(self):
|
||||
"""
|
||||
Create the motor map plot.
|
||||
"""
|
||||
# Create limit map
|
||||
motor_x_limit = self.config.signals.x.limits
|
||||
motor_y_limit = self.config.signals.y.limits
|
||||
if motor_x_limit is not None or motor_y_limit is not None:
|
||||
self.plot_components["limit_map"] = self._make_limit_map(motor_x_limit, motor_y_limit)
|
||||
self.plot_item.addItem(self.plot_components["limit_map"])
|
||||
self.plot_components["limit_map"].setZValue(-1)
|
||||
|
||||
# Create scatter plot
|
||||
scatter_size = self.config.scatter_size
|
||||
self.plot_components["scatter"] = pg.ScatterPlotItem(
|
||||
size=scatter_size, brush=pg.mkBrush(255, 255, 255, 255)
|
||||
)
|
||||
self.plot_item.addItem(self.plot_components["scatter"])
|
||||
self.plot_components["scatter"].setZValue(0)
|
||||
|
||||
# Enable Grid
|
||||
self.set_grid(True, True)
|
||||
|
||||
# Add the crosshair for initial motor coordinates
|
||||
initial_position_x = self._get_motor_init_position(
|
||||
self.motor_x, self.config.signals.x.entry, self.config.precision
|
||||
)
|
||||
initial_position_y = self._get_motor_init_position(
|
||||
self.motor_y, self.config.signals.y.entry, self.config.precision
|
||||
)
|
||||
|
||||
self.database_buffer["x"] = [initial_position_x]
|
||||
self.database_buffer["y"] = [initial_position_y]
|
||||
|
||||
self.plot_components["scatter"].setData([initial_position_x], [initial_position_y])
|
||||
self._add_coordinantes_crosshair(initial_position_x, initial_position_y)
|
||||
|
||||
# Set default labels for the plot
|
||||
self.set(x_label=f"Motor X ({self.motor_x})", y_label=f"Motor Y ({self.motor_y})")
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
def _add_coordinantes_crosshair(self, x: float, y: float) -> None:
|
||||
"""
|
||||
Add crosshair to the plot to highlight the current position.
|
||||
|
||||
Args:
|
||||
x(float): X coordinate.
|
||||
y(float): Y coordinate.
|
||||
"""
|
||||
|
||||
# Crosshair to highlight the current position
|
||||
highlight_H = pg.InfiniteLine(
|
||||
angle=0, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
|
||||
)
|
||||
highlight_V = pg.InfiniteLine(
|
||||
angle=90, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
|
||||
)
|
||||
|
||||
# Add crosshair to the curve list for future referencing
|
||||
self.plot_components["highlight_H"] = highlight_H
|
||||
self.plot_components["highlight_V"] = highlight_V
|
||||
|
||||
# Add crosshair to the plot
|
||||
self.plot_item.addItem(highlight_H)
|
||||
self.plot_item.addItem(highlight_V)
|
||||
|
||||
highlight_V.setPos(x)
|
||||
highlight_H.setPos(y)
|
||||
|
||||
def _make_limit_map(self, limits_x: list, limits_y: list) -> pg.ImageItem:
|
||||
"""
|
||||
Create a limit map for the motor map plot.
|
||||
|
||||
Args:
|
||||
limits_x(list): Motor limits for the x axis.
|
||||
limits_y(list): Motor limits for the y axis.
|
||||
|
||||
Returns:
|
||||
pg.ImageItem: Limit map.
|
||||
"""
|
||||
limit_x_min, limit_x_max = limits_x
|
||||
limit_y_min, limit_y_max = limits_y
|
||||
|
||||
map_width = int(limit_x_max - limit_x_min + 1)
|
||||
map_height = int(limit_y_max - limit_y_min + 1)
|
||||
|
||||
# Create limits map
|
||||
background_value = self.config.background_value
|
||||
limit_map_data = np.full((map_width, map_height), background_value, dtype=np.float32)
|
||||
limit_map = pg.ImageItem()
|
||||
limit_map.setImage(limit_map_data)
|
||||
|
||||
# Translate and scale the image item to match the motor coordinates
|
||||
tr = QtGui.QTransform()
|
||||
tr.translate(limit_x_min, limit_y_min)
|
||||
limit_map.setTransform(tr)
|
||||
|
||||
return limit_map
|
||||
|
||||
def _get_motor_init_position(self, name: str, entry: str, precision: int) -> float:
|
||||
"""
|
||||
Get the motor initial position from the config.
|
||||
|
||||
Args:
|
||||
name(str): Motor name.
|
||||
entry(str): Motor entry.
|
||||
precision(int): Decimal precision of the motor position.
|
||||
|
||||
Returns:
|
||||
float: Motor initial position.
|
||||
"""
|
||||
init_position = round(float(self.dev[name].read()[entry]["value"]), precision)
|
||||
return init_position
|
||||
|
||||
def _validate_signal_entries(
|
||||
self,
|
||||
x_name: str,
|
||||
y_name: str,
|
||||
x_entry: str | None,
|
||||
y_entry: str | None,
|
||||
validate_bec: bool = True,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Validate the signal name and entry.
|
||||
|
||||
Args:
|
||||
x_name(str): Name of the x signal.
|
||||
y_name(str): Name of the y signal.
|
||||
x_entry(str|None): Entry of the x signal.
|
||||
y_entry(str|None): Entry of the y signal.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
|
||||
Returns:
|
||||
tuple[str,str]: Validated x and y entries.
|
||||
"""
|
||||
if validate_bec:
|
||||
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
|
||||
y_entry = self.entry_validator.validate_signal(y_name, y_entry)
|
||||
else:
|
||||
x_entry = x_name if x_entry is None else x_entry
|
||||
y_entry = y_name if y_entry is None else y_entry
|
||||
return x_entry, y_entry
|
||||
|
||||
def _get_motor_limit(self, motor: str) -> Union[list | None]: # TODO check if works correctly
|
||||
"""
|
||||
Get the motor limit from the config.
|
||||
|
||||
Args:
|
||||
motor(str): Motor name.
|
||||
|
||||
Returns:
|
||||
float: Motor limit.
|
||||
"""
|
||||
try:
|
||||
limits = self.dev[motor].limits
|
||||
if limits == [0, 0]:
|
||||
return None
|
||||
return limits
|
||||
except AttributeError: # TODO maybe not needed, if no limits it returns [0,0]
|
||||
# If the motor doesn't have a 'limits' attribute, return a default value or raise a custom exception
|
||||
logger.error(f"The device '{motor}' does not have defined limits.")
|
||||
return None
|
||||
|
||||
@Slot()
|
||||
def _update_plot(self, _=None):
|
||||
"""Update the motor map plot."""
|
||||
# If the number of points exceeds max_points, delete the oldest points
|
||||
if len(self.database_buffer["x"]) > self.config.max_points:
|
||||
self.database_buffer["x"] = self.database_buffer["x"][-self.config.max_points :]
|
||||
self.database_buffer["y"] = self.database_buffer["y"][-self.config.max_points :]
|
||||
|
||||
x = self.database_buffer["x"]
|
||||
y = self.database_buffer["y"]
|
||||
|
||||
# Setup gradient brush for history
|
||||
brushes = [pg.mkBrush(50, 50, 50, 255)] * len(x)
|
||||
|
||||
# RGB color
|
||||
r, g, b, a = self.config.color
|
||||
|
||||
# Calculate the decrement step based on self.num_dim_points
|
||||
num_dim_points = self.config.num_dim_points
|
||||
decrement_step = (255 - 50) / num_dim_points
|
||||
|
||||
for i in range(1, min(num_dim_points + 1, len(x) + 1)):
|
||||
brightness = max(60, 255 - decrement_step * (i - 1))
|
||||
dim_r = int(r * (brightness / 255))
|
||||
dim_g = int(g * (brightness / 255))
|
||||
dim_b = int(b * (brightness / 255))
|
||||
brushes[-i] = pg.mkBrush(dim_r, dim_g, dim_b, a)
|
||||
brushes[-1] = pg.mkBrush(r, g, b, a) # Newest point is always full brightness
|
||||
scatter_size = self.config.scatter_size
|
||||
|
||||
# Update the scatter plot
|
||||
self.plot_components["scatter"].setData(
|
||||
x=x, y=y, brush=brushes, pen=None, size=scatter_size
|
||||
)
|
||||
|
||||
# Get last know position for crosshair
|
||||
current_x = x[-1]
|
||||
current_y = y[-1]
|
||||
|
||||
# Update the crosshair
|
||||
self.plot_components["highlight_V"].setPos(current_x)
|
||||
self.plot_components["highlight_H"].setPos(current_y)
|
||||
|
||||
# TODO not update title but some label
|
||||
# Update plot title
|
||||
precision = self.config.precision
|
||||
self.set_title(
|
||||
f"Motor position: ({round(float(current_x),precision)}, {round(float(current_y),precision)})"
|
||||
)
|
||||
|
||||
@Slot(dict, dict)
|
||||
def on_device_readback(self, msg: dict, metadata: dict) -> None:
|
||||
"""
|
||||
Update the motor map plot with the new motor position.
|
||||
|
||||
Args:
|
||||
msg(dict): Message from the device readback.
|
||||
metadata(dict): Metadata of the message.
|
||||
"""
|
||||
if self.motor_x is None or self.motor_y is None:
|
||||
return
|
||||
|
||||
if self.motor_x in msg["signals"]:
|
||||
x = msg["signals"][self.motor_x]["value"]
|
||||
self.database_buffer["x"].append(x)
|
||||
self.database_buffer["y"].append(self.database_buffer["y"][-1])
|
||||
|
||||
elif self.motor_y in msg["signals"]:
|
||||
y = msg["signals"][self.motor_y]["value"]
|
||||
self.database_buffer["y"].append(y)
|
||||
self.database_buffer["x"].append(self.database_buffer["x"][-1])
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
self._disconnect_current_motors()
|
||||
@@ -0,0 +1,353 @@
|
||||
from collections import deque
|
||||
from typing import Literal, Optional
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field, field_validator
|
||||
from pyqtgraph.exporters import MatplotlibExporter
|
||||
from qtpy.QtCore import Signal, Slot
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECMultiWaveformConfig(SubplotConfig):
|
||||
color_palette: Optional[str] = Field(
|
||||
"magma", description="The color palette of the figure widget.", validate_default=True
|
||||
)
|
||||
curve_limit: Optional[int] = Field(
|
||||
200, description="The maximum number of curves to display on the plot."
|
||||
)
|
||||
flush_buffer: Optional[bool] = Field(
|
||||
False, description="Flush the buffer of the plot widget when the curve limit is reached."
|
||||
)
|
||||
monitor: Optional[str] = Field(
|
||||
None, description="The monitor to set for the plot widget."
|
||||
) # TODO validate monitor in bec -> maybe make it as SignalData class for validation purpose
|
||||
curve_width: Optional[int] = Field(1, description="The width of the curve on the plot.")
|
||||
opacity: Optional[int] = Field(50, description="The opacity of the curve on the plot.")
|
||||
highlight_last_curve: Optional[bool] = Field(
|
||||
True, description="Highlight the last curve on the plot."
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
_validate_color_map_z = field_validator("color_palette")(Colors.validate_color_map)
|
||||
|
||||
|
||||
class BECMultiWaveform(BECPlotBase):
|
||||
monitor_signal_updated = Signal()
|
||||
highlighted_curve_index_changed = Signal(int)
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"curves",
|
||||
"set_monitor",
|
||||
"set_opacity",
|
||||
"set_curve_limit",
|
||||
"set_curve_highlight",
|
||||
"set_colormap",
|
||||
"set",
|
||||
"set_title",
|
||||
"set_x_label",
|
||||
"set_y_label",
|
||||
"set_x_scale",
|
||||
"set_y_scale",
|
||||
"set_x_lim",
|
||||
"set_y_lim",
|
||||
"set_grid",
|
||||
"set_colormap",
|
||||
"enable_fps_monitor",
|
||||
"lock_aspect_ratio",
|
||||
"export",
|
||||
"get_all_data",
|
||||
"remove",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent_figure=None,
|
||||
config: Optional[BECMultiWaveformConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
):
|
||||
if config is None:
|
||||
config = BECMultiWaveformConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(
|
||||
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
|
||||
)
|
||||
self.old_scan_id = None
|
||||
self.scan_id = None
|
||||
self.monitor = None
|
||||
self.connected = False
|
||||
self.current_highlight_index = 0
|
||||
self._curves = deque()
|
||||
self.visible_curves = []
|
||||
self.number_of_visible_curves = 0
|
||||
|
||||
# Get bec shortcuts dev, scans, queue, scan_storage, dap
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@property
|
||||
def curves(self) -> deque:
|
||||
"""
|
||||
Get the curves of the plot widget as a deque.
|
||||
Returns:
|
||||
deque: Deque of curves.
|
||||
"""
|
||||
return self._curves
|
||||
|
||||
@curves.setter
|
||||
def curves(self, value: deque):
|
||||
self._curves = value
|
||||
|
||||
@property
|
||||
def highlight_last_curve(self) -> bool:
|
||||
"""
|
||||
Get the highlight_last_curve property.
|
||||
Returns:
|
||||
bool: The highlight_last_curve property.
|
||||
"""
|
||||
return self.config.highlight_last_curve
|
||||
|
||||
@highlight_last_curve.setter
|
||||
def highlight_last_curve(self, value: bool):
|
||||
self.config.highlight_last_curve = value
|
||||
|
||||
def set_monitor(self, monitor: str):
|
||||
"""
|
||||
Set the monitor for the plot widget.
|
||||
Args:
|
||||
monitor (str): The monitor to set.
|
||||
"""
|
||||
self.config.monitor = monitor
|
||||
self._connect_monitor()
|
||||
|
||||
def _connect_monitor(self):
|
||||
"""
|
||||
Connect the monitor to the plot widget.
|
||||
"""
|
||||
try:
|
||||
previous_monitor = self.monitor
|
||||
except AttributeError:
|
||||
previous_monitor = None
|
||||
|
||||
if previous_monitor and self.connected is True:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(previous_monitor)
|
||||
)
|
||||
if self.config.monitor and self.connected is False:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(self.config.monitor)
|
||||
)
|
||||
self.connected = True
|
||||
self.monitor = self.config.monitor
|
||||
|
||||
@Slot(dict, dict)
|
||||
def on_monitor_1d_update(self, msg: dict, metadata: dict):
|
||||
"""
|
||||
Update the plot widget with the monitor data.
|
||||
|
||||
Args:
|
||||
msg(dict): The message data.
|
||||
metadata(dict): The metadata of the message.
|
||||
"""
|
||||
data = msg.get("data", None)
|
||||
current_scan_id = metadata.get("scan_id", None)
|
||||
|
||||
if current_scan_id != self.scan_id:
|
||||
self.scan_id = current_scan_id
|
||||
self.clear_curves()
|
||||
self.curves.clear()
|
||||
if self.crosshair:
|
||||
self.crosshair.clear_markers()
|
||||
|
||||
# Always create a new curve and add it
|
||||
curve = pg.PlotDataItem()
|
||||
curve.setData(data)
|
||||
self.plot_item.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
|
||||
# Max Trace and scale colors
|
||||
self.set_curve_limit(self.config.curve_limit, self.config.flush_buffer)
|
||||
|
||||
self.monitor_signal_updated.emit()
|
||||
|
||||
@Slot(int)
|
||||
def set_curve_highlight(self, index: int):
|
||||
"""
|
||||
Set the curve highlight based on visible curves.
|
||||
|
||||
Args:
|
||||
index (int): The index of the curve to highlight among visible curves.
|
||||
"""
|
||||
self.plot_item.visible_curves = [curve for curve in self.curves if curve.isVisible()]
|
||||
num_visible_curves = len(self.plot_item.visible_curves)
|
||||
self.number_of_visible_curves = num_visible_curves
|
||||
|
||||
if num_visible_curves == 0:
|
||||
return # No curves to highlight
|
||||
|
||||
if index >= num_visible_curves:
|
||||
index = num_visible_curves - 1
|
||||
elif index < 0:
|
||||
index = num_visible_curves + index
|
||||
self.current_highlight_index = index
|
||||
num_colors = num_visible_curves
|
||||
colors = Colors.evenly_spaced_colors(
|
||||
colormap=self.config.color_palette, num=num_colors, format="HEX"
|
||||
)
|
||||
for i, curve in enumerate(self.plot_item.visible_curves):
|
||||
curve.setPen()
|
||||
if i == self.current_highlight_index:
|
||||
curve.setPen(pg.mkPen(color=colors[i], width=5))
|
||||
curve.setAlpha(alpha=1, auto=False)
|
||||
curve.setZValue(1)
|
||||
else:
|
||||
curve.setPen(pg.mkPen(color=colors[i], width=1))
|
||||
curve.setAlpha(alpha=self.config.opacity / 100, auto=False)
|
||||
curve.setZValue(0)
|
||||
|
||||
self.highlighted_curve_index_changed.emit(self.current_highlight_index)
|
||||
|
||||
@Slot(int)
|
||||
def set_opacity(self, opacity: int):
|
||||
"""
|
||||
Set the opacity of the curve on the plot.
|
||||
|
||||
Args:
|
||||
opacity(int): The opacity of the curve. 0-100.
|
||||
"""
|
||||
self.config.opacity = max(0, min(100, opacity))
|
||||
self.set_curve_highlight(self.current_highlight_index)
|
||||
|
||||
@Slot(int, bool)
|
||||
def set_curve_limit(self, max_trace: int, flush_buffer: bool = False):
|
||||
"""
|
||||
Set the maximum number of traces to display on the plot.
|
||||
|
||||
Args:
|
||||
max_trace (int): The maximum number of traces to display.
|
||||
flush_buffer (bool): Flush the buffer.
|
||||
"""
|
||||
self.config.curve_limit = max_trace
|
||||
self.config.flush_buffer = flush_buffer
|
||||
|
||||
if self.config.curve_limit is None:
|
||||
self.scale_colors()
|
||||
return
|
||||
|
||||
if self.config.flush_buffer:
|
||||
# Remove excess curves from the plot and the deque
|
||||
while len(self.curves) > self.config.curve_limit:
|
||||
curve = self.curves.popleft()
|
||||
self.plot_item.removeItem(curve)
|
||||
else:
|
||||
# Hide or show curves based on the new max_trace
|
||||
num_curves_to_show = min(self.config.curve_limit, len(self.curves))
|
||||
for i, curve in enumerate(self.curves):
|
||||
if i < len(self.curves) - num_curves_to_show:
|
||||
curve.hide()
|
||||
else:
|
||||
curve.show()
|
||||
self.scale_colors()
|
||||
|
||||
def scale_colors(self):
|
||||
"""
|
||||
Scale the colors of the curves based on the current colormap.
|
||||
"""
|
||||
if self.config.highlight_last_curve:
|
||||
self.set_curve_highlight(-1) # Use -1 to highlight the last visible curve
|
||||
else:
|
||||
self.set_curve_highlight(self.current_highlight_index)
|
||||
|
||||
def set_colormap(self, colormap: str):
|
||||
"""
|
||||
Set the colormap for the curves.
|
||||
|
||||
Args:
|
||||
colormap(str): Colormap for the curves.
|
||||
"""
|
||||
self.config.color_palette = colormap
|
||||
self.set_curve_highlight(self.current_highlight_index)
|
||||
|
||||
def hook_crosshair(self) -> None:
|
||||
super().hook_crosshair()
|
||||
if self.crosshair:
|
||||
self.highlighted_curve_index_changed.connect(self.crosshair.update_highlighted_curve)
|
||||
if self.curves:
|
||||
self.crosshair.update_highlighted_curve(self.current_highlight_index)
|
||||
|
||||
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict:
|
||||
"""
|
||||
Extract all curve data into a dictionary or a pandas DataFrame.
|
||||
|
||||
Args:
|
||||
output (Literal["dict", "pandas"]): Format of the output data.
|
||||
|
||||
Returns:
|
||||
dict | pd.DataFrame: Data of all curves in the specified format.
|
||||
"""
|
||||
data = {}
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
pd = None
|
||||
if output == "pandas":
|
||||
logger.warning(
|
||||
"Pandas is not installed. "
|
||||
"Please install pandas using 'pip install pandas'."
|
||||
"Output will be dictionary instead."
|
||||
)
|
||||
output = "dict"
|
||||
|
||||
curve_keys = []
|
||||
curves_list = list(self.curves)
|
||||
for i, curve in enumerate(curves_list):
|
||||
x_data, y_data = curve.getData()
|
||||
if x_data is not None or y_data is not None:
|
||||
key = f"curve_{i}"
|
||||
curve_keys.append(key)
|
||||
if output == "dict":
|
||||
data[key] = {"x": x_data.tolist(), "y": y_data.tolist()}
|
||||
elif output == "pandas" and pd is not None:
|
||||
data[key] = pd.DataFrame({"x": x_data, "y": y_data})
|
||||
|
||||
if output == "pandas" and pd is not None:
|
||||
combined_data = pd.concat([data[key] for key in curve_keys], axis=1, keys=curve_keys)
|
||||
return combined_data
|
||||
return data
|
||||
|
||||
def clear_curves(self):
|
||||
"""
|
||||
Remove all curves from the plot, excluding crosshair items.
|
||||
"""
|
||||
items_to_remove = []
|
||||
for item in self.plot_item.items:
|
||||
if not getattr(item, "is_crosshair", False) and isinstance(item, pg.PlotDataItem):
|
||||
items_to_remove.append(item)
|
||||
for item in items_to_remove:
|
||||
self.plot_item.removeItem(item)
|
||||
|
||||
def export_to_matplotlib(self):
|
||||
"""
|
||||
Export current waveform to matplotlib GUI. Available only if matplotlib is installed in the environment.
|
||||
"""
|
||||
MatplotlibExporter(self.plot_item).export()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.containers.figure import BECFigure
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = BECFigure()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
504
bec_widgets/widgets/containers/figure/plots/plot_base.py
Normal file
504
bec_widgets/widgets/containers/figure/plots/plot_base.py
Normal file
@@ -0,0 +1,504 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
import bec_qthemes
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtCore import Signal, Slot
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.crosshair import Crosshair
|
||||
from bec_widgets.utils.fps_counter import FPSCounter
|
||||
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class AxisConfig(BaseModel):
|
||||
title: Optional[str] = Field(None, description="The title of the axes.")
|
||||
title_size: Optional[int] = Field(None, description="The font size of the title.")
|
||||
x_label: Optional[str] = Field(None, description="The label for the x-axis.")
|
||||
x_label_size: Optional[int] = Field(None, description="The font size of the x-axis label.")
|
||||
y_label: Optional[str] = Field(None, description="The label for the y-axis.")
|
||||
y_label_size: Optional[int] = Field(None, description="The font size of the y-axis label.")
|
||||
legend_label_size: Optional[int] = Field(
|
||||
None, description="The font size of the legend labels."
|
||||
)
|
||||
x_scale: Literal["linear", "log"] = Field("linear", description="The scale of the x-axis.")
|
||||
y_scale: Literal["linear", "log"] = Field("linear", description="The scale of the y-axis.")
|
||||
x_lim: Optional[tuple] = Field(None, description="The limits of the x-axis.")
|
||||
y_lim: Optional[tuple] = Field(None, description="The limits of the y-axis.")
|
||||
x_grid: bool = Field(False, description="Show grid on the x-axis.")
|
||||
y_grid: bool = Field(False, description="Show grid on the y-axis.")
|
||||
outer_axes: bool = Field(False, description="Show the outer axes of the plot widget.")
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
class SubplotConfig(ConnectionConfig):
|
||||
parent_id: Optional[str] = Field(None, description="The parent figure of the plot.")
|
||||
|
||||
# Coordinates in the figure
|
||||
row: int = Field(0, description="The row coordinate in the figure.")
|
||||
col: int = Field(0, description="The column coordinate in the figure.")
|
||||
|
||||
# Appearance settings
|
||||
axis: AxisConfig = Field(
|
||||
default_factory=AxisConfig, description="The axis configuration of the plot."
|
||||
)
|
||||
|
||||
|
||||
class BECViewBox(pg.ViewBox):
|
||||
sigPaint = Signal()
|
||||
|
||||
def paint(self, painter, opt, widget):
|
||||
super().paint(painter, opt, widget)
|
||||
self.sigPaint.emit()
|
||||
|
||||
def itemBoundsChanged(self, item):
|
||||
self._itemBoundsCache.pop(item, None)
|
||||
if (self.state["autoRange"][0] is not False) or (self.state["autoRange"][1] is not False):
|
||||
# check if the call is coming from a mouse-move event
|
||||
if hasattr(item, "skip_auto_range") and item.skip_auto_range:
|
||||
return
|
||||
self._autoRangeNeedsUpdate = True
|
||||
self.update()
|
||||
|
||||
|
||||
class BECPlotBase(BECConnector, pg.GraphicsLayout):
|
||||
crosshair_position_changed = Signal(tuple)
|
||||
crosshair_position_clicked = Signal(tuple)
|
||||
crosshair_coordinates_changed = Signal(tuple)
|
||||
crosshair_coordinates_clicked = Signal(tuple)
|
||||
USER_ACCESS = [
|
||||
"_config_dict",
|
||||
"set",
|
||||
"set_title",
|
||||
"set_x_label",
|
||||
"set_y_label",
|
||||
"set_x_scale",
|
||||
"set_y_scale",
|
||||
"set_x_lim",
|
||||
"set_y_lim",
|
||||
"set_grid",
|
||||
"set_outer_axes",
|
||||
"enable_fps_monitor",
|
||||
"lock_aspect_ratio",
|
||||
"export",
|
||||
"remove",
|
||||
"set_legend_label_size",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None, # TODO decide if needed for this class
|
||||
parent_figure=None,
|
||||
config: Optional[SubplotConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
):
|
||||
if config is None:
|
||||
config = SubplotConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
pg.GraphicsLayout.__init__(self, parent)
|
||||
|
||||
self.figure = parent_figure
|
||||
|
||||
self.plot_item = pg.PlotItem(viewBox=BECViewBox(parent=self, enableMenu=True), parent=self)
|
||||
self.addItem(self.plot_item, row=1, col=0)
|
||||
|
||||
self.add_legend()
|
||||
self.crosshair = None
|
||||
self.fps_monitor = None
|
||||
self.fps_label = None
|
||||
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
|
||||
self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item)
|
||||
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)
|
||||
|
||||
@Slot(str)
|
||||
def _update_theme(self, theme: str):
|
||||
"""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 widget.
|
||||
|
||||
Args:
|
||||
theme(str, optional): The theme to be applied.
|
||||
"""
|
||||
palette = bec_qthemes.load_palette(theme)
|
||||
text_pen = pg.mkPen(color=palette.text().color())
|
||||
|
||||
for axis in ["left", "bottom", "right", "top"]:
|
||||
self.plot_item.getAxis(axis).setPen(text_pen)
|
||||
self.plot_item.getAxis(axis).setTextPen(text_pen)
|
||||
if self.plot_item.legend is not None:
|
||||
for sample, label in self.plot_item.legend.items:
|
||||
label.setText(label.text, color=palette.text().color())
|
||||
|
||||
def set(self, **kwargs) -> None:
|
||||
"""
|
||||
Set the properties of the plot widget.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
|
||||
Possible properties:
|
||||
- title: str
|
||||
- x_label: str
|
||||
- y_label: str
|
||||
- x_scale: Literal["linear", "log"]
|
||||
- y_scale: Literal["linear", "log"]
|
||||
- x_lim: tuple
|
||||
- y_lim: tuple
|
||||
- legend_label_size: int
|
||||
"""
|
||||
# Mapping of keywords to setter methods
|
||||
method_map = {
|
||||
"title": self.set_title,
|
||||
"x_label": self.set_x_label,
|
||||
"y_label": self.set_y_label,
|
||||
"x_scale": self.set_x_scale,
|
||||
"y_scale": self.set_y_scale,
|
||||
"x_lim": self.set_x_lim,
|
||||
"y_lim": self.set_y_lim,
|
||||
"legend_label_size": self.set_legend_label_size,
|
||||
}
|
||||
for key, value in kwargs.items():
|
||||
if key in method_map:
|
||||
method_map[key](value)
|
||||
else:
|
||||
logger.warning(f"Warning: '{key}' is not a recognized property.")
|
||||
|
||||
def apply_axis_config(self):
|
||||
"""Apply the axis configuration to the plot widget."""
|
||||
config_mappings = {
|
||||
"title": self.config.axis.title,
|
||||
"x_label": self.config.axis.x_label,
|
||||
"y_label": self.config.axis.y_label,
|
||||
"x_scale": self.config.axis.x_scale,
|
||||
"y_scale": self.config.axis.y_scale,
|
||||
"x_lim": self.config.axis.x_lim,
|
||||
"y_lim": self.config.axis.y_lim,
|
||||
}
|
||||
|
||||
self.set(**{k: v for k, v in config_mappings.items() if v is not None})
|
||||
|
||||
def set_legend_label_size(self, size: int = None):
|
||||
"""
|
||||
Set the font size of the legend.
|
||||
|
||||
Args:
|
||||
size(int): Font size of the legend.
|
||||
"""
|
||||
if not self.plot_item.legend:
|
||||
return
|
||||
if self.config.axis.legend_label_size or size:
|
||||
if size:
|
||||
self.config.axis.legend_label_size = size
|
||||
scale = (
|
||||
size / 9
|
||||
) # 9 is the default font size of the legend, so we always scale it against 9
|
||||
self.plot_item.legend.setScale(scale)
|
||||
|
||||
def get_text_color(self):
|
||||
return "#FFF" if self.figure.config.theme == "dark" else "#000"
|
||||
|
||||
def set_title(self, title: str, size: int = None):
|
||||
"""
|
||||
Set the title of the plot widget.
|
||||
|
||||
Args:
|
||||
title(str): Title of the plot widget.
|
||||
size(int): Font size of the title.
|
||||
"""
|
||||
if self.config.axis.title_size or size:
|
||||
if size:
|
||||
self.config.axis.title_size = size
|
||||
style = {"color": self.get_text_color(), "size": f"{self.config.axis.title_size}pt"}
|
||||
else:
|
||||
style = {}
|
||||
self.plot_item.setTitle(title, **style)
|
||||
self.config.axis.title = title
|
||||
|
||||
def set_x_label(self, label: str, size: int = None):
|
||||
"""
|
||||
Set the label of the x-axis.
|
||||
|
||||
Args:
|
||||
label(str): Label of the x-axis.
|
||||
size(int): Font size of the label.
|
||||
"""
|
||||
if self.config.axis.x_label_size or size:
|
||||
if size:
|
||||
self.config.axis.x_label_size = size
|
||||
style = {
|
||||
"color": self.get_text_color(),
|
||||
"font-size": f"{self.config.axis.x_label_size}pt",
|
||||
}
|
||||
else:
|
||||
style = {}
|
||||
self.plot_item.setLabel("bottom", label, **style)
|
||||
self.config.axis.x_label = label
|
||||
|
||||
def set_y_label(self, label: str, size: int = None):
|
||||
"""
|
||||
Set the label of the y-axis.
|
||||
|
||||
Args:
|
||||
label(str): Label of the y-axis.
|
||||
size(int): Font size of the label.
|
||||
"""
|
||||
if self.config.axis.y_label_size or size:
|
||||
if size:
|
||||
self.config.axis.y_label_size = size
|
||||
color = self.get_text_color()
|
||||
style = {"color": color, "font-size": f"{self.config.axis.y_label_size}pt"}
|
||||
else:
|
||||
style = {}
|
||||
self.plot_item.setLabel("left", label, **style)
|
||||
self.config.axis.y_label = label
|
||||
|
||||
def set_x_scale(self, scale: Literal["linear", "log"] = "linear"):
|
||||
"""
|
||||
Set the scale of the x-axis.
|
||||
|
||||
Args:
|
||||
scale(Literal["linear", "log"]): Scale of the x-axis.
|
||||
"""
|
||||
self.plot_item.setLogMode(x=(scale == "log"))
|
||||
self.config.axis.x_scale = scale
|
||||
|
||||
def set_y_scale(self, scale: Literal["linear", "log"] = "linear"):
|
||||
"""
|
||||
Set the scale of the y-axis.
|
||||
|
||||
Args:
|
||||
scale(Literal["linear", "log"]): Scale of the y-axis.
|
||||
"""
|
||||
self.plot_item.setLogMode(y=(scale == "log"))
|
||||
self.config.axis.y_scale = scale
|
||||
|
||||
def set_x_lim(self, *args) -> None:
|
||||
"""
|
||||
Set the limits of the x-axis. This method can accept either two separate arguments
|
||||
for the minimum and maximum x-axis values, or a single tuple containing both limits.
|
||||
|
||||
Usage:
|
||||
set_x_lim(x_min, x_max)
|
||||
set_x_lim((x_min, x_max))
|
||||
|
||||
Args:
|
||||
*args: A variable number of arguments. Can be two integers (x_min and x_max)
|
||||
or a single tuple with two integers.
|
||||
"""
|
||||
if len(args) == 1 and isinstance(args[0], tuple):
|
||||
x_min, x_max = args[0]
|
||||
elif len(args) == 2:
|
||||
x_min, x_max = args
|
||||
else:
|
||||
raise ValueError("set_x_lim expects either two separate arguments or a single tuple")
|
||||
|
||||
self.plot_item.setXRange(x_min, x_max)
|
||||
self.config.axis.x_lim = (x_min, x_max)
|
||||
|
||||
def set_y_lim(self, *args) -> None:
|
||||
"""
|
||||
Set the limits of the y-axis. This method can accept either two separate arguments
|
||||
for the minimum and maximum y-axis values, or a single tuple containing both limits.
|
||||
|
||||
Usage:
|
||||
set_y_lim(y_min, y_max)
|
||||
set_y_lim((y_min, y_max))
|
||||
|
||||
Args:
|
||||
*args: A variable number of arguments. Can be two integers (y_min and y_max)
|
||||
or a single tuple with two integers.
|
||||
"""
|
||||
if len(args) == 1 and isinstance(args[0], tuple):
|
||||
y_min, y_max = args[0]
|
||||
elif len(args) == 2:
|
||||
y_min, y_max = args
|
||||
else:
|
||||
raise ValueError("set_y_lim expects either two separate arguments or a single tuple")
|
||||
|
||||
self.plot_item.setYRange(y_min, y_max)
|
||||
self.config.axis.y_lim = (y_min, y_max)
|
||||
|
||||
def set_grid(self, x: bool = False, y: bool = False):
|
||||
"""
|
||||
Set the grid of the plot widget.
|
||||
|
||||
Args:
|
||||
x(bool): Show grid on the x-axis.
|
||||
y(bool): Show grid on the y-axis.
|
||||
"""
|
||||
self.plot_item.showGrid(x, y)
|
||||
self.config.axis.x_grid = x
|
||||
self.config.axis.y_grid = y
|
||||
|
||||
def set_outer_axes(self, show: bool = True):
|
||||
"""
|
||||
Set the outer axes of the plot widget.
|
||||
|
||||
Args:
|
||||
show(bool): Show the outer axes.
|
||||
"""
|
||||
self.plot_item.showAxis("top", show)
|
||||
self.plot_item.showAxis("right", show)
|
||||
self.config.axis.outer_axes = show
|
||||
|
||||
def add_legend(self):
|
||||
"""Add legend to the plot"""
|
||||
self.plot_item.addLegend()
|
||||
|
||||
def lock_aspect_ratio(self, lock):
|
||||
"""
|
||||
Lock aspect ratio.
|
||||
|
||||
Args:
|
||||
lock(bool): True to lock, False to unlock.
|
||||
"""
|
||||
self.plot_item.setAspectLocked(lock)
|
||||
|
||||
def set_auto_range(self, enabled: bool, axis: str = "xy"):
|
||||
"""
|
||||
Set the auto range of the plot widget.
|
||||
|
||||
Args:
|
||||
enabled(bool): If True, enable the auto range.
|
||||
axis(str, optional): The axis to enable the auto range.
|
||||
- "xy": Enable auto range for both x and y axis.
|
||||
- "x": Enable auto range for x axis.
|
||||
- "y": Enable auto range for y axis.
|
||||
"""
|
||||
self.plot_item.enableAutoRange(axis, enabled)
|
||||
|
||||
############################################################
|
||||
###################### Crosshair ###########################
|
||||
############################################################
|
||||
|
||||
def hook_crosshair(self) -> None:
|
||||
"""Hook the crosshair to all plots."""
|
||||
if self.crosshair is None:
|
||||
self.crosshair = Crosshair(self.plot_item, precision=3)
|
||||
self.crosshair.crosshairChanged.connect(self.crosshair_position_changed)
|
||||
self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked)
|
||||
self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed)
|
||||
self.crosshair.coordinatesClicked1D.connect(self.crosshair_coordinates_clicked)
|
||||
self.crosshair.coordinatesChanged2D.connect(self.crosshair_coordinates_changed)
|
||||
self.crosshair.coordinatesClicked2D.connect(self.crosshair_coordinates_clicked)
|
||||
|
||||
def unhook_crosshair(self) -> None:
|
||||
"""Unhook the crosshair from all plots."""
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.crosshairChanged.disconnect(self.crosshair_position_changed)
|
||||
self.crosshair.crosshairClicked.disconnect(self.crosshair_position_clicked)
|
||||
self.crosshair.coordinatesChanged1D.disconnect(self.crosshair_coordinates_changed)
|
||||
self.crosshair.coordinatesClicked1D.disconnect(self.crosshair_coordinates_clicked)
|
||||
self.crosshair.coordinatesChanged2D.disconnect(self.crosshair_coordinates_changed)
|
||||
self.crosshair.coordinatesClicked2D.disconnect(self.crosshair_coordinates_clicked)
|
||||
self.crosshair.cleanup()
|
||||
self.crosshair.deleteLater()
|
||||
self.crosshair = None
|
||||
|
||||
def toggle_crosshair(self) -> None:
|
||||
"""Toggle the crosshair on all plots."""
|
||||
if self.crosshair is None:
|
||||
return self.hook_crosshair()
|
||||
|
||||
self.unhook_crosshair()
|
||||
|
||||
@Slot()
|
||||
def reset(self) -> None:
|
||||
"""Reset the plot widget."""
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.clear_markers()
|
||||
self.crosshair.update_markers()
|
||||
|
||||
############################################################
|
||||
##################### FPS Counter ##########################
|
||||
############################################################
|
||||
|
||||
def update_fps_label(self, fps: float) -> None:
|
||||
"""
|
||||
Update the FPS label.
|
||||
|
||||
Args:
|
||||
fps(float): The frames per second.
|
||||
"""
|
||||
if self.fps_label:
|
||||
self.fps_label.setText(f"FPS: {fps:.2f}")
|
||||
|
||||
def hook_fps_monitor(self):
|
||||
"""Hook the FPS monitor to the plot."""
|
||||
if self.fps_monitor is None:
|
||||
# text_color = self.get_text_color()#TODO later
|
||||
self.fps_monitor = FPSCounter(self.plot_item.vb) # text_color=text_color)
|
||||
self.fps_label = pg.LabelItem(justify="right")
|
||||
self.addItem(self.fps_label, row=0, col=0)
|
||||
|
||||
self.fps_monitor.sigFpsUpdate.connect(self.update_fps_label)
|
||||
|
||||
def unhook_fps_monitor(self, delete_label=True):
|
||||
"""Unhook the FPS monitor from the plot."""
|
||||
if self.fps_monitor is not None:
|
||||
# Remove Monitor
|
||||
self.fps_monitor.cleanup()
|
||||
self.fps_monitor.deleteLater()
|
||||
self.fps_monitor = None
|
||||
if self.fps_label is not None and delete_label:
|
||||
# Remove Label
|
||||
self.removeItem(self.fps_label)
|
||||
self.fps_label.deleteLater()
|
||||
self.fps_label = None
|
||||
|
||||
def enable_fps_monitor(self, enable: bool = True):
|
||||
"""
|
||||
Enable the FPS monitor.
|
||||
|
||||
Args:
|
||||
enable(bool): True to enable, False to disable.
|
||||
"""
|
||||
if enable and self.fps_monitor is None:
|
||||
self.hook_fps_monitor()
|
||||
elif not enable and self.fps_monitor is not None:
|
||||
self.unhook_fps_monitor()
|
||||
|
||||
def export(self):
|
||||
"""Show the Export Dialog of the plot widget."""
|
||||
scene = self.plot_item.scene()
|
||||
scene.contextMenuItem = self.plot_item
|
||||
scene.showExportDialog()
|
||||
|
||||
def remove(self):
|
||||
"""Remove the plot widget from the figure."""
|
||||
if self.figure is not None:
|
||||
self.figure.remove(widget_id=self.gui_id)
|
||||
|
||||
def cleanup_pyqtgraph(self):
|
||||
"""Cleanup pyqtgraph items."""
|
||||
self.unhook_crosshair()
|
||||
self.unhook_fps_monitor(delete_label=False)
|
||||
self.tick_item.cleanup()
|
||||
self.arrow_item.cleanup()
|
||||
item = self.plot_item
|
||||
item.vb.menu.close()
|
||||
item.vb.menu.deleteLater()
|
||||
item.ctrlMenu.close()
|
||||
item.ctrlMenu.deleteLater()
|
||||
1531
bec_widgets/widgets/containers/figure/plots/waveform/waveform.py
Normal file
1531
bec_widgets/widgets/containers/figure/plots/waveform/waveform.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,64 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import TYPE_CHECKING, Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy import QtCore
|
||||
|
||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.containers.figure.plots.waveform import BECWaveform1D
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class DeviceSignal(BaseModel):
|
||||
"""The configuration of a signal in the 1D waveform widget."""
|
||||
class SignalData(BaseModel):
|
||||
"""The data configuration of a signal in the 1D waveform widget for x and y axis."""
|
||||
|
||||
name: str
|
||||
entry: str
|
||||
dap: str | None = None
|
||||
dap_oversample: int = 1
|
||||
|
||||
unit: Optional[str] = None # todo implement later
|
||||
modifier: Optional[str] = None # todo implement later
|
||||
limits: Optional[list[float]] = None # todo implement later
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
class Signal(BaseModel):
|
||||
"""The configuration of a signal in the 1D waveform widget."""
|
||||
|
||||
source: str
|
||||
x: Optional[SignalData] = None
|
||||
y: SignalData
|
||||
z: Optional[SignalData] = None
|
||||
dap: Optional[str] = None
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class CurveConfig(ConnectionConfig):
|
||||
parent_id: str | None = Field(None, description="The parent plot of the curve.")
|
||||
label: str | None = Field(None, description="The label of the curve.")
|
||||
color: str | tuple | None = Field(None, description="The color of the curve.")
|
||||
symbol: str | None = Field("o", description="The symbol of the curve.")
|
||||
symbol_color: str | tuple | None = Field(
|
||||
parent_id: Optional[str] = Field(None, description="The parent plot of the curve.")
|
||||
label: Optional[str] = Field(None, description="The label of the curve.")
|
||||
color: Optional[str | tuple] = Field(None, description="The color of the curve.")
|
||||
symbol: Optional[str | None] = Field("o", description="The symbol of the curve.")
|
||||
symbol_color: Optional[str | tuple] = Field(
|
||||
None, description="The color of the symbol of the curve."
|
||||
)
|
||||
symbol_size: int | None = Field(7, description="The size of the symbol of the curve.")
|
||||
pen_width: int | None = Field(4, description="The width of the pen of the curve.")
|
||||
pen_style: Literal["solid", "dash", "dot", "dashdot"] | None = Field(
|
||||
symbol_size: Optional[int] = Field(7, description="The size of the symbol of the curve.")
|
||||
pen_width: Optional[int] = Field(4, description="The width of the pen of the curve.")
|
||||
pen_style: Optional[Literal["solid", "dash", "dot", "dashdot"]] = Field(
|
||||
"solid", description="The style of the pen of the curve."
|
||||
)
|
||||
source: Literal["device", "dap", "custom"] = Field(
|
||||
"custom", description="The source of the curve."
|
||||
)
|
||||
signal: DeviceSignal | None = Field(None, description="The signal of the curve.")
|
||||
parent_label: str | None = Field(
|
||||
None, description="The label of the parent plot, only relevant for dap curves."
|
||||
source: Optional[str] = Field(None, description="The source of the curve.")
|
||||
signals: Optional[Signal] = Field(None, description="The signal of the curve.")
|
||||
color_map_z: Optional[str] = Field(
|
||||
"magma", description="The colormap of the curves z gradient.", validate_default=True
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
_validate_color_map_z = field_validator("color_map_z")(Colors.validate_color_map)
|
||||
_validate_color = field_validator("color")(Colors.validate_color)
|
||||
_validate_symbol_color = field_validator("symbol_color")(Colors.validate_color)
|
||||
|
||||
|
||||
class Curve(BECConnector, pg.PlotDataItem):
|
||||
class BECCurve(BECConnector, pg.PlotDataItem):
|
||||
USER_ACCESS = [
|
||||
"remove",
|
||||
"dap_params",
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"set",
|
||||
@@ -72,17 +81,14 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
"set_pen_style",
|
||||
"get_data",
|
||||
"dap_params",
|
||||
"dap_summary",
|
||||
"dap_oversample",
|
||||
"dap_oversample.setter",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str | None = None,
|
||||
config: CurveConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
parent_item: Waveform | None = None,
|
||||
name: Optional[str] = None,
|
||||
config: Optional[CurveConfig] = None,
|
||||
gui_id: Optional[str] = None,
|
||||
parent_item: Optional[BECWaveform1D] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if config is None:
|
||||
@@ -90,6 +96,7 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
self.config = config
|
||||
else:
|
||||
self.config = config
|
||||
# config.widget_class = self.__class__.__name__
|
||||
super().__init__(config=config, gui_id=gui_id)
|
||||
pg.PlotDataItem.__init__(self, name=name)
|
||||
|
||||
@@ -100,19 +107,7 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
if kwargs:
|
||||
self.set(**kwargs)
|
||||
|
||||
def apply_config(self, config: dict | CurveConfig | None = None, **kwargs) -> None:
|
||||
"""
|
||||
Apply the configuration to the curve.
|
||||
|
||||
Args:
|
||||
config(dict|CurveConfig, optional): The configuration to apply.
|
||||
"""
|
||||
|
||||
if config is not None:
|
||||
if isinstance(config, dict):
|
||||
config = CurveConfig(**config)
|
||||
self.config = config
|
||||
|
||||
def apply_config(self):
|
||||
pen_style_map = {
|
||||
"solid": QtCore.Qt.SolidLine,
|
||||
"dash": QtCore.Qt.DashLine,
|
||||
@@ -134,64 +129,21 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
|
||||
@property
|
||||
def dap_params(self):
|
||||
"""
|
||||
Get the dap parameters.
|
||||
"""
|
||||
return self._dap_params
|
||||
|
||||
@dap_params.setter
|
||||
def dap_params(self, value):
|
||||
"""
|
||||
Set the dap parameters.
|
||||
|
||||
Args:
|
||||
value(dict): The dap parameters.
|
||||
"""
|
||||
self._dap_params = value
|
||||
|
||||
@property
|
||||
def dap_summary(self):
|
||||
"""
|
||||
Get the dap summary.
|
||||
"""
|
||||
return self._dap_report
|
||||
|
||||
@dap_summary.setter
|
||||
def dap_summary(self, value):
|
||||
"""
|
||||
Set the dap summary.
|
||||
"""
|
||||
self._dap_report = value
|
||||
|
||||
@property
|
||||
def dap_oversample(self):
|
||||
"""
|
||||
Get the dap oversample.
|
||||
"""
|
||||
return self.config.signal.dap_oversample
|
||||
|
||||
@dap_oversample.setter
|
||||
def dap_oversample(self, value):
|
||||
"""
|
||||
Set the dap oversample.
|
||||
|
||||
Args:
|
||||
value(int): The dap oversample.
|
||||
"""
|
||||
self.config.signal.dap_oversample = value
|
||||
self.parent_item.request_dap() # do immediate request for dap update
|
||||
|
||||
def set_data(self, x: list | np.ndarray, y: list | np.ndarray):
|
||||
"""
|
||||
Set the data of the curve.
|
||||
|
||||
Args:
|
||||
x(list|np.ndarray): The x data.
|
||||
y(list|np.ndarray): The y data.
|
||||
|
||||
Raises:
|
||||
ValueError: If the source is not custom.
|
||||
"""
|
||||
def set_data(self, x, y):
|
||||
if self.config.source == "custom":
|
||||
self.setData(x, y)
|
||||
else:
|
||||
@@ -229,7 +181,7 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
else:
|
||||
logger.warning(f"Warning: '{key}' is not a recognized property.")
|
||||
|
||||
def set_color(self, color: str, symbol_color: str | None = None):
|
||||
def set_color(self, color: str, symbol_color: Optional[str] = None):
|
||||
"""
|
||||
Change the color of the curve.
|
||||
|
||||
@@ -301,7 +253,7 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
"""
|
||||
self.config.color_map_z = colormap
|
||||
self.apply_config()
|
||||
self.parent_item.update_with_scan_history(-1)
|
||||
self.parent_item.scan_history(-1)
|
||||
|
||||
def get_data(self) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
@@ -316,13 +268,10 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
return x_data, y_data
|
||||
|
||||
def clear_data(self):
|
||||
"""
|
||||
Clear the data of the curve.
|
||||
"""
|
||||
self.setData([], [])
|
||||
|
||||
def remove(self):
|
||||
"""Remove the curve from the plot."""
|
||||
# self.parent_item.removeItem(self)
|
||||
self.parent_item.remove_curve(self.name())
|
||||
super().remove()
|
||||
self.rpc_register.remove_rpc(self)
|
||||
@@ -1,882 +0,0 @@
|
||||
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.setObjectName("LayoutManagerWidget")
|
||||
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_())
|
||||
@@ -1,74 +1,9 @@
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow
|
||||
from qtpy.QtWidgets import QMainWindow
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
logger = bec_logger.logger
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
|
||||
class BECMainWindow(BECWidget, QMainWindow):
|
||||
def __init__(self, gui_id: str = None, *args, **kwargs):
|
||||
BECWidget.__init__(self, gui_id=gui_id, **kwargs)
|
||||
class BECMainWindow(QMainWindow, BECConnector):
|
||||
def __init__(self, *args, **kwargs):
|
||||
BECConnector.__init__(self, **kwargs)
|
||||
QMainWindow.__init__(self, *args, **kwargs)
|
||||
|
||||
def _dump(self):
|
||||
"""Return a dictionary with informations about the application state, for use in tests"""
|
||||
# TODO: ModularToolBar and something else leak top-level widgets (3 or 4 QMenu + 2 QWidget);
|
||||
# so, a filtering based on title is applied here, but the solution is to not have those widgets
|
||||
# as top-level (so for now, a window with no title does not appear in _dump() result)
|
||||
|
||||
# NOTE: the main window itself is excluded, since we want to dump dock areas
|
||||
info = {
|
||||
tlw.gui_id: {
|
||||
"title": tlw.windowTitle(),
|
||||
"visible": tlw.isVisible(),
|
||||
"class": str(type(tlw)),
|
||||
}
|
||||
for tlw in QApplication.instance().topLevelWidgets()
|
||||
if tlw is not self and tlw.windowTitle()
|
||||
}
|
||||
# Add the main window dock area
|
||||
info[self.centralWidget().gui_id] = {
|
||||
"title": self.windowTitle(),
|
||||
"visible": self.isVisible(),
|
||||
"class": str(type(self.centralWidget())),
|
||||
}
|
||||
return info
|
||||
|
||||
def new_dock_area(
|
||||
self, name: str | None = None, geometry: tuple[int, int, int, int] | None = None
|
||||
) -> BECDockArea:
|
||||
"""Create a new dock area.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock area.
|
||||
geometry(tuple): The geometry parameters to be passed to the dock area.
|
||||
Returns:
|
||||
BECDockArea: The newly created dock area.
|
||||
"""
|
||||
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}."
|
||||
)
|
||||
else:
|
||||
name = "dock_area"
|
||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
||||
dock_area = BECDockArea(name=name)
|
||||
dock_area.resize(dock_area.minimumSizeHint())
|
||||
# TODO Should we simply use the specified name as title here?
|
||||
dock_area.window().setWindowTitle(f"BEC - {name}")
|
||||
logger.info(f"Created new dock area: {name}")
|
||||
logger.info(f"Existing dock areas: {geometry}")
|
||||
if geometry is not None:
|
||||
dock_area.setGeometry(*geometry)
|
||||
dock_area.show()
|
||||
return dock_area
|
||||
|
||||
def cleanup(self):
|
||||
super().close()
|
||||
|
||||
@@ -2,8 +2,8 @@ from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class AbortButton(BECWidget, QWidget):
|
||||
@@ -13,16 +13,9 @@ class AbortButton(BECWidget, QWidget):
|
||||
ICON_NAME = "cancel"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config=None,
|
||||
gui_id=None,
|
||||
toolbar=False,
|
||||
scan_id=None,
|
||||
**kwargs,
|
||||
self, parent=None, client=None, config=None, gui_id=None, toolbar=False, scan_id=None
|
||||
):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@@ -2,8 +2,8 @@ from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QHBoxLayout, QMessageBox, QPushButton, QToolButton, QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class ResetButton(BECWidget, QWidget):
|
||||
@@ -12,8 +12,8 @@ class ResetButton(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "restart_alt"
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@@ -2,8 +2,8 @@ from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class ResumeButton(BECWidget, QWidget):
|
||||
@@ -12,8 +12,8 @@ class ResumeButton(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "resume"
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@@ -2,8 +2,8 @@ from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class StopButton(BECWidget, QWidget):
|
||||
@@ -12,8 +12,8 @@ class StopButton(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "dangerous"
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@@ -12,8 +12,8 @@ class PositionIndicator(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "horizontal_distribute"
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
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)
|
||||
self.position = 50
|
||||
self.min_value = 0
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box import (
|
||||
PositionerBox,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import (
|
||||
PositionerBox2D,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
|
||||
PositionerControlLine,
|
||||
)
|
||||
|
||||
__ALL__ = ["PositionerBox", "PositionerControlLine", "PositionerBox2D"]
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .positioner_box_base import PositionerBoxBase
|
||||
|
||||
__ALL__ = ["PositionerBoxBase"]
|
||||
@@ -1,244 +0,0 @@
|
||||
import uuid
|
||||
from abc import abstractmethod
|
||||
from typing import Callable, TypedDict
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanQueueMessage
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDoubleSpinBox,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
|
||||
PositionIndicator,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceUpdateUIComponents(TypedDict):
|
||||
spinner: SpinnerWidget
|
||||
setpoint: QLineEdit
|
||||
readback: QLabel
|
||||
position_indicator: PositionIndicator
|
||||
step_size: QDoubleSpinBox
|
||||
device_box: QGroupBox
|
||||
stop: QPushButton
|
||||
tweak_increase: QPushButton
|
||||
tweak_decrease: QPushButton
|
||||
|
||||
|
||||
class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
"""Contains some core logic for positioner box widgets"""
|
||||
|
||||
current_path = ""
|
||||
ICON_NAME = "switch_right"
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
"""Initialize the PositionerBox widget.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
device (Positioner): The device to control.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
|
||||
self._dialog = None
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
def _check_device_is_valid(self, device: str):
|
||||
"""Check if the device is a positioner
|
||||
|
||||
Args:
|
||||
device (str): The device name
|
||||
"""
|
||||
if device not in self.dev:
|
||||
logger.info(f"Device {device} not found in the device list")
|
||||
return False
|
||||
if not isinstance(self.dev[device], Positioner):
|
||||
logger.info(f"Device {device} is not a positioner")
|
||||
return False
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
def _device_ui_components(self, device: str) -> DeviceUpdateUIComponents: ...
|
||||
|
||||
def _init_device(
|
||||
self,
|
||||
device: str,
|
||||
position_emit: Callable[[float], None],
|
||||
limit_update: Callable[[tuple[float, float]], None],
|
||||
):
|
||||
"""Init the device view and readback"""
|
||||
if self._check_device_is_valid(device):
|
||||
data = self.dev[device].read()
|
||||
self._on_device_readback(
|
||||
device,
|
||||
self._device_ui_components(device),
|
||||
{"signals": data},
|
||||
{},
|
||||
position_emit,
|
||||
limit_update,
|
||||
)
|
||||
|
||||
def _stop_device(self, device: str):
|
||||
"""Stop call"""
|
||||
request_id = str(uuid.uuid4())
|
||||
params = {"device": device, "rpc_id": request_id, "func": "stop", "args": [], "kwargs": {}}
|
||||
msg = ScanQueueMessage(
|
||||
scan_type="device_rpc",
|
||||
parameter=params,
|
||||
queue="emergency",
|
||||
metadata={"RID": request_id, "response": False},
|
||||
)
|
||||
self.client.connector.send(MessageEndpoints.scan_queue_request(), msg)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _on_device_readback(
|
||||
self,
|
||||
device: str,
|
||||
ui_components: DeviceUpdateUIComponents,
|
||||
msg_content: dict,
|
||||
metadata: dict,
|
||||
position_emit: Callable[[float], None],
|
||||
limit_update: Callable[[tuple[float, float]], None],
|
||||
):
|
||||
signals = msg_content.get("signals", {})
|
||||
# pylint: disable=protected-access
|
||||
hinted_signals = self.dev[device]._hints
|
||||
precision = self.dev[device].precision
|
||||
|
||||
spinner = ui_components["spinner"]
|
||||
position_indicator = ui_components["position_indicator"]
|
||||
readback = ui_components["readback"]
|
||||
setpoint = ui_components["setpoint"]
|
||||
|
||||
readback_val = None
|
||||
setpoint_val = None
|
||||
|
||||
if len(hinted_signals) == 1:
|
||||
signal = hinted_signals[0]
|
||||
readback_val = signals.get(signal, {}).get("value")
|
||||
|
||||
for setpoint_signal in ["setpoint", "user_setpoint"]:
|
||||
setpoint_val = signals.get(f"{device}_{setpoint_signal}", {}).get("value")
|
||||
if setpoint_val is not None:
|
||||
break
|
||||
|
||||
if f"{device}_motor_done_move" in signals:
|
||||
is_moving = not signals[f"{device}_motor_done_move"].get("value")
|
||||
elif f"{device}_motor_is_moving" in signals:
|
||||
is_moving = signals[f"{device}_motor_is_moving"].get("value")
|
||||
else:
|
||||
is_moving = None
|
||||
|
||||
if is_moving is not None:
|
||||
spinner.setVisible(True)
|
||||
if is_moving:
|
||||
spinner.start()
|
||||
spinner.setToolTip("Device is moving")
|
||||
self.set_global_state("warning")
|
||||
else:
|
||||
spinner.stop()
|
||||
spinner.setToolTip("Device is idle")
|
||||
self.set_global_state("success")
|
||||
else:
|
||||
spinner.setVisible(False)
|
||||
|
||||
if readback_val is not None:
|
||||
readback.setText(f"{readback_val:.{precision}f}")
|
||||
position_emit(readback_val)
|
||||
|
||||
if setpoint_val is not None:
|
||||
setpoint.setText(f"{setpoint_val:.{precision}f}")
|
||||
|
||||
limits = self.dev[device].limits
|
||||
limit_update(limits)
|
||||
if limits is not None and readback_val is not None and limits[0] != limits[1]:
|
||||
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
|
||||
position_indicator.set_value(pos)
|
||||
|
||||
def _update_limits_ui(
|
||||
self, limits: tuple[float, float], position_indicator, setpoint_validator
|
||||
):
|
||||
if limits is not None and limits[0] != limits[1]:
|
||||
position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
|
||||
setpoint_validator.setRange(limits[0], limits[1])
|
||||
else:
|
||||
position_indicator.setToolTip("No limits set")
|
||||
setpoint_validator.setRange(float("-inf"), float("inf"))
|
||||
|
||||
def _update_device_ui(self, device: str, ui: DeviceUpdateUIComponents):
|
||||
ui["device_box"].setTitle(device)
|
||||
ui["readback"].setToolTip(f"{device} readback")
|
||||
ui["setpoint"].setToolTip(f"{device} setpoint")
|
||||
ui["step_size"].setToolTip(f"Step size for {device}")
|
||||
precision = self.dev[device].precision
|
||||
if precision is not None:
|
||||
ui["step_size"].setDecimals(precision)
|
||||
ui["step_size"].setValue(10**-precision * 10)
|
||||
|
||||
def _swap_readback_signal_connection(self, slot, old_device, new_device):
|
||||
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
|
||||
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
|
||||
|
||||
def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
|
||||
"""Toogle enable/disable on available buttons
|
||||
|
||||
Args:
|
||||
enable (bool): Enable buttons
|
||||
"""
|
||||
ui["tweak_increase"].setEnabled(enable)
|
||||
ui["tweak_decrease"].setEnabled(enable)
|
||||
ui["stop"].setEnabled(enable)
|
||||
ui["setpoint"].setEnabled(enable)
|
||||
ui["step_size"].setEnabled(enable)
|
||||
|
||||
def _on_device_change(
|
||||
self,
|
||||
old_device: str,
|
||||
new_device: str,
|
||||
position_emit: Callable[[float], None],
|
||||
limit_update: Callable[[tuple[float, float]], None],
|
||||
on_device_readback: Callable,
|
||||
ui: DeviceUpdateUIComponents,
|
||||
):
|
||||
logger.info(f"Device changed from {old_device} to {new_device}")
|
||||
self._toggle_enable_buttons(ui, True)
|
||||
self._init_device(new_device, position_emit, limit_update)
|
||||
self._swap_readback_signal_connection(on_device_readback, old_device, new_device)
|
||||
self._update_device_ui(new_device, ui)
|
||||
|
||||
def _open_dialog_selection(self, set_positioner: Callable):
|
||||
def _ods():
|
||||
"""Open dialog window for positioner selection"""
|
||||
self._dialog = QDialog(self)
|
||||
self._dialog.setWindowTitle("Positioner Selection")
|
||||
layout = QVBoxLayout()
|
||||
line_edit = DeviceLineEdit(
|
||||
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
|
||||
)
|
||||
line_edit.textChanged.connect(set_positioner)
|
||||
layout.addWidget(line_edit)
|
||||
close_button = QPushButton("Close")
|
||||
close_button.clicked.connect(self._dialog.accept)
|
||||
layout.addWidget(close_button)
|
||||
self._dialog.setLayout(layout)
|
||||
self._dialog.exec()
|
||||
self._dialog = None
|
||||
|
||||
return _ods
|
||||
@@ -0,0 +1,352 @@
|
||||
""" Module for a PositionerBox widget to control a positioner device."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanQueueMessage
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QDialog, QDoubleSpinBox, QPushButton, QVBoxLayout
|
||||
|
||||
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors, set_theme
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
class PositionerBox(BECWidget, CompactPopupWidget):
|
||||
"""Simple Widget to control a positioner in box form"""
|
||||
|
||||
ui_file = "positioner_box.ui"
|
||||
dimensions = (234, 224)
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "switch_right"
|
||||
USER_ACCESS = ["set_positioner"]
|
||||
device_changed = Signal(str, str)
|
||||
# Signal emitted to inform listeners about a position update
|
||||
position_update = Signal(float)
|
||||
|
||||
def __init__(self, parent=None, device: Positioner = None, **kwargs):
|
||||
"""Initialize the PositionerBox widget.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
device (Positioner): The device to control.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
|
||||
self.get_bec_shortcuts()
|
||||
self._device = ""
|
||||
self._limits = None
|
||||
self._dialog = None
|
||||
|
||||
self.init_ui()
|
||||
|
||||
if device is not None:
|
||||
self.device = device
|
||||
self.init_device()
|
||||
|
||||
def init_ui(self):
|
||||
"""Init the ui"""
|
||||
self.device_changed.connect(self.on_device_change)
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
self.ui = UILoader(self).loader(os.path.join(current_path, self.ui_file))
|
||||
|
||||
self.addWidget(self.ui)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# fix the size of the device box
|
||||
db = self.ui.device_box
|
||||
db.setFixedHeight(self.dimensions[0])
|
||||
db.setFixedWidth(self.dimensions[1])
|
||||
|
||||
self.ui.step_size.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType)
|
||||
self.ui.stop.clicked.connect(self.on_stop)
|
||||
self.ui.stop.setToolTip("Stop")
|
||||
self.ui.stop.setStyleSheet(
|
||||
f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}"
|
||||
)
|
||||
self.ui.tweak_right.clicked.connect(self.on_tweak_right)
|
||||
self.ui.tweak_right.setToolTip("Tweak right")
|
||||
self.ui.tweak_left.clicked.connect(self.on_tweak_left)
|
||||
self.ui.tweak_left.setToolTip("Tweak left")
|
||||
self.ui.setpoint.returnPressed.connect(self.on_setpoint_change)
|
||||
|
||||
self.setpoint_validator = QDoubleValidator()
|
||||
self.ui.setpoint.setValidator(self.setpoint_validator)
|
||||
self.ui.spinner_widget.start()
|
||||
self.ui.tool_button.clicked.connect(self._open_dialog_selection)
|
||||
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
|
||||
self.ui.tool_button.setIcon(icon)
|
||||
|
||||
def _open_dialog_selection(self):
|
||||
"""Open dialog window for positioner selection"""
|
||||
self._dialog = QDialog(self)
|
||||
self._dialog.setWindowTitle("Positioner Selection")
|
||||
layout = QVBoxLayout()
|
||||
line_edit = DeviceLineEdit(
|
||||
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
|
||||
)
|
||||
line_edit.textChanged.connect(self.set_positioner)
|
||||
layout.addWidget(line_edit)
|
||||
close_button = QPushButton("Close")
|
||||
close_button.clicked.connect(self._dialog.accept)
|
||||
layout.addWidget(close_button)
|
||||
self._dialog.setLayout(layout)
|
||||
self._dialog.exec()
|
||||
self._dialog = None
|
||||
|
||||
def init_device(self):
|
||||
"""Init the device view and readback"""
|
||||
if self._check_device_is_valid(self.device):
|
||||
data = self.dev[self.device].read()
|
||||
self.on_device_readback({"signals": data}, {})
|
||||
|
||||
def _toogle_enable_buttons(self, enable: bool) -> None:
|
||||
"""Toogle enable/disable on available buttons
|
||||
|
||||
Args:
|
||||
enable (bool): Enable buttons
|
||||
"""
|
||||
self.ui.tweak_left.setEnabled(enable)
|
||||
self.ui.tweak_right.setEnabled(enable)
|
||||
self.ui.stop.setEnabled(enable)
|
||||
self.ui.setpoint.setEnabled(enable)
|
||||
self.ui.step_size.setEnabled(enable)
|
||||
|
||||
@Property(str)
|
||||
def device(self):
|
||||
"""Property to set the device"""
|
||||
return self._device
|
||||
|
||||
@device.setter
|
||||
def device(self, value: str):
|
||||
"""Setter, checks if device is a string"""
|
||||
if not value or not isinstance(value, str):
|
||||
return
|
||||
if not self._check_device_is_valid(value):
|
||||
return
|
||||
old_device = self._device
|
||||
self._device = value
|
||||
if not self.label:
|
||||
self.label = value
|
||||
self.device_changed.emit(old_device, value)
|
||||
|
||||
@Property(bool)
|
||||
def hide_device_selection(self):
|
||||
"""Hide the device selection"""
|
||||
return not self.ui.tool_button.isVisible()
|
||||
|
||||
@hide_device_selection.setter
|
||||
def hide_device_selection(self, value: bool):
|
||||
"""Set the device selection visibility"""
|
||||
self.ui.tool_button.setVisible(not value)
|
||||
|
||||
@Slot(bool)
|
||||
def show_device_selection(self, value: bool):
|
||||
"""Show the device selection
|
||||
|
||||
Args:
|
||||
value (bool): Show the device selection
|
||||
"""
|
||||
self.hide_device_selection = not value
|
||||
|
||||
@Slot(str)
|
||||
def set_positioner(self, positioner: str | Positioner):
|
||||
"""Set the device
|
||||
|
||||
Args:
|
||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||
"""
|
||||
if isinstance(positioner, Positioner):
|
||||
positioner = positioner.name
|
||||
self.device = positioner
|
||||
|
||||
def _check_device_is_valid(self, device: str):
|
||||
"""Check if the device is a positioner
|
||||
|
||||
Args:
|
||||
device (str): The device name
|
||||
"""
|
||||
if device not in self.dev:
|
||||
logger.info(f"Device {device} not found in the device list")
|
||||
return False
|
||||
if not isinstance(self.dev[device], Positioner):
|
||||
logger.info(f"Device {device} is not a positioner")
|
||||
return False
|
||||
return True
|
||||
|
||||
@Slot(str, str)
|
||||
def on_device_change(self, old_device: str, new_device: str):
|
||||
"""Upon changing the device, a check will be performed if the device is a Positioner.
|
||||
|
||||
Args:
|
||||
old_device (str): The old device name.
|
||||
new_device (str): The new device name.
|
||||
"""
|
||||
if not self._check_device_is_valid(new_device):
|
||||
return
|
||||
logger.info(f"Device changed from {old_device} to {new_device}")
|
||||
self._toogle_enable_buttons(True)
|
||||
self.init_device()
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_device_readback, MessageEndpoints.device_readback(old_device)
|
||||
)
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_device_readback, MessageEndpoints.device_readback(new_device)
|
||||
)
|
||||
self.ui.device_box.setTitle(new_device)
|
||||
self.ui.readback.setToolTip(f"{self.device} readback")
|
||||
self.ui.setpoint.setToolTip(f"{self.device} setpoint")
|
||||
self.ui.step_size.setToolTip(f"Step size for {new_device}")
|
||||
|
||||
precision = self.dev[new_device].precision
|
||||
if precision is not None:
|
||||
self.ui.step_size.setDecimals(precision)
|
||||
self.ui.step_size.setValue(10**-precision * 10)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@Slot(dict, dict)
|
||||
def on_device_readback(self, msg_content: dict, metadata: dict):
|
||||
"""Callback for device readback.
|
||||
|
||||
Args:
|
||||
msg_content (dict): The message content.
|
||||
metadata (dict): The message metadata.
|
||||
"""
|
||||
signals = msg_content.get("signals", {})
|
||||
# pylint: disable=protected-access
|
||||
hinted_signals = self.dev[self.device]._hints
|
||||
precision = self.dev[self.device].precision
|
||||
|
||||
readback_val = None
|
||||
setpoint_val = None
|
||||
|
||||
if len(hinted_signals) == 1:
|
||||
signal = hinted_signals[0]
|
||||
readback_val = signals.get(signal, {}).get("value")
|
||||
|
||||
for setpoint_signal in ["setpoint", "user_setpoint"]:
|
||||
setpoint_val = signals.get(f"{self.device}_{setpoint_signal}", {}).get("value")
|
||||
if setpoint_val is not None:
|
||||
break
|
||||
|
||||
for moving_signal in ["motor_done_move", "motor_is_moving"]:
|
||||
is_moving = signals.get(f"{self.device}_{moving_signal}", {}).get("value")
|
||||
if is_moving is not None:
|
||||
break
|
||||
|
||||
if is_moving is not None:
|
||||
self.ui.spinner_widget.setVisible(True)
|
||||
if is_moving:
|
||||
self.ui.spinner_widget.start()
|
||||
self.ui.spinner_widget.setToolTip("Device is moving")
|
||||
self.set_global_state("warning")
|
||||
else:
|
||||
self.ui.spinner_widget.stop()
|
||||
self.ui.spinner_widget.setToolTip("Device is idle")
|
||||
self.set_global_state("success")
|
||||
else:
|
||||
self.ui.spinner_widget.setVisible(False)
|
||||
|
||||
if readback_val is not None:
|
||||
self.ui.readback.setText(f"{readback_val:.{precision}f}")
|
||||
self.position_update.emit(readback_val)
|
||||
|
||||
if setpoint_val is not None:
|
||||
self.ui.setpoint.setText(f"{setpoint_val:.{precision}f}")
|
||||
|
||||
limits = self.dev[self.device].limits
|
||||
self.update_limits(limits)
|
||||
if limits is not None and readback_val is not None and limits[0] != limits[1]:
|
||||
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
|
||||
self.ui.position_indicator.set_value(pos)
|
||||
|
||||
def update_limits(self, limits: tuple):
|
||||
"""Update limits
|
||||
|
||||
Args:
|
||||
limits (tuple): Limits of the positioner
|
||||
"""
|
||||
if limits == self._limits:
|
||||
return
|
||||
self._limits = limits
|
||||
if limits is not None and limits[0] != limits[1]:
|
||||
self.ui.position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
|
||||
self.setpoint_validator.setRange(limits[0], limits[1])
|
||||
else:
|
||||
self.ui.position_indicator.setToolTip("No limits set")
|
||||
self.setpoint_validator.setRange(float("-inf"), float("inf"))
|
||||
|
||||
@Slot()
|
||||
def on_stop(self):
|
||||
"""Stop call"""
|
||||
request_id = str(uuid.uuid4())
|
||||
params = {
|
||||
"device": self.device,
|
||||
"rpc_id": request_id,
|
||||
"func": "stop",
|
||||
"args": [],
|
||||
"kwargs": {},
|
||||
}
|
||||
msg = ScanQueueMessage(
|
||||
scan_type="device_rpc",
|
||||
parameter=params,
|
||||
queue="emergency",
|
||||
metadata={"RID": request_id, "response": False},
|
||||
)
|
||||
self.client.connector.send(MessageEndpoints.scan_queue_request(), msg)
|
||||
|
||||
@property
|
||||
def step_size(self):
|
||||
"""Step size for tweak"""
|
||||
return self.ui.step_size.value()
|
||||
|
||||
@Slot()
|
||||
def on_tweak_right(self):
|
||||
"""Tweak motor right"""
|
||||
self.dev[self.device].move(self.step_size, relative=True)
|
||||
|
||||
@Slot()
|
||||
def on_tweak_left(self):
|
||||
"""Tweak motor left"""
|
||||
self.dev[self.device].move(-self.step_size, relative=True)
|
||||
|
||||
@Slot()
|
||||
def on_setpoint_change(self):
|
||||
"""Change the setpoint for the motor"""
|
||||
self.ui.setpoint.clearFocus()
|
||||
setpoint = self.ui.setpoint.text()
|
||||
self.dev[self.device].move(float(setpoint), relative=False)
|
||||
self.ui.tweak_left.setToolTip(f"Tweak left by {self.step_size}")
|
||||
self.ui.tweak_right.setToolTip(f"Tweak right by {self.step_size}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
widget = PositionerBox(device="bpm4i")
|
||||
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,264 +0,0 @@
|
||||
"""Module for a PositionerBox widget to control a positioner device."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QDoubleSpinBox
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import get_accent_colors, set_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
DeviceUpdateUIComponents,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
class PositionerBox(PositionerBoxBase):
|
||||
"""Simple Widget to control a positioner in box form"""
|
||||
|
||||
ui_file = "positioner_box.ui"
|
||||
dimensions = (234, 224)
|
||||
|
||||
PLUGIN = True
|
||||
|
||||
USER_ACCESS = ["set_positioner"]
|
||||
device_changed = Signal(str, str)
|
||||
# Signal emitted to inform listeners about a position update
|
||||
position_update = Signal(float)
|
||||
|
||||
def __init__(self, parent=None, device: Positioner | str | None = None, **kwargs):
|
||||
"""Initialize the PositionerBox widget.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
device (Positioner): The device to control.
|
||||
"""
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
|
||||
self._device = ""
|
||||
self._limits = None
|
||||
if self.current_path == "":
|
||||
self.current_path = os.path.dirname(__file__)
|
||||
|
||||
self.init_ui()
|
||||
self.device = device
|
||||
self._init_device(self.device, self.position_update.emit, self.update_limits)
|
||||
|
||||
def init_ui(self):
|
||||
"""Init the ui"""
|
||||
self.device_changed.connect(self.on_device_change)
|
||||
|
||||
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
|
||||
|
||||
self.addWidget(self.ui)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# fix the size of the device box
|
||||
db = self.ui.device_box
|
||||
db.setFixedHeight(self.dimensions[0])
|
||||
db.setFixedWidth(self.dimensions[1])
|
||||
|
||||
self.ui.step_size.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType)
|
||||
self.ui.stop.clicked.connect(self.on_stop)
|
||||
self.ui.stop.setToolTip("Stop")
|
||||
self.ui.stop.setStyleSheet(
|
||||
f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}"
|
||||
)
|
||||
self.ui.tweak_right.clicked.connect(self.on_tweak_right)
|
||||
self.ui.tweak_right.setToolTip("Tweak right")
|
||||
self.ui.tweak_left.clicked.connect(self.on_tweak_left)
|
||||
self.ui.tweak_left.setToolTip("Tweak left")
|
||||
self.ui.setpoint.returnPressed.connect(self.on_setpoint_change)
|
||||
|
||||
self.setpoint_validator = QDoubleValidator()
|
||||
self.ui.setpoint.setValidator(self.setpoint_validator)
|
||||
self.ui.spinner_widget.start()
|
||||
self.ui.tool_button.clicked.connect(self._open_dialog_selection(self.set_positioner))
|
||||
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
|
||||
self.ui.tool_button.setIcon(icon)
|
||||
|
||||
def force_update_readback(self):
|
||||
self._init_device(self.device, self.position_update.emit, self.update_limits)
|
||||
|
||||
@SafeProperty(str)
|
||||
def device(self):
|
||||
"""Property to set the device"""
|
||||
return self._device
|
||||
|
||||
@device.setter
|
||||
def device(self, value: str):
|
||||
"""Setter, checks if device is a string"""
|
||||
if not value or not isinstance(value, str):
|
||||
return
|
||||
if not self._check_device_is_valid(value):
|
||||
return
|
||||
old_device = self._device
|
||||
self._device = value
|
||||
if not self.label:
|
||||
self.label = value
|
||||
self.device_changed.emit(old_device, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def hide_device_selection(self):
|
||||
"""Hide the device selection"""
|
||||
return not self.ui.tool_button.isVisible()
|
||||
|
||||
@hide_device_selection.setter
|
||||
def hide_device_selection(self, value: bool):
|
||||
"""Set the device selection visibility"""
|
||||
self.ui.tool_button.setVisible(not value)
|
||||
|
||||
@SafeSlot(bool)
|
||||
def show_device_selection(self, value: bool):
|
||||
"""Show the device selection
|
||||
|
||||
Args:
|
||||
value (bool): Show the device selection
|
||||
"""
|
||||
self.hide_device_selection = not value
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_positioner(self, positioner: str | Positioner):
|
||||
"""Set the device
|
||||
|
||||
Args:
|
||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||
"""
|
||||
if isinstance(positioner, Positioner):
|
||||
positioner = positioner.name
|
||||
self.device = positioner
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def on_device_change(self, old_device: str, new_device: str):
|
||||
"""Upon changing the device, a check will be performed if the device is a Positioner.
|
||||
|
||||
Args:
|
||||
old_device (str): The old device name.
|
||||
new_device (str): The new device name.
|
||||
"""
|
||||
if not self._check_device_is_valid(new_device):
|
||||
return
|
||||
self._on_device_change(
|
||||
old_device,
|
||||
new_device,
|
||||
self.position_update.emit,
|
||||
self.update_limits,
|
||||
self.on_device_readback,
|
||||
self._device_ui_components(new_device),
|
||||
)
|
||||
|
||||
def _device_ui_components(self, device: str) -> DeviceUpdateUIComponents:
|
||||
return {
|
||||
"spinner": self.ui.spinner_widget,
|
||||
"position_indicator": self.ui.position_indicator,
|
||||
"readback": self.ui.readback,
|
||||
"setpoint": self.ui.setpoint,
|
||||
"step_size": self.ui.step_size,
|
||||
"device_box": self.ui.device_box,
|
||||
"stop": self.ui.stop,
|
||||
"tweak_increase": self.ui.tweak_right,
|
||||
"tweak_decrease": self.ui.tweak_left,
|
||||
}
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback(self, msg_content: dict, metadata: dict):
|
||||
"""Callback for device readback.
|
||||
|
||||
Args:
|
||||
msg_content (dict): The message content.
|
||||
metadata (dict): The message metadata.
|
||||
"""
|
||||
self._on_device_readback(
|
||||
self.device,
|
||||
self._device_ui_components(self.device),
|
||||
msg_content,
|
||||
metadata,
|
||||
self.position_update.emit,
|
||||
self.update_limits,
|
||||
)
|
||||
|
||||
def update_limits(self, limits: tuple):
|
||||
"""Update limits
|
||||
|
||||
Args:
|
||||
limits (tuple): Limits of the positioner
|
||||
"""
|
||||
if limits == self._limits:
|
||||
return
|
||||
self._limits = limits
|
||||
self._update_limits_ui(limits, self.ui.position_indicator, self.setpoint_validator)
|
||||
|
||||
@SafeSlot()
|
||||
def on_stop(self):
|
||||
self._stop_device(self.device)
|
||||
|
||||
@property
|
||||
def step_size(self):
|
||||
"""Step size for tweak"""
|
||||
return self.ui.step_size.value()
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_right(self):
|
||||
"""Tweak motor right"""
|
||||
setpoint = self._get_setpoint()
|
||||
if setpoint is None:
|
||||
self.dev[self.device].move(self.step_size, relative=True)
|
||||
return
|
||||
target = setpoint + self.step_size
|
||||
self.dev[self.device].move(target, relative=False)
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_left(self):
|
||||
"""Tweak motor left"""
|
||||
setpoint = self._get_setpoint()
|
||||
if setpoint is None:
|
||||
self.dev[self.device].move(-self.step_size, relative=True)
|
||||
return
|
||||
target = setpoint - self.step_size
|
||||
self.dev[self.device].move(target, relative=False)
|
||||
|
||||
def _get_setpoint(self) -> float | None:
|
||||
"""Get the setpoint of the motor"""
|
||||
setpoint = getattr(self.dev[self.device], "setpoint", None)
|
||||
if not setpoint:
|
||||
setpoint = getattr(self.dev[self.device], "user_setpoint", None)
|
||||
if not setpoint:
|
||||
return None
|
||||
try:
|
||||
return float(setpoint.get())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@SafeSlot()
|
||||
def on_setpoint_change(self):
|
||||
"""Change the setpoint for the motor"""
|
||||
self.ui.setpoint.clearFocus()
|
||||
setpoint = self.ui.setpoint.text()
|
||||
self.dev[self.device].move(float(setpoint), relative=False)
|
||||
self.ui.tweak_left.setToolTip(f"Tweak left by {self.step_size}")
|
||||
self.ui.tweak_right.setToolTip(f"Tweak right by {self.step_size}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
widget = PositionerBox(device="bpm4i")
|
||||
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['positioner_box_2d.py']}
|
||||
@@ -1,482 +0,0 @@
|
||||
""" Module for a PositionerBox2D widget to control two positioner devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QDoubleSpinBox
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
DeviceUpdateUIComponents,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
DeviceId = Literal["horizontal", "vertical"]
|
||||
|
||||
|
||||
class PositionerBox2D(PositionerBoxBase):
|
||||
"""Simple Widget to control two positioners in box form"""
|
||||
|
||||
ui_file = "positioner_box_2d.ui"
|
||||
|
||||
PLUGIN = True
|
||||
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver"]
|
||||
|
||||
device_changed_hor = Signal(str, str)
|
||||
device_changed_ver = Signal(str, str)
|
||||
# Signals emitted to inform listeners about a position update
|
||||
position_update_hor = Signal(float)
|
||||
position_update_ver = Signal(float)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
device_hor: Positioner | str | None = None,
|
||||
device_ver: Positioner | str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the PositionerBox widget.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
device_hor (Positioner | str): The first device to control - assigned the horizontal axis.
|
||||
device_ver (Positioner | str): The second device to control - assigned the vertical axis.
|
||||
"""
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
|
||||
self._device_hor = ""
|
||||
self._device_ver = ""
|
||||
self._limits_hor = None
|
||||
self._limits_ver = None
|
||||
self._dialog = None
|
||||
if self.current_path == "":
|
||||
self.current_path = os.path.dirname(__file__)
|
||||
self.init_ui()
|
||||
self.device_hor = device_hor
|
||||
self.device_ver = device_ver
|
||||
|
||||
self.connect_ui()
|
||||
|
||||
def init_ui(self):
|
||||
"""Init the ui"""
|
||||
self.device_changed_hor.connect(self.on_device_change_hor)
|
||||
self.device_changed_ver.connect(self.on_device_change_ver)
|
||||
|
||||
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
|
||||
self.setpoint_validator_hor = QDoubleValidator()
|
||||
self.setpoint_validator_ver = QDoubleValidator()
|
||||
|
||||
def connect_ui(self):
|
||||
"""Connect the UI components to signals, data, or routines"""
|
||||
self.addWidget(self.ui)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
def _init_ui(val: QDoubleValidator, device_id: DeviceId):
|
||||
ui = self._device_ui_components_hv(device_id)
|
||||
tweak_inc = (
|
||||
self.on_tweak_inc_hor if device_id == "horizontal" else self.on_tweak_inc_ver
|
||||
)
|
||||
tweak_dec = (
|
||||
self.on_tweak_dec_hor if device_id == "horizontal" else self.on_tweak_dec_ver
|
||||
)
|
||||
ui["setpoint"].setValidator(val)
|
||||
ui["setpoint"].returnPressed.connect(
|
||||
self.on_setpoint_change_hor
|
||||
if device_id == "horizontal"
|
||||
else self.on_setpoint_change_ver
|
||||
)
|
||||
ui["stop"].setToolTip("Stop")
|
||||
ui["step_size"].setStepType(QDoubleSpinBox.StepType.AdaptiveDecimalStepType)
|
||||
ui["tweak_increase"].clicked.connect(tweak_inc)
|
||||
ui["tweak_decrease"].clicked.connect(tweak_dec)
|
||||
|
||||
_init_ui(self.setpoint_validator_hor, "horizontal")
|
||||
_init_ui(self.setpoint_validator_ver, "vertical")
|
||||
|
||||
self.ui.stop_button.button.clicked.connect(self.on_stop)
|
||||
|
||||
self.ui.step_decrease_hor.clicked.connect(self.on_step_dec_hor)
|
||||
self.ui.step_decrease_ver.clicked.connect(self.on_step_dec_ver)
|
||||
self.ui.step_increase_hor.clicked.connect(self.on_step_inc_hor)
|
||||
self.ui.step_increase_ver.clicked.connect(self.on_step_inc_ver)
|
||||
|
||||
self.ui.tool_button_hor.clicked.connect(
|
||||
self._open_dialog_selection(self.set_positioner_hor)
|
||||
)
|
||||
self.ui.tool_button_ver.clicked.connect(
|
||||
self._open_dialog_selection(self.set_positioner_ver)
|
||||
)
|
||||
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
|
||||
self.ui.tool_button_hor.setIcon(icon)
|
||||
self.ui.tool_button_ver.setIcon(icon)
|
||||
|
||||
step_tooltip = "Step by the step size"
|
||||
tweak_tooltip = "Tweak by 1/10th the step size"
|
||||
|
||||
for b in [
|
||||
self.ui.step_increase_hor,
|
||||
self.ui.step_increase_ver,
|
||||
self.ui.step_decrease_hor,
|
||||
self.ui.step_decrease_ver,
|
||||
]:
|
||||
b.setToolTip(step_tooltip)
|
||||
|
||||
for b in [
|
||||
self.ui.tweak_increase_hor,
|
||||
self.ui.tweak_increase_ver,
|
||||
self.ui.tweak_decrease_hor,
|
||||
self.ui.tweak_decrease_ver,
|
||||
]:
|
||||
b.setToolTip(tweak_tooltip)
|
||||
|
||||
icon_options = {"size": (16, 16), "convert_to_pixmap": False}
|
||||
self.ui.tweak_increase_hor.setIcon(
|
||||
material_icon(icon_name="keyboard_arrow_right", **icon_options)
|
||||
)
|
||||
self.ui.step_increase_hor.setIcon(
|
||||
material_icon(icon_name="keyboard_double_arrow_right", **icon_options)
|
||||
)
|
||||
self.ui.tweak_decrease_hor.setIcon(
|
||||
material_icon(icon_name="keyboard_arrow_left", **icon_options)
|
||||
)
|
||||
self.ui.step_decrease_hor.setIcon(
|
||||
material_icon(icon_name="keyboard_double_arrow_left", **icon_options)
|
||||
)
|
||||
self.ui.tweak_increase_ver.setIcon(
|
||||
material_icon(icon_name="keyboard_arrow_up", **icon_options)
|
||||
)
|
||||
self.ui.step_increase_ver.setIcon(
|
||||
material_icon(icon_name="keyboard_double_arrow_up", **icon_options)
|
||||
)
|
||||
self.ui.tweak_decrease_ver.setIcon(
|
||||
material_icon(icon_name="keyboard_arrow_down", **icon_options)
|
||||
)
|
||||
self.ui.step_decrease_ver.setIcon(
|
||||
material_icon(icon_name="keyboard_double_arrow_down", **icon_options)
|
||||
)
|
||||
|
||||
@SafeProperty(str)
|
||||
def device_hor(self):
|
||||
"""SafeProperty to set the device"""
|
||||
return self._device_hor
|
||||
|
||||
@device_hor.setter
|
||||
def device_hor(self, value: str):
|
||||
"""Setter, checks if device is a string"""
|
||||
if not value or not isinstance(value, str):
|
||||
return
|
||||
if not self._check_device_is_valid(value):
|
||||
return
|
||||
if value == self.device_ver:
|
||||
return
|
||||
old_device = self._device_hor
|
||||
self._device_hor = value
|
||||
self.label = f"{self._device_hor}, {self._device_ver}"
|
||||
self.device_changed_hor.emit(old_device, value)
|
||||
self._init_device(self.device_hor, self.position_update_hor.emit, self.update_limits_hor)
|
||||
|
||||
@SafeProperty(str)
|
||||
def device_ver(self):
|
||||
"""SafeProperty to set the device"""
|
||||
return self._device_ver
|
||||
|
||||
@device_ver.setter
|
||||
def device_ver(self, value: str):
|
||||
"""Setter, checks if device is a string"""
|
||||
if not value or not isinstance(value, str):
|
||||
return
|
||||
if not self._check_device_is_valid(value):
|
||||
return
|
||||
if value == self.device_hor:
|
||||
return
|
||||
old_device = self._device_ver
|
||||
self._device_ver = value
|
||||
self.label = f"{self._device_hor}, {self._device_ver}"
|
||||
self.device_changed_ver.emit(old_device, value)
|
||||
self._init_device(self.device_ver, self.position_update_ver.emit, self.update_limits_ver)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def hide_device_selection(self):
|
||||
"""Hide the device selection"""
|
||||
return not self.ui.tool_button_hor.isVisible()
|
||||
|
||||
@hide_device_selection.setter
|
||||
def hide_device_selection(self, value: bool):
|
||||
"""Set the device selection visibility"""
|
||||
self.ui.tool_button_hor.setVisible(not value)
|
||||
self.ui.tool_button_ver.setVisible(not value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def hide_device_boxes(self):
|
||||
"""Hide the device selection"""
|
||||
return not self.ui.device_box_hor.isVisible()
|
||||
|
||||
@hide_device_boxes.setter
|
||||
def hide_device_boxes(self, value: bool):
|
||||
"""Set the device selection visibility"""
|
||||
self.ui.device_box_hor.setVisible(not value)
|
||||
self.ui.device_box_ver.setVisible(not value)
|
||||
|
||||
@SafeSlot(bool)
|
||||
def show_device_selection(self, value: bool):
|
||||
"""Show the device selection
|
||||
|
||||
Args:
|
||||
value (bool): Show the device selection
|
||||
"""
|
||||
self.hide_device_selection = not value
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_positioner_hor(self, positioner: str | Positioner):
|
||||
"""Set the device
|
||||
|
||||
Args:
|
||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||
"""
|
||||
if isinstance(positioner, Positioner):
|
||||
positioner = positioner.name
|
||||
self.device_hor = positioner
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_positioner_ver(self, positioner: str | Positioner):
|
||||
"""Set the device
|
||||
|
||||
Args:
|
||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||
"""
|
||||
if isinstance(positioner, Positioner):
|
||||
positioner = positioner.name
|
||||
self.device_ver = positioner
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def on_device_change_hor(self, old_device: str, new_device: str):
|
||||
"""Upon changing the device, a check will be performed if the device is a Positioner.
|
||||
|
||||
Args:
|
||||
old_device (str): The old device name.
|
||||
new_device (str): The new device name.
|
||||
"""
|
||||
if not self._check_device_is_valid(new_device):
|
||||
return
|
||||
self._on_device_change(
|
||||
old_device,
|
||||
new_device,
|
||||
self.position_update_hor.emit,
|
||||
self.update_limits_hor,
|
||||
self.on_device_readback_hor,
|
||||
self._device_ui_components_hv("horizontal"),
|
||||
)
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def on_device_change_ver(self, old_device: str, new_device: str):
|
||||
"""Upon changing the device, a check will be performed if the device is a Positioner.
|
||||
|
||||
Args:
|
||||
old_device (str): The old device name.
|
||||
new_device (str): The new device name.
|
||||
"""
|
||||
if not self._check_device_is_valid(new_device):
|
||||
return
|
||||
self._on_device_change(
|
||||
old_device,
|
||||
new_device,
|
||||
self.position_update_ver.emit,
|
||||
self.update_limits_ver,
|
||||
self.on_device_readback_ver,
|
||||
self._device_ui_components_hv("vertical"),
|
||||
)
|
||||
|
||||
def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents:
|
||||
if device == "horizontal":
|
||||
return {
|
||||
"spinner": self.ui.spinner_widget_hor,
|
||||
"position_indicator": self.ui.position_indicator_hor,
|
||||
"readback": self.ui.readback_hor,
|
||||
"setpoint": self.ui.setpoint_hor,
|
||||
"step_size": self.ui.step_size_hor,
|
||||
"device_box": self.ui.device_box_hor,
|
||||
"stop": self.ui.stop_button,
|
||||
"tweak_increase": self.ui.tweak_increase_hor,
|
||||
"tweak_decrease": self.ui.tweak_decrease_hor,
|
||||
}
|
||||
elif device == "vertical":
|
||||
return {
|
||||
"spinner": self.ui.spinner_widget_ver,
|
||||
"position_indicator": self.ui.position_indicator_ver,
|
||||
"readback": self.ui.readback_ver,
|
||||
"setpoint": self.ui.setpoint_ver,
|
||||
"step_size": self.ui.step_size_ver,
|
||||
"device_box": self.ui.device_box_ver,
|
||||
"stop": self.ui.stop_button,
|
||||
"tweak_increase": self.ui.tweak_increase_ver,
|
||||
"tweak_decrease": self.ui.tweak_decrease_ver,
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Device {device} is not represented by this UI")
|
||||
|
||||
def _device_ui_components(self, device: str):
|
||||
if device == self.device_hor:
|
||||
return self._device_ui_components_hv("horizontal")
|
||||
if device == self.device_ver:
|
||||
return self._device_ui_components_hv("vertical")
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback_hor(self, msg_content: dict, metadata: dict):
|
||||
"""Callback for device readback.
|
||||
|
||||
Args:
|
||||
msg_content (dict): The message content.
|
||||
metadata (dict): The message metadata.
|
||||
"""
|
||||
self._on_device_readback(
|
||||
self.device_hor,
|
||||
self._device_ui_components_hv("horizontal"),
|
||||
msg_content,
|
||||
metadata,
|
||||
self.position_update_hor.emit,
|
||||
self.update_limits_hor,
|
||||
)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback_ver(self, msg_content: dict, metadata: dict):
|
||||
"""Callback for device readback.
|
||||
|
||||
Args:
|
||||
msg_content (dict): The message content.
|
||||
metadata (dict): The message metadata.
|
||||
"""
|
||||
self._on_device_readback(
|
||||
self.device_ver,
|
||||
self._device_ui_components_hv("vertical"),
|
||||
msg_content,
|
||||
metadata,
|
||||
self.position_update_ver.emit,
|
||||
self.update_limits_ver,
|
||||
)
|
||||
|
||||
def update_limits_hor(self, limits: tuple):
|
||||
"""Update limits
|
||||
|
||||
Args:
|
||||
limits (tuple): Limits of the positioner
|
||||
"""
|
||||
if limits == self._limits_hor:
|
||||
return
|
||||
self._limits_hor = limits
|
||||
self._update_limits_ui(limits, self.ui.position_indicator_hor, self.setpoint_validator_hor)
|
||||
|
||||
def update_limits_ver(self, limits: tuple):
|
||||
"""Update limits
|
||||
|
||||
Args:
|
||||
limits (tuple): Limits of the positioner
|
||||
"""
|
||||
if limits == self._limits_ver:
|
||||
return
|
||||
self._limits_ver = limits
|
||||
self._update_limits_ui(limits, self.ui.position_indicator_ver, self.setpoint_validator_ver)
|
||||
|
||||
@SafeSlot()
|
||||
def on_stop(self):
|
||||
self._stop_device(f"{self.device_hor} or {self.device_ver}")
|
||||
|
||||
@SafeProperty(float)
|
||||
def step_size_hor(self):
|
||||
"""Step size for tweak"""
|
||||
return self.ui.step_size_hor.value()
|
||||
|
||||
@step_size_hor.setter
|
||||
def step_size_hor(self, val: float):
|
||||
"""Step size for tweak"""
|
||||
self.ui.step_size_hor.setValue(val)
|
||||
|
||||
@SafeProperty(float)
|
||||
def step_size_ver(self):
|
||||
"""Step size for tweak"""
|
||||
return self.ui.step_size_ver.value()
|
||||
|
||||
@step_size_ver.setter
|
||||
def step_size_ver(self, val: float):
|
||||
"""Step size for tweak"""
|
||||
self.ui.step_size_ver.setValue(val)
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_inc_hor(self):
|
||||
"""Tweak device a up"""
|
||||
self.dev[self.device_hor].move(self.step_size_hor / 10, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_dec_hor(self):
|
||||
"""Tweak device a down"""
|
||||
self.dev[self.device_hor].move(-self.step_size_hor / 10, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_step_inc_hor(self):
|
||||
"""Tweak device a up"""
|
||||
self.dev[self.device_hor].move(self.step_size_hor, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_step_dec_hor(self):
|
||||
"""Tweak device a down"""
|
||||
self.dev[self.device_hor].move(-self.step_size_hor, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_inc_ver(self):
|
||||
"""Tweak device a up"""
|
||||
self.dev[self.device_ver].move(self.step_size_ver / 10, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_dec_ver(self):
|
||||
"""Tweak device b down"""
|
||||
self.dev[self.device_ver].move(-self.step_size_ver / 10, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_step_inc_ver(self):
|
||||
"""Tweak device b up"""
|
||||
self.dev[self.device_ver].move(self.step_size_ver, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_step_dec_ver(self):
|
||||
"""Tweak device a down"""
|
||||
self.dev[self.device_ver].move(-self.step_size_ver, relative=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_setpoint_change_hor(self):
|
||||
"""Change the setpoint for device a"""
|
||||
self.ui.setpoint_hor.clearFocus()
|
||||
setpoint = self.ui.setpoint_hor.text()
|
||||
self.dev[self.device_hor].move(float(setpoint), relative=False)
|
||||
|
||||
@SafeSlot()
|
||||
def on_setpoint_change_ver(self):
|
||||
"""Change the setpoint for device b"""
|
||||
self.ui.setpoint_ver.clearFocus()
|
||||
setpoint = self.ui.setpoint_ver.text()
|
||||
self.dev[self.device_ver].move(float(setpoint), relative=False)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
widget = PositionerBox2D()
|
||||
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,562 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>326</width>
|
||||
<height>323</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="1">
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="0" column="1">
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="2">
|
||||
<widget class="QGroupBox" name="device_box_ver">
|
||||
<property name="title">
|
||||
<string>No positioner selected</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_6" rowstretch="0,0,0,0,0,0">
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item row="1" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_5">
|
||||
<item>
|
||||
<widget class="QToolButton" name="tool_button_ver">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="readback_ver">
|
||||
<property name="text">
|
||||
<string>Position</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="SpinnerWidget" name="spinner_widget_ver">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>25</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>25</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLineEdit" name="setpoint_ver">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::StrongFocus</enum>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_6">
|
||||
<item>
|
||||
<widget class="QDoubleSpinBox" name="step_size_ver">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::StrongFocus</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QGroupBox" name="device_box_hor">
|
||||
<property name="title">
|
||||
<string>No positioner selected</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_5" rowstretch="0,0,0,0,0,0,0,0,0,0,0">
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item row="9" column="0">
|
||||
<widget class="QLineEdit" name="setpoint_hor">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::StrongFocus</enum>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||
<item>
|
||||
<widget class="QToolButton" name="tool_button_hor">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="readback_hor">
|
||||
<property name="text">
|
||||
<string>Position</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="SpinnerWidget" name="spinner_widget_hor">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>25</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>25</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="10" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_7">
|
||||
<item>
|
||||
<widget class="QDoubleSpinBox" name="step_size_hor">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::StrongFocus</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="PositionIndicator" name="position_indicator_ver">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximum" stdset="0">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
<property name="vertical" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="value" stdset="0">
|
||||
<double>0.500000000000000</double>
|
||||
</property>
|
||||
<property name="indicator_width" stdset="0">
|
||||
<number>4</number>
|
||||
</property>
|
||||
<property name="rounded_corners" stdset="0">
|
||||
<number>4</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="3">
|
||||
<widget class="QPushButton" name="step_increase_ver">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="5">
|
||||
<spacer name="horizontalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="6" column="3">
|
||||
<widget class="QPushButton" name="step_decrease_ver">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="5">
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="6" column="2">
|
||||
<spacer name="horizontalSpacer_16">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>10</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<spacer name="horizontalSpacer_14">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>10</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<spacer name="horizontalSpacer_17">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<spacer name="horizontalSpacer_7">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>10</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="6" column="1">
|
||||
<spacer name="horizontalSpacer_4">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="3">
|
||||
<spacer name="horizontalSpacer_6">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="5">
|
||||
<widget class="QPushButton" name="step_increase_hor">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="6" column="4">
|
||||
<spacer name="horizontalSpacer_10">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QPushButton" name="tweak_decrease_hor">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<spacer name="horizontalSpacer_15">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>10</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<spacer name="horizontalSpacer_35">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="4" column="3">
|
||||
<widget class="QPushButton" name="tweak_decrease_ver">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QPushButton" name="step_decrease_hor">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="4">
|
||||
<widget class="QPushButton" name="tweak_increase_hor">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QPushButton" name="tweak_increase_ver">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::FocusPolicy::NoFocus</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="4">
|
||||
<spacer name="horizontalSpacer_12">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="4">
|
||||
<spacer name="horizontalSpacer_8">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="4" column="4">
|
||||
<spacer name="horizontalSpacer_11">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="5">
|
||||
<spacer name="horizontalSpacer_5">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="6" column="5">
|
||||
<widget class="StopButton" name="stop_button"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="PositionIndicator" name="position_indicator_hor">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximum" stdset="0">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
<property name="value" stdset="0">
|
||||
<double>0.500000000000000</double>
|
||||
</property>
|
||||
<property name="indicator_width" stdset="0">
|
||||
<number>4</number>
|
||||
</property>
|
||||
<property name="rounded_corners" stdset="0">
|
||||
<number>4</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>StopButton</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>stop_button</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>PositionIndicator</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>position_indicator</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>SpinnerWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>spinner_widget</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<tabstops>
|
||||
<tabstop>tool_button_hor</tabstop>
|
||||
<tabstop>tool_button_ver</tabstop>
|
||||
<tabstop>setpoint_hor</tabstop>
|
||||
<tabstop>setpoint_ver</tabstop>
|
||||
<tabstop>step_size_hor</tabstop>
|
||||
<tabstop>step_size_ver</tabstop>
|
||||
<tabstop>tweak_decrease_hor</tabstop>
|
||||
<tabstop>tweak_increase_ver</tabstop>
|
||||
<tabstop>tweak_increase_hor</tabstop>
|
||||
<tabstop>tweak_decrease_ver</tabstop>
|
||||
<tabstop>step_decrease_hor</tabstop>
|
||||
<tabstop>step_increase_ver</tabstop>
|
||||
<tabstop>step_increase_hor</tabstop>
|
||||
<tabstop>step_decrease_ver</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,17 +0,0 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box2_d_plugin import (
|
||||
PositionerBox2DPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(PositionerBox2DPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -6,7 +6,7 @@ import os
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
@@ -1,8 +1,6 @@
|
||||
import os
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
|
||||
|
||||
|
||||
class PositionerControlLine(PositionerBox):
|
||||
@@ -14,14 +12,13 @@ class PositionerControlLine(PositionerBox):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "switch_left"
|
||||
|
||||
def __init__(self, parent=None, device: Positioner | str | None = None, *args, **kwargs):
|
||||
def __init__(self, parent=None, device: Positioner = None, *args, **kwargs):
|
||||
"""Initialize the DeviceControlLine.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
device (Positioner): The device to control.
|
||||
"""
|
||||
self.current_path = os.path.dirname(__file__)
|
||||
super().__init__(parent=parent, device=device, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import os
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerControlLine
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line import (
|
||||
PositionerControlLine,
|
||||
)
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box_plugin import (
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_plugin import (
|
||||
PositionerBoxPlugin,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line_plugin import (
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line_plugin import (
|
||||
PositionerControlLinePlugin,
|
||||
)
|
||||
|
||||
@@ -4,12 +4,11 @@ from __future__ import annotations
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtCore import Property, QSize, Signal, Slot
|
||||
from qtpy.QtWidgets import QGridLayout, QGroupBox, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -33,7 +32,7 @@ class PositionerGroupBox(QGroupBox):
|
||||
self.widget.position_update.connect(self._on_position_update)
|
||||
self.widget.expand.connect(self._on_expand)
|
||||
self.setTitle(self.device_name)
|
||||
self.widget.force_update_readback()
|
||||
self.widget.init_device() # force readback
|
||||
|
||||
def _on_expand(self, expand):
|
||||
if expand:
|
||||
@@ -83,7 +82,7 @@ class PositionerGroup(BECWidget, QWidget):
|
||||
def minimumSizeHint(self):
|
||||
return QSize(300, 30)
|
||||
|
||||
@SafeSlot(str)
|
||||
@Slot(str)
|
||||
def set_positioners(self, device_names: str):
|
||||
"""Redraw grid with positioners from device_names string
|
||||
|
||||
@@ -131,7 +130,7 @@ class PositionerGroup(BECWidget, QWidget):
|
||||
widget = self.sender()
|
||||
self.device_position_update.emit(widget.title(), pos)
|
||||
|
||||
@SafeProperty(str)
|
||||
@Property(str)
|
||||
def devices_list(self):
|
||||
"""Device names string separated by space"""
|
||||
return " ".join(self._device_widgets)
|
||||
@@ -145,7 +144,7 @@ class PositionerGroup(BECWidget, QWidget):
|
||||
return
|
||||
self.set_positioners(device_names)
|
||||
|
||||
@SafeProperty(int)
|
||||
@Property(int)
|
||||
def grid_max_cols(self):
|
||||
"""Max number of columns for widgets grid"""
|
||||
return self._grid_ncols
|
||||
|
||||
@@ -5,7 +5,6 @@ import enum
|
||||
from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
|
||||
from bec_lib.device import Signal as BECSignal
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import field_validator
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
@@ -26,35 +25,13 @@ class BECDeviceFilter(enum.Enum):
|
||||
|
||||
|
||||
class DeviceInputConfig(ConnectionConfig):
|
||||
device_filter: list[str] = []
|
||||
readout_filter: list[str] = []
|
||||
device_filter: list[BECDeviceFilter] = []
|
||||
readout_filter: list[ReadoutPriority] = []
|
||||
devices: list[str] = []
|
||||
default: str | None = None
|
||||
arg_name: str | None = None
|
||||
apply_filter: bool = True
|
||||
|
||||
@field_validator("device_filter")
|
||||
@classmethod
|
||||
def check_device_filter(cls, v, values):
|
||||
valid_device_filters = [entry.value for entry in BECDeviceFilter]
|
||||
for filt in v:
|
||||
if filt not in valid_device_filters:
|
||||
raise ValueError(
|
||||
f"Device filter {filt} is not a valid device filter {valid_device_filters}."
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("readout_filter")
|
||||
@classmethod
|
||||
def check_readout_filter(cls, v, values):
|
||||
valid_device_filters = [entry.value for entry in ReadoutPriority]
|
||||
for filt in v:
|
||||
if filt not in valid_device_filters:
|
||||
raise ValueError(
|
||||
f"Device filter {filt} is not a valid device filter {valid_device_filters}."
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class DeviceInputBase(BECWidget):
|
||||
"""
|
||||
@@ -82,7 +59,7 @@ class DeviceInputBase(BECWidget):
|
||||
ReadoutPriority.ON_REQUEST: "readout_on_request",
|
||||
}
|
||||
|
||||
def __init__(self, client=None, config=None, gui_id: str | None = None, **kwargs):
|
||||
def __init__(self, client=None, config=None, gui_id: str = None):
|
||||
|
||||
if config is None:
|
||||
config = DeviceInputConfig(widget_class=self.__class__.__name__)
|
||||
@@ -90,7 +67,7 @@ class DeviceInputBase(BECWidget):
|
||||
if isinstance(config, dict):
|
||||
config = DeviceInputConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True)
|
||||
self.get_bec_shortcuts()
|
||||
self._device_filter = []
|
||||
self._readout_filter = []
|
||||
|
||||
@@ -35,14 +35,14 @@ class DeviceSignalInputBase(BECWidget):
|
||||
Kind.config: "include_config_signals",
|
||||
}
|
||||
|
||||
def __init__(self, client=None, config=None, gui_id: str = None, **kwargs):
|
||||
def __init__(self, client=None, config=None, gui_id: str = None):
|
||||
if config is None:
|
||||
config = DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DeviceSignalInputBaseConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
|
||||
self._device = None
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@@ -45,9 +45,8 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
available_devices: list[str] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QComboBox.__init__(self, parent=parent)
|
||||
if arg_name is not None:
|
||||
self.config.arg_name = arg_name
|
||||
@@ -104,7 +103,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
"""Cleanup the widget."""
|
||||
if self._callback_id is not None:
|
||||
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
|
||||
super().cleanup()
|
||||
|
||||
def get_current_device(self) -> object:
|
||||
"""
|
||||
|
||||
@@ -48,12 +48,11 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
available_devices: list[str] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._callback_id = None
|
||||
self._is_valid_input = False
|
||||
self._accent_colors = get_accent_colors()
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QLineEdit.__init__(self, parent=parent)
|
||||
self.completer = QCompleter(self)
|
||||
self.setCompleter(self.completer)
|
||||
@@ -111,7 +110,6 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
"""Cleanup the widget."""
|
||||
if self._callback_id is not None:
|
||||
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
|
||||
super().cleanup()
|
||||
|
||||
def get_current_device(self) -> object:
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combo_box_plugin import (
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox_plugin import (
|
||||
SignalComboBoxPlugin,
|
||||
)
|
||||
|
||||
@@ -38,9 +38,8 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
signal_filter: str | list[str] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QComboBox.__init__(self, parent=parent)
|
||||
if arg_name is not None:
|
||||
self.config.arg_name = arg_name
|
||||
|
||||
@@ -8,7 +8,7 @@ from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox im
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='SignalComboBox' name='signal_combo_box'>
|
||||
<widget class='SignalComboBox' name='signal_combobox'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
@@ -33,7 +33,7 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return designer_material_icon(SignalComboBox.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "signal_combo_box"
|
||||
return "signal_combobox"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -48,7 +48,7 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return "SignalComboBox"
|
||||
|
||||
def toolTip(self):
|
||||
return "Signal ComboBox Example for BEC Widgets with autocomplete."
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -39,10 +39,9 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
|
||||
signal_filter: str | list[str] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._is_valid_input = False
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QLineEdit.__init__(self, parent=parent)
|
||||
self._accent_colors = get_accent_colors()
|
||||
self.completer = QCompleter(self)
|
||||
|
||||
@@ -50,7 +50,7 @@ class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return "SignalLineEdit"
|
||||
|
||||
def toolTip(self):
|
||||
return "Signal LineEdit Example for BEC Widgets with autocomplete."
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user