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

Compare commits

..

2 Commits

485 changed files with 4611 additions and 26879 deletions

View File

@@ -5,16 +5,9 @@ image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
#commands to run in the Docker container before starting each job.
variables:
DOCKER_TLS_CERTDIR: ""
BEC_CORE_BRANCH:
description: bec branch
value: main
OPHYD_DEVICES_BRANCH:
description: ophyd_devices branch
value: main
BEC_CORE_BRANCH: "main"
OPHYD_DEVICES_BRANCH: "main"
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
CHECK_PKG_VERSIONS:
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
value: 0
workflow:
rules:
@@ -34,9 +27,8 @@ include:
inputs:
stage: test
path: "."
pytest_args: "-v,--random-order,tests/unit_tests"
ignore_dep_group: "pyqt6"
pip_args: ".[dev,pyside6]"
pytest_args: "-v --random-order tests/"
exclude_packages: ""
# different stages in the pipeline
stages:
@@ -51,20 +43,13 @@ stages:
- export QTWEBENGINE_DISABLE_SANDBOX=1
.clone-repos: &clone-repos
- echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
.install-repos: &install-repos
- pip install -e ./ophyd_devices
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
.install-os-packages: &install-os-packages
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- *install-qt-webengine-deps
before_script:
@@ -146,9 +131,10 @@ tests:
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
- pip install -e .[dev,pyqt6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
@@ -183,9 +169,11 @@ test-matrix:
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
- pip install -e .[dev,$QT_PCKG]
- pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
allow_failure: true
end-2-end-conda:
stage: End2End
@@ -207,9 +195,10 @@ end-2-end-conda:
- cd ./bec
- source ./bin/install_bec_dev.sh -t
- cd ../
- pip install -e ./ophyd_devices
- pip install -e ./bec_lib[dev]
- pip install -e ./bec_ipython_client[dev]
- cd ../
- pip install -e .[dev,pyqt6]
- cd ./tests/end-2-end
- pytest -v --start-servers --flush-redis --random-order

View File

@@ -1,214 +1,141 @@
# CHANGELOG
## v0.85.0 (2024-07-16)
## v1.7.0 (2024-12-02)
### Feature
### Bug Fixes
* feat(color_map_selector): added colormap selector with plugin ([`b98fd00`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b98fd00adef97adf57f49b60ade99972b9f5a6bc))
- **tests**: Add test for Console widget
([`da579b6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/da579b6d213bcdf28c40c1a9e4e2535fdde824fb))
## v0.84.0 (2024-07-15)
### Features
### Feature
- **console**: Add "prompt" signal to inform when shell is at prompt
([`3aeb0b6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3aeb0b66fbeb03d3d0ee60e108cc6b98fd9aa9b9))
* feat(waveform): async readback update implemented for async devices ([`0c6a9f2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0c6a9f2310df31ddcd68050a17cfbf52c3e2e226))
- **console**: Add 'terminate' and 'send_ctrl_c' methods to Console
([`02086ae`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/02086aeae09233ec4e6ccc0e6a17f2b078d500b8))
* feat(waveform): data are taken directly from ScanItem which is defined from scan_status endpoint; scan update is triggered from scan_segment; plots can be added just specifying y_name -> best effort for setting x reported device ([`b8717f1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b8717f13276734dd655ab03cd6005985ad5af9fb))
.terminate() ends the started process, sending SIGTERM signal. If process is not dead after optional
timeout, SIGKILL is sent. .send_ctrl_c() sends SIGINT to the child process, and waits for prompt
until optional timeout is reached. Timeouts raise 'TimeoutError' exception.
### Fix
* fix(waveform): timestamp are not converted to human readable format ([`e495fd3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e495fd30c4c16474689943c7263e3060cb09ffb4))
## v1.6.0 (2024-11-27)
* fix(waveform): set_x method various bugs fixed ([`8516a1d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8516a1d639925a877f174fa13f427a71131cc918))
### Bug Fixes
* fix(waveform): x axis switching logic fixed when axis are not compatible ([`e4e1a90`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e4e1a905d19def22f970b364c18c953f00e10389))
- Add back accidentally removed variables
([`e998352`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e9983521ed2a1c04af048a55ece70a1943a84313))
* fix(waveform): dap leaked RID for all daps in current process; dap RID is now f"{scan_id}-{gui_id}" to distinguish for each plot instance ([`d23fd8b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d23fd8bd074ede6e14eb8e85e025cbced4bd45ef))
- Differentiate click and drag for DeviceItem, adapt tests accordingly
([`cffcdf2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cffcdf292363249bcc7efa9d130431d0bc727fda))
* fix(waveform): only one type of x axis allowed; x mode validated ([`9d6ae87`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9d6ae87d0f03ca227570fcca8af2d8190828d271))
This fixes the blocking "QDrag.exec_()" on Linux, indeed before the drag'n'drop operation was
started with a simple click and it was waiting for drop forever. Now there are 2 different cases,
click or drag'n'drop - the drag'n'drop test actually moves the mouse and releases the button.
* fix(waveform): data for axis are taken by separate method; validation consolidated ([`fc5a8bd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fc5a8bdd8b260f5e9b59ec71a4610c57442e43fe))
- Do not quit automatically when last window is "closed"
([`96e255e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/96e255e4ef394eb79006a66d13e06775ae235667))
* fix(bec_dispatcher): connect_slot can accept kwargs ([`0aa317a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0aa317aae58d3612d46f05b85f8b0db3d12bbe14))
Qt confuses closed and hidden
### Refactor
- No need to call inspect.signature - it can fail on methods coming from C (like Qt methods)
([`6029246`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/60292465e9e52d3248ae681c68c07298b9b3ce14))
* refactor(waveform): plot can be prompted without specifying kwargs ([`48911e9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/48911e934815923c94edb5ced6042058a11a97f5))
- **rpc**: Gui hide/show also hide/show all floating docks
([`c27d058`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c27d058b01fe604eccec76454e39360122e48515))
* refactor(jupyter_console_window): added more examples of waveforms ([`fc935d9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fc935d9fc81067c3a67389ff88ea97da2e0c903e))
- **server**: Use dock area by default
([`2fe7f5e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2fe7f5e1510a5ea72676045e6ea3485e6b11c220))
### Test
- **tests**: Make use of BECDockArea with client mixin to start server and use it in tests
([`da18c2c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/da18c2ceecf9aeaf0e0ea9b78f4c867b27b9c314))
* test(waveform): tests extended ([`006992e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/006992e43cc56d56261bc4fd3e9cae9abcab2153))
Depending on the test, auto-updates are enabled or not.
## v0.83.1 (2024-07-14)
### Features
### Fix
- '._auto_updates_enabled' attribute can be used to activate auto updates installation in
BECDockArea
([`31d8703`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/31d87036c9801e639a7ca6fc003c90e0c4edb19d))
* fix(toolbar): default transparent background ([`eab7883`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/eab78839792f175b7ac127ca603385c6baa5ff15))
- Add '--hide' argument to BEC GUI server
([`1f60fec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1f60fec7201ed252d7e49bf16f2166ee7f6bed6a))
* fix: use apply_theme ([`2d4249e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2d4249e73a792fed1c2c7ab79bb8aec38c57466c))
- Add main window container widget
([`f80ec33`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f80ec33ae5a261dbcab901ae30f4cc802316e554))
* fix: spinner: update reference image for widget test, use apply_theme ([`63db135`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/63db1352ee883d35670b3a692dbe51d6d01872ae))
- Add rpc_id member to client objects
([`3ba0b1d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3ba0b1daf5b83da840e90fbbc063ed7b86ebe99b))
* fix: replace pyqtdarktheme by qdarkstyle, add 'apply_theme' function (in utils/colors.py) ([`8308115`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8308115f3646245d825fc47ab57297d3460bbcf5))
- Asynchronous .start() for GUI
([`2047e48`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2047e484d5a4b2f5ea494a1e49035b35b1bbde35))
### Test
- Do not take focus when GUI is loaded
([`1f71d8e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1f71d8e5eded9952f9b34bfc427e2ff44cf5fc18))
* test(toolbar): added reference pngs for spinner for Darwin ([`11a7204`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/11a7204c98e0bf211a8721d296b45d24a3102b97))
- **client**: Add show()/hide() methods to "gui" object
([`e68e2b5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e68e2b5978339475b97555c3e20795807932fbc9))
## v0.83.0 (2024-07-08)
- **server**: Add main window, with proper gui_id derived from given id
([`daf6ea0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/daf6ea0159c9ffc7b53bb7ae6b9abc16a302972c))
### Feature
* feat: added reference utils to compare renderings of widgets ([`2988fd3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2988fd387e6b8076fffec1d57e3ccab89ddb2aeb))
## v1.5.3 (2024-11-21)
* feat(widgets): added device box with spinner ([`1b017ed`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1b017edfad8e78fa079210486123976695b8915c))
### Bug Fixes
* feat(designer): added option to skip the widget validation for DesignerPluginGenerator ([`41bcb80`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/41bcb801674ab6c4d6069bba34ffee09c9e665db))
- **alignment_1d**: Fix imports after widget module refactor
([`e71e3b2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e71e3b2956feb3f3051e538432133f6e85bbd5a8))
### Fix
### Continuous Integration
* fix(terminal): added default args to avoid designer crashes on startup ([`360d171`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/360d17135573e44b80ab517756da3c0b31daab0f))
- Fix ci syntax for package-dep-job
([`6e39bdb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6e39bdbf53b147c8ff163527b45691835ce9a2eb))
* fix(widget): fixed widget cleanup routine ([`2b29e34`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2b29e34b52d056349647bb2fcf649b749a60d292))
* fix(bec_widget): added cleanup method to bec widget base class ([`fd8766e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fd8766ed87770661da6591aeb4df5abdaf38afc7))
## v1.5.2 (2024-11-18)
* fix(website): fixed dummy input ([`903ce7d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/903ce7d46b5d37d40486d0fda92d3694d3faca62))
### Bug Fixes
### Test
- Support for bec v3
([`746359b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/746359b2cc07a317473907adfcabbe5fe5d1b64c))
* test(vscode): fixed vscode tests for new cleanup routine ([`eb26e2a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/eb26e2a11b229a52efe2e6d4fb28d760d3740136))
* test(vscode): improved vscode test ([`5de8804`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5de8804da1e41eafad2472344904b3324438c13b))
## v1.5.1 (2024-11-14)
## v0.82.2 (2024-07-08)
### Bug Fixes
### Fix
- **plugin_utils**: Plugin utils are able to detect classes for plugin creation based on class
attribute rather than if it is top level widget
([`7a1b874`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7a1b8748a433f854671ac95f2eaf4604e6b8df20))
* fix(rpc_server): pass cli config to server ([`90178e2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/90178e2f61fa9dac7d82c0d0db40a9767bb133e6))
### Refactoring
## v0.82.1 (2024-07-07)
- **widgets**: Widget module structure reorganised
([`aab0229`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/aab0229a4067ad626de919e38a5c8a2e9e7b03c2))
### Fix
* fix(motor_map): bug where motors without limits were selected ([`c78cd89`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c78cd898f203f950d7cb589eb5609feaa88062cf))
## v1.5.0 (2024-11-12)
### Refactor
### Bug Fixes
* refactor(setting_dialog): moved to qt_utils ([`3826bb3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3826bb3d9e870e85709b5b20ef09a4d22641280c))
- **crosshair**: Crosshair adapted for multi waveform widget
([`0cd85ed`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0cd85ed9fa5b67a6ecce89985cd4f54b7bbe3a4b))
* refactor(toolbar): toolbar moved from widgets to qt_utils ([`7ffc06f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7ffc06f3c7ddd86a1681408a75221b9bbadb236b))
### Documentation
### Test
- **multi_waveform**: Docs added
([`42d4f18`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/42d4f182f790a97687ca3b6d0e72866070a89767))
* test(setting_dialog): tests added ([`74a249b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/74a249bd065d01006cb532bfff2a9bfedb34b592))
### Features
### Unknown
- **multi-waveform**: New widget added
([`f3a39a6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f3a39a69e29d490b3023a508ced18028c4205772))
* tests(motor_map_widget): tests added ([`734f4c7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/734f4c77507a1edafd477d81b5f7401d8e759be2))
* feat(settings_dialog):apply button ([`2020953`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2020953b933b6fcad61ecc770588d39518c26fdd))
## v1.4.1 (2024-11-12)
## v0.82.0 (2024-07-07)
### Bug Fixes
### Feature
- **positioner_box**: Adjusted default signals
([`8e5c0ad`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8e5c0ad8c8eff5a9308169bc663d2b7230f0ebb1))
* feat(toggle): added angular component-like toggle ([`b9bff38`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b9bff38b64b86f06b3bc047922ef9df0c7d32e71))
### Refactor
## v1.4.0 (2024-11-11)
* refactor(device_input): DeviceComboBox and DeviceLineEdit moved to top layer of widgets ([`f048629`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f04862933f049030554086adef3ec9e1aebd3eda))
### Bug Fixes
* refactor(stop_button): moved to top layer, plugin added ([`f5b8375`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f5b8375fd36e3bb681de571da86a6c0bdb3cb6f0))
- **crosshair**: Label of coordinates of TextItem displays numbers in general format
([`11e5937`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/11e5937ae0f3c1413acd4e66878a692ebe4ef7d0))
* refactor(motor_map_widget): removed restriction of only PySide6 for widget ([`db1cdf4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/db1cdf42806fef6d7c6d2db83528f32df3f9751d))
- **crosshair**: Label of coordinates of TextItem is updated according to the current theme of qapp
([`4f31ea6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4f31ea655cf6190e141e6a2720a2d6da517a2b5b))
* refactor(color_button): ColorButton moved to top level of widgets ([`fa1e86f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fa1e86ff07b25d2c47c73117b00765b8e2f25da4))
- **crosshair**: Log is separately scaled for backend logic and for signal emit
([`b2eb71a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b2eb71aae0b6a7c82158f2d150ae1e31411cfdeb))
## v0.81.2 (2024-07-07)
### Features
### Fix
- **crosshair**: Textitem to display crosshair coordinates
([`035136d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/035136d5171ec5f4311d15a9aa5bad2bdbc1f6cb))
* fix(waveform): scan_history error check for IndexError ([`dd1875e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/dd1875ea5cc18bcef9aad743347a8accf144c08d))
### Testing
## v0.81.1 (2024-07-07)
- **crosshair**: Tests extended
([`64df805`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/64df805a9ed92bb97e580ac3bc0a1bbd2b1cb81e))
### Fix
* fix(motor_control): temporary remove of motor control widgets ([`99114f1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/99114f14f62202e1fd8bf145616fa8c69937ada4))
## v1.3.3 (2024-11-07)
### Bug Fixes
- **scan_control**: Devicelineedit kwargs readings changed to get name of the positioner
([`5fabd4b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5fabd4bea95bafd2352102686357cc1db80813fd))
### Documentation
- Update outdated text in docs
([`4f0693c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4f0693cae34b391d75884837e1ae6353a0501868))
## v1.3.2 (2024-11-05)
### Bug Fixes
- **plot_base**: Legend text color is changed when changing dark-light theme
([`2304c9f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2304c9f8497c1ab1492f3e6690bb79b0464c0df8))
### Build System
- Pyside6 version fixed 6.7.2
([`c6e48ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c6e48ec1fe5aaee6a7c7a6f930f1520cd439cdb2))
## v1.3.1 (2024-10-31)
### Bug Fixes
- **ophyd_kind_util**: Kind enums are imported from the bec widget util class
([`940ee65`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/940ee6552c1ee8d9b4e4a74c62351f2e133ab678))
## v1.3.0 (2024-10-30)
### Bug Fixes
- **colors**: Extend color map validation for matplotlib and colorcet maps (if available)
([`14dd8c5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/14dd8c5b2947c92f6643b888d71975e4e8d4ee88))
### Features
- **colormap_button**: Colormap button with menu to select colormap filtered by the colormap type
([`b039933`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b039933405e2fbe92bd81bd0748e79e8d443a741))
## v0.81.0 (2024-07-06)

View File

@@ -1,198 +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.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.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
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()

View File

@@ -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>

View File

@@ -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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import threading
from queue import Queue
from typing import TYPE_CHECKING
from pydantic import BaseModel
@@ -27,17 +25,6 @@ class AutoUpdates:
def __init__(self, gui: BECDockArea):
self.gui = gui
self.msg_queue = Queue()
self.auto_update_thread = None
self._shutdown_sentinel = object()
self.start()
def start(self):
"""
Start the auto update thread.
"""
self.auto_update_thread = threading.Thread(target=self.process_queue)
self.auto_update_thread.start()
def start_default_dock(self):
"""
@@ -92,16 +79,6 @@ class AutoUpdates:
info = self.get_scan_info(msg)
self.handler(info)
def process_queue(self):
"""
Process the message queue.
"""
while True:
msg = self.msg_queue.get()
if msg is self._shutdown_sentinel:
break
self.run(msg)
@staticmethod
def get_selected_device(monitored_devices, selected_device):
"""
@@ -174,11 +151,3 @@ class AutoUpdates:
fig.clear_all()
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def shutdown(self):
"""
Shutdown the auto update thread.
"""
self.msg_queue.put(self._shutdown_sentinel)
if self.auto_update_thread:
self.auto_update_thread.join()

File diff suppressed because it is too large Load Diff

View File

@@ -6,16 +6,17 @@ import json
import os
import select
import subprocess
import sys
import threading
import time
import uuid
from functools import wraps
from typing import TYPE_CHECKING
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
from qtpy.QtCore import QEventLoop, QSocketNotifier, QTimer
import bec_widgets.cli.client as client
from bec_widgets.cli.auto_updates import AutoUpdates
@@ -23,6 +24,10 @@ from bec_widgets.cli.auto_updates import AutoUpdates
if TYPE_CHECKING:
from bec_lib.device import DeviceBase
from bec_widgets.cli.client import BECDockArea, BECFigure
from bec_lib.serialization import MsgpackSerialization
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
@@ -80,7 +85,7 @@ def _get_output(process, logger) -> None:
buf.clear()
buf.append(remaining)
except Exception as e:
logger.error(f"Error reading process output: {str(e)}")
print(f"Error reading process output: {str(e)}")
def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger=None) -> None:
@@ -92,15 +97,14 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
process will not be captured.
"""
# pylint: disable=subprocess-run-check
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__, "--hide"]
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__]
if config:
if isinstance(config, dict):
config = json.dumps(config)
command.extend(["--config", str(config)])
command.extend(["--config", config])
env_dict = os.environ.copy()
env_dict["PYTHONUNBUFFERED"] = "1"
if logger is None:
stdout_redirect = subprocess.DEVNULL
stderr_redirect = subprocess.DEVNULL
@@ -126,36 +130,15 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
return process, process_output_processing_thread
class RepeatTimer(threading.Timer):
def run(self):
while not self.finished.wait(self.interval):
self.function(*self.args, **self.kwargs)
class BECGuiClientMixin:
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._auto_updates_enabled = True
self._auto_updates = None
self._gui_started_timer = None
self._gui_started_event = threading.Event()
self._process = None
self._process_output_processing_thread = None
self.auto_updates = self._get_update_script()
self._target_endpoint = MessageEndpoints.scan_status()
self._selected_device = None
@property
def auto_updates(self):
if self._auto_updates_enabled:
self._gui_started_event.wait()
return self._auto_updates
def shutdown_auto_updates(self):
if self._auto_updates_enabled:
if self._auto_updates is not None:
self._auto_updates.shutdown()
self._auto_updates = None
def _get_update_script(self) -> AutoUpdates | None:
eps = imd.entry_points(group="bec.widgets.auto_updates")
for ep in eps:
@@ -167,7 +150,7 @@ class BECGuiClientMixin:
continue
return ep.load()(gui=self)
except Exception as e:
logger.error(f"Error loading auto update script from plugin: {str(e)}")
print(f"Error loading auto update script from plugin: {str(e)}")
return None
@property
@@ -201,80 +184,35 @@ class BECGuiClientMixin:
if isinstance(msg, messages.ScanStatusMessage):
if not self.gui_is_alive():
return
if self._auto_updates_enabled:
self.auto_updates.msg_queue.put(msg)
self.auto_updates.run(msg)
def _gui_post_startup(self):
if self._auto_updates_enabled:
if self._auto_updates is None:
auto_updates = self._get_update_script()
if auto_updates is None:
AutoUpdates.create_default_dock = True
AutoUpdates.enabled = True
auto_updates = AutoUpdates(gui=self)
if auto_updates.create_default_dock:
auto_updates.start_default_dock()
# fig = auto_updates.get_default_figure()
self._auto_updates = auto_updates
self._gui_started_event.set()
self.show_all()
def start_server(self, wait=False) -> None:
def show(self) -> 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._gui_started_event.clear()
self._start_update_script()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id, self.__class__, self._client._service_config.config, logger=logger
self._gui_id, self.__class__, self._client._service_config.config
)
def gui_started_callback(callback):
try:
if callable(callback):
callback()
finally:
threading.current_thread().cancel()
self._gui_started_timer = RepeatTimer(
1, lambda: self.gui_is_alive() and gui_started_callback(self._gui_post_startup)
)
self._gui_started_timer.start()
if wait:
self._gui_started_event.wait()
def show_all(self):
self._gui_started_event.wait()
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("show")
def hide_all(self):
self._gui_started_event.wait()
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("hide")
while not self.gui_is_alive():
print("Waiting for GUI to start...")
time.sleep(1)
def close(self) -> None:
"""
Close the gui window.
"""
if self._gui_started_timer is not None:
self._gui_started_timer.cancel()
self._gui_started_timer.join()
if self._process is None:
return
self._client.shutdown()
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
self.shutdown_auto_updates()
class RPCResponseTimeoutError(Exception):
@@ -286,14 +224,54 @@ class RPCResponseTimeoutError(Exception):
)
class QtRedisMessageWaiter:
def __init__(self, redis_connector, message_to_wait):
self.ev_loop = QEventLoop()
self.response = None
self.connector = redis_connector
self.message_to_wait = message_to_wait
self.pubsub = redis_connector._redis_conn.pubsub()
self.pubsub.subscribe(self.message_to_wait.endpoint)
fd = self.pubsub.connection._sock.fileno()
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
self.notifier.activated.connect(self._pubsub_readable)
def _msg_received(self, msg_obj):
self.response = msg_obj.value
self.ev_loop.quit()
def wait(self, timeout=1):
timer = QTimer()
timer.singleShot(timeout * 1000, self.ev_loop.quit)
self.ev_loop.exec_()
timer.stop()
self.notifier.setEnabled(False)
self.pubsub.close()
return self.response
def _pubsub_readable(self, fd):
while True:
msg = self.pubsub.get_message()
if msg:
if msg["type"] == "subscribe":
# get_message buffers, so we may already have the answer
# let's check...
continue
else:
break
else:
return
channel = msg["channel"].decode()
msg = MessageObject(topic=channel, value=MsgpackSerialization.loads(msg["data"]))
self.connector._execute_callback(self._msg_received, msg, {})
class RPCBase:
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
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())[:5]
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())
self._parent = parent
self._msg_wait_event = threading.Event()
self._rpc_response = None
super().__init__()
# print(f"RPCBase: {self._gui_id}")
@@ -337,38 +315,23 @@ class RPCBase:
# 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,
redis_msg = QtRedisMessageWaiter(
self._client.connector, MessageEndpoints.gui_instruction_response(request_id)
)
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
if wait_for_rpc_response:
try:
finished = self._msg_wait_event.wait(10)
if not finished:
raise RPCResponseTimeoutError(request_id, timeout)
finally:
self._msg_wait_event.clear()
self._client.connector.unregister(
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
# get class name
if not self._rpc_response.accepted:
raise ValueError(self._rpc_response.message["error"])
msg_result = self._rpc_response.message.get("result")
self._rpc_response = None
return self._create_widget_from_msg_result(msg_result)
response = redis_msg.wait(timeout)
@staticmethod
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
msg = msg.value
parent._msg_wait_event.set()
parent._rpc_response = msg
if response is None:
raise RPCResponseTimeoutError(request_id, timeout)
# get class name
if not response.accepted:
raise ValueError(response.message["error"])
msg_result = response.message.get("result")
return self._create_widget_from_msg_result(msg_result)
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
@@ -396,8 +359,4 @@ class RPCBase:
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
return heart is not None

View File

@@ -8,10 +8,9 @@ import sys
import black
import isort
from qtpy.QtCore import Property as QtProperty
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
from bec_widgets.utils.plugin_utils import BECClassContainer, get_rpc_classes
if sys.version_info >= (3, 11):
from typing import get_overloads
@@ -31,7 +30,6 @@ else:
class ClientGenerator:
def __init__(self):
self.header = """# This file was automatically generated by generate_cli.py\n
from __future__ import annotations
import enum
from typing import Literal, Optional, overload
@@ -92,27 +90,11 @@ class {class_name}(RPCBase):"""
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__}. 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:
@@ -175,7 +157,7 @@ def main():
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
rpc_classes = get_custom_classes("bec_widgets")
rpc_classes = get_rpc_classes("bec_widgets")
generator = ClientGenerator()
generator.generate_client(rpc_classes)

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from threading import Lock
from weakref import WeakValueDictionary

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from bec_widgets.utils import BECConnector
@@ -28,10 +26,10 @@ class RPCWidgetHandler:
Returns:
None
"""
from bec_widgets.utils.plugin_utils import get_custom_classes
from bec_widgets.utils.plugin_utils import get_rpc_classes
clss = get_custom_classes("bec_widgets")
self._widget_classes = {cls.__name__: cls for cls in clss.widgets}
clss = get_rpc_classes("bec_widgets")
self._widget_classes = {cls.__name__: cls for cls in clss.top_level_classes}
def create_widget(self, widget_type, **kwargs) -> BECConnector:
"""

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import inspect
import json
import signal
import sys
@@ -10,14 +11,14 @@ from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import Qt, QTimer
from qtpy.QtCore import QTimer
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
@@ -27,13 +28,12 @@ class BECWidgetsCLIServer:
def __init__(
self,
gui_id: str,
gui_id: str = None,
dispatcher: BECDispatcher = None,
client=None,
config=None,
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
) -> None:
self.status = messages.BECStatus.BUSY
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
@@ -47,16 +47,13 @@ class BECWidgetsCLIServer:
)
# Setup QTimer for heartbeat
self._shutdown_event = False
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200)
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
@@ -64,10 +61,9 @@ class BECWidgetsCLIServer:
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
except Exception as e:
logger.error(f"Error while executing RPC instruction: {e}")
print(e)
self.send_response(request_id, False, {"error": str(e)})
else:
logger.debug(f"RPC instruction executed successfully: {res}")
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict):
@@ -85,17 +81,16 @@ class BECWidgetsCLIServer:
return obj
def run_rpc(self, obj, method, args, kwargs):
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
if not args:
res = method_obj
else:
setattr(obj, method, args[0])
res = None
res = method_obj
else:
res = method_obj(*args, **kwargs)
sig = inspect.signature(method_obj)
if sig.parameters:
res = method_obj(*args, **kwargs)
else:
res = method_obj()
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
@@ -116,18 +111,16 @@ class BECWidgetsCLIServer:
return obj
def emit_heartbeat(self):
logger.trace(f"Emitting heartbeat for {self.gui_id}")
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
if self._shutdown_event is False:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=1, info={}),
expire=1,
)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
logger.info(f"Shutting down server with gui_id: {self.gui_id}")
self.status = messages.BECStatus.IDLE
self._shutdown_event = True
self._heartbeat_timer.stop()
self.emit_heartbeat()
self.gui.close()
self.client.shutdown()
@@ -135,16 +128,15 @@ class BECWidgetsCLIServer:
class SimpleFileLikeFromLogOutputFunc:
def __init__(self, log_func):
self._log_func = log_func
self._buffer = []
def write(self, buffer):
self._buffer.append(buffer)
for line in buffer.rstrip().splitlines():
line = line.rstrip()
if line:
self._log_func(line)
def flush(self):
lines, _, remaining = "".join(self._buffer).rpartition("\n")
if lines:
self._log_func(lines)
self._buffer = [remaining]
return
def close(self):
return
@@ -161,12 +153,12 @@ def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config:
# if no config is provided, use the default config
service_config = ServiceConfig()
# bec_logger.configure(
# service_config.redis,
# QtRedisConnector,
# service_name="BECWidgetsCLIServer",
# service_config=service_config.service_config,
# )
bec_logger.configure(
service_config.redis,
QtRedisConnector,
service_name="BECWidgetsCLIServer",
service_config=service_config.service_config,
)
server = BECWidgetsCLIServer(gui_id=gui_id, config=service_config, gui_class=gui_class)
return server
@@ -177,16 +169,10 @@ def main():
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
from qtpy.QtWidgets import QApplication, QMainWindow
import bec_widgets
bec_logger.level = bec_logger.LOGLEVEL.DEBUG
if __name__ != "__main__":
# if not running as main, set the log level to critical
# pylint: disable=protected-access
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.CRITICAL
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
parser.add_argument("--id", type=str, help="The id of the server")
parser.add_argument(
@@ -195,7 +181,6 @@ def main():
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
)
parser.add_argument("--config", type=str, help="Config file or config string.")
parser.add_argument("--hide", action="store_true", help="Hide on startup")
args = parser.parse_args()
@@ -208,34 +193,28 @@ def main():
"Please specify a valid gui_class to run. Use -h for help."
"\n Starting with default gui_class BECFigure."
)
gui_class = BECDockArea
gui_class = BECFigure
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.debug)):
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
app = QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)
app.setApplicationName("BEC Figure")
module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon()
icon.addFile(
os.path.join(module_path, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
os.path.join(module_path, "assets", "bec_widgets_icon.png"), size=QSize(48, 48)
)
app.setWindowIcon(icon)
server = _start_server(args.id, gui_class, args.config)
win = BECMainWindow(gui_id=f"{server.gui_id}:window")
win.setAttribute(Qt.WA_ShowWithoutActivating)
win = QMainWindow()
win.setWindowTitle("BEC Widgets")
RPCRegister().add_rpc(win)
server = _start_server(args.id, gui_class, args.config)
gui = server.gui
win.setCentralWidget(gui)
win.resize(800, 600)
if not args.hide:
win.show()
win.show()
app.aboutToQuit.connect(server.shutdown)
@@ -251,5 +230,4 @@ def main():
if __name__ == "__main__": # pragma: no cover
sys.argv = ["bec_widgets.cli.server", "--id", "e2860", "--gui_class", "BECDockArea"]
main()

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,8 @@ import os
import numpy as np
import pyqtgraph as pg
from bec_qthemes import material_icon
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import (
QApplication,
QGroupBox,
@@ -15,9 +16,9 @@ from qtpy.QtWidgets import (
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.jupyter_console.jupyter_console import BECJupyterConsole
class JupyterConsoleWindow(QWidget): # pragma: no cover:
@@ -45,16 +46,11 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"w7": self.w7,
"w8": self.w8,
"w9": self.w9,
"w10": self.w10,
"d0": self.d0,
"d1": self.d1,
"d2": self.d2,
"wave": self.wf,
# "bar": self.bar,
# "cm": self.colormap,
"im": self.im,
"mm": self.mm,
"mw": self.mw,
"plt": self.plt,
"bar": self.bar,
}
)
@@ -158,35 +154,33 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
def _init_dock(self):
self.d0 = self.dock.add_dock(name="dock_0")
self.mm = self.d0.add_widget("BECMotorMapWidget")
self.mm.change_motors("samx", "samy")
self.fig0 = self.d0.add_widget("BECFigure")
data = np.random.rand(10, 2)
self.fig0.plot(data, label="2d Data")
self.fig0.image("eiger", vrange=(0, 100))
self.d1 = self.dock.add_dock(name="dock_1", position="right")
self.im = self.d1.add_widget("BECImageWidget")
self.im.image("waveform", "1d")
self.fig1 = self.d1.add_widget("BECFigure")
self.fig1.plot(x_name="samx", y_name="bpm4i")
self.fig1.plot(x_name="samx", y_name="bpm3a")
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
self.wf = self.d2.add_widget("BECFigure", row=0, col=0)
self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
# self.wf.plot(x_name="samx", y_name="bpm3a")
# self.wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
# self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
# self.bar.set_diameter(200)
# self.d3 = self.dock.add_dock(name="dock_3", position="bottom")
# self.colormap = pg.GradientWidget()
# self.d3.add_widget(self.colormap, row=0, col=0)
self.fig2 = self.d2.add_widget("BECFigure", row=0, col=0)
self.plt = self.fig2.plot(x_name="samx", y_name="bpm3a")
self.plt.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
self.bar.set_diameter(200)
self.dock.save_state()
def closeEvent(self, event):
"""Override to handle things when main window is closed."""
self.dock.clear_all()
self.dock.cleanup()
self.dock.close()
self.figure.clear_all()
self.figure.cleanup()
self.figure.close()
self.console.close()
super().closeEvent(event)
@@ -202,7 +196,8 @@ if __name__ == "__main__": # pragma: no cover
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
apply_theme("dark")
icon = material_icon("terminal", color="#434343", filled=True)
icon = QIcon()
icon.addFile(os.path.join(module_path, "assets", "terminal_icon.png"), size=QSize(48, 48))
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher()
@@ -211,7 +206,6 @@ if __name__ == "__main__": # pragma: no cover
win = JupyterConsoleWindow()
win.show()
win.resize(1200, 800)
app.aboutToQuit.connect(win.close)
sys.exit(app.exec_())

View File

@@ -7,8 +7,7 @@ import sys
from bec_ipython_client.main import BECIPythonClient
from qtpy.QtWidgets import QApplication
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
from tictactoe import TicTacToe
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)

View File

@@ -2,9 +2,8 @@
# 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
from tictactoe import TicTacToe
from tictactoeplugin import TicTacToePlugin
# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin

View File

@@ -1,13 +1,10 @@
# 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
from qtpy.QtGui import QIcon
from tictactoe import TicTacToe
from tictactoetaskmenu import TicTacToeTaskMenuFactory
DOM_XML = """
<ui language='c++'>
@@ -27,8 +24,6 @@ DOM_XML = """
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class TicTacToePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -43,10 +38,10 @@ class TicTacToePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "Games"
return ""
def icon(self):
return designer_material_icon("sports_esports")
return QIcon()
def includeFile(self):
return "tictactoe"

View File

@@ -1,12 +1,11 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtCore import Slot
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.qt_utils.error_popups import SafeSlot as Slot
from tictactoe import TicTacToe
class TicTacToeDialog(QDialog): # pragma: no cover

View File

@@ -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()

View File

@@ -1,4 +1,3 @@
import functools
import sys
import traceback
@@ -6,35 +5,15 @@ from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
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:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=popup_error)
return wrapper
return error_managed
class WarningPopupUtility(QObject):
"""
Utility class to show warning popups in the application.
"""
@SafeSlot(str, str, str, QWidget)
def __init__(self, parent=None):
super().__init__(parent)
@Slot(str, str, str, QWidget)
def show_warning_message(self, title, message, detailed_text, widget):
msg = QMessageBox(widget)
msg.setIcon(QMessageBox.Warning)
@@ -57,10 +36,7 @@ class WarningPopupUtility(QObject):
self.show_warning_message(title, message, detailed_text, widget)
_popup_utility_instance = None
class _ErrorPopupUtility(QObject):
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.
@@ -68,14 +44,24 @@ class _ErrorPopupUtility(QObject):
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
_instance = None
_initialized = False
@SafeSlot(str, str, QWidget)
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(ErrorPopupUtility, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self, parent=None):
if not self._initialized:
super().__init__(parent=parent)
self.error_occurred.connect(self.show_error_message)
self.enable_error_popup = False
self.original_excepthook = sys.excepthook
self._initialized = True
@Slot(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)
@@ -127,16 +113,12 @@ class _ErrorPopupUtility(QObject):
error_message = " ".join(captured_message)
return error_message
def custom_exception_hook(self, exctype, value, tb, popup_error=False):
if popup_error or self.enable_error_popup:
def custom_exception_hook(self, exctype, value, tb):
if self.enable_error_popup:
error_message = traceback.format_exception(exctype, value, tb)
self.error_occurred.emit(
"Method error" if popup_error else "Application Error",
"".join(error_message),
self.parent(),
)
self.error_occurred.emit("Application Error", "".join(error_message), self.parent())
else:
sys.__excepthook__(exctype, value, tb) # Call the original excepthook
self.original_excepthook(exctype, value, tb) # Call the original excepthook
def enable_global_error_popups(self, state: bool):
"""
@@ -146,13 +128,34 @@ class _ErrorPopupUtility(QObject):
state(bool): True to enable error popups, False to disable error popups.
"""
self.enable_error_popup = bool(state)
if self.enable_error_popup:
sys.excepthook = self.custom_exception_hook
else:
sys.excepthook = self.original_excepthook
@classmethod
def reset_singleton(cls):
"""
Reset the singleton instance.
"""
cls._instance = None
cls._initialized = False
def ErrorPopupUtility():
global _popup_utility_instance
if not _popup_utility_instance:
_popup_utility_instance = _ErrorPopupUtility()
return _popup_utility_instance
def error_managed(method):
"""Decorator to manage errors with the ErrorPopupUtility"""
def wrapper(*args, **kwargs):
try:
return method(*args, **kwargs)
except Exception as e:
exc_type, exc_value, exc_traceback = sys.exc_info()
error_message = traceback.format_exception(exc_type, exc_value, exc_traceback)
ErrorPopupUtility().error_occurred.emit("Error in Method", "".join(error_message), None)
if not ErrorPopupUtility()._instance.enable_error_popup:
raise
return wrapper
class ExampleWidget(QWidget): # pragma: no cover
@@ -185,17 +188,15 @@ class ExampleWidget(QWidget): # pragma: no cover
self.warning_button.clicked.connect(self.trigger_warning)
self.layout.addWidget(self.warning_button)
@SafeSlot(popup_error=True)
@error_managed
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(

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
from qtpy.QtCore import Slot
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
class SettingWidget(QWidget):
"""
@@ -106,14 +105,3 @@ class SettingsDialog(QDialog):
Apply the changes made in the settings widget without closing the dialog.
"""
self.widget.accept_changes()
def cleanup(self):
"""
Cleanup the dialog.
"""
self.button_box.close()
self.button_box.deleteLater()
def closeEvent(self, event):
self.cleanup()
super().closeEvent(event)

View File

@@ -1,48 +1,12 @@
# pylint: disable=no-name-in-module
from __future__ import annotations
import os
from abc import ABC, abstractmethod
from collections import defaultdict
from typing import Literal
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QMenu,
QSizePolicy,
QToolBar,
QToolButton,
QWidget,
)
import bec_widgets
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
# pylint: disable=no-name-in-module
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QToolBar, QWidget
class ToolBarAction(ABC):
"""
Abstract base class for toolbar actions.
Args:
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
tooltip (bool, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
"""
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
self.icon_path = (
os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None
)
self.tooltip = tooltip
self.checkable = checkable
self.action = None
@abstractmethod
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""Adds an action or widget to a toolbar.
@@ -53,225 +17,22 @@ class ToolBarAction(ABC):
"""
class SeparatorAction(ToolBarAction):
"""Separator action for the toolbar."""
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
toolbar.addSeparator()
class IconAction(ToolBarAction):
"""
Action with an icon for the toolbar.
Args:
icon_path (str): The path to the icon file.
tooltip (str): The tooltip for the action.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
"""
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
super().__init__(icon_path, tooltip, checkable)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
icon = QIcon()
icon.addFile(self.icon_path, size=QSize(20, 20))
self.action = QAction(icon, self.tooltip, target)
self.action.setCheckable(self.checkable)
toolbar.addAction(self.action)
class MaterialIconAction:
"""
Action with a Material icon for the toolbar.
Args:
icon_path (str, optional): The name of the Material icon. Defaults to None.
tooltip (bool, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
filled (bool, optional): Whether the icon is filled. Defaults to False.
"""
def __init__(
self,
icon_name: str = None,
tooltip: str = None,
checkable: bool = False,
filled: bool = False,
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
):
self.icon_name = icon_name
self.tooltip = tooltip
self.checkable = checkable
self.action = None
self.filled = filled
self.color = color
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
icon = self.get_icon()
self.action = QAction(icon, self.tooltip, target)
self.action.setCheckable(self.checkable)
toolbar.addAction(self.action)
def get_icon(self):
icon = material_icon(
self.icon_name,
size=(20, 20),
convert_to_pixmap=False,
filled=self.filled,
color=self.color,
)
return icon
class DeviceSelectionAction(ToolBarAction):
"""
Action for selecting a device in a combobox.
Args:
label (str): The label for the combobox.
device_combobox (DeviceComboBox): The combobox for selecting the device.
"""
def __init__(self, label: str, device_combobox):
super().__init__()
self.label = label
self.device_combobox = device_combobox
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
def add_to_toolbar(self, toolbar, target):
widget = QWidget()
layout = QHBoxLayout(widget)
label = QLabel(f"{self.label}")
layout.addWidget(label)
layout.addWidget(self.device_combobox)
toolbar.addWidget(widget)
def set_combobox_style(self, color: str):
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
class WidgetAction(ToolBarAction):
"""
Action for adding any widget to the toolbar.
Args:
label (str|None): The label for the widget.
widget (QWidget): The widget to be added to the toolbar.
"""
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
super().__init__(parent)
self.label = label
self.widget = widget
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
if self.label is not None:
label_widget = QLabel(f"{self.label}")
label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
layout.addWidget(label_widget)
if isinstance(self.widget, QComboBox):
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.widget.setSizePolicy(size_policy)
self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget))
else:
self.widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
layout.addWidget(self.widget)
toolbar.addWidget(container)
@staticmethod
def calculate_minimum_width(combo_box: QComboBox) -> int:
"""
Calculate the minimum width required to display the longest item in the combo box.
Args:
combo_box (QComboBox): The combo box to calculate the width for.
Returns:
int: The calculated minimum width in pixels.
"""
font_metrics = combo_box.fontMetrics()
max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count()))
return max_width + 60
class ExpandableMenuAction(ToolBarAction):
"""
Action for an expandable menu in the toolbar.
Args:
label (str): The label for the menu.
actions (dict): A dictionary of actions to populate the menu.
icon_path (str, optional): The path to the icon file. Defaults to None.
"""
def __init__(self, label: str, actions: dict, icon_path: str = None):
super().__init__(icon_path, label)
self.actions = actions
self.widgets = defaultdict(dict)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
button = QToolButton(toolbar)
if self.icon_path:
button.setIcon(QIcon(self.icon_path))
button.setText(self.tooltip)
button.setPopupMode(QToolButton.InstantPopup)
button.setStyleSheet(
"""
QToolButton {
font-size: 14px;
}
QMenu {
font-size: 14px;
}
"""
)
menu = QMenu(button)
for action_id, action in self.actions.items():
sub_action = QAction(action.tooltip, target)
if hasattr(action, "icon_path"):
icon = QIcon()
icon.addFile(action.icon_path, size=QSize(20, 20))
sub_action.setIcon(icon)
elif hasattr(action, "get_icon"):
sub_action.setIcon(action.get_icon())
sub_action.setCheckable(action.checkable)
menu.addAction(sub_action)
self.widgets[action_id] = sub_action
button.setMenu(menu)
toolbar.addWidget(button)
class ModularToolBar(QToolBar):
"""Modular toolbar with optional automatic initialization.
Args:
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
actions (list[ToolBarAction], optional): A list of action creators to populate the toolbar. Defaults to None.
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
color (str, optional): The background color of the toolbar. Defaults to "black".
"""
def __init__(self, parent=None, actions: dict | None = None, target_widget=None):
def __init__(
self, parent=None, actions=None, target_widget=None, color: str = "rgba(255, 255, 255, 0)"
):
super().__init__(parent)
self.widgets = defaultdict(dict)
self.set_background_color()
self.set_background_color(color)
if actions is not None and target_widget is not None:
self.populate_toolbar(actions, target_widget)
@@ -288,9 +49,9 @@ class ModularToolBar(QToolBar):
action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action
def set_background_color(self):
def set_background_color(self, color: str):
self.setStyleSheet(f"QToolBar {{ background: {color}; }}")
self.setIconSize(QSize(20, 20))
self.setMovable(False)
self.setFloatable(False)
self.setContentsMargins(0, 0, 0, 0)
self.setStyleSheet("QToolBar { background-color: rgba(0, 0, 0, 0); border: none; }")

View File

@@ -1,226 +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"),
]

View File

@@ -6,18 +6,17 @@ import time
import uuid
from typing import Optional
from bec_lib.logger import bec_logger
import yaml
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
from qtpy.QtWidgets import QApplication
from qtpy.QtCore import Slot as pyqtSlot
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
logger = bec_logger.logger
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
@@ -66,35 +65,21 @@ class Worker(QRunnable):
self.signals.completed.emit()
class BECConnector:
"""Connection mixin class to handle BEC client and device manager"""
class BECConnector(BECWidget):
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
EXIT_HANDLERS = {}
USER_ACCESS = ["_config_dict", "_get_all_rpc"]
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
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."
)
@@ -107,8 +92,6 @@ class BECConnector:
self.gui_id = self.config.gui_id
# register widget to rpc register
# be careful: when registering, and the object is not a BECWidget,
# cleanup has to called manually since there is no 'closeEvent'
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
@@ -301,3 +284,18 @@ class BECConnector:
return self.config.model_dump()
else:
return self.config
def cleanup(self):
"""Cleanup the widget."""
self.rpc_register.remove_rpc(self)
all_connections = self.rpc_register.list_all_connections()
if len(all_connections) == 0:
print("No more connections. Shutting down GUI BEC client.")
self.bec_dispatcher.disconnect_all()
self.client.shutdown()
if hasattr(super(), "cleanup"):
super().cleanup()
# def closeEvent(self, event):
# self.cleanup()
# super().closeEvent(event)

View File

@@ -6,9 +6,7 @@ import sys
import sysconfig
from pathlib import Path
from bec_qthemes import material_icon
from qtpy import PYSIDE6
from qtpy.QtGui import QIcon
if PYSIDE6:
from PySide6.scripts.pyside_tool import (
@@ -23,19 +21,6 @@ if PYSIDE6:
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.

View File

@@ -6,14 +6,11 @@ from typing import TYPE_CHECKING, 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 qtpy.QtCore import QObject
from qtpy.QtCore import PYQT6, PYSIDE6, QCoreApplication, QObject
from qtpy.QtCore import Signal as pyqtSignal
logger = bec_logger.logger
if TYPE_CHECKING:
from bec_lib.endpoints import EndpointInfo
@@ -68,11 +65,17 @@ class QtRedisConnector(RedisConnector):
cb(msg.content, msg.metadata)
class BECClientWithoutLoggerInit(BECClient):
def _initialize_logger(self):
return
class BECDispatcher:
"""Utility class to keep track of slots connected to a particular redis connector"""
_instance = None
_initialized = False
qapp = None
def __new__(cls, client=None, config: str = None, *args, **kwargs):
if cls._instance is None:
@@ -84,6 +87,9 @@ class BECDispatcher:
if self._initialized:
return
if not QCoreApplication.instance():
BECDispatcher.qapp = QCoreApplication([])
self._slots = collections.defaultdict(set)
self.client = client
@@ -92,22 +98,24 @@ class BECDispatcher:
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"
)
self.client = BECClientWithoutLoggerInit(
config=config, connector_cls=QtRedisConnector
) # , forced=True)
else:
self.client = BECClientWithoutLoggerInit(
connector_cls=QtRedisConnector
) # , forced=True)
else:
if self.client.started:
# have to reinitialize client to use proper connector
logger.info("Shutting down BECClient to switch to QtRedisConnector")
self.client.shutdown()
self.client._BECClient__init_params["connector_cls"] = QtRedisConnector
try:
self.client.start()
except redis.exceptions.ConnectionError:
logger.warning("Could not connect to Redis, skipping start of BECClient.")
print("Could not connect to Redis, skipping start of BECClient.")
logger.success("Initialized BECDispatcher")
self._initialized = True
@classmethod
@@ -115,13 +123,23 @@ class BECDispatcher:
cls._instance = None
cls._initialized = False
if not cls.qapp:
return
# shutdown QCoreApp if it exists
if PYQT6:
cls.qapp.exit()
elif PYSIDE6:
cls.qapp.shutdown()
cls.qapp = None
def connect_slot(
self,
slot: Callable,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
**kwargs,
) -> 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
@@ -134,13 +152,6 @@ class BECDispatcher:
self._slots[slot].update(set(topics_str))
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
"""
Disconnect a slot from a topic.
Args:
slot(Callable): The slot to disconnect
topics(Union[str, list]): The topic(s) to disconnect from
"""
# find the right slot to disconnect from ;
# slot callbacks are wrapped in QtThreadSafeCallback objects,
# but the slot we receive here is the original callable
@@ -151,17 +162,11 @@ class BECDispatcher:
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]
self._slots[slot].difference_update(set(topics_str))
if not self._slots[slot]:
del self._slots[slot]
def disconnect_topics(self, topics: Union[str, list]):
"""
Disconnect all slots from a topic.
Args:
topics(Union[str, list]): The topic(s) to disconnect from
"""
self.client.connector.unregister(topics)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
for slot in list(self._slots.keys()):
@@ -171,11 +176,4 @@ class BECDispatcher:
del self._slots[slot]
def disconnect_all(self, *args, **kwargs):
"""
Disconnect all slots from all topics.
Args:
*args: Arbitrary positional arguments
**kwargs: Arbitrary keyword arguments
"""
self.disconnect_topics(self.client.connector._topics_cb)

View File

@@ -1,54 +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 Signal, Slot
class BECSignalProxy(SignalProxy):
"""Thin wrapper around the SignalProxy class to allow signal calls to be blocked, but args still being stored
Args:
*args: Arguments to pass to the SignalProxy class
rateLimit (int): The rateLimit of the proxy
**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, **kwargs):
super().__init__(*args, rateLimit=rateLimit, **kwargs)
self._blocking = False
self.old_args = None
self.new_args = None
@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)
@Slot()
def unblock_proxy(self):
"""Unblock the proxy, and call the signalReceived method in case there was an update of the args."""
self.blocked = False
if self.new_args != self.old_args:
self.signalReceived(*self.new_args)

View File

@@ -1,96 +1,8 @@
from __future__ import annotations
import darkdetect
from bec_lib.logger import bec_logger
from qtpy.QtCore import Slot
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import set_theme
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"
def __init__(
self,
client=None,
config: ConnectionConfig = None,
gui_id: str = None,
theme_update: bool = False,
):
"""
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.
"""
if not isinstance(self, QWidget):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
super().__init__(client=client, config=config, gui_id=gui_id)
# Set the theme to auto if it is not set yet
app = QApplication.instance()
if not hasattr(app, "theme"):
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
# 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):
"""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."""
class BECWidget:
"""Base class for all BEC widgets."""
def closeEvent(self, event):
self.rpc_register.remove_rpc(self)
try:
if hasattr(self, "cleanup"):
self.cleanup()
finally:
if hasattr(super(), "closeEvent"):
super().closeEvent(event)

View File

@@ -1,88 +1,36 @@
from __future__ import annotations
import itertools
import re
from typing import TYPE_CHECKING, Literal
from typing import Literal
import bec_qthemes
import numpy as np
import pyqtgraph as pg
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
import qdarkstyle
from pydantic_core import PydanticCustomError
from qdarkstyle import DarkPalette, LightPalette
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
if TYPE_CHECKING:
from bec_qthemes._main import AccentColors
CURRENT_THEME = "dark"
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)
return DarkPalette if CURRENT_THEME == "dark" else LightPalette
def apply_theme(theme: Literal["dark", "light"]):
"""
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
"""
global CURRENT_THEME
CURRENT_THEME = theme
app = QApplication.instance()
# go through all pyqtgraph widgets and set background
children = itertools.chain.from_iterable(
top.findChildren(pg.GraphicsLayoutWidget) for top in app.topLevelWidgets()
)
pg.setConfigOptions(
foreground="d" if theme == "dark" else "k", background="k" if theme == "dark" else "w"
)
for pg_widget in children:
pg_widget.setBackground("k" if theme == "dark" else "w")
# now define stylesheet according to theme and apply it
style = bec_qthemes.load_stylesheet(theme)
style = qdarkstyle.load_stylesheet(palette=get_theme_palette())
app.setStyleSheet(style)
@@ -107,98 +55,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.
@@ -207,39 +66,33 @@ class Colors:
colormap (str): Name of the colormap.
num (int): Number of requested colors.
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
Returns:
list: List of colors in the specified format.
Raises:
ValueError: If theme_offset is not between 0 and 1.
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
"""
cmap = pg.colormap.get(colormap)
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
# Generate positions within the acceptable range
positions = np.mod(np.arange(num) * golden_angle_conjugate, 1)
positions = min_pos + positions * (max_pos - min_pos)
# Sample colors from the colormap at the calculated positions
colors = cmap.map(positions, mode="float")
color_list = []
for color in colors:
cmap_colors = cmap.getColors(mode="float")
if num > len(cmap_colors):
raise ValueError(
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
)
angles = Colors.golden_ratio(len(cmap_colors))
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
colors = []
for ii in color_selection[:num]:
color = cmap_colors[int(ii)]
if format.upper() == "HEX":
color_list.append(QColor.fromRgbF(*color).name())
colors.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
color_list.append(tuple((np.array(color) * 255).astype(int)))
colors.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR":
color_list.append(QColor.fromRgbF(*color))
colors.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
return color_list
return colors
@staticmethod
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
@@ -468,7 +321,7 @@ class Colors:
return color
@staticmethod
def validate_color_map(color_map: str, return_error: bool = True) -> str | bool:
def validate_color_map(color_map: str) -> str:
"""
Validate the colormap input if it is supported by pyqtgraph. Can be used in any pydantic model as a field validator. If validation fails it prints all available colormaps from pyqtgraph instance.
@@ -476,24 +329,13 @@ class Colors:
color_map(str): The colormap to be validated.
Returns:
str: The validated colormap, if colormap is valid.
bool: False, if colormap is invalid.
Raises:
PydanticCustomError: If colormap is invalid.
str: The validated colormap.
"""
available_pg_maps = pg.colormap.listMaps()
available_mpl_maps = pg.colormap.listMaps("matplotlib")
available_mpl_colorcet = pg.colormap.listMaps("colorcet")
available_colormaps = available_pg_maps + available_mpl_maps + available_mpl_colorcet
available_colormaps = pg.colormap.listMaps()
if color_map not in available_colormaps:
if return_error:
raise PydanticCustomError(
"unsupported colormap",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
{"wrong_value": color_map},
)
else:
return False
raise PydanticCustomError(
"unsupported colormap",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
{"wrong_value": color_map},
)
return color_map

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import itertools
from typing import Type

View File

@@ -1,35 +1,18 @@
from collections import defaultdict
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication
class CrosshairScatterItem(pg.ScatterPlotItem):
def setDownsampling(self, ds=None, auto=None, method=None):
pass
def setClipToView(self, state):
pass
def setAlpha(self, *args, **kwargs):
pass
# from qtpy.QtCore import QObject, pyqtSignal
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
class Crosshair(QObject):
# QT Position of mouse cursor
positionChanged = Signal(tuple)
positionClicked = Signal(tuple)
# Plain crosshair position signals mapped to real coordinates
crosshairChanged = Signal(tuple)
crosshairClicked = Signal(tuple)
# Signal for 1D plot
coordinatesChanged1D = Signal(tuple)
coordinatesClicked1D = Signal(tuple)
coordinatesChanged1D = pyqtSignal(tuple)
coordinatesClicked1D = pyqtSignal(tuple)
# Signal for 2D plot
coordinatesChanged2D = Signal(tuple)
coordinatesClicked2D = Signal(tuple)
coordinatesChanged2D = pyqtSignal(tuple)
coordinatesClicked2D = pyqtSignal(tuple)
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
"""
@@ -43,184 +26,85 @@ class Crosshair(QObject):
super().__init__(parent)
self.is_log_y = None
self.is_log_x = None
self.is_derivative = None
self.plot_item = plot_item
self.precision = precision
self.v_line = pg.InfiniteLine(angle=90, movable=False)
self.v_line.skip_auto_range = True
self.h_line = pg.InfiniteLine(angle=0, movable=False)
self.h_line.skip_auto_range = True
# Add custom attribute to identify crosshair lines
self.v_line.is_crosshair = True
self.h_line.is_crosshair = True
self.plot_item.addItem(self.v_line, ignoreBounds=True)
self.plot_item.addItem(self.h_line, ignoreBounds=True)
# Initialize highlighted curve in a case of multiple curves
self.highlighted_curve_index = None
# Add TextItem to display coordinates
self.coord_label = pg.TextItem("", anchor=(1, 1), fill=(0, 0, 0, 100))
self.coord_label.setVisible(False) # Hide initially
self.coord_label.skip_auto_range = True
self.plot_item.addItem(self.coord_label)
# Signals to connect
self.proxy = pg.SignalProxy(
self.plot_item.scene().sigMouseMoved, rateLimit=60, slot=self.mouse_moved
)
self.positionChanged.connect(self.update_coord_label)
self.plot_item.scene().sigMouseClicked.connect(self.mouse_clicked)
# Connect signals from pyqtgraph right click menu
self.plot_item.ctrl.derivativeCheck.checkStateChanged.connect(self.check_derivatives)
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.downsampleSpin.valueChanged.connect(self.clear_markers)
# Initialize markers
self.items = []
self.marker_moved_1d = {}
self.marker_clicked_1d = {}
self.marker_moved_1d = []
self.marker_clicked_1d = []
self.marker_2d = None
self.update_markers()
self.check_log()
self.check_derivatives()
self._connect_to_theme_change()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
self._update_theme()
@Slot(str)
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
theme = qapp.theme.theme
else:
theme = "dark"
self.apply_theme(theme)
def apply_theme(self, theme: str):
"""Apply the theme to the plot."""
if theme == "dark":
text_color = "w"
label_bg_color = (50, 50, 50, 150)
elif theme == "light":
text_color = "k"
label_bg_color = (240, 240, 240, 150)
else:
text_color = "w"
label_bg_color = (50, 50, 50, 150)
self.coord_label.setColor(text_color)
self.coord_label.fill = pg.mkBrush(label_bg_color)
self.coord_label.border = pg.mkPen(None)
@Slot(int)
def update_highlighted_curve(self, curve_index: int):
"""
Update the highlighted curve in the case of multiple curves in a plot item.
Args:
curve_index(int): The index of curve to highlight
"""
self.highlighted_curve_index = curve_index
self.clear_markers()
self.update_markers()
def update_markers(self):
"""Update the markers for the crosshair, creating new ones if necessary."""
if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"):
# Focus on the highlighted curve only
self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]]
else:
# Handle all curves
self.items = self.plot_item.items
# Clear existing markers
for marker in self.marker_moved_1d + self.marker_clicked_1d:
self.plot_item.removeItem(marker)
if self.marker_2d:
self.plot_item.removeItem(self.marker_2d)
# Create or update markers
for item in self.items:
# Create new markers
self.marker_moved_1d = []
self.marker_clicked_1d = []
self.marker_2d = None
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem): # 1D plot
pen = item.opts["pen"]
color = pen.color() if hasattr(pen, "color") else pg.mkColor(pen)
name = item.name() or str(id(item))
if name in self.marker_moved_1d:
# Update existing markers
marker_moved = self.marker_moved_1d[name]
marker_moved.setPen(pg.mkPen(color))
# Update clicked markers' brushes
for marker_clicked in self.marker_clicked_1d[name]:
alpha = marker_clicked.opts["brush"].color().alpha()
marker_clicked.setBrush(
pg.mkBrush(color.red(), color.green(), color.blue(), alpha)
)
# Update z-values
marker_moved.setZValue(item.zValue() + 1)
for marker_clicked in self.marker_clicked_1d[name]:
marker_clicked.setZValue(item.zValue() + 1)
else:
# Create new markers
marker_moved = CrosshairScatterItem(
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
marker_moved = pg.ScatterPlotItem(
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
)
marker_clicked = pg.ScatterPlotItem(
size=10, pen=pg.mkPen(None), brush=pg.mkBrush(color)
)
self.marker_moved_1d.append(marker_moved)
self.plot_item.addItem(marker_moved)
# Create glowing effect markers for clicked events
marker_clicked_list = []
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
marker_clicked = pg.ScatterPlotItem(
size=size,
pen=pg.mkPen(None),
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
)
marker_moved.skip_auto_range = True
marker_moved.is_crosshair = True
self.marker_moved_1d[name] = marker_moved
self.plot_item.addItem(marker_moved)
# Set marker z-value higher than the curve
marker_moved.setZValue(item.zValue() + 1)
marker_clicked_list.append(marker_clicked)
self.plot_item.addItem(marker_clicked)
# Create glowing effect markers for clicked events
marker_clicked_list = []
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
marker_clicked = CrosshairScatterItem(
size=size,
pen=pg.mkPen(None),
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
)
marker_clicked.skip_auto_range = True
marker_clicked.is_crosshair = True
self.plot_item.addItem(marker_clicked)
marker_clicked.setZValue(item.zValue() + 1)
marker_clicked_list.append(marker_clicked)
self.marker_clicked_1d[name] = marker_clicked_list
self.marker_clicked_1d.append(marker_clicked_list)
elif isinstance(item, pg.ImageItem): # 2D plot
if self.marker_2d is not None:
continue
self.marker_2d = pg.ROI(
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
)
self.plot_item.addItem(self.marker_2d)
def snap_to_data(self, x, y) -> tuple[defaultdict[list], defaultdict[list]]:
def snap_to_data(self, x, y) -> tuple:
"""
Finds the nearest data points to the given x and y coordinates.
Args:
x: The x-coordinate of the mouse cursor
y: 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])
@@ -228,56 +112,43 @@ class Crosshair(QObject):
min_x_data = np.min(x_data)
max_x_data = np.max(x_data)
if x < min_x_data or x > max_x_data:
y_values[name] = None
x_values[name] = None
continue
return None, None
closest_x, closest_y = self.closest_x_y_value(x, x_data, y_data)
y_values[name] = closest_y
x_values[name] = closest_x
y_values_1d.append(closest_y)
x_values_1d.append(closest_x)
elif isinstance(item, pg.ImageItem): # 2D plot
name = item.config.monitor
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.
@@ -285,50 +156,39 @@ class Crosshair(QObject):
Args:
event: The mouse moved event
"""
self.check_log()
pos = event[0]
self.update_markers()
if self.plot_item.vb.sceneBoundingRect().contains(pos):
mouse_point = self.plot_item.vb.mapSceneToView(pos)
self.v_line.setPos(mouse_point.x())
self.h_line.setPos(mouse_point.y())
x, y = mouse_point.x(), mouse_point.y()
self.v_line.setPos(x)
self.h_line.setPos(y)
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
self.crosshairChanged.emit((scaled_x, scaled_y))
self.positionChanged.emit((x, y))
if self.is_log_x:
x = 10**x
if self.is_log_y:
y = 10**y
x, y_values = self.snap_to_data(x, y)
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
return
if all(v is None for v in x_snap_values.values()) or all(
v is None for v in y_snap_values.values()
):
# not sure how we got here, but just to be safe...
return
for item in self.items:
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_moved_1d[name].setData([x], [y])
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
if x is None or all(v is None for v in y_values):
return
coordinate_to_emit = (
name,
round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, self.precision),
round(x, self.precision),
[round(y_val, self.precision) for y_val in y_values],
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
for i, y_val in enumerate(y_values):
self.marker_moved_1d[i].setData(
[x if not self.is_log_x else np.log10(x)],
[y_val if not self.is_log_y else np.log10(y_val)],
)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_2d.setPos([x, y])
coordinate_to_emit = (name, x, y)
if x is None or y_values is None:
return
coordinate_to_emit = (x, y_values)
self.coordinatesChanged2D.emit(coordinate_to_emit)
else:
continue
def mouse_clicked(self, event):
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
@@ -336,108 +196,40 @@ class Crosshair(QObject):
Args:
event: The mouse clicked event
"""
# we only accept left mouse clicks
if event.button() != Qt.MouseButton.LeftButton:
return
self.update_markers()
self.check_log()
if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos):
mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos)
x, y = mouse_point.x(), mouse_point.y()
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
self.crosshairClicked.emit((scaled_x, scaled_y))
self.positionClicked.emit((x, y))
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if self.is_log_x:
x = 10**x
if self.is_log_y:
y = 10**y
x, y_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
return
if all(v is None for v in x_snap_values.values()) or all(
v is None for v in y_snap_values.values()
):
# not sure how we got here, but just to be safe...
return
for item in self.items:
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
for marker_clicked in self.marker_clicked_1d[name]:
marker_clicked.setData([x], [y])
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
if x is None or all(v is None for v in y_values):
return
coordinate_to_emit = (
name,
round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, self.precision),
round(x, self.precision),
[round(y_val, self.precision) for y_val in y_values],
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
for i, y_val in enumerate(y_values):
for marker in self.marker_clicked_1d[i]:
marker.setData(
[x if not self.is_log_x else np.log10(x)],
[y_val if not self.is_log_y else np.log10(y_val)],
)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_2d.setPos([x, y])
coordinate_to_emit = (name, x, y)
if x is None or y_values is None:
return
coordinate_to_emit = (x, y_values)
self.coordinatesClicked2D.emit(coordinate_to_emit)
else:
continue
def clear_markers(self):
"""Clears the markers from the plot."""
for marker in self.marker_moved_1d.values():
self.plot_item.removeItem(marker)
for markers in self.marker_clicked_1d.values():
for marker in markers:
self.plot_item.removeItem(marker)
self.marker_moved_1d.clear()
self.marker_clicked_1d.clear()
def scale_emitted_coordinates(self, x, y):
"""Scales the emitted coordinates if the axes are in log scale.
Args:
x (float): The x-coordinate
y (float): The y-coordinate
Returns:
tuple: The scaled x and y coordinates
"""
if self.is_log_x:
x = 10**x
if self.is_log_y:
y = 10**y
return x, y
def update_coord_label(self, pos: tuple):
"""Updates the coordinate label based on the crosshair position and axis scales.
Args:
pos (tuple): The (x, y) position of the crosshair.
"""
x, y = pos
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
# Update coordinate label
self.coord_label.setText(f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})")
self.coord_label.setPos(x, y)
self.coord_label.setVisible(True)
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):
self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label)
self.clear_markers()
self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked()
self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked()

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import re
from qtpy.QtCore import QObject
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock"]
class DesignerPluginInfo:
@@ -143,7 +143,7 @@ class DesignerPluginGenerator:
if __name__ == "__main__": # pragma: no cover
# from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.utility.spinner import SpinnerWidget
from bec_widgets.widgets.spinner.spinner import SpinnerWidget
generator = DesignerPluginGenerator(SpinnerWidget)
generator.run(validate=False)

View File

@@ -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()

View File

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

View File

@@ -1,247 +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()
if self.tick_item is not None:
self.tick_item.close()
self.tick_item.deleteLater()
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(parent=parent)
self.arrow_item.skip_auto_range = True
self._pos = (0, 0)
self.arrow_item.setVisible(False)
@Slot(dict)
def set_style(self, style: dict) -> None:
"""Set the style of the arrow item
Args:
style (dict): The style of the arrow item. Dictionary with key,
value pairs which are accepted from the pg.ArrowItem.setStyle method.
"""
self.arrow_item.setStyle(**style)
@Slot(tuple)
def set_position(self, pos: tuple[float, float]) -> None:
"""Set the position of the arrow item
Args:
pos (tuple): The position of the arrow item as a tuple (x, y).
"""
self._pos = pos
pos_x = pos[0]
pos_y = pos[1]
if self.is_log_x is True:
pos_x = np.log10(pos_x) if pos_x > 0 else 1e-10
view_box = self.plot_item.getViewBox() # Ensure you're accessing the correct view box
view_range = view_box.viewRange()[0]
# Avoid values outside the view range in the negative direction. Otherwise, there is
# a buggy behaviour of the arrow item and it appears at the wrong position.
if pos_x < view_range[0]:
pos_x = view_range[0]
if self.is_log_y is True:
pos_y = np.log10(pos_y) if pos_y > 0 else 1e-10
self.arrow_item.setPos(pos_x, pos_y)
self.position_changed.emit(self._pos)
self.position_changed_str.emit((str(self._pos[0]), str(self._pos[1])))
def add_to_plot(self):
"""Add the arrow item to the view box or plot item."""
if not self.arrow_item:
logger.warning(f"Arrow item was already destroyed, cannot be created")
return
self.arrow_item.setStyle(
angle=-90,
pen=pg.mkPen(self.accent_colors.emergency, width=1),
brush=pg.mkBrush(self.accent_colors.highlight),
headLen=20,
)
self.arrow_item.setVisible(True)
if self.plot_item is not None:
self.plot_item.addItem(self.arrow_item)
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
self.item_on_plot = True
def remove_from_plot(self):
"""Remove the arrow item from the view box or plot item."""
if self.plot_item is not None and self.item_on_plot is True:
self.plot_item.ctrl.logXCheck.checkStateChanged.disconnect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.disconnect(self.check_log)
self.plot_item.removeItem(self.arrow_item)
self.item_on_plot = False
def cleanup(self) -> None:
"""Cleanup the item"""
self.remove_from_plot()
self.arrow_item = None

View File

@@ -2,8 +2,8 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from bec_widgets.utils.bec_designer import designer_material_icon
{widget_import}
DOM_XML = """
@@ -30,7 +30,7 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
return ""
def icon(self):
return designer_material_icon({plugin_name_pascal}.ICON_NAME)
return QIcon()
def includeFile(self):
return "{plugin_name_snake}"

View File

@@ -53,7 +53,7 @@ class BECClassInfo:
obj: type
is_connector: bool = False
is_widget: bool = False
is_plugin: bool = False
is_top_level: bool = False
class BECClassContainer:
@@ -88,14 +88,14 @@ class BECClassContainer:
"""
Get all top-level classes.
"""
return [info.obj for info in self.collection if info.is_plugin]
return [info.obj for info in self.collection if info.is_top_level]
@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]
return [info.obj for info in self.collection if info.is_widget and info.is_top_level]
@property
def widgets(self):
@@ -109,17 +109,10 @@ class BECClassContainer:
"""
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]
return [info.obj for info in self.collection if info.is_top_level and info.is_connector]
def get_custom_classes(repo_name: str) -> BECClassContainer:
def get_rpc_classes(repo_name: str) -> BECClassContainer:
"""
Get all RPC-enabled classes in the specified repository.
@@ -160,8 +153,6 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
class_info.is_top_level = True
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
class_info.is_plugin = True
collection.add_class(class_info)
return collection

View File

@@ -4,7 +4,7 @@ from qtpy import PYQT6, PYSIDE6, QT_VERSION
from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_custom_classes
from bec_widgets.utils.plugin_utils import get_rpc_classes
if PYSIDE6:
from PySide6.QtUiTools import QUiLoader
@@ -30,7 +30,7 @@ class UILoader:
def __init__(self, parent=None):
self.parent = parent
widgets = get_custom_classes("bec_widgets").classes
widgets = get_rpc_classes("bec_widgets").top_level_classes
self.custom_widgets = {widget.__name__: widget for widget in widgets}

View File

@@ -1,6 +1,5 @@
# pylint: disable=no-name-in-module
from abc import ABC, abstractmethod
from typing import Literal
from qtpy.QtWidgets import (
QApplication,
@@ -21,7 +20,7 @@ class WidgetHandler(ABC):
"""Abstract base class for all widget handlers."""
@abstractmethod
def get_value(self, widget: QWidget, **kwargs):
def get_value(self, widget: QWidget):
"""Retrieve value from the widget instance."""
@abstractmethod
@@ -32,7 +31,7 @@ class WidgetHandler(ABC):
class LineEditHandler(WidgetHandler):
"""Handler for QLineEdit widgets."""
def get_value(self, widget: QLineEdit, **kwargs) -> str:
def get_value(self, widget: QLineEdit) -> str:
return widget.text()
def set_value(self, widget: QLineEdit, value: str) -> None:
@@ -42,9 +41,7 @@ class LineEditHandler(WidgetHandler):
class ComboBoxHandler(WidgetHandler):
"""Handler for QComboBox widgets."""
def get_value(self, widget: QComboBox, as_string: bool = False, **kwargs) -> int | str:
if as_string is True:
return widget.currentText()
def get_value(self, widget: QComboBox) -> int:
return widget.currentIndex()
def set_value(self, widget: QComboBox, value: int | str) -> None:
@@ -57,7 +54,7 @@ class ComboBoxHandler(WidgetHandler):
class TableWidgetHandler(WidgetHandler):
"""Handler for QTableWidget widgets."""
def get_value(self, widget: QTableWidget, **kwargs) -> list:
def get_value(self, widget: QTableWidget) -> list:
return [
[
widget.item(row, col).text() if widget.item(row, col) else ""
@@ -76,7 +73,7 @@ class TableWidgetHandler(WidgetHandler):
class SpinBoxHandler(WidgetHandler):
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
def get_value(self, widget, **kwargs):
def get_value(self, widget):
return widget.value()
def set_value(self, widget, value):
@@ -86,7 +83,7 @@ class SpinBoxHandler(WidgetHandler):
class CheckBoxHandler(WidgetHandler):
"""Handler for QCheckBox widgets."""
def get_value(self, widget, **kwargs):
def get_value(self, widget):
return widget.isChecked()
def set_value(self, widget, value):
@@ -96,7 +93,7 @@ class CheckBoxHandler(WidgetHandler):
class LabelHandler(WidgetHandler):
"""Handler for QLabel widgets."""
def get_value(self, widget, **kwargs):
def get_value(self, widget):
return widget.text()
def set_value(self, widget, value):
@@ -117,7 +114,7 @@ class WidgetIO:
}
@staticmethod
def get_value(widget, ignore_errors=False, **kwargs):
def get_value(widget, ignore_errors=False):
"""
Retrieve value from the widget instance.
@@ -127,7 +124,7 @@ class WidgetIO:
"""
handler_class = WidgetIO._find_handler(widget)
if handler_class:
return handler_class().get_value(widget, **kwargs) # Instantiate the handler
return handler_class().get_value(widget) # Instantiate the handler
if not ignore_errors:
raise ValueError(f"No handler for widget type: {type(widget)}")
return None

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
from bec_widgets.utils import BECConnector, ConnectionConfig
class DeviceInputConfig(ConnectionConfig):
device_filter: str | list[str] | None = None
default: str | None = None
arg_name: str | None = None
class DeviceInputBase(BECConnector):
"""
Mixin class for device input widgets. This class provides methods to get the device list and device object based
on the current text of the widget.
"""
def __init__(self, client=None, config=None, gui_id=None):
if config is None:
config = DeviceInputConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceInputConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
self.get_bec_shortcuts()
self._device_filter = None
self._devices = []
@property
def devices(self) -> list[str]:
"""
Get the list of devices.
Returns:
list[str]: List of devices.
"""
return self._devices
@devices.setter
def devices(self, value: list[str]):
"""
Set the list of devices.
Args:
value: List of devices.
"""
self._devices = value
def set_device_filter(self, device_filter: str | list[str]):
"""
Set the device filter.
Args:
device_filter(str): Device filter, name of the device class.
"""
self.validate_device_filter(device_filter)
self.config.device_filter = device_filter
self._device_filter = device_filter
def set_default_device(self, default_device: str):
"""
Set the default device.
Args:
default_device(str): Default device name.
"""
self.validate_device(default_device)
self.config.default = default_device
def get_device_list(self, filter: str | list[str] | None = None) -> list[str]:
"""
Get the list of device names based on the filter of current BEC client.
Args:
filter(str|None): Class name filter to apply on the device list.
Returns:
devices(list[str]): List of device names.
"""
all_devices = self.dev.enabled_devices
if filter is not None:
self.validate_device_filter(filter)
if isinstance(filter, str):
filter = [filter]
devices = [device.name for device in all_devices if device.__class__.__name__ in filter]
else:
devices = [device.name for device in all_devices]
return devices
def get_available_filters(self):
"""
Get the available device classes which can be used as filters.
"""
all_devices = self.dev.enabled_devices
filters = {device.__class__.__name__ for device in all_devices}
return filters
def validate_device_filter(self, filter: str | list[str]) -> None:
"""
Validate the device filter if the class name is present in the current BEC instance.
Args:
filter(str|list[str]): Class name to use as a device filter.
"""
if isinstance(filter, str):
filter = [filter]
available_filters = self.get_available_filters()
for f in filter:
if f not in available_filters:
raise ValueError(f"Device filter {f} is not valid.")
def validate_device(self, device: str) -> None:
"""
Validate the device if it is present in current BEC instance.
Args:
device(str): Device to validate.
"""
if device not in self.get_device_list(self.config.device_filter):
raise ValueError(f"Device {device} is not valid.")
def cleanup(self):
super().cleanup()

View File

@@ -0,0 +1,111 @@
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import Qt, Slot
from qtpy.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
class BECQueue(BECConnector, QTableWidget):
"""
Widget to display the BEC queue.
"""
def __init__(
self,
parent: QWidget | None = None,
client=None,
config: ConnectionConfig = None,
gui_id: str = None,
):
super().__init__(client, config, gui_id)
QTableWidget.__init__(self, parent=parent)
self.setColumnCount(3)
self.setHorizontalHeaderLabels(["Scan Number", "Type", "Status"])
header = self.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Stretch)
self.bec_dispatcher.connect_slot(self.update_queue, MessageEndpoints.scan_queue_status())
self.reset_content()
@Slot(dict, dict)
def update_queue(self, content, _metadata):
"""
Update the queue table with the latest queue information.
Args:
content (dict): The queue content.
_metadata (dict): The metadata.
"""
# only show the primary queue for now
queue_info = content.get("queue", {}).get("primary", {}).get("info", [])
self.setRowCount(len(queue_info))
self.clearContents()
if not queue_info:
self.reset_content()
return
for index, item in enumerate(queue_info):
blocks = item.get("request_blocks", [])
scan_types = []
scan_numbers = []
status = item.get("status", "")
for request_block in blocks:
scan_type = request_block.get("content", {}).get("scan_type", "")
if scan_type:
scan_types.append(scan_type)
scan_number = request_block.get("scan_number", "")
if scan_number:
scan_numbers.append(str(scan_number))
if scan_types:
scan_types = ", ".join(scan_types)
if scan_numbers:
scan_numbers = ", ".join(scan_numbers)
self.set_row(index, scan_numbers, scan_types, status)
def format_item(self, content: str) -> QTableWidgetItem:
"""
Format the content of the table item.
Args:
content (str): The content to be formatted.
Returns:
QTableWidgetItem: The formatted item.
"""
item = QTableWidgetItem(content)
item.setTextAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
return item
def set_row(self, index: int, scan_number: str, scan_type: str, status: str):
"""
Set the row of the table.
Args:
index (int): The index of the row.
scan_number (str): The scan number.
scan_type (str): The scan type.
status (str): The status.
"""
self.setItem(index, 0, self.format_item(scan_number))
self.setItem(index, 1, self.format_item(scan_type))
self.setItem(index, 2, self.format_item(status))
def reset_content(self):
"""
Reset the content of the table.
"""
self.setRowCount(1)
self.set_row(0, "", "", "")
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = BECQueue()
widget.show()
sys.exit(app.exec_())

View File

@@ -1,12 +1,10 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
DOM_XML = """
<ui language='c++'>
@@ -15,8 +13,6 @@ DOM_XML = """
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECQueuePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -31,10 +27,10 @@ class BECQueuePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Services"
return ""
def icon(self):
return designer_material_icon(BECQueue.ICON_NAME)
return QIcon()
def includeFile(self):
return "bec_queue"

View File

@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.services.bec_queue.bec_queue_plugin import BECQueuePlugin
from bec_widgets.widgets.bec_queue.bec_queue_plugin import BECQueuePlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECQueuePlugin())

View File

@@ -4,17 +4,18 @@ The widget automatically updates the status of all running BEC services, and dis
from __future__ import annotations
import sys
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING
from bec_lib.utils.import_utils import lazy_import_from
from qtpy.QtCore import QObject, QTimer, Signal, Slot
from qtpy.QtWidgets import QHBoxLayout, QTreeWidget, QTreeWidgetItem
from qtpy.QtWidgets import QHBoxLayout, QTreeWidget, QTreeWidgetItem, QWidget
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.services.bec_status_box.status_item import StatusItem
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.bec_status_box.status_item import StatusItem
if TYPE_CHECKING:
from bec_lib.client import BECClient
@@ -42,8 +43,6 @@ class BECServiceStatusMixin(QObject):
services_update = Signal(dict, dict)
ICON_NAME = "dns"
def __init__(self, parent, client: BECClient):
super().__init__(parent)
self.client = client
@@ -57,13 +56,8 @@ class BECServiceStatusMixin(QObject):
self.client._update_existing_services()
self.services_update.emit(self.client._services_info, self.client._services_metric)
def cleanup(self):
"""Cleanup the BECServiceStatusMixin."""
self._service_update_timer.stop()
self._service_update_timer.deleteLater()
class BECStatusBox(BECWidget, CompactPopupWidget):
class BECStatusBox(BECConnector, QWidget):
"""An autonomous widget to display the status of BEC services.
Args:
@@ -74,7 +68,6 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
gui_id Optional(str): The unique id for the widget. Defaults to None.
"""
PLUGIN = True
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
service_update = Signal(BECServiceInfoContainer)
@@ -83,13 +76,15 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
def __init__(
self,
parent=None,
box_name: str = "BEC Servers",
box_name: str = "BEC Server",
client: BECClient = None,
bec_service_status_mixin: BECServiceStatusMixin = None,
gui_id: str = None,
):
super().__init__(client=client, gui_id=gui_id)
CompactPopupWidget.__init__(self, parent=parent, layout=QHBoxLayout)
QWidget.__init__(self, parent=parent)
self.tree = QTreeWidget(self)
self.layout = QHBoxLayout(self)
self.box_name = box_name
self.status_container = defaultdict(lambda: {"info": None, "item": None, "widget": None})
@@ -98,13 +93,11 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
bec_service_status_mixin = BECServiceStatusMixin(self, client=self.client)
self.bec_service_status = bec_service_status_mixin
self.label = box_name
self.tooltip = "BEC servers health status"
self.init_ui()
self.bec_service_status.services_update.connect(self.update_service_status)
self.bec_core_state.connect(self.update_top_item_status)
self.tree.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
self.addWidget(self.tree)
self.layout.addWidget(self.tree)
def init_ui(self) -> None:
"""Init the UI for the BECStatusBox widget, should only take place once."""
@@ -121,44 +114,18 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
def init_ui_tree_widget(self) -> None:
"""Initialise the tree widget for the status box."""
self.tree = QTreeWidget(self)
self.tree.setHeaderHidden(True)
# TODO probably here is a problem still with setting the stylesheet
# self.tree.setStyleSheet(
# "QTreeWidget::item:!selected "
# "{ "
# "border: 1px solid gainsboro; "
# "border-left: none; "
# "border-top: none; "
# "}"
# "QTreeWidget::item:selected {}"
# )
self.tree.setStyleSheet(
"QTreeWidget::item:!selected { "
"QTreeWidget::item:!selected "
"{ "
"border: 1px solid gainsboro; "
"border-left: none; "
"border-top: none; "
"} "
"QTreeWidget::item:selected {} "
"QTreeView::branch { "
"border-image: none; "
"background: transparent; "
"} "
"QTreeView::branch:has-siblings:!adjoins-item { "
"border-image: none; "
"} "
"QTreeView::branch:has-children:!has-siblings:closed, "
"QTreeView::branch:closed:has-children:has-siblings { "
"border-image: none; "
"} "
"QTreeView::branch:open:has-children:!has-siblings, "
"QTreeView::branch:open:has-children:has-siblings { "
"border-image: none; "
"}"
"QTreeWidget::item:selected {}"
)
# self.tree.setRootIsDecorated(False)
def _create_status_widget(
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
) -> StatusItem:
@@ -189,7 +156,6 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
status (BECStatus): The state of the core services.
"""
self.status_container[self.box_name]["info"].status = status
self.set_global_state("emergency" if status == "NOTCONNECTED" else "success")
self.service_update.emit(self.status_container[self.box_name]["info"])
def _update_status_container(
@@ -324,21 +290,27 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
if objects["item"] == item:
objects["widget"].show_popup()
def cleanup(self):
"""Cleanup the BECStatusBox widget."""
self.bec_service_status.cleanup()
return super().cleanup()
def closeEvent(self, event):
"""Upon closing the widget, clean up the BECStatusBox and the QWidget.
Args:
event: The close event.
"""
super().cleanup()
super().closeEvent(event)
if __name__ == "__main__": # pragma: no cover
import sys
def main():
"""Main method to run the BECStatusBox widget."""
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
app = QApplication(sys.argv)
set_theme("dark")
apply_theme("dark")
main_window = BECStatusBox()
main_window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@@ -1,12 +1,10 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.bec_status_box.bec_status_box import BECStatusBox
DOM_XML = """
<ui language='c++'>
@@ -15,8 +13,6 @@ DOM_XML = """
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECStatusBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -31,10 +27,10 @@ class BECStatusBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Services"
return ""
def icon(self):
return designer_material_icon(BECStatusBox.ICON_NAME)
return QIcon()
def includeFile(self):
return "bec_status_box"

View File

@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.services.bec_status_box.bec_status_box_plugin import BECStatusBoxPlugin
from bec_widgets.widgets.bec_status_box.bec_status_box_plugin import BECStatusBoxPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECStatusBoxPlugin())

View File

@@ -2,32 +2,24 @@
The widget is bound to be used with the BECStatusBox widget."""
import enum
import os
from datetime import datetime
from bec_lib.utils.import_utils import lazy_import_from
from bec_qthemes import material_icon
from qtpy.QtCore import Qt, Slot
from qtpy.QtGui import QIcon, QPainter
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QVBoxLayout, QWidget
import bec_widgets
from bec_widgets.utils.colors import get_accent_colors
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QStyle, QVBoxLayout, QWidget
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class IconsEnum(enum.Enum):
"""Enum class for icons in the status item widget."""
RUNNING = "check_circle"
BUSY = "progress_activity"
IDLE = "progress_activity"
ERROR = "emergency_home"
NOTCONNECTED = "signal_disconnected"
RUNNING = "SP_DialogApplyButton"
BUSY = "SP_BrowserReload"
IDLE = "SP_MessageBoxWarning"
ERROR = "SP_DialogCancelButton"
NOTCONNECTED = "SP_TitleBarContextHelpButton"
class StatusItem(QWidget):
@@ -45,13 +37,13 @@ class StatusItem(QWidget):
raise ValueError(
"Please initialize the StatusItem with a BECServiceInfoContainer for config, received None."
)
self.accent_colors = get_accent_colors()
self.config = config
self.parent = parent
self.layout = None
self._label = None
self._icon = None
self.icon_size = (24, 24)
self._popup_label_ref = {}
self.init_ui()
@@ -99,15 +91,8 @@ class StatusItem(QWidget):
def set_status(self) -> None:
"""Set the status icon for the status item widget."""
status = self.config.status
if status in ["RUNNING", "BUSY"]:
color = self.accent_colors.success
elif status == "IDLE":
color = self.accent_colors.warning
elif status in ["ERROR", "NOTCONNECTED"]:
color = self.accent_colors.emergency
icon = QIcon(material_icon(IconsEnum[status].value, filled=True, color=color))
icon_name = IconsEnum[self.config.status].value
icon = self.style().standardIcon(getattr(QStyle.StandardPixmap, icon_name))
self._icon.setPixmap(icon.pixmap(*self.icon_size))
self._icon.setAlignment(Qt.AlignmentFlag.AlignRight)
@@ -141,7 +126,6 @@ class StatusItem(QWidget):
metrics_text = (
f"<b>SERVICE:</b> {self.config.service_name}<br><b>STATUS:</b> {self.config.status}<br>"
)
metrics_text += f"<b>BEC_LIB VERSION:</b> {self.config.info['version']}<br>"
if self.config.metrics:
for key, value in self.config.metrics.items():
if key == "create_time":

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from typing import Literal
import pyqtgraph as pg
class ColorButton(pg.ColorButton):
"""
A ColorButton that opens a dialog to select a color. Inherits from pyqtgraph.ColorButton.
Patches event loop of the ColorDialog, if opened in another QDialog.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def selectColor(self):
self.origColor = self.color()
self.colorDialog.setCurrentColor(self.color())
self.colorDialog.open()
self.colorDialog.exec()
def get_color(self, format: Literal["RGBA", "HEX"] = "RGBA") -> tuple | str:
"""
Get the color of the button in the specified format.
Args:
format(Literal["RGBA", "HEX"]): The format of the returned color.
Returns:
tuple|str: The color in the specified format.
"""
if format == "RGBA":
return self.color().getRgb()
if format == "HEX":
return self.color().name()

View File

@@ -1,10 +1,9 @@
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.visual.color_button.color_button import ColorButton
from bec_widgets.widgets.color_button.color_button import ColorButton
DOM_XML = """
<ui language='c++'>
@@ -12,7 +11,6 @@ DOM_XML = """
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class ColorButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
@@ -31,7 +29,9 @@ class ColorButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "BEC Buttons"
def icon(self):
return designer_material_icon(ColorButton.ICON_NAME)
current_path = os.path.dirname(__file__)
icon_path = os.path.join(current_path, "assets", "color_button.png")
return QIcon(icon_path)
def includeFile(self):
return "color_button"

View File

@@ -6,9 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.utility.visual.color_button.color_button_plugin import (
ColorButtonPlugin,
)
from bec_widgets.widgets.color_button.color_button_plugin import ColorButtonPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(ColorButtonPlugin())

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pyqtgraph as pg
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtGui import QColor, QFontMetrics, QImage
@@ -46,11 +44,9 @@ class ColormapSelector(QWidget):
"""
colormap_changed_signal = Signal(str)
ICON_NAME = "palette"
PLUGIN = True
def __init__(self, parent=None, default_colormaps=None):
super().__init__(parent=parent)
super().__init__(parent)
self._colormaps = []
self.initUI(default_colormaps)

View File

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

View File

@@ -3,20 +3,17 @@
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.visual.colormap_selector.colormap_selector import ColormapSelector
from bec_widgets.widgets.color_map_selector.color_map_selector import ColormapSelector
DOM_XML = """
<ui language='c++'>
<widget class='ColormapSelector' name='colormap_selector'>
<widget class='ColormapSelector' name='color_map_selector'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class ColormapSelectorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -34,10 +31,12 @@ class ColormapSelectorPlugin(QDesignerCustomWidgetInterface): # pragma: no cove
return "BEC Buttons"
def icon(self):
return designer_material_icon(ColormapSelector.ICON_NAME)
current_path = os.path.dirname(__file__)
icon_path = os.path.join(current_path, "assets", "color_map_selector_icon.png")
return QIcon(icon_path)
def includeFile(self):
return "colormap_selector"
return "color_map_selector"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -52,7 +51,7 @@ class ColormapSelectorPlugin(QDesignerCustomWidgetInterface): # pragma: no cove
return "ColormapSelector"
def toolTip(self):
return ""
return "A custom QComboBox widget for selecting colormaps."
def whatsThis(self):
return self.toolTip()

View File

@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.utility.visual.colormap_selector.colormap_selector_plugin import (
from bec_widgets.widgets.color_map_selector.color_map_selector_plugin import (
ColormapSelectorPlugin,
)

View File

@@ -0,0 +1,496 @@
"""
BECConsole is a Qt widget that runs a Bash shell. The widget can be used and
embedded like any other Qt widget.
BECConsole is powered by Pyte, a Python based terminal emulator
(https://github.com/selectel/pyte).
"""
import fcntl
import html
import os
import pty
import subprocess
import sys
import threading
import pyte
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import QSize, QSocketNotifier, Qt
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtGui import QClipboard, QTextCursor
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
ansi_colors = {
"black": "#000000",
"red": "#CD0000",
"green": "#00CD00",
"brown": "#996633", # Brown, replacing the yellow
"blue": "#0000EE",
"magenta": "#CD00CD",
"cyan": "#00CDCD",
"white": "#E5E5E5",
"brightblack": "#7F7F7F",
"brightred": "#FF0000",
"brightgreen": "#00FF00",
"brightyellow": "#FFFF00",
"brightblue": "#5C5CFF",
"brightmagenta": "#FF00FF",
"brightcyan": "#00FFFF",
"brightwhite": "#FFFFFF",
}
control_keys_mapping = {
QtCore.Qt.Key_A: b"\x01", # Ctrl-A
QtCore.Qt.Key_B: b"\x02", # Ctrl-B
QtCore.Qt.Key_C: b"\x03", # Ctrl-C
QtCore.Qt.Key_D: b"\x04", # Ctrl-D
QtCore.Qt.Key_E: b"\x05", # Ctrl-E
QtCore.Qt.Key_F: b"\x06", # Ctrl-F
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
QtCore.Qt.Key_J: b"\x0A", # Ctrl-J (Line Feed)
QtCore.Qt.Key_K: b"\x0B", # Ctrl-K (Vertical Tab)
QtCore.Qt.Key_L: b"\x0C", # Ctrl-L (Form Feed)
QtCore.Qt.Key_M: b"\x0D", # Ctrl-M (Carriage Return)
QtCore.Qt.Key_N: b"\x0E", # Ctrl-N
QtCore.Qt.Key_O: b"\x0F", # Ctrl-O
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
QtCore.Qt.Key_S: b"\x13", # Ctrl-S
QtCore.Qt.Key_T: b"\x14", # Ctrl-T
QtCore.Qt.Key_U: b"\x15", # Ctrl-U
QtCore.Qt.Key_V: b"\x16", # Ctrl-V
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
QtCore.Qt.Key_Z: b"\x1A", # Ctrl-Z
QtCore.Qt.Key_Escape: b"\x1B", # Ctrl-Escape
QtCore.Qt.Key_Backslash: b"\x1C", # Ctrl-\
QtCore.Qt.Key_Underscore: b"\x1F", # Ctrl-_
}
normal_keys_mapping = {
QtCore.Qt.Key_Return: b"\n",
QtCore.Qt.Key_Space: b" ",
QtCore.Qt.Key_Enter: b"\n",
QtCore.Qt.Key_Tab: b"\t",
QtCore.Qt.Key_Backspace: b"\x08",
QtCore.Qt.Key_Home: b"\x47",
QtCore.Qt.Key_End: b"\x4f",
QtCore.Qt.Key_Left: b"\x02",
QtCore.Qt.Key_Up: b"\x10",
QtCore.Qt.Key_Right: b"\x06",
QtCore.Qt.Key_Down: b"\x0E",
QtCore.Qt.Key_PageUp: b"\x49",
QtCore.Qt.Key_PageDown: b"\x51",
QtCore.Qt.Key_F1: b"\x1b\x31",
QtCore.Qt.Key_F2: b"\x1b\x32",
QtCore.Qt.Key_F3: b"\x1b\x33",
QtCore.Qt.Key_F4: b"\x1b\x34",
QtCore.Qt.Key_F5: b"\x1b\x35",
QtCore.Qt.Key_F6: b"\x1b\x36",
QtCore.Qt.Key_F7: b"\x1b\x37",
QtCore.Qt.Key_F8: b"\x1b\x38",
QtCore.Qt.Key_F9: b"\x1b\x39",
QtCore.Qt.Key_F10: b"\x1b\x30",
QtCore.Qt.Key_F11: b"\x45",
QtCore.Qt.Key_F12: b"\x46",
}
def QtKeyToAscii(event):
"""
Convert the Qt key event to the corresponding ASCII sequence for
the terminal. This works fine for standard alphanumerical characters, but
most other characters require terminal specific control sequences.
The conversion below works for TERM="linux" terminals.
"""
if sys.platform == "darwin":
# special case for MacOS
# /!\ Qt maps ControlModifier to CMD
# CMD-C, CMD-V for copy/paste
# CTRL-C and other modifiers -> key mapping
if event.modifiers() == QtCore.Qt.MetaModifier:
if event.key() == Qt.Key_Backspace:
return control_keys_mapping.get(Qt.Key_W)
return control_keys_mapping.get(event.key())
elif event.modifiers() == QtCore.Qt.ControlModifier:
if event.key() == Qt.Key_C:
# copy
return "copy"
elif event.key() == Qt.Key_V:
# paste
return "paste"
return None
else:
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
if event.modifiers() == QtCore.Qt.ControlModifier:
return control_keys_mapping.get(event.key())
else:
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
class Screen(pyte.HistoryScreen):
def __init__(self, stdin_fd, numColumns, numLines, historyLength):
super().__init__(numColumns, numLines, historyLength, ratio=1 / numLines)
self._fd = stdin_fd
def write_process_input(self, data):
"""Response to CPR request for example"""
os.write(self._fd, data.encode("utf-8"))
class Backend(QtCore.QObject):
"""
Poll Bash.
This class will run as a qsocketnotifier (started in ``_TerminalWidget``) and poll the
file descriptor of the Bash terminal.
"""
# Signals to communicate with ``_TerminalWidget``.
startWork = pyqtSignal()
dataReady = pyqtSignal(object)
def __init__(self, fd, numColumns, numLines):
super().__init__()
# File descriptor that connects to Bash process.
self.fd = fd
# Setup Pyte (hard coded display size for now).
self.screen = Screen(self.fd, numColumns, numLines, 10000)
self.stream = pyte.ByteStream()
self.stream.attach(self.screen)
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
self.notifier.activated.connect(self._fd_readable)
def _fd_readable(self):
"""
Poll the Bash output, run it through Pyte, and notify the main applet.
"""
# Read the shell output until the file descriptor is closed.
try:
out = os.read(self.fd, 2**16)
except OSError:
return
# Feed output into Pyte's state machine and send the new screen
# output to the GUI
self.stream.feed(out)
self.dataReady.emit(self.screen)
class BECConsole(QtWidgets.QScrollArea):
"""Container widget for the terminal text area"""
def __init__(self, parent=None, numLines=50, numColumns=125):
super().__init__(parent)
self.innerWidget = QtWidgets.QWidget(self)
QHBoxLayout(self.innerWidget)
self.innerWidget.layout().setContentsMargins(0, 0, 0, 0)
self.term = _TerminalWidget(self.innerWidget, numLines, numColumns)
self.term.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.innerWidget.layout().addWidget(self.term)
self.scroll_bar = QScrollBar(Qt.Vertical, self.term)
self.innerWidget.layout().addWidget(self.scroll_bar)
self.term.set_scroll(self.scroll_bar)
self.setWidget(self.innerWidget)
def start(self, cmd=["bec", "--nogui"], deactivate_ctrl_d=True):
self.term._cmd = cmd
self.term.start(deactivate_ctrl_d=deactivate_ctrl_d)
def push(self, text):
"""Push some text to the terminal"""
return self.term.push(text)
class _TerminalWidget(QtWidgets.QPlainTextEdit):
"""
Start ``Backend`` process and render Pyte output as text.
"""
def __init__(self, parent, numColumns=125, numLines=50, **kwargs):
super().__init__(parent)
# file descriptor to communicate with the subprocess
self.fd = None
self.backend = None
self.lock = threading.Lock()
# command to execute
self._cmd = None
# should ctrl-d be deactivated ? (prevent Python exit)
self._deactivate_ctrl_d = False
# Specify the terminal size in terms of lines and columns.
self.numLines = numLines
self.numColumns = numColumns
self.output = [""] * numLines
# Use Monospace fonts and disable line wrapping.
self.setFont(QtGui.QFont("Courier", 9))
self.setFont(QtGui.QFont("Monospace"))
self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
# Disable vertical scrollbar (we use our own, to be set via .set_scroll())
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
fmt = QtGui.QFontMetrics(self.font())
self._char_width = fmt.width("w")
self._char_height = fmt.height()
self.setCursorWidth(self._char_width)
# self.setStyleSheet("QPlainTextEdit { color: #ffff00; background-color: #303030; } ");
def start(self, deactivate_ctrl_d=False):
self._deactivate_ctrl_d = deactivate_ctrl_d
# Start the Bash process
self.fd = self.forkShell()
# Create the ``Backend`` object
self.backend = Backend(self.fd, self.numColumns, self.numLines)
self.backend.dataReady.connect(self.dataReady)
def minimumSizeHint(self):
width = self._char_width * self.numColumns
height = self._char_height * self.numLines
return QSize(width, height + 20)
def set_scroll(self, scroll):
self.scroll = scroll
self.scroll.setMinimum(0)
self.scroll.valueChanged.connect(self.scroll_value_change)
def scroll_value_change(self, value, old={"value": 0}):
if value <= old["value"]:
# scroll up
# value is number of lines from the start
nlines = old["value"] - value
# history ratio gives prev_page == 1 line
for i in range(nlines):
self.backend.screen.prev_page()
else:
# scroll down
nlines = value - old["value"]
for i in range(nlines):
self.backend.screen.next_page()
old["value"] = value
self.dataReady(self.backend.screen, reset_scroll=False)
@pyqtSlot(object)
def keyPressEvent(self, event):
"""
Redirect all keystrokes to the terminal process.
"""
# Convert the Qt key to the correct ASCII code.
if (
self._deactivate_ctrl_d
and event.modifiers() == QtCore.Qt.ControlModifier
and event.key() == QtCore.Qt.Key_D
):
return None
code = QtKeyToAscii(event)
if code == "copy":
# MacOS only: CMD-C handling
self.copy()
elif code == "paste":
# MacOS only: CMD-V handling
self._push_clipboard()
elif code is not None:
os.write(self.fd, code)
def push(self, text):
"""
Write 'text' to terminal
"""
os.write(self.fd, text.encode("utf-8"))
def contextMenuEvent(self, event):
menu = self.createStandardContextMenu()
for action in menu.actions():
# remove all actions except copy and paste
if "opy" in action.text():
# redefine text without shortcut
# since it probably clashes with control codes (like CTRL-C etc)
action.setText("Copy")
continue
if "aste" in action.text():
# redefine text without shortcut
action.setText("Paste")
# paste -> have to insert with self.push
action.triggered.connect(self._push_clipboard)
continue
menu.removeAction(action)
menu.exec_(event.globalPos())
def _push_clipboard(self):
clipboard = QApplication.instance().clipboard()
self.push(clipboard.text())
def mouseReleaseEvent(self, event):
if event.button() == Qt.MiddleButton:
# push primary selection buffer ("mouse clipboard") to terminal
clipboard = QApplication.instance().clipboard()
if clipboard.supportsSelection():
self.push(clipboard.text(QClipboard.Selection))
return None
elif event.button() == Qt.LeftButton:
# left button click
textCursor = self.textCursor()
if textCursor.selectedText():
# mouse was used to select text -> nothing to do
pass
else:
# a simple 'click', make cursor going to end
textCursor.setPosition(0)
textCursor.movePosition(
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
)
textCursor.movePosition(
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
)
self.setTextCursor(textCursor)
self.ensureCursorVisible()
return None
return super().mouseReleaseEvent(event)
def dataReady(self, screenData, reset_scroll=True):
"""
Render the new screen as text into the widget.
This method is triggered via a signal from ``Backend``.
"""
with self.lock:
# Clear the widget
self.clear()
# Prepare the HTML output
for line_no in screenData.dirty:
line = text = ""
style = old_style = ""
for ch in screenData.buffer[line_no].values():
style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}"
if style != old_style:
if old_style:
line += f"<span style={repr(old_style)}>{html.escape(text, quote=True)}</span>"
else:
line += html.escape(text, quote=True)
text = ""
old_style = style
text += ch.data
if style:
line += f"<span style={repr(style)}>{html.escape(text, quote=True)}</span>"
else:
line += html.escape(text, quote=True)
self.output[line_no] = line
# fill the text area with HTML contents in one go
self.appendHtml(f"<pre>{chr(10).join(self.output)}</pre>")
# done updates, all clean
screenData.dirty.clear()
# Activate cursor
textCursor = self.textCursor()
textCursor.setPosition(0)
textCursor.movePosition(QTextCursor.Down, QTextCursor.MoveAnchor, screenData.cursor.y)
textCursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, screenData.cursor.x)
self.setTextCursor(textCursor)
self.ensureCursorVisible()
# manage scroll
if reset_scroll:
self.scroll.valueChanged.disconnect(self.scroll_value_change)
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
self.scroll.setMaximum(tmp if tmp > 0 else 0)
self.scroll.setSliderPosition(len(self.backend.screen.history.top))
self.scroll.valueChanged.connect(self.scroll_value_change)
# def resizeEvent(self, event):
# with self.lock:
# self.numColumns = int(self.width() / self._char_width)
# self.numLines = int(self.height() / self._char_height)
# self.output = [""] * self.numLines
# print("RESIZING TO", self.numColumns, "x", self.numLines)
# self.backend.screen.resize(self.numLines, self.numColumns)
def wheelEvent(self, event):
y = event.angleDelta().y()
if y > 0:
self.backend.screen.prev_page()
else:
self.backend.screen.next_page()
self.dataReady(self.backend.screen, reset_scroll=False)
def forkShell(self):
"""
Fork the current process and execute bec in shell.
"""
try:
pid, fd = pty.fork()
except (IOError, OSError):
return False
if pid == 0:
# Safe way to make it work under BSD and Linux
try:
ls = os.environ["LANG"].split(".")
except KeyError:
ls = []
if len(ls) < 2:
ls = ["en_US", "UTF-8"]
try:
os.putenv("COLUMNS", str(self.numColumns))
os.putenv("LINES", str(self.numLines))
os.putenv("TERM", "linux")
os.putenv("LANG", ls[0] + ".UTF-8")
if isinstance(self._cmd, str):
os.execvp(self._cmd, self._cmd)
else:
os.execvp(self._cmd[0], self._cmd)
# print "child_pid", child_pid, sts
except (IOError, OSError):
pass
# self.proc_finish(sid)
os._exit(0)
else:
# We are in the parent process.
# Set file control
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
print("Spawned Bash shell (PID {})".format(pid))
return fd
if __name__ == "__main__":
import os
import sys
from qtpy import QtGui, QtWidgets
# Terminal size in characters.
numLines = 25
numColumns = 100
# Create the Qt application and QBash instance.
app = QtWidgets.QApplication([])
mainwin = QtWidgets.QMainWindow()
title = "BECConsole ({}x{})".format(numColumns, numLines)
mainwin.setWindowTitle(title)
console = BECConsole(mainwin, numColumns, numLines)
mainwin.setCentralWidget(console)
console.start()
# Show widget and launch Qt's event loop.
mainwin.show()
sys.exit(app.exec_())

View File

@@ -1,439 +0,0 @@
from __future__ import annotations
from typing import Literal, Optional
from weakref import WeakValueDictionary
from pydantic import Field
from pyqtgraph.dockarea.DockArea import DockArea
from qtpy.QtCore import Qt
from qtpy.QtGui import QPainter, QPaintEvent
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.toolbar import (
ExpandableMenuAction,
MaterialIconAction,
ModularToolBar,
SeparatorAction,
)
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
from bec_widgets.widgets.plots.motor_map.motor_map_widget import BECMotorMapWidget
from bec_widgets.widgets.plots.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class DockAreaConfig(ConnectionConfig):
docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.")
docks_state: Optional[dict] = Field(
None, description="The state of the docks in the dock area."
)
class BECDockArea(BECWidget, QWidget):
PLUGIN = True
USER_ACCESS = [
"_config_dict",
"panels",
"save_state",
"remove_dock",
"restore_state",
"add_dock",
"clear_all",
"detach_dock",
"attach_all",
"_get_all_rpc",
"temp_areas",
"show",
"hide",
]
def __init__(
self,
parent: QWidget | None = None,
config: DockAreaConfig | None = None,
client=None,
gui_id: str = None,
) -> None:
if config is None:
config = DockAreaConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DockAreaConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.layout = QVBoxLayout(self)
self.layout.setSpacing(5)
self.layout.setContentsMargins(0, 0, 0, 0)
self._instructions_visible = True
self.dock_area = DockArea()
self.toolbar = ModularToolBar(
actions={
"menu_plots": ExpandableMenuAction(
label="Add Plot ",
actions={
"waveform": MaterialIconAction(
icon_name=BECWaveformWidget.ICON_NAME,
tooltip="Add Waveform",
filled=True,
),
"multi_waveform": MaterialIconAction(
icon_name=BECMultiWaveformWidget.ICON_NAME,
tooltip="Add Multi Waveform",
filled=True,
),
"image": MaterialIconAction(
icon_name=BECImageWidget.ICON_NAME, tooltip="Add Image", filled=True
),
"motor_map": MaterialIconAction(
icon_name=BECMotorMapWidget.ICON_NAME,
tooltip="Add Motor Map",
filled=True,
),
},
),
"separator_0": SeparatorAction(),
"menu_devices": ExpandableMenuAction(
label="Add Device Control ",
actions={
"scan_control": MaterialIconAction(
icon_name=ScanControl.ICON_NAME, tooltip="Add Scan Control", filled=True
),
"positioner_box": MaterialIconAction(
icon_name=PositionerBox.ICON_NAME, tooltip="Add Device Box", filled=True
),
},
),
"separator_1": SeparatorAction(),
"menu_utils": ExpandableMenuAction(
label="Add Utils ",
actions={
"queue": MaterialIconAction(
icon_name=BECQueue.ICON_NAME, tooltip="Add Scan Queue", filled=True
),
"vs_code": MaterialIconAction(
icon_name=VSCodeEditor.ICON_NAME, tooltip="Add VS Code", filled=True
),
"status": MaterialIconAction(
icon_name=BECStatusBox.ICON_NAME,
tooltip="Add BEC Status Box",
filled=True,
),
"progress_bar": MaterialIconAction(
icon_name=RingProgressBar.ICON_NAME,
tooltip="Add Circular ProgressBar",
filled=True,
),
},
),
"separator_2": SeparatorAction(),
"attach_all": MaterialIconAction(
icon_name="zoom_in_map", tooltip="Attach all floating docks"
),
"save_state": MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State"),
"restore_state": MaterialIconAction(
icon_name="frame_reload", tooltip="Restore Dock State"
),
},
target_widget=self,
)
self.layout.addWidget(self.toolbar)
self.layout.addWidget(self.dock_area)
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(DarkModeButton(toolbar=True))
self._hook_toolbar()
def _hook_toolbar(self):
# Menu Plot
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
lambda: self.add_dock(widget="BECWaveformWidget", prefix="waveform")
)
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
lambda: self.add_dock(widget="BECMultiWaveformWidget", prefix="multi_waveform")
)
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
lambda: self.add_dock(widget="BECImageWidget", prefix="image")
)
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
lambda: self.add_dock(widget="BECMotorMapWidget", prefix="motor_map")
)
# Menu Devices
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
lambda: self.add_dock(widget="ScanControl", prefix="scan_control")
)
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
lambda: self.add_dock(widget="PositionerBox", prefix="positioner_box")
)
# Menu Utils
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
lambda: self.add_dock(widget="BECQueue", prefix="queue")
)
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
lambda: self.add_dock(widget="BECStatusBox", prefix="status")
)
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
lambda: self.add_dock(widget="VSCodeEditor", prefix="vs_code")
)
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self.add_dock(widget="RingProgressBar", prefix="progress_bar")
)
# Icons
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
super().paintEvent(event)
if self._instructions_visible:
painter = QPainter(self)
painter.drawText(
self.rect(),
Qt.AlignCenter,
"Add docks using 'add_dock' method from CLI\n or \n Add widget docks using the toolbar",
)
@property
def panels(self) -> dict[str, BECDock]:
"""
Get the docks in the dock area.
Returns:
dock_dict(dict): The docks in the dock area.
"""
return dict(self.dock_area.docks)
@panels.setter
def panels(self, value: dict[str, BECDock]):
self.dock_area.docks = WeakValueDictionary(value)
@property
def temp_areas(self) -> list:
"""
Get the temporary areas in the dock area.
Returns:
list: The temporary areas in the dock area.
"""
return list(map(str, self.dock_area.tempAreas))
@temp_areas.setter
def temp_areas(self, value: list):
self.dock_area.tempAreas = list(map(str, value))
@SafeSlot()
def restore_state(
self, state: dict = None, missing: Literal["ignore", "error"] = "ignore", extra="bottom"
):
"""
Restore the state of the dock area. If no state is provided, the last state is restored.
Args:
state(dict): The state to restore.
missing(Literal['ignore','error']): What to do if a dock is missing.
extra(str): Extra docks that are in the dockarea but that are not mentioned in state will be added to the bottom of the dockarea, unless otherwise specified by the extra argument.
"""
if state is None:
state = self.config.docks_state
self.dock_area.restoreState(state, missing=missing, extra=extra)
@SafeSlot()
def save_state(self) -> dict:
"""
Save the state of the dock area.
Returns:
dict: The state of the dock area.
"""
last_state = self.dock_area.saveState()
self.config.docks_state = last_state
return last_state
def remove_dock(self, name: str):
"""
Remove a dock by name and ensure it is properly closed and cleaned up.
Args:
name(str): The name of the dock to remove.
"""
dock = self.dock_area.docks.pop(name, None)
self.config.docks.pop(name, None)
if dock:
dock.close()
dock.deleteLater()
if len(self.dock_area.docks) <= 1:
for dock in self.dock_area.docks.values():
dock.hide_title_bar()
else:
raise ValueError(f"Dock with name {name} does not exist.")
@SafeSlot(popup_error=True)
def add_dock(
self,
name: str = None,
position: Literal["bottom", "top", "left", "right", "above", "below"] = None,
relative_to: BECDock | None = None,
closable: bool = True,
floating: bool = False,
prefix: str = "dock",
widget: str | QWidget | None = None,
row: int = None,
col: int = 0,
rowspan: int = 1,
colspan: int = 1,
) -> BECDock:
"""
Add a dock to the dock area. Dock has QGridLayout as layout manager by default.
Args:
name(str): The name of the dock to be displayed and for further references. Has to be unique.
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
relative_to(BECDock): The dock to which the new dock should be added relative to.
closable(bool): Whether the dock is closable.
floating(bool): Whether the dock is detached after creating.
prefix(str): The prefix for the dock name if no name is provided.
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
row(int): The row of the added widget.
col(int): The column of the added widget.
rowspan(int): The rowspan of the added widget.
colspan(int): The colspan of the added widget.
Returns:
BECDock: The created dock.
"""
if name is None:
name = WidgetContainerUtils.generate_unique_widget_id(
container=self.dock_area.docks, prefix=prefix
)
if name in set(self.dock_area.docks.keys()):
raise ValueError(f"Dock with name {name} already exists.")
if position is None:
position = "bottom"
dock = BECDock(name=name, parent_dock_area=self, closable=closable)
dock.config.position = position
self.config.docks[name] = dock.config
self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to)
if len(self.dock_area.docks) <= 1:
dock.hide_title_bar()
elif len(self.dock_area.docks) > 1:
for dock in self.dock_area.docks.values():
dock.show_title_bar()
if widget is not None and isinstance(widget, str):
dock.add_widget(widget=widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
elif widget is not None and isinstance(widget, QWidget):
dock.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
if (
self._instructions_visible
): # TODO still decide how initial instructions should be handled
self._instructions_visible = False
self.update()
if floating:
dock.detach()
return dock
def detach_dock(self, dock_name: str) -> BECDock:
"""
Undock a dock from the dock area.
Args:
dock_name(str): The dock to undock.
Returns:
BECDock: The undocked dock.
"""
dock = self.dock_area.docks[dock_name]
dock.detach()
return dock
@SafeSlot()
def attach_all(self):
"""
Return all floating docks to the dock area.
"""
while self.dock_area.tempAreas:
for temp_area in self.dock_area.tempAreas:
self.remove_temp_area(temp_area)
def remove_temp_area(self, area):
"""
Remove a temporary area from the dock area.
This is a patched method of pyqtgraph's removeTempArea
"""
self.dock_area.tempAreas.remove(area)
area.window().close()
area.window().deleteLater()
def clear_all(self):
"""
Close all docks and remove all temp areas.
"""
self.attach_all()
for dock in dict(self.dock_area.docks).values():
dock.remove()
self.dock_area.docks.clear()
def cleanup(self):
"""
Cleanup the dock area.
"""
self.clear_all()
self.toolbar.close()
self.toolbar.deleteLater()
self.dock_area.close()
self.dock_area.deleteLater()
super().cleanup()
def close(self):
"""
Close the dock area and cleanup.
Has to be implemented to overwrite pyqtgraph event accept in Container close.
"""
self.cleanup()
super().close()
def show(self):
"""Show all windows including floating docks."""
super().show()
for docks in self.panels.values():
docks.window().show()
def hide(self):
"""Hide all windows including floating docks."""
super().hide()
for docks in self.panels.values():
docks.window().hide()
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("auto")
dock_area = BECDockArea()
dock_area.show()
app.exec_()

View File

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

View File

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

View File

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

View File

@@ -1,353 +0,0 @@
from collections import deque
from typing import Literal, Optional
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field, field_validator
from pyqtgraph.exporters import MatplotlibExporter
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import Colors
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
logger = bec_logger.logger
class BECMultiWaveformConfig(SubplotConfig):
color_palette: Optional[str] = Field(
"magma", description="The color palette of the figure widget.", validate_default=True
)
curve_limit: Optional[int] = Field(
200, description="The maximum number of curves to display on the plot."
)
flush_buffer: Optional[bool] = Field(
False, description="Flush the buffer of the plot widget when the curve limit is reached."
)
monitor: Optional[str] = Field(
None, description="The monitor to set for the plot widget."
) # TODO validate monitor in bec -> maybe make it as SignalData class for validation purpose
curve_width: Optional[int] = Field(1, description="The width of the curve on the plot.")
opacity: Optional[int] = Field(50, description="The opacity of the curve on the plot.")
highlight_last_curve: Optional[bool] = Field(
True, description="Highlight the last curve on the plot."
)
model_config: dict = {"validate_assignment": True}
_validate_color_map_z = field_validator("color_palette")(Colors.validate_color_map)
class BECMultiWaveform(BECPlotBase):
monitor_signal_updated = Signal()
highlighted_curve_index_changed = Signal(int)
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"curves",
"set_monitor",
"set_opacity",
"set_curve_limit",
"set_curve_highlight",
"set_colormap",
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_grid",
"set_colormap",
"enable_fps_monitor",
"lock_aspect_ratio",
"export",
"get_all_data",
"remove",
]
def __init__(
self,
parent: Optional[QWidget] = None,
parent_figure=None,
config: Optional[BECMultiWaveformConfig] = None,
client=None,
gui_id: Optional[str] = None,
):
if config is None:
config = BECMultiWaveformConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
)
self.old_scan_id = None
self.scan_id = None
self.monitor = None
self.connected = False
self.current_highlight_index = 0
self._curves = deque()
self.visible_curves = []
self.number_of_visible_curves = 0
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.get_bec_shortcuts()
@property
def curves(self) -> deque:
"""
Get the curves of the plot widget as a deque.
Returns:
deque: Deque of curves.
"""
return self._curves
@curves.setter
def curves(self, value: deque):
self._curves = value
@property
def highlight_last_curve(self) -> bool:
"""
Get the highlight_last_curve property.
Returns:
bool: The highlight_last_curve property.
"""
return self.config.highlight_last_curve
@highlight_last_curve.setter
def highlight_last_curve(self, value: bool):
self.config.highlight_last_curve = value
def set_monitor(self, monitor: str):
"""
Set the monitor for the plot widget.
Args:
monitor (str): The monitor to set.
"""
self.config.monitor = monitor
self._connect_monitor()
def _connect_monitor(self):
"""
Connect the monitor to the plot widget.
"""
try:
previous_monitor = self.monitor
except AttributeError:
previous_monitor = None
if previous_monitor and self.connected is True:
self.bec_dispatcher.disconnect_slot(
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(previous_monitor)
)
if self.config.monitor and self.connected is False:
self.bec_dispatcher.connect_slot(
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(self.config.monitor)
)
self.connected = True
self.monitor = self.config.monitor
@Slot(dict, dict)
def on_monitor_1d_update(self, msg: dict, metadata: dict):
"""
Update the plot widget with the monitor data.
Args:
msg(dict): The message data.
metadata(dict): The metadata of the message.
"""
data = msg.get("data", None)
current_scan_id = metadata.get("scan_id", None)
if current_scan_id != self.scan_id:
self.scan_id = current_scan_id
self.clear_curves()
self.curves.clear()
if self.crosshair:
self.crosshair.clear_markers()
# Always create a new curve and add it
curve = pg.PlotDataItem()
curve.setData(data)
self.plot_item.addItem(curve)
self.curves.append(curve)
# Max Trace and scale colors
self.set_curve_limit(self.config.curve_limit, self.config.flush_buffer)
self.monitor_signal_updated.emit()
@Slot(int)
def set_curve_highlight(self, index: int):
"""
Set the curve highlight based on visible curves.
Args:
index (int): The index of the curve to highlight among visible curves.
"""
self.plot_item.visible_curves = [curve for curve in self.curves if curve.isVisible()]
num_visible_curves = len(self.plot_item.visible_curves)
self.number_of_visible_curves = num_visible_curves
if num_visible_curves == 0:
return # No curves to highlight
if index >= num_visible_curves:
index = num_visible_curves - 1
elif index < 0:
index = num_visible_curves + index
self.current_highlight_index = index
num_colors = num_visible_curves
colors = Colors.evenly_spaced_colors(
colormap=self.config.color_palette, num=num_colors, format="HEX"
)
for i, curve in enumerate(self.plot_item.visible_curves):
curve.setPen()
if i == self.current_highlight_index:
curve.setPen(pg.mkPen(color=colors[i], width=5))
curve.setAlpha(alpha=1, auto=False)
curve.setZValue(1)
else:
curve.setPen(pg.mkPen(color=colors[i], width=1))
curve.setAlpha(alpha=self.config.opacity / 100, auto=False)
curve.setZValue(0)
self.highlighted_curve_index_changed.emit(self.current_highlight_index)
@Slot(int)
def set_opacity(self, opacity: int):
"""
Set the opacity of the curve on the plot.
Args:
opacity(int): The opacity of the curve. 0-100.
"""
self.config.opacity = max(0, min(100, opacity))
self.set_curve_highlight(self.current_highlight_index)
@Slot(int, bool)
def set_curve_limit(self, max_trace: int, flush_buffer: bool = False):
"""
Set the maximum number of traces to display on the plot.
Args:
max_trace (int): The maximum number of traces to display.
flush_buffer (bool): Flush the buffer.
"""
self.config.curve_limit = max_trace
self.config.flush_buffer = flush_buffer
if self.config.curve_limit is None:
self.scale_colors()
return
if self.config.flush_buffer:
# Remove excess curves from the plot and the deque
while len(self.curves) > self.config.curve_limit:
curve = self.curves.popleft()
self.plot_item.removeItem(curve)
else:
# Hide or show curves based on the new max_trace
num_curves_to_show = min(self.config.curve_limit, len(self.curves))
for i, curve in enumerate(self.curves):
if i < len(self.curves) - num_curves_to_show:
curve.hide()
else:
curve.show()
self.scale_colors()
def scale_colors(self):
"""
Scale the colors of the curves based on the current colormap.
"""
if self.config.highlight_last_curve:
self.set_curve_highlight(-1) # Use -1 to highlight the last visible curve
else:
self.set_curve_highlight(self.current_highlight_index)
def set_colormap(self, colormap: str):
"""
Set the colormap for the curves.
Args:
colormap(str): Colormap for the curves.
"""
self.config.color_palette = colormap
self.set_curve_highlight(self.current_highlight_index)
def hook_crosshair(self) -> None:
super().hook_crosshair()
if self.crosshair:
self.highlighted_curve_index_changed.connect(self.crosshair.update_highlighted_curve)
if self.curves:
self.crosshair.update_highlighted_curve(self.current_highlight_index)
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict:
"""
Extract all curve data into a dictionary or a pandas DataFrame.
Args:
output (Literal["dict", "pandas"]): Format of the output data.
Returns:
dict | pd.DataFrame: Data of all curves in the specified format.
"""
data = {}
try:
import pandas as pd
except ImportError:
pd = None
if output == "pandas":
logger.warning(
"Pandas is not installed. "
"Please install pandas using 'pip install pandas'."
"Output will be dictionary instead."
)
output = "dict"
curve_keys = []
curves_list = list(self.curves)
for i, curve in enumerate(curves_list):
x_data, y_data = curve.getData()
if x_data is not None or y_data is not None:
key = f"curve_{i}"
curve_keys.append(key)
if output == "dict":
data[key] = {"x": x_data.tolist(), "y": y_data.tolist()}
elif output == "pandas" and pd is not None:
data[key] = pd.DataFrame({"x": x_data, "y": y_data})
if output == "pandas" and pd is not None:
combined_data = pd.concat([data[key] for key in curve_keys], axis=1, keys=curve_keys)
return combined_data
return data
def clear_curves(self):
"""
Remove all curves from the plot, excluding crosshair items.
"""
items_to_remove = []
for item in self.plot_item.items:
if not getattr(item, "is_crosshair", False) and isinstance(item, pg.PlotDataItem):
items_to_remove.append(item)
for item in items_to_remove:
self.plot_item.removeItem(item)
def export_to_matplotlib(self):
"""
Export current waveform to matplotlib GUI. Available only if matplotlib is installed in the environment.
"""
MatplotlibExporter(self.plot_item).export()
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.containers.figure import BECFigure
app = QApplication(sys.argv)
widget = BECFigure()
widget.show()
sys.exit(app.exec_())

View File

@@ -1,9 +0,0 @@
from qtpy.QtWidgets import QMainWindow
from bec_widgets.utils import BECConnector
class BECMainWindow(QMainWindow, BECConnector):
def __init__(self, *args, **kwargs):
BECConnector.__init__(self, **kwargs)
QMainWindow.__init__(self, *args, **kwargs)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,60 +0,0 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QMessageBox, QPushButton, QToolButton, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
class ResetButton(BECWidget, QWidget):
"""A button that resets the scan queue."""
PLUGIN = True
ICON_NAME = "restart_alt"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()
self.layout = QHBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
if toolbar:
icon = material_icon(
"restart_alt", color="#F19E39", filled=True, convert_to_pixmap=False
)
self.button = QToolButton(icon=icon)
self.button.setToolTip("Reset the scan queue")
else:
self.button = QPushButton()
self.button.setText("Reset Queue")
self.button.setStyleSheet(
"background-color: #F19E39; color: white; font-weight: bold; font-size: 12px;"
)
self.button.clicked.connect(self.confirm_reset_queue)
self.layout.addWidget(self.button)
@SafeSlot()
def confirm_reset_queue(self):
"""Prompt the user to confirm the queue reset."""
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("Confirm Reset")
msg_box.setText(
"Are you sure you want to reset the scan queue? This action cannot be undone."
)
msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
msg_box.setDefaultButton(QMessageBox.No)
if msg_box.exec_() == QMessageBox.Yes:
self.reset_queue()
@SafeSlot()
def reset_queue(self):
"""Reset the scan queue."""
self.queue.request_queue_reset()

View File

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

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
class ResumeButton(BECWidget, QWidget):
"""A button that continue scan queue."""
PLUGIN = True
ICON_NAME = "resume"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()
self.layout = QHBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
if toolbar:
icon = material_icon("resume", color="#2793e8", filled=True, convert_to_pixmap=False)
self.button = QToolButton(icon=icon)
self.button.setToolTip("Resume the scan queue")
else:
self.button = QPushButton()
self.button.setText("Resume")
self.button.setStyleSheet(
"background-color: #2793e8; color: white; font-weight: bold; font-size: 12px;"
)
self.button.clicked.connect(self.continue_scan)
self.layout.addWidget(self.button)
@SafeSlot()
def continue_scan(self):
"""Stop the scan."""
self.queue.request_scan_continuation()

View File

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

View File

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

View File

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

View File

@@ -1,62 +0,0 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
class StopButton(BECWidget, QWidget):
"""A button that stops the current scan."""
PLUGIN = True
ICON_NAME = "dangerous"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()
self.layout = QHBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
if toolbar:
icon = material_icon("stop", color="#cc181e", filled=True, convert_to_pixmap=False)
self.button = QToolButton(icon=icon)
self.button.setToolTip("Stop the scan queue")
else:
self.button = QPushButton()
self.button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
self.button.setText("Stop")
self.button.setStyleSheet(
f"background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;"
)
self.button.clicked.connect(self.stop_scan)
self.layout.addWidget(self.button)
@SafeSlot()
def stop_scan(
self,
): # , scan_id: str | None = None): #FIXME scan_id will be added when combining with Queue widget
"""
Stop the scan.
Args:
scan_id(str|None): The scan id to stop. If None, the current scan will be stopped.
"""
self.queue.request_scan_halt()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
w = StopButton()
w.show()
sys.exit(app.exec_())

View File

@@ -1,291 +0,0 @@
import numpy as np
from qtpy.QtCore import Property, QSize, Qt, Slot
from qtpy.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
class PositionIndicator(BECWidget, QWidget):
USER_ACCESS = ["set_value", "set_range", "vertical", "indicator_width", "rounded_corners"]
PLUGIN = True
ICON_NAME = "horizontal_distribute"
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.position = 50
self.min_value = 0
self.max_value = 100
self.scaling_factor = 0.5
self.is_vertical = False
self._current_indicator_position = 0
self._draw_position = 0
self._rounded_corners = 10
self._indicator_width = 2
self._indicator_color = get_accent_colors().success
self._background_color = get_theme_palette().mid().color()
self._use_color_palette = True
def set_range(self, min_value: float, max_value: float):
"""
Set the range of the position indicator
Args:
min_value(float): Minimum value of the range
max_value(float): Maximum value of the range
"""
self.minimum = min_value
self.maximum = max_value
@Property(float)
def minimum(self):
"""
Property to get the minimum value of the position indicator
"""
return self.min_value
@minimum.setter
def minimum(self, min_value: float):
"""
Setter for the minimum property
Args:
min_value: The minimum value of the position indicator
"""
self.min_value = min_value
self.update()
@Property(float)
def maximum(self):
"""
Property to get the maximum value of the position indicator
"""
return self.max_value
@maximum.setter
def maximum(self, max_value: float):
"""
Setter for the maximum property
Args:
max_value: The maximum value of the position indicator
"""
self.max_value = max_value
self.update()
@Property(bool)
def vertical(self):
"""
Property to determine the orientation of the position indicator
"""
return self.is_vertical
@vertical.setter
def vertical(self, is_vertical: bool):
"""
Setter for the vertical property
Args:
is_vertical: True if the indicator should be vertical, False if horizontal
"""
self.is_vertical = is_vertical
self.update()
@Property(float)
def value(self):
"""
Property to get the current value of the position indicator
"""
return self.position
@value.setter
def value(self, position: float):
"""
Setter for the value property
Args:
position: The new position of the indicator
"""
self.set_value(position)
@Property(int)
def indicator_width(self):
"""
Property to get the width of the indicator
"""
return self._indicator_width
@indicator_width.setter
def indicator_width(self, width: int):
"""
Setter for the indicator width property
Args:
width: The new width of the indicator
"""
self._indicator_width = width
self.update()
@Property(int)
def rounded_corners(self):
"""
Property to get the rounded corners of the position indicator
"""
return self._rounded_corners
@rounded_corners.setter
def rounded_corners(self, value: int):
"""
Setter for the rounded corners property
Args:
value: The new value for the rounded corners
"""
self._rounded_corners = value
self.update()
@Property(QColor)
def indicator_color(self):
"""
Property to get the color of the indicator
"""
return self._indicator_color
@indicator_color.setter
def indicator_color(self, color: QColor):
"""
Setter for the indicator color property
Args:
color: The new color for the indicator
"""
self._indicator_color = color
self.update()
@Property(QColor)
def background_color(self):
"""
Property to get the background color of the position indicator
"""
return self._background_color
@background_color.setter
def background_color(self, color: QColor):
"""
Setter for the background color property
Args:
color: The new background color
"""
self._background_color = color
self.update()
@Property(bool)
def use_color_palette(self):
"""
Property to determine if the indicator should use the color palette or the custom color.
"""
return self._use_color_palette
@use_color_palette.setter
def use_color_palette(self, use_palette: bool):
"""
Setter for the use color palette property
Args:
use_palette: True if the indicator should use the color palette, False if custom color
"""
self._use_color_palette = use_palette
self.update()
# @Property(float)
@Slot(int)
@Slot(float)
def set_value(self, position: float):
self.position = position
self.update()
def _get_indicator_color(self):
if self._use_color_palette:
return get_accent_colors().success
return self._indicator_color
def _get_background_brush(self):
if self._use_color_palette:
return get_theme_palette().mid()
return QBrush(self._background_color)
def paintEvent(self, event):
painter = QPainter(self)
width = self.width()
height = self.height()
# Set up the brush for the background
painter.setBrush(self._get_background_brush())
# Create a QPainterPath with a rounded rectangle for clipping
path = QPainterPath()
path.addRoundedRect(0, 0, width, height, self._rounded_corners, self._rounded_corners)
# Set clipping to the rounded rectangle
painter.setClipPath(path)
# Draw the rounded rectangle background first
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(0, 0, width, height, self._rounded_corners, self._rounded_corners)
# get the position scaled to the defined min and max values
self._current_indicator_position = position = np.interp(
self.position, [self.min_value, self.max_value], [0, 100]
)
if self.is_vertical:
# If vertical, rotate the coordinate system by -90 degrees
painter.translate(width // 2, height // 2) # Move origin to center
painter.rotate(-90) # Rotate by -90 degrees for vertical drawing
painter.translate(-height // 2, -width // 2) # Restore the origin for drawing
# Switch width and height for the vertical orientation
width, height = height, width
# Draw the moving vertical indicator, respecting the clip path
self._draw_position = x_pos = round(
position * width / 100
) # Position for the vertical line
indicator_pen = QPen(self._get_indicator_color(), self._indicator_width)
painter.setPen(indicator_pen)
painter.drawLine(x_pos, 0, x_pos, height)
painter.end()
def minimumSizeHint(self):
# Set the smallest possible size
return QSize(10, 10)
if __name__ == "__main__": # pragma: no cover
from bec_qthemes import setup_theme
from qtpy.QtWidgets import QApplication, QSlider, QVBoxLayout
app = QApplication([])
setup_theme("dark")
# Create position indicator and slider
position_indicator = PositionIndicator()
# position_indicator.set_range(0, 1)
slider = QSlider(Qt.Horizontal)
slider.valueChanged.connect(lambda value: position_indicator.set_value(value))
position_indicator.is_vertical = False
# position_indicator.set_value(100)
layout = QVBoxLayout()
layout.addWidget(position_indicator)
layout.addWidget(slider)
widget = QWidget()
widget.setLayout(layout)
widget.show()
app.exec_()

View File

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

View File

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

View File

@@ -1,57 +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
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
DOM_XML = """
<ui language='c++'>
<widget class='PositionerBox' name='positioner_box'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = PositionerBox(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "Device Control"
def icon(self):
return designer_material_icon(PositionerBox.ICON_NAME)
def includeFile(self):
return "positioner_box"
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 "PositionerBox"
def toolTip(self):
return "Simple Widget to control a positioner in box form"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,34 +0,0 @@
from bec_lib.device import Positioner
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
class PositionerControlLine(PositionerBox):
"""A widget that controls a single device."""
ui_file = "positioner_control_line.ui"
dimensions = (60, 600) # height, width
PLUGIN = True
ICON_NAME = "switch_left"
def __init__(self, parent=None, device: Positioner = None, *args, **kwargs):
"""Initialize the DeviceControlLine.
Args:
parent: The parent widget.
device (Positioner): The device to control.
"""
super().__init__(parent=parent, device=device, *args, **kwargs)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = PositionerControlLine(device="samy")
widget.show()
sys.exit(app.exec_())

View File

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

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