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

Compare commits

..

91 Commits

Author SHA1 Message Date
3653fe9799 wip - fix formatter 2025-03-13 10:32:04 +01:00
750350dc52 wip - removed rpcreference import 2025-03-13 10:30:04 +01:00
9eb1608b01 wip - test namespace 2025-03-13 10:24:35 +01:00
0433b40054 wip - namespace 2025-03-13 10:24:35 +01:00
d1a41752c4 wip - namespace 2025-03-13 10:24:35 +01:00
bf060b3aba wip - namespace 2025-03-13 10:24:35 +01:00
5f0dd62f25 wip - namespace 2025-03-13 10:24:35 +01:00
21b1f0b2de wip - namespace 2025-03-13 10:24:35 +01:00
17f6dbb0d4 wip - namespace 2025-03-13 10:24:35 +01:00
ba347e026a wip - namespace update 2025-03-13 10:06:11 +01:00
705f157c04 docs(plot_base): update docstrings for properties and setters 2025-03-06 16:07:56 +01:00
4736c2fad1 refactor(waveform_widget): removed and replaced by Waveform 2025-03-06 16:07:56 +01:00
31b40aeede test(plot_indicators): tests adapted to not be dependent on BECWaveformWidget 2025-03-06 16:07:56 +01:00
77e8a5c884 fix(plot_indicators): cleanup adjusted 2025-03-06 16:07:56 +01:00
0f4365bbb0 feat(waveform): new Waveform widget based on NextGen PlotBase 2025-03-06 16:07:56 +01:00
906ca03929 fix(entry_validator): validator reports list of signal if user chooses the wrong one 2025-03-06 16:07:56 +01:00
1206069a8f fix(plot_base): update mouse mode state on mode change 2025-03-06 16:07:56 +01:00
86487a5f4d fix(plot_base): aspect ratio removed from the PlotBase 2025-03-06 16:07:56 +01:00
4bdcae7028 fix(plot_base): inner and outer axis setting in popup mode 2025-03-06 16:07:56 +01:00
81f61f3c3b fix(plot_base): fix cleanup of popups if popups are still open when PlotBase is closed 2025-03-06 16:07:56 +01:00
89e8ebf1b6 fix(lmfit_dialog_vertical): vertical sizePolicy fixed 2025-03-06 16:07:56 +01:00
semantic-release
66f4f9bfa8 1.24.5
Automatically generated by python-semantic-release
2025-03-06 14:51:03 +00:00
66c6c7fa50 fix: add support for additional keyword arguments in widget constructors 2025-03-06 15:39:16 +01:00
semantic-release
31c3337300 1.24.4
Automatically generated by python-semantic-release
2025-03-05 19:59:54 +00:00
2c506ee3c8 fix(cli/server): handle RedisError during heartbeat emission to properly close the app even if the Redis connection is lost 2025-03-05 20:41:33 +01:00
semantic-release
25423f4a3a 1.24.3
Automatically generated by python-semantic-release
2025-03-05 09:46:53 +00:00
fa91366dcb fix(multi_waveform): update on_async_readback to use structured metadata for async updates with "add" instead of "extend" 2025-03-04 22:31:14 +01:00
semantic-release
4db0f9f10c 1.24.2
Automatically generated by python-semantic-release
2025-02-27 10:08:57 +00:00
46b1a228be fix(e2e): added wait time to flaky e2e 2025-02-27 10:54:36 +01:00
semantic-release
531018b0ac 1.24.1
Automatically generated by python-semantic-release
2025-02-26 21:06:09 +00:00
8679b5f08b test: extended test coverage for axis settings, plot base and qt toolbar action 2025-02-26 21:54:33 +01:00
6f2c2401ac refactor(plot_base): toolbar buttons adapted for the Switch actions from toolbar; plot export and mouse modes consolidated into one switch button 2025-02-26 21:54:33 +01:00
6d1106e33e fix(toolbar): Switch Actions for default checked actions fixed 2025-02-26 21:54:33 +01:00
90a184643a refactor(axis_settings): spinbox migrated to new BECSpinBoxes 2025-02-26 21:54:33 +01:00
3aa2f2225f fix(plot_base): ability to choose between popup or side panel gui mode 2025-02-26 21:54:33 +01:00
semantic-release
f54e69f1cf 1.24.0
Automatically generated by python-semantic-release
2025-02-26 11:20:07 +00:00
7309c1dede feat: add metadata widget to scan control 2025-02-26 12:08:32 +01:00
1c0021f98b fix: make scan metadata use collapsible frame 2025-02-26 12:08:32 +01:00
d32952a0d5 style: isort 2025-02-26 12:08:32 +01:00
5206528fec feat: add expandable/collapsible frame 2025-02-26 12:08:32 +01:00
42665b69c5 fix: replace add'l md table w/ tree view 2025-02-26 12:08:32 +01:00
semantic-release
209c898e3d 1.23.1
Automatically generated by python-semantic-release
2025-02-24 13:54:40 +00:00
6a43554f3b fix: update redis mock for changes in bec 2025-02-24 14:43:02 +01:00
semantic-release
95c931af0b 1.23.0
Automatically generated by python-semantic-release
2025-02-24 10:00:25 +00:00
f19d9485df feat(bec_spin_box): double spin box with setting inside for defining decimals 2025-02-24 10:49:10 +01:00
semantic-release
575c988c4f 1.22.0
Automatically generated by python-semantic-release
2025-02-19 16:54:57 +00:00
6b08f7cfb2 refactor(toolbar): added dark mode button for testing appearance for the toolbar example 2025-02-19 17:43:49 +01:00
6ae33a23a6 test(toolbar): blocking tests fixed 2025-02-19 17:08:56 +01:00
facb8c30ff fix(toolbar): update_separators logic updated, there cannot be two separators next to each other 2025-02-19 15:44:44 +01:00
333570ba2f feat(toolbar): SwitchableToolBarButton 2025-02-19 15:42:31 +01:00
ef36a7124d fix(toolbar): widget actions are more compact 2025-02-19 15:02:17 +01:00
c2c022154b fix(toolbar): QMenu Icons are visible 2025-02-19 15:02:17 +01:00
4c4f1592c2 fix(modular_toolbar): add action to an already existing bundle 2025-02-19 15:02:17 +01:00
semantic-release
d7fb291877 1.21.4
Automatically generated by python-semantic-release
2025-02-19 13:29:43 +00:00
ae18279685 fix(colors): pyqtgraph styling updated on the app level 2025-02-19 14:18:18 +01:00
97c0ed53df fix(plot_base): mouse interactions default state fetch to toolbar 2025-02-19 14:18:18 +01:00
ff8e282034 refactor(plot_base): Change the PlotWidget to GraphicalLayoutWidget 2025-02-19 14:18:18 +01:00
semantic-release
440f36f289 1.21.3
Automatically generated by python-semantic-release
2025-02-19 12:44:37 +00:00
0addef5f17 fix(bec_signal_proxy): unblock signal timer cleanup added 2025-02-19 13:33:16 +01:00
semantic-release
8c2a5e61fc 1.21.2
Automatically generated by python-semantic-release
2025-02-18 14:41:43 +00:00
056731c9ad fix(client_utils): autoupdate has correct propagation of BECDockArea to plugin repos 2025-02-18 15:06:53 +01:00
semantic-release
911c81a167 1.21.1
Automatically generated by python-semantic-release
2025-02-17 14:54:21 +00:00
8651314d93 build:unlock pyside version 2025-02-17 15:18:29 +01:00
383936ffc2 fix(bec_connector): workers stored in reference to not be cleaned up with garbage collector 2025-02-17 15:18:29 +01:00
semantic-release
4378d33880 1.21.0
Automatically generated by python-semantic-release
2025-02-17 10:37:33 +00:00
1708bd405f feat: generated form for scan metadata 2025-02-17 11:21:08 +01:00
12811eccdb tests(scan_control): fixed hard-coded redis paths 2025-02-13 17:49:00 +01:00
semantic-release
5959fa87de 1.20.0
Automatically generated by python-semantic-release
2025-02-06 15:37:33 +00:00
b3217b7ca5 feat(widget): add LogPanel widget
hopefully without segfaults - compared to first implementation:
- explicitly set parent of all dialog components
- try/except and log for redis new message callback
- pass in ServiceStatusMixin and explicitly clean it up
2025-02-06 16:26:02 +01:00
semantic-release
35b941d054 1.19.2
Automatically generated by python-semantic-release
2025-02-06 15:23:58 +00:00
fc6d7c0824 fix: cleanup timer in Minesweeper 2025-02-06 15:12:48 +01:00
fb051865d5 fix: mock QTimer, improve timeout message 2025-02-06 15:12:48 +01:00
semantic-release
8aba3d975f 1.19.1
Automatically generated by python-semantic-release
2025-02-05 13:49:03 +00:00
5e3289f5bd fix(macos): suppress IMKClient warning on macos 2025-02-05 13:01:40 +01:00
d07744397e Revert "feat(widget): add LogPanel widget"
This reverts commit f048880277
2025-02-05 08:57:09 +01:00
semantic-release
dc7bf6b3c4 1.19.0
Automatically generated by python-semantic-release
2025-01-31 10:57:04 +00:00
f219c6fb57 docs: add docs for LogPanel 2025-01-31 10:10:08 +01:00
f048880277 feat(widget): add LogPanel widget 2025-01-31 10:10:08 +01:00
50a572dacd fix: enable type checking for BECDispatcher in BECConnector 2025-01-30 17:28:30 +01:00
semantic-release
b87549ba99 1.18.1
Automatically generated by python-semantic-release
2025-01-30 16:22:51 +00:00
f0c4efefa0 docs: add screenshots for device and signal input 2025-01-30 17:11:44 +01:00
db70442cc2 fix(signal_combo_box): added missing plugin modules for signal line_edit/combobox 2025-01-30 17:11:44 +01:00
semantic-release
07b8910686 1.18.0
Automatically generated by python-semantic-release
2025-01-30 16:07:01 +00:00
e7c97290cd feat(plot_base_next_gen): new type of plot base inherited from QWidget 2025-01-30 16:49:13 +01:00
48fc63d83e fix(generate_cli): widgets can be tagged with RPC=False, then they are excluded from client.py for RPC 2025-01-30 16:49:13 +01:00
a20935e862 build: pyqt6 support dropped 2025-01-30 15:53:38 +01:00
4f8e6835fe ci: fix formatter 2024 versions 2025-01-30 14:41:00 +01:00
semantic-release
042adfa51e 1.17.2
Automatically generated by python-semantic-release
2025-01-28 19:12:25 +00:00
b2b0450bcb fix(widget_state_manager): skip QLabel saving; skip_setting property widget excluded from INI; stored=False property excluded from INI 2025-01-28 18:34:21 +01:00
semantic-release
12e06fa971 1.17.1
Automatically generated by python-semantic-release
2025-01-26 15:32:17 +00:00
6f2f2aa06a fix(bec_signal_proxy): timeout for blocking implemented 2025-01-26 14:29:30 +01:00
159 changed files with 11940 additions and 3723 deletions

View File

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

View File

@@ -1,6 +1,288 @@
# CHANGELOG
## v1.24.5 (2025-03-06)
### Bug Fixes
- Add support for additional keyword arguments in widget constructors
([`66c6c7f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/66c6c7fa5075dcd5b6729fa3c2166aa821a6c51d))
## v1.24.4 (2025-03-05)
### Bug Fixes
- **cli/server**: Handle RedisError during heartbeat emission to properly close the app even if the
Redis connection is lost
([`2c506ee`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2c506ee3c8bcf924c651fddffe4f3f9a2ffd19a4))
## v1.24.3 (2025-03-05)
### Bug Fixes
- **multi_waveform**: Update on_async_readback to use structured metadata for async updates with
"add" instead of "extend"
([`fa91366`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fa91366dcbb383319dc0a0f26400aa93ee445299))
## v1.24.2 (2025-02-27)
### Bug Fixes
- **e2e**: Added wait time to flaky e2e
([`46b1a22`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/46b1a228be4ef5eb21ecf6c7020a2cd05d06b61a))
## v1.24.1 (2025-02-26)
### Bug Fixes
- **plot_base**: Ability to choose between popup or side panel gui mode
([`3aa2f22`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3aa2f2225fba499b648d191ea27553b6db303c18))
- **toolbar**: Switch Actions for default checked actions fixed
([`6d1106e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6d1106e33e1fc3839244b11a601fb71e81a65e61))
### Refactoring
- **axis_settings**: Spinbox migrated to new BECSpinBoxes
([`90a1846`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/90a184643aaaaabaa4feb02d2f406fe2bb9daecc))
- **plot_base**: Toolbar buttons adapted for the Switch actions from toolbar; plot export and mouse
modes consolidated into one switch button
([`6f2c240`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6f2c2401ac99b2b8a9af9af76854669a248b516b))
### Testing
- Extended test coverage for axis settings, plot base and qt toolbar action
([`8679b5f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8679b5f08bef8a4a2b6338d9bee4cd70d564f288))
## v1.24.0 (2025-02-26)
### Bug Fixes
- Make scan metadata use collapsible frame
([`1c0021f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1c0021f98b8e0419dba883b891a6035653c0ba0d))
- Replace add'l md table w/ tree view
([`42665b6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/42665b69c5cca60a9e5f2d7bd43dbfe5da5a7eb3))
### Code Style
- Isort
([`d32952a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d32952a0d590b03007271427bd85f00b88ef0851))
### Features
- Add expandable/collapsible frame
([`5206528`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5206528feccaf192f3d5872ac785470562b493f9))
- Add metadata widget to scan control
([`7309c1d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7309c1dede2ec93bf08f84f13596ce18dfdb1476))
## v1.23.1 (2025-02-24)
### Bug Fixes
- Update redis mock for changes in bec
([`6a43554`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6a43554f3b57045325f57bdd5079d7f91af40bb6))
## v1.23.0 (2025-02-24)
### Features
- **bec_spin_box**: Double spin box with setting inside for defining decimals
([`f19d948`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f19d9485df403cb755315ac1a0ff4402d7a85f77))
## v1.22.0 (2025-02-19)
### Bug Fixes
- **modular_toolbar**: Add action to an already existing bundle
([`4c4f159`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4c4f1592c29974bb095c3c8325e93a1383efa289))
- **toolbar**: Qmenu Icons are visible
([`c2c0221`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c2c022154bddc15d81eb55aad912d8fe1e34c698))
- **toolbar**: Update_separators logic updated, there cannot be two separators next to each other
([`facb8c3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/facb8c30ffa3b12a97c7c68f8594b0354372ca17))
- **toolbar**: Widget actions are more compact
([`ef36a71`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ef36a7124d54319c2cd592433c95e4f7513e982e))
### Features
- **toolbar**: Switchabletoolbarbutton
([`333570b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/333570ba2fe67cb51fdbab17718003dfdb7f7b55))
### Refactoring
- **toolbar**: Added dark mode button for testing appearance for the toolbar example
([`6b08f7c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b08f7cfb2115609a6dc6f681631ecfae23fa899))
### Testing
- **toolbar**: Blocking tests fixed
([`6ae33a2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6ae33a23a62eafb7c820e1fde9d6d91ec1796e55))
## v1.21.4 (2025-02-19)
### Bug Fixes
- **colors**: Pyqtgraph styling updated on the app level
([`ae18279`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ae182796855719437bdf911c2e969e3f438d6982))
- **plot_base**: Mouse interactions default state fetch to toolbar
([`97c0ed5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/97c0ed53df21053fef9811c3dea3b79020137030))
### Refactoring
- **plot_base**: Change the PlotWidget to GraphicalLayoutWidget
([`ff8e282`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ff8e282034f0970b69cf0447fc5f88b4f30bf470))
## v1.21.3 (2025-02-19)
### Bug Fixes
- **bec_signal_proxy**: Unblock signal timer cleanup added
([`0addef5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0addef5f172a7cc1412ac146a6eec2a2caa8ad9c))
## v1.21.2 (2025-02-18)
### Bug Fixes
- **client_utils**: Autoupdate has correct propagation of BECDockArea to plugin repos
([`056731c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/056731c9add7d92f7da7fa833343cf65e8f383a8))
## v1.21.1 (2025-02-17)
### Bug Fixes
- **bec_connector**: Workers stored in reference to not be cleaned up with garbage collector
([`383936f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/383936ffc2bd7d2e088d3367c76b14efa3d1732c))
## v1.21.0 (2025-02-17)
### Features
- Generated form for scan metadata
([`1708bd4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1708bd405f86b1353828b01fbf5f98383a19ec2a))
## v1.20.0 (2025-02-06)
### Features
- **widget**: Add LogPanel widget
([`b3217b7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b3217b7ca5cabe8798f06787de4ae3f3ec1af3b6))
hopefully without segfaults - compared to first implementation: - explicitly set parent of all
dialog components - try/except and log for redis new message callback - pass in ServiceStatusMixin
and explicitly clean it up
## v1.19.2 (2025-02-06)
### Bug Fixes
- Cleanup timer in Minesweeper
([`fc6d7c0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fc6d7c0824be841f1bff23c8dd66b203f5798333))
- Mock QTimer, improve timeout message
([`fb05186`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fb051865d5fe44150a9c3599f13e2473530970bc))
## v1.19.1 (2025-02-05)
### Bug Fixes
- **macos**: Suppress IMKClient warning on macos
([`5e3289f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5e3289f5bdd2af02423b9975749e53c011b8dcfa))
## v1.19.0 (2025-01-31)
### Bug Fixes
- Enable type checking for BECDispatcher in BECConnector
([`50a572d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/50a572dacd5dfc29a9ecf1b567aac6822b632f60))
### Documentation
- Add docs for LogPanel
([`f219c6f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f219c6fb573cf42964f6a7c6f4a0b0b9946fb98d))
### Features
- **widget**: Add LogPanel widget
([`f048880`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f0488802775401319a54a51d05a0ad534292af09))
## v1.18.1 (2025-01-30)
### Bug Fixes
- **signal_combo_box**: Added missing plugin modules for signal line_edit/combobox
([`db70442`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/db70442cc21247d20e6f6ad78ad0e1d3aca24bf7))
### Documentation
- Add screenshots for device and signal input
([`f0c4efe`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f0c4efefa03bf36ae57bf1a17f6a1b2e4d32c6c4))
## v1.18.0 (2025-01-30)
### Bug Fixes
- **generate_cli**: Widgets can be tagged with RPC=False, then they are excluded from client.py for
RPC
([`48fc63d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/48fc63d83e26889843b09b1eb4792612b53200ec))
### Build System
- Pyqt6 support dropped
([`a20935e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a20935e8625a9490e6c451a3b4012476e19317e5))
### Continuous Integration
- Fix formatter 2024 versions
([`4f8e683`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4f8e6835fe2312151dc2b40f0ab9eb50a9173f7c))
### Features
- **plot_base_next_gen**: New type of plot base inherited from QWidget
([`e7c9729`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e7c97290cd783d19128625567835d7ae9a414989))
## v1.17.2 (2025-01-28)
### Bug Fixes
- **widget_state_manager**: Skip QLabel saving; skip_setting property widget excluded from INI;
stored=False property excluded from INI
([`b2b0450`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b2b0450bcb07c974e5f8002e084b350599c32d39))
## v1.17.1 (2025-01-26)
### Bug Fixes
- **bec_signal_proxy**: Timeout for blocking implemented
([`6f2f2aa`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6f2f2aa06ae9b50f0451029caa1d8d83890a5b30))
## v1.17.0 (2025-01-23)
### Bug Fixes

View File

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

View File

@@ -29,6 +29,7 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
logger = bec_logger.logger
# FIXME BECWaveFormWidget is gone, this app will not work until adapted to new Waveform
class Alignment1D:
"""Alignment GUI to perform 1D scans"""

View File

@@ -35,9 +35,9 @@ class AutoUpdates:
Create a default dock for the auto updates.
"""
self.dock_name = "default_figure"
self._default_dock = self.gui.add_dock(self.dock_name)
self._default_dock.add_widget("BECFigure")
self._default_fig = self._default_dock.widget_list[0]
self._default_dock = self.gui.new(self.dock_name)
self._default_dock.new("BECFigure")
self._default_fig = self._default_dock.elements_list[0]
@staticmethod
def get_scan_info(msg) -> ScanInfo:

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
"""Client utilities for the BEC GUI."""
from __future__ import annotations
import importlib
@@ -7,13 +9,15 @@ import os
import select
import subprocess
import threading
import time
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from rich.console import Console
from rich.table import Table
import bec_widgets.cli.client as client
from bec_widgets.cli.auto_updates import AutoUpdates
@@ -23,16 +27,28 @@ if TYPE_CHECKING:
from bec_lib import messages
from bec_lib.connector import MessageObject
from bec_lib.device import DeviceBase
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_lib.redis_connector import StreamMessage
else:
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
StreamMessage = lazy_import_from("bec_lib.redis_connector", ("StreamMessage",))
logger = bec_logger.logger
IGNORE_WIDGETS = ["BECDockArea", "BECDock"]
def _filter_output(output: str) -> str:
"""
Filter out the output from the process.
"""
if "IMKClient" in output:
# only relevant on macOS
# see https://discussions.apple.com/thread/255761734?sortBy=rank
return ""
return output
def _get_output(process, logger) -> None:
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
@@ -47,6 +63,7 @@ def _get_output(process, logger) -> None:
if stream in readylist:
buf.append(stream.read(4096))
output, _, remaining = "".join(buf).rpartition("\n")
output = _filter_output(output)
if output:
log_func[stream](output)
buf.clear()
@@ -55,7 +72,9 @@ def _get_output(process, logger) -> None:
logger.error(f"Error reading process output: {str(e)}")
def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger=None) -> None:
def _start_plot_process(
gui_id: str, gui_class: type, gui_class_id: str, config: dict | str, logger=None
) -> None:
"""
Start the plot in a new process.
@@ -64,7 +83,16 @@ 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__,
"--gui_class_id",
gui_class_id,
"--hide",
]
if config:
if isinstance(config, dict):
config = json.dumps(config)
@@ -99,16 +127,20 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
class RepeatTimer(threading.Timer):
"""RepeatTimer class."""
def run(self):
while not self.finished.wait(self.interval):
self.function(*self.args, **self.kwargs)
# pylint: disable=protected-access
@contextmanager
def wait_for_server(client):
def wait_for_server(client: BECGuiClient):
"""Context manager to wait for the server to start."""
timeout = client._startup_timeout
if not timeout:
if client.gui_is_alive():
if client._gui_is_alive():
# there is hope, let's wait a bit
timeout = 1
else:
@@ -126,42 +158,63 @@ def wait_for_server(client):
yield
### ----------------------------
### NOTE
### it is far easier to extend the 'delete' method on the client side,
### to know when the client is deleted, rather than listening to server
### to get notified. However, 'generate_cli.py' cannot add extra stuff
### in the generated client module. So, here a class with the same name
### is created, and client module is patched.
class WidgetNameSpace:
def __repr__(self):
console = Console()
table = Table(title="Available widgets for BEC CLI usage")
table.add_column("Widget Name", justify="left", style="magenta")
table.add_column("Description", justify="left")
for attr, value in self.__dict__.items():
docs = value.__doc__
docs = docs if docs else "No description available"
table.add_row(attr, docs)
console.print(table)
return f""
class AvailableWidgetsNamespace:
"""Namespace for available widgets in the BEC GUI."""
def __init__(self):
for widget in client.Widgets:
name = widget.value
if name in IGNORE_WIDGETS:
continue
setattr(self, name, name)
def __repr__(self):
console = Console()
table = Table(title="Available widgets for BEC CLI usage")
table.add_column("Widget Name", justify="left", style="magenta")
table.add_column("Description", justify="left")
for attr_name, _ in self.__dict__.items():
docs = getattr(client, attr_name).__doc__
docs = docs if docs else "No description available"
table.add_row(attr_name, docs if len(docs.strip()) > 0 else "No description available")
console.print(table)
return "" # f"<{self.__class__.__name__}>"
class BECDockArea(client.BECDockArea):
def delete(self):
if self is BECGuiClient._top_level["main"].widget:
raise RuntimeError("Cannot delete main window")
super().delete()
try:
del BECGuiClient._top_level[self._gui_id]
except KeyError:
# if a dock area is not at top level
pass
"""Extend the BECDockArea class and add namespaces to access widgets of docks."""
client.BECDockArea = BECDockArea
### ----------------------------
@dataclass
class WidgetDesc:
title: str
widget: BECDockArea
def __init__(self, gui_id=None, config=None, name=None, parent=None):
super().__init__(gui_id, config, name, parent)
# Add namespaces for DockArea
self.elements = WidgetNameSpace()
class BECGuiClient(RPCBase):
"""BEC GUI client class. Container for GUI applications within Python."""
_top_level = {}
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._default_dock_name = "bec"
self._auto_updates_enabled = True
self._auto_updates = None
self._killed = False
self._startup_timeout = 0
self._gui_started_timer = None
self._gui_started_event = threading.Event()
@@ -169,14 +222,21 @@ class BECGuiClient(RPCBase):
self._process_output_processing_thread = None
@property
def windows(self):
def windows(self) -> dict:
"""Dictionary with dock ares in the GUI."""
return self._top_level
@property
def auto_updates(self):
if self._auto_updates_enabled:
with wait_for_server(self):
return self._auto_updates
def window_list(self) -> list:
"""List with dock areas in the GUI."""
return list(self._top_level.values())
# FIXME AUTO UPDATES
# @property
# def auto_updates(self):
# if self._auto_updates_enabled:
# with wait_for_server(self):
# return self._auto_updates
def _get_update_script(self) -> AutoUpdates | None:
eps = imd.entry_points(group="bec.widgets.auto_updates")
@@ -187,51 +247,53 @@ class BECGuiClient(RPCBase):
# if the module is not found, we skip it
if spec is None:
continue
return ep.load()(gui=self)
return ep.load()(gui=self._top_level["main"])
except Exception as e:
logger.error(f"Error loading auto update script from plugin: {str(e)}")
return None
@property
def selected_device(self):
"""
Selected device for the plot.
"""
auto_update_config_ep = MessageEndpoints.gui_auto_update_config(self._gui_id)
auto_update_config = self._client.connector.get(auto_update_config_ep)
if auto_update_config:
return auto_update_config.selected_device
return None
# FIXME AUTO UPDATES
# @property
# def selected_device(self) -> str | None:
# """
# Selected device for the plot.
# """
# auto_update_config_ep = MessageEndpoints.gui_auto_update_config(self._gui_id)
# auto_update_config = self._client.connector.get(auto_update_config_ep)
# if auto_update_config:
# return auto_update_config.selected_device
# return None
@selected_device.setter
def selected_device(self, device: str | DeviceBase):
if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
self._client.connector.set_and_publish(
MessageEndpoints.gui_auto_update_config(self._gui_id),
messages.GUIAutoUpdateConfigMessage(selected_device=device.name),
)
elif isinstance(device, str):
self._client.connector.set_and_publish(
MessageEndpoints.gui_auto_update_config(self._gui_id),
messages.GUIAutoUpdateConfigMessage(selected_device=device),
)
else:
raise ValueError("Device must be a string or a device object")
# @selected_device.setter
# def selected_device(self, device: str | DeviceBase):
# if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
# self._client.connector.set_and_publish(
# MessageEndpoints.gui_auto_update_config(self._gui_id),
# messages.GUIAutoUpdateConfigMessage(selected_device=device.name),
# )
# elif isinstance(device, str):
# self._client.connector.set_and_publish(
# MessageEndpoints.gui_auto_update_config(self._gui_id),
# messages.GUIAutoUpdateConfigMessage(selected_device=device),
# )
# else:
# raise ValueError("Device must be a string or a device object")
def _start_update_script(self) -> None:
self._client.connector.register(MessageEndpoints.scan_status(), cb=self._handle_msg_update)
# FIXME AUTO UPDATES
# def _start_update_script(self) -> None:
# self._client.connector.register(MessageEndpoints.scan_status(), cb=self._handle_msg_update)
def _handle_msg_update(self, msg: MessageObject) -> None:
if self.auto_updates is not None:
# pylint: disable=protected-access
return self._update_script_msg_parser(msg.value)
# def _handle_msg_update(self, msg: StreamMessage) -> None:
# if self.auto_updates is not None:
# # pylint: disable=protected-access
# return self._update_script_msg_parser(msg.value)
def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
if isinstance(msg, messages.ScanStatusMessage):
if not self.gui_is_alive():
return
if self._auto_updates_enabled:
return self.auto_updates.do_update(msg)
# def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
# if isinstance(msg, messages.ScanStatusMessage):
# if not self._gui_is_alive():
# return
# if self._auto_updates_enabled:
# return self.auto_updates.do_update(msg)
def _gui_post_startup(self):
self._top_level["main"] = WidgetDesc(
@@ -251,7 +313,7 @@ class BECGuiClient(RPCBase):
self._do_show_all()
self._gui_started_event.set()
def start_server(self, wait=False) -> None:
def _start_server(self, wait: bool = False) -> None:
"""
Start the GUI server, and execute callback when it is launched
"""
@@ -260,7 +322,11 @@ class BECGuiClient(RPCBase):
self._startup_timeout = 5
self._gui_started_event.clear()
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__,
gui_class_id=self._default_dock_name,
config=self._client._service_config.config, # pylint: disable=protected-access
logger=logger,
)
def gui_started_callback(callback):
@@ -271,7 +337,7 @@ class BECGuiClient(RPCBase):
threading.current_thread().cancel()
self._gui_started_timer = RepeatTimer(
0.5, lambda: self.gui_is_alive() and gui_started_callback(self._gui_post_startup)
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup)
)
self._gui_started_timer.start()
@@ -287,49 +353,91 @@ class BECGuiClient(RPCBase):
def _do_show_all(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("show")
rpc_client._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values():
window.widget.show()
window.show()
def show_all(self):
def _show_all(self):
with wait_for_server(self):
return self._do_show_all()
def hide_all(self):
def _hide_all(self):
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("hide")
for window in self._top_level.values():
window.widget.hide()
rpc_client._run_rpc("hide") # pylint: disable=protected-access
# because of the registry callbacks, we may have
# dock areas that are already killed, but not yet
# removed from the registry state
if not self._killed:
for window in self._top_level.values():
window.hide()
def show(self):
"""Show the GUI window."""
if self._process is not None:
return self.show_all()
return self._show_all()
# backward compatibility: show() was also starting server
return self.start_server(wait=True)
return self._start_server(wait=True)
def hide(self):
return self.hide_all()
"""Hide the GUI window."""
return self._hide_all()
@property
def main(self):
"""Return client to main dock area (in main window)"""
with wait_for_server(self):
return self._top_level["main"].widget
def new(
self,
name: str | None = None,
wait: bool = True,
geometry: tuple[int, int, int, int] | None = None,
) -> BECDockArea:
"""Create a new top-level dock area.
def new(self, title):
"""Ask main window to create a new top-level dock area"""
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
widget = rpc_client._run_rpc("new_dock_area", title)
self._top_level[widget._gui_id] = WidgetDesc(title=title, widget=widget)
return widget
def close(self) -> None:
Args:
name(str, optional): The name of the dock area. Defaults to None.
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h)
Returns:
BECDockArea: The new dock area.
"""
Close the gui window.
if len(self.window_list) == 0:
self.show()
if wait:
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
widget = rpc_client._run_rpc(
"new_dock_area", name, geometry
) # pylint: disable=protected-access
return widget
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
widget = rpc_client._run_rpc(
"new_dock_area", name, geometry
) # pylint: disable=protected-access
return widget
def delete(self, name: str) -> None:
"""Delete a dock area.
Args:
name(str): The name of the dock area.
"""
widget = self.windows.get(name)
if widget is None:
raise ValueError(f"Dock area {name} not found.")
widget._run_rpc("close") # pylint: disable=protected-access
def delete_all(self) -> None:
"""Delete all dock areas."""
for widget_name in self.windows.keys():
self.delete(widget_name)
def close(self):
"""Deprecated. Use kill_server() instead."""
# FIXME, deprecated in favor of kill, will be removed in the future
self.kill_server()
def kill_server(self) -> None:
"""Kill the GUI server."""
self._top_level.clear()
self._killed = True
if self._gui_started_timer is not None:
self._gui_started_timer.cancel()

View File

@@ -43,14 +43,21 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
def generate_client(self, class_container: BECClassContainer):
"""
Generate the client for the published classes.
Generate the client for the published classes, skipping any classes
that have `RPC = False`.
Args:
class_container: The class container with the classes to generate the client for.
"""
rpc_top_level_classes = class_container.rpc_top_level_classes
# Filter out classes that explicitly have RPC=False
rpc_top_level_classes = [
cls for cls in class_container.rpc_top_level_classes if getattr(cls, "RPC", True)
]
rpc_top_level_classes.sort(key=lambda x: x.__name__)
connector_classes = class_container.connector_classes
connector_classes = [
cls for cls in class_container.connector_classes if getattr(cls, "RPC", True)
]
connector_classes.sort(key=lambda x: x.__name__)
self.write_client_enum(rpc_top_level_classes)
@@ -81,16 +88,28 @@ class Widgets(str, enum.Enum):
class_name = cls.__name__
# Generate the content
if cls.__name__ == "BECDockArea":
if class_name == "BECDockArea":
self.content += f"""
class {class_name}(RPCBase):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
if cls.__doc__:
# We only want the first line of the docstring
# But skip the first line if it's a blank line
first_line = cls.__doc__.split("\n")[0]
if first_line:
class_docs = first_line
else:
class_docs = cls.__doc__.split("\n")[1]
self.content += f"""
\"\"\"{class_docs}\"\"\"
"""
if not cls.USER_ACCESS:
self.content += """...
"""
for method in cls.USER_ACCESS:
is_property_setter = False
obj = getattr(cls, method, None)
@@ -100,8 +119,10 @@ class {class_name}(RPCBase):"""
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."
f"Method {method} not found in class {cls.__name__}. "
f"Please check the USER_ACCESS list."
)
if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties
if is_property_setter:

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import threading
import uuid
from functools import wraps
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, cast
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
@@ -44,7 +44,7 @@ def rpc_call(func):
for key, val in kwargs.items():
if hasattr(val, "name"):
kwargs[key] = val.name
if not self.gui_is_alive():
if not self._root._gui_is_alive():
raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs)
@@ -61,10 +61,17 @@ class RPCResponseTimeoutError(Exception):
class RPCBase:
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
def __init__(
self,
gui_id: str | None = None,
config: dict | None = None,
name: str | None = None,
parent=None,
) -> None:
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
self._config = config if config is not None else {}
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
self._name = name if name is not None else str(uuid.uuid4())[:5]
self._parent = parent
self._msg_wait_event = threading.Event()
self._rpc_response = None
@@ -74,7 +81,20 @@ class RPCBase:
def __repr__(self):
type_ = type(self)
qualname = type_.__qualname__
return f"<{qualname} object at {hex(id(self))}>"
return f"<{qualname} with name: {self.widget_name}>"
def remove(self):
"""
Remove the widget.
"""
self._run_rpc("remove")
@property
def widget_name(self):
"""
Get the widget name.
"""
return self._name
@property
def _root(self):
@@ -88,7 +108,7 @@ class RPCBase:
parent = parent._parent
return parent
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs) -> Any:
"""
Run the RPC call.
@@ -165,7 +185,7 @@ class RPCBase:
return cls(parent=self, **msg_result)
return msg_result
def gui_is_alive(self):
def _gui_is_alive(self):
"""
Check if the GUI is alive.
"""

View File

@@ -1,10 +1,21 @@
from __future__ import annotations
from functools import wraps
from threading import Lock
from typing import TYPE_CHECKING, Callable
from weakref import WeakValueDictionary
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject
if TYPE_CHECKING:
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
logger = bec_logger.logger
class RPCRegister:
"""
@@ -49,7 +60,7 @@ class RPCRegister:
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
self._rpc_register.pop(rpc.gui_id, None)
def get_rpc_by_id(self, gui_id: str) -> QObject:
def get_rpc_by_id(self, gui_id: str) -> QObject | None:
"""
Get an RPC object by its ID.
@@ -57,11 +68,25 @@ class RPCRegister:
gui_id(str): The ID of the RPC object to be retrieved.
Returns:
QObject: The RPC object with the given ID.
QObject | None: The RPC object with the given ID or None
"""
rpc_object = self._rpc_register.get(gui_id, None)
return rpc_object
def get_rpc_by_name(self, name: str) -> QObject | None:
"""
Get an RPC object by its name.
Args:
name(str): The name of the RPC object to be retrieved.
Returns:
QObject | None: The RPC object with the given name.
"""
rpc_object = [rpc for rpc in self._rpc_register if rpc._name == name]
rpc_object = rpc_object[0] if len(rpc_object) > 0 else None
return rpc_object
def list_all_connections(self) -> dict:
"""
List all the registered RPC objects.
@@ -73,6 +98,19 @@ class RPCRegister:
connections = dict(self._rpc_register)
return connections
def get_names_of_rpc_by_class_type(
self, cls: BECWidget | BECConnector | BECDock | BECDockArea
) -> list[str]:
"""Get all the names of the widgets.
Args:
cls(BECWidget | BECConnector): The class of the RPC object to be retrieved.
"""
# This retrieves any rpc objects that are subclass of BECWidget,
# i.e. curve and image items are excluded
widgets = [rpc for rpc in self._rpc_register.values() if isinstance(rpc, cls)]
return [widget._name for widget in widgets]
@classmethod
def reset_singleton(cls):
"""

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
from bec_widgets.utils import BECConnector
from typing import Any
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.utils.bec_widget import BECWidget
class RPCWidgetHandler:
@@ -10,7 +13,7 @@ class RPCWidgetHandler:
self._widget_classes = None
@property
def widget_classes(self):
def widget_classes(self) -> dict[str, Any]:
"""
Get the available widget classes.
@@ -19,7 +22,7 @@ class RPCWidgetHandler:
"""
if self._widget_classes is None:
self.update_available_widgets()
return self._widget_classes
return self._widget_classes # type: ignore
def update_available_widgets(self):
"""
@@ -31,24 +34,27 @@ class RPCWidgetHandler:
from bec_widgets.utils.plugin_utils import get_custom_classes
clss = get_custom_classes("bec_widgets")
self._widget_classes = {cls.__name__: cls for cls in clss.widgets}
self._widget_classes = {
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
}
def create_widget(self, widget_type, **kwargs) -> BECConnector:
def create_widget(self, widget_type, name: str | None = None, **kwargs) -> BECWidget:
"""
Create a widget from an RPC message.
Args:
widget_type(str): The type of the widget.
name (str): The name of the widget.
**kwargs: The keyword arguments for the widget.
Returns:
widget(BECConnector): The created widget.
widget(BECWidget): The created widget.
"""
if self._widget_classes is None:
self.update_available_widgets()
widget_class = self._widget_classes.get(widget_type)
widget_class = self._widget_classes.get(widget_type) # type: ignore
if widget_class:
return widget_class(**kwargs)
return widget_class(name=name, **kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")

View File

@@ -13,7 +13,9 @@ from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import Qt, QTimer
from redis.exceptions import RedisError
from bec_widgets.cli.rpc import rpc_register
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.utils import BECDispatcher
@@ -35,6 +37,8 @@ def rpc_exception_hook(err_func):
old_exception_hook = popup.custom_exception_hook
# install err_func, if it is a callable
# IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook
# of the ErrorPopupUtility (popup instance) class.
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
err_func({"error": popup.get_error_message(exc_type, value, tb)})
@@ -55,14 +59,15 @@ class BECWidgetsCLIServer:
dispatcher: BECDispatcher = None,
client=None,
config=None,
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
gui_class: Union[BECFigure, BECDockArea] = BECDockArea,
gui_class_id: str = "bec",
) -> None:
self.status = messages.BECStatus.BUSY
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
self.gui_id = gui_id
self.gui = gui_class(gui_id=self.gui_id)
# register broadcast callback
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self.gui)
@@ -77,6 +82,8 @@ class BECWidgetsCLIServer:
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
# Create initial object -> BECFigure or BECDockArea
self.gui = gui_class(parent=None, name=gui_class_id)
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
@@ -134,6 +141,9 @@ class BECWidgetsCLIServer:
if isinstance(obj, BECConnector):
return {
"gui_id": obj.gui_id,
"name": (
obj._name if hasattr(obj, "_name") else obj.__class__.__name__
), # pylint: disable=protected-access
"widget_class": obj.__class__.__name__,
"config": obj.config.model_dump(),
"__rpc__": True,
@@ -142,11 +152,14 @@ class BECWidgetsCLIServer:
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,
)
try:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
except RedisError as exc:
logger.error(f"Error while emitting heartbeat: {exc}")
def 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}")
@@ -175,7 +188,12 @@ class SimpleFileLikeFromLogOutputFunc:
return
def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config: str | None = None):
def _start_server(
gui_id: str,
gui_class: Union[BECFigure, BECDockArea],
gui_class_id: str = "bec",
config: str | None = None,
):
if config:
try:
config = json.loads(config)
@@ -192,7 +210,9 @@ def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config:
# service_name="BECWidgetsCLIServer",
# service_config=service_config.service_config,
# )
server = BECWidgetsCLIServer(gui_id=gui_id, config=service_config, gui_class=gui_class)
server = BECWidgetsCLIServer(
gui_id=gui_id, config=service_config, gui_class=gui_class, gui_class_id=gui_class_id
)
return server
@@ -213,6 +233,12 @@ def main():
type=str,
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
)
parser.add_argument(
"--gui_class_id",
type=str,
default="bec",
help="The id of the gui class that is added to the QApplication",
)
parser.add_argument("--config", type=str, help="Config file or config string.")
parser.add_argument("--hide", action="store_true", help="Hide on startup")
@@ -252,14 +278,14 @@ def main():
# store gui id within QApplication object, to make it available to all widgets
app.gui_id = args.id
server = _start_server(args.id, gui_class, args.config)
# args.id = "abff6"
server = _start_server(args.id, gui_class, args.gui_class_id, args.config)
win = BECMainWindow(gui_id=f"{server.gui_id}:window")
win.setAttribute(Qt.WA_ShowWithoutActivating)
win.setWindowTitle("BEC Widgets")
win.setWindowTitle("BEC")
RPCRegister().add_rpc(win)
gui = server.gui
win.setCentralWidget(gui)
if not args.hide:

View File

@@ -20,6 +20,8 @@ from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
class JupyterConsoleWindow(QWidget): # pragma: no cover:
@@ -50,8 +52,6 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"w10": self.w10,
"d0": self.d0,
"d1": self.d1,
"d2": self.d2,
"wave": self.wf,
"im": self.im,
"mm": self.mm,
"mw": self.mw,
@@ -62,6 +62,9 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"btn4": self.btn4,
"btn5": self.btn5,
"btn6": self.btn6,
"pb": self.pb,
"pi": self.pi,
"wf": self.wf,
}
)
@@ -92,6 +95,15 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
third_tab_layout.addWidget(self.lm)
tab_widget.addTab(third_tab, "Layout Manager Widget")
fourth_tab = QWidget()
fourth_tab_layout = QVBoxLayout(fourth_tab)
self.pb = PlotBase()
self.pi = self.pb.plot_item
fourth_tab_layout.addWidget(self.pb)
tab_widget.addTab(fourth_tab, "PlotBase")
tab_widget.setCurrentIndex(3)
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
@@ -105,6 +117,15 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.btn5 = QPushButton("Button 5")
self.btn6 = QPushButton("Button 6")
fifth_tab = QWidget()
fifth_tab_layout = QVBoxLayout(fifth_tab)
self.wf = Waveform()
fifth_tab_layout.addWidget(self.wf)
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
tab_widget.setCurrentIndex(4)
# add stuff to the new Waveform widget
self._init_waveform()
# add stuff to figure
self._init_figure()
@@ -113,6 +134,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.setWindowTitle("Jupyter Console Window")
def _init_waveform(self):
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve1")
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve2")
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve3")
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
def _init_figure(self):
self.w1 = self.figure.plot(x_name="samx", y_name="bpm4i", row=0, col=0)
self.w1.set(
@@ -170,18 +198,19 @@ 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.d0 = self.dock.new(name="dock_0")
self.mm = self.d0.new("BECMotorMapWidget")
self.mm.change_motors("samx", "samy")
self.d1 = self.dock.add_dock(name="dock_1", position="right")
self.im = self.d1.add_widget("BECImageWidget")
self.d1 = self.dock.new(name="dock_1", position="right")
self.im = self.d1.new("BECImageWidget")
self.im.image("waveform", "1d")
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
self.wf = self.d2.add_widget("BECFigure", row=0, col=0)
self.d2 = self.dock.new(name="dock_2", position="bottom")
self.wf = self.d2.new("BECFigure", row=0, col=0)
self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
self.mw = None # self.wf.multi_waveform(monitor="waveform") # , config=config)
self.dock.save_state()
@@ -207,7 +236,7 @@ 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 = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher()

View File

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

View File

@@ -1,6 +1,6 @@
import pyqtgraph as pg
from qtpy.QtCore import Property
from qtpy.QtWidgets import QApplication, QFrame, QVBoxLayout, QWidget
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
@@ -29,23 +29,22 @@ class RoundedFrame(BECWidget, QFrame):
self._radius = radius
# Apply rounded frame styling
self.setProperty("skip_settings", True)
self.setObjectName("roundedFrame")
self.update_style()
# Create a layout for the frame
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
# Add the content widget to the layout
if content_widget:
layout.addWidget(content_widget)
self.layout.addWidget(content_widget)
# Store reference to the content widget
self.content_widget = content_widget
# Automatically apply initial styles to the PlotWidget if applicable
if isinstance(content_widget, pg.PlotWidget):
self.apply_plot_widget_style()
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
self.apply_plot_widget_style()
self._connect_to_theme_change()
@@ -64,10 +63,6 @@ class RoundedFrame(BECWidget, QFrame):
self.update_style()
# Update PlotWidget's background color and axis styles if applicable
if isinstance(self.content_widget, pg.PlotWidget):
self.apply_plot_widget_style()
@Property(int)
def radius(self):
"""Radius of the rounded corners."""
@@ -91,6 +86,7 @@ class RoundedFrame(BECWidget, QFrame):
}}
"""
)
self.apply_plot_widget_style()
def apply_plot_widget_style(self, border: str = "none"):
"""
@@ -99,33 +95,16 @@ class RoundedFrame(BECWidget, QFrame):
Args:
border (str): Border style (e.g., 'none', '1px solid red').
"""
if isinstance(self.content_widget, pg.PlotWidget):
# Sync PlotWidget's background color with the RoundedFrame's background color
self.content_widget.setBackground(self.background_color)
# Calculate contrast-optimized axis and label colors
if self.background_color == "#e9ecef": # Light mode
label_color = "#000000"
axis_color = "#666666"
else: # Dark mode
label_color = "#FFFFFF"
axis_color = "#CCCCCC"
# Apply axis label and tick colors
plot_item = self.content_widget.getPlotItem()
plot_item.getAxis("left").setPen(pg.mkPen(color=axis_color))
plot_item.getAxis("bottom").setPen(pg.mkPen(color=axis_color))
plot_item.getAxis("left").setTextPen(pg.mkPen(color=label_color))
plot_item.getAxis("bottom").setTextPen(pg.mkPen(color=label_color))
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
# Apply border style via stylesheet
self.content_widget.setStyleSheet(
f"""
PlotWidget {{
GraphicsLayoutWidget {{
border: {border}; /* Explicitly set the border */
}}
"""
)
self.content_widget.setBackground(self.background_color)
class ExampleApp(QWidget): # pragma: no cover
@@ -139,26 +118,27 @@ class ExampleApp(QWidget): # pragma: no cover
dark_button = DarkModeButton()
# Create PlotWidgets
plot1 = pg.PlotWidget()
plot1.plot([1, 3, 2, 4, 6, 5], pen="r")
plot1 = pg.GraphicsLayoutWidget()
plot_item_1 = pg.PlotItem()
plot_item_1.plot([1, 3, 2, 4, 6, 5], pen="r")
plot1.plot_item = plot_item_1
plot2 = pg.PlotWidget()
plot2.plot([1, 2, 4, 8, 16, 32], pen="r")
plot2 = pg.GraphicsLayoutWidget()
plot_item_2 = pg.PlotItem()
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
plot2.plot_item = plot_item_2
# Wrap PlotWidgets in RoundedFrame
rounded_plot1 = RoundedFrame(content_widget=plot1, theme_update=True)
rounded_plot2 = RoundedFrame(content_widget=plot2, theme_update=True)
round = RoundedFrame()
# Add to layout
layout.addWidget(dark_button)
layout.addWidget(rounded_plot1)
layout.addWidget(rounded_plot2)
layout.addWidget(round)
self.setLayout(layout)
# Simulate theme change after 2 seconds
from qtpy.QtCore import QTimer
def change_theme():

View File

@@ -1,6 +1,6 @@
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.qt_utils.error_popups import SafeSlot
class SettingWidget(QWidget):
@@ -20,14 +20,14 @@ class SettingWidget(QWidget):
def set_target_widget(self, target_widget: QWidget):
self.target_widget = target_widget
@Slot()
@SafeSlot()
def accept_changes(self):
"""
Accepts the changes made in the settings widget and applies them to the target widget.
"""
pass
@Slot(dict)
@SafeSlot(dict)
def display_current_settings(self, config_dict: dict):
"""
Displays the current settings of the target widget in the settings widget.
@@ -54,12 +54,13 @@ class SettingsDialog(QDialog):
settings_widget: SettingWidget = None,
window_title: str = "Settings",
config: dict = None,
modal: bool = False,
*args,
**kwargs,
):
super().__init__(parent, *args, **kwargs)
self.setModal(False)
self.setModal(modal)
self.setWindowTitle(window_title)
@@ -92,7 +93,7 @@ class SettingsDialog(QDialog):
ok_button.setDefault(True)
ok_button.setAutoDefault(True)
@Slot()
@SafeSlot()
def accept(self):
"""
Accept the changes made in the settings widget and close the dialog.
@@ -100,7 +101,7 @@ class SettingsDialog(QDialog):
self.widget.accept_changes()
super().accept()
@Slot()
@SafeSlot()
def apply_changes(self):
"""
Apply the changes made in the settings widget without closing the dialog.

View File

@@ -5,18 +5,18 @@ from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation
from qtpy.QtGui import QAction
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QScrollArea,
QSizePolicy,
QSpacerItem,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
class SidePanel(QWidget):
@@ -34,11 +34,13 @@ class SidePanel(QWidget):
):
super().__init__(parent=parent)
self.setProperty("skip_settings", True)
self.setObjectName("SidePanel")
self._orientation = orientation
self._panel_max_width = panel_max_width
self._animation_duration = animation_duration
self._animations_enabled = animations_enabled
self._orientation = orientation
self._panel_width = 0
self._panel_height = 0
@@ -68,6 +70,7 @@ class SidePanel(QWidget):
self.stack_widget = QStackedWidget()
self.stack_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.stack_widget.setMinimumWidth(5)
self.stack_widget.setMaximumWidth(self._panel_max_width)
if self._orientation == "left":
self.main_layout.addWidget(self.toolbar)
@@ -77,7 +80,10 @@ class SidePanel(QWidget):
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
self.stack_widget.setMaximumWidth(self._panel_max_width)
self.menu_anim = QPropertyAnimation(self, b"panel_width")
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.panel_width = 0 # start hidden
else:
self.main_layout = QVBoxLayout(self)
@@ -94,6 +100,7 @@ class SidePanel(QWidget):
self.stack_widget = QStackedWidget()
self.stack_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.stack_widget.setMinimumHeight(5)
self.stack_widget.setMaximumHeight(self._panel_max_width)
if self._orientation == "top":
self.main_layout.addWidget(self.toolbar)
@@ -103,74 +110,46 @@ class SidePanel(QWidget):
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
self.stack_widget.setMaximumHeight(self._panel_max_width)
if self._orientation in ("left", "right"):
self.menu_anim = QPropertyAnimation(self, b"panel_width")
else:
self.menu_anim = QPropertyAnimation(self, b"panel_height")
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.panel_height = 0 # start hidden
self.menu_anim.setDuration(self._animation_duration)
self.menu_anim.setEasingCurve(QEasingCurve.InOutQuad)
if self._orientation in ("left", "right"):
self.panel_width = 0
else:
self.panel_height = 0
@Property(int)
def panel_width(self):
"""
Get the panel width.
"""
"""Get the panel width."""
return self._panel_width
@panel_width.setter
def panel_width(self, width: int):
"""
Set the panel width.
Args:
width(int): The width of the panel.
"""
"""Set the panel width."""
self._panel_width = width
if self._orientation in ("left", "right"):
self.stack_widget.setFixedWidth(width)
@Property(int)
def panel_height(self):
"""
Get the panel height.
"""
"""Get the panel height."""
return self._panel_height
@panel_height.setter
def panel_height(self, height: int):
"""
Set the panel height.
Args:
height(int): The height of the panel.
"""
"""Set the panel height."""
self._panel_height = height
if self._orientation in ("top", "bottom"):
self.stack_widget.setFixedHeight(height)
@Property(int)
def panel_max_width(self):
"""
Get the maximum width of the panel.
"""
"""Get the maximum width of the panel."""
return self._panel_max_width
@panel_max_width.setter
def panel_max_width(self, size: int):
"""
Set the maximum width of the panel.
Args:
size(int): The maximum width of the panel.
"""
"""Set the maximum width of the panel."""
self._panel_max_width = size
if self._orientation in ("left", "right"):
self.stack_widget.setMaximumWidth(self._panel_max_width)
@@ -179,45 +158,28 @@ class SidePanel(QWidget):
@Property(int)
def animation_duration(self):
"""
Get the duration of the animation.
"""
"""Get the duration of the animation."""
return self._animation_duration
@animation_duration.setter
def animation_duration(self, duration: int):
"""
Set the duration of the animation.
Args:
duration(int): The duration of the animation.
"""
"""Set the duration of the animation."""
self._animation_duration = duration
self.menu_anim.setDuration(duration)
@Property(bool)
def animations_enabled(self):
"""
Get the status of the animations.
"""
"""Get the status of the animations."""
return self._animations_enabled
@animations_enabled.setter
def animations_enabled(self, enabled: bool):
"""
Set the status of the animations.
Args:
enabled(bool): The status of the animations.
"""
"""Set the status of the animations."""
self._animations_enabled = enabled
def show_panel(self, idx: int):
"""
Show the side panel with animation and switch to idx.
Args:
idx(int): The index of the panel to show.
"""
self.stack_widget.setCurrentIndex(idx)
self.panel_visible = True
@@ -265,9 +227,6 @@ class SidePanel(QWidget):
def switch_to(self, idx: int):
"""
Switch to the specified index without animation.
Args:
idx(int): The index of the panel to switch to.
"""
if self.current_index != idx:
self.stack_widget.setCurrentIndex(idx)
@@ -284,21 +243,35 @@ class SidePanel(QWidget):
widget(QWidget): The widget to add to the panel.
title(str): The title of the panel.
"""
# container_widget: top-level container for the stacked page
container_widget = QWidget()
container_layout = QVBoxLayout(container_widget)
container_widget.setStyleSheet("background-color: rgba(0,0,0,0);")
title_label = QLabel(f"<b>{title}</b>")
title_label.setStyleSheet("font-size: 16px;")
spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
container_layout.addWidget(title_label)
container_layout.addWidget(widget)
container_layout.addItem(spacer)
container_layout.setContentsMargins(5, 5, 5, 5)
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.setSpacing(5)
title_label = QLabel(f"<b>{title}</b>")
title_label.setStyleSheet("font-size: 16px;")
container_layout.addWidget(title_label)
# Create a QScrollArea for the actual widget to ensure scrolling if the widget inside is too large
scroll_area = QScrollArea()
scroll_area.setFrameShape(QFrame.NoFrame)
scroll_area.setWidgetResizable(True)
# Let the scroll area expand in both directions if there's room
scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
scroll_area.setWidget(widget)
# Put the scroll area in the container layout
container_layout.addWidget(scroll_area)
# Optionally stretch the scroll area to fill vertical space
container_layout.setStretchFactor(scroll_area, 1)
# Add container_widget to the stacked widget
index = self.stack_widget.count()
self.stack_widget.addWidget(container_widget)
# Add an action to the toolbar
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
@@ -326,6 +299,11 @@ class SidePanel(QWidget):
action.action.toggled.connect(on_action_toggled)
############################################
# DEMO APPLICATION
############################################
class ExampleApp(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
@@ -333,20 +311,24 @@ class ExampleApp(QMainWindow): # pragma: no cover
central_widget = QWidget()
self.setCentralWidget(central_widget)
self.side_panel = SidePanel(self, orientation="left")
self.layout = QHBoxLayout(central_widget)
# Create side panel
self.side_panel = SidePanel(self, orientation="left", panel_max_width=250)
self.layout.addWidget(self.side_panel)
self.plot = BECWaveformWidget()
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
self.plot = Waveform()
self.layout.addWidget(self.plot)
self.add_side_menus()
def add_side_menus(self):
widget1 = QWidget()
widget1_layout = QVBoxLayout(widget1)
widget1_layout.addWidget(QLabel("This is Widget 1"))
layout1 = QVBoxLayout(widget1)
for i in range(15):
layout1.addWidget(QLabel(f"Widget 1 label row {i}"))
self.side_panel.add_menu(
action_id="widget1",
icon_name="counter_1",
@@ -356,8 +338,8 @@ class ExampleApp(QMainWindow): # pragma: no cover
)
widget2 = QWidget()
widget2_layout = QVBoxLayout(widget2)
widget2_layout.addWidget(QLabel("This is Widget 2"))
layout2 = QVBoxLayout(widget2)
layout2.addWidget(QLabel("Short widget 2 content"))
self.side_panel.add_menu(
action_id="widget2",
icon_name="counter_2",
@@ -367,8 +349,9 @@ class ExampleApp(QMainWindow): # pragma: no cover
)
widget3 = QWidget()
widget3_layout = QVBoxLayout(widget3)
widget3_layout.addWidget(QLabel("This is Widget 3"))
layout3 = QVBoxLayout(widget3)
for i in range(10):
layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
self.side_panel.add_menu(
action_id="widget3",
icon_name="counter_3",
@@ -381,6 +364,6 @@ class ExampleApp(QMainWindow): # pragma: no cover
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = ExampleApp()
window.resize(800, 600)
window.resize(1000, 700)
window.show()
sys.exit(app.exec())

View File

@@ -8,7 +8,7 @@ from collections import defaultdict
from typing import Dict, List, Literal, Tuple
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt
from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtWidgets import (
QApplication,
@@ -18,15 +18,54 @@ from qtpy.QtWidgets import (
QMainWindow,
QMenu,
QSizePolicy,
QStyle,
QToolBar,
QToolButton,
QVBoxLayout,
QWidget,
)
import bec_widgets
from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
# Ensure that icons are shown in menus (especially on macOS)
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
class LongPressToolButton(QToolButton):
def __init__(self, *args, long_press_threshold=500, **kwargs):
super().__init__(*args, **kwargs)
self.long_press_threshold = long_press_threshold
self._long_press_timer = QTimer(self)
self._long_press_timer.setSingleShot(True)
self._long_press_timer.timeout.connect(self.handleLongPress)
self._pressed = False
self._longPressed = False
def mousePressEvent(self, event):
self._pressed = True
self._longPressed = False
self._long_press_timer.start(self.long_press_threshold)
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
self._pressed = False
if self._longPressed:
self._longPressed = False
self._long_press_timer.stop()
event.accept() # Prevent normal click action after a long press
return
self._long_press_timer.stop()
super().mouseReleaseEvent(event)
def handleLongPress(self):
if self._pressed:
self._longPressed = True
self.showMenu()
class ToolBarAction(ABC):
"""
@@ -84,6 +123,21 @@ class IconAction(ToolBarAction):
toolbar.addAction(self.action)
class QtIconAction(ToolBarAction):
def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.standard_icon = standard_icon
self.icon = QApplication.style().standardIcon(standard_icon)
self.action = QAction(self.icon, self.tooltip, parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar, target):
toolbar.addAction(self.action)
def get_icon(self):
return self.icon
class MaterialIconAction(ToolBarAction):
"""
Action with a Material icon for the toolbar.
@@ -111,7 +165,7 @@ class MaterialIconAction(ToolBarAction):
self.icon_name = icon_name
self.filled = filled
self.color = color
# Generate the icon
# Generate the icon using the material_icon helper
self.icon = material_icon(
self.icon_name,
size=(20, 20),
@@ -119,7 +173,6 @@ class MaterialIconAction(ToolBarAction):
filled=self.filled,
color=self.color,
)
# Immediately create an QAction with the given parent
self.action = QAction(self.icon, self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
@@ -152,7 +205,7 @@ class DeviceSelectionAction(ToolBarAction):
device_combobox (DeviceComboBox): The combobox for selecting the device.
"""
def __init__(self, label: str, device_combobox):
def __init__(self, label: str | None = None, device_combobox=None):
super().__init__()
self.label = label
self.device_combobox = device_combobox
@@ -161,15 +214,101 @@ class DeviceSelectionAction(ToolBarAction):
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)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
if self.label is not None:
label = QLabel(f"{self.label}")
layout.addWidget(label)
if self.device_combobox is not None:
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 SwitchableToolBarAction(ToolBarAction):
"""
A split toolbar action that combines a main action and a drop-down menu for additional actions.
The main button displays the currently selected action's icon and tooltip. Clicking on the main button
triggers that action. Clicking on the drop-down arrow displays a menu with alternative actions. When an
alternative action is selected, it becomes the new default and its callback is immediately executed.
This design mimics the behavior seen in Adobe Photoshop or Affinity Designer toolbars.
Args:
actions (dict): A dictionary mapping a unique key to a ToolBarAction instance.
initial_action (str, optional): The key of the initial default action. If not provided, the first action is used.
tooltip (str, optional): An optional tooltip for the split action; if provided, it overrides the default action's tooltip.
checkable (bool, optional): Whether the action is checkable. Defaults to True.
parent (QWidget, optional): Parent widget for the underlying QAction.
"""
def __init__(
self,
actions: Dict[str, ToolBarAction],
initial_action: str = None,
tooltip: str = None,
checkable: bool = True,
default_state_checked: bool = False,
parent=None,
):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.actions = actions
self.current_key = initial_action if initial_action is not None else next(iter(actions))
self.parent = parent
self.checkable = checkable
self.default_state_checked = default_state_checked
self.main_button = None
self.menu_actions: Dict[str, QAction] = {}
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the split action to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the action to.
target (QWidget): The target widget for the action.
"""
self.main_button = LongPressToolButton(toolbar)
self.main_button.setPopupMode(QToolButton.MenuButtonPopup)
self.main_button.setCheckable(self.checkable)
default_action = self.actions[self.current_key]
self.main_button.setIcon(default_action.get_icon())
self.main_button.setToolTip(default_action.tooltip)
self.main_button.clicked.connect(self._trigger_current_action)
menu = QMenu(self.main_button)
self.menu_actions = {}
for key, action_obj in self.actions.items():
menu_action = QAction(action_obj.get_icon(), action_obj.tooltip, self.main_button)
menu_action.setIconVisibleInMenu(True)
menu_action.setCheckable(self.checkable)
menu_action.setChecked(key == self.current_key)
menu_action.triggered.connect(lambda checked, k=key: self.set_default_action(k))
menu.addAction(menu_action)
self.menu_actions[key] = menu_action
self.main_button.setMenu(menu)
toolbar.addWidget(self.main_button)
def _trigger_current_action(self):
action_obj = self.actions[self.current_key]
action_obj.action.trigger()
def set_default_action(self, key: str):
self.current_key = key
new_action = self.actions[self.current_key]
self.main_button.setIcon(new_action.get_icon())
self.main_button.setToolTip(new_action.tooltip)
# Update check state of menu items
for k, menu_act in self.menu_actions.items():
menu_act.setChecked(k == key)
new_action.action.trigger()
def get_icon(self) -> QIcon:
return self.actions[self.current_key].get_icon()
class WidgetAction(ToolBarAction):
"""
Action for adding any widget to the toolbar.
@@ -180,15 +319,23 @@ class WidgetAction(ToolBarAction):
"""
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
super().__init__(parent)
super().__init__(icon_path=None, tooltip=label, checkable=False)
self.label = label
self.widget = widget
self.container = None
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
container = QWidget()
layout = QHBoxLayout(container)
"""
Adds the widget to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the widget to.
target (QWidget): The target widget for the action.
"""
self.container = QWidget()
layout = QHBoxLayout(self.container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
layout.setSpacing(0)
if self.label is not None:
label_widget = QLabel(f"{self.label}")
@@ -209,19 +356,12 @@ class WidgetAction(ToolBarAction):
layout.addWidget(self.widget)
toolbar.addWidget(container)
toolbar.addWidget(self.container)
# Store the container as the action to allow toggling visibility.
self.action = self.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
@@ -261,12 +401,15 @@ class ExpandableMenuAction(ToolBarAction):
menu = QMenu(button)
for action_id, action in self.actions.items():
sub_action = QAction(action.tooltip, target)
if hasattr(action, "icon_path"):
sub_action.setIconVisibleInMenu(True)
if 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())
elif hasattr(action, "get_icon") and callable(action.get_icon):
sub_icon = action.get_icon()
if sub_icon and not sub_icon.isNull():
sub_action.setIcon(sub_icon)
sub_action.setCheckable(action.checkable)
menu.addAction(sub_action)
self.widgets[action_id] = sub_action
@@ -289,7 +432,6 @@ class ToolbarBundle:
self.bundle_id = bundle_id
self._actions: dict[str, ToolBarAction] = {}
# If you passed in a list of tuples, load them into the dictionary
if actions is not None:
for action_id, action in actions:
self._actions[action_id] = action
@@ -331,7 +473,7 @@ class ModularToolBar(QToolBar):
actions (dict, optional): A dictionary of action creators to populate the toolbar. Defaults to None.
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
orientation (Literal["horizontal", "vertical"], optional): The initial orientation of the toolbar. Defaults to "horizontal".
background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)" - transparent background.
background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)".
"""
def __init__(
@@ -378,7 +520,7 @@ class ModularToolBar(QToolBar):
Sets the background color and other appearance settings.
Args:
color(str): The background color of the toolbar.
color (str): The background color of the toolbar.
"""
self.setIconSize(QSize(20, 20))
self.setMovable(False)
@@ -402,100 +544,133 @@ class ModularToolBar(QToolBar):
def update_material_icon_colors(self, new_color: str | tuple | QColor):
"""
Updates the color of all MaterialIconAction icons in the toolbar.
Updates the color of all MaterialIconAction icons.
Args:
new_color (str | tuple | QColor): The new color for the icons.
new_color (str | tuple | QColor): The new color.
"""
for action in self.widgets.values():
if isinstance(action, MaterialIconAction):
action.color = new_color
# Refresh the icon
updated_icon = action.get_icon()
action.action.setIcon(updated_icon)
def add_action(self, action_id: str, action: ToolBarAction, target_widget: QWidget):
"""
Adds a new standalone action to the toolbar dynamically.
Adds a new standalone action dynamically.
Args:
action_id (str): Unique identifier for the action.
action (ToolBarAction): The action to add to the toolbar.
target_widget (QWidget): The target widget for the action.
action_id (str): Unique identifier.
action (ToolBarAction): The action to add.
target_widget (QWidget): The target widget.
"""
if action_id in self.widgets:
raise ValueError(f"Action with ID '{action_id}' already exists.")
action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action
self.toolbar_items.append(("action", action_id))
self.update_separators() # Update separators after adding the action
self.update_separators()
def hide_action(self, action_id: str):
"""
Hides a specific action on the toolbar.
Hides a specific action.
Args:
action_id (str): Unique identifier for the action to hide.
action_id (str): Unique identifier.
"""
if action_id not in self.widgets:
raise ValueError(f"Action with ID '{action_id}' does not exist.")
action = self.widgets[action_id]
if hasattr(action, "action") and isinstance(action.action, QAction):
if hasattr(action, "action") and action.action is not None:
action.action.setVisible(False)
self.update_separators() # Update separators after hiding the action
self.update_separators()
def show_action(self, action_id: str):
"""
Shows a specific action on the toolbar.
Shows a specific action.
Args:
action_id (str): Unique identifier for the action to show.
action_id (str): Unique identifier.
"""
if action_id not in self.widgets:
raise ValueError(f"Action with ID '{action_id}' does not exist.")
action = self.widgets[action_id]
if hasattr(action, "action") and isinstance(action.action, QAction):
if hasattr(action, "action") and action.action is not None:
action.action.setVisible(True)
self.update_separators() # Update separators after showing the action
self.update_separators()
def add_bundle(self, bundle: ToolbarBundle, target_widget: QWidget):
"""
Adds a bundle of actions to the toolbar, separated by a separator.
Adds a bundle of actions, separated by a separator.
Args:
bundle (ToolbarBundle): The bundle to add.
target_widget (QWidget): The target widget for the actions.
bundle (ToolbarBundle): The bundle.
target_widget (QWidget): The target widget.
"""
if bundle.bundle_id in self.bundles:
raise ValueError(f"ToolbarBundle with ID '{bundle.bundle_id}' already exists.")
# Add a separator before the bundle (but not to first one)
if self.toolbar_items:
sep = SeparatorAction()
sep.add_to_toolbar(self, target_widget)
self.toolbar_items.append(("separator", None))
# Add each action in the bundle
for action_id, action_obj in bundle.actions.items():
action_obj.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action_obj
# Register the bundle
self.bundles[bundle.bundle_id] = list(bundle.actions.keys())
self.toolbar_items.append(("bundle", bundle.bundle_id))
self.update_separators()
self.update_separators() # Update separators after adding the bundle
def add_action_to_bundle(self, bundle_id: str, action_id: str, action, target_widget: QWidget):
"""
Dynamically adds an action to an existing bundle.
Args:
bundle_id (str): The bundle ID.
action_id (str): Unique identifier.
action (ToolBarAction): The action to add.
target_widget (QWidget): The target widget.
"""
if bundle_id not in self.bundles:
raise ValueError(f"Bundle '{bundle_id}' does not exist.")
if action_id in self.widgets:
raise ValueError(f"Action with ID '{action_id}' already exists.")
action.add_to_toolbar(self, target_widget)
new_qaction = action.action
self.removeAction(new_qaction)
bundle_action_ids = self.bundles[bundle_id]
if bundle_action_ids:
last_bundle_action = self.widgets[bundle_action_ids[-1]].action
actions_list = self.actions()
try:
index = actions_list.index(last_bundle_action)
except ValueError:
self.addAction(new_qaction)
else:
if index + 1 < len(actions_list):
before_action = actions_list[index + 1]
self.insertAction(before_action, new_qaction)
else:
self.addAction(new_qaction)
else:
self.addAction(new_qaction)
self.widgets[action_id] = action
self.bundles[bundle_id].append(action_id)
self.update_separators()
def contextMenuEvent(self, event):
"""
Overrides the context menu event to show a list of toolbar actions with checkboxes and icons, including separators.
Overrides the context menu event to show toolbar actions with checkboxes and icons.
Args:
event(QContextMenuEvent): The context menu event.
event (QContextMenuEvent): The context menu event.
"""
menu = QMenu(self)
# Iterate through the toolbar items in order
for item_type, identifier in self.toolbar_items:
if item_type == "separator":
menu.addSeparator()
@@ -503,18 +678,16 @@ class ModularToolBar(QToolBar):
self.handle_bundle_context_menu(menu, identifier)
elif item_type == "action":
self.handle_action_context_menu(menu, identifier)
# Connect the triggered signal after all actions are added
menu.triggered.connect(self.handle_menu_triggered)
menu.exec_(event.globalPos())
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
"""
Adds a set of bundle actions to the context menu.
Adds bundle actions to the context menu.
Args:
menu (QMenu): The context menu to which the actions are added.
bundle_id (str): The identifier for the bundle.
menu (QMenu): The context menu.
bundle_id (str): The bundle identifier.
"""
action_ids = self.bundles.get(bundle_id, [])
for act_id in action_ids:
@@ -535,7 +708,6 @@ class ModularToolBar(QToolBar):
# Set the icon if available
if qaction.icon() and not qaction.icon().isNull():
menu_action.setIcon(qaction.icon())
menu.addAction(menu_action)
def handle_action_context_menu(self, menu: QMenu, action_id: str):
@@ -565,73 +737,95 @@ class ModularToolBar(QToolBar):
menu.addAction(menu_action)
def handle_menu_triggered(self, action):
"""Handles the toggling of toolbar actions from the context menu."""
"""
Handles the triggered signal from the context menu.
Args:
action: Action triggered.
"""
action_id = action.data()
if action_id:
self.toggle_action_visibility(action_id, action.isChecked())
def toggle_action_visibility(self, action_id: str, visible: bool):
"""
Toggles the visibility of a specific action on the toolbar.
Toggles the visibility of a specific action.
Args:
action_id(str): Unique identifier for the action to toggle.
visible(bool): Whether the action should be visible.
action_id (str): Unique identifier.
visible (bool): Whether the action should be visible.
"""
if action_id not in self.widgets:
return
tool_action = self.widgets[action_id]
if hasattr(tool_action, "action") and isinstance(tool_action.action, QAction):
if hasattr(tool_action, "action") and tool_action.action is not None:
tool_action.action.setVisible(visible)
self.update_separators()
def update_separators(self):
"""
Hide separators that are adjacent to another separator or have no actions next to them.
Hide separators that are adjacent to another separator or have no non-separator actions between them.
"""
toolbar_actions = self.actions()
# First pass: set visibility based on surrounding non-separator actions.
for i, action in enumerate(toolbar_actions):
if not action.isSeparator():
continue
# Find the previous visible action
prev_visible = None
for j in range(i - 1, -1, -1):
if toolbar_actions[j].isVisible():
prev_visible = toolbar_actions[j]
break
# Find the next visible action
next_visible = None
for j in range(i + 1, len(toolbar_actions)):
if toolbar_actions[j].isVisible():
next_visible = toolbar_actions[j]
break
# Determine if the separator should be hidden
# Hide if both previous and next visible actions are separators or non-existent
if (prev_visible is None or prev_visible.isSeparator()) and (
next_visible is None or next_visible.isSeparator()
):
action.setVisible(False)
else:
action.setVisible(True)
# Second pass: ensure no two visible separators are adjacent.
prev = None
for action in toolbar_actions:
if action.isVisible() and action.isSeparator():
if prev and prev.isSeparator():
action.setVisible(False)
else:
prev = action
else:
if action.isVisible():
prev = action
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
self.test_label = QLabel(text="This is a test label.")
self.central_widget.layout = QVBoxLayout(self.central_widget)
self.central_widget.layout.addWidget(self.test_label)
# Create a modular toolbar
self.toolbar = ModularToolBar(parent=self, target_widget=self)
self.addToolBar(self.toolbar)
# Example: Add a single bundle
self.add_switchable_button_checkable()
self.add_switchable_button_non_checkable()
self.add_widget_actions()
self.add_bundles()
self.add_menus()
# For theme testing
self.dark_button = DarkModeButton(toolbar=True)
dark_mode_action = WidgetAction(label=None, widget=self.dark_button)
self.toolbar.add_action("dark_mode", dark_mode_action, self)
def add_bundles(self):
home_action = MaterialIconAction(
icon_name="home", tooltip="Home", checkable=True, parent=self
)
@@ -651,12 +845,11 @@ class MainWindow(QMainWindow): # pragma: no cover
)
self.toolbar.add_bundle(main_actions_bundle, target_widget=self)
# Another bundle
search_action = MaterialIconAction(
icon_name="search", tooltip="Search", checkable=True, parent=self
icon_name="search", tooltip="Search", checkable=False, parent=self
)
help_action = MaterialIconAction(
icon_name="help", tooltip="Help", checkable=True, parent=self
icon_name="help", tooltip="Help", checkable=False, parent=self
)
second_bundle = ToolbarBundle(
bundle_id="secondary_actions",
@@ -664,9 +857,102 @@ class MainWindow(QMainWindow): # pragma: no cover
)
self.toolbar.add_bundle(second_bundle, target_widget=self)
new_action = MaterialIconAction(
icon_name="counter_1", tooltip="New Action", checkable=True, parent=self
)
self.toolbar.add_action_to_bundle(
"main_actions", "new_action", new_action, target_widget=self
)
def add_menus(self):
menu_material_actions = {
"mat1": MaterialIconAction(
icon_name="home", tooltip="Material Home", checkable=True, parent=self
),
"mat2": MaterialIconAction(
icon_name="settings", tooltip="Material Settings", checkable=True, parent=self
),
"mat3": MaterialIconAction(
icon_name="info", tooltip="Material Info", checkable=True, parent=self
),
}
menu_qt_actions = {
"qt1": QtIconAction(
standard_icon=QStyle.SP_FileIcon, tooltip="Qt File", checkable=True, parent=self
),
"qt2": QtIconAction(
standard_icon=QStyle.SP_DirIcon, tooltip="Qt Directory", checkable=True, parent=self
),
"qt3": QtIconAction(
standard_icon=QStyle.SP_TrashIcon, tooltip="Qt Trash", checkable=True, parent=self
),
}
expandable_menu_material = ExpandableMenuAction(
label="Material Menu", actions=menu_material_actions
)
expandable_menu_qt = ExpandableMenuAction(label="Qt Menu", actions=menu_qt_actions)
self.toolbar.add_action("material_menu", expandable_menu_material, self)
self.toolbar.add_action("qt_menu", expandable_menu_qt, self)
def add_switchable_button_checkable(self):
action1 = MaterialIconAction(
icon_name="counter_1", tooltip="Action 1", checkable=True, parent=self
)
action2 = MaterialIconAction(
icon_name="counter_2", tooltip="Action 2", checkable=True, parent=self
)
switchable_action = SwitchableToolBarAction(
actions={"action1": action1, "action2": action2},
initial_action="action1",
tooltip="Switchable Action",
checkable=True,
parent=self,
)
self.toolbar.add_action("switchable_action", switchable_action, self)
action1.action.toggled.connect(
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
)
action2.action.toggled.connect(
lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}")
)
def add_switchable_button_non_checkable(self):
action1 = MaterialIconAction(
icon_name="counter_1", tooltip="Action 1", checkable=False, parent=self
)
action2 = MaterialIconAction(
icon_name="counter_2", tooltip="Action 2", checkable=False, parent=self
)
switchable_action = SwitchableToolBarAction(
actions={"action1": action1, "action2": action2},
initial_action="action1",
tooltip="Switchable Action",
checkable=True,
parent=self,
)
self.toolbar.add_action("switchable_action_no_toggle", switchable_action, self)
action1.action.triggered.connect(
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
)
action2.action.triggered.connect(
lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}")
)
switchable_action.actions["action1"].action.setChecked(True)
def add_widget_actions(self):
combo = QComboBox()
combo.addItems(["Option 1", "Option 2", "Option 3"])
self.toolbar.add_action("device_combo", WidgetAction(label="Device:", widget=combo), self)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
set_theme("light")
main_window = MainWindow()
main_window.show()
sys.exit(app.exec_())

View File

@@ -4,7 +4,8 @@ from __future__ import annotations
import os
import time
import uuid
from typing import Optional
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
@@ -15,8 +16,12 @@ from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.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.container_utils import WidgetContainerUtils
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
if TYPE_CHECKING:
from bec_widgets.utils.bec_dispatcher import BECDispatcher
logger = bec_logger.logger
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
@@ -36,8 +41,7 @@ class ConnectionConfig(BaseModel):
"""Generate a GUI ID if none is provided."""
if v is None:
widget_class = values.data["widget_class"]
v = f"{widget_class}_{str(time.time())}"
return v
v = f"{widget_class}_{datetime.now().strftime('%Y_%m_%d_%H_%M_%S_%f')}"
return v
@@ -72,7 +76,13 @@ class BECConnector:
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
EXIT_HANDLERS = {}
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
def __init__(
self,
client=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
name: str | None = None,
):
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
@@ -100,15 +110,22 @@ class BECConnector:
)
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
# I feel that we should not allow BECConnector to be created with a custom gui_id
# because this would break with the logic in the RPCRegister of retrieving widgets by type
# iterating over all widgets and checkinf if the register widget starts with the string that is passsed.
# If the gui_id is randomly generated, this would break since that widget would have a
# gui_id that is generated in a different way.
if gui_id:
self.config.gui_id = gui_id
self.gui_id = gui_id
self.gui_id: str = gui_id
else:
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.gui_id: str = self.config.gui_id # type: ignore
if name is None:
name = self.__class__.__name__
else:
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(f"Name {name} contains invalid characters.")
self._name = name if name else self.__class__.__name__
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
@@ -116,6 +133,8 @@ class BECConnector:
self.error_utility = ErrorPopupUtility()
self._thread_pool = QThreadPool.globalInstance()
# Store references to running workers so they're not garbage collected prematurely.
self._workers = []
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
"""
@@ -144,11 +163,14 @@ class BECConnector:
>>> def on_complete():
>>> print("Task complete")
>>> self.submit_task(my_function, 1, 2, on_complete=on_complete)
"""
worker = Worker(fn, *args, **kwargs)
if on_complete:
worker.signals.completed.connect(on_complete)
# Keep a reference to the worker so it is not garbage collected.
self._workers.append(worker)
# When the worker is done, remove it from our list.
worker.signals.completed.connect(lambda: self._workers.remove(worker))
self._thread_pool.start(worker)
return worker
@@ -180,37 +202,39 @@ class BECConnector:
@_config_dict.setter
def _config_dict(self, config: BaseModel) -> None:
"""
Get the configuration of the widget.
Set the configuration of the widget.
Returns:
dict: The configuration of the widget.
Args:
config (BaseModel): The new configuration model.
"""
self.config = config
# FIXME some thoughts are required to decide how thhis should work with rpc registry
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
"""
Apply the configuration to the widget.
Args:
config(dict): Configuration settings.
generate_new_id(bool): If True, generate a new GUI ID for the widget.
config (dict): Configuration settings.
generate_new_id (bool): If True, generate a new GUI ID for the widget.
"""
self.config = ConnectionConfig(**config)
if generate_new_id is True:
gui_id = str(uuid.uuid4())
self.rpc_register.remove_rpc(self)
self.set_gui_id(gui_id)
self._set_gui_id(gui_id)
self.rpc_register.add_rpc(self)
else:
self.gui_id = self.config.gui_id
# FIXME some thoughts are required to decide how thhis should work with rpc registry
def load_config(self, path: str | None = None, gui: bool = False):
"""
Load the configuration of the widget from YAML.
Args:
path(str): Path to the configuration file for non-GUI dialog mode.
gui(bool): If True, use the GUI dialog to load the configuration file.
path (str | None): Path to the configuration file for non-GUI dialog mode.
gui (bool): If True, use the GUI dialog to load the configuration file.
"""
if gui is True:
config = load_yaml_gui(self)
@@ -229,8 +253,8 @@ class BECConnector:
Save the configuration of the widget to YAML.
Args:
path(str): Path to save the configuration file for non-GUI dialog mode.
gui(bool): If True, use the GUI dialog to save the configuration file.
path (str | None): Path to save the configuration file for non-GUI dialog mode.
gui (bool): If True, use the GUI dialog to save the configuration file.
"""
if gui is True:
save_yaml_gui(self, self._config_dict)
@@ -238,16 +262,15 @@ class BECConnector:
if path is None:
path = os.getcwd()
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
save_yaml(file_path, self._config_dict)
@pyqtSlot(str)
def set_gui_id(self, gui_id: str) -> None:
# @pyqtSlot(str)
def _set_gui_id(self, gui_id: str) -> None:
"""
Set the GUI ID for the widget.
Args:
gui_id(str): GUI ID
gui_id (str): GUI ID.
"""
self.config.gui_id = gui_id
self.gui_id = gui_id
@@ -268,7 +291,7 @@ class BECConnector:
"""Update the client and device manager from BEC and create object for BEC shortcuts.
Args:
client: BEC client
client: BEC client.
"""
self.client = client
self.get_bec_shortcuts()
@@ -279,25 +302,68 @@ class BECConnector:
Update the configuration for the widget.
Args:
config(ConnectionConfig): Configuration settings.
config (ConnectionConfig | dict): Configuration settings.
"""
gui_id = getattr(config, "gui_id", None)
if isinstance(config, dict):
config = ConnectionConfig(**config)
# TODO add error handler
self.config = config
if gui_id and config.gui_id != gui_id: # Recreating config should not overwrite the gui_id
self.config.gui_id = gui_id
def remove(self):
"""Cleanup the BECConnector"""
if hasattr(self, "close"):
self.close()
if hasattr(self, "deleteLater"):
self.deleteLater()
else:
self.rpc_register.remove_rpc(self)
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
"""
Get the configuration of the widget.
Args:
dict_output(bool): If True, return the configuration as a dictionary. If False, return the configuration as a pydantic model.
dict_output (bool): If True, return the configuration as a dictionary.
If False, return the configuration as a pydantic model.
Returns:
dict: The configuration of the plot widget.
dict | BaseModel: The configuration of the widget.
"""
if dict_output:
return self.config.model_dump()
else:
return self.config
# --- Example usage of BECConnector: running a simple task ---
if __name__ == "__main__": # pragma: no cover
import sys
# Create a QApplication instance (required for QThreadPool)
app = QApplication(sys.argv)
connector = BECConnector()
def print_numbers():
"""
Task function that prints numbers 1 to 10 with a 0.5 second delay between each.
"""
for i in range(1, 11):
print(i)
time.sleep(0.5)
def task_complete():
"""
Called when the task is complete.
"""
print("Task complete")
# Exit the application after the task completes.
app.quit()
# Submit the task using the connector's submit_task method.
connector.submit_task(print_numbers, on_complete=task_complete)
# Start the Qt event loop.
sys.exit(app.exec_())

View File

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

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import darkdetect
from bec_lib.logger import bec_logger
from qtpy.QtCore import Slot
@@ -7,6 +9,10 @@ from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
if TYPE_CHECKING:
from bec_widgets.widgets.containers.dock import BECDock
logger = bec_logger.logger
@@ -17,13 +23,18 @@ class BECWidget(BECConnector):
# The icon name is the name of the icon in the icon theme, typically a name taken
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
ICON_NAME = "widgets"
USER_ACCESS = ["remove"]
# pylint: disable=too-many-arguments
def __init__(
self,
client=None,
config: ConnectionConfig = None,
gui_id: str = None,
gui_id: str | None = None,
theme_update: bool = False,
name: str | None = None,
parent_dock: BECDock | None = None,
**kwargs,
):
"""
Base class for all BEC widgets. This class should be used as a mixin class for all BEC widgets, e.g.:
@@ -44,9 +55,15 @@ class BECWidget(BECConnector):
"""
if not isinstance(self, QWidget):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
super().__init__(client=client, config=config, gui_id=gui_id)
# Set the theme to auto if it is not set yet
# Create a default name if None is provided
if name is None:
name = "bec_widget_init_without_name"
# name = self.__class__.__name__
# Check for invalid chars in the name
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(f"Name {name} contains invalid characters.")
super().__init__(client=client, config=config, gui_id=gui_id, name=name)
self._parent_dock = parent_dock
app = QApplication.instance()
if not hasattr(app, "theme"):
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
@@ -66,7 +83,7 @@ class BECWidget(BECConnector):
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
def _update_theme(self, theme: str):
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
qapp = QApplication.instance()
@@ -87,10 +104,13 @@ class BECWidget(BECConnector):
def cleanup(self):
"""Cleanup the widget."""
# needed here instead of closeEvent, to be checked why
# However, all widgets need to call super().cleanup() in their cleanup method
self.rpc_register.remove_rpc(self)
def closeEvent(self, event):
self.rpc_register.remove_rpc(self)
"""Wrap the close even to ensure the rpc_register is cleaned up."""
try:
self.cleanup()
finally:
super().closeEvent(event)
super().closeEvent(event) # pylint: disable=no-member

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import itertools
import re
from typing import TYPE_CHECKING, Literal
@@ -71,15 +70,64 @@ def apply_theme(theme: Literal["dark", "light"]):
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
"""
app = QApplication.instance()
# 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")
graphic_layouts = [
child
for top in app.topLevelWidgets()
for child in top.findChildren(pg.GraphicsLayoutWidget)
]
plot_items = [
item
for gl in graphic_layouts
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
if isinstance(item, pg.PlotItem)
]
histograms = [
item
for gl in graphic_layouts
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
if isinstance(item, pg.HistogramLUTItem)
]
# Update background color based on the theme
if theme == "light":
background_color = "#e9ecef" # Subtle contrast for light mode
foreground_color = "#141414"
label_color = "#000000"
axis_color = "#666666"
else:
background_color = "#141414" # Dark mode
foreground_color = "#e9ecef"
label_color = "#FFFFFF"
axis_color = "#CCCCCC"
# update GraphicsLayoutWidget
pg.setConfigOptions(foreground=foreground_color, background=background_color)
for pg_widget in graphic_layouts:
pg_widget.setBackground(background_color)
# update PlotItems
for plot_item in plot_items:
for axis in ["left", "right", "top", "bottom"]:
plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
# Change title color
plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
# Change legend color
if hasattr(plot_item, "legend") and plot_item.legend is not None:
plot_item.legend.setLabelTextColor(label_color)
# if legend is in plot item and theme is changed, has to be like that because of pg opt logic
for sample, label in plot_item.legend.items:
label_text = label.text
label.setText(label_text, color=label_color)
# update HistogramLUTItem
for histogram in histograms:
histogram.axis.setPen(pg.mkPen(color=axis_color))
histogram.axis.setTextPen(pg.mkPen(color=label_color))
# now define stylesheet according to theme and apply it
style = bec_qthemes.load_stylesheet(theme)

View File

@@ -1,30 +1,55 @@
from __future__ import annotations
import itertools
from typing import Type
from typing import Literal, Type
from qtpy.QtWidgets import QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
class WidgetContainerUtils:
# We need one handler that checks if a WIDGET of a given name is already created for that DOCKAREA
# 1. If the name exists, then it depends whether the name was auto-generated -> add _1 to the name
# or alternatively raise an error that it can't be added again ( just raise an error)
# 2. Dock names in between docks should also be unique
@staticmethod
def generate_unique_widget_id(container: dict, prefix: str = "widget") -> str:
"""
Generate a unique widget ID.
def has_name_valid_chars(name: str) -> bool:
"""Check if the name is valid.
Args:
container(dict): The container of widgets.
prefix(str): The prefix of the widget ID.
name(str): The name to be checked.
Returns:
widget_id(str): The unique widget ID.
bool: True if the name is valid, False otherwise.
"""
existing_ids = set(container.keys())
for i in itertools.count(1):
widget_id = f"{prefix}_{i}"
if widget_id not in existing_ids:
return widget_id
if not name or len(name) > 256:
return False # Don't accept empty names or names longer than 256 characters
check_value = name.replace("_", "").replace("-", "")
if not check_value.isalnum() or not check_value.isascii():
return False
return True
@staticmethod
def generate_unique_name(name: str, list_of_names: list[str] | None = None) -> str:
"""Generate a unique ID.
Args:
name(str): The name of the widget.
Returns:
tuple (str): The unique name
"""
if list_of_names is None:
list_of_names = []
ii = 0
while ii < 1000: # 1000 is arbritrary!
name_candidate = f"{name}_{ii}"
if name_candidate not in list_of_names:
return name_candidate
ii += 1
raise ValueError("Could not generate a unique name after within 1000 attempts.")
@staticmethod
def find_first_widget_by_class(

View File

@@ -22,7 +22,9 @@ class EntryValidator:
if entry is None or entry == "":
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description:
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
raise ValueError(
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
)
return entry

View File

@@ -148,10 +148,7 @@ class BECTickItem(BECIndicatorItem):
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
self.tick_item = None
class BECArrowItem(BECIndicatorItem):

View File

@@ -1,11 +1,13 @@
from __future__ import annotations
from bec_lib import bec_logger
from qtpy.QtCore import QSettings
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSpinBox,
@@ -13,6 +15,8 @@ from qtpy.QtWidgets import (
QWidget,
)
logger = bec_logger.logger
class WidgetStateManager:
"""
@@ -27,7 +31,7 @@ class WidgetStateManager:
def save_state(self, filename: str = None):
"""
Save the state of the widget to a INI file.
Save the state of the widget to an INI file.
Args:
filename(str): The filename to save the state to.
@@ -42,7 +46,7 @@ class WidgetStateManager:
def load_state(self, filename: str = None):
"""
Load the state of the widget from a INI file.
Load the state of the widget from an INI file.
Args:
filename(str): The filename to load the state from.
@@ -63,18 +67,33 @@ class WidgetStateManager:
widget(QWidget): The widget to save the state for.
settings(QSettings): The QSettings object to save the state to.
"""
if widget.property("skip_settings") is True:
return
meta = widget.metaObject()
settings.beginGroup(widget.objectName())
widget_name = self._get_full_widget_name(widget)
settings.beginGroup(widget_name)
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
if (
name == "objectName"
or not prop.isReadable()
or not prop.isWritable()
or not prop.isStored() # can be extended to fine filter
):
continue
value = widget.property(name)
settings.setValue(name, value)
settings.endGroup()
# Recursively save child widgets
for child in widget.findChildren(QWidget):
if child.objectName():
# Recursively process children (only if they aren't skipped)
for child in widget.children():
if (
child.objectName()
and child.property("skip_settings") is not True
and not isinstance(child, QLabel)
):
self._save_widget_state_qsettings(child, settings)
def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
@@ -85,8 +104,12 @@ class WidgetStateManager:
widget(QWidget): The widget to load the state for.
settings(QSettings): The QSettings object to load the state from.
"""
if widget.property("skip_settings") is True:
return
meta = widget.metaObject()
settings.beginGroup(widget.objectName())
widget_name = self._get_full_widget_name(widget)
settings.beginGroup(widget_name)
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
@@ -95,13 +118,35 @@ class WidgetStateManager:
widget.setProperty(name, value)
settings.endGroup()
# Recursively load child widgets
for child in widget.findChildren(QWidget):
if child.objectName():
# Recursively process children (only if they aren't skipped)
for child in widget.children():
if (
child.objectName()
and child.property("skip_settings") is not True
and not isinstance(child, QLabel)
):
self._load_widget_state_qsettings(child, settings)
def _get_full_widget_name(self, widget: QWidget):
"""
Get the full name of the widget including its parent names.
class ExampleApp(QWidget): # pragma: no cover
Args:
widget(QWidget): The widget to get the full name for.
Returns:
str: The full name of the widget.
"""
name = widget.objectName()
parent = widget.parent()
while parent:
obj_name = parent.objectName() or parent.metaObject().className()
name = obj_name + "." + name
parent = parent.parent()
return name
class ExampleApp(QWidget): # pragma: no cover:
def __init__(self):
super().__init__()
self.setObjectName("MainWindow")
@@ -126,7 +171,34 @@ class ExampleApp(QWidget): # pragma: no cover
self.check_box.setObjectName("MyCheckBox")
layout.addWidget(self.check_box)
# Buttons to save and load state
# A checkbox that we want to skip
self.check_box_skip = QCheckBox("Enable feature - skip save?", self)
self.check_box_skip.setProperty("skip_state", True)
self.check_box_skip.setObjectName("MyCheckBoxSkip")
layout.addWidget(self.check_box_skip)
# CREATE A "SIDE PANEL" with nested structure and skip all what is inside
self.side_panel = QWidget(self)
self.side_panel.setObjectName("SidePanel")
self.side_panel.setProperty("skip_settings", True) # skip the ENTIRE panel
layout.addWidget(self.side_panel)
# Put some sub-widgets inside side_panel
panel_layout = QVBoxLayout(self.side_panel)
self.panel_label = QLabel("Label in side panel", self.side_panel)
self.panel_label.setObjectName("PanelLabel")
panel_layout.addWidget(self.panel_label)
self.panel_edit = QLineEdit(self.side_panel)
self.panel_edit.setObjectName("PanelLineEdit")
self.panel_edit.setPlaceholderText("I am inside side panel")
panel_layout.addWidget(self.panel_edit)
self.panel_checkbox = QCheckBox("Enable feature in side panel?", self.side_panel)
self.panel_checkbox.setObjectName("PanelCheckBox")
panel_layout.addWidget(self.panel_checkbox)
# Save/Load buttons
button_layout = QHBoxLayout()
self.save_button = QPushButton("Save State", self)
self.load_button = QPushButton("Load State", self)

View File

@@ -1 +0,0 @@

View File

@@ -1,25 +1,33 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, Optional
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
from bec_lib.logger import bec_logger
from pydantic import Field
from pyqtgraph.dockarea import Dock, DockLabel
from qtpy import QtCore, QtGui
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.container_utils import WidgetContainerUtils
logger = bec_logger.logger
if TYPE_CHECKING:
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
class DockConfig(ConnectionConfig):
widgets: dict[str, Any] = Field({}, description="The widgets in the dock.")
position: Literal["bottom", "top", "left", "right", "above", "below"] = Field(
"bottom", description="The position of the dock."
)
parent_dock_area: Optional[str] = Field(
parent_dock_area: Optional[str] | None = Field(
None, description="The GUI ID of parent dock area of the dock."
)
@@ -103,16 +111,17 @@ class BECDock(BECWidget, Dock):
ICON_NAME = "widgets"
USER_ACCESS = [
"_config_dict",
"_rpc_id",
"widget_list",
"element_list",
"elements",
"new",
"show",
"hide",
"show_title_bar",
"hide_title_bar",
"get_widgets_positions",
"set_title",
"add_widget",
"list_eligible_widgets",
"move_widget",
"remove_widget",
"hide_title_bar",
"available_widgets",
"delete",
"delete_all",
"remove",
"attach",
"detach",
@@ -121,7 +130,7 @@ class BECDock(BECWidget, Dock):
def __init__(
self,
parent: QWidget | None = None,
parent_dock_area: QWidget | None = None,
parent_dock_area: BECDockArea | None = None,
config: DockConfig | None = None,
name: str | None = None,
client=None,
@@ -129,21 +138,24 @@ class BECDock(BECWidget, Dock):
closable: bool = True,
**kwargs,
) -> None:
if config is None:
config = DockConfig(
widget_class=self.__class__.__name__, parent_dock_area=parent_dock_area.gui_id
widget_class=self.__class__.__name__,
parent_dock_area=parent_dock_area.gui_id if parent_dock_area else None,
)
else:
if isinstance(config, dict):
config = DockConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(
client=client, config=config, gui_id=gui_id, name=name
) # Name was checked and created in BEC Widget
label = CustomDockLabel(text=name, closable=closable)
Dock.__init__(self, name=name, label=label, **kwargs)
Dock.__init__(self, name=name, label=label, parent=self, **kwargs)
# Dock.__init__(self, name=name, **kwargs)
self.parent_dock_area = parent_dock_area
# Layout Manager
self.layout_manager = GridLayoutManager(self.layout)
@@ -173,7 +185,18 @@ class BECDock(BECWidget, Dock):
super().float()
@property
def widget_list(self) -> list[BECWidget]:
def elements(self) -> dict[str, BECWidget]:
"""
Get the widgets in the dock.
Returns:
widgets(dict): The widgets in the dock.
"""
# pylint: disable=protected-access
return dict((widget._name, widget) for widget in self.element_list)
@property
def element_list(self) -> list[BECWidget]:
"""
Get the widgets in the dock.
@@ -182,10 +205,6 @@ class BECDock(BECWidget, Dock):
"""
return self.widgets
@widget_list.setter
def widget_list(self, value: list[BECWidget]):
self.widgets = value
def hide_title_bar(self):
"""
Hide the title bar of the dock.
@@ -194,6 +213,20 @@ class BECDock(BECWidget, Dock):
self.label.hide()
self.labelHidden = True
def show(self):
"""
Show the dock.
"""
super().show()
self.show_title_bar()
def hide(self):
"""
Hide the dock.
"""
self.hide_title_bar()
super().hide()
def show_title_bar(self):
"""
Hide the title bar of the dock.
@@ -211,7 +244,6 @@ class BECDock(BECWidget, Dock):
"""
self.orig_area.docks[title] = self.orig_area.docks.pop(self.name())
self.setTitle(title)
self._name = title
def get_widgets_positions(self) -> dict:
"""
@@ -222,7 +254,7 @@ class BECDock(BECWidget, Dock):
"""
return self.layout_manager.get_widgets_positions()
def list_eligible_widgets(
def available_widgets(
self,
) -> list: # TODO can be moved to some util mixin like container class for rpc widgets
"""
@@ -233,20 +265,29 @@ class BECDock(BECWidget, Dock):
"""
return list(widget_handler.widget_classes.keys())
def add_widget(
def _get_list_of_widget_name_of_parent_dock_area(self):
docks = self.parent_dock_area.panel_list
widgets = []
for dock in docks:
widgets.extend(dock.elements.keys())
return widgets
def new(
self,
widget: BECWidget | str,
row=None,
col=0,
rowspan=1,
colspan=1,
name: str | None = None,
row: int | None = None,
col: int = 0,
rowspan: int = 1,
colspan: int = 1,
shift: Literal["down", "up", "left", "right"] = "down",
) -> BECWidget:
"""
Add a widget to the dock.
Args:
widget(QWidget): The widget to add.
widget(QWidget): The widget to add. It can not be BECDock or BECDockArea.
name(str): The name of the widget.
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
col(int): The column to add the widget to.
rowspan(int): The number of rows the widget should span.
@@ -254,15 +295,39 @@ class BECDock(BECWidget, Dock):
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
"""
if row is None:
# row = cast(int, self.layout.rowCount()) # type:ignore
row = self.layout.rowCount()
# row = cast(int, row)
if self.layout_manager.is_position_occupied(row, col):
self.layout_manager.shift_widgets(shift, start_row=row)
existing_widgets_parent_dock = self._get_list_of_widget_name_of_parent_dock_area()
if name is not None: # Name is provided
if name in existing_widgets_parent_dock:
# pylint: disable=protected-access
raise ValueError(
f"Name {name} must be unique for widgets, but already exists in DockArea "
f"with name: {self.parent_dock_area._name} and id {self.parent_dock_area.gui_id}."
)
else: # Name is not provided
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
name = WidgetContainerUtils.generate_unique_name(
name=widget_class_name, list_of_names=existing_widgets_parent_dock
)
# Check that Widget is not BECDock or BECDockArea
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
if widget_class_name in IGNORE_WIDGETS:
raise ValueError(f"Widget {widget} can not be added to dock.")
if isinstance(widget, str):
widget = widget_handler.create_widget(widget)
widget = cast(
BECWidget,
widget_handler.create_widget(widget_type=widget, name=name, parent_dock=self),
)
else:
widget = widget
widget._name = name # pylint: disable=protected-access
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
@@ -294,37 +359,72 @@ class BECDock(BECWidget, Dock):
"""
self.float()
def remove_widget(self, widget_rpc_id: str):
"""
Remove a widget from the dock.
Args:
widget_rpc_id(str): The ID of the widget to remove.
"""
widget = self.rpc_register.get_rpc_by_id(widget_rpc_id)
self.layout.removeWidget(widget)
self.config.widgets.pop(widget_rpc_id, None)
widget.close()
def remove(self):
"""
Remove the dock from the parent dock area.
"""
# self.cleanup()
self.parent_dock_area.remove_dock(self.name())
self.parent_dock_area.delete(self._name)
def delete(self, widget_name: str) -> None:
"""
Remove a widget from the dock.
Args:
widget_name(str): Delete the widget with the given name.
"""
# pylint: disable=protected-access
widgets = [widget for widget in self.widgets if widget._name == widget_name]
if len(widgets) == 0:
logger.warning(
f"Widget with name {widget_name} not found in dock {self.name()}. "
f"Checking if gui_id was passed as widget_name."
)
# Try to find the widget in the RPC register, maybe the gui_id was passed as widget_name
widget = self.rpc_register.get_rpc_by_id(widget_name)
if widget is None:
logger.warning(
f"Widget not found for name or gui_id: {widget_name} in dock {self.name()}"
)
return
else:
widget = widgets[0]
self.layout.removeWidget(widget)
self.config.widgets.pop(widget._name, None)
if widget in self.widgets:
self.widgets.remove(widget)
widget.close()
self._broadcast_update()
def delete_all(self):
"""
Remove all widgets from the dock.
"""
for widget in self.widgets:
self.delete(widget._name) # pylint: disable=protected-access
def cleanup(self):
"""
Clean up the dock, including all its widgets.
"""
for widget in self.widgets:
if hasattr(widget, "cleanup"):
widget.cleanup()
# Remove the dock from the parent dock area
if self.parent_dock_area:
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
self.parent_dock_area.config.docks.pop(self.name(), None)
self.delete_all()
self.widgets.clear()
self.label.close()
self.label.deleteLater()
super().cleanup()
# def closeEvent(self, event): # pylint: disable=uselsess-parent-delegation
# """Close Event for dock and cleanup.
# This wrapper ensures that the BECWidget close event is triggered.
# If removed, the closeEvent from pyqtgraph will be triggered, which
# is not calling super().closeEvent(event) and will not trigger the BECWidget close event.
# """
# return super().closeEvent(event)
def close(self):
"""
Close the dock area and cleanup.
@@ -332,4 +432,15 @@ class BECDock(BECWidget, Dock):
"""
self.cleanup()
super().close()
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication([])
dock = BECDock(name="dock")
dock.show()
app.exec_()
sys.exit(app.exec_())

View File

@@ -4,12 +4,14 @@ from typing import Literal, Optional
from weakref import WeakValueDictionary
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field
from pyqtgraph.dockarea.DockArea import DockArea
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QPainter, QPaintEvent
from qtpy.QtWidgets import QApplication, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.toolbar import (
ExpandableMenuAction,
@@ -26,12 +28,15 @@ from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
from bec_widgets.widgets.plots.motor_map.motor_map_widget import BECMotorMapWidget
from bec_widgets.widgets.plots.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger
class DockAreaConfig(ConnectionConfig):
docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.")
@@ -43,21 +48,19 @@ class DockAreaConfig(ConnectionConfig):
class BECDockArea(BECWidget, QWidget):
PLUGIN = True
USER_ACCESS = [
"_config_dict",
"selected_device",
"panels",
"save_state",
"remove_dock",
"restore_state",
"add_dock",
"clear_all",
"detach_dock",
"attach_all",
"_get_all_rpc",
"temp_areas",
"new",
"show",
"hide",
"panels",
"panel_list",
"delete",
"delete_all",
"remove",
"detach_dock",
"attach_all",
"selected_device",
"save_state",
"restore_state",
]
def __init__(
@@ -66,6 +69,8 @@ class BECDockArea(BECWidget, QWidget):
config: DockAreaConfig | None = None,
client=None,
gui_id: str = None,
name: str | None = None,
**kwargs,
) -> None:
if config is None:
config = DockAreaConfig(widget_class=self.__class__.__name__)
@@ -73,8 +78,9 @@ class BECDockArea(BECWidget, QWidget):
if isinstance(config, dict):
config = DockAreaConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, name=name, **kwargs)
QWidget.__init__(self, parent=parent)
self._parent = parent
self.layout = QVBoxLayout(self)
self.layout.setSpacing(5)
self.layout.setContentsMargins(0, 0, 0, 0)
@@ -88,9 +94,7 @@ class BECDockArea(BECWidget, QWidget):
label="Add Plot ",
actions={
"waveform": MaterialIconAction(
icon_name=BECWaveformWidget.ICON_NAME,
tooltip="Add Waveform",
filled=True,
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
),
"multi_waveform": MaterialIconAction(
icon_name=BECMultiWaveformWidget.ICON_NAME,
@@ -139,6 +143,9 @@ class BECDockArea(BECWidget, QWidget):
tooltip="Add Circular ProgressBar",
filled=True,
),
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME, tooltip="Add LogPanel", filled=True
),
},
),
"separator_2": SeparatorAction(),
@@ -167,38 +174,41 @@ class BECDockArea(BECWidget, QWidget):
def _hook_toolbar(self):
# Menu Plot
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
lambda: self.add_dock(widget="BECWaveformWidget", prefix="waveform")
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
)
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
lambda: self.add_dock(widget="BECMultiWaveformWidget", prefix="multi_waveform")
lambda: self._create_widget_from_toolbar(widget_name="BECMultiWaveformWidget")
)
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
lambda: self.add_dock(widget="BECImageWidget", prefix="image")
lambda: self._create_widget_from_toolbar(widget_name="BECImageWidget")
)
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
lambda: self.add_dock(widget="BECMotorMapWidget", prefix="motor_map")
lambda: self._create_widget_from_toolbar(widget_name="BECMotorMapWidget")
)
# Menu Devices
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
lambda: self.add_dock(widget="ScanControl", prefix="scan_control")
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
)
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
lambda: self.add_dock(widget="PositionerBox", prefix="positioner_box")
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
)
# Menu Utils
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
lambda: self.add_dock(widget="BECQueue", prefix="queue")
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
)
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
lambda: self.add_dock(widget="BECStatusBox", prefix="status")
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
)
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
lambda: self.add_dock(widget="VSCodeEditor", prefix="vs_code")
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
)
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self.add_dock(widget="RingProgressBar", prefix="progress_bar")
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
)
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
)
# Icons
@@ -206,6 +216,11 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
@SafeSlot()
def _create_widget_from_toolbar(self, widget_name: str) -> None:
dock_name = WidgetContainerUtils.generate_unique_name(widget_name, self.panels.keys())
self.new(name=dock_name, widget=widget_name)
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
super().paintEvent(event)
if self._instructions_visible:
@@ -213,7 +228,7 @@ class BECDockArea(BECWidget, QWidget):
painter.drawText(
self.rect(),
Qt.AlignCenter,
"Add docks using 'add_dock' method from CLI\n or \n Add widget docks using the toolbar",
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
)
@property
@@ -238,7 +253,17 @@ class BECDockArea(BECWidget, QWidget):
@panels.setter
def panels(self, value: dict[str, BECDock]):
self.dock_area.docks = WeakValueDictionary(value)
self.dock_area.docks = WeakValueDictionary(value) # This can not work can it?
@property
def panel_list(self) -> list[BECDock]:
"""
Get the docks in the dock area.
Returns:
list: The docks in the dock area.
"""
return list(self.dock_area.docks.values())
@property
def temp_areas(self) -> list:
@@ -282,36 +307,17 @@ class BECDockArea(BECWidget, QWidget):
self.config.docks_state = last_state
return last_state
def remove_dock(self, name: str):
"""
Remove a dock by name and ensure it is properly closed and cleaned up.
Args:
name(str): The name of the dock to remove.
"""
dock = self.dock_area.docks.pop(name, None)
self.config.docks.pop(name, None)
if dock:
dock.close()
dock.deleteLater()
if len(self.dock_area.docks) <= 1:
for dock in self.dock_area.docks.values():
dock.hide_title_bar()
else:
raise ValueError(f"Dock with name {name} does not exist.")
@SafeSlot(popup_error=True)
def add_dock(
def new(
self,
name: str = None,
position: Literal["bottom", "top", "left", "right", "above", "below"] = None,
name: str | None = None,
widget: str | QWidget | None = None,
widget_name: str | None = None,
position: Literal["bottom", "top", "left", "right", "above", "below"] = "bottom",
relative_to: BECDock | None = None,
closable: bool = True,
floating: bool = False,
prefix: str = "dock",
widget: str | QWidget | None = None,
row: int = None,
row: int | None = None,
col: int = 0,
rowspan: int = 1,
colspan: int = 1,
@@ -321,12 +327,11 @@ class BECDockArea(BECWidget, QWidget):
Args:
name(str): The name of the dock to be displayed and for further references. Has to be unique.
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
relative_to(BECDock): The dock to which the new dock should be added relative to.
closable(bool): Whether the dock is closable.
floating(bool): Whether the dock is detached after creating.
prefix(str): The prefix for the dock name if no name is provided.
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
row(int): The row of the added widget.
col(int): The column of the added widget.
rowspan(int): The rowspan of the added widget.
@@ -335,21 +340,20 @@ class BECDockArea(BECWidget, QWidget):
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_names = [dock._name for dock in self.panel_list] # pylint: disable=protected-access
if name is not None: # Name is provided
if name in dock_names:
raise ValueError(
f"Name {name} must be unique for docks, but already exists in DockArea "
f"with name: {self._name} and id {self.gui_id}."
)
else: # Name is not provided
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
dock = BECDock(name=name, parent_dock_area=self, closable=closable)
dock.config.position = position
self.config.docks[name] = dock.config
self.config.docks[dock.name()] = dock.config
# The dock.name is equal to the name passed to BECDock
self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to)
if len(self.dock_area.docks) <= 1:
@@ -358,10 +362,11 @@ class BECDockArea(BECWidget, QWidget):
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 widget is not None:
# Check if widget name exists.
dock.new(
widget=widget, name=widget_name, row=row, col=col, rowspan=rowspan, colspan=colspan
)
if (
self._instructions_visible
): # TODO still decide how initial instructions should be handled
@@ -399,49 +404,26 @@ class BECDockArea(BECWidget, QWidget):
Remove a temporary area from the dock area.
This is a patched method of pyqtgraph's removeTempArea
"""
if area not in self.dock_area.tempAreas:
# FIXME add some context for the logging, I am not sure which object is passed.
# It looks like a pyqtgraph.DockArea
logger.info(f"Attempted to remove dock_area, but was not floating.")
return
self.dock_area.tempAreas.remove(area)
area.window().close()
area.window().deleteLater()
def clear_all(self):
"""
Close all docks and remove all temp areas.
"""
self.attach_all()
for dock in dict(self.dock_area.docks).values():
dock.remove()
self.dock_area.docks.clear()
def cleanup(self):
"""
Cleanup the dock area.
"""
self.clear_all()
self.delete_all()
self.toolbar.close()
self.toolbar.deleteLater()
self.dock_area.close()
self.dock_area.deleteLater()
super().cleanup()
def closeEvent(self, event):
if self.parent() is None:
# we are at top-level (independent window)
if self.isVisible():
# we are visible => user clicked on [X]
# (when closeEvent is called from shutdown procedure,
# everything is hidden first)
# so, let's ignore "close", and do hide instead
event.ignore()
self.setVisible(False)
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()
@@ -460,18 +442,52 @@ class BECDockArea(BECWidget, QWidget):
continue
docks.window().hide()
def delete(self):
self.hide()
self.deleteLater()
def delete_all(self) -> None:
"""
Delete all docks.
"""
self.attach_all()
for dock_name in self.panels.keys():
self.delete(dock_name)
def delete(self, dock_name: str):
"""
Delete a dock by name.
Args:
dock_name(str): The name of the dock to delete.
"""
dock = self.dock_area.docks.pop(dock_name, None)
self.config.docks.pop(dock_name, None)
if dock:
dock.close()
dock.deleteLater()
if len(self.dock_area.docks) <= 1:
for dock in self.dock_area.docks.values():
dock.hide_title_bar()
else:
raise ValueError(f"Dock with name {dock_name} does not exist.")
self._broadcast_update()
def remove(self) -> None:
"""Remove the dock area."""
self.close()
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
if __name__ == "__main__": # pragma: no cover
import sys
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("auto")
dock_area = BECDockArea()
dock_1 = dock_area.new(name="dock_0", widget="Waveform")
# dock_1 = dock_area.new(name="dock_0", widget="Waveform")
dock_area.new(widget="Waveform")
dock_area.show()
dock_area.setGeometry(100, 100, 800, 600)
app.topLevelWidgets()
app.exec_()
sys.exit(app.exec_())

View File

@@ -78,13 +78,7 @@ class WidgetHandler:
}
def create_widget(
self,
widget_type: str,
widget_id: str,
parent_figure,
parent_id: str,
config: dict = None,
**axis_kwargs,
self, widget_type: str, parent_figure, parent_id: str, config: dict = None, **axis_kwargs
) -> BECPlotBase:
"""
Create and configure a widget based on its type.
@@ -109,7 +103,6 @@ class WidgetHandler:
widget_config_dict = {
"widget_class": widget_class.__name__,
"parent_id": parent_id,
"gui_id": widget_id,
**(config if config is not None else {}),
}
widget_config = config_class(**widget_config_dict)
@@ -162,13 +155,14 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
config: Optional[FigureConfig] = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
) -> None:
if config is None:
config = FigureConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = FigureConfig(**config)
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
pg.GraphicsLayoutWidget.__init__(self, parent)
self.widget_handler = WidgetHandler()
@@ -567,15 +561,12 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
widget = self.widget_handler.create_widget(
widget_type=widget_type,
widget_id=widget_id,
parent_figure=self,
parent_id=self.gui_id,
config=config,
**axis_kwargs,
)
# has to be changed manually to ensure unique id, if config is copied from existing widget, the id could be
# used otherwise multiple times
widget.set_gui_id(widget_id)
widget_id = widget.gui_id
widget.config.row = row
widget.config.col = col
@@ -588,6 +579,7 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
self.config.num_cols = max(self.config.num_cols, col + 1)
# Saving config for future referencing
self.config.widgets[widget_id] = widget.config
self._widgets[widget_id] = widget

View File

@@ -72,7 +72,7 @@ class BECImageItem(BECConnector, pg.ImageItem):
self.config = config
else:
self.config = config
super().__init__(config=config, gui_id=gui_id)
super().__init__(config=config, gui_id=gui_id, **kwargs)
pg.ImageItem.__init__(self)
self.parent_image = parent_image

View File

@@ -338,16 +338,3 @@ class BECMultiWaveform(BECPlotBase):
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

@@ -98,10 +98,11 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
config: Optional[SubplotConfig] = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
):
if config is None:
config = SubplotConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
pg.GraphicsLayout.__init__(self, parent)
self.figure = parent_figure

View File

@@ -99,11 +99,17 @@ class BECWaveform(BECPlotBase):
config: Optional[Waveform1DConfig] = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
):
if config is None:
config = Waveform1DConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
parent=parent,
parent_figure=parent_figure,
config=config,
client=client,
gui_id=gui_id,
**kwargs,
)
self._curves_data = defaultdict(dict)
@@ -1242,16 +1248,22 @@ class BECWaveform(BECPlotBase):
msg(dict): Message with the async data.
metadata(dict): Metadata of the message.
"""
instruction = metadata.get("async_update")
y_data = None
x_data = None
instruction = metadata.get("async_update", {}).get("type")
max_shape = metadata.get("async_update", {}).get("max_shape", [])
for curve in self._curves_data["async"].values():
y_name = curve.config.signals.y.name
y_entry = curve.config.signals.y.entry
x_name = self._x_axis_mode["name"]
for device, async_data in msg["signals"].items():
if device == y_entry:
data_plot = async_data["value"]
if instruction == "extend":
x_data, y_data = curve.get_data()
if instruction == "add":
if len(max_shape) > 1:
if len(data_plot.shape) > 1:
data_plot = data_plot[-1, :]
else:
x_data, y_data = curve.get_data()
if y_data is not None:
new_data = np.hstack((y_data, data_plot))
else:

View File

@@ -97,7 +97,7 @@ class BECCurve(BECConnector, pg.PlotDataItem):
else:
self.config = config
# config.widget_class = self.__class__.__name__
super().__init__(config=config, gui_id=gui_id)
super().__init__(config=config, gui_id=gui_id, **kwargs)
pg.PlotDataItem.__init__(self, name=name)
self.parent_item = parent_item

View File

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

View File

@@ -1,12 +1,17 @@
from bec_lib.logger import bec_logger
from qtpy.QtWidgets import QApplication, QMainWindow
from bec_widgets.utils import BECConnector
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
logger = bec_logger.logger
class BECMainWindow(QMainWindow, BECConnector):
def __init__(self, *args, **kwargs):
BECConnector.__init__(self, **kwargs)
class BECMainWindow(BECWidget, QMainWindow):
def __init__(self, gui_id: str = None, *args, **kwargs):
BECWidget.__init__(self, gui_id=gui_id, **kwargs)
QMainWindow.__init__(self, *args, **kwargs)
def _dump(self):
@@ -33,9 +38,38 @@ class BECMainWindow(QMainWindow, BECConnector):
}
return info
def new_dock_area(self, name):
dock_area = BECDockArea()
def new_dock_area(
self, name: str | None = None, geometry: tuple[int, int, int, int] | None = None
) -> BECDockArea:
"""Create a new dock area.
Args:
name(str): The name of the dock area.
geometry(tuple): The geometry parameters to be passed to the dock area.
Returns:
BECDockArea: The newly created dock area.
"""
rpc_register = RPCRegister()
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
if name is not None:
if name in existing_dock_areas:
raise ValueError(
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
)
else:
name = "dock_area"
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
dock_area = BECDockArea(name=name)
dock_area.resize(dock_area.minimumSizeHint())
dock_area.window().setWindowTitle(name)
# TODO Should we simply use the specified name as title here?
dock_area.window().setWindowTitle(f"BEC - {name}")
logger.info(f"Created new dock area: {name}")
logger.info(f"Existing dock areas: {geometry}")
if geometry is not None:
dock_area.setGeometry(*geometry)
dock_area.show()
return dock_area
def cleanup(self):
# TODO
super().close()

View File

@@ -13,9 +13,16 @@ class AbortButton(BECWidget, QWidget):
ICON_NAME = "cancel"
def __init__(
self, parent=None, client=None, config=None, gui_id=None, toolbar=False, scan_id=None
self,
parent=None,
client=None,
config=None,
gui_id=None,
toolbar=False,
scan_id=None,
**kwargs,
):
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()

View File

@@ -12,8 +12,8 @@ class ResetButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "restart_alt"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
super().__init__(client=client, config=config, gui_id=gui_id)
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()

View File

@@ -12,8 +12,8 @@ class ResumeButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "resume"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
super().__init__(client=client, config=config, gui_id=gui_id)
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()

View File

@@ -12,8 +12,8 @@ class StopButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "dangerous"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
super().__init__(client=client, config=config, gui_id=gui_id)
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()

View File

@@ -12,8 +12,8 @@ class PositionIndicator(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "horizontal_distribute"
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.position = 50
self.min_value = 0

View File

@@ -59,7 +59,7 @@ class DeviceInputBase(BECWidget):
ReadoutPriority.ON_REQUEST: "readout_on_request",
}
def __init__(self, client=None, config=None, gui_id: str = None):
def __init__(self, client=None, config=None, gui_id: str | None = None, **kwargs):
if config is None:
config = DeviceInputConfig(widget_class=self.__class__.__name__)
@@ -67,7 +67,7 @@ class DeviceInputBase(BECWidget):
if isinstance(config, dict):
config = DeviceInputConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True)
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs)
self.get_bec_shortcuts()
self._device_filter = []
self._readout_filter = []

View File

@@ -35,14 +35,14 @@ class DeviceSignalInputBase(BECWidget):
Kind.config: "include_config_signals",
}
def __init__(self, client=None, config=None, gui_id: str = None):
def __init__(self, client=None, config=None, gui_id: str = None, **kwargs):
if config is None:
config = DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceSignalInputBaseConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
self._device = None
self.get_bec_shortcuts()

View File

@@ -45,8 +45,9 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
available_devices: list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
**kwargs,
):
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QComboBox.__init__(self, parent=parent)
if arg_name is not None:
self.config.arg_name = arg_name

View File

@@ -48,11 +48,12 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
available_devices: list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
**kwargs,
):
self._callback_id = None
self._is_valid_input = False
self._accent_colors = get_accent_colors()
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QLineEdit.__init__(self, parent=parent)
self.completer = QCompleter(self)
self.setCompleter(self.completer)

View File

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

View File

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

View File

@@ -1,43 +1,39 @@
# 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.plots.waveform.waveform_widget import BECWaveformWidget
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
DOM_XML = """
<ui language='c++'>
<widget class='BECWaveformWidget' name='bec_waveform_widget'>
<widget class='SignalComboBox' name='signal_combo_box'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECWaveformWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECWaveformWidget(parent)
t = SignalComboBox(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Plots"
return "BEC Input Widgets"
def icon(self):
return designer_material_icon(BECWaveformWidget.ICON_NAME)
return designer_material_icon(SignalComboBox.ICON_NAME)
def includeFile(self):
return "bec_waveform_widget"
return "signal_combo_box"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -49,10 +45,10 @@ class BECWaveformWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cov
return self._form_editor is not None
def name(self):
return "BECWaveformWidget"
return "SignalComboBox"
def toolTip(self):
return "BECWaveformWidget"
return "Signal ComboBox Example for BEC Widgets with autocomplete."
def whatsThis(self):
return self.toolTip()

View File

@@ -38,8 +38,9 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
signal_filter: str | list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
**kwargs,
):
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QComboBox.__init__(self, parent=parent)
if arg_name is not None:
self.config.arg_name = arg_name

View File

@@ -39,9 +39,10 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
signal_filter: str | list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
**kwargs,
):
self._is_valid_input = False
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QLineEdit.__init__(self, parent=parent)
self._accent_colors = get_accent_colors()
self.completer = QCompleter(self)

View File

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

View File

@@ -1,10 +1,10 @@
from collections import defaultdict
from types import SimpleNamespace
from types import NoneType, SimpleNamespace
from typing import Optional
from bec_lib.endpoints import MessageEndpoints
from pydantic import BaseModel, Field
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtCore import Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QApplication,
@@ -18,12 +18,13 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
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.scan_control.scan_group_box import ScanGroupBox
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
@@ -42,6 +43,7 @@ class ScanControlConfig(ConnectionConfig):
class ScanControl(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "tune"
ARG_BOX_POSITION: int = 2
scan_started = Signal()
scan_selected = Signal(str)
@@ -56,13 +58,14 @@ class ScanControl(BECWidget, QWidget):
gui_id: str | None = None,
allowed_scans: list | None = None,
default_scan: str | None = None,
**kwargs,
):
if config is None:
config = ScanControlConfig(
widget_class=self.__class__.__name__, allowed_scans=allowed_scans
)
super().__init__(client=client, gui_id=gui_id, config=config)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
QWidget.__init__(self, parent=parent)
self._hide_add_remove_buttons = False
@@ -83,6 +86,8 @@ class ScanControl(BECWidget, QWidget):
self.config.default_scan = default_scan
self.config.allowed_scans = allowed_scans
self._scan_metadata: dict | None = None
# Create and set main layout
self._init_UI()
@@ -152,6 +157,20 @@ class ScanControl(BECWidget, QWidget):
# Initialize scan selection
self.populate_scans()
# Append metadata form
self._add_metadata_form()
self.layout.addStretch()
def _add_metadata_form(self):
self._metadata_form = ScanMetadata()
self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
self._metadata_form.metadata_updated.connect(self.update_scan_metadata)
self._metadata_form.metadata_cleared.connect(self.update_scan_metadata)
self._metadata_form.validate_form()
def populate_scans(self):
"""Populates the scan selection combo box with available scans from BEC session."""
self.available_scans = self.client.connector.get(
@@ -176,8 +195,9 @@ class ScanControl(BECWidget, QWidget):
self.request_last_executed_scan_parameters()
self.restore_scan_parameters(selected_scan_name)
@Slot()
def request_last_executed_scan_parameters(self):
@SafeSlot()
@SafeSlot(bool)
def request_last_executed_scan_parameters(self, *_):
"""
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
"""
@@ -211,7 +231,7 @@ class ScanControl(BECWidget, QWidget):
else:
self.last_scan_found = False
@Property(str)
@SafeProperty(str)
def current_scan(self):
"""Returns the scan name for the currently selected scan."""
return self.comboBox_scan_selection.currentText()
@@ -227,7 +247,7 @@ class ScanControl(BECWidget, QWidget):
return
self.comboBox_scan_selection.setCurrentText(scan_name)
@Slot(str)
@SafeSlot(str)
def set_current_scan(self, scan_name: str):
"""Slot for setting the current scan to the given scan name.
@@ -236,7 +256,7 @@ class ScanControl(BECWidget, QWidget):
"""
self.current_scan = scan_name
@Property(bool)
@SafeProperty(bool)
def hide_arg_box(self):
"""Property to hide the argument box."""
if self.arg_box is None:
@@ -253,7 +273,7 @@ class ScanControl(BECWidget, QWidget):
if self.arg_box is not None:
self.arg_box.setVisible(not hide)
@Property(bool)
@SafeProperty(bool)
def hide_kwarg_boxes(self):
"""Property to hide the keyword argument boxes."""
if len(self.kwarg_boxes) == 0:
@@ -274,7 +294,7 @@ class ScanControl(BECWidget, QWidget):
for box in self.kwarg_boxes:
box.setVisible(not hide)
@Property(bool)
@SafeProperty(bool)
def hide_scan_control_buttons(self):
"""Property to hide the scan control buttons."""
return not self.button_run_scan.isVisible()
@@ -288,12 +308,40 @@ class ScanControl(BECWidget, QWidget):
"""
self.show_scan_control_buttons(not hide)
@Slot(bool)
@SafeProperty(bool)
def hide_metadata(self):
"""Property to hide the metadata form."""
return not self._metadata_form.isVisible()
@hide_metadata.setter
def hide_metadata(self, hide: bool):
"""Setter for the hide_metadata property.
Args:
hide(bool): Hide or show the metadata form.
"""
self._metadata_form.setVisible(not hide)
@SafeProperty(bool)
def hide_optional_metadata(self):
"""Property to hide the optional metadata form."""
return self._metadata_form.hide_optional_metadata
@hide_optional_metadata.setter
def hide_optional_metadata(self, hide: bool):
"""Setter for the hide_optional_metadata property.
Args:
hide(bool): Hide or show the optional metadata form.
"""
self._metadata_form.hide_optional_metadata = hide
@SafeSlot(bool)
def show_scan_control_buttons(self, show: bool):
"""Shows or hides the scan control buttons."""
self.scan_control_group.setVisible(show)
@Property(bool)
@SafeProperty(bool)
def hide_scan_selection_combobox(self):
"""Property to hide the scan selection combobox."""
return not self.comboBox_scan_selection.isVisible()
@@ -307,12 +355,12 @@ class ScanControl(BECWidget, QWidget):
"""
self.show_scan_selection_combobox(not hide)
@Slot(bool)
@SafeSlot(bool)
def show_scan_selection_combobox(self, show: bool):
"""Shows or hides the scan selection combobox."""
self.scan_selection_group.setVisible(show)
@Slot(str)
@SafeSlot(str)
def scan_select(self, scan_name: str):
"""
Slot for scan selection. Updates the scan control layout based on the selected scan.
@@ -335,7 +383,7 @@ class ScanControl(BECWidget, QWidget):
self.update()
self.adjustSize()
@Property(bool)
@SafeProperty(bool)
def hide_add_remove_buttons(self):
"""Property to hide the add_remove buttons."""
return self._hide_add_remove_buttons
@@ -358,10 +406,11 @@ class ScanControl(BECWidget, QWidget):
Args:
groups(list): List of dictionaries containing the gui_group information.
"""
position = self.ARG_BOX_POSITION + (1 if self.arg_box is not None else 0)
for group in groups:
box = ScanGroupBox(box_type="kwargs", config=group)
box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.layout.addWidget(box)
self.layout.insertWidget(position + len(self.kwarg_boxes), box)
self.kwarg_boxes.append(box)
def add_arg_group(self, group: dict):
@@ -374,9 +423,9 @@ class ScanControl(BECWidget, QWidget):
self.arg_box.device_selected.connect(self.emit_device_selected)
self.arg_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons
self.layout.addWidget(self.arg_box)
self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box)
@Slot(str)
@SafeSlot(str)
def emit_device_selected(self, dev_names):
"""
Emit the signal to inform about selected device(s)
@@ -454,10 +503,20 @@ class ScanControl(BECWidget, QWidget):
scan_params = ScanParameterConfig(name=scan_name, args=args, kwargs=kwargs)
self.config.scans[scan_name] = scan_params
@SafeSlot(dict)
@SafeSlot(NoneType)
def update_scan_metadata(self, md: dict | None):
self._scan_metadata = md
if md is None:
self.button_run_scan.setEnabled(False)
else:
self.button_run_scan.setEnabled(True)
@SafeSlot(popup_error=True)
def run_scan(self):
"""Starts the selected scan with the given parameters."""
args, kwargs = self.get_scan_parameters()
kwargs["metadata"] = self._scan_metadata
self.scan_args.emit(args)
scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText())
if callable(scan_function):

View File

@@ -37,9 +37,14 @@ class DapComboBox(BECWidget, QWidget):
fit_model_updated = Signal(str)
def __init__(
self, parent=None, client=None, gui_id: str | None = None, default_fit: str | None = None
self,
parent=None,
client=None,
gui_id: str | None = None,
default_fit: str | None = None,
**kwargs,
):
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.layout = QVBoxLayout(self)
self.fit_model_combobox = QComboBox(self)

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
AdditionalMetadataTable,
AdditionalMetadataTableModel,
)
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
__all__ = ["ScanMetadata", "AdditionalMetadataTable", "AdditionalMetadataTableModel"]

View File

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

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import sys
from decimal import Decimal
from math import inf, nextafter
from typing import TYPE_CHECKING, TypeVar, get_args
from annotated_types import Ge, Gt, Le, Lt
from bec_lib.logger import bec_logger
from pydantic_core import PydanticUndefined
if TYPE_CHECKING:
from pydantic.fields import FieldInfo
logger = bec_logger.logger
_MININT = -2147483648
_MAXINT = 2147483647
_MINFLOAT = -sys.float_info.max
_MAXFLOAT = sys.float_info.max
T = TypeVar("T", int, float, Decimal)
def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]:
_min = _MININT if type_ is int else _MINFLOAT
_max = _MAXINT if type_ is int else _MAXFLOAT
for md in info.metadata:
if isinstance(md, Ge):
_min = type_(md.ge) # type: ignore
if isinstance(md, Gt):
_min = type_(md.gt) + 1 if type_ is int else nextafter(type_(md.gt), inf) # type: ignore
if isinstance(md, Lt):
_max = type_(md.lt) - 1 if type_ is int else nextafter(type_(md.lt), -inf) # type: ignore
if isinstance(md, Le):
_max = type_(md.le) # type: ignore
return _min, _max # type: ignore
def _get_anno(info: FieldInfo, annotation: str, default):
for md in info.metadata:
if hasattr(md, annotation):
return getattr(md, annotation)
return default
def field_precision(info: FieldInfo):
return _get_anno(info, "decimal_places", 307)
def field_maxlen(info: FieldInfo):
return _get_anno(info, "max_length", None)
def field_minlen(info: FieldInfo):
return _get_anno(info, "min_length", None)
def field_default(info: FieldInfo):
if info.default is PydanticUndefined:
return
return info.default
def clearable_required(info: FieldInfo):
return type(None) in get_args(info.annotation) or info.is_required()

View File

@@ -0,0 +1,149 @@
from __future__ import annotations
from typing import Any
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QHBoxLayout,
QPushButton,
QSizePolicy,
QTreeView,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.error_popups import SafeSlot
class AdditionalMetadataTableModel(QAbstractTableModel):
def __init__(self, data):
super().__init__()
self._data: list[list[str]] = data
self._disallowed_keys: list[str] = []
def headerData(
self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole()
) -> Any:
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
return "Key" if section == 0 else "Value"
return super().headerData(section, orientation, role)
def rowCount(self, index: QModelIndex = QModelIndex()):
return 0 if index.isValid() else len(self._data)
def columnCount(self, index: QModelIndex = QModelIndex()):
return 0 if index.isValid() else 2
def data(self, index, role=Qt.ItemDataRole):
if index.isValid():
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
return str(self._data[index.row()][index.column()])
def setData(self, index, value, role):
if role == Qt.ItemDataRole.EditRole:
if value in self._disallowed_keys or value in self._other_keys(index.row()):
return False
self._data[index.row()][index.column()] = str(value)
return True
return False
def update_disallowed_keys(self, keys: list[str]):
self._disallowed_keys = keys
for i, item in enumerate(self._data):
if item[0] in self._disallowed_keys:
self._data[i][0] = ""
self.dataChanged.emit(self.index(i, 0), self.index(i, 0))
def _other_keys(self, row: int):
return [r[0] for r in self._data[:row] + self._data[row + 1 :]]
def flags(self, _):
return Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsEditable
def insertRows(self, row, number, index):
"""We only support adding one at a time for now"""
if row != self.rowCount() or number != 1:
return False
self.beginInsertRows(QModelIndex(), 0, 0)
self._data.append(["", ""])
self.endInsertRows()
return True
def removeRows(self, row, number, index):
"""This can only be consecutive, so instead of trying to be clever, only support removing one at a time"""
if number != 1:
return False
self.beginRemoveRows(QModelIndex(), row, row)
del self._data[row]
self.endRemoveRows()
return True
@SafeSlot()
def add_row(self):
self.insertRow(self.rowCount())
@SafeSlot(list)
def delete_rows(self, rows: list[int]):
# delete from the end so indices stay correct
for row in sorted(rows, reverse=True):
self.removeRows(row, 1, QModelIndex())
def dump_dict(self):
if self._data == [[]]:
return {}
return dict(self._data)
class AdditionalMetadataTable(QWidget):
delete_rows = Signal(list)
def __init__(self, initial_data: list[list[str]]):
super().__init__()
self._layout = QHBoxLayout()
self.setLayout(self._layout)
self._table_model = AdditionalMetadataTableModel(initial_data)
self._table_view = QTreeView()
self._table_view.setModel(self._table_model)
self._table_view.setSizePolicy(
QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
)
self._table_view.setAlternatingRowColors(True)
self._layout.addWidget(self._table_view)
self._buttons = QVBoxLayout()
self._layout.addLayout(self._buttons)
self._add_button = QPushButton("+")
self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-")
self._remove_button.setToolTip("delete rows containing any selected cells")
self._buttons.addWidget(self._add_button)
self._buttons.addWidget(self._remove_button)
self._add_button.clicked.connect(self._table_model.add_row)
self._remove_button.clicked.connect(self.delete_selected_rows)
self.delete_rows.connect(self._table_model.delete_rows)
def delete_selected_rows(self):
cells: list[QModelIndex] = self._table_view.selectionModel().selectedIndexes()
row_indices = list({r.row() for r in cells})
if row_indices:
self.delete_rows.emit(row_indices)
def dump_dict(self):
return self._table_model.dump_dict()
def update_disallowed_keys(self, keys: list[str]):
self._table_model.update_disallowed_keys(keys)
if __name__ == "__main__": # pragma: no cover
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("dark")
window = AdditionalMetadataTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window.show()
app.exec()

View File

@@ -0,0 +1,234 @@
from __future__ import annotations
from decimal import Decimal
from types import NoneType
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from bec_lib.metadata_schema import get_metadata_schema_for_scan
from bec_qthemes import material_icon
from pydantic import Field, ValidationError
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGridLayout,
QHBoxLayout,
QLabel,
QLayout,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.qt_utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import widget_from_type
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
AdditionalMetadataTable,
)
if TYPE_CHECKING:
from pydantic.fields import FieldInfo
logger = bec_logger.logger
class ScanMetadata(BECWidget, QWidget):
"""Dynamically generates a form for inclusion of metadata for a scan. Uses the
metadata schema registry supplied in the plugin repo to find pydantic models
associated with the scan type. Sets limits for numerical values if specified."""
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
def __init__(
self,
parent=None,
client=None,
scan_name: str | None = None,
initial_extras: list[list[str]] | None = None,
**kwargs,
):
super().__init__(client=client, **kwargs)
QWidget.__init__(self, parent=parent)
self.set_schema(scan_name)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._required_md_box = ExpandableGroupFrame("Scan schema metadata")
self._layout.addWidget(self._required_md_box)
self._required_md_box_layout = QHBoxLayout()
self._required_md_box.set_layout(self._required_md_box_layout)
self._md_grid = QWidget()
self._required_md_box_layout.addWidget(self._md_grid)
self._grid_container = QVBoxLayout()
self._md_grid.setLayout(self._grid_container)
self._new_grid_layout()
self._grid_container.addLayout(self._md_grid_layout)
self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False)
self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout = QHBoxLayout()
self._additional_md_box.set_layout(self._additional_md_box_layout)
self._additional_metadata = AdditionalMetadataTable(initial_extras or [])
self._additional_md_box_layout.addWidget(self._additional_metadata)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "Metadata validity" # type: ignore
self._validity.compact_show_popup.setIcon(
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
)
self._validity_message = QLabel("Not yet validated")
self._validity.addWidget(self._validity_message)
self._layout.addWidget(self._validity)
self.populate()
@SafeSlot(str)
def update_with_new_scan(self, scan_name: str):
self.set_schema(scan_name)
self.populate()
self.validate_form()
def validate_form(self, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true.
Otherwise, emits on metadata_cleared and returns false."""
try:
metadata_dict = self.get_full_model_dict()
self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
self.metadata_updated.emit(metadata_dict)
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
self.metadata_cleared.emit(None)
def get_full_model_dict(self):
"""Get the entered metadata as a dict"""
return self._additional_metadata.dump_dict() | self._dict_from_grid()
def set_schema(self, scan_name: str | None = None):
self._scan_name = scan_name or ""
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
def populate(self):
self._clear_grid()
self._populate()
def _populate(self):
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
for i, (field_name, info) in enumerate(self._md_schema.model_fields.items()):
self._add_griditem(field_name, info, i)
def _add_griditem(self, field_name: str, info: FieldInfo, row: int):
grid = self._md_grid_layout
label = QLabel(info.title or field_name)
label.setProperty("_model_field_name", field_name)
label.setToolTip(info.description or field_name)
grid.addWidget(label, row, 0)
widget = widget_from_type(info.annotation)(info)
widget.valueChanged.connect(self.validate_form)
grid.addWidget(widget, row, 1)
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
grid = self._md_grid_layout
return {
grid.itemAtPosition(i, 0).widget().property("_model_field_name"): grid.itemAtPosition(i, 1).widget().getValue() # type: ignore # we only add 'MetadataWidget's here
for i in range(grid.rowCount())
}
def _clear_grid(self):
while self._md_grid_layout.count():
item = self._md_grid_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
self._md_grid_layout.deleteLater()
self._new_grid_layout()
self._grid_container.addLayout(self._md_grid_layout)
self._md_grid.adjustSize()
self.adjustSize()
def _new_grid_layout(self):
self._md_grid_layout = QGridLayout()
self._md_grid_layout.setContentsMargins(0, 0, 0, 0)
self._md_grid_layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
@SafeProperty(bool)
def hide_optional_metadata(self): # type: ignore
"""Property to hide the optional metadata table."""
return not self._additional_md_box.isVisible()
@hide_optional_metadata.setter
def hide_optional_metadata(self, hide: bool):
"""Setter for the hide_optional_metadata property.
Args:
hide(bool): Hide or show the optional metadata table.
"""
self._additional_md_box.setVisible(not hide)
if __name__ == "__main__": # pragma: no cover
from unittest.mock import patch
from bec_lib.metadata_schema import BasicScanMetadata
from bec_widgets.utils.colors import set_theme
class ExampleSchema1(BasicScanMetadata):
abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C")
foo: str = Field(max_length=12, description="Sample database code", default="DEF123")
xyz: Decimal = Field(decimal_places=4)
baz: bool
class ExampleSchema2(BasicScanMetadata):
checkbox_up_top: bool
checkbox_again: bool = Field(
title="Checkbox Again", description="this one defaults to True", default=True
)
different_items: int | None = Field(
None, description="This is just one different item...", gt=-100, lt=0
)
length_limited_string: str = Field(max_length=32)
float_with_2dp: Decimal = Field(decimal_places=2)
class ExampleSchema3(BasicScanMetadata):
optional_with_regex: str | None = Field(None, pattern=r"^\d+-\d+$")
with patch(
"bec_lib.metadata_schema._get_metadata_schema_registry",
lambda: {"scan1": ExampleSchema1, "scan2": ExampleSchema2, "scan3": ExampleSchema3},
):
app = QApplication([])
w = QWidget()
selection = QComboBox()
selection.addItems(["grid_scan", "scan1", "scan2", "scan3"])
layout = QVBoxLayout()
w.setLayout(layout)
scan_metadata = ScanMetadata(
scan_name="grid_scan",
initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]],
)
selection.currentTextChanged.connect(scan_metadata.update_with_new_scan)
layout.addWidget(selection)
layout.addWidget(scan_metadata)
set_theme("dark")
window = w
window.show()
app.exec()

View File

@@ -5,9 +5,9 @@ from html.parser import HTMLParser
from bec_lib.logger import bec_logger
from pydantic import Field
from qtpy.QtCore import Property, Slot
from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
@@ -42,14 +42,14 @@ class TextBox(BECWidget, QWidget):
USER_ACCESS = ["set_plain_text", "set_html_text"]
ICON_NAME = "chat"
def __init__(self, parent=None, client=None, config=None, gui_id=None):
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
if config is None:
config = TextBoxConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = TextBoxConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)
self.text_box_text_edit = QTextEdit(parent=self)
@@ -66,7 +66,7 @@ class TextBox(BECWidget, QWidget):
else:
self.set_html_text(DEFAULT_TEXT)
@Slot(str)
@SafeSlot(str)
def set_plain_text(self, text: str) -> None:
"""Set the plain text of the widget.
@@ -77,7 +77,7 @@ class TextBox(BECWidget, QWidget):
self.config.text = text
self.config.is_html = False
@Slot(str)
@SafeSlot(str)
def set_html_text(self, text: str) -> None:
"""Set the HTML text of the widget.
@@ -88,7 +88,7 @@ class TextBox(BECWidget, QWidget):
self.config.text = text
self.config.is_html = True
@Property(str)
@SafeProperty(str)
def plain_text(self) -> str:
"""Get the text of the widget.
@@ -106,7 +106,7 @@ class TextBox(BECWidget, QWidget):
"""
self.set_plain_text(text)
@Property(str)
@SafeProperty(str)
def html_text(self) -> str:
"""Get the HTML text of the widget.

View File

@@ -45,12 +45,12 @@ class VSCodeEditor(WebsiteWidget):
USER_ACCESS = []
ICON_NAME = "developer_mode_tv"
def __init__(self, parent=None, config=None, client=None, gui_id=None):
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
self.process = None
self.port = get_free_port()
self._url = f"http://{self.host}:{self.port}?tkn={self.token}"
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id)
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id, **kwargs)
self.start_server()
self.bec_dispatcher.connect_slot(self.on_vscode_event, f"vscode-events/{self.gui_id}")

View File

@@ -23,8 +23,10 @@ class WebsiteWidget(BECWidget, QWidget):
ICON_NAME = "travel_explore"
USER_ACCESS = ["set_url", "get_url", "reload", "back", "forward"]
def __init__(self, parent=None, url: str = None, config=None, client=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
def __init__(
self, parent=None, url: str = None, config=None, client=None, gui_id=None, **kwargs
):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)

View File

@@ -401,6 +401,9 @@ class Minesweeper(BECWidget, QWidget):
def _set_level_params(self, level: tuple[int, int]):
self.b_size, self.num_mines = level
def cleanup(self):
self._timer.stop()
if __name__ == "__main__":
from bec_widgets.utils.colors import set_theme

View File

@@ -54,13 +54,14 @@ class BECImageWidget(BECWidget, QWidget):
config: ImageConfig | dict = None,
client=None,
gui_id: str | None = None,
**kwargs,
) -> None:
if config is None:
config = ImageConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = ImageConfig(**config)
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)
self.layout.setSpacing(0)

View File

@@ -35,13 +35,14 @@ class BECMotorMapWidget(BECWidget, QWidget):
config: MotorMapConfig | None = None,
client=None,
gui_id: str | None = None,
**kwargs,
) -> None:
if config is None:
config = MotorMapConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = MotorMapConfig(**config)
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)

View File

@@ -63,13 +63,14 @@ class BECMultiWaveformWidget(BECWidget, QWidget):
config: BECMultiWaveformConfig | dict = None,
client=None,
gui_id: str | None = None,
**kwargs,
) -> None:
if config is None:
config = BECMultiWaveformConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = BECMultiWaveformConfig(**config)
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)

View File

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

View File

@@ -1,336 +0,0 @@
from __future__ import annotations
import os
from typing import Literal
from bec_qthemes import material_icon
from pydantic import BaseModel
from qtpy.QtCore import QObject, Slot
from qtpy.QtWidgets import QComboBox, QLineEdit, QPushButton, QSpinBox, QTableWidget, QVBoxLayout
import bec_widgets
from bec_widgets.qt_utils.error_popups import WarningPopupUtility
from bec_widgets.qt_utils.settings_dialog import SettingWidget
from bec_widgets.utils import Colors, UILoader
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from bec_widgets.widgets.utility.visual.color_button.color_button import ColorButton
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class CurveSettings(SettingWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
current_path = os.path.dirname(__file__)
self.ui = UILoader(self).loader(os.path.join(current_path, "curve_dialog.ui"))
self._setup_icons()
self.warning_util = WarningPopupUtility(self)
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.ui)
self.ui.add_curve.clicked.connect(self.add_curve)
self.ui.add_dap.clicked.connect(self.add_dap)
self.ui.x_mode.currentIndexChanged.connect(self.set_x_mode)
self.ui.normalize_colors_scan.clicked.connect(lambda: self.change_colormap("scan"))
self.ui.normalize_colors_dap.clicked.connect(lambda: self.change_colormap("dap"))
def _setup_icons(self):
add_icon = material_icon(icon_name="add", size=(20, 20), convert_to_pixmap=False)
self.ui.add_dap.setIcon(add_icon)
self.ui.add_dap.setToolTip("Add DAP Curve")
self.ui.add_curve.setIcon(add_icon)
self.ui.add_curve.setToolTip("Add Scan Curve")
@Slot(dict)
def display_current_settings(self, config: dict | BaseModel):
# What elements should be enabled
x_name = self.target_widget.waveform._x_axis_mode["name"]
x_entry = self.target_widget.waveform._x_axis_mode["entry"]
self._enable_ui_elements(x_name, x_entry)
cm = self.target_widget.config.color_palette
self.ui.color_map_selector_scan.colormap = cm
# Scan Curve Table
for source in ["scan_segment", "async"]:
for label, curve in config[source].items():
row_count = self.ui.scan_table.rowCount()
self.ui.scan_table.insertRow(row_count)
DialogRow(
parent=self,
table_widget=self.ui.scan_table,
client=self.target_widget.client,
row=row_count,
config=curve.config,
).add_scan_row()
# Add DAP Curves
for label, curve in config["DAP"].items():
row_count = self.ui.dap_table.rowCount()
self.ui.dap_table.insertRow(row_count)
DialogRow(
parent=self,
table_widget=self.ui.dap_table,
client=self.target_widget.client,
row=row_count,
config=curve.config,
).add_dap_row()
def _enable_ui_elements(self, name, entry):
if name is None:
name = "best_effort"
if name in ["index", "timestamp", "best_effort"]:
self.ui.x_mode.setCurrentText(name)
self.set_x_mode()
else:
self.ui.x_mode.setCurrentText("device")
self.set_x_mode()
self.ui.x_name.setText(name)
self.ui.x_entry.setText(entry)
@Slot()
def set_x_mode(self):
x_mode = self.ui.x_mode.currentText()
if x_mode in ["index", "timestamp", "best_effort"]:
self.ui.x_name.setEnabled(False)
self.ui.x_entry.setEnabled(False)
self.ui.dap_table.setEnabled(False)
self.ui.add_dap.setEnabled(False)
if self.ui.dap_table.rowCount() > 0:
self.warning_util.show_warning(
title="DAP Warning",
message="DAP is not supported without specific x-axis device. All current DAP curves will be removed.",
detailed_text=f"Affected curves: {[self.ui.dap_table.cellWidget(row, 0).text() for row in range(self.ui.dap_table.rowCount())]}",
)
else:
self.ui.x_name.setEnabled(True)
self.ui.x_entry.setEnabled(True)
self.ui.dap_table.setEnabled(True)
self.ui.add_dap.setEnabled(True)
@Slot()
def change_colormap(self, target: Literal["scan", "dap"]):
if target == "scan":
cm = self.ui.color_map_selector_scan.colormap
table = self.ui.scan_table
if target == "dap":
cm = self.ui.color_map_selector_dap.colormap
table = self.ui.dap_table
rows = table.rowCount()
colors = Colors.golden_angle_color(colormap=cm, num=max(10, rows + 1), format="HEX")
color_button_col = 2 if target == "scan" else 3
for row in range(rows):
table.cellWidget(row, color_button_col).set_color(colors[row])
@Slot()
def accept_changes(self):
self.accept_curve_changes()
def accept_curve_changes(self):
sources = ["scan_segment", "async", "DAP"]
old_curves = []
for source in sources:
old_curves += list(self.target_widget.waveform._curves_data[source].values())
for curve in old_curves:
curve.remove()
self.get_curve_params()
def get_curve_params(self):
x_mode = self.ui.x_mode.currentText()
if x_mode in ["index", "timestamp", "best_effort"]:
x_name = x_mode
x_entry = x_mode
else:
x_name = self.ui.x_name.text()
x_entry = self.ui.x_entry.text()
self.target_widget.set_x(x_name=x_name, x_entry=x_entry)
for row in range(self.ui.scan_table.rowCount()):
y_name = self.ui.scan_table.cellWidget(row, 0).text()
y_entry = self.ui.scan_table.cellWidget(row, 1).text()
color = self.ui.scan_table.cellWidget(row, 2).get_color()
style = self.ui.scan_table.cellWidget(row, 3).currentText()
width = self.ui.scan_table.cellWidget(row, 4).value()
symbol_size = self.ui.scan_table.cellWidget(row, 5).value()
self.target_widget.plot(
y_name=y_name,
y_entry=y_entry,
color=color,
pen_style=style,
pen_width=width,
symbol_size=symbol_size,
)
if x_mode not in ["index", "timestamp", "best_effort"]:
for row in range(self.ui.dap_table.rowCount()):
y_name = self.ui.dap_table.cellWidget(row, 0).text()
y_entry = self.ui.dap_table.cellWidget(row, 1).text()
dap = self.ui.dap_table.cellWidget(row, 2).currentText()
color = self.ui.dap_table.cellWidget(row, 3).get_color()
style = self.ui.dap_table.cellWidget(row, 4).currentText()
width = self.ui.dap_table.cellWidget(row, 5).value()
symbol_size = self.ui.dap_table.cellWidget(row, 6).value()
self.target_widget.add_dap(
x_name=x_name,
x_entry=x_entry,
y_name=y_name,
y_entry=y_entry,
dap=dap,
color=color,
pen_style=style,
pen_width=width,
symbol_size=symbol_size,
)
self.target_widget.scan_history(-1)
def add_curve(self):
row_count = self.ui.scan_table.rowCount()
self.ui.scan_table.insertRow(row_count)
DialogRow(
parent=self,
table_widget=self.ui.scan_table,
client=self.target_widget.client,
row=row_count,
config=None,
).add_scan_row()
def add_dap(self):
row_count = self.ui.dap_table.rowCount()
self.ui.dap_table.insertRow(row_count)
DialogRow(
parent=self,
table_widget=self.ui.dap_table,
client=self.target_widget.client,
row=row_count,
config=None,
).add_dap_row()
class DialogRow(QObject):
def __init__(
self,
parent=None,
table_widget: QTableWidget = None,
row: int = None,
config: dict = None,
client=None,
):
super().__init__(parent=parent)
self.client = client
self.table_widget = table_widget
self.row = row
self.config = config
self.init_default_widgets()
def init_default_widgets(self):
# Remove Button
self.remove_button = RemoveButton()
# Name and Entry
self.device_line_edit = DeviceLineEdit()
self.entry_line_edit = QLineEdit()
self.dap_combo = DapComboBox()
self.dap_combo.populate_fit_model_combobox()
self.dap_combo.select_fit_model("GaussianModel")
# Styling
self.color_button = ColorButton()
self.style_combo = StyleComboBox()
self.width = QSpinBox()
self.width.setMinimum(1)
self.width.setMaximum(20)
self.width.setValue(4)
self.symbol_size = QSpinBox()
self.symbol_size.setMinimum(1)
self.symbol_size.setMaximum(20)
self.symbol_size.setValue(7)
self.remove_button.clicked.connect(
lambda: self.remove_row()
) # From some reason do not work without lambda
def add_scan_row(self):
if self.config is not None:
self.device_line_edit.setText(self.config.signals.y.name)
self.entry_line_edit.setText(self.config.signals.y.entry)
self.color_button.set_color(self.config.color)
self.style_combo.setCurrentText(self.config.pen_style)
self.width.setValue(self.config.pen_width)
self.symbol_size.setValue(self.config.symbol_size)
else:
default_colors = Colors.golden_angle_color(
colormap="magma", num=max(10, self.row + 1), format="HEX"
)
default_color = default_colors[self.row]
self.color_button.set_color(default_color)
self.table_widget.setCellWidget(self.row, 0, self.device_line_edit)
self.table_widget.setCellWidget(self.row, 1, self.entry_line_edit)
self.table_widget.setCellWidget(self.row, 2, self.color_button)
self.table_widget.setCellWidget(self.row, 3, self.style_combo)
self.table_widget.setCellWidget(self.row, 4, self.width)
self.table_widget.setCellWidget(self.row, 5, self.symbol_size)
self.table_widget.setCellWidget(self.row, 6, self.remove_button)
def add_dap_row(self):
if self.config is not None:
self.device_line_edit.setText(self.config.signals.y.name)
self.entry_line_edit.setText(self.config.signals.y.entry)
self.dap_combo.fit_model_combobox.setCurrentText(self.config.signals.dap)
self.color_button.set_color(self.config.color)
self.style_combo.setCurrentText(self.config.pen_style)
self.width.setValue(self.config.pen_width)
self.symbol_size.setValue(self.config.symbol_size)
else:
default_colors = Colors.golden_angle_color(
colormap="magma", num=max(10, self.row + 1), format="HEX"
)
default_color = default_colors[self.row]
self.color_button.set_color(default_color)
self.table_widget.setCellWidget(self.row, 0, self.device_line_edit)
self.table_widget.setCellWidget(self.row, 1, self.entry_line_edit)
self.table_widget.setCellWidget(self.row, 2, self.dap_combo.fit_model_combobox)
self.table_widget.setCellWidget(self.row, 3, self.color_button)
self.table_widget.setCellWidget(self.row, 4, self.style_combo)
self.table_widget.setCellWidget(self.row, 5, self.width)
self.table_widget.setCellWidget(self.row, 6, self.symbol_size)
self.table_widget.setCellWidget(self.row, 7, self.remove_button)
@Slot()
def remove_row(self):
row = self.table_widget.indexAt(self.remove_button.pos()).row()
self.cleanup()
self.table_widget.removeRow(row)
def cleanup(self):
self.device_line_edit.cleanup()
class StyleComboBox(QComboBox):
def __init__(self, parent=None):
super().__init__(parent)
self.addItems(["solid", "dash", "dot", "dashdot"])
class RemoveButton(QPushButton):
def __init__(self, parent=None):
super().__init__(parent)
icon = material_icon("disabled_by_default", size=(20, 20), convert_to_pixmap=False)
self.setIcon(icon)

View File

@@ -1,372 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>720</width>
<height>806</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<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="QGroupBox" name="x_group_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout" stretch="0,0,0,0,1,3,1,3">
<item>
<widget class="QLabel" name="x_mode_label">
<property name="text">
<string>X Axis Mode</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="x_mode">
<property name="minimumSize">
<size>
<width>150</width>
<height>26</height>
</size>
</property>
<item>
<property name="text">
<string>best_effort</string>
</property>
</item>
<item>
<property name="text">
<string>device</string>
</property>
</item>
<item>
<property name="text">
<string>index</string>
</property>
</item>
<item>
<property name="text">
<string>timestamp</string>
</property>
</item>
</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>
<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="QLabel" name="x_name_label">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item>
<widget class="DeviceLineEdit" name="x_name"/>
</item>
<item>
<widget class="QLabel" name="x_entry_label">
<property name="text">
<string>Entry</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="x_entry"/>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="y_group_box">
<property name="title">
<string>Y Axis</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tab_scan">
<attribute name="title">
<string>Scan</string>
</attribute>
<layout class="QGridLayout" name="gridLayout">
<property name="leftMargin">
<number>5</number>
</property>
<property name="topMargin">
<number>5</number>
</property>
<property name="rightMargin">
<number>5</number>
</property>
<property name="bottomMargin">
<number>5</number>
</property>
<item row="0" column="2">
<widget class="QPushButton" name="normalize_colors_scan">
<property name="text">
<string>Normalize Colors</string>
</property>
</widget>
</item>
<item row="1" column="0" colspan="4">
<widget class="QTableWidget" name="scan_table">
<property name="rowCount">
<number>0</number>
</property>
<attribute name="horizontalHeaderCascadingSectionResizes">
<bool>false</bool>
</attribute>
<attribute name="horizontalHeaderShowSortIndicator" stdset="0">
<bool>true</bool>
</attribute>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
<attribute name="verticalHeaderStretchLastSection">
<bool>false</bool>
</attribute>
<column>
<property name="text">
<string>Name</string>
</property>
</column>
<column>
<property name="text">
<string>Entry</string>
</property>
</column>
<column>
<property name="text">
<string>Color</string>
</property>
</column>
<column>
<property name="text">
<string>Style</string>
</property>
</column>
<column>
<property name="text">
<string>Width</string>
</property>
</column>
<column>
<property name="text">
<string>Symbol Size</string>
</property>
</column>
<column>
<property name="text">
<string>Delete</string>
</property>
</column>
</widget>
</item>
<item row="0" column="1">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="add_curve">
<property name="toolTip">
<string/>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="BECColorMapWidget" name="color_map_selector_scan">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_DAP">
<attribute name="title">
<string>DAP</string>
</attribute>
<layout class="QGridLayout" name="gridLayout_2">
<property name="leftMargin">
<number>5</number>
</property>
<property name="topMargin">
<number>5</number>
</property>
<property name="rightMargin">
<number>5</number>
</property>
<property name="bottomMargin">
<number>5</number>
</property>
<item row="1" column="0" colspan="4">
<widget class="QTableWidget" name="dap_table">
<attribute name="horizontalHeaderShowSortIndicator" stdset="0">
<bool>true</bool>
</attribute>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
<column>
<property name="text">
<string>Name</string>
</property>
</column>
<column>
<property name="text">
<string>Entry</string>
</property>
</column>
<column>
<property name="text">
<string>Model</string>
</property>
</column>
<column>
<property name="text">
<string>Color</string>
</property>
</column>
<column>
<property name="text">
<string>Style</string>
</property>
</column>
<column>
<property name="text">
<string>Width</string>
</property>
</column>
<column>
<property name="text">
<string>Symbol Size</string>
</property>
</column>
<column>
<property name="text">
<string>Delete</string>
</property>
</column>
</widget>
</item>
<item row="0" column="2">
<widget class="QPushButton" name="normalize_colors_dap">
<property name="text">
<string>Normalize Colors</string>
</property>
</widget>
</item>
<item row="0" column="1">
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>585</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="add_dap">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="BECColorMapWidget" name="color_map_selector_dap">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>DeviceLineEdit</class>
<extends>QLineEdit</extends>
<header>device_line_edit</header>
</customwidget>
<customwidget>
<class>BECColorMapWidget</class>
<extends>QWidget</extends>
<header>bec_color_map_widget</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,25 +0,0 @@
from qtpy.QtWidgets import QDialog, QVBoxLayout
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
class FitSummaryWidget(QDialog):
def __init__(self, parent=None, target_widget=None):
super().__init__(parent=parent)
self.setModal(True)
self.target_widget = target_widget
self.dap_dialog = LMFitDialog(parent=self, ui_file="lmfit_dialog_compact.ui")
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.dap_dialog)
self.target_widget.dap_summary_update.connect(self.dap_dialog.update_summary_tree)
self.setLayout(self.layout)
self._get_dap_from_target_widget()
def _get_dap_from_target_widget(self) -> None:
"""Get the DAP data from the target widget and update the DAP dialog manually on creation."""
dap_summary = self.target_widget.get_dap_summary()
for curve_id, data in dap_summary.items():
md = {"curve_id": curve_id}
self.dap_dialog.update_summary_tree(data=data, metadata=md)

View File

@@ -1,750 +0,0 @@
from __future__ import annotations
import sys
from typing import Literal
import numpy as np
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar, SeparatorAction
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.figure.plots.axis_settings import AxisSettings
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import Waveform1DConfig
from bec_widgets.widgets.containers.figure.plots.waveform.waveform_curve import BECCurve
from bec_widgets.widgets.plots.waveform.waveform_popups.curve_dialog.curve_dialog import (
CurveSettings,
)
from bec_widgets.widgets.plots.waveform.waveform_popups.dap_summary_dialog.dap_summary_dialog import (
FitSummaryWidget,
)
try:
import pandas as pd
except ImportError:
pd = None
logger = bec_logger.logger
class BECWaveformWidget(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "show_chart"
USER_ACCESS = [
"curves",
"plot",
"add_dap",
"get_dap_params",
"remove_curve",
"scan_history",
"get_all_data",
"set",
"set_x",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_legend_label_size",
"set_auto_range",
"set_grid",
"enable_fps_monitor",
"enable_scatter",
"lock_aspect_ratio",
"export",
"export_to_matplotlib",
"toggle_roi",
"select_roi",
]
scan_signal_update = Signal()
async_signal_update = Signal()
dap_summary_update = Signal(dict, dict)
dap_params_update = Signal(dict, dict)
autorange_signal = Signal()
new_scan = Signal()
crosshair_position_changed = Signal(tuple)
crosshair_position_changed_string = Signal(str)
crosshair_position_clicked = Signal(tuple)
crosshair_position_clicked_string = Signal(str)
crosshair_coordinates_changed = Signal(tuple)
crosshair_coordinates_changed_string = Signal(str)
crosshair_coordinates_clicked = Signal(tuple)
crosshair_coordinates_clicked_string = Signal(str)
roi_changed = Signal(tuple)
roi_active = Signal(bool)
def __init__(
self,
parent: QWidget | None = None,
config: Waveform1DConfig | dict = None,
client=None,
gui_id: str | None = None,
) -> None:
if config is None:
config = Waveform1DConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = Waveform1DConfig(**config)
super().__init__(client=client, gui_id=gui_id)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.fig = BECFigure()
self.toolbar = ModularToolBar(
actions={
"save": MaterialIconAction(icon_name="save", tooltip="Open Export Dialog"),
"matplotlib": MaterialIconAction(
icon_name="photo_library", tooltip="Open Matplotlib Plot"
),
"separator_1": SeparatorAction(),
"drag_mode": MaterialIconAction(
icon_name="drag_pan", tooltip="Drag Mouse Mode", checkable=True
),
"rectangle_mode": MaterialIconAction(
icon_name="frame_inspect", tooltip="Rectangle Zoom Mode", checkable=True
),
"auto_range": MaterialIconAction(
icon_name="open_in_full", tooltip="Autorange Plot"
),
"separator_2": SeparatorAction(),
"curves": MaterialIconAction(
icon_name="timeline", tooltip="Open Curves Configuration"
),
"fit_params": MaterialIconAction(
icon_name="monitoring", tooltip="Open Fitting Parameters"
),
"separator_3": SeparatorAction(),
"crosshair": MaterialIconAction(
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
),
"roi_select": MaterialIconAction(
icon_name="align_justify_space_between",
tooltip="Add ROI region for DAP",
checkable=True,
),
"separator_4": SeparatorAction(),
"fps_monitor": MaterialIconAction(
icon_name="speed", tooltip="Show FPS Monitor", checkable=True
),
"axis_settings": MaterialIconAction(
icon_name="settings", tooltip="Open Configuration Dialog"
),
},
target_widget=self,
)
self.layout.addWidget(self.toolbar)
self.layout.addWidget(self.fig)
self.warning_util = WarningPopupUtility(self)
self.waveform = self.fig.plot()
self.waveform.apply_config(config)
self.config = config
self._clear_curves_on_plot_update = False
self.hook_waveform_signals()
self._hook_actions()
def hook_waveform_signals(self):
self.waveform.scan_signal_update.connect(self.scan_signal_update)
self.waveform.async_signal_update.connect(self.async_signal_update)
self.waveform.dap_params_update.connect(self.dap_params_update)
self.waveform.dap_summary_update.connect(self.dap_summary_update)
self.waveform.autorange_signal.connect(self.autorange_signal)
self.waveform.new_scan.connect(self.new_scan)
self.waveform.crosshair_coordinates_changed.connect(self.crosshair_coordinates_changed)
self.waveform.crosshair_coordinates_clicked.connect(self.crosshair_coordinates_clicked)
self.waveform.crosshair_coordinates_changed.connect(
self._emit_crosshair_coordinates_changed_string
)
self.waveform.crosshair_coordinates_clicked.connect(
self._emit_crosshair_coordinates_clicked_string
)
self.waveform.crosshair_position_changed.connect(self.crosshair_position_changed)
self.waveform.crosshair_position_clicked.connect(self.crosshair_position_clicked)
self.waveform.crosshair_position_changed.connect(
self._emit_crosshair_position_changed_string
)
self.waveform.crosshair_position_clicked.connect(
self._emit_crosshair_position_clicked_string
)
self.waveform.roi_changed.connect(self.roi_changed)
self.waveform.roi_active.connect(self.roi_active)
def _hook_actions(self):
self.toolbar.widgets["save"].action.triggered.connect(self.export)
self.toolbar.widgets["matplotlib"].action.triggered.connect(self.export_to_matplotlib)
self.toolbar.widgets["drag_mode"].action.triggered.connect(self.enable_mouse_pan_mode)
self.toolbar.widgets["rectangle_mode"].action.triggered.connect(
self.enable_mouse_rectangle_mode
)
self.toolbar.widgets["auto_range"].action.triggered.connect(self._auto_range_from_toolbar)
self.toolbar.widgets["curves"].action.triggered.connect(self.show_curve_settings)
self.toolbar.widgets["fit_params"].action.triggered.connect(self.show_fit_summary_dialog)
self.toolbar.widgets["axis_settings"].action.triggered.connect(self.show_axis_settings)
self.toolbar.widgets["crosshair"].action.triggered.connect(self.waveform.toggle_crosshair)
self.toolbar.widgets["roi_select"].action.toggled.connect(self.waveform.toggle_roi)
self.toolbar.widgets["fps_monitor"].action.toggled.connect(self.enable_fps_monitor)
# self.toolbar.widgets["import"].action.triggered.connect(
# lambda: self.load_config(path=None, gui=True)
# )
# self.toolbar.widgets["export"].action.triggered.connect(
# lambda: self.save_config(path=None, gui=True)
# )
@Slot(bool)
def toogle_roi_select(self, checked: bool):
"""Toggle the linear region selector.
Args:
checked(bool): If True, enable the linear region selector.
"""
self.toolbar.widgets["roi_select"].action.setChecked(checked)
@Property(bool)
def clear_curves_on_plot_update(self) -> bool:
"""If True, clear curves on plot update."""
return self._clear_curves_on_plot_update
@clear_curves_on_plot_update.setter
def clear_curves_on_plot_update(self, value: bool):
"""Set the clear curves on plot update property.
Args:
value(bool): If True, clear curves on plot update.
"""
self._clear_curves_on_plot_update = value
@SafeSlot(tuple)
def _emit_crosshair_coordinates_changed_string(self, coordinates):
self.crosshair_coordinates_changed_string.emit(str(coordinates))
@SafeSlot(tuple)
def _emit_crosshair_coordinates_clicked_string(self, coordinates):
self.crosshair_coordinates_clicked_string.emit(str(coordinates))
@SafeSlot(tuple)
def _emit_crosshair_position_changed_string(self, position):
self.crosshair_position_changed_string.emit(str(position))
@SafeSlot(tuple)
def _emit_crosshair_position_clicked_string(self, position):
self.crosshair_position_clicked_string.emit(str(position))
###################################
# Dialog Windows
###################################
def show_axis_settings(self):
dialog = SettingsDialog(
self,
settings_widget=AxisSettings(),
window_title="Axis Settings",
config=self._config_dict["axis"],
)
dialog.exec()
def show_curve_settings(self):
dialog = SettingsDialog(
self,
settings_widget=CurveSettings(),
window_title="Curve Settings",
config=self.waveform._curves_data,
)
dialog.resize(800, 600)
dialog.exec()
def show_fit_summary_dialog(self):
dialog = FitSummaryWidget(target_widget=self)
dialog.resize(800, 600)
dialog.exec()
###################################
# User Access Methods from Waveform
###################################
@property
def curves(self) -> list[BECCurve]:
"""
Get the curves of the plot widget as a list
Returns:
list: List of curves.
"""
return self.waveform._curves
@curves.setter
def curves(self, value: list[BECCurve]):
self.waveform._curves = value
def get_curve(self, identifier) -> BECCurve:
"""
Get the curve by its index or ID.
Args:
identifier(int|str): Identifier of the curve. Can be either an integer (index) or a string (curve_id).
Returns:
BECCurve: The curve object.
"""
return self.waveform.get_curve(identifier)
def set_colormap(self, colormap: str):
"""
Set the colormap of the plot widget.
Args:
colormap(str, optional): Scale the colors of curves to colormap. If None, use the default color palette.
"""
self.waveform.set_colormap(colormap)
@Slot(str, str) # Slot for x_name, x_entry
@SafeSlot(str, popup_error=True) # Slot for x_name and
def set_x(self, x_name: str, x_entry: str | None = None):
"""
Change the x axis of the plot widget.
Args:
x_name(str): Name of the x signal.
- "best_effort": Use the best effort signal.
- "timestamp": Use the timestamp signal.
- "index": Use the index signal.
- Custom signal name of device from BEC.
x_entry(str): Entry of the x signal.
"""
self.waveform.set_x(x_name, x_entry)
@Slot(str) # Slot for y_name
@SafeSlot(popup_error=True)
def plot(
self,
arg1: list | np.ndarray | str | None = None,
x: list | np.ndarray | None = None,
y: list | np.ndarray | None = None,
x_name: str | None = None,
y_name: str | None = None,
z_name: str | None = None,
x_entry: str | None = None,
y_entry: str | None = None,
z_entry: str | None = None,
color: str | None = None,
color_map_z: str | None = "magma",
label: str | None = None,
validate: bool = True,
dap: str | None = None, # TODO add dap custom curve wrapper
**kwargs,
) -> BECCurve:
"""
Plot a curve to the plot widget.
Args:
arg1(list | np.ndarray | str | None): First argument which can be x data(list | np.ndarray), y data(list | np.ndarray), or y_name(str).
x(list | np.ndarray): Custom x data to plot.
y(list | np.ndarray): Custom y data to plot.
x_name(str): The name of the device for the x-axis.
y_name(str): The name of the device for the y-axis.
z_name(str): The name of the device for the z-axis.
x_entry(str): The name of the entry for the x-axis.
y_entry(str): The name of the entry for the y-axis.
z_entry(str): The name of the entry for the z-axis.
color(str): The color of the curve.
color_map_z(str): The color map to use for the z-axis.
label(str): The label of the curve.
validate(bool): If True, validate the device names and entries.
dap(str): The dap model to use for the curve. If not specified, none will be added.
Returns:
BECCurve: The curve object.
"""
if self.clear_curves_on_plot_update is True:
self.waveform.clear_source(source="scan_segment")
return self.waveform.plot(
arg1=arg1,
x=x,
y=y,
x_name=x_name,
y_name=y_name,
z_name=z_name,
x_entry=x_entry,
y_entry=y_entry,
z_entry=z_entry,
color=color,
color_map_z=color_map_z,
label=label,
validate=validate,
dap=dap,
**kwargs,
)
@Slot(
str, str, str, str, str, str, bool
) # Slot for x_name, y_name, x_entry, y_entry, color, validate_bec
@SafeSlot(str, str, str, popup_error=True)
def add_dap(
self,
x_name: str,
y_name: str,
dap: str,
x_entry: str | None = None,
y_entry: str | None = None,
color: str | None = None,
validate_bec: bool = True,
**kwargs,
) -> BECCurve:
"""
Add LMFIT dap model curve to the plot widget.
Args:
x_name(str): Name of the x signal.
x_entry(str): Entry of the x signal.
y_name(str): Name of the y signal.
y_entry(str): Entry of the y signal.
color(str, optional): Color of the curve. Defaults to None.
dap(str): The dap model to use for the curve.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
**kwargs: Additional keyword arguments for the curve configuration.
Returns:
BECCurve: The curve object.
"""
if self.clear_curves_on_plot_update is True:
self.waveform.clear_source(source="DAP")
return self.waveform.add_dap(
x_name=x_name,
y_name=y_name,
x_entry=x_entry,
y_entry=y_entry,
color=color,
dap=dap,
validate_bec=validate_bec,
**kwargs,
)
def get_dap_params(self) -> dict:
"""
Get the DAP parameters of all DAP curves.
Returns:
dict: DAP parameters of all DAP curves.
"""
return self.waveform.get_dap_params()
def get_dap_summary(self) -> dict:
"""
Get the DAP summary of all DAP curves.
Returns:
dict: DAP summary of all DAP curves.
"""
return self.waveform.get_dap_summary()
def remove_curve(self, *identifiers):
"""
Remove a curve from the plot widget.
Args:
*identifiers: Identifier of the curve to be removed. Can be either an integer (index) or a string (curve_id).
"""
self.waveform.remove_curve(*identifiers)
def scan_history(self, scan_index: int = None, scan_id: str = None):
"""
Update the scan curves with the data from the scan storage.
Provide only one of scan_id or scan_index.
Args:
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
"""
self.waveform.scan_history(scan_index, scan_id)
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict | pd.DataFrame:
"""
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.
"""
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"
return self.waveform.get_all_data(output)
###################################
# User Access Methods from Plotbase
###################################
def set(self, **kwargs):
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- title: str
- x_label: str
- y_label: str
- x_scale: Literal["linear", "log"]
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
self.waveform.set(**kwargs)
def set_title(self, title: str):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot.
"""
self.waveform.set_title(title)
def set_x_label(self, x_label: str):
"""
Set the x-axis label of the plot widget.
Args:
x_label(str): Label of the x-axis.
"""
self.waveform.set_x_label(x_label)
def set_y_label(self, y_label: str):
"""
Set the y-axis label of the plot widget.
Args:
y_label(str): Label of the y-axis.
"""
self.waveform.set_y_label(y_label)
def set_x_scale(self, x_scale: Literal["linear", "log"]):
"""
Set the scale of the x-axis of the plot widget.
Args:
x_scale(Literal["linear", "log"]): Scale of the x-axis.
"""
self.waveform.set_x_scale(x_scale)
def set_y_scale(self, y_scale: Literal["linear", "log"]):
"""
Set the scale of the y-axis of the plot widget.
Args:
y_scale(Literal["linear", "log"]): Scale of the y-axis.
"""
self.waveform.set_y_scale(y_scale)
def set_x_lim(self, x_lim: tuple):
"""
Set the limits of the x-axis of the plot widget.
Args:
x_lim(tuple): Limits of the x-axis.
"""
self.waveform.set_x_lim(x_lim)
def set_y_lim(self, y_lim: tuple):
"""
Set the limits of the y-axis of the plot widget.
Args:
y_lim(tuple): Limits of the y-axis.
"""
self.waveform.set_y_lim(y_lim)
def set_legend_label_size(self, legend_label_size: int):
"""
Set the size of the legend labels of the plot widget.
Args:
legend_label_size(int): Size of the legend labels.
"""
self.waveform.set_legend_label_size(legend_label_size)
def set_auto_range(self, enabled: bool, axis: str = "xy"):
"""
Set the auto range of the plot widget.
Args:
enabled(bool): If True, enable the auto range.
axis(str, optional): The axis to enable the auto range.
- "xy": Enable auto range for both x and y axis.
- "x": Enable auto range for x axis.
- "y": Enable auto range for y axis.
"""
self.waveform.set_auto_range(enabled, axis)
def toggle_roi(self, checked: bool):
"""Toggle the linear region selector.
Args:
checked(bool): If True, enable the linear region selector.
"""
self.waveform.toggle_roi(checked)
if self.toolbar.widgets["roi_select"].action.isChecked() != checked:
self.toolbar.widgets["roi_select"].action.setChecked(checked)
def select_roi(self, region: tuple):
"""
Set the region of interest of the plot widget.
Args:
region(tuple): Region of interest.
"""
self.waveform.select_roi(region)
def enable_fps_monitor(self, enabled: bool):
"""
Enable the FPS monitor of the plot widget.
Args:
enabled(bool): If True, enable the FPS monitor.
"""
self.waveform.enable_fps_monitor(enabled)
if self.toolbar.widgets["fps_monitor"].action.isChecked() != enabled:
self.toolbar.widgets["fps_monitor"].action.setChecked(enabled)
@SafeSlot()
def _auto_range_from_toolbar(self):
"""
Set the auto range of the plot widget from the toolbar.
"""
self.waveform.set_auto_range(True, "xy")
def set_grid(self, x_grid: bool, y_grid: bool):
"""
Set the grid visibility of the plot widget.
Args:
x_grid(bool): Visibility of the x-axis grid.
y_grid(bool): Visibility of the y-axis grid.
"""
self.waveform.set_grid(x_grid, y_grid)
def set_outer_axes(self, show: bool):
"""
Set the outer axes visibility of the plot widget.
Args:
show(bool): Visibility of the outer axes.
"""
self.waveform.set_outer_axes(show)
def enable_scatter(self, enabled: bool):
"""
Enable the scatter plot of the plot widget.
Args:
enabled(bool): If True, enable the scatter plot.
"""
self.waveform.enable_scatter(enabled)
def lock_aspect_ratio(self, lock: bool):
"""
Lock the aspect ratio of the plot widget.
Args:
lock(bool): Lock the aspect ratio.
"""
self.waveform.lock_aspect_ratio(lock)
@SafeSlot()
def enable_mouse_rectangle_mode(self):
self.toolbar.widgets["rectangle_mode"].action.setChecked(True)
self.toolbar.widgets["drag_mode"].action.setChecked(False)
self.waveform.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
@SafeSlot()
def enable_mouse_pan_mode(self):
self.toolbar.widgets["drag_mode"].action.setChecked(True)
self.toolbar.widgets["rectangle_mode"].action.setChecked(False)
self.waveform.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
def export(self):
"""
Show the export dialog for the plot widget.
"""
self.waveform.export()
def export_to_matplotlib(self):
"""
Export the plot widget to Matplotlib.
"""
try:
import matplotlib as mpl
except ImportError:
self.warning_util.show_warning(
title="Matplotlib not installed",
message="Matplotlib is required for this feature.",
detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.",
)
return
self.waveform.export_to_matplotlib()
#######################################
# User Access Methods from BECConnector
######################################
def load_config(self, path: str | None = None, gui: bool = False):
"""
Load the configuration of the widget from YAML.
Args:
path(str): Path to the configuration file for non-GUI dialog mode.
gui(bool): If True, use the GUI dialog to load the configuration file.
"""
self.fig.load_config(path=path, gui=gui)
def save_config(self, path: str | None = None, gui: bool = False):
"""
Save the configuration of the widget to YAML.
Args:
path(str): Path to save the configuration file for non-GUI dialog mode.
gui(bool): If True, use the GUI dialog to save the configuration file.
"""
self.fig.save_config(path=path, gui=gui)
def cleanup(self):
self.fig.cleanup()
return super().cleanup()
def main(): # pragma: no cover
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = BECWaveformWidget()
widget.plot(x_name="samx", y_name="bpm4i")
widget.plot(y_name="bpm3i")
widget.plot(y_name="bpm4a")
widget.plot(y_name="bpm5i")
widget.show()
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,986 @@
from __future__ import annotations
from enum import Enum
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from qtpy.QtCore import QPoint, QPointF, Qt, Signal
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.qt_utils.round_frame import RoundedFrame
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
from bec_widgets.qt_utils.side_panel import SidePanel
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar, ToolbarBundle
from bec_widgets.utils import ConnectionConfig, Crosshair, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.fps_counter import FPSCounter
from bec_widgets.utils.widget_state_manager import WidgetStateManager
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
from bec_widgets.widgets.plots_next_gen.setting_menus.axis_settings import AxisSettings
from bec_widgets.widgets.plots_next_gen.toolbar_bundles.mouse_interactions import (
MouseInteractionToolbarBundle,
)
from bec_widgets.widgets.plots_next_gen.toolbar_bundles.plot_export import PlotExportBundle
from bec_widgets.widgets.plots_next_gen.toolbar_bundles.roi_bundle import ROIBundle
logger = bec_logger.logger
class BECViewBox(pg.ViewBox):
sigPaint = Signal()
def paint(self, painter, opt, widget):
super().paint(painter, opt, widget)
self.sigPaint.emit()
def itemBoundsChanged(self, item):
self._itemBoundsCache.pop(item, None)
if (self.state["autoRange"][0] is not False) or (self.state["autoRange"][1] is not False):
# check if the call is coming from a mouse-move event
if hasattr(item, "skip_auto_range") and item.skip_auto_range:
return
self._autoRangeNeedsUpdate = True
self.update()
class UIMode(Enum):
NONE = 0
POPUP = 1
SIDE = 2
class PlotBase(BECWidget, QWidget):
PLUGIN = False
RPC = False
# Custom Signals
property_changed = Signal(str, object)
crosshair_position_changed = Signal(tuple)
crosshair_position_clicked = Signal(tuple)
crosshair_coordinates_changed = Signal(tuple)
crosshair_coordinates_clicked = Signal(tuple)
def __init__(
self,
parent: QWidget | None = None,
config: ConnectionConfig | None = None,
client=None,
gui_id: str | None = None,
popups: bool = False,
**kwargs,
) -> None:
if config is None:
config = ConnectionConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
QWidget.__init__(self, parent=parent)
# For PropertyManager identification
self.setObjectName("PlotBase")
self.get_bec_shortcuts()
# Layout Management
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(0)
self.layout_manager = LayoutManagerWidget(parent=self)
self.layout_manager.layout.setContentsMargins(0, 0, 0, 0)
self.layout_manager.layout.setSpacing(0)
# Property Manager
self.state_manager = WidgetStateManager(self)
# Entry Validator
self.entry_validator = EntryValidator(self.dev)
# Base widgets elements
self._popups = popups
self._ui_mode = UIMode.POPUP if popups else UIMode.SIDE
self.axis_settings_dialog = None
self.plot_widget = pg.GraphicsLayoutWidget(parent=self)
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.plot_widget.addItem(self.plot_item)
self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
self._init_toolbar()
# PlotItem Addons
self.plot_item.addLegend()
self.crosshair = None
self.fps_monitor = None
self.fps_label = QLabel(alignment=Qt.AlignmentFlag.AlignRight)
self._user_x_label = ""
self._x_label_suffix = ""
self._init_ui()
self._connect_to_theme_change()
self._update_theme()
def apply_theme(self, theme: str):
self.round_plot_widget.apply_theme(theme)
def _init_ui(self):
self.layout.addWidget(self.layout_manager)
self.round_plot_widget = RoundedFrame(content_widget=self.plot_widget, theme_update=True)
self.layout_manager.add_widget(self.round_plot_widget)
self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top")
self.fps_label.hide()
self.layout_manager.add_widget_relative(self.side_panel, self.round_plot_widget, "left")
self.layout_manager.add_widget_relative(self.toolbar, self.fps_label, "top")
self.ui_mode = self._ui_mode # to initiate the first time
# PlotItem ViewBox Signals
self.plot_item.vb.sigStateChanged.connect(self.viewbox_state_changed)
def _init_toolbar(self):
self.popup_bundle = None
self.performance_bundle = ToolbarBundle("performance")
self.plot_export_bundle = PlotExportBundle("plot_export", target_widget=self)
self.mouse_bundle = MouseInteractionToolbarBundle("mouse_interaction", target_widget=self)
# self.state_export_bundle = SaveStateBundle("state_export", target_widget=self) #TODO ATM disabled, cannot be used in DockArea, which is exposed to the user
self.roi_bundle = ROIBundle("roi", target_widget=self)
# Add elements to toolbar
self.toolbar.add_bundle(self.plot_export_bundle, target_widget=self)
# self.toolbar.add_bundle(self.state_export_bundle, target_widget=self) #TODO ATM disabled, cannot be used in DockArea, which is exposed to the user
self.toolbar.add_bundle(self.mouse_bundle, target_widget=self)
self.toolbar.add_bundle(self.roi_bundle, target_widget=self)
self.performance_bundle.add_action(
"fps_monitor",
MaterialIconAction(
icon_name="speed", tooltip="Show FPS Monitor", checkable=True, parent=self
),
)
self.toolbar.add_bundle(self.performance_bundle, target_widget=self)
self.toolbar.widgets["fps_monitor"].action.toggled.connect(
lambda checked: setattr(self, "enable_fps_monitor", checked)
)
# hide some options by default
self.toolbar.toggle_action_visibility("fps_monitor", False)
def add_side_menus(self):
"""Adds multiple menus to the side panel."""
# Setting Axis Widget
try:
axis_setting = AxisSettings(target_widget=self)
self.side_panel.add_menu(
action_id="axis",
icon_name="settings",
tooltip="Show Axis Settings",
widget=axis_setting,
title="Axis Settings",
)
except ValueError:
return
def add_popups(self):
"""
Add popups to the toolbar.
"""
self.popup_bundle = ToolbarBundle("popup_bundle")
settings = MaterialIconAction(
icon_name="settings", tooltip="Show Axis Settings", checkable=True, parent=self
)
self.popup_bundle.add_action("axis", settings)
self.toolbar.add_bundle(self.popup_bundle, target_widget=self)
self.toolbar.widgets["axis"].action.triggered.connect(self.show_axis_settings_popup)
def show_axis_settings_popup(self):
"""
Show the axis settings dialog.
"""
settings_action = self.toolbar.widgets["axis"].action
if self.axis_settings_dialog is None or not self.axis_settings_dialog.isVisible():
axis_setting = AxisSettings(target_widget=self, popup=True)
self.axis_settings_dialog = SettingsDialog(
self, settings_widget=axis_setting, window_title="Axis Settings", modal=False
)
# When the dialog is closed, update the toolbar icon and clear the reference
self.axis_settings_dialog.finished.connect(self._axis_settings_closed)
self.axis_settings_dialog.show()
settings_action.setChecked(True)
else:
# If already open, bring it to the front
self.axis_settings_dialog.raise_()
self.axis_settings_dialog.activateWindow()
settings_action.setChecked(True) # keep it toggled
def _axis_settings_closed(self):
"""
Slot for when the axis settings dialog is closed.
"""
self.axis_settings_dialog = None
self.toolbar.widgets["axis"].action.setChecked(False)
def reset_legend(self):
"""In the case that the legend is not visible, reset it to be visible to top left corner"""
self.plot_item.legend.autoAnchor(50)
################################################################################
# Toggle UI Elements
################################################################################
@property
def ui_mode(self) -> UIMode:
"""
Get the UI mode.
"""
return self._ui_mode
@ui_mode.setter
def ui_mode(self, mode: UIMode):
"""
Set the UI mode.
Args:
mode(UIMode): The UI mode to set.
"""
if not isinstance(mode, UIMode):
raise ValueError("ui_mode must be an instance of UIMode")
self._ui_mode = mode
# First, clear both UI elements:
if self.popup_bundle is not None:
for action_id in self.toolbar.bundles["popup_bundle"]:
self.toolbar.widgets[action_id].action.setVisible(False)
if self.axis_settings_dialog is not None and self.axis_settings_dialog.isVisible():
self.axis_settings_dialog.close()
self.side_panel.hide()
# Now, apply the new mode:
if mode == UIMode.POPUP:
if self.popup_bundle is None:
self.add_popups()
else:
for action_id in self.toolbar.bundles["popup_bundle"]:
self.toolbar.widgets[action_id].action.setVisible(True)
elif mode == UIMode.SIDE:
self.add_side_menus()
self.side_panel.show()
@SafeProperty(bool, doc="Enable popups setting dialogs for the plot widget.")
def enable_popups(self):
"""
Enable popups setting dialogs for the plot widget.
"""
return self.ui_mode == UIMode.POPUP
@enable_popups.setter
def enable_popups(self, value: bool):
"""
Set the popups setting dialogs for the plot widget.
Args:
value(bool): The value to set.
"""
if value:
self.ui_mode = UIMode.POPUP
else:
if self.ui_mode == UIMode.POPUP:
self.ui_mode = UIMode.NONE
@SafeProperty(bool, doc="Show Side Panel")
def enable_side_panel(self) -> bool:
"""
Show Side Panel
"""
return self.ui_mode == UIMode.SIDE
@enable_side_panel.setter
def enable_side_panel(self, value: bool):
"""
Show Side Panel
Args:
value(bool): The value to set.
"""
if value:
self.ui_mode = UIMode.SIDE
else:
if self.ui_mode == UIMode.SIDE:
self.ui_mode = UIMode.NONE
@SafeProperty(bool, doc="Show Toolbar")
def enable_toolbar(self) -> bool:
"""
Show Toolbar.
"""
return self.toolbar.isVisible()
@enable_toolbar.setter
def enable_toolbar(self, value: bool):
"""
Show Toolbar.
Args:
value(bool): The value to set.
"""
if value:
# Disable popup mode
if self._popups:
# Directly update the internal flag to avoid recursion
self._popups = False
# Hide the popup bundle if it exists and close any open dialogs
if self.popup_bundle is not None:
for action in self.toolbar.bundles["popup_bundle"].actions:
action.setVisible(False)
if self.axis_settings_dialog is not None and self.axis_settings_dialog.isVisible():
self.axis_settings_dialog.close()
self.side_panel.show()
# Add side menus if not already added
self.add_side_menus()
else:
self.side_panel.hide()
@SafeProperty(bool, doc="Enable the FPS monitor.")
def enable_fps_monitor(self) -> bool:
"""
Enable the FPS monitor.
"""
return self.fps_label.isVisible()
@enable_fps_monitor.setter
def enable_fps_monitor(self, value: bool):
"""
Enable the FPS monitor.
Args:
value(bool): The value to set.
"""
if value and self.fps_monitor is None:
self.hook_fps_monitor()
elif not value and self.fps_monitor is not None:
self.unhook_fps_monitor()
################################################################################
# ViewBox State Signals
################################################################################
def viewbox_state_changed(self):
"""
Emit a signal when the state of the viewbox has changed.
Merges the default pyqtgraphs signal states and also CTRL menu toggles.
"""
viewbox_state = self.plot_item.vb.getState()
# Range Limits
x_min, x_max = viewbox_state["targetRange"][0]
y_min, y_max = viewbox_state["targetRange"][1]
self.property_changed.emit("x_min", x_min)
self.property_changed.emit("x_max", x_max)
self.property_changed.emit("y_min", y_min)
self.property_changed.emit("y_max", y_max)
# Grid Toggles
################################################################################
# Plot Properties
################################################################################
def set(self, **kwargs):
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
"""
property_map = {
"title": self.title,
"x_label": self.x_label,
"y_label": self.y_label,
"x_limits": self.x_limits,
"y_limits": self.y_limits,
"x_grid": self.x_grid,
"y_grid": self.y_grid,
"inner_axes": self.inner_axes,
"outer_axes": self.outer_axes,
"lock_aspect_ratio": self.lock_aspect_ratio,
"auto_range_x": self.auto_range_x,
"auto_range_y": self.auto_range_y,
"x_log": self.x_log,
"y_log": self.y_log,
"legend_label_size": self.legend_label_size,
}
for key, value in kwargs.items():
if key in property_map:
setattr(self, key, value)
else:
logger.warning(f"Property {key} not found.")
@SafeProperty(str, doc="The title of the axes.")
def title(self) -> str:
"""
Set title of the plot.
"""
return self.plot_item.titleLabel.text
@title.setter
def title(self, value: str):
"""
Set title of the plot.
Args:
value(str): The title to set.
"""
self.plot_item.setTitle(value)
self.property_changed.emit("title", value)
@SafeProperty(str, doc="The text of the x label")
def x_label(self) -> str:
"""
The set label for the x-axis.
"""
return self._user_x_label
@x_label.setter
def x_label(self, value: str):
"""
The set label for the x-axis.
Args:
value(str): The label to set.
"""
self._user_x_label = value
self._apply_x_label()
self.property_changed.emit("x_label", self._user_x_label)
@property
def x_label_suffix(self) -> str:
"""
A read-only (or internal) suffix automatically appended to the user label.
Not settable by the user directly from the UI.
"""
return self._x_label_suffix
def set_x_label_suffix(self, suffix: str):
"""
Public or protected method to update the suffix.
The user code or subclass (Waveform) can call this
when x_mode changes, but the AxisSettings won't show it.
"""
self._x_label_suffix = suffix
self._apply_x_label()
@property
def x_label_combined(self) -> str:
"""
The final label shown on the axis = user portion + suffix.
"""
return self._user_x_label + self._x_label_suffix
def _apply_x_label(self):
"""
Actually updates the pyqtgraph axis label text to
the combined label. Called whenever user label or suffix changes.
"""
final_label = self.x_label_combined
self.plot_item.setLabel("bottom", text=final_label)
@SafeProperty(str, doc="The text of the y label")
def y_label(self) -> str:
"""
The set label for the y-axis.
"""
return self.plot_item.getAxis("left").labelText
@y_label.setter
def y_label(self, value: str):
"""
The set label for the y-axis.
Args:
value(str): The label to set.
"""
self.plot_item.setLabel("left", text=value)
self.property_changed.emit("y_label", value)
def _tuple_to_qpointf(self, tuple: tuple | list):
"""
Helper function to convert a tuple to a QPointF.
Args:
tuple(tuple|list): Tuple or list of two numbers.
Returns:
QPointF: The tuple converted to a QPointF.
"""
if len(tuple) != 2:
raise ValueError("Limits must be a tuple or list of two numbers.")
min_val, max_val = tuple
if not isinstance(min_val, (int, float)) or not isinstance(max_val, (int, float)):
raise TypeError("Limits must be numbers.")
if min_val > max_val:
raise ValueError("Minimum limit cannot be greater than maximum limit.")
return QPoint(*tuple)
################################################################################
# X limits, has to be SaveProperty("QPointF") because of the tuple conversion for designer,
# the python properties are used for CLI and API for context dialog settings.
@SafeProperty("QPointF")
def x_limits(self) -> QPointF:
"""
Get the x limits of the plot.
"""
current_lim = self.plot_item.vb.viewRange()[0]
return QPointF(current_lim[0], current_lim[1])
@x_limits.setter
def x_limits(self, value):
"""
Set the x limits of the plot.
Args:
value(QPointF|tuple|list): The x limits to set.
"""
if isinstance(value, (tuple, list)):
value = self._tuple_to_qpointf(value)
self.plot_item.vb.setXRange(value.x(), value.y(), padding=0)
@property
def x_lim(self) -> tuple:
"""
Get the x limits of the plot.
"""
return (self.x_limits.x(), self.x_limits.y())
@x_lim.setter
def x_lim(self, value):
"""
Set the x limits of the plot.
Args:
value(tuple): The x limits to set.
"""
self.x_limits = value
@property
def x_min(self) -> float:
"""
Get the minimum x limit of the plot.
"""
return self.x_limits.x()
@x_min.setter
def x_min(self, value: float):
"""
Set the minimum x limit of the plot.
Args:
value(float): The minimum x limit to set.
"""
self.x_limits = (value, self.x_lim[1])
@property
def x_max(self) -> float:
"""
Get the maximum x limit of the plot.
"""
return self.x_limits.y()
@x_max.setter
def x_max(self, value: float):
"""
Set the maximum x limit of the plot.
Args:
value(float): The maximum x limit to set.
"""
self.x_limits = (self.x_lim[0], value)
################################################################################
# Y limits, has to be SaveProperty("QPointF") because of the tuple conversion for designer,
# the python properties are used for CLI and API for context dialog settings.
@SafeProperty("QPointF")
def y_limits(self) -> QPointF:
"""
Get the y limits of the plot.
"""
current_lim = self.plot_item.vb.viewRange()[1]
return QPointF(current_lim[0], current_lim[1])
@y_limits.setter
def y_limits(self, value):
"""
Set the y limits of the plot.
Args:
value(QPointF|tuple|list): The y limits to set.
"""
if isinstance(value, (tuple, list)):
value = self._tuple_to_qpointf(value)
self.plot_item.vb.setYRange(value.x(), value.y(), padding=0)
@property
def y_lim(self) -> tuple:
"""
Get the y limits of the plot.
"""
return (self.y_limits.x(), self.y_limits.y())
@y_lim.setter
def y_lim(self, value):
"""
Set the y limits of the plot.
Args:
value(tuple): The y limits to set.
"""
self.y_limits = value
@property
def y_min(self) -> float:
"""
Get the minimum y limit of the plot.
"""
return self.y_limits.x()
@y_min.setter
def y_min(self, value: float):
"""
Set the minimum y limit of the plot.
Args:
value(float): The minimum y limit to set.
"""
self.y_limits = (value, self.y_lim[1])
@property
def y_max(self) -> float:
"""
Get the maximum y limit of the plot.
"""
return self.y_limits.y()
@y_max.setter
def y_max(self, value: float):
"""
Set the maximum y limit of the plot.
Args:
value(float): The maximum y limit to set.
"""
self.y_limits = (self.y_lim[0], value)
@SafeProperty(bool, doc="Show grid on the x-axis.")
def x_grid(self) -> bool:
"""
Show grid on the x-axis.
"""
return self.plot_item.ctrl.xGridCheck.isChecked()
@x_grid.setter
def x_grid(self, value: bool):
"""
Show grid on the x-axis.
Args:
value(bool): The value to set.
"""
self.plot_item.showGrid(x=value)
self.property_changed.emit("x_grid", value)
@SafeProperty(bool, doc="Show grid on the y-axis.")
def y_grid(self) -> bool:
"""
Show grid on the y-axis.
"""
return self.plot_item.ctrl.yGridCheck.isChecked()
@y_grid.setter
def y_grid(self, value: bool):
"""
Show grid on the y-axis.
Args:
value(bool): The value to set.
"""
self.plot_item.showGrid(y=value)
self.property_changed.emit("y_grid", value)
@SafeProperty(bool, doc="Set X-axis to log scale if True, linear if False.")
def x_log(self) -> bool:
"""
Set X-axis to log scale if True, linear if False.
"""
return bool(self.plot_item.vb.state.get("logMode", [False, False])[0])
@x_log.setter
def x_log(self, value: bool):
"""
Set X-axis to log scale if True, linear if False.
Args:
value(bool): The value to set.
"""
self.plot_item.setLogMode(x=value)
self.property_changed.emit("x_log", value)
@SafeProperty(bool, doc="Set Y-axis to log scale if True, linear if False.")
def y_log(self) -> bool:
"""
Set Y-axis to log scale if True, linear if False.
"""
return bool(self.plot_item.vb.state.get("logMode", [False, False])[1])
@y_log.setter
def y_log(self, value: bool):
"""
Set Y-axis to log scale if True, linear if False.
Args:
value(bool): The value to set.
"""
self.plot_item.setLogMode(y=value)
self.property_changed.emit("y_log", value)
@SafeProperty(bool, doc="Show the outer axes of the plot widget.")
def outer_axes(self) -> bool:
"""
Show the outer axes of the plot widget.
"""
return self.plot_item.getAxis("top").isVisible()
@outer_axes.setter
def outer_axes(self, value: bool):
"""
Show the outer axes of the plot widget.
Args:
value(bool): The value to set.
"""
self.plot_item.showAxis("top", value)
self.plot_item.showAxis("right", value)
self.property_changed.emit("outer_axes", value)
@SafeProperty(bool, doc="Show inner axes of the plot widget.")
def inner_axes(self) -> bool:
"""
Show inner axes of the plot widget.
"""
return self.plot_item.getAxis("bottom").isVisible()
@inner_axes.setter
def inner_axes(self, value: bool):
"""
Show inner axes of the plot widget.
Args:
value(bool): The value to set.
"""
self.plot_item.showAxis("bottom", value)
self.plot_item.showAxis("left", value)
self.property_changed.emit("inner_axes", value)
@SafeProperty(bool, doc="Lock aspect ratio of the plot widget.")
def lock_aspect_ratio(self) -> bool:
"""
Lock aspect ratio of the plot widget.
"""
return bool(self.plot_item.vb.getState()["aspectLocked"])
@lock_aspect_ratio.setter
def lock_aspect_ratio(self, value: bool):
"""
Lock aspect ratio of the plot widget.
Args:
value(bool): The value to set.
"""
self.plot_item.setAspectLocked(value)
@SafeProperty(bool, doc="Set auto range for the x-axis.")
def auto_range_x(self) -> bool:
"""
Set auto range for the x-axis.
"""
return bool(self.plot_item.vb.getState()["autoRange"][0])
@auto_range_x.setter
def auto_range_x(self, value: bool):
"""
Set auto range for the x-axis.
Args:
value(bool): The value to set.
"""
self.plot_item.enableAutoRange(x=value)
@SafeProperty(bool, doc="Set auto range for the y-axis.")
def auto_range_y(self) -> bool:
"""
Set auto range for the y-axis.
"""
return bool(self.plot_item.vb.getState()["autoRange"][1])
@auto_range_y.setter
def auto_range_y(self, value: bool):
"""
Set auto range for the y-axis.
Args:
value(bool): The value to set.
"""
self.plot_item.enableAutoRange(y=value)
@SafeProperty(int, doc="The font size of the legend font.")
def legend_label_size(self) -> int:
"""
The font size of the legend font.
"""
if not self.plot_item.legend:
return
scale = self.plot_item.legend.scale() * 9
return scale
@legend_label_size.setter
def legend_label_size(self, value: int):
"""
The font size of the legend font.
Args:
value(int): The font size to set.
"""
if not self.plot_item.legend:
return
scale = (
value / 9
) # 9 is the default font size of the legend, so we always scale it against 9
self.plot_item.legend.setScale(scale)
################################################################################
# FPS Counter
################################################################################
def update_fps_label(self, fps: float) -> None:
"""
Update the FPS label.
Args:
fps(float): The frames per second.
"""
if self.fps_label:
self.fps_label.setText(f"FPS: {fps:.2f}")
def hook_fps_monitor(self):
"""Hook the FPS monitor to the plot."""
if self.fps_monitor is None:
self.fps_monitor = FPSCounter(self.plot_item.vb)
self.fps_label.show()
self.fps_monitor.sigFpsUpdate.connect(self.update_fps_label)
self.update_fps_label(0)
def unhook_fps_monitor(self, delete_label=True):
"""Unhook the FPS monitor from the plot."""
if self.fps_monitor is not None and delete_label:
# Remove Monitor
self.fps_monitor.cleanup()
self.fps_monitor.deleteLater()
self.fps_monitor = None
if self.fps_label is not None:
# Hide Label
self.fps_label.hide()
################################################################################
# Crosshair
################################################################################
def hook_crosshair(self) -> None:
"""Hook the crosshair to all plots."""
if self.crosshair is None:
self.crosshair = Crosshair(self.plot_item, precision=3)
self.crosshair.crosshairChanged.connect(self.crosshair_position_changed)
self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked)
self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked1D.connect(self.crosshair_coordinates_clicked)
self.crosshair.coordinatesChanged2D.connect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked2D.connect(self.crosshair_coordinates_clicked)
def unhook_crosshair(self) -> None:
"""Unhook the crosshair from all plots."""
if self.crosshair is not None:
self.crosshair.crosshairChanged.disconnect(self.crosshair_position_changed)
self.crosshair.crosshairClicked.disconnect(self.crosshair_position_clicked)
self.crosshair.coordinatesChanged1D.disconnect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked1D.disconnect(self.crosshair_coordinates_clicked)
self.crosshair.coordinatesChanged2D.disconnect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked2D.disconnect(self.crosshair_coordinates_clicked)
self.crosshair.cleanup()
self.crosshair.deleteLater()
self.crosshair = None
def toggle_crosshair(self) -> None:
"""Toggle the crosshair on all plots."""
if self.crosshair is None:
return self.hook_crosshair()
self.unhook_crosshair()
@SafeSlot()
def reset(self) -> None:
"""Reset the plot widget."""
if self.crosshair is not None:
self.crosshair.clear_markers()
self.crosshair.update_markers()
def cleanup(self):
self.unhook_crosshair()
self.unhook_fps_monitor(delete_label=True)
if self.axis_settings_dialog is not None:
self.axis_settings_dialog.close()
self.axis_settings_dialog = None
self.cleanup_pyqtgraph()
super().cleanup()
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""
item = self.plot_item
item.vb.menu.close()
item.vb.menu.deleteLater()
item.ctrlMenu.close()
item.ctrlMenu.deleteLater()
class DemoPlotBase(QMainWindow): # pragma: no cover:
def __init__(self):
super().__init__()
self.main_widget = QWidget()
self.setCentralWidget(self.main_widget)
self.main_widget.layout = QHBoxLayout(self.main_widget)
self.plot_popup = PlotBase(popups=True)
self.plot_popup.title = "PlotBase with popups"
self.plot_side_panels = PlotBase(popups=False)
self.plot_side_panels.title = "PlotBase with side panels"
self.plot_popup.plot_item.plot(np.random.rand(100), pen=(255, 0, 0))
self.plot_side_panels.plot_item.plot(np.random.rand(100), pen=(0, 255, 0))
self.main_widget.layout.addWidget(self.plot_side_panels)
self.main_widget.layout.addWidget(self.plot_popup)
self.resize(1400, 600)
if __name__ == "__main__": # pragma: no cover:
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
window = DemoPlotBase()
window.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,149 @@
import os
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.settings_dialog import SettingWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.widget_io import WidgetIO
class AxisSettings(SettingWidget):
def __init__(self, parent=None, target_widget=None, popup=False, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
# This is a settings widget that depends on the target widget
# and should mirror what is in the target widget.
# Saving settings for this widget could result in recursively setting the target widget.
self.setProperty("skip_settings", True)
self.setObjectName("AxisSettings")
current_path = os.path.dirname(__file__)
if popup:
form = UILoader().load_ui(
os.path.join(current_path, "axis_settings_horizontal.ui"), self
)
else:
form = UILoader().load_ui(os.path.join(current_path, "axis_settings_vertical.ui"), self)
self.target_widget = target_widget
self.popup = popup
# # Scroll area
self.scroll_area = QScrollArea(self)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShape(QFrame.NoFrame)
self.scroll_area.setWidget(form)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.scroll_area)
self.ui = form
if self.target_widget is not None and self.popup is False:
self.connect_all_signals()
self.target_widget.property_changed.connect(self.update_property)
if self.popup is True:
self.fetch_all_properties()
def connect_all_signals(self):
for widget in [
self.ui.title,
self.ui.x_label,
self.ui.x_min,
self.ui.x_max,
self.ui.x_log,
self.ui.x_grid,
self.ui.y_label,
self.ui.y_min,
self.ui.y_max,
self.ui.y_log,
self.ui.y_grid,
self.ui.inner_axes,
self.ui.outer_axes,
]:
WidgetIO.connect_widget_change_signal(widget, self.set_property)
@SafeSlot()
def set_property(self, widget: QWidget, value):
"""
Set property of the target widget based on the widget that emitted the signal.
The name of the property has to be the same as the objectName of the widget
and compatible with WidgetIO.
Args:
widget(QWidget): The widget that emitted the signal.
value(): The value to set the property to.
"""
try: # to avoid crashing when the widget is not found in Designer
property_name = widget.objectName()
setattr(self.target_widget, property_name, value)
except RuntimeError:
return
@SafeSlot()
def update_property(self, property_name: str, value):
"""
Update the value of the widget based on the property name and value.
The name of the property has to be the same as the objectName of the widget
and compatible with WidgetIO.
Args:
property_name(str): The name of the property to update.
value: The value to set the property to.
"""
try: # to avoid crashing when the widget is not found in Designer
widget_to_set = self.ui.findChild(QWidget, property_name)
except RuntimeError:
return
# Block signals to avoid triggering set_property again
was_blocked = widget_to_set.blockSignals(True)
WidgetIO.set_value(widget_to_set, value)
widget_to_set.blockSignals(was_blocked)
def fetch_all_properties(self):
"""
Fetch all properties from the target widget and update the settings widget.
"""
for widget in [
self.ui.title,
self.ui.inner_axes,
self.ui.outer_axes,
self.ui.x_label,
self.ui.x_min,
self.ui.x_max,
self.ui.x_log,
self.ui.x_grid,
self.ui.y_label,
self.ui.y_min,
self.ui.y_max,
self.ui.y_log,
self.ui.y_grid,
]:
property_name = widget.objectName()
value = getattr(self.target_widget, property_name)
WidgetIO.set_value(widget, value)
def accept_changes(self):
"""
Apply all properties from the settings widget to the target widget.
"""
for widget in [
self.ui.title,
self.ui.x_label,
self.ui.x_min,
self.ui.x_max,
self.ui.x_log,
self.ui.x_grid,
self.ui.y_label,
self.ui.y_min,
self.ui.y_max,
self.ui.y_log,
self.ui.y_grid,
self.ui.outer_axes,
self.ui.inner_axes,
]:
property_name = widget.objectName()
value = WidgetIO.get_value(widget)
setattr(self.target_widget, property_name, value)

View File

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

View File

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

View File

@@ -0,0 +1,111 @@
import pyqtgraph as pg
from qtpy.QtCore import QTimer
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.toolbar import MaterialIconAction, SwitchableToolBarAction, ToolbarBundle
class MouseInteractionToolbarBundle(ToolbarBundle):
"""
A bundle of actions that are hooked in this constructor itself,
so that you can immediately connect the signals and toggle states.
This bundle is for a toolbar that controls mouse interactions on a plot.
"""
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
self.mouse_mode = None
# Create each MaterialIconAction with a parent
# so the signals can fire even if the toolbar isn't added yet.
drag = MaterialIconAction(
icon_name="drag_pan",
tooltip="Drag Mouse Mode",
checkable=True,
parent=self.target_widget, # or any valid parent
)
rect = MaterialIconAction(
icon_name="frame_inspect",
tooltip="Rectangle Zoom Mode",
checkable=True,
parent=self.target_widget,
)
auto = MaterialIconAction(
icon_name="open_in_full",
tooltip="Autorange Plot",
checkable=False,
parent=self.target_widget,
)
self.switch_mouse_action = SwitchableToolBarAction(
actions={"drag_mode": drag, "rectangle_mode": rect},
initial_action="drag_mode",
tooltip="Mouse Modes",
checkable=True,
parent=self,
)
# Add them to the bundle
self.add_action("switch_mouse", self.switch_mouse_action)
self.add_action("auto_range", auto)
# Immediately connect signals
drag.action.toggled.connect(self.enable_mouse_pan_mode)
rect.action.toggled.connect(self.enable_mouse_rectangle_mode)
auto.action.triggered.connect(self.autorange_plot)
# Give some time to check the state
QTimer.singleShot(10, self.get_viewbox_mode)
def get_viewbox_mode(self):
"""
Returns the current interaction mode of a PyQtGraph ViewBox and sets the corresponding action.
"""
if self.target_widget:
viewbox = self.target_widget.plot_item.getViewBox()
if viewbox.getState()["mouseMode"] == 3:
self.switch_mouse_action.set_default_action("drag_mode")
self.switch_mouse_action.main_button.setChecked(True)
self.mouse_mode = "PanMode"
elif viewbox.getState()["mouseMode"] == 1:
self.switch_mouse_action.set_default_action("rectangle_mode")
self.switch_mouse_action.main_button.setChecked(True)
self.mouse_mode = "RectMode"
@SafeSlot(bool)
def enable_mouse_rectangle_mode(self, checked: bool):
"""
Enable the rectangle zoom mode on the plot widget.
"""
if self.mouse_mode == "RectMode":
self.switch_mouse_action.main_button.setChecked(True)
return
self.actions["switch_mouse"].actions["drag_mode"].action.setChecked(not checked)
if self.target_widget and checked:
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
self.mouse_mode = "RectMode"
@SafeSlot(bool)
def enable_mouse_pan_mode(self, checked: bool):
"""
Enable the pan mode on the plot widget.
"""
if self.mouse_mode == "PanMode":
self.switch_mouse_action.main_button.setChecked(True)
return
self.actions["switch_mouse"].actions["rectangle_mode"].action.setChecked(not checked)
if self.target_widget and checked:
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
self.mouse_mode = "PanMode"
@SafeSlot()
def autorange_plot(self):
"""
Enable autorange on the plot widget.
"""
if self.target_widget:
self.target_widget.auto_range_x = True
self.target_widget.auto_range_y = True

View File

@@ -0,0 +1,70 @@
from pyqtgraph.exporters import MatplotlibExporter
from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility
from bec_widgets.qt_utils.toolbar import MaterialIconAction, SwitchableToolBarAction, ToolbarBundle
class PlotExportBundle(ToolbarBundle):
"""
A bundle of actions that are hooked in this constructor itself,
so that you can immediately connect the signals and toggle states.
This bundle is for a toolbar that controls exporting a plot.
"""
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Create each MaterialIconAction with a parent
# so the signals can fire even if the toolbar isn't added yet.
save = MaterialIconAction(
icon_name="save", tooltip="Open Export Dialog", parent=self.target_widget
)
matplotlib = MaterialIconAction(
icon_name="photo_library", tooltip="Open Matplotlib Dialog", parent=self.target_widget
)
switch_export_action = SwitchableToolBarAction(
actions={"save": save, "matplotlib": matplotlib},
initial_action="save",
tooltip="Switchable Action",
checkable=False,
parent=self,
)
# Add them to the bundle
self.add_action("export_switch", switch_export_action)
# Immediately connect signals
save.action.triggered.connect(self.export_dialog)
matplotlib.action.triggered.connect(self.matplotlib_dialog)
@SafeSlot()
def export_dialog(self):
"""
Open the export dialog for the plot widget.
"""
if self.target_widget:
scene = self.target_widget.plot_item.scene()
scene.contextMenuItem = self.target_widget.plot_item
scene.showExportDialog()
@SafeSlot()
def matplotlib_dialog(self):
"""
Export the plot widget to Matplotlib.
"""
if self.target_widget:
try:
import matplotlib as mpl
MatplotlibExporter(self.target_widget.plot_item).export()
except:
warning_util = WarningPopupUtility()
warning_util.show_warning(
title="Matplotlib not installed",
message="Matplotlib is required for this feature.",
detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.",
)
return

View File

@@ -0,0 +1,31 @@
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle
class ROIBundle(ToolbarBundle):
"""
A bundle of actions that are hooked in this constructor itself,
so that you can immediately connect the signals and toggle states.
This bundle is for a toolbar that controls crosshair and ROI interaction.
"""
def __init__(self, bundle_id="roi", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Create each MaterialIconAction with a parent
# so the signals can fire even if the toolbar isn't added yet.
crosshair = MaterialIconAction(
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
)
reset_legend = MaterialIconAction(
icon_name="restart_alt", tooltip="Reset the position of legend.", checkable=False
)
# Add them to the bundle
self.add_action("crosshair", crosshair)
self.add_action("reset_legend", reset_legend)
# Immediately connect signals
crosshair.action.toggled.connect(self.target_widget.toggle_crosshair)
reset_legend.action.triggered.connect(self.target_widget.reset_legend)

View File

@@ -0,0 +1,48 @@
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle
class SaveStateBundle(ToolbarBundle):
"""
A bundle of actions that are hooked in this constructor itself,
so that you can immediately connect the signals and toggle states.
This bundle is for a toolbar that controls saving the state of the widget.
"""
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Create each MaterialIconAction with a parent
# so the signals can fire even if the toolbar isn't added yet.
save_state = MaterialIconAction(
icon_name="download", tooltip="Save Widget State", parent=self.target_widget
)
load_state = MaterialIconAction(
icon_name="upload", tooltip="Load Widget State", parent=self.target_widget
)
# Add them to the bundle
self.add_action("save", save_state)
self.add_action("matplotlib", load_state)
# Immediately connect signals
save_state.action.triggered.connect(self.save_state_dialog)
load_state.action.triggered.connect(self.load_state_dialog)
@SafeSlot()
def save_state_dialog(self):
"""
Open the export dialog to save a state of the widget.
"""
if self.target_widget:
self.target_widget.state_manager.save_state()
@SafeSlot()
def load_state_dialog(self):
"""
Load a saved state of the widget.
"""
if self.target_widget:
self.target_widget.state_manager.load_state()

View File

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

View File

@@ -6,11 +6,9 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.plots.waveform.bec_waveform_widget_plugin import (
BECWaveformWidgetPlugin,
)
from bec_widgets.widgets.plots_next_gen.waveform.waveform_plugin import WaveformPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECWaveformWidgetPlugin())
QPyDesignerCustomWidgetCollection.addCustomWidget(WaveformPlugin())
if __name__ == "__main__": # pragma: no cover

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from qtpy.QtWidgets import (
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.settings_dialog import SettingWidget
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.plots_next_gen.waveform.settings.curve_settings.curve_tree import CurveTree
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
class CurveSetting(SettingWidget):
def __init__(self, parent=None, target_widget: Waveform = None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
self.setProperty("skip_settings", True)
self.setObjectName("CurveSetting")
self.target_widget = target_widget
self.layout = QVBoxLayout(self)
self._init_x_box()
self._init_y_box()
self.setFixedWidth(580) # TODO height is still debate
def _init_x_box(self):
self.x_axis_box = QGroupBox("X Axis")
self.x_axis_box.layout = QHBoxLayout(self.x_axis_box)
self.x_axis_box.layout.setContentsMargins(10, 10, 10, 10)
self.x_axis_box.layout.setSpacing(10)
self.mode_combo_label = QLabel("Mode")
self.mode_combo = QComboBox()
self.mode_combo.addItems(["auto", "index", "timestamp", "device"])
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.device_x_label = QLabel("Device")
self.device_x = DeviceLineEdit()
self._get_x_mode_from_waveform()
self.switch_x_device_selection()
self.mode_combo.currentTextChanged.connect(self.switch_x_device_selection)
self.x_axis_box.layout.addWidget(self.mode_combo_label)
self.x_axis_box.layout.addWidget(self.mode_combo)
self.x_axis_box.layout.addWidget(self.spacer)
self.x_axis_box.layout.addWidget(self.device_x_label)
self.x_axis_box.layout.addWidget(self.device_x)
self.x_axis_box.setFixedHeight(80)
self.layout.addWidget(self.x_axis_box)
def _get_x_mode_from_waveform(self):
if self.target_widget.x_mode in ["auto", "index", "timestamp"]:
self.mode_combo.setCurrentText(self.target_widget.x_mode)
else:
self.mode_combo.setCurrentText("device")
def switch_x_device_selection(self):
if self.mode_combo.currentText() == "device":
self.device_x.setEnabled(True)
self.device_x.setText(self.target_widget.x_axis_mode["name"])
else:
self.device_x.setEnabled(False)
def _init_y_box(self):
self.y_axis_box = QGroupBox("Y Axis")
self.y_axis_box.layout = QVBoxLayout(self.y_axis_box)
self.y_axis_box.layout.setContentsMargins(0, 0, 0, 0)
self.y_axis_box.layout.setSpacing(0)
self.curve_manager = CurveTree(self, waveform=self.target_widget)
self.y_axis_box.layout.addWidget(self.curve_manager)
self.layout.addWidget(self.y_axis_box)
@SafeSlot()
def accept_changes(self):
"""
Accepts the changes made in the settings widget and applies them to the target widget.
"""
if self.mode_combo.currentText() == "device":
self.target_widget.x_mode = self.device_x.text()
else:
self.target_widget.x_mode = self.mode_combo.currentText()
self.curve_manager.send_curve_json()
@SafeSlot()
def refresh(self):
"""Refresh the curve tree and the x axis combo box in the case Waveform is modified from rpc."""
self.curve_manager.refresh_from_waveform()
self._get_x_mode_from_waveform()

View File

@@ -0,0 +1,538 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QColorDialog,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QSpinBox,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.utils import ConnectionConfig, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from bec_widgets.widgets.plots_next_gen.waveform.curve import CurveConfig, DeviceSignal
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
class ColorButton(QPushButton):
"""A QPushButton subclass that displays a color.
The background is set to the given color and the button text is the hex code.
The text color is chosen automatically (black if the background is light, white if dark)
to guarantee good readability.
"""
def __init__(self, color="#000000", parent=None):
"""Initialize the color button.
Args:
color (str): The initial color in hex format (e.g., '#000000').
parent: Optional QWidget parent.
"""
super().__init__(parent)
self.set_color(color)
def set_color(self, color):
"""Set the button's color and update its appearance.
Args:
color (str or QColor): The new color to assign.
"""
if isinstance(color, QColor):
self._color = color.name()
else:
self._color = color
self._update_appearance()
def color(self):
"""Return the current color in hex."""
return self._color
def _update_appearance(self):
"""Update the button style based on the background color's brightness."""
c = QColor(self._color)
brightness = c.lightnessF()
text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
self.setStyleSheet(f"background-color: {self._color}; color: {text_color};")
self.setText(self._color)
class CurveRow(QTreeWidgetItem):
DELETE_BUTTON_COLOR = "#CC181E"
"""A unified row that can represent either a device or a DAP curve.
Columns:
0: Actions (delete or "Add DAP" if source=device)
1..2: DeviceLineEdit and QLineEdit if source=device, or "Model" label and DapComboBox if source=dap
3: ColorButton
4: Style QComboBox
5: Pen width QSpinBox
6: Symbol size QSpinBox
"""
def __init__(
self,
tree: QTreeWidget,
parent_item: QTreeWidgetItem | None = None,
config: CurveConfig | None = None,
device_manager=None,
):
if parent_item:
super().__init__(parent_item)
else:
# A top-level device row.
super().__init__(tree)
self.tree = tree
self.parent_item = parent_item
self.curve_tree = tree.parent() # The CurveTree widget
self.curve_tree.all_items.append(self) # Track stable ordering
self.dev = device_manager
self.entry_validator = EntryValidator(self.dev)
self.config = config or CurveConfig()
self.source = self.config.source
# Create column 0 (Actions)
self._init_actions()
# Create columns 1..2, depending on source
self._init_source_ui()
# Create columns 3..6 (color, style, width, symbol)
self._init_style_controls()
def _init_actions(self):
"""Create the actions widget in column 0, including a delete button and maybe 'Add DAP'."""
self.actions_widget = QWidget()
actions_layout = QHBoxLayout(self.actions_widget)
actions_layout.setContentsMargins(0, 0, 0, 0)
actions_layout.setSpacing(0)
# Delete button
self.delete_button = QToolButton()
delete_icon = material_icon(
"delete",
size=(20, 20),
convert_to_pixmap=False,
filled=False,
color=self.DELETE_BUTTON_COLOR,
)
self.delete_button.setIcon(delete_icon)
self.delete_button.clicked.connect(lambda: self.remove_self())
actions_layout.addWidget(self.delete_button)
# If device row, add "Add DAP" button
if self.source == "device":
self.add_dap_button = QPushButton("DAP")
self.add_dap_button.clicked.connect(lambda: self.add_dap_row())
actions_layout.addWidget(self.add_dap_button)
self.tree.setItemWidget(self, 0, self.actions_widget)
def _init_source_ui(self):
"""Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo."""
if self.source == "device":
# Device row: columns 1..2 are device line edits
self.device_edit = DeviceLineEdit()
self.entry_edit = QLineEdit() # TODO in future will be signal line edit
if self.config.signal:
self.device_edit.setText(self.config.signal.name or "")
self.entry_edit.setText(self.config.signal.entry or "")
self.tree.setItemWidget(self, 1, self.device_edit)
self.tree.setItemWidget(self, 2, self.entry_edit)
else:
# DAP row: column1= "Model" label, column2= DapComboBox
self.label_widget = QLabel("Model")
self.tree.setItemWidget(self, 1, self.label_widget)
self.dap_combo = DapComboBox()
self.dap_combo.populate_fit_model_combobox()
# If config.signal has a dap
if self.config.signal and self.config.signal.dap:
dap_value = self.config.signal.dap
idx = self.dap_combo.fit_model_combobox.findText(dap_value)
if idx >= 0:
self.dap_combo.fit_model_combobox.setCurrentIndex(idx)
else:
self.dap_combo.select_fit_model("GaussianModel") # default
self.tree.setItemWidget(self, 2, self.dap_combo)
def _init_style_controls(self):
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
# Color in col 3
self.color_button = ColorButton(self.config.color)
self.color_button.clicked.connect(lambda: self._select_color(self.color_button))
self.tree.setItemWidget(self, 3, self.color_button)
# Style in col 4
self.style_combo = QComboBox()
self.style_combo.addItems(["solid", "dash", "dot", "dashdot"])
idx = self.style_combo.findText(self.config.pen_style)
if idx >= 0:
self.style_combo.setCurrentIndex(idx)
self.tree.setItemWidget(self, 4, self.style_combo)
# Pen width in col 5
self.width_spin = QSpinBox()
self.width_spin.setRange(1, 20)
self.width_spin.setValue(self.config.pen_width)
self.tree.setItemWidget(self, 5, self.width_spin)
# Symbol size in col 6
self.symbol_spin = QSpinBox()
self.symbol_spin.setRange(1, 20)
self.symbol_spin.setValue(self.config.symbol_size)
self.tree.setItemWidget(self, 6, self.symbol_spin)
def _select_color(self, button):
"""
Selects a new color using a color dialog and applies it to the specified button. Updates
related configuration properties based on the chosen color.
Args:
button: The button widget whose color is being modified.
"""
current_color = QColor(button.color())
chosen_color = QColorDialog.getColor(current_color, self.tree, "Select Curve Color")
if chosen_color.isValid():
button.set_color(chosen_color)
self.config.color = chosen_color.name()
self.config.symbol_color = chosen_color.name()
def add_dap_row(self):
"""Create a new DAP row as a child. Only valid if source='device'."""
if self.source != "device":
return
curve_tree = self.tree.parent()
parent_label = self.config.label
# Inherit device name/entry
dev_name = ""
dev_entry = ""
if self.config.signal:
dev_name = self.config.signal.name
dev_entry = self.config.signal.entry
# Create a new config for the DAP row
dap_cfg = CurveConfig(
widget_class="Curve",
source="dap",
parent_label=parent_label,
signal=DeviceSignal(name=dev_name, entry=dev_entry),
)
new_dap = CurveRow(self.tree, parent_item=self, config=dap_cfg, device_manager=self.dev)
# Expand device row to show new child
self.tree.expandItem(self)
# Give the new row a color from the buffer:
curve_tree._ensure_color_buffer_size()
idx = len(curve_tree.all_items) - 1
new_col = curve_tree.color_buffer[idx]
new_dap.color_button.set_color(new_col)
new_dap.config.color = new_col
new_dap.config.symbol_color = new_col
def remove_self(self):
"""Remove this row from the tree and from the parent's item list."""
# If top-level:
index = self.tree.indexOfTopLevelItem(self)
if index != -1:
self.tree.takeTopLevelItem(index)
else:
# If child item
if self.parent_item:
self.parent_item.removeChild(self)
# Also remove from all_items
curve_tree = self.tree.parent()
if self in curve_tree.all_items:
curve_tree.all_items.remove(self)
def export_data(self) -> dict:
"""Collect data from the GUI widgets, update config, and return as a dict.
Returns:
dict: The serialized config based on the GUI state.
"""
if self.source == "device":
# Gather device name/entry
device_name = ""
device_entry = ""
if hasattr(self, "device_edit"):
device_name = self.device_edit.text()
if hasattr(self, "entry_edit"):
device_entry = self.entry_validator.validate_signal(
name=device_name, entry=self.entry_edit.text()
)
self.entry_edit.setText(device_entry)
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
self.config.source = "device"
if not self.config.label:
self.config.label = f"{device_name}-{device_entry}".strip("-")
else:
# DAP logic
parent_conf_dict = {}
if self.parent_item:
parent_conf_dict = self.parent_item.export_data()
parent_conf = CurveConfig(**parent_conf_dict)
dev_name = ""
dev_entry = ""
if parent_conf.signal:
dev_name = parent_conf.signal.name
dev_entry = parent_conf.signal.entry
# Dap from the DapComboBox
new_dap = "GaussianModel"
if hasattr(self, "dap_combo"):
new_dap = self.dap_combo.fit_model_combobox.currentText()
self.config.signal = DeviceSignal(name=dev_name, entry=dev_entry, dap=new_dap)
self.config.source = "dap"
self.config.parent_label = parent_conf.label
self.config.label = f"{parent_conf.label}-{new_dap}".strip("-")
# Common style fields
self.config.color = self.color_button.color()
self.config.symbol_color = self.color_button.color()
self.config.pen_style = self.style_combo.currentText()
self.config.pen_width = self.width_spin.value()
self.config.symbol_size = self.symbol_spin.value()
return self.config.model_dump()
class CurveTree(BECWidget, QWidget):
"""A tree widget that manages device and DAP curves."""
PLUGIN = False
RPC = False
def __init__(
self,
parent: QWidget | None = None,
config: ConnectionConfig | None = None,
client=None,
gui_id: str | None = None,
waveform: Waveform | None = None,
) -> None:
if config is None:
config = ConnectionConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, gui_id=gui_id, config=config)
QWidget.__init__(self, parent=parent)
self.waveform = waveform
if self.waveform and hasattr(self.waveform, "color_palette"):
self.color_palette = self.waveform.color_palette
else:
self.color_palette = "magma"
self.get_bec_shortcuts()
self.color_buffer = []
self.all_items = []
self.layout = QVBoxLayout(self)
self._init_toolbar()
self._init_tree()
self.refresh_from_waveform()
def _init_toolbar(self):
"""Initialize the toolbar with actions: add, send, refresh, expand, collapse, renormalize."""
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
add = MaterialIconAction(
icon_name="add", tooltip="Add new curve", checkable=False, parent=self
)
expand = MaterialIconAction(
icon_name="unfold_more", tooltip="Expand All DAP", checkable=False, parent=self
)
collapse = MaterialIconAction(
icon_name="unfold_less", tooltip="Collapse All DAP", checkable=False, parent=self
)
self.toolbar.add_action("add", add, self)
self.toolbar.add_action("expand_all", expand, self)
self.toolbar.add_action("collapse_all", collapse, self)
# Add colormap widget (not updating waveform's color_palette until Send is pressed)
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
# Renormalize colors button
renorm_action = MaterialIconAction(
icon_name="palette", tooltip="Normalize All Colors", checkable=False, parent=self
)
self.toolbar.add_action("renormalize_colors", renorm_action, self)
renorm_action.action.triggered.connect(lambda checked: self.renormalize_colors())
self.colormap_widget = BECColorMapWidget(cmap=self.color_palette or "magma")
self.toolbar.addWidget(self.colormap_widget)
self.colormap_widget.colormap_changed_signal.connect(self.handle_colormap_changed)
add.action.triggered.connect(lambda checked: self.add_new_curve())
expand.action.triggered.connect(lambda checked: self.expand_all_daps())
collapse.action.triggered.connect(lambda checked: self.collapse_all_daps())
self.layout.addWidget(self.toolbar)
def _init_tree(self):
"""Initialize the QTreeWidget with 7 columns and compact widths."""
self.tree = QTreeWidget()
self.tree.setColumnCount(7)
self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"])
self.tree.setColumnWidth(0, 90)
self.tree.setColumnWidth(1, 100)
self.tree.setColumnWidth(2, 100)
self.tree.setColumnWidth(3, 70)
self.tree.setColumnWidth(4, 80)
self.tree.setColumnWidth(5, 40)
self.tree.setColumnWidth(6, 40)
self.layout.addWidget(self.tree)
def _init_color_buffer(self, size: int):
"""
Initializes the color buffer with a calculated set of colors based on the golden
angle sequence.
Args:
size (int): The number of colors to be generated for the color buffer.
"""
self.color_buffer = Colors.golden_angle_color(
colormap=self.colormap_widget.colormap, num=size, format="HEX"
)
def _ensure_color_buffer_size(self):
"""
Ensures that the color buffer size meets the required number of items.
"""
current_count = len(self.color_buffer)
color_list = Colors.golden_angle_color(
colormap=self.color_palette, num=max(10, current_count + 1), format="HEX"
)
self.color_buffer = color_list
def handle_colormap_changed(self, new_cmap: str):
"""
Handles the updating of the color palette when the colormap is changed.
Args:
new_cmap: The new colormap to be set as the color palette.
"""
self.color_palette = new_cmap
def renormalize_colors(self):
"""Overwrite all existing rows with new colors from the buffer in their creation order."""
total = len(self.all_items)
self._ensure_color_buffer_size()
for idx, item in enumerate(self.all_items):
if hasattr(item, "color_button"):
new_col = self.color_buffer[idx]
item.color_button.set_color(new_col)
if hasattr(item, "config"):
item.config.color = new_col
item.config.symbol_color = new_col
def add_new_curve(self, name: str = None, entry: str = None):
"""Add a new device-type CurveRow with an assigned colormap color.
Args:
name (str, optional): Device name.
entry (str, optional): Device entry.
style (str, optional): Pen style. Defaults to "solid".
width (int, optional): Pen width. Defaults to 4.
symbol_size (int, optional): Symbol size. Defaults to 7.
Returns:
CurveRow: The newly created top-level row.
"""
cfg = CurveConfig(
widget_class="Curve",
parent_id=self.waveform.gui_id,
source="device",
signal=DeviceSignal(name=name or "", entry=entry or ""),
)
new_row = CurveRow(self.tree, parent_item=None, config=cfg, device_manager=self.dev)
# Assign color from the buffer ONLY to this new curve.
total_items = len(self.all_items)
self._ensure_color_buffer_size()
color_idx = total_items - 1 # new row is last
new_col = self.color_buffer[color_idx]
new_row.color_button.set_color(new_col)
new_row.config.color = new_col
new_row.config.symbol_color = new_col
return new_row
def send_curve_json(self):
"""Send the current tree's config as JSON to the waveform, updating wavefrom.color_palette as well."""
if self.waveform is not None:
self.waveform.color_palette = self.color_palette
data = self.export_all_curves()
json_data = json.dumps(data, indent=2)
if self.waveform is not None:
self.waveform.curve_json = json_data
def export_all_curves(self) -> list:
"""Recursively export data from each row.
Returns:
list: A list of exported config dicts for every row (device and DAP).
"""
curves = []
for i in range(self.tree.topLevelItemCount()):
item = self.tree.topLevelItem(i)
if isinstance(item, CurveRow):
curves.append(item.export_data())
for j in range(item.childCount()):
child = item.child(j)
if isinstance(child, CurveRow):
curves.append(child.export_data())
return curves
def expand_all_daps(self):
"""Expand all top-level rows to reveal child DAP rows."""
for i in range(self.tree.topLevelItemCount()):
item = self.tree.topLevelItem(i)
self.tree.expandItem(item)
def collapse_all_daps(self):
"""Collapse all top-level rows, hiding child DAP rows."""
for i in range(self.tree.topLevelItemCount()):
item = self.tree.topLevelItem(i)
self.tree.collapseItem(item)
def refresh_from_waveform(self):
"""Clear the tree and rebuild from the waveform's existing curves if any, else add sample rows."""
if self.waveform is None:
return
self.tree.clear()
self.all_items = []
device_curves = [c for c in self.waveform.curves if c.config.source == "device"]
dap_curves = [c for c in self.waveform.curves if c.config.source == "dap"]
for dev in device_curves:
dr = CurveRow(self.tree, parent_item=None, config=dev.config, device_manager=self.dev)
for dap in dap_curves:
if dap.config.parent_label == dev.config.label:
CurveRow(self.tree, parent_item=dr, config=dap.config, device_manager=self.dev)

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,54 @@
# 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.plots_next_gen.waveform.waveform import Waveform
DOM_XML = """
<ui language='c++'>
<widget class='Waveform' name='waveform'>
</widget>
</ui>
"""
class WaveformPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = Waveform(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "Plot Widgets Next Gen"
def icon(self):
return designer_material_icon(Waveform.ICON_NAME)
def includeFile(self):
return "waveform"
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 "Waveform"
def toolTip(self):
return "Waveform"
def whatsThis(self):
return self.toolTip()

View File

@@ -24,8 +24,8 @@ class BECProgressBar(BECWidget, QWidget):
]
ICON_NAME = "page_control"
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
accent_colors = get_accent_colors()

View File

@@ -99,6 +99,7 @@ class Ring(BECConnector):
config: RingConfig | dict | None = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
):
if config is None:
config = RingConfig(widget_class=self.__class__.__name__)
@@ -107,7 +108,7 @@ class Ring(BECConnector):
if isinstance(config, dict):
config = RingConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
self.parent_progress_widget = parent_progress_widget
self.color = None

View File

@@ -101,6 +101,7 @@ class RingProgressBar(BECWidget, QWidget):
client=None,
gui_id: str | None = None,
num_bars: int | None = None,
**kwargs,
):
if config is None:
config = RingProgressBarConfig(widget_class=self.__class__.__name__)
@@ -109,7 +110,7 @@ class RingProgressBar(BECWidget, QWidget):
if isinstance(config, dict):
config = RingProgressBarConfig(**config, widget_class=self.__class__.__name__)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()

View File

@@ -42,8 +42,9 @@ class BECQueue(BECWidget, CompactPopupWidget):
config: ConnectionConfig = None,
gui_id: str = None,
refresh_upon_start: bool = True,
**kwargs,
):
super().__init__(client, config, gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)

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