mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-14 12:40:54 +02:00
Compare commits
91 Commits
v1.17.0
...
refactor/n
| Author | SHA1 | Date | |
|---|---|---|---|
| 3653fe9799 | |||
| 750350dc52 | |||
| 9eb1608b01 | |||
| 0433b40054 | |||
| d1a41752c4 | |||
| bf060b3aba | |||
| 5f0dd62f25 | |||
| 21b1f0b2de | |||
| 17f6dbb0d4 | |||
| ba347e026a | |||
| 705f157c04 | |||
| 4736c2fad1 | |||
| 31b40aeede | |||
| 77e8a5c884 | |||
| 0f4365bbb0 | |||
| 906ca03929 | |||
| 1206069a8f | |||
| 86487a5f4d | |||
| 4bdcae7028 | |||
| 81f61f3c3b | |||
| 89e8ebf1b6 | |||
|
|
66f4f9bfa8 | ||
| 66c6c7fa50 | |||
|
|
31c3337300 | ||
| 2c506ee3c8 | |||
|
|
25423f4a3a | ||
| fa91366dcb | |||
|
|
4db0f9f10c | ||
| 46b1a228be | |||
|
|
531018b0ac | ||
| 8679b5f08b | |||
| 6f2c2401ac | |||
| 6d1106e33e | |||
| 90a184643a | |||
| 3aa2f2225f | |||
|
|
f54e69f1cf | ||
| 7309c1dede | |||
| 1c0021f98b | |||
| d32952a0d5 | |||
| 5206528fec | |||
| 42665b69c5 | |||
|
|
209c898e3d | ||
| 6a43554f3b | |||
|
|
95c931af0b | ||
| f19d9485df | |||
|
|
575c988c4f | ||
| 6b08f7cfb2 | |||
| 6ae33a23a6 | |||
| facb8c30ff | |||
| 333570ba2f | |||
| ef36a7124d | |||
| c2c022154b | |||
| 4c4f1592c2 | |||
|
|
d7fb291877 | ||
| ae18279685 | |||
| 97c0ed53df | |||
| ff8e282034 | |||
|
|
440f36f289 | ||
| 0addef5f17 | |||
|
|
8c2a5e61fc | ||
| 056731c9ad | |||
|
|
911c81a167 | ||
| 8651314d93 | |||
| 383936ffc2 | |||
|
|
4378d33880 | ||
| 1708bd405f | |||
| 12811eccdb | |||
|
|
5959fa87de | ||
| b3217b7ca5 | |||
|
|
35b941d054 | ||
| fc6d7c0824 | |||
| fb051865d5 | |||
|
|
8aba3d975f | ||
| 5e3289f5bd | |||
| d07744397e | |||
|
|
dc7bf6b3c4 | ||
| f219c6fb57 | |||
| f048880277 | |||
| 50a572dacd | |||
|
|
b87549ba99 | ||
| f0c4efefa0 | |||
| db70442cc2 | |||
|
|
07b8910686 | ||
| e7c97290cd | |||
| 48fc63d83e | |||
| a20935e862 | |||
| 4f8e6835fe | |||
|
|
042adfa51e | ||
| b2b0450bcb | |||
|
|
12e06fa971 | ||
| 6f2f2aa06a |
@@ -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
|
||||
|
||||
|
||||
282
CHANGELOG.md
282
CHANGELOG.md
@@ -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
|
||||
|
||||
34
README.md
34
README.md
@@ -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/)
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
72
bec_widgets/qt_utils/expandable_frame.py
Normal file
72
bec_widgets/qt_utils/expandable_frame.py
Normal 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
|
||||
)
|
||||
)
|
||||
@@ -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():
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['signal_combobox.py']}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
7
bec_widgets/widgets/editors/scan_metadata/__init__.py
Normal file
7
bec_widgets/widgets/editors/scan_metadata/__init__.py
Normal 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"]
|
||||
275
bec_widgets/widgets/editors/scan_metadata/_metadata_widgets.py
Normal file
275
bec_widgets/widgets/editors/scan_metadata/_metadata_widgets.py
Normal 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()
|
||||
67
bec_widgets/widgets/editors/scan_metadata/_util.py
Normal file
67
bec_widgets/widgets/editors/scan_metadata/_util.py
Normal 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()
|
||||
@@ -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()
|
||||
234
bec_widgets/widgets/editors/scan_metadata/scan_metadata.py
Normal file
234
bec_widgets/widgets/editors/scan_metadata/scan_metadata.py
Normal 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()
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['waveform_widget.py']}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
986
bec_widgets/widgets/plots_next_gen/plot_base.py
Normal file
986
bec_widgets/widgets/plots_next_gen/plot_base.py
Normal 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_())
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
328
bec_widgets/widgets/plots_next_gen/waveform/curve.py
Normal file
328
bec_widgets/widgets/plots_next_gen/waveform/curve.py
Normal 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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
1614
bec_widgets/widgets/plots_next_gen/waveform/waveform.py
Normal file
1614
bec_widgets/widgets/plots_next_gen/waveform/waveform.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{'files': ['waveform.py']}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user