mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 01:37:53 +02:00
Compare commits
3 Commits
tests/add_
...
94-bec-fig
| Author | SHA1 | Date | |
|---|---|---|---|
| d996ff9284 | |||
| 8b43eba282 | |||
| 9c822ec480 |
@@ -1,3 +1,2 @@
|
||||
black --line-length=100 $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
isort --line-length=100 --profile=black --multi-line=3 --trailing-comma $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
git add $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
black --line-length=100 $(git diff --cached --name-only --diff-filter=ACM -- '***.py')
|
||||
git add $(git diff --cached --name-only --diff-filter=ACM -- '***.py')
|
||||
|
||||
206
.gitlab-ci.yml
206
.gitlab-ci.yml
@@ -1,88 +1,28 @@
|
||||
# This file is a template, and might need editing before it works on your project.
|
||||
# Official language image. Look for the different tagged releases at:
|
||||
# https://hub.docker.com/r/library/python/tags/
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
|
||||
image: $CI_DOCKER_REGISTRY/python:3.9
|
||||
#commands to run in the Docker container before starting each job.
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
BEC_CORE_BRANCH:
|
||||
description: bec branch
|
||||
value: main
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: ophyd_devices branch
|
||||
value: main
|
||||
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
|
||||
CHECK_PKG_VERSIONS:
|
||||
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
|
||||
value: 0
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "schedule"
|
||||
- if: $CI_PIPELINE_SOURCE == "web"
|
||||
- if: $CI_PIPELINE_SOURCE == "pipeline"
|
||||
- if: $CI_PIPELINE_SOURCE == "parent_pipeline"
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
|
||||
when: never
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
include:
|
||||
- template: Security/Secret-Detection.gitlab-ci.yml
|
||||
- project: "bec/awi_utils"
|
||||
file: "/templates/check-packages-job.yml"
|
||||
inputs:
|
||||
stage: test
|
||||
path: "."
|
||||
pytest_args: "-v,--random-order,tests/unit_tests"
|
||||
pip_args: ".[dev]"
|
||||
|
||||
|
||||
# different stages in the pipeline
|
||||
stages:
|
||||
- Formatter
|
||||
- test
|
||||
- AdditionalTests
|
||||
- End2End
|
||||
- Deploy
|
||||
|
||||
.install-qt-webengine-deps: &install-qt-webengine-deps
|
||||
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
|
||||
- export QTWEBENGINE_DISABLE_SANDBOX=1
|
||||
|
||||
.clone-repos: &clone-repos
|
||||
- echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
|
||||
.install-repos: &install-repos
|
||||
- pip install -e ./ophyd_devices
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e ./bec/bec_ipython_client
|
||||
- pip install -e ./bec/pytest_bec_e2e
|
||||
|
||||
.install-os-packages: &install-os-packages
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
- *install-qt-webengine-deps
|
||||
|
||||
before_script:
|
||||
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
|
||||
echo -e "\033[35;1m Using branch $CHILD_PIPELINE_BRANCH of BEC Widgets \033[0;m";
|
||||
test -d bec_widgets || git clone --branch $CHILD_PIPELINE_BRANCH https://gitlab.psi.ch/bec/bec_widgets.git; cd bec_widgets;
|
||||
fi
|
||||
|
||||
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 ./
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
- pip install black
|
||||
- black --check --diff --color --line-length=100 ./
|
||||
pylint:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
@@ -99,8 +39,6 @@ pylint:
|
||||
paths:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
pylint-check:
|
||||
stage: Formatter
|
||||
@@ -111,21 +49,18 @@ pylint-check:
|
||||
- apt-get update
|
||||
- apt-get install -y bc
|
||||
script:
|
||||
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
|
||||
# Identify changed Python files
|
||||
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
|
||||
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
|
||||
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
|
||||
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse $CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
|
||||
CHANGED_FILES=$(git diff --name-only $SOURCE_BRANCH_COMMIT_SHA $TARGET_BRANCH_COMMIT_SHA | grep '\.py$' || true);
|
||||
else
|
||||
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
|
||||
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
|
||||
fi
|
||||
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
|
||||
|
||||
- echo "Changed Python files:"
|
||||
- $CHANGED_FILES
|
||||
# Run pylint only on changed files
|
||||
- mkdir ./pylint
|
||||
- pylint $CHANGED_FILES --output-format=text | tee ./pylint/pylint_changed_files.log || pylint-exit $?
|
||||
- pylint $CHANGED_FILES --output-format=text . | tee ./pylint/pylint_changed_files.log || pylint-exit $?
|
||||
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
|
||||
- echo "Pylint score is $PYLINT_SCORE"
|
||||
|
||||
@@ -135,20 +70,17 @@ pylint-check:
|
||||
paths:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
tests:
|
||||
stage: test
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,pyside6]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- pip install .[dev]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests
|
||||
- coverage report
|
||||
- coverage xml
|
||||
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
|
||||
@@ -158,80 +90,36 @@ tests:
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
paths:
|
||||
- tests/reference_failures/
|
||||
when: always
|
||||
|
||||
test-matrix:
|
||||
parallel:
|
||||
matrix:
|
||||
- PYTHON_VERSION:
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
QT_PCKG:
|
||||
- "pyside6"
|
||||
#tests-3.9-pyqt5: #todo enable when we decide what qt distributions we want to support
|
||||
# extends: "tests"
|
||||
# stage: AdditionalTests
|
||||
# image: $CI_DOCKER_REGISTRY/python:3.9
|
||||
# script:
|
||||
# - apt-get update
|
||||
# - apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
# - pip install .[dev,pyqt5]
|
||||
# - pytest -v --random-order ./tests
|
||||
|
||||
tests-3.10:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
PYTHON_VERSION: ""
|
||||
QT_PCKG: ""
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:$PYTHON_VERSION
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,$QT_PCKG]
|
||||
- pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
image: $CI_DOCKER_REGISTRY/python:3.10
|
||||
allow_failure: true
|
||||
|
||||
end-2-end-conda:
|
||||
stage: End2End
|
||||
needs: []
|
||||
image: continuumio/miniconda3
|
||||
allow_failure: false
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- conda config --show-sources
|
||||
- conda config --add channels conda-forge
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --show-sources
|
||||
- conda config --set channel_priority strict
|
||||
- conda config --set always_yes yes --set changeps1 no
|
||||
- conda create -q -n test-environment python=3.11
|
||||
- conda init bash
|
||||
- source ~/.bashrc
|
||||
- conda activate test-environment
|
||||
|
||||
- cd ./bec
|
||||
- source ./bin/install_bec_dev.sh -t
|
||||
- cd ../
|
||||
- pip install -e ./ophyd_devices
|
||||
tests-3.11:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DOCKER_REGISTRY/python:3.11
|
||||
allow_failure: true
|
||||
|
||||
- pip install -e .[dev,pyside6]
|
||||
- pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
tests-3.12:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DOCKER_REGISTRY/python:3.12
|
||||
allow_failure: true
|
||||
|
||||
artifacts:
|
||||
when: on_failure
|
||||
paths:
|
||||
- ./logs/*.log
|
||||
expire_in: 1 week
|
||||
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "schedule"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "web"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
|
||||
- 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
|
||||
@@ -247,19 +135,19 @@ semver:
|
||||
- git fetch --tags
|
||||
- git tag
|
||||
|
||||
# build and publish package
|
||||
- pip install python-semantic-release==9.* wheel build twine
|
||||
# build
|
||||
- pip install python-semantic-release==7.* wheel
|
||||
- export GL_TOKEN=$CI_UPDATES
|
||||
- semantic-release -vv version
|
||||
|
||||
# check if any artifacts were created
|
||||
- if [ ! -d dist ]; then echo No release will be made; exit 0; fi
|
||||
- twine upload dist/* -u __token__ -p $CI_PYPI_TOKEN --skip-existing
|
||||
- semantic-release publish
|
||||
- export REPOSITORY_USERNAME=__token__
|
||||
- export REPOSITORY_PASSWORD=$CI_PYPI_TOKEN
|
||||
- >
|
||||
semantic-release publish -v DEBUG
|
||||
-D version_variable=./setup.py:__version__
|
||||
-D hvcs=gitlab
|
||||
|
||||
allow_failure: false
|
||||
rules:
|
||||
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
- if: '$CI_COMMIT_REF_NAME == "master"'
|
||||
|
||||
pages:
|
||||
stage: Deploy
|
||||
@@ -267,9 +155,9 @@ pages:
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_REF_NAME
|
||||
rules:
|
||||
- if: "$CI_COMMIT_TAG != null"
|
||||
- if: '$CI_COMMIT_TAG != null'
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_TAG
|
||||
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
- if: '$CI_COMMIT_REF_NAME == "master"'
|
||||
script:
|
||||
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code.
|
||||
extension-pkg-allow-list=PyQt6, PySide6, pyqtgraph
|
||||
extension-pkg-allow-list=PyQt5, pyqtgraph
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
@@ -52,7 +52,7 @@ persistent=yes
|
||||
|
||||
# Minimum Python version to use for version dependent checks. Will default to
|
||||
# the version used to run pylint.
|
||||
py-version=3.10
|
||||
py-version=3.9
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages.
|
||||
|
||||
@@ -9,7 +9,7 @@ version: 2
|
||||
build:
|
||||
os: ubuntu-20.04
|
||||
tools:
|
||||
python: "3.10"
|
||||
python: "3.9"
|
||||
|
||||
# Build documentation in the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
|
||||
6445
CHANGELOG.md
6445
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
37
README.md
37
README.md
@@ -1,40 +1,44 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
For development purposes, you can clone the repository and install the package locally in editable mode:
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
cd bec_widgets
|
||||
pip install -e .[dev,pyside6]
|
||||
cd bec-widgets
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
|
||||
specified.
|
||||
BEC Widgets currently supports both PyQt5 and PyQt6. By default, PyQt6 is installed.
|
||||
|
||||
To select a specific Python Qt distribution, install the package with an additional tag:
|
||||
|
||||
```bash
|
||||
pip install bec-widgets[pyqt6]
|
||||
```
|
||||
or
|
||||
|
||||
```bash
|
||||
pip install bec-widgets[pyqt5]
|
||||
```
|
||||
## Documentation
|
||||
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
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://beamline-experiment-control.readthedocs.io/en/latest/).
|
||||
|
||||
## Contributing
|
||||
|
||||
All commits should use the Angular commit scheme:
|
||||
|
||||
> #### <a name="commit-header"></a>Angular Commit Message Header
|
||||
>
|
||||
>
|
||||
> ```
|
||||
> <type>(<scope>): <short summary>
|
||||
> │ │ │
|
||||
@@ -48,13 +52,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 +70,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"]
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
""" This module contains the GUI for the 1D alignment application.
|
||||
It is a preliminary version of the GUI, which will be added to the main branch and steadily updated to be improved.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from bec_lib.device import Signal as BECSignal
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
import bec_widgets
|
||||
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,
|
||||
)
|
||||
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
|
||||
|
||||
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"""
|
||||
|
||||
def __init__(self, client=None, gui_id: Optional[str] = None) -> None:
|
||||
"""Initialization
|
||||
|
||||
Args:
|
||||
config: Configuration of the application.
|
||||
client: BEC client object.
|
||||
gui_id: GUI ID.
|
||||
"""
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
QApplication.instance().aboutToQuit.connect(self.close)
|
||||
self.dev = self.client.device_manager.devices
|
||||
|
||||
self._accent_colors = get_accent_colors()
|
||||
self.ui_file = "alignment_1d.ui"
|
||||
self.ui = None
|
||||
self.progress_bar = None
|
||||
self.waveform = None
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
"""Initialise the UI from QT Designer file"""
|
||||
current_path = os.path.dirname(__file__)
|
||||
self.ui = UILoader(None).loader(os.path.join(current_path, self.ui_file))
|
||||
# Customize the plotting widget
|
||||
self.waveform = self.ui.findChild(BECWaveformWidget, "bec_waveform_widget")
|
||||
self._customise_bec_waveform_widget()
|
||||
# Setup comboboxes for motor and signal selection
|
||||
# FIXME after changing the filtering in the combobox
|
||||
self._setup_signal_combobox()
|
||||
# Setup motor indicator
|
||||
self._setup_motor_indicator()
|
||||
# Setup progress bar
|
||||
self._setup_progress_bar()
|
||||
# Add actions buttons
|
||||
self._customise_buttons()
|
||||
# Hook scaninfo updates
|
||||
self.bec_dispatcher.connect_slot(self.scan_status_callback, MessageEndpoints.scan_status())
|
||||
|
||||
def show(self):
|
||||
return self.ui.show()
|
||||
|
||||
##############################
|
||||
############ SLOTS ###########
|
||||
##############################
|
||||
|
||||
@Slot(dict, dict)
|
||||
def scan_status_callback(self, content: dict, _) -> None:
|
||||
"""This slot allows to enable/disable the UI critical components when a scan is running"""
|
||||
if content["status"] in ["open"]:
|
||||
self.enable_ui(False)
|
||||
elif content["status"] in ["aborted", "halted", "closed"]:
|
||||
self.enable_ui(True)
|
||||
|
||||
@Slot(tuple)
|
||||
def move_to_center(self, move_request: tuple) -> None:
|
||||
"""Move the selected motor to the center"""
|
||||
motor = self.ui.device_combobox.currentText()
|
||||
if move_request[0] in ["center", "center1", "center2"]:
|
||||
pos = move_request[1]
|
||||
self.dev.get(motor).move(float(pos), relative=False)
|
||||
|
||||
@Slot()
|
||||
def reset_progress_bar(self) -> None:
|
||||
"""Reset the progress bar"""
|
||||
self.progress_bar.set_value(0)
|
||||
self.progress_bar.set_minimum(0)
|
||||
|
||||
@Slot(dict, dict)
|
||||
def update_progress_bar(self, content: dict, _) -> None:
|
||||
"""Hook to update the progress bar
|
||||
|
||||
Args:
|
||||
content: Content of the scan progress message.
|
||||
metadata: Metadata of the message.
|
||||
"""
|
||||
if content["max_value"] == 0:
|
||||
self.progress_bar.set_value(0)
|
||||
return
|
||||
self.progress_bar.set_maximum(content["max_value"])
|
||||
self.progress_bar.set_value(content["value"])
|
||||
|
||||
@Slot()
|
||||
def clear_queue(self) -> None:
|
||||
"""Clear the scan queue"""
|
||||
self.queue.request_queue_reset()
|
||||
|
||||
##############################
|
||||
######## END OF SLOTS ########
|
||||
##############################
|
||||
|
||||
def enable_ui(self, enable: bool) -> None:
|
||||
"""Enable or disable the UI components"""
|
||||
# Enable/disable motor and signal selection
|
||||
self.ui.device_combobox_2.setEnabled(enable)
|
||||
# Enable/disable DAP selection
|
||||
self.ui.dap_combo_box.setEnabled(enable)
|
||||
# Enable/disable Scan Button
|
||||
# self.ui.scan_button.setEnabled(enable)
|
||||
# Disable move to buttons in LMFitDialog
|
||||
self.ui.findChild(LMFitDialog).set_actions_enabled(enable)
|
||||
|
||||
def _customise_buttons(self) -> None:
|
||||
"""Add action buttons for the Action Control.
|
||||
In addition, we are adding a callback to also clear the queue to the stop button
|
||||
to ensure that upon clicking the button, no scans from another client may be queued
|
||||
which would be confusing without the queue widget.
|
||||
"""
|
||||
fit_dialog = self.ui.findChild(LMFitDialog)
|
||||
fit_dialog.active_action_list = ["center", "center1", "center2"]
|
||||
fit_dialog.move_action.connect(self.move_to_center)
|
||||
stop_button = self.ui.findChild(StopButton)
|
||||
stop_button.button.setText("Stop and Clear Queue")
|
||||
stop_button.button.clicked.connect(self.clear_queue)
|
||||
|
||||
def _customise_bec_waveform_widget(self) -> None:
|
||||
"""Customise the BEC Waveform Widget, i.e. clear the toolbar"""
|
||||
self.waveform.toolbar.clear()
|
||||
|
||||
def _setup_motor_indicator(self) -> None:
|
||||
"""Setup the arrow item"""
|
||||
self.waveform.waveform.tick_item.add_to_plot()
|
||||
positioner_box = self.ui.findChild(PositionerGroup)
|
||||
positioner_box.position_update.connect(self.waveform.waveform.tick_item.set_position)
|
||||
self.waveform.waveform.tick_item.set_position(0)
|
||||
|
||||
def _setup_signal_combobox(self) -> None:
|
||||
"""Setup signal selection"""
|
||||
# FIXME after changing the filtering in the combobox
|
||||
signals = [name for name in self.dev if isinstance(self.dev.get(name), BECSignal)]
|
||||
self.ui.device_combobox_2.setCurrentText(signals[0])
|
||||
self.ui.device_combobox_2.set_device_filter("Signal")
|
||||
|
||||
def _setup_progress_bar(self) -> None:
|
||||
"""Setup progress bar"""
|
||||
# FIXME once the BECScanProgressBar is implemented
|
||||
self.progress_bar = self.ui.findChild(BECProgressBar, "bec_progress_bar")
|
||||
self.progress_bar.set_value(0)
|
||||
self.ui.bec_waveform_widget.new_scan.connect(self.reset_progress_bar)
|
||||
self.bec_dispatcher.connect_slot(self.update_progress_bar, MessageEndpoints.scan_progress())
|
||||
|
||||
def close(self):
|
||||
logger.info("Disconnecting", repr(self.bec_dispatcher))
|
||||
self.bec_dispatcher.disconnect_all()
|
||||
logger.info("Shutting down BEC Client", repr(self.client))
|
||||
self.client.shutdown()
|
||||
|
||||
|
||||
def main():
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(MODULE_PATH, "assets", "app_icons", "alignment_1d.png"), size=QSize(48, 48)
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
window = Alignment1D()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,615 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>mainWindow</class>
|
||||
<widget class="QMainWindow" name="mainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1611</width>
|
||||
<height>1019</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Alignment tool</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="widget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_5">
|
||||
<item>
|
||||
<widget class="DarkModeButton" name="dark_mode_button"/>
|
||||
</item>
|
||||
<item>
|
||||
<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>
|
||||
<widget class="BECStatusBox" name="bec_status_box">
|
||||
<property name="compact_view" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="label" stdset="0">
|
||||
<string>BEC Servers</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<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>
|
||||
<widget class="BECQueue" name="bec_queue">
|
||||
<property name="compact_view" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<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>
|
||||
<widget class="QRadioButton" name="radioButton">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>SLS Light On</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoExclusive">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_7">
|
||||
<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>
|
||||
<widget class="QRadioButton" name="radioButton_3">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>BEAMLINE Checks</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoExclusive">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<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>
|
||||
<widget class="StopButton" name="stop_button">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECProgressBar" name="bec_progress_bar">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="ControlTab">
|
||||
<attribute name="title">
|
||||
<string>Alignment Control</string>
|
||||
</attribute>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_6">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_4" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>1</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="ScanControl" name="scan_control">
|
||||
<property name="current_scan" stdset="0">
|
||||
<string>line_scan</string>
|
||||
</property>
|
||||
<property name="hide_arg_box" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="hide_scan_selection_combobox" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="hide_add_remove_buttons" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="PositionerGroup" name="positioner_group"/>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_3" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>4</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_2" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="font">
|
||||
<font/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Monitor</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="DeviceComboBox" name="device_combobox_2"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="font">
|
||||
<font/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>LMFit Model</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="DapComboBox" name="dap_combo_box"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Enable ROI</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ToggleSwitch" name="toggle_switch">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>3</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Activate linear region select for LMFit</string>
|
||||
</property>
|
||||
<property name="layoutDirection">
|
||||
<enum>Qt::LayoutDirection::LeftToRight</enum>
|
||||
</property>
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<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>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECWaveformWidget" name="bec_waveform_widget">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>1</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>600</width>
|
||||
<height>450</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="clear_curves_on_plot_update" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="LMFitDialog" name="lm_fit_dialog">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>190</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="always_show_latest" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="hide_curve_selection" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="hide_summary" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_2">
|
||||
<attribute name="title">
|
||||
<string>Logbook</string>
|
||||
</attribute>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="WebsiteWidget" name="website_widget">
|
||||
<property name="url" stdset="0">
|
||||
<string>https://scilog.psi.ch/login</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>DapComboBox</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>dap_combo_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>StopButton</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>stop_button</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>WebsiteWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>website_widget</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECQueue</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_queue</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ScanControl</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>scan_control</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>toggle_switch</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECProgressBar</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_progress_bar</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>DarkModeButton</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>dark_mode_button</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>PositionerGroup</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>positioner_group</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECWaveformWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_waveform_widget</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>DeviceComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>device_combobox</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>LMFitDialog</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>lm_fit_dialog</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECStatusBox</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_status_box</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>toggle_switch</sender>
|
||||
<signal>enabled(bool)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>toogle_roi_select(bool)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>1042</x>
|
||||
<y>212</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1416</x>
|
||||
<y>322</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>bec_waveform_widget</sender>
|
||||
<signal>dap_summary_update(QVariantMap,QVariantMap)</signal>
|
||||
<receiver>lm_fit_dialog</receiver>
|
||||
<slot>update_summary_tree(QVariantMap,QVariantMap)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>1099</x>
|
||||
<y>258</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1157</x>
|
||||
<y>929</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_combobox_2</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>plot(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>577</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1416</x>
|
||||
<y>427</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_combobox_2</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>dap_combo_box</receiver>
|
||||
<slot>select_y_axis(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>577</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>909</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>dap_combo_box</sender>
|
||||
<signal>new_dap_config(QString,QString,QString)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>add_dap(QString,QString,QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>909</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1416</x>
|
||||
<y>447</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>scan_control</sender>
|
||||
<signal>device_selected(QString)</signal>
|
||||
<receiver>positioner_group</receiver>
|
||||
<slot>set_positioners(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>230</x>
|
||||
<y>306</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>187</x>
|
||||
<y>926</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>scan_control</sender>
|
||||
<signal>device_selected(QString)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>set_x(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>187</x>
|
||||
<y>356</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>972</x>
|
||||
<y>509</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>scan_control</sender>
|
||||
<signal>device_selected(QString)</signal>
|
||||
<receiver>dap_combo_box</receiver>
|
||||
<slot>select_x_axis(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>187</x>
|
||||
<y>356</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>794</x>
|
||||
<y>202</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
@@ -1,84 +0,0 @@
|
||||
"""
|
||||
Launcher for BEC GUI Applications
|
||||
|
||||
Application must be located in bec_widgets/applications ;
|
||||
in order for the launcher to find the application, it has to be put in
|
||||
a subdirectory with the same name as the main Python module:
|
||||
|
||||
/bec_widgets/applications
|
||||
├── alignment
|
||||
│ └── alignment_1d
|
||||
│ └── alignment_1d.py
|
||||
├── other_app
|
||||
└── other_app.py
|
||||
|
||||
The tree above would contain 2 applications, alignment_1d and other_app.
|
||||
|
||||
The Python module for the application must have `if __name__ == "__main__":`
|
||||
in order for the launcher to execute it (it is run with `python -m`).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
MODULE_PATH = os.path.dirname(__file__)
|
||||
|
||||
|
||||
def find_apps(base_dir: str) -> list[str]:
|
||||
matching_modules = []
|
||||
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
parent_dir = os.path.basename(root)
|
||||
|
||||
for file in files:
|
||||
if file.endswith(".py") and file != "__init__.py":
|
||||
file_name_without_ext = os.path.splitext(file)[0]
|
||||
|
||||
if file_name_without_ext == parent_dir:
|
||||
rel_path = os.path.relpath(root, base_dir)
|
||||
module_path = rel_path.replace(os.sep, ".")
|
||||
|
||||
module_name = f"{module_path}.{file_name_without_ext}"
|
||||
matching_modules.append((file_name_without_ext, module_name))
|
||||
|
||||
return matching_modules
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="BEC application launcher")
|
||||
|
||||
parser.add_argument("-m", "--module", type=str, help="The module to run (string argument).")
|
||||
|
||||
# Add a positional argument for the module, which acts as a fallback if -m is not provided
|
||||
parser.add_argument(
|
||||
"positional_module",
|
||||
nargs="?", # This makes the positional argument optional
|
||||
help="Positional argument that is treated as module if -m is not specified.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
# If the -m/--module is not provided, fallback to the positional argument
|
||||
module = args.module if args.module else args.positional_module
|
||||
|
||||
if module:
|
||||
for app_name, app_module in find_apps(MODULE_PATH):
|
||||
if module in (app_name, app_module):
|
||||
print("Starting:", app_name)
|
||||
python_executable = sys.executable
|
||||
|
||||
# Replace the current process with the new Python module
|
||||
os.execvp(
|
||||
python_executable,
|
||||
[python_executable, "-m", f"bec_widgets.applications.{app_module}"],
|
||||
)
|
||||
print(f"Error: cannot find application {module}")
|
||||
|
||||
# display list of apps
|
||||
print("Available applications:")
|
||||
for app, _ in find_apps(MODULE_PATH):
|
||||
print(f" - {app}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,21 +0,0 @@
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
|
||||
def dock_area(object_name: str | None = None) -> BECDockArea:
|
||||
_dock_area = BECDockArea(object_name=object_name)
|
||||
return _dock_area
|
||||
|
||||
|
||||
def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates:
|
||||
"""
|
||||
Create a dock area with auto update enabled.
|
||||
|
||||
Args:
|
||||
object_name(str): The name of the dock area.
|
||||
|
||||
Returns:
|
||||
BECDockArea: The created dock area.
|
||||
"""
|
||||
_auto_update = AutoUpdates(object_name=object_name)
|
||||
return _auto_update
|
||||
@@ -1,381 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtGui import QPainter, QPainterPath, QPixmap
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.utils.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, UILaunchWindow
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class LaunchTile(RoundedFrame):
|
||||
open_signal = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QObject | None = None,
|
||||
icon_path: str | None = None,
|
||||
top_label: str | None = None,
|
||||
main_label: str | None = None,
|
||||
description: str | None = None,
|
||||
show_selector: bool = False,
|
||||
):
|
||||
super().__init__(parent=parent, orientation="vertical")
|
||||
|
||||
self.icon_label = QLabel(parent=self)
|
||||
self.icon_label.setFixedSize(100, 100)
|
||||
self.icon_label.setScaledContents(True)
|
||||
pixmap = QPixmap(icon_path)
|
||||
if not pixmap.isNull():
|
||||
size = 100
|
||||
circular_pixmap = QPixmap(size, size)
|
||||
circular_pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(circular_pixmap)
|
||||
painter.setRenderHints(QPainter.Antialiasing, True)
|
||||
path = QPainterPath()
|
||||
path.addEllipse(0, 0, size, size)
|
||||
painter.setClipPath(path)
|
||||
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
painter.drawPixmap(0, 0, pixmap)
|
||||
painter.end()
|
||||
|
||||
self.icon_label.setPixmap(circular_pixmap)
|
||||
self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter)
|
||||
|
||||
# Top label
|
||||
self.top_label = QLabel(top_label.upper())
|
||||
font_top = self.top_label.font()
|
||||
font_top.setPointSize(10)
|
||||
self.top_label.setFont(font_top)
|
||||
self.layout.addWidget(self.top_label, alignment=Qt.AlignCenter)
|
||||
|
||||
# Main label
|
||||
self.main_label = QLabel(main_label)
|
||||
font_main = self.main_label.font()
|
||||
font_main.setPointSize(14)
|
||||
font_main.setBold(True)
|
||||
self.main_label.setFont(font_main)
|
||||
self.main_label.setWordWrap(True)
|
||||
self.main_label.setAlignment(Qt.AlignCenter)
|
||||
self.layout.addWidget(self.main_label)
|
||||
|
||||
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.layout.addItem(self.spacer_top)
|
||||
|
||||
# Description
|
||||
self.description_label = QLabel(description)
|
||||
self.description_label.setWordWrap(True)
|
||||
self.description_label.setAlignment(Qt.AlignCenter)
|
||||
self.layout.addWidget(self.description_label)
|
||||
|
||||
# Selector
|
||||
if show_selector:
|
||||
self.selector = QComboBox(self)
|
||||
self.layout.addWidget(self.selector)
|
||||
else:
|
||||
self.selector = None
|
||||
|
||||
self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
self.layout.addItem(self.spacer_bottom)
|
||||
|
||||
# Action button
|
||||
self.action_button = QPushButton("Open")
|
||||
self.action_button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #007AFF;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #005BB5;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
|
||||
|
||||
|
||||
class LaunchWindow(BECMainWindow):
|
||||
RPC = True
|
||||
TILE_SIZE = (250, 300)
|
||||
USER_ACCESS = ["show_launcher", "hide_launcher"]
|
||||
|
||||
def __init__(
|
||||
self, parent=None, gui_id: str = None, window_title="BEC Launcher", *args, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
|
||||
|
||||
self.app = QApplication.instance()
|
||||
|
||||
# Toolbar
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
|
||||
self.spacer = QWidget()
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
self.toolbar.addWidget(self.dark_mode_button)
|
||||
|
||||
# Main Widget
|
||||
self.central_widget = QWidget(self)
|
||||
self.central_widget.layout = QHBoxLayout(self.central_widget)
|
||||
self.setCentralWidget(self.central_widget)
|
||||
|
||||
self.tile_dock_area = LaunchTile(
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
|
||||
top_label="Get started",
|
||||
main_label="BEC Dock Area",
|
||||
description="Highly flexible and customizable dock area application with modular widgets.",
|
||||
)
|
||||
self.tile_dock_area.setFixedSize(*self.TILE_SIZE)
|
||||
|
||||
self.tile_auto_update = LaunchTile(
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
|
||||
top_label="Get automated",
|
||||
main_label="BEC Auto Update Dock Area",
|
||||
description="Dock area with auto update functionality for BEC widgets plotting.",
|
||||
show_selector=True,
|
||||
)
|
||||
self.tile_auto_update.setFixedSize(*self.TILE_SIZE)
|
||||
|
||||
self.tile_ui_file = LaunchTile(
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
|
||||
top_label="Get customized",
|
||||
main_label="Launch Custom UI File",
|
||||
description="GUI application with custom UI file.",
|
||||
)
|
||||
self.tile_ui_file.setFixedSize(*self.TILE_SIZE)
|
||||
|
||||
# Add tiles to the main layout
|
||||
self.central_widget.layout.addWidget(self.tile_dock_area)
|
||||
self.central_widget.layout.addWidget(self.tile_auto_update)
|
||||
self.central_widget.layout.addWidget(self.tile_ui_file)
|
||||
|
||||
# hacky solution no time to waste
|
||||
self.tiles = [self.tile_dock_area, self.tile_auto_update, self.tile_ui_file]
|
||||
|
||||
# Connect signals
|
||||
self.tile_dock_area.action_button.clicked.connect(lambda: self.launch("dock_area"))
|
||||
self.tile_auto_update.action_button.clicked.connect(self._open_auto_update)
|
||||
self.tile_ui_file.action_button.clicked.connect(self._open_custom_ui_file)
|
||||
self._update_theme()
|
||||
|
||||
# Auto updates
|
||||
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
|
||||
self._update_available_auto_updates()
|
||||
)
|
||||
if self.tile_auto_update.selector is not None:
|
||||
self.tile_auto_update.selector.addItems(
|
||||
list(self.available_auto_updates.keys()) + ["Default"]
|
||||
)
|
||||
|
||||
def launch(
|
||||
self,
|
||||
launch_script: str,
|
||||
name: str | None = None,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
**kwargs,
|
||||
) -> QWidget:
|
||||
"""Launch the specified script. If the launch script creates a QWidget, it will be
|
||||
embedded in a BECMainWindow. If the launch script creates a BECMainWindow, it will be shown
|
||||
as a separate window.
|
||||
|
||||
Args:
|
||||
launch_script(str): The name of the script to be launched.
|
||||
name(str): The name of the dock area.
|
||||
geometry(tuple): The geometry parameters to be passed to the dock area.
|
||||
Returns:
|
||||
QWidget: The created dock area.
|
||||
"""
|
||||
from bec_widgets.applications import bw_launch
|
||||
|
||||
with RPCRegister.delayed_broadcast() as rpc_register:
|
||||
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
|
||||
if name is not None:
|
||||
if name in existing_dock_areas:
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
|
||||
)
|
||||
else:
|
||||
name = "dock_area"
|
||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
||||
|
||||
if launch_script is None:
|
||||
launch_script = "dock_area"
|
||||
if not isinstance(launch_script, str):
|
||||
raise ValueError(f"Launch script must be a string, but got {type(launch_script)}.")
|
||||
|
||||
if launch_script == "custom_ui_file":
|
||||
ui_file = kwargs.pop("ui_file", None)
|
||||
return self._launch_custom_ui_file(ui_file)
|
||||
|
||||
if launch_script == "auto_update":
|
||||
auto_update = kwargs.pop("auto_update", None)
|
||||
return self._launch_auto_update(auto_update)
|
||||
|
||||
launch = getattr(bw_launch, launch_script, None)
|
||||
if launch is None:
|
||||
raise ValueError(f"Launch script {launch_script} not found.")
|
||||
|
||||
result_widget = launch(name)
|
||||
result_widget.resize(result_widget.minimumSizeHint())
|
||||
# TODO Should we simply use the specified name as title here?
|
||||
result_widget.window().setWindowTitle(f"BEC - {name}")
|
||||
logger.info(f"Created new dock area: {name}")
|
||||
|
||||
if geometry is not None:
|
||||
result_widget.setGeometry(*geometry)
|
||||
if isinstance(result_widget, BECMainWindow):
|
||||
result_widget.show()
|
||||
else:
|
||||
window = BECMainWindow()
|
||||
window.setCentralWidget(result_widget)
|
||||
window.show()
|
||||
return result_widget
|
||||
|
||||
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
|
||||
# Load the custom UI file
|
||||
if ui_file is None:
|
||||
raise ValueError("UI file must be provided for custom UI file launch.")
|
||||
filename = os.path.basename(ui_file).split(".")[0]
|
||||
|
||||
tree = ET.parse(ui_file)
|
||||
root = tree.getroot()
|
||||
# Check if the top-level widget is a QMainWindow
|
||||
widget = root.find("widget")
|
||||
if widget is None:
|
||||
raise ValueError("No widget found in the UI file.")
|
||||
|
||||
if widget.attrib.get("class") == "QMainWindow":
|
||||
raise ValueError(
|
||||
"Loading a QMainWindow from a UI file is currently not supported. "
|
||||
"If you need this, please contact the BEC team or create a ticket on gitlab.psi.ch/bec/bec_widgets."
|
||||
)
|
||||
|
||||
window = UILaunchWindow(object_name=filename)
|
||||
QApplication.processEvents()
|
||||
result_widget = UILoader(window).loader(ui_file)
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {window.object_name}")
|
||||
window.show()
|
||||
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}")
|
||||
return window
|
||||
|
||||
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
|
||||
if auto_update in self.available_auto_updates:
|
||||
auto_update_cls = self.available_auto_updates[auto_update]
|
||||
window = auto_update_cls()
|
||||
else:
|
||||
|
||||
auto_update = "auto_updates"
|
||||
window = AutoUpdates()
|
||||
|
||||
window.resize(window.minimumSizeHint())
|
||||
QApplication.processEvents()
|
||||
window.setWindowTitle(f"BEC - {window.objectName()}")
|
||||
window.show()
|
||||
return window
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Change the theme of the application.
|
||||
"""
|
||||
for tile in self.tiles:
|
||||
tile.apply_theme(theme)
|
||||
|
||||
super().apply_theme(theme)
|
||||
|
||||
def _open_auto_update(self):
|
||||
"""
|
||||
Open the auto update window.
|
||||
"""
|
||||
if self.tile_auto_update.selector is None:
|
||||
auto_update = None
|
||||
else:
|
||||
auto_update = self.tile_auto_update.selector.currentText()
|
||||
if auto_update == "Default":
|
||||
auto_update = None
|
||||
return self.launch("auto_update", auto_update=auto_update)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def _open_custom_ui_file(self):
|
||||
"""
|
||||
Open a file dialog to select a custom UI file and launch it.
|
||||
"""
|
||||
ui_file, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select UI File", "", "UI Files (*.ui);;All Files (*)"
|
||||
)
|
||||
self.launch("custom_ui_file", ui_file=ui_file)
|
||||
|
||||
@staticmethod
|
||||
def _update_available_auto_updates() -> dict[str, type[AutoUpdates]]:
|
||||
"""
|
||||
Load all available auto updates from the plugin repository.
|
||||
"""
|
||||
try:
|
||||
auto_updates = get_plugin_auto_updates()
|
||||
logger.info(f"Available auto updates: {auto_updates.keys()}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to load auto updates: {exc}")
|
||||
return {}
|
||||
return auto_updates
|
||||
|
||||
def show_launcher(self):
|
||||
"""
|
||||
Show the launcher window.
|
||||
"""
|
||||
self.show()
|
||||
|
||||
def hide_launcher(self):
|
||||
"""
|
||||
Hide the launcher window.
|
||||
"""
|
||||
self.hide()
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self.setFixedSize(self.size())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
launcher = LaunchWindow()
|
||||
launcher.show()
|
||||
sys.exit(app.exec())
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 437 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,526 +1,175 @@
|
||||
"""Client utilities for the BEC GUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import importlib
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
|
||||
import uuid
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from functools import wraps
|
||||
|
||||
from qtpy.QtCore import QCoreApplication
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.messages import GUIRegistryStateMessage
|
||||
else:
|
||||
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
IGNORE_WIDGETS = ["LaunchWindow"]
|
||||
|
||||
RegistryState: TypeAlias = dict[
|
||||
Literal["gui_id", "name", "widget_class", "config", "__rpc__"], str | bool | dict
|
||||
]
|
||||
|
||||
# pylint: disable=redefined-outer-scope
|
||||
from bec_lib import MessageEndpoints, messages
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
def _filter_output(output: str) -> str:
|
||||
def rpc_call(func):
|
||||
"""
|
||||
Filter out the output from the process.
|
||||
A decorator for calling a function on the server.
|
||||
|
||||
Args:
|
||||
func: The function to call.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
"""
|
||||
if "IMKClient" in output:
|
||||
# only relevant on macOS
|
||||
# see https://discussions.apple.com/thread/255761734?sortBy=rank
|
||||
return ""
|
||||
return output
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _get_output(process, logger) -> None:
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
|
||||
stream_buffer = {process.stdout: [], process.stderr: []}
|
||||
try:
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
os.set_blocking(process.stderr.fileno(), False)
|
||||
while process.poll() is None:
|
||||
readylist, _, _ = select.select([process.stdout, process.stderr], [], [], 1)
|
||||
for stream in (process.stdout, process.stderr):
|
||||
buf = stream_buffer[stream]
|
||||
if stream in readylist:
|
||||
buf.append(stream.read(4096))
|
||||
output, _, remaining = "".join(buf).rpartition("\n")
|
||||
output = _filter_output(output)
|
||||
if output:
|
||||
log_func[stream](output)
|
||||
buf.clear()
|
||||
buf.append(remaining)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading process output: {str(e)}")
|
||||
|
||||
|
||||
def _start_plot_process(
|
||||
gui_id: str,
|
||||
gui_class_id: str,
|
||||
config: dict | str,
|
||||
gui_class: str = "dock_area",
|
||||
logger=None, # FIXME change gui_class back to "launcher" later
|
||||
) -> tuple[subprocess.Popen[str], threading.Thread | None]:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
|
||||
Logger must be a logger object with "debug" and "error" functions,
|
||||
or it can be left to "None" as default. None means output from the
|
||||
process will not be captured.
|
||||
"""
|
||||
# pylint: disable=subprocess-run-check
|
||||
command = [
|
||||
"bec-gui-server",
|
||||
"--id",
|
||||
gui_id,
|
||||
"--gui_class",
|
||||
gui_class,
|
||||
"--gui_class_id",
|
||||
gui_class_id,
|
||||
"--hide",
|
||||
]
|
||||
if config:
|
||||
if isinstance(config, dict):
|
||||
config = json.dumps(config)
|
||||
command.extend(["--config", str(config)])
|
||||
|
||||
env_dict = os.environ.copy()
|
||||
env_dict["PYTHONUNBUFFERED"] = "1"
|
||||
|
||||
if logger is None:
|
||||
stdout_redirect = subprocess.DEVNULL
|
||||
stderr_redirect = subprocess.DEVNULL
|
||||
else:
|
||||
stdout_redirect = subprocess.PIPE
|
||||
stderr_redirect = subprocess.PIPE
|
||||
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
stdout=stdout_redirect,
|
||||
stderr=stderr_redirect,
|
||||
env=env_dict,
|
||||
)
|
||||
if logger is None:
|
||||
process_output_processing_thread = None
|
||||
else:
|
||||
process_output_processing_thread = threading.Thread(
|
||||
target=_get_output, args=(process, logger)
|
||||
)
|
||||
process_output_processing_thread.start()
|
||||
return process, process_output_processing_thread
|
||||
|
||||
|
||||
class RepeatTimer(threading.Timer):
|
||||
"""RepeatTimer class."""
|
||||
|
||||
def run(self):
|
||||
while not self.finished.wait(self.interval):
|
||||
self.function(*self.args, **self.kwargs)
|
||||
|
||||
|
||||
# pylint: disable=protected-access
|
||||
@contextmanager
|
||||
def wait_for_server(client: BECGuiClient):
|
||||
"""Context manager to wait for the server to start."""
|
||||
timeout = client._startup_timeout
|
||||
if not timeout:
|
||||
if client._gui_is_alive():
|
||||
# there is hope, let's wait a bit
|
||||
timeout = 1
|
||||
else:
|
||||
raise RuntimeError("GUI is not alive")
|
||||
try:
|
||||
if client._gui_started_event.wait(timeout=timeout):
|
||||
client._gui_started_timer.cancel()
|
||||
client._gui_started_timer.join()
|
||||
else:
|
||||
raise TimeoutError("Could not connect to GUI server")
|
||||
finally:
|
||||
# after initial waiting period, do not wait so much any more
|
||||
# (only relevant if GUI didn't start)
|
||||
client._startup_timeout = 0
|
||||
yield
|
||||
|
||||
|
||||
class WidgetNameSpace:
|
||||
def __repr__(self):
|
||||
console = Console()
|
||||
table = Table(title="Available widgets for BEC CLI usage")
|
||||
table.add_column("Widget Name", justify="left", style="magenta")
|
||||
table.add_column("Description", justify="left")
|
||||
for attr, value in self.__dict__.items():
|
||||
docs = value.__doc__
|
||||
docs = docs if docs else "No description available"
|
||||
table.add_row(attr, docs)
|
||||
console.print(table)
|
||||
return ""
|
||||
|
||||
|
||||
class AvailableWidgetsNamespace:
|
||||
"""Namespace for available widgets in the BEC GUI."""
|
||||
|
||||
def __init__(self):
|
||||
for widget in client.Widgets:
|
||||
name = widget.value
|
||||
if name in IGNORE_WIDGETS:
|
||||
continue
|
||||
setattr(self, name, name)
|
||||
|
||||
def __repr__(self):
|
||||
console = Console()
|
||||
table = Table(title="Available widgets for BEC CLI usage")
|
||||
table.add_column("Widget Name", justify="left", style="magenta")
|
||||
table.add_column("Description", justify="left")
|
||||
for attr_name, _ in self.__dict__.items():
|
||||
docs = getattr(client, attr_name).__doc__
|
||||
docs = docs if docs else "No description available"
|
||||
table.add_row(attr_name, docs if len(docs.strip()) > 0 else "No description available")
|
||||
console.print(table)
|
||||
return ""
|
||||
|
||||
|
||||
class BECGuiClient(RPCBase):
|
||||
"""BEC GUI client class. Container for GUI applications within Python."""
|
||||
|
||||
class BECFigureClientMixin:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._lock = Lock()
|
||||
self._anchor_widget = "launcher"
|
||||
self._killed = False
|
||||
self._top_level: dict[str, RPCReference] = {}
|
||||
self._startup_timeout = 0
|
||||
self._gui_started_timer = None
|
||||
self._gui_started_event = threading.Event()
|
||||
self._process = None
|
||||
self._process_output_processing_thread = None
|
||||
self._server_registry: dict[str, RegistryState] = {}
|
||||
self._ipython_registry: dict[str, RPCReference] = {}
|
||||
self.available_widgets = AvailableWidgetsNamespace()
|
||||
|
||||
####################
|
||||
#### Client API ####
|
||||
####################
|
||||
|
||||
@property
|
||||
def launcher(self) -> RPCBase:
|
||||
"""The launcher object."""
|
||||
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
|
||||
|
||||
def connect_to_gui_server(self, gui_id: str) -> None:
|
||||
"""Connect to a GUI server"""
|
||||
# Unregister the old callback
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
)
|
||||
self._gui_id = gui_id
|
||||
|
||||
# reset the namespace
|
||||
self._update_dynamic_namespace({})
|
||||
self._server_registry = {}
|
||||
self._top_level = {}
|
||||
self._ipython_registry = {}
|
||||
|
||||
# Register the new callback
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id),
|
||||
cb=self._handle_registry_update,
|
||||
parent=self,
|
||||
from_start=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def windows(self) -> dict:
|
||||
"""Dictionary with dock areas in the GUI."""
|
||||
return {widget.object_name: widget for widget in self._top_level.values()}
|
||||
|
||||
@property
|
||||
def window_list(self) -> list:
|
||||
"""List with dock areas in the GUI."""
|
||||
return list(self._top_level.values())
|
||||
|
||||
def start(self, wait: bool = False) -> None:
|
||||
"""Start the GUI server."""
|
||||
return self._start(wait=wait)
|
||||
|
||||
def show(self):
|
||||
"""Show the GUI window."""
|
||||
if self._check_if_server_is_alive():
|
||||
return self._show_all()
|
||||
return self.start(wait=True)
|
||||
|
||||
def hide(self):
|
||||
"""Hide the GUI window."""
|
||||
return self._hide_all()
|
||||
|
||||
def new(
|
||||
self,
|
||||
name: str | None = None,
|
||||
wait: bool = True,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
) -> 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 show(self) -> None:
|
||||
"""
|
||||
if not self._check_if_server_is_alive():
|
||||
self.start(wait=True)
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
widget = self.launcher._run_rpc(
|
||||
"launch", "dock_area", name, geometry
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
widget = self.launcher._run_rpc(
|
||||
"new_dock_area", name, geometry
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
"""Delete a dock area.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock area.
|
||||
"""
|
||||
widget = self.windows.get(name)
|
||||
if widget is None:
|
||||
raise ValueError(f"Dock area {name} not found.")
|
||||
widget._run_rpc("close") # pylint: disable=protected-access
|
||||
|
||||
def delete_all(self) -> None:
|
||||
"""Delete all dock areas."""
|
||||
for widget_name in self.windows:
|
||||
self.delete(widget_name)
|
||||
|
||||
def kill_server(self) -> None:
|
||||
"""Kill the GUI server."""
|
||||
# Unregister the registry state
|
||||
self._killed = True
|
||||
|
||||
if self._gui_started_timer is not None:
|
||||
self._gui_started_timer.cancel()
|
||||
self._gui_started_timer.join()
|
||||
|
||||
if self._process is None:
|
||||
return
|
||||
|
||||
if self._process:
|
||||
logger.success("Stopping GUI...")
|
||||
self._process.terminate()
|
||||
if self._process_output_processing_thread:
|
||||
self._process_output_processing_thread.join()
|
||||
self._process.wait()
|
||||
self._process = None
|
||||
|
||||
# Unregister the registry state
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
)
|
||||
# Remove all reference from top level
|
||||
self._top_level.clear()
|
||||
self._server_registry.clear()
|
||||
|
||||
def close(self):
|
||||
"""Deprecated. Use kill_server() instead."""
|
||||
# FIXME, deprecated in favor of kill, will be removed in the future
|
||||
self.kill_server()
|
||||
|
||||
#########################
|
||||
#### Private methods ####
|
||||
#########################
|
||||
|
||||
def _check_if_server_is_alive(self):
|
||||
"""Checks if the process is alive"""
|
||||
if self._process is None:
|
||||
return False
|
||||
if self._process.poll() is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _gui_post_startup(self):
|
||||
timeout = 60
|
||||
# Wait for 'bec' gui to be registered, this may take some time
|
||||
# After 60s timeout. Should this raise an exception on timeout?
|
||||
while time.time() < time.time() + timeout:
|
||||
if len(list(self._server_registry.keys())) < 2 or not hasattr(
|
||||
self, self._anchor_widget
|
||||
):
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
break
|
||||
|
||||
self._gui_started_event.set()
|
||||
|
||||
def _start_server(self, wait: bool = False) -> None:
|
||||
"""
|
||||
Start the GUI server, and execute callback when it is launched
|
||||
Show the figure.
|
||||
"""
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
logger.success("GUI starting...")
|
||||
self._startup_timeout = 5
|
||||
self._gui_started_event.clear()
|
||||
self._process, self._process_output_processing_thread = _start_plot_process(
|
||||
self._gui_id,
|
||||
gui_class_id="bec",
|
||||
config=self._client._service_config.config, # pylint: disable=protected-access
|
||||
logger=logger,
|
||||
)
|
||||
self._start_plot_process()
|
||||
|
||||
def gui_started_callback(callback):
|
||||
try:
|
||||
if callable(callback):
|
||||
callback()
|
||||
finally:
|
||||
threading.current_thread().cancel() # type: ignore
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close the figure.
|
||||
"""
|
||||
if self._process is None:
|
||||
return
|
||||
self._run_rpc("close", (), wait_for_rpc_response=False)
|
||||
self._process.kill()
|
||||
self._process = None
|
||||
|
||||
self._gui_started_timer = RepeatTimer(
|
||||
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup)
|
||||
)
|
||||
self._gui_started_timer.start()
|
||||
def _start_plot_process(self) -> None:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
"""
|
||||
# pylint: disable=subprocess-run-check
|
||||
monitor_module = importlib.import_module("bec_widgets.cli.server")
|
||||
monitor_path = monitor_module.__file__
|
||||
|
||||
if wait:
|
||||
self._gui_started_event.wait()
|
||||
|
||||
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,
|
||||
parent=self,
|
||||
command = f"python {monitor_path} --id {self._gui_id}"
|
||||
self._process = subprocess.Popen(
|
||||
command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
return self._start_server(wait=wait)
|
||||
|
||||
@staticmethod
|
||||
def _handle_registry_update(
|
||||
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
|
||||
) -> None:
|
||||
# This was causing a deadlock during shutdown, not sure why.
|
||||
# with self._lock:
|
||||
self = parent
|
||||
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
|
||||
self._update_dynamic_namespace(self._server_registry)
|
||||
|
||||
def _do_show_all(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.show()
|
||||
|
||||
def _show_all(self):
|
||||
with wait_for_server(self):
|
||||
return self._do_show_all()
|
||||
|
||||
def _hide_all(self):
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("hide") # pylint: disable=protected-access
|
||||
if not self._killed:
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
|
||||
def _update_dynamic_namespace(self, server_registry: dict):
|
||||
def print_log(self) -> None:
|
||||
"""
|
||||
Update the dynamic name space with the given server registry.
|
||||
Setting the server registry to an empty dictionary will remove all widgets from the namespace.
|
||||
Print the log of the plot process.
|
||||
"""
|
||||
if self._process is None:
|
||||
return
|
||||
print(self._get_stderr_output())
|
||||
|
||||
def _get_stderr_output(self) -> str:
|
||||
stderr_output = []
|
||||
while self._process.poll() is not None:
|
||||
readylist, _, _ = select.select([self._process.stderr], [], [], 0.1)
|
||||
if not readylist:
|
||||
break
|
||||
line = self._process.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
stderr_output.append(line.decode("utf-8"))
|
||||
return "".join(stderr_output)
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
class RPCBase:
|
||||
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
|
||||
self._client = BECDispatcher().client
|
||||
self._config = config if config is not None else {}
|
||||
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())
|
||||
self._parent = parent
|
||||
super().__init__()
|
||||
print(f"RPCBase: {self._gui_id}")
|
||||
|
||||
@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, **kwargs):
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
Args:
|
||||
server_registry (dict): The server registry
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
top_level_widgets: dict[str, RPCReference] = {}
|
||||
for gui_id, state in server_registry.items():
|
||||
widget = self._add_widget(state, self)
|
||||
if widget is None:
|
||||
# ignore widgets that are not supported
|
||||
continue
|
||||
# get all top-level widgets. These are widgets that have no parent
|
||||
if not state["config"].get("parent_id"):
|
||||
top_level_widgets[gui_id] = widget
|
||||
request_id = str(uuid.uuid4())
|
||||
rpc_msg = messages.GUIInstructionMessage(
|
||||
action=method,
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
|
||||
metadata={"request_id": request_id},
|
||||
)
|
||||
print(f"RPCBase: {rpc_msg}")
|
||||
# pylint: disable=protected-access
|
||||
receiver = self._root._gui_id
|
||||
self._client.connector.send(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
remove_from_registry = []
|
||||
for gui_id, widget in self._ipython_registry.items():
|
||||
if gui_id not in server_registry:
|
||||
remove_from_registry.append(gui_id)
|
||||
for gui_id in remove_from_registry:
|
||||
self._ipython_registry.pop(gui_id)
|
||||
if not wait_for_rpc_response:
|
||||
return None
|
||||
response = self._wait_for_response(request_id)
|
||||
# get class name
|
||||
if not response.content["accepted"]:
|
||||
raise ValueError(response.content["message"]["error"])
|
||||
msg_result = response.content["message"].get("result")
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
|
||||
removed_widgets = [
|
||||
widget.object_name for widget in self._top_level.values() if widget._is_deleted()
|
||||
]
|
||||
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 msg_result
|
||||
cls = msg_result.pop("widget_class", None)
|
||||
msg_result.pop("__rpc__", None)
|
||||
|
||||
for widget_name in removed_widgets:
|
||||
# the check is not strictly necessary, but better safe
|
||||
# than sorry; who knows what the user has done
|
||||
if hasattr(self, widget_name):
|
||||
delattr(self, widget_name)
|
||||
if not cls:
|
||||
return msg_result
|
||||
|
||||
for gui_id, widget_ref in top_level_widgets.items():
|
||||
setattr(self, widget_ref.object_name, widget_ref)
|
||||
cls = getattr(client, cls)
|
||||
print(msg_result)
|
||||
return cls(parent=self, **msg_result)
|
||||
return msg_result
|
||||
|
||||
self._top_level = top_level_widgets
|
||||
|
||||
for widget in self._ipython_registry.values():
|
||||
widget._refresh_references()
|
||||
|
||||
def _add_widget(self, state: dict, parent: object) -> RPCReference | None:
|
||||
"""Add a widget to the namespace
|
||||
|
||||
Args:
|
||||
state (dict): The state of the widget from the _server_registry.
|
||||
parent (object): The parent object.
|
||||
def _wait_for_response(self, request_id):
|
||||
"""
|
||||
object_name = state["object_name"]
|
||||
gui_id = state["gui_id"]
|
||||
if state["widget_class"] in IGNORE_WIDGETS:
|
||||
return
|
||||
widget_class = getattr(client, state["widget_class"], None)
|
||||
if widget_class is None:
|
||||
return
|
||||
obj = self._ipython_registry.get(gui_id)
|
||||
if obj is None:
|
||||
widget = widget_class(gui_id=gui_id, object_name=object_name, parent=parent)
|
||||
self._ipython_registry[gui_id] = widget
|
||||
else:
|
||||
widget = obj
|
||||
obj = RPCReference(registry=self._ipython_registry, gui_id=gui_id)
|
||||
return obj
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
|
||||
try:
|
||||
config = ServiceConfig()
|
||||
bec_client = BECClient(config)
|
||||
bec_client.start()
|
||||
|
||||
# Test the client_utils.py module
|
||||
gui = BECGuiClient()
|
||||
|
||||
gui.start(wait=True)
|
||||
gui.new().new(widget="Waveform")
|
||||
time.sleep(10)
|
||||
finally:
|
||||
gui.kill_server()
|
||||
Wait for the response from the server.
|
||||
"""
|
||||
response = None
|
||||
while response is None:
|
||||
response = self._client.connector.get(
|
||||
MessageEndpoints.gui_instruction_response(request_id)
|
||||
)
|
||||
QCoreApplication.processEvents() # keep UI responsive (and execute signals/slots)
|
||||
return response
|
||||
|
||||
@@ -1,198 +1,59 @@
|
||||
# pylint: disable=missing-module-docstring
|
||||
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.plugin_utils import BECClassContainer, get_custom_classes
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import get_overloads
|
||||
else:
|
||||
print(
|
||||
"Python version is less than 3.11, using dummy function for get_overloads. "
|
||||
"If you want to use the real function 'typing.get_overloads()', please use Python 3.11 or later."
|
||||
)
|
||||
|
||||
def get_overloads(_obj):
|
||||
"""
|
||||
Dummy function for Python versions before 3.11.
|
||||
"""
|
||||
return []
|
||||
import typing
|
||||
|
||||
|
||||
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
|
||||
# type: ignore \n
|
||||
from __future__ import annotations
|
||||
{base_imports}
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
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
|
||||
|
||||
# pylint: skip-file"""
|
||||
def __init__(self):
|
||||
self.header = """# This file was automatically generated by generate_cli.py\n
|
||||
from bec_widgets.cli.client_utils import rpc_call, RPCBase, BECFigureClientMixin
|
||||
from typing import Literal, Optional, overload"""
|
||||
|
||||
self.content = ""
|
||||
|
||||
def generate_client(self, class_container: BECClassContainer):
|
||||
def generate_client(self, published_classes: list):
|
||||
"""
|
||||
Generate the client for the published classes, skipping any classes
|
||||
that have `RPC = False`.
|
||||
Generate the client for the published classes.
|
||||
|
||||
Args:
|
||||
class_container: The class container with the classes to generate the client for.
|
||||
published_classes(list): The list of published classes (e.g. [BECWaveform1D, BECFigure]).
|
||||
"""
|
||||
# Filter out classes that explicitly have RPC=False
|
||||
rpc_top_level_classes = [
|
||||
cls for cls in class_container.rpc_top_level_classes if getattr(cls, "RPC", True)
|
||||
]
|
||||
rpc_top_level_classes.sort(key=lambda x: x.__name__)
|
||||
|
||||
connector_classes = [
|
||||
cls for cls in class_container.connector_classes if getattr(cls, "RPC", True)
|
||||
]
|
||||
connector_classes.sort(key=lambda x: x.__name__)
|
||||
|
||||
self.write_client_enum(rpc_top_level_classes)
|
||||
for cls in connector_classes:
|
||||
logger.debug(f"generating RPC client class for {cls.__name__}")
|
||||
for cls in published_classes:
|
||||
self.content += "\n\n"
|
||||
self.generate_content_for_class(cls)
|
||||
|
||||
def write_client_enum(self, published_classes: list[type]):
|
||||
"""
|
||||
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 = {
|
||||
"""
|
||||
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
|
||||
|
||||
"""
|
||||
|
||||
def generate_content_for_class(self, cls):
|
||||
"""
|
||||
Generate the content for the class.
|
||||
|
||||
Args:
|
||||
cls: The class for which to generate the content.
|
||||
"""
|
||||
|
||||
class_name = cls.__name__
|
||||
module = cls.__module__
|
||||
|
||||
if class_name == "BECDockArea":
|
||||
# Generate the header
|
||||
# self.header += f"""
|
||||
# from {module} import {class_name}"""
|
||||
|
||||
# Generate the content
|
||||
if cls.__name__ == "BECFigure":
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
class {class_name}(RPCBase, BECFigureClientMixin):"""
|
||||
else:
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
|
||||
if cls.__doc__:
|
||||
# We only want the first line of the docstring
|
||||
# But skip the first line if it's a blank line
|
||||
first_line = cls.__doc__.split("\n")[0]
|
||||
if first_line:
|
||||
class_docs = first_line
|
||||
else:
|
||||
class_docs = cls.__doc__.split("\n")[1]
|
||||
self.content += f"""
|
||||
\"\"\"{class_docs}\"\"\"
|
||||
"""
|
||||
if not cls.USER_ACCESS:
|
||||
self.content += """...
|
||||
"""
|
||||
|
||||
for method in cls.USER_ACCESS:
|
||||
is_property_setter = False
|
||||
obj = getattr(cls, method, None)
|
||||
if obj is None:
|
||||
obj = getattr(cls, method.split(".setter")[0], None)
|
||||
is_property_setter = True
|
||||
method = method.split(".setter")[0]
|
||||
if obj is None:
|
||||
raise AttributeError(
|
||||
f"Method {method} not found in class {cls.__name__}. "
|
||||
f"Please check the USER_ACCESS list."
|
||||
)
|
||||
|
||||
if isinstance(obj, (property, QtProperty)):
|
||||
# for the cli, we can map qt properties to regular properties
|
||||
if is_property_setter:
|
||||
self.content += f"""
|
||||
@{method}.setter
|
||||
@rpc_call"""
|
||||
else:
|
||||
self.content += """
|
||||
obj = getattr(cls, method)
|
||||
if isinstance(obj, property):
|
||||
self.content += """
|
||||
@property
|
||||
@rpc_call"""
|
||||
|
||||
sig = str(inspect.signature(obj.fget))
|
||||
doc = inspect.getdoc(obj.fget)
|
||||
else:
|
||||
sig = str(inspect.signature(obj))
|
||||
doc = inspect.getdoc(obj)
|
||||
overloads = get_overloads(obj)
|
||||
overloads = typing.get_overloads(obj)
|
||||
for overload in overloads:
|
||||
sig_overload = str(inspect.signature(overload))
|
||||
self.content += f"""
|
||||
@@ -210,106 +71,26 @@ class {class_name}(RPCBase):"""
|
||||
|
||||
def write(self, file_name: str):
|
||||
"""
|
||||
Write the content to a file, automatically formatted with black.
|
||||
Write the content to a file.
|
||||
|
||||
Args:
|
||||
file_name(str): The name of the file to write to.
|
||||
"""
|
||||
# Combine header and content, then format with black
|
||||
full_content = self.header + "\n" + self.content
|
||||
try:
|
||||
formatted_content = black.format_str(full_content, mode=black.FileMode(line_length=100))
|
||||
except black.NothingChanged:
|
||||
formatted_content = full_content
|
||||
|
||||
isort.Config(
|
||||
profile="black",
|
||||
line_length=100,
|
||||
multi_line_output=3,
|
||||
include_trailing_comma=True,
|
||||
known_first_party=["bec_widgets"],
|
||||
)
|
||||
formatted_content = isort.code(formatted_content)
|
||||
|
||||
with open(file_name, "w", encoding="utf-8") as file:
|
||||
file.write(formatted_content)
|
||||
file.write(self.header)
|
||||
file.write(self.content)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main entry point for the script, controlled by command line arguments.
|
||||
"""
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
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)",
|
||||
)
|
||||
from bec_widgets.widgets.figure import BECFigure
|
||||
from bec_widgets.widgets.plots import BECPlotBase, BECWaveform1D # ,BECCurve
|
||||
from bec_widgets.widgets.plots.waveform1d import BECCurve
|
||||
|
||||
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}")
|
||||
|
||||
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"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
assert module.__file__ is not None
|
||||
module_file = Path(module.__file__)
|
||||
module_dir = module_file.parent if module_file.is_file() else module_file
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load module {module_name} for code generation: {e}")
|
||||
return
|
||||
|
||||
client_path = module_dir / client_subdir / "client.py"
|
||||
|
||||
rpc_classes = get_custom_classes(module_name)
|
||||
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
|
||||
|
||||
generator = ClientGenerator(base=module_name == "bec_widgets")
|
||||
logger.info(f"Generating client file at {client_path}")
|
||||
generator.generate_client(rpc_classes)
|
||||
generator.write(str(client_path))
|
||||
|
||||
if module_name != "bec_widgets":
|
||||
non_overwrite_classes = list(clsinfo.name for clsinfo in get_custom_classes("bec_widgets"))
|
||||
logger.info(
|
||||
f"Not writing plugins which would conflict with builtin classes: {non_overwrite_classes}"
|
||||
)
|
||||
else:
|
||||
non_overwrite_classes = []
|
||||
|
||||
for cls in rpc_classes.plugins:
|
||||
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
|
||||
|
||||
if cls.__name__ in non_overwrite_classes:
|
||||
logger.error(
|
||||
f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists"
|
||||
)
|
||||
|
||||
plugin = DesignerPluginGenerator(cls)
|
||||
if not hasattr(plugin, "info"):
|
||||
continue
|
||||
|
||||
def _exists(file: str):
|
||||
return os.path.exists(os.path.join(plugin.info.base_path, file))
|
||||
|
||||
if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)):
|
||||
logger.debug(
|
||||
f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists."
|
||||
)
|
||||
continue
|
||||
|
||||
plugin.run()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
current_path = os.path.dirname(__file__)
|
||||
client_path = os.path.join(current_path, "client.py")
|
||||
clss = [BECPlotBase, BECWaveform1D, BECFigure, BECCurve]
|
||||
generator = ClientGenerator()
|
||||
generator.generate_client(clss)
|
||||
generator.write(client_path)
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import threading
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib import messages
|
||||
from bec_lib.connector import MessageObject
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.client_utils import BECGuiClient
|
||||
else:
|
||||
client = lazy_import("bec_widgets.cli.client") # avoid circular import
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
|
||||
def rpc_call(func):
|
||||
"""
|
||||
A decorator for calling a function on the server.
|
||||
|
||||
Args:
|
||||
func: The function to call.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# we could rely on a strict type check here, but this is more flexible
|
||||
# moreover, it would anyway crash for objects...
|
||||
caller_frame = inspect.currentframe().f_back # type: ignore
|
||||
while caller_frame:
|
||||
if "jedi" in caller_frame.f_globals:
|
||||
# Jedi module is present, likely tab completion
|
||||
# Do not run the RPC call
|
||||
return None # func(*args, **kwargs)
|
||||
caller_frame = caller_frame.f_back
|
||||
|
||||
out = []
|
||||
for arg in args:
|
||||
if hasattr(arg, "name"):
|
||||
arg = arg.name
|
||||
out.append(arg)
|
||||
args = tuple(out)
|
||||
for key, val in kwargs.items():
|
||||
if hasattr(val, "name"):
|
||||
kwargs[key] = val.name
|
||||
if not self._root._gui_is_alive():
|
||||
raise RuntimeError("GUI is not alive")
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RPCResponseTimeoutError(Exception):
|
||||
"""Exception raised when an RPC response is not received within the expected time."""
|
||||
|
||||
def __init__(self, request_id, timeout):
|
||||
super().__init__(
|
||||
f"RPC response not received within {timeout} seconds for request ID {request_id}"
|
||||
)
|
||||
|
||||
|
||||
class DeletedWidgetError(Exception): ...
|
||||
|
||||
|
||||
def check_for_deleted_widget(func):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
if self._gui_id not in self._registry:
|
||||
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RPCReference:
|
||||
def __init__(self, registry: dict, gui_id: str) -> None:
|
||||
self._registry = registry
|
||||
self._gui_id = gui_id
|
||||
self.object_name = self._registry[self._gui_id].object_name
|
||||
|
||||
@check_for_deleted_widget
|
||||
def __getattr__(self, name):
|
||||
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
|
||||
return super().__getattribute__(name)
|
||||
return self._registry[self._gui_id].__getattribute__(name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
|
||||
return super().__setattr__(name, value)
|
||||
if self._gui_id not in self._registry:
|
||||
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
|
||||
self._registry[self._gui_id].__setattr__(name, value)
|
||||
|
||||
def __repr__(self):
|
||||
if self._gui_id not in self._registry:
|
||||
return f"<Deleted widget with gui_id {self._gui_id}>"
|
||||
return self._registry[self._gui_id].__repr__()
|
||||
|
||||
def __str__(self):
|
||||
if self._gui_id not in self._registry:
|
||||
return f"<Deleted widget with gui_id {self._gui_id}>"
|
||||
return self._registry[self._gui_id].__str__()
|
||||
|
||||
def __dir__(self):
|
||||
if self._gui_id not in self._registry:
|
||||
return []
|
||||
return self._registry[self._gui_id].__dir__()
|
||||
|
||||
def _is_deleted(self) -> bool:
|
||||
return self._gui_id not in self._registry
|
||||
|
||||
|
||||
class RPCBase:
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str | None = None,
|
||||
config: dict | None = None,
|
||||
object_name: str | None = None,
|
||||
parent=None,
|
||||
) -> None:
|
||||
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
|
||||
self._config = config if config is not None else {}
|
||||
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
|
||||
self.object_name = object_name if object_name is not None else str(uuid.uuid4())[:5]
|
||||
self._parent = parent
|
||||
self._msg_wait_event = threading.Event()
|
||||
self._rpc_response = None
|
||||
super().__init__()
|
||||
self._rpc_references: dict[str, str] = {}
|
||||
|
||||
def __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} with name: {self.widget_name}>"
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the widget.
|
||||
"""
|
||||
self._run_rpc("remove")
|
||||
|
||||
@property
|
||||
def widget_name(self):
|
||||
"""
|
||||
Get the widget name.
|
||||
"""
|
||||
return self.object_name
|
||||
|
||||
@property
|
||||
def _root(self) -> BECGuiClient:
|
||||
"""
|
||||
Get the root widget. This is the BECFigure widget that holds
|
||||
the anchor gui_id.
|
||||
"""
|
||||
parent = self
|
||||
# pylint: disable=protected-access
|
||||
while parent._parent is not None:
|
||||
parent = parent._parent
|
||||
return parent # type: ignore
|
||||
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=5, **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
|
||||
)
|
||||
|
||||
# we can assume that the response is a RequestResponseMessage, updated by
|
||||
# the _on_rpc_response method
|
||||
assert isinstance(self._rpc_response, messages.RequestResponseMessage)
|
||||
|
||||
if not self._rpc_response.accepted:
|
||||
raise ValueError(self._rpc_response.message["error"])
|
||||
msg_result = self._rpc_response.message.get("result")
|
||||
self._rpc_response = None
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
|
||||
@staticmethod
|
||||
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
|
||||
msg = cast(messages.RequestResponseMessage, msg_obj.value)
|
||||
parent._rpc_response = msg
|
||||
parent._msg_wait_event.set()
|
||||
|
||||
def _create_widget_from_msg_result(self, msg_result):
|
||||
if msg_result is None:
|
||||
return None
|
||||
if isinstance(msg_result, list):
|
||||
return [self._create_widget_from_msg_result(res) for res in msg_result]
|
||||
if isinstance(msg_result, dict):
|
||||
if "__rpc__" not in msg_result:
|
||||
return {
|
||||
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
|
||||
}
|
||||
cls = msg_result.pop("widget_class", None)
|
||||
msg_result.pop("__rpc__", None)
|
||||
|
||||
if not cls:
|
||||
return msg_result
|
||||
|
||||
cls = getattr(client, cls)
|
||||
# The namespace of the object will be updated dynamically on the client side
|
||||
# Therefore it is important to check if the object is already in the registry
|
||||
# If yes, we return the reference to the object, otherwise we create a new object
|
||||
# pylint: disable=protected-access
|
||||
if msg_result["gui_id"] in self._root._ipython_registry:
|
||||
return RPCReference(self._root._ipython_registry, msg_result["gui_id"])
|
||||
ret = cls(parent=self, **msg_result)
|
||||
self._root._ipython_registry[ret._gui_id] = ret
|
||||
self._refresh_references()
|
||||
obj = RPCReference(self._root._ipython_registry, ret._gui_id)
|
||||
return obj
|
||||
# return ret
|
||||
return msg_result
|
||||
|
||||
def _gui_is_alive(self):
|
||||
"""
|
||||
Check if the GUI is alive.
|
||||
"""
|
||||
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
|
||||
if heart is None:
|
||||
return False
|
||||
if heart.status == messages.BECStatus.RUNNING:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _refresh_references(self):
|
||||
"""
|
||||
Refresh the references.
|
||||
"""
|
||||
with self._root._lock:
|
||||
references = {}
|
||||
for key, val in self._root._server_registry.items():
|
||||
parent_id = val["config"].get("parent_id")
|
||||
if parent_id == self._gui_id:
|
||||
references[key] = {
|
||||
"gui_id": val["config"]["gui_id"],
|
||||
"object_name": val["object_name"],
|
||||
}
|
||||
removed_references = set(self._rpc_references.keys()) - set(references.keys())
|
||||
for key in removed_references:
|
||||
delattr(self, self._rpc_references[key]["object_name"])
|
||||
self._rpc_references = references
|
||||
for key, val in references.items():
|
||||
setattr(
|
||||
self,
|
||||
val["object_name"],
|
||||
RPCReference(self._root._ipython_registry, val["gui_id"]),
|
||||
)
|
||||
@@ -1,189 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from threading import RLock
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def broadcast_update(func):
|
||||
"""
|
||||
Decorator to broadcast updates to the RPCRegister whenever a new RPC object is added or removed.
|
||||
If class attribute _skip_broadcast is set to True, the broadcast will be skipped
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
result = func(self, *args, **kwargs)
|
||||
self.broadcast()
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RPCRegister:
|
||||
"""
|
||||
A singleton class that keeps track of all the RPC objects registered in the system for CLI usage.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(RPCRegister, cls).__new__(cls)
|
||||
cls._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self._rpc_register = WeakValueDictionary()
|
||||
self._broadcast_on_hold = RPCRegisterBroadcast(self)
|
||||
self._lock = RLock()
|
||||
self._skip_broadcast = False
|
||||
self._initialized = True
|
||||
self.callbacks = []
|
||||
|
||||
@classmethod
|
||||
def delayed_broadcast(cls):
|
||||
"""
|
||||
Delay the broadcast of the update to all the callbacks.
|
||||
"""
|
||||
register = cls()
|
||||
return register._broadcast_on_hold
|
||||
|
||||
@broadcast_update
|
||||
def add_rpc(self, rpc: BECConnector):
|
||||
"""
|
||||
Add an RPC object to the register.
|
||||
|
||||
Args:
|
||||
rpc(QObject): The RPC object to be added to the register.
|
||||
"""
|
||||
if not hasattr(rpc, "gui_id"):
|
||||
raise ValueError("RPC object must have a 'gui_id' attribute.")
|
||||
self._rpc_register[rpc.gui_id] = rpc
|
||||
|
||||
@broadcast_update
|
||||
def remove_rpc(self, rpc: BECConnector):
|
||||
"""
|
||||
Remove an RPC object from the register.
|
||||
|
||||
Args:
|
||||
rpc(str): The RPC object to be removed from the register.
|
||||
"""
|
||||
if not hasattr(rpc, "gui_id"):
|
||||
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
|
||||
self._rpc_register.pop(rpc.gui_id, None)
|
||||
|
||||
def get_rpc_by_id(self, gui_id: str) -> QObject | None:
|
||||
"""
|
||||
Get an RPC object by its ID.
|
||||
|
||||
Args:
|
||||
gui_id(str): The ID of the RPC object to be retrieved.
|
||||
|
||||
Returns:
|
||||
QObject | None: The RPC object with the given ID or None
|
||||
"""
|
||||
rpc_object = self._rpc_register.get(gui_id, None)
|
||||
return rpc_object
|
||||
|
||||
def list_all_connections(self) -> dict:
|
||||
"""
|
||||
List all the registered RPC objects.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing all the registered RPC objects.
|
||||
"""
|
||||
with self._lock:
|
||||
connections = dict(self._rpc_register)
|
||||
return connections
|
||||
|
||||
def get_names_of_rpc_by_class_type(
|
||||
self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea]
|
||||
) -> list[str]:
|
||||
"""Get all the names of the widgets.
|
||||
|
||||
Args:
|
||||
cls(BECWidget | BECConnector): The class of the RPC object to be retrieved.
|
||||
"""
|
||||
# This retrieves any rpc objects that are subclass of BECWidget,
|
||||
# i.e. curve and image items are excluded
|
||||
widgets = [rpc for rpc in self._rpc_register.values() if isinstance(rpc, cls)]
|
||||
return [widget.object_name for widget in widgets]
|
||||
|
||||
def broadcast(self):
|
||||
"""
|
||||
Broadcast the update to all the callbacks.
|
||||
"""
|
||||
|
||||
if self._skip_broadcast:
|
||||
return
|
||||
connections = self.list_all_connections()
|
||||
for callback in self.callbacks:
|
||||
callback(connections)
|
||||
|
||||
def object_is_registered(self, obj: BECConnector) -> bool:
|
||||
"""
|
||||
Check if an object is registered in the RPC register.
|
||||
|
||||
Args:
|
||||
obj(QObject): The object to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the object is registered, False otherwise.
|
||||
"""
|
||||
return obj.gui_id in self._rpc_register
|
||||
|
||||
def add_callback(self, callback: Callable[[dict], None]):
|
||||
"""
|
||||
Add a callback that will be called whenever the registry is updated.
|
||||
|
||||
Args:
|
||||
callback(Callable[[dict], None]): The callback to be added. It should accept a dictionary of all the
|
||||
registered RPC objects as an argument.
|
||||
"""
|
||||
self.callbacks.append(callback)
|
||||
|
||||
@classmethod
|
||||
def reset_singleton(cls):
|
||||
"""
|
||||
Reset the singleton instance.
|
||||
"""
|
||||
cls._instance = None
|
||||
cls._initialized = False
|
||||
|
||||
|
||||
class RPCRegisterBroadcast:
|
||||
"""Context manager for RPCRegister broadcast."""
|
||||
|
||||
def __init__(self, rpc_register: RPCRegister) -> None:
|
||||
self.rpc_register = rpc_register
|
||||
self._call_depth = 0
|
||||
|
||||
def __enter__(self):
|
||||
"""Enter the context manager"""
|
||||
self._call_depth += 1 # Needed for nested calls
|
||||
self.rpc_register._skip_broadcast = True
|
||||
return self.rpc_register
|
||||
|
||||
def __exit__(self, *exc):
|
||||
"""Exit the context manager"""
|
||||
|
||||
self._call_depth -= 1 # Remove nested calls
|
||||
if self._call_depth == 0: # The Last one to exit is responsible for broadcasting
|
||||
self.rpc_register._skip_broadcast = False
|
||||
self.rpc_register.broadcast()
|
||||
@@ -1,57 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
|
||||
class RPCWidgetHandler:
|
||||
"""Handler class for creating widgets from RPC messages."""
|
||||
|
||||
def __init__(self):
|
||||
self._widget_classes = None
|
||||
|
||||
@property
|
||||
def widget_classes(self) -> dict[str, type[BECWidget]]:
|
||||
"""
|
||||
Get the available widget classes.
|
||||
|
||||
Returns:
|
||||
dict: The available widget classes.
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
return self._widget_classes # type: ignore
|
||||
|
||||
def update_available_widgets(self):
|
||||
"""
|
||||
Update the available widgets.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
clss = get_custom_classes("bec_widgets")
|
||||
self._widget_classes = get_all_plugin_widgets() | {
|
||||
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
|
||||
}
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECWidget:
|
||||
"""
|
||||
Create a widget from an RPC message.
|
||||
|
||||
Args:
|
||||
widget_type(str): The type of the widget.
|
||||
name (str): The name of the widget.
|
||||
**kwargs: The keyword arguments for the widget.
|
||||
|
||||
Returns:
|
||||
widget(BECWidget): The created widget.
|
||||
"""
|
||||
widget_class = self.widget_classes.get(widget_type) # type: ignore
|
||||
if widget_class:
|
||||
return widget_class(**kwargs)
|
||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||
|
||||
|
||||
widget_handler = RPCWidgetHandler()
|
||||
@@ -1,205 +1,110 @@
|
||||
from __future__ import annotations
|
||||
import inspect
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import cast
|
||||
from bec_lib import MessageEndpoints, messages
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.applications.launch_window import LaunchWindow
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.widgets.figure import BECFigure
|
||||
from bec_widgets.widgets.plots import BECCurve, BECWaveform1D
|
||||
|
||||
|
||||
class SimpleFileLikeFromLogOutputFunc:
|
||||
def __init__(self, log_func):
|
||||
self._log_func = log_func
|
||||
self._buffer = []
|
||||
class BECWidgetsCLIServer:
|
||||
WIDGETS = [BECWaveform1D, BECFigure, BECCurve]
|
||||
|
||||
def write(self, buffer):
|
||||
self._buffer.append(buffer)
|
||||
def __init__(self, gui_id: str = None, dispatcher: BECDispatcher = None) -> None:
|
||||
self.dispatcher = BECDispatcher() if dispatcher is None else dispatcher
|
||||
self.client = self.dispatcher.client
|
||||
self.client.start()
|
||||
self.gui_id = gui_id
|
||||
self.fig = BECFigure(gui_id=self.gui_id)
|
||||
print(f"Server started with gui_id {self.gui_id}")
|
||||
|
||||
def flush(self):
|
||||
lines, _, remaining = "".join(self._buffer).rpartition("\n")
|
||||
if lines:
|
||||
self._log_func(lines)
|
||||
self._buffer = [remaining]
|
||||
|
||||
def close(self):
|
||||
return
|
||||
|
||||
|
||||
class GUIServer:
|
||||
"""
|
||||
This class is used to start the BEC GUI and is the main entry point for launching BEC Widgets in a subprocess.
|
||||
"""
|
||||
|
||||
def __init__(self, args):
|
||||
self.config = args.config
|
||||
self.gui_id = args.id
|
||||
self.gui_class = args.gui_class
|
||||
self.gui_class_id = args.gui_class_id
|
||||
self.hide = args.hide
|
||||
self.app: QApplication | None = None
|
||||
self.launcher_window: LaunchWindow | None = None
|
||||
self.dispatcher: BECDispatcher | None = None
|
||||
self.dispatcher.connect_slot(
|
||||
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
|
||||
)
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the GUI server.
|
||||
"""
|
||||
bec_logger.level = bec_logger.LOGLEVEL.INFO
|
||||
if self.hide:
|
||||
# pylint: disable=protected-access
|
||||
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
|
||||
bec_logger._update_sinks()
|
||||
"""Start the figure window."""
|
||||
self.fig.start()
|
||||
|
||||
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)): # type: ignore
|
||||
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)): # type: ignore
|
||||
self._run()
|
||||
|
||||
def _get_service_config(self) -> ServiceConfig:
|
||||
if self.config:
|
||||
try:
|
||||
config = json.loads(self.config)
|
||||
service_config = ServiceConfig(config=config)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
service_config = ServiceConfig(config_path=config)
|
||||
def on_rpc_update(self, msg: dict, metadata: dict):
|
||||
request_id = metadata.get("request_id")
|
||||
try:
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
self.send_response(request_id, False, {"error": str(e)})
|
||||
else:
|
||||
# if no config is provided, use the default config
|
||||
service_config = ServiceConfig()
|
||||
return service_config
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
|
||||
def _turn_off_the_lights(self, connections: dict):
|
||||
"""
|
||||
If there is only one connection remaining, it is the launcher, so we show it.
|
||||
Once the launcher is closed as the last window, we quit the application.
|
||||
"""
|
||||
self.launcher_window = cast(LaunchWindow, self.launcher_window)
|
||||
|
||||
remaining_connections = [
|
||||
connection
|
||||
for connection in connections.values()
|
||||
if connection.parent_id != self.launcher_window.gui_id
|
||||
]
|
||||
if len(remaining_connections) <= 1:
|
||||
self.launcher_window.show()
|
||||
self.launcher_window.activateWindow()
|
||||
self.launcher_window.raise_()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(True)
|
||||
else:
|
||||
self.launcher_window.hide()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
def _run(self):
|
||||
"""
|
||||
Run the GUI server.
|
||||
"""
|
||||
self.app = QApplication(sys.argv)
|
||||
self.app.setApplicationName("BEC")
|
||||
self.app.gui_id = self.gui_id # type: ignore
|
||||
self.setup_bec_icon()
|
||||
|
||||
service_config = self._get_service_config()
|
||||
self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id)
|
||||
# self.dispatcher.start_cli_server(gui_id=self.gui_id)
|
||||
|
||||
self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher")
|
||||
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
|
||||
|
||||
self.app.aboutToQuit.connect(self.shutdown)
|
||||
self.app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
register = RPCRegister()
|
||||
register.callbacks.append(self._turn_off_the_lights)
|
||||
register.broadcast()
|
||||
|
||||
if self.gui_class:
|
||||
# If the server is started with a specific gui class, we launch it.
|
||||
# This will automatically hide the launcher.
|
||||
self.launcher_window.launch(self.gui_class, name=self.gui_class_id)
|
||||
|
||||
def sigint_handler(*args):
|
||||
# display message, for people to let it terminate gracefully
|
||||
print("Caught SIGINT, exiting")
|
||||
# Widgets should be all closed.
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
|
||||
widget.close()
|
||||
if self.app:
|
||||
self.app.quit()
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
signal.signal(signal.SIGTERM, sigint_handler)
|
||||
|
||||
sys.exit(self.app.exec())
|
||||
|
||||
def setup_bec_icon(self):
|
||||
"""
|
||||
Set the BEC icon for the application
|
||||
"""
|
||||
if self.app is None:
|
||||
return
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
|
||||
size=QSize(48, 48),
|
||||
def send_response(self, request_id: str, accepted: bool, msg: dict):
|
||||
self.client.connector.set(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
messages.RequestResponseMessage(accepted=accepted, message=msg),
|
||||
expire=60,
|
||||
)
|
||||
self.app.setWindowIcon(icon)
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Shutdown the GUI server.
|
||||
"""
|
||||
if self.dispatcher:
|
||||
self.dispatcher.stop_cli_server()
|
||||
self.dispatcher.disconnect_all()
|
||||
def get_object_from_config(self, config: dict):
|
||||
gui_id = config.get("gui_id")
|
||||
# check if the object is the figure
|
||||
if gui_id == self.fig.gui_id:
|
||||
return self.fig
|
||||
# check if the object is a widget
|
||||
if gui_id in self.fig.widgets:
|
||||
obj = self.fig.widgets[config["gui_id"]]
|
||||
return obj
|
||||
if self.fig.widgets:
|
||||
for widget in self.fig.widgets.values():
|
||||
item = widget.find_widget_by_id(gui_id)
|
||||
if item:
|
||||
return item
|
||||
raise NotImplementedError(
|
||||
f"gui_id lookup for widget of type {widget.__class__.__name__} not implemented"
|
||||
)
|
||||
|
||||
raise ValueError(f"Object with gui_id {gui_id} not found")
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main entry point for subprocesses that start a GUI server.
|
||||
"""
|
||||
def run_rpc(self, obj, method, args, kwargs):
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
res = method_obj
|
||||
else:
|
||||
sig = inspect.signature(method_obj)
|
||||
if sig.parameters:
|
||||
res = method_obj(*args, **kwargs)
|
||||
else:
|
||||
res = method_obj()
|
||||
|
||||
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(
|
||||
"--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")
|
||||
if isinstance(res, list):
|
||||
res = [self.serialize_object(obj) for obj in res]
|
||||
else:
|
||||
res = self.serialize_object(res)
|
||||
return res
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
server = GUIServer(args)
|
||||
server.start()
|
||||
def serialize_object(self, obj):
|
||||
if isinstance(obj, BECConnector):
|
||||
return {
|
||||
"gui_id": obj.gui_id,
|
||||
"widget_class": obj.__class__.__name__,
|
||||
"config": obj.config.model_dump(),
|
||||
"__rpc__": True,
|
||||
}
|
||||
return obj
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# import sys
|
||||
import argparse
|
||||
|
||||
# sys.argv = ["bec_widgets", "--gui_class", "MainWindow"]
|
||||
main()
|
||||
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
|
||||
parser.add_argument("--id", type=str, help="The id of the server")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
server = BECWidgetsCLIServer(gui_id=args.id)
|
||||
# server = BECWidgetsCLIServer(gui_id="test")
|
||||
server.start()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from .motor_movement import (
|
||||
MotorControlApp,
|
||||
MotorControlMap,
|
||||
MotorControlPanel,
|
||||
MotorControlPanelRelative,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorCoordinateTable,
|
||||
MotorThread,
|
||||
)
|
||||
|
||||
168
bec_widgets/examples/crosshair_example/crosshair_example.py
Normal file
168
bec_widgets/examples/crosshair_example/crosshair_example.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QSpinBox,
|
||||
)
|
||||
from pyqtgraph import mkPen
|
||||
from pyqtgraph.Qt import QtCore
|
||||
from bec_widgets.utils import Crosshair
|
||||
|
||||
|
||||
class ExampleApp(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# Layout
|
||||
self.layout = QHBoxLayout()
|
||||
self.setLayout(self.layout)
|
||||
|
||||
##########################
|
||||
# 1D Plot
|
||||
##########################
|
||||
|
||||
# PlotWidget
|
||||
self.plot_widget_1d = pg.PlotWidget(title="1D PlotWidget with multiple curves")
|
||||
self.plot_item_1d = self.plot_widget_1d.getPlotItem()
|
||||
self.plot_item_1d.setLogMode(True, True)
|
||||
|
||||
# 1D Datasets
|
||||
self.x_data = np.linspace(0, 10, 1000)
|
||||
|
||||
def gauss(x, mu, sigma):
|
||||
return (1 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
|
||||
|
||||
# same convention as in line_plot.py
|
||||
self.y_value_list = [
|
||||
gauss(self.x_data, 1, 1),
|
||||
gauss(self.x_data, 1.5, 3),
|
||||
abs(np.sin(self.x_data)),
|
||||
abs(np.cos(self.x_data)),
|
||||
abs(np.sin(2 * self.x_data)),
|
||||
] # List of y-values for multiple curves
|
||||
|
||||
self.curve_names = ["Gauss(1,1)", "Gauss(1.5,3)", "Abs(Sine)", "Abs(Cosine)", "Abs(Sine2x)"]
|
||||
self.curves = []
|
||||
|
||||
##########################
|
||||
# 2D Plot
|
||||
##########################
|
||||
self.plot_widget_2d = pg.PlotWidget(title="2D plot with crosshair and ROI square")
|
||||
self.data_2D = np.random.random((100, 200))
|
||||
self.plot_item_2d = self.plot_widget_2d.getPlotItem()
|
||||
self.image_item = pg.ImageItem(self.data_2D)
|
||||
self.plot_item_2d.addItem(self.image_item)
|
||||
|
||||
##########################
|
||||
# Table
|
||||
##########################
|
||||
self.table = QTableWidget(len(self.curve_names), 2)
|
||||
self.table.setHorizontalHeaderLabels(["(X, Y) - Moved", "(X, Y) - Clicked"])
|
||||
self.table.setVerticalHeaderLabels(self.curve_names)
|
||||
self.table.resizeColumnsToContents()
|
||||
|
||||
##########################
|
||||
# Spinbox for N curves
|
||||
##########################
|
||||
self.spin_box = QSpinBox()
|
||||
self.spin_box.setMinimum(0)
|
||||
self.spin_box.setMaximum(len(self.y_value_list))
|
||||
self.spin_box.setValue(2)
|
||||
self.spin_box.valueChanged.connect(lambda: self.update_curves(self.spin_box.value()))
|
||||
|
||||
##########################
|
||||
# Adding widgets to layout
|
||||
##########################
|
||||
|
||||
##### left side #####
|
||||
self.column1 = QVBoxLayout()
|
||||
self.layout.addLayout(self.column1)
|
||||
|
||||
# SpinBox
|
||||
self.spin_row = QHBoxLayout()
|
||||
self.column1.addLayout(self.spin_row)
|
||||
self.spin_row.addWidget(QLabel("Number of curves:"))
|
||||
self.spin_row.addWidget(self.spin_box)
|
||||
|
||||
# label
|
||||
self.clicked_label_1d = QLabel("Clicked Coordinates (1D):")
|
||||
self.column1.addWidget(self.clicked_label_1d)
|
||||
|
||||
# table
|
||||
self.column1.addWidget(self.table)
|
||||
|
||||
# 1D plot
|
||||
self.column1.addWidget(self.plot_widget_1d)
|
||||
|
||||
##### left side #####
|
||||
self.column2 = QVBoxLayout()
|
||||
self.layout.addLayout(self.column2)
|
||||
|
||||
# labels
|
||||
self.clicked_label_2d = QLabel("Clicked Coordinates (2D):")
|
||||
self.moved_label_2d = QLabel("Moved Coordinates (2D):")
|
||||
self.column2.addWidget(self.clicked_label_2d)
|
||||
self.column2.addWidget(self.moved_label_2d)
|
||||
|
||||
# 2D plot
|
||||
self.column2.addWidget(self.plot_widget_2d)
|
||||
|
||||
self.update_curves(2) # just Gaussian curves
|
||||
|
||||
def hook_crosshair(self):
|
||||
self.crosshair_1d = Crosshair(self.plot_item_1d, precision=10)
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.update_table(self.table, x, y, column=0)
|
||||
)
|
||||
self.crosshair_1d.coordinatesClicked1D.connect(
|
||||
lambda x, y: self.update_table(self.table, x, y, column=1)
|
||||
)
|
||||
# 2D
|
||||
self.crosshair_2d = Crosshair(self.plot_item_2d)
|
||||
self.crosshair_2d.coordinatesChanged2D.connect(
|
||||
lambda x, y: self.moved_label_2d.setText(f"Mouse Moved Coordinates (2D): x={x}, y={y}")
|
||||
)
|
||||
self.crosshair_2d.coordinatesClicked2D.connect(
|
||||
lambda x, y: self.clicked_label_2d.setText(f"Clicked Coordinates (2D): x={x}, y={y}")
|
||||
)
|
||||
|
||||
def update_table(self, table_widget, x, y_values, column):
|
||||
"""Update the table with the new coordinates"""
|
||||
for i, y in enumerate(y_values):
|
||||
table_widget.setItem(i, column, QTableWidgetItem(f"({x}, {y})"))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update_curves(self, num_curves):
|
||||
"""Update the number of curves"""
|
||||
|
||||
self.plot_item_1d.clear()
|
||||
|
||||
# Curves
|
||||
color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"]
|
||||
self.plot_item_1d.addLegend()
|
||||
self.curves = []
|
||||
|
||||
y_value_list = self.y_value_list[:num_curves]
|
||||
|
||||
for ii, y_value in enumerate(y_value_list):
|
||||
pen = mkPen(color=color_list[ii], width=2, style=QtCore.Qt.DashLine)
|
||||
curve = pg.PlotDataItem(
|
||||
self.x_data, y_value, pen=pen, skipFiniteCheck=True, name=self.curve_names[ii]
|
||||
)
|
||||
self.plot_item_1d.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
|
||||
self.hook_crosshair()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication([])
|
||||
window = ExampleApp()
|
||||
window.show()
|
||||
app.exec()
|
||||
315
bec_widgets/examples/eiger_plot/eiger_plot.py
Normal file
315
bec_widgets/examples/eiger_plot/eiger_plot.py
Normal file
@@ -0,0 +1,315 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import zmq
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtGui import QKeySequence
|
||||
from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
QFileDialog,
|
||||
QShortcut,
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QFrame,
|
||||
)
|
||||
from pyqtgraph.Qt import uic
|
||||
|
||||
|
||||
# from scipy.stats import multivariate_normal
|
||||
|
||||
|
||||
class EigerPlot(QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
# pg.setConfigOptions(background="w", foreground="k", antialias=True)
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "eiger_plot.ui"), self)
|
||||
|
||||
# Set widow name
|
||||
self.setWindowTitle("Eiger Plot")
|
||||
|
||||
self.hist_lims = None
|
||||
self.mask = None
|
||||
self.image = None
|
||||
|
||||
# UI
|
||||
self.init_ui()
|
||||
self.hook_signals()
|
||||
self.key_bindings()
|
||||
|
||||
# ZMQ Consumer
|
||||
self._zmq_consumer_exit_event = threading.Event()
|
||||
self._zmq_consumer_thread = self.start_zmq_consumer()
|
||||
|
||||
def close(self):
|
||||
super().close()
|
||||
self._zmq_consumer_exit_event.set()
|
||||
self._zmq_consumer_thread.join()
|
||||
|
||||
def init_ui(self):
|
||||
# Create Plot and add ImageItem
|
||||
self.plot_item = pg.PlotItem()
|
||||
self.plot_item.setAspectLocked(True)
|
||||
self.imageItem = pg.ImageItem()
|
||||
self.plot_item.addItem(self.imageItem)
|
||||
|
||||
# Setting up histogram
|
||||
self.hist = pg.HistogramLUTItem()
|
||||
self.hist.setImageItem(self.imageItem)
|
||||
self.hist.gradient.loadPreset("magma")
|
||||
self.update_hist()
|
||||
|
||||
# Adding Items to Graphical Layout
|
||||
self.glw.addItem(self.plot_item)
|
||||
self.glw.addItem(self.hist)
|
||||
|
||||
def hook_signals(self):
|
||||
# Buttons
|
||||
# self.pushButton_test.clicked.connect(self.start_sim_stream)
|
||||
self.pushButton_mask.clicked.connect(self.load_mask_dialog)
|
||||
self.pushButton_delete_mask.clicked.connect(self.delete_mask)
|
||||
self.pushButton_help.clicked.connect(self.show_help_dialog)
|
||||
|
||||
# SpinBoxes
|
||||
self.doubleSpinBox_hist_min.valueChanged.connect(self.update_hist)
|
||||
self.doubleSpinBox_hist_max.valueChanged.connect(self.update_hist)
|
||||
|
||||
# Signal/Slots
|
||||
self.update_signal.connect(self.on_image_update)
|
||||
|
||||
def key_bindings(self):
|
||||
# Key bindings for rotation
|
||||
rotate_plus = QShortcut(QKeySequence("Ctrl+A"), self)
|
||||
rotate_minus = QShortcut(QKeySequence("Ctrl+Z"), self)
|
||||
self.comboBox_rotation.setToolTip("Increase rotation: Ctrl+A\nDecrease rotation: Ctrl+Z")
|
||||
self.checkBox_transpose.setToolTip("Toggle transpose: Ctrl+T")
|
||||
|
||||
max_index = self.comboBox_rotation.count() - 1 # Maximum valid index
|
||||
|
||||
rotate_plus.activated.connect(
|
||||
lambda: self.comboBox_rotation.setCurrentIndex(
|
||||
min(self.comboBox_rotation.currentIndex() + 1, max_index)
|
||||
)
|
||||
)
|
||||
|
||||
rotate_minus.activated.connect(
|
||||
lambda: self.comboBox_rotation.setCurrentIndex(
|
||||
max(self.comboBox_rotation.currentIndex() - 1, 0)
|
||||
)
|
||||
)
|
||||
|
||||
# Key bindings for transpose
|
||||
transpose = QShortcut(QKeySequence("Ctrl+T"), self)
|
||||
transpose.activated.connect(self.checkBox_transpose.toggle)
|
||||
|
||||
FFT = QShortcut(QKeySequence("Ctrl+F"), self)
|
||||
FFT.activated.connect(self.checkBox_FFT.toggle)
|
||||
self.checkBox_FFT.setToolTip("Toggle FFT: Ctrl+F")
|
||||
|
||||
log = QShortcut(QKeySequence("Ctrl+L"), self)
|
||||
log.activated.connect(self.checkBox_log.toggle)
|
||||
self.checkBox_log.setToolTip("Toggle log: Ctrl+L")
|
||||
|
||||
mask = QShortcut(QKeySequence("Ctrl+M"), self)
|
||||
mask.activated.connect(self.pushButton_mask.click)
|
||||
self.pushButton_mask.setToolTip("Load mask: Ctrl+M")
|
||||
|
||||
delete_mask = QShortcut(QKeySequence("Ctrl+D"), self)
|
||||
delete_mask.activated.connect(self.pushButton_delete_mask.click)
|
||||
self.pushButton_delete_mask.setToolTip("Delete mask: Ctrl+D")
|
||||
|
||||
def update_hist(self):
|
||||
self.hist_levels = [
|
||||
self.doubleSpinBox_hist_min.value(),
|
||||
self.doubleSpinBox_hist_max.value(),
|
||||
]
|
||||
self.hist.setLevels(min=self.hist_levels[0], max=self.hist_levels[1])
|
||||
self.hist.setHistogramRange(
|
||||
self.hist_levels[0] - 0.1 * self.hist_levels[0],
|
||||
self.hist_levels[1] + 0.1 * self.hist_levels[1],
|
||||
)
|
||||
|
||||
def load_mask_dialog(self):
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.ReadOnly
|
||||
file_name, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select Mask File", "", "H5 Files (*.h5);;All Files (*)", options=options
|
||||
)
|
||||
if file_name:
|
||||
self.load_mask(file_name)
|
||||
|
||||
def load_mask(self, path):
|
||||
try:
|
||||
with h5py.File(path, "r") as f:
|
||||
self.mask = f["data"][...]
|
||||
if self.mask is not None:
|
||||
# Set label to mask name without path
|
||||
self.label_mask.setText(os.path.basename(path))
|
||||
except KeyError as e:
|
||||
# Update GUI with the error message
|
||||
print(f"Error: {str(e)}")
|
||||
|
||||
def delete_mask(self):
|
||||
self.mask = None
|
||||
self.label_mask.setText("No Mask")
|
||||
|
||||
@pyqtSlot()
|
||||
def on_image_update(self):
|
||||
# TODO first rotate then transpose
|
||||
if self.mask is not None:
|
||||
# self.image = np.ma.masked_array(self.image, mask=self.mask) #TODO test if np works
|
||||
self.image = self.image * (1 - self.mask) + 1
|
||||
|
||||
if self.checkBox_FFT.isChecked():
|
||||
self.image = np.abs(np.fft.fftshift(np.fft.fft2(self.image)))
|
||||
|
||||
if self.comboBox_rotation.currentIndex() > 0: # rotate
|
||||
self.image = np.rot90(self.image, k=self.comboBox_rotation.currentIndex(), axes=(0, 1))
|
||||
|
||||
if self.checkBox_transpose.isChecked(): # transpose
|
||||
self.image = np.transpose(self.image)
|
||||
|
||||
if self.checkBox_log.isChecked():
|
||||
self.image = np.log10(self.image)
|
||||
|
||||
self.imageItem.setImage(self.image, autoLevels=False)
|
||||
|
||||
###############################
|
||||
# ZMQ Consumer
|
||||
###############################
|
||||
|
||||
def start_zmq_consumer(self):
|
||||
consumer_thread = threading.Thread(
|
||||
target=self.zmq_consumer, args=(self._zmq_consumer_exit_event,), daemon=True
|
||||
)
|
||||
consumer_thread.start()
|
||||
return consumer_thread
|
||||
|
||||
def zmq_consumer(self, exit_event):
|
||||
print("starting consumer")
|
||||
live_stream_url = "tcp://129.129.95.38:20000"
|
||||
receiver = zmq.Context().socket(zmq.SUB)
|
||||
receiver.connect(live_stream_url)
|
||||
receiver.setsockopt_string(zmq.SUBSCRIBE, "")
|
||||
|
||||
poller = zmq.Poller()
|
||||
poller.register(receiver, zmq.POLLIN)
|
||||
|
||||
# code could be a bit simpler here, testing exit_event in
|
||||
# 'while' condition, but like this it is easier for the
|
||||
# 'test_zmq_consumer' test
|
||||
while True:
|
||||
if poller.poll(1000): # 1s timeout
|
||||
raw_meta, raw_data = receiver.recv_multipart(zmq.NOBLOCK)
|
||||
|
||||
meta = json.loads(raw_meta.decode("utf-8"))
|
||||
self.image = np.frombuffer(raw_data, dtype=meta["type"]).reshape(meta["shape"])
|
||||
self.update_signal.emit()
|
||||
if exit_event.is_set():
|
||||
break
|
||||
|
||||
receiver.disconnect(live_stream_url)
|
||||
|
||||
###############################
|
||||
# just simulations from here
|
||||
###############################
|
||||
|
||||
def show_help_dialog(self):
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Help")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Key bindings section
|
||||
layout.addWidget(QLabel("Keyboard Shortcuts:"))
|
||||
|
||||
key_bindings = [
|
||||
("Ctrl+A", "Increase rotation"),
|
||||
("Ctrl+Z", "Decrease rotation"),
|
||||
("Ctrl+T", "Toggle transpose"),
|
||||
("Ctrl+F", "Toggle FFT"),
|
||||
("Ctrl+L", "Toggle log scale"),
|
||||
("Ctrl+M", "Load mask"),
|
||||
("Ctrl+D", "Delete mask"),
|
||||
]
|
||||
|
||||
for keys, action in key_bindings:
|
||||
layout.addWidget(QLabel(f"{keys} - {action}"))
|
||||
|
||||
# Separator
|
||||
separator = QFrame()
|
||||
separator.setFrameShape(QFrame.HLine)
|
||||
separator.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(separator)
|
||||
|
||||
# Histogram section
|
||||
layout.addWidget(QLabel("Histogram:"))
|
||||
layout.addWidget(
|
||||
QLabel(
|
||||
"Use the Double Spin Boxes to adjust the minimum and maximum values of the histogram."
|
||||
)
|
||||
)
|
||||
|
||||
# Another Separator
|
||||
another_separator = QFrame()
|
||||
another_separator.setFrameShape(QFrame.HLine)
|
||||
another_separator.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(another_separator)
|
||||
|
||||
# Mask section
|
||||
layout.addWidget(QLabel("Mask:"))
|
||||
layout.addWidget(
|
||||
QLabel(
|
||||
"Use 'Load Mask' to load a mask from an H5 file. 'Delete Mask' removes the current mask."
|
||||
)
|
||||
)
|
||||
|
||||
dialog.setLayout(layout)
|
||||
dialog.exec()
|
||||
|
||||
###############################
|
||||
# just simulations from here
|
||||
###############################
|
||||
# def start_sim_stream(self):
|
||||
# sim_stream_thread = threading.Thread(target=self.sim_stream, daemon=True)
|
||||
# sim_stream_thread.start()
|
||||
#
|
||||
# def sim_stream(self):
|
||||
# for i in range(100):
|
||||
# # Generate 100x100 image of random noise
|
||||
# self.image = np.random.rand(100, 100) * 0.2
|
||||
#
|
||||
# # Define Gaussian parameters
|
||||
# x, y = np.mgrid[0:50, 0:50]
|
||||
# pos = np.dstack((x, y))
|
||||
#
|
||||
# # Center at (25, 25) longer along y-axis
|
||||
# rv = multivariate_normal(mean=[25, 25], cov=[[25, 0], [0, 80]])
|
||||
#
|
||||
# # Generate Gaussian in the first quadrant
|
||||
# gaussian_quadrant = rv.pdf(pos) * 40
|
||||
#
|
||||
# # Place Gaussian in the first quadrant
|
||||
# self.image[0:50, 0:50] += gaussian_quadrant * 10
|
||||
#
|
||||
# self.update_signal.emit()
|
||||
# time.sleep(0.1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
plot = EigerPlot()
|
||||
plot.show()
|
||||
sys.exit(app.exec())
|
||||
207
bec_widgets/examples/eiger_plot/eiger_plot.ui
Normal file
207
bec_widgets/examples/eiger_plot/eiger_plot.ui
Normal file
@@ -0,0 +1,207 @@
|
||||
<?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>874</width>
|
||||
<height>762</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>Plot Control</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Histogram MIN</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QDoubleSpinBox" name="doubleSpinBox_hist_min">
|
||||
<property name="minimum">
|
||||
<double>-100000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>100000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Histogram MAX</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="doubleSpinBox_hist_max">
|
||||
<property name="minimum">
|
||||
<double>-100000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>100000.000000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>2.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Data Control</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_FFT">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>FFT</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_log">
|
||||
<property name="text">
|
||||
<string>log</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_mask">
|
||||
<property name="text">
|
||||
<string>Load Mask</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_delete_mask">
|
||||
<property name="text">
|
||||
<string>Delete Mask</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_3">
|
||||
<property name="title">
|
||||
<string>Orientation</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="2" column="1">
|
||||
<widget class="QComboBox" name="comboBox_rotation">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>0</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>90</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>180</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>270</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Rotation</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0" colspan="2">
|
||||
<widget class="QCheckBox" name="checkBox_transpose">
|
||||
<property name="text">
|
||||
<string>Transpose</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_4">
|
||||
<property name="title">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_mask">
|
||||
<property name="text">
|
||||
<string>No Mask</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_help">
|
||||
<property name="text">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="GraphicsLayoutWidget" name="glw"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,92 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QActionGroup, QIcon
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.examples.general_app.web_links import BECWebLinksMixin
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class BECGeneralApp(QMainWindow):
|
||||
def __init__(self, parent=None):
|
||||
super(BECGeneralApp, self).__init__(parent)
|
||||
ui_file_path = os.path.join(os.path.dirname(__file__), "general_app.ui")
|
||||
self.load_ui(ui_file_path)
|
||||
|
||||
self.resize(1280, 720)
|
||||
|
||||
self.ini_ui()
|
||||
|
||||
def ini_ui(self):
|
||||
self._setup_icons()
|
||||
self._hook_menubar_docs()
|
||||
self._hook_theme_bar()
|
||||
|
||||
def load_ui(self, ui_file):
|
||||
loader = UILoader(self)
|
||||
self.ui = loader.loader(ui_file)
|
||||
self.setCentralWidget(self.ui)
|
||||
|
||||
def _hook_menubar_docs(self):
|
||||
# BEC Docs
|
||||
self.ui.action_BEC_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
|
||||
# BEC Widgets Docs
|
||||
self.ui.action_BEC_widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
|
||||
# Bug report
|
||||
self.ui.action_bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
|
||||
|
||||
def change_theme(self, theme):
|
||||
apply_theme(theme)
|
||||
|
||||
def _setup_icons(self):
|
||||
help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion)
|
||||
bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation)
|
||||
computer_icon = QIcon.fromTheme("computer")
|
||||
widget_icon = QIcon(os.path.join(MODULE_PATH, "assets", "designer_icons", "dock_area.png"))
|
||||
|
||||
self.ui.action_BEC_docs.setIcon(help_icon)
|
||||
self.ui.action_BEC_widgets_docs.setIcon(help_icon)
|
||||
self.ui.action_bug_report.setIcon(bug_icon)
|
||||
|
||||
self.ui.central_tab.setTabIcon(0, widget_icon)
|
||||
self.ui.central_tab.setTabIcon(1, computer_icon)
|
||||
|
||||
def _hook_theme_bar(self):
|
||||
self.ui.action_light.setCheckable(True)
|
||||
self.ui.action_dark.setCheckable(True)
|
||||
|
||||
# Create an action group to make sure only one can be checked at a time
|
||||
theme_group = QActionGroup(self)
|
||||
theme_group.addAction(self.ui.action_light)
|
||||
theme_group.addAction(self.ui.action_dark)
|
||||
theme_group.setExclusive(True)
|
||||
|
||||
# Connect the actions to the theme change method
|
||||
|
||||
self.ui.action_light.triggered.connect(lambda: self.change_theme("light"))
|
||||
self.ui.action_dark.triggered.connect(lambda: self.change_theme("dark"))
|
||||
|
||||
self.ui.action_dark.trigger()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"), size=QSize(48, 48)
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
main_window = BECGeneralApp()
|
||||
main_window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,262 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1718</width>
|
||||
<height>1139</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<property name="tabShape">
|
||||
<enum>QTabWidget::TabShape::Rounded</enum>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QTabWidget" name="central_tab">
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="dock_area_tab">
|
||||
<attribute name="title">
|
||||
<string>Dock Area</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="BECDockArea" name="dock_area"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="vscode_tab">
|
||||
<attribute name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::Computer"/>
|
||||
</attribute>
|
||||
<attribute name="title">
|
||||
<string>Visual Studio Code</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="VSCodeEditor" name="vscode"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1718</width>
|
||||
<height>31</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuHelp">
|
||||
<property name="title">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
<addaction name="action_BEC_docs"/>
|
||||
<addaction name="action_BEC_widgets_docs"/>
|
||||
<addaction name="action_bug_report"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuTheme">
|
||||
<property name="title">
|
||||
<string>Theme</string>
|
||||
</property>
|
||||
<addaction name="action_light"/>
|
||||
<addaction name="action_dark"/>
|
||||
</widget>
|
||||
<addaction name="menuTheme"/>
|
||||
<addaction name="menuHelp"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<widget class="QDockWidget" name="dock_scan_control">
|
||||
<property name="windowTitle">
|
||||
<string>Scan Control</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>2</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_2">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="ScanControl" name="scan_control"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QDockWidget" name="dock_status_2">
|
||||
<property name="windowTitle">
|
||||
<string>BEC Service Status</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>2</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_3">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_5">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="BECStatusBox" name="bec_status_box_2"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QDockWidget" name="dock_queue">
|
||||
<property name="windowTitle">
|
||||
<string>Scan Queue</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>2</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_4">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_6">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="BECQueue" name="bec_queue">
|
||||
<row/>
|
||||
<column/>
|
||||
<column/>
|
||||
<column/>
|
||||
<item row="0" column="0"/>
|
||||
<item row="0" column="1"/>
|
||||
<item row="0" column="2"/>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<action name="action_BEC_docs">
|
||||
<property name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>BEC Docs</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_BEC_widgets_docs">
|
||||
<property name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>BEC Widgets Docs</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_bug_report">
|
||||
<property name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::DialogError"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Bug Report</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_light">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Light</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_dark">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Dark</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>WebsiteWidget</class>
|
||||
<extends>QWebEngineView</extends>
|
||||
<header>website_widget</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECQueue</class>
|
||||
<extends>QTableWidget</extends>
|
||||
<header>bec_queue</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ScanControl</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>scan_control</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>VSCodeEditor</class>
|
||||
<extends>WebsiteWidget</extends>
|
||||
<header>vs_code_editor</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECStatusBox</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_status_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECDockArea</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>dock_area</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>QWebEngineView</class>
|
||||
<extends></extends>
|
||||
<header location="global">QtWebEngineWidgets/QWebEngineView</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,15 +0,0 @@
|
||||
import webbrowser
|
||||
|
||||
|
||||
class BECWebLinksMixin:
|
||||
@staticmethod
|
||||
def open_bec_docs():
|
||||
webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/")
|
||||
|
||||
@staticmethod
|
||||
def open_bec_widgets_docs():
|
||||
webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/")
|
||||
|
||||
@staticmethod
|
||||
def open_bec_bug_report():
|
||||
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")
|
||||
@@ -1,186 +0,0 @@
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API)."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._init_ui()
|
||||
|
||||
# console push
|
||||
if self.console.inprocess is True:
|
||||
self.console.kernel_manager.kernel.shell.push(
|
||||
{
|
||||
"np": np,
|
||||
"pg": pg,
|
||||
"wh": wh,
|
||||
"dock": self.dock,
|
||||
"im": self.im,
|
||||
"mi": self.mi,
|
||||
"mm": self.mm,
|
||||
"lm": self.lm,
|
||||
"btn1": self.btn1,
|
||||
"btn2": self.btn2,
|
||||
"btn3": self.btn3,
|
||||
"btn4": self.btn4,
|
||||
"btn5": self.btn5,
|
||||
"btn6": self.btn6,
|
||||
"pb": self.pb,
|
||||
"pi": self.pi,
|
||||
"wf": self.wf,
|
||||
"scatter": self.scatter,
|
||||
"scatter_mi": self.scatter,
|
||||
"mwf": self.mwf,
|
||||
}
|
||||
)
|
||||
|
||||
def _init_ui(self):
|
||||
self.layout = QHBoxLayout(self)
|
||||
|
||||
# Horizontal splitter
|
||||
splitter = QSplitter(self)
|
||||
self.layout.addWidget(splitter)
|
||||
|
||||
tab_widget = QTabWidget(splitter)
|
||||
|
||||
first_tab = QWidget()
|
||||
first_tab_layout = QVBoxLayout(first_tab)
|
||||
self.dock = BECDockArea(gui_id="dock")
|
||||
first_tab_layout.addWidget(self.dock)
|
||||
tab_widget.addTab(first_tab, "Dock Area")
|
||||
|
||||
third_tab = QWidget()
|
||||
third_tab_layout = QVBoxLayout(third_tab)
|
||||
self.lm = LayoutManagerWidget()
|
||||
third_tab_layout.addWidget(self.lm)
|
||||
tab_widget.addTab(third_tab, "Layout Manager Widget")
|
||||
|
||||
fourth_tab = QWidget()
|
||||
fourth_tab_layout = QVBoxLayout(fourth_tab)
|
||||
self.pb = PlotBase()
|
||||
self.pi = self.pb.plot_item
|
||||
fourth_tab_layout.addWidget(self.pb)
|
||||
tab_widget.addTab(fourth_tab, "PlotBase")
|
||||
|
||||
tab_widget.setCurrentIndex(3)
|
||||
|
||||
group_box = QGroupBox("Jupyter Console", splitter)
|
||||
group_box_layout = QVBoxLayout(group_box)
|
||||
self.console = BECJupyterConsole(inprocess=True)
|
||||
group_box_layout.addWidget(self.console)
|
||||
|
||||
# Some buttons for layout testing
|
||||
self.btn1 = QPushButton("Button 1")
|
||||
self.btn2 = QPushButton("Button 2")
|
||||
self.btn3 = QPushButton("Button 3")
|
||||
self.btn4 = QPushButton("Button 4")
|
||||
self.btn5 = QPushButton("Button 5")
|
||||
self.btn6 = QPushButton("Button 6")
|
||||
|
||||
fifth_tab = QWidget()
|
||||
fifth_tab_layout = QVBoxLayout(fifth_tab)
|
||||
self.wf = Waveform()
|
||||
fifth_tab_layout.addWidget(self.wf)
|
||||
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
|
||||
tab_widget.setCurrentIndex(4)
|
||||
|
||||
sixth_tab = QWidget()
|
||||
sixth_tab_layout = QVBoxLayout(sixth_tab)
|
||||
self.im = Image()
|
||||
self.mi = self.im.main_image
|
||||
sixth_tab_layout.addWidget(self.im)
|
||||
tab_widget.addTab(sixth_tab, "Image Next Gen")
|
||||
tab_widget.setCurrentIndex(5)
|
||||
|
||||
seventh_tab = QWidget()
|
||||
seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||
self.scatter = ScatterWaveform()
|
||||
self.scatter_mi = self.scatter.main_curve
|
||||
self.scatter.plot("samx", "samy", "bpm4i")
|
||||
seventh_tab_layout.addWidget(self.scatter)
|
||||
tab_widget.addTab(seventh_tab, "Scatter Waveform")
|
||||
tab_widget.setCurrentIndex(6)
|
||||
|
||||
eighth_tab = QWidget()
|
||||
eighth_tab_layout = QVBoxLayout(eighth_tab)
|
||||
self.mm = MotorMap()
|
||||
eighth_tab_layout.addWidget(self.mm)
|
||||
tab_widget.addTab(eighth_tab, "Motor Map")
|
||||
tab_widget.setCurrentIndex(7)
|
||||
|
||||
ninth_tab = QWidget()
|
||||
ninth_tab_layout = QVBoxLayout(ninth_tab)
|
||||
self.mwf = MultiWaveform()
|
||||
ninth_tab_layout.addWidget(self.mwf)
|
||||
tab_widget.addTab(ninth_tab, "MultiWaveform")
|
||||
tab_widget.setCurrentIndex(8)
|
||||
|
||||
# add stuff to the new Waveform widget
|
||||
self._init_waveform()
|
||||
|
||||
self.setWindowTitle("Jupyter Console Window")
|
||||
|
||||
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 closeEvent(self, event):
|
||||
"""Override to handle things when main window is closed."""
|
||||
self.dock.cleanup()
|
||||
self.dock.close()
|
||||
self.console.close()
|
||||
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
import bec_widgets
|
||||
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
win = JupyterConsoleWindow()
|
||||
win.show()
|
||||
win.resize(1500, 800)
|
||||
|
||||
app.aboutToQuit.connect(win.close)
|
||||
sys.exit(app.exec_())
|
||||
161
bec_widgets/examples/mca_readout/mca_plot.py
Normal file
161
bec_widgets/examples/mca_readout/mca_plot.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# import simulation_progress as SP
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import Signal as pyqtSignal, Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_lib import MessageEndpoints, messages
|
||||
|
||||
|
||||
class StreamApp(QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
new_scanID = pyqtSignal(str)
|
||||
|
||||
def __init__(self, device, sub_device):
|
||||
super().__init__()
|
||||
pg.setConfigOptions(background="w", foreground="k")
|
||||
self.init_ui()
|
||||
|
||||
self.setWindowTitle("MCA readout")
|
||||
|
||||
self.data = None
|
||||
self.scanID = None
|
||||
self.stream_consumer = None
|
||||
|
||||
self.device = device
|
||||
self.sub_device = sub_device
|
||||
|
||||
self.start_device_consumer()
|
||||
|
||||
# self.start_device_consumer(self.device) # for simulation
|
||||
|
||||
self.new_scanID.connect(self.create_new_stream_consumer)
|
||||
self.update_signal.connect(self.plot_new)
|
||||
|
||||
def init_ui(self):
|
||||
# Create layout and add widgets
|
||||
self.layout = QVBoxLayout()
|
||||
self.setLayout(self.layout)
|
||||
|
||||
# Create plot
|
||||
self.glw = pg.GraphicsLayoutWidget()
|
||||
self.layout.addWidget(self.glw)
|
||||
|
||||
# Create Plot and add ImageItem
|
||||
self.plot_item = pg.PlotItem()
|
||||
self.plot_item.setAspectLocked(False)
|
||||
self.imageItem = pg.ImageItem()
|
||||
# self.plot_item1D = pg.PlotItem()
|
||||
# self.plot_item.addItem(self.imageItem)
|
||||
# self.plot_item.addItem(self.plot_item1D)
|
||||
|
||||
# Setting up histogram
|
||||
# self.hist = pg.HistogramLUTItem()
|
||||
# self.hist.setImageItem(self.imageItem)
|
||||
# self.hist.gradient.loadPreset("magma")
|
||||
# self.update_hist()
|
||||
|
||||
# Adding Items to Graphical Layout
|
||||
self.glw.addItem(self.plot_item)
|
||||
# self.glw.addItem(self.hist)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def create_new_stream_consumer(self, scanID: str):
|
||||
print(f"Creating new stream consumer for scanID: {scanID}")
|
||||
|
||||
self.connect_stream_consumer(scanID, self.device)
|
||||
|
||||
def connect_stream_consumer(self, scanID, device):
|
||||
if self.stream_consumer is not None:
|
||||
self.stream_consumer.shutdown()
|
||||
|
||||
self.stream_consumer = connector.stream_consumer(
|
||||
topics=MessageEndpoints.device_async_readback(scanID=scanID, device=device),
|
||||
cb=self._streamer_cb,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.stream_consumer.start()
|
||||
|
||||
def start_device_consumer(self):
|
||||
self.device_consumer = connector.consumer(
|
||||
topics=MessageEndpoints.scan_status(), cb=self._device_cv, parent=self
|
||||
)
|
||||
|
||||
self.device_consumer.start()
|
||||
|
||||
# def start_device_consumer(self, device): #for simulation
|
||||
# self.device_consumer = connector.consumer(
|
||||
# topics=MessageEndpoints.device_status(device), cb=self._device_cv, parent=self
|
||||
# )
|
||||
#
|
||||
# self.device_consumer.start()
|
||||
|
||||
def plot_new(self):
|
||||
print(f"Printing data from plot update: {self.data}")
|
||||
self.plot_item.plot(self.data[0])
|
||||
# self.imageItem.setImage(self.data, autoLevels=False)
|
||||
|
||||
@staticmethod
|
||||
def _streamer_cb(msg, *, parent, **_kwargs) -> None:
|
||||
msgMCS = msg.value
|
||||
print(msgMCS)
|
||||
row = msgMCS.content["signals"][parent.sub_device]
|
||||
metadata = msgMCS.metadata
|
||||
|
||||
# Check if the current number of rows is odd
|
||||
# if parent.data is not None and parent.data.shape[0] % 2 == 1:
|
||||
# row = np.flip(row) # Flip the row
|
||||
print(f"Printing data from callback update: {row}")
|
||||
parent.data = np.array([row])
|
||||
# if parent.data is None:
|
||||
# parent.data = np.array([row])
|
||||
# else:
|
||||
# parent.data = np.vstack((parent.data, row))
|
||||
|
||||
parent.update_signal.emit()
|
||||
|
||||
@staticmethod
|
||||
def _device_cv(msg, *, parent, **_kwargs) -> None:
|
||||
print("Getting ScanID")
|
||||
|
||||
msgDEV = msg.value
|
||||
|
||||
current_scanID = msgDEV.content["scanID"]
|
||||
|
||||
if parent.scanID is None:
|
||||
parent.scanID = current_scanID
|
||||
parent.new_scanID.emit(current_scanID)
|
||||
print(f"New scanID: {current_scanID}")
|
||||
|
||||
if current_scanID != parent.scanID:
|
||||
parent.scanID = current_scanID
|
||||
# parent.data = None
|
||||
# parent.imageItem.clear()
|
||||
parent.new_scanID.emit(current_scanID)
|
||||
|
||||
print(f"New scanID: {current_scanID}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
from bec_lib import RedisConnector
|
||||
|
||||
parser = argparse.ArgumentParser(description="Stream App.")
|
||||
parser.add_argument("--port", type=str, default="pc15543:6379", help="Port for RedisConnector")
|
||||
parser.add_argument("--device", type=str, default="mcs", help="Device name")
|
||||
parser.add_argument("--sub_device", type=str, default="mca4", help="Sub-device name")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
connector = RedisConnector(args.port)
|
||||
|
||||
app = QApplication([])
|
||||
streamApp = StreamApp(device=args.device, sub_device=args.sub_device)
|
||||
|
||||
streamApp.show()
|
||||
app.exec()
|
||||
31
bec_widgets/examples/mca_readout/mca_sim.py
Normal file
31
bec_widgets/examples/mca_readout/mca_sim.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from bec_lib import messages, MessageEndpoints, RedisConnector
|
||||
import time
|
||||
|
||||
connector = RedisConnector("localhost:6379")
|
||||
metadata = {}
|
||||
|
||||
scanID = "ScanID1"
|
||||
|
||||
metadata.update(
|
||||
{
|
||||
"scanID": scanID, # this will be different for each scan
|
||||
"async_update": "append",
|
||||
}
|
||||
)
|
||||
for ii in range(20):
|
||||
data = {"mca1": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "mca2": [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]}
|
||||
msg = messages.DeviceMessage(
|
||||
signals=data,
|
||||
metadata=metadata,
|
||||
).dumps()
|
||||
|
||||
connector.xadd(
|
||||
topic=MessageEndpoints.device_async_readback(
|
||||
scanID=scanID, device="mca"
|
||||
), # scanID will be different for each scan
|
||||
msg={"data": msg}, # TODO should be msg_dict
|
||||
expire=1800,
|
||||
)
|
||||
|
||||
print(f"Sent {ii}")
|
||||
time.sleep(0.5)
|
||||
92
bec_widgets/examples/modular_app/modular.ui
Normal file
92
bec_widgets/examples/modular_app/modular.ui
Normal file
@@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1433</width>
|
||||
<height>689</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="1" column="2">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Plot Config 2</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0" colspan="2">
|
||||
<widget class="BECMonitor" name="plot_1"/>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="QPushButton" name="pushButton_setting_2">
|
||||
<property name="text">
|
||||
<string>Setting Plot 2</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2" colspan="2">
|
||||
<widget class="BECMonitor" name="plot_2"/>
|
||||
</item>
|
||||
<item row="1" column="4">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Plot Scan Types = True</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QPushButton" name="pushButton_setting_1">
|
||||
<property name="text">
|
||||
<string>Setting Plot 1</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Plot Config 1</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="5">
|
||||
<widget class="QPushButton" name="pushButton_setting_3">
|
||||
<property name="text">
|
||||
<string>Setting Plot 3</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="4" colspan="2">
|
||||
<widget class="BECMonitor" name="plot_3"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1433</width>
|
||||
<height>37</height>
|
||||
</rect>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>BECMonitor</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header location="global">bec_widgets.widgets.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
200
bec_widgets/examples/modular_app/modular_app.py
Normal file
200
bec_widgets/examples/modular_app/modular_app.py
Normal file
@@ -0,0 +1,200 @@
|
||||
import os
|
||||
|
||||
from qtpy import uic
|
||||
from qtpy.QtWidgets import QMainWindow, QApplication
|
||||
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.widgets import BECMonitor
|
||||
|
||||
# some default configs for demonstration purposes
|
||||
CONFIG_SIMPLE = {
|
||||
"plot_settings": {
|
||||
"background_color": "black",
|
||||
"num_columns": 2,
|
||||
"colormap": "plasma",
|
||||
"scan_types": False,
|
||||
},
|
||||
"plot_data": [
|
||||
{
|
||||
"plot_name": "BPM4i plots vs samx",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "bpm4i",
|
||||
"sources": [
|
||||
{
|
||||
"type": "scan_segment",
|
||||
"signals": {
|
||||
"x": [{"name": "samx"}],
|
||||
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
|
||||
},
|
||||
},
|
||||
# {
|
||||
# "type": "history",
|
||||
# "signals": {
|
||||
# "x": [{"name": "samx"}],
|
||||
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
|
||||
# },
|
||||
# },
|
||||
# {
|
||||
# "type": "dap",
|
||||
# 'worker':'some_worker',
|
||||
# "signals": {
|
||||
# "x": [{"name": "samx"}],
|
||||
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
|
||||
# },
|
||||
# },
|
||||
],
|
||||
},
|
||||
{
|
||||
"plot_name": "Gauss plots vs samx",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "Gauss",
|
||||
"sources": [
|
||||
{
|
||||
"type": "scan_segment",
|
||||
"signals": {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
CONFIG_SCAN_MODE = {
|
||||
"plot_settings": {
|
||||
"background_color": "white",
|
||||
"num_columns": 3,
|
||||
"colormap": "plasma",
|
||||
"scan_types": True,
|
||||
},
|
||||
"plot_data": {
|
||||
"grid_scan": [
|
||||
{
|
||||
"plot_name": "Grid plot 1",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "BPM",
|
||||
"sources": [
|
||||
{
|
||||
"type": "scan_segment",
|
||||
"signals": {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "gauss_bpm"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"plot_name": "Grid plot 2",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "BPM",
|
||||
"sources": [
|
||||
{
|
||||
"type": "scan_segment",
|
||||
"signals": {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "gauss_adc1"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"plot_name": "Grid plot 3",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "BPM",
|
||||
"sources": [
|
||||
{
|
||||
"type": "scan_segment",
|
||||
"signals": {
|
||||
"x": [{"name": "samy"}],
|
||||
"y": [{"name": "gauss_adc2"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"plot_name": "Grid plot 4",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "BPM",
|
||||
"sources": [
|
||||
{
|
||||
"type": "scan_segment",
|
||||
"signals": {
|
||||
"x": [{"name": "samy", "entry": "samy"}],
|
||||
"y": [{"name": "gauss_adc3"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"line_scan": [
|
||||
{
|
||||
"plot_name": "BPM plots vs samx",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "Gauss",
|
||||
"sources": [
|
||||
{
|
||||
"type": "scan_segment",
|
||||
"signals": {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "bpm4i"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"plot_name": "Gauss plots vs samx",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "Gauss",
|
||||
"sources": [
|
||||
{
|
||||
"type": "scan_segment",
|
||||
"signals": {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ModularApp(QMainWindow):
|
||||
def __init__(self, client=None, parent=None):
|
||||
super(ModularApp, self).__init__(parent)
|
||||
|
||||
# Client and device manager from BEC
|
||||
self.client = BECDispatcher().client if client is None else client
|
||||
|
||||
# Loading UI
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "modular.ui"), self)
|
||||
|
||||
self._init_plots()
|
||||
|
||||
def _init_plots(self):
|
||||
"""Initialize plots and connect the buttons to the config dialogs"""
|
||||
plots = [self.plot_1, self.plot_2, self.plot_3]
|
||||
configs = [CONFIG_SIMPLE, CONFIG_SCAN_MODE, CONFIG_SCAN_MODE]
|
||||
buttons = [self.pushButton_setting_1, self.pushButton_setting_2, self.pushButton_setting_3]
|
||||
|
||||
# hook plots, configs and buttons together
|
||||
for plot, config, button in zip(plots, configs, buttons):
|
||||
plot.on_config_update(config)
|
||||
button.clicked.connect(plot.show_config_dialog)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# BECclient global variables
|
||||
client = BECDispatcher().client
|
||||
client.start()
|
||||
|
||||
app = QApplication([])
|
||||
modularApp = ModularApp(client=client)
|
||||
|
||||
window = modularApp
|
||||
window.show()
|
||||
app.exec()
|
||||
9
bec_widgets/examples/motor_movement/__init__.py
Normal file
9
bec_widgets/examples/motor_movement/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .motor_control_compilations import (
|
||||
MotorControlApp,
|
||||
MotorControlMap,
|
||||
MotorControlPanel,
|
||||
MotorControlPanelRelative,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorCoordinateTable,
|
||||
MotorThread,
|
||||
)
|
||||
17
bec_widgets/examples/motor_movement/config_example.yaml
Normal file
17
bec_widgets/examples/motor_movement/config_example.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
selected_motors:
|
||||
motor_x: "samx"
|
||||
motor_y: "samy"
|
||||
|
||||
plot_motors:
|
||||
max_points: 1000
|
||||
num_dim_points: 100
|
||||
scatter_size: 5
|
||||
precision: 3
|
||||
mode_lock: False # "Individual" or "Start/Stop". False to unlock
|
||||
extra_columns:
|
||||
- sample name: "sample 1"
|
||||
- step_x [mu]: 25
|
||||
- step_y [mu]: 25
|
||||
- exp_time [s]: 1
|
||||
- start: 1
|
||||
- tilt [deg]: 0
|
||||
10
bec_widgets/examples/motor_movement/csax_bec_config.yaml
Normal file
10
bec_widgets/examples/motor_movement/csax_bec_config.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
redis:
|
||||
host: pc15543
|
||||
port: 6379
|
||||
mongodb:
|
||||
host: localhost
|
||||
port: 27017
|
||||
scibec:
|
||||
host: http://localhost
|
||||
port: 3030
|
||||
beamline: MyBeamline
|
||||
17
bec_widgets/examples/motor_movement/csaxs_config.yaml
Normal file
17
bec_widgets/examples/motor_movement/csaxs_config.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
selected_motors:
|
||||
motor_x: "samx"
|
||||
motor_y: "samy"
|
||||
|
||||
plot_motors:
|
||||
max_points: 1000
|
||||
num_dim_points: 100
|
||||
scatter_size: 5
|
||||
precision: 3
|
||||
mode_lock: Start/Stop # "Individual" or "Start/Stop"
|
||||
extra_columns:
|
||||
- sample name: "sample 1"
|
||||
- step_x [mu]: 25
|
||||
- step_y [mu]: 25
|
||||
- exp_time [s]: 1
|
||||
- start: 1
|
||||
- tilt [deg]: 0
|
||||
@@ -0,0 +1,260 @@
|
||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||
|
||||
import qdarktheme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from qtpy.QtWidgets import QVBoxLayout
|
||||
from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
QSplitter,
|
||||
)
|
||||
from qtpy.QtCore import Qt
|
||||
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.widgets import (
|
||||
MotorControlAbsolute,
|
||||
MotorControlRelative,
|
||||
MotorControlSelection,
|
||||
MotorThread,
|
||||
MotorMap,
|
||||
MotorCoordinateTable,
|
||||
)
|
||||
|
||||
CONFIG_DEFAULT = {
|
||||
"motor_control": {
|
||||
"motor_x": "samx",
|
||||
"motor_y": "samy",
|
||||
"step_size_x": 3,
|
||||
"step_size_y": 3,
|
||||
"precision": 4,
|
||||
"step_x_y_same": False,
|
||||
"move_with_arrows": False,
|
||||
},
|
||||
"plot_settings": {
|
||||
"colormap": "Greys",
|
||||
"scatter_size": 5,
|
||||
"max_points": 1000,
|
||||
"num_dim_points": 100,
|
||||
"precision": 2,
|
||||
"num_columns": 1,
|
||||
"background_value": 25,
|
||||
},
|
||||
"motors": [
|
||||
{
|
||||
"plot_name": "Motor Map",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "Motor Y",
|
||||
"signals": {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "samy", "entry": "samy"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class MotorControlApp(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
# Widgets
|
||||
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
|
||||
# Create MotorMap
|
||||
self.motion_map = MotorMap(client=self.client, config=self.config)
|
||||
# Create MotorCoordinateTable
|
||||
self.motor_table = MotorCoordinateTable(client=self.client, config=self.config)
|
||||
|
||||
# Create the splitter and add MotorMap and MotorControlPanel
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
splitter.addWidget(self.motion_map)
|
||||
splitter.addWidget(self.motor_control_panel)
|
||||
splitter.addWidget(self.motor_table)
|
||||
|
||||
# Set the main layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(splitter)
|
||||
self.setLayout(layout)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.motor_control_panel.selection_widget.selected_motors_signal.connect(
|
||||
lambda x, y: self.motion_map.change_motors(x, y, 0)
|
||||
)
|
||||
self.motor_control_panel.absolute_widget.coordinates_signal.connect(
|
||||
self.motor_table.add_coordinate
|
||||
)
|
||||
self.motor_control_panel.relative_widget.precision_signal.connect(
|
||||
self.motor_table.set_precision
|
||||
)
|
||||
self.motor_control_panel.relative_widget.precision_signal.connect(
|
||||
self.motor_control_panel.absolute_widget.set_precision
|
||||
)
|
||||
|
||||
self.motor_table.plot_coordinates_signal.connect(self.motion_map.plot_saved_coordinates)
|
||||
|
||||
|
||||
class MotorControlMap(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
# Widgets
|
||||
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
|
||||
# Create MotorMap
|
||||
self.motion_map = MotorMap(client=self.client, config=self.config)
|
||||
|
||||
# Create the splitter and add MotorMap and MotorControlPanel
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
splitter.addWidget(self.motion_map)
|
||||
splitter.addWidget(self.motor_control_panel)
|
||||
|
||||
# Set the main layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(splitter)
|
||||
self.setLayout(layout)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.motor_control_panel.selection_widget.selected_motors_signal.connect(
|
||||
lambda x, y: self.motion_map.change_motors(x, y, 0)
|
||||
)
|
||||
|
||||
|
||||
class MotorControlPanel(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.relative_widget = MotorControlRelative(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.absolute_widget = MotorControlAbsolute(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.relative_widget)
|
||||
layout.addWidget(self.absolute_widget)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
|
||||
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
|
||||
|
||||
# Set the window to a fixed size based on its contents
|
||||
self.layout().setSizeConstraint(layout.SetFixedSize)
|
||||
|
||||
|
||||
class MotorControlPanelAbsolute(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.absolute_widget = MotorControlAbsolute(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.absolute_widget)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
|
||||
|
||||
# Set the window to a fixed size based on its contents
|
||||
self.layout().setSizeConstraint(layout.SetFixedSize)
|
||||
|
||||
|
||||
class MotorControlPanelRelative(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.relative_widget = MotorControlRelative(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.relative_widget)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
|
||||
|
||||
# Set the window to a fixed size based on its contents
|
||||
self.layout().setSizeConstraint(layout.SetFixedSize)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run various Motor Control Widgets compositions.")
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--variant",
|
||||
type=str,
|
||||
choices=["app", "map", "panel", "panel_abs", "panel_rel"],
|
||||
help="Select the variant of the motor control to run. "
|
||||
"'app' for the full application, "
|
||||
"'map' for MotorMap, "
|
||||
"'panel' for the MotorControlPanel, "
|
||||
"'panel_abs' for MotorControlPanel with absolute control, "
|
||||
"'panel_rel' for MotorControlPanel with relative control.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
app = QApplication([])
|
||||
qdarktheme.setup_theme("auto")
|
||||
|
||||
if args.variant == "app":
|
||||
window = MotorControlApp(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "map":
|
||||
window = MotorControlMap(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel":
|
||||
window = MotorControlPanel(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel_abs":
|
||||
window = MotorControlPanelAbsolute(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel_rel":
|
||||
window = MotorControlPanelRelative(client=client, config=CONFIG_DEFAULT)
|
||||
else:
|
||||
print("Please specify a valid variant to run. Use -h for help.")
|
||||
print("Running the full application by default.")
|
||||
window = MotorControlApp(client=client, config=CONFIG_DEFAULT)
|
||||
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
926
bec_widgets/examples/motor_movement/motor_controller.ui
Normal file
926
bec_widgets/examples/motor_movement/motor_controller.ui
Normal file
@@ -0,0 +1,926 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1561</width>
|
||||
<height>748</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>1409</width>
|
||||
<height>748</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Motor Controller</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout" stretch="8,5,8">
|
||||
<item>
|
||||
<widget class="GraphicsLayoutWidget" name="glw">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QFrame" name="Controls">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>221</width>
|
||||
<height>471</height>
|
||||
</size>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_6" stretch="1,1,1,0,1">
|
||||
<property name="spacing">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SetMinimumSize</enum>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorSelection">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>145</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Motor Selection</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_8">
|
||||
<property name="text">
|
||||
<string>Motor Y</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="comboBox_motor_x"/>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QComboBox" name="comboBox_motor_y"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>Motor X</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0" colspan="2">
|
||||
<widget class="QPushButton" name="pushButton_connecMotors">
|
||||
<property name="text">
|
||||
<string>Connect Motors</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeType">
|
||||
<enum>QSizePolicy::Minimum</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>18</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorControl">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>339</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Motor Relative</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_5">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_enableArrows">
|
||||
<property name="text">
|
||||
<string>Move with arrow keys</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_same_xy">
|
||||
<property name="text">
|
||||
<string>Step [X] = Step [Y]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="step_grid">
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_step_y">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Step [Y]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Decimal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_step_x">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>0.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>99.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_step_x">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Step [X]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_step_y">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>0.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>99.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_precision">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>8</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>2</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="direction_grid">
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SetDefaultConstraint</enum>
|
||||
</property>
|
||||
<item row="1" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
|
||||
<widget class="QToolButton" name="toolButton_up">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::UpArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="4">
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
|
||||
<widget class="QToolButton" name="toolButton_down">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::DownArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QToolButton" name="toolButton_left">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::LeftArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QToolButton" name="toolButton_right">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::RightArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<spacer name="verticalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer_4">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeType">
|
||||
<enum>QSizePolicy::Minimum</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>18</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorControl_absolute">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>195</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Move Absolute</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_save_with_go">
|
||||
<property name="text">
|
||||
<string>Save position with Go</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_y">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_x">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_save">
|
||||
<property name="text">
|
||||
<string>Save</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_set">
|
||||
<property name="text">
|
||||
<string>Set</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_go_absolute">
|
||||
<property name="text">
|
||||
<string>Go</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_stop">
|
||||
<property name="text">
|
||||
<string>Stop Movement</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget_tables">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="tab_coordinates">
|
||||
<attribute name="title">
|
||||
<string>Coordinates</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Entries Mode:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="comboBox_mode">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Individual</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Start/Stop</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="tableWidget_coordinates">
|
||||
<property name="selectionMode">
|
||||
<enum>QAbstractItemView::MultiSelection</enum>
|
||||
</property>
|
||||
<property name="selectionBehavior">
|
||||
<enum>QAbstractItemView::SelectRows</enum>
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Show</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Move</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Tag</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="1" column="1">
|
||||
<widget class="QPushButton" name="pushButton_resize_table">
|
||||
<property name="text">
|
||||
<string>Resize Table</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QCheckBox" name="checkBox_resize_auto">
|
||||
<property name="text">
|
||||
<string>Resize Auto</string>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QPushButton" name="pushButton_importCSV">
|
||||
<property name="text">
|
||||
<string>Import CSV</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="pushButton_exportCSV">
|
||||
<property name="text">
|
||||
<string>Export CSV</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QPushButton" name="pushButton_help">
|
||||
<property name="text">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="pushButton_duplicate">
|
||||
<property name="text">
|
||||
<string>Duplicate Last Entry</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_settings">
|
||||
<attribute name="title">
|
||||
<string>Settings</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorLimits">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Motor Limits</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="pushButton_updateLimits">
|
||||
<property name="text">
|
||||
<string>Update</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_Y_max">
|
||||
<property name="text">
|
||||
<string>+ Y</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QLabel" name="label_Y_min">
|
||||
<property name="text">
|
||||
<string>- Y</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_X_min">
|
||||
<property name="text">
|
||||
<string>- X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLabel" name="label_X_max">
|
||||
<property name="text">
|
||||
<string>+ X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_y_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-1000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>1000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_y_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-1000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>1000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_x_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-1000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>1000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_x_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-1000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>1000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Plotting Options</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_5">
|
||||
<item row="0" column="1" colspan="2">
|
||||
<widget class="QSpinBox" name="spinBox_max_points">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>100</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>10000</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>100</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>5000</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_15">
|
||||
<property name="text">
|
||||
<string>Max Points</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1" colspan="2">
|
||||
<widget class="QSpinBox" name="spinBox_scatter_size">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>15</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>5</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_11">
|
||||
<property name="text">
|
||||
<string>Scatter Size</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0" colspan="3">
|
||||
<widget class="QPushButton" name="pushButton_update_config">
|
||||
<property name="text">
|
||||
<string>Update Settings</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1" colspan="2">
|
||||
<widget class="QSpinBox" name="spinBox_num_dim_points">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_16">
|
||||
<property name="text">
|
||||
<string>N dim</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0" colspan="3">
|
||||
<widget class="QPushButton" name="pushButton_enableGUI">
|
||||
<property name="text">
|
||||
<string>Enable Control GUI</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_queue">
|
||||
<attribute name="title">
|
||||
<string>Queue</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Work in progress</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_5">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Reset Queue</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="tableWidget_2">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>queueID</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>scanID</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>is_scan</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>type</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>scan_number</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>IQ status</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
1353
bec_widgets/examples/motor_movement/motor_example.py
Normal file
1353
bec_widgets/examples/motor_movement/motor_example.py
Normal file
File diff suppressed because it is too large
Load Diff
3
bec_widgets/examples/oneplot/config_gaussworker.yaml
Normal file
3
bec_widgets/examples/oneplot/config_gaussworker.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
x_value: "samx"
|
||||
y_values: ["gauss_bpm", "gauss_adc1", "gauss_adc2"]
|
||||
dap_worker: "gaussian_fit_worker_3"
|
||||
3
bec_widgets/examples/oneplot/config_noworker.yaml
Normal file
3
bec_widgets/examples/oneplot/config_noworker.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
x_value: "samx"
|
||||
y_values: ["gauss_bpm", "gauss_adc1", "gauss_adc2"]
|
||||
dap_worker: None
|
||||
271
bec_widgets/examples/oneplot/oneplot.py
Normal file
271
bec_widgets/examples/oneplot/oneplot.py
Normal file
@@ -0,0 +1,271 @@
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import qtpy.QtWidgets
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import MessageEndpoints
|
||||
from qtpy.QtCore import Signal as pyqtSignal, Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import QApplication, QTableWidgetItem, QWidget
|
||||
from pyqtgraph import mkBrush, mkColor, mkPen
|
||||
from pyqtgraph.Qt import QtCore, uic
|
||||
|
||||
from bec_widgets.utils import Crosshair, ctrl_c
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
# TODO implement:
|
||||
# - implement scanID database for visualizing previous scans
|
||||
# - multiple signals for different monitors
|
||||
# - change how dap is handled in bec_dispatcher to handle more workers
|
||||
|
||||
|
||||
class PlotApp(QWidget):
|
||||
"""
|
||||
Main class for the PlotApp used to plot two signals from the BEC.
|
||||
|
||||
Attributes:
|
||||
update_signal (pyqtSignal): Signal to trigger plot updates.
|
||||
update_dap_signal (pyqtSignal): Signal to trigger DAP updates.
|
||||
|
||||
Args:
|
||||
x_value (str): The x device/signal for plotting.
|
||||
y_values (list of str): List of y device/signals for plotting.
|
||||
dap_worker (str, optional): DAP process to specify. Set to None to disable.
|
||||
parent (QWidget, optional): Parent widget.
|
||||
"""
|
||||
|
||||
update_signal = pyqtSignal()
|
||||
update_dap_signal = pyqtSignal()
|
||||
|
||||
def __init__(self, x_value, y_values, dap_worker=None, parent=None):
|
||||
super(PlotApp, self).__init__(parent)
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "oneplot.ui"), self)
|
||||
|
||||
self.x_value = x_value
|
||||
self.y_values = y_values
|
||||
self.dap_worker = dap_worker
|
||||
|
||||
self.scanID = None
|
||||
self.data_x = []
|
||||
self.data_y = []
|
||||
|
||||
self.dap_x = np.array([])
|
||||
self.dap_y = np.array([])
|
||||
|
||||
self.fit = None
|
||||
|
||||
self.init_ui()
|
||||
self.init_curves()
|
||||
self.hook_crosshair()
|
||||
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self.update_plot
|
||||
)
|
||||
self.proxy_update_fit = pg.SignalProxy(
|
||||
self.update_dap_signal, rateLimit=25, slot=self.update_fit_table
|
||||
)
|
||||
|
||||
def init_ui(self) -> None:
|
||||
"""Initialize the UI components."""
|
||||
self.plot = pg.PlotItem()
|
||||
self.glw.addItem(self.plot)
|
||||
self.plot.setLabel("bottom", self.x_value)
|
||||
self.plot.setLabel("left", ", ".join(self.y_values))
|
||||
self.plot.addLegend()
|
||||
|
||||
def init_curves(self) -> None:
|
||||
"""Initialize curve data and properties."""
|
||||
self.plot.clear()
|
||||
|
||||
self.curves_data = []
|
||||
self.curves_dap = []
|
||||
|
||||
colors_y_values = PlotApp.golden_angle_color(colormap="CET-R2", num=len(self.y_values))
|
||||
# colors_y_daps = PlotApp.golden_angle_color(
|
||||
# colormap="CET-I2", num=len(self.dap_worker)
|
||||
# ) # TODO adapt for multiple dap_workers
|
||||
|
||||
# Initialize curves for y_values
|
||||
for ii, (signal, color) in enumerate(zip(self.y_values, colors_y_values)):
|
||||
pen_curve = mkPen(color=color, width=2, style=QtCore.Qt.DashLine)
|
||||
brush_curve = mkBrush(color=color)
|
||||
curve_data = pg.PlotDataItem(
|
||||
symbolSize=5,
|
||||
symbolBrush=brush_curve,
|
||||
pen=pen_curve,
|
||||
skipFiniteCheck=True,
|
||||
name=f"{signal}",
|
||||
)
|
||||
self.curves_data.append(curve_data)
|
||||
self.plot.addItem(curve_data)
|
||||
|
||||
# Initialize curves for DAP if dap_worker is not None
|
||||
if self.dap_worker is not None:
|
||||
# for ii, (monitor, color) in enumerate(zip(self.dap_worker, colors_y_daps)):#TODO adapt for multiple dap_workers
|
||||
pen_dap = mkPen(color="#3b5998", width=2, style=QtCore.Qt.DashLine)
|
||||
curve_dap = pg.PlotDataItem(
|
||||
pen=pen_dap, skipFiniteCheck=True, symbolSize=5, name=f"{self.dap_worker}"
|
||||
)
|
||||
self.curves_dap.append(curve_dap)
|
||||
self.plot.addItem(curve_dap)
|
||||
|
||||
self.tableWidget_crosshair.setRowCount(len(self.y_values))
|
||||
self.tableWidget_crosshair.setVerticalHeaderLabels(self.y_values)
|
||||
self.hook_crosshair()
|
||||
|
||||
def hook_crosshair(self) -> None:
|
||||
"""Attach the crosshair to the plot."""
|
||||
self.crosshair_1d = Crosshair(self.plot, precision=3)
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.update_table(self.tableWidget_crosshair, x, y, column=0)
|
||||
)
|
||||
self.crosshair_1d.coordinatesClicked1D.connect(
|
||||
lambda x, y: self.update_table(self.tableWidget_crosshair, x, y, column=1)
|
||||
)
|
||||
|
||||
def update_table(
|
||||
self, table_widget: qtpy.QtWidgets.QTableWidget, x: float, y_values: list, column: int
|
||||
) -> None:
|
||||
for i, y in enumerate(y_values):
|
||||
table_widget.setItem(i, column, QTableWidgetItem(f"({x}, {y})"))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update_plot(self) -> None:
|
||||
"""Update the plot data."""
|
||||
for ii, curve in enumerate(self.curves_data):
|
||||
curve.setData(self.data_x, self.data_y[ii])
|
||||
|
||||
if self.dap_worker is not None:
|
||||
# for ii, curve in enumerate(self.curves_dap): #TODO adapt for multiple dap_workers
|
||||
# curve.setData(self.dap_x, self.dap_y[ii])
|
||||
self.curves_dap[0].setData(self.dap_x, self.dap_y)
|
||||
|
||||
def update_fit_table(self):
|
||||
"""Update the table for fit data."""
|
||||
|
||||
self.tableWidget_fit.setData(self.fit)
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_dap_update(self, msg: dict, metadata: dict) -> None:
|
||||
"""
|
||||
Update DAP related data.
|
||||
|
||||
Args:
|
||||
msg (dict): Message received with data.
|
||||
metadata (dict): Metadata of the DAP.
|
||||
"""
|
||||
|
||||
# TODO adapt for multiple dap_workers
|
||||
self.dap_x = msg[self.dap_worker]["x"]
|
||||
self.dap_y = msg[self.dap_worker]["y"]
|
||||
|
||||
self.fit = metadata["fit_parameters"]
|
||||
|
||||
self.update_dap_signal.emit()
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(self, msg: dict, metadata: dict):
|
||||
"""
|
||||
Handle new scan segments.
|
||||
|
||||
Args:
|
||||
msg (dict): Message received with scan data.
|
||||
metadata (dict): Metadata of the scan.
|
||||
"""
|
||||
current_scanID = msg["scanID"]
|
||||
|
||||
if current_scanID != self.scanID:
|
||||
self.scanID = current_scanID
|
||||
self.data_x = []
|
||||
self.data_y = [[] for _ in self.y_values]
|
||||
self.init_curves()
|
||||
|
||||
dev_x = self.x_value
|
||||
data_x = msg["data"][dev_x][dev[dev_x]._hints[0]]["value"]
|
||||
self.data_x.append(data_x)
|
||||
|
||||
for ii, dev_y in enumerate(self.y_values):
|
||||
data_y = msg["data"][dev_y][dev[dev_y]._hints[0]]["value"]
|
||||
self.data_y[ii].append(data_y)
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(colormap: str, num: int) -> list:
|
||||
"""
|
||||
Extract num colors for from the specified colormap following golden angle distribution.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap
|
||||
num (int): Number of requested colors
|
||||
|
||||
Returns:
|
||||
list: List of colors with length <num>
|
||||
|
||||
Raises:
|
||||
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
|
||||
"""
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
cmap_colors = cmap.color
|
||||
if num > len(cmap_colors):
|
||||
raise ValueError(
|
||||
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
|
||||
)
|
||||
angles = PlotApp.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = [
|
||||
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
|
||||
]
|
||||
return colors
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import yaml
|
||||
|
||||
with open("config_noworker.yaml", "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
|
||||
x_value = config["x_value"]
|
||||
y_values = config["y_values"]
|
||||
dap_worker = config["dap_worker"]
|
||||
|
||||
dap_worker = None if dap_worker == "None" else dap_worker
|
||||
|
||||
# BECclient global variables
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
dev = client.device_manager.devices
|
||||
scans = client.scans
|
||||
queue = client.queue
|
||||
|
||||
app = QApplication([])
|
||||
plotApp = PlotApp(x_value=x_value, y_values=y_values, dap_worker=dap_worker)
|
||||
|
||||
# Connecting signals from bec_dispatcher
|
||||
bec_dispatcher.connect_slot(plotApp.on_dap_update, MessageEndpoints.processed_data(dap_worker))
|
||||
bec_dispatcher.connect_slot(plotApp.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
ctrl_c.setup(app)
|
||||
|
||||
window = plotApp
|
||||
window.show()
|
||||
app.exec()
|
||||
75
bec_widgets/examples/oneplot/oneplot.ui
Normal file
75
bec_widgets/examples/oneplot/oneplot.ui
Normal file
@@ -0,0 +1,75 @@
|
||||
<?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>547</width>
|
||||
<height>653</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout" stretch="1,2">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout" stretch="2,1">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>Cursor</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QTableWidget" name="tableWidget_crosshair">
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Moved</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Clicked</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Fit</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="TableWidget" name="tableWidget_fit"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="GraphicsLayoutWidget" name="glw"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>TableWidget</class>
|
||||
<extends>QTableWidget</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
130
bec_widgets/examples/plot_app/config_example.yaml
Normal file
130
bec_widgets/examples/plot_app/config_example.yaml
Normal file
@@ -0,0 +1,130 @@
|
||||
plot_settings:
|
||||
background_color: "black"
|
||||
num_columns: 2
|
||||
colormap: "plasma"
|
||||
scan_types: False # True to show scan types
|
||||
|
||||
# example to use without scan_type -> only one general configuration
|
||||
plot_data:
|
||||
- plot_name: "BPM4i plots vs samy"
|
||||
x:
|
||||
label: 'Motor Y'
|
||||
signals:
|
||||
- name: "samy"
|
||||
# entry: "samy" # here I also forgot to specify entry
|
||||
y:
|
||||
label: 'bpm4i'
|
||||
signals:
|
||||
- name: "bpm4i"
|
||||
entry: "bpm4i"
|
||||
# I will not specify entry, because I want to take hint from gauss_adc2
|
||||
- plot_name: "BPM4i plots vs samx"
|
||||
x:
|
||||
label: 'Motor Y'
|
||||
signals:
|
||||
- name: "samy"
|
||||
# entry: "samy" # here I also forgot to specify entry
|
||||
y:
|
||||
label: 'bpm4i'
|
||||
signals:
|
||||
- name: "bpm4i"
|
||||
entry: "bpm4i"
|
||||
# I will not specify entry, because I want to take hint from gauss_adc2
|
||||
- plot_name: "MCS Channel 4 (Cyberstar) vs samx"
|
||||
x:
|
||||
label: 'Motor X'
|
||||
signals:
|
||||
- name: "samx"
|
||||
entry: "samx"
|
||||
y:
|
||||
label: 'mcs4 cyberstar'
|
||||
signals:
|
||||
- name: "mcs"
|
||||
entry: "mca4"
|
||||
- plot_name: "MCS Channel 4 (Cyberstar) vs samy"
|
||||
x:
|
||||
label: 'Motor X'
|
||||
signals:
|
||||
- name: "samy"
|
||||
entry: "samy"
|
||||
y:
|
||||
label: 'mcs4 cyberstar'
|
||||
signals:
|
||||
- name: "mcs"
|
||||
entry: "mca4"
|
||||
|
||||
|
||||
|
||||
# example to use with scan_type -> different configuration for different scan types
|
||||
#plot_data:
|
||||
# line_scan:
|
||||
# - plot_name: "BPM plot"
|
||||
# x:
|
||||
# label: 'Motor X'
|
||||
# signals:
|
||||
# - name: "samx"
|
||||
# # entry: "samx"
|
||||
# y:
|
||||
# label: 'BPM'
|
||||
# signals:
|
||||
# - name: "gauss_bpm"
|
||||
# entry: "gauss_bpm"
|
||||
# - name: "gauss_adc1"
|
||||
# entry: "gauss_adc1"
|
||||
# - name: "gauss_adc2"
|
||||
# entry: "gauss_adc2"
|
||||
#
|
||||
# - plot_name: "Multi"
|
||||
# x:
|
||||
# label: 'Motor X'
|
||||
# signals:
|
||||
# - name: "samx"
|
||||
# entry: "samx"
|
||||
# y:
|
||||
# label: 'Multi'
|
||||
# signals:
|
||||
# - name: "gauss_bpm"
|
||||
# entry: "gauss_bpm"
|
||||
# - name: "samx"
|
||||
# entry: ["samx", "samx_setpoint"]
|
||||
#
|
||||
# grid_scan:
|
||||
# - plot_name: "Grid plot 1"
|
||||
# x:
|
||||
# label: 'Motor X'
|
||||
# signals:
|
||||
# - name: "samx"
|
||||
# entry: "samx"
|
||||
# y:
|
||||
# label: 'BPM'
|
||||
# signals:
|
||||
# - name: "gauss_bpm"
|
||||
# entry: "gauss_bpm"
|
||||
# - name: "gauss_adc1"
|
||||
# entry: "gauss_adc1"
|
||||
# - plot_name: "Grid plot 2"
|
||||
# x:
|
||||
# label: 'Motor X'
|
||||
# signals:
|
||||
# - name: "samx"
|
||||
# entry: "samx"
|
||||
# y:
|
||||
# label: 'BPM'
|
||||
# signals:
|
||||
# - name: "gauss_bpm"
|
||||
# entry: "gauss_bpm"
|
||||
# - name: "gauss_adc1"
|
||||
# entry: "gauss_adc1"
|
||||
#
|
||||
# - plot_name: "Grid plot 3"
|
||||
# x:
|
||||
# label: 'Motor Y'
|
||||
# signals:
|
||||
# - name: "samy"
|
||||
# entry: "samy"
|
||||
# y:
|
||||
# label: 'BPM'
|
||||
# signals:
|
||||
# - name: "gauss_bpm"
|
||||
# entry: "gauss_bpm"
|
||||
|
||||
91
bec_widgets/examples/plot_app/config_scans_example.yaml
Normal file
91
bec_widgets/examples/plot_app/config_scans_example.yaml
Normal file
@@ -0,0 +1,91 @@
|
||||
plot_settings:
|
||||
background_color: "black"
|
||||
num_columns: 2
|
||||
colormap: "plasma"
|
||||
scan_types: True # True to show scan types
|
||||
|
||||
# example to use with scan_type -> different configuration for different scan types
|
||||
plot_data:
|
||||
line_scan:
|
||||
- plot_name: "BPM plot"
|
||||
x:
|
||||
label: 'Motor X'
|
||||
signals:
|
||||
- name: "samx"
|
||||
# entry: "samx"
|
||||
y:
|
||||
label: 'BPM'
|
||||
signals:
|
||||
- name: "gauss_bpm"
|
||||
entry: "gauss_bpm"
|
||||
- name: "gauss_adc1"
|
||||
entry: "gauss_adc1"
|
||||
- name: "gauss_adc2"
|
||||
entry: "gauss_adc2"
|
||||
|
||||
- plot_name: "Multi"
|
||||
x:
|
||||
label: 'Motor X'
|
||||
signals:
|
||||
- name: "samx"
|
||||
entry: "samx"
|
||||
y:
|
||||
label: 'Multi'
|
||||
signals:
|
||||
- name: "gauss_bpm"
|
||||
entry: "gauss_bpm"
|
||||
- name: "samx"
|
||||
entry: ["samx", "samx_setpoint"]
|
||||
|
||||
grid_scan:
|
||||
- plot_name: "Grid plot 1"
|
||||
x:
|
||||
label: 'Motor X'
|
||||
signals:
|
||||
- name: "samx"
|
||||
entry: "samx"
|
||||
y:
|
||||
label: 'BPM'
|
||||
signals:
|
||||
- name: "gauss_bpm"
|
||||
entry: "gauss_bpm"
|
||||
- name: "gauss_adc1"
|
||||
entry: "gauss_adc1"
|
||||
- plot_name: "Grid plot 2"
|
||||
x:
|
||||
label: 'Motor X'
|
||||
signals:
|
||||
- name: "samx"
|
||||
entry: "samx"
|
||||
y:
|
||||
label: 'BPM'
|
||||
signals:
|
||||
- name: "gauss_bpm"
|
||||
entry: "gauss_bpm"
|
||||
- name: "gauss_adc1"
|
||||
entry: "gauss_adc1"
|
||||
|
||||
- plot_name: "Grid plot 3"
|
||||
x:
|
||||
label: 'Motor Y'
|
||||
signals:
|
||||
- name: "samy"
|
||||
entry: "samy"
|
||||
y:
|
||||
label: 'BPM'
|
||||
signals:
|
||||
- name: "gauss_bpm"
|
||||
entry: "gauss_bpm"
|
||||
|
||||
- plot_name: "Grid plot 4"
|
||||
x:
|
||||
label: 'Motor Y'
|
||||
signals:
|
||||
- name: "samy"
|
||||
entry: "samy"
|
||||
y:
|
||||
label: 'BPM'
|
||||
signals:
|
||||
- name: "gauss_adc3"
|
||||
entry: "gauss_adc3"
|
||||
|
||||
730
bec_widgets/examples/plot_app/plot_app.py
Normal file
730
bec_widgets/examples/plot_app/plot_app.py
Normal file
@@ -0,0 +1,730 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
# import traceback
|
||||
|
||||
import pyqtgraph
|
||||
import pyqtgraph as pg
|
||||
|
||||
from qtpy.QtCore import Signal as pyqtSignal, Slot as pyqtSlot
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QWidget,
|
||||
QTableWidgetItem,
|
||||
QTableWidget,
|
||||
QFileDialog,
|
||||
QMessageBox,
|
||||
)
|
||||
from pyqtgraph import ColorButton
|
||||
from pyqtgraph import mkBrush, mkPen
|
||||
from pyqtgraph.Qt import QtCore, uic
|
||||
from pyqtgraph.Qt import QtWidgets
|
||||
|
||||
from bec_lib import MessageEndpoints
|
||||
from bec_widgets.utils import Crosshair, Colors
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
# TODO implement:
|
||||
# - implement scanID database for visualizing previous scans
|
||||
|
||||
|
||||
class PlotApp(QWidget):
|
||||
"""
|
||||
Main class for PlotApp, designed to plot multiple signals in a grid layout
|
||||
based on a flexible YAML configuration.
|
||||
|
||||
Attributes:
|
||||
update_signal (pyqtSignal): Signal to trigger plot updates.
|
||||
plot_data (list of dict): List of dictionaries containing plot configurations.
|
||||
Each dictionary specifies x and y signals, including their
|
||||
name and entry, for a particular plot.
|
||||
|
||||
Args:
|
||||
config (dict): Configuration dictionary containing all settings for the plotting app.
|
||||
It should include the following keys:
|
||||
- 'plot_settings': Dictionary containing global plot settings.
|
||||
- 'plot_data': List of dictionaries specifying the signals to plot.
|
||||
parent (QWidget, optional): Parent widget.
|
||||
|
||||
Example:
|
||||
General Plot Configuration:
|
||||
{
|
||||
'plot_settings': {'background_color': 'black', 'num_columns': 2, 'colormap': 'plasma', 'scan_types': False},
|
||||
'plot_data': [
|
||||
{
|
||||
'plot_name': 'Plot A',
|
||||
'x': {'label': 'X-axis', 'signals': [{'name': 'device_x', 'entry': 'entry_x'}]},
|
||||
'y': {'label': 'Y-axis', 'signals': [{'name': 'device_y', 'entry': 'entry_y'}]}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Different Scans Mode Configuration:
|
||||
{
|
||||
'plot_settings': {'background_color': 'black', 'num_columns': 2, 'colormap': 'plasma', 'scan_types': True},
|
||||
'plot_data': {
|
||||
'scan_type_1': [
|
||||
{
|
||||
'plot_name': 'Plot 1',
|
||||
'x': {'label': 'X-axis', 'signals': [{'name': 'device_x1', 'entry': 'entry_x1'}]},
|
||||
'y': {'label': 'Y-axis', 'signals': [{'name': 'device_y1', 'entry': 'entry_y1'}]}
|
||||
}
|
||||
],
|
||||
'scan_type_2': [
|
||||
{
|
||||
'plot_name': 'Plot 2',
|
||||
'x': {'label': 'X-axis', 'signals': [{'name': 'device_x2', 'entry': 'entry_x2'}]},
|
||||
'y': {'label': 'Y-axis', 'signals': [{'name': 'device_y2', 'entry': 'entry_y2'}]}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
update_signal = pyqtSignal()
|
||||
update_dap_signal = pyqtSignal()
|
||||
|
||||
def __init__(self, config: dict, client=None, parent=None):
|
||||
super(PlotApp, self).__init__(parent)
|
||||
|
||||
# Error handler
|
||||
self.error_handler = ErrorHandler(parent=self)
|
||||
|
||||
# Client and device manager from BEC
|
||||
self.client = BECDispatcher().client if client is None else client
|
||||
self.dev = self.client.device_manager.devices
|
||||
|
||||
# Loading UI
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "plot_app.ui"), self)
|
||||
|
||||
self.data = {}
|
||||
|
||||
self.crosshairs = None
|
||||
self.plots = None
|
||||
self.curves_data = None
|
||||
self.grid_coordinates = None
|
||||
self.scanID = None
|
||||
|
||||
self.user_colors = {} # key: (plot_name, y_name, y_entry), value: color
|
||||
|
||||
# Default config
|
||||
self.config = config
|
||||
|
||||
# Validate the configuration before proceeding
|
||||
self.load_config(self.config)
|
||||
|
||||
# Default splitter size
|
||||
self.splitter.setSizes([400, 100])
|
||||
|
||||
# Buttons
|
||||
self.pushButton_save.clicked.connect(self.save_settings_to_yaml)
|
||||
self.pushButton_load.clicked.connect(self.load_settings_from_yaml)
|
||||
|
||||
# Connect the update signal to the update plot method
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self.update_plot
|
||||
)
|
||||
|
||||
# Change layout of plots when the number of columns is changed in GUI
|
||||
self.spinBox_N_columns.valueChanged.connect(lambda x: self.init_ui(x))
|
||||
|
||||
def load_config(self, config: dict) -> None:
|
||||
"""
|
||||
Load and validate the configuration, retrying until a valid configuration is provided or the user cancels.
|
||||
Args:
|
||||
config (dict): Configuration dictionary form .yaml file.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
valid_config = False
|
||||
self.error_handler.set_retry_action(self.load_settings_from_yaml)
|
||||
while not valid_config:
|
||||
if config is None:
|
||||
self.config = (
|
||||
self.load_settings_from_yaml()
|
||||
) # Load config if it hasn't been loaded yet
|
||||
try: # Validate loaded config file
|
||||
self.error_handler.validate_config_file(config)
|
||||
valid_config = True
|
||||
except ValueError as e:
|
||||
self.config = None # Reset config_to_test to force reloading configuration
|
||||
self.config = self.error_handler.handle_error(str(e))
|
||||
if valid_config is True: # Initialize config if validation succeeds
|
||||
self.init_config(self.config)
|
||||
|
||||
def init_config(self, config: dict) -> None:
|
||||
"""
|
||||
Initializes or update the configuration settings for the PlotApp.
|
||||
|
||||
Args:
|
||||
config (dict): Dictionary containing plot settings and data configurations.
|
||||
"""
|
||||
|
||||
# YAML config
|
||||
self.plot_settings = config.get("plot_settings", {})
|
||||
self.plot_data_config = config.get("plot_data", {})
|
||||
self.scan_types = self.plot_settings.get("scan_types", False)
|
||||
|
||||
if self.scan_types is False: # Device tracking mode
|
||||
self.plot_data = self.plot_data_config # TODO logic has to be improved
|
||||
else: # setup first line scan as default, then changed with different scan type
|
||||
self.plot_data = self.plot_data_config[list(self.plot_data_config.keys())[0]]
|
||||
|
||||
# Setting global plot settings
|
||||
self.init_plot_background(self.plot_settings["background_color"])
|
||||
|
||||
# Initialize the UI
|
||||
self.init_ui(self.plot_settings["num_columns"])
|
||||
self.spinBox_N_columns.setValue(
|
||||
self.plot_settings["num_columns"]
|
||||
) # TODO has to be checked if it will not setup more columns than plots
|
||||
self.spinBox_N_columns.setMaximum(len(self.plot_data))
|
||||
|
||||
def init_plot_background(self, background_color: str) -> None:
|
||||
"""
|
||||
Initialize plot settings based on the background color.
|
||||
|
||||
Args:
|
||||
background_color (str): The background color ('white' or 'black').
|
||||
|
||||
This method sets the background and foreground colors for pyqtgraph.
|
||||
If the background is dark ('black'), the foreground will be set to 'white',
|
||||
and vice versa.
|
||||
"""
|
||||
if background_color.lower() == "black":
|
||||
pg.setConfigOption("background", "k")
|
||||
pg.setConfigOption("foreground", "w")
|
||||
elif background_color.lower() == "white":
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid background color {background_color}. Allowed values are 'white' or 'black'."
|
||||
)
|
||||
|
||||
# TODO simplify -> find way how to setup also foreground color
|
||||
# if background_color.lower() not in ["black", "white"]:
|
||||
# raise ValueError(
|
||||
# f"Invalid background color {background_color}. Allowed values are 'white' or 'black'."
|
||||
# )
|
||||
# self.glw.setBackground(background_color.lower())
|
||||
|
||||
def init_ui(self, num_columns: int = 3) -> None:
|
||||
"""
|
||||
Initialize the UI components, create plots and store their grid positions.
|
||||
|
||||
Args:
|
||||
num_columns (int): Number of columns to wrap the layout.
|
||||
|
||||
This method initializes a dictionary `self.plots` to store the plot objects
|
||||
along with their corresponding x and y signal names. It dynamically arranges
|
||||
the plots in a grid layout based on the given number of columns and dynamically
|
||||
stretches the last plots to fit the remaining space.
|
||||
"""
|
||||
self.glw.clear()
|
||||
self.plots = {}
|
||||
self.grid_coordinates = []
|
||||
|
||||
num_plots = len(self.plot_data)
|
||||
|
||||
# Check if num_columns exceeds the number of plots
|
||||
if num_columns >= num_plots:
|
||||
num_columns = num_plots
|
||||
self.plot_settings["num_columns"] = num_columns # Update the settings
|
||||
print(
|
||||
f"Warning: num_columns in the YAML file was greater than the number of plots. Resetting num_columns to number of plots:{num_columns}."
|
||||
)
|
||||
else:
|
||||
self.plot_settings["num_columns"] = num_columns # Update the settings
|
||||
|
||||
num_rows = num_plots // num_columns
|
||||
last_row_cols = num_plots % num_columns
|
||||
remaining_space = num_columns - last_row_cols
|
||||
|
||||
for i, plot_config in enumerate(self.plot_data):
|
||||
row, col = i // num_columns, i % num_columns
|
||||
colspan = 1
|
||||
|
||||
if row == num_rows and remaining_space > 0:
|
||||
if last_row_cols == 1:
|
||||
colspan = num_columns
|
||||
else:
|
||||
colspan = remaining_space // last_row_cols + 1
|
||||
remaining_space -= colspan - 1
|
||||
last_row_cols -= 1
|
||||
|
||||
plot_name = plot_config.get("plot_name", "")
|
||||
x_label = plot_config["x"].get("label", "")
|
||||
y_label = plot_config["y"].get("label", "")
|
||||
|
||||
plot = self.glw.addPlot(row=row, col=col, colspan=colspan, title=plot_name)
|
||||
plot.setLabel("bottom", x_label)
|
||||
plot.setLabel("left", y_label)
|
||||
plot.addLegend()
|
||||
|
||||
self.plots[plot_name] = plot
|
||||
self.grid_coordinates.append((row, col))
|
||||
|
||||
self.init_curves()
|
||||
|
||||
def init_curves(self) -> None:
|
||||
"""
|
||||
Initialize curve data and properties, and update table row labels.
|
||||
|
||||
This method initializes a nested dictionary `self.curves_data` to store
|
||||
the curve objects for each x and y signal pair. It also updates the row labels
|
||||
in `self.tableWidget_crosshair` to include the grid position for each y-value.
|
||||
"""
|
||||
self.curves_data = {}
|
||||
row_labels = []
|
||||
|
||||
for idx, plot_config in enumerate(self.plot_data):
|
||||
plot_name = plot_config.get("plot_name", "")
|
||||
plot = self.plots[plot_name]
|
||||
plot.clear()
|
||||
|
||||
y_configs = plot_config["y"]["signals"]
|
||||
colors_ys = Colors.golden_angle_color(
|
||||
colormap=self.plot_settings["colormap"], num=len(y_configs)
|
||||
)
|
||||
|
||||
curve_list = []
|
||||
for i, (y_config, color) in enumerate(zip(y_configs, colors_ys)):
|
||||
# print(y_config)
|
||||
y_name = y_config["name"]
|
||||
y_entries = y_config.get("entry", [y_name])
|
||||
|
||||
if not isinstance(y_entries, list):
|
||||
y_entries = [y_entries]
|
||||
|
||||
for y_entry in y_entries:
|
||||
user_color = self.user_colors.get((plot_name, y_name, y_entry), None)
|
||||
color_to_use = user_color if user_color else color
|
||||
|
||||
pen_curve = mkPen(color=color_to_use, width=2, style=QtCore.Qt.DashLine)
|
||||
brush_curve = mkBrush(color=color_to_use)
|
||||
|
||||
curve_data = pg.PlotDataItem(
|
||||
symbolSize=5,
|
||||
symbolBrush=brush_curve,
|
||||
pen=pen_curve,
|
||||
skipFiniteCheck=True,
|
||||
name=f"{y_name} ({y_entry})",
|
||||
)
|
||||
|
||||
curve_list.append((y_name, y_entry, curve_data))
|
||||
plot.addItem(curve_data)
|
||||
row_labels.append(f"{y_name} ({y_entry}) - {plot_name}")
|
||||
|
||||
# Create a ColorButton and set its color
|
||||
color_btn = ColorButton()
|
||||
color_btn.setColor(color_to_use)
|
||||
color_btn.sigColorChanged.connect(
|
||||
lambda btn=color_btn, plot=plot_name, yname=y_name, yentry=y_entry, curve=curve_data: self.change_curve_color(
|
||||
btn, plot, yname, yentry, curve
|
||||
)
|
||||
)
|
||||
|
||||
# Add the ColorButton as a QWidget to the table
|
||||
color_widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QHBoxLayout()
|
||||
layout.addWidget(color_btn)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
color_widget.setLayout(layout)
|
||||
|
||||
row = len(row_labels) - 1 # The row index in the table
|
||||
self.tableWidget_crosshair.setCellWidget(row, 2, color_widget)
|
||||
|
||||
self.curves_data[plot_name] = curve_list
|
||||
|
||||
self.tableWidget_crosshair.setRowCount(len(row_labels))
|
||||
self.tableWidget_crosshair.setVerticalHeaderLabels(row_labels)
|
||||
self.hook_crosshair()
|
||||
|
||||
def change_curve_color(
|
||||
self,
|
||||
btn: pyqtgraph.ColorButton,
|
||||
plot_name: str,
|
||||
y_name: str,
|
||||
y_entry: str,
|
||||
curve: pyqtgraph.PlotDataItem,
|
||||
) -> None:
|
||||
"""
|
||||
Change the color of a curve and update the corresponding ColorButton.
|
||||
|
||||
Args:
|
||||
btn (ColorButton): The ColorButton that was clicked.
|
||||
plot_name (str): The name of the plot where the curve belongs.
|
||||
y_name (str): The name of the y signal.
|
||||
y_entry (str): The entry of the y signal.
|
||||
curve (PlotDataItem): The curve to be changed.
|
||||
"""
|
||||
color = btn.color()
|
||||
pen_curve = mkPen(color=color, width=2, style=QtCore.Qt.DashLine)
|
||||
brush_curve = mkBrush(color=color)
|
||||
curve.setPen(pen_curve)
|
||||
curve.setSymbolBrush(brush_curve)
|
||||
self.user_colors[(plot_name, y_name, y_entry)] = color
|
||||
|
||||
def hook_crosshair(self) -> None:
|
||||
"""Attach crosshairs to each plot and connect them to the update_table method."""
|
||||
self.crosshairs = {}
|
||||
for plot_name, plot in self.plots.items():
|
||||
crosshair = Crosshair(plot, precision=3)
|
||||
crosshair.coordinatesChanged1D.connect(
|
||||
lambda x, y, plot=plot: self.update_table(
|
||||
self.tableWidget_crosshair, x, y, column=0, plot=plot
|
||||
)
|
||||
)
|
||||
crosshair.coordinatesClicked1D.connect(
|
||||
lambda x, y, plot=plot: self.update_table(
|
||||
self.tableWidget_crosshair, x, y, column=1, plot=plot
|
||||
)
|
||||
)
|
||||
self.crosshairs[plot_name] = crosshair
|
||||
|
||||
def update_table(
|
||||
self, table_widget: QTableWidget, x: float, y_values: list, column: int, plot: pg.PlotItem
|
||||
) -> None:
|
||||
"""
|
||||
Update the table with coordinates based on cursor movements and clicks.
|
||||
|
||||
Args:
|
||||
table_widget (QTableWidget): The table to be updated.
|
||||
x (float): The x-coordinate from the plot.
|
||||
y_values (list): The y-coordinates from the plot.
|
||||
column (int): The column in the table to be updated.
|
||||
plot (PlotItem): The plot from which the coordinates are taken.
|
||||
|
||||
This method calculates the correct row in the table for each y-value
|
||||
and updates the cell at (row, column) with the new x and y coordinates.
|
||||
"""
|
||||
plot_name = [name for name, value in self.plots.items() if value == plot][0]
|
||||
|
||||
starting_row = 0
|
||||
for plot_config in self.plot_data:
|
||||
if plot_config.get("plot_name", "") == plot_name:
|
||||
break
|
||||
for y_config in plot_config.get("y", {}).get("signals", []):
|
||||
y_entries = y_config.get("entry", [y_config.get("name", "")])
|
||||
if not isinstance(y_entries, list):
|
||||
y_entries = [y_entries]
|
||||
starting_row += len(y_entries)
|
||||
|
||||
for i, y in enumerate(y_values):
|
||||
row = starting_row + i
|
||||
table_widget.setItem(row, column, QTableWidgetItem(f"({x}, {y})"))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update_plot(self) -> None:
|
||||
"""Update the plot data based on the stored data dictionary."""
|
||||
for plot_name, curve_list in self.curves_data.items():
|
||||
for y_name, y_entry, curve in curve_list:
|
||||
x_config = next(
|
||||
(pc["x"] for pc in self.plot_data if pc.get("plot_name") == plot_name), {}
|
||||
)
|
||||
x_signal_config = x_config["signals"][0]
|
||||
x_name = x_signal_config.get("name", "")
|
||||
x_entry = x_signal_config.get("entry", x_name)
|
||||
|
||||
key = (x_name, x_entry, y_name, y_entry)
|
||||
data_x = self.data.get(key, {}).get("x", [])
|
||||
data_y = self.data.get(key, {}).get("y", [])
|
||||
|
||||
curve.setData(data_x, data_y)
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(
|
||||
self, msg, metadata
|
||||
) -> None: # TODO the logic should be separated from GUI operation
|
||||
"""
|
||||
Handle new scan segments and saves data to a dictionary. Linked through bec_dispatcher.
|
||||
|
||||
Args:
|
||||
msg (dict): Message received with scan data.
|
||||
metadata (dict): Metadata of the scan.
|
||||
"""
|
||||
|
||||
current_scanID = msg.get("scanID", None)
|
||||
if current_scanID is None:
|
||||
return
|
||||
|
||||
if current_scanID != self.scanID:
|
||||
if self.scan_types is False:
|
||||
self.plot_data = self.plot_data_config
|
||||
elif self.scan_types is True:
|
||||
currentName = metadata.get("scan_name")
|
||||
if currentName is None:
|
||||
raise ValueError(
|
||||
f"Scan name not found in metadata. Please check the scan_name in the YAML config or in bec "
|
||||
f"configuration."
|
||||
)
|
||||
self.plot_data = self.plot_data_config.get(currentName, [])
|
||||
if self.plot_data == []:
|
||||
raise ValueError(
|
||||
f"Scan name {currentName} not found in the YAML config. Please check the scan_name in the "
|
||||
f"YAML config or in bec configuration."
|
||||
)
|
||||
|
||||
# Init UI
|
||||
self.init_ui(self.plot_settings["num_columns"])
|
||||
self.spinBox_N_columns.setValue(
|
||||
self.plot_settings["num_columns"]
|
||||
) # TODO has to be checked if it will not setup more columns than plots
|
||||
self.spinBox_N_columns.setMaximum(len(self.plot_data))
|
||||
|
||||
self.scanID = current_scanID
|
||||
self.data = {}
|
||||
self.init_curves()
|
||||
|
||||
for plot_config in self.plot_data:
|
||||
plot_name = plot_config.get("plot_name", "Unnamed Plot")
|
||||
x_config = plot_config["x"]
|
||||
x_signal_config = x_config["signals"][0] # Assuming there's at least one signal for x
|
||||
|
||||
x_name = x_signal_config.get("name", "")
|
||||
if not x_name:
|
||||
raise ValueError(f"Name for x signal must be specified in plot: {plot_name}.")
|
||||
|
||||
x_entry_list = x_signal_config.get("entry", [])
|
||||
if not x_entry_list:
|
||||
x_entry_list = (
|
||||
self.dev[x_name]._hints if hasattr(self.dev[x_name], "_hints") else [x_name]
|
||||
)
|
||||
|
||||
if not isinstance(x_entry_list, list):
|
||||
x_entry_list = [x_entry_list]
|
||||
|
||||
y_configs = plot_config["y"]["signals"]
|
||||
|
||||
for x_entry in x_entry_list:
|
||||
for y_config in y_configs:
|
||||
y_name = y_config.get("name", "")
|
||||
if not y_name:
|
||||
raise ValueError(
|
||||
f"Name for y signal must be specified in plot: {plot_name}."
|
||||
)
|
||||
|
||||
y_entry_list = y_config.get("entry", [])
|
||||
if not y_entry_list:
|
||||
y_entry_list = (
|
||||
self.dev[y_name]._hints
|
||||
if hasattr(self.dev[y_name], "_hints")
|
||||
else [y_name]
|
||||
)
|
||||
|
||||
if not isinstance(y_entry_list, list):
|
||||
y_entry_list = [y_entry_list]
|
||||
|
||||
for y_entry in y_entry_list:
|
||||
key = (x_name, x_entry, y_name, y_entry)
|
||||
|
||||
data_x = msg["data"].get(x_name, {}).get(x_entry, {}).get("value", None)
|
||||
data_y = msg["data"].get(y_name, {}).get(y_entry, {}).get("value", None)
|
||||
|
||||
if data_x is None:
|
||||
raise ValueError(
|
||||
f"Incorrect entry '{x_entry}' specified for x in plot: {plot_name}, x name: {x_name}"
|
||||
)
|
||||
|
||||
if data_y is None:
|
||||
if hasattr(self.dev[y_name], "_hints"):
|
||||
raise ValueError(
|
||||
f"Incorrect entry '{y_entry}' specified for y in plot: {plot_name}, y name: {y_name}"
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"No hints available for y in plot: {plot_name}, and name '{y_name}' did not work as entry"
|
||||
)
|
||||
|
||||
if data_x is not None:
|
||||
self.data.setdefault(key, {}).setdefault("x", []).append(data_x)
|
||||
|
||||
if data_y is not None:
|
||||
self.data.setdefault(key, {}).setdefault("y", []).append(data_y)
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
def save_settings_to_yaml(self):
|
||||
"""Save the current settings to a .yaml file using a file dialog."""
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.DontUseNativeDialog
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Save Settings", "", "YAML Files (*.yaml);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
if file_path:
|
||||
try:
|
||||
if not file_path.endswith(".yaml"):
|
||||
file_path += ".yaml"
|
||||
|
||||
with open(file_path, "w") as file:
|
||||
yaml.dump(
|
||||
{"plot_settings": self.plot_settings, "plot_data": self.plot_data_config},
|
||||
file,
|
||||
)
|
||||
print(f"Settings saved to {file_path}")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while saving the settings to {file_path}: {e}")
|
||||
|
||||
def load_settings_from_yaml(self) -> dict: # TODO can be replace by the utils function
|
||||
"""Load settings from a .yaml file using a file dialog and update the current settings."""
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.DontUseNativeDialog
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Load Settings", "", "YAML Files (*.yaml);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
if file_path:
|
||||
try:
|
||||
with open(file_path, "r") as file:
|
||||
self.config = yaml.safe_load(file)
|
||||
self.load_config(self.config) # validate new config
|
||||
return config
|
||||
except FileNotFoundError:
|
||||
print(f"The file {file_path} was not found.")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while loading the settings from {file_path}: {e}")
|
||||
return None # Return None on exception to indicate failure
|
||||
|
||||
|
||||
class ErrorHandler:
|
||||
def __init__(self, parent=None):
|
||||
self.parent = parent
|
||||
self.errors = []
|
||||
self.retry_action = None
|
||||
logging.basicConfig(level=logging.ERROR) # Configure logging
|
||||
|
||||
def set_retry_action(self, action):
|
||||
self.retry_action = action # Store a reference to the retry action
|
||||
|
||||
def handle_error(self, error_message: str):
|
||||
# logging.error(f"{error_message}\n{traceback.format_exc()}") #TODO decide if useful
|
||||
|
||||
choice = QMessageBox.critical(
|
||||
self.parent,
|
||||
"Error",
|
||||
f"{error_message}\n\nWould you like to retry?",
|
||||
QMessageBox.Retry | QMessageBox.Cancel,
|
||||
)
|
||||
if choice == QMessageBox.Retry and self.retry_action is not None:
|
||||
return self.retry_action()
|
||||
else:
|
||||
exit(1) # Exit the program if the user selects Cancel or if no retry_action is provided
|
||||
|
||||
def validate_config_file(self, config: dict) -> None:
|
||||
"""
|
||||
Validate the configuration dictionary.
|
||||
Args:
|
||||
config (dict): Configuration dictionary form .yaml file.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self.errors = []
|
||||
|
||||
# Validate common keys
|
||||
required_top_level_keys = ["plot_settings", "plot_data"]
|
||||
for key in required_top_level_keys:
|
||||
if key not in config:
|
||||
self.errors.append(f"Missing required key: {key}")
|
||||
|
||||
# Only continue if no errors so far
|
||||
if not self.errors:
|
||||
# Determine the configuration mode (device or scan)
|
||||
plot_settings = config.get("plot_settings", {})
|
||||
scan_types = plot_settings.get("scan_types", False)
|
||||
|
||||
plot_data = config.get("plot_data", [])
|
||||
|
||||
if scan_types:
|
||||
# Validate scan mode configuration
|
||||
for scan_type, plots in plot_data.items():
|
||||
for i, plot_config in enumerate(plots):
|
||||
self.validate_plot_config(plot_config, i)
|
||||
else:
|
||||
# Validate device mode configuration
|
||||
for i, plot_config in enumerate(plot_data):
|
||||
self.validate_plot_config(plot_config, i)
|
||||
|
||||
if self.errors != []:
|
||||
self.handle_error("\n".join(self.errors))
|
||||
|
||||
def validate_plot_config(self, plot_config: dict, i: int):
|
||||
"""
|
||||
Validate individual plot configuration.
|
||||
Args:
|
||||
plot_config (dict): Individual plot configuration.
|
||||
i (int): Index of the plot configuration.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
for axis in ["x", "y"]:
|
||||
axis_config = plot_config.get(axis)
|
||||
plot_name = plot_config.get("plot_name", "")
|
||||
if axis_config is None:
|
||||
error_msg = f"Missing '{axis}' configuration in plot {i} - {plot_name}"
|
||||
logging.error(error_msg) # Log the error
|
||||
self.errors.append(error_msg)
|
||||
|
||||
signals_config = axis_config.get("signals")
|
||||
if signals_config is None:
|
||||
error_msg = (
|
||||
f"Missing 'signals' configuration for {axis} axis in plot {i} - '{plot_name}'"
|
||||
)
|
||||
logging.error(error_msg) # Log the error
|
||||
self.errors.append(error_msg)
|
||||
elif not isinstance(signals_config, list) or len(signals_config) == 0:
|
||||
error_msg = (
|
||||
f"'signals' configuration for {axis} axis in plot {i} must be a non-empty list"
|
||||
)
|
||||
logging.error(error_msg) # Log the error
|
||||
self.errors.append(error_msg)
|
||||
# TODO add condition for name and entry
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import yaml
|
||||
import argparse
|
||||
|
||||
# from bec_widgets import ctrl_c
|
||||
parser = argparse.ArgumentParser(description="Plotting App")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
"-c",
|
||||
help="Path to the .yaml configuration file",
|
||||
default="config_example.yaml",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
with open(args.config, "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"The file {args.config} was not found.")
|
||||
exit(1)
|
||||
except Exception as e:
|
||||
print(f"An error occurred while loading the config file: {e}")
|
||||
exit(1)
|
||||
|
||||
# BECclient global variables
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
app = QApplication([])
|
||||
plotApp = PlotApp(config=config, client=client)
|
||||
|
||||
# Connecting signals from bec_dispatcher
|
||||
bec_dispatcher.connect_slot(plotApp.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
# ctrl_c.setup(app)
|
||||
|
||||
window = plotApp
|
||||
window.show()
|
||||
app.exec()
|
||||
115
bec_widgets/examples/plot_app/plot_app.ui
Normal file
115
bec_widgets/examples/plot_app/plot_app.ui
Normal file
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MultiWindow</class>
|
||||
<widget class="QWidget" name="MultiWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1248</width>
|
||||
<height>564</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MultiWindow</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="GraphicsLayoutWidget" name="glw"/>
|
||||
<widget class="QWidget" name="">
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="1" column="0">
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="0" colspan="3">
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>Cursor</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QTableWidget" name="tableWidget_crosshair">
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Moved</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Clicked</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Color</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Number of Columns:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QSpinBox" name="spinBox_N_columns">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>3</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QPushButton" name="pushButton_load">
|
||||
<property name="text">
|
||||
<string>Load Config</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="pushButton_save">
|
||||
<property name="text">
|
||||
<string>Save Config</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,18 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
"""PySide6 port of the Qt Designer taskmenuextension example from Qt v6.x"""
|
||||
|
||||
import sys
|
||||
|
||||
from bec_ipython_client.main import BECIPythonClient
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
window = TicTacToe()
|
||||
window.state = "-X-XO----"
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,13 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoeplugin import TicTacToePlugin
|
||||
|
||||
# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(TicTacToePlugin())
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"files": ["tictactoe.py", "main.py", "registertictactoe.py", "tictactoeplugin.py",
|
||||
"tictactoetaskmenu.py"]
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtCore import Property, QPoint, QRect, QSize, Qt, Slot
|
||||
from qtpy.QtGui import QPainter, QPen
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
EMPTY = "-"
|
||||
CROSS = "X"
|
||||
NOUGHT = "O"
|
||||
DEFAULT_STATE = "---------"
|
||||
|
||||
|
||||
class TicTacToe(QWidget): # pragma: no cover
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._state = DEFAULT_STATE
|
||||
self._turn_number = 0
|
||||
|
||||
def minimumSizeHint(self):
|
||||
return QSize(200, 200)
|
||||
|
||||
def sizeHint(self):
|
||||
return QSize(200, 200)
|
||||
|
||||
def setState(self, new_state):
|
||||
self._turn_number = 0
|
||||
self._state = DEFAULT_STATE
|
||||
for position in range(min(9, len(new_state))):
|
||||
mark = new_state[position]
|
||||
if mark == CROSS or mark == NOUGHT:
|
||||
self._turn_number += 1
|
||||
self._change_state_at(position, mark)
|
||||
position += 1
|
||||
self.update()
|
||||
|
||||
def state(self):
|
||||
return self._state
|
||||
|
||||
@Slot()
|
||||
def clear_board(self):
|
||||
self._state = DEFAULT_STATE
|
||||
self._turn_number = 0
|
||||
self.update()
|
||||
|
||||
def _change_state_at(self, pos, new_state):
|
||||
self._state = self._state[:pos] + new_state + self._state[pos + 1 :]
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if self._turn_number == 9:
|
||||
self.clear_board()
|
||||
return
|
||||
for position in range(9):
|
||||
cell = self._cell_rect(position)
|
||||
if cell.contains(event.position().toPoint()):
|
||||
if self._state[position] == EMPTY:
|
||||
new_state = CROSS if self._turn_number % 2 == 0 else NOUGHT
|
||||
self._change_state_at(position, new_state)
|
||||
self._turn_number += 1
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
with QPainter(self) as painter:
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
painter.setPen(QPen(Qt.darkGreen, 1))
|
||||
painter.drawLine(self._cell_width(), 0, self._cell_width(), self.height())
|
||||
painter.drawLine(2 * self._cell_width(), 0, 2 * self._cell_width(), self.height())
|
||||
painter.drawLine(0, self._cell_height(), self.width(), self._cell_height())
|
||||
painter.drawLine(0, 2 * self._cell_height(), self.width(), 2 * self._cell_height())
|
||||
|
||||
painter.setPen(QPen(Qt.darkBlue, 2))
|
||||
|
||||
for position in range(9):
|
||||
cell = self._cell_rect(position)
|
||||
if self._state[position] == CROSS:
|
||||
painter.drawLine(cell.topLeft(), cell.bottomRight())
|
||||
painter.drawLine(cell.topRight(), cell.bottomLeft())
|
||||
elif self._state[position] == NOUGHT:
|
||||
painter.drawEllipse(cell)
|
||||
|
||||
painter.setPen(QPen(Qt.yellow, 3))
|
||||
|
||||
for position in range(0, 8, 3):
|
||||
if (
|
||||
self._state[position] != EMPTY
|
||||
and self._state[position + 1] == self._state[position]
|
||||
and self._state[position + 2] == self._state[position]
|
||||
):
|
||||
y = self._cell_rect(position).center().y()
|
||||
painter.drawLine(0, y, self.width(), y)
|
||||
self._turn_number = 9
|
||||
|
||||
for position in range(3):
|
||||
if (
|
||||
self._state[position] != EMPTY
|
||||
and self._state[position + 3] == self._state[position]
|
||||
and self._state[position + 6] == self._state[position]
|
||||
):
|
||||
x = self._cell_rect(position).center().x()
|
||||
painter.drawLine(x, 0, x, self.height())
|
||||
self._turn_number = 9
|
||||
|
||||
if (
|
||||
self._state[0] != EMPTY
|
||||
and self._state[4] == self._state[0]
|
||||
and self._state[8] == self._state[0]
|
||||
):
|
||||
painter.drawLine(0, 0, self.width(), self.height())
|
||||
self._turn_number = 9
|
||||
|
||||
if (
|
||||
self._state[2] != EMPTY
|
||||
and self._state[4] == self._state[2]
|
||||
and self._state[6] == self._state[2]
|
||||
):
|
||||
painter.drawLine(0, self.height(), self.width(), 0)
|
||||
self._turn_number = 9
|
||||
|
||||
def _cell_rect(self, position):
|
||||
h_margin = self.width() / 30
|
||||
v_margin = self.height() / 30
|
||||
row = int(position / 3)
|
||||
column = position - 3 * row
|
||||
pos = QPoint(column * self._cell_width() + h_margin, row * self._cell_height() + v_margin)
|
||||
size = QSize(self._cell_width() - 2 * h_margin, self._cell_height() - 2 * v_margin)
|
||||
return QRect(pos, size)
|
||||
|
||||
def _cell_width(self):
|
||||
return self.width() / 3
|
||||
|
||||
def _cell_height(self):
|
||||
return self.height() / 3
|
||||
|
||||
state = Property(str, state, setState)
|
||||
@@ -1,73 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoetaskmenu import TicTacToeTaskMenuFactory
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='TicTacToe' name='ticTacToe'>
|
||||
<property name='geometry'>
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>200</width>
|
||||
<height>200</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name='state'>
|
||||
<string>-X-XO----</string>
|
||||
</property>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class TicTacToePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = TicTacToe(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "Games"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon("sports_esports")
|
||||
|
||||
def includeFile(self):
|
||||
return "tictactoe"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
manager = form_editor.extensionManager()
|
||||
iid = TicTacToeTaskMenuFactory.task_menu_iid()
|
||||
manager.registerExtensions(TicTacToeTaskMenuFactory(manager), iid)
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "TicTacToe"
|
||||
|
||||
def toolTip(self):
|
||||
return "Tic Tac Toe Example, demonstrating class QDesignerTaskMenuExtension (Python)"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -1,68 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QExtensionFactory, QPyDesignerTaskMenuExtension
|
||||
from qtpy.QtGui import QAction
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
|
||||
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
|
||||
from bec_widgets.utils.error_popups import SafeSlot as Slot
|
||||
|
||||
|
||||
class TicTacToeDialog(QDialog): # pragma: no cover
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
self._ticTacToe = TicTacToe(self)
|
||||
layout.addWidget(self._ticTacToe)
|
||||
button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Reset
|
||||
)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
reset_button = button_box.button(QDialogButtonBox.Reset)
|
||||
reset_button.clicked.connect(self._ticTacToe.clear_board)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def set_state(self, new_state):
|
||||
self._ticTacToe.setState(new_state)
|
||||
|
||||
def state(self):
|
||||
return self._ticTacToe.state
|
||||
|
||||
|
||||
class TicTacToeTaskMenu(QPyDesignerTaskMenuExtension):
|
||||
def __init__(self, ticTacToe, parent):
|
||||
super().__init__(parent)
|
||||
self._ticTacToe = ticTacToe
|
||||
self._edit_state_action = QAction("Edit State...", None)
|
||||
self._edit_state_action.triggered.connect(self._edit_state)
|
||||
|
||||
def taskActions(self):
|
||||
return [self._edit_state_action]
|
||||
|
||||
def preferredEditAction(self):
|
||||
return self._edit_state_action
|
||||
|
||||
@Slot()
|
||||
def _edit_state(self):
|
||||
dialog = TicTacToeDialog(self._ticTacToe)
|
||||
dialog.set_state(self._ticTacToe.state)
|
||||
if dialog.exec() == QDialog.Accepted:
|
||||
self._ticTacToe.state = dialog.state()
|
||||
|
||||
|
||||
class TicTacToeTaskMenuFactory(QExtensionFactory):
|
||||
def __init__(self, extension_manager):
|
||||
super().__init__(extension_manager)
|
||||
|
||||
@staticmethod
|
||||
def task_menu_iid():
|
||||
return "org.qt-project.Qt.Designer.TaskMenu"
|
||||
|
||||
def createExtension(self, object, iid, parent):
|
||||
if iid != TicTacToeTaskMenuFactory.task_menu_iid():
|
||||
return None
|
||||
if object.__class__.__name__ != "TicTacToe":
|
||||
return None
|
||||
return TicTacToeTaskMenu(object, parent)
|
||||
155
bec_widgets/examples/stream_plot/line_plot.ui
Normal file
155
bec_widgets/examples/stream_plot/line_plot.ui
Normal file
@@ -0,0 +1,155 @@
|
||||
<?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>845</width>
|
||||
<height>635</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Line Plot</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="QSplitter" name="splitter_plot">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>1</horstretch>
|
||||
<verstretch>1</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<widget class="GraphicsLayoutWidget" name="glw_plot"/>
|
||||
<widget class="GraphicsLayoutWidget" name="glw_image"/>
|
||||
</widget>
|
||||
<widget class="QWidget" name="">
|
||||
<layout class="QVBoxLayout" name="verticalLayout" stretch="1,1,1,15">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_generate">
|
||||
<property name="text">
|
||||
<string>Generate 1D and 2D data without stream</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>1st angle of azimutal segment (deg)</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QDoubleSpinBox" name="doubleSpinBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>360.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.250000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="comboBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>f1amp</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>f2amp</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>f2 phase</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Precision</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="spinBox_precision">
|
||||
<property name="value">
|
||||
<number>4</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="cursor_table">
|
||||
<property name="textElideMode">
|
||||
<enum>Qt::ElideMiddle</enum>
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Display</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
335
bec_widgets/examples/stream_plot/stream_plot.py
Normal file
335
bec_widgets/examples/stream_plot/stream_plot.py
Normal file
@@ -0,0 +1,335 @@
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import messages, MessageEndpoints
|
||||
from bec_lib.redis_connector import RedisConnector
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import QTableWidgetItem
|
||||
from pyqtgraph import mkBrush, mkPen
|
||||
from pyqtgraph.Qt import QtCore, QtWidgets, uic
|
||||
from pyqtgraph.Qt.QtCore import pyqtSignal
|
||||
from bec_widgets.utils import Crosshair, Colors
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
class StreamPlot(QtWidgets.QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
roi_signal = pyqtSignal(tuple)
|
||||
|
||||
def __init__(self, name="", y_value_list=["gauss_bpm"], client=None, parent=None) -> None:
|
||||
"""
|
||||
Basic plot widget for displaying scan data.
|
||||
|
||||
Args:
|
||||
name (str, optional): Name of the plot. Defaults to "".
|
||||
y_value_list (list, optional): List of signals to be plotted. Defaults to ["gauss_bpm"].
|
||||
"""
|
||||
|
||||
# Client and device manager from BEC
|
||||
self.client = BECDispatcher().client if client is None else client
|
||||
|
||||
super(StreamPlot, self).__init__()
|
||||
# Set style for pyqtgraph plots
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "line_plot.ui"), self)
|
||||
|
||||
self._idle_time = 100
|
||||
self.connector = RedisConnector(["localhost:6379"])
|
||||
|
||||
self.y_value_list = y_value_list
|
||||
self.previous_y_value_list = None
|
||||
self.plotter_data_x = []
|
||||
self.plotter_data_y = []
|
||||
|
||||
self.plotter_scan_id = None
|
||||
|
||||
self._current_proj = None
|
||||
self._current_metadata_ep = "px_stream/projection_{}/metadata"
|
||||
|
||||
self.proxy_update = pg.SignalProxy(self.update_signal, rateLimit=25, slot=self.update)
|
||||
|
||||
self._data_retriever_thread_exit_event = threading.Event()
|
||||
self.data_retriever = threading.Thread(
|
||||
target=self.on_projection, args=(self._data_retriever_thread_exit_event,), daemon=True
|
||||
)
|
||||
self.data_retriever.start()
|
||||
|
||||
##########################
|
||||
# UI
|
||||
##########################
|
||||
self.init_ui()
|
||||
self.init_curves()
|
||||
self.hook_crosshair()
|
||||
|
||||
def close(self):
|
||||
super().close()
|
||||
self._data_retriever_thread_exit_event.set()
|
||||
self.data_retriever.join()
|
||||
|
||||
def init_ui(self):
|
||||
"""Setup all ui elements"""
|
||||
##########################
|
||||
# 1D Plot
|
||||
##########################
|
||||
|
||||
# LabelItem for ROI
|
||||
self.label_plot = pg.LabelItem(justify="center")
|
||||
self.glw_plot.addItem(self.label_plot)
|
||||
self.label_plot.setText("ROI region")
|
||||
|
||||
# ROI selector - so far from [-1,1] #TODO update to scale with xrange
|
||||
self.roi_selector = pg.LinearRegionItem([-1, 1])
|
||||
|
||||
self.glw_plot.nextRow() # TODO update of cursor
|
||||
self.label_plot_moved = pg.LabelItem(justify="center")
|
||||
self.glw_plot.addItem(self.label_plot_moved)
|
||||
self.label_plot_moved.setText("Actual coordinates (X, Y)")
|
||||
|
||||
# Label for coordinates clicked
|
||||
self.glw_plot.nextRow()
|
||||
self.label_plot_clicked = pg.LabelItem(justify="center")
|
||||
self.glw_plot.addItem(self.label_plot_clicked)
|
||||
self.label_plot_clicked.setText("Clicked coordinates (X, Y)")
|
||||
|
||||
# 1D PlotItem
|
||||
self.glw_plot.nextRow()
|
||||
self.plot = pg.PlotItem()
|
||||
self.plot.setLogMode(True, True)
|
||||
self.glw_plot.addItem(self.plot)
|
||||
self.plot.addLegend()
|
||||
|
||||
##########################
|
||||
# 2D Plot
|
||||
##########################
|
||||
|
||||
# Label for coordinates moved
|
||||
self.label_image_moved = pg.LabelItem(justify="center")
|
||||
self.glw_image.addItem(self.label_image_moved)
|
||||
self.label_image_moved.setText("Actual coordinates (X, Y)")
|
||||
|
||||
# Label for coordinates clicked
|
||||
self.glw_image.nextRow()
|
||||
self.label_image_clicked = pg.LabelItem(justify="center")
|
||||
self.glw_image.addItem(self.label_image_clicked)
|
||||
self.label_image_clicked.setText("Clicked coordinates (X, Y)")
|
||||
|
||||
# TODO try to lock aspect ratio with view
|
||||
|
||||
# # Create a window
|
||||
# win = pg.GraphicsLayoutWidget()
|
||||
# win.show()
|
||||
#
|
||||
# # Create a ViewBox
|
||||
# view = win.addViewBox()
|
||||
#
|
||||
# # Lock the aspect ratio
|
||||
# view.setAspectLocked(True)
|
||||
|
||||
# # Create an ImageItem
|
||||
# image_item = pg.ImageItem(np.random.random((100, 100)))
|
||||
#
|
||||
# # Add the ImageItem to the ViewBox
|
||||
# view.addItem(image_item)
|
||||
|
||||
# 2D ImageItem
|
||||
self.glw_image.nextRow()
|
||||
self.plot_image = pg.PlotItem()
|
||||
self.glw_image.addItem(self.plot_image)
|
||||
|
||||
def init_curves(self):
|
||||
# init of 1D plot
|
||||
self.plot.clear()
|
||||
|
||||
self.curves = []
|
||||
self.pens = []
|
||||
self.brushs = []
|
||||
|
||||
self.color_list = Colors.golden_angle_color(colormap="CET-R2", num=len(self.y_value_list))
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
pen = mkPen(color=self.color_list[ii], width=2, style=QtCore.Qt.DashLine)
|
||||
brush = mkBrush(color=self.color_list[ii])
|
||||
curve = pg.PlotDataItem(symbolBrush=brush, pen=pen, skipFiniteCheck=True, name=y_value)
|
||||
self.plot.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
self.pens.append(pen)
|
||||
self.brushs.append(brush)
|
||||
|
||||
# check if roi selector is in the plot
|
||||
if self.roi_selector not in self.plot.items:
|
||||
self.plot.addItem(self.roi_selector)
|
||||
|
||||
# init of 2D plot
|
||||
self.plot_image.clear()
|
||||
|
||||
self.img = pg.ImageItem()
|
||||
self.plot_image.addItem(self.img)
|
||||
|
||||
# hooking signals
|
||||
self.hook_crosshair()
|
||||
self.init_table()
|
||||
|
||||
def splitter_sizes(self): ...
|
||||
|
||||
def hook_crosshair(self):
|
||||
self.crosshair_1d = Crosshair(self.plot, precision=4)
|
||||
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.label_plot_moved.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
self.crosshair_1d.coordinatesClicked1D.connect(
|
||||
lambda x, y: self.label_plot_clicked.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.update_table(table_widget=self.cursor_table, x=x, y_values=y)
|
||||
)
|
||||
|
||||
self.crosshair_2D = Crosshair(self.plot_image)
|
||||
|
||||
self.crosshair_2D.coordinatesChanged2D.connect(
|
||||
lambda x, y: self.label_image_moved.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
self.crosshair_2D.coordinatesClicked2D.connect(
|
||||
lambda x, y: self.label_image_clicked.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
|
||||
# ROI
|
||||
self.roi_selector.sigRegionChangeFinished.connect(self.get_roi_region)
|
||||
|
||||
def get_roi_region(self):
|
||||
"""For testing purpose now, get roi region and print it to self.label as tuple"""
|
||||
region = self.roi_selector.getRegion()
|
||||
self.label_plot.setText(f"x = {(10 ** region[0]):.4f}, y ={(10 ** region[1]):.4f}")
|
||||
return_dict = {
|
||||
"horiz_roi": [
|
||||
np.where(self.plotter_data_x[0] > 10 ** region[0])[0][0],
|
||||
np.where(self.plotter_data_x[0] < 10 ** region[1])[0][-1],
|
||||
]
|
||||
}
|
||||
msg = messages.DeviceMessage(signals=return_dict).dumps()
|
||||
self.connector.set_and_publish("px_stream/gui_event", msg=msg)
|
||||
self.roi_signal.emit(region)
|
||||
|
||||
def init_table(self):
|
||||
# Init number of rows in table according to n of devices
|
||||
self.cursor_table.setRowCount(len(self.y_value_list))
|
||||
# self.table.setHorizontalHeaderLabels(["(X, Y) - Moved", "(X, Y) - Clicked"]) #TODO can be dynamic
|
||||
self.cursor_table.setVerticalHeaderLabels(self.y_value_list)
|
||||
self.cursor_table.resizeColumnsToContents()
|
||||
|
||||
def update_table(self, table_widget, x, y_values):
|
||||
for i, y in enumerate(y_values):
|
||||
table_widget.setItem(i, 1, QTableWidgetItem(str(x)))
|
||||
table_widget.setItem(i, 2, QTableWidgetItem(str(y)))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update(self):
|
||||
"""Update the plot with the new data."""
|
||||
|
||||
# check if QTable was initialised and if list of devices was changed
|
||||
# if self.y_value_list != self.previous_y_value_list:
|
||||
# self.setup_cursor_table()
|
||||
# self.previous_y_value_list = self.y_value_list.copy() if self.y_value_list else None
|
||||
|
||||
self.curves[0].setData(self.plotter_data_x[0], self.plotter_data_y[0])
|
||||
|
||||
@staticmethod
|
||||
def flip_even_rows(arr):
|
||||
arr_copy = np.copy(arr) # Create a writable copy
|
||||
arr_copy[1::2, :] = arr_copy[1::2, ::-1]
|
||||
return arr_copy
|
||||
|
||||
@staticmethod
|
||||
def remove_curve_by_name(plot: pyqtgraph.PlotItem, name: str) -> None:
|
||||
# def remove_curve_by_name(plot: pyqtgraph.PlotItem, checkbox: QtWidgets.QCheckBox, name: str) -> None:
|
||||
"""Removes a curve from the given plot by the specified name.
|
||||
|
||||
Args:
|
||||
plot (pyqtgraph.PlotItem): The plot from which to remove the curve.
|
||||
name (str): The name of the curve to remove.
|
||||
"""
|
||||
# if checkbox.isChecked():
|
||||
for item in plot.items:
|
||||
if isinstance(item, pg.PlotDataItem) and getattr(item, "opts", {}).get("name") == name:
|
||||
plot.removeItem(item)
|
||||
return
|
||||
|
||||
# else:
|
||||
# return
|
||||
|
||||
def on_projection(self, exit_event):
|
||||
while not exit_event.is_set():
|
||||
if self._current_proj is None:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
endpoint = f"px_stream/projection_{self._current_proj}/data"
|
||||
msgs = self.client.connector.lrange(topic=endpoint, start=-1, end=-1)
|
||||
data = msgs
|
||||
if not data:
|
||||
continue
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
self.plotter_data_y = [
|
||||
np.sum(
|
||||
np.sum(data[-1].content["signals"]["data"] * self._current_norm, axis=1)
|
||||
/ np.sum(self._current_norm, axis=0),
|
||||
axis=0,
|
||||
).squeeze()
|
||||
]
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_dap_update(self, data: dict, metadata: dict):
|
||||
flipped_data = self.flip_even_rows(data["data"]["z"])
|
||||
|
||||
self.img.setImage(flipped_data)
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def new_proj(self, content: dict, _metadata: dict):
|
||||
proj_nr = content["signals"]["proj_nr"]
|
||||
endpoint = f"px_stream/projection_{proj_nr}/metadata"
|
||||
msg_raw = self.client.connector.get(topic=endpoint)
|
||||
msg = messages.DeviceMessage.loads(msg_raw)
|
||||
self._current_q = msg.content["signals"]["q"]
|
||||
self._current_norm = msg.content["signals"]["norm_sum"]
|
||||
self._current_metadata = msg.content["signals"]["metadata"]
|
||||
|
||||
self.plotter_data_x = [self._current_q]
|
||||
self._current_proj = proj_nr
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
# from bec_widgets import ctrl_c # TODO uncomment when ctrl_c is ready to be compatible with qtpy
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--signals", help="specify recorded signals", nargs="+", default=["gauss_bpm"]
|
||||
)
|
||||
# default = ["gauss_bpm", "bpm4i", "bpm5i", "bpm6i", "xert"],
|
||||
value = parser.parse_args()
|
||||
print(f"Plotting signals for: {', '.join(value.signals)}")
|
||||
|
||||
# Client from dispatcher
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
|
||||
app = QtWidgets.QApplication([])
|
||||
# ctrl_c.setup(app) # TODO uncomment when ctrl_c is ready to be compatible with qtpy
|
||||
plot = StreamPlot(y_value_list=value.signals, client=client)
|
||||
|
||||
bec_dispatcher.connect_slot(plot.new_proj, "px_stream/proj_nr")
|
||||
bec_dispatcher.connect_slot(
|
||||
plot.on_dap_update, MessageEndpoints.processed_data("px_dap_worker")
|
||||
)
|
||||
plot.show()
|
||||
# client.callbacks.register("scan_segment", plot, sync=False)
|
||||
app.exec()
|
||||
56
bec_widgets/qtdesigner_plugins/scan2d_plot_plugin.py
Normal file
56
bec_widgets/qtdesigner_plugins/scan2d_plot_plugin.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from qtpy.QtDesigner import QPyDesignerCustomWidgetPlugin
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.widgets.scan_plot.scan2d_plot import BECScanPlot2D
|
||||
|
||||
|
||||
class BECScanPlot2DPlugin(QPyDesignerCustomWidgetPlugin):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._initialized = False
|
||||
|
||||
def initialize(self, formEditor):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def isInitialized(self):
|
||||
return self._initialized
|
||||
|
||||
def createWidget(self, parent):
|
||||
return BECScanPlot2D(parent)
|
||||
|
||||
def name(self):
|
||||
return "BECScanPlot2D"
|
||||
|
||||
def group(self):
|
||||
return "BEC widgets"
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def toolTip(self):
|
||||
return "BEC plot for 2D scans"
|
||||
|
||||
def whatsThis(self):
|
||||
return "BEC plot for 2D scans"
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def domXml(self):
|
||||
return (
|
||||
'<widget class="BECScanPlot2D" name="BECScanPlot2D">\n'
|
||||
' <property name="toolTip" >\n'
|
||||
" <string>BEC plot for 2D scans</string>\n"
|
||||
" </property>\n"
|
||||
' <property name="whatsThis" >\n'
|
||||
" <string>BEC plot for 2D scans in Python using PyQt.</string>\n"
|
||||
" </property>\n"
|
||||
"</widget>\n"
|
||||
)
|
||||
|
||||
def includeFile(self):
|
||||
return "scan2d_plot"
|
||||
56
bec_widgets/qtdesigner_plugins/scan_plot_plugin.py
Normal file
56
bec_widgets/qtdesigner_plugins/scan_plot_plugin.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from qtpy.QtDesigner import QPyDesignerCustomWidgetPlugin
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.widgets.scan_plot.scan_plot import BECScanPlot
|
||||
|
||||
|
||||
class BECScanPlotPlugin(QPyDesignerCustomWidgetPlugin):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._initialized = False
|
||||
|
||||
def initialize(self, formEditor):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def isInitialized(self):
|
||||
return self._initialized
|
||||
|
||||
def createWidget(self, parent):
|
||||
return BECScanPlot(parent)
|
||||
|
||||
def name(self):
|
||||
return "BECScanPlot"
|
||||
|
||||
def group(self):
|
||||
return "BEC widgets"
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def toolTip(self):
|
||||
return "BEC plot for scans"
|
||||
|
||||
def whatsThis(self):
|
||||
return "BEC plot for scans"
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def domXml(self):
|
||||
return (
|
||||
'<widget class="BECScanPlot" name="BECScanPlot">\n'
|
||||
' <property name="toolTip" >\n'
|
||||
" <string>BEC plot for scans</string>\n"
|
||||
" </property>\n"
|
||||
' <property name="whatsThis" >\n'
|
||||
" <string>BEC plot for scans in Python using PyQt.</string>\n"
|
||||
" </property>\n"
|
||||
"</widget>\n"
|
||||
)
|
||||
|
||||
def includeFile(self):
|
||||
return "scan_plot"
|
||||
@@ -1,234 +0,0 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bec_lib.device import Device as BECDevice
|
||||
from bec_lib.device import Positioner as BECPositioner
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from bec_lib.devicemanager import DeviceContainer
|
||||
|
||||
|
||||
class FakeDevice(BECDevice):
|
||||
"""Fake minimal positioner class for testing."""
|
||||
|
||||
def __init__(self, name, enabled=True, readout_priority=ReadoutPriority.MONITORED):
|
||||
super().__init__(name=name)
|
||||
self._enabled = enabled
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._readout_priority = readout_priority
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd.Device",
|
||||
"deviceConfig": {},
|
||||
"deviceTags": ["user device"],
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
}
|
||||
|
||||
@property
|
||||
def readout_priority(self):
|
||||
return self._readout_priority
|
||||
|
||||
@readout_priority.setter
|
||||
def readout_priority(self, value):
|
||||
self._readout_priority = value
|
||||
|
||||
@property
|
||||
def limits(self) -> tuple[float, float]:
|
||||
return self._limits
|
||||
|
||||
@limits.setter
|
||||
def limits(self, value: tuple[float, float]):
|
||||
self._limits = value
|
||||
|
||||
def __contains__(self, item):
|
||||
return item == self.name
|
||||
|
||||
@property
|
||||
def _hints(self):
|
||||
return [self.name]
|
||||
|
||||
def set_value(self, fake_value: float = 1.0) -> None:
|
||||
"""
|
||||
Setup fake value for device readout
|
||||
Args:
|
||||
fake_value(float): Desired fake value
|
||||
"""
|
||||
self.signals[self.name]["value"] = fake_value
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""
|
||||
Get the description of the device
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self.description
|
||||
|
||||
|
||||
class FakePositioner(BECPositioner):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
enabled=True,
|
||||
limits=None,
|
||||
read_value=1.0,
|
||||
readout_priority=ReadoutPriority.MONITORED,
|
||||
):
|
||||
super().__init__(name=name)
|
||||
# self.limits = limits if limits is not None else [0.0, 0.0]
|
||||
self.read_value = read_value
|
||||
self.setpoint_value = read_value
|
||||
self.motor_is_moving_value = 0
|
||||
self._enabled = enabled
|
||||
self._limits = limits
|
||||
self._readout_priority = readout_priority
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd_devices.SimPositioner",
|
||||
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
|
||||
"deviceTags": ["user motors"],
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
}
|
||||
self._info = {
|
||||
"signals": {
|
||||
"readback": {"kind_str": "5"}, # hinted
|
||||
"setpoint": {"kind_str": "1"}, # normal
|
||||
"velocity": {"kind_str": "2"}, # config
|
||||
}
|
||||
}
|
||||
self.signals = {
|
||||
self.name: {"value": self.read_value},
|
||||
f"{self.name}_setpoint": {"value": self.setpoint_value},
|
||||
f"{self.name}_motor_is_moving": {"value": self.motor_is_moving_value},
|
||||
}
|
||||
|
||||
@property
|
||||
def readout_priority(self):
|
||||
return self._readout_priority
|
||||
|
||||
@readout_priority.setter
|
||||
def readout_priority(self, value):
|
||||
self._readout_priority = value
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool):
|
||||
self._enabled = value
|
||||
|
||||
@property
|
||||
def limits(self) -> tuple[float, float]:
|
||||
return self._limits
|
||||
|
||||
@limits.setter
|
||||
def limits(self, value: tuple[float, float]):
|
||||
self._limits = value
|
||||
|
||||
def __contains__(self, item):
|
||||
return item == self.name
|
||||
|
||||
@property
|
||||
def _hints(self):
|
||||
return [self.name]
|
||||
|
||||
def set_value(self, fake_value: float = 1.0) -> None:
|
||||
"""
|
||||
Setup fake value for device readout
|
||||
Args:
|
||||
fake_value(float): Desired fake value
|
||||
"""
|
||||
self.read_value = fake_value
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""
|
||||
Get the description of the device
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self.description
|
||||
|
||||
@property
|
||||
def precision(self):
|
||||
return 3
|
||||
|
||||
def set_read_value(self, value):
|
||||
self.read_value = value
|
||||
|
||||
def read(self):
|
||||
return self.signals
|
||||
|
||||
def set_limits(self, limits):
|
||||
self.limits = limits
|
||||
|
||||
def move(self, value, relative=False):
|
||||
"""Simulates moving the device to a new position."""
|
||||
if relative:
|
||||
self.read_value += value
|
||||
else:
|
||||
self.read_value = value
|
||||
# Respect the limits
|
||||
self.read_value = max(min(self.read_value, self.limits[1]), self.limits[0])
|
||||
|
||||
@property
|
||||
def readback(self):
|
||||
return MagicMock(get=MagicMock(return_value=self.read_value))
|
||||
|
||||
|
||||
class Positioner(FakePositioner):
|
||||
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
|
||||
|
||||
def __init__(self, name="test", limits=None, read_value=1.0):
|
||||
super().__init__(name, limits, read_value)
|
||||
|
||||
|
||||
class Device(FakeDevice):
|
||||
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
|
||||
|
||||
def __init__(self, name, enabled=True):
|
||||
super().__init__(name, enabled)
|
||||
|
||||
|
||||
class DMMock:
|
||||
def __init__(self):
|
||||
self.devices = DeviceContainer()
|
||||
self.enabled_devices = [device for device in self.devices if device.enabled]
|
||||
|
||||
def add_devives(self, devices: list):
|
||||
for device in devices:
|
||||
self.devices[device.name] = device
|
||||
|
||||
|
||||
DEVICES = [
|
||||
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
|
||||
FakePositioner("samy", limits=[-5, 5], read_value=3.0),
|
||||
FakePositioner("samz", limits=[-8, 8], read_value=4.0),
|
||||
FakePositioner("aptrx", limits=None, read_value=4.0),
|
||||
FakePositioner("aptry", limits=None, read_value=5.0),
|
||||
FakeDevice("gauss_bpm"),
|
||||
FakeDevice("gauss_adc1"),
|
||||
FakeDevice("gauss_adc2"),
|
||||
FakeDevice("gauss_adc3"),
|
||||
FakeDevice("bpm4i"),
|
||||
FakeDevice("bpm3a"),
|
||||
FakeDevice("bpm3i"),
|
||||
FakeDevice("eiger", readout_priority=ReadoutPriority.ASYNC),
|
||||
FakeDevice("waveform1d"),
|
||||
FakeDevice("async_device", readout_priority=ReadoutPriority.ASYNC),
|
||||
Positioner("test", limits=[-10, 10], read_value=2.0),
|
||||
Device("test_device"),
|
||||
]
|
||||
|
||||
|
||||
def check_remote_data_size(widget, plot_name, num_elements):
|
||||
"""
|
||||
Check if the remote data has the correct number of elements.
|
||||
Used in the qtbot.waitUntil function.
|
||||
"""
|
||||
return len(widget.get_all_data()[plot_name]["x"]) == num_elements
|
||||
@@ -1,13 +1,8 @@
|
||||
from qtpy.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from .crosshair import Crosshair
|
||||
from .colors import Colors
|
||||
from .validator_delegate import DoubleValidationDelegate
|
||||
from .bec_table import BECTable
|
||||
from .bec_connector import BECConnector, ConnectionConfig
|
||||
from .bec_dispatcher import BECDispatcher
|
||||
from .bec_table import BECTable
|
||||
from .colors import Colors
|
||||
from .container_utils import WidgetContainerUtils
|
||||
from .crosshair import Crosshair
|
||||
from .rpc_decorator import rpc_public, register_rpc_methods
|
||||
from .entry_validator import EntryValidator
|
||||
from .layout_manager import GridLayoutManager
|
||||
from .rpc_decorator import register_rpc_methods, rpc_public
|
||||
from .ui_loader import UILoader
|
||||
from .validator_delegate import DoubleValidationDelegate
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import Type, Optional
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.utils.error_popups import SafeSlot as pyqtSlot
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
else:
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
logger = bec_logger.logger
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
class ConnectionConfig(BaseModel):
|
||||
@@ -35,334 +17,46 @@ class ConnectionConfig(BaseModel):
|
||||
gui_id: Optional[str] = Field(
|
||||
default=None, validate_default=True, description="The GUI ID of the widget."
|
||||
)
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
@field_validator("gui_id")
|
||||
@classmethod
|
||||
def generate_gui_id(cls, v, values):
|
||||
"""Generate a GUI ID if none is provided."""
|
||||
if v is None:
|
||||
widget_class = values.data["widget_class"]
|
||||
v = f"{widget_class}_{datetime.now().strftime('%Y_%m_%d_%H_%M_%S_%f')}"
|
||||
v = f"{widget_class}_{str(time.time())}"
|
||||
return v
|
||||
return v
|
||||
|
||||
|
||||
class WorkerSignals(QObject):
|
||||
progress = Signal(dict)
|
||||
completed = Signal()
|
||||
|
||||
|
||||
class Worker(QRunnable):
|
||||
"""
|
||||
Worker class to run a function in a separate thread.
|
||||
"""
|
||||
|
||||
def __init__(self, func, *args, **kwargs):
|
||||
super().__init__()
|
||||
self.signals = WorkerSignals()
|
||||
self.func = func
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Run the specified function in the thread.
|
||||
"""
|
||||
self.func(*self.args, **self.kwargs)
|
||||
self.signals.completed.emit()
|
||||
|
||||
|
||||
class BECConnector:
|
||||
"""Connection mixin class to handle BEC client and device manager"""
|
||||
|
||||
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
|
||||
EXIT_HANDLERS = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
object_name: str | None = None,
|
||||
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
|
||||
parent_id: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
# Extract object_name from kwargs to not pass it to Qt class
|
||||
object_name = object_name or kwargs.pop("objectName", None)
|
||||
# Ensure the parent is always the first argument for QObject
|
||||
parent = kwargs.pop("parent", None)
|
||||
# This initializes the QObject or any qt related class BECConnector has to be used from this line down with QObject, otherwise hierarchy logic will not work
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
|
||||
assert isinstance(
|
||||
self, QObject
|
||||
), "BECConnector must be used with a QObject or any qt related class."
|
||||
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
|
||||
|
||||
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
|
||||
# BEC related connections
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.bec_dispatcher = BECDispatcher()
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
self._parent_dock = parent_dock # TODO also remove at some point -> issue created #473
|
||||
self.rpc_register = RPCRegister()
|
||||
|
||||
if not self.client in BECConnector.EXIT_HANDLERS:
|
||||
# register function to clean connections at exit;
|
||||
# the function depends on BECClient, and BECDispatcher
|
||||
@pyqtSlot()
|
||||
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
|
||||
logger.info("Disconnecting", repr(dispatcher))
|
||||
dispatcher.disconnect_all()
|
||||
logger.info("Shutting down BEC Client", repr(client))
|
||||
client.shutdown()
|
||||
|
||||
BECConnector.EXIT_HANDLERS[self.client] = terminate
|
||||
QApplication.instance().aboutToQuit.connect(terminate)
|
||||
|
||||
if config:
|
||||
self.config = config
|
||||
self.config.widget_class = self.__class__.__name__
|
||||
else:
|
||||
logger.debug(
|
||||
print(
|
||||
f"No initial config found for {self.__class__.__name__}.\n"
|
||||
f"Initializing with default config."
|
||||
)
|
||||
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
|
||||
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
|
||||
else:
|
||||
self.gui_id: str = self.config.gui_id # type: ignore
|
||||
|
||||
if object_name is not None:
|
||||
self.setObjectName(object_name)
|
||||
|
||||
# 1) If no objectName is set, set the initial name
|
||||
if not self.objectName():
|
||||
self.setObjectName(self.__class__.__name__)
|
||||
self.object_name = self.objectName()
|
||||
|
||||
# 2) Enforce unique objectName among siblings with the same BECConnector parent
|
||||
self.setParent(parent)
|
||||
if parent_id is None:
|
||||
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
if connector_parent is not None:
|
||||
self.parent_id = connector_parent.gui_id
|
||||
|
||||
# Error popups
|
||||
self.error_utility = ErrorPopupUtility()
|
||||
|
||||
self._thread_pool = QThreadPool.globalInstance()
|
||||
# Store references to running workers so they're not garbage collected prematurely.
|
||||
self._workers = []
|
||||
|
||||
QTimer.singleShot(0, self._update_object_name)
|
||||
|
||||
def _update_object_name(self) -> None:
|
||||
"""
|
||||
Enforce a unique object name among siblings and register the object for RPC.
|
||||
This method is called through a single shot timer kicked off in the constructor.
|
||||
"""
|
||||
# 1) Enforce unique objectName among siblings with the same BECConnector parent
|
||||
self._enforce_unique_sibling_name()
|
||||
# 2) Register the object for RPC
|
||||
self.rpc_register.add_rpc(self)
|
||||
|
||||
def _enforce_unique_sibling_name(self):
|
||||
"""
|
||||
Enforce that this BECConnector has a unique objectName among its siblings.
|
||||
|
||||
Sibling logic:
|
||||
- If there's a nearest BECConnector parent, only compare with children of that parent.
|
||||
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
|
||||
"""
|
||||
QApplication.processEvents()
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
|
||||
if parent_bec:
|
||||
# We have a parent => only compare with siblings under that parent
|
||||
siblings = parent_bec.findChildren(BECConnector)
|
||||
else:
|
||||
# No parent => treat all top-level BECConnectors as siblings
|
||||
# 1) Gather all BECConnectors from QApplication
|
||||
all_widgets = QApplication.allWidgets()
|
||||
all_bec = [w for w in all_widgets if isinstance(w, BECConnector)]
|
||||
# 2) "Top-level" means closest BECConnector parent is None
|
||||
top_level_bec = [
|
||||
w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None
|
||||
]
|
||||
# 3) We are among these top-level siblings
|
||||
siblings = top_level_bec
|
||||
|
||||
# Collect used names among siblings
|
||||
used_names = {sib.objectName() for sib in siblings if sib is not self}
|
||||
|
||||
base_name = self.object_name
|
||||
if base_name not in used_names:
|
||||
# Name is already unique among siblings
|
||||
return
|
||||
|
||||
# Need a suffix to avoid collision
|
||||
counter = 0
|
||||
while True:
|
||||
trial_name = f"{base_name}_{counter}"
|
||||
if trial_name not in used_names:
|
||||
self.setObjectName(trial_name)
|
||||
self.object_name = trial_name
|
||||
break
|
||||
counter += 1
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def setObjectName(self, name: str) -> None:
|
||||
"""
|
||||
Set the object name of the widget.
|
||||
|
||||
Args:
|
||||
name (str): The new object name.
|
||||
"""
|
||||
super().setObjectName(name)
|
||||
self.object_name = name
|
||||
if self.rpc_register.object_is_registered(self):
|
||||
self.rpc_register.broadcast()
|
||||
|
||||
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
|
||||
"""
|
||||
Submit a task to run in a separate thread. The task will run the specified
|
||||
function with the provided arguments and emit the completed signal when done.
|
||||
|
||||
Use this method if you want to wait for a task to complete without blocking the
|
||||
main thread.
|
||||
|
||||
Args:
|
||||
fn: Function to run in a separate thread.
|
||||
*args: Arguments for the function.
|
||||
on_complete: Slot to run when the task is complete.
|
||||
**kwargs: Keyword arguments for the function.
|
||||
|
||||
Returns:
|
||||
worker: The worker object that will run the task.
|
||||
|
||||
Examples:
|
||||
>>> def my_function(a, b):
|
||||
>>> print(a + b)
|
||||
>>> self.submit_task(my_function, 1, 2)
|
||||
|
||||
>>> def my_function(a, b):
|
||||
>>> print(a + b)
|
||||
>>> def on_complete():
|
||||
>>> print("Task complete")
|
||||
>>> self.submit_task(my_function, 1, 2, on_complete=on_complete)
|
||||
"""
|
||||
worker = Worker(fn, *args, **kwargs)
|
||||
if on_complete:
|
||||
worker.signals.completed.connect(on_complete)
|
||||
# Keep a reference to the worker so it is not garbage collected.
|
||||
self._workers.append(worker)
|
||||
# When the worker is done, remove it from our list.
|
||||
worker.signals.completed.connect(lambda: self._workers.remove(worker))
|
||||
self._thread_pool.start(worker)
|
||||
return worker
|
||||
|
||||
def _get_all_rpc(self) -> dict:
|
||||
"""Get all registered RPC objects."""
|
||||
all_connections = self.rpc_register.list_all_connections()
|
||||
return dict(all_connections)
|
||||
|
||||
@property
|
||||
def _rpc_id(self) -> str:
|
||||
"""Get the RPC ID of the widget."""
|
||||
return self.gui_id
|
||||
|
||||
@_rpc_id.setter
|
||||
def _rpc_id(self, rpc_id: str) -> None:
|
||||
"""Set the RPC ID of the widget."""
|
||||
self.gui_id = rpc_id
|
||||
|
||||
@property
|
||||
def _config_dict(self) -> dict:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
return self.config.model_dump()
|
||||
|
||||
@_config_dict.setter
|
||||
def _config_dict(self, config: BaseModel) -> None:
|
||||
"""
|
||||
Set the configuration of the widget.
|
||||
|
||||
Args:
|
||||
config (BaseModel): The new configuration model.
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
|
||||
"""
|
||||
Apply the configuration to the widget.
|
||||
|
||||
Args:
|
||||
config (dict): Configuration settings.
|
||||
generate_new_id (bool): If True, generate a new GUI ID for the widget.
|
||||
"""
|
||||
self.config = ConnectionConfig(**config)
|
||||
if generate_new_id is True:
|
||||
gui_id = str(uuid.uuid4())
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self._set_gui_id(gui_id)
|
||||
self.rpc_register.add_rpc(self)
|
||||
self.gui_id = gui_id
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
def load_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Load the configuration of the widget from YAML.
|
||||
|
||||
Args:
|
||||
path (str | None): Path to the configuration file for non-GUI dialog mode.
|
||||
gui (bool): If True, use the GUI dialog to load the configuration file.
|
||||
"""
|
||||
if gui is True:
|
||||
config = load_yaml_gui(self)
|
||||
else:
|
||||
config = load_yaml(path)
|
||||
|
||||
if config is not None:
|
||||
if config.get("widget_class") != self.__class__.__name__:
|
||||
raise ValueError(
|
||||
f"Configuration file is not for {self.__class__.__name__}. Got configuration for {config.get('widget_class')}."
|
||||
)
|
||||
self.apply_config(config)
|
||||
|
||||
def save_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Save the configuration of the widget to YAML.
|
||||
|
||||
Args:
|
||||
path (str | None): Path to save the configuration file for non-GUI dialog mode.
|
||||
gui (bool): If True, use the GUI dialog to save the configuration file.
|
||||
"""
|
||||
if gui is True:
|
||||
save_yaml_gui(self, self._config_dict)
|
||||
else:
|
||||
if path is None:
|
||||
path = os.getcwd()
|
||||
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
|
||||
save_yaml(file_path, self._config_dict)
|
||||
|
||||
# @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
|
||||
@@ -381,9 +75,8 @@ class BECConnector:
|
||||
|
||||
def update_client(self, client) -> None:
|
||||
"""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()
|
||||
@@ -392,75 +85,24 @@ class BECConnector:
|
||||
def on_config_update(self, config: ConnectionConfig | dict) -> None:
|
||||
"""
|
||||
Update the configuration for the widget.
|
||||
|
||||
Args:
|
||||
config (ConnectionConfig | dict): Configuration settings.
|
||||
config(ConnectionConfig): Configuration settings.
|
||||
"""
|
||||
gui_id = getattr(config, "gui_id", None)
|
||||
if isinstance(config, dict):
|
||||
config = ConnectionConfig(**config)
|
||||
self.config = config
|
||||
if gui_id and config.gui_id != gui_id: # Recreating config should not overwrite the gui_id
|
||||
self.config.gui_id = gui_id
|
||||
# TODO add error handler
|
||||
|
||||
def remove(self):
|
||||
"""Cleanup the BECConnector"""
|
||||
# If the widget is attached to a dock, remove it from the dock.
|
||||
# TODO this should be handled by dock and dock are not by BECConnector -> issue created #473
|
||||
if self._parent_dock is not None:
|
||||
self._parent_dock.delete(self.object_name)
|
||||
# If the widget is from Qt, trigger its close method.
|
||||
elif hasattr(self, "close"):
|
||||
self.close()
|
||||
# If the widget is neither from a Dock nor from Qt, remove it from the RPC registry.
|
||||
# i.e. Curve Item from Waveform
|
||||
else:
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.config = config
|
||||
|
||||
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Args:
|
||||
dict_output (bool): If True, return the configuration as a dictionary.
|
||||
If False, return the configuration as a pydantic model.
|
||||
|
||||
dict_output(bool): If True, return the configuration as a dictionary. If False, return the configuration as a pydantic model.
|
||||
Returns:
|
||||
dict | BaseModel: The configuration of the widget.
|
||||
dict: The configuration of the plot widget.
|
||||
"""
|
||||
if dict_output:
|
||||
return self.config.model_dump()
|
||||
else:
|
||||
return self.config
|
||||
|
||||
|
||||
# --- 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,167 +0,0 @@
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import site
|
||||
import sys
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import PYSIDE6
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.utils.bec_plugin_helper import user_widget_plugin
|
||||
|
||||
if PYSIDE6:
|
||||
from PySide6.scripts.pyside_tool import (
|
||||
_extend_path_var,
|
||||
init_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
is_pyenv_python,
|
||||
is_virtual_env,
|
||||
ui_tool_binary,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
|
||||
|
||||
def designer_material_icon(icon_name: str) -> QIcon:
|
||||
"""
|
||||
Create a QIcon for the BECDesigner with the given material icon name.
|
||||
|
||||
Args:
|
||||
icon_name (str): The name of the material icon.
|
||||
|
||||
Returns:
|
||||
QIcon: The QIcon for the material icon.
|
||||
"""
|
||||
return QIcon(material_icon(icon_name, filled=True, convert_to_pixmap=True))
|
||||
|
||||
|
||||
def list_editable_packages() -> set[str]:
|
||||
"""
|
||||
List all editable packages in the environment.
|
||||
|
||||
Returns:
|
||||
set: A set of paths to editable packages.
|
||||
"""
|
||||
|
||||
editable_packages = set()
|
||||
|
||||
# Get site-packages directories
|
||||
site_packages = site.getsitepackages()
|
||||
if hasattr(site, "getusersitepackages"):
|
||||
site_packages.append(site.getusersitepackages())
|
||||
|
||||
for dist in importlib.metadata.distributions():
|
||||
location = dist.locate_file("").resolve()
|
||||
is_editable = all(not str(location).startswith(site_pkg) for site_pkg in site_packages)
|
||||
|
||||
if is_editable:
|
||||
editable_packages.add(str(location))
|
||||
|
||||
for packages in site_packages:
|
||||
# all dist-info directories in site-packages that contain a direct_url.json file
|
||||
dist_info_dirs = Path(packages).rglob("*.dist-info")
|
||||
for dist_info_dir in dist_info_dirs:
|
||||
direct_url = dist_info_dir / "direct_url.json"
|
||||
if not direct_url.exists():
|
||||
continue
|
||||
# load the json file and get the path to the package
|
||||
with open(direct_url, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
path = data.get("url", "")
|
||||
if path.startswith("file://"):
|
||||
path = path[7:]
|
||||
editable_packages.add(path)
|
||||
|
||||
return editable_packages
|
||||
|
||||
|
||||
def patch_designer(): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
|
||||
init_virtual_env()
|
||||
|
||||
major_version = sys.version_info[0]
|
||||
minor_version = sys.version_info[1]
|
||||
os.environ["PY_MAJOR_VERSION"] = str(major_version)
|
||||
os.environ["PY_MINOR_VERSION"] = str(minor_version)
|
||||
|
||||
if sys.platform == "win32":
|
||||
if is_virtual_env():
|
||||
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
|
||||
else:
|
||||
if sys.platform == "linux":
|
||||
env_var = "LD_PRELOAD"
|
||||
current_pid = os.getpid()
|
||||
with open(f"/proc/{current_pid}/maps", "rt") as f:
|
||||
for line in f:
|
||||
if "libpython" in line:
|
||||
lib_path = line.split()[-1]
|
||||
os.environ[env_var] = lib_path
|
||||
break
|
||||
|
||||
elif sys.platform == "darwin":
|
||||
suffix = ".dylib"
|
||||
env_var = "DYLD_INSERT_LIBRARIES"
|
||||
version = f"{major_version}.{minor_version}"
|
||||
library_name = f"libpython{version}{suffix}"
|
||||
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
|
||||
os.environ[env_var] = lib_path
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {sys.platform}")
|
||||
|
||||
if is_pyenv_python() or is_virtual_env():
|
||||
# append all editable packages to the PYTHONPATH
|
||||
editable_packages = list_editable_packages()
|
||||
for pckg in editable_packages:
|
||||
_extend_path_var("PYTHONPATH", pckg, True)
|
||||
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
|
||||
|
||||
|
||||
def find_plugin_paths(base_path: Path):
|
||||
"""
|
||||
Recursively find all directories containing a .pyproject file.
|
||||
"""
|
||||
plugin_paths = []
|
||||
for path in base_path.rglob("*.pyproject"):
|
||||
plugin_paths.append(str(path.parent))
|
||||
return plugin_paths
|
||||
|
||||
|
||||
def set_plugin_environment_variable(plugin_paths):
|
||||
"""
|
||||
Set the PYSIDE_DESIGNER_PLUGINS environment variable with the given plugin paths.
|
||||
"""
|
||||
current_paths = os.environ.get("PYSIDE_DESIGNER_PLUGINS", "")
|
||||
if current_paths:
|
||||
current_paths = current_paths.split(os.pathsep)
|
||||
else:
|
||||
current_paths = []
|
||||
|
||||
current_paths.extend(plugin_paths)
|
||||
os.environ["PYSIDE_DESIGNER_PLUGINS"] = os.pathsep.join(current_paths)
|
||||
|
||||
|
||||
# Patch the designer function
|
||||
def 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()
|
||||
plugin_paths.extend(find_plugin_paths(plugin_repo_dir))
|
||||
|
||||
set_plugin_environment_variable(plugin_paths)
|
||||
|
||||
patch_designer()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,245 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import random
|
||||
import string
|
||||
import argparse
|
||||
import itertools
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Union
|
||||
from typing import Union
|
||||
|
||||
import redis
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.redis_connector import MessageObject, RedisConnector
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_lib import BECClient, ServiceConfig
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.endpoints import EndpointInfo
|
||||
|
||||
from bec_widgets.utils.rpc_server import RPCServer
|
||||
# Adding a new pyqt signal requires a class factory, as they must be part of the class definition
|
||||
# and cannot be dynamically added as class attributes after the class has been defined.
|
||||
_signal_class_factory = (
|
||||
type(f"Signal{i}", (QObject,), dict(signal=pyqtSignal(dict, dict))) for i in itertools.count()
|
||||
)
|
||||
|
||||
|
||||
class QtThreadSafeCallback(QObject):
|
||||
cb_signal = pyqtSignal(dict, dict)
|
||||
|
||||
def __init__(self, cb, cb_info: dict | None = None):
|
||||
super().__init__()
|
||||
self.cb_info = cb_info
|
||||
self.cb = cb
|
||||
self.cb_signal.connect(self.cb)
|
||||
|
||||
def __hash__(self):
|
||||
# make 2 differents QtThreadSafeCallback to look
|
||||
# identical when used as dictionary keys, if the
|
||||
# callback is the same
|
||||
return id(self.cb)
|
||||
|
||||
def __call__(self, msg_content, metadata):
|
||||
self.cb_signal.emit(msg_content, metadata)
|
||||
|
||||
|
||||
class QtRedisConnector(RedisConnector):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _execute_callback(self, cb, msg, kwargs):
|
||||
if not isinstance(cb, QtThreadSafeCallback):
|
||||
return super()._execute_callback(cb, msg, kwargs)
|
||||
# if msg.msg_type == "bundle_message":
|
||||
# # big warning: how to handle bundle messages?
|
||||
# # message with messages inside ; which slot to call?
|
||||
# # bundle_msg = msg
|
||||
# # for msg in bundle_msg:
|
||||
# # ...
|
||||
# # for now, only consider the 1st message
|
||||
# msg = msg[0]
|
||||
# raise RuntimeError(f"
|
||||
if isinstance(msg, MessageObject):
|
||||
if isinstance(msg.value, list):
|
||||
msg = msg.value[0]
|
||||
else:
|
||||
msg = msg.value
|
||||
|
||||
# we can notice kwargs are lost when passed to Qt slot
|
||||
metadata = msg.metadata
|
||||
cb(msg.content, metadata)
|
||||
else:
|
||||
# from stream
|
||||
msg = msg["data"]
|
||||
cb(msg.content, msg.metadata)
|
||||
|
||||
|
||||
class BECDispatcher:
|
||||
class _Connection:
|
||||
"""Utility class to keep track of slots connected to a particular redis connector"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
client: BECClient
|
||||
cli_server: RPCServer | None = None
|
||||
def __init__(self, callback) -> None:
|
||||
self.callback = callback
|
||||
|
||||
def __new__(
|
||||
cls,
|
||||
client=None,
|
||||
config: str | ServiceConfig | None = None,
|
||||
gui_id: str = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(BECDispatcher, cls).__new__(cls)
|
||||
cls._initialized = False
|
||||
return cls._instance
|
||||
self.slots = set()
|
||||
# keep a reference to a new signal class, so it is not gc'ed
|
||||
self._signal_container = next(_signal_class_factory)()
|
||||
self.signal: pyqtSignal = self._signal_container.signal
|
||||
|
||||
def __init__(self, client=None, config: str | ServiceConfig | None = None, gui_id: str = None):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._slots = collections.defaultdict(set)
|
||||
self.client = client
|
||||
class _BECDispatcher(QObject):
|
||||
"""Utility class to keep track of slots connected to a particular redis connector"""
|
||||
|
||||
if self.client is None:
|
||||
if config is not None:
|
||||
if not isinstance(config, ServiceConfig):
|
||||
# config is supposed to be a path
|
||||
config = ServiceConfig(config)
|
||||
self.client = BECClient(
|
||||
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
|
||||
)
|
||||
else:
|
||||
if self.client.started:
|
||||
# have to reinitialize client to use proper connector
|
||||
logger.info("Shutting down BECClient to switch to QtRedisConnector")
|
||||
self.client.shutdown()
|
||||
self.client._BECClient__init_params["connector_cls"] = QtRedisConnector
|
||||
def __init__(self, bec_config=None):
|
||||
super().__init__()
|
||||
self.client = BECClient(forced=True) # make a new instance
|
||||
|
||||
# TODO: this is a workaround for now to provide service config within qtdesigner, but is
|
||||
# it possible to provide config via a cli arg?
|
||||
if bec_config is None and os.path.isfile("bec_config.yaml"):
|
||||
bec_config = "bec_config.yaml"
|
||||
|
||||
try:
|
||||
self.client.start()
|
||||
except redis.exceptions.ConnectionError:
|
||||
logger.warning("Could not connect to Redis, skipping start of BECClient.")
|
||||
|
||||
logger.success("Initialized BECDispatcher")
|
||||
|
||||
self.start_cli_server(gui_id=gui_id)
|
||||
self._initialized = True
|
||||
|
||||
@classmethod
|
||||
def reset_singleton(cls):
|
||||
"""
|
||||
Reset the singleton instance of the BECDispatcher.
|
||||
"""
|
||||
cls._instance = None
|
||||
cls._initialized = False
|
||||
self.client.initialize(config=ServiceConfig(config_path=bec_config))
|
||||
except redis.exceptions.ConnectionError as e:
|
||||
print(f"Failed to initialize BECClient: {e}")
|
||||
self._connections = {}
|
||||
|
||||
def connect_slot(
|
||||
self,
|
||||
slot: Callable,
|
||||
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
|
||||
cb_info: dict | None = None,
|
||||
**kwargs,
|
||||
self, slot: Callable, topics: Union[str, list], single_callback_for_all_topics=False
|
||||
) -> None:
|
||||
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
|
||||
"""Connect widget's pyqt slot, so that it is called on new pub/sub topic message.
|
||||
|
||||
Args:
|
||||
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
|
||||
the corresponding pub/sub message
|
||||
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
topics (str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
single_callback_for_all_topics (bool): If True, use the same callback for all topics, otherwise use
|
||||
separate callbacks.
|
||||
"""
|
||||
slot = QtThreadSafeCallback(slot, cb_info=cb_info)
|
||||
self.client.connector.register(topics, cb=slot, **kwargs)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._slots[slot].update(set(topics_str))
|
||||
if isinstance(topics, str):
|
||||
topics = [topics]
|
||||
|
||||
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
|
||||
"""
|
||||
Disconnect a slot from a topic.
|
||||
# Ensure topics_key is a tuple, whether single_callback_for_all_topics is True or False.
|
||||
topics_key = tuple(sorted(topics)) if single_callback_for_all_topics else tuple(topics)
|
||||
|
||||
if topics_key not in self._connections:
|
||||
self._connections[topics_key] = self._create_connection(topics)
|
||||
connection = self._connections[topics_key]
|
||||
if slot not in connection.slots:
|
||||
connection.signal.connect(slot)
|
||||
connection.slots.add(slot)
|
||||
|
||||
def _create_connection(self, topics: list) -> _Connection:
|
||||
"""Creates a new connection for given topics."""
|
||||
|
||||
def cb(msg):
|
||||
msg = msg.value
|
||||
for connection_key, connection in self._connections.items():
|
||||
if set(topics).intersection(connection_key):
|
||||
connection.signal.emit(msg.content, msg.metadata)
|
||||
|
||||
try:
|
||||
self.client.connector.register(topics=topics, cb=cb)
|
||||
except redis.exceptions.ConnectionError:
|
||||
print("Could not connect to Redis, skipping registration of topics.")
|
||||
|
||||
return _Connection(cb)
|
||||
|
||||
def _do_disconnect_slot(self, topic, slot):
|
||||
print(f"Disconnecting {slot} from {topic}")
|
||||
connection = self._connections[topic]
|
||||
try:
|
||||
connection.signal.disconnect(slot)
|
||||
except TypeError:
|
||||
print(f"Could not disconnect slot:'{slot}' from topic:'{topic}'")
|
||||
print("Continue to remove slot:'{slot}' from 'connection.slots'.")
|
||||
connection.slots.remove(slot)
|
||||
if not connection.slots:
|
||||
del self._connections[topic]
|
||||
|
||||
def _disconnect_slot_from_topic(self, slot: Callable, topic: str) -> None:
|
||||
"""A helper method to disconnect a slot from a specific topic.
|
||||
|
||||
Args:
|
||||
slot(Callable): The slot to disconnect
|
||||
topics(Union[str, list]): The topic(s) to disconnect from
|
||||
slot (Callable): A slot to be disconnected
|
||||
topic (str): A corresponding topic that can typically be acquired via
|
||||
bec_lib.MessageEndpoints
|
||||
"""
|
||||
# find the right slot to disconnect from ;
|
||||
# slot callbacks are wrapped in QtThreadSafeCallback objects,
|
||||
# but the slot we receive here is the original callable
|
||||
for connected_slot in self._slots:
|
||||
if connected_slot.cb == slot:
|
||||
break
|
||||
else:
|
||||
return
|
||||
self.client.connector.unregister(topics, cb=connected_slot)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._slots[connected_slot].difference_update(set(topics_str))
|
||||
if not self._slots[connected_slot]:
|
||||
del self._slots[connected_slot]
|
||||
connection = self._connections.get(topic)
|
||||
if connection and slot in connection.slots:
|
||||
self._do_disconnect_slot(topic, slot)
|
||||
|
||||
def disconnect_topics(self, topics: Union[str, list]):
|
||||
"""
|
||||
Disconnect all slots from a topic.
|
||||
def disconnect_slot(self, slot: Callable, topics: Union[str, list]) -> None:
|
||||
"""Disconnect widget's pyqt slot from pub/sub updates on a topic.
|
||||
|
||||
Args:
|
||||
topics(Union[str, list]): The topic(s) to disconnect from
|
||||
slot (Callable): A slot to be disconnected
|
||||
topics (str | list): A corresponding topic or list of topics that can typically be acquired via
|
||||
bec_lib.MessageEndpoints
|
||||
"""
|
||||
self.client.connector.unregister(topics)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
for slot in list(self._slots.keys()):
|
||||
slot_topics = self._slots[slot]
|
||||
slot_topics.difference_update(set(topics_str))
|
||||
if not slot_topics:
|
||||
del self._slots[slot]
|
||||
if isinstance(topics, str):
|
||||
topics = [topics]
|
||||
|
||||
def disconnect_all(self, *args, **kwargs):
|
||||
"""
|
||||
Disconnect all slots from all topics.
|
||||
for key, connection in list(self._connections.items()):
|
||||
if slot in connection.slots:
|
||||
common_topics = set(topics).intersection(key)
|
||||
if common_topics:
|
||||
remaining_topics = set(key) - set(topics)
|
||||
# Disconnect slot from common topics
|
||||
self._do_disconnect_slot(key, slot)
|
||||
# Reconnect slot to remaining topics if any
|
||||
if remaining_topics:
|
||||
self.connect_slot(slot, list(remaining_topics), True)
|
||||
|
||||
Args:
|
||||
*args: Arbitrary positional arguments
|
||||
**kwargs: Arbitrary keyword arguments
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
self.disconnect_topics(self.client.connector._topics_cb)
|
||||
def disconnect_all(self):
|
||||
"""Disconnect all slots from all topics."""
|
||||
for key, connection in list(self._connections.items()):
|
||||
for slot in list(connection.slots):
|
||||
self._disconnect_slot_from_topic(slot, key)
|
||||
|
||||
def start_cli_server(self, gui_id: str | None = None):
|
||||
"""
|
||||
Start the CLI server.
|
||||
|
||||
Args:
|
||||
gui_id(str, optional): The GUI ID. Defaults to None. If None, a unique identifier will be generated.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from bec_widgets.utils.rpc_server import RPCServer
|
||||
# variable holding the Singleton instance of BECDispatcher
|
||||
_bec_dispatcher = None
|
||||
|
||||
if gui_id is None:
|
||||
gui_id = self.generate_unique_identifier()
|
||||
|
||||
if not self.client.started:
|
||||
logger.error("Cannot start CLI server without a running client")
|
||||
return
|
||||
self.cli_server = RPCServer(gui_id, dispatcher=self, client=self.client)
|
||||
logger.success(f"Started CLI server with gui_id: {gui_id}")
|
||||
def BECDispatcher():
|
||||
global _bec_dispatcher
|
||||
if _bec_dispatcher is None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--bec-config", default=None)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
def stop_cli_server(self):
|
||||
"""
|
||||
Stop the CLI server.
|
||||
"""
|
||||
if self.cli_server is None:
|
||||
logger.error("Cannot stop CLI server without starting it first")
|
||||
return
|
||||
self.cli_server.shutdown()
|
||||
self.cli_server = None
|
||||
logger.success("Stopped CLI server")
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_identifier(length: int = 4) -> str:
|
||||
"""
|
||||
Generate a unique identifier for the application.
|
||||
|
||||
Args:
|
||||
length: The length of the identifier. Defaults to 4.
|
||||
|
||||
Returns:
|
||||
str: The unique identifier.
|
||||
"""
|
||||
allowed_chars = string.ascii_lowercase + string.digits
|
||||
return "".join(random.choices(allowed_chars, k=length))
|
||||
_bec_dispatcher = _BECDispatcher(args.bec_config)
|
||||
return _bec_dispatcher
|
||||
|
||||
@@ -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()
|
||||
...
|
||||
@@ -1,90 +0,0 @@
|
||||
""" This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
|
||||
Unblocking the proxy needs to be done through the slot unblock_proxy. The most likely use case for this class is
|
||||
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
|
||||
analyse data. Requesting a new fit may lead to request piling up and an overall slow done of performance. This proxy
|
||||
will allow you to decide by yourself when to unblock and execute the callback again."""
|
||||
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class BECSignalProxy(SignalProxy):
|
||||
"""
|
||||
Thin wrapper around the SignalProxy class to allow signal calls to be blocked,
|
||||
but arguments still being stored.
|
||||
|
||||
Args:
|
||||
*args: Arguments to pass to the SignalProxy class.
|
||||
rateLimit (int): The rateLimit of the proxy.
|
||||
timeout (float): The number of seconds after which the proxy automatically
|
||||
unblocks if still blocked. Default is 10.0 seconds.
|
||||
**kwargs: Keyword arguments to pass to the SignalProxy class.
|
||||
|
||||
Example:
|
||||
>>> proxy = BECSignalProxy(signal, rate_limit=25, slot=callback)
|
||||
"""
|
||||
|
||||
is_blocked = Signal(bool)
|
||||
|
||||
def __init__(self, *args, rateLimit=25, timeout=10.0, **kwargs):
|
||||
super().__init__(*args, rateLimit=rateLimit, **kwargs)
|
||||
self._blocking = False
|
||||
self.old_args = None
|
||||
self.new_args = None
|
||||
|
||||
# Store timeout value (in seconds)
|
||||
self._timeout = timeout
|
||||
|
||||
# Create a single-shot timer for auto-unblocking
|
||||
self._timer = QTimer()
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._timeout_unblock)
|
||||
|
||||
@property
|
||||
def blocked(self):
|
||||
"""Returns if the proxy is blocked"""
|
||||
return self._blocking
|
||||
|
||||
@blocked.setter
|
||||
def blocked(self, value: bool):
|
||||
self._blocking = value
|
||||
self.is_blocked.emit(value)
|
||||
|
||||
def signalReceived(self, *args):
|
||||
"""Receive signal, store the args and call signalReceived from the parent class if not blocked"""
|
||||
self.new_args = args
|
||||
if self.blocked is True:
|
||||
return
|
||||
self.blocked = True
|
||||
self.old_args = args
|
||||
super().signalReceived(*args)
|
||||
|
||||
self._timer.start(int(self._timeout * 1000))
|
||||
|
||||
@SafeSlot()
|
||||
def unblock_proxy(self):
|
||||
"""Unblock the proxy, and call the signalReceived method in case there was an update of the args."""
|
||||
if self.blocked:
|
||||
self._timer.stop()
|
||||
self.blocked = False
|
||||
if self.new_args != self.old_args:
|
||||
self.signalReceived(*self.new_args)
|
||||
|
||||
@SafeSlot()
|
||||
def _timeout_unblock(self):
|
||||
"""
|
||||
Internal method called by the QTimer upon timeout. Unblocks the proxy
|
||||
automatically if it is still blocked.
|
||||
"""
|
||||
if self.blocked:
|
||||
self.unblock_proxy()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the proxy by stopping the timer and disconnecting the timeout signal.
|
||||
"""
|
||||
self._timer.stop()
|
||||
self._timer.timeout.disconnect(self._timeout_unblock)
|
||||
self._timer.deleteLater()
|
||||
@@ -1,5 +1,5 @@
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QTableWidget
|
||||
from qtpy.QtCore import Qt
|
||||
|
||||
|
||||
class BECTable(QTableWidget):
|
||||
@@ -8,7 +8,6 @@ class BECTable(QTableWidget):
|
||||
def keyPressEvent(self, event) -> None:
|
||||
"""
|
||||
Delete selected rows with backspace or delete key
|
||||
|
||||
Args:
|
||||
event: keyPressEvent
|
||||
"""
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import darkdetect
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject, Slot
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECWidget(BECConnector):
|
||||
"""Mixin class for all BEC widgets, to handle cleanup"""
|
||||
|
||||
# The icon name is the name of the icon in the icon theme, typically a name taken
|
||||
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = ["remove"]
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
|
||||
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.:
|
||||
|
||||
|
||||
>>> class MyWidget(BECWidget, QWidget):
|
||||
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
|
||||
>>> super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
>>> QWidget.__init__(self, parent=parent)
|
||||
|
||||
|
||||
Args:
|
||||
client(BECClient, optional): The BEC client.
|
||||
config(ConnectionConfig, optional): The connection configuration.
|
||||
gui_id(str, optional): The GUI ID.
|
||||
theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the
|
||||
widget's apply_theme method will be called when the theme changes.
|
||||
"""
|
||||
|
||||
super().__init__(
|
||||
client=client,
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
parent_dock=parent_dock,
|
||||
parent_id=parent_id,
|
||||
**kwargs,
|
||||
)
|
||||
if not isinstance(self, QObject):
|
||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||
app = QApplication.instance()
|
||||
if not hasattr(app, "theme"):
|
||||
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
|
||||
# Instead, we will set the theme to the system setting on startup
|
||||
if darkdetect.isDark():
|
||||
set_theme("dark")
|
||||
else:
|
||||
set_theme("light")
|
||||
|
||||
if theme_update:
|
||||
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
|
||||
self._connect_to_theme_change()
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self._update_theme)
|
||||
|
||||
def _update_theme(self, theme: str | None = None):
|
||||
"""Update the theme."""
|
||||
if theme is None:
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme"):
|
||||
theme = qapp.theme.theme
|
||||
else:
|
||||
theme = "dark"
|
||||
self.apply_theme(theme)
|
||||
|
||||
@Slot(str)
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Apply the theme to the widget.
|
||||
|
||||
Args:
|
||||
theme(str, optional): The theme to be applied.
|
||||
"""
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
with RPCRegister.delayed_broadcast():
|
||||
# All widgets need to call super().cleanup() in their cleanup method
|
||||
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
|
||||
self.rpc_register.remove_rpc(self)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
try:
|
||||
self.cleanup()
|
||||
finally:
|
||||
super().closeEvent(event) # pylint: disable=no-member
|
||||
@@ -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,141 +1,11 @@
|
||||
from __future__ import annotations
|
||||
from typing import Literal
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import bec_qthemes
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_qthemes._main import AccentColors
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
theme = "dark"
|
||||
else:
|
||||
theme = QApplication.instance().theme.theme
|
||||
return bec_qthemes.load_palette(theme)
|
||||
|
||||
|
||||
def get_accent_colors() -> AccentColors | None:
|
||||
"""
|
||||
Get the accent colors for the current theme. These colors are extensions of the color palette
|
||||
and are used to highlight specific elements in the UI.
|
||||
"""
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
return None
|
||||
return QApplication.instance().theme.accent_colors
|
||||
|
||||
|
||||
def _theme_update_callback():
|
||||
"""
|
||||
Internal callback function to update the theme based on the system theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
# pylint: disable=protected-access
|
||||
app.theme.theme = app.os_listener._theme.lower()
|
||||
app.theme_signal.theme_updated.emit(app.theme.theme)
|
||||
apply_theme(app.os_listener._theme.lower())
|
||||
|
||||
|
||||
def set_theme(theme: Literal["dark", "light", "auto"]):
|
||||
"""
|
||||
Set the theme for the application.
|
||||
|
||||
Args:
|
||||
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
bec_qthemes.setup_theme(theme, install_event_filter=False)
|
||||
|
||||
app.theme_signal.theme_updated.emit(theme)
|
||||
apply_theme(theme)
|
||||
|
||||
if theme != "auto":
|
||||
return
|
||||
|
||||
if not hasattr(app, "os_listener") or app.os_listener is None:
|
||||
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
|
||||
app.installEventFilter(app.os_listener)
|
||||
|
||||
|
||||
def apply_theme(theme: Literal["dark", "light"]):
|
||||
"""
|
||||
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
graphic_layouts = [
|
||||
child
|
||||
for top in app.topLevelWidgets()
|
||||
for child in top.findChildren(pg.GraphicsLayoutWidget)
|
||||
]
|
||||
|
||||
plot_items = [
|
||||
item
|
||||
for gl in graphic_layouts
|
||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
||||
if isinstance(item, pg.PlotItem)
|
||||
]
|
||||
|
||||
histograms = [
|
||||
item
|
||||
for gl in graphic_layouts
|
||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
||||
if isinstance(item, pg.HistogramLUTItem)
|
||||
]
|
||||
|
||||
# Update background color based on the theme
|
||||
if theme == "light":
|
||||
background_color = "#e9ecef" # Subtle contrast for light mode
|
||||
foreground_color = "#141414"
|
||||
label_color = "#000000"
|
||||
axis_color = "#666666"
|
||||
else:
|
||||
background_color = "#141414" # Dark mode
|
||||
foreground_color = "#e9ecef"
|
||||
label_color = "#FFFFFF"
|
||||
axis_color = "#CCCCCC"
|
||||
|
||||
# update GraphicsLayoutWidget
|
||||
pg.setConfigOptions(foreground=foreground_color, background=background_color)
|
||||
for pg_widget in graphic_layouts:
|
||||
pg_widget.setBackground(background_color)
|
||||
|
||||
# update PlotItems
|
||||
for plot_item in plot_items:
|
||||
for axis in ["left", "right", "top", "bottom"]:
|
||||
plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
|
||||
plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
|
||||
|
||||
# Change title color
|
||||
plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
|
||||
|
||||
# Change legend color
|
||||
if hasattr(plot_item, "legend") and plot_item.legend is not None:
|
||||
plot_item.legend.setLabelTextColor(label_color)
|
||||
# if legend is in plot item and theme is changed, has to be like that because of pg opt logic
|
||||
for sample, label in plot_item.legend.items:
|
||||
label_text = label.text
|
||||
label.setText(label_text, color=label_color)
|
||||
|
||||
# update HistogramLUTItem
|
||||
for histogram in histograms:
|
||||
histogram.axis.setPen(pg.mkPen(color=axis_color))
|
||||
histogram.axis.setTextPen(pg.mkPen(color=label_color))
|
||||
|
||||
# now define stylesheet according to theme and apply it
|
||||
style = bec_qthemes.load_stylesheet(theme)
|
||||
app.setStyleSheet(style)
|
||||
|
||||
|
||||
class Colors:
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
@@ -155,98 +25,9 @@ class Colors:
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def set_theme_offset(theme: Literal["light", "dark"] | None = None, offset=0.2) -> tuple:
|
||||
"""
|
||||
Set the theme offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
|
||||
|
||||
Args:
|
||||
theme(str): The theme to be applied.
|
||||
offset(float): Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
|
||||
|
||||
Returns:
|
||||
tuple: Tuple of min_pos and max_pos.
|
||||
|
||||
Raises:
|
||||
ValueError: If theme_offset is not between 0 and 1.
|
||||
"""
|
||||
|
||||
if offset < 0 or offset > 1:
|
||||
raise ValueError("theme_offset must be between 0 and 1")
|
||||
|
||||
if theme is None:
|
||||
app = QApplication.instance()
|
||||
if hasattr(app, "theme"):
|
||||
theme = app.theme.theme
|
||||
|
||||
if theme == "light":
|
||||
min_pos = 0.0
|
||||
max_pos = 1 - offset
|
||||
else:
|
||||
min_pos = 0.0 + offset
|
||||
max_pos = 1.0
|
||||
|
||||
return min_pos, max_pos
|
||||
|
||||
@staticmethod
|
||||
def evenly_spaced_colors(
|
||||
colormap: str,
|
||||
num: int,
|
||||
format: Literal["QColor", "HEX", "RGB"] = "QColor",
|
||||
theme_offset=0.2,
|
||||
theme: Literal["light", "dark"] | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
Extract `num` colors from the specified colormap, evenly spaced along its range,
|
||||
and return them in the specified format.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap.
|
||||
num (int): Number of requested colors.
|
||||
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
|
||||
theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
|
||||
theme (Literal['light', 'dark'] | None): The theme to be applied. Overrides the QApplication theme if specified.
|
||||
|
||||
Returns:
|
||||
list: List of colors in the specified format.
|
||||
|
||||
Raises:
|
||||
ValueError: If theme_offset is not between 0 and 1.
|
||||
"""
|
||||
if theme_offset < 0 or theme_offset > 1:
|
||||
raise ValueError("theme_offset must be between 0 and 1")
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
|
||||
|
||||
# Generate positions that are evenly spaced within the acceptable range
|
||||
if num == 1:
|
||||
positions = np.array([(min_pos + max_pos) / 2])
|
||||
else:
|
||||
positions = np.linspace(min_pos, max_pos, num)
|
||||
|
||||
# Sample colors from the colormap at the calculated positions
|
||||
colors = cmap.map(positions, mode="float")
|
||||
color_list = []
|
||||
|
||||
for color in colors:
|
||||
if format.upper() == "HEX":
|
||||
color_list.append(QColor.fromRgbF(*color).name())
|
||||
elif format.upper() == "RGB":
|
||||
color_list.append(tuple((np.array(color) * 255).astype(int)))
|
||||
elif format.upper() == "QCOLOR":
|
||||
color_list.append(QColor.fromRgbF(*color))
|
||||
else:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
return color_list
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(
|
||||
colormap: str,
|
||||
num: int,
|
||||
format: Literal["QColor", "HEX", "RGB"] = "QColor",
|
||||
theme_offset=0.2,
|
||||
theme: Literal["dark", "light"] | None = None,
|
||||
colormap: str, num: int, format: Literal["QColor", "HEX", "RGB"] = "QColor"
|
||||
) -> list:
|
||||
"""
|
||||
Extract num colors from the specified colormap following golden angle distribution and return them in the specified format.
|
||||
@@ -255,293 +36,30 @@ class Colors:
|
||||
colormap (str): Name of the colormap.
|
||||
num (int): Number of requested colors.
|
||||
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
|
||||
theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
|
||||
|
||||
Returns:
|
||||
list: List of colors in the specified format.
|
||||
|
||||
Raises:
|
||||
ValueError: If theme_offset is not between 0 and 1.
|
||||
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
|
||||
"""
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
|
||||
golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125
|
||||
|
||||
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
|
||||
|
||||
# Generate positions within the acceptable range
|
||||
positions = np.mod(np.arange(num) * golden_angle_conjugate, 1)
|
||||
positions = min_pos + positions * (max_pos - min_pos)
|
||||
|
||||
# Sample colors from the colormap at the calculated positions
|
||||
colors = cmap.map(positions, mode="float")
|
||||
color_list = []
|
||||
|
||||
for color in colors:
|
||||
cmap_colors = cmap.getColors(mode="float")
|
||||
if num > len(cmap_colors):
|
||||
raise ValueError(
|
||||
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
|
||||
)
|
||||
angles = Colors.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = []
|
||||
for ii in color_selection[:num]:
|
||||
color = cmap_colors[int(ii)]
|
||||
if format.upper() == "HEX":
|
||||
color_list.append(QColor.fromRgbF(*color).name())
|
||||
colors.append(QColor.fromRgbF(*color).name())
|
||||
elif format.upper() == "RGB":
|
||||
color_list.append(tuple((np.array(color) * 255).astype(int)))
|
||||
colors.append(tuple((np.array(color) * 255).astype(int)))
|
||||
elif format.upper() == "QCOLOR":
|
||||
color_list.append(QColor.fromRgbF(*color))
|
||||
colors.append(QColor.fromRgbF(*color))
|
||||
else:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
return color_list
|
||||
|
||||
@staticmethod
|
||||
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
|
||||
"""
|
||||
Convert HEX color to RGBA.
|
||||
|
||||
Args:
|
||||
hex_color(str): HEX color string.
|
||||
alpha(int): Alpha value (0-255). Default is 255 (opaque).
|
||||
|
||||
Returns:
|
||||
tuple: RGBA color tuple (r, g, b, a).
|
||||
"""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if len(hex_color) == 6:
|
||||
r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
|
||||
elif len(hex_color) == 8:
|
||||
r, g, b, a = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4, 6))
|
||||
return (r, g, b, a)
|
||||
else:
|
||||
raise ValueError("HEX color must be 6 or 8 characters long.")
|
||||
return (r, g, b, alpha)
|
||||
|
||||
@staticmethod
|
||||
def rgba_to_hex(r: int, g: int, b: int, a: int = 255) -> str:
|
||||
"""
|
||||
Convert RGBA color to HEX.
|
||||
|
||||
Args:
|
||||
r(int): Red value (0-255).
|
||||
g(int): Green value (0-255).
|
||||
b(int): Blue value (0-255).
|
||||
a(int): Alpha value (0-255). Default is 255 (opaque).
|
||||
|
||||
Returns:
|
||||
hec_color(str): HEX color string.
|
||||
"""
|
||||
return "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, a)
|
||||
|
||||
@staticmethod
|
||||
def validate_color(color: tuple | str) -> tuple | str:
|
||||
"""
|
||||
Validate the color input if it is HEX or RGBA compatible. Can be used in any pydantic model as a field validator.
|
||||
|
||||
Args:
|
||||
color(tuple|str): The color to be validated. Can be a tuple of RGBA values or a HEX string.
|
||||
|
||||
Returns:
|
||||
tuple|str: The validated color.
|
||||
"""
|
||||
CSS_COLOR_NAMES = {
|
||||
"aliceblue",
|
||||
"antiquewhite",
|
||||
"aqua",
|
||||
"aquamarine",
|
||||
"azure",
|
||||
"beige",
|
||||
"bisque",
|
||||
"black",
|
||||
"blanchedalmond",
|
||||
"blue",
|
||||
"blueviolet",
|
||||
"brown",
|
||||
"burlywood",
|
||||
"cadetblue",
|
||||
"chartreuse",
|
||||
"chocolate",
|
||||
"coral",
|
||||
"cornflowerblue",
|
||||
"cornsilk",
|
||||
"crimson",
|
||||
"cyan",
|
||||
"darkblue",
|
||||
"darkcyan",
|
||||
"darkgoldenrod",
|
||||
"darkgray",
|
||||
"darkgreen",
|
||||
"darkgrey",
|
||||
"darkkhaki",
|
||||
"darkmagenta",
|
||||
"darkolivegreen",
|
||||
"darkorange",
|
||||
"darkorchid",
|
||||
"darkred",
|
||||
"darksalmon",
|
||||
"darkseagreen",
|
||||
"darkslateblue",
|
||||
"darkslategray",
|
||||
"darkslategrey",
|
||||
"darkturquoise",
|
||||
"darkviolet",
|
||||
"deeppink",
|
||||
"deepskyblue",
|
||||
"dimgray",
|
||||
"dimgrey",
|
||||
"dodgerblue",
|
||||
"firebrick",
|
||||
"floralwhite",
|
||||
"forestgreen",
|
||||
"fuchsia",
|
||||
"gainsboro",
|
||||
"ghostwhite",
|
||||
"gold",
|
||||
"goldenrod",
|
||||
"gray",
|
||||
"green",
|
||||
"greenyellow",
|
||||
"grey",
|
||||
"honeydew",
|
||||
"hotpink",
|
||||
"indianred",
|
||||
"indigo",
|
||||
"ivory",
|
||||
"khaki",
|
||||
"lavender",
|
||||
"lavenderblush",
|
||||
"lawngreen",
|
||||
"lemonchiffon",
|
||||
"lightblue",
|
||||
"lightcoral",
|
||||
"lightcyan",
|
||||
"lightgoldenrodyellow",
|
||||
"lightgray",
|
||||
"lightgreen",
|
||||
"lightgrey",
|
||||
"lightpink",
|
||||
"lightsalmon",
|
||||
"lightseagreen",
|
||||
"lightskyblue",
|
||||
"lightslategray",
|
||||
"lightslategrey",
|
||||
"lightsteelblue",
|
||||
"lightyellow",
|
||||
"lime",
|
||||
"limegreen",
|
||||
"linen",
|
||||
"magenta",
|
||||
"maroon",
|
||||
"mediumaquamarine",
|
||||
"mediumblue",
|
||||
"mediumorchid",
|
||||
"mediumpurple",
|
||||
"mediumseagreen",
|
||||
"mediumslateblue",
|
||||
"mediumspringgreen",
|
||||
"mediumturquoise",
|
||||
"mediumvioletred",
|
||||
"midnightblue",
|
||||
"mintcream",
|
||||
"mistyrose",
|
||||
"moccasin",
|
||||
"navajowhite",
|
||||
"navy",
|
||||
"oldlace",
|
||||
"olive",
|
||||
"olivedrab",
|
||||
"orange",
|
||||
"orangered",
|
||||
"orchid",
|
||||
"palegoldenrod",
|
||||
"palegreen",
|
||||
"paleturquoise",
|
||||
"palevioletred",
|
||||
"papayawhip",
|
||||
"peachpuff",
|
||||
"peru",
|
||||
"pink",
|
||||
"plum",
|
||||
"powderblue",
|
||||
"purple",
|
||||
"red",
|
||||
"rosybrown",
|
||||
"royalblue",
|
||||
"saddlebrown",
|
||||
"salmon",
|
||||
"sandybrown",
|
||||
"seagreen",
|
||||
"seashell",
|
||||
"sienna",
|
||||
"silver",
|
||||
"skyblue",
|
||||
"slateblue",
|
||||
"slategray",
|
||||
"slategrey",
|
||||
"snow",
|
||||
"springgreen",
|
||||
"steelblue",
|
||||
"tan",
|
||||
"teal",
|
||||
"thistle",
|
||||
"tomato",
|
||||
"turquoise",
|
||||
"violet",
|
||||
"wheat",
|
||||
"white",
|
||||
"whitesmoke",
|
||||
"yellow",
|
||||
"yellowgreen",
|
||||
}
|
||||
if isinstance(color, str):
|
||||
hex_pattern = re.compile(r"^#(?:[0-9a-fA-F]{3}){1,2}$")
|
||||
if hex_pattern.match(color):
|
||||
return color
|
||||
elif color.lower() in CSS_COLOR_NAMES:
|
||||
return color
|
||||
else:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
"The color must be a valid HEX string or CSS Color.",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
elif isinstance(color, tuple):
|
||||
if len(color) != 4:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
"The color must be a tuple of 4 elements (R, G, B, A).",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
for value in color:
|
||||
if not 0 <= value <= 255:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
f"The color values must be between 0 and 255 in RGBA format (R,G,B,A)",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
return color
|
||||
|
||||
@staticmethod
|
||||
def validate_color_map(color_map: str, return_error: bool = True) -> str | bool:
|
||||
"""
|
||||
Validate the colormap input if it is supported by pyqtgraph. Can be used in any pydantic model as a field validator. If validation fails it prints all available colormaps from pyqtgraph instance.
|
||||
|
||||
Args:
|
||||
color_map(str): The colormap to be validated.
|
||||
|
||||
Returns:
|
||||
str: The validated colormap, if colormap is valid.
|
||||
bool: False, if colormap is invalid.
|
||||
|
||||
Raises:
|
||||
PydanticCustomError: If colormap is invalid.
|
||||
"""
|
||||
available_pg_maps = pg.colormap.listMaps()
|
||||
available_mpl_maps = pg.colormap.listMaps("matplotlib")
|
||||
available_mpl_colorcet = pg.colormap.listMaps("colorcet")
|
||||
|
||||
available_colormaps = available_pg_maps + available_mpl_maps + available_mpl_colorcet
|
||||
if color_map not in available_colormaps:
|
||||
if return_error:
|
||||
raise PydanticCustomError(
|
||||
"unsupported colormap",
|
||||
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
|
||||
{"wrong_value": color_map},
|
||||
)
|
||||
else:
|
||||
return False
|
||||
return color_map
|
||||
return colors
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Property, Qt, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
|
||||
|
||||
class LedLabel(QLabel):
|
||||
success_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.145, y1:0.16, x2:1, y2:1, stop:0 %s, stop:1 %s);"
|
||||
emergency_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.145, y1:0.16, x2:0.92, y2:0.988636, stop:0 %s, stop:1 %s);"
|
||||
warning_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.232, y1:0.272, x2:0.98, y2:0.959773, stop:0 %s, stop:1 %s);"
|
||||
default_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.04, y1:0.0565909, x2:0.799, y2:0.795, stop:0 %s, stop:1 %s);"
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.palette = get_accent_colors()
|
||||
if self.palette is None:
|
||||
# no theme!
|
||||
self.palette = SimpleNamespace(
|
||||
default=QColor("blue"),
|
||||
success=QColor("green"),
|
||||
warning=QColor("orange"),
|
||||
emergency=QColor("red"),
|
||||
)
|
||||
self.setState("default")
|
||||
self.setFixedSize(20, 20)
|
||||
|
||||
def setState(self, state: str):
|
||||
match state:
|
||||
case "success":
|
||||
r, g, b, a = self.palette.success.getRgb()
|
||||
self.setStyleSheet(
|
||||
LedLabel.success_led
|
||||
% (
|
||||
f"rgba({r},{g},{b},{a})",
|
||||
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
|
||||
)
|
||||
)
|
||||
case "default":
|
||||
r, g, b, a = self.palette.default.getRgb()
|
||||
self.setStyleSheet(
|
||||
LedLabel.default_led
|
||||
% (
|
||||
f"rgba({r},{g},{b},{a})",
|
||||
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
|
||||
)
|
||||
)
|
||||
case "warning":
|
||||
r, g, b, a = self.palette.warning.getRgb()
|
||||
self.setStyleSheet(
|
||||
LedLabel.warning_led
|
||||
% (
|
||||
f"rgba({r},{g},{b},{a})",
|
||||
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
|
||||
)
|
||||
)
|
||||
case "emergency":
|
||||
r, g, b, a = self.palette.emergency.getRgb()
|
||||
self.setStyleSheet(
|
||||
LedLabel.emergency_led
|
||||
% (
|
||||
f"rgba({r},{g},{b},{a})",
|
||||
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
|
||||
)
|
||||
)
|
||||
case unknown_state:
|
||||
raise ValueError(
|
||||
f"Unknown state {repr(unknown_state)}, must be one of default, success, warning or emergency"
|
||||
)
|
||||
|
||||
|
||||
class PopupDialog(QDialog):
|
||||
def __init__(self, content_widget):
|
||||
self.parent = content_widget.parent()
|
||||
self.content_widget = content_widget
|
||||
|
||||
super().__init__(self.parent)
|
||||
|
||||
self.setAttribute(Qt.WA_DeleteOnClose)
|
||||
|
||||
self.content_widget.setParent(self)
|
||||
QVBoxLayout(self)
|
||||
self.layout().addWidget(self.content_widget)
|
||||
self.content_widget.setVisible(True)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.content_widget.setVisible(False)
|
||||
self.content_widget.setParent(self.parent)
|
||||
self.done(True)
|
||||
|
||||
|
||||
class CompactPopupWidget(QWidget):
|
||||
"""Container widget, that can display its content or have a compact form,
|
||||
in this case clicking on a small button pops the contained widget up.
|
||||
|
||||
In the compact form, a LED-like indicator shows a status indicator.
|
||||
"""
|
||||
|
||||
expand = Signal(bool)
|
||||
|
||||
def __init__(self, parent=None, layout=QVBoxLayout):
|
||||
super().__init__(parent)
|
||||
|
||||
self._popup_window = None
|
||||
self._expand_popup = True
|
||||
|
||||
QVBoxLayout(self)
|
||||
self.compact_view_widget = QWidget(self)
|
||||
self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
QHBoxLayout(self.compact_view_widget)
|
||||
self.compact_view_widget.layout().setSpacing(0)
|
||||
self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self.compact_view_widget.layout().addSpacerItem(
|
||||
QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
)
|
||||
self.compact_label = QLabel(self.compact_view_widget)
|
||||
self.compact_status = LedLabel(self.compact_view_widget)
|
||||
self.compact_show_popup = QPushButton(self.compact_view_widget)
|
||||
self.compact_show_popup.setFlat(True)
|
||||
self.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self.compact_view_widget.layout().addWidget(self.compact_label)
|
||||
self.compact_view_widget.layout().addWidget(self.compact_status)
|
||||
self.compact_view_widget.layout().addWidget(self.compact_show_popup)
|
||||
self.compact_view_widget.setVisible(False)
|
||||
self.layout().addWidget(self.compact_view_widget)
|
||||
self.container = QWidget(self)
|
||||
self.layout().addWidget(self.container)
|
||||
self.container.setVisible(True)
|
||||
layout(self.container)
|
||||
self.layout = self.container.layout()
|
||||
|
||||
self.compact_show_popup.clicked.connect(self.show_popup)
|
||||
|
||||
def set_global_state(self, state: str):
|
||||
"""Set the LED-indicator state
|
||||
|
||||
The LED indicator represents the 'global' state. State can be one of the
|
||||
following: "default", "success", "warning", "emergency"
|
||||
"""
|
||||
self.compact_status.setState(state)
|
||||
|
||||
def show_popup(self):
|
||||
"""Display the contained widgets in a popup dialog"""
|
||||
if self._expand_popup:
|
||||
# show popup
|
||||
self._popup_window = PopupDialog(self.container)
|
||||
self._popup_window.show()
|
||||
self._popup_window.finished.connect(lambda: self.expand.emit(False))
|
||||
self.expand.emit(True)
|
||||
else:
|
||||
if self.compact_view:
|
||||
# expand in place
|
||||
self.compact_view = False
|
||||
self.compact_view_widget.setVisible(True)
|
||||
self.compact_label.setVisible(False)
|
||||
self.compact_status.setVisible(False)
|
||||
self.compact_show_popup.setIcon(
|
||||
material_icon(
|
||||
icon_name="collapse_content", size=(10, 10), convert_to_pixmap=False
|
||||
)
|
||||
)
|
||||
self.expand.emit(True)
|
||||
else:
|
||||
# back to compact form
|
||||
self.compact_label.setVisible(True)
|
||||
self.compact_status.setVisible(True)
|
||||
self.compact_show_popup.setIcon(
|
||||
material_icon(
|
||||
icon_name="expand_content", size=(10, 10), convert_to_pixmap=False
|
||||
)
|
||||
)
|
||||
self.compact_view = True
|
||||
self.expand.emit(False)
|
||||
|
||||
def setSizePolicy(self, size_policy1, size_policy2=None):
|
||||
# setting size policy on the compact popup widget will set
|
||||
# the policy for the container, and for itself
|
||||
if size_policy2 is None:
|
||||
# assuming first form: setSizePolicy(QSizePolicy)
|
||||
self.container.setSizePolicy(size_policy1)
|
||||
QWidget.setSizePolicy(self, size_policy1)
|
||||
else:
|
||||
self.container.setSizePolicy(size_policy1, size_policy2)
|
||||
QWidget.setSizePolicy(self, size_policy1, size_policy2)
|
||||
|
||||
def addWidget(self, widget):
|
||||
"""Add a widget to the popup container
|
||||
|
||||
The popup container corresponds to the "full view" (not compact)
|
||||
The widget is reparented to the container, and added to the container layout
|
||||
"""
|
||||
widget.setParent(self.container)
|
||||
self.container.layout().addWidget(widget)
|
||||
|
||||
@Property(bool)
|
||||
def compact_view(self):
|
||||
return self.compact_label.isVisible()
|
||||
|
||||
@compact_view.setter
|
||||
def compact_view(self, set_compact: bool):
|
||||
"""Sets the compact form
|
||||
|
||||
If set_compact is True, the compact view is displayed ; otherwise,
|
||||
the full view is displayed. This is handled by toggling visibility of
|
||||
the container widget or the compact view widget.
|
||||
"""
|
||||
if set_compact:
|
||||
self.compact_view_widget.setVisible(True)
|
||||
self.container.setVisible(False)
|
||||
QWidget.setSizePolicy(self, QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
else:
|
||||
self.compact_view_widget.setVisible(False)
|
||||
self.container.setVisible(True)
|
||||
QWidget.setSizePolicy(self, self.container.sizePolicy())
|
||||
if self.parentWidget():
|
||||
self.parentWidget().adjustSize()
|
||||
else:
|
||||
self.adjustSize()
|
||||
|
||||
@Property(str)
|
||||
def label(self):
|
||||
return self.compact_label.text()
|
||||
|
||||
@label.setter
|
||||
def label(self, compact_label_text: str):
|
||||
"""Set the label text associated to the compact view"""
|
||||
self.compact_label.setText(compact_label_text)
|
||||
|
||||
@Property(str)
|
||||
def tooltip(self):
|
||||
return self.compact_label.toolTip()
|
||||
|
||||
@tooltip.setter
|
||||
def tooltip(self, tooltip: str):
|
||||
"""Set the tooltip text associated to the compact view"""
|
||||
self.compact_label.setToolTip(tooltip)
|
||||
self.compact_status.setToolTip(tooltip)
|
||||
|
||||
@Property(bool)
|
||||
def expand_popup(self):
|
||||
return self._expand_popup
|
||||
|
||||
@expand_popup.setter
|
||||
def expand_popup(self, popup: bool):
|
||||
self._expand_popup = popup
|
||||
|
||||
def closeEvent(self, event):
|
||||
# Called by Qt, on closing - since the children widgets can be
|
||||
# BECWidgets, it is good to explicitely call 'close' on them,
|
||||
# to ensure proper resources cleanup
|
||||
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
|
||||
child.close()
|
||||
@@ -1,75 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from typing import Literal, 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.
|
||||
|
||||
Args:
|
||||
name(str): The name to be checked.
|
||||
|
||||
Returns:
|
||||
bool: True if the name is valid, False otherwise.
|
||||
"""
|
||||
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.")
|
||||
|
||||
@staticmethod
|
||||
def find_first_widget_by_class(
|
||||
container: dict, widget_class: Type[QWidget], can_fail: bool = True
|
||||
) -> QWidget | None:
|
||||
"""
|
||||
Find the first widget of a given class in the figure.
|
||||
|
||||
Args:
|
||||
container(dict): The container of widgets.
|
||||
widget_class(Type): The class of the widget to find.
|
||||
can_fail(bool): If True, the method will return None if no widget is found. If False, it will raise an error.
|
||||
|
||||
Returns:
|
||||
widget: The widget of the given class.
|
||||
"""
|
||||
for widget_id, widget in container.items():
|
||||
if isinstance(widget, widget_class):
|
||||
return widget
|
||||
if can_fail:
|
||||
return None
|
||||
else:
|
||||
raise ValueError(f"No widget of class {widget_class} found.")
|
||||
@@ -1,43 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, Qt, Signal, Slot
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
|
||||
class CrosshairScatterItem(pg.ScatterPlotItem):
|
||||
def setDownsampling(self, ds=None, auto=None, method=None):
|
||||
pass
|
||||
|
||||
def setClipToView(self, state):
|
||||
pass
|
||||
|
||||
def setAlpha(self, *args, **kwargs):
|
||||
pass
|
||||
# from qtpy.QtCore import QObject, pyqtSignal
|
||||
from qtpy.QtCore import QObject, Signal as pyqtSignal
|
||||
|
||||
|
||||
class Crosshair(QObject):
|
||||
# QT Position of mouse cursor
|
||||
positionChanged = Signal(tuple)
|
||||
positionClicked = Signal(tuple)
|
||||
# Plain crosshair position signals mapped to real coordinates
|
||||
crosshairChanged = Signal(tuple)
|
||||
crosshairClicked = Signal(tuple)
|
||||
# Signal for 1D plot
|
||||
coordinatesChanged1D = Signal(tuple)
|
||||
coordinatesClicked1D = Signal(tuple)
|
||||
coordinatesChanged1D = pyqtSignal(float, list)
|
||||
coordinatesClicked1D = pyqtSignal(float, list)
|
||||
# Signal for 2D plot
|
||||
coordinatesChanged2D = Signal(tuple)
|
||||
coordinatesClicked2D = Signal(tuple)
|
||||
coordinatesChanged2D = pyqtSignal(float, float)
|
||||
coordinatesClicked2D = pyqtSignal(float, float)
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
|
||||
def __init__(self, plot_item: pg.PlotItem, precision: int = None, parent=None):
|
||||
"""
|
||||
Crosshair for 1D and 2D plots.
|
||||
|
||||
Args:
|
||||
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
|
||||
precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
|
||||
@@ -46,187 +24,85 @@ class Crosshair(QObject):
|
||||
super().__init__(parent)
|
||||
self.is_log_y = None
|
||||
self.is_log_x = None
|
||||
self.is_derivative = None
|
||||
self.plot_item = plot_item
|
||||
self.precision = precision
|
||||
self.v_line = pg.InfiniteLine(angle=90, movable=False)
|
||||
self.v_line.skip_auto_range = True
|
||||
self.h_line = pg.InfiniteLine(angle=0, movable=False)
|
||||
self.h_line.skip_auto_range = True
|
||||
# Add custom attribute to identify crosshair lines
|
||||
self.v_line.is_crosshair = True
|
||||
self.h_line.is_crosshair = True
|
||||
self.plot_item.addItem(self.v_line, ignoreBounds=True)
|
||||
self.plot_item.addItem(self.h_line, ignoreBounds=True)
|
||||
|
||||
# Initialize highlighted curve in a case of multiple curves
|
||||
self.highlighted_curve_index = None
|
||||
|
||||
# Add TextItem to display coordinates
|
||||
self.coord_label = pg.TextItem("", anchor=(1, 1), fill=(0, 0, 0, 100))
|
||||
self.coord_label.setVisible(False) # Hide initially
|
||||
self.coord_label.skip_auto_range = True
|
||||
self.plot_item.addItem(self.coord_label)
|
||||
|
||||
# Signals to connect
|
||||
self.proxy = pg.SignalProxy(
|
||||
self.plot_item.scene().sigMouseMoved, rateLimit=60, slot=self.mouse_moved
|
||||
)
|
||||
self.positionChanged.connect(self.update_coord_label)
|
||||
self.plot_item.scene().sigMouseClicked.connect(self.mouse_clicked)
|
||||
|
||||
# Connect signals from pyqtgraph right click menu
|
||||
self.plot_item.ctrl.derivativeCheck.checkStateChanged.connect(self.check_derivatives)
|
||||
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
|
||||
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
|
||||
self.plot_item.ctrl.downsampleSpin.valueChanged.connect(self.clear_markers)
|
||||
|
||||
# Initialize markers
|
||||
self.items = []
|
||||
self.marker_moved_1d = {}
|
||||
self.marker_clicked_1d = {}
|
||||
self.marker_moved_1d = []
|
||||
self.marker_clicked_1d = []
|
||||
self.marker_2d = None
|
||||
self.update_markers()
|
||||
self.check_log()
|
||||
self.check_derivatives()
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self._update_theme)
|
||||
self._update_theme()
|
||||
|
||||
@Slot(str)
|
||||
def _update_theme(self, theme: str | None = None):
|
||||
"""Update the theme."""
|
||||
if theme is None:
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme"):
|
||||
theme = qapp.theme.theme
|
||||
else:
|
||||
theme = "dark"
|
||||
self.apply_theme(theme)
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""Apply the theme to the plot."""
|
||||
if theme == "dark":
|
||||
text_color = "w"
|
||||
label_bg_color = (50, 50, 50, 150)
|
||||
elif theme == "light":
|
||||
text_color = "k"
|
||||
label_bg_color = (240, 240, 240, 150)
|
||||
else:
|
||||
text_color = "w"
|
||||
label_bg_color = (50, 50, 50, 150)
|
||||
|
||||
self.coord_label.setColor(text_color)
|
||||
self.coord_label.fill = pg.mkBrush(label_bg_color)
|
||||
self.coord_label.border = pg.mkPen(None)
|
||||
|
||||
@Slot(int)
|
||||
def update_highlighted_curve(self, curve_index: int):
|
||||
"""
|
||||
Update the highlighted curve in the case of multiple curves in a plot item.
|
||||
|
||||
Args:
|
||||
curve_index(int): The index of curve to highlight
|
||||
"""
|
||||
self.highlighted_curve_index = curve_index
|
||||
self.clear_markers()
|
||||
self.update_markers()
|
||||
|
||||
def update_markers(self):
|
||||
"""Update the markers for the crosshair, creating new ones if necessary."""
|
||||
|
||||
if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"):
|
||||
# Focus on the highlighted curve only
|
||||
self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]]
|
||||
else:
|
||||
# Handle all curves
|
||||
self.items = self.plot_item.items
|
||||
# Clear existing markers
|
||||
for marker in self.marker_moved_1d + self.marker_clicked_1d:
|
||||
self.plot_item.removeItem(marker)
|
||||
if self.marker_2d:
|
||||
self.plot_item.removeItem(self.marker_2d)
|
||||
|
||||
# Create or update markers
|
||||
for item in self.items:
|
||||
# Create new markers
|
||||
self.marker_moved_1d = []
|
||||
self.marker_clicked_1d = []
|
||||
self.marker_2d = None
|
||||
for item in self.plot_item.items:
|
||||
if isinstance(item, pg.PlotDataItem): # 1D plot
|
||||
pen = item.opts["pen"]
|
||||
color = pen.color() if hasattr(pen, "color") else pg.mkColor(pen)
|
||||
name = item.name() or str(id(item))
|
||||
if name in self.marker_moved_1d:
|
||||
# Update existing markers
|
||||
marker_moved = self.marker_moved_1d[name]
|
||||
marker_moved.setPen(pg.mkPen(color))
|
||||
# Update clicked markers' brushes
|
||||
for marker_clicked in self.marker_clicked_1d[name]:
|
||||
alpha = marker_clicked.opts["brush"].color().alpha()
|
||||
marker_clicked.setBrush(
|
||||
pg.mkBrush(color.red(), color.green(), color.blue(), alpha)
|
||||
)
|
||||
# Update z-values
|
||||
marker_moved.setZValue(item.zValue() + 1)
|
||||
for marker_clicked in self.marker_clicked_1d[name]:
|
||||
marker_clicked.setZValue(item.zValue() + 1)
|
||||
else:
|
||||
# Create new markers
|
||||
marker_moved = CrosshairScatterItem(
|
||||
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
|
||||
marker_moved = pg.ScatterPlotItem(
|
||||
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
|
||||
)
|
||||
marker_clicked = pg.ScatterPlotItem(
|
||||
size=10, pen=pg.mkPen(None), brush=pg.mkBrush(color)
|
||||
)
|
||||
self.marker_moved_1d.append(marker_moved)
|
||||
self.plot_item.addItem(marker_moved)
|
||||
# Create glowing effect markers for clicked events
|
||||
marker_clicked_list = []
|
||||
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
|
||||
marker_clicked = pg.ScatterPlotItem(
|
||||
size=size,
|
||||
pen=pg.mkPen(None),
|
||||
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
|
||||
)
|
||||
marker_moved.skip_auto_range = True
|
||||
marker_moved.is_crosshair = True
|
||||
self.marker_moved_1d[name] = marker_moved
|
||||
self.plot_item.addItem(marker_moved)
|
||||
# Set marker z-value higher than the curve
|
||||
marker_moved.setZValue(item.zValue() + 1)
|
||||
marker_clicked_list.append(marker_clicked)
|
||||
self.plot_item.addItem(marker_clicked)
|
||||
|
||||
# Create glowing effect markers for clicked events
|
||||
marker_clicked_list = []
|
||||
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
|
||||
marker_clicked = CrosshairScatterItem(
|
||||
size=size,
|
||||
pen=pg.mkPen(None),
|
||||
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
|
||||
)
|
||||
marker_clicked.skip_auto_range = True
|
||||
marker_clicked.is_crosshair = True
|
||||
self.plot_item.addItem(marker_clicked)
|
||||
marker_clicked.setZValue(item.zValue() + 1)
|
||||
marker_clicked_list.append(marker_clicked)
|
||||
self.marker_clicked_1d[name] = marker_clicked_list
|
||||
self.marker_clicked_1d.append(marker_clicked_list)
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
if self.marker_2d is not None:
|
||||
continue
|
||||
self.marker_2d = pg.ROI(
|
||||
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
|
||||
)
|
||||
self.marker_2d.skip_auto_range = True
|
||||
self.plot_item.addItem(self.marker_2d)
|
||||
|
||||
def snap_to_data(
|
||||
self, x: float, y: float
|
||||
) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]:
|
||||
def snap_to_data(self, x, y) -> tuple:
|
||||
"""
|
||||
Finds the nearest data points to the given x and y coordinates.
|
||||
|
||||
Args:
|
||||
x(float): The x-coordinate of the mouse cursor
|
||||
y(float): The y-coordinate of the mouse cursor
|
||||
x: The x-coordinate
|
||||
y: The y-coordinate
|
||||
|
||||
Returns:
|
||||
tuple: x and y values snapped to the nearest data
|
||||
tuple: The nearest x and y values
|
||||
"""
|
||||
y_values = defaultdict(list)
|
||||
x_values = defaultdict(list)
|
||||
y_values_1d = []
|
||||
x_values_1d = []
|
||||
image_2d = None
|
||||
|
||||
# Iterate through items in the plot
|
||||
for item in self.items:
|
||||
for item in self.plot_item.items:
|
||||
if isinstance(item, pg.PlotDataItem): # 1D plot
|
||||
name = item.name() or str(id(item))
|
||||
plot_data = item._getDisplayDataset()
|
||||
if plot_data is None:
|
||||
continue
|
||||
x_data, y_data = plot_data.x, plot_data.y
|
||||
x_data, y_data = item.xData, item.yData
|
||||
if x_data is not None and y_data is not None:
|
||||
if self.is_log_x:
|
||||
min_x_data = np.min(x_data[x_data > 0])
|
||||
@@ -234,56 +110,43 @@ class Crosshair(QObject):
|
||||
min_x_data = np.min(x_data)
|
||||
max_x_data = np.max(x_data)
|
||||
if x < min_x_data or x > max_x_data:
|
||||
y_values[name] = None
|
||||
x_values[name] = None
|
||||
continue
|
||||
return None, None
|
||||
closest_x, closest_y = self.closest_x_y_value(x, x_data, y_data)
|
||||
y_values[name] = closest_y
|
||||
x_values[name] = closest_x
|
||||
y_values_1d.append(closest_y)
|
||||
x_values_1d.append(closest_x)
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
name = item.config.monitor or str(id(item))
|
||||
image_2d = item.image
|
||||
# Clip the x and y values to the image dimensions to avoid out of bounds errors
|
||||
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
|
||||
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
|
||||
|
||||
if x_values and y_values:
|
||||
if all(v is None for v in x_values.values()) or all(
|
||||
v is None for v in y_values.values()
|
||||
):
|
||||
# Handle 1D plot
|
||||
if y_values_1d:
|
||||
if all(v is None for v in x_values_1d) or all(v is None for v in y_values_1d):
|
||||
return None, None
|
||||
return x_values, y_values
|
||||
closest_x = min(x_values_1d, key=lambda xi: abs(xi - x)) # Snap x to closest data point
|
||||
return closest_x, y_values_1d
|
||||
|
||||
# Handle 2D plot
|
||||
if image_2d is not None:
|
||||
x_idx = int(np.clip(x, 0, image_2d.shape[0] - 1))
|
||||
y_idx = int(np.clip(y, 0, image_2d.shape[1] - 1))
|
||||
return x_idx, y_idx
|
||||
|
||||
return None, None
|
||||
|
||||
def closest_x_y_value(self, input_x: float, list_x: list, list_y: list) -> tuple:
|
||||
def closest_x_y_value(self, input_value: float, list_x: list, list_y: list) -> tuple:
|
||||
"""
|
||||
Find the closest x and y value to the input value.
|
||||
|
||||
Args:
|
||||
input_x (float): Input value
|
||||
input_value (float): Input value
|
||||
list_x (list): List of x values
|
||||
list_y (list): List of y values
|
||||
|
||||
Returns:
|
||||
tuple: Closest x and y value
|
||||
"""
|
||||
# Convert lists to NumPy arrays
|
||||
arr_x = np.asarray(list_x)
|
||||
|
||||
# Get the indices where x is not NaN
|
||||
valid_indices = ~np.isnan(arr_x)
|
||||
|
||||
# Filter x array to exclude NaN values
|
||||
filtered_x = arr_x[valid_indices]
|
||||
|
||||
# Find the index of the closest value in the filtered x array
|
||||
closest_index = np.abs(filtered_x - input_x).argmin()
|
||||
|
||||
# Map back to the original index in the list_x and list_y arrays
|
||||
original_index = np.where(valid_indices)[0][closest_index]
|
||||
|
||||
return list_x[original_index], list_y[original_index]
|
||||
arr = np.asarray(list_x)
|
||||
i = (np.abs(arr - input_value)).argmin()
|
||||
return list_x[i], list_y[i]
|
||||
|
||||
def mouse_moved(self, event):
|
||||
"""Handles the mouse moved event, updating the crosshair position and emitting signals.
|
||||
@@ -291,50 +154,37 @@ class Crosshair(QObject):
|
||||
Args:
|
||||
event: The mouse moved event
|
||||
"""
|
||||
self.check_log()
|
||||
pos = event[0]
|
||||
self.update_markers()
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(pos):
|
||||
mouse_point = self.plot_item.vb.mapSceneToView(pos)
|
||||
self.v_line.setPos(mouse_point.x())
|
||||
self.h_line.setPos(mouse_point.y())
|
||||
|
||||
x, y = mouse_point.x(), mouse_point.y()
|
||||
self.v_line.setPos(x)
|
||||
self.h_line.setPos(y)
|
||||
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
|
||||
self.crosshairChanged.emit((scaled_x, scaled_y))
|
||||
self.positionChanged.emit((x, y))
|
||||
if self.is_log_x:
|
||||
x = 10**x
|
||||
if self.is_log_y:
|
||||
y = 10**y
|
||||
x, y_values = self.snap_to_data(x, y)
|
||||
|
||||
x_snap_values, y_snap_values = self.snap_to_data(x, y)
|
||||
if x_snap_values is None or y_snap_values is None:
|
||||
return
|
||||
if all(v is None for v in x_snap_values.values()) or all(
|
||||
v is None for v in y_snap_values.values()
|
||||
):
|
||||
# not sure how we got here, but just to be safe...
|
||||
return
|
||||
|
||||
for item in self.items:
|
||||
for item in self.plot_item.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_moved_1d[name].setData([x], [y])
|
||||
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
|
||||
coordinate_to_emit = (
|
||||
name,
|
||||
round(x_snapped_scaled, self.precision),
|
||||
round(y_snapped_scaled, self.precision),
|
||||
if x is None or all(v is None for v in y_values):
|
||||
return
|
||||
self.coordinatesChanged1D.emit(
|
||||
round(x, self.precision),
|
||||
[round(y_val, self.precision) for y_val in y_values],
|
||||
)
|
||||
self.coordinatesChanged1D.emit(coordinate_to_emit)
|
||||
for i, y_val in enumerate(y_values):
|
||||
self.marker_moved_1d[i].setData(
|
||||
[x if not self.is_log_x else np.log10(x)],
|
||||
[y_val if not self.is_log_y else np.log10(y_val)],
|
||||
)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.config.monitor or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_2d.setPos([x, y])
|
||||
coordinate_to_emit = (name, x, y)
|
||||
self.coordinatesChanged2D.emit(coordinate_to_emit)
|
||||
else:
|
||||
continue
|
||||
if x is None or y_values is None:
|
||||
return
|
||||
self.coordinatesChanged2D.emit(x, y_values)
|
||||
|
||||
def mouse_clicked(self, event):
|
||||
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
|
||||
@@ -342,119 +192,38 @@ class Crosshair(QObject):
|
||||
Args:
|
||||
event: The mouse clicked event
|
||||
"""
|
||||
|
||||
# we only accept left mouse clicks
|
||||
if event.button() != Qt.MouseButton.LeftButton:
|
||||
return
|
||||
self.update_markers()
|
||||
self.check_log()
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos):
|
||||
mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos)
|
||||
x, y = mouse_point.x(), mouse_point.y()
|
||||
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
|
||||
self.crosshairClicked.emit((scaled_x, scaled_y))
|
||||
self.positionClicked.emit((x, y))
|
||||
|
||||
x_snap_values, y_snap_values = self.snap_to_data(x, y)
|
||||
if self.is_log_x:
|
||||
x = 10**x
|
||||
if self.is_log_y:
|
||||
y = 10**y
|
||||
x, y_values = self.snap_to_data(x, y)
|
||||
|
||||
if x_snap_values is None or y_snap_values is None:
|
||||
return
|
||||
if all(v is None for v in x_snap_values.values()) or all(
|
||||
v is None for v in y_snap_values.values()
|
||||
):
|
||||
# not sure how we got here, but just to be safe...
|
||||
return
|
||||
|
||||
for item in self.items:
|
||||
for item in self.plot_item.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
for marker_clicked in self.marker_clicked_1d[name]:
|
||||
marker_clicked.setData([x], [y])
|
||||
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
|
||||
coordinate_to_emit = (
|
||||
name,
|
||||
round(x_snapped_scaled, self.precision),
|
||||
round(y_snapped_scaled, self.precision),
|
||||
if x is None or all(v is None for v in y_values):
|
||||
return
|
||||
self.coordinatesClicked1D.emit(
|
||||
round(x, self.precision),
|
||||
[round(y_val, self.precision) for y_val in y_values],
|
||||
)
|
||||
self.coordinatesClicked1D.emit(coordinate_to_emit)
|
||||
for i, y_val in enumerate(y_values):
|
||||
for marker in self.marker_clicked_1d[i]:
|
||||
marker.setData(
|
||||
[x if not self.is_log_x else np.log10(x)],
|
||||
[y_val if not self.is_log_y else np.log10(y_val)],
|
||||
)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.config.monitor or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_2d.setPos([x, y])
|
||||
coordinate_to_emit = (name, x, y)
|
||||
self.coordinatesClicked2D.emit(coordinate_to_emit)
|
||||
else:
|
||||
continue
|
||||
|
||||
def clear_markers(self):
|
||||
"""Clears the markers from the plot."""
|
||||
for marker in self.marker_moved_1d.values():
|
||||
self.plot_item.removeItem(marker)
|
||||
for markers in self.marker_clicked_1d.values():
|
||||
for marker in markers:
|
||||
self.plot_item.removeItem(marker)
|
||||
self.marker_moved_1d.clear()
|
||||
self.marker_clicked_1d.clear()
|
||||
|
||||
def scale_emitted_coordinates(self, x, y):
|
||||
"""Scales the emitted coordinates if the axes are in log scale.
|
||||
|
||||
Args:
|
||||
x (float): The x-coordinate
|
||||
y (float): The y-coordinate
|
||||
|
||||
Returns:
|
||||
tuple: The scaled x and y coordinates
|
||||
"""
|
||||
if self.is_log_x:
|
||||
x = 10**x
|
||||
if self.is_log_y:
|
||||
y = 10**y
|
||||
return x, y
|
||||
|
||||
def update_coord_label(self, pos: tuple):
|
||||
"""Updates the coordinate label based on the crosshair position and axis scales.
|
||||
|
||||
Args:
|
||||
pos (tuple): The (x, y) position of the crosshair.
|
||||
"""
|
||||
x, y = pos
|
||||
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
|
||||
text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})"
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.ImageItem):
|
||||
image = item.image
|
||||
ix = int(np.clip(x, 0, image.shape[0] - 1))
|
||||
iy = int(np.clip(y, 0, image.shape[1] - 1))
|
||||
intensity = image[ix, iy]
|
||||
text += f"\nIntensity: {intensity:.{self.precision}g}"
|
||||
break
|
||||
# Update coordinate label
|
||||
self.coord_label.setText(text)
|
||||
self.coord_label.setPos(x, y)
|
||||
self.coord_label.setVisible(True)
|
||||
if x is None or y_values is None:
|
||||
return
|
||||
self.coordinatesClicked2D.emit(x, y_values)
|
||||
self.marker_2d.setPos([x, y_values])
|
||||
|
||||
def check_log(self):
|
||||
"""Checks if the x or y axis is in log scale and updates the internal state accordingly."""
|
||||
self.is_log_x = self.plot_item.axes["bottom"]["item"].logMode
|
||||
self.is_log_y = self.plot_item.axes["left"]["item"].logMode
|
||||
self.clear_markers()
|
||||
|
||||
def check_derivatives(self):
|
||||
"""Checks if the derivatives are enabled and updates the internal state accordingly."""
|
||||
self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked()
|
||||
self.clear_markers()
|
||||
|
||||
def cleanup(self):
|
||||
if self.marker_2d is not None:
|
||||
self.plot_item.removeItem(self.marker_2d)
|
||||
self.marker_2d = None
|
||||
self.plot_item.removeItem(self.v_line)
|
||||
self.plot_item.removeItem(self.h_line)
|
||||
self.plot_item.removeItem(self.coord_label)
|
||||
|
||||
self.clear_markers()
|
||||
self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked()
|
||||
self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked()
|
||||
|
||||
39
bec_widgets/utils/ctrl_c.py
Normal file
39
bec_widgets/utils/ctrl_c.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# TODO haven't found yet how to deal with QAbstractSocket in qtpy
|
||||
# import signal
|
||||
# import socket
|
||||
# from PyQt5.QtNetwork import QAbstractSocket
|
||||
#
|
||||
#
|
||||
# def setup(app):
|
||||
# app.signalwatchdog = SignalWatchdog() # need to store to keep socket pair alive
|
||||
# signal.signal(signal.SIGINT, make_quit_handler(app))
|
||||
#
|
||||
#
|
||||
# def make_quit_handler(app):
|
||||
# def handler(*args):
|
||||
# print() # make ^C appear on its own line
|
||||
# app.quit()
|
||||
#
|
||||
# return handler
|
||||
#
|
||||
#
|
||||
# class SignalWatchdog(QAbstractSocket):
|
||||
# def __init__(self):
|
||||
# """
|
||||
# Propagates system signals from Python to QEventLoop
|
||||
# adapted from https://stackoverflow.com/a/65802260/655404
|
||||
# """
|
||||
# super().__init__(QAbstractSocket.SctpSocket, None)
|
||||
#
|
||||
# self.writer, self.reader = writer, reader = socket.socketpair()
|
||||
# writer.setblocking(False)
|
||||
#
|
||||
# fd_writer = writer.fileno()
|
||||
# fd_reader = reader.fileno()
|
||||
#
|
||||
# signal.set_wakeup_fd(fd_writer) # Python hook
|
||||
# self.setSocketDescriptor(fd_reader) # Qt hook
|
||||
#
|
||||
# self.readyRead.connect(
|
||||
# lambda: None
|
||||
# ) # dummy function call that lets the Python interpreter run
|
||||
@@ -3,42 +3,15 @@ class EntryValidator:
|
||||
self.devices = devices
|
||||
|
||||
def validate_signal(self, name: str, entry: str = None) -> str:
|
||||
"""
|
||||
Validate a signal entry for a given device. If the entry is not provided, the first signal entry will be used from the device hints.
|
||||
|
||||
Args:
|
||||
name(str): Device name
|
||||
entry(str): Signal entry
|
||||
|
||||
Returns:
|
||||
str: Signal entry
|
||||
"""
|
||||
if name not in self.devices:
|
||||
raise ValueError(f"Device '{name}' not found in current BEC session")
|
||||
|
||||
device = self.devices[name]
|
||||
description = device.describe()
|
||||
|
||||
if entry is None or entry == "":
|
||||
if entry is None:
|
||||
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
|
||||
if entry not in description:
|
||||
raise ValueError(
|
||||
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
|
||||
)
|
||||
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
|
||||
|
||||
return entry
|
||||
|
||||
def validate_monitor(self, monitor: str) -> str:
|
||||
"""
|
||||
Validate a monitor entry for a given device.
|
||||
|
||||
Args:
|
||||
monitor(str): Monitor entry
|
||||
|
||||
Returns:
|
||||
str: Monitor entry
|
||||
"""
|
||||
if monitor not in self.devices:
|
||||
raise ValueError(f"Device '{monitor}' not found in current BEC session")
|
||||
|
||||
return monitor
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import functools
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs):
|
||||
"""
|
||||
Decorator to create a Qt Property with safe getter and setter so that
|
||||
Qt Designer won't crash if an exception occurs in either method.
|
||||
|
||||
Args:
|
||||
prop_type: The property type (e.g., str, bool, int, custom classes, etc.)
|
||||
popup_error (bool): If True, show a popup for any error; otherwise, ignore or log silently.
|
||||
default: Any default/fallback value to return if the getter raises an exception.
|
||||
*prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor.
|
||||
|
||||
Usage:
|
||||
@SafeProperty(int, default=-1)
|
||||
def some_value(self) -> int:
|
||||
# your getter logic
|
||||
return ... # if an exception is raised, returns -1
|
||||
|
||||
@some_value.setter
|
||||
def some_value(self, val: int):
|
||||
# your setter logic
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(py_getter):
|
||||
"""Decorator for the user's property getter function."""
|
||||
|
||||
@functools.wraps(py_getter)
|
||||
def safe_getter(self_):
|
||||
try:
|
||||
return py_getter(self_)
|
||||
except Exception:
|
||||
# Identify which property function triggered error
|
||||
prop_name = f"{py_getter.__module__}.{py_getter.__qualname__}"
|
||||
error_msg = traceback.format_exc()
|
||||
|
||||
if popup_error:
|
||||
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
|
||||
logger.error(f"SafeProperty error in GETTER of '{prop_name}':\n{error_msg}")
|
||||
return default
|
||||
|
||||
class PropertyWrapper:
|
||||
"""
|
||||
Intermediate wrapper used so that the user can optionally chain .setter(...).
|
||||
"""
|
||||
|
||||
def __init__(self, getter_func):
|
||||
# We store only our safe_getter in the wrapper
|
||||
self.getter_func = safe_getter
|
||||
|
||||
def setter(self, setter_func):
|
||||
"""Wraps the user-defined setter to handle errors safely."""
|
||||
|
||||
@functools.wraps(setter_func)
|
||||
def safe_setter(self_, value):
|
||||
try:
|
||||
return setter_func(self_, value)
|
||||
except Exception:
|
||||
prop_name = f"{setter_func.__module__}.{setter_func.__qualname__}"
|
||||
error_msg = traceback.format_exc()
|
||||
|
||||
if popup_error:
|
||||
ErrorPopupUtility().custom_exception_hook(
|
||||
*sys.exc_info(), popup_error=True
|
||||
)
|
||||
logger.error(f"SafeProperty error in SETTER of '{prop_name}':\n{error_msg}")
|
||||
return
|
||||
|
||||
# Return the full read/write Property
|
||||
return Property(prop_type, self.getter_func, safe_setter, *prop_args, **prop_kwargs)
|
||||
|
||||
def __call__(self):
|
||||
"""
|
||||
If user never calls `.setter(...)`, produce a read-only property.
|
||||
"""
|
||||
return Property(prop_type, self.getter_func, None, *prop_args, **prop_kwargs)
|
||||
|
||||
return PropertyWrapper(py_getter)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
|
||||
to the passed function, to display errors instead of potentially raising an exception
|
||||
|
||||
'popup_error' keyword argument can be passed with boolean value if a dialog should pop up,
|
||||
otherwise error display is left to the original exception hook
|
||||
"""
|
||||
popup_error = bool(slot_kwargs.pop("popup_error", False))
|
||||
|
||||
def error_managed(method):
|
||||
@Slot(*slot_args, **slot_kwargs)
|
||||
@functools.wraps(method)
|
||||
def wrapper(*args, **kwargs):
|
||||
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}")
|
||||
|
||||
return wrapper
|
||||
|
||||
return error_managed
|
||||
|
||||
|
||||
class WarningPopupUtility(QObject):
|
||||
"""
|
||||
Utility class to show warning popups in the application.
|
||||
"""
|
||||
|
||||
@SafeSlot(str, str, str, QWidget)
|
||||
def show_warning_message(self, title, message, detailed_text, widget):
|
||||
msg = QMessageBox(widget)
|
||||
msg.setIcon(QMessageBox.Warning)
|
||||
msg.setWindowTitle(title)
|
||||
msg.setText(message)
|
||||
msg.setStandardButtons(QMessageBox.Ok)
|
||||
msg.setDetailedText(detailed_text)
|
||||
msg.exec_()
|
||||
|
||||
def show_warning(self, title: str, message: str, detailed_text: str, widget: QWidget = None):
|
||||
"""
|
||||
Show a warning message with the given title, message, and detailed text.
|
||||
|
||||
Args:
|
||||
title (str): The title of the warning message.
|
||||
message (str): The main text of the warning message.
|
||||
detailed_text (str): The detailed text to show when the user expands the message.
|
||||
widget (QWidget): The parent widget for the message box.
|
||||
"""
|
||||
self.show_warning_message(title, message, detailed_text, widget)
|
||||
|
||||
|
||||
_popup_utility_instance = None
|
||||
|
||||
|
||||
class _ErrorPopupUtility(QObject):
|
||||
"""
|
||||
Utility class to manage error popups in the application to show error messages to the users.
|
||||
This class is singleton and the error popup can be enabled or disabled globally or attach to widget methods with decorator @error_managed.
|
||||
"""
|
||||
|
||||
error_occurred = Signal(str, str, QWidget)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.error_occurred.connect(self.show_error_message)
|
||||
self.enable_error_popup = False
|
||||
self._initialized = True
|
||||
sys.excepthook = self.custom_exception_hook
|
||||
|
||||
@SafeSlot(str, str, QWidget)
|
||||
def show_error_message(self, title, message, widget):
|
||||
detailed_text = self.format_traceback(message)
|
||||
error_message = self.parse_error_message(detailed_text)
|
||||
|
||||
msg = QMessageBox(widget)
|
||||
msg.setIcon(QMessageBox.Critical)
|
||||
msg.setWindowTitle(title)
|
||||
msg.setText(error_message)
|
||||
msg.setStandardButtons(QMessageBox.Ok)
|
||||
msg.setDetailedText(detailed_text)
|
||||
msg.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
msg.setMinimumWidth(600)
|
||||
msg.setMinimumHeight(400)
|
||||
msg.exec_()
|
||||
|
||||
def show_property_error(self, title, message, widget):
|
||||
"""
|
||||
Show a property-specific error message.
|
||||
"""
|
||||
self.error_occurred.emit(title, message, widget)
|
||||
|
||||
def format_traceback(self, traceback_message: str) -> str:
|
||||
"""
|
||||
Format the traceback message to be displayed in the error popup by adding indentation to each line.
|
||||
|
||||
Args:
|
||||
traceback_message(str): The traceback message to be formatted.
|
||||
|
||||
Returns:
|
||||
str: The formatted traceback message.
|
||||
"""
|
||||
formatted_lines = []
|
||||
lines = traceback_message.split("\n")
|
||||
for line in lines:
|
||||
formatted_lines.append(" " + line) # Add indentation to each line
|
||||
return "\n".join(formatted_lines)
|
||||
|
||||
def parse_error_message(self, traceback_message):
|
||||
lines = traceback_message.split("\n")
|
||||
error_message = "Error occurred. See details."
|
||||
capture = False
|
||||
captured_message = []
|
||||
|
||||
for line in lines:
|
||||
if "raise" in line:
|
||||
capture = True
|
||||
continue
|
||||
if capture:
|
||||
if line.strip() and not line.startswith(" File "):
|
||||
captured_message.append(line.strip())
|
||||
else:
|
||||
break
|
||||
|
||||
if captured_message:
|
||||
error_message = " ".join(captured_message)
|
||||
return error_message
|
||||
|
||||
def get_error_message(self, exctype, value, tb):
|
||||
return "".join(traceback.format_exception(exctype, value, tb))
|
||||
|
||||
def custom_exception_hook(self, exctype, value, tb, popup_error=False):
|
||||
if popup_error or self.enable_error_popup:
|
||||
self.error_occurred.emit(
|
||||
"Method error" if popup_error else "Application Error",
|
||||
self.get_error_message(exctype, value, tb),
|
||||
self.parent(),
|
||||
)
|
||||
else:
|
||||
sys.__excepthook__(exctype, value, tb) # Call the original excepthook
|
||||
|
||||
def enable_global_error_popups(self, state: bool):
|
||||
"""
|
||||
Enable or disable global error popups for all applications.
|
||||
|
||||
Args:
|
||||
state(bool): True to enable error popups, False to disable error popups.
|
||||
"""
|
||||
self.enable_error_popup = bool(state)
|
||||
|
||||
|
||||
def ErrorPopupUtility():
|
||||
global _popup_utility_instance
|
||||
if not _popup_utility_instance:
|
||||
_popup_utility_instance = _ErrorPopupUtility()
|
||||
return _popup_utility_instance
|
||||
|
||||
|
||||
class ExampleWidget(QWidget): # pragma: no cover
|
||||
"""
|
||||
Example widget to demonstrate error handling with the ErrorPopupUtility.
|
||||
|
||||
Warnings -> This example works properly only with PySide6, PyQt6 has a bug with the error handling.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.init_ui()
|
||||
self.warning_utility = WarningPopupUtility(self)
|
||||
|
||||
def init_ui(self):
|
||||
self.layout = QVBoxLayout(self)
|
||||
|
||||
# Button to trigger method with error handling
|
||||
self.error_button = QPushButton("Trigger Handled Error", self)
|
||||
self.error_button.clicked.connect(self.method_with_error_handling)
|
||||
self.layout.addWidget(self.error_button)
|
||||
|
||||
# Button to trigger method without error handling
|
||||
self.normal_button = QPushButton("Trigger Normal Error", self)
|
||||
self.normal_button.clicked.connect(self.method_without_error_handling)
|
||||
self.layout.addWidget(self.normal_button)
|
||||
|
||||
# Button to trigger warning popup
|
||||
self.warning_button = QPushButton("Trigger Warning", self)
|
||||
self.warning_button.clicked.connect(self.trigger_warning)
|
||||
self.layout.addWidget(self.warning_button)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def method_with_error_handling(self):
|
||||
"""This method raises an error and the exception is handled by the decorator."""
|
||||
raise ValueError("This is a handled error.")
|
||||
|
||||
@SafeSlot()
|
||||
def method_without_error_handling(self):
|
||||
"""This method raises an error and the exception is not handled here."""
|
||||
raise ValueError("This is an unhandled error.")
|
||||
|
||||
@SafeSlot()
|
||||
def trigger_warning(self):
|
||||
"""Trigger a warning using the WarningPopupUtility."""
|
||||
self.warning_utility.show_warning(
|
||||
title="Warning",
|
||||
message="This is a warning message.",
|
||||
detailed_text="This is the detailed text of the warning message.",
|
||||
widget=self,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = ExampleWidget()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -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,156 +0,0 @@
|
||||
"""Module for handling filter I/O operations in BEC Widgets for input fields.
|
||||
These operations include filtering device/signal names and/or device types.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QStringListModel
|
||||
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class WidgetFilterHandler(ABC):
|
||||
"""Abstract base class for widget filter handlers"""
|
||||
|
||||
@abstractmethod
|
||||
def set_selection(self, widget, selection: list) -> None:
|
||||
"""Set the filtered_selection for the widget
|
||||
|
||||
Args:
|
||||
selection (list): Filtered selection of items
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def check_input(self, widget, text: str) -> bool:
|
||||
"""Check if the input text is in the filtered selection
|
||||
|
||||
Args:
|
||||
widget: Widget instance
|
||||
text (str): Input text
|
||||
|
||||
Returns:
|
||||
bool: True if the input text is in the filtered selection
|
||||
"""
|
||||
|
||||
|
||||
class LineEditFilterHandler(WidgetFilterHandler):
|
||||
"""Handler for QLineEdit widget"""
|
||||
|
||||
def set_selection(self, widget: QLineEdit, selection: list) -> None:
|
||||
"""Set the selection for the widget to the completer model
|
||||
|
||||
Args:
|
||||
widget (QLineEdit): The QLineEdit widget
|
||||
selection (list): Filtered selection of items
|
||||
"""
|
||||
if not isinstance(widget.completer, QCompleter):
|
||||
completer = QCompleter(widget)
|
||||
widget.setCompleter(completer)
|
||||
widget.completer.setModel(QStringListModel(selection, widget))
|
||||
|
||||
def check_input(self, widget: QLineEdit, text: str) -> bool:
|
||||
"""Check if the input text is in the filtered selection
|
||||
|
||||
Args:
|
||||
widget (QLineEdit): The QLineEdit widget
|
||||
text (str): Input text
|
||||
|
||||
Returns:
|
||||
bool: True if the input text is in the filtered selection
|
||||
"""
|
||||
model = widget.completer.model()
|
||||
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
|
||||
return text in model_data
|
||||
|
||||
|
||||
class ComboBoxFilterHandler(WidgetFilterHandler):
|
||||
"""Handler for QComboBox widget"""
|
||||
|
||||
def set_selection(self, widget: QComboBox, selection: list) -> None:
|
||||
"""Set the selection for the widget to the completer model
|
||||
|
||||
Args:
|
||||
widget (QComboBox): The QComboBox widget
|
||||
selection (list): Filtered selection of items
|
||||
"""
|
||||
widget.clear()
|
||||
widget.addItems(selection)
|
||||
|
||||
def check_input(self, widget: QComboBox, text: str) -> bool:
|
||||
"""Check if the input text is in the filtered selection
|
||||
|
||||
Args:
|
||||
widget (QComboBox): The QComboBox widget
|
||||
text (str): Input text
|
||||
|
||||
Returns:
|
||||
bool: True if the input text is in the filtered selection
|
||||
"""
|
||||
return text in [widget.itemText(i) for i in range(widget.count())]
|
||||
|
||||
|
||||
class FilterIO:
|
||||
"""Public interface to set filters for input widgets.
|
||||
It supports the list of widgets stored in class attribute _handlers.
|
||||
"""
|
||||
|
||||
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
|
||||
|
||||
@staticmethod
|
||||
def set_selection(widget, selection: list, ignore_errors=True):
|
||||
"""
|
||||
Retrieve value from the widget instance.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
selection(list): List of filtered selection items.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().set_selection(widget=widget, selection=selection)
|
||||
if not ignore_errors:
|
||||
raise ValueError(
|
||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def check_input(widget, text: str, ignore_errors=True):
|
||||
"""
|
||||
Check if the input text is in the filtered selection.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
text(str): Input text.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
|
||||
Returns:
|
||||
bool: True if the input text is in the filtered selection.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().check_input(widget=widget, text=text)
|
||||
if not ignore_errors:
|
||||
raise ValueError(
|
||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _find_handler(widget):
|
||||
"""
|
||||
Find the appropriate handler for the widget by checking its base classes.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
|
||||
Returns:
|
||||
handler_class: The handler class if found, otherwise None.
|
||||
"""
|
||||
for base in type(widget).__mro__:
|
||||
if base in FilterIO._handlers:
|
||||
return FilterIO._handlers[base]
|
||||
return None
|
||||
@@ -1,84 +0,0 @@
|
||||
"""
|
||||
This module provides a utility class for counting and reporting frames per second (FPS) in a PyQtGraph application.
|
||||
|
||||
Classes:
|
||||
FPSCounter: A class that monitors the paint events of a `ViewBox` to calculate and emit FPS values.
|
||||
|
||||
Usage:
|
||||
The `FPSCounter` class can be used to monitor the rendering performance of a `ViewBox` in a PyQtGraph application.
|
||||
It connects to the `ViewBox`'s paint event and calculates the FPS over a specified interval, emitting the FPS value
|
||||
at regular intervals.
|
||||
|
||||
Example:
|
||||
from qtpy import QtWidgets, QtCore
|
||||
import pyqtgraph as pg
|
||||
from fps_counter import FPSCounter
|
||||
|
||||
app = pg.mkQApp("FPS Counter Example")
|
||||
win = pg.GraphicsLayoutWidget()
|
||||
win.show()
|
||||
|
||||
vb = pg.ViewBox()
|
||||
plot_item = pg.PlotItem(viewBox=vb)
|
||||
win.addItem(plot_item)
|
||||
|
||||
fps_counter = FPSCounter(vb)
|
||||
fps_counter.sigFpsUpdate.connect(lambda fps: print(f"FPS: {fps:.2f}"))
|
||||
|
||||
sys.exit(app.exec_())
|
||||
"""
|
||||
|
||||
from time import perf_counter
|
||||
|
||||
import pyqtgraph as pg
|
||||
from qtpy import QtCore
|
||||
|
||||
|
||||
class FPSCounter(QtCore.QObject):
|
||||
"""
|
||||
A utility class for counting and reporting frames per second (FPS).
|
||||
|
||||
This class connects to a `ViewBox`'s paint event to count the number of
|
||||
frames rendered and calculates the FPS over a specified interval. It emits
|
||||
a signal with the FPS value at regular intervals.
|
||||
|
||||
Attributes:
|
||||
sigFpsUpdate (QtCore.Signal): Signal emitted with the FPS value.
|
||||
view_box (pg.ViewBox): The `ViewBox` instance to monitor.
|
||||
"""
|
||||
|
||||
sigFpsUpdate = QtCore.Signal(float)
|
||||
|
||||
def __init__(self, view_box):
|
||||
super().__init__()
|
||||
self.view_box = view_box
|
||||
self.view_box.sigPaint.connect(self.increment_count)
|
||||
self.count = 0
|
||||
self.last_update = perf_counter()
|
||||
self.timer = QtCore.QTimer()
|
||||
self.timer.timeout.connect(self.calculate_fps)
|
||||
self.timer.start(1000)
|
||||
|
||||
def increment_count(self):
|
||||
"""
|
||||
Increment the frame count when the `ViewBox` is painted.
|
||||
"""
|
||||
self.count += 1
|
||||
|
||||
def calculate_fps(self):
|
||||
"""
|
||||
Calculate the frames per second (FPS) based on the number of frames
|
||||
"""
|
||||
now = perf_counter()
|
||||
elapsed = now - self.last_update
|
||||
fps = self.count / elapsed if elapsed > 0 else 0.0
|
||||
self.last_update = now
|
||||
self.count = 0
|
||||
self.sigFpsUpdate.emit(fps)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the FPS counter by stopping the timer and disconnecting the signal.
|
||||
"""
|
||||
self.timer.stop()
|
||||
self.timer.timeout.disconnect(self.calculate_fps)
|
||||
@@ -1,156 +0,0 @@
|
||||
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
|
||||
self.plugin_name_pascal = plugin_class.__name__
|
||||
self.plugin_name_snake = self.pascal_to_snake(self.plugin_name_pascal)
|
||||
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
|
||||
plugin_module = (
|
||||
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
|
||||
)
|
||||
self.plugin_import = f"from {plugin_module} import {self.plugin_name_pascal}Plugin"
|
||||
|
||||
# first sentence / line of the docstring is used as tooltip
|
||||
self.plugin_tooltip = (
|
||||
plugin_class.__doc__.split("\n")[0].strip().replace('"', "'")
|
||||
if plugin_class.__doc__
|
||||
else self.plugin_name_pascal
|
||||
)
|
||||
|
||||
self.base_path = os.path.dirname(inspect.getfile(plugin_class))
|
||||
|
||||
@staticmethod
|
||||
def pascal_to_snake(name: str) -> str:
|
||||
"""
|
||||
Convert PascalCase to snake_case.
|
||||
|
||||
Args:
|
||||
name (str): The name to be converted.
|
||||
|
||||
Returns:
|
||||
str: The converted name.
|
||||
"""
|
||||
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
||||
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
|
||||
return s2.lower()
|
||||
|
||||
|
||||
class DesignerPluginGenerator:
|
||||
def __init__(self, widget: type):
|
||||
self._excluded = False
|
||||
self.widget = widget
|
||||
self.info = DesignerPluginInfo(widget)
|
||||
if widget.__name__ in EXCLUDED_PLUGINS:
|
||||
|
||||
self._excluded = True
|
||||
return
|
||||
|
||||
self.templates: dict[str, str] = {}
|
||||
self.template_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "plugin_templates"
|
||||
)
|
||||
|
||||
@property
|
||||
def filenames(self):
|
||||
return plugin_filenames(self.info.plugin_name_snake)
|
||||
|
||||
def run(self, validate=True):
|
||||
if self._excluded:
|
||||
print(f"Plugin {self.widget.__name__} is excluded from generation.")
|
||||
return
|
||||
if validate:
|
||||
self._check_class_validity()
|
||||
self._load_templates()
|
||||
self._write_templates()
|
||||
|
||||
def _check_class_validity(self):
|
||||
|
||||
# Check if the widget is a QWidget subclass
|
||||
if not issubclass(self.widget, QObject):
|
||||
return
|
||||
|
||||
# Check if the widget class has parent as the first argument. This is a strict requirement of Qt!
|
||||
signature = list(inspect.signature(self.widget.__init__).parameters.values())
|
||||
if len(signature) == 1 or signature[1].name != "parent":
|
||||
raise ValueError(
|
||||
f"Widget class {self.widget.__name__} must have parent as the first argument."
|
||||
)
|
||||
|
||||
base_cls = [val for val in self.widget.__bases__ if issubclass(val, QObject)]
|
||||
if not base_cls:
|
||||
raise ValueError(
|
||||
f"Widget class {self.widget.__name__} must inherit from a QObject subclass."
|
||||
)
|
||||
|
||||
# Check if the widget class calls the super constructor with parent argument
|
||||
init_source = inspect.getsource(self.widget.__init__)
|
||||
cls_init_found = (
|
||||
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
|
||||
)
|
||||
super_init_found = (
|
||||
bool(
|
||||
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
|
||||
)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
|
||||
)
|
||||
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
|
||||
super_init_found = (
|
||||
bool(init_source.find("super().__init__(parent=parent") > 0)
|
||||
or bool(init_source.find("super().__init__(parent,") > 0)
|
||||
or bool(init_source.find("super().__init__(parent)") > 0)
|
||||
)
|
||||
|
||||
if not cls_init_found and not super_init_found:
|
||||
raise ValueError(
|
||||
f"Widget class {self.widget.__name__} must call the super constructor with parent."
|
||||
)
|
||||
|
||||
def _write_file(self, name: str, contents: str):
|
||||
with open(os.path.join(self.info.base_path, name), "w", encoding="utf-8") as f:
|
||||
f.write(contents)
|
||||
|
||||
def _format(self, name: str):
|
||||
return self.templates[name].format(**self.info.__dict__)
|
||||
|
||||
def _write_templates(self):
|
||||
self._write_file(self.filenames.register, self._format("register"))
|
||||
self._write_file(self.filenames.plugin, self._format("plugin"))
|
||||
pyproj = str({"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]})
|
||||
self._write_file(self.filenames.pyproj, pyproj)
|
||||
|
||||
def _load_templates(self):
|
||||
for file in os.listdir(self.template_path):
|
||||
if not file.endswith(".template"):
|
||||
continue
|
||||
with open(os.path.join(self.template_path, file), "r", encoding="utf-8") as f:
|
||||
self.templates[file.split(".")[0]] = f.read()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.utility.spinner import SpinnerWidget
|
||||
|
||||
generator = DesignerPluginGenerator(SpinnerWidget)
|
||||
generator.run(validate=False)
|
||||
@@ -1,121 +0,0 @@
|
||||
from collections import OrderedDict
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import QGridLayout, QWidget
|
||||
|
||||
|
||||
class GridLayoutManager:
|
||||
"""
|
||||
GridLayoutManager class is used to manage widgets in a QGridLayout and extend its functionality.
|
||||
|
||||
The GridLayoutManager class provides methods to add, move, and check the position of widgets in a QGridLayout.
|
||||
It also provides a method to get the positions of all widgets in the layout.
|
||||
|
||||
Args:
|
||||
layout(QGridLayout): The layout to manage.
|
||||
"""
|
||||
|
||||
def __init__(self, layout: QGridLayout):
|
||||
self.layout = layout
|
||||
|
||||
def is_position_occupied(self, row: int, col: int) -> bool:
|
||||
"""
|
||||
Check if the position in the layout is occupied by a widget.
|
||||
|
||||
Args:
|
||||
row(int): The row to check.
|
||||
col(int): The column to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the position is occupied, False otherwise.
|
||||
"""
|
||||
for i in range(self.layout.count()):
|
||||
widget_row, widget_col, _, _ = self.layout.getItemPosition(i)
|
||||
if widget_row == row and widget_col == col:
|
||||
return True
|
||||
return False
|
||||
|
||||
def shift_widgets(
|
||||
self,
|
||||
direction: Literal["down", "up", "left", "right"] = "down",
|
||||
start_row: int = 0,
|
||||
start_col: int = 0,
|
||||
):
|
||||
"""
|
||||
Shift widgets in the layout in the specified direction starting from the specified position.
|
||||
|
||||
Args:
|
||||
direction(str): The direction to shift the widgets. Can be "down", "up", "left", or "right".
|
||||
start_row(int): The row to start shifting from. Default is 0.
|
||||
start_col(int): The column to start shifting from. Default is 0.
|
||||
"""
|
||||
for i in reversed(range(self.layout.count())):
|
||||
widget_item = self.layout.itemAt(i)
|
||||
widget = widget_item.widget()
|
||||
row, col, rowspan, colspan = self.layout.getItemPosition(i)
|
||||
if direction == "down" and row >= start_row:
|
||||
self.layout.addWidget(widget, row + 1, col, rowspan, colspan)
|
||||
elif direction == "up" and row > start_row:
|
||||
self.layout.addWidget(widget, row - 1, col, rowspan, colspan)
|
||||
elif direction == "right" and col >= start_col:
|
||||
self.layout.addWidget(widget, row, col + 1, rowspan, colspan)
|
||||
elif direction == "left" and col > start_col:
|
||||
self.layout.addWidget(widget, row, col - 1, rowspan, colspan)
|
||||
|
||||
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
|
||||
"""
|
||||
Move a widget to a new position in the layout.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to move.
|
||||
new_row(int): The new row to move the widget to.
|
||||
new_col(int): The new column to move the widget to.
|
||||
"""
|
||||
self.layout.removeWidget(widget)
|
||||
self.layout.addWidget(widget, new_row, new_col)
|
||||
|
||||
def add_widget(
|
||||
self,
|
||||
widget: QWidget,
|
||||
row=None,
|
||||
col=0,
|
||||
rowspan=1,
|
||||
colspan=1,
|
||||
shift: Literal["down", "up", "left", "right"] = "down",
|
||||
):
|
||||
"""
|
||||
Add a widget to the layout at the specified position.
|
||||
|
||||
Args:
|
||||
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. Default is 0.
|
||||
rowspan(int): The number of rows the widget will span. Default is 1.
|
||||
colspan(int): The number of columns the widget will span. Default is 1.
|
||||
shift(str): The direction to shift the widgets if the position is occupied. Can be "down", "up", "left", or "right".
|
||||
"""
|
||||
if row is None:
|
||||
row = self.layout.rowCount()
|
||||
if self.is_position_occupied(row, col):
|
||||
self.shift_widgets(shift, start_row=row)
|
||||
self.layout.addWidget(widget, row, col, rowspan, colspan)
|
||||
|
||||
def get_widgets_positions(self) -> dict:
|
||||
"""
|
||||
Get the positions of all widgets in the layout.
|
||||
Returns:
|
||||
dict: A dictionary with the positions of the widgets in the layout.
|
||||
|
||||
"""
|
||||
positions = []
|
||||
for i in range(self.layout.count()):
|
||||
widget_item = self.layout.itemAt(i)
|
||||
widget = widget_item.widget()
|
||||
if widget:
|
||||
position = self.layout.getItemPosition(i)
|
||||
positions.append((position, widget))
|
||||
positions.sort(key=lambda x: (x[0][0], x[0][1], x[0][2], x[0][3]))
|
||||
ordered_positions = OrderedDict()
|
||||
for pos, widget in positions:
|
||||
ordered_positions[pos] = widget
|
||||
return ordered_positions
|
||||
@@ -1,84 +0,0 @@
|
||||
""" Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
|
||||
The class is mainly designed for usage with the BECWaveform and 1D plots. """
|
||||
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, Signal, Slot
|
||||
from qtpy.QtGui import QColor
|
||||
|
||||
|
||||
class LinearRegionWrapper(QObject):
|
||||
"""Wrapper class for the LinearRegionItem in pyqtgraph for 1D plots (BECWaveform)
|
||||
|
||||
Args:
|
||||
plot_item (pg.PlotItem): The plot item to add the region selector to.
|
||||
parent (QObject): The parent object.
|
||||
color (QColor): The color of the region selector.
|
||||
hover_color (QColor): The color of the region selector when the mouse is over it.
|
||||
"""
|
||||
|
||||
# Signal with the region tuble (start, end)
|
||||
region_changed = Signal(tuple)
|
||||
|
||||
def __init__(
|
||||
self, plot_item: pg.PlotItem, color: QColor = None, hover_color: QColor = None, parent=None
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.is_log_x = None
|
||||
self._edge_width = 2
|
||||
self.plot_item = plot_item
|
||||
self.linear_region_selector = pg.LinearRegionItem()
|
||||
self.proxy = None
|
||||
self.change_roi_color((color, hover_color))
|
||||
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
|
||||
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
|
||||
|
||||
# Slot for changing the color of the region selector (edge and fill)
|
||||
@Slot(tuple)
|
||||
def change_roi_color(self, colors: tuple[QColor | str | tuple, QColor | str | tuple]):
|
||||
"""Change the color and hover color of the region selector.
|
||||
Hover color means the color when the mouse is over the region.
|
||||
|
||||
Args:
|
||||
colors (tuple): Tuple with the color and hover color
|
||||
"""
|
||||
color, hover_color = colors
|
||||
if color is not None:
|
||||
self.linear_region_selector.setBrush(pg.mkBrush(color))
|
||||
if hover_color is not None:
|
||||
self.linear_region_selector.setHoverBrush(pg.mkBrush(hover_color))
|
||||
|
||||
@Slot()
|
||||
def add_region_selector(self):
|
||||
"""Add the region selector to the plot item"""
|
||||
self.plot_item.addItem(self.linear_region_selector)
|
||||
# Use proxy to limit the update rate of the region change signal to 10Hz
|
||||
self.proxy = pg.SignalProxy(
|
||||
self.linear_region_selector.sigRegionChanged,
|
||||
rateLimit=10,
|
||||
slot=self._region_change_proxy,
|
||||
)
|
||||
|
||||
@Slot()
|
||||
def remove_region_selector(self):
|
||||
"""Remove the region selector from the plot item"""
|
||||
self.proxy.disconnect()
|
||||
self.proxy = None
|
||||
self.plot_item.removeItem(self.linear_region_selector)
|
||||
|
||||
def _region_change_proxy(self):
|
||||
"""Emit the region change signal. If the plot is in log mode, convert the region to log."""
|
||||
x_low, x_high = self.linear_region_selector.getRegion()
|
||||
if self.is_log_x:
|
||||
x_low = 10**x_low
|
||||
x_high = 10**x_high
|
||||
self.region_changed.emit((x_low, x_high))
|
||||
|
||||
@Slot()
|
||||
def check_log(self):
|
||||
"""Check if the plot is in log mode."""
|
||||
self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked()
|
||||
self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
self.remove_region_selector()
|
||||
@@ -1,26 +0,0 @@
|
||||
from enum import IntFlag
|
||||
|
||||
try:
|
||||
|
||||
from enum import KEEP
|
||||
|
||||
class IFBase(IntFlag, boundary=KEEP): ...
|
||||
|
||||
except ImportError:
|
||||
|
||||
IFBase = IntFlag
|
||||
|
||||
|
||||
class Kind(IFBase):
|
||||
"""
|
||||
This is used in the .kind attribute of all OphydObj (Signals, Devices).
|
||||
|
||||
A Device examines its components' .kind atttribute to decide whether to
|
||||
traverse it in read(), read_configuration(), or neither. Additionally, if
|
||||
decides whether to include its name in `hints['fields']`.
|
||||
"""
|
||||
|
||||
omitted = 0b000
|
||||
normal = 0b001
|
||||
config = 0b010
|
||||
hinted = 0b101 # Notice that bool(hinted & normal) is True.
|
||||
@@ -1,183 +0,0 @@
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
|
||||
class PaletteViewer(BECWidget, QWidget):
|
||||
"""
|
||||
This class is a widget that displays current palette colors.
|
||||
"""
|
||||
|
||||
ICON_NAME = "palette"
|
||||
RPC = False
|
||||
|
||||
def __init__(self, *args, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, theme_update=True, **kwargs)
|
||||
self.setFixedSize(400, 600)
|
||||
layout = QVBoxLayout(self)
|
||||
dark_mode_button = DarkModeButton(self)
|
||||
layout.addWidget(dark_mode_button)
|
||||
|
||||
# Create a scroll area to hold the color boxes
|
||||
scroll_area = QScrollArea(self)
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
# Create a frame to hold the color boxes
|
||||
self.frame = QFrame(self)
|
||||
self.frame_layout = QGridLayout(self.frame)
|
||||
self.frame_layout.setSpacing(0)
|
||||
self.frame_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
scroll_area.setWidget(self.frame)
|
||||
layout.addWidget(scroll_area)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
self.update_palette()
|
||||
|
||||
def apply_theme(self, theme) -> None:
|
||||
"""
|
||||
Apply the theme to the widget.
|
||||
|
||||
Args:
|
||||
theme (str): The theme to apply.
|
||||
"""
|
||||
self.update_palette()
|
||||
|
||||
def clear_palette(self) -> None:
|
||||
"""
|
||||
Clear the palette colors from the frame.
|
||||
Recursively removes all widgets and layouts in the frame layout.
|
||||
"""
|
||||
# Iterate over all items in the layout in reverse to safely remove them
|
||||
for i in reversed(range(self.frame_layout.count())):
|
||||
item = self.frame_layout.itemAt(i)
|
||||
|
||||
# If the item is a layout, clear its contents
|
||||
if isinstance(item, QHBoxLayout):
|
||||
# Recursively remove all widgets from the layout
|
||||
for j in reversed(range(item.count())):
|
||||
widget = item.itemAt(j).widget()
|
||||
if widget:
|
||||
item.removeWidget(widget)
|
||||
widget.deleteLater()
|
||||
self.frame_layout.removeItem(item)
|
||||
|
||||
# If the item is a widget, remove and delete it
|
||||
elif item.widget():
|
||||
widget = item.widget()
|
||||
self.frame_layout.removeWidget(widget)
|
||||
widget.deleteLater()
|
||||
|
||||
def update_palette(self) -> None:
|
||||
"""
|
||||
Update the palette colors in the frame.
|
||||
"""
|
||||
self.clear_palette()
|
||||
palette_label = QLabel("Palette Colors (e.g. palette.windowText().color())")
|
||||
palette_label.setStyleSheet("font-weight: bold;")
|
||||
self.frame_layout.addWidget(palette_label, 0, 0)
|
||||
|
||||
palette = get_theme_palette()
|
||||
# Add the palette colors (roles) to the frame
|
||||
palette_roles = [
|
||||
palette.windowText,
|
||||
palette.toolTipText,
|
||||
palette.placeholderText,
|
||||
palette.text,
|
||||
palette.buttonText,
|
||||
palette.highlight,
|
||||
palette.link,
|
||||
palette.light,
|
||||
palette.midlight,
|
||||
palette.mid,
|
||||
palette.shadow,
|
||||
palette.button,
|
||||
palette.brightText,
|
||||
palette.toolTipBase,
|
||||
palette.alternateBase,
|
||||
palette.dark,
|
||||
palette.base,
|
||||
palette.window,
|
||||
palette.highlightedText,
|
||||
palette.linkVisited,
|
||||
]
|
||||
|
||||
offset = 1
|
||||
for i, pal in enumerate(palette_roles):
|
||||
i += offset
|
||||
color = pal().color()
|
||||
label_layout = QHBoxLayout()
|
||||
color_label = QLabel(f"{pal().color().name()} ({pal.__name__})")
|
||||
background_label = self.background_label_with_clipboard(color)
|
||||
label_layout.addWidget(color_label)
|
||||
label_layout.addWidget(background_label)
|
||||
self.frame_layout.addLayout(label_layout, i, 0)
|
||||
|
||||
# add a horizontal spacer
|
||||
spacer = QLabel()
|
||||
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||
self.frame_layout.addWidget(spacer, i + 1, 0)
|
||||
|
||||
accent_colors_label = QLabel("Accent Colors (e.g. accent_colors.default)")
|
||||
accent_colors_label.setStyleSheet("font-weight: bold;")
|
||||
self.frame_layout.addWidget(accent_colors_label, i + 2, 0)
|
||||
|
||||
accent_colors = get_accent_colors()
|
||||
items = [
|
||||
(accent_colors.default, "default"),
|
||||
(accent_colors.success, "success"),
|
||||
(accent_colors.warning, "warning"),
|
||||
(accent_colors.emergency, "emergency"),
|
||||
(accent_colors.highlight, "highlight"),
|
||||
]
|
||||
|
||||
offset = len(palette_roles) + 2
|
||||
for i, (color, name) in enumerate(items):
|
||||
i += offset
|
||||
label_layout = QHBoxLayout()
|
||||
color_label = QLabel(f"{color.name()} ({name})")
|
||||
background_label = self.background_label_with_clipboard(color)
|
||||
label_layout.addWidget(color_label)
|
||||
label_layout.addWidget(background_label)
|
||||
self.frame_layout.addLayout(label_layout, i + 2, 0)
|
||||
|
||||
def background_label_with_clipboard(self, color) -> QLabel:
|
||||
"""
|
||||
Create a label with a background color that copies the color to the clipboard when clicked.
|
||||
|
||||
Args:
|
||||
color (QColor): The color to display in the background.
|
||||
|
||||
Returns:
|
||||
QLabel: The label with the background color.
|
||||
"""
|
||||
button = QLabel()
|
||||
button.setStyleSheet(f"QLabel {{ background-color: {color.name()}; }}")
|
||||
button.setToolTip("Click to copy color to clipboard")
|
||||
button.setCursor(Qt.PointingHandCursor)
|
||||
button.mousePressEvent = lambda event: QApplication.clipboard().setText(color.name())
|
||||
return button
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
viewer = PaletteViewer()
|
||||
viewer.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,244 +0,0 @@
|
||||
"""Module to create an arrow item for a pyqtgraph plot"""
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject, QPointF, Signal, Slot
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECIndicatorItem(QObject):
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem = None, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.accent_colors = get_accent_colors()
|
||||
self.plot_item = plot_item
|
||||
self._item_on_plot = False
|
||||
self._pos = None
|
||||
self.is_log_x = False
|
||||
self.is_log_y = False
|
||||
|
||||
@property
|
||||
def item_on_plot(self) -> bool:
|
||||
"""Returns if the item is on the plot"""
|
||||
return self._item_on_plot
|
||||
|
||||
@item_on_plot.setter
|
||||
def item_on_plot(self, value: bool) -> None:
|
||||
self._item_on_plot = value
|
||||
|
||||
def add_to_plot(self) -> None:
|
||||
"""Add the item to the plot"""
|
||||
raise NotImplementedError("Method add_to_plot not implemented")
|
||||
|
||||
def remove_from_plot(self) -> None:
|
||||
"""Remove the item from the plot"""
|
||||
raise NotImplementedError("Method remove_from_plot not implemented")
|
||||
|
||||
def set_position(self, pos) -> None:
|
||||
"""This method should implement the logic to set the position of the
|
||||
item on the plot. Depending on the child class, the position can be
|
||||
a tuple (x,y) or a single value, i.e. x position where y position is fixed.
|
||||
"""
|
||||
raise NotImplementedError("Method set_position not implemented")
|
||||
|
||||
def check_log(self):
|
||||
"""Checks if the x or y axis is in log scale and updates the internal state accordingly."""
|
||||
self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked()
|
||||
self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked()
|
||||
self.set_position(self._pos)
|
||||
|
||||
|
||||
class BECTickItem(BECIndicatorItem):
|
||||
"""Class to create a tick item which can be added to a pyqtgraph plot.
|
||||
The tick item will be added to the layout of the plot item and can be used to indicate
|
||||
a position"""
|
||||
|
||||
position_changed = Signal(float)
|
||||
position_changed_str = Signal(str)
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem = None, parent=None):
|
||||
super().__init__(plot_item=plot_item, parent=parent)
|
||||
self.tick_item = pg.TickSliderItem(
|
||||
parent=parent, allowAdd=False, allowRemove=False, orientation="bottom"
|
||||
)
|
||||
self.tick_item.skip_auto_range = True
|
||||
self.tick = None
|
||||
self._pos = 0.0
|
||||
self._range = [0, 1]
|
||||
|
||||
@Slot(float)
|
||||
def set_position(self, pos: float) -> None:
|
||||
"""Set the x position of the tick item
|
||||
|
||||
Args:
|
||||
pos (float): The position of the tick item.
|
||||
"""
|
||||
if self.is_log_x is True:
|
||||
pos = pos if pos > 0 else 1e-10
|
||||
pos = np.log10(pos)
|
||||
self._pos = pos
|
||||
view_box = self.plot_item.getViewBox() # Ensure you're accessing the correct view box
|
||||
view_range = view_box.viewRange()[0]
|
||||
self.update_range(self.plot_item.vb, view_range)
|
||||
self.position_changed.emit(pos)
|
||||
self.position_changed_str.emit(str(pos))
|
||||
|
||||
@Slot()
|
||||
def update_range(self, _, view_range: tuple[float, float]) -> None:
|
||||
"""Update the range of the tick item
|
||||
|
||||
Args:
|
||||
vb (pg.ViewBox): The view box.
|
||||
viewRange (tuple): The view range.
|
||||
"""
|
||||
if self._pos < view_range[0] or self._pos > view_range[1]:
|
||||
self.tick_item.setVisible(False)
|
||||
else:
|
||||
self.tick_item.setVisible(True)
|
||||
|
||||
if self.tick_item.isVisible():
|
||||
origin = self.tick_item.tickSize / 2.0
|
||||
length = self.tick_item.length
|
||||
|
||||
length_with_padding = length + self.tick_item.tickSize + 2
|
||||
|
||||
self._range = view_range
|
||||
tick_with_padding = (self._pos - view_range[0]) / (view_range[1] - view_range[0])
|
||||
tick_value = (tick_with_padding * length_with_padding - origin) / length
|
||||
self.tick_item.setTickValue(self.tick, tick_value)
|
||||
|
||||
def add_to_plot(self):
|
||||
"""Add the tick item to the view box or plot item."""
|
||||
if self.plot_item is None:
|
||||
return
|
||||
|
||||
self.plot_item.layout.addItem(self.tick_item, 2, 1)
|
||||
self.tick_item.setOrientation("top")
|
||||
self.tick = self.tick_item.addTick(0, movable=False, color=self.accent_colors.highlight)
|
||||
self.update_tick_pos_y()
|
||||
self.plot_item.vb.sigXRangeChanged.connect(self.update_range)
|
||||
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
|
||||
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
|
||||
self.plot_item.vb.geometryChanged.connect(self.update_tick_pos_y)
|
||||
self.item_on_plot = True
|
||||
|
||||
@Slot()
|
||||
def update_tick_pos_y(self):
|
||||
"""Update tick position, while respecting the tick_item coordinates"""
|
||||
pos = self.tick.pos()
|
||||
pos = self.tick_item.mapToParent(pos)
|
||||
new_pos = self.plot_item.vb.geometry().bottom()
|
||||
new_pos = self.tick_item.mapFromParent(QPointF(pos.x(), new_pos))
|
||||
self.tick.setPos(new_pos)
|
||||
|
||||
def remove_from_plot(self):
|
||||
"""Remove the tick item from the view box or plot item."""
|
||||
if self.plot_item is not None and self.item_on_plot is True:
|
||||
self.plot_item.vb.sigXRangeChanged.disconnect(self.update_range)
|
||||
self.plot_item.ctrl.logXCheck.checkStateChanged.disconnect(self.check_log)
|
||||
self.plot_item.ctrl.logYCheck.checkStateChanged.disconnect(self.check_log)
|
||||
if self.plot_item.layout is not None:
|
||||
self.plot_item.layout.removeItem(self.tick_item)
|
||||
self.item_on_plot = False
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup the item"""
|
||||
self.remove_from_plot()
|
||||
self.tick_item = None
|
||||
|
||||
|
||||
class BECArrowItem(BECIndicatorItem):
|
||||
"""Class to create an arrow item which can be added to a pyqtgraph plot.
|
||||
It can be either added directly to a view box or a plot item.
|
||||
To add the arrow item to a view box or plot item, use the add_to_plot method.
|
||||
|
||||
Args:
|
||||
view_box (pg.ViewBox | pg.PlotItem): The view box or plot item to which the arrow item should be added.
|
||||
parent (QObject): The parent object.
|
||||
|
||||
Signals:
|
||||
position_changed (tuple[float, float]): Signal emitted when the position of the arrow item has changed.
|
||||
position_changed_str (tuple[str, str]): Signal emitted when the position of the arrow item has changed.
|
||||
"""
|
||||
|
||||
# Signal to emit if the position of the arrow item has changed
|
||||
position_changed = Signal(tuple)
|
||||
position_changed_str = Signal(tuple)
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem = None, parent=None):
|
||||
super().__init__(plot_item=plot_item, parent=parent)
|
||||
self.arrow_item = pg.ArrowItem()
|
||||
self.arrow_item.skip_auto_range = True
|
||||
self._pos = (0, 0)
|
||||
self.arrow_item.setVisible(False)
|
||||
|
||||
@Slot(dict)
|
||||
def set_style(self, style: dict) -> None:
|
||||
"""Set the style of the arrow item
|
||||
|
||||
Args:
|
||||
style (dict): The style of the arrow item. Dictionary with key,
|
||||
value pairs which are accepted from the pg.ArrowItem.setStyle method.
|
||||
"""
|
||||
self.arrow_item.setStyle(**style)
|
||||
|
||||
@Slot(tuple)
|
||||
def set_position(self, pos: tuple[float, float]) -> None:
|
||||
"""Set the position of the arrow item
|
||||
|
||||
Args:
|
||||
pos (tuple): The position of the arrow item as a tuple (x, y).
|
||||
"""
|
||||
self._pos = pos
|
||||
pos_x = pos[0]
|
||||
pos_y = pos[1]
|
||||
if self.is_log_x is True:
|
||||
pos_x = np.log10(pos_x) if pos_x > 0 else 1e-10
|
||||
view_box = self.plot_item.getViewBox() # Ensure you're accessing the correct view box
|
||||
view_range = view_box.viewRange()[0]
|
||||
# Avoid values outside the view range in the negative direction. Otherwise, there is
|
||||
# a buggy behaviour of the arrow item and it appears at the wrong position.
|
||||
if pos_x < view_range[0]:
|
||||
pos_x = view_range[0]
|
||||
if self.is_log_y is True:
|
||||
pos_y = np.log10(pos_y) if pos_y > 0 else 1e-10
|
||||
|
||||
self.arrow_item.setPos(pos_x, pos_y)
|
||||
self.position_changed.emit(self._pos)
|
||||
self.position_changed_str.emit((str(self._pos[0]), str(self._pos[1])))
|
||||
|
||||
def add_to_plot(self):
|
||||
"""Add the arrow item to the view box or plot item."""
|
||||
if not self.arrow_item:
|
||||
logger.warning(f"Arrow item was already destroyed, cannot be created")
|
||||
return
|
||||
|
||||
self.arrow_item.setStyle(
|
||||
angle=-90,
|
||||
pen=pg.mkPen(self.accent_colors.emergency, width=1),
|
||||
brush=pg.mkBrush(self.accent_colors.highlight),
|
||||
headLen=20,
|
||||
)
|
||||
self.arrow_item.setVisible(True)
|
||||
if self.plot_item is not None:
|
||||
self.plot_item.addItem(self.arrow_item)
|
||||
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
|
||||
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
|
||||
self.item_on_plot = True
|
||||
|
||||
def remove_from_plot(self):
|
||||
"""Remove the arrow item from the view box or plot item."""
|
||||
if self.plot_item is not None and self.item_on_plot is True:
|
||||
self.plot_item.ctrl.logXCheck.checkStateChanged.disconnect(self.check_log)
|
||||
self.plot_item.ctrl.logYCheck.checkStateChanged.disconnect(self.check_log)
|
||||
self.plot_item.removeItem(self.arrow_item)
|
||||
self.item_on_plot = False
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup the item"""
|
||||
self.remove_from_plot()
|
||||
self.arrow_item = None
|
||||
@@ -1,54 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
{widget_import}
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='{plugin_name_pascal}' name='{plugin_name_snake}'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = {plugin_name_pascal}(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon({plugin_name_pascal}.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "{plugin_name_snake}"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "{plugin_name_pascal}"
|
||||
|
||||
def toolTip(self):
|
||||
return "{plugin_tooltip}"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -1,15 +0,0 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
{plugin_import}
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget({plugin_name_pascal}Plugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,213 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.plugin_helper import _get_available_plugins
|
||||
from qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
|
||||
|
||||
def get_plugin_widgets() -> dict[str, BECConnector]:
|
||||
"""
|
||||
Get all available widgets from the plugin directory. Widgets are classes that inherit from BECConnector.
|
||||
The plugins are provided through python plugins and specified in the respective pyproject.toml file using
|
||||
the following key:
|
||||
|
||||
[project.entry-points."bec.widgets.user_widgets"]
|
||||
plugin_widgets = "path.to.plugin.module"
|
||||
|
||||
e.g.
|
||||
[project.entry-points."bec.widgets.user_widgets"]
|
||||
plugin_widgets = "pxiii_bec.bec_widgets.widgets"
|
||||
|
||||
assuming that the widgets module for the package pxiii_bec is located at pxiii_bec/bec_widgets/widgets and
|
||||
contains the widgets to be loaded within the pxiii_bec/bec_widgets/widgets/__init__.py file.
|
||||
|
||||
Returns:
|
||||
dict[str, BECConnector]: A dictionary of widget names and their respective classes.
|
||||
"""
|
||||
modules = _get_available_plugins("bec.widgets.user_widgets")
|
||||
loaded_plugins = {}
|
||||
print(modules)
|
||||
for module in modules:
|
||||
mods = inspect.getmembers(module, predicate=_filter_plugins)
|
||||
for name, mod_cls in mods:
|
||||
if name in loaded_plugins:
|
||||
print(f"Duplicated widgets plugin {name}.")
|
||||
loaded_plugins[name] = mod_cls
|
||||
return loaded_plugins
|
||||
|
||||
|
||||
def _filter_plugins(obj):
|
||||
return inspect.isclass(obj) and issubclass(obj, BECConnector)
|
||||
|
||||
|
||||
def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
|
||||
"""
|
||||
Get all available auto update classes from the plugin directory. AutoUpdates must inherit from AutoUpdate and be
|
||||
placed in the plugin repository's bec_widgets/auto_updates directory. The entry point for the auto updates is
|
||||
specified in the respective pyproject.toml file using the following key:
|
||||
[project.entry-points."bec.widgets.auto_updates"]
|
||||
plugin_widgets_update = "<beamline_name>.bec_widgets.auto_updates"
|
||||
|
||||
e.g.
|
||||
[project.entry-points."bec.widgets.auto_updates"]
|
||||
plugin_widgets_update = "pxiii_bec.bec_widgets.auto_updates"
|
||||
|
||||
Returns:
|
||||
dict[str, AutoUpdates]: A dictionary of widget names and their respective classes.
|
||||
"""
|
||||
modules = _get_available_plugins("bec.widgets.auto_updates")
|
||||
loaded_plugins = {}
|
||||
for module in modules:
|
||||
mods = inspect.getmembers(module, predicate=_filter_auto_updates)
|
||||
for name, mod_cls in mods:
|
||||
if name in loaded_plugins:
|
||||
print(f"Duplicated auto update {name}.")
|
||||
loaded_plugins[name] = mod_cls
|
||||
return loaded_plugins
|
||||
|
||||
|
||||
def _filter_auto_updates(obj):
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
|
||||
return (
|
||||
inspect.isclass(obj) and issubclass(obj, AutoUpdates) and not obj.__name__ == "AutoUpdates"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BECClassInfo:
|
||||
name: str
|
||||
module: str
|
||||
file: str
|
||||
obj: type
|
||||
is_connector: bool = False
|
||||
is_widget: bool = False
|
||||
is_plugin: bool = False
|
||||
|
||||
|
||||
class BECClassContainer:
|
||||
def __init__(self):
|
||||
self._collection: list[BECClassInfo] = []
|
||||
|
||||
def __repr__(self):
|
||||
return str(list(cl.name for cl in self.collection))
|
||||
|
||||
def __iter__(self):
|
||||
return self._collection.__iter__()
|
||||
|
||||
def add_class(self, class_info: BECClassInfo):
|
||||
"""
|
||||
Add a class to the collection.
|
||||
|
||||
Args:
|
||||
class_info(BECClassInfo): The class information
|
||||
"""
|
||||
self.collection.append(class_info)
|
||||
|
||||
@property
|
||||
def collection(self):
|
||||
"""
|
||||
Get the collection of classes.
|
||||
"""
|
||||
return self._collection
|
||||
|
||||
@property
|
||||
def connector_classes(self):
|
||||
"""
|
||||
Get all connector classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_connector]
|
||||
|
||||
@property
|
||||
def top_level_classes(self):
|
||||
"""
|
||||
Get all top-level classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_plugin]
|
||||
|
||||
@property
|
||||
def plugins(self):
|
||||
"""
|
||||
Get all plugins. These are all classes that are on the top level and are widgets.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_widget and info.is_plugin]
|
||||
|
||||
@property
|
||||
def widgets(self):
|
||||
"""
|
||||
Get all widgets. These are all classes inheriting from BECWidget.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_widget]
|
||||
|
||||
@property
|
||||
def rpc_top_level_classes(self):
|
||||
"""
|
||||
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_plugin and info.is_connector]
|
||||
|
||||
@property
|
||||
def classes(self):
|
||||
"""
|
||||
Get all classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection]
|
||||
|
||||
|
||||
def get_custom_classes(repo_name: str) -> BECClassContainer:
|
||||
"""
|
||||
Get all RPC-enabled classes in the specified repository.
|
||||
|
||||
Args:
|
||||
repo_name(str): The name of the repository.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
|
||||
"""
|
||||
collection = BECClassContainer()
|
||||
anchor_module = importlib.import_module(f"{repo_name}.widgets")
|
||||
directory = os.path.dirname(anchor_module.__file__)
|
||||
for root, _, files in sorted(os.walk(directory)):
|
||||
for file in files:
|
||||
if not file.endswith(".py") or file.startswith("__"):
|
||||
continue
|
||||
|
||||
path = os.path.join(root, file)
|
||||
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
|
||||
if len(subs) == 1 and not subs[0]:
|
||||
module_name = file.split(".")[0]
|
||||
else:
|
||||
module_name = ".".join(subs + [file.split(".")[0]])
|
||||
|
||||
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
|
||||
|
||||
for name in dir(module):
|
||||
obj = getattr(module, name)
|
||||
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
|
||||
continue
|
||||
if isinstance(obj, type):
|
||||
class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
|
||||
if issubclass(obj, BECConnector):
|
||||
class_info.is_connector = True
|
||||
if issubclass(obj, BECWidget):
|
||||
class_info.is_widget = True
|
||||
if len(subs) == 1 and (
|
||||
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
|
||||
):
|
||||
class_info.is_top_level = True
|
||||
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
|
||||
class_info.is_plugin = True
|
||||
collection.add_class(class_info)
|
||||
|
||||
return collection
|
||||
@@ -1,47 +0,0 @@
|
||||
from bec_lib.serialization import MsgpackSerialization
|
||||
from bec_lib.utils import lazy_import_from
|
||||
from qtpy.QtCore import QEventLoop, QSocketNotifier, QTimer
|
||||
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
|
||||
|
||||
class QtRedisMessageWaiter:
|
||||
def __init__(self, redis_connector, message_to_wait):
|
||||
self.ev_loop = QEventLoop()
|
||||
self.response = None
|
||||
self.connector = redis_connector
|
||||
self.message_to_wait = message_to_wait
|
||||
self.pubsub = redis_connector._redis_conn.pubsub()
|
||||
self.pubsub.subscribe(self.message_to_wait.endpoint)
|
||||
fd = self.pubsub.connection._sock.fileno()
|
||||
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
|
||||
self.notifier.activated.connect(self._pubsub_readable)
|
||||
|
||||
def _msg_received(self, msg_obj):
|
||||
self.response = msg_obj.value
|
||||
self.ev_loop.quit()
|
||||
|
||||
def wait(self, timeout=1):
|
||||
timer = QTimer()
|
||||
timer.singleShot(timeout * 1000, self.ev_loop.quit)
|
||||
self.ev_loop.exec_()
|
||||
timer.stop()
|
||||
self.notifier.setEnabled(False)
|
||||
self.pubsub.close()
|
||||
return self.response
|
||||
|
||||
def _pubsub_readable(self, fd):
|
||||
while True:
|
||||
msg = self.pubsub.get_message()
|
||||
if msg:
|
||||
if msg["type"] == "subscribe":
|
||||
# get_message buffers, so we may already have the answer
|
||||
# let's check...
|
||||
continue
|
||||
else:
|
||||
break
|
||||
else:
|
||||
return
|
||||
channel = msg["channel"].decode()
|
||||
msg = MessageObject(topic=channel, value=MsgpackSerialization.loads(msg["data"]))
|
||||
self.connector._execute_callback(self._msg_received, msg, {})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user