mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 01:37:53 +02:00
Compare commits
86 Commits
v2.15.0
...
fix/design
| Author | SHA1 | Date | |
|---|---|---|---|
| b9eff4eecf | |||
|
|
72b5c46912 | ||
| 244bca4e1e | |||
|
|
c50ace5818 | ||
| 25f28c47e3 | |||
| db720e8fa4 | |||
|
|
f10140e0f3 | ||
| 09c5a443aa | |||
| 3f5ab142a3 | |||
|
|
422d06d141 | ||
| 371bc485d0 | |||
|
|
70970ecf00 | ||
| 3d59c25aa9 | |||
|
|
70a06c5fd1 | ||
| 7ba8863d6a | |||
|
|
00ea8bb6c6 | ||
| e841468892 | |||
| 48a0e5831f | |||
| 1e9dd4cd25 | |||
| d10328cb5c | |||
|
|
6b248e93f5 | ||
| bc3085ab8c | |||
| 9cba696afd | |||
|
|
881b7a7e9d | ||
| 29a26b19f9 | |||
|
|
cba4d47f76 | ||
| 9f3dcc3ab3 | |||
| 57f75bd4d5 | |||
| 4456297beb | |||
|
|
ae26b43fb1 | ||
| 7484f5160c | |||
| 6421050116 | |||
|
|
5a137d1219 | ||
| d5a40dabc7 | |||
| f3da6e959e | |||
| 3a103410e7 | |||
| 3378051250 | |||
|
|
77db658f3d | ||
| 6e2f2cea91 | |||
| eea5f7ebbd | |||
| a9708f6d8f | |||
| b51de1a00e | |||
| 8e8acd672c | |||
| 4c2c0c5525 | |||
| 5a564a5f3f | |||
|
|
43ad207aa8 | ||
| a4274ff8cd | |||
| b2a46e284d | |||
| 9ff170660e | |||
| 6c04eac18c | |||
| aca6efb567 | |||
| 88b42e49e3 | |||
| d3a9e0903a | |||
| 3bbb8daa24 | |||
| e8ae9725fa | |||
| 497e394deb | |||
| d5ca7b8433 | |||
| b02c870dbf | |||
| 92d0ffee65 | |||
| c4b85381a4 | |||
| a451625a5a | |||
|
|
54dd0a9913 | ||
| 3146d98c57 | |||
| a3ffcefe80 | |||
|
|
1a7052073d | ||
| 235aabf307 | |||
|
|
c1cb69b0e8 | ||
| 11131ef14c | |||
| 5e4c129af6 | |||
| 4d8c07cdd1 | |||
| 8f4c8e45b3 | |||
| 5623547e92 | |||
| be73349c70 | |||
| 1a350c3b16 | |||
| 138d4cabbd | |||
| b0d03c0648 | |||
| a9613a07b0 | |||
| 886964bb54 | |||
| 7fc85bac7f | |||
| d626caae3d | |||
| dea2568de3 | |||
| a55f561971 | |||
| 9ce31c9833 | |||
|
|
95ce98c622 | ||
| 187bf493a5 | |||
| 1612933dd9 |
2
.github/workflows/formatter.yml
vendored
2
.github/workflows/formatter.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
isort --check --diff ./
|
||||
|
||||
- name: Check for disallowed imports from PySide
|
||||
run: '! grep -re "from PySide6\." bec_widgets/ | grep -v -e "PySide6.QtDesigner" -e "PySide6.scripts"'
|
||||
run: '! grep -re "from PySide6\." bec_widgets/ tests/ | grep -v -e "PySide6.QtDesigner" -e "PySide6.scripts"'
|
||||
|
||||
Pylint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
.github/workflows/pytest-matrix.yml
vendored
2
.github/workflows/pytest-matrix.yml
vendored
@@ -56,4 +56,4 @@ jobs:
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
pip install pytest pytest-random-order
|
||||
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
|
||||
326
CHANGELOG.md
326
CHANGELOG.md
@@ -1,6 +1,332 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.21.4 (2025-07-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image_roi_tree**: Changing color dialog from ColorButtonNative is open once
|
||||
([`244bca4`](https://github.com/bec-project/bec_widgets/commit/244bca4e1ec7c00109534b9f503ff2eb125c1ffe))
|
||||
|
||||
|
||||
## v2.21.3 (2025-07-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **connector**: Remove safeslot for now
|
||||
([`25f28c4`](https://github.com/bec-project/bec_widgets/commit/25f28c47e32af1be7778803dc27d8c2a367172ed))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **toolbar**: Split toolbar into components, bundles and connections
|
||||
([`db720e8`](https://github.com/bec-project/bec_widgets/commit/db720e8fa46bb2fb10c73afa1b4f039cd256d68b))
|
||||
|
||||
|
||||
## v2.21.2 (2025-06-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **waveform**: Fix waveform categorisation for aborted scans
|
||||
([`09c5a44`](https://github.com/bec-project/bec_widgets/commit/09c5a443aac675f02fa1e38179deb9863af152e2))
|
||||
|
||||
### Testing
|
||||
|
||||
- Assert config for equality, not identity
|
||||
([`3f5ab14`](https://github.com/bec-project/bec_widgets/commit/3f5ab142a3cb5446261c4faebdc7b13f10ef4a80))
|
||||
|
||||
|
||||
## v2.21.1 (2025-06-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **sbb monitor**: Add missing pyproject file
|
||||
([`371bc48`](https://github.com/bec-project/bec_widgets/commit/371bc485d060404433082c9e3e00780961ce6ae3))
|
||||
|
||||
|
||||
## v2.21.0 (2025-06-28)
|
||||
|
||||
### Features
|
||||
|
||||
- **sbb monitor**: Add sbb monitor widget
|
||||
([`3d59c25`](https://github.com/bec-project/bec_widgets/commit/3d59c25aa93590a62ab4d31a4ab08589402bf407))
|
||||
|
||||
|
||||
## v2.20.1 (2025-06-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **signal input base**: Unregister callback to avoid accessing deleted qt objects
|
||||
([`7ba8863`](https://github.com/bec-project/bec_widgets/commit/7ba8863d6a0c21f772e4ef8a5d4180c2a7ab49cb))
|
||||
|
||||
|
||||
## v2.20.0 (2025-06-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **curve_settings**: Larger minimalWidth for the x device combobox selection
|
||||
([`48a0e58`](https://github.com/bec-project/bec_widgets/commit/48a0e5831feccd30f24218821bbc9d73f8c47933))
|
||||
|
||||
### Features
|
||||
|
||||
- **waveform**: Move x axis selection to a combobox
|
||||
([`d10328c`](https://github.com/bec-project/bec_widgets/commit/d10328cb5c775a9b7b40ed4e9f2889e63eb039ff))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **curve settings**: Move signal logic to SignalCombobox
|
||||
([`e841468`](https://github.com/bec-project/bec_widgets/commit/e84146889210165de1c4e63eb20b39f30cc5c623))
|
||||
|
||||
### Testing
|
||||
|
||||
- **curve settings**: Add curve tree elements to the dialog test
|
||||
([`1e9dd4c`](https://github.com/bec-project/bec_widgets/commit/1e9dd4cd2561d37bdda1cd86b511295c259b2831))
|
||||
|
||||
|
||||
## v2.19.4 (2025-06-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **curve tree**: Remove manual interception of the close event; call parent cleanup
|
||||
([`bc3085a`](https://github.com/bec-project/bec_widgets/commit/bc3085ab8cb6688da358df4a7c07fc213a99f2df))
|
||||
|
||||
- **waveform**: Curve tree elements must clean up signal combobox
|
||||
([`9cba696`](https://github.com/bec-project/bec_widgets/commit/9cba696afd3300a76678dfdc4226604696cc3696))
|
||||
|
||||
|
||||
## v2.19.3 (2025-06-25)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scan_control**: Safeguard against empty history; reversed history to fetch the newest scan
|
||||
([`29a26b1`](https://github.com/bec-project/bec_widgets/commit/29a26b19f9ab829b0d877c3233613a0936db0a12))
|
||||
|
||||
|
||||
## v2.19.2 (2025-06-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scan_control**: Scan parameters fetched from the scan_history, fix #707
|
||||
([`4456297`](https://github.com/bec-project/bec_widgets/commit/4456297beb940b147882f96caee6fb19aaf93c73))
|
||||
|
||||
### Build System
|
||||
|
||||
- Bec_lib 3.44 required
|
||||
([`9f3dcc3`](https://github.com/bec-project/bec_widgets/commit/9f3dcc3ab30a2c238ffffa8d594735ccaf6f1ca4))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **scan_control**: Request_last_executed_scan_parameters logic adjusted
|
||||
([`57f75bd`](https://github.com/bec-project/bec_widgets/commit/57f75bd4d506ca4d8dc982f3051d0d4c29b0d41c))
|
||||
|
||||
|
||||
## v2.19.1 (2025-06-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **launch_window**: Number of remaining connections extended to 4
|
||||
([`7484f51`](https://github.com/bec-project/bec_widgets/commit/7484f5160c8c6d632fd27996035ff6c0dda2e657))
|
||||
|
||||
|
||||
## v2.19.0 (2025-06-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ci**: Extend check for pyside import to tests
|
||||
([`d5a40da`](https://github.com/bec-project/bec_widgets/commit/d5a40dabc74753acad05e3eb6b121499fc1e03d7))
|
||||
|
||||
### Features
|
||||
|
||||
- (#494) add signal display to device browser
|
||||
([`f3da6e9`](https://github.com/bec-project/bec_widgets/commit/f3da6e959e0416827ee5d02e34e6ad0ecfc8e5e7))
|
||||
|
||||
- (#494) add tabbed layout for device item
|
||||
([`3378051`](https://github.com/bec-project/bec_widgets/commit/337805125098c3e028a17b74ef6d9ae4b9ba3d6d))
|
||||
|
||||
- (#494) display device signals
|
||||
([`3a10341`](https://github.com/bec-project/bec_widgets/commit/3a103410e7448256a56b59bb3276fee056ec42a0))
|
||||
|
||||
|
||||
## v2.18.0 (2025-06-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Make settings dialog resizable
|
||||
([`5a564a5`](https://github.com/bec-project/bec_widgets/commit/5a564a5f3f3229e6407ea52a59d3e63319dc214a))
|
||||
|
||||
- **curve settings**: Add initial size hint
|
||||
([`a9708f6`](https://github.com/bec-project/bec_widgets/commit/a9708f6d8f15c42b142488da1e392a8f3179932a))
|
||||
|
||||
### Features
|
||||
|
||||
- **curve settings**: Add combobox selection for device and signal
|
||||
([`eea5f7e`](https://github.com/bec-project/bec_widgets/commit/eea5f7ebbd2b477b3ed19c7efcc76390dd391f26))
|
||||
|
||||
- **device combobox**: Emit reset event if validation fails
|
||||
([`4c2c0c5`](https://github.com/bec-project/bec_widgets/commit/4c2c0c5525d593d8ec7fd554336cb11adbe32de2))
|
||||
|
||||
- **FilterIO**: Add support for item data
|
||||
([`8e8acd6`](https://github.com/bec-project/bec_widgets/commit/8e8acd672c0deb8dcd928886fb574452ac956de7))
|
||||
|
||||
- **signal combobox**: Add reset_selection slot
|
||||
([`b51de1a`](https://github.com/bec-project/bec_widgets/commit/b51de1a00e4b17c44cab23e5097391c6fa8ea0e2))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **device input**: Refactor to SafeProperty and SafeSlot
|
||||
([`6e2f2ce`](https://github.com/bec-project/bec_widgets/commit/6e2f2cea91ba3af33e9891532506f4b0b65b90c8))
|
||||
|
||||
|
||||
## v2.17.0 (2025-06-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **bec_progressbar**: Layout and sizing adjustments
|
||||
([`b02c870`](https://github.com/bec-project/bec_widgets/commit/b02c870dbfecb4bc6921ec4c915dac0e67beb9b4))
|
||||
|
||||
- **launch_window**: Number of remaining connections increase to 2 to include the ScanProgressBar
|
||||
([`3bbb8da`](https://github.com/bec-project/bec_widgets/commit/3bbb8daa24348613f62bde667a446d37dcec8fb0))
|
||||
|
||||
- **main_window**: Labels and sizing of scan progress adopted
|
||||
([`aca6efb`](https://github.com/bec-project/bec_widgets/commit/aca6efb567528eb3c68521a59b4f9903a5616c6f))
|
||||
|
||||
- **scan_progressbar**: Cleanup adjusted
|
||||
([`e8ae972`](https://github.com/bec-project/bec_widgets/commit/e8ae9725fa86b7db52a147ca5a2acc62fa2ccf43))
|
||||
|
||||
- **scan_progressbar**: Mapping of bec progress states to the progressbar enums
|
||||
([`88b42e4`](https://github.com/bec-project/bec_widgets/commit/88b42e49e30a0aa0edc2de4d970408f4be5bde6b))
|
||||
|
||||
### Build System
|
||||
|
||||
- Update min dependency of bec to 3.42.4
|
||||
([`a4274ff`](https://github.com/bec-project/bec_widgets/commit/a4274ff8cd9f3e73a61b2eaf902c172c028d21b0))
|
||||
|
||||
### Features
|
||||
|
||||
- **main_window**: Added scan progress bar to BECMainWindow status bar
|
||||
([`497e394`](https://github.com/bec-project/bec_widgets/commit/497e394deb5cfe36c8fc4f769fef26f109fd1c1f))
|
||||
|
||||
- **main_window**: Timer to show hide scan progress when it is relevant only
|
||||
([`9ff1706`](https://github.com/bec-project/bec_widgets/commit/9ff170660edd9e03f99eccee60b5e20fc1cf5a8d))
|
||||
|
||||
- **progressbar**: Added padding as designer property
|
||||
([`a451625`](https://github.com/bec-project/bec_widgets/commit/a451625a5ab804ca8259f9c9f83c4f9ebbea4a5b))
|
||||
|
||||
- **progressbar**: State setting and dynamic corner radius
|
||||
([`d3a9e09`](https://github.com/bec-project/bec_widgets/commit/d3a9e0903a263d735ecab3a2ad9319c9d5e86092))
|
||||
|
||||
- **scan_progressbar**: Added oneline design for compact applications
|
||||
([`d5ca7b8`](https://github.com/bec-project/bec_widgets/commit/d5ca7b84337cf60aa66f961d357ae66994f53c7a))
|
||||
|
||||
- **scan_progressbar**: Added progressbar with hooks to scan progress and device progress
|
||||
([`c4b8538`](https://github.com/bec-project/bec_widgets/commit/c4b85381a41e4742567680864668ee83d498b1d1))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **progressbar**: Change slot / property to safeslot / safeproperty
|
||||
([`92d0ffe`](https://github.com/bec-project/bec_widgets/commit/92d0ffee65babc718fafd60131d0a4f291e5ca2b))
|
||||
|
||||
### Testing
|
||||
|
||||
- **scan progress**: Add test for queue update logic
|
||||
([`b2a46e2`](https://github.com/bec-project/bec_widgets/commit/b2a46e284d45e97dd9853d1a3c8e95de7e530267))
|
||||
|
||||
- **scan_progress**: Tests extended
|
||||
([`6c04eac`](https://github.com/bec-project/bec_widgets/commit/6c04eac18c887526b333f58fc1118c3b4029abd8))
|
||||
|
||||
|
||||
## v2.16.2 (2025-06-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **waveform**: Asyncsignal are handled with the same update mechanism as async readback
|
||||
([`a3ffcef`](https://github.com/bec-project/bec_widgets/commit/a3ffcefe8085fa1a88d679f8ef6adfdff786492e))
|
||||
|
||||
### Testing
|
||||
|
||||
- **utils**: Dmmock can fetch get_bec_signals method
|
||||
([`3146d98`](https://github.com/bec-project/bec_widgets/commit/3146d98c572ff2bb8ab77f71b75d9612e364ffe0))
|
||||
|
||||
|
||||
## v2.16.1 (2025-06-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scatter**: Fix tab order
|
||||
([`235aabf`](https://github.com/bec-project/bec_widgets/commit/235aabf307ef0c01a51a5cd8be4eb53915ed360c))
|
||||
|
||||
|
||||
## v2.16.0 (2025-06-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Adjust height of list widget
|
||||
([`11131ef`](https://github.com/bec-project/bec_widgets/commit/11131ef14c7e8714a4eaf70256da9e5835d60810))
|
||||
|
||||
- Make website test robust
|
||||
([`4d8c07c`](https://github.com/bec-project/bec_widgets/commit/4d8c07cdd142bab4c0d8224c43e66517a02da7c1))
|
||||
|
||||
- Parse config on submission and reload after
|
||||
([`5e4c129`](https://github.com/bec-project/bec_widgets/commit/5e4c129af6ae6644e4bb94f4129c6770fd26542d))
|
||||
|
||||
- Pass on kwargs from PydanticModelForm
|
||||
([`a55f561`](https://github.com/bec-project/bec_widgets/commit/a55f561971a9ce2295cd835cd5cb6ce436d6c693))
|
||||
|
||||
- Put waiting in thread
|
||||
([`1a350c3`](https://github.com/bec-project/bec_widgets/commit/1a350c3b16da0d990afd53d14934040e5e063177))
|
||||
|
||||
- Reset dict table properly
|
||||
([`5623547`](https://github.com/bec-project/bec_widgets/commit/5623547e926b86eeb5e2164fa6ec9e36b99b8f63))
|
||||
|
||||
- Scale dict widget height
|
||||
([`dea2568`](https://github.com/bec-project/bec_widgets/commit/dea2568de370450ca871fe7bf3573eec9acf8122))
|
||||
|
||||
- Tidy up form widget formatting
|
||||
([`8f4c8e4`](https://github.com/bec-project/bec_widgets/commit/8f4c8e45b3d4a15c67e36cd52d475c3117eca1d3))
|
||||
|
||||
### Features
|
||||
|
||||
- Add a widget to edit lists in forms
|
||||
([`7fc85ba`](https://github.com/bec-project/bec_widgets/commit/7fc85bac7fff8555b73d28eefe9a538540d574b9))
|
||||
|
||||
- Add set form item
|
||||
([`be73349`](https://github.com/bec-project/bec_widgets/commit/be73349c706582c144813f70dbc477372057de86))
|
||||
|
||||
- Allow editing device config from browser
|
||||
([`886964b`](https://github.com/bec-project/bec_widgets/commit/886964bb54d2f3923fb6baf198652bb05cf28eb2))
|
||||
|
||||
- Generate combobox for literal str
|
||||
([`138d4ca`](https://github.com/bec-project/bec_widgets/commit/138d4cabbd50e3c86ab18e9cdc25bbb5cdabc511))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
- Replace wait with waitUntil
|
||||
([`d626caa`](https://github.com/bec-project/bec_widgets/commit/d626caae3dc71683134cc47073bc131eba4820f5))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Move device config form to module
|
||||
([`9ce31c9`](https://github.com/bec-project/bec_widgets/commit/9ce31c9833ae38721b2246cdcac50f1154fba99d))
|
||||
|
||||
- Rename field widgets
|
||||
([`b0d03c0`](https://github.com/bec-project/bec_widgets/commit/b0d03c0648cd365143dfed27d4755d6f5b9c7a45))
|
||||
|
||||
### Testing
|
||||
|
||||
- Add tests for config dialog
|
||||
([`a9613a0`](https://github.com/bec-project/bec_widgets/commit/a9613a07b0cd9cd9455fd996d124c77218c9388f))
|
||||
|
||||
|
||||
## v2.15.1 (2025-06-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **main_window**: Added expiration timer for scroll label for ClientInfoMessage
|
||||
([`187bf49`](https://github.com/bec-project/bec_widgets/commit/187bf493a5b18299a10939901b9ed7e308435092))
|
||||
|
||||
- **scroll_label**: Updating label during scrolling is done imminently, regardless scrolling
|
||||
([`1612933`](https://github.com/bec-project/bec_widgets/commit/1612933dd9689f2bf480ad81811c051201a9ff70))
|
||||
|
||||
|
||||
## v2.15.0 (2025-06-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -27,7 +27,7 @@ from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.utils.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
@@ -542,7 +542,7 @@ class LaunchWindow(BECMainWindow):
|
||||
remaining_connections = [
|
||||
connection for connection in connections.values() if connection.parent_id != self.gui_id
|
||||
]
|
||||
return len(remaining_connections) <= 1
|
||||
return len(remaining_connections) <= 4
|
||||
|
||||
def _turn_off_the_lights(self, connections: dict):
|
||||
"""
|
||||
|
||||
@@ -49,6 +49,7 @@ _Widgets = {
|
||||
"ResetButton": "ResetButton",
|
||||
"ResumeButton": "ResumeButton",
|
||||
"RingProgressBar": "RingProgressBar",
|
||||
"SBBMonitor": "SBBMonitor",
|
||||
"ScanControl": "ScanControl",
|
||||
"ScatterWaveform": "ScatterWaveform",
|
||||
"SignalComboBox": "SignalComboBox",
|
||||
@@ -474,6 +475,20 @@ class BECProgressBar(RPCBase):
|
||||
>>> progressbar.label_template = "$value / $percentage %"
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def state(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@state.setter
|
||||
@rpc_call
|
||||
def state(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def _get_label(self) -> str:
|
||||
"""
|
||||
@@ -3235,6 +3250,12 @@ class RingProgressBar(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class SBBMonitor(RPCBase):
|
||||
"""A widget to display the SBB monitor website."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class ScanControl(RPCBase):
|
||||
"""Widget to submit new scans to the queue."""
|
||||
|
||||
@@ -3245,6 +3266,16 @@ class ScanControl(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class ScanProgressBar(RPCBase):
|
||||
"""Widget to display a progress bar that is hooked up to the scan progress of a scan."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
|
||||
class ScatterCurve(RPCBase):
|
||||
"""Scatter curve item for the scatter waveform widget."""
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class FakeDevice(BECDevice):
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd.Device",
|
||||
"deviceConfig": {},
|
||||
"deviceTags": ["user device"],
|
||||
"deviceTags": {"user device"},
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
@@ -89,16 +89,28 @@ class FakePositioner(BECPositioner):
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd_devices.SimPositioner",
|
||||
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
|
||||
"deviceTags": ["user motors"],
|
||||
"deviceTags": {"user motors"},
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
}
|
||||
self._info = {
|
||||
"signals": {
|
||||
"readback": {"kind_str": "hinted"}, # hinted
|
||||
"setpoint": {"kind_str": "normal"}, # normal
|
||||
"velocity": {"kind_str": "config"}, # config
|
||||
"readback": {
|
||||
"kind_str": "hinted",
|
||||
"component_name": "readback",
|
||||
"obj_name": self.name,
|
||||
}, # hinted
|
||||
"setpoint": {
|
||||
"kind_str": "normal",
|
||||
"component_name": "setpoint",
|
||||
"obj_name": f"{self.name}_setpoint",
|
||||
}, # normal
|
||||
"velocity": {
|
||||
"kind_str": "config",
|
||||
"component_name": "velocity",
|
||||
"obj_name": f"{self.name}_velocity",
|
||||
}, # config
|
||||
}
|
||||
}
|
||||
self.signals = {
|
||||
@@ -210,6 +222,39 @@ class DMMock:
|
||||
for device in devices:
|
||||
self.devices[device.name] = device
|
||||
|
||||
def get_bec_signals(self, signal_class_name: str):
|
||||
"""
|
||||
Emulate DeviceManager.get_bec_signals for unit-tests.
|
||||
|
||||
For “AsyncSignal” we list every device whose readout_priority is
|
||||
ReadoutPriority.ASYNC and build a minimal tuple
|
||||
(device_name, signal_name, signal_info_dict) that matches the real
|
||||
API shape used by Waveform._check_async_signal_found.
|
||||
"""
|
||||
signals: list[tuple[str, str, dict]] = []
|
||||
if signal_class_name != "AsyncSignal":
|
||||
return signals
|
||||
|
||||
for device in self.devices.values():
|
||||
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
|
||||
device_name = device.name
|
||||
signal_name = device.name # primary signal in our mocks
|
||||
signal_info = {
|
||||
"component_name": signal_name,
|
||||
"obj_name": signal_name,
|
||||
"kind_str": "hinted",
|
||||
"signal_class": signal_class_name,
|
||||
"metadata": {
|
||||
"connected": True,
|
||||
"precision": None,
|
||||
"read_access": True,
|
||||
"timestamp": 0.0,
|
||||
"write_access": True,
|
||||
},
|
||||
}
|
||||
signals.append((device_name, signal_name, signal_info))
|
||||
return signals
|
||||
|
||||
|
||||
DEVICES = [
|
||||
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
|
||||
|
||||
@@ -186,7 +186,6 @@ class BECConnector:
|
||||
except:
|
||||
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
|
||||
|
||||
@SafeSlot()
|
||||
def _run_cleanup_on_deleted_parent(self) -> None:
|
||||
"""
|
||||
Run cleanup on the deleted parent.
|
||||
|
||||
@@ -37,6 +37,16 @@ class ExpandableGroupFrame(QFrame):
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._create_title_layout(title, icon)
|
||||
|
||||
self._contents = QWidget(self)
|
||||
self._layout.addWidget(self._contents)
|
||||
|
||||
self._expansion_button.clicked.connect(self.switch_expanded_state)
|
||||
self.expanded = self._expanded # type: ignore
|
||||
self.expansion_state_changed.emit()
|
||||
|
||||
def _create_title_layout(self, title: str, icon: str):
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._layout.addLayout(self._title_layout)
|
||||
|
||||
@@ -45,6 +55,8 @@ class ExpandableGroupFrame(QFrame):
|
||||
self._title_layout.addWidget(self._title_icon)
|
||||
self._title_layout.addWidget(self._title)
|
||||
self.icon_name = icon
|
||||
self._title.clicked.connect(self.switch_expanded_state)
|
||||
self._title_icon.clicked.connect(self.switch_expanded_state)
|
||||
|
||||
self._title_layout.addStretch(1)
|
||||
|
||||
@@ -52,13 +64,6 @@ class ExpandableGroupFrame(QFrame):
|
||||
self._update_expansion_icon()
|
||||
self._title_layout.addWidget(self._expansion_button, stretch=1)
|
||||
|
||||
self._contents = QWidget(self)
|
||||
self._layout.addWidget(self._contents)
|
||||
|
||||
self._expansion_button.clicked.connect(self.switch_expanded_state)
|
||||
self.expanded = self._expanded # type: ignore
|
||||
self.expansion_state_changed.emit()
|
||||
|
||||
def set_layout(self, layout: QLayout) -> None:
|
||||
self._contents.setLayout(layout)
|
||||
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
|
||||
|
||||
@@ -8,6 +8,8 @@ from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QStringListModel
|
||||
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
|
||||
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@@ -15,11 +17,13 @@ class WidgetFilterHandler(ABC):
|
||||
"""Abstract base class for widget filter handlers"""
|
||||
|
||||
@abstractmethod
|
||||
def set_selection(self, widget, selection: list) -> None:
|
||||
def set_selection(self, widget, selection: list[str | tuple]) -> None:
|
||||
"""Set the filtered_selection for the widget
|
||||
|
||||
Args:
|
||||
selection (list): Filtered selection of items
|
||||
widget: Widget instance
|
||||
selection (list[str | tuple]): Filtered selection of items.
|
||||
If tuple, it contains (text, data) pairs.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@@ -34,17 +38,37 @@ class WidgetFilterHandler(ABC):
|
||||
bool: True if the input text is in the filtered selection
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def update_with_kind(
|
||||
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
||||
) -> list[str | tuple]:
|
||||
"""Update the selection based on the kind of signal.
|
||||
|
||||
Args:
|
||||
kind (Kind): The kind of signal to filter.
|
||||
signal_filter (set): Set of signal kinds to filter.
|
||||
device_info (dict): Dictionary containing device information.
|
||||
device_name (str): Name of the device.
|
||||
|
||||
Returns:
|
||||
list[str | tuple]: A list of filtered signals based on the kind.
|
||||
"""
|
||||
# This method should be implemented in subclasses or extended as needed
|
||||
|
||||
|
||||
class LineEditFilterHandler(WidgetFilterHandler):
|
||||
"""Handler for QLineEdit widget"""
|
||||
|
||||
def set_selection(self, widget: QLineEdit, selection: list) -> None:
|
||||
def set_selection(self, widget: QLineEdit, selection: list[str | tuple]) -> None:
|
||||
"""Set the selection for the widget to the completer model
|
||||
|
||||
Args:
|
||||
widget (QLineEdit): The QLineEdit widget
|
||||
selection (list): Filtered selection of items
|
||||
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
|
||||
"""
|
||||
if isinstance(selection, tuple):
|
||||
# If selection is a tuple, it contains (text, data) pairs
|
||||
selection = [text for text, _ in selection]
|
||||
if not isinstance(widget.completer, QCompleter):
|
||||
completer = QCompleter(widget)
|
||||
widget.setCompleter(completer)
|
||||
@@ -64,19 +88,47 @@ class LineEditFilterHandler(WidgetFilterHandler):
|
||||
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
|
||||
return text in model_data
|
||||
|
||||
def update_with_kind(
|
||||
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
||||
) -> list[str | tuple]:
|
||||
"""Update the selection based on the kind of signal.
|
||||
|
||||
Args:
|
||||
kind (Kind): The kind of signal to filter.
|
||||
signal_filter (set): Set of signal kinds to filter.
|
||||
device_info (dict): Dictionary containing device information.
|
||||
device_name (str): Name of the device.
|
||||
|
||||
Returns:
|
||||
list[str | tuple]: A list of filtered signals based on the kind.
|
||||
"""
|
||||
|
||||
return [
|
||||
signal
|
||||
for signal, signal_info in device_info.items()
|
||||
if kind in signal_filter and (signal_info.get("kind_str", None) == str(kind.name))
|
||||
]
|
||||
|
||||
|
||||
class ComboBoxFilterHandler(WidgetFilterHandler):
|
||||
"""Handler for QComboBox widget"""
|
||||
|
||||
def set_selection(self, widget: QComboBox, selection: list) -> None:
|
||||
def set_selection(self, widget: QComboBox, selection: list[str | tuple]) -> None:
|
||||
"""Set the selection for the widget to the completer model
|
||||
|
||||
Args:
|
||||
widget (QComboBox): The QComboBox widget
|
||||
selection (list): Filtered selection of items
|
||||
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
|
||||
"""
|
||||
widget.clear()
|
||||
widget.addItems(selection)
|
||||
if len(selection) == 0:
|
||||
return
|
||||
for element in selection:
|
||||
if isinstance(element, str):
|
||||
widget.addItem(element)
|
||||
elif isinstance(element, tuple):
|
||||
# If element is a tuple, it contains (text, data) pairs
|
||||
widget.addItem(*element)
|
||||
|
||||
def check_input(self, widget: QComboBox, text: str) -> bool:
|
||||
"""Check if the input text is in the filtered selection
|
||||
@@ -90,6 +142,40 @@ class ComboBoxFilterHandler(WidgetFilterHandler):
|
||||
"""
|
||||
return text in [widget.itemText(i) for i in range(widget.count())]
|
||||
|
||||
def update_with_kind(
|
||||
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
||||
) -> list[str | tuple]:
|
||||
"""Update the selection based on the kind of signal.
|
||||
|
||||
Args:
|
||||
kind (Kind): The kind of signal to filter.
|
||||
signal_filter (set): Set of signal kinds to filter.
|
||||
device_info (dict): Dictionary containing device information.
|
||||
device_name (str): Name of the device.
|
||||
|
||||
Returns:
|
||||
list[str | tuple]: A list of filtered signals based on the kind.
|
||||
"""
|
||||
out = []
|
||||
for signal, signal_info in device_info.items():
|
||||
if kind not in signal_filter or (signal_info.get("kind_str", None) != str(kind.name)):
|
||||
continue
|
||||
obj_name = signal_info.get("obj_name", "")
|
||||
component_name = signal_info.get("component_name", "")
|
||||
signal_wo_device = obj_name.removeprefix(f"{device_name}_")
|
||||
if not signal_wo_device:
|
||||
signal_wo_device = obj_name
|
||||
|
||||
if signal_wo_device != signal and component_name.replace(".", "_") != signal_wo_device:
|
||||
# If the object name is not the same as the signal name, we use the object name
|
||||
# to display in the combobox.
|
||||
out.append((f"{signal_wo_device} ({signal})", signal_info))
|
||||
else:
|
||||
# If the object name is the same as the signal name, we do not change it.
|
||||
out.append((signal, signal_info))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
class FilterIO:
|
||||
"""Public interface to set filters for input widgets.
|
||||
@@ -99,13 +185,14 @@ class FilterIO:
|
||||
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
|
||||
|
||||
@staticmethod
|
||||
def set_selection(widget, selection: list, ignore_errors=True):
|
||||
def set_selection(widget, selection: list[str | tuple], ignore_errors=True):
|
||||
"""
|
||||
Retrieve value from the widget instance.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
selection(list): List of filtered selection items.
|
||||
selection (list[str | tuple]): Filtered selection of items.
|
||||
If tuple, it contains (text, data) pairs.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
@@ -139,6 +226,35 @@ class FilterIO:
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def update_with_kind(
|
||||
widget, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
||||
) -> list[str | tuple]:
|
||||
"""
|
||||
Update the selection based on the kind of signal.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
kind (Kind): The kind of signal to filter.
|
||||
signal_filter (set): Set of signal kinds to filter.
|
||||
device_info (dict): Dictionary containing device information.
|
||||
device_name (str): Name of the device.
|
||||
|
||||
Returns:
|
||||
list[str | tuple]: A list of filtered signals based on the kind.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().update_with_kind(
|
||||
kind=kind,
|
||||
signal_filter=signal_filter,
|
||||
device_info=device_info,
|
||||
device_name=device_name,
|
||||
)
|
||||
raise ValueError(
|
||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _find_handler(widget):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import NoneType
|
||||
from typing import NamedTuple
|
||||
|
||||
@@ -8,11 +7,12 @@ from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QSizePolicy, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.utils.forms_from_types import styles
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
DynamicFormItem,
|
||||
DynamicFormItemType,
|
||||
@@ -68,15 +68,11 @@ class TypedForm(BECWidget, QWidget):
|
||||
logger.error("Must specify one and only one of items and form_item_specs!")
|
||||
items = []
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
self._items = (
|
||||
form_item_specs
|
||||
if form_item_specs is not None
|
||||
else [
|
||||
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
|
||||
for name, item_type in items # type: ignore
|
||||
]
|
||||
)
|
||||
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
self._items = form_item_specs or [
|
||||
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
|
||||
for name, item_type in items # type: ignore
|
||||
]
|
||||
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
@@ -84,15 +80,19 @@ class TypedForm(BECWidget, QWidget):
|
||||
self._enabled: bool = enabled
|
||||
|
||||
self._form_grid_container = QWidget(parent=self)
|
||||
self._form_grid_container.setSizePolicy(
|
||||
QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding
|
||||
)
|
||||
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid = QWidget(parent=self._form_grid_container)
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._layout.addWidget(self._form_grid_container)
|
||||
self._form_grid_container.setLayout(QVBoxLayout())
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
|
||||
self._widget_types: dict | None = None
|
||||
self._widget_from_type = widget_from_type
|
||||
self._post_init()
|
||||
|
||||
def _post_init(self):
|
||||
"""Override this if a subclass should do things after super().__init__ and before populate()"""
|
||||
self.populate()
|
||||
self.enabled = self._enabled # type: ignore # QProperty
|
||||
|
||||
@@ -100,6 +100,8 @@ class TypedForm(BECWidget, QWidget):
|
||||
self._clear_grid()
|
||||
for r, item in enumerate(self._items):
|
||||
self._add_griditem(item, r)
|
||||
gl: QGridLayout = self._form_grid.layout()
|
||||
gl.setRowStretch(gl.rowCount(), 1)
|
||||
|
||||
def _add_griditem(self, item: FormItemSpec, row: int):
|
||||
grid = self._form_grid.layout()
|
||||
@@ -107,16 +109,16 @@ class TypedForm(BECWidget, QWidget):
|
||||
label.setProperty("_model_field_name", item.name)
|
||||
label.setToolTip(item.info.description or item.name)
|
||||
grid.addWidget(label, row, 0)
|
||||
widget = widget_from_type(item.item_type)(parent=self, spec=item)
|
||||
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
|
||||
widget.valueChanged.connect(self.value_changed)
|
||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
grid.addWidget(widget, row, 1)
|
||||
|
||||
def enumerate_form_widgets(self):
|
||||
"""Return a generator over the rows of the form, with the row number, the label widget (to
|
||||
which the field name is attached as a property), and the entry widget"""
|
||||
which the field name is attached as a property "_model_field_name"), and the entry widget"""
|
||||
grid: QGridLayout = self._form_grid.layout() # type: ignore
|
||||
for i in range(grid.rowCount()):
|
||||
for i in range(grid.rowCount() - 1): # One extra row for stretch
|
||||
yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget())
|
||||
|
||||
def _dict_from_grid(self) -> dict[str, DynamicFormItemType]:
|
||||
@@ -135,7 +137,7 @@ class TypedForm(BECWidget, QWidget):
|
||||
old_layout.deleteLater()
|
||||
self._form_grid.deleteLater()
|
||||
self._form_grid = QWidget()
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
self._form_grid_container.layout().addWidget(self._form_grid)
|
||||
|
||||
@@ -186,14 +188,18 @@ class PydanticModelForm(TypedForm):
|
||||
|
||||
Args:
|
||||
data_model (type[BaseModel]): the model class for which to generate a form.
|
||||
enabled (bool): whether fields are enabled for editing.
|
||||
enabled (bool, optional): whether fields are enabled for editing.
|
||||
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
|
||||
|
||||
"""
|
||||
self._pretty_display = pretty_display
|
||||
self._md_schema = data_model
|
||||
super().__init__(
|
||||
parent=parent, form_item_specs=self._form_item_specs(), enabled=enabled, client=client
|
||||
parent=parent,
|
||||
form_item_specs=self._form_item_specs(),
|
||||
enabled=enabled,
|
||||
client=client,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
@@ -207,6 +213,18 @@ class PydanticModelForm(TypedForm):
|
||||
self._layout.addWidget(self._validity)
|
||||
self.value_changed.connect(self.validate_form)
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
def set_pretty_display_theme(self, theme: str = "dark"):
|
||||
if self._pretty_display:
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
|
||||
|
||||
def set_schema(self, schema: type[BaseModel]):
|
||||
self._md_schema = schema
|
||||
self.populate()
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from types import GenericAlias, UnionType
|
||||
from typing import Literal
|
||||
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic.fields import FieldInfo
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from pydantic_core import PydanticUndefined
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtCore import QSize, Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QFontMetrics
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
|
||||
from bec_widgets.widgets.editors.scan_metadata._util import (
|
||||
clearable_required,
|
||||
@@ -36,6 +47,7 @@ from bec_widgets.widgets.editors.scan_metadata._util import (
|
||||
field_minlen,
|
||||
field_precision,
|
||||
)
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -123,7 +135,7 @@ class ClearableBoolEntry(QWidget):
|
||||
self._false.setToolTip(tooltip)
|
||||
|
||||
|
||||
DynamicFormItemType = str | int | float | Decimal | bool | dict
|
||||
DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None
|
||||
|
||||
|
||||
class DynamicFormItem(QWidget):
|
||||
@@ -146,8 +158,9 @@ class DynamicFormItem(QWidget):
|
||||
self._desc = self._spec.info.description
|
||||
self.setLayout(self._layout)
|
||||
self._add_main_widget()
|
||||
self._main_widget: QWidget
|
||||
self._main_widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
|
||||
self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
if not spec.pretty_display:
|
||||
if clearable_required(spec.info):
|
||||
self._add_clear_button()
|
||||
@@ -167,6 +180,8 @@ class DynamicFormItem(QWidget):
|
||||
|
||||
def _set_pretty_display(self):
|
||||
self.setEnabled(False)
|
||||
if button := getattr(self, "_clear_button", None):
|
||||
button.setVisible(False)
|
||||
|
||||
def _describe(self, pad=" "):
|
||||
return pad + (self._desc if self._desc else "")
|
||||
@@ -185,7 +200,7 @@ class DynamicFormItem(QWidget):
|
||||
self.valueChanged.emit()
|
||||
|
||||
|
||||
class StrMetadataField(DynamicFormItem):
|
||||
class StrFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
@@ -210,11 +225,11 @@ class StrMetadataField(DynamicFormItem):
|
||||
|
||||
def setValue(self, value: str):
|
||||
if value is None:
|
||||
self._main_widget.setText("")
|
||||
return self._main_widget.setText("")
|
||||
self._main_widget.setText(str(value))
|
||||
|
||||
|
||||
class IntMetadataField(DynamicFormItem):
|
||||
class IntFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
@@ -243,7 +258,7 @@ class IntMetadataField(DynamicFormItem):
|
||||
self._main_widget.setValue(value)
|
||||
|
||||
|
||||
class FloatDecimalMetadataField(DynamicFormItem):
|
||||
class FloatDecimalFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
@@ -277,7 +292,7 @@ class FloatDecimalMetadataField(DynamicFormItem):
|
||||
self._main_widget.setValue(float(value))
|
||||
|
||||
|
||||
class BoolMetadataField(DynamicFormItem):
|
||||
class BoolFormItem(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.stateChanged.connect(self._value_changed)
|
||||
@@ -298,10 +313,26 @@ class BoolMetadataField(DynamicFormItem):
|
||||
self._main_widget.setChecked(value)
|
||||
|
||||
|
||||
class DictMetadataField(DynamicFormItem):
|
||||
class BoolToggleFormItem(BoolFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
if spec.info.default is PydanticUndefined:
|
||||
spec.info.default = False
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = ToggleSwitch()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
if self._default is not None:
|
||||
self._main_widget.setChecked(self._default)
|
||||
|
||||
|
||||
class DictFormItem(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.data_changed.connect(self._value_changed)
|
||||
if spec.info.default is not PydanticUndefined:
|
||||
self._main_widget.set_default(spec.info.default)
|
||||
|
||||
def _set_pretty_display(self):
|
||||
self._main_widget.set_button_visibility(False)
|
||||
@@ -319,44 +350,263 @@ class DictMetadataField(DynamicFormItem):
|
||||
self._main_widget.replace_data(value)
|
||||
|
||||
|
||||
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
|
||||
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
|
||||
if annotation in [dict, dict | None] or (
|
||||
isinstance(annotation, GenericAlias) and annotation.__origin__ is dict
|
||||
):
|
||||
return DictMetadataField
|
||||
if annotation in [list, list | None] or (
|
||||
isinstance(annotation, GenericAlias) and annotation.__origin__ is list
|
||||
):
|
||||
return StrMetadataField
|
||||
else:
|
||||
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
|
||||
return StrMetadataField
|
||||
class _ItemAndWidgetType(NamedTuple):
|
||||
# TODO: this should be generic but not supported in 3.10
|
||||
item: type[int | float | str]
|
||||
widget: type[QWidget]
|
||||
default: int | float | str
|
||||
|
||||
|
||||
class ListFormItem(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
if spec.info.annotation is list:
|
||||
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||
elif isinstance(spec.info.annotation, GenericAlias):
|
||||
args = set(typing.get_args(spec.info.annotation))
|
||||
if args == {str}:
|
||||
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||
if args == {int}:
|
||||
self._types = _ItemAndWidgetType(int, QSpinBox, 0)
|
||||
if args == {float} or args == {int, float}:
|
||||
self._types = _ItemAndWidgetType(float, QDoubleSpinBox, 0.0)
|
||||
else:
|
||||
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self._main_widget: QListWidget
|
||||
self._data = []
|
||||
self._min_lines = 2 if spec.pretty_display else 4
|
||||
self._repop(self._data)
|
||||
|
||||
def sizeHint(self):
|
||||
default = super().sizeHint()
|
||||
return QSize(default.width(), QFontMetrics(self.font()).height() * 6)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QListWidget()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self._add_buttons()
|
||||
|
||||
def _add_buttons(self):
|
||||
self._button_holder = QWidget()
|
||||
self._buttons = QVBoxLayout()
|
||||
self._button_holder.setLayout(self._buttons)
|
||||
self._layout.addWidget(self._button_holder)
|
||||
self._add_button = QPushButton("+")
|
||||
self._add_button.setToolTip("add a new row")
|
||||
self._remove_button = QPushButton("-")
|
||||
self._remove_button.setToolTip("delete the focused row (if any)")
|
||||
self._add_button.clicked.connect(self._add_row)
|
||||
self._remove_button.clicked.connect(self._delete_row)
|
||||
self._buttons.addWidget(self._add_button)
|
||||
self._buttons.addWidget(self._remove_button)
|
||||
|
||||
def _set_pretty_display(self):
|
||||
super()._set_pretty_display()
|
||||
self._button_holder.setHidden(True)
|
||||
|
||||
def _repop(self, data):
|
||||
self._main_widget.clear()
|
||||
for val in data:
|
||||
self._add_list_item(val)
|
||||
self.scale_to_data()
|
||||
|
||||
def _add_data_item(self, val=None):
|
||||
val = val or self._types.default
|
||||
self._data.append(val)
|
||||
self._add_list_item(val)
|
||||
self._repop(self._data)
|
||||
|
||||
def _add_list_item(self, val):
|
||||
item = QListWidgetItem(self._main_widget)
|
||||
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable)
|
||||
item_widget = self._types.widget(parent=self)
|
||||
WidgetIO.set_value(item_widget, val)
|
||||
self._main_widget.setItemWidget(item, item_widget)
|
||||
self._main_widget.addItem(item)
|
||||
WidgetIO.connect_widget_change_signal(item_widget, self._update)
|
||||
return item_widget
|
||||
|
||||
def _update(self, _, value, *args):
|
||||
self._data[self._main_widget.currentRow()] = value
|
||||
|
||||
@SafeSlot()
|
||||
def _add_row(self):
|
||||
self._add_data_item(self._types.default)
|
||||
self._repop(self._data)
|
||||
|
||||
@SafeSlot()
|
||||
def _delete_row(self):
|
||||
if selected := self._main_widget.currentItem():
|
||||
self._main_widget.removeItemWidget(selected)
|
||||
row = self._main_widget.currentRow()
|
||||
self._main_widget.takeItem(row)
|
||||
self._data.pop(row)
|
||||
self._repop(self._data)
|
||||
|
||||
@SafeSlot()
|
||||
def clear(self):
|
||||
self._repop([])
|
||||
|
||||
def getValue(self):
|
||||
return self._data
|
||||
|
||||
def setValue(self, value: Iterable):
|
||||
if set(map(type, value)) | {self._types.item} != {self._types.item}:
|
||||
raise ValueError(f"This widget only accepts items of type {self._types.item}")
|
||||
self._data = list(value)
|
||||
self._repop(self._data)
|
||||
|
||||
def _line_height(self):
|
||||
return QFontMetrics(self._main_widget.font()).height()
|
||||
|
||||
def set_max_height_in_lines(self, lines: int):
|
||||
outer_inc = 1 if self._spec.pretty_display else 3
|
||||
self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines))
|
||||
self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1))
|
||||
self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc))
|
||||
|
||||
def scale_to_data(self, *_):
|
||||
self.set_max_height_in_lines(self._main_widget.count() + 1)
|
||||
|
||||
|
||||
class SetFormItem(ListFormItem):
|
||||
def _add_main_widget(self) -> None:
|
||||
super()._add_main_widget()
|
||||
self._add_item_field = self._types.widget()
|
||||
self._buttons.addWidget(QLabel("Add new:"))
|
||||
self._buttons.addWidget(self._add_item_field)
|
||||
self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Minimum)
|
||||
|
||||
@SafeSlot()
|
||||
def _add_row(self):
|
||||
self._add_data_item(WidgetIO.get_value(self._add_item_field))
|
||||
self._repop(self._data)
|
||||
|
||||
def _update(self, _, value, *args):
|
||||
if value in self._data:
|
||||
return
|
||||
return super()._update(_, value, *args)
|
||||
|
||||
def _add_data_item(self, val=None):
|
||||
val = val or self._types.default
|
||||
if val == self._types.default or val in self._data:
|
||||
return
|
||||
self._data.append(val)
|
||||
self._add_list_item(val)
|
||||
|
||||
def _add_list_item(self, val):
|
||||
item_widget = super()._add_list_item(val)
|
||||
if isinstance(item_widget, QLineEdit):
|
||||
item_widget.setReadOnly(True)
|
||||
return item_widget
|
||||
|
||||
def getValue(self):
|
||||
return set(self._data)
|
||||
|
||||
def setValue(self, value: set):
|
||||
return super().setValue(set(value))
|
||||
|
||||
|
||||
class StrLiteralFormItem(DynamicFormItem):
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QComboBox()
|
||||
self._options = get_args(self._spec.info.annotation)
|
||||
for opt in self._options:
|
||||
self._main_widget.addItem(opt)
|
||||
self._layout.addWidget(self._main_widget)
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.currentText()
|
||||
|
||||
def setValue(self, value: str | None):
|
||||
if value is None:
|
||||
self.clear()
|
||||
for i in range(self._main_widget.count()):
|
||||
if self._main_widget.itemText(i) == value:
|
||||
self._main_widget.setCurrentIndex(i)
|
||||
return
|
||||
raise ValueError(f"Cannot set value: {value}, options are: {self._options}")
|
||||
|
||||
def clear(self):
|
||||
self._main_widget.setCurrentIndex(-1)
|
||||
|
||||
|
||||
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
|
||||
|
||||
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
|
||||
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
|
||||
# and delete/insert keys or change the order
|
||||
"literal_str": (
|
||||
lambda spec: type(spec.info.annotation) is type(Literal[""])
|
||||
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str},
|
||||
StrLiteralFormItem,
|
||||
),
|
||||
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
|
||||
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
|
||||
"float_decimal": (
|
||||
lambda spec: spec.item_type in [float, float | None, Decimal, Decimal | None],
|
||||
FloatDecimalFormItem,
|
||||
),
|
||||
"bool": (lambda spec: spec.item_type in [bool, bool | None], BoolFormItem),
|
||||
"dict": (
|
||||
lambda spec: spec.item_type in [dict, dict | None]
|
||||
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is dict),
|
||||
DictFormItem,
|
||||
),
|
||||
"list": (
|
||||
lambda spec: spec.item_type in [list, list | None]
|
||||
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is list),
|
||||
ListFormItem,
|
||||
),
|
||||
"set": (
|
||||
lambda spec: spec.item_type in [set, set | None]
|
||||
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is set),
|
||||
SetFormItem,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def widget_from_type(
|
||||
spec: FormItemSpec, widget_types: WidgetTypeRegistry | None = None
|
||||
) -> type[DynamicFormItem]:
|
||||
widget_types = widget_types or DEFAULT_WIDGET_TYPES
|
||||
for predicate, widget_type in widget_types.values():
|
||||
if predicate(spec):
|
||||
return widget_type
|
||||
logger.warning(
|
||||
f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation."
|
||||
)
|
||||
return StrFormItem
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
class TestModel(BaseModel):
|
||||
value0: set = Field(set(["a", "b"]))
|
||||
value1: str | None = Field(None)
|
||||
value2: bool | None = Field(None)
|
||||
value3: bool = Field(True)
|
||||
value4: int = Field(123)
|
||||
value5: int | None = Field()
|
||||
value6: list[int] = Field()
|
||||
value7: list = Field()
|
||||
|
||||
app = QApplication([])
|
||||
w = QWidget()
|
||||
layout = QGridLayout()
|
||||
w.setLayout(layout)
|
||||
items = []
|
||||
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
|
||||
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
|
||||
layout.addWidget(QLabel(field_name), i, 0)
|
||||
layout.addWidget(widget_from_type(info.annotation)(info), i, 1)
|
||||
widg = widget_from_type(spec)(spec=spec)
|
||||
items.append(widg)
|
||||
layout.addWidget(widg, i, 1)
|
||||
|
||||
items[6].setValue([1, 2, 3, 4])
|
||||
items[7].setValue(["1", "2", "asdfg", "qwerty"])
|
||||
|
||||
w.show()
|
||||
app.exec()
|
||||
|
||||
@@ -37,7 +37,6 @@ def get_plugin_widgets() -> dict[str, BECConnector]:
|
||||
"""
|
||||
modules = _get_available_plugins("bec.widgets.user_widgets")
|
||||
loaded_plugins = {}
|
||||
print(modules)
|
||||
for module in modules:
|
||||
mods = inspect.getmembers(module, predicate=_filter_plugins)
|
||||
for name, mod_cls in mods:
|
||||
|
||||
@@ -16,7 +16,8 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||
|
||||
|
||||
class SidePanel(QWidget):
|
||||
@@ -61,7 +62,7 @@ class SidePanel(QWidget):
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="vertical")
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="vertical")
|
||||
|
||||
self.container = QWidget()
|
||||
self.container.layout = QVBoxLayout(self.container)
|
||||
@@ -92,7 +93,7 @@ class SidePanel(QWidget):
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
|
||||
|
||||
self.container = QWidget()
|
||||
self.container.layout = QVBoxLayout(self.container)
|
||||
@@ -288,8 +289,16 @@ class SidePanel(QWidget):
|
||||
|
||||
# Add an action to the toolbar if action_id, icon_name, and tooltip are provided
|
||||
if action_id is not None and icon_name is not None and tooltip is not None:
|
||||
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
|
||||
self.toolbar.add_action(action_id, action, target_widget=self)
|
||||
action = MaterialIconAction(
|
||||
icon_name=icon_name, tooltip=tooltip, checkable=True, parent=self
|
||||
)
|
||||
self.toolbar.components.add_safe(action_id, action)
|
||||
bundle = ToolbarBundle(action_id, self.toolbar.components)
|
||||
bundle.add_action(action_id)
|
||||
self.toolbar.add_bundle(bundle)
|
||||
shown_bundles = self.toolbar.shown_bundles
|
||||
shown_bundles.append(action_id)
|
||||
self.toolbar.show_bundles(shown_bundles)
|
||||
|
||||
def on_action_toggled(checked: bool):
|
||||
if self.switching_actions:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
524
bec_widgets/utils/toolbars/actions.py
Normal file
524
bec_widgets/utils/toolbars/actions.py
Normal file
@@ -0,0 +1,524 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict, Literal
|
||||
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from qtpy.QtCore import QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QColor, QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QSizePolicy,
|
||||
QStyledItemDelegate,
|
||||
QToolBar,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class NoCheckDelegate(QStyledItemDelegate):
|
||||
"""To reduce space in combo boxes by removing the checkmark."""
|
||||
|
||||
def initStyleOption(self, option, index):
|
||||
super().initStyleOption(option, index)
|
||||
# Remove any check indicator
|
||||
option.checkState = Qt.Unchecked
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Abstract base class for toolbar actions.
|
||||
|
||||
Args:
|
||||
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
|
||||
tooltip (str, optional): The tooltip for the action. Defaults to None.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||
"""
|
||||
|
||||
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
|
||||
self.icon_path = (
|
||||
os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None
|
||||
)
|
||||
self.tooltip = tooltip
|
||||
self.checkable = checkable
|
||||
self.action = None
|
||||
|
||||
@abstractmethod
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""Adds an action or widget to a toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the action or widget to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleans up the action, if necessary."""
|
||||
pass
|
||||
|
||||
|
||||
class SeparatorAction(ToolBarAction):
|
||||
"""Separator action for the toolbar."""
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
toolbar.addSeparator()
|
||||
|
||||
|
||||
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(icon=self.icon, text=self.tooltip, parent=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.
|
||||
|
||||
Args:
|
||||
icon_name (str, optional): The name of the Material icon. Defaults to None.
|
||||
tooltip (str, optional): The tooltip for the action. Defaults to None.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||
filled (bool, optional): Whether the icon is filled. Defaults to False.
|
||||
color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon.
|
||||
Defaults to None.
|
||||
parent (QWidget or None, optional): Parent widget for the underlying QAction.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
icon_name: str = None,
|
||||
tooltip: str = None,
|
||||
checkable: bool = False,
|
||||
filled: bool = False,
|
||||
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.icon_name = icon_name
|
||||
self.filled = filled
|
||||
self.color = color
|
||||
# Generate the icon using the material_icon helper
|
||||
self.icon = material_icon(
|
||||
self.icon_name,
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
filled=self.filled,
|
||||
color=self.color,
|
||||
)
|
||||
if parent is None:
|
||||
logger.warning(
|
||||
"MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues."
|
||||
)
|
||||
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
|
||||
self.action.setCheckable(self.checkable)
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the action to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar(QToolBar): The toolbar to add the action to.
|
||||
target(QWidget): The target widget for the action.
|
||||
"""
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
"""
|
||||
Returns the icon for the action.
|
||||
|
||||
Returns:
|
||||
QIcon: The icon for the action.
|
||||
"""
|
||||
return self.icon
|
||||
|
||||
|
||||
class DeviceSelectionAction(ToolBarAction):
|
||||
"""
|
||||
Action for selecting a device in a combobox.
|
||||
|
||||
Args:
|
||||
label (str): The label for the combobox.
|
||||
device_combobox (DeviceComboBox): The combobox for selecting the device.
|
||||
"""
|
||||
|
||||
def __init__(self, label: str | None = None, device_combobox=None):
|
||||
super().__init__()
|
||||
self.label = label
|
||||
self.device_combobox = device_combobox
|
||||
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
|
||||
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
widget = QWidget(parent=target)
|
||||
layout = QHBoxLayout(widget)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
if self.label is not None:
|
||||
label = QLabel(text=f"{self.label}", parent=target)
|
||||
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)
|
||||
for key, action_obj in self.actions.items():
|
||||
menu_action = QAction(
|
||||
icon=action_obj.get_icon(), text=action_obj.tooltip, parent=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.main_button.setMenu(menu)
|
||||
if self.default_state_checked:
|
||||
self.main_button.setChecked(True)
|
||||
self.action = toolbar.addWidget(self.main_button)
|
||||
|
||||
def _trigger_current_action(self):
|
||||
"""
|
||||
Triggers the current action associated with the main button.
|
||||
"""
|
||||
action_obj = self.actions[self.current_key]
|
||||
action_obj.action.trigger()
|
||||
|
||||
def set_default_action(self, key: str):
|
||||
"""
|
||||
Sets the default action for the split action.
|
||||
|
||||
Args:
|
||||
key(str): The key of the action to set as default.
|
||||
"""
|
||||
if self.main_button is None:
|
||||
return
|
||||
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.actions.items():
|
||||
menu_act.action.setChecked(False)
|
||||
new_action.action.trigger()
|
||||
# Active action chosen from menu is always checked, uncheck through main button
|
||||
if self.checkable:
|
||||
new_action.action.setChecked(True)
|
||||
self.main_button.setChecked(True)
|
||||
|
||||
def block_all_signals(self, block: bool = True):
|
||||
"""
|
||||
Blocks or unblocks all signals for the actions in the toolbar.
|
||||
|
||||
Args:
|
||||
block (bool): Whether to block signals. Defaults to True.
|
||||
"""
|
||||
if self.main_button is not None:
|
||||
self.main_button.blockSignals(block)
|
||||
|
||||
for action in self.actions.values():
|
||||
action.action.blockSignals(block)
|
||||
|
||||
@contextmanager
|
||||
def signal_blocker(self):
|
||||
"""
|
||||
Context manager to block signals for all actions in the toolbar.
|
||||
"""
|
||||
self.block_all_signals(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.block_all_signals(False)
|
||||
|
||||
def set_state_all(self, state: bool):
|
||||
"""
|
||||
Uncheck all actions in the toolbar.
|
||||
"""
|
||||
for action in self.actions.values():
|
||||
action.action.setChecked(state)
|
||||
if self.main_button is None:
|
||||
return
|
||||
self.main_button.setChecked(state)
|
||||
|
||||
def get_icon(self) -> QIcon:
|
||||
return self.actions[self.current_key].get_icon()
|
||||
|
||||
|
||||
class WidgetAction(ToolBarAction):
|
||||
"""
|
||||
Action for adding any widget to the toolbar.
|
||||
Please note that the injected widget must be life-cycled by the parent widget,
|
||||
i.e., the widget must be properly cleaned up outside of this action. The WidgetAction
|
||||
will not perform any cleanup on the widget itself, only on the container that holds it.
|
||||
|
||||
Args:
|
||||
label (str|None): The label for the widget.
|
||||
widget (QWidget): The widget to be added to the toolbar.
|
||||
adjust_size (bool): Whether to adjust the size of the widget based on its contents. Defaults to True.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: str | None = None,
|
||||
widget: QWidget = None,
|
||||
adjust_size: bool = True,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(icon_path=None, tooltip=label, checkable=False)
|
||||
self.label = label
|
||||
self.widget = widget
|
||||
self.container = None
|
||||
self.adjust_size = adjust_size
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
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(parent=target)
|
||||
layout = QHBoxLayout(self.container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
if self.label is not None:
|
||||
label_widget = QLabel(text=f"{self.label}", parent=target)
|
||||
label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
|
||||
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
|
||||
layout.addWidget(label_widget)
|
||||
|
||||
if isinstance(self.widget, QComboBox) and self.adjust_size:
|
||||
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||
|
||||
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.widget.setSizePolicy(size_policy)
|
||||
|
||||
self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget))
|
||||
|
||||
layout.addWidget(self.widget)
|
||||
|
||||
toolbar.addWidget(self.container)
|
||||
# Store the container as the action to allow toggling visibility.
|
||||
self.action = self.container
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the action by closing and deleting the container widget.
|
||||
This method will be called automatically when the toolbar is cleaned up.
|
||||
"""
|
||||
if self.container is not None:
|
||||
self.container.close()
|
||||
self.container.deleteLater()
|
||||
return super().cleanup()
|
||||
|
||||
@staticmethod
|
||||
def calculate_minimum_width(combo_box: QComboBox) -> int:
|
||||
font_metrics = combo_box.fontMetrics()
|
||||
max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count()))
|
||||
return max_width + 60
|
||||
|
||||
|
||||
class ExpandableMenuAction(ToolBarAction):
|
||||
"""
|
||||
Action for an expandable menu in the toolbar.
|
||||
|
||||
Args:
|
||||
label (str): The label for the menu.
|
||||
actions (dict): A dictionary of actions to populate the menu.
|
||||
icon_path (str, optional): The path to the icon file. Defaults to None.
|
||||
"""
|
||||
|
||||
def __init__(self, label: str, actions: dict, icon_path: str = None):
|
||||
super().__init__(icon_path, label)
|
||||
self.actions = actions
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
button = QToolButton(toolbar)
|
||||
if self.icon_path:
|
||||
button.setIcon(QIcon(self.icon_path))
|
||||
button.setText(self.tooltip)
|
||||
button.setPopupMode(QToolButton.InstantPopup)
|
||||
button.setStyleSheet(
|
||||
"""
|
||||
QToolButton {
|
||||
font-size: 14px;
|
||||
}
|
||||
QMenu {
|
||||
font-size: 14px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
menu = QMenu(button)
|
||||
for action_container in self.actions.values():
|
||||
action: QAction = action_container.action
|
||||
action.setIconVisibleInMenu(True)
|
||||
if action_container.icon_path:
|
||||
icon = QIcon()
|
||||
icon.addFile(action_container.icon_path, size=QSize(20, 20))
|
||||
action.setIcon(icon)
|
||||
elif hasattr(action, "get_icon") and callable(action_container.get_icon):
|
||||
sub_icon = action_container.get_icon()
|
||||
if sub_icon and not sub_icon.isNull():
|
||||
action.setIcon(sub_icon)
|
||||
action.setCheckable(action_container.checkable)
|
||||
menu.addAction(action)
|
||||
button.setMenu(menu)
|
||||
toolbar.addWidget(button)
|
||||
|
||||
|
||||
class DeviceComboBoxAction(WidgetAction):
|
||||
"""
|
||||
Action for a device selection combobox in the toolbar.
|
||||
|
||||
Args:
|
||||
label (str): The label for the combobox.
|
||||
device_combobox (QComboBox): The combobox for selecting the device.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target_widget: QWidget,
|
||||
device_filter: list[BECDeviceFilter] | None = None,
|
||||
readout_priority_filter: (
|
||||
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
|
||||
) = None,
|
||||
tooltip: str | None = None,
|
||||
add_empty_item: bool = False,
|
||||
no_check_delegate: bool = False,
|
||||
):
|
||||
self.combobox = DeviceComboBox(
|
||||
parent=target_widget,
|
||||
device_filter=device_filter,
|
||||
readout_priority_filter=readout_priority_filter,
|
||||
)
|
||||
super().__init__(widget=self.combobox, adjust_size=False)
|
||||
|
||||
if add_empty_item:
|
||||
self.combobox.addItem("", None)
|
||||
self.combobox.setCurrentText("")
|
||||
if tooltip is not None:
|
||||
self.combobox.setToolTip(tooltip)
|
||||
if no_check_delegate:
|
||||
self.combobox.setItemDelegate(NoCheckDelegate(self.combobox))
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the action by closing and deleting the combobox widget.
|
||||
This method will be called automatically when the toolbar is cleaned up.
|
||||
"""
|
||||
if self.combobox is not None:
|
||||
self.combobox.close()
|
||||
self.combobox.deleteLater()
|
||||
return super().cleanup()
|
||||
244
bec_widgets/utils/toolbars/bundles.py
Normal file
244
bec_widgets/utils/toolbars/bundles.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, DefaultDict
|
||||
from weakref import ReferenceType
|
||||
|
||||
import louie
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bec_widgets.utils.toolbars.actions import SeparatorAction, ToolBarAction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ActionInfo(BaseModel):
|
||||
action: ToolBarAction
|
||||
toolbar_bundle: ToolbarBundle | None = None
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
|
||||
class ToolbarComponents:
|
||||
def __init__(self, toolbar: ModularToolBar):
|
||||
"""
|
||||
Initializes the toolbar components.
|
||||
|
||||
Args:
|
||||
toolbar (ModularToolBar): The toolbar to which the components will be added.
|
||||
"""
|
||||
self.toolbar = toolbar
|
||||
|
||||
self._components: dict[str, ActionInfo] = {}
|
||||
self.add("separator", SeparatorAction())
|
||||
|
||||
def add(self, name: str, component: ToolBarAction):
|
||||
"""
|
||||
Adds a component to the toolbar.
|
||||
|
||||
Args:
|
||||
component (ToolBarAction): The component to add.
|
||||
"""
|
||||
if name in self._components:
|
||||
raise ValueError(f"Component with name '{name}' already exists.")
|
||||
self._components[name] = ActionInfo(action=component, toolbar_bundle=None)
|
||||
|
||||
def add_safe(self, name: str, component: ToolBarAction):
|
||||
"""
|
||||
Adds a component to the toolbar, ensuring it does not already exist.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component.
|
||||
component (ToolBarAction): The component to add.
|
||||
"""
|
||||
if self.exists(name):
|
||||
logger.info(f"Component with name '{name}' already exists. Skipping addition.")
|
||||
return
|
||||
self.add(name, component)
|
||||
|
||||
def exists(self, name: str) -> bool:
|
||||
"""
|
||||
Checks if a component exists in the toolbar.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the component exists, False otherwise.
|
||||
"""
|
||||
return name in self._components
|
||||
|
||||
def get_action_reference(self, name: str) -> ReferenceType[ToolBarAction]:
|
||||
"""
|
||||
Retrieves a component by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component to retrieve.
|
||||
|
||||
"""
|
||||
if not self.exists(name):
|
||||
raise KeyError(f"Component with name '{name}' does not exist.")
|
||||
return louie.saferef.safe_ref(self._components[name].action)
|
||||
|
||||
def get_action(self, name: str) -> ToolBarAction:
|
||||
"""
|
||||
Retrieves a component by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component to retrieve.
|
||||
|
||||
Returns:
|
||||
ToolBarAction: The action associated with the given name.
|
||||
"""
|
||||
if not self.exists(name):
|
||||
raise KeyError(
|
||||
f"Component with name '{name}' does not exist. The following components are available: {list(self._components.keys())}"
|
||||
)
|
||||
return self._components[name].action
|
||||
|
||||
def set_bundle(self, name: str, bundle: ToolbarBundle):
|
||||
"""
|
||||
Sets the bundle for a component.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component.
|
||||
bundle (ToolbarBundle): The bundle to set.
|
||||
"""
|
||||
if not self.exists(name):
|
||||
raise KeyError(f"Component with name '{name}' does not exist.")
|
||||
comp = self._components[name]
|
||||
if comp.toolbar_bundle is not None:
|
||||
logger.info(
|
||||
f"Component '{name}' already has a bundle ({comp.toolbar_bundle.name}). Setting it to {bundle.name}."
|
||||
)
|
||||
comp.toolbar_bundle.bundle_actions.pop(name, None)
|
||||
comp.toolbar_bundle = bundle
|
||||
|
||||
def remove_action(self, name: str):
|
||||
"""
|
||||
Removes a component from the toolbar.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component to remove.
|
||||
"""
|
||||
if not self.exists(name):
|
||||
raise KeyError(f"Action with ID '{name}' does not exist.")
|
||||
action_info = self._components.pop(name)
|
||||
if action_info.toolbar_bundle:
|
||||
action_info.toolbar_bundle.bundle_actions.pop(name, None)
|
||||
self.toolbar.refresh()
|
||||
action_info.toolbar_bundle = None
|
||||
if hasattr(action_info.action, "cleanup"):
|
||||
# Call cleanup if the action has a cleanup method
|
||||
action_info.action.cleanup()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the toolbar components by removing all actions and bundles.
|
||||
"""
|
||||
for action_info in self._components.values():
|
||||
if hasattr(action_info.action, "cleanup"):
|
||||
# Call cleanup if the action has a cleanup method
|
||||
action_info.action.cleanup()
|
||||
self._components.clear()
|
||||
|
||||
|
||||
class ToolbarBundle:
|
||||
def __init__(self, name: str, components: ToolbarComponents):
|
||||
"""
|
||||
Initializes a new bundle component.
|
||||
|
||||
Args:
|
||||
bundle_name (str): Unique identifier for the bundle.
|
||||
"""
|
||||
self.name = name
|
||||
self.components = components
|
||||
self.bundle_actions: DefaultDict[str, ReferenceType[ToolBarAction]] = defaultdict()
|
||||
self._connections: dict[str, BundleConnection] = {}
|
||||
|
||||
def add_action(self, name: str):
|
||||
"""
|
||||
Adds an action to the bundle.
|
||||
|
||||
Args:
|
||||
name (str): Unique identifier for the action.
|
||||
action (ToolBarAction): The action to add.
|
||||
"""
|
||||
if name in self.bundle_actions:
|
||||
raise ValueError(f"Action with name '{name}' already exists in bundle '{self.name}'.")
|
||||
if not self.components.exists(name):
|
||||
raise ValueError(
|
||||
f"Component with name '{name}' does not exist in the toolbar. Please add it first using the `ToolbarComponents.add` method."
|
||||
)
|
||||
self.bundle_actions[name] = self.components.get_action_reference(name)
|
||||
self.components.set_bundle(name, self)
|
||||
|
||||
def remove_action(self, name: str):
|
||||
"""
|
||||
Removes an action from the bundle.
|
||||
|
||||
Args:
|
||||
name (str): The name of the action to remove.
|
||||
"""
|
||||
if name not in self.bundle_actions:
|
||||
raise KeyError(f"Action with name '{name}' does not exist in bundle '{self.name}'.")
|
||||
del self.bundle_actions[name]
|
||||
|
||||
def add_separator(self):
|
||||
"""
|
||||
Adds a separator action to the bundle.
|
||||
"""
|
||||
self.add_action("separator")
|
||||
|
||||
def add_connection(self, name: str, connection):
|
||||
"""
|
||||
Adds a connection to the bundle.
|
||||
|
||||
Args:
|
||||
name (str): Unique identifier for the connection.
|
||||
connection: The connection to add.
|
||||
"""
|
||||
if name in self._connections:
|
||||
raise ValueError(
|
||||
f"Connection with name '{name}' already exists in bundle '{self.name}'."
|
||||
)
|
||||
self._connections[name] = connection
|
||||
|
||||
def remove_connection(self, name: str):
|
||||
"""
|
||||
Removes a connection from the bundle.
|
||||
|
||||
Args:
|
||||
name (str): The name of the connection to remove.
|
||||
"""
|
||||
if name not in self._connections:
|
||||
raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.")
|
||||
self._connections[name].disconnect()
|
||||
del self._connections[name]
|
||||
|
||||
def get_connection(self, name: str):
|
||||
"""
|
||||
Retrieves a connection by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the connection to retrieve.
|
||||
|
||||
Returns:
|
||||
The connection associated with the given name.
|
||||
"""
|
||||
if name not in self._connections:
|
||||
raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.")
|
||||
return self._connections[name]
|
||||
|
||||
def disconnect(self):
|
||||
"""
|
||||
Disconnects all connections in the bundle.
|
||||
"""
|
||||
for connection in self._connections.values():
|
||||
connection.disconnect()
|
||||
self._connections.clear()
|
||||
23
bec_widgets/utils/toolbars/connections.py
Normal file
23
bec_widgets/utils/toolbars/connections.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
|
||||
class BundleConnection(QObject):
|
||||
bundle_name: str
|
||||
|
||||
@abstractmethod
|
||||
def connect(self):
|
||||
"""
|
||||
Connects the bundle to the target widget or application.
|
||||
This method should be implemented by subclasses to define how the bundle interacts with the target.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self):
|
||||
"""
|
||||
Disconnects the bundle from the target widget or application.
|
||||
This method should be implemented by subclasses to define how to clean up connections.
|
||||
"""
|
||||
58
bec_widgets/utils/toolbars/performance.py
Normal file
58
bec_widgets/utils/toolbars/performance.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.toolbars.toolbar import ToolbarComponents
|
||||
|
||||
|
||||
def performance_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a performance toolbar bundle.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The performance toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"fps_monitor",
|
||||
MaterialIconAction(
|
||||
icon_name="speed", tooltip="Show FPS Monitor", checkable=True, parent=components.toolbar
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("performance", components)
|
||||
bundle.add_action("fps_monitor")
|
||||
return bundle
|
||||
|
||||
|
||||
class PerformanceConnection(BundleConnection):
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "performance"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if not hasattr(self.target_widget, "enable_fps_monitor"):
|
||||
raise AttributeError("Target widget must implement 'enable_fps_monitor'.")
|
||||
super().__init__()
|
||||
self._connected = False
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action_reference("fps_monitor")().action.toggled.connect(
|
||||
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action_reference("fps_monitor")().action.toggled.disconnect(
|
||||
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
|
||||
)
|
||||
513
bec_widgets/utils/toolbars/toolbar.py
Normal file
513
bec_widgets/utils/toolbars/toolbar.py
Normal file
@@ -0,0 +1,513 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from typing import DefaultDict, Literal
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QAction, QColor
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_name, set_theme
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# Ensure that icons are shown in menus (especially on macOS)
|
||||
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
|
||||
|
||||
|
||||
class ModularToolBar(QToolBar):
|
||||
"""Modular toolbar with optional automatic initialization.
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
|
||||
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)".
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
orientation: Literal["horizontal", "vertical"] = "horizontal",
|
||||
background_color: str = "rgba(0, 0, 0, 0)",
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.background_color = background_color
|
||||
self.set_background_color(self.background_color)
|
||||
|
||||
# Set the initial orientation
|
||||
self.set_orientation(orientation)
|
||||
|
||||
self.components = ToolbarComponents(self)
|
||||
|
||||
# Initialize bundles
|
||||
self.bundles: dict[str, ToolbarBundle] = {}
|
||||
self.shown_bundles: list[str] = []
|
||||
|
||||
#########################
|
||||
# outdated items... remove
|
||||
self.available_widgets: DefaultDict[str, ToolBarAction] = defaultdict()
|
||||
########################
|
||||
|
||||
def new_bundle(self, name: str) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a new bundle component.
|
||||
|
||||
Args:
|
||||
name (str): Unique identifier for the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The new bundle component.
|
||||
"""
|
||||
if name in self.bundles:
|
||||
raise ValueError(f"Bundle with name '{name}' already exists.")
|
||||
bundle = ToolbarBundle(name=name, components=self.components)
|
||||
self.bundles[name] = bundle
|
||||
return bundle
|
||||
|
||||
def add_bundle(self, bundle: ToolbarBundle):
|
||||
"""
|
||||
Adds a bundle component to the toolbar.
|
||||
|
||||
Args:
|
||||
bundle (ToolbarBundle): The bundle component to add.
|
||||
"""
|
||||
if bundle.name in self.bundles:
|
||||
raise ValueError(f"Bundle with name '{bundle.name}' already exists.")
|
||||
self.bundles[bundle.name] = bundle
|
||||
if not bundle.bundle_actions:
|
||||
logger.warning(f"Bundle '{bundle.name}' has no actions.")
|
||||
|
||||
def remove_bundle(self, name: str):
|
||||
"""
|
||||
Removes a bundle component by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the bundle to remove.
|
||||
"""
|
||||
if name not in self.bundles:
|
||||
raise KeyError(f"Bundle with name '{name}' does not exist.")
|
||||
del self.bundles[name]
|
||||
if name in self.shown_bundles:
|
||||
self.shown_bundles.remove(name)
|
||||
logger.info(f"Bundle '{name}' removed from the toolbar.")
|
||||
|
||||
def get_bundle(self, name: str) -> ToolbarBundle:
|
||||
"""
|
||||
Retrieves a bundle component by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the bundle to retrieve.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The bundle component.
|
||||
"""
|
||||
if name not in self.bundles:
|
||||
raise KeyError(
|
||||
f"Bundle with name '{name}' does not exist. Available bundles: {list(self.bundles.keys())}"
|
||||
)
|
||||
return self.bundles[name]
|
||||
|
||||
def show_bundles(self, bundle_names: list[str]):
|
||||
"""
|
||||
Sets the bundles to be shown for the toolbar.
|
||||
|
||||
Args:
|
||||
bundle_names (list[str]): A list of bundle names to show. If a bundle is not in this list, its actions will be hidden.
|
||||
"""
|
||||
self.clear()
|
||||
for requested_bundle in bundle_names:
|
||||
bundle = self.get_bundle(requested_bundle)
|
||||
for bundle_action in bundle.bundle_actions.values():
|
||||
action = bundle_action()
|
||||
if action is None:
|
||||
logger.warning(
|
||||
f"Action for bundle '{requested_bundle}' has been deleted. Skipping."
|
||||
)
|
||||
continue
|
||||
action.add_to_toolbar(self, self.parent())
|
||||
separator = self.components.get_action_reference("separator")()
|
||||
if separator is not None:
|
||||
separator.add_to_toolbar(self, self.parent())
|
||||
self.update_separators() # Ensure separators are updated after showing bundles
|
||||
self.shown_bundles = bundle_names
|
||||
|
||||
def add_action(self, action_name: str, action: ToolBarAction):
|
||||
"""
|
||||
Adds a single action to the toolbar. It will create a new bundle
|
||||
with the same name as the action.
|
||||
|
||||
Args:
|
||||
action_name (str): Unique identifier for the action.
|
||||
action (ToolBarAction): The action to add.
|
||||
"""
|
||||
self.components.add_safe(action_name, action)
|
||||
bundle = ToolbarBundle(name=action_name, components=self.components)
|
||||
bundle.add_action(action_name)
|
||||
self.add_bundle(bundle)
|
||||
|
||||
def hide_action(self, action_name: str):
|
||||
"""
|
||||
Hides a specific action in the toolbar.
|
||||
|
||||
Args:
|
||||
action_name (str): Unique identifier for the action to hide.
|
||||
"""
|
||||
action = self.components.get_action(action_name)
|
||||
if hasattr(action, "action") and action.action is not None:
|
||||
action.action.setVisible(False)
|
||||
self.update_separators()
|
||||
|
||||
def show_action(self, action_name: str):
|
||||
"""
|
||||
Shows a specific action in the toolbar.
|
||||
|
||||
Args:
|
||||
action_name (str): Unique identifier for the action to show.
|
||||
"""
|
||||
action = self.components.get_action(action_name)
|
||||
if hasattr(action, "action") and action.action is not None:
|
||||
action.action.setVisible(True)
|
||||
self.update_separators()
|
||||
|
||||
@property
|
||||
def toolbar_actions(self) -> list[ToolBarAction]:
|
||||
"""
|
||||
Returns a list of all actions currently in the toolbar.
|
||||
|
||||
Returns:
|
||||
list[ToolBarAction]: List of actions in the toolbar.
|
||||
"""
|
||||
actions = []
|
||||
for bundle in self.shown_bundles:
|
||||
if bundle not in self.bundles:
|
||||
continue
|
||||
for action in self.bundles[bundle].bundle_actions.values():
|
||||
action_instance = action()
|
||||
if action_instance is not None:
|
||||
actions.append(action_instance)
|
||||
return actions
|
||||
|
||||
def refresh(self):
|
||||
"""Refreshes the toolbar by clearing and re-populating it."""
|
||||
self.clear()
|
||||
self.show_bundles(self.shown_bundles)
|
||||
|
||||
def connect_bundle(self, connection_name: str, connector: BundleConnection):
|
||||
"""
|
||||
Connects a bundle to a target widget or application.
|
||||
|
||||
Args:
|
||||
bundle_name (str): The name of the bundle to connect.
|
||||
connector (BundleConnection): The connector instance that implements the connection logic.
|
||||
"""
|
||||
bundle_name = connector.bundle_name
|
||||
if bundle_name not in self.bundles:
|
||||
raise KeyError(f"Bundle with name '{bundle_name}' does not exist.")
|
||||
connector.connect()
|
||||
self.bundles[bundle_name].add_connection(connection_name, connector)
|
||||
|
||||
def disconnect_bundle(self, bundle_name: str, connection_name: str | None = None):
|
||||
"""
|
||||
Disconnects a bundle connection.
|
||||
|
||||
Args:
|
||||
bundle_name (str): The name of the bundle to disconnect.
|
||||
connection_name (str): The name of the connection to disconnect. If None, disconnects all connections for the bundle.
|
||||
"""
|
||||
if bundle_name not in self.bundles:
|
||||
raise KeyError(f"Bundle with name '{bundle_name}' does not exist.")
|
||||
bundle = self.bundles[bundle_name]
|
||||
if connection_name is None:
|
||||
# Disconnect all connections in the bundle
|
||||
bundle.disconnect()
|
||||
else:
|
||||
bundle.remove_connection(name=connection_name)
|
||||
|
||||
def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"):
|
||||
"""
|
||||
Sets the background color and other appearance settings.
|
||||
|
||||
Args:
|
||||
color (str): The background color of the toolbar.
|
||||
"""
|
||||
self.setIconSize(QSize(20, 20))
|
||||
self.setMovable(False)
|
||||
self.setFloatable(False)
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self.background_color = color
|
||||
self.setStyleSheet(f"QToolBar {{ background-color: {color}; border: none; }}")
|
||||
|
||||
def set_orientation(self, orientation: Literal["horizontal", "vertical"]):
|
||||
"""Sets the orientation of the toolbar.
|
||||
|
||||
Args:
|
||||
orientation (Literal["horizontal", "vertical"]): The desired orientation of the toolbar.
|
||||
"""
|
||||
if orientation == "horizontal":
|
||||
self.setOrientation(Qt.Horizontal)
|
||||
elif orientation == "vertical":
|
||||
self.setOrientation(Qt.Vertical)
|
||||
else:
|
||||
raise ValueError("Orientation must be 'horizontal' or 'vertical'.")
|
||||
|
||||
def update_material_icon_colors(self, new_color: str | tuple | QColor):
|
||||
"""
|
||||
Updates the color of all MaterialIconAction icons.
|
||||
|
||||
Args:
|
||||
new_color (str | tuple | QColor): The new color.
|
||||
"""
|
||||
for action in self.available_widgets.values():
|
||||
if isinstance(action, MaterialIconAction):
|
||||
action.color = new_color
|
||||
updated_icon = action.get_icon()
|
||||
action.action.setIcon(updated_icon)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
"""
|
||||
Overrides the context menu event to show toolbar actions with checkboxes and icons.
|
||||
|
||||
Args:
|
||||
event (QContextMenuEvent): The context menu event.
|
||||
"""
|
||||
menu = QMenu(self)
|
||||
theme = get_theme_name()
|
||||
if theme == "dark":
|
||||
menu.setStyleSheet(
|
||||
"""
|
||||
QMenu {
|
||||
background-color: rgba(50, 50, 50, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: rgba(0, 0, 255, 0.2);
|
||||
}
|
||||
"""
|
||||
)
|
||||
else:
|
||||
# Light theme styling
|
||||
menu.setStyleSheet(
|
||||
"""
|
||||
QMenu {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: rgba(0, 0, 255, 0.2);
|
||||
}
|
||||
"""
|
||||
)
|
||||
for ii, bundle in enumerate(self.shown_bundles):
|
||||
self.handle_bundle_context_menu(menu, bundle)
|
||||
if ii < len(self.shown_bundles) - 1:
|
||||
menu.addSeparator()
|
||||
menu.triggered.connect(self.handle_menu_triggered)
|
||||
menu.exec_(event.globalPos())
|
||||
|
||||
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
|
||||
"""
|
||||
Adds bundle actions to the context menu.
|
||||
|
||||
Args:
|
||||
menu (QMenu): The context menu.
|
||||
bundle_id (str): The bundle identifier.
|
||||
"""
|
||||
bundle = self.bundles.get(bundle_id)
|
||||
if not bundle:
|
||||
return
|
||||
for act_id in bundle.bundle_actions:
|
||||
toolbar_action = self.components.get_action(act_id)
|
||||
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(
|
||||
toolbar_action, "action"
|
||||
):
|
||||
continue
|
||||
qaction = toolbar_action.action
|
||||
if not isinstance(qaction, QAction):
|
||||
continue
|
||||
self._add_qaction_to_menu(menu, qaction, toolbar_action, act_id)
|
||||
|
||||
def _add_qaction_to_menu(
|
||||
self, menu: QMenu, qaction: QAction, toolbar_action: ToolBarAction, act_id: str
|
||||
):
|
||||
display_name = qaction.text() or toolbar_action.tooltip or act_id
|
||||
menu_action = QAction(display_name, self)
|
||||
menu_action.setCheckable(True)
|
||||
menu_action.setChecked(qaction.isVisible())
|
||||
menu_action.setData(act_id) # Store the action_id
|
||||
|
||||
# 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):
|
||||
"""
|
||||
Adds a single toolbar action to the context menu.
|
||||
|
||||
Args:
|
||||
menu (QMenu): The context menu to which the action is added.
|
||||
action_id (str): Unique identifier for the action.
|
||||
"""
|
||||
toolbar_action = self.available_widgets.get(action_id)
|
||||
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(toolbar_action, "action"):
|
||||
return
|
||||
qaction = toolbar_action.action
|
||||
if not isinstance(qaction, QAction):
|
||||
return
|
||||
display_name = qaction.text() or toolbar_action.tooltip or action_id
|
||||
menu_action = QAction(display_name, self)
|
||||
menu_action.setCheckable(True)
|
||||
menu_action.setChecked(qaction.isVisible())
|
||||
menu_action.setIconVisibleInMenu(True)
|
||||
menu_action.setData(action_id) # Store the action_id
|
||||
|
||||
# Set the icon if available
|
||||
if qaction.icon() and not qaction.icon().isNull():
|
||||
menu_action.setIcon(qaction.icon())
|
||||
|
||||
menu.addAction(menu_action)
|
||||
|
||||
def handle_menu_triggered(self, action):
|
||||
"""
|
||||
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)
|
||||
|
||||
def toggle_action_visibility(self, action_id: str, visible: bool | None = None):
|
||||
"""
|
||||
Toggles the visibility of a specific action.
|
||||
|
||||
Args:
|
||||
action_id (str): Unique identifier.
|
||||
visible (bool): Whether the action should be visible. If None, toggles the current visibility.
|
||||
"""
|
||||
if not self.components.exists(action_id):
|
||||
return
|
||||
tool_action = self.components.get_action(action_id)
|
||||
if hasattr(tool_action, "action") and tool_action.action is not None:
|
||||
if visible is None:
|
||||
visible = not tool_action.action.isVisible()
|
||||
tool_action.action.setVisible(visible)
|
||||
self.update_separators()
|
||||
|
||||
def update_separators(self):
|
||||
"""
|
||||
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
|
||||
prev_visible = None
|
||||
for j in range(i - 1, -1, -1):
|
||||
if toolbar_actions[j].isVisible():
|
||||
prev_visible = toolbar_actions[j]
|
||||
break
|
||||
next_visible = None
|
||||
for j in range(i + 1, len(toolbar_actions)):
|
||||
if toolbar_actions[j].isVisible():
|
||||
next_visible = toolbar_actions[j]
|
||||
break
|
||||
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
|
||||
|
||||
if not toolbar_actions:
|
||||
return
|
||||
|
||||
# Make sure the first visible action is not a separator
|
||||
for i, action in enumerate(toolbar_actions):
|
||||
if action.isVisible():
|
||||
if action.isSeparator():
|
||||
action.setVisible(False)
|
||||
break
|
||||
|
||||
# Make sure the last visible action is not a separator
|
||||
for i, action in enumerate(reversed(toolbar_actions)):
|
||||
if action.isVisible():
|
||||
if action.isSeparator():
|
||||
action.setVisible(False)
|
||||
break
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the toolbar by removing all actions and bundles.
|
||||
"""
|
||||
# First, disconnect all bundles
|
||||
for bundle_name in list(self.bundles.keys()):
|
||||
self.disconnect_bundle(bundle_name)
|
||||
|
||||
# Clear all components
|
||||
self.components.cleanup()
|
||||
self.bundles.clear()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle
|
||||
from bec_widgets.widgets.plots.toolbar_components.plot_export import plot_export_bundle
|
||||
|
||||
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)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.addToolBar(self.toolbar)
|
||||
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
|
||||
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
|
||||
self.toolbar.connect_bundle(
|
||||
"base", PerformanceConnection(self.toolbar.components, self)
|
||||
)
|
||||
self.toolbar.show_bundles(["performance", "plot_export"])
|
||||
self.toolbar.get_bundle("performance").add_action("save")
|
||||
self.toolbar.refresh()
|
||||
|
||||
def enable_fps_monitor(self, enabled: bool):
|
||||
"""
|
||||
Example method to enable or disable FPS monitoring.
|
||||
This method should be implemented in the target widget.
|
||||
"""
|
||||
if enabled:
|
||||
self.test_label.setText("FPS Monitor Enabled")
|
||||
else:
|
||||
self.test_label.setText("FPS Monitor Disabled")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
main_window = MainWindow()
|
||||
main_window.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -15,12 +15,13 @@ from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
from bec_widgets.utils.toolbar import (
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
ExpandableMenuAction,
|
||||
MaterialIconAction,
|
||||
ModularToolBar,
|
||||
SeparatorAction,
|
||||
WidgetAction,
|
||||
)
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
@@ -104,145 +105,227 @@ class BECDockArea(BECWidget, QWidget):
|
||||
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self.dock_area = DockArea(parent=self)
|
||||
self.toolbar = ModularToolBar(
|
||||
parent=self,
|
||||
actions={
|
||||
"menu_plots": ExpandableMenuAction(
|
||||
label="Add Plot ",
|
||||
actions={
|
||||
"waveform": MaterialIconAction(
|
||||
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
|
||||
),
|
||||
"scatter_waveform": MaterialIconAction(
|
||||
icon_name=ScatterWaveform.ICON_NAME,
|
||||
tooltip="Add Scatter Waveform",
|
||||
filled=True,
|
||||
),
|
||||
"multi_waveform": MaterialIconAction(
|
||||
icon_name=MultiWaveform.ICON_NAME,
|
||||
tooltip="Add Multi Waveform",
|
||||
filled=True,
|
||||
),
|
||||
"image": MaterialIconAction(
|
||||
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True
|
||||
),
|
||||
"motor_map": MaterialIconAction(
|
||||
icon_name=MotorMap.ICON_NAME, tooltip="Add Motor Map", filled=True
|
||||
),
|
||||
},
|
||||
),
|
||||
"separator_0": SeparatorAction(),
|
||||
"menu_devices": ExpandableMenuAction(
|
||||
label="Add Device Control ",
|
||||
actions={
|
||||
"scan_control": MaterialIconAction(
|
||||
icon_name=ScanControl.ICON_NAME, tooltip="Add Scan Control", filled=True
|
||||
),
|
||||
"positioner_box": MaterialIconAction(
|
||||
icon_name=PositionerBox.ICON_NAME, tooltip="Add Device Box", filled=True
|
||||
),
|
||||
},
|
||||
),
|
||||
"separator_1": SeparatorAction(),
|
||||
"menu_utils": ExpandableMenuAction(
|
||||
label="Add Utils ",
|
||||
actions={
|
||||
"queue": MaterialIconAction(
|
||||
icon_name=BECQueue.ICON_NAME, tooltip="Add Scan Queue", filled=True
|
||||
),
|
||||
"vs_code": MaterialIconAction(
|
||||
icon_name=VSCodeEditor.ICON_NAME, tooltip="Add VS Code", filled=True
|
||||
),
|
||||
"status": MaterialIconAction(
|
||||
icon_name=BECStatusBox.ICON_NAME,
|
||||
tooltip="Add BEC Status Box",
|
||||
filled=True,
|
||||
),
|
||||
"progress_bar": MaterialIconAction(
|
||||
icon_name=RingProgressBar.ICON_NAME,
|
||||
tooltip="Add Circular ProgressBar",
|
||||
filled=True,
|
||||
),
|
||||
# FIXME temporarily disabled -> issue #644
|
||||
"log_panel": MaterialIconAction(
|
||||
icon_name=LogPanel.ICON_NAME,
|
||||
tooltip="Add LogPanel - Disabled",
|
||||
filled=True,
|
||||
),
|
||||
},
|
||||
),
|
||||
"separator_2": SeparatorAction(),
|
||||
"attach_all": MaterialIconAction(
|
||||
icon_name="zoom_in_map", tooltip="Attach all floating docks"
|
||||
),
|
||||
"save_state": MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State"),
|
||||
"restore_state": MaterialIconAction(
|
||||
icon_name="frame_reload", tooltip="Restore Dock State"
|
||||
),
|
||||
},
|
||||
target_widget=self,
|
||||
)
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self._setup_toolbar()
|
||||
|
||||
self.layout.addWidget(self.toolbar)
|
||||
self.layout.addWidget(self.dock_area)
|
||||
self.spacer = QWidget(parent=self)
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
self.toolbar.addWidget(self.dark_mode_button)
|
||||
|
||||
self._hook_toolbar()
|
||||
self.toolbar.show_bundles(
|
||||
["menu_plots", "menu_devices", "menu_utils", "dock_actions", "dark_mode"]
|
||||
)
|
||||
|
||||
def minimumSizeHint(self):
|
||||
return QSize(800, 600)
|
||||
|
||||
def _setup_toolbar(self):
|
||||
|
||||
# Add plot menu
|
||||
self.toolbar.components.add_safe(
|
||||
"menu_plots",
|
||||
ExpandableMenuAction(
|
||||
label="Add Plot ",
|
||||
actions={
|
||||
"waveform": MaterialIconAction(
|
||||
icon_name=Waveform.ICON_NAME,
|
||||
tooltip="Add Waveform",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"scatter_waveform": MaterialIconAction(
|
||||
icon_name=ScatterWaveform.ICON_NAME,
|
||||
tooltip="Add Scatter Waveform",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"multi_waveform": MaterialIconAction(
|
||||
icon_name=MultiWaveform.ICON_NAME,
|
||||
tooltip="Add Multi Waveform",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"image": MaterialIconAction(
|
||||
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True, parent=self
|
||||
),
|
||||
"motor_map": MaterialIconAction(
|
||||
icon_name=MotorMap.ICON_NAME,
|
||||
tooltip="Add Motor Map",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("menu_plots", self.toolbar.components)
|
||||
bundle.add_action("menu_plots")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
# Add control menu
|
||||
self.toolbar.components.add_safe(
|
||||
"menu_devices",
|
||||
ExpandableMenuAction(
|
||||
label="Add Device Control ",
|
||||
actions={
|
||||
"scan_control": MaterialIconAction(
|
||||
icon_name=ScanControl.ICON_NAME,
|
||||
tooltip="Add Scan Control",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"positioner_box": MaterialIconAction(
|
||||
icon_name=PositionerBox.ICON_NAME,
|
||||
tooltip="Add Device Box",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("menu_devices", self.toolbar.components)
|
||||
bundle.add_action("menu_devices")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
# Add utils menu
|
||||
self.toolbar.components.add_safe(
|
||||
"menu_utils",
|
||||
ExpandableMenuAction(
|
||||
label="Add Utils ",
|
||||
actions={
|
||||
"queue": MaterialIconAction(
|
||||
icon_name=BECQueue.ICON_NAME,
|
||||
tooltip="Add Scan Queue",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"vs_code": MaterialIconAction(
|
||||
icon_name=VSCodeEditor.ICON_NAME,
|
||||
tooltip="Add VS Code",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"status": MaterialIconAction(
|
||||
icon_name=BECStatusBox.ICON_NAME,
|
||||
tooltip="Add BEC Status Box",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"progress_bar": MaterialIconAction(
|
||||
icon_name=RingProgressBar.ICON_NAME,
|
||||
tooltip="Add Circular ProgressBar",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
# FIXME temporarily disabled -> issue #644
|
||||
"log_panel": MaterialIconAction(
|
||||
icon_name=LogPanel.ICON_NAME,
|
||||
tooltip="Add LogPanel - Disabled",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"sbb_monitor": MaterialIconAction(
|
||||
icon_name="train", tooltip="Add SBB Monitor", filled=True, parent=self
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("menu_utils", self.toolbar.components)
|
||||
bundle.add_action("menu_utils")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
########## Dock Actions ##########
|
||||
spacer = QWidget(parent=self)
|
||||
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False)
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("dark_mode", self.toolbar.components)
|
||||
bundle.add_action("spacer")
|
||||
bundle.add_action("dark_mode")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"attach_all",
|
||||
MaterialIconAction(
|
||||
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
|
||||
),
|
||||
)
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"save_state",
|
||||
MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State", parent=self),
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"restore_state",
|
||||
MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self),
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("dock_actions", self.toolbar.components)
|
||||
bundle.add_action("attach_all")
|
||||
bundle.add_action("save_state")
|
||||
bundle.add_action("restore_state")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
def _hook_toolbar(self):
|
||||
# Menu Plot
|
||||
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
|
||||
menu_plots = self.toolbar.components.get_action("menu_plots")
|
||||
menu_devices = self.toolbar.components.get_action("menu_devices")
|
||||
menu_utils = self.toolbar.components.get_action("menu_utils")
|
||||
|
||||
menu_plots.actions["waveform"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].triggered.connect(
|
||||
|
||||
menu_plots.actions["scatter_waveform"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
|
||||
menu_plots.actions["multi_waveform"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
|
||||
menu_plots.actions["image"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Image")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
|
||||
menu_plots.actions["motor_map"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
|
||||
)
|
||||
|
||||
# Menu Devices
|
||||
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
|
||||
menu_devices.actions["scan_control"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
|
||||
)
|
||||
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
|
||||
menu_devices.actions["positioner_box"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
|
||||
)
|
||||
|
||||
# Menu Utils
|
||||
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
|
||||
menu_utils.actions["queue"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
|
||||
menu_utils.actions["status"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
|
||||
menu_utils.actions["vs_code"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
|
||||
menu_utils.actions["progress_bar"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
|
||||
)
|
||||
# FIXME temporarily disabled -> issue #644
|
||||
self.toolbar.widgets["menu_utils"].widgets["log_panel"].setEnabled(False)
|
||||
# self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
|
||||
# lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
|
||||
# )
|
||||
menu_utils.actions["log_panel"].action.setEnabled(False)
|
||||
|
||||
menu_utils.actions["sbb_monitor"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor")
|
||||
)
|
||||
|
||||
# Icons
|
||||
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)
|
||||
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
|
||||
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
|
||||
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
|
||||
self.toolbar.components.get_action("save_state").action.triggered.connect(self.save_state)
|
||||
self.toolbar.components.get_action("restore_state").action.triggered.connect(
|
||||
self.restore_state
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _create_widget_from_toolbar(self, widget_name: str) -> None:
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import sys
|
||||
|
||||
from qtpy.QtCore import QPoint, Qt
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class WidgetTooltip(QWidget):
|
||||
"""Frameless, always-on-top window that behaves like a tooltip."""
|
||||
|
||||
def __init__(self, content: QWidget) -> None:
|
||||
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
self.setMouseTracking(True)
|
||||
self.content = content
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(6, 6, 6, 6)
|
||||
layout.addWidget(self.content)
|
||||
self.adjustSize()
|
||||
|
||||
def leaveEvent(self, _event) -> None:
|
||||
self.hide()
|
||||
|
||||
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
|
||||
self.adjustSize()
|
||||
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
|
||||
screen_geo = screen.availableGeometry()
|
||||
geom = self.geometry()
|
||||
|
||||
x = global_pos.x() - geom.width() // 2
|
||||
y = global_pos.y() - geom.height() - offset
|
||||
|
||||
x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
|
||||
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
|
||||
|
||||
self.move(x, y)
|
||||
self.show()
|
||||
|
||||
|
||||
class HoverWidget(QWidget):
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, *, simple: QWidget, full: QWidget):
|
||||
super().__init__(parent)
|
||||
self._simple = simple
|
||||
self._full = full
|
||||
self._full.setVisible(False)
|
||||
self._tooltip = None
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addWidget(simple)
|
||||
|
||||
def enterEvent(self, event):
|
||||
# suppress empty-label tooltips for labels
|
||||
if isinstance(self._full, QLabel) and not self._full.text():
|
||||
return
|
||||
|
||||
if self._tooltip is None: # first time only
|
||||
self._tooltip = WidgetTooltip(self._full)
|
||||
self._full.setVisible(True)
|
||||
|
||||
centre = self.mapToGlobal(self.rect().center())
|
||||
self._tooltip.show_above(centre)
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
if self._tooltip and self._tooltip.isVisible():
|
||||
self._tooltip.hide()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def close(self):
|
||||
if self._tooltip:
|
||||
self._tooltip.close()
|
||||
self._tooltip.deleteLater()
|
||||
self._tooltip = None
|
||||
super().close()
|
||||
|
||||
|
||||
################################################################################
|
||||
# Demo
|
||||
# Just a simple example to show how the HoverWidget can be used to display
|
||||
# a tooltip with a full widget inside (two different widgets are used
|
||||
# for the simple and full versions).
|
||||
################################################################################
|
||||
|
||||
|
||||
class DemoSimpleWidget(QLabel): # pragma: no cover
|
||||
"""A simple widget to be used as a trigger for the tooltip."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setText("Hover me for a preview!")
|
||||
|
||||
|
||||
class DemoFullWidget(QProgressBar): # pragma: no cover
|
||||
"""A full widget to be shown in the tooltip."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setRange(0, 100)
|
||||
self.setValue(75)
|
||||
self.setFixedWidth(320)
|
||||
self.setFixedHeight(30)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
window = QWidget()
|
||||
window.layout = QHBoxLayout(window)
|
||||
hover_widget = HoverWidget(simple=DemoSimpleWidget(), full=DemoFullWidget())
|
||||
window.layout.addWidget(hover_widget)
|
||||
window.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
@@ -25,17 +25,29 @@ class ScrollLabel(QLabel):
|
||||
self._step_px = step_px
|
||||
|
||||
def setText(self, text):
|
||||
"""
|
||||
Overridden to ensure that new text replaces the current one
|
||||
immediately.
|
||||
If the label was already scrolling (or in its delay phase),
|
||||
the next message starts **without** the extra delay.
|
||||
"""
|
||||
# Determine whether the widget was already in a scrolling cycle
|
||||
was_scrolling = self._timer.isActive() or self._delay_timer.isActive()
|
||||
|
||||
super().setText(text)
|
||||
|
||||
fm = QFontMetrics(self.font())
|
||||
self._text_width = fm.horizontalAdvance(text)
|
||||
self._offset = 0
|
||||
self._update_timer()
|
||||
|
||||
# Skip the delay when we were already scrolling
|
||||
self._update_timer(skip_delay=was_scrolling)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._update_timer()
|
||||
|
||||
def _update_timer(self):
|
||||
def _update_timer(self, *, skip_delay: bool = False):
|
||||
"""
|
||||
Decide whether to start or stop scrolling.
|
||||
|
||||
@@ -46,10 +58,19 @@ class ScrollLabel(QLabel):
|
||||
needs_scroll = self._text_width > self.width()
|
||||
|
||||
if needs_scroll:
|
||||
# Reset any running timers
|
||||
if self._timer.isActive():
|
||||
self._timer.stop()
|
||||
if self._delay_timer.isActive():
|
||||
self._delay_timer.stop()
|
||||
|
||||
self._offset = 0
|
||||
if not self._delay_timer.isActive():
|
||||
|
||||
# Start scrolling immediately when we should skip the delay,
|
||||
# otherwise apply the configured delay_ms interval
|
||||
if skip_delay:
|
||||
self._timer.start()
|
||||
else:
|
||||
self._delay_timer.start()
|
||||
else:
|
||||
if self._delay_timer.isActive():
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import QEvent, QSize, Qt
|
||||
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
||||
from qtpy.QtWidgets import QApplication, QFrame, QLabel, QMainWindow, QStyle, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QStyle,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils import UILoader
|
||||
@@ -11,8 +22,10 @@ from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
|
||||
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
|
||||
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
|
||||
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
@@ -20,6 +33,8 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
class BECMainWindow(BECWidget, QMainWindow):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
SCAN_PROGRESS_WIDTH = 100 # px
|
||||
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -33,6 +48,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
super().__init__(parent=parent, gui_id=gui_id, **kwargs)
|
||||
|
||||
self.app = QApplication.instance()
|
||||
self.status_bar = self.statusBar()
|
||||
self.setWindowTitle(window_title)
|
||||
self._init_ui()
|
||||
self._connect_to_theme_change()
|
||||
@@ -61,28 +77,143 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
"""
|
||||
Prepare the BEC specific widgets in the status bar.
|
||||
"""
|
||||
status_bar = self.statusBar()
|
||||
|
||||
# Left: App‑ID label
|
||||
self._app_id_label = QLabel()
|
||||
self._app_id_label.setAlignment(
|
||||
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
status_bar.addWidget(self._app_id_label)
|
||||
self.status_bar.addWidget(self._app_id_label)
|
||||
|
||||
# Add a separator after the app ID label
|
||||
self._add_separator()
|
||||
|
||||
# Centre: Client‑info label (stretch=1 so it expands)
|
||||
self._client_info_label = ScrollLabel()
|
||||
self._add_client_info_label()
|
||||
|
||||
# Add scan_progress bar with display logic
|
||||
self._add_scan_progress_bar()
|
||||
|
||||
################################################################################
|
||||
# Client message status bar widget helpers
|
||||
|
||||
def _add_client_info_label(self):
|
||||
"""
|
||||
Add a client info label to the status bar.
|
||||
This label will display messages from the BEC dispatcher.
|
||||
"""
|
||||
|
||||
# Scroll label for client info in Status Bar
|
||||
self._client_info_label = ScrollLabel(self)
|
||||
self._client_info_label.setAlignment(
|
||||
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
status_bar.addWidget(self._client_info_label, 1)
|
||||
# Full label used in the hover widget
|
||||
self._client_info_label_full = QLabel(self)
|
||||
self._client_info_label_full.setAlignment(
|
||||
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
# Hover widget to show the full client info label
|
||||
self._client_info_hover = HoverWidget(
|
||||
self, simple=self._client_info_label, full=self._client_info_label_full
|
||||
)
|
||||
self.status_bar.addWidget(self._client_info_hover, 1)
|
||||
|
||||
def _add_separator(self):
|
||||
# Timer to automatically clear client messages once they expire
|
||||
self._client_info_expire_timer = QTimer(self)
|
||||
self._client_info_expire_timer.setSingleShot(True)
|
||||
self._client_info_expire_timer.timeout.connect(lambda: self._client_info_label.setText(""))
|
||||
self._client_info_expire_timer.timeout.connect(
|
||||
lambda: self._client_info_label_full.setText("")
|
||||
)
|
||||
|
||||
################################################################################
|
||||
# Progress‑bar helpers
|
||||
def _add_scan_progress_bar(self):
|
||||
|
||||
# Setting HoverWidget for the scan progress bar - minimal and full version
|
||||
self._scan_progress_bar_simple = ScanProgressBar(self, one_line_design=True)
|
||||
self._scan_progress_bar_simple.show_elapsed_time = False
|
||||
self._scan_progress_bar_simple.show_remaining_time = False
|
||||
self._scan_progress_bar_simple.show_source_label = False
|
||||
self._scan_progress_bar_simple.progressbar.label_template = ""
|
||||
self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
|
||||
self._scan_progress_bar_full = ScanProgressBar(self)
|
||||
self._scan_progress_hover = HoverWidget(
|
||||
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
|
||||
)
|
||||
|
||||
# Bundle the progress bar with a separator
|
||||
separator = self._add_separator(separate_object=True)
|
||||
self._scan_progress_bar_with_separator = QWidget()
|
||||
self._scan_progress_bar_with_separator.layout = QHBoxLayout(
|
||||
self._scan_progress_bar_with_separator
|
||||
)
|
||||
self._scan_progress_bar_with_separator.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._scan_progress_bar_with_separator.layout.setSpacing(0)
|
||||
self._scan_progress_bar_with_separator.layout.addWidget(separator)
|
||||
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
|
||||
|
||||
# Set Size
|
||||
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
|
||||
self._scan_progress_bar_with_separator.setMaximumWidth(self._scan_progress_bar_target_width)
|
||||
|
||||
self.status_bar.addWidget(self._scan_progress_bar_with_separator)
|
||||
|
||||
# Visibility logic
|
||||
self._scan_progress_bar_with_separator.hide()
|
||||
self._scan_progress_bar_with_separator.setMaximumWidth(0)
|
||||
|
||||
# Timer for hiding logic
|
||||
self._scan_progress_hide_timer = QTimer(self)
|
||||
self._scan_progress_hide_timer.setSingleShot(True)
|
||||
self._scan_progress_hide_timer.setInterval(self.STATUS_BAR_WIDGETS_EXPIRE_TIME)
|
||||
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
|
||||
|
||||
# Show / hide behaviour
|
||||
self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar)
|
||||
self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar)
|
||||
|
||||
def _show_scan_progress_bar(self):
|
||||
if self._scan_progress_hide_timer.isActive():
|
||||
self._scan_progress_hide_timer.stop()
|
||||
if self._scan_progress_bar_with_separator.isVisible():
|
||||
return
|
||||
|
||||
# Make visible and reset width
|
||||
self._scan_progress_bar_with_separator.show()
|
||||
self._scan_progress_bar_with_separator.setMaximumWidth(0)
|
||||
|
||||
self._show_container_anim = QPropertyAnimation(
|
||||
self._scan_progress_bar_with_separator, b"maximumWidth", self
|
||||
)
|
||||
self._show_container_anim.setDuration(300)
|
||||
self._show_container_anim.setStartValue(0)
|
||||
self._show_container_anim.setEndValue(self._scan_progress_bar_target_width)
|
||||
self._show_container_anim.setEasingCurve(QEasingCurve.OutCubic)
|
||||
self._show_container_anim.start()
|
||||
|
||||
def _delay_hide_scan_progress_bar(self):
|
||||
"""Start the countdown to hide the scan progress bar."""
|
||||
if hasattr(self, "_scan_progress_hide_timer"):
|
||||
self._scan_progress_hide_timer.start()
|
||||
|
||||
def _animate_hide_scan_progress_bar(self):
|
||||
"""Shrink container to the right, then hide."""
|
||||
self._hide_container_anim = QPropertyAnimation(
|
||||
self._scan_progress_bar_with_separator, b"maximumWidth", self
|
||||
)
|
||||
self._hide_container_anim.setDuration(300)
|
||||
self._hide_container_anim.setStartValue(self._scan_progress_bar_with_separator.width())
|
||||
self._hide_container_anim.setEndValue(0)
|
||||
self._hide_container_anim.setEasingCurve(QEasingCurve.InCubic)
|
||||
self._hide_container_anim.finished.connect(self._scan_progress_bar_with_separator.hide)
|
||||
self._hide_container_anim.start()
|
||||
|
||||
def _add_separator(self, separate_object: bool = False) -> QWidget | None:
|
||||
"""
|
||||
Add a vertically centred separator to the status bar.
|
||||
Add a vertically centred separator to the status bar or just return it as a separate object.
|
||||
"""
|
||||
status_bar = self.statusBar()
|
||||
|
||||
@@ -101,6 +232,8 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
vbox.addStretch()
|
||||
wrapper.setFixedWidth(line.sizeHint().width())
|
||||
|
||||
if separate_object:
|
||||
return wrapper
|
||||
status_bar.addWidget(wrapper)
|
||||
|
||||
def _init_bec_icon(self):
|
||||
@@ -222,8 +355,23 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def display_client_message(self, msg: dict, meta: dict):
|
||||
"""
|
||||
Display a client message in the status bar.
|
||||
|
||||
Args:
|
||||
msg(dict): The message to display, should contain:
|
||||
meta(dict): Metadata about the message, usually empty.
|
||||
"""
|
||||
message = msg.get("message", "")
|
||||
expiration = msg.get("expire", 0) # 0 → never expire
|
||||
self._client_info_label.setText(message)
|
||||
self._client_info_label_full.setText(message)
|
||||
|
||||
# Restart the expiration timer if necessary
|
||||
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
|
||||
self._client_info_expire_timer.stop()
|
||||
if expiration and expiration > 0:
|
||||
self._client_info_expire_timer.start(int(expiration * 1000))
|
||||
|
||||
################################################################################
|
||||
# General and Cleanup Methods
|
||||
@@ -259,10 +407,38 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
child.close()
|
||||
child.deleteLater()
|
||||
|
||||
# Timer cleanup
|
||||
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
|
||||
self._client_info_expire_timer.stop()
|
||||
if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
|
||||
self._scan_progress_hide_timer.stop()
|
||||
|
||||
########################################
|
||||
# Status bar widgets cleanup
|
||||
|
||||
# Client info label cleanup
|
||||
self._client_info_label.cleanup()
|
||||
self._client_info_hover.close()
|
||||
self._client_info_hover.deleteLater()
|
||||
# Scan progress bar cleanup
|
||||
self._scan_progress_bar_simple.close()
|
||||
self._scan_progress_bar_simple.deleteLater()
|
||||
self._scan_progress_bar_full.close()
|
||||
self._scan_progress_bar_full.deleteLater()
|
||||
self._scan_progress_hover.close()
|
||||
self._scan_progress_hover.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
class UILaunchWindow(BECMainWindow):
|
||||
RPC = True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
main_window = UILaunchWindow()
|
||||
main_window.show()
|
||||
main_window.resize(800, 600)
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -6,10 +6,10 @@ from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
|
||||
from bec_lib.device import Signal as BECSignal
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import field_validator
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.filter_io import FilterIO
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
@@ -100,7 +100,7 @@ class DeviceInputBase(BECWidget):
|
||||
|
||||
### QtSlots ###
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def set_device(self, device: str):
|
||||
"""
|
||||
Set the device.
|
||||
@@ -114,7 +114,7 @@ class DeviceInputBase(BECWidget):
|
||||
else:
|
||||
logger.warning(f"Device {device} is not in the filtered selection.")
|
||||
|
||||
@Slot()
|
||||
@SafeSlot()
|
||||
def update_devices_from_filters(self):
|
||||
"""Update the devices based on the current filter selection
|
||||
in self.device_filter and self.readout_filter. If apply_filter is False,
|
||||
@@ -133,7 +133,7 @@ class DeviceInputBase(BECWidget):
|
||||
self.devices = [device.name for device in devs]
|
||||
self.set_device(current_device)
|
||||
|
||||
@Slot(list)
|
||||
@SafeSlot(list)
|
||||
def set_available_devices(self, devices: list[str]):
|
||||
"""
|
||||
Set the devices. If a device in the list is not valid, it will not be considered.
|
||||
@@ -146,7 +146,7 @@ class DeviceInputBase(BECWidget):
|
||||
|
||||
### QtProperties ###
|
||||
|
||||
@Property(
|
||||
@SafeProperty(
|
||||
"QStringList",
|
||||
doc="List of devices. If updated, it will disable the apply filters property.",
|
||||
)
|
||||
@@ -165,7 +165,7 @@ class DeviceInputBase(BECWidget):
|
||||
self.config.devices = value
|
||||
FilterIO.set_selection(widget=self, selection=value)
|
||||
|
||||
@Property(str)
|
||||
@SafeProperty(str)
|
||||
def default(self):
|
||||
"""Get the default device name. If set through this property, it will update only if the device is within the filtered selection."""
|
||||
return self.config.default
|
||||
@@ -177,7 +177,7 @@ class DeviceInputBase(BECWidget):
|
||||
self.config.default = value
|
||||
WidgetIO.set_value(widget=self, value=value)
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def apply_filter(self):
|
||||
"""Apply the filters on the devices."""
|
||||
return self.config.apply_filter
|
||||
@@ -187,7 +187,7 @@ class DeviceInputBase(BECWidget):
|
||||
self.config.apply_filter = value
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def filter_to_device(self):
|
||||
"""Include devices in filters."""
|
||||
return BECDeviceFilter.DEVICE in self.device_filter
|
||||
@@ -200,7 +200,7 @@ class DeviceInputBase(BECWidget):
|
||||
self._device_filter.remove(BECDeviceFilter.DEVICE)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def filter_to_positioner(self):
|
||||
"""Include devices of type Positioner in filters."""
|
||||
return BECDeviceFilter.POSITIONER in self.device_filter
|
||||
@@ -213,7 +213,7 @@ class DeviceInputBase(BECWidget):
|
||||
self._device_filter.remove(BECDeviceFilter.POSITIONER)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def filter_to_signal(self):
|
||||
"""Include devices of type Signal in filters."""
|
||||
return BECDeviceFilter.SIGNAL in self.device_filter
|
||||
@@ -226,7 +226,7 @@ class DeviceInputBase(BECWidget):
|
||||
self._device_filter.remove(BECDeviceFilter.SIGNAL)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def filter_to_computed_signal(self):
|
||||
"""Include devices of type ComputedSignal in filters."""
|
||||
return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter
|
||||
@@ -239,7 +239,7 @@ class DeviceInputBase(BECWidget):
|
||||
self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def readout_monitored(self):
|
||||
"""Include devices with readout priority Monitored in filters."""
|
||||
return ReadoutPriority.MONITORED in self.readout_filter
|
||||
@@ -252,7 +252,7 @@ class DeviceInputBase(BECWidget):
|
||||
self._readout_filter.remove(ReadoutPriority.MONITORED)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def readout_baseline(self):
|
||||
"""Include devices with readout priority Baseline in filters."""
|
||||
return ReadoutPriority.BASELINE in self.readout_filter
|
||||
@@ -265,7 +265,7 @@ class DeviceInputBase(BECWidget):
|
||||
self._readout_filter.remove(ReadoutPriority.BASELINE)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def readout_async(self):
|
||||
"""Include devices with readout priority Async in filters."""
|
||||
return ReadoutPriority.ASYNC in self.readout_filter
|
||||
@@ -278,7 +278,7 @@ class DeviceInputBase(BECWidget):
|
||||
self._readout_filter.remove(ReadoutPriority.ASYNC)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def readout_continuous(self):
|
||||
"""Include devices with readout priority continuous in filters."""
|
||||
return ReadoutPriority.CONTINUOUS in self.readout_filter
|
||||
@@ -291,7 +291,7 @@ class DeviceInputBase(BECWidget):
|
||||
self._readout_filter.remove(ReadoutPriority.CONTINUOUS)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def readout_on_request(self):
|
||||
"""Include devices with readout priority OnRequest in filters."""
|
||||
return ReadoutPriority.ON_REQUEST in self.readout_filter
|
||||
|
||||
@@ -6,7 +6,7 @@ from qtpy.QtCore import Property
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.filter_io import FilterIO
|
||||
from bec_widgets.utils.filter_io import FilterIO, LineEditFilterHandler
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
@@ -55,7 +55,7 @@ class DeviceSignalInputBase(BECWidget):
|
||||
self._hinted_signals = []
|
||||
self._normal_signals = []
|
||||
self._config_signals = []
|
||||
self.bec_dispatcher.client.callbacks.register(
|
||||
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.update_signals_from_filters
|
||||
)
|
||||
|
||||
@@ -108,25 +108,32 @@ class DeviceSignalInputBase(BECWidget):
|
||||
if not self.validate_device(self._device):
|
||||
self._device = None
|
||||
self.config.device = self._device
|
||||
return
|
||||
device = self.get_device_object(self._device)
|
||||
# See above convention for Signals and ComputedSignals
|
||||
if isinstance(device, Signal):
|
||||
self._signals = [self._device]
|
||||
self._hinted_signals = [self._device]
|
||||
self._signals = []
|
||||
self._hinted_signals = []
|
||||
self._normal_signals = []
|
||||
self._config_signals = []
|
||||
FilterIO.set_selection(widget=self, selection=self._signals)
|
||||
return
|
||||
device = self.get_device_object(self._device)
|
||||
device_info = device._info.get("signals", {})
|
||||
|
||||
# See above convention for Signals and ComputedSignals
|
||||
if isinstance(device, Signal):
|
||||
self._signals = [(self._device, {})]
|
||||
self._hinted_signals = [(self._device, {})]
|
||||
self._normal_signals = []
|
||||
self._config_signals = []
|
||||
FilterIO.set_selection(widget=self, selection=self._signals)
|
||||
return
|
||||
|
||||
def _update(kind: Kind):
|
||||
return [
|
||||
signal
|
||||
for signal, signal_info in device_info.items()
|
||||
if kind in self.signal_filter
|
||||
and (signal_info.get("kind_str", None) == str(kind.name))
|
||||
]
|
||||
return FilterIO.update_with_kind(
|
||||
widget=self,
|
||||
kind=kind,
|
||||
signal_filter=self.signal_filter,
|
||||
device_info=device_info,
|
||||
device_name=self._device,
|
||||
)
|
||||
|
||||
self._hinted_signals = _update(Kind.hinted)
|
||||
self._normal_signals = _update(Kind.normal)
|
||||
@@ -271,11 +278,21 @@ class DeviceSignalInputBase(BECWidget):
|
||||
Args:
|
||||
signal(str): Signal to validate.
|
||||
"""
|
||||
if signal in self.signals:
|
||||
return True
|
||||
for entry in self.signals:
|
||||
if isinstance(entry, tuple):
|
||||
entry = entry[0]
|
||||
if entry == signal:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None):
|
||||
if config is None:
|
||||
return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
|
||||
return DeviceSignalInputBaseConfig.model_validate(config)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the widget.
|
||||
"""
|
||||
self.bec_dispatcher.client.callbacks.remove(self._device_update_register)
|
||||
super().cleanup()
|
||||
|
||||
@@ -34,6 +34,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
PLUGIN = True
|
||||
|
||||
device_selected = Signal(str)
|
||||
device_reset = Signal()
|
||||
device_config_update = Signal()
|
||||
|
||||
def __init__(
|
||||
@@ -147,6 +148,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
self.device_selected.emit(input_text)
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
self.device_reset.emit()
|
||||
self.update()
|
||||
|
||||
def validate_device(self, device: str) -> bool: # type: ignore[override]
|
||||
|
||||
@@ -90,6 +90,44 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
self.insertItem(0, "Hinted Signals")
|
||||
self.model().item(0).setEnabled(False)
|
||||
|
||||
def set_to_obj_name(self, obj_name: str) -> bool:
|
||||
"""
|
||||
Set the combobox to the object name of the signal.
|
||||
|
||||
Args:
|
||||
obj_name (str): Object name of the signal.
|
||||
|
||||
Returns:
|
||||
bool: True if the object name was found and set, False otherwise.
|
||||
"""
|
||||
for i in range(self.count()):
|
||||
signal_data = self.itemData(i)
|
||||
if signal_data and signal_data.get("obj_name") == obj_name:
|
||||
self.setCurrentIndex(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_to_first_enabled(self) -> bool:
|
||||
"""
|
||||
Set the combobox to the first enabled item.
|
||||
|
||||
Returns:
|
||||
bool: True if an enabled item was found and set, False otherwise.
|
||||
"""
|
||||
for i in range(self.count()):
|
||||
if self.model().item(i).isEnabled():
|
||||
self.setCurrentIndex(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
@SafeSlot()
|
||||
def reset_selection(self):
|
||||
"""Reset the selection of the combobox."""
|
||||
self.clear()
|
||||
self.setItemText(0, "Select a device")
|
||||
self.update_signals_from_filters()
|
||||
self.device_signal_changed.emit("")
|
||||
|
||||
@SafeSlot(str)
|
||||
def on_text_changed(self, text: str):
|
||||
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
|
||||
@@ -102,11 +140,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
return
|
||||
if self.validate_signal(text) is False:
|
||||
return
|
||||
if text == "readback" and isinstance(self.get_device_object(self.device), Positioner):
|
||||
device_signal = self.device
|
||||
else:
|
||||
device_signal = f"{self.device}_{text}"
|
||||
self.device_signal_changed.emit(device_signal)
|
||||
self.device_signal_changed.emit(text)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -203,35 +203,40 @@ class ScanControl(BECWidget, QWidget):
|
||||
"""
|
||||
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
|
||||
"""
|
||||
enabled = self.toggle.checked
|
||||
current_scan = self.comboBox_scan_selection.currentText()
|
||||
if enabled:
|
||||
history = self.client.connector.lrange(MessageEndpoints.scan_queue_history(), 0, -1)
|
||||
self.last_scan_found = False
|
||||
if not self.toggle.checked:
|
||||
return
|
||||
|
||||
for scan in history:
|
||||
scan_name = scan.content["info"]["request_blocks"][-1]["msg"].content["scan_type"]
|
||||
if scan_name == current_scan:
|
||||
args_dict = scan.content["info"]["request_blocks"][-1]["msg"].content[
|
||||
"parameter"
|
||||
]["args"]
|
||||
args_list = []
|
||||
for key, value in args_dict.items():
|
||||
args_list.append(key)
|
||||
args_list.extend(value)
|
||||
if len(args_list) > 1 and self.arg_box is not None:
|
||||
self.arg_box.set_parameters(args_list)
|
||||
kwargs = scan.content["info"]["request_blocks"][-1]["msg"].content["parameter"][
|
||||
"kwargs"
|
||||
]
|
||||
if kwargs and self.kwarg_boxes:
|
||||
for box in self.kwarg_boxes:
|
||||
box.set_parameters(kwargs)
|
||||
self.last_scan_found = True
|
||||
break
|
||||
else:
|
||||
self.last_scan_found = False
|
||||
else:
|
||||
self.last_scan_found = False
|
||||
current_scan = self.comboBox_scan_selection.currentText()
|
||||
history = (
|
||||
self.client.connector.xread(
|
||||
MessageEndpoints.scan_history(), from_start=True, user_id=self.object_name
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
||||
for scan in reversed(history):
|
||||
scan_data = scan.get("data")
|
||||
if not scan_data:
|
||||
continue
|
||||
|
||||
if scan_data.scan_name != current_scan:
|
||||
continue
|
||||
|
||||
ri = getattr(scan_data, "request_inputs", {}) or {}
|
||||
args_list = ri.get("arg_bundle", [])
|
||||
if args_list and self.arg_box:
|
||||
self.arg_box.set_parameters(args_list)
|
||||
|
||||
inputs = ri.get("inputs", {})
|
||||
kwargs = ri.get("kwargs", {})
|
||||
merged = {**inputs, **kwargs}
|
||||
if merged and self.kwarg_boxes:
|
||||
for box in self.kwarg_boxes:
|
||||
box.set_parameters(merged)
|
||||
|
||||
self.last_scan_found = True
|
||||
break
|
||||
|
||||
@SafeProperty(str)
|
||||
def current_scan(self):
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any
|
||||
|
||||
from qtpy import QtWidgets
|
||||
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QFontMetrics
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QHBoxLayout,
|
||||
@@ -14,7 +15,9 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
_NOT_SET = object()
|
||||
|
||||
|
||||
class DictBackedTableModel(QAbstractTableModel):
|
||||
@@ -26,6 +29,7 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
data (list[list[str]]): list of key-value pairs to initialise with"""
|
||||
super().__init__()
|
||||
self._data: list[list[str]] = data
|
||||
self._default = _NOT_SET
|
||||
self._disallowed_keys: list[str] = []
|
||||
|
||||
# pylint: disable=missing-function-docstring
|
||||
@@ -51,7 +55,10 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
Qt.ItemDataRole.EditRole,
|
||||
Qt.ItemDataRole.ToolTipRole,
|
||||
]:
|
||||
return str(self._data[index.row()][index.column()])
|
||||
try:
|
||||
return str(self._data[index.row()][index.column()])
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def setData(self, index, value, role):
|
||||
if role == Qt.ItemDataRole.EditRole:
|
||||
@@ -63,9 +70,10 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
return False
|
||||
|
||||
def replaceData(self, data: dict):
|
||||
self.delete_rows(list(range(len(self._data))))
|
||||
self.resetInternalData()
|
||||
self._data = [[k, v] for k, v in data.items()]
|
||||
self.dataChanged.emit(self.index(0, 0), self.index(len(self._data), 0))
|
||||
self._data = [[str(k), str(v)] for k, v in data.items()]
|
||||
self.dataChanged.emit(self.index(0, 0), self.index(len(self._data), 1))
|
||||
|
||||
def update_disallowed_keys(self, keys: list[str]):
|
||||
"""Set the list of keys which may not be used.
|
||||
@@ -76,7 +84,7 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
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))
|
||||
self.dataChanged.emit(self.index(i, 0), self.index(i, 1))
|
||||
|
||||
def _other_keys(self, row: int):
|
||||
return [r[0] for r in self._data[:row] + self._data[row + 1 :]]
|
||||
@@ -105,24 +113,39 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
@SafeSlot()
|
||||
def add_row(self):
|
||||
self.insertRow(self.rowCount())
|
||||
self.dataChanged.emit(self.index(self.rowCount(), 0), self.index(self.rowCount(), 1), 0)
|
||||
|
||||
@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.dataChanged.emit(self.index(row, 0), self.index(row, 1), 0)
|
||||
self.removeRows(row, 1, QModelIndex())
|
||||
|
||||
def set_default(self, value: dict | None):
|
||||
self._default = value
|
||||
|
||||
def dump_dict(self):
|
||||
if self._data == [[]]:
|
||||
if self._data in [[], [[]], [["", ""]]]:
|
||||
if self._default is not _NOT_SET:
|
||||
return self._default
|
||||
return {}
|
||||
return dict(self._data)
|
||||
|
||||
def length(self):
|
||||
return len(self._data)
|
||||
|
||||
|
||||
class DictBackedTable(QWidget):
|
||||
delete_rows = Signal(list)
|
||||
data_changed = Signal(dict)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, initial_data: list[list[str]] = []):
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
initial_data: list[list[str]] = [],
|
||||
autoscale_to_data: bool = True,
|
||||
):
|
||||
"""Widget which uses a DictBackedTableModel to display an editable table
|
||||
which can be extracted as a dict.
|
||||
|
||||
@@ -133,15 +156,25 @@ class DictBackedTable(QWidget):
|
||||
|
||||
self._layout = QHBoxLayout()
|
||||
self.setLayout(self._layout)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self._table_model = DictBackedTableModel(initial_data)
|
||||
self._table_view = QTreeView()
|
||||
|
||||
self._table_view.setModel(self._table_model)
|
||||
self._min_lines = 3
|
||||
self.set_height_in_lines(len(initial_data))
|
||||
self._table_view.setSizePolicy(
|
||||
QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
)
|
||||
self._table_view.setAlternatingRowColors(True)
|
||||
self._table_view.setUniformRowHeights(True)
|
||||
self._table_view.setWordWrap(False)
|
||||
self._table_view.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
|
||||
self._table_view.header().setSectionResizeMode(5, QtWidgets.QHeaderView.Stretch)
|
||||
self.autoscale = autoscale_to_data
|
||||
if self.autoscale:
|
||||
self.data_changed.connect(self.scale_to_data)
|
||||
self._layout.addWidget(self._table_view)
|
||||
|
||||
self._button_holder = QWidget()
|
||||
@@ -157,8 +190,12 @@ class DictBackedTable(QWidget):
|
||||
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)
|
||||
|
||||
self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict()))
|
||||
|
||||
def set_default(self, value: dict | None):
|
||||
self._table_model.set_default(value)
|
||||
|
||||
def set_button_visibility(self, value: bool):
|
||||
self._button_holder.setVisible(value)
|
||||
|
||||
@@ -166,8 +203,8 @@ class DictBackedTable(QWidget):
|
||||
def clear(self):
|
||||
self._table_model.replaceData({})
|
||||
|
||||
def replace_data(self, data: dict):
|
||||
self._table_model.replaceData(data)
|
||||
def replace_data(self, data: dict | None):
|
||||
self._table_model.replaceData(data or {})
|
||||
|
||||
def delete_selected_rows(self):
|
||||
"""Delete rows which are part of the selection model"""
|
||||
@@ -187,6 +224,29 @@ class DictBackedTable(QWidget):
|
||||
keys (list[str]): list of keys which are forbidden."""
|
||||
self._table_model.update_disallowed_keys(keys)
|
||||
|
||||
def set_height_in_lines(self, lines: int):
|
||||
self._table_view.setMaximumHeight(
|
||||
int(QFontMetrics(self._table_view.font()).height() * max(lines + 2, self._min_lines))
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(dict)
|
||||
def scale_to_data(self, *_):
|
||||
self.set_height_in_lines(self._table_model.length())
|
||||
|
||||
@SafeProperty(bool)
|
||||
def autoscale(self): # type: ignore
|
||||
return self._autoscale
|
||||
|
||||
@autoscale.setter
|
||||
def autoscale(self, autoscale: bool):
|
||||
self._autoscale = autoscale
|
||||
if self._autoscale:
|
||||
self.scale_to_data()
|
||||
self.data_changed.connect(self.scale_to_data)
|
||||
else:
|
||||
self.data_changed.disconnect(self.scale_to_data)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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.editors.sbb_monitor.sbb_monitor_plugin import SBBMonitorPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(SBBMonitorPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
15
bec_widgets/widgets/editors/sbb_monitor/sbb_monitor.py
Normal file
15
bec_widgets/widgets/editors/sbb_monitor/sbb_monitor.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from bec_widgets.widgets.editors.website.website import WebsiteWidget
|
||||
|
||||
|
||||
class SBBMonitor(WebsiteWidget):
|
||||
"""
|
||||
A widget to display the SBB monitor website.
|
||||
"""
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "train"
|
||||
USER_ACCESS = []
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
url = "https://free.oevplus.ch/monitor/?viewType=splitView&layout=1&showClock=true&showPerron=true&stationGroup1Title=Villigen%2C%20PSI%20West&stationGroup2Title=Siggenthal-Würenlingen&station_1_id=85%3A3592&station_1_name=Villigen%2C%20PSI%20West&station_1_quantity=5&station_1_group=1&station_2_id=85%3A3502&station_2_name=Siggenthal-Würenlingen&station_2_quantity=5&station_2_group=2"
|
||||
super().__init__(parent=parent, url=url, **kwargs)
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['sbb_monitor.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.editors.sbb_monitor.sbb_monitor import SBBMonitor
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='SBBMonitor' name='sbb_monitor'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class SBBMonitorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = SBBMonitor(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(SBBMonitor.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "sbb_monitor"
|
||||
|
||||
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 "SBBMonitor"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -67,4 +67,6 @@ def field_default(info: FieldInfo):
|
||||
|
||||
|
||||
def clearable_required(info: FieldInfo):
|
||||
return type(None) in get_args(info.annotation) or info.is_required()
|
||||
return type(None) in get_args(info.annotation) or (
|
||||
info.is_required() and info.default is PydanticUndefined
|
||||
)
|
||||
|
||||
@@ -149,7 +149,6 @@ _web_console_registry = WebConsoleRegistry()
|
||||
def suppress_qt_messages(type_, context, msg):
|
||||
if context.category in ["js", "default"]:
|
||||
return
|
||||
print(msg)
|
||||
|
||||
|
||||
qInstallMessageHandler(suppress_qt_messages)
|
||||
|
||||
@@ -8,7 +8,6 @@ from bec_widgets.utils.bec_widget import BECWidget
|
||||
def suppress_qt_messages(type_, context, msg):
|
||||
if context.category in ["js", "default"]:
|
||||
return
|
||||
print(msg)
|
||||
|
||||
|
||||
qInstallMessageHandler(suppress_qt_messages)
|
||||
|
||||
@@ -7,11 +7,19 @@ import numpy as np
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate, QWidget
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
|
||||
BECDeviceFilter,
|
||||
ReadoutPriority,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
|
||||
@@ -139,9 +147,119 @@ class Image(ImageBase):
|
||||
super().__init__(
|
||||
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
|
||||
)
|
||||
self._init_toolbar_image()
|
||||
self.layer_removed.connect(self._on_layer_removed)
|
||||
self.scan_id = None
|
||||
|
||||
##################################
|
||||
### Toolbar Initialization
|
||||
##################################
|
||||
|
||||
def _init_toolbar_image(self):
|
||||
"""
|
||||
Initializes the toolbar for the image widget.
|
||||
"""
|
||||
self.device_combo_box = DeviceComboBox(
|
||||
parent=self,
|
||||
device_filter=BECDeviceFilter.DEVICE,
|
||||
readout_priority_filter=[ReadoutPriority.ASYNC],
|
||||
)
|
||||
self.device_combo_box.addItem("", None)
|
||||
self.device_combo_box.setCurrentText("")
|
||||
self.device_combo_box.setToolTip("Select Device")
|
||||
self.device_combo_box.setFixedWidth(150)
|
||||
self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box))
|
||||
|
||||
self.dim_combo_box = QComboBox(parent=self)
|
||||
self.dim_combo_box.addItems(["auto", "1d", "2d"])
|
||||
self.dim_combo_box.setCurrentText("auto")
|
||||
self.dim_combo_box.setToolTip("Monitor Dimension")
|
||||
self.dim_combo_box.setFixedWidth(100)
|
||||
self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box))
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"image_device_combo", WidgetAction(widget=self.device_combo_box, adjust_size=False)
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"image_dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False)
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("monitor_selection", self.toolbar.components)
|
||||
bundle.add_action("image_device_combo")
|
||||
bundle.add_action("image_dim_combo")
|
||||
|
||||
self.toolbar.add_bundle(bundle)
|
||||
self.device_combo_box.currentTextChanged.connect(self.connect_monitor)
|
||||
self.dim_combo_box.currentTextChanged.connect(self.connect_monitor)
|
||||
|
||||
crosshair_bundle = self.toolbar.get_bundle("image_crosshair")
|
||||
crosshair_bundle.add_action("image_autorange")
|
||||
crosshair_bundle.add_action("image_colorbar_switch")
|
||||
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"monitor_selection",
|
||||
"plot_export",
|
||||
"mouse_interaction",
|
||||
"image_crosshair",
|
||||
"image_processing",
|
||||
"axis_popup",
|
||||
]
|
||||
)
|
||||
|
||||
QTimer.singleShot(0, self._adjust_and_connect)
|
||||
|
||||
def _adjust_and_connect(self):
|
||||
"""
|
||||
Adjust the size of the device combo box and populate it with preview signals.
|
||||
Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing.
|
||||
"""
|
||||
self._populate_preview_signals()
|
||||
self._reverse_device_items()
|
||||
self.device_combo_box.setCurrentText("") # set again default to empty string
|
||||
|
||||
def _populate_preview_signals(self) -> None:
|
||||
"""
|
||||
Populate the device combo box with preview-signal devices in the
|
||||
format '<device>_<signal>' and store the tuple(device, signal) in
|
||||
the item's userData for later use.
|
||||
"""
|
||||
preview_signals = self.client.device_manager.get_bec_signals("PreviewSignal")
|
||||
for device, signal, signal_config in preview_signals:
|
||||
label = signal_config.get("obj_name", f"{device}_{signal}")
|
||||
self.device_combo_box.addItem(label, (device, signal, signal_config))
|
||||
|
||||
def _reverse_device_items(self) -> None:
|
||||
"""
|
||||
Reverse the current order of items in the device combo box while
|
||||
keeping their userData and restoring the previous selection.
|
||||
"""
|
||||
current_text = self.device_combo_box.currentText()
|
||||
items = [
|
||||
(self.device_combo_box.itemText(i), self.device_combo_box.itemData(i))
|
||||
for i in range(self.device_combo_box.count())
|
||||
]
|
||||
self.device_combo_box.clear()
|
||||
for text, data in reversed(items):
|
||||
self.device_combo_box.addItem(text, data)
|
||||
if current_text:
|
||||
self.device_combo_box.setCurrentText(current_text)
|
||||
|
||||
@SafeSlot()
|
||||
def connect_monitor(self, *args, **kwargs):
|
||||
"""
|
||||
Connect the target widget to the selected monitor based on the current device and dimension.
|
||||
|
||||
If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor.
|
||||
"""
|
||||
dim = self.dim_combo_box.currentText()
|
||||
data = self.device_combo_box.currentData()
|
||||
|
||||
if isinstance(data, tuple):
|
||||
self.image(monitor=data, monitor_type="auto")
|
||||
else:
|
||||
self.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)
|
||||
|
||||
################################################################################
|
||||
# Data Acquisition
|
||||
|
||||
@@ -227,35 +345,21 @@ class Image(ImageBase):
|
||||
"""
|
||||
config = self.subscriptions["main"]
|
||||
if config.monitor is not None:
|
||||
for combo in (
|
||||
self.selection_bundle.device_combo_box,
|
||||
self.selection_bundle.dim_combo_box,
|
||||
):
|
||||
for combo in (self.device_combo_box, self.dim_combo_box):
|
||||
combo.blockSignals(True)
|
||||
if isinstance(config.monitor, tuple):
|
||||
self.selection_bundle.device_combo_box.setCurrentText(
|
||||
f"{config.monitor[0]}_{config.monitor[1]}"
|
||||
)
|
||||
self.device_combo_box.setCurrentText(f"{config.monitor[0]}_{config.monitor[1]}")
|
||||
else:
|
||||
self.selection_bundle.device_combo_box.setCurrentText(config.monitor)
|
||||
self.selection_bundle.dim_combo_box.setCurrentText(config.monitor_type)
|
||||
for combo in (
|
||||
self.selection_bundle.device_combo_box,
|
||||
self.selection_bundle.dim_combo_box,
|
||||
):
|
||||
self.device_combo_box.setCurrentText(config.monitor)
|
||||
self.dim_combo_box.setCurrentText(config.monitor_type)
|
||||
for combo in (self.device_combo_box, self.dim_combo_box):
|
||||
combo.blockSignals(False)
|
||||
else:
|
||||
for combo in (
|
||||
self.selection_bundle.device_combo_box,
|
||||
self.selection_bundle.dim_combo_box,
|
||||
):
|
||||
for combo in (self.device_combo_box, self.dim_combo_box):
|
||||
combo.blockSignals(True)
|
||||
self.selection_bundle.device_combo_box.setCurrentText("")
|
||||
self.selection_bundle.dim_combo_box.setCurrentText("auto")
|
||||
for combo in (
|
||||
self.selection_bundle.device_combo_box,
|
||||
self.selection_bundle.dim_combo_box,
|
||||
):
|
||||
self.device_combo_box.setCurrentText("")
|
||||
self.dim_combo_box.setCurrentText("auto")
|
||||
for combo in (self.device_combo_box, self.dim_combo_box):
|
||||
combo.blockSignals(False)
|
||||
|
||||
################################################################################
|
||||
@@ -554,8 +658,10 @@ class Image(ImageBase):
|
||||
self.subscriptions.clear()
|
||||
|
||||
# Toolbar cleanup
|
||||
self.toolbar.widgets["monitor"].widget.close()
|
||||
self.toolbar.widgets["monitor"].widget.deleteLater()
|
||||
self.device_combo_box.close()
|
||||
self.device_combo_box.deleteLater()
|
||||
self.dim_combo_box.close()
|
||||
self.dim_combo_box.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
@@ -570,10 +676,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
ml = QHBoxLayout(win)
|
||||
|
||||
image_popup = Image(popups=True)
|
||||
image_side_panel = Image(popups=False)
|
||||
# image_side_panel = Image(popups=False)
|
||||
|
||||
ml.addWidget(image_popup)
|
||||
ml.addWidget(image_side_panel)
|
||||
# ml.addWidget(image_side_panel)
|
||||
|
||||
win.resize(1500, 800)
|
||||
win.show()
|
||||
|
||||
@@ -12,14 +12,19 @@ from qtpy.QtWidgets import QDialog, QVBoxLayout
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.side_panel import SidePanel
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
from bec_widgets.widgets.plots.image.image_roi_plot import ImageROIPlot
|
||||
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
|
||||
from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
|
||||
MonitorSelectionToolbarBundle,
|
||||
from bec_widgets.widgets.plots.image.toolbar_components.image_base_actions import (
|
||||
ImageColorbarConnection,
|
||||
ImageProcessingConnection,
|
||||
ImageRoiConnection,
|
||||
image_autorange,
|
||||
image_colorbar,
|
||||
image_processing,
|
||||
image_roi_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.image.toolbar_bundles.processing import ImageProcessingToolbarBundle
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||
BaseROI,
|
||||
@@ -256,6 +261,7 @@ class ImageBase(PlotBase):
|
||||
self.x_roi = None
|
||||
self.y_roi = None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.roi_controller = ROIController(colormap="viridis")
|
||||
|
||||
# Headless controller keeps the canonical list.
|
||||
@@ -264,6 +270,7 @@ class ImageBase(PlotBase):
|
||||
self, plot_item=self.plot_item, on_add=self.layer_added, on_remove=self.layer_removed
|
||||
)
|
||||
self.layer_manager.add("main")
|
||||
self._init_image_base_toolbar()
|
||||
|
||||
self.autorange = True
|
||||
self.autorange_mode = "mean"
|
||||
@@ -274,6 +281,16 @@ class ImageBase(PlotBase):
|
||||
# Refresh theme for ROI plots
|
||||
self._update_theme()
|
||||
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"image_crosshair",
|
||||
"mouse_interaction",
|
||||
"image_autorange",
|
||||
"image_colorbar",
|
||||
"image_processing",
|
||||
]
|
||||
)
|
||||
|
||||
################################################################################
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
@@ -318,135 +335,66 @@ class ImageBase(PlotBase):
|
||||
"""
|
||||
return list(self.layer_manager.layers.values())
|
||||
|
||||
def _init_toolbar(self):
|
||||
def _init_image_base_toolbar(self):
|
||||
|
||||
try:
|
||||
# add to the first position
|
||||
self.selection_bundle = MonitorSelectionToolbarBundle(
|
||||
bundle_id="selection", target_widget=self
|
||||
)
|
||||
self.toolbar.add_bundle(self.selection_bundle, self)
|
||||
|
||||
super()._init_toolbar()
|
||||
|
||||
# Image specific changes to PlotBase toolbar
|
||||
self.toolbar.widgets["reset_legend"].action.setVisible(False)
|
||||
|
||||
# ROI Bundle replacement with switchable crosshair
|
||||
self.toolbar.remove_bundle("roi")
|
||||
crosshair = MaterialIconAction(
|
||||
icon_name="point_scan", tooltip="Show Crosshair", checkable=True, parent=self
|
||||
)
|
||||
crosshair_roi = MaterialIconAction(
|
||||
icon_name="my_location",
|
||||
tooltip="Show Crosshair with ROI plots",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
crosshair_roi.action.toggled.connect(self.toggle_roi_panels)
|
||||
crosshair.action.toggled.connect(self.toggle_crosshair)
|
||||
switch_crosshair = SwitchableToolBarAction(
|
||||
actions={"crosshair_simple": crosshair, "crosshair_roi": crosshair_roi},
|
||||
initial_action="crosshair_simple",
|
||||
tooltip="Crosshair",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action(
|
||||
action_id="switch_crosshair", action=switch_crosshair, target_widget=self
|
||||
# ROI Actions
|
||||
self.toolbar.add_bundle(image_roi_bundle(self.toolbar.components))
|
||||
self.toolbar.connect_bundle(
|
||||
"image_base", ImageRoiConnection(self.toolbar.components, target_widget=self)
|
||||
)
|
||||
|
||||
# Lock aspect ratio button
|
||||
self.lock_aspect_ratio_action = MaterialIconAction(
|
||||
# Lock Aspect Ratio Action
|
||||
lock_aspect_ratio_action = MaterialIconAction(
|
||||
icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="mouse_interaction",
|
||||
action_id="lock_aspect_ratio",
|
||||
action=self.lock_aspect_ratio_action,
|
||||
target_widget=self,
|
||||
)
|
||||
self.lock_aspect_ratio_action.action.toggled.connect(
|
||||
self.toolbar.components.add_safe("lock_aspect_ratio", lock_aspect_ratio_action)
|
||||
self.toolbar.get_bundle("mouse_interaction").add_action("lock_aspect_ratio")
|
||||
lock_aspect_ratio_action.action.toggled.connect(
|
||||
lambda checked: self.setProperty("lock_aspect_ratio", checked)
|
||||
)
|
||||
self.lock_aspect_ratio_action.action.setChecked(True)
|
||||
lock_aspect_ratio_action.action.setChecked(True)
|
||||
|
||||
self._init_autorange_action()
|
||||
self._init_colorbar_action()
|
||||
|
||||
# Processing Bundle
|
||||
self.processing_bundle = ImageProcessingToolbarBundle(
|
||||
bundle_id="processing", target_widget=self
|
||||
# Autorange Action
|
||||
self.toolbar.add_bundle(image_autorange(self.toolbar.components))
|
||||
action = self.toolbar.components.get_action("image_autorange")
|
||||
action.actions["mean"].action.toggled.connect(
|
||||
lambda checked: self.toggle_autorange(checked, mode="mean")
|
||||
)
|
||||
action.actions["max"].action.toggled.connect(
|
||||
lambda checked: self.toggle_autorange(checked, mode="max")
|
||||
)
|
||||
|
||||
# Colorbar Actions
|
||||
self.toolbar.add_bundle(image_colorbar(self.toolbar.components))
|
||||
|
||||
self.toolbar.connect_bundle(
|
||||
"image_colorbar",
|
||||
ImageColorbarConnection(self.toolbar.components, target_widget=self),
|
||||
)
|
||||
|
||||
# Image Processing Actions
|
||||
self.toolbar.add_bundle(image_processing(self.toolbar.components))
|
||||
self.toolbar.connect_bundle(
|
||||
"image_processing",
|
||||
ImageProcessingConnection(self.toolbar.components, target_widget=self),
|
||||
)
|
||||
|
||||
# ROI Manager Action
|
||||
self.toolbar.components.add_safe(
|
||||
"roi_mgr",
|
||||
MaterialIconAction(
|
||||
icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.get_bundle("axis_popup").add_action("roi_mgr")
|
||||
self.toolbar.components.get_action("roi_mgr").action.triggered.connect(
|
||||
self.show_roi_manager_popup
|
||||
)
|
||||
self.toolbar.add_bundle(self.processing_bundle, target_widget=self)
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing toolbar: {e}")
|
||||
|
||||
def _init_autorange_action(self):
|
||||
|
||||
self.autorange_mean_action = MaterialIconAction(
|
||||
icon_name="hdr_auto", tooltip="Enable Auto Range (Mean)", checkable=True, parent=self
|
||||
)
|
||||
self.autorange_max_action = MaterialIconAction(
|
||||
icon_name="hdr_auto",
|
||||
tooltip="Enable Auto Range (Max)",
|
||||
checkable=True,
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.autorange_switch = SwitchableToolBarAction(
|
||||
actions={
|
||||
"auto_range_mean": self.autorange_mean_action,
|
||||
"auto_range_max": self.autorange_max_action,
|
||||
},
|
||||
initial_action="auto_range_mean",
|
||||
tooltip="Enable Auto Range",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.toolbar.add_action(
|
||||
action_id="autorange_image", action=self.autorange_switch, target_widget=self
|
||||
)
|
||||
|
||||
self.autorange_mean_action.action.toggled.connect(
|
||||
lambda checked: self.toggle_autorange(checked, mode="mean")
|
||||
)
|
||||
self.autorange_max_action.action.toggled.connect(
|
||||
lambda checked: self.toggle_autorange(checked, mode="max")
|
||||
)
|
||||
|
||||
def _init_colorbar_action(self):
|
||||
self.full_colorbar_action = MaterialIconAction(
|
||||
icon_name="edgesensor_low", tooltip="Enable Full Colorbar", checkable=True, parent=self
|
||||
)
|
||||
self.simple_colorbar_action = MaterialIconAction(
|
||||
icon_name="smartphone", tooltip="Enable Simple Colorbar", checkable=True, parent=self
|
||||
)
|
||||
|
||||
self.colorbar_switch = SwitchableToolBarAction(
|
||||
actions={
|
||||
"full_colorbar": self.full_colorbar_action,
|
||||
"simple_colorbar": self.simple_colorbar_action,
|
||||
},
|
||||
initial_action="full_colorbar",
|
||||
tooltip="Enable Full Colorbar",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.toolbar.add_action(
|
||||
action_id="switch_colorbar", action=self.colorbar_switch, target_widget=self
|
||||
)
|
||||
|
||||
self.simple_colorbar_action.action.toggled.connect(
|
||||
lambda checked: self.enable_colorbar(checked, style="simple")
|
||||
)
|
||||
self.full_colorbar_action.action.toggled.connect(
|
||||
lambda checked: self.enable_colorbar(checked, style="full")
|
||||
)
|
||||
|
||||
########################################
|
||||
# ROI Gui Manager
|
||||
def add_side_menus(self):
|
||||
@@ -461,20 +409,8 @@ class ImageBase(PlotBase):
|
||||
title="ROI Manager",
|
||||
)
|
||||
|
||||
def add_popups(self):
|
||||
super().add_popups() # keep Axis Settings
|
||||
|
||||
roi_action = MaterialIconAction(
|
||||
icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self
|
||||
)
|
||||
# self.popup_bundle.add_action("roi_mgr", roi_action)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="popup_bundle", action_id="roi_mgr", action=roi_action, target_widget=self
|
||||
)
|
||||
self.toolbar.widgets["roi_mgr"].action.triggered.connect(self.show_roi_manager_popup)
|
||||
|
||||
def show_roi_manager_popup(self):
|
||||
roi_action = self.toolbar.widgets["roi_mgr"].action
|
||||
roi_action = self.toolbar.components.get_action("roi_mgr").action
|
||||
if self.roi_manager_dialog is None or not self.roi_manager_dialog.isVisible():
|
||||
self.roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
|
||||
self.roi_manager_dialog = QDialog(modal=False)
|
||||
@@ -494,7 +430,7 @@ class ImageBase(PlotBase):
|
||||
self.roi_manager_dialog.close()
|
||||
self.roi_manager_dialog.deleteLater()
|
||||
self.roi_manager_dialog = None
|
||||
self.toolbar.widgets["roi_mgr"].action.setChecked(False)
|
||||
self.toolbar.components.get_action("roi_mgr").action.setChecked(False)
|
||||
|
||||
def enable_colorbar(
|
||||
self,
|
||||
@@ -518,12 +454,11 @@ class ImageBase(PlotBase):
|
||||
self.plot_widget.removeItem(self._color_bar)
|
||||
self._color_bar = None
|
||||
|
||||
def disable_autorange():
|
||||
print("Disabling autorange")
|
||||
self.setProperty("autorange", False)
|
||||
|
||||
if style == "simple":
|
||||
|
||||
def disable_autorange():
|
||||
print("Disabling autorange")
|
||||
self.setProperty("autorange", False)
|
||||
|
||||
self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
|
||||
self._color_bar.setImageItem(self.layer_manager["main"].image)
|
||||
self._color_bar.sigLevelsChangeFinished.connect(disable_autorange)
|
||||
@@ -532,9 +467,7 @@ class ImageBase(PlotBase):
|
||||
self._color_bar = pg.HistogramLUTItem()
|
||||
self._color_bar.setImageItem(self.layer_manager["main"].image)
|
||||
self._color_bar.gradient.loadPreset(self.config.color_map)
|
||||
self._color_bar.sigLevelsChanged.connect(
|
||||
lambda: self.setProperty("autorange", False)
|
||||
)
|
||||
self._color_bar.sigLevelsChanged.connect(disable_autorange)
|
||||
|
||||
self.plot_widget.addItem(self._color_bar, row=0, col=1)
|
||||
self.config.color_bar = style
|
||||
@@ -827,6 +760,9 @@ class ImageBase(PlotBase):
|
||||
Args:
|
||||
value(tuple | list | QPointF): The range of values to set.
|
||||
"""
|
||||
self._set_vrange(value, disable_autorange=True)
|
||||
|
||||
def _set_vrange(self, value: tuple | list | QPointF, disable_autorange: bool = True):
|
||||
if isinstance(value, (tuple, list)):
|
||||
value = self._tuple_to_qpointf(value)
|
||||
|
||||
@@ -835,7 +771,7 @@ class ImageBase(PlotBase):
|
||||
for layer in self.layer_manager:
|
||||
if not layer.sync.v_range:
|
||||
continue
|
||||
layer.image.v_range = (vmin, vmax)
|
||||
layer.image.set_v_range((vmin, vmax), disable_autorange=disable_autorange)
|
||||
|
||||
# propagate to colorbar if exists
|
||||
if self._color_bar:
|
||||
@@ -845,7 +781,7 @@ class ImageBase(PlotBase):
|
||||
self._color_bar.setLevels(min=vmin, max=vmax)
|
||||
self._color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
|
||||
|
||||
self.autorange_switch.set_state_all(False)
|
||||
# self.toolbar.components.get_action("image_autorange").set_state_all(False)
|
||||
|
||||
@property
|
||||
def v_min(self) -> float:
|
||||
@@ -919,12 +855,23 @@ class ImageBase(PlotBase):
|
||||
Args:
|
||||
enabled(bool): Whether to enable autorange.
|
||||
"""
|
||||
self._set_autorange(enabled)
|
||||
|
||||
def _set_autorange(self, enabled: bool, sync: bool = True):
|
||||
"""
|
||||
Set the autorange for all layers.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable autorange.
|
||||
sync(bool): Whether to synchronize the autorange state across all layers.
|
||||
"""
|
||||
for layer in self.layer_manager:
|
||||
if not layer.sync.autorange:
|
||||
continue
|
||||
layer.image.autorange = enabled
|
||||
if enabled and layer.image.raw_data is not None:
|
||||
layer.image.apply_autorange()
|
||||
# if sync:
|
||||
self._sync_colorbar_levels()
|
||||
self._sync_autorange_switch()
|
||||
|
||||
@@ -969,7 +916,7 @@ class ImageBase(PlotBase):
|
||||
"""
|
||||
if not self.layer_manager:
|
||||
return
|
||||
|
||||
print(f"Toggling autorange to {enabled} with mode {mode}")
|
||||
for layer in self.layer_manager:
|
||||
if layer.sync.autorange:
|
||||
layer.image.autorange = enabled
|
||||
@@ -981,19 +928,16 @@ class ImageBase(PlotBase):
|
||||
# We only need to apply autorange if we enabled it
|
||||
layer.image.apply_autorange()
|
||||
|
||||
if enabled:
|
||||
self._sync_colorbar_levels()
|
||||
self._sync_colorbar_levels()
|
||||
|
||||
def _sync_autorange_switch(self):
|
||||
"""
|
||||
Synchronize the autorange switch with the current autorange state and mode if changed from outside.
|
||||
"""
|
||||
self.autorange_switch.block_all_signals(True)
|
||||
self.autorange_switch.set_default_action(
|
||||
f"auto_range_{self.layer_manager['main'].image.autorange_mode}"
|
||||
)
|
||||
self.autorange_switch.set_state_all(self.layer_manager["main"].image.autorange)
|
||||
self.autorange_switch.block_all_signals(False)
|
||||
action: SwitchableToolBarAction = self.toolbar.components.get_action("image_autorange") # type: ignore
|
||||
with action.signal_blocker():
|
||||
action.set_default_action(f"{self.layer_manager['main'].image.autorange_mode}")
|
||||
action.set_state_all(self.layer_manager["main"].image.autorange)
|
||||
|
||||
def _sync_colorbar_levels(self):
|
||||
"""Immediately propagate current levels to the active colorbar."""
|
||||
@@ -1009,20 +953,22 @@ class ImageBase(PlotBase):
|
||||
total_vrange = (min(total_vrange[0], img.v_min), max(total_vrange[1], img.v_max))
|
||||
|
||||
self._color_bar.blockSignals(True)
|
||||
self.v_range = total_vrange # type: ignore
|
||||
self._set_vrange(total_vrange, disable_autorange=False) # type: ignore
|
||||
self._color_bar.blockSignals(False)
|
||||
|
||||
def _sync_colorbar_actions(self):
|
||||
"""
|
||||
Synchronize the colorbar actions with the current colorbar state.
|
||||
"""
|
||||
self.colorbar_switch.block_all_signals(True)
|
||||
if self._color_bar is not None:
|
||||
self.colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar")
|
||||
self.colorbar_switch.set_state_all(True)
|
||||
else:
|
||||
self.colorbar_switch.set_state_all(False)
|
||||
self.colorbar_switch.block_all_signals(False)
|
||||
colorbar_switch: SwitchableToolBarAction = self.toolbar.components.get_action(
|
||||
"image_colorbar_switch"
|
||||
)
|
||||
with colorbar_switch.signal_blocker():
|
||||
if self._color_bar is not None:
|
||||
colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar")
|
||||
colorbar_switch.set_state_all(True)
|
||||
else:
|
||||
colorbar_switch.set_state_all(False)
|
||||
|
||||
@staticmethod
|
||||
def cleanup_histogram_lut_item(histogram_lut_item: pg.HistogramLUTItem):
|
||||
|
||||
@@ -20,7 +20,9 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils import BECDispatcher, ConnectionConfig
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||
BaseROI,
|
||||
CircularROI,
|
||||
@@ -121,20 +123,33 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
|
||||
# --------------------------------------------------------------------- UI
|
||||
def _init_toolbar(self):
|
||||
tb = ModularToolBar(self, self, orientation="horizontal")
|
||||
tb = self.toolbar = ModularToolBar(self, orientation="horizontal")
|
||||
self._draw_actions: dict[str, MaterialIconAction] = {}
|
||||
# --- ROI draw actions (toggleable) ---
|
||||
self.add_rect_action = MaterialIconAction("add_box", "Add Rect ROI", True, self)
|
||||
tb.add_action("Add Rect ROI", self.add_rect_action, self)
|
||||
self._draw_actions["rect"] = self.add_rect_action
|
||||
self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self)
|
||||
tb.add_action("Add Circle ROI", self.add_circle_action, self)
|
||||
self._draw_actions["circle"] = self.add_circle_action
|
||||
# --- Ellipse ROI draw action ---
|
||||
self.add_ellipse_action = MaterialIconAction("vignette", "Add Ellipse ROI", True, self)
|
||||
tb.add_action("Add Ellipse ROI", self.add_ellipse_action, self)
|
||||
self._draw_actions["ellipse"] = self.add_ellipse_action
|
||||
|
||||
tb.components.add_safe(
|
||||
"roi_rectangle",
|
||||
MaterialIconAction("add_box", "Add Rect ROI", checkable=True, parent=self),
|
||||
)
|
||||
tb.components.add_safe(
|
||||
"roi_circle",
|
||||
MaterialIconAction("add_circle", "Add Circle ROI", checkable=True, parent=self),
|
||||
)
|
||||
tb.components.add_safe(
|
||||
"roi_ellipse",
|
||||
MaterialIconAction("vignette", "Add Ellipse ROI", checkable=True, parent=self),
|
||||
)
|
||||
bundle = ToolbarBundle("roi_draw", tb.components)
|
||||
bundle.add_action("roi_rectangle")
|
||||
bundle.add_action("roi_circle")
|
||||
bundle.add_action("roi_ellipse")
|
||||
tb.add_bundle(bundle)
|
||||
|
||||
self._draw_actions = {
|
||||
"rect": tb.components.get_action("roi_rectangle"),
|
||||
"circle": tb.components.get_action("roi_circle"),
|
||||
"ellipse": tb.components.get_action("roi_ellipse"),
|
||||
}
|
||||
for mode, act in self._draw_actions.items():
|
||||
act.action.toggled.connect(lambda on, m=mode: self._on_draw_action_toggled(m, on))
|
||||
|
||||
@@ -142,7 +157,7 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
self.expand_toggle = MaterialIconAction(
|
||||
"unfold_more", "Expand/Collapse", checkable=True, parent=self # icon when collapsed
|
||||
)
|
||||
tb.add_action("Expand/Collapse", self.expand_toggle, self)
|
||||
tb.components.add_safe("expand_toggle", self.expand_toggle)
|
||||
|
||||
def _exp_toggled(on: bool):
|
||||
if on:
|
||||
@@ -163,7 +178,7 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
self.lock_all_action = MaterialIconAction(
|
||||
"lock_open_right", "Lock/Unlock all ROIs", checkable=True, parent=self
|
||||
)
|
||||
tb.add_action("Lock/Unlock all ROIs", self.lock_all_action, self)
|
||||
tb.components.add_safe("lock_unlock_all", self.lock_all_action)
|
||||
|
||||
def _lock_all(checked: bool):
|
||||
# checked -> everything locked (movable = False)
|
||||
@@ -178,12 +193,23 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
|
||||
# colormap widget
|
||||
self.cmap = BECColorMapWidget(cmap=self.controller.colormap)
|
||||
tb.addWidget(QWidget()) # spacer
|
||||
tb.addWidget(self.cmap)
|
||||
|
||||
tb.components.add_safe("roi_tree_spacer", WidgetAction(widget=QWidget()))
|
||||
tb.components.add_safe("roi_tree_cmap", WidgetAction(widget=self.cmap))
|
||||
|
||||
self.cmap.colormap_changed_signal.connect(self.controller.set_colormap)
|
||||
self.layout.addWidget(tb)
|
||||
self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap))
|
||||
|
||||
bundle = ToolbarBundle("roi_tools", tb.components)
|
||||
bundle.add_action("expand_toggle")
|
||||
bundle.add_action("lock_unlock_all")
|
||||
bundle.add_action("roi_tree_spacer")
|
||||
bundle.add_action("roi_tree_cmap")
|
||||
tb.add_bundle(bundle)
|
||||
|
||||
tb.show_bundles(["roi_draw", "roi_tools"])
|
||||
|
||||
# ROI drawing state
|
||||
self._roi_draw_mode = None # 'rect' | 'circle' | 'ellipse' | None
|
||||
self._roi_start_pos = None # QPointF in image coords
|
||||
@@ -337,7 +363,9 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
# color button
|
||||
color_btn = ColorButtonNative(parent=self, color=roi.line_color)
|
||||
self.tree.setItemWidget(parent, self.COL_PROPS, color_btn)
|
||||
color_btn.clicked.connect(lambda: self._pick_color(roi, color_btn))
|
||||
color_btn.color_changed.connect(
|
||||
lambda new_color, r=roi: setattr(r, "line_color", new_color)
|
||||
)
|
||||
|
||||
# child rows (3 columns: action, ROI, properties)
|
||||
QTreeWidgetItem(parent, ["", "Type", roi.__class__.__name__])
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
|
||||
class NoCheckDelegate(QStyledItemDelegate):
|
||||
"""To reduce space in combo boxes by removing the checkmark."""
|
||||
|
||||
def initStyleOption(self, option, index):
|
||||
super().initStyleOption(option, index)
|
||||
# Remove any check indicator
|
||||
option.checkState = Qt.Unchecked
|
||||
|
||||
|
||||
class MonitorSelectionToolbarBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions for a toolbar that controls monitor selection on a plot.
|
||||
"""
|
||||
|
||||
def __init__(self, bundle_id="device_selection", target_widget=None, **kwargs):
|
||||
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
|
||||
self.target_widget = target_widget
|
||||
|
||||
# 1) Device combo box
|
||||
self.device_combo_box = DeviceComboBox(
|
||||
parent=self.target_widget,
|
||||
device_filter=BECDeviceFilter.DEVICE,
|
||||
readout_priority_filter=[ReadoutPriority.ASYNC],
|
||||
)
|
||||
self.device_combo_box.addItem("", None)
|
||||
self.device_combo_box.setCurrentText("")
|
||||
self.device_combo_box.setToolTip("Select Device")
|
||||
self.device_combo_box.setFixedWidth(150)
|
||||
self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box))
|
||||
|
||||
self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=False))
|
||||
|
||||
# 2) Dimension combo box
|
||||
self.dim_combo_box = QComboBox(parent=self.target_widget)
|
||||
self.dim_combo_box.addItems(["auto", "1d", "2d"])
|
||||
self.dim_combo_box.setCurrentText("auto")
|
||||
self.dim_combo_box.setToolTip("Monitor Dimension")
|
||||
self.dim_combo_box.setFixedWidth(100)
|
||||
self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box))
|
||||
|
||||
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False))
|
||||
|
||||
self.device_combo_box.currentTextChanged.connect(self.connect_monitor)
|
||||
self.dim_combo_box.currentTextChanged.connect(self.connect_monitor)
|
||||
|
||||
QTimer.singleShot(0, self._adjust_and_connect)
|
||||
|
||||
def _adjust_and_connect(self):
|
||||
"""
|
||||
Adjust the size of the device combo box and populate it with preview signals.
|
||||
Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing.
|
||||
"""
|
||||
self._populate_preview_signals()
|
||||
self._reverse_device_items()
|
||||
self.device_combo_box.setCurrentText("") # set again default to empty string
|
||||
|
||||
def _populate_preview_signals(self) -> None:
|
||||
"""
|
||||
Populate the device combo box with preview‑signal devices in the
|
||||
format '<device>_<signal>' and store the tuple(device, signal) in
|
||||
the item's userData for later use.
|
||||
"""
|
||||
preview_signals = self.target_widget.client.device_manager.get_bec_signals("PreviewSignal")
|
||||
for device, signal, signal_config in preview_signals:
|
||||
label = signal_config.get("obj_name", f"{device}_{signal}")
|
||||
self.device_combo_box.addItem(label, (device, signal, signal_config))
|
||||
|
||||
def _reverse_device_items(self) -> None:
|
||||
"""
|
||||
Reverse the current order of items in the device combo box while
|
||||
keeping their userData and restoring the previous selection.
|
||||
"""
|
||||
current_text = self.device_combo_box.currentText()
|
||||
items = [
|
||||
(self.device_combo_box.itemText(i), self.device_combo_box.itemData(i))
|
||||
for i in range(self.device_combo_box.count())
|
||||
]
|
||||
self.device_combo_box.clear()
|
||||
for text, data in reversed(items):
|
||||
self.device_combo_box.addItem(text, data)
|
||||
if current_text:
|
||||
self.device_combo_box.setCurrentText(current_text)
|
||||
|
||||
@SafeSlot()
|
||||
def connect_monitor(self, *args, **kwargs):
|
||||
"""
|
||||
Connect the target widget to the selected monitor based on the current device and dimension.
|
||||
|
||||
If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor.
|
||||
"""
|
||||
dim = self.dim_combo_box.currentText()
|
||||
data = self.device_combo_box.currentData()
|
||||
|
||||
if isinstance(data, tuple):
|
||||
self.target_widget.image(monitor=data, monitor_type="auto")
|
||||
else:
|
||||
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)
|
||||
@@ -1,92 +0,0 @@
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ToolbarBundle
|
||||
|
||||
|
||||
class ImageProcessingToolbarBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions for a toolbar that controls processing of monitor.
|
||||
"""
|
||||
|
||||
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.fft = MaterialIconAction(
|
||||
icon_name="fft", tooltip="Toggle FFT", checkable=True, parent=self.target_widget
|
||||
)
|
||||
self.log = MaterialIconAction(
|
||||
icon_name="log_scale", tooltip="Toggle Log", checkable=True, parent=self.target_widget
|
||||
)
|
||||
self.transpose = MaterialIconAction(
|
||||
icon_name="transform",
|
||||
tooltip="Transpose Image",
|
||||
checkable=True,
|
||||
parent=self.target_widget,
|
||||
)
|
||||
self.right = MaterialIconAction(
|
||||
icon_name="rotate_right",
|
||||
tooltip="Rotate image clockwise by 90 deg",
|
||||
parent=self.target_widget,
|
||||
)
|
||||
self.left = MaterialIconAction(
|
||||
icon_name="rotate_left",
|
||||
tooltip="Rotate image counterclockwise by 90 deg",
|
||||
parent=self.target_widget,
|
||||
)
|
||||
self.reset = MaterialIconAction(
|
||||
icon_name="reset_settings", tooltip="Reset Image Settings", parent=self.target_widget
|
||||
)
|
||||
|
||||
self.add_action("fft", self.fft)
|
||||
self.add_action("log", self.log)
|
||||
self.add_action("transpose", self.transpose)
|
||||
self.add_action("rotate_right", self.right)
|
||||
self.add_action("rotate_left", self.left)
|
||||
self.add_action("reset", self.reset)
|
||||
|
||||
self.fft.action.triggered.connect(self.toggle_fft)
|
||||
self.log.action.triggered.connect(self.toggle_log)
|
||||
self.transpose.action.triggered.connect(self.toggle_transpose)
|
||||
self.right.action.triggered.connect(self.rotate_right)
|
||||
self.left.action.triggered.connect(self.rotate_left)
|
||||
self.reset.action.triggered.connect(self.reset_settings)
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_fft(self):
|
||||
checked = self.fft.action.isChecked()
|
||||
self.target_widget.fft = checked
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_log(self):
|
||||
checked = self.log.action.isChecked()
|
||||
self.target_widget.log = checked
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_transpose(self):
|
||||
checked = self.transpose.action.isChecked()
|
||||
self.target_widget.transpose = checked
|
||||
|
||||
@SafeSlot()
|
||||
def rotate_right(self):
|
||||
if self.target_widget.num_rotation_90 is None:
|
||||
return
|
||||
rotation = (self.target_widget.num_rotation_90 - 1) % 4
|
||||
self.target_widget.num_rotation_90 = rotation
|
||||
|
||||
@SafeSlot()
|
||||
def rotate_left(self):
|
||||
if self.target_widget.num_rotation_90 is None:
|
||||
return
|
||||
rotation = (self.target_widget.num_rotation_90 + 1) % 4
|
||||
self.target_widget.num_rotation_90 = rotation
|
||||
|
||||
@SafeSlot()
|
||||
def reset_settings(self):
|
||||
self.target_widget.fft = False
|
||||
self.target_widget.log = False
|
||||
self.target_widget.transpose = False
|
||||
self.target_widget.num_rotation_90 = 0
|
||||
|
||||
self.fft.action.setChecked(False)
|
||||
self.log.action.setChecked(False)
|
||||
self.transpose.action.setChecked(False)
|
||||
@@ -0,0 +1,390 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
|
||||
def image_roi_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a toolbar bundle for ROI and crosshair interaction.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The ROI toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"image_crosshair",
|
||||
MaterialIconAction(
|
||||
icon_name="point_scan",
|
||||
tooltip="Show Crosshair",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_crosshair_roi",
|
||||
MaterialIconAction(
|
||||
icon_name="my_location",
|
||||
tooltip="Show Crosshair with ROI plots",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_switch_crosshair",
|
||||
SwitchableToolBarAction(
|
||||
actions={
|
||||
"crosshair": components.get_action_reference("image_crosshair")(),
|
||||
"crosshair_roi": components.get_action_reference("image_crosshair_roi")(),
|
||||
},
|
||||
initial_action="crosshair",
|
||||
tooltip="Crosshair",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("image_crosshair", components)
|
||||
bundle.add_action("image_switch_crosshair")
|
||||
return bundle
|
||||
|
||||
|
||||
class ImageRoiConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for the ROI toolbar bundle.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "roi"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if not hasattr(self.target_widget, "toggle_roi_panels") or not hasattr(
|
||||
self.target_widget, "toggle_crosshair"
|
||||
):
|
||||
raise AttributeError(
|
||||
"Target widget must implement 'toggle_roi_panels' and 'toggle_crosshair'."
|
||||
)
|
||||
super().__init__()
|
||||
self._connected = False
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action("image_crosshair").action.toggled.connect(
|
||||
self.target_widget.toggle_crosshair
|
||||
)
|
||||
self.components.get_action("image_crosshair_roi").action.triggered.connect(
|
||||
self.target_widget.toggle_roi_panels
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action("image_crosshair").action.toggled.disconnect(
|
||||
self.target_widget.toggle_crosshair
|
||||
)
|
||||
self.components.get_action("image_crosshair_roi").action.triggered.disconnect(
|
||||
self.target_widget.toggle_roi_panels
|
||||
)
|
||||
|
||||
|
||||
def image_autorange(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a toolbar bundle for image autorange functionality.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The autorange toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"image_autorange_mean",
|
||||
MaterialIconAction(
|
||||
icon_name="hdr_auto",
|
||||
tooltip="Enable Auto Range (Mean)",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_autorange_max",
|
||||
MaterialIconAction(
|
||||
icon_name="hdr_auto",
|
||||
tooltip="Enable Auto Range (Max)",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
filled=True,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_autorange",
|
||||
SwitchableToolBarAction(
|
||||
actions={
|
||||
"mean": components.get_action_reference("image_autorange_mean")(),
|
||||
"max": components.get_action_reference("image_autorange_max")(),
|
||||
},
|
||||
initial_action="mean",
|
||||
tooltip="Autorange",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
default_state_checked=True,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("image_autorange", components)
|
||||
bundle.add_action("image_autorange")
|
||||
return bundle
|
||||
|
||||
|
||||
def image_colorbar(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a toolbar bundle for image colorbar functionality.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The colorbar toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"image_full_colorbar",
|
||||
MaterialIconAction(
|
||||
icon_name="edgesensor_low",
|
||||
tooltip="Enable Full Colorbar",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_simple_colorbar",
|
||||
MaterialIconAction(
|
||||
icon_name="smartphone",
|
||||
tooltip="Enable Simple Colorbar",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_colorbar_switch",
|
||||
SwitchableToolBarAction(
|
||||
actions={
|
||||
"full_colorbar": components.get_action_reference("image_full_colorbar")(),
|
||||
"simple_colorbar": components.get_action_reference("image_simple_colorbar")(),
|
||||
},
|
||||
initial_action="full_colorbar",
|
||||
tooltip="Colorbar",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("image_colorbar", components)
|
||||
bundle.add_action("image_colorbar_switch")
|
||||
return bundle
|
||||
|
||||
|
||||
class ImageColorbarConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for the image colorbar toolbar bundle.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "image_colorbar"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if not hasattr(self.target_widget, "enable_colorbar"):
|
||||
raise AttributeError("Target widget must implement 'enable_colorbar' method.")
|
||||
super().__init__()
|
||||
self._connected = False
|
||||
|
||||
def _enable_full_colorbar(self, checked: bool):
|
||||
"""
|
||||
Enable or disable the full colorbar based on the checked state.
|
||||
"""
|
||||
self.target_widget.enable_colorbar(checked, style="full")
|
||||
|
||||
def _enable_simple_colorbar(self, checked: bool):
|
||||
"""
|
||||
Enable or disable the simple colorbar based on the checked state.
|
||||
"""
|
||||
self.target_widget.enable_colorbar(checked, style="simple")
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action("image_full_colorbar").action.toggled.connect(
|
||||
self._enable_full_colorbar
|
||||
)
|
||||
self.components.get_action("image_simple_colorbar").action.toggled.connect(
|
||||
self._enable_simple_colorbar
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action("image_full_colorbar").action.toggled.disconnect(
|
||||
self._enable_full_colorbar
|
||||
)
|
||||
self.components.get_action("image_simple_colorbar").action.toggled.disconnect(
|
||||
self._enable_simple_colorbar
|
||||
)
|
||||
|
||||
|
||||
def image_processing(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a toolbar bundle for image processing functionality.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The image processing toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"image_processing_fft",
|
||||
MaterialIconAction(
|
||||
icon_name="fft", tooltip="Toggle FFT", checkable=True, parent=components.toolbar
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_processing_log",
|
||||
MaterialIconAction(
|
||||
icon_name="log_scale", tooltip="Toggle Log", checkable=True, parent=components.toolbar
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_processing_transpose",
|
||||
MaterialIconAction(
|
||||
icon_name="transform",
|
||||
tooltip="Transpose Image",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_processing_rotate_right",
|
||||
MaterialIconAction(
|
||||
icon_name="rotate_right",
|
||||
tooltip="Rotate image clockwise by 90 deg",
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_processing_rotate_left",
|
||||
MaterialIconAction(
|
||||
icon_name="rotate_left",
|
||||
tooltip="Rotate image counterclockwise by 90 deg",
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_processing_reset",
|
||||
MaterialIconAction(
|
||||
icon_name="reset_settings", tooltip="Reset Image Settings", parent=components.toolbar
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("image_processing", components)
|
||||
bundle.add_action("image_processing_fft")
|
||||
bundle.add_action("image_processing_log")
|
||||
bundle.add_action("image_processing_transpose")
|
||||
bundle.add_action("image_processing_rotate_right")
|
||||
bundle.add_action("image_processing_rotate_left")
|
||||
bundle.add_action("image_processing_reset")
|
||||
return bundle
|
||||
|
||||
|
||||
class ImageProcessingConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for the image processing toolbar bundle.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "image_processing"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if (
|
||||
not hasattr(self.target_widget, "fft")
|
||||
or not hasattr(self.target_widget, "log")
|
||||
or not hasattr(self.target_widget, "transpose")
|
||||
or not hasattr(self.target_widget, "num_rotation_90")
|
||||
):
|
||||
raise AttributeError(
|
||||
"Target widget must implement 'fft', 'log', 'transpose', and 'num_rotation_90' attributes."
|
||||
)
|
||||
super().__init__()
|
||||
self.fft = components.get_action("image_processing_fft")
|
||||
self.log = components.get_action("image_processing_log")
|
||||
self.transpose = components.get_action("image_processing_transpose")
|
||||
self.right = components.get_action("image_processing_rotate_right")
|
||||
self.left = components.get_action("image_processing_rotate_left")
|
||||
self.reset = components.get_action("image_processing_reset")
|
||||
self._connected = False
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_fft(self):
|
||||
checked = self.fft.action.isChecked()
|
||||
self.target_widget.fft = checked
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_log(self):
|
||||
checked = self.log.action.isChecked()
|
||||
self.target_widget.log = checked
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_transpose(self):
|
||||
checked = self.transpose.action.isChecked()
|
||||
self.target_widget.transpose = checked
|
||||
|
||||
@SafeSlot()
|
||||
def rotate_right(self):
|
||||
if self.target_widget.num_rotation_90 is None:
|
||||
return
|
||||
rotation = (self.target_widget.num_rotation_90 - 1) % 4
|
||||
self.target_widget.num_rotation_90 = rotation
|
||||
|
||||
@SafeSlot()
|
||||
def rotate_left(self):
|
||||
if self.target_widget.num_rotation_90 is None:
|
||||
return
|
||||
rotation = (self.target_widget.num_rotation_90 + 1) % 4
|
||||
self.target_widget.num_rotation_90 = rotation
|
||||
|
||||
@SafeSlot()
|
||||
def reset_settings(self):
|
||||
self.target_widget.fft = False
|
||||
self.target_widget.log = False
|
||||
self.target_widget.transpose = False
|
||||
self.target_widget.num_rotation_90 = 0
|
||||
|
||||
self.fft.action.setChecked(False)
|
||||
self.log.action.setChecked(False)
|
||||
self.transpose.action.setChecked(False)
|
||||
|
||||
def connect(self):
|
||||
"""
|
||||
Connect the actions to the target widget's methods.
|
||||
"""
|
||||
self._connected = True
|
||||
self.fft.action.triggered.connect(self.toggle_fft)
|
||||
self.log.action.triggered.connect(self.toggle_log)
|
||||
self.transpose.action.triggered.connect(self.toggle_transpose)
|
||||
self.right.action.triggered.connect(self.rotate_right)
|
||||
self.left.action.triggered.connect(self.rotate_left)
|
||||
self.reset.action.triggered.connect(self.reset_settings)
|
||||
|
||||
def disconnect(self):
|
||||
"""
|
||||
Disconnect the actions from the target widget's methods.
|
||||
"""
|
||||
if not self._connected:
|
||||
return
|
||||
self.fft.action.triggered.disconnect(self.toggle_fft)
|
||||
self.log.action.triggered.disconnect(self.toggle_log)
|
||||
self.transpose.action.triggered.disconnect(self.toggle_transpose)
|
||||
self.right.action.triggered.disconnect(self.rotate_right)
|
||||
self.left.action.triggered.disconnect(self.rotate_left)
|
||||
self.reset.action.triggered.disconnect(self.reset_settings)
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
@@ -15,12 +14,12 @@ from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
from bec_widgets.widgets.plots.motor_map.settings.motor_map_settings import MotorMapSettings
|
||||
from bec_widgets.widgets.plots.motor_map.toolbar_bundles.motor_selection import (
|
||||
MotorSelectionToolbarBundle,
|
||||
from bec_widgets.widgets.plots.motor_map.toolbar_components.motor_selection import (
|
||||
MotorSelectionAction,
|
||||
)
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -182,33 +181,60 @@ class MotorMap(PlotBase):
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self._update_plot
|
||||
)
|
||||
self._init_motor_map_toolbar()
|
||||
self._add_motor_map_settings()
|
||||
|
||||
################################################################################
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
|
||||
def _init_toolbar(self):
|
||||
def _init_motor_map_toolbar(self):
|
||||
"""
|
||||
Initialize the toolbar for the motor map widget.
|
||||
"""
|
||||
self.motor_selection_bundle = MotorSelectionToolbarBundle(
|
||||
bundle_id="motor_selection", target_widget=self
|
||||
)
|
||||
self.toolbar.add_bundle(self.motor_selection_bundle, target_widget=self)
|
||||
super()._init_toolbar()
|
||||
self.toolbar.widgets["reset_legend"].action.setVisible(False)
|
||||
motor_selection = MotorSelectionAction(parent=self)
|
||||
self.toolbar.add_action("motor_selection", motor_selection)
|
||||
|
||||
self.reset_legend_action = MaterialIconAction(
|
||||
icon_name="history", tooltip="Reset the position of legend."
|
||||
motor_selection.motor_x.currentTextChanged.connect(self.on_motor_selection_changed)
|
||||
motor_selection.motor_y.currentTextChanged.connect(self.on_motor_selection_changed)
|
||||
|
||||
self.toolbar.components.get_action("reset_legend").action.setVisible(False)
|
||||
|
||||
reset_legend = MaterialIconAction(
|
||||
icon_name="history",
|
||||
tooltip="Reset the position of legend.",
|
||||
checkable=False,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="roi",
|
||||
action_id="motor_map_history",
|
||||
action=self.reset_legend_action,
|
||||
target_widget=self,
|
||||
self.toolbar.components.add_safe("reset_motor_map_legend", reset_legend)
|
||||
self.toolbar.get_bundle("roi").add_action("reset_motor_map_legend")
|
||||
reset_legend.action.triggered.connect(self.reset_history)
|
||||
|
||||
settings_brightness = MaterialIconAction(
|
||||
icon_name="settings_brightness",
|
||||
tooltip="Show Motor Map Settings",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.reset_legend_action.action.triggered.connect(self.reset_history)
|
||||
self.toolbar.components.add_safe("motor_map_settings", settings_brightness)
|
||||
self.toolbar.get_bundle("axis_popup").add_action("motor_map_settings")
|
||||
|
||||
settings_brightness.action.triggered.connect(self.show_motor_map_settings)
|
||||
|
||||
bundles = ["motor_selection", "plot_export", "mouse_interaction", "roi"]
|
||||
if self.ui_mode == UIMode.POPUP:
|
||||
bundles.append("axis_popup")
|
||||
self.toolbar.show_bundles(bundles)
|
||||
|
||||
@SafeSlot()
|
||||
def on_motor_selection_changed(self, _):
|
||||
action: MotorSelectionAction = self.toolbar.components.get_action("motor_selection")
|
||||
motor_x = action.motor_x.currentText()
|
||||
motor_y = action.motor_y.currentText()
|
||||
|
||||
if motor_x != "" and motor_y != "":
|
||||
if motor_x != self.config.x_motor.name or motor_y != self.config.y_motor.name:
|
||||
self.map(motor_x, motor_y)
|
||||
|
||||
def _add_motor_map_settings(self):
|
||||
"""Add the motor map settings to the side panel."""
|
||||
@@ -221,32 +247,11 @@ class MotorMap(PlotBase):
|
||||
title="Motor Map Settings",
|
||||
)
|
||||
|
||||
def add_popups(self):
|
||||
"""
|
||||
Add popups to the ScatterWaveform widget.
|
||||
"""
|
||||
super().add_popups()
|
||||
scatter_curve_setting_action = MaterialIconAction(
|
||||
icon_name="settings_brightness",
|
||||
tooltip="Show Motor Map Settings",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="popup_bundle",
|
||||
action_id="motor_map_settings",
|
||||
action=scatter_curve_setting_action,
|
||||
target_widget=self,
|
||||
)
|
||||
self.toolbar.widgets["motor_map_settings"].action.triggered.connect(
|
||||
self.show_motor_map_settings
|
||||
)
|
||||
|
||||
def show_motor_map_settings(self):
|
||||
"""
|
||||
Show the DAP summary popup.
|
||||
"""
|
||||
action = self.toolbar.widgets["motor_map_settings"].action
|
||||
action = self.toolbar.components.get_action("motor_map_settings").action
|
||||
if self.motor_map_settings is None or not self.motor_map_settings.isVisible():
|
||||
motor_map_settings = MotorMapSettings(parent=self, target_widget=self, popup=True)
|
||||
self.motor_map_settings = SettingsDialog(
|
||||
@@ -272,7 +277,7 @@ class MotorMap(PlotBase):
|
||||
"""
|
||||
self.motor_map_settings.deleteLater()
|
||||
self.motor_map_settings = None
|
||||
self.toolbar.widgets["motor_map_settings"].action.setChecked(False)
|
||||
self.toolbar.components.get_action("motor_map_settings").action.setChecked(False)
|
||||
|
||||
################################################################################
|
||||
# Widget Specific Properties
|
||||
@@ -766,20 +771,21 @@ class MotorMap(PlotBase):
|
||||
"""
|
||||
Sync the motor map selection toolbar with the current motor map.
|
||||
"""
|
||||
if self.motor_selection_bundle is not None:
|
||||
motor_x = self.motor_selection_bundle.motor_x.currentText()
|
||||
motor_y = self.motor_selection_bundle.motor_y.currentText()
|
||||
motor_selection = self.toolbar.components.get_action("motor_selection")
|
||||
|
||||
if motor_x != self.config.x_motor.name:
|
||||
self.motor_selection_bundle.motor_x.blockSignals(True)
|
||||
self.motor_selection_bundle.motor_x.set_device(self.config.x_motor.name)
|
||||
self.motor_selection_bundle.motor_x.check_validity(self.config.x_motor.name)
|
||||
self.motor_selection_bundle.motor_x.blockSignals(False)
|
||||
if motor_y != self.config.y_motor.name:
|
||||
self.motor_selection_bundle.motor_y.blockSignals(True)
|
||||
self.motor_selection_bundle.motor_y.set_device(self.config.y_motor.name)
|
||||
self.motor_selection_bundle.motor_y.check_validity(self.config.y_motor.name)
|
||||
self.motor_selection_bundle.motor_y.blockSignals(False)
|
||||
motor_x = motor_selection.motor_x.currentText()
|
||||
motor_y = motor_selection.motor_y.currentText()
|
||||
|
||||
if motor_x != self.config.x_motor.name:
|
||||
motor_selection.motor_x.blockSignals(True)
|
||||
motor_selection.motor_x.set_device(self.config.x_motor.name)
|
||||
motor_selection.motor_x.check_validity(self.config.x_motor.name)
|
||||
motor_selection.motor_x.blockSignals(False)
|
||||
if motor_y != self.config.y_motor.name:
|
||||
motor_selection.motor_y.blockSignals(True)
|
||||
motor_selection.motor_y.set_device(self.config.y_motor.name)
|
||||
motor_selection.motor_y.check_validity(self.config.y_motor.name)
|
||||
motor_selection.motor_y.blockSignals(False)
|
||||
|
||||
################################################################################
|
||||
# Export Methods
|
||||
@@ -795,10 +801,6 @@ class MotorMap(PlotBase):
|
||||
data = {"x": self._buffer["x"], "y": self._buffer["y"]}
|
||||
return data
|
||||
|
||||
def cleanup(self):
|
||||
self.motor_selection_bundle.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
class DemoApp(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QStyledItemDelegate
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
|
||||
class NoCheckDelegate(QStyledItemDelegate):
|
||||
"""To reduce space in combo boxes by removing the checkmark."""
|
||||
|
||||
def initStyleOption(self, option, index):
|
||||
super().initStyleOption(option, index)
|
||||
# Remove any check indicator
|
||||
option.checkState = Qt.Unchecked
|
||||
|
||||
|
||||
class MotorSelectionToolbarBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions for a toolbar that selects motors.
|
||||
"""
|
||||
|
||||
def __init__(self, bundle_id="motor_selection", target_widget=None, **kwargs):
|
||||
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
|
||||
self.target_widget = target_widget
|
||||
|
||||
# Motor X
|
||||
self.motor_x = DeviceComboBox(
|
||||
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
|
||||
)
|
||||
self.motor_x.addItem("", None)
|
||||
self.motor_x.setCurrentText("")
|
||||
self.motor_x.setToolTip("Select Motor X")
|
||||
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
|
||||
|
||||
# Motor X
|
||||
self.motor_y = DeviceComboBox(
|
||||
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
|
||||
)
|
||||
self.motor_y.addItem("", None)
|
||||
self.motor_y.setCurrentText("")
|
||||
self.motor_y.setToolTip("Select Motor Y")
|
||||
self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y))
|
||||
|
||||
self.add_action("motor_x", WidgetAction(widget=self.motor_x, adjust_size=False))
|
||||
self.add_action("motor_y", WidgetAction(widget=self.motor_y, adjust_size=False))
|
||||
|
||||
# Connect slots, a device will be connected upon change of any combobox
|
||||
self.motor_x.currentTextChanged.connect(lambda: self.connect_motors())
|
||||
self.motor_y.currentTextChanged.connect(lambda: self.connect_motors())
|
||||
|
||||
@SafeSlot()
|
||||
def connect_motors(self):
|
||||
motor_x = self.motor_x.currentText()
|
||||
motor_y = self.motor_y.currentText()
|
||||
|
||||
if motor_x != "" and motor_y != "":
|
||||
if (
|
||||
motor_x != self.target_widget.config.x_motor.name
|
||||
or motor_y != self.target_widget.config.y_motor.name
|
||||
):
|
||||
self.target_widget.map(motor_x, motor_y)
|
||||
|
||||
def cleanup(self):
|
||||
self.motor_x.close()
|
||||
self.motor_x.deleteLater()
|
||||
self.motor_y.close()
|
||||
self.motor_y.deleteLater()
|
||||
@@ -0,0 +1,51 @@
|
||||
from qtpy.QtWidgets import QHBoxLayout, QToolBar, QWidget
|
||||
|
||||
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, ToolBarAction
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
|
||||
class MotorSelectionAction(ToolBarAction):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(icon_path=None, tooltip=None, checkable=False)
|
||||
self.motor_x = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER])
|
||||
self.motor_x.addItem("", None)
|
||||
self.motor_x.setCurrentText("")
|
||||
self.motor_x.setToolTip("Select Motor X")
|
||||
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
|
||||
self.motor_y = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER])
|
||||
self.motor_y.addItem("", None)
|
||||
self.motor_y.setCurrentText("")
|
||||
self.motor_y.setToolTip("Select Motor Y")
|
||||
self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y))
|
||||
|
||||
self.container = QWidget(parent)
|
||||
layout = QHBoxLayout(self.container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.motor_x)
|
||||
layout.addWidget(self.motor_y)
|
||||
self.container.setLayout(layout)
|
||||
self.action = self.container
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the widget to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the widget to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
|
||||
toolbar.addWidget(self.container)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the action, if necessary.
|
||||
"""
|
||||
self.motor_x.close()
|
||||
self.motor_x.deleteLater()
|
||||
self.motor_y.close()
|
||||
self.motor_y.deleteLater()
|
||||
self.container.close()
|
||||
self.container.deleteLater()
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
@@ -12,13 +13,15 @@ from qtpy.QtWidgets import QWidget
|
||||
from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.side_panel import SidePanel
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.plots.multi_waveform.settings.control_panel import (
|
||||
MultiWaveformControlPanel,
|
||||
)
|
||||
from bec_widgets.widgets.plots.multi_waveform.toolbar_bundles.monitor_selection import (
|
||||
MultiWaveformSelectionToolbarBundle,
|
||||
from bec_widgets.widgets.plots.multi_waveform.toolbar_components.monitor_selection import (
|
||||
monitor_selection_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -141,33 +144,54 @@ class MultiWaveform(PlotBase):
|
||||
self.visible_curves = []
|
||||
self.number_of_visible_curves = 0
|
||||
|
||||
self._init_control_panel()
|
||||
self._init_multiwaveform_toolbar()
|
||||
|
||||
################################################################################
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
def _init_toolbar(self):
|
||||
self.monitor_selection_bundle = MultiWaveformSelectionToolbarBundle(
|
||||
bundle_id="motor_selection", target_widget=self
|
||||
def _init_multiwaveform_toolbar(self):
|
||||
self.toolbar.add_bundle(
|
||||
monitor_selection_bundle(self.toolbar.components, target_widget=self)
|
||||
)
|
||||
self.toolbar.add_bundle(self.monitor_selection_bundle, target_widget=self)
|
||||
super()._init_toolbar()
|
||||
self.toolbar.widgets["reset_legend"].action.setVisible(False)
|
||||
self.toolbar.toggle_action_visibility("reset_legend", visible=False)
|
||||
|
||||
combobox = self.toolbar.components.get_action("monitor_selection").widget
|
||||
combobox.currentTextChanged.connect(self.connect_monitor)
|
||||
|
||||
cmap = self.toolbar.components.get_action("color_map").widget
|
||||
cmap.colormap_changed_signal.connect(self.change_colormap)
|
||||
|
||||
bundles = self.toolbar.shown_bundles
|
||||
bundles.insert(0, "monitor_selection")
|
||||
self.toolbar.show_bundles(bundles)
|
||||
|
||||
self._init_control_panel()
|
||||
|
||||
def _init_control_panel(self):
|
||||
self.control_panel = SidePanel(self, orientation="top", panel_max_width=90)
|
||||
self.layout_manager.add_widget_relative(
|
||||
self.control_panel, self.round_plot_widget, "bottom"
|
||||
)
|
||||
control_panel = SidePanel(self, orientation="top", panel_max_width=90)
|
||||
self.layout_manager.add_widget_relative(control_panel, self.round_plot_widget, "bottom")
|
||||
self.controls = MultiWaveformControlPanel(parent=self, target_widget=self)
|
||||
self.control_panel.add_menu(
|
||||
control_panel.add_menu(
|
||||
action_id="control",
|
||||
icon_name="tune",
|
||||
tooltip="Show Control panel",
|
||||
widget=self.controls,
|
||||
title=None,
|
||||
)
|
||||
self.control_panel.toolbar.widgets["control"].action.trigger()
|
||||
control_panel.toolbar.components.get_action("control").action.trigger()
|
||||
|
||||
@SafeSlot()
|
||||
def connect_monitor(self, _):
|
||||
combobox = self.toolbar.components.get_action("monitor_selection").widget
|
||||
monitor = combobox.currentText()
|
||||
|
||||
if monitor != "":
|
||||
if monitor != self.config.monitor:
|
||||
self.config.monitor = monitor
|
||||
|
||||
@SafeSlot(str)
|
||||
def change_colormap(self, colormap: str):
|
||||
self.color_palette = colormap
|
||||
|
||||
################################################################################
|
||||
# Widget Specific Properties
|
||||
@@ -488,23 +512,30 @@ class MultiWaveform(PlotBase):
|
||||
"""
|
||||
Sync the motor map selection toolbar with the current motor map.
|
||||
"""
|
||||
if self.monitor_selection_bundle is not None:
|
||||
monitor = self.monitor_selection_bundle.monitor.currentText()
|
||||
color_palette = self.monitor_selection_bundle.colormap_widget.colormap
|
||||
|
||||
if monitor != self.config.monitor:
|
||||
self.monitor_selection_bundle.monitor.blockSignals(True)
|
||||
self.monitor_selection_bundle.monitor.set_device(self.config.monitor)
|
||||
self.monitor_selection_bundle.monitor.check_validity(self.config.monitor)
|
||||
self.monitor_selection_bundle.monitor.blockSignals(False)
|
||||
combobox_widget: DeviceComboBox = cast(
|
||||
DeviceComboBox, self.toolbar.components.get_action("monitor_selection").widget
|
||||
)
|
||||
cmap_widget: BECColorMapWidget = cast(
|
||||
BECColorMapWidget, self.toolbar.components.get_action("color_map").widget
|
||||
)
|
||||
|
||||
if color_palette != self.config.color_palette:
|
||||
self.monitor_selection_bundle.colormap_widget.blockSignals(True)
|
||||
self.monitor_selection_bundle.colormap_widget.colormap = self.config.color_palette
|
||||
self.monitor_selection_bundle.colormap_widget.blockSignals(False)
|
||||
monitor = combobox_widget.currentText()
|
||||
color_palette = cmap_widget.colormap
|
||||
|
||||
if monitor != self.config.monitor:
|
||||
combobox_widget.setCurrentText(monitor)
|
||||
combobox_widget.blockSignals(True)
|
||||
combobox_widget.set_device(self.config.monitor)
|
||||
combobox_widget.check_validity(self.config.monitor)
|
||||
combobox_widget.blockSignals(False)
|
||||
|
||||
if color_palette != self.config.color_palette:
|
||||
cmap_widget.blockSignals(True)
|
||||
cmap_widget.colormap = self.config.color_palette
|
||||
cmap_widget.blockSignals(False)
|
||||
|
||||
def cleanup(self):
|
||||
self._disconnect_monitor()
|
||||
self.clear_curves()
|
||||
self.monitor_selection_bundle.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QStyledItemDelegate
|
||||
from qtpy.QtWidgets import QStyledItemDelegate, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction
|
||||
from bec_widgets.utils.toolbars.actions import DeviceComboBoxAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.toolbar import ToolbarBundle
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||
@@ -18,6 +20,37 @@ class NoCheckDelegate(QStyledItemDelegate):
|
||||
option.checkState = Qt.Unchecked
|
||||
|
||||
|
||||
def monitor_selection_bundle(
|
||||
components: ToolbarComponents, target_widget: QWidget
|
||||
) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a monitor selection toolbar bundle.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The monitor selection toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"monitor_selection",
|
||||
DeviceComboBoxAction(
|
||||
target_widget=target_widget,
|
||||
device_filter=[BECDeviceFilter.DEVICE],
|
||||
readout_priority_filter=ReadoutPriority.ASYNC,
|
||||
add_empty_item=True,
|
||||
no_check_delegate=True,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"color_map", WidgetAction(widget=BECColorMapWidget(cmap="plasma"), adjust_size=False)
|
||||
)
|
||||
bundle = ToolbarBundle("monitor_selection", components)
|
||||
bundle.add_action("monitor_selection")
|
||||
bundle.add_action("color_map")
|
||||
return bundle
|
||||
|
||||
|
||||
class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions for a toolbar that selects motors.
|
||||
@@ -14,17 +14,25 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.fps_counter import FPSCounter
|
||||
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.side_panel import SidePanel
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar, ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
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.setting_menus.axis_settings import AxisSettings
|
||||
from bec_widgets.widgets.plots.toolbar_bundles.mouse_interactions import (
|
||||
MouseInteractionToolbarBundle,
|
||||
from bec_widgets.widgets.plots.toolbar_components.axis_settings_popup import (
|
||||
AxisSettingsPopupConnection,
|
||||
axis_popup_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.toolbar_bundles.plot_export import PlotExportBundle
|
||||
from bec_widgets.widgets.plots.toolbar_bundles.roi_bundle import ROIBundle
|
||||
from bec_widgets.widgets.plots.toolbar_components.mouse_interactions import (
|
||||
MouseInteractionConnection,
|
||||
mouse_interaction_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.toolbar_components.plot_export import (
|
||||
PlotExportConnection,
|
||||
plot_export_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.toolbar_components.roi import RoiConnection, roi_bundle
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -102,8 +110,6 @@ class PlotBase(BECWidget, QWidget):
|
||||
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(parent=self, target_widget=self, orientation="horizontal")
|
||||
self._init_toolbar()
|
||||
|
||||
# PlotItem Addons
|
||||
self.plot_item.addLegend()
|
||||
@@ -122,6 +128,9 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
|
||||
self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
|
||||
self._init_toolbar()
|
||||
|
||||
self._init_ui()
|
||||
|
||||
self._connect_to_theme_change()
|
||||
@@ -146,36 +155,33 @@ class PlotBase(BECWidget, QWidget):
|
||||
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)
|
||||
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
|
||||
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
|
||||
self.toolbar.add_bundle(mouse_interaction_bundle(self.toolbar.components))
|
||||
self.toolbar.add_bundle(roi_bundle(self.toolbar.components))
|
||||
self.toolbar.add_bundle(axis_popup_bundle(self.toolbar.components))
|
||||
|
||||
# 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.connect_bundle(
|
||||
"plot_base", PlotExportConnection(self.toolbar.components, 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)
|
||||
self.toolbar.connect_bundle(
|
||||
"plot_base", PerformanceConnection(self.toolbar.components, self)
|
||||
)
|
||||
self.toolbar.connect_bundle(
|
||||
"plot_base", MouseInteractionConnection(self.toolbar.components, self)
|
||||
)
|
||||
self.toolbar.connect_bundle("plot_base", RoiConnection(self.toolbar.components, self))
|
||||
self.toolbar.connect_bundle(
|
||||
"plot_base", AxisSettingsPopupConnection(self.toolbar.components, self)
|
||||
)
|
||||
|
||||
# hide some options by default
|
||||
self.toolbar.toggle_action_visibility("fps_monitor", False)
|
||||
|
||||
# Get default viewbox state
|
||||
self.mouse_bundle.get_viewbox_mode()
|
||||
self.toolbar.show_bundles(
|
||||
["plot_export", "mouse_interaction", "roi", "performance", "axis_popup"]
|
||||
)
|
||||
|
||||
def add_side_menus(self):
|
||||
"""Adds multiple menus to the side panel."""
|
||||
@@ -192,45 +198,6 @@ class PlotBase(BECWidget, QWidget):
|
||||
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(parent=self, 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)
|
||||
@@ -257,22 +224,23 @@ class PlotBase(BECWidget, QWidget):
|
||||
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)
|
||||
shown_bundles = self.toolbar.shown_bundles
|
||||
if "axis_popup" not in shown_bundles:
|
||||
shown_bundles.append("axis_popup")
|
||||
self.toolbar.show_bundles(shown_bundles)
|
||||
self.side_panel.hide()
|
||||
|
||||
elif mode == UIMode.SIDE:
|
||||
shown_bundles = self.toolbar.shown_bundles
|
||||
if "axis_popup" in shown_bundles:
|
||||
shown_bundles.remove("axis_popup")
|
||||
self.toolbar.show_bundles(shown_bundles)
|
||||
pb_connection = self.toolbar.bundles["axis_popup"].get_connection("plot_base")
|
||||
if pb_connection.axis_settings_dialog is not None:
|
||||
pb_connection.axis_settings_dialog.close()
|
||||
pb_connection.axis_settings_dialog = None
|
||||
self.add_side_menus()
|
||||
self.side_panel.show()
|
||||
|
||||
@@ -1049,6 +1017,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.axis_settings_dialog = None
|
||||
self.cleanup_pyqtgraph()
|
||||
self.round_plot_widget.close()
|
||||
self.toolbar.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
def cleanup_pyqtgraph(self, item: pg.PlotItem | None = None):
|
||||
@@ -1087,8 +1056,12 @@ if __name__ == "__main__": # pragma: no cover:
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
window = PlotBase()
|
||||
window.show()
|
||||
launch_window = BECMainWindow()
|
||||
pb = PlotBase(popups=False)
|
||||
launch_window.setCentralWidget(pb)
|
||||
launch_window.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -13,7 +13,7 @@ from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_curve import (
|
||||
ScatterCurve,
|
||||
@@ -131,8 +131,8 @@ class ScatterWaveform(PlotBase):
|
||||
self.proxy_update_sync = pg.SignalProxy(
|
||||
self.sync_signal_update, rateLimit=25, slot=self.update_sync_curves
|
||||
)
|
||||
if self.ui_mode == UIMode.SIDE:
|
||||
self._init_scatter_curve_settings()
|
||||
|
||||
self._init_scatter_curve_settings()
|
||||
self.update_with_scan_history(-1)
|
||||
|
||||
################################################################################
|
||||
@@ -143,44 +143,40 @@ class ScatterWaveform(PlotBase):
|
||||
"""
|
||||
Initialize the scatter curve settings menu.
|
||||
"""
|
||||
if self.ui_mode == UIMode.SIDE:
|
||||
self.scatter_curve_settings = ScatterCurveSettings(
|
||||
parent=self, target_widget=self, popup=False
|
||||
)
|
||||
self.side_panel.add_menu(
|
||||
action_id="scatter_curve",
|
||||
icon_name="scatter_plot",
|
||||
tooltip="Show Scatter Curve Settings",
|
||||
widget=self.scatter_curve_settings,
|
||||
title="Scatter Curve Settings",
|
||||
)
|
||||
else:
|
||||
scatter_curve_action = MaterialIconAction(
|
||||
icon_name="scatter_plot",
|
||||
tooltip="Show Scatter Curve Settings",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.components.add_safe("scatter_waveform_settings", scatter_curve_action)
|
||||
self.toolbar.get_bundle("axis_popup").add_action("scatter_waveform_settings")
|
||||
scatter_curve_action.action.triggered.connect(self.show_scatter_curve_settings)
|
||||
|
||||
self.scatter_curve_settings = ScatterCurveSettings(
|
||||
parent=self, target_widget=self, popup=False
|
||||
)
|
||||
self.side_panel.add_menu(
|
||||
action_id="scatter_curve",
|
||||
icon_name="scatter_plot",
|
||||
tooltip="Show Scatter Curve Settings",
|
||||
widget=self.scatter_curve_settings,
|
||||
title="Scatter Curve Settings",
|
||||
)
|
||||
|
||||
def add_popups(self):
|
||||
"""
|
||||
Add popups to the ScatterWaveform widget.
|
||||
"""
|
||||
super().add_popups()
|
||||
scatter_curve_setting_action = MaterialIconAction(
|
||||
icon_name="scatter_plot",
|
||||
tooltip="Show Scatter Curve Settings",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="popup_bundle",
|
||||
action_id="scatter_waveform_settings",
|
||||
action=scatter_curve_setting_action,
|
||||
target_widget=self,
|
||||
)
|
||||
self.toolbar.widgets["scatter_waveform_settings"].action.triggered.connect(
|
||||
self.show_scatter_curve_settings
|
||||
)
|
||||
shown_bundles = self.toolbar.shown_bundles
|
||||
if "performance" in shown_bundles:
|
||||
shown_bundles.remove("performance")
|
||||
self.toolbar.show_bundles(shown_bundles)
|
||||
|
||||
def show_scatter_curve_settings(self):
|
||||
"""
|
||||
Show the scatter curve settings dialog.
|
||||
"""
|
||||
scatter_settings_action = self.toolbar.widgets["scatter_waveform_settings"].action
|
||||
scatter_settings_action = self.toolbar.components.get_action(
|
||||
"scatter_waveform_settings"
|
||||
).action
|
||||
if self.scatter_dialog is None or not self.scatter_dialog.isVisible():
|
||||
scatter_settings = ScatterCurveSettings(parent=self, target_widget=self, popup=True)
|
||||
self.scatter_dialog = SettingsDialog(
|
||||
@@ -205,7 +201,7 @@ class ScatterWaveform(PlotBase):
|
||||
Slot for when the scatter curve settings dialog is closed.
|
||||
"""
|
||||
self.scatter_dialog = None
|
||||
self.toolbar.widgets["scatter_waveform_settings"].action.setChecked(False)
|
||||
self.toolbar.components.get_action("scatter_waveform_settings").action.setChecked(False)
|
||||
|
||||
################################################################################
|
||||
# Widget Specific Properties
|
||||
|
||||
@@ -141,6 +141,14 @@
|
||||
<header>bec_color_map_widget</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<tabstops>
|
||||
<tabstop>x_name</tabstop>
|
||||
<tabstop>x_entry</tabstop>
|
||||
<tabstop>y_name</tabstop>
|
||||
<tabstop>y_entry</tabstop>
|
||||
<tabstop>z_name</tabstop>
|
||||
<tabstop>z_entry</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.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.target_widget,
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
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
|
||||
@@ -1,81 +0,0 @@
|
||||
import traceback
|
||||
|
||||
from pyqtgraph.exporters import MatplotlibExporter
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot, WarningPopupUtility
|
||||
from bec_widgets.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 ModuleNotFoundError:
|
||||
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
|
||||
except TypeError:
|
||||
warning_util = WarningPopupUtility()
|
||||
error_msg = traceback.format_exc()
|
||||
warning_util.show_warning(
|
||||
title="Matplotlib TypeError",
|
||||
message="Matplotlib exporter could not resolve the plot item.",
|
||||
detailed_text=error_msg,
|
||||
)
|
||||
return
|
||||
@@ -1,31 +0,0 @@
|
||||
from bec_widgets.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)
|
||||
@@ -1,48 +0,0 @@
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.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()
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.widgets.plots.setting_menus.axis_settings import AxisSettings
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.toolbars.toolbar import ToolbarComponents
|
||||
|
||||
|
||||
def axis_popup_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates an axis popup toolbar bundle.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The axis popup toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"axis_settings_popup",
|
||||
MaterialIconAction(
|
||||
icon_name="settings",
|
||||
tooltip="Show Axis Settings",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("axis_popup", components)
|
||||
bundle.add_action("axis_settings_popup")
|
||||
return bundle
|
||||
|
||||
|
||||
class AxisSettingsPopupConnection(BundleConnection):
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "axis_popup"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
self.axis_settings_dialog = None
|
||||
self._connected = False
|
||||
super().__init__()
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action_reference("axis_settings_popup")().action.triggered.connect(
|
||||
self.show_axis_settings_popup
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action_reference("axis_settings_popup")().action.triggered.disconnect(
|
||||
self.show_axis_settings_popup
|
||||
)
|
||||
|
||||
def show_axis_settings_popup(self):
|
||||
"""
|
||||
Show the axis settings dialog.
|
||||
"""
|
||||
settings_action = self.components.get_action_reference("axis_settings_popup")().action
|
||||
if self.axis_settings_dialog is None or not self.axis_settings_dialog.isVisible():
|
||||
axis_setting = AxisSettings(
|
||||
parent=self.target_widget, target_widget=self.target_widget, popup=True
|
||||
)
|
||||
self.axis_settings_dialog = SettingsDialog(
|
||||
self.target_widget,
|
||||
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.components.get_action_reference("axis_settings_popup")().action.setChecked(False)
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pyqtgraph as pg
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.toolbars.toolbar import ToolbarComponents
|
||||
|
||||
|
||||
def mouse_interaction_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a mouse interaction toolbar bundle.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The mouse interaction toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"mouse_drag",
|
||||
MaterialIconAction(
|
||||
icon_name="drag_pan",
|
||||
tooltip="Drag Mouse Mode",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"mouse_rect",
|
||||
MaterialIconAction(
|
||||
icon_name="frame_inspect",
|
||||
tooltip="Rectangle Zoom Mode",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"auto_range",
|
||||
MaterialIconAction(
|
||||
icon_name="open_in_full",
|
||||
tooltip="Autorange Plot",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"switch_mouse_mode",
|
||||
SwitchableToolBarAction(
|
||||
actions={
|
||||
"drag_mode": components.get_action_reference("mouse_drag")(),
|
||||
"rectangle_mode": components.get_action_reference("mouse_rect")(),
|
||||
},
|
||||
initial_action="drag_mode",
|
||||
tooltip="Mouse Modes",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
default_state_checked=True,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("mouse_interaction", components)
|
||||
bundle.add_action("switch_mouse_mode")
|
||||
bundle.add_action("auto_range")
|
||||
return bundle
|
||||
|
||||
|
||||
class MouseInteractionConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for mouse interaction toolbar bundle.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "mouse_interaction"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
self.mouse_mode = None
|
||||
if (
|
||||
not hasattr(self.target_widget, "plot_item")
|
||||
or not hasattr(self.target_widget, "auto_range_x")
|
||||
or not hasattr(self.target_widget, "auto_range_y")
|
||||
):
|
||||
raise AttributeError(
|
||||
"Target widget must implement required methods for mouse interactions."
|
||||
)
|
||||
super().__init__()
|
||||
self._connected = False # Track if the connection has been made
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
drag = self.components.get_action_reference("mouse_drag")()
|
||||
rect = self.components.get_action_reference("mouse_rect")()
|
||||
auto = self.components.get_action_reference("auto_range")()
|
||||
|
||||
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)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
drag = self.components.get_action_reference("mouse_drag")()
|
||||
rect = self.components.get_action_reference("mouse_rect")()
|
||||
auto = self.components.get_action_reference("auto_range")()
|
||||
drag.action.toggled.disconnect(self.enable_mouse_pan_mode)
|
||||
rect.action.toggled.disconnect(self.enable_mouse_rectangle_mode)
|
||||
auto.action.triggered.disconnect(self.autorange_plot)
|
||||
|
||||
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()
|
||||
switch_mouse_action = self.components.get_action_reference("switch_mouse_mode")()
|
||||
if viewbox.getState()["mouseMode"] == 3:
|
||||
switch_mouse_action.set_default_action("drag_mode")
|
||||
switch_mouse_action.main_button.setChecked(True)
|
||||
self.mouse_mode = "PanMode"
|
||||
elif viewbox.getState()["mouseMode"] == 1:
|
||||
switch_mouse_action.set_default_action("rectangle_mode")
|
||||
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.
|
||||
"""
|
||||
switch_mouse_action = self.components.get_action_reference("switch_mouse_mode")()
|
||||
if self.mouse_mode == "RectMode":
|
||||
switch_mouse_action.main_button.setChecked(True)
|
||||
return
|
||||
drag_mode = self.components.get_action_reference("mouse_drag")()
|
||||
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":
|
||||
switch_mouse_action = self.components.get_action_reference("switch_mouse_mode")()
|
||||
switch_mouse_action.main_button.setChecked(True)
|
||||
return
|
||||
rect_mode = self.components.get_action_reference("mouse_rect")()
|
||||
rect_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
|
||||
123
bec_widgets/widgets/plots/toolbar_components/plot_export.py
Normal file
123
bec_widgets/widgets/plots/toolbar_components/plot_export.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot, WarningPopupUtility
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
|
||||
def plot_export_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a plot export toolbar bundle.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The plot export toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"save",
|
||||
MaterialIconAction(
|
||||
icon_name="save", tooltip="Open Export Dialog", parent=components.toolbar
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"matplotlib",
|
||||
MaterialIconAction(
|
||||
icon_name="photo_library", tooltip="Open Matplotlib Dialog", parent=components.toolbar
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"export_switch",
|
||||
SwitchableToolBarAction(
|
||||
actions={
|
||||
"save": components.get_action_reference("save")(),
|
||||
"matplotlib": components.get_action_reference("matplotlib")(),
|
||||
},
|
||||
initial_action="save",
|
||||
tooltip="Export Plot",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("plot_export", components)
|
||||
bundle.add_action("export_switch")
|
||||
return bundle
|
||||
|
||||
|
||||
def plot_export_connection(components: ToolbarComponents, target_widget=None):
|
||||
"""
|
||||
Connects the plot export actions to the target widget.
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be connected.
|
||||
target_widget: The widget to which the actions will be connected.
|
||||
"""
|
||||
|
||||
|
||||
class PlotExportConnection(BundleConnection):
|
||||
def __init__(self, components: ToolbarComponents, target_widget):
|
||||
super().__init__()
|
||||
self.bundle_name = "plot_export"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
self._connected = False # Track if the connection has been made
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the actions to the target widget
|
||||
self.components.get_action_reference("save")().action.triggered.connect(self.export_dialog)
|
||||
self.components.get_action_reference("matplotlib")().action.triggered.connect(
|
||||
self.matplotlib_dialog
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the actions from the target widget
|
||||
self.components.get_action_reference("save")().action.triggered.disconnect(
|
||||
self.export_dialog
|
||||
)
|
||||
self.components.get_action_reference("matplotlib")().action.triggered.disconnect(
|
||||
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 ModuleNotFoundError:
|
||||
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
|
||||
except TypeError:
|
||||
warning_util = WarningPopupUtility()
|
||||
error_msg = traceback.format_exc()
|
||||
warning_util.show_warning(
|
||||
title="Matplotlib TypeError",
|
||||
message="Matplotlib exporter could not resolve the plot item.",
|
||||
detailed_text=error_msg,
|
||||
)
|
||||
return
|
||||
79
bec_widgets/widgets/plots/toolbar_components/roi.py
Normal file
79
bec_widgets/widgets/plots/toolbar_components/roi.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
|
||||
def roi_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a toolbar bundle for ROI and crosshair interaction.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The ROI toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"crosshair",
|
||||
MaterialIconAction(
|
||||
icon_name="point_scan",
|
||||
tooltip="Show Crosshair",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"reset_legend",
|
||||
MaterialIconAction(
|
||||
icon_name="restart_alt",
|
||||
tooltip="Reset the position of legend.",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("roi", components)
|
||||
bundle.add_action("crosshair")
|
||||
bundle.add_action("reset_legend")
|
||||
return bundle
|
||||
|
||||
|
||||
class RoiConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for the ROI toolbar bundle.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "roi"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if not hasattr(self.target_widget, "toggle_crosshair") or not hasattr(
|
||||
self.target_widget, "reset_legend"
|
||||
):
|
||||
raise AttributeError(
|
||||
"Target widget must implement 'toggle_crosshair' and 'reset_legend'."
|
||||
)
|
||||
super().__init__()
|
||||
self._connected = False
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action_reference("crosshair")().action.toggled.connect(
|
||||
self.target_widget.toggle_crosshair
|
||||
)
|
||||
self.components.get_action_reference("reset_legend")().action.triggered.connect(
|
||||
self.target_widget.reset_legend
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action_reference("crosshair")().action.toggled.disconnect(
|
||||
self.target_widget.toggle_crosshair
|
||||
)
|
||||
self.components.get_action_reference("reset_legend")().action.triggered.disconnect(
|
||||
self.target_widget.reset_legend
|
||||
)
|
||||
@@ -2,12 +2,12 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -15,9 +15,8 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import CurveTree
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -29,13 +28,18 @@ class CurveSetting(SettingWidget):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self.setProperty("skip_settings", True)
|
||||
self.target_widget = target_widget
|
||||
self._x_settings_connected = False
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
|
||||
self._init_x_box()
|
||||
self._init_y_box()
|
||||
|
||||
self.setFixedWidth(580) # TODO height is still debate
|
||||
def sizeHint(self) -> QSize:
|
||||
"""
|
||||
Returns the size hint for the settings widget.
|
||||
"""
|
||||
return QSize(800, 500)
|
||||
|
||||
def _init_x_box(self):
|
||||
self.x_axis_box = QGroupBox("X Axis")
|
||||
@@ -46,15 +50,23 @@ class CurveSetting(SettingWidget):
|
||||
self.mode_combo_label = QLabel("Mode")
|
||||
self.mode_combo = QComboBox()
|
||||
self.mode_combo.addItems(["auto", "index", "timestamp", "device"])
|
||||
self.mode_combo.setMinimumWidth(120)
|
||||
|
||||
self.spacer = QWidget()
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
self.device_x_label = QLabel("Device")
|
||||
self.device_x = DeviceLineEdit(parent=self)
|
||||
self.device_x = DeviceComboBox(parent=self)
|
||||
self.device_x.insertItem(0, "")
|
||||
self.device_x.setEditable(True)
|
||||
self.device_x.setMinimumWidth(180)
|
||||
|
||||
self.signal_x_label = QLabel("Signal")
|
||||
self.signal_x = QLineEdit()
|
||||
self.signal_x = SignalComboBox(parent=self)
|
||||
self.signal_x.include_config_signals = False
|
||||
self.signal_x.insertItem(0, "")
|
||||
self.signal_x.setEditable(True)
|
||||
self.signal_x.setMinimumWidth(180)
|
||||
|
||||
self._get_x_mode_from_waveform()
|
||||
self.switch_x_device_selection()
|
||||
@@ -80,11 +92,32 @@ class CurveSetting(SettingWidget):
|
||||
|
||||
def switch_x_device_selection(self):
|
||||
if self.mode_combo.currentText() == "device":
|
||||
self._x_settings_connected = True
|
||||
self.device_x.currentTextChanged.connect(self.signal_x.set_device)
|
||||
self.device_x.device_reset.connect(self.signal_x.reset_selection)
|
||||
|
||||
self.device_x.setEnabled(True)
|
||||
self.device_x.setText(self.target_widget.x_axis_mode["name"])
|
||||
self.signal_x.setText(self.target_widget.x_axis_mode["entry"])
|
||||
self.signal_x.setEnabled(True)
|
||||
item = self.device_x.findText(self.target_widget.x_axis_mode["name"])
|
||||
self.device_x.setCurrentIndex(item if item != -1 else 0)
|
||||
signal_x = self.target_widget.x_axis_mode.get("entry", "")
|
||||
if signal_x:
|
||||
self.signal_x.set_to_obj_name(signal_x)
|
||||
else:
|
||||
# If no match is found, set to the first enabled item
|
||||
if not self.signal_x.set_to_first_enabled():
|
||||
# If no enabled item is found, set to the first item
|
||||
self.signal_x.setCurrentIndex(0)
|
||||
else:
|
||||
self.device_x.setEnabled(False)
|
||||
self.signal_x.setEnabled(False)
|
||||
self.device_x.setCurrentIndex(0)
|
||||
self.signal_x.setCurrentIndex(0)
|
||||
|
||||
if self._x_settings_connected:
|
||||
self._x_settings_connected = False
|
||||
self.device_x.currentTextChanged.disconnect(self.signal_x.set_device)
|
||||
self.device_x.device_reset.disconnect(self.signal_x.reset_selection)
|
||||
|
||||
def _init_y_box(self):
|
||||
self.y_axis_box = QGroupBox("Y Axis")
|
||||
@@ -97,16 +130,17 @@ class CurveSetting(SettingWidget):
|
||||
|
||||
self.layout.addWidget(self.y_axis_box)
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(popup_error=True)
|
||||
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()
|
||||
signal_x = self.signal_x.text()
|
||||
self.target_widget.x_mode = self.device_x.currentText()
|
||||
signal_x = self.signal_x.currentText()
|
||||
signal_data = self.signal_x.itemData(self.signal_x.currentIndex())
|
||||
if signal_x != "":
|
||||
self.target_widget.x_entry = signal_x
|
||||
self.target_widget.x_entry = signal_data.get("obj_name", signal_x)
|
||||
else:
|
||||
self.target_widget.x_mode = self.mode_combo.currentText()
|
||||
self.curve_manager.send_curve_json()
|
||||
@@ -121,5 +155,7 @@ class CurveSetting(SettingWidget):
|
||||
"""Cleanup the widget."""
|
||||
self.device_x.close()
|
||||
self.device_x.deleteLater()
|
||||
self.signal_x.close()
|
||||
self.signal_x.deleteLater()
|
||||
self.curve_manager.close()
|
||||
self.curve_manager.deleteLater()
|
||||
|
||||
@@ -5,13 +5,12 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
@@ -26,10 +25,11 @@ from bec_widgets import SafeSlot
|
||||
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.utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
|
||||
from bec_widgets.widgets.plots.waveform.curve import CurveConfig, DeviceSignal
|
||||
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
|
||||
@@ -125,11 +125,30 @@ class CurveRow(QTreeWidgetItem):
|
||||
"""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(parent=self.tree)
|
||||
self.entry_edit = QLineEdit(parent=self.tree) # TODO in future will be signal line edit
|
||||
self.device_edit = DeviceComboBox(parent=self.tree)
|
||||
self.device_edit.insertItem(0, "")
|
||||
self.device_edit.setEditable(True)
|
||||
self.entry_edit = SignalComboBox(parent=self.tree)
|
||||
self.entry_edit.include_config_signals = False
|
||||
self.entry_edit.insertItem(0, "")
|
||||
self.entry_edit.setEditable(True)
|
||||
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
|
||||
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
|
||||
if self.config.signal:
|
||||
self.device_edit.setText(self.config.signal.name or "")
|
||||
self.entry_edit.setText(self.config.signal.entry or "")
|
||||
device_index = self.device_edit.findText(self.config.signal.name or "")
|
||||
if device_index >= 0:
|
||||
self.device_edit.setCurrentIndex(device_index)
|
||||
# Force the entry_edit to update based on the device name
|
||||
self.device_edit.currentTextChanged.emit(self.device_edit.currentText())
|
||||
else:
|
||||
# If the device name is not found, set the first enabled item
|
||||
self.device_edit.setCurrentIndex(0)
|
||||
|
||||
if not self.entry_edit.set_to_obj_name(self.config.signal.entry):
|
||||
# If the entry is not found, try to set it to the first enabled item
|
||||
if not self.entry_edit.set_to_first_enabled():
|
||||
# If no enabled item is found, set to the first item
|
||||
self.entry_edit.setCurrentIndex(0)
|
||||
|
||||
self.tree.setItemWidget(self, 1, self.device_edit)
|
||||
self.tree.setItemWidget(self, 2, self.entry_edit)
|
||||
@@ -236,6 +255,11 @@ class CurveRow(QTreeWidgetItem):
|
||||
self.device_edit.deleteLater()
|
||||
self.device_edit = None
|
||||
|
||||
if getattr(self, "entry_edit", None) is not None:
|
||||
self.entry_edit.close()
|
||||
self.entry_edit.deleteLater()
|
||||
self.entry_edit = None
|
||||
|
||||
if getattr(self, "dap_combo", None) is not None:
|
||||
self.dap_combo.close()
|
||||
self.dap_combo.deleteLater()
|
||||
@@ -268,13 +292,22 @@ class CurveRow(QTreeWidgetItem):
|
||||
# Gather device name/entry
|
||||
device_name = ""
|
||||
device_entry = ""
|
||||
|
||||
## TODO: Move this to itemData
|
||||
if hasattr(self, "device_edit"):
|
||||
device_name = self.device_edit.text()
|
||||
device_name = self.device_edit.currentText()
|
||||
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)
|
||||
device_entry = self.entry_edit.currentText()
|
||||
index = self.entry_edit.findText(device_entry)
|
||||
if index > -1:
|
||||
device_entry_info = self.entry_edit.itemData(index)
|
||||
if device_entry_info:
|
||||
device_entry = device_entry_info.get("obj_name", device_entry)
|
||||
else:
|
||||
device_entry = self.entry_validator.validate_signal(
|
||||
name=device_name, entry=device_entry
|
||||
)
|
||||
|
||||
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
|
||||
self.config.source = "device"
|
||||
self.config.label = f"{device_name}-{device_entry}"
|
||||
@@ -348,55 +381,86 @@ class CurveTree(BECWidget, QWidget):
|
||||
|
||||
def _init_toolbar(self):
|
||||
"""Initialize the toolbar with actions: add, send, refresh, expand, collapse, renormalize."""
|
||||
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
|
||||
add = MaterialIconAction(
|
||||
icon_name="add", tooltip="Add new curve", checkable=False, parent=self
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
|
||||
self.toolbar.components.add_safe(
|
||||
"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
|
||||
self.toolbar.components.add_safe(
|
||||
"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.components.add_safe(
|
||||
"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)
|
||||
bundle = ToolbarBundle("curve_tree", self.toolbar.components)
|
||||
bundle.add_action("add")
|
||||
bundle.add_action("expand")
|
||||
bundle.add_action("collapse")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
# 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)
|
||||
spacer = QWidget()
|
||||
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
|
||||
bundle.add_action("spacer")
|
||||
|
||||
# Renormalize colors button
|
||||
renorm_action = MaterialIconAction(
|
||||
icon_name="palette", tooltip="Normalize All Colors", checkable=False, parent=self
|
||||
self.toolbar.components.add_safe(
|
||||
"renormalize_colors",
|
||||
MaterialIconAction(
|
||||
icon_name="palette", tooltip="Normalize All Colors", checkable=False, parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.add_action("renormalize_colors", renorm_action, self)
|
||||
bundle.add_action("renormalize_colors")
|
||||
renorm_action = self.toolbar.components.get_action("renormalize_colors")
|
||||
renorm_action.action.triggered.connect(lambda checked: self.renormalize_colors())
|
||||
|
||||
self.colormap_widget = BECColorMapWidget(cmap=self.color_palette or "plasma")
|
||||
self.toolbar.addWidget(self.colormap_widget)
|
||||
self.toolbar.components.add_safe(
|
||||
"colormap_widget", WidgetAction(widget=self.colormap_widget)
|
||||
)
|
||||
bundle.add_action("colormap_widget")
|
||||
self.colormap_widget.colormap_changed_signal.connect(self.handle_colormap_changed)
|
||||
|
||||
add = self.toolbar.components.get_action("add")
|
||||
expand = self.toolbar.components.get_action("expand")
|
||||
collapse = self.toolbar.components.get_action("collapse")
|
||||
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)
|
||||
|
||||
self.toolbar.show_bundles(["curve_tree"])
|
||||
|
||||
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"])
|
||||
|
||||
header = self.tree.header()
|
||||
for idx in range(self.tree.columnCount()):
|
||||
if idx in (1, 2): # Device name and entry should stretch
|
||||
header.setSectionResizeMode(idx, QHeaderView.Stretch)
|
||||
else:
|
||||
header.setSectionResizeMode(idx, QHeaderView.Fixed)
|
||||
header.setStretchLastSection(False)
|
||||
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.tree.setColumnWidth(5, 50)
|
||||
self.tree.setColumnWidth(6, 50)
|
||||
|
||||
self.layout.addWidget(self.tree)
|
||||
|
||||
def _init_color_buffer(self, size: int):
|
||||
@@ -532,7 +596,4 @@ class CurveTree(BECWidget, QWidget):
|
||||
all_items = list(self.all_items)
|
||||
for item in all_items:
|
||||
item.remove_self()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.cleanup()
|
||||
return super().closeEvent(event)
|
||||
super().cleanup()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Literal
|
||||
from typing import Literal
|
||||
|
||||
import lmfit
|
||||
import numpy as np
|
||||
@@ -29,7 +29,7 @@ from bec_widgets.utils.colors import Colors, set_theme
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal
|
||||
@@ -182,6 +182,7 @@ class Waveform(PlotBase):
|
||||
self._init_roi_manager()
|
||||
self.dap_summary = None
|
||||
self.dap_summary_dialog = None
|
||||
self._add_fit_parameters_popup()
|
||||
self._enable_roi_toolbar_action(False) # default state where are no dap curves
|
||||
self._init_curve_dialog()
|
||||
self.curve_settings_dialog = None
|
||||
@@ -214,6 +215,8 @@ class Waveform(PlotBase):
|
||||
# To fix the ViewAll action with clipToView activated
|
||||
self._connect_viewbox_menu_actions()
|
||||
|
||||
self.toolbar.show_bundles(["plot_export", "mouse_interaction", "roi", "axis_popup"])
|
||||
|
||||
def _connect_viewbox_menu_actions(self):
|
||||
"""Connect the viewbox menu action ViewAll to the custom reset_view method."""
|
||||
menu = self.plot_item.vb.menu
|
||||
@@ -247,21 +250,21 @@ class Waveform(PlotBase):
|
||||
super().add_side_menus()
|
||||
self._add_dap_summary_side_menu()
|
||||
|
||||
def add_popups(self):
|
||||
def _add_fit_parameters_popup(self):
|
||||
"""
|
||||
Add popups to the Waveform widget.
|
||||
"""
|
||||
super().add_popups()
|
||||
LMFitDialog_action = MaterialIconAction(
|
||||
icon_name="monitoring", tooltip="Open Fit Parameters", checkable=True, parent=self
|
||||
self.toolbar.components.add_safe(
|
||||
"fit_params",
|
||||
MaterialIconAction(
|
||||
icon_name="monitoring", tooltip="Open Fit Parameters", checkable=True, parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="popup_bundle",
|
||||
action_id="fit_params",
|
||||
action=LMFitDialog_action,
|
||||
target_widget=self,
|
||||
self.toolbar.get_bundle("axis_popup").add_action("fit_params")
|
||||
|
||||
self.toolbar.components.get_action("fit_params").action.triggered.connect(
|
||||
self.show_dap_summary_popup
|
||||
)
|
||||
self.toolbar.widgets["fit_params"].action.triggered.connect(self.show_dap_summary_popup)
|
||||
|
||||
@SafeSlot()
|
||||
def _reset_view(self):
|
||||
@@ -290,14 +293,17 @@ class Waveform(PlotBase):
|
||||
Initialize the ROI manager for the Waveform widget.
|
||||
"""
|
||||
# Add toolbar icon
|
||||
roi = MaterialIconAction(
|
||||
icon_name="align_justify_space_between",
|
||||
tooltip="Add ROI region for DAP",
|
||||
checkable=True,
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="roi", action_id="roi_linear", action=roi, target_widget=self
|
||||
self.toolbar.components.add_safe(
|
||||
"roi_linear",
|
||||
MaterialIconAction(
|
||||
icon_name="align_justify_space_between",
|
||||
tooltip="Add ROI region for DAP",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
),
|
||||
)
|
||||
self.toolbar.get_bundle("roi").add_action("roi_linear")
|
||||
|
||||
self._roi_manager = WaveformROIManager(self.plot_item, parent=self)
|
||||
|
||||
# Connect manager signals -> forward them via Waveform's own signals
|
||||
@@ -307,30 +313,36 @@ class Waveform(PlotBase):
|
||||
# Example: connect ROI changed to re-request DAP
|
||||
self.roi_changed.connect(self._on_roi_changed_for_dap)
|
||||
self._roi_manager.roi_active.connect(self.request_dap_update)
|
||||
self.toolbar.widgets["roi_linear"].action.toggled.connect(self._roi_manager.toggle_roi)
|
||||
self.toolbar.components.get_action("roi_linear").action.toggled.connect(
|
||||
self._roi_manager.toggle_roi
|
||||
)
|
||||
|
||||
def _init_curve_dialog(self):
|
||||
"""
|
||||
Initializes the Curve dialog within the toolbar.
|
||||
"""
|
||||
curve_settings = MaterialIconAction(
|
||||
icon_name="timeline", tooltip="Show Curve dialog.", checkable=True
|
||||
self.toolbar.components.add_safe(
|
||||
"curve",
|
||||
MaterialIconAction(
|
||||
icon_name="timeline", tooltip="Show Curve dialog.", checkable=True, parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.get_bundle("axis_popup").add_action("curve")
|
||||
self.toolbar.components.get_action("curve").action.triggered.connect(
|
||||
self.show_curve_settings_popup
|
||||
)
|
||||
self.toolbar.add_action("curve", curve_settings, target_widget=self)
|
||||
self.toolbar.widgets["curve"].action.triggered.connect(self.show_curve_settings_popup)
|
||||
|
||||
def show_curve_settings_popup(self):
|
||||
"""
|
||||
Displays the curve settings popup to allow users to modify curve-related configurations.
|
||||
"""
|
||||
curve_action = self.toolbar.widgets["curve"].action
|
||||
curve_action = self.toolbar.components.get_action("curve").action
|
||||
|
||||
if self.curve_settings_dialog is None or not self.curve_settings_dialog.isVisible():
|
||||
curve_setting = CurveSetting(parent=self, target_widget=self)
|
||||
self.curve_settings_dialog = SettingsDialog(
|
||||
self, settings_widget=curve_setting, window_title="Curve Settings", modal=False
|
||||
)
|
||||
self.curve_settings_dialog.setFixedWidth(580)
|
||||
# When the dialog is closed, update the toolbar icon and clear the reference
|
||||
self.curve_settings_dialog.finished.connect(self._curve_settings_closed)
|
||||
self.curve_settings_dialog.show()
|
||||
@@ -348,7 +360,7 @@ class Waveform(PlotBase):
|
||||
self.curve_settings_dialog.close()
|
||||
self.curve_settings_dialog.deleteLater()
|
||||
self.curve_settings_dialog = None
|
||||
self.toolbar.widgets["curve"].action.setChecked(False)
|
||||
self.toolbar.components.get_action("curve").action.setChecked(False)
|
||||
|
||||
@property
|
||||
def roi_region(self) -> tuple[float, float] | None:
|
||||
@@ -395,9 +407,9 @@ class Waveform(PlotBase):
|
||||
Args:
|
||||
enable(bool): Enable or disable the ROI toolbar action.
|
||||
"""
|
||||
self.toolbar.widgets["roi_linear"].action.setEnabled(enable)
|
||||
self.toolbar.components.get_action("roi_linear").action.setEnabled(enable)
|
||||
if enable is False:
|
||||
self.toolbar.widgets["roi_linear"].action.setChecked(False)
|
||||
self.toolbar.components.get_action("roi_linear").action.setChecked(False)
|
||||
self._roi_manager.toggle_roi(False)
|
||||
|
||||
################################################################################
|
||||
@@ -421,7 +433,7 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
Show the DAP summary popup.
|
||||
"""
|
||||
fit_action = self.toolbar.widgets["fit_params"].action
|
||||
fit_action = self.toolbar.components.get_action("fit_params").action
|
||||
if self.dap_summary_dialog is None or not self.dap_summary_dialog.isVisible():
|
||||
self.dap_summary = LMFitDialog(parent=self)
|
||||
self.dap_summary_dialog = QDialog(modal=False)
|
||||
@@ -447,7 +459,7 @@ class Waveform(PlotBase):
|
||||
self.dap_summary.deleteLater()
|
||||
self.dap_summary_dialog.deleteLater()
|
||||
self.dap_summary_dialog = None
|
||||
self.toolbar.widgets["fit_params"].action.setChecked(False)
|
||||
self.toolbar.components.get_action("fit_params").action.setChecked(False)
|
||||
|
||||
def _get_dap_from_target_widget(self) -> None:
|
||||
"""Get the DAP data from the target widget and update the DAP dialog manually on creation."""
|
||||
@@ -1246,6 +1258,23 @@ class Waveform(PlotBase):
|
||||
|
||||
self.request_dap_update.emit()
|
||||
|
||||
def _check_async_signal_found(self, name: str, signal: str) -> bool:
|
||||
"""
|
||||
Check if the async signal is found in the BEC device manager.
|
||||
|
||||
Args:
|
||||
name(str): The name of the async signal.
|
||||
signal(str): The entry of the async signal.
|
||||
|
||||
Returns:
|
||||
bool: True if the async signal is found, False otherwise.
|
||||
"""
|
||||
bec_async_signals = self.client.device_manager.get_bec_signals("AsyncSignal")
|
||||
for entry_name, _, entry_data in bec_async_signals:
|
||||
if entry_name == name and entry_data.get("obj_name") == signal:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _setup_async_curve(self, curve: Curve):
|
||||
"""
|
||||
Setup async curve.
|
||||
@@ -1254,20 +1283,40 @@ class Waveform(PlotBase):
|
||||
curve(Curve): The curve to set up.
|
||||
"""
|
||||
name = curve.config.signal.name
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_async_readback, MessageEndpoints.device_async_readback(self.old_scan_id, name)
|
||||
)
|
||||
signal = curve.config.signal.entry
|
||||
async_signal_found = self._check_async_signal_found(name, signal)
|
||||
|
||||
try:
|
||||
curve.clear_data()
|
||||
except KeyError:
|
||||
logger.warning(f"Curve {name} not found in plot item.")
|
||||
pass
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_async_readback,
|
||||
MessageEndpoints.device_async_readback(self.scan_id, name),
|
||||
from_start=True,
|
||||
cb_info={"scan_id": self.scan_id},
|
||||
)
|
||||
|
||||
# New endpoint for async signals
|
||||
if async_signal_found:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_async_readback,
|
||||
MessageEndpoints.device_async_signal(self.old_scan_id, name, signal),
|
||||
)
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_async_readback,
|
||||
MessageEndpoints.device_async_signal(self.scan_id, name, signal),
|
||||
from_start=True,
|
||||
cb_info={"scan_id": self.scan_id},
|
||||
)
|
||||
|
||||
# old endpoint
|
||||
else:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_async_readback,
|
||||
MessageEndpoints.device_async_readback(self.old_scan_id, name),
|
||||
)
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_async_readback,
|
||||
MessageEndpoints.device_async_readback(self.scan_id, name),
|
||||
from_start=True,
|
||||
cb_info={"scan_id": self.scan_id},
|
||||
)
|
||||
logger.info(f"Setup async curve {name}")
|
||||
|
||||
@SafeSlot(dict, dict, verify_sender=True)
|
||||
@@ -1647,9 +1696,14 @@ class Waveform(PlotBase):
|
||||
return None
|
||||
|
||||
if hasattr(self.scan_item, "live_data"):
|
||||
readout_priority = self.scan_item.status_message.info["readout_priority"] # live data
|
||||
readout_priority = self.scan_item.status_message.info.get(
|
||||
"readout_priority"
|
||||
) # live data
|
||||
else:
|
||||
readout_priority = self.scan_item.metadata["bec"]["readout_priority"] # history
|
||||
readout_priority = self.scan_item.metadata["bec"].get("readout_priority") # history
|
||||
|
||||
if readout_priority is None:
|
||||
return None
|
||||
|
||||
# Reset sync/async curve lists
|
||||
self._async_curves.clear()
|
||||
|
||||
@@ -1,12 +1,46 @@
|
||||
import sys
|
||||
from enum import Enum
|
||||
from string import Template
|
||||
|
||||
from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer, Slot
|
||||
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer
|
||||
from qtpy.QtGui import QColor, QPainter, QPainterPath
|
||||
|
||||
|
||||
class ProgressState(Enum):
|
||||
NORMAL = "normal"
|
||||
PAUSED = "paused"
|
||||
INTERRUPTED = "interrupted"
|
||||
COMPLETED = "completed"
|
||||
|
||||
@classmethod
|
||||
def from_bec_status(cls, status: str) -> "ProgressState":
|
||||
"""
|
||||
Map a BEC status string (open, paused, aborted, halted, closed)
|
||||
to the corresponding ProgressState.
|
||||
Any unknown status falls back to NORMAL.
|
||||
"""
|
||||
mapping = {
|
||||
"open": cls.NORMAL,
|
||||
"paused": cls.PAUSED,
|
||||
"aborted": cls.INTERRUPTED,
|
||||
"halted": cls.PAUSED,
|
||||
"closed": cls.COMPLETED,
|
||||
}
|
||||
return mapping.get(status.lower(), cls.NORMAL)
|
||||
|
||||
|
||||
PROGRESS_STATE_COLORS = {
|
||||
ProgressState.NORMAL: QColor("#2979ff"), # blue – normal progress
|
||||
ProgressState.PAUSED: QColor("#ffca28"), # orange/amber – paused
|
||||
ProgressState.INTERRUPTED: QColor("#ff5252"), # red – interrupted
|
||||
ProgressState.COMPLETED: QColor("#00e676"), # green – finished
|
||||
}
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
|
||||
class BECProgressBar(BECWidget, QWidget):
|
||||
@@ -21,6 +55,8 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
"set_minimum",
|
||||
"label_template",
|
||||
"label_template.setter",
|
||||
"state",
|
||||
"state.setter",
|
||||
"_get_label",
|
||||
]
|
||||
ICON_NAME = "page_control"
|
||||
@@ -48,27 +84,38 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
|
||||
self._completed_color = accent_colors.success
|
||||
self._border_color = QColor(50, 50, 50)
|
||||
# Corner‑rounding: base radius in pixels (auto‑reduced if bar is small)
|
||||
self._corner_radius = 10
|
||||
|
||||
# Progress‑bar state handling
|
||||
self._state = ProgressState.NORMAL
|
||||
self._state_colors = dict(PROGRESS_STATE_COLORS)
|
||||
|
||||
# layout settings
|
||||
self._padding_left_right = 10
|
||||
self._value_animation = QPropertyAnimation(self, b"_progressbar_value")
|
||||
self._value_animation.setDuration(200)
|
||||
self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
|
||||
|
||||
# label on top of the progress bar
|
||||
self.center_label = QLabel(self)
|
||||
self.center_label.setAlignment(Qt.AlignCenter)
|
||||
self.center_label.setAlignment(Qt.AlignHCenter)
|
||||
self.center_label.setStyleSheet("color: white;")
|
||||
self.center_label.setMinimumSize(0, 0)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setContentsMargins(10, 0, 10, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.center_label)
|
||||
layout.setAlignment(self.center_label, Qt.AlignCenter)
|
||||
self.setLayout(layout)
|
||||
|
||||
self.update()
|
||||
self._adjust_label_width()
|
||||
|
||||
@Property(str, doc="The template for the center label. Use $value, $maximum, and $percentage.")
|
||||
@SafeProperty(
|
||||
str, doc="The template for the center label. Use $value, $maximum, and $percentage."
|
||||
)
|
||||
def label_template(self):
|
||||
"""
|
||||
The template for the center label. Use $value, $maximum, and $percentage to insert the values.
|
||||
@@ -83,10 +130,11 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
@label_template.setter
|
||||
def label_template(self, template):
|
||||
self._label_template = template
|
||||
self._adjust_label_width()
|
||||
self.set_value(self._user_value)
|
||||
self.update()
|
||||
|
||||
@Property(float, designable=False)
|
||||
@SafeProperty(float, designable=False)
|
||||
def _progressbar_value(self):
|
||||
"""
|
||||
The current value of the progress bar.
|
||||
@@ -106,8 +154,20 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
percentage=int((self.map_value(self._user_value) / self._maximum) * 100),
|
||||
)
|
||||
|
||||
@Slot(float)
|
||||
@Slot(int)
|
||||
def _adjust_label_width(self):
|
||||
"""
|
||||
Reserve enough horizontal space for the center label so the widget
|
||||
doesn't resize as the text grows during progress.
|
||||
"""
|
||||
template = Template(self._label_template)
|
||||
sample_text = template.safe_substitute(
|
||||
value=self._user_maximum, maximum=self._user_maximum, percentage=100
|
||||
)
|
||||
width = self.center_label.fontMetrics().horizontalAdvance(sample_text)
|
||||
self.center_label.setFixedWidth(width)
|
||||
|
||||
@SafeSlot(float)
|
||||
@SafeSlot(int)
|
||||
def set_value(self, value):
|
||||
"""
|
||||
Set the value of the progress bar.
|
||||
@@ -122,35 +182,88 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
self._target_value = self.map_value(value)
|
||||
self._user_value = value
|
||||
self.center_label.setText(self._update_template())
|
||||
# Update state automatically unless paused or interrupted
|
||||
if self._state not in (ProgressState.PAUSED, ProgressState.INTERRUPTED):
|
||||
self._state = (
|
||||
ProgressState.COMPLETED
|
||||
if self._user_value >= self._user_maximum
|
||||
else ProgressState.NORMAL
|
||||
)
|
||||
self.animate_progress()
|
||||
|
||||
@SafeProperty(object, doc="Current visual state of the progress bar.")
|
||||
def state(self):
|
||||
return self._state
|
||||
|
||||
@state.setter
|
||||
def state(self, state):
|
||||
"""
|
||||
Set the visual state of the progress bar.
|
||||
|
||||
Args:
|
||||
state(ProgressState | str): The state to set. Can be one of the
|
||||
"""
|
||||
if isinstance(state, str):
|
||||
state = ProgressState(state.lower())
|
||||
if not isinstance(state, ProgressState):
|
||||
raise ValueError("state must be a ProgressState or its value")
|
||||
self._state = state
|
||||
self.update()
|
||||
|
||||
@SafeProperty(float, doc="Base corner radius in pixels (auto‑scaled down on small bars).")
|
||||
def corner_radius(self) -> float:
|
||||
return self._corner_radius
|
||||
|
||||
@corner_radius.setter
|
||||
def corner_radius(self, radius: float):
|
||||
self._corner_radius = max(0.0, radius)
|
||||
self.update()
|
||||
|
||||
@SafeProperty(float)
|
||||
def padding_left_right(self) -> float:
|
||||
return self._padding_left_right
|
||||
|
||||
@padding_left_right.setter
|
||||
def padding_left_right(self, padding: float):
|
||||
self._padding_left_right = padding
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
rect = self.rect().adjusted(10, 0, -10, -1)
|
||||
rect = self.rect().adjusted(self._padding_left_right, 0, -self._padding_left_right, -1)
|
||||
|
||||
# Corner radius adapts to widget height so it never exceeds half the bar’s thickness
|
||||
radius = min(self._corner_radius, rect.height() / 2)
|
||||
|
||||
# Draw background
|
||||
painter.setBrush(self._background_color)
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.drawRoundedRect(rect, 10, 10) # Rounded corners
|
||||
painter.drawRoundedRect(rect, radius, radius) # Rounded corners
|
||||
|
||||
# Draw border
|
||||
painter.setBrush(Qt.NoBrush)
|
||||
painter.setPen(self._border_color)
|
||||
painter.drawRoundedRect(rect, 10, 10)
|
||||
painter.drawRoundedRect(rect, radius, radius)
|
||||
|
||||
# Determine progress color based on completion
|
||||
if self._value >= self._maximum:
|
||||
current_color = self._completed_color
|
||||
# Determine progress colour based on current state
|
||||
if self._state == ProgressState.PAUSED:
|
||||
current_color = self._state_colors[ProgressState.PAUSED]
|
||||
elif self._state == ProgressState.INTERRUPTED:
|
||||
current_color = self._state_colors[ProgressState.INTERRUPTED]
|
||||
elif self._state == ProgressState.COMPLETED or self._value >= self._maximum:
|
||||
current_color = self._state_colors[ProgressState.COMPLETED]
|
||||
else:
|
||||
current_color = self._progress_color
|
||||
current_color = self._state_colors[ProgressState.NORMAL]
|
||||
|
||||
# Set clipping region to preserve the background's rounded corners
|
||||
progress_rect = rect.adjusted(
|
||||
0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0
|
||||
)
|
||||
clip_path = QPainterPath()
|
||||
clip_path.addRoundedRect(QRectF(rect), 10, 10) # Clip to the background's rounded corners
|
||||
clip_path.addRoundedRect(
|
||||
QRectF(rect), radius, radius
|
||||
) # Clip to the background's rounded corners
|
||||
painter.setClipPath(clip_path)
|
||||
|
||||
# Draw progress bar
|
||||
@@ -168,7 +281,7 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
self._value_animation.setEndValue(self._target_value)
|
||||
self._value_animation.start()
|
||||
|
||||
@Property(float)
|
||||
@SafeProperty(float)
|
||||
def maximum(self):
|
||||
"""
|
||||
The maximum value of the progress bar.
|
||||
@@ -182,7 +295,7 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
"""
|
||||
self.set_maximum(maximum)
|
||||
|
||||
@Property(float)
|
||||
@SafeProperty(float)
|
||||
def minimum(self):
|
||||
"""
|
||||
The minimum value of the progress bar.
|
||||
@@ -193,7 +306,7 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
def minimum(self, minimum: float):
|
||||
self.set_minimum(minimum)
|
||||
|
||||
@Property(float)
|
||||
@SafeProperty(float)
|
||||
def initial_value(self):
|
||||
"""
|
||||
The initial value of the progress bar.
|
||||
@@ -204,7 +317,7 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
def initial_value(self, value: float):
|
||||
self.set_value(value)
|
||||
|
||||
@Slot(float)
|
||||
@SafeSlot(float)
|
||||
def set_maximum(self, maximum: float):
|
||||
"""
|
||||
Set the maximum value of the progress bar.
|
||||
@@ -213,10 +326,11 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
maximum (float): The maximum value.
|
||||
"""
|
||||
self._user_maximum = maximum
|
||||
self._adjust_label_width()
|
||||
self.set_value(self._user_value) # Update the value to fit the new range
|
||||
self.update()
|
||||
|
||||
@Slot(float)
|
||||
@SafeSlot(float)
|
||||
def set_minimum(self, minimum: float):
|
||||
"""
|
||||
Set the minimum value of the progress bar.
|
||||
|
||||
@@ -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.progress.scan_progressbar.scan_progress_bar_plugin import (
|
||||
ScanProgressBarPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(ScanProgressBarPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['scan_progressbar.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.progress.scan_progressbar.scan_progressbar import ScanProgressBar
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='ScanProgressBar' name='scan_progress_bar'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class ScanProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = ScanProgressBar(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Utils"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(ScanProgressBar.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "scan_progress_bar"
|
||||
|
||||
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 "ScanProgressBar"
|
||||
|
||||
def toolTip(self):
|
||||
return "A progress bar that is hooked up to the scan progress of a scan."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,320 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import os
|
||||
import time
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject, QTimer, Signal
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import ProgressState
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ProgressSource(enum.Enum):
|
||||
"""
|
||||
Enum to define the source of the progress.
|
||||
"""
|
||||
|
||||
SCAN_PROGRESS = "scan_progress"
|
||||
DEVICE_PROGRESS = "device_progress"
|
||||
|
||||
|
||||
class ProgressTask(QObject):
|
||||
"""
|
||||
Class to store progress information.
|
||||
Inspired by https://github.com/Textualize/rich/blob/master/rich/progress.py
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget, value: float = 0, max_value: float = 0, done: bool = False):
|
||||
super().__init__(parent=parent)
|
||||
self.start_time = time.time()
|
||||
self.done = done
|
||||
self.value = value
|
||||
self.max_value = max_value
|
||||
self._elapsed_time = 0
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.timeout.connect(self.update_elapsed_time)
|
||||
self.timer.start(100) # update the elapsed time every 100 ms
|
||||
|
||||
def update(self, value: float, max_value: float, done: bool = False):
|
||||
"""
|
||||
Update the progress.
|
||||
"""
|
||||
self.max_value = max_value
|
||||
self.done = done
|
||||
self.value = value
|
||||
if done:
|
||||
self.timer.stop()
|
||||
|
||||
def update_elapsed_time(self):
|
||||
"""
|
||||
Update the time estimates. This is called every 100 ms by a QTimer.
|
||||
"""
|
||||
self._elapsed_time += 0.1
|
||||
|
||||
@property
|
||||
def percentage(self) -> float:
|
||||
"""float: Get progress of task as a percentage. If a None total was set, returns 0"""
|
||||
if not self.max_value:
|
||||
return 0.0
|
||||
completed = (self.value / self.max_value) * 100.0
|
||||
completed = min(100.0, max(0.0, completed))
|
||||
return completed
|
||||
|
||||
@property
|
||||
def speed(self) -> float:
|
||||
"""Get the estimated speed in steps per second."""
|
||||
if self._elapsed_time == 0:
|
||||
return 0.0
|
||||
|
||||
return self.value / self._elapsed_time
|
||||
|
||||
@property
|
||||
def frequency(self) -> float:
|
||||
"""Get the estimated frequency in steps per second."""
|
||||
if self.speed == 0:
|
||||
return 0.0
|
||||
return 1 / self.speed
|
||||
|
||||
@property
|
||||
def time_elapsed(self) -> str:
|
||||
# format the elapsed time to a string in the format HH:MM:SS
|
||||
return self._format_time(int(self._elapsed_time))
|
||||
|
||||
@property
|
||||
def remaining(self) -> float:
|
||||
"""Get the estimated remaining steps."""
|
||||
if self.done:
|
||||
return 0.0
|
||||
remaining = self.max_value - self.value
|
||||
return remaining
|
||||
|
||||
@property
|
||||
def time_remaining(self) -> str:
|
||||
"""
|
||||
Get the estimated remaining time in the format HH:MM:SS.
|
||||
"""
|
||||
if self.done or not self.speed or not self.remaining:
|
||||
return self._format_time(0)
|
||||
estimate = int(np.round(self.remaining / self.speed))
|
||||
|
||||
return self._format_time(estimate)
|
||||
|
||||
def _format_time(self, seconds: float) -> str:
|
||||
"""
|
||||
Format the time in seconds to a string in the format HH:MM:SS.
|
||||
"""
|
||||
return f"{seconds // 3600:02}:{(seconds // 60) % 60:02}:{seconds % 60:02}"
|
||||
|
||||
|
||||
class ScanProgressBar(BECWidget, QWidget):
|
||||
"""
|
||||
Widget to display a progress bar that is hooked up to the scan progress of a scan.
|
||||
If you want to manually set the progress, it is recommended to use the BECProgressbar or QProgressbar directly.
|
||||
"""
|
||||
|
||||
ICON_NAME = "timelapse"
|
||||
progress_started = Signal()
|
||||
progress_finished = Signal()
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, one_line_design=False):
|
||||
super().__init__(parent=parent, client=client, config=config, gui_id=gui_id)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
ui_file = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"scan_progressbar_one_line.ui" if one_line_design else "scan_progressbar.ui",
|
||||
)
|
||||
self.ui = UILoader(self).loader(ui_file)
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.addWidget(self.ui)
|
||||
self.setLayout(self.layout)
|
||||
self.progressbar = self.ui.progressbar
|
||||
|
||||
self.connect_to_queue()
|
||||
self._progress_source = None
|
||||
self.task = None
|
||||
self.scan_number = None
|
||||
self.progress_started.connect(lambda: print("Scan progress started"))
|
||||
|
||||
def connect_to_queue(self):
|
||||
"""
|
||||
Connect to the queue status signal.
|
||||
"""
|
||||
self.bec_dispatcher.connect_slot(self.on_queue_update, MessageEndpoints.scan_queue_status())
|
||||
|
||||
def set_progress_source(self, source: ProgressSource, device=None):
|
||||
"""
|
||||
Set the source of the progress.
|
||||
"""
|
||||
if self._progress_source == source:
|
||||
self.update_source_label(source, device=device)
|
||||
return
|
||||
if self._progress_source is not None:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_progress_update,
|
||||
(
|
||||
MessageEndpoints.scan_progress()
|
||||
if self._progress_source == ProgressSource.SCAN_PROGRESS
|
||||
else MessageEndpoints.device_progress(device=device)
|
||||
),
|
||||
)
|
||||
self._progress_source = source
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_progress_update,
|
||||
(
|
||||
MessageEndpoints.scan_progress()
|
||||
if source == ProgressSource.SCAN_PROGRESS
|
||||
else MessageEndpoints.device_progress(device=device)
|
||||
),
|
||||
)
|
||||
self.update_source_label(source, device=device)
|
||||
# self.progress_started.emit()
|
||||
|
||||
def update_source_label(self, source: ProgressSource, device=None):
|
||||
scan_text = f"Scan {self.scan_number}" if self.scan_number is not None else "Scan"
|
||||
text = scan_text if source == ProgressSource.SCAN_PROGRESS else f"Device {device}"
|
||||
logger.info(f"Set progress source to {text}")
|
||||
self.ui.source_label.setText(text)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_progress_update(self, msg_content: dict, metadata: dict):
|
||||
"""
|
||||
Update the progress bar based on the progress message.
|
||||
"""
|
||||
value = msg_content["value"]
|
||||
max_value = msg_content.get("max_value", 100)
|
||||
done = msg_content.get("done", False)
|
||||
status: Literal["open", "paused", "aborted", "halted", "closed"] = metadata.get(
|
||||
"status", "open"
|
||||
)
|
||||
|
||||
if self.task is None:
|
||||
return
|
||||
self.task.update(value, max_value, done)
|
||||
|
||||
self.update_labels()
|
||||
|
||||
self.progressbar.set_maximum(self.task.max_value)
|
||||
self.progressbar.state = ProgressState.from_bec_status(status)
|
||||
self.progressbar.set_value(self.task.value)
|
||||
|
||||
if done:
|
||||
self.task = None
|
||||
self.progress_finished.emit()
|
||||
return
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_elapsed_time(self):
|
||||
return self.ui.elapsed_time_label.isVisible()
|
||||
|
||||
@show_elapsed_time.setter
|
||||
def show_elapsed_time(self, value):
|
||||
self.ui.elapsed_time_label.setVisible(value)
|
||||
if hasattr(self.ui, "dash"):
|
||||
self.ui.dash.setVisible(value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_remaining_time(self):
|
||||
return self.ui.remaining_time_label.isVisible()
|
||||
|
||||
@show_remaining_time.setter
|
||||
def show_remaining_time(self, value):
|
||||
self.ui.remaining_time_label.setVisible(value)
|
||||
if hasattr(self.ui, "dash"):
|
||||
self.ui.dash.setVisible(value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_source_label(self):
|
||||
return self.ui.source_label.isVisible()
|
||||
|
||||
@show_source_label.setter
|
||||
def show_source_label(self, value):
|
||||
self.ui.source_label.setVisible(value)
|
||||
|
||||
def update_labels(self):
|
||||
"""
|
||||
Update the labels based on the progress task.
|
||||
"""
|
||||
if self.task is None:
|
||||
return
|
||||
|
||||
self.ui.elapsed_time_label.setText(self.task.time_elapsed)
|
||||
self.ui.remaining_time_label.setText(self.task.time_remaining)
|
||||
|
||||
@SafeSlot(dict, dict, verify_sender=True)
|
||||
def on_queue_update(self, msg_content, metadata):
|
||||
"""
|
||||
Update the progress bar based on the queue status.
|
||||
"""
|
||||
if not "queue" in msg_content:
|
||||
return
|
||||
primary_queue_info = msg_content["queue"].get("primary", {}).get("info", [])
|
||||
if len(primary_queue_info) == 0:
|
||||
return
|
||||
scan_info = primary_queue_info[0]
|
||||
if scan_info is None:
|
||||
return
|
||||
if scan_info.get("status").lower() == "running" and self.task is None:
|
||||
self.task = ProgressTask(parent=self)
|
||||
self.progress_started.emit()
|
||||
|
||||
active_request_block = scan_info.get("active_request_block", {})
|
||||
if active_request_block is None:
|
||||
return
|
||||
|
||||
self.scan_number = active_request_block.get("scan_number")
|
||||
report_instructions = active_request_block.get("report_instructions", [])
|
||||
if not report_instructions:
|
||||
return
|
||||
|
||||
# for now, let's just use the first instruction
|
||||
instruction = report_instructions[0]
|
||||
|
||||
if "scan_progress" in instruction:
|
||||
self.set_progress_source(ProgressSource.SCAN_PROGRESS)
|
||||
elif "device_progress" in instruction:
|
||||
device = instruction["device_progress"][0]
|
||||
self.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
|
||||
|
||||
def cleanup(self):
|
||||
if self.task is not None:
|
||||
self.task.timer.stop()
|
||||
self.close()
|
||||
self.deleteLater()
|
||||
if self._progress_source is not None:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_progress_update,
|
||||
(
|
||||
MessageEndpoints.scan_progress()
|
||||
if self._progress_source == ProgressSource.SCAN_PROGRESS
|
||||
else MessageEndpoints.device_progress(device=self._progress_source.value)
|
||||
),
|
||||
)
|
||||
self.progressbar.close()
|
||||
self.progressbar.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
bec_logger.disabled_modules = ["bec_lib"]
|
||||
app = QApplication([])
|
||||
|
||||
widget = ScanProgressBar()
|
||||
widget.show()
|
||||
|
||||
app.exec_()
|
||||
@@ -0,0 +1,141 @@
|
||||
<?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>211</width>
|
||||
<height>60</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>60</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<property name="spacing">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="source_layout">
|
||||
<item>
|
||||
<widget class="QLabel" name="source_label">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Scan</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="source_spacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>10</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECProgressBar" name="progressbar">
|
||||
<property name="padding_left_right" stdset="0">
|
||||
<double>2.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="timer_layout">
|
||||
<item>
|
||||
<widget class="QLabel" name="remaining_time_label">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>0:00:00</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="timer_spacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="elapsed_time_label">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>0:00:00</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>BECProgressBar</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_progress_bar</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,124 @@
|
||||
<?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>328</width>
|
||||
<height>24</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>24</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>24</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2" stretch="0,1,0">
|
||||
<property name="leftMargin">
|
||||
<number>0</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="QLabel" name="source_label">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Scan</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECProgressBar" name="progressbar">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>30</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="padding_left_right" stdset="0">
|
||||
<double>5.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="remaining_time_label">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>0:00:00</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="dash">
|
||||
<property name="text">
|
||||
<string>-</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="elapsed_time_label">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>0:00:00</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>BECProgressBar</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_progress_bar</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -9,7 +9,9 @@ from qtpy.QtWidgets import QHeaderView, QLabel, QTableWidget, QTableWidgetItem,
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils.toolbar import ModularToolBar, SeparatorAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
|
||||
from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton
|
||||
from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton
|
||||
@@ -76,17 +78,26 @@ class BECQueue(BECWidget, CompactPopupWidget):
|
||||
"""
|
||||
widget_label = QLabel(text="Live Queue", parent=self)
|
||||
widget_label.setStyleSheet("font-weight: bold;")
|
||||
self.toolbar = ModularToolBar(
|
||||
parent=self,
|
||||
actions={
|
||||
"widget_label": WidgetAction(widget=widget_label),
|
||||
"separator_1": SeparatorAction(),
|
||||
"resume": WidgetAction(widget=ResumeButton(parent=self, toolbar=False)),
|
||||
"stop": WidgetAction(widget=StopButton(parent=self, toolbar=False)),
|
||||
"reset": WidgetAction(widget=ResetButton(parent=self, toolbar=False)),
|
||||
},
|
||||
target_widget=self,
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.toolbar.components.add_safe("widget_label", WidgetAction(widget=widget_label))
|
||||
bundle = ToolbarBundle("queue_label", self.toolbar.components)
|
||||
bundle.add_action("widget_label")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
self.toolbar.add_action(
|
||||
"resume", WidgetAction(widget=ResumeButton(parent=self, toolbar=True))
|
||||
)
|
||||
self.toolbar.add_action("stop", WidgetAction(widget=StopButton(parent=self, toolbar=True)))
|
||||
self.toolbar.add_action(
|
||||
"reset", WidgetAction(widget=ResetButton(parent=self, toolbar=True))
|
||||
)
|
||||
|
||||
control_bundle = ToolbarBundle("control", self.toolbar.components)
|
||||
control_bundle.add_action("resume")
|
||||
control_bundle.add_action("stop")
|
||||
control_bundle.add_action("reset")
|
||||
self.toolbar.add_bundle(control_bundle)
|
||||
self.toolbar.show_bundles(["queue_label", "control"])
|
||||
|
||||
self.addWidget(self.toolbar)
|
||||
|
||||
|
||||
@@ -87,14 +87,13 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
for device, device_obj in self.dev.items():
|
||||
item = QListWidgetItem(self.dev_list)
|
||||
device_item = DeviceItem(
|
||||
parent=self, device=device, icon=map_device_type_to_icon(device_obj)
|
||||
parent=self,
|
||||
device=device,
|
||||
devices=self.dev,
|
||||
icon=map_device_type_to_icon(device_obj),
|
||||
)
|
||||
|
||||
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
|
||||
|
||||
device_config = self.dev[device]._config # pylint: disable=protected-access
|
||||
device_item.set_display_config(device_config)
|
||||
tooltip = device_config.get("description", "")
|
||||
tooltip = self.dev[device]._config.get("description", "")
|
||||
device_item.setToolTip(tooltip)
|
||||
device_item.broadcast_size_hint.connect(item.setSizeHint)
|
||||
item.setSizeHint(device_item.sizeHint())
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
from ast import literal_eval
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QLabel,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
|
||||
DeviceConfigForm,
|
||||
)
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class _CommSignals(QObject):
|
||||
error = Signal(Exception)
|
||||
done = Signal()
|
||||
|
||||
|
||||
class _CommunicateUpdate(QRunnable):
|
||||
|
||||
def __init__(self, config_helper: ConfigHelper, device: str, config: dict) -> None:
|
||||
super().__init__()
|
||||
self.config_helper = config_helper
|
||||
self.device = device
|
||||
self.config = config
|
||||
self.signals = _CommSignals()
|
||||
|
||||
@SafeSlot()
|
||||
def run(self):
|
||||
try:
|
||||
timeout = self.config_helper.suggested_timeout_s(self.config)
|
||||
RID = self.config_helper.send_config_request(
|
||||
action="update", config={self.device: self.config}, wait_for_response=False
|
||||
)
|
||||
logger.info("Waiting for config reply")
|
||||
reply = self.config_helper.wait_for_config_reply(RID, timeout=timeout)
|
||||
self.config_helper.handle_update_reply(reply, RID, timeout)
|
||||
logger.info("Done updating config!")
|
||||
except Exception as e:
|
||||
self.signals.error.emit(e)
|
||||
finally:
|
||||
self.signals.done.emit()
|
||||
|
||||
|
||||
class DeviceConfigDialog(BECWidget, QDialog):
|
||||
RPC = False
|
||||
applied = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
device: str | None = None,
|
||||
config_helper: ConfigHelper | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._config_helper = config_helper or ConfigHelper(
|
||||
self.client.connector, self.client._service_name
|
||||
)
|
||||
self.threadpool = QThreadPool()
|
||||
self._device = device
|
||||
self.setWindowTitle(f"Edit config for: {device}")
|
||||
self._container = QStackedLayout()
|
||||
self._container.setStackingMode(QStackedLayout.StackAll)
|
||||
|
||||
self._layout = QVBoxLayout()
|
||||
user_warning = QLabel(
|
||||
"Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n"
|
||||
"Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc."
|
||||
)
|
||||
user_warning.setWordWrap(True)
|
||||
user_warning.setStyleSheet("QLabel { color: red; }")
|
||||
self._layout.addWidget(user_warning)
|
||||
self._add_form()
|
||||
self._add_overlay()
|
||||
self._add_buttons()
|
||||
|
||||
self.setLayout(self._container)
|
||||
self._overlay_widget.setVisible(False)
|
||||
|
||||
def _add_form(self):
|
||||
self._form_widget = QWidget()
|
||||
self._form_widget.setLayout(self._layout)
|
||||
self._form = DeviceConfigForm()
|
||||
self._layout.addWidget(self._form)
|
||||
|
||||
for row in self._form.enumerate_form_widgets():
|
||||
if row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE:
|
||||
row.widget._set_pretty_display()
|
||||
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
self._container.addWidget(self._form_widget)
|
||||
|
||||
def _add_overlay(self):
|
||||
self._overlay_widget = QWidget()
|
||||
self._overlay_widget.setStyleSheet("background-color:rgba(128,128,128,128);")
|
||||
self._overlay_widget.setAutoFillBackground(True)
|
||||
self._overlay_layout = QVBoxLayout()
|
||||
self._overlay_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._overlay_widget.setLayout(self._overlay_layout)
|
||||
|
||||
self._spinner = SpinnerWidget(parent=self)
|
||||
self._spinner.setMinimumSize(QSize(100, 100))
|
||||
self._overlay_layout.addWidget(self._spinner)
|
||||
self._container.addWidget(self._overlay_widget)
|
||||
|
||||
def _add_buttons(self):
|
||||
button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.Apply | QDialogButtonBox.Ok | QDialogButtonBox.Cancel
|
||||
)
|
||||
button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
self._layout.addWidget(button_box)
|
||||
|
||||
def _fetch_config(self):
|
||||
self._initial_config = {}
|
||||
if (
|
||||
self.client.device_manager is not None
|
||||
and self._device in self.client.device_manager.devices
|
||||
):
|
||||
self._initial_config = self.client.device_manager.devices.get(self._device)._config
|
||||
|
||||
def _fill_form(self):
|
||||
self._form.set_data(DeviceConfigModel.model_validate(self._initial_config))
|
||||
|
||||
def updated_config(self):
|
||||
new_config = self._form.get_form_data()
|
||||
diff = {
|
||||
k: v for k, v in new_config.items() if self._initial_config.get(k) != new_config.get(k)
|
||||
}
|
||||
if diff.get("deviceConfig") is not None:
|
||||
# TODO: special cased in some parts of device manager but not others, should
|
||||
# be removed in config update as with below issue
|
||||
diff["deviceConfig"].pop("device_access", None)
|
||||
# TODO: replace when https://github.com/bec-project/bec/issues/528 is resolved
|
||||
diff["deviceConfig"] = {
|
||||
k: literal_eval(str(v)) for k, v in diff["deviceConfig"].items()
|
||||
}
|
||||
return diff
|
||||
|
||||
@SafeSlot()
|
||||
def apply(self):
|
||||
self._process_update_action()
|
||||
self.applied.emit()
|
||||
|
||||
@SafeSlot()
|
||||
def accept(self):
|
||||
self._process_update_action()
|
||||
return super().accept()
|
||||
|
||||
def _process_update_action(self):
|
||||
updated_config = self.updated_config()
|
||||
if (device_name := updated_config.get("name")) == "":
|
||||
logger.warning("Can't create a device with no name!")
|
||||
elif set(updated_config.keys()) & set(DEVICE_CONF_KEYS.NON_UPDATABLE):
|
||||
logger.info(
|
||||
f"Removing old device {self._device} and adding new device {device_name or self._device} with modified config: {updated_config}"
|
||||
)
|
||||
else:
|
||||
self._update_device_config(updated_config)
|
||||
|
||||
def _update_device_config(self, config: dict):
|
||||
if self._device is None:
|
||||
return
|
||||
if config == {}:
|
||||
logger.info("No changes made to device config")
|
||||
return
|
||||
logger.info(f"Sending request to update device config: {config}")
|
||||
|
||||
self._start_waiting_display()
|
||||
communicate_update = _CommunicateUpdate(self._config_helper, self._device, config)
|
||||
communicate_update.signals.error.connect(self.update_error)
|
||||
communicate_update.signals.done.connect(self.update_done)
|
||||
self.threadpool.start(communicate_update)
|
||||
|
||||
@SafeSlot()
|
||||
def update_done(self):
|
||||
self._stop_waiting_display()
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
|
||||
@SafeSlot(Exception, popup_error=True)
|
||||
def update_error(self, e: Exception):
|
||||
raise RuntimeError("Failed to update device configuration") from e
|
||||
|
||||
def _start_waiting_display(self):
|
||||
self._overlay_widget.setVisible(True)
|
||||
self._spinner.start()
|
||||
QApplication.processEvents()
|
||||
|
||||
def _stop_waiting_display(self):
|
||||
self._overlay_widget.setVisible(False)
|
||||
self._spinner.stop()
|
||||
QApplication.processEvents()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QLineEdit, QPushButton, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
dialog = None
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
widget = QWidget()
|
||||
widget.setLayout(QVBoxLayout())
|
||||
|
||||
device = QLineEdit()
|
||||
widget.layout().addWidget(device)
|
||||
|
||||
def _destroy_dialog(*_):
|
||||
nonlocal dialog
|
||||
dialog = None
|
||||
|
||||
def accept(*args):
|
||||
logger.success(f"submitted device config form {dialog} {args}")
|
||||
_destroy_dialog()
|
||||
|
||||
def _show_dialog(*_):
|
||||
nonlocal dialog
|
||||
if dialog is None:
|
||||
dialog = DeviceConfigDialog(device=device.text())
|
||||
dialog.accepted.connect(accept)
|
||||
dialog.rejected.connect(_destroy_dialog)
|
||||
dialog.open()
|
||||
|
||||
button = QPushButton("Show device dialog")
|
||||
widget.layout().addWidget(button)
|
||||
button.clicked.connect(_show_dialog)
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_name
|
||||
from bec_widgets.utils.forms_from_types import styles
|
||||
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
DEFAULT_WIDGET_TYPES,
|
||||
BoolFormItem,
|
||||
BoolToggleFormItem,
|
||||
)
|
||||
|
||||
|
||||
class DeviceConfigForm(PydanticModelForm):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(self, parent=None, client=None, pretty_display=False, **kwargs):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
data_model=DeviceConfigModel,
|
||||
pretty_display=pretty_display,
|
||||
client=client,
|
||||
**kwargs,
|
||||
)
|
||||
self._widget_types = DEFAULT_WIDGET_TYPES.copy()
|
||||
self._widget_types["bool"] = (lambda spec: spec.item_type is bool, BoolToggleFormItem)
|
||||
self._widget_types["optional_bool"] = (
|
||||
lambda spec: spec.item_type == bool | None,
|
||||
BoolFormItem,
|
||||
)
|
||||
self._validity.setVisible(False)
|
||||
self._connect_to_theme_change()
|
||||
self.populate()
|
||||
|
||||
def _post_init(self): ...
|
||||
|
||||
def set_pretty_display_theme(self, theme: str | None = None):
|
||||
if theme is None:
|
||||
theme = get_theme_name()
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
|
||||
def get_form_data(self):
|
||||
"""Get the entered metadata as a dict."""
|
||||
return self._md_schema.model_validate(super().get_form_data()).model_dump()
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
|
||||
|
||||
def set_schema(self, schema: type[BaseModel]):
|
||||
raise TypeError("This class doesn't support changing the schema")
|
||||
|
||||
def set_data(self, data: DeviceConfigModel): # type: ignore # This class locks the type
|
||||
super().set_data(data)
|
||||
@@ -3,79 +3,89 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
from bec_lib.devicemanager import DeviceContainer
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QMimeData, QSize, Qt, Signal
|
||||
from qtpy.QtGui import QDrag
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QTabWidget, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_name
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
|
||||
from bec_widgets.utils.forms_from_types import styles
|
||||
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
DeviceConfigDialog,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
|
||||
DeviceConfigForm,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_signal_display import (
|
||||
SignalDisplay,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtGui import QMouseEvent
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceItemForm(PydanticModelForm):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(self, parent=None, client=None, pretty_display=False, **kwargs):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
data_model=DeviceConfigModel,
|
||||
pretty_display=pretty_display,
|
||||
client=client,
|
||||
**kwargs,
|
||||
)
|
||||
self._validity.setVisible(False)
|
||||
self._connect_to_theme_change()
|
||||
|
||||
def set_pretty_display_theme(self, theme: str | None = None):
|
||||
if theme is None:
|
||||
theme = get_theme_name()
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
|
||||
|
||||
|
||||
class DeviceItem(ExpandableGroupFrame):
|
||||
broadcast_size_hint = Signal(QSize)
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent, device: str, icon: str = "") -> None:
|
||||
def __init__(self, parent, device: str, devices: DeviceContainer, icon: str = "") -> None:
|
||||
super().__init__(parent, title=device, expanded=False, icon=icon)
|
||||
|
||||
self.dev = devices
|
||||
self._drag_pos = None
|
||||
self._expanded_first_time = False
|
||||
self._data = None
|
||||
self.device = device
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.set_layout(layout)
|
||||
|
||||
self._layout = QHBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._tab_widget = QTabWidget(tabShape=QTabWidget.TabShape.Rounded)
|
||||
self._tab_widget.setDocumentMode(True)
|
||||
self._layout.addWidget(self._tab_widget)
|
||||
|
||||
self.set_layout(self._layout)
|
||||
|
||||
self._form_page = QWidget()
|
||||
self._form_page_layout = QVBoxLayout()
|
||||
self._form_page.setLayout(self._form_page_layout)
|
||||
|
||||
self._signal_page = QWidget()
|
||||
self._signal_page_layout = QVBoxLayout()
|
||||
self._signal_page.setLayout(self._signal_page_layout)
|
||||
|
||||
self._tab_widget.addTab(self._form_page, "Configuration")
|
||||
self._tab_widget.addTab(self._signal_page, "Signals")
|
||||
self.adjustSize()
|
||||
self._title.clicked.connect(self.switch_expanded_state)
|
||||
self._title_icon.clicked.connect(self.switch_expanded_state)
|
||||
|
||||
def _create_title_layout(self, title: str, icon: str):
|
||||
super()._create_title_layout(title, icon)
|
||||
self.edit_button = QToolButton()
|
||||
self.edit_button.setIcon(
|
||||
material_icon(icon_name="edit", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._title_layout.insertWidget(self._title_layout.count() - 1, self.edit_button)
|
||||
self.edit_button.clicked.connect(self._create_edit_dialog)
|
||||
|
||||
def _create_edit_dialog(self):
|
||||
dialog = DeviceConfigDialog(parent=self, device=self.device)
|
||||
dialog.accepted.connect(self._reload_config)
|
||||
dialog.applied.connect(self._reload_config)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot()
|
||||
def switch_expanded_state(self):
|
||||
if not self.expanded and not self._expanded_first_time:
|
||||
self._expanded_first_time = True
|
||||
self.form = DeviceItemForm(parent=self, pretty_display=True)
|
||||
self._contents.layout().addWidget(self.form)
|
||||
if self._data:
|
||||
self.form.set_data(self._data)
|
||||
self.form = DeviceConfigForm(parent=self, pretty_display=True)
|
||||
self._form_page_layout.addWidget(self.form)
|
||||
self.signals = SignalDisplay(parent=self, device=self.device)
|
||||
self._signal_page_layout.addWidget(self.signals)
|
||||
self._reload_config()
|
||||
self.broadcast_size_hint.emit(self.sizeHint())
|
||||
super().switch_expanded_state()
|
||||
if self._expanded_first_time:
|
||||
@@ -86,6 +96,10 @@ class DeviceItem(ExpandableGroupFrame):
|
||||
self.adjustSize()
|
||||
self.broadcast_size_hint.emit(self.sizeHint())
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def _reload_config(self, *_):
|
||||
self.set_display_config(self.dev[self.device]._config)
|
||||
|
||||
def set_display_config(self, config_dict: dict):
|
||||
"""Set the displayed information from a device config dict, which must conform to the
|
||||
bec_lib.atlas_models.Device config model."""
|
||||
@@ -118,29 +132,33 @@ class DeviceItem(ExpandableGroupFrame):
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
|
||||
DeviceConfigForm,
|
||||
)
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = QWidget()
|
||||
layout = QHBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
item = DeviceItem("Device")
|
||||
mock_config = {
|
||||
"name": "Test Device",
|
||||
"enabled": True,
|
||||
"deviceClass": "FakeDeviceClass",
|
||||
"deviceConfig": {"kwarg1": "value1"},
|
||||
"readoutPriority": "baseline",
|
||||
"description": "A device for testing out a widget",
|
||||
"readOnly": True,
|
||||
"softwareTrigger": False,
|
||||
"deviceTags": {"tag1", "tag2", "tag3"},
|
||||
"userParameter": {"some_setting": "some_ value"},
|
||||
}
|
||||
item = DeviceItem(widget, "Device", {"Device": MagicMock(enabled=True, _config=mock_config)})
|
||||
layout.addWidget(DarkModeButton())
|
||||
layout.addWidget(item)
|
||||
item.set_display_config(
|
||||
{
|
||||
"name": "Test Device",
|
||||
"enabled": True,
|
||||
"deviceClass": "FakeDeviceClass",
|
||||
"deviceConfig": {"kwarg1": "value1"},
|
||||
"readoutPriority": "baseline",
|
||||
"description": "A device for testing out a widget",
|
||||
"readOnly": True,
|
||||
"softwareTrigger": False,
|
||||
"deviceTags": ["tag1", "tag2", "tag3"],
|
||||
"userParameter": {"some_setting": "some_ value"},
|
||||
}
|
||||
)
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock
|
||||
from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel
|
||||
|
||||
|
||||
class SignalDisplay(BECWidget, QWidget):
|
||||
RPC = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
device: str = "",
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
parent_dock: BECDock | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""A widget to display all the signals from a given device, and allow getting
|
||||
a fresh reading."""
|
||||
super().__init__(client, config, gui_id, theme_update, parent_dock, **kwargs)
|
||||
self.get_bec_shortcuts()
|
||||
self._layout = QVBoxLayout()
|
||||
self.setLayout(self._layout)
|
||||
self._content = QWidget()
|
||||
self._layout.addWidget(self._content)
|
||||
self._device = device
|
||||
self.device = device
|
||||
|
||||
@SafeSlot()
|
||||
def _refresh(self):
|
||||
if self.device in self.dev:
|
||||
self.dev.get(self.device).read(cached=False)
|
||||
self.dev.get(self.device).read_configuration(cached=False)
|
||||
|
||||
def _add_refresh_button(self):
|
||||
button_holder = QWidget()
|
||||
button_holder.setLayout(QHBoxLayout())
|
||||
button_holder.layout().setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
button_holder.layout().setContentsMargins(0, 0, 0, 0)
|
||||
refresh_button = QToolButton()
|
||||
refresh_button.setIcon(
|
||||
material_icon(icon_name="refresh", size=(20, 20), convert_to_pixmap=False)
|
||||
)
|
||||
refresh_button.clicked.connect(self._refresh)
|
||||
button_holder.layout().addWidget(refresh_button)
|
||||
self._content_layout.addWidget(button_holder)
|
||||
|
||||
def _populate(self):
|
||||
self._content.deleteLater()
|
||||
self._content = QWidget()
|
||||
self._layout.addWidget(self._content)
|
||||
self._content_layout = QVBoxLayout()
|
||||
self._content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._content.setLayout(self._content_layout)
|
||||
|
||||
self._add_refresh_button()
|
||||
|
||||
if self._device in self.dev:
|
||||
for sig in self.dev[self.device]._info.get("signals", {}).keys():
|
||||
self._content_layout.addWidget(
|
||||
SignalLabel(
|
||||
device=self._device,
|
||||
signal=sig,
|
||||
show_select_button=False,
|
||||
show_default_units=True,
|
||||
)
|
||||
)
|
||||
self._content_layout.addStretch(1)
|
||||
else:
|
||||
self._content_layout.addWidget(
|
||||
QLabel(f"Device {self.device} not found in device manager!")
|
||||
)
|
||||
|
||||
@SafeProperty(str)
|
||||
def device(self):
|
||||
return self._device
|
||||
|
||||
@device.setter
|
||||
def device(self, value: str):
|
||||
self._device = value
|
||||
self._populate()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
widget = SignalDisplay(device="samx")
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -16,7 +16,6 @@ from qtpy.QtWidgets import (
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -180,6 +179,7 @@ class SignalLabel(BECWidget, QWidget):
|
||||
self._custom_units: str = custom_units
|
||||
self._show_default_units: bool = show_default_units
|
||||
self._decimal_places = 3
|
||||
self._dtype = None
|
||||
|
||||
self._show_hinted_signals: bool = True
|
||||
self._show_normal_signals: bool = False
|
||||
@@ -241,8 +241,10 @@ class SignalLabel(BECWidget, QWidget):
|
||||
"""Subscribe to the Redis topic for the device to display"""
|
||||
if not self._connected and self._device and self._device in self.dev:
|
||||
self._connected = True
|
||||
self._readback_endpoint = MessageEndpoints.device_readback(self._device)
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, self._readback_endpoint)
|
||||
self._read_endpoint = MessageEndpoints.device_read(self._device)
|
||||
self._read_config_endpoint = MessageEndpoints.device_read_configuration(self._device)
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, self._read_endpoint)
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, self._read_config_endpoint)
|
||||
self._manual_read()
|
||||
self.set_display_value(self._value)
|
||||
|
||||
@@ -250,7 +252,8 @@ class SignalLabel(BECWidget, QWidget):
|
||||
"""Unsubscribe from the Redis topic for the device to display"""
|
||||
if self._connected:
|
||||
self._connected = False
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._readback_endpoint)
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._read_endpoint)
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._read_config_endpoint)
|
||||
|
||||
def _manual_read(self):
|
||||
if self._device is None or not isinstance(
|
||||
@@ -259,8 +262,13 @@ class SignalLabel(BECWidget, QWidget):
|
||||
self._units = ""
|
||||
self._value = "__"
|
||||
return
|
||||
signal: Signal = (
|
||||
getattr(device, self.signal, None) if isinstance(device, Device) else device
|
||||
signal, info = (
|
||||
(
|
||||
getattr(device, self.signal, None),
|
||||
device._info.get("signals", {}).get(self._signal, {}).get("describe", {}),
|
||||
)
|
||||
if isinstance(device, Device)
|
||||
else (device, device.describe().get(self._device))
|
||||
)
|
||||
if not isinstance(signal, Signal): # Avoid getting other attributes of device, e.g. methods
|
||||
signal = None
|
||||
@@ -269,7 +277,8 @@ class SignalLabel(BECWidget, QWidget):
|
||||
self._value = "__"
|
||||
return
|
||||
self._value = signal.get()
|
||||
self._units = signal.get_device_config().get("egu", "")
|
||||
self._units = info.get("egu", "")
|
||||
self._dtype = info.get("dtype", "float")
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback(self, msg: dict, metadata: dict) -> None:
|
||||
@@ -278,8 +287,10 @@ class SignalLabel(BECWidget, QWidget):
|
||||
"""
|
||||
try:
|
||||
signal_to_read = self._patch_hinted_signal()
|
||||
self._value = msg["signals"][signal_to_read]["value"]
|
||||
self.set_display_value(self._value)
|
||||
_value = msg["signals"].get(signal_to_read, {}).get("value")
|
||||
if _value is not None:
|
||||
self._value = _value
|
||||
self.set_display_value(self._value)
|
||||
except Exception as e:
|
||||
self._display.setText("ERROR!")
|
||||
self._display.setToolTip(
|
||||
@@ -401,7 +412,10 @@ class SignalLabel(BECWidget, QWidget):
|
||||
if self._decimal_places == 0:
|
||||
return value
|
||||
try:
|
||||
return f"{float(value):0.{self._decimal_places}f}"
|
||||
if self._dtype in ("integer", "float"):
|
||||
return f"{float(value):0.{self._decimal_places}f}"
|
||||
else:
|
||||
return str(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ class ToggleSwitch(QWidget):
|
||||
A simple toggle.
|
||||
"""
|
||||
|
||||
stateChanged = Signal(bool)
|
||||
enabled = Signal(bool)
|
||||
ICON_NAME = "toggle_on"
|
||||
PLUGIN = True
|
||||
@@ -42,11 +43,19 @@ class ToggleSwitch(QWidget):
|
||||
|
||||
@checked.setter
|
||||
def checked(self, state):
|
||||
if self._checked != state:
|
||||
self.stateChanged.emit(state)
|
||||
self._checked = state
|
||||
self.update_colors()
|
||||
self.set_thumb_pos_to_state()
|
||||
self.enabled.emit(self._checked)
|
||||
|
||||
def setChecked(self, state: bool):
|
||||
self.checked = state
|
||||
|
||||
def isChecked(self):
|
||||
return self.checked
|
||||
|
||||
@Property(QPointF)
|
||||
def thumb_pos(self):
|
||||
return self._thumb_pos
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.15.0"
|
||||
version = "2.21.4"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -13,15 +13,15 @@ classifiers = [
|
||||
"Topic :: Scientific/Engineering",
|
||||
]
|
||||
dependencies = [
|
||||
"bec_ipython_client>=3.38, <=4.0", # needed for jupyter console
|
||||
"bec_lib>=3.38, <=4.0",
|
||||
"bec_ipython_client>=3.42.4, <=4.0", # needed for jupyter console
|
||||
"bec_lib>=3.44, <=4.0",
|
||||
"bec_qthemes~=0.7, >=0.7",
|
||||
"black~=25.0", # needed for bw-generate-cli
|
||||
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
|
||||
"black~=25.0", # needed for bw-generate-cli
|
||||
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
|
||||
"pydantic~=2.0",
|
||||
"pyqtgraph~=0.13",
|
||||
"PySide6~=6.8.2",
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
]
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ def test_rpc_add_dock_with_plots_e2e(qtbot, bec_client_lib, connected_client_gui
|
||||
|
||||
# Waii until docks are registered
|
||||
qtbot.waitUntil(check_docks_registered, timeout=5000)
|
||||
qtbot.wait(500)
|
||||
assert len(dock.panels) == 3
|
||||
assert hasattr(gui.bec, "dock_0")
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
|
||||
gui = connected_client_gui_obj
|
||||
dock_area = gui.bec
|
||||
# Number of top level widgets, should be 4
|
||||
top_level_widgets_count = 4
|
||||
top_level_widgets_count = 12
|
||||
assert len(gui._server_registry) == top_level_widgets_count
|
||||
# Number of widgets with parent_id == None, should be 2
|
||||
widgets = [
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_scan_metadata_for_custom_scan(
|
||||
def do_test():
|
||||
# Set the metadata
|
||||
grid: QGridLayout = scan_control._metadata_form._form_grid.layout()
|
||||
for i in range(grid.rowCount()): # type: ignore
|
||||
for i in range(grid.rowCount() - 1): # type: ignore
|
||||
field_name = grid.itemAtPosition(i, 0).widget().property("_model_field_name")
|
||||
if (value_to_set := md.pop(field_name, None)) is not None:
|
||||
grid.itemAtPosition(i, 1).widget().setValue(value_to_set)
|
||||
|
||||
@@ -12,12 +12,11 @@ may not be created immediately after the rpc call is made.
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bec_widgets.cli.client import BECDockArea
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
|
||||
|
||||
PYTEST_TIMEOUT = 50
|
||||
@@ -321,20 +320,20 @@ def test_widgets_e2e_signal_combobox(qtbot, connected_client_gui_obj, random_gen
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
|
||||
dock: client.BECDock
|
||||
_, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
|
||||
widget: client.SignalComboBox
|
||||
|
||||
widget.set_device("samx")
|
||||
info = bec.device_manager.devices.samx._info["signals"]
|
||||
assert widget.signals == [
|
||||
"readback",
|
||||
"setpoint",
|
||||
"motor_is_moving",
|
||||
"velocity",
|
||||
"acceleration",
|
||||
"tolerance",
|
||||
["samx (readback)", info.get("readback")],
|
||||
["setpoint", info.get("setpoint")],
|
||||
["motor_is_moving", info.get("motor_is_moving")],
|
||||
["velocity", info.get("velocity")],
|
||||
["acceleration", info.get("acceleration")],
|
||||
["tolerance", info.get("tolerance")],
|
||||
]
|
||||
widget.set_signal("readback")
|
||||
widget.set_signal("samx (readback)")
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
@@ -168,11 +168,16 @@ def test_accept_changes(axis_settings_fixture, qtbot):
|
||||
axis_settings.ui.x_grid.checked = True
|
||||
|
||||
axis_settings.accept_changes()
|
||||
qtbot.wait(200)
|
||||
|
||||
assert plot_base.title == "New Title"
|
||||
assert plot_base.x_min == 10
|
||||
assert plot_base.x_max == 20
|
||||
assert plot_base.x_label == "New X Label"
|
||||
assert plot_base.x_log is True
|
||||
assert plot_base.x_grid is True
|
||||
qtbot.waitUntil(
|
||||
lambda: all(
|
||||
[
|
||||
plot_base.title == "New Title",
|
||||
plot_base.x_min == 10,
|
||||
plot_base.x_max == 20,
|
||||
plot_base.x_label == "New X Label",
|
||||
plot_base.x_log is True,
|
||||
plot_base.x_grid is True,
|
||||
]
|
||||
),
|
||||
timeout=200,
|
||||
)
|
||||
|
||||
@@ -47,24 +47,21 @@ def test_bec_dock_area_add_remove_dock(bec_dock_area, qtbot):
|
||||
# Remove docks
|
||||
d0_name = d0.name()
|
||||
bec_dock_area.delete(d0_name)
|
||||
qtbot.wait(200)
|
||||
d1.remove()
|
||||
qtbot.wait(200)
|
||||
|
||||
assert len(bec_dock_area.dock_area.docks) == initial_count + 1
|
||||
qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == initial_count + 1, timeout=200)
|
||||
assert d0.name() not in dict(bec_dock_area.dock_area.docks)
|
||||
assert d1.name() not in dict(bec_dock_area.dock_area.docks)
|
||||
assert d2.name() in dict(bec_dock_area.dock_area.docks)
|
||||
|
||||
|
||||
def test_close_docks(bec_dock_area, qtbot):
|
||||
d0 = bec_dock_area.new(name="dock_0")
|
||||
d1 = bec_dock_area.new(name="dock_1")
|
||||
d2 = bec_dock_area.new(name="dock_2")
|
||||
_ = bec_dock_area.new(name="dock_0")
|
||||
_ = bec_dock_area.new(name="dock_1")
|
||||
_ = bec_dock_area.new(name="dock_2")
|
||||
|
||||
bec_dock_area.delete_all()
|
||||
qtbot.wait(200)
|
||||
assert len(bec_dock_area.dock_area.docks) == 0
|
||||
qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == 0)
|
||||
|
||||
|
||||
def test_undock_and_dock_docks(bec_dock_area, qtbot):
|
||||
@@ -100,13 +97,15 @@ def test_new_dock_raises_for_invalid_name(bec_dock_area):
|
||||
# Toolbar Actions
|
||||
###################################
|
||||
def test_toolbar_add_plot_waveform(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_plots"].widgets["waveform"].trigger()
|
||||
bec_dock_area.toolbar.components.get_action("menu_plots").actions["waveform"].action.trigger()
|
||||
assert "waveform_0" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["waveform_0"].widgets[0].config.widget_class == "Waveform"
|
||||
|
||||
|
||||
def test_toolbar_add_plot_scatter_waveform(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].trigger()
|
||||
bec_dock_area.toolbar.components.get_action("menu_plots").actions[
|
||||
"scatter_waveform"
|
||||
].action.trigger()
|
||||
assert "scatter_waveform_0" in bec_dock_area.panels
|
||||
assert (
|
||||
bec_dock_area.panels["scatter_waveform_0"].widgets[0].config.widget_class
|
||||
@@ -115,19 +114,22 @@ def test_toolbar_add_plot_scatter_waveform(bec_dock_area):
|
||||
|
||||
|
||||
def test_toolbar_add_plot_image(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_plots"].widgets["image"].trigger()
|
||||
bec_dock_area.toolbar.components.get_action("menu_plots").actions["image"].action.trigger()
|
||||
assert "image_0" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["image_0"].widgets[0].config.widget_class == "Image"
|
||||
|
||||
|
||||
def test_toolbar_add_plot_motor_map(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_plots"].widgets["motor_map"].trigger()
|
||||
bec_dock_area.toolbar.components.get_action("menu_plots").actions["motor_map"].action.trigger()
|
||||
assert "motor_map_0" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["motor_map_0"].widgets[0].config.widget_class == "MotorMap"
|
||||
|
||||
|
||||
def test_toolbar_add_multi_waveform(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_plots"].widgets["multi_waveform"].trigger()
|
||||
bec_dock_area.toolbar.components.get_action("menu_plots").actions[
|
||||
"multi_waveform"
|
||||
].action.trigger()
|
||||
# Check if the MultiWaveform panel is created
|
||||
assert "multi_waveform_0" in bec_dock_area.panels
|
||||
assert (
|
||||
bec_dock_area.panels["multi_waveform_0"].widgets[0].config.widget_class == "MultiWaveform"
|
||||
@@ -135,7 +137,9 @@ def test_toolbar_add_multi_waveform(bec_dock_area):
|
||||
|
||||
|
||||
def test_toolbar_add_device_positioner_box(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_devices"].widgets["positioner_box"].trigger()
|
||||
bec_dock_area.toolbar.components.get_action("menu_devices").actions[
|
||||
"positioner_box"
|
||||
].action.trigger()
|
||||
assert "positioner_box_0" in bec_dock_area.panels
|
||||
assert (
|
||||
bec_dock_area.panels["positioner_box_0"].widgets[0].config.widget_class == "PositionerBox"
|
||||
@@ -146,19 +150,21 @@ def test_toolbar_add_utils_queue(bec_dock_area, bec_queue_msg_full):
|
||||
bec_dock_area.client.connector.set_and_publish(
|
||||
MessageEndpoints.scan_queue_status(), bec_queue_msg_full
|
||||
)
|
||||
bec_dock_area.toolbar.widgets["menu_utils"].widgets["queue"].trigger()
|
||||
bec_dock_area.toolbar.components.get_action("menu_utils").actions["queue"].action.trigger()
|
||||
assert "bec_queue_0" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["bec_queue_0"].widgets[0].config.widget_class == "BECQueue"
|
||||
|
||||
|
||||
def test_toolbar_add_utils_status(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_utils"].widgets["status"].trigger()
|
||||
bec_dock_area.toolbar.components.get_action("menu_utils").actions["status"].action.trigger()
|
||||
assert "bec_status_box_0" in bec_dock_area.panels
|
||||
assert bec_dock_area.panels["bec_status_box_0"].widgets[0].config.widget_class == "BECStatusBox"
|
||||
|
||||
|
||||
def test_toolbar_add_utils_progress_bar(bec_dock_area):
|
||||
bec_dock_area.toolbar.widgets["menu_utils"].widgets["progress_bar"].trigger()
|
||||
bec_dock_area.toolbar.components.get_action("menu_utils").actions[
|
||||
"progress_bar"
|
||||
].action.trigger()
|
||||
assert "ring_progress_bar_0" in bec_dock_area.panels
|
||||
assert (
|
||||
bec_dock_area.panels["ring_progress_bar_0"].widgets[0].config.widget_class
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
|
||||
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
|
||||
BECProgressBar,
|
||||
ProgressState,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -33,3 +36,23 @@ def test_progressbar_label(progressbar):
|
||||
progressbar.label_template = "Test: $value"
|
||||
progressbar.set_value(50)
|
||||
assert progressbar.center_label.text() == "Test: 50"
|
||||
|
||||
|
||||
def test_progress_state_from_bec_status():
|
||||
"""ProgressState.from_bec_status() maps BEC literals correctly."""
|
||||
mapping = {
|
||||
"open": ProgressState.NORMAL,
|
||||
"paused": ProgressState.PAUSED,
|
||||
"aborted": ProgressState.INTERRUPTED,
|
||||
"halted": ProgressState.PAUSED,
|
||||
"closed": ProgressState.COMPLETED,
|
||||
"UNKNOWN": ProgressState.NORMAL, # fallback
|
||||
}
|
||||
for text, expected in mapping.items():
|
||||
assert ProgressState.from_bec_status(text) is expected
|
||||
|
||||
|
||||
def test_progressbar_state_setter(progressbar):
|
||||
"""Setting .state reflects internally."""
|
||||
progressbar.state = ProgressState.PAUSED
|
||||
assert progressbar.state is ProgressState.PAUSED
|
||||
|
||||
@@ -93,7 +93,7 @@ def test_curve_setting_switch_device_mode(curve_setting_fixture, qtbot):
|
||||
assert curve_setting.device_x.isEnabled()
|
||||
|
||||
# This line edit should reflect the waveform.x_axis_mode["name"], or be blank if none
|
||||
assert curve_setting.device_x.text() == wf.x_axis_mode["name"]
|
||||
assert curve_setting.device_x.currentText() == ""
|
||||
|
||||
|
||||
def test_curve_setting_refresh(curve_setting_fixture, qtbot):
|
||||
@@ -127,8 +127,8 @@ def test_change_device_from_target_widget(curve_setting_fixture, qtbot):
|
||||
|
||||
assert curve_setting.mode_combo.currentText() == "device"
|
||||
assert curve_setting.device_x.isEnabled()
|
||||
assert curve_setting.device_x.text() == wf.x_axis_mode["name"]
|
||||
assert curve_setting.signal_x.text() == wf.x_axis_mode["entry"]
|
||||
assert curve_setting.device_x.currentText() == wf.x_axis_mode["name"]
|
||||
assert curve_setting.signal_x.currentText() == f"{wf.x_axis_mode['entry']} (readback)"
|
||||
|
||||
|
||||
##################################################
|
||||
@@ -157,10 +157,10 @@ def test_curve_tree_init(curve_tree_fixture):
|
||||
assert curve_tree.color_palette == "plasma"
|
||||
assert curve_tree.tree.columnCount() == 7
|
||||
|
||||
assert "add" in curve_tree.toolbar.widgets
|
||||
assert "expand_all" in curve_tree.toolbar.widgets
|
||||
assert "collapse_all" in curve_tree.toolbar.widgets
|
||||
assert "renormalize_colors" in curve_tree.toolbar.widgets
|
||||
assert curve_tree.toolbar.components.exists("add")
|
||||
assert curve_tree.toolbar.components.exists("expand")
|
||||
assert curve_tree.toolbar.components.exists("collapse")
|
||||
assert curve_tree.toolbar.components.exists("renormalize_colors")
|
||||
|
||||
|
||||
def test_add_new_curve(curve_tree_fixture):
|
||||
|
||||
@@ -3,9 +3,12 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import QPoint, Qt
|
||||
from qtpy.QtWidgets import QTabWidget
|
||||
|
||||
from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_item import DeviceItemForm
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
|
||||
DeviceConfigForm,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
@@ -24,6 +27,7 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
@pytest.fixture
|
||||
def device_browser(qtbot, mocked_client):
|
||||
dev_browser = DeviceBrowser(client=mocked_client)
|
||||
dev_browser.dev["samx"].read_configuration = mock.MagicMock()
|
||||
qtbot.addWidget(dev_browser)
|
||||
qtbot.waitExposed(dev_browser)
|
||||
yield dev_browser
|
||||
@@ -83,11 +87,16 @@ def test_device_item_expansion(device_browser, qtbot):
|
||||
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
|
||||
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
|
||||
form = widget._contents.layout().itemAt(0).widget()
|
||||
qtbot.waitUntil(lambda: isinstance(form, DeviceItemForm), timeout=500)
|
||||
tab_widget: QTabWidget = widget._contents.layout().itemAt(0).widget()
|
||||
qtbot.waitUntil(lambda: tab_widget.widget(0) is not None, timeout=100)
|
||||
qtbot.waitUntil(
|
||||
lambda: isinstance(tab_widget.widget(0).layout().itemAt(0).widget(), DeviceConfigForm),
|
||||
timeout=100,
|
||||
)
|
||||
form = tab_widget.widget(0).layout().itemAt(0).widget()
|
||||
assert widget.expanded
|
||||
assert (name_field := form.widget_dict.get("name")) is not None
|
||||
assert name_field.getValue() == "samx"
|
||||
qtbot.waitUntil(lambda: name_field.getValue() == "samx", timeout=500)
|
||||
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
|
||||
assert not widget.expanded
|
||||
|
||||
|
||||
98
tests/unit_tests/test_device_config_form_dialog.py
Normal file
98
tests/unit_tests/test_device_config_form_dialog.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
DeviceConfigDialog,
|
||||
)
|
||||
|
||||
_BASIC_CONFIG = {
|
||||
"name": "test_device",
|
||||
"enabled": True,
|
||||
"deviceClass": "TestDevice",
|
||||
"readoutPriority": "monitored",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dialog(qtbot):
|
||||
"""Fixture to create a DeviceConfigDialog instance."""
|
||||
mock_device = MagicMock(_config=DeviceConfigModel.model_validate(_BASIC_CONFIG).model_dump())
|
||||
mock_client = MagicMock()
|
||||
mock_client.device_manager.devices = {"test_device": mock_device}
|
||||
dialog = DeviceConfigDialog(device="test_device", config_helper=MagicMock(), client=mock_client)
|
||||
qtbot.addWidget(dialog)
|
||||
return dialog
|
||||
|
||||
|
||||
def test_initialization(dialog):
|
||||
assert dialog._device == "test_device"
|
||||
assert dialog._container.count() == 2
|
||||
|
||||
|
||||
def test_fill_form(dialog):
|
||||
with patch.object(dialog._form, "set_data") as mock_set_data:
|
||||
dialog._fill_form()
|
||||
mock_set_data.assert_called_once_with(DeviceConfigModel.model_validate(_BASIC_CONFIG))
|
||||
|
||||
|
||||
def test_updated_config(dialog):
|
||||
"""Test that updated_config returns the correct changes."""
|
||||
dialog._initial_config = {"key1": "value1", "key2": "value2"}
|
||||
with patch.object(
|
||||
dialog._form, "get_form_data", return_value={"key1": "value1", "key2": "new_value"}
|
||||
):
|
||||
updated = dialog.updated_config()
|
||||
assert updated == {"key2": "new_value"}
|
||||
|
||||
|
||||
def test_apply(dialog):
|
||||
with patch.object(dialog, "_process_update_action") as mock_process_update:
|
||||
dialog.apply()
|
||||
mock_process_update.assert_called_once()
|
||||
|
||||
|
||||
def test_accept(dialog):
|
||||
with (
|
||||
patch.object(dialog, "_process_update_action") as mock_process_update,
|
||||
patch("qtpy.QtWidgets.QDialog.accept") as mock_parent_accept,
|
||||
):
|
||||
dialog.accept()
|
||||
mock_process_update.assert_called_once()
|
||||
mock_parent_accept.assert_called_once()
|
||||
|
||||
|
||||
def test_waiting_display(dialog, qtbot):
|
||||
with (
|
||||
patch.object(dialog._spinner, "start") as mock_spinner_start,
|
||||
patch.object(dialog._spinner, "stop") as mock_spinner_stop,
|
||||
):
|
||||
dialog.show()
|
||||
dialog._start_waiting_display()
|
||||
qtbot.waitUntil(dialog._overlay_widget.isVisible, timeout=100)
|
||||
mock_spinner_start.assert_called_once()
|
||||
mock_spinner_stop.assert_not_called()
|
||||
dialog._stop_waiting_display()
|
||||
qtbot.waitUntil(lambda: not dialog._overlay_widget.isVisible(), timeout=100)
|
||||
mock_spinner_stop.assert_called_once()
|
||||
|
||||
|
||||
def test_update_cycle(dialog, qtbot):
|
||||
update = {"enabled": False, "readoutPriority": "baseline", "deviceTags": {"tag"}}
|
||||
|
||||
def _mock_send(action="update", config=None, wait_for_response=True, timeout_s=None):
|
||||
dialog.client.device_manager.devices["test_device"]._config = config["test_device"] # type: ignore
|
||||
|
||||
dialog._config_helper.send_config_request = MagicMock(side_effect=_mock_send)
|
||||
for item in dialog._form.enumerate_form_widgets():
|
||||
if (val := update.get(item.label.property("_model_field_name"))) is not None:
|
||||
item.widget.setValue(val)
|
||||
|
||||
assert dialog.updated_config() == update
|
||||
dialog.apply()
|
||||
qtbot.waitUntil(lambda: dialog._config_helper.send_config_request.call_count == 1, timeout=100)
|
||||
|
||||
dialog._config_helper.send_config_request.assert_called_with(
|
||||
action="update", config={"test_device": update}, wait_for_response=False
|
||||
)
|
||||
@@ -94,18 +94,6 @@ def test_device_signal_qproperties(device_signal_base):
|
||||
assert device_signal_base._signal_filter == {Kind.config, Kind.normal}
|
||||
|
||||
|
||||
def test_device_signal_set_device(device_signal_base):
|
||||
"""Test if the set_device method works correctly"""
|
||||
device_signal_base.include_hinted_signals = True
|
||||
device_signal_base.set_device("samx")
|
||||
assert device_signal_base.device == "samx"
|
||||
assert device_signal_base.signals == ["readback"]
|
||||
device_signal_base.include_normal_signals = True
|
||||
assert device_signal_base.signals == ["readback", "setpoint"]
|
||||
device_signal_base.include_config_signals = True
|
||||
assert device_signal_base.signals == ["readback", "setpoint", "velocity"]
|
||||
|
||||
|
||||
def test_signal_combobox(qtbot, device_signal_combobox):
|
||||
"""Test the signal_combobox"""
|
||||
container = []
|
||||
@@ -120,17 +108,25 @@ def test_signal_combobox(qtbot, device_signal_combobox):
|
||||
device_signal_combobox.include_config_signals = True
|
||||
assert device_signal_combobox.signals == []
|
||||
device_signal_combobox.set_device("samx")
|
||||
assert device_signal_combobox.signals == ["readback", "setpoint", "velocity"]
|
||||
samx = device_signal_combobox.dev.samx
|
||||
assert device_signal_combobox.signals == [
|
||||
("samx (readback)", samx._info["signals"].get("readback")),
|
||||
("setpoint", samx._info["signals"].get("setpoint")),
|
||||
("velocity", samx._info["signals"].get("velocity")),
|
||||
]
|
||||
qtbot.wait(100)
|
||||
assert container == ["samx"]
|
||||
assert container == ["samx (readback)"]
|
||||
# Set the type of class from the FakeDevice to Signal
|
||||
fake_signal = FakeSignal(name="fake_signal")
|
||||
fake_signal = FakeSignal(name="fake_signal", info={"device_info": {"signals": {}}})
|
||||
device_signal_combobox.client.device_manager.add_devices([fake_signal])
|
||||
device_signal_combobox.set_device("fake_signal")
|
||||
assert device_signal_combobox.signals == ["fake_signal"]
|
||||
fake_signal = device_signal_combobox.dev.fake_signal
|
||||
assert device_signal_combobox.signals == [
|
||||
("fake_signal", fake_signal._info["signals"].get("fake_signal", {}))
|
||||
]
|
||||
assert device_signal_combobox._config_signals == []
|
||||
assert device_signal_combobox._normal_signals == []
|
||||
assert device_signal_combobox._hinted_signals == ["fake_signal"]
|
||||
assert device_signal_combobox._hinted_signals == [("fake_signal", {})]
|
||||
|
||||
|
||||
def test_signal_lineedit(device_signal_line_edit):
|
||||
@@ -148,3 +144,12 @@ def test_signal_lineedit(device_signal_line_edit):
|
||||
assert device_signal_line_edit._is_valid_input is True
|
||||
device_signal_line_edit.setText("invalid")
|
||||
assert device_signal_line_edit._is_valid_input is False
|
||||
|
||||
|
||||
def test_device_signal_input_base_cleanup(qtbot, mocked_client):
|
||||
|
||||
widget = DeviceInputWidget(client=mocked_client)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
|
||||
mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register)
|
||||
|
||||
@@ -3,12 +3,8 @@ from decimal import Decimal
|
||||
import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
FloatDecimalMetadataField,
|
||||
IntMetadataField,
|
||||
StrMetadataField,
|
||||
)
|
||||
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, TypedForm
|
||||
from bec_widgets.utils.forms_from_types.items import FloatDecimalFormItem, IntFormItem, StrFormItem
|
||||
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=missing-function-docstring
|
||||
@@ -58,9 +54,9 @@ def model_widget(qtbot):
|
||||
|
||||
|
||||
def test_widget_dict(model_widget: PydanticModelForm):
|
||||
assert isinstance(model_widget.widget_dict["str_optional"], StrMetadataField)
|
||||
assert isinstance(model_widget.widget_dict["float_nodefault"], FloatDecimalMetadataField)
|
||||
assert isinstance(model_widget.widget_dict["int_default"], IntMetadataField)
|
||||
assert isinstance(model_widget.widget_dict["str_optional"], StrFormItem)
|
||||
assert isinstance(model_widget.widget_dict["float_nodefault"], FloatDecimalFormItem)
|
||||
assert isinstance(model_widget.widget_dict["int_default"], IntFormItem)
|
||||
|
||||
|
||||
def test_widget_set_data(model_widget: PydanticModelForm):
|
||||
@@ -1,11 +1,12 @@
|
||||
import sys
|
||||
from typing import Literal
|
||||
from typing import Any, Literal, get_args
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
from bec_widgets.utils.forms_from_types.items import FormItemSpec
|
||||
from bec_widgets.utils.forms_from_types.items import FormItemSpec, ListFormItem
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 11), reason="Generic types don't support this in 3.10")
|
||||
@@ -58,3 +59,65 @@ def test_form_item_spec(input, validity):
|
||||
else:
|
||||
with pytest.raises(ValidationError):
|
||||
FormItemSpec.model_validate(input)
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
params=[
|
||||
{"type": list[int], "value": [1, 2, 3], "extra": 79},
|
||||
{"type": list[str], "value": ["a", "b", "c"], "extra": "string"},
|
||||
{"type": list[float], "value": [0.1, 0.2, 0.3], "extra": 79.0},
|
||||
]
|
||||
)
|
||||
def list_field_and_values(request, qtbot):
|
||||
itype, vals, extra = (
|
||||
request.param.get("type"),
|
||||
request.param.get("value"),
|
||||
request.param.get("extra"),
|
||||
)
|
||||
spec = FormItemSpec(item_type=itype, name="test_list", info=FieldInfo(annotation=itype))
|
||||
(widget := ListFormItem(parent=None, spec=spec)).setValue(vals)
|
||||
qtbot.addWidget(widget)
|
||||
yield widget, vals, extra, get_args(itype)[0]
|
||||
|
||||
|
||||
def test_list_metadata_field(list_field_and_values: tuple[ListFormItem, list, Any, type]):
|
||||
list_field, vals, extra, _ = list_field_and_values
|
||||
assert list_field.getValue() == vals
|
||||
assert list_field._main_widget.count() == 3
|
||||
|
||||
list_field._add_button.click()
|
||||
assert len(list_field.getValue()) == 4
|
||||
assert list_field._main_widget.count() == 4
|
||||
|
||||
list_field._main_widget.setCurrentRow(-1)
|
||||
list_field._remove_button.click()
|
||||
assert len(list_field.getValue()) == 4
|
||||
assert list_field._main_widget.count() == 4
|
||||
|
||||
list_field._main_widget.setCurrentRow(2)
|
||||
list_field._remove_button.click()
|
||||
assert list_field.getValue() == vals[:2] + [list_field._types.default]
|
||||
assert list_field._main_widget.count() == 3
|
||||
|
||||
list_field._main_widget.setCurrentRow(1)
|
||||
WidgetIO.set_value(list_field._main_widget.itemWidget(list_field._main_widget.item(1)), extra)
|
||||
assert list_field._main_widget.count() == 3
|
||||
assert list_field.getValue() == [vals[0], extra, list_field._types.default]
|
||||
|
||||
list_field._add_data_item(extra)
|
||||
assert list_field._main_widget.count() == 4
|
||||
assert list_field.getValue() == [vals[0], extra, list_field._types.default, extra]
|
||||
|
||||
|
||||
def test_list_field_value_acceptance(list_field_and_values: tuple[ListFormItem, list, Any, type]):
|
||||
class _WrongType(object): ...
|
||||
|
||||
list_field, _, _, t = list_field_and_values
|
||||
list_field.setValue([])
|
||||
assert list_field._main_widget.count() == 0
|
||||
list_field.setValue([t(), t(), t()])
|
||||
assert list_field._main_widget.count() == 3
|
||||
with pytest.raises(ValueError) as e:
|
||||
list_field.setValue([_WrongType()])
|
||||
assert list_field._main_widget.count() == 3
|
||||
assert e.match(f"This widget only accepts items of type {t}")
|
||||
|
||||
@@ -39,9 +39,11 @@ def test_initialization(roi_tree, image_widget):
|
||||
assert len(roi_tree.tree.findItems("", Qt.MatchContains)) == 0 # Empty tree initially
|
||||
|
||||
# Check toolbar actions
|
||||
assert hasattr(roi_tree, "add_rect_action")
|
||||
assert hasattr(roi_tree, "add_circle_action")
|
||||
assert hasattr(roi_tree, "expand_toggle")
|
||||
assert roi_tree.toolbar.components.get_action("roi_rectangle")
|
||||
assert roi_tree.toolbar.components.get_action("roi_circle")
|
||||
assert roi_tree.toolbar.components.get_action("roi_ellipse")
|
||||
assert roi_tree.toolbar.components.get_action("expand_toggle")
|
||||
assert roi_tree.toolbar.components.get_action("lock_unlock_all")
|
||||
|
||||
# Check tree view setup
|
||||
assert roi_tree.tree.columnCount() == 3
|
||||
@@ -120,11 +122,11 @@ def test_roi_name_edit(roi_tree, image_widget, qtbot):
|
||||
roi_tree.tree.editItem(item, roi_tree.COL_ROI)
|
||||
qtbot.keyClicks(roi_tree.tree.viewport().focusWidget(), "new_name")
|
||||
qtbot.keyClick(roi_tree.tree.viewport().focusWidget(), Qt.Key_Return)
|
||||
qtbot.wait(200)
|
||||
|
||||
# Check the ROI name was updated
|
||||
assert roi.label == "new_name"
|
||||
assert item.text(roi_tree.COL_ROI) == "new_name"
|
||||
qtbot.waitUntil(
|
||||
lambda: all([roi.label == "new_name", item.text(roi_tree.COL_ROI) == "new_name"]),
|
||||
timeout=200,
|
||||
)
|
||||
|
||||
|
||||
def test_roi_width_edit(roi_tree, image_widget, qtbot):
|
||||
@@ -138,9 +140,8 @@ def test_roi_width_edit(roi_tree, image_widget, qtbot):
|
||||
|
||||
# Change the width
|
||||
width_spin.setValue(25)
|
||||
qtbot.wait(200)
|
||||
# Check the ROI width was updated
|
||||
assert roi.line_width == 25
|
||||
qtbot.waitUntil(lambda: roi.line_width == 25, timeout=200)
|
||||
|
||||
|
||||
def test_delete_roi_button(roi_tree, image_widget, qtbot):
|
||||
@@ -153,11 +154,12 @@ def test_delete_roi_button(roi_tree, image_widget, qtbot):
|
||||
|
||||
del_btn = layout.itemAt(1).widget()
|
||||
del_btn.click()
|
||||
qtbot.wait(200)
|
||||
|
||||
# Verify ROI was removed
|
||||
assert roi not in roi_tree.roi_items
|
||||
assert roi not in image_widget.roi_controller.rois
|
||||
qtbot.waitUntil(
|
||||
lambda: all([roi not in roi_tree.roi_items, roi not in image_widget.roi_controller.rois]),
|
||||
timeout=200,
|
||||
)
|
||||
|
||||
|
||||
def test_roi_color_change_from_roi(roi_tree, image_widget):
|
||||
@@ -216,23 +218,25 @@ def test_draw_mode_toggle(roi_tree, qtbot):
|
||||
assert roi_tree._roi_draw_mode is None
|
||||
|
||||
# Toggle rect mode on
|
||||
roi_tree.add_rect_action.action.toggle()
|
||||
rect_action = roi_tree.toolbar.components.get_action("roi_rectangle").action
|
||||
circle_action = roi_tree.toolbar.components.get_action("roi_circle").action
|
||||
rect_action.toggle()
|
||||
assert roi_tree._roi_draw_mode == "rect"
|
||||
assert roi_tree.add_rect_action.action.isChecked()
|
||||
assert not roi_tree.add_circle_action.action.isChecked()
|
||||
assert rect_action.isChecked()
|
||||
assert not circle_action.isChecked()
|
||||
|
||||
# Toggle circle mode on (should turn off rect mode)
|
||||
roi_tree.add_circle_action.action.toggle()
|
||||
circle_action.toggle()
|
||||
qtbot.wait(200)
|
||||
assert roi_tree._roi_draw_mode == "circle"
|
||||
assert not roi_tree.add_rect_action.action.isChecked()
|
||||
assert roi_tree.add_circle_action.action.isChecked()
|
||||
assert not rect_action.isChecked()
|
||||
assert circle_action.isChecked()
|
||||
|
||||
# Toggle circle mode off
|
||||
roi_tree.add_circle_action.action.toggle()
|
||||
circle_action.toggle()
|
||||
assert roi_tree._roi_draw_mode is None
|
||||
assert not roi_tree.add_rect_action.action.isChecked()
|
||||
assert not roi_tree.add_circle_action.action.isChecked()
|
||||
assert not circle_action.isChecked()
|
||||
assert not rect_action.isChecked()
|
||||
|
||||
|
||||
def test_add_roi_from_toolbar(qtbot, mocked_client):
|
||||
@@ -250,7 +254,7 @@ def test_add_roi_from_toolbar(qtbot, mocked_client):
|
||||
|
||||
# Test rectangle ROI creation
|
||||
# 1. Activate rectangle drawing mode
|
||||
roi_tree.add_rect_action.action.setChecked(True)
|
||||
roi_tree.toolbar.components.get_action("roi_rectangle").action.setChecked(True)
|
||||
assert roi_tree._roi_draw_mode == "rect"
|
||||
|
||||
# Get plot widget and view
|
||||
@@ -294,8 +298,8 @@ def test_add_roi_from_toolbar(qtbot, mocked_client):
|
||||
|
||||
# Test circle ROI creation
|
||||
# Reset ROI draw mode
|
||||
roi_tree.add_rect_action.action.setChecked(False)
|
||||
roi_tree.add_circle_action.action.setChecked(True)
|
||||
roi_tree.toolbar.components.get_action("roi_rectangle").action.setChecked(False)
|
||||
roi_tree.toolbar.components.get_action("roi_circle").action.setChecked(True)
|
||||
assert roi_tree._roi_draw_mode == "circle"
|
||||
|
||||
# Define new positions for circle ROI
|
||||
|
||||
@@ -242,10 +242,11 @@ def test_image_data_update_1d(qtbot, mocked_client):
|
||||
|
||||
def test_toolbar_actions_presence(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
assert "autorange_image" in bec_image_view.toolbar.widgets
|
||||
assert "lock_aspect_ratio" in bec_image_view.toolbar.bundles["mouse_interaction"]
|
||||
assert "processing" in bec_image_view.toolbar.bundles
|
||||
assert "selection" in bec_image_view.toolbar.bundles
|
||||
assert bec_image_view.toolbar.components.exists("image_autorange")
|
||||
assert bec_image_view.toolbar.components.exists("lock_aspect_ratio")
|
||||
assert bec_image_view.toolbar.components.exists("image_processing_fft")
|
||||
assert bec_image_view.toolbar.components.exists("image_device_combo")
|
||||
assert bec_image_view.toolbar.components.exists("image_dim_combo")
|
||||
|
||||
|
||||
def test_image_processing_fft_toggle(qtbot, mocked_client):
|
||||
@@ -304,8 +305,8 @@ def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type):
|
||||
def test_setup_image_from_toolbar(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
bec_image_view.selection_bundle.device_combo_box.setCurrentText("eiger")
|
||||
bec_image_view.selection_bundle.dim_combo_box.setCurrentText("2d")
|
||||
bec_image_view.device_combo_box.setCurrentText("eiger")
|
||||
bec_image_view.dim_combo_box.setCurrentText("2d")
|
||||
|
||||
assert bec_image_view.monitor == "eiger"
|
||||
assert bec_image_view.subscriptions["main"].source == "device_monitor_2d"
|
||||
@@ -318,17 +319,17 @@ def test_image_actions_interactions(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.autorange = False # Change the initial state to False
|
||||
|
||||
bec_image_view.autorange_mean_action.action.trigger()
|
||||
bec_image_view.toolbar.components.get_action("image_autorange_mean").action.trigger()
|
||||
assert bec_image_view.autorange is True
|
||||
assert bec_image_view.main_image.autorange is True
|
||||
assert bec_image_view.autorange_mode == "mean"
|
||||
|
||||
bec_image_view.autorange_max_action.action.trigger()
|
||||
bec_image_view.toolbar.components.get_action("image_autorange_max").action.trigger()
|
||||
assert bec_image_view.autorange is True
|
||||
assert bec_image_view.main_image.autorange is True
|
||||
assert bec_image_view.autorange_mode == "max"
|
||||
|
||||
bec_image_view.toolbar.widgets["lock_aspect_ratio"].action.trigger()
|
||||
bec_image_view.toolbar.components.get_action("lock_aspect_ratio").action.trigger()
|
||||
assert bec_image_view.lock_aspect_ratio is False
|
||||
assert bool(bec_image_view.plot_item.getViewBox().state["aspectLocked"]) is False
|
||||
|
||||
@@ -336,7 +337,7 @@ def test_image_actions_interactions(qtbot, mocked_client):
|
||||
def test_image_toggle_action_fft(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
bec_image_view.processing_bundle.fft.action.trigger()
|
||||
bec_image_view.toolbar.components.get_action("image_processing_fft").action.trigger()
|
||||
|
||||
assert bec_image_view.fft is True
|
||||
assert bec_image_view.main_image.fft is True
|
||||
@@ -346,7 +347,7 @@ def test_image_toggle_action_fft(qtbot, mocked_client):
|
||||
def test_image_toggle_action_log(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
bec_image_view.processing_bundle.log.action.trigger()
|
||||
bec_image_view.toolbar.components.get_action("image_processing_log").action.trigger()
|
||||
|
||||
assert bec_image_view.log is True
|
||||
assert bec_image_view.main_image.log is True
|
||||
@@ -356,7 +357,7 @@ def test_image_toggle_action_log(qtbot, mocked_client):
|
||||
def test_image_toggle_action_transpose(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
bec_image_view.processing_bundle.transpose.action.trigger()
|
||||
bec_image_view.toolbar.components.get_action("image_processing_transpose").action.trigger()
|
||||
|
||||
assert bec_image_view.transpose is True
|
||||
assert bec_image_view.main_image.transpose is True
|
||||
@@ -366,7 +367,7 @@ def test_image_toggle_action_transpose(qtbot, mocked_client):
|
||||
def test_image_toggle_action_rotate_right(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
bec_image_view.processing_bundle.right.action.trigger()
|
||||
bec_image_view.toolbar.components.get_action("image_processing_rotate_right").action.trigger()
|
||||
|
||||
assert bec_image_view.num_rotation_90 == 3
|
||||
assert bec_image_view.main_image.num_rotation_90 == 3
|
||||
@@ -376,7 +377,7 @@ def test_image_toggle_action_rotate_right(qtbot, mocked_client):
|
||||
def test_image_toggle_action_rotate_left(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
bec_image_view.processing_bundle.left.action.trigger()
|
||||
bec_image_view.toolbar.components.get_action("image_processing_rotate_left").action.trigger()
|
||||
|
||||
assert bec_image_view.num_rotation_90 == 1
|
||||
assert bec_image_view.main_image.num_rotation_90 == 1
|
||||
@@ -392,7 +393,7 @@ def test_image_toggle_action_reset(qtbot, mocked_client):
|
||||
bec_image_view.transpose = True
|
||||
bec_image_view.num_rotation_90 = 2
|
||||
|
||||
bec_image_view.processing_bundle.reset.action.trigger()
|
||||
bec_image_view.toolbar.components.get_action("image_processing_reset").action.trigger()
|
||||
|
||||
assert bec_image_view.num_rotation_90 == 0
|
||||
assert bec_image_view.main_image.num_rotation_90 == 0
|
||||
@@ -473,8 +474,8 @@ def test_show_roi_manager_popup(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, Image, client=mocked_client, popups=True)
|
||||
|
||||
# ROI-manager toggle is exposed via the toolbar.
|
||||
assert "roi_mgr" in view.toolbar.widgets
|
||||
roi_action = view.toolbar.widgets["roi_mgr"].action
|
||||
assert view.toolbar.components.exists("roi_mgr")
|
||||
roi_action = view.toolbar.components.get_action("roi_mgr").action
|
||||
assert roi_action.isChecked() is False, "Should start unchecked"
|
||||
|
||||
# Open the popup.
|
||||
@@ -497,10 +498,10 @@ def test_show_roi_manager_popup(qtbot, mocked_client):
|
||||
|
||||
def test_crosshair_roi_panels_visibility(qtbot, mocked_client):
|
||||
"""
|
||||
Verify that enabling the ROI‑crosshair shows ROI panels and disabling hides them.
|
||||
Verify that enabling the ROI-crosshair shows ROI panels and disabling hides them.
|
||||
"""
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
switch = bec_image_view.toolbar.widgets["switch_crosshair"]
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
|
||||
# Initially panels should be hidden
|
||||
assert bec_image_view.side_panel_x.panel_height == 0
|
||||
@@ -508,19 +509,31 @@ def test_crosshair_roi_panels_visibility(qtbot, mocked_client):
|
||||
|
||||
# Enable ROI crosshair
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(500)
|
||||
|
||||
# Panels must be visible
|
||||
assert bec_image_view.side_panel_x.panel_height > 0
|
||||
assert bec_image_view.side_panel_y.panel_width > 0
|
||||
qtbot.waitUntil(
|
||||
lambda: all(
|
||||
[
|
||||
bec_image_view.side_panel_x.panel_height > 0,
|
||||
bec_image_view.side_panel_y.panel_width > 0,
|
||||
]
|
||||
),
|
||||
timeout=500,
|
||||
)
|
||||
|
||||
# Disable ROI crosshair
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(500)
|
||||
|
||||
# Panels hidden again
|
||||
assert bec_image_view.side_panel_x.panel_height == 0
|
||||
assert bec_image_view.side_panel_y.panel_width == 0
|
||||
qtbot.waitUntil(
|
||||
lambda: all(
|
||||
[
|
||||
bec_image_view.side_panel_x.panel_height == 0,
|
||||
bec_image_view.side_panel_y.panel_width == 0,
|
||||
]
|
||||
),
|
||||
timeout=500,
|
||||
)
|
||||
|
||||
|
||||
def test_roi_plot_data_from_image(qtbot, mocked_client):
|
||||
@@ -536,7 +549,7 @@ def test_roi_plot_data_from_image(qtbot, mocked_client):
|
||||
bec_image_view.on_image_update_2d({"data": test_data}, {})
|
||||
|
||||
# Activate ROI crosshair
|
||||
switch = bec_image_view.toolbar.widgets["switch_crosshair"]
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(50)
|
||||
|
||||
@@ -567,11 +580,10 @@ def test_roi_plot_data_from_image(qtbot, mocked_client):
|
||||
def test_monitor_selection_reverse_device_items(qtbot, mocked_client):
|
||||
"""
|
||||
Verify that _reverse_device_items correctly reverses the order of items in the
|
||||
device combo‑box while preserving the current selection.
|
||||
device combobox while preserving the current selection.
|
||||
"""
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bundle = view.selection_bundle
|
||||
combo = bundle.device_combo_box
|
||||
combo = view.device_combo_box
|
||||
|
||||
# Replace existing items with a deterministic list
|
||||
combo.clear()
|
||||
@@ -581,7 +593,7 @@ def test_monitor_selection_reverse_device_items(qtbot, mocked_client):
|
||||
combo.setCurrentText("samy")
|
||||
|
||||
# Reverse the items
|
||||
bundle._reverse_device_items()
|
||||
view._reverse_device_items()
|
||||
|
||||
# Order should be reversed and selection preserved
|
||||
assert [combo.itemText(i) for i in range(combo.count())] == ["samz", "samy", "samx"]
|
||||
@@ -594,7 +606,6 @@ def test_monitor_selection_populate_preview_signals(qtbot, mocked_client, monkey
|
||||
with the correct userData.
|
||||
"""
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bundle = view.selection_bundle
|
||||
|
||||
# Provide a deterministic fake device_manager with get_bec_signals
|
||||
class _FakeDM:
|
||||
@@ -606,27 +617,26 @@ def test_monitor_selection_populate_preview_signals(qtbot, mocked_client, monkey
|
||||
|
||||
monkeypatch.setattr(view.client, "device_manager", _FakeDM())
|
||||
|
||||
initial_count = bundle.device_combo_box.count()
|
||||
initial_count = view.device_combo_box.count()
|
||||
|
||||
bundle._populate_preview_signals()
|
||||
view._populate_preview_signals()
|
||||
|
||||
# Two new entries should have been added
|
||||
assert bundle.device_combo_box.count() == initial_count + 2
|
||||
assert view.device_combo_box.count() == initial_count + 2
|
||||
|
||||
# The first newly added item should carry tuple userData describing the device/signal
|
||||
data = bundle.device_combo_box.itemData(initial_count)
|
||||
data = view.device_combo_box.itemData(initial_count)
|
||||
assert isinstance(data, tuple) and data[0] == "eiger"
|
||||
|
||||
|
||||
def test_monitor_selection_adjust_and_connect(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Verify that _adjust_and_connect performs the full set‑up:
|
||||
‑ fills the combo‑box with preview signals,
|
||||
‑ reverses their order,
|
||||
‑ and resets the currentText to an empty string.
|
||||
Verify that _adjust_and_connect performs the full set-up:
|
||||
- fills the combobox with preview signals,
|
||||
- reverses their order,
|
||||
- and resets the currentText to an empty string.
|
||||
"""
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bundle = view.selection_bundle
|
||||
|
||||
# Deterministic fake device_manager
|
||||
class _FakeDM:
|
||||
@@ -635,14 +645,14 @@ def test_monitor_selection_adjust_and_connect(qtbot, mocked_client, monkeypatch)
|
||||
|
||||
monkeypatch.setattr(view.client, "device_manager", _FakeDM())
|
||||
|
||||
combo = bundle.device_combo_box
|
||||
combo = view.device_combo_box
|
||||
# Start from a clean state
|
||||
combo.clear()
|
||||
combo.addItem("", None)
|
||||
combo.setCurrentText("")
|
||||
|
||||
# Execute the method under test
|
||||
bundle._adjust_and_connect()
|
||||
view._adjust_and_connect()
|
||||
|
||||
# Expect exactly two items: preview label followed by the empty default
|
||||
assert combo.count() == 2
|
||||
|
||||
@@ -102,7 +102,34 @@ def test_launch_window_launch_plugin_auto_update(bec_launch_window):
|
||||
[
|
||||
({}, False),
|
||||
({"launcher": mock.MagicMock()}, False),
|
||||
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, True),
|
||||
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, False),
|
||||
(
|
||||
{
|
||||
"launcher": mock.MagicMock(),
|
||||
"dock_area": mock.MagicMock(),
|
||||
"scan_progress": mock.MagicMock(),
|
||||
},
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
"launcher": mock.MagicMock(),
|
||||
"dock_area": mock.MagicMock(),
|
||||
"scan_progress_simple": mock.MagicMock(),
|
||||
"scan_progress_full": mock.MagicMock(),
|
||||
},
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
"launcher": mock.MagicMock(),
|
||||
"dock_area": mock.MagicMock(),
|
||||
"scan_progress_simple": mock.MagicMock(),
|
||||
"scan_progress_full": mock.MagicMock(),
|
||||
"hover_widget": mock.MagicMock(),
|
||||
},
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide):
|
||||
@@ -132,7 +159,34 @@ def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide):
|
||||
[
|
||||
({}, True),
|
||||
({"launcher": mock.MagicMock()}, True),
|
||||
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, False),
|
||||
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, True),
|
||||
(
|
||||
{
|
||||
"launcher": mock.MagicMock(),
|
||||
"dock_area": mock.MagicMock(),
|
||||
"scan_progress": mock.MagicMock(),
|
||||
},
|
||||
True,
|
||||
),
|
||||
(
|
||||
{
|
||||
"launcher": mock.MagicMock(),
|
||||
"dock_area": mock.MagicMock(),
|
||||
"scan_progress_simple": mock.MagicMock(),
|
||||
"scan_progress_full": mock.MagicMock(),
|
||||
},
|
||||
True,
|
||||
),
|
||||
(
|
||||
{
|
||||
"launcher": mock.MagicMock(),
|
||||
"dock_area": mock.MagicMock(),
|
||||
"scan_progress_simple": mock.MagicMock(),
|
||||
"scan_progress_full": mock.MagicMock(),
|
||||
"hover_widget": mock.MagicMock(),
|
||||
},
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_launch_window_closes(bec_launch_window, connections, close_called):
|
||||
|
||||
@@ -93,7 +93,6 @@ def test_logpanel_output(qtbot, log_panel: LogPanel):
|
||||
assert log_panel.plain_text == TEST_COMBINED_PLAINTEXT
|
||||
|
||||
def display_queue_empty():
|
||||
print(log_panel._log_manager._display_queue)
|
||||
return len(log_panel._log_manager._display_queue) == 0
|
||||
|
||||
next_text = "datetime | error | test log message"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user