mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-13 00:20:57 +02:00
Compare commits
84 Commits
v3.9.0
...
feature/sounds
| Author | SHA1 | Date | |
|---|---|---|---|
| 9213360c44 | |||
| d2cbd84479 | |||
| 3dfed232ef | |||
| 64ed28ba4f | |||
| 434f9f561f | |||
| 768c138576 | |||
| 9550866b67 | |||
| 64cbf93d64 | |||
| 4bb7e811dd | |||
| 08650e86a3 | |||
| 563603b80e | |||
| d07d03c1be | |||
| 6aa1f7e74a | |||
| 2546cc484d | |||
| b20897f4bf | |||
| 7e6dca4912 | |||
| f6f590cabd | |||
| f78bc26a26 | |||
| aca2c4a7a5 | |||
| 6db198e684 | |||
| 6f3ee6316b | |||
| 3d93cf2f01 | |||
| e547ec71ae | |||
| e8bd80377e | |||
| e8e67f68a2 | |||
| 51f7652b1f | |||
| 007f9306a6 | |||
| acfc1b4b88 | |||
| af125e2222 | |||
| b2e0b79210 | |||
| 1427c70cfb | |||
| 154ae6026a | |||
| 9f94ca7748 | |||
| 3796984182 | |||
| 8a180eaa7b | |||
| 4572760b56 | |||
| e42a9824cc | |||
| 2fb7fb2ff4 | |||
| c8275fcfd5 | |||
| 07515d24be | |||
| 859563abb3 | |||
| bd66afb98d | |||
| 8e1e282fac | |||
| 878745b99a | |||
| e41e60956b | |||
| ed68eb5ac6 | |||
| b119c5ad76 | |||
| 9a58dba414 | |||
| c9fc0a82b9 | |||
| 668b1bd9cd | |||
| 1a6c8bf30f | |||
| c346bd0f18 | |||
| 5f86e41a03 | |||
| f7a48b5f6a | |||
| b4beb274da | |||
| 80694d151f | |||
| f03a5d9e85 | |||
| 5e8f0e8083 | |||
| 9eb05416ab | |||
| ab6a1aecc1 | |||
| d99db7d042 | |||
| a976837cff | |||
| 56427a7f0c | |||
| c4d4b78846 | |||
| 2dc0227d38 | |||
| 2d8e1eed4d | |||
| 3b579e740f | |||
| b8740c9594 | |||
| d5bf10e216 | |||
| 3a165b26ed | |||
| faa200bf5c | |||
| b0fc0d325e | |||
| daa1ba020c | |||
| 3d934a8c38 | |||
| c47b246a9f | |||
| bb6c0bb08f | |||
| ce456572d7 | |||
| e22ab7e4c1 | |||
| 0cd000dfa1 | |||
| 1057db9d76 | |||
| be35e249f9 | |||
| cdd833dfc2 | |||
| 3c7834b492 | |||
| acd35a2786 |
@@ -45,6 +45,18 @@ jobs:
|
||||
cd ./bec
|
||||
pip install pytest pytest-random-order
|
||||
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./bec_server/tests ./bec_ipython_client/tests/client_tests ./bec_lib/tests
|
||||
|
||||
- name: Upload BEC unit test artifacts if job fails
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bec-unit-test-artifacts
|
||||
path: |
|
||||
./bec/report.xml
|
||||
./bec/logs/*.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
bec-e2e-test:
|
||||
name: BEC End2End Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -62,3 +74,12 @@ jobs:
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
PYTHON_VERSION: '3.11'
|
||||
|
||||
- name: Upload BEC e2e logs if job fails
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bec-e2e-test-logs
|
||||
path: ./_e2e_test_checkout_/bec/logs/*.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
+299
@@ -1,6 +1,305 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v3.15.0 (2026-06-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **beamline-states**: Better pydantic model handling
|
||||
([`64cbf93`](https://github.com/bec-project/bec_widgets/commit/64cbf93d64895cc8af9522506512c4e8f5de939d))
|
||||
|
||||
- **bec_widget**: Removal of non existing TYPE check for old dock area
|
||||
([`08650e8`](https://github.com/bec-project/bec_widgets/commit/08650e86a3d3aa1098514bad3d8119401ace1d3f))
|
||||
|
||||
- **device-input**: Align validity styling
|
||||
([`6db198e`](https://github.com/bec-project/bec_widgets/commit/6db198e68422967c1c6d8ffb749b993e0e4975e1))
|
||||
|
||||
- **notification-banner**: Eventfilter guard for QStandartItem
|
||||
([`d07d03c`](https://github.com/bec-project/bec_widgets/commit/d07d03c1be7b405e52126cfc507fdcd79093cb45))
|
||||
|
||||
- **notification-center**: Sync light theme styling
|
||||
([`f78bc26`](https://github.com/bec-project/bec_widgets/commit/f78bc26a26bfebfc7df83a9c6cd7dd5c95a35d05))
|
||||
|
||||
- **pydantic**: Adoption to new ScanArgument refactor from bec
|
||||
([`3dfed23`](https://github.com/bec-project/bec_widgets/commit/3dfed232efb8baff14ebe62b6b14e62e0a4acd36))
|
||||
|
||||
- **widget_it**: Device/signal combobox handler
|
||||
([`434f9f5`](https://github.com/bec-project/bec_widgets/commit/434f9f561fca199aadc798db682a7e863a3246b3))
|
||||
|
||||
### Build System
|
||||
|
||||
- **bec**: Bump bec_lib and bec_ipython_client to v3.134
|
||||
([`9550866`](https://github.com/bec-project/bec_widgets/commit/9550866b677bb304ab0ea490827d0d0c09064722))
|
||||
|
||||
### Features
|
||||
|
||||
- **beamline-states**: Add state manager widget
|
||||
([`2546cc4`](https://github.com/bec-project/bec_widgets/commit/2546cc484d1c483fd45f1b80847a7e0200faba43))
|
||||
|
||||
- **beamline-states**: Collapse all functionality with cleanup of not used settings widgets if state
|
||||
is not dirty
|
||||
([`768c138`](https://github.com/bec-project/bec_widgets/commit/768c138576ad924dfad334888583131cc452e4f0))
|
||||
|
||||
- **dock-area**: Expose beamline state manager
|
||||
([`6aa1f7e`](https://github.com/bec-project/bec_widgets/commit/6aa1f7e74ae4497d449b03e600e5c3fbbfc34be0))
|
||||
|
||||
- **forms**: Add pydantic widget form
|
||||
([`b20897f`](https://github.com/bec-project/bec_widgets/commit/b20897f4bf2c05653e57bd79c5e292883d1f8ee8))
|
||||
|
||||
- **forms**: Unified pydantic and scan control adapter for pydantic models
|
||||
([`563603b`](https://github.com/bec-project/bec_widgets/commit/563603b80e52ec6746a57fa68b1f7b2dbc101439))
|
||||
|
||||
- **widget_io**: Register handler
|
||||
([`7e6dca4`](https://github.com/bec-project/bec_widgets/commit/7e6dca49120fba480a8756a0cb951b88a2df3584))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **beamline-states**: Beamlinestatemanager widget moved to separate module
|
||||
([`4bb7e81`](https://github.com/bec-project/bec_widgets/commit/4bb7e811dd514e69e9a507505b976c3b8de1d035))
|
||||
|
||||
- **colors**: Consolidate theme helpers
|
||||
([`aca2c4a`](https://github.com/bec-project/bec_widgets/commit/aca2c4a7a50e85b0d0f61251f71bc097a258599f))
|
||||
|
||||
- **main-window**: Remove status-tip override
|
||||
([`f6f590c`](https://github.com/bec-project/bec_widgets/commit/f6f590cabdfd66f4bb4f8095414db5d120b0f9a1))
|
||||
|
||||
- **notification_banner**: Remove defensive patterns
|
||||
([`64ed28b`](https://github.com/bec-project/bec_widgets/commit/64ed28ba4f554958305cc9cf37da1a124ba2a99b))
|
||||
|
||||
|
||||
## v3.14.0 (2026-06-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **bec_progress_bar**: Replace the custom paint event progressbar with native QProgressBar
|
||||
([`007f930`](https://github.com/bec-project/bec_widgets/commit/007f9306a62f60cf66a268f608984b6a954b8653))
|
||||
|
||||
- **progress**: Scan progress reset on_scan_status in unified backend
|
||||
([`3d93cf2`](https://github.com/bec-project/bec_widgets/commit/3d93cf2f01778a9825a94adcba44a26fbeaf4be4))
|
||||
|
||||
- **ring**: Progresssignal fetch logic back
|
||||
([`e8bd803`](https://github.com/bec-project/bec_widgets/commit/e8bd80377e0bee40142c5cd0d4a9c35d35f2d950))
|
||||
|
||||
- **scan_control**: Remove parent from layout to prevent `QLayout: Attempting to add QLayout "" to
|
||||
ScanGroupBox "", which already has a layout`
|
||||
([`e8e67f6`](https://github.com/bec-project/bec_widgets/commit/e8e67f68a2912c69a7df3d82daaa67fab3ae1139))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- **child_repos**: Artifact logs upload if child pipelines fail
|
||||
([`acfc1b4`](https://github.com/bec-project/bec_widgets/commit/acfc1b4b883b2a3cf0596c881489cb2c953dd219))
|
||||
|
||||
### Features
|
||||
|
||||
- **progress**: Progress is tracked from bec; unified progress backend
|
||||
([`51f7652`](https://github.com/bec-project/bec_widgets/commit/51f7652b1fe59db6bf94a8183ae0e3a715601aa6))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **bec_progress**: Simplification of chunk radius calculation
|
||||
([`e547ec7`](https://github.com/bec-project/bec_widgets/commit/e547ec71ae1f45db72d9b8cde0b5fe564466333c))
|
||||
|
||||
### Testing
|
||||
|
||||
- **e2e**: Increase rpc test_available_widgets timout back to 100
|
||||
([`af125e2`](https://github.com/bec-project/bec_widgets/commit/af125e2222ff11a73f28dadb3a5d93e409ad010e))
|
||||
|
||||
|
||||
## v3.13.5 (2026-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Change prints into proper logs
|
||||
([`c8275fc`](https://github.com/bec-project/bec_widgets/commit/c8275fcfd5c920393df3aa201c32a632ac8086a5))
|
||||
|
||||
- **abort_button**: From __future__ import annotations
|
||||
([`3796984`](https://github.com/bec-project/bec_widgets/commit/37969841822c8c38c23a1d8fca8e38aec684957b))
|
||||
|
||||
- **client_utils**: Increase default rpc timeout to 60s
|
||||
([`07515d2`](https://github.com/bec-project/bec_widgets/commit/07515d24be6e930b1b40170fc710255914cb7454))
|
||||
|
||||
- **client_utils**: Stop output reader thread on shutdown
|
||||
([`4572760`](https://github.com/bec-project/bec_widgets/commit/4572760b56ca2ab6435db3a6a4ba0d270e9008d1))
|
||||
|
||||
- **companion_app**: Disable logging of bec_lib.scan_items on widget side
|
||||
([`bd66afb`](https://github.com/bec-project/bec_widgets/commit/bd66afb98dcb76ca87b0db1334df3c1af0a9dbad))
|
||||
|
||||
- **forms**: Gridlayout applied to widget which already has layout
|
||||
([`1427c70`](https://github.com/bec-project/bec_widgets/commit/1427c70cfb6f84bbced7f72ec5cfa55ac0b9b742))
|
||||
|
||||
- **launch_window**: Exclude launcher check for non-parented widgets for BECMainWindow
|
||||
([`ed68eb5`](https://github.com/bec-project/bec_widgets/commit/ed68eb5ac6b20cfc7ca2c0b91864dc54fb579499))
|
||||
|
||||
- **launcher**: Avoid orphan widgets detection and logging
|
||||
([`9f94ca7`](https://github.com/bec-project/bec_widgets/commit/9f94ca7748d73a30622ecbaef384f4bc73a3d2fb))
|
||||
|
||||
- **logging**: Removed args/kwargs from logging messages
|
||||
([`2fb7fb2`](https://github.com/bec-project/bec_widgets/commit/2fb7fb2ff487863c3bc931498496da74b25e52d8))
|
||||
|
||||
- **rpc**: Additional logs
|
||||
([`e41e609`](https://github.com/bec-project/bec_widgets/commit/e41e60956b54890b70b3390b981196c9477abd93))
|
||||
|
||||
- **rpc**: Client/server rpc handshake for shutdown
|
||||
([`8a180ea`](https://github.com/bec-project/bec_widgets/commit/8a180eaa7be5c1603d893cf3b50585f88f9b0c83))
|
||||
|
||||
- **rpc**: Log dispatcher receipt before qt callback
|
||||
([`878745b`](https://github.com/bec-project/bec_widgets/commit/878745b99ac1e22c0fbddecc294e599469a2adfe))
|
||||
|
||||
- **rpc**: More robust shutdown section with PID logging
|
||||
([`e42a982`](https://github.com/bec-project/bec_widgets/commit/e42a9824ccd54b71a3141aaf2aa4e02af6a13782))
|
||||
|
||||
- **rpc_server**: Log warning if rpc call is repeated
|
||||
([`859563a`](https://github.com/bec-project/bec_widgets/commit/859563abb3e94ff55886e72db3177522900a89b8))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **client_utils**: Simplify PID fetching
|
||||
([`154ae60`](https://github.com/bec-project/bec_widgets/commit/154ae6026a6471b7c1db42f7c2ff3dc7be4b4afb))
|
||||
|
||||
- **rpc**: Share logging helpers
|
||||
([`8e1e282`](https://github.com/bec-project/bec_widgets/commit/8e1e282fac22ab6f726049758306c7ca17af70eb))
|
||||
|
||||
|
||||
## v3.13.4 (2026-05-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **positioner_box**: Fix STOP button
|
||||
([`9a58dba`](https://github.com/bec-project/bec_widgets/commit/9a58dba414d9eec32fd7de7fc64c97c38f020b84))
|
||||
|
||||
|
||||
## v3.13.3 (2026-05-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **tests**: Rename description attribute to _description in FakeDevice
|
||||
([`668b1bd`](https://github.com/bec-project/bec_widgets/commit/668b1bd9cd158fc12cff2c340d7317f30a212121))
|
||||
|
||||
|
||||
## v3.13.2 (2026-05-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **tests**: Rename description attribute to _description in FakePositioner
|
||||
([`c346bd0`](https://github.com/bec-project/bec_widgets/commit/c346bd0f18ce873ff5ca6c59150c9581c9edca8d))
|
||||
|
||||
|
||||
## v3.13.1 (2026-05-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use .show instead of .start
|
||||
([`b4beb27`](https://github.com/bec-project/bec_widgets/commit/b4beb274da745da618f9b37ec241cd0109c088f1))
|
||||
|
||||
- **gui**: Replace window.show() with window.raise_window() and add hide() method
|
||||
([`f7a48b5`](https://github.com/bec-project/bec_widgets/commit/f7a48b5f6a51d391dca26ca42d03bad4f278ff22))
|
||||
|
||||
|
||||
## v3.13.0 (2026-05-21)
|
||||
|
||||
### Features
|
||||
|
||||
- **rpc-base**: Set default RPC timeout and allow customization
|
||||
([`f03a5d9`](https://github.com/bec-project/bec_widgets/commit/f03a5d9e853bd62b8ec1bad1c1e112fe01befe70))
|
||||
|
||||
|
||||
## v3.12.2 (2026-05-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **toggle**: Disable styling implemented
|
||||
([`9eb0541`](https://github.com/bec-project/bec_widgets/commit/9eb05416ab68dcb88732dca8974c665030d34e0b))
|
||||
|
||||
|
||||
## v3.12.1 (2026-05-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **device_input**: Correct cleanup unsubscribe
|
||||
([`56427a7`](https://github.com/bec-project/bec_widgets/commit/56427a7f0c3a89fe847d415c8b45212e663434c4))
|
||||
|
||||
- **device_input**: Ensure callback is removed after cleanup
|
||||
([`d99db7d`](https://github.com/bec-project/bec_widgets/commit/d99db7d04208945b86a39d65022b211ba093caed))
|
||||
|
||||
- **signal_combobox**: Signature matched for update_signals_from_filters
|
||||
([`a976837`](https://github.com/bec-project/bec_widgets/commit/a976837cff612349f2a3f17900903c203bc3d250))
|
||||
|
||||
|
||||
## v3.12.0 (2026-05-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scan-control**: Filter out private scans from allowed scans
|
||||
([`2dc0227`](https://github.com/bec-project/bec_widgets/commit/2dc0227d38f0e217e252a5e5751bafd60363a5a4))
|
||||
|
||||
- **scan-control**: Hide hidden scan arguments
|
||||
([`2d8e1ee`](https://github.com/bec-project/bec_widgets/commit/2d8e1eed4d6503c42a38c8de910ddaa54132405d))
|
||||
|
||||
- **scan-control**: Reject unsupported scan input types
|
||||
([`3b579e7`](https://github.com/bec-project/bec_widgets/commit/3b579e740f36c60c3635681a9b2c35b518498f58))
|
||||
|
||||
- **scan-control**: Skip duplicate visible scan kwargs
|
||||
([`b8740c9`](https://github.com/bec-project/bec_widgets/commit/b8740c95941d36102f07a51d74a50e6f262a6646))
|
||||
|
||||
### Features
|
||||
|
||||
- Add support for new scan signatures including units
|
||||
([`d5bf10e`](https://github.com/bec-project/bec_widgets/commit/d5bf10e21682ae8270078c7858a036bafbabf10e))
|
||||
|
||||
|
||||
## v3.11.1 (2026-05-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scan progressbar**: Fix device subscription cleanup
|
||||
([`faa200b`](https://github.com/bec-project/bec_widgets/commit/faa200bf5c3cf0c5bebb9858700106899f583695))
|
||||
|
||||
|
||||
## v3.11.0 (2026-05-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove device/signal line edit and abstraction layer for combobox/lineEdit
|
||||
([`bb6c0bb`](https://github.com/bec-project/bec_widgets/commit/bb6c0bb08fc9802bec0d6b9994a76a5bcf2a3a81))
|
||||
|
||||
- **scan_control**: Scangroupbox enforce correct device combobox type in correct order
|
||||
([`c47b246`](https://github.com/bec-project/bec_widgets/commit/c47b246a9fd5c9aff2512c2744b8ff19c87e6e03))
|
||||
|
||||
### Features
|
||||
|
||||
- **device_input**: Comboboxes can have line edit like autocomplete
|
||||
([`3d934a8`](https://github.com/bec-project/bec_widgets/commit/3d934a8c3825b17319c3cb99750b96042e0bc230))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **device_input**: Consolidation of device/signal combobox logic; docsrtings added
|
||||
([`daa1ba0`](https://github.com/bec-project/bec_widgets/commit/daa1ba020ce6d05800186d2467a496c1024e8aa5))
|
||||
|
||||
|
||||
## v3.10.0 (2026-05-13)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix link to doc page
|
||||
([`0cd000d`](https://github.com/bec-project/bec_widgets/commit/0cd000dfa1bb6f1b4d286e5aab30299361f436f6))
|
||||
|
||||
### Features
|
||||
|
||||
- Bl plugin menu in BECDockArea
|
||||
([`e22ab7e`](https://github.com/bec-project/bec_widgets/commit/e22ab7e4c10552e22aaaa9dbc30d098fbfa9c49c))
|
||||
|
||||
|
||||
## v3.9.1 (2026-05-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Logpanel fixture overwriting xread
|
||||
([`3c7834b`](https://github.com/bec-project/bec_widgets/commit/3c7834b492a5d2da13689f58b20caf38dda9ac1d))
|
||||
|
||||
- **scan_control**: Restore scan parameters from history are fetched on demand with button
|
||||
([`acd35a2`](https://github.com/bec-project/bec_widgets/commit/acd35a278660ce4962167af6237b5d12007f0774))
|
||||
|
||||
|
||||
## v3.9.0 (2026-05-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -192,8 +192,7 @@ Positioner boxes and tweak controls handle precise moves, homing, and calibratio
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of
|
||||
the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
The documentation can be found [here](https://bec.readthedocs.io/).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
import darkdetect
|
||||
@@ -63,6 +64,7 @@ class GUIServer:
|
||||
self.app: QApplication | None = None
|
||||
self.launcher_window: LaunchWindow | None = None
|
||||
self.dispatcher: BECDispatcher | None = None
|
||||
self._shutdown_started = False
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
@@ -74,6 +76,7 @@ class GUIServer:
|
||||
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
|
||||
bec_logger._update_sinks()
|
||||
|
||||
bec_logger.disabled_modules = ["bec_lib.scan_items"]
|
||||
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)): # type: ignore
|
||||
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)): # type: ignore
|
||||
self._run()
|
||||
@@ -122,17 +125,8 @@ class GUIServer:
|
||||
self.app.aboutToQuit.connect(self.shutdown)
|
||||
self.app.setQuitOnLastWindowClosed(True)
|
||||
|
||||
def sigint_handler(*args):
|
||||
# display message, for people to let it terminate gracefully
|
||||
print("Caught SIGINT, exiting")
|
||||
# Widgets should be all closed.
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
|
||||
widget.close()
|
||||
self.shutdown()
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
signal.signal(signal.SIGTERM, sigint_handler)
|
||||
signal.signal(signal.SIGINT, self.request_shutdown)
|
||||
signal.signal(signal.SIGTERM, self.request_shutdown)
|
||||
|
||||
sys.exit(self.app.exec())
|
||||
|
||||
@@ -149,16 +143,67 @@ class GUIServer:
|
||||
)
|
||||
self.app.setWindowIcon(icon)
|
||||
|
||||
def request_shutdown(self, signum=None, _frame=None):
|
||||
"""
|
||||
Request Qt application shutdown from an RPC call or OS signal.
|
||||
|
||||
Cleanup itself is handled by ``shutdown()``, which is connected to
|
||||
``QApplication.aboutToQuit``. Calling it directly here would run BEC/RPC
|
||||
teardown before Qt has processed the widget close events.
|
||||
"""
|
||||
signal_name = signal.Signals(signum).name if signum is not None else "shutdown"
|
||||
pid = os.getpid()
|
||||
if self.app is None:
|
||||
logger.info(f"Caught {signal_name}, shutting down GUI server pid={pid} without app")
|
||||
self.shutdown()
|
||||
return
|
||||
|
||||
widgets = [
|
||||
f"{widget.__class__.__name__}(objectName={widget.objectName()!r})"
|
||||
for widget in self.app.topLevelWidgets()
|
||||
]
|
||||
logger.info(
|
||||
f"Caught {signal_name}, requesting GUI server shutdown pid={pid} "
|
||||
f"top_level_widgets={widgets}"
|
||||
)
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for widget in self.app.topLevelWidgets():
|
||||
widget.close()
|
||||
self.app.quit()
|
||||
|
||||
@staticmethod
|
||||
def _run_shutdown_step(step: str, callback):
|
||||
try:
|
||||
callback()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"GUIServer shutdown step failed pid={os.getpid()} step={step}: {exc}\n"
|
||||
f"{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
def shutdown(self):
|
||||
logger.info("Shutdown GUIServer", repr(self))
|
||||
if self.launcher_window and shiboken6.isValid(self.launcher_window):
|
||||
self.launcher_window.close()
|
||||
self.launcher_window.deleteLater()
|
||||
if pylsp_server.is_running():
|
||||
pylsp_server.stop()
|
||||
if self.dispatcher:
|
||||
self.dispatcher.stop_cli_server()
|
||||
self.dispatcher.disconnect_all()
|
||||
if self._shutdown_started:
|
||||
return
|
||||
self._shutdown_started = True
|
||||
logger.info(f"Shutdown GUIServer pid={os.getpid()} {repr(self)}")
|
||||
|
||||
def close_launcher_window():
|
||||
if self.launcher_window and shiboken6.isValid(self.launcher_window):
|
||||
self.launcher_window.close()
|
||||
self.launcher_window.deleteLater()
|
||||
|
||||
def stop_pylsp_server():
|
||||
if pylsp_server.is_running():
|
||||
pylsp_server.stop()
|
||||
|
||||
def stop_dispatcher():
|
||||
if self.dispatcher:
|
||||
self.dispatcher.stop_cli_server()
|
||||
self.dispatcher.disconnect_all()
|
||||
|
||||
self._run_shutdown_step("close_launcher_window", close_launcher_window)
|
||||
self._run_shutdown_step("stop_pylsp_server", stop_pylsp_server)
|
||||
self._run_shutdown_step("stop_dispatcher", stop_dispatcher)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -207,6 +207,7 @@ class LaunchWindow(BECMainWindow):
|
||||
|
||||
self.app = QApplication.instance()
|
||||
self.tiles: dict[str, LaunchTile] = {}
|
||||
self._logged_unparented_connections: set[str] = set()
|
||||
# Track the smallest main‑label font size chosen so far
|
||||
self._min_main_label_pt: int | None = None
|
||||
|
||||
@@ -655,53 +656,83 @@ class LaunchWindow(BECMainWindow):
|
||||
super().showEvent(event)
|
||||
self.setFixedSize(self.size())
|
||||
|
||||
def _launcher_is_last_widget(self, connections: dict) -> bool:
|
||||
def _has_external_window(self, connections: dict) -> bool:
|
||||
"""
|
||||
Check if the launcher is the last widget in the application.
|
||||
Check if any registered non-launcher connection owns a top-level Qt window.
|
||||
"""
|
||||
|
||||
# get all parents of connections
|
||||
for connection in connections.values():
|
||||
try:
|
||||
parent = connection.parent()
|
||||
if parent is None and connection.objectName() != self.objectName():
|
||||
logger.info(
|
||||
f"Found non-launcher connection without parent: {connection.objectName()}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting parent of connection: {e}")
|
||||
return False
|
||||
return True
|
||||
if self._connection_belongs_to_launcher(connection):
|
||||
continue
|
||||
if isinstance(connection, QWidget) and connection.isWindow():
|
||||
return True
|
||||
return False
|
||||
|
||||
def _log_unparented_connections(self, connections: dict) -> None:
|
||||
"""
|
||||
Log non-launcher RPC connections that remain without an active top-level window.
|
||||
"""
|
||||
for connection in connections.values():
|
||||
if self._connection_belongs_to_launcher(connection):
|
||||
continue
|
||||
if isinstance(connection, QWidget) and connection.isWindow():
|
||||
continue
|
||||
|
||||
connection_description = (
|
||||
f"type={type(connection).__name__} objectName={connection.objectName()!r} "
|
||||
f"gui_id={connection.gui_id!r}"
|
||||
)
|
||||
if connection_description in self._logged_unparented_connections:
|
||||
continue
|
||||
self._logged_unparented_connections.add(connection_description)
|
||||
logger.warning(
|
||||
"Registered non-launcher RPC connection has no active top-level window: "
|
||||
f"{connection_description}"
|
||||
)
|
||||
|
||||
def _connection_belongs_to_launcher(self, connection: QObject) -> bool:
|
||||
"""
|
||||
Check whether a registered connection is the launcher itself or part of its Qt hierarchy.
|
||||
"""
|
||||
if connection is self or connection.gui_id == self.gui_id:
|
||||
return True
|
||||
|
||||
parent = connection.parent()
|
||||
while parent is not None:
|
||||
if parent is self:
|
||||
return True
|
||||
parent = parent.parent()
|
||||
|
||||
return False
|
||||
|
||||
def _turn_off_the_lights(self, connections: dict):
|
||||
"""
|
||||
If there is only one connection remaining, it is the launcher, so we show it.
|
||||
Once the launcher is closed as the last window, we quit the application.
|
||||
"""
|
||||
if self._launcher_is_last_widget(connections):
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
if self._has_external_window(connections):
|
||||
self.hide()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(True) # type: ignore
|
||||
self.app.setQuitOnLastWindowClosed(False) # type: ignore
|
||||
return
|
||||
|
||||
self.hide()
|
||||
self._log_unparented_connections(connections)
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(False) # type: ignore
|
||||
self.app.setQuitOnLastWindowClosed(True) # type: ignore
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""
|
||||
Close the launcher window.
|
||||
"""
|
||||
connections = self.register.list_all_connections()
|
||||
if self._launcher_is_last_widget(connections):
|
||||
event.accept()
|
||||
if self._has_external_window(connections):
|
||||
event.ignore()
|
||||
self.hide()
|
||||
return
|
||||
|
||||
event.ignore()
|
||||
self.hide()
|
||||
event.accept()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+54
-25
@@ -32,6 +32,7 @@ _Widgets = {
|
||||
"BECQueue": "BECQueue",
|
||||
"BECShell": "BECShell",
|
||||
"BECStatusBox": "BECStatusBox",
|
||||
"BeamlineStateManager": "BeamlineStateManager",
|
||||
"BecConsole": "BecConsole",
|
||||
"DapComboBox": "DapComboBox",
|
||||
"DeviceBrowser": "DeviceBrowser",
|
||||
@@ -427,7 +428,7 @@ class BECMainWindow(RPCBase):
|
||||
|
||||
|
||||
class BECProgressBar(RPCBase):
|
||||
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
|
||||
"""A BEC progress bar backed by Qt's native QProgressBar."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.progress.bec_progressbar.bec_progressbar"
|
||||
|
||||
@@ -717,6 +718,58 @@ class BaseROI(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class BeamlineStateManager(RPCBase):
|
||||
"""Widget displaying and managing all BEC beamline states."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.services.beamline_states.beamline_state_manager"
|
||||
|
||||
@rpc_call
|
||||
def clear_filters(self) -> "None":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def collapse_all(self) -> "None":
|
||||
"""
|
||||
Collapse the settings panel of all displayed state pills.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def state_summary(self) -> "dict[str, dict[str, str]]":
|
||||
"""
|
||||
Return all beamline states (including filtered ones) with their current status and label.
|
||||
|
||||
Returns:
|
||||
dict: Mapping of state name to a dictionary with ``status`` and ``label`` keys.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
|
||||
class BecConsole(RPCBase):
|
||||
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
|
||||
|
||||
@@ -1131,30 +1184,6 @@ class DeviceInitializationProgressBar(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class DeviceInputBase(RPCBase):
|
||||
"""Mixin base class for device input widgets."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.device_input.base_classes.device_input_base"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class DeviceManagerView(RPCBase):
|
||||
"""A view for users to manage devices within the application."""
|
||||
|
||||
|
||||
+159
-11
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -33,6 +34,12 @@ else:
|
||||
logger = bec_logger.logger
|
||||
|
||||
IGNORE_WIDGETS = ["LaunchWindow"]
|
||||
PROCESS_TERMINATION_TIMEOUT = 10
|
||||
PROCESS_OUTPUT_THREAD_JOIN_TIMEOUT = 2
|
||||
PROCESS_OUTPUT_SELECT_TIMEOUT = 0.2
|
||||
GRACEFUL_SERVER_SHUTDOWN_RPC_TIMEOUT = 3
|
||||
GRACEFUL_SERVER_SHUTDOWN_TIMEOUT = 5
|
||||
OUTPUT_READER_STOP_EVENT_ATTR = "_bec_output_reader_stop_event"
|
||||
|
||||
RegistryState: TypeAlias = dict[
|
||||
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
|
||||
@@ -53,14 +60,16 @@ def _filter_output(output: str) -> str:
|
||||
return output
|
||||
|
||||
|
||||
def _get_output(process, logger) -> None:
|
||||
def _get_output(process, logger, stop_event: threading.Event | None = None) -> None:
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.info}
|
||||
stream_buffer = {process.stdout: [], process.stderr: []}
|
||||
try:
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
os.set_blocking(process.stderr.fileno(), False)
|
||||
while process.poll() is None:
|
||||
readylist, _, _ = select.select([process.stdout, process.stderr], [], [], 1)
|
||||
while process.poll() is None and not (stop_event and stop_event.is_set()):
|
||||
readylist, _, _ = select.select(
|
||||
[process.stdout, process.stderr], [], [], PROCESS_OUTPUT_SELECT_TIMEOUT
|
||||
)
|
||||
for stream in (process.stdout, process.stderr):
|
||||
buf = stream_buffer[stream]
|
||||
if stream in readylist:
|
||||
@@ -75,6 +84,95 @@ def _get_output(process, logger) -> None:
|
||||
logger.error(f"Error reading process output: {str(e)}")
|
||||
|
||||
|
||||
def _process_group_snapshot(process) -> str:
|
||||
try:
|
||||
pgid = os.getpgid(process.pid)
|
||||
except ProcessLookupError:
|
||||
return "Process group snapshot unavailable: process already exited"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ps", "-o", "pid,ppid,pgid,stat,command", "-g", str(pgid)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
except Exception as exc:
|
||||
return f"Process group snapshot unavailable: {exc}"
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
return f"Process group snapshot empty for pgid={pgid}"
|
||||
return output
|
||||
|
||||
|
||||
def _terminate_plot_process(process, logger, timeout: float = PROCESS_TERMINATION_TIMEOUT) -> None:
|
||||
if process.poll() is not None:
|
||||
return
|
||||
|
||||
process_info = f"pid={process.pid} command={process.args}"
|
||||
try:
|
||||
pgid = os.getpgid(process.pid)
|
||||
process_info = f"pid={process.pid} pgid={pgid} command={process.args}"
|
||||
logger.info(f"Terminating GUI process group {process_info}")
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
process.wait(timeout=timeout)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to terminate GUI process group; terminating process only.")
|
||||
logger.info(f"GUI process termination failure details: {exc}. pid={process.pid}")
|
||||
process.terminate()
|
||||
|
||||
try:
|
||||
process.wait(timeout=timeout)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(f"GUI process did not stop within {timeout}s; killing it.")
|
||||
logger.info(
|
||||
f"GUI process force-kill details: {process_info}\n"
|
||||
f"{_process_group_snapshot(process)}"
|
||||
)
|
||||
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
except ProcessLookupError as e:
|
||||
logger.error(f"Failed to kill GUI process group: {e}")
|
||||
process.wait(timeout=timeout)
|
||||
return
|
||||
process.wait(timeout=timeout)
|
||||
|
||||
|
||||
def _wait_for_process_exit(process, timeout: float) -> bool:
|
||||
try:
|
||||
process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _join_process_output_thread(process, thread: threading.Thread | None, logger) -> None:
|
||||
if thread is None:
|
||||
return
|
||||
thread.join(timeout=PROCESS_OUTPUT_THREAD_JOIN_TIMEOUT)
|
||||
if not thread.is_alive():
|
||||
return
|
||||
|
||||
if stop_event := getattr(thread, OUTPUT_READER_STOP_EVENT_ATTR, None):
|
||||
stop_event.set()
|
||||
|
||||
for stream in (process.stdout, process.stderr):
|
||||
if stream is None:
|
||||
continue
|
||||
try:
|
||||
stream.close()
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to close stream {str(e)}")
|
||||
thread.join(timeout=PROCESS_OUTPUT_THREAD_JOIN_TIMEOUT)
|
||||
if thread.is_alive():
|
||||
logger.warning("GUI process output reader thread did not stop after process shutdown.")
|
||||
logger.info(f"GUI process output reader thread details: pid={process.pid}")
|
||||
|
||||
|
||||
def _start_plot_process(
|
||||
gui_id: str,
|
||||
gui_class_id: str,
|
||||
@@ -126,8 +224,14 @@ def _start_plot_process(
|
||||
if logger is None:
|
||||
process_output_processing_thread = None
|
||||
else:
|
||||
process_output_stop_event = threading.Event()
|
||||
process_output_processing_thread = threading.Thread(
|
||||
target=_get_output, args=(process, logger)
|
||||
target=_get_output, args=(process, logger, process_output_stop_event)
|
||||
)
|
||||
setattr(
|
||||
process_output_processing_thread,
|
||||
OUTPUT_READER_STOP_EVENT_ATTR,
|
||||
process_output_stop_event,
|
||||
)
|
||||
process_output_processing_thread.start()
|
||||
return process, process_output_processing_thread
|
||||
@@ -222,6 +326,7 @@ class BECGuiClient(RPCBase):
|
||||
self._ipython_registry: dict[str, RPCReference] = {}
|
||||
self.available_widgets = AvailableWidgetsNamespace()
|
||||
register_serializer_extension()
|
||||
self._rpc_timeout = 60
|
||||
|
||||
####################
|
||||
#### Client API ####
|
||||
@@ -232,6 +337,16 @@ class BECGuiClient(RPCBase):
|
||||
"""The launcher object."""
|
||||
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
|
||||
|
||||
def set_rpc_timeout(self, timeout: float):
|
||||
"""Set the timeout for RPC calls to the GUI server.
|
||||
|
||||
Args:
|
||||
timeout(float): The timeout in seconds.
|
||||
"""
|
||||
if not isinstance(timeout, (int, float)) or timeout < 0:
|
||||
raise ValueError("Timeout must be a non-negative number.")
|
||||
self._rpc_timeout = timeout
|
||||
|
||||
def _safe_register_stream(self, endpoint: EndpointInfo, cb: Callable, **kwargs):
|
||||
"""Check if already registered for registration in idempotent functions."""
|
||||
if not self._client.connector.any_stream_is_registered(endpoint, cb=cb):
|
||||
@@ -358,7 +473,7 @@ class BECGuiClient(RPCBase):
|
||||
)
|
||||
|
||||
if not self._check_if_server_is_alive():
|
||||
self.start(wait=True)
|
||||
self.show(wait=True)
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
return self._new_impl(
|
||||
@@ -454,11 +569,13 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
if self._process:
|
||||
logger.success("Stopping GUI...")
|
||||
self._process.terminate()
|
||||
if self._process_output_processing_thread:
|
||||
self._process_output_processing_thread.join()
|
||||
self._process.wait()
|
||||
if not self._request_server_shutdown():
|
||||
_terminate_plot_process(self._process, logger)
|
||||
_join_process_output_thread(
|
||||
self._process, self._process_output_processing_thread, logger
|
||||
)
|
||||
self._process = None
|
||||
self._process_output_processing_thread = None
|
||||
|
||||
# Unregister the registry state
|
||||
self._client.connector.unregister(
|
||||
@@ -477,6 +594,37 @@ class BECGuiClient(RPCBase):
|
||||
#### Private methods ####
|
||||
#########################
|
||||
|
||||
def _request_server_shutdown(self) -> bool:
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
return True
|
||||
process_details = f"pid={self._process.pid} command={self._process.args}"
|
||||
logger.info(f"Requesting graceful GUI shutdown {process_details}")
|
||||
try:
|
||||
self.launcher._run_rpc( # pylint: disable=protected-access
|
||||
"system.shutdown",
|
||||
wait_for_rpc_response=True,
|
||||
timeout=GRACEFUL_SERVER_SHUTDOWN_RPC_TIMEOUT,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not confirm graceful GUI shutdown via RPC; "
|
||||
"falling back to process termination."
|
||||
)
|
||||
logger.info(f"Graceful GUI shutdown RPC failure details: {exc}. {process_details}")
|
||||
return False
|
||||
if _wait_for_process_exit(self._process, GRACEFUL_SERVER_SHUTDOWN_TIMEOUT):
|
||||
logger.info(f"GUI server exited after graceful shutdown {process_details}")
|
||||
return True
|
||||
logger.warning(
|
||||
"GUI server did not exit after graceful shutdown request; "
|
||||
"falling back to process termination."
|
||||
)
|
||||
logger.info(
|
||||
f"Graceful GUI shutdown timeout details: {process_details}\n"
|
||||
f"{_process_group_snapshot(self._process)}"
|
||||
)
|
||||
return False
|
||||
|
||||
def _check_if_server_is_alive(self):
|
||||
"""Checks if the process is alive"""
|
||||
if self._process is None:
|
||||
@@ -550,7 +698,7 @@ class BECGuiClient(RPCBase):
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.show()
|
||||
window.raise_window()
|
||||
|
||||
def _show_all(self):
|
||||
with wait_for_server(self):
|
||||
@@ -569,7 +717,7 @@ class BECGuiClient(RPCBase):
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("raise") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window._run_rpc("raise") # type: ignore[attr-defined]
|
||||
window.raise_window()
|
||||
|
||||
def _raise_all(self):
|
||||
with wait_for_server(self):
|
||||
|
||||
@@ -19,6 +19,10 @@ designer_plugins = {
|
||||
"BECShell": ("bec_widgets.widgets.editors.bec_console.bec_console", "BECShell"),
|
||||
"BECSpinBox": ("bec_widgets.widgets.utility.spinbox.decimal_spinbox", "BECSpinBox"),
|
||||
"BECStatusBox": ("bec_widgets.widgets.services.bec_status_box.bec_status_box", "BECStatusBox"),
|
||||
"BeamlineStateManager": (
|
||||
"bec_widgets.widgets.services.beamline_states.beamline_state_manager",
|
||||
"BeamlineStateManager",
|
||||
),
|
||||
"BecConsole": ("bec_widgets.widgets.editors.bec_console.bec_console", "BecConsole"),
|
||||
"ColorButton": ("bec_widgets.widgets.utility.visual.color_button.color_button", "ColorButton"),
|
||||
"ColorButtonNative": (
|
||||
@@ -42,10 +46,6 @@ designer_plugins = {
|
||||
"bec_widgets.widgets.control.device_input.device_combobox.device_combobox",
|
||||
"DeviceComboBox",
|
||||
),
|
||||
"DeviceLineEdit": (
|
||||
"bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit",
|
||||
"DeviceLineEdit",
|
||||
),
|
||||
"Heatmap": ("bec_widgets.widgets.plots.heatmap.heatmap", "Heatmap"),
|
||||
"IDEExplorer": ("bec_widgets.widgets.utility.ide_explorer.ide_explorer", "IDEExplorer"),
|
||||
"Image": ("bec_widgets.widgets.plots.image.image", "Image"),
|
||||
@@ -101,10 +101,6 @@ designer_plugins = {
|
||||
"SignalComboBox",
|
||||
),
|
||||
"SignalLabel": ("bec_widgets.widgets.utility.signal_label.signal_label", "SignalLabel"),
|
||||
"SignalLineEdit": (
|
||||
"bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit",
|
||||
"SignalLineEdit",
|
||||
),
|
||||
"SpinnerWidget": ("bec_widgets.widgets.utility.spinner.spinner", "SpinnerWidget"),
|
||||
"StopButton": ("bec_widgets.widgets.control.buttons.stop_button.stop_button", "StopButton"),
|
||||
"TextBox": ("bec_widgets.widgets.editors.text_box.text_box", "TextBox"),
|
||||
@@ -126,6 +122,7 @@ widget_icons = {
|
||||
"BECShell": "hub",
|
||||
"BECSpinBox": "123",
|
||||
"BECStatusBox": "widgets",
|
||||
"BeamlineStateManager": "format_list_bulleted",
|
||||
"BecConsole": "terminal",
|
||||
"ColorButton": "colors",
|
||||
"ColorButtonNative": "colors",
|
||||
@@ -134,7 +131,6 @@ widget_icons = {
|
||||
"DarkModeButton": "dark_mode",
|
||||
"DeviceBrowser": "lists",
|
||||
"DeviceComboBox": "list_alt",
|
||||
"DeviceLineEdit": "edit_note",
|
||||
"Heatmap": "dataset",
|
||||
"IDEExplorer": "widgets",
|
||||
"Image": "image",
|
||||
@@ -160,7 +156,6 @@ widget_icons = {
|
||||
"ScatterWaveform": "scatter_plot",
|
||||
"SignalComboBox": "list_alt",
|
||||
"SignalLabel": "scoreboard",
|
||||
"SignalLineEdit": "vital_signs",
|
||||
"SpinnerWidget": "progress_activity",
|
||||
"StopButton": "dangerous",
|
||||
"TextBox": "chat",
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -9,6 +10,7 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.device import DeviceBaseWithConfig
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -24,6 +26,9 @@ else:
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
_DEFAULT_RPC_TIMEOUT = object()
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def _name_arg(arg):
|
||||
if isinstance(arg, DeviceBaseWithConfig):
|
||||
@@ -154,6 +159,7 @@ class RPCReference:
|
||||
|
||||
|
||||
class RPCBase:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str | None = None,
|
||||
@@ -207,12 +213,16 @@ class RPCBase:
|
||||
# Use explicit call to ensure action name is 'raise' (not 'raise_')
|
||||
return self._run_rpc("raise")
|
||||
|
||||
def hide(self):
|
||||
"""Hide this widget (or its container)."""
|
||||
return self._run_rpc("hide")
|
||||
|
||||
def _run_rpc(
|
||||
self,
|
||||
method,
|
||||
*args,
|
||||
wait_for_rpc_response=True,
|
||||
timeout=5,
|
||||
wait_for_rpc_response: bool = True,
|
||||
timeout: float | None | object = _DEFAULT_RPC_TIMEOUT,
|
||||
gui_id: str | None = None,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
@@ -223,13 +233,16 @@ class RPCBase:
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
timeout: The timeout for the RPC response.
|
||||
timeout: The timeout for the RPC response. If omitted, the client's default RPC
|
||||
timeout is used. If explicitly set to None, wait indefinitely.
|
||||
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
if timeout is _DEFAULT_RPC_TIMEOUT:
|
||||
timeout = self._root._rpc_timeout
|
||||
if method in ["show", "hide", "raise"] and gui_id is None:
|
||||
obj = self._root._server_registry.get(self._gui_id)
|
||||
if obj is None:
|
||||
@@ -251,12 +264,39 @@ class RPCBase:
|
||||
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
||||
)
|
||||
|
||||
target_gui_id = gui_id or self._gui_id
|
||||
sent_at = time.time()
|
||||
deadline = sent_at + timeout if timeout is not None else None
|
||||
rpc_msg.metadata.update(
|
||||
{
|
||||
"method": method,
|
||||
"receiver": receiver,
|
||||
"target_gui_id": target_gui_id,
|
||||
"object_name": self.object_name,
|
||||
"wait_for_response": wait_for_rpc_response,
|
||||
"timeout": timeout,
|
||||
"sent_at": sent_at,
|
||||
"deadline": deadline,
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
"Sending GUI RPC request "
|
||||
f"request_id={request_id} method={method} receiver={receiver} "
|
||||
f"target_gui_id={target_gui_id} object_name={self.object_name} "
|
||||
f"wait_for_response={wait_for_rpc_response} timeout={timeout}"
|
||||
)
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
if wait_for_rpc_response:
|
||||
try:
|
||||
finished = self._msg_wait_event.wait(timeout)
|
||||
if not finished:
|
||||
logger.error(
|
||||
"GUI RPC response timeout "
|
||||
f"request_id={request_id} method={method} receiver={receiver} "
|
||||
f"target_gui_id={target_gui_id} object_name={self.object_name} "
|
||||
f"timeout={timeout}"
|
||||
)
|
||||
raise RPCResponseTimeoutError(request_id, timeout)
|
||||
finally:
|
||||
self._msg_wait_event.clear()
|
||||
@@ -268,6 +308,12 @@ class RPCBase:
|
||||
# the _on_rpc_response method
|
||||
assert isinstance(self._rpc_response, messages.RequestResponseMessage)
|
||||
|
||||
logger.info(
|
||||
"Received GUI RPC response "
|
||||
f"request_id={request_id} method={method} receiver={receiver} "
|
||||
f"target_gui_id={target_gui_id} object_name={self.object_name} "
|
||||
f"accepted={self._rpc_response.accepted}"
|
||||
)
|
||||
if not self._rpc_response.accepted:
|
||||
raise ValueError(self._rpc_response.message["error"])
|
||||
msg_result = self._rpc_response.message.get("result")
|
||||
@@ -276,6 +322,7 @@ class RPCBase:
|
||||
|
||||
def _on_rpc_response(self, msg_obj: MessageObject) -> None:
|
||||
msg = cast(messages.RequestResponseMessage, msg_obj.value)
|
||||
logger.debug(f"GUI RPC response callback received: {msg}")
|
||||
self._rpc_response = msg
|
||||
self._msg_wait_event.set()
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class FakeDevice(BECDevice):
|
||||
super().__init__(name=name)
|
||||
self._enabled = enabled
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._readout_priority = readout_priority
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
@@ -74,7 +74,7 @@ class FakeDevice(BECDevice):
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self.description
|
||||
return self._description
|
||||
|
||||
|
||||
class FakePositioner(BECPositioner):
|
||||
@@ -96,7 +96,7 @@ class FakePositioner(BECPositioner):
|
||||
self._limits = limits
|
||||
self._readout_priority = readout_priority
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd_devices.SimPositioner",
|
||||
@@ -176,7 +176,7 @@ class FakePositioner(BECPositioner):
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self.description
|
||||
return self._description
|
||||
|
||||
@property
|
||||
def precision(self):
|
||||
|
||||
@@ -3,8 +3,9 @@ from __future__ import annotations
|
||||
import collections
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
|
||||
from typing import TYPE_CHECKING, Any, DefaultDict, Hashable, Union
|
||||
|
||||
import louie
|
||||
import redis
|
||||
@@ -15,6 +16,7 @@ from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
from bec_widgets.utils.rpc_logging import elapsed_seconds, format_elapsed
|
||||
from bec_widgets.utils.serialization import register_serializer_extension
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -25,6 +27,39 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.rpc_server import RPCServer
|
||||
|
||||
|
||||
def _log_rpc_dispatcher_receive(msg_content: Any, metadata: Any) -> None:
|
||||
if not isinstance(msg_content, dict) or not isinstance(metadata, dict):
|
||||
return
|
||||
request_id = metadata.get("request_id")
|
||||
method = msg_content.get("action")
|
||||
parameter = msg_content.get("parameter")
|
||||
if request_id is None or method is None or not isinstance(parameter, dict):
|
||||
return
|
||||
|
||||
dispatch_received_at = time.time()
|
||||
sent_at = metadata.get("sent_at")
|
||||
deadline = metadata.get("deadline")
|
||||
timeout = metadata.get("timeout")
|
||||
dispatch_latency = elapsed_seconds(sent_at, dispatch_received_at)
|
||||
stale_on_dispatch = deadline is not None and dispatch_received_at > deadline
|
||||
target_gui_id = parameter.get("gui_id") or metadata.get("target_gui_id")
|
||||
|
||||
logger.info(
|
||||
"GUI RPC dispatcher received request before Qt callback emit "
|
||||
f"request_id={request_id} method={method} receiver={metadata.get('receiver')} "
|
||||
f"target_gui_id={target_gui_id} object_name={metadata.get('object_name')} "
|
||||
f"timeout={timeout} dispatch_latency_s={format_elapsed(dispatch_latency)} "
|
||||
f"stale_on_dispatch={stale_on_dispatch}"
|
||||
)
|
||||
if stale_on_dispatch:
|
||||
logger.warning(
|
||||
"GUI RPC dispatcher received request after client timeout deadline "
|
||||
f"request_id={request_id} method={method} receiver={metadata.get('receiver')} "
|
||||
f"target_gui_id={target_gui_id} object_name={metadata.get('object_name')} "
|
||||
f"timeout={timeout} dispatch_latency_s={format_elapsed(dispatch_latency)}"
|
||||
)
|
||||
|
||||
|
||||
class QtThreadSafeCallback(QObject):
|
||||
"""QtThreadSafeCallback is a wrapper around a callback function to make it thread-safe for Qt."""
|
||||
|
||||
@@ -88,10 +123,12 @@ class QtRedisConnector(RedisConnector):
|
||||
|
||||
# we can notice kwargs are lost when passed to Qt slot
|
||||
metadata = msg.metadata
|
||||
_log_rpc_dispatcher_receive(msg.content, metadata)
|
||||
cb(msg.content, metadata)
|
||||
else:
|
||||
# from stream
|
||||
msg = msg["data"]
|
||||
_log_rpc_dispatcher_receive(msg.content, msg.metadata)
|
||||
cb(msg.content, msg.metadata)
|
||||
|
||||
|
||||
|
||||
@@ -143,6 +143,15 @@ def get_plugin_designer_registry() -> dict[str, tuple[str, str]]:
|
||||
return {}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_plugin_widget_icons() -> dict[str, str]:
|
||||
"""If there is a plugin repository installed, return the designer widget icon registry."""
|
||||
designer_module = get_plugin_designer_module()
|
||||
if designer_module and hasattr(designer_module, "widget_icons"):
|
||||
return designer_module.widget_icons
|
||||
return {}
|
||||
|
||||
|
||||
def get_all_plugin_widgets() -> BECClassContainer:
|
||||
"""If there is a plugin repository installed, load all widgets from it."""
|
||||
if plugin := user_widget_plugin():
|
||||
|
||||
@@ -20,7 +20,6 @@ from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.busy_loader import BusyLoaderOverlay
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -331,32 +330,34 @@ class BECWidget(BECConnector):
|
||||
# All widgets need to call super().cleanup() in their cleanup method
|
||||
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
|
||||
self.rpc_register.remove_rpc(self)
|
||||
children = self.findChildren(BECWidget)
|
||||
for child in children:
|
||||
if not shiboken6.isValid(child):
|
||||
# If the child is not valid, it means it has already been deleted
|
||||
continue
|
||||
child.close()
|
||||
child.deleteLater()
|
||||
children = self.findChildren(BECWidget)
|
||||
for child in children:
|
||||
if not shiboken6.isValid(child):
|
||||
# If the child is not valid, it means it has already been deleted
|
||||
continue
|
||||
child.close()
|
||||
child.deleteLater()
|
||||
|
||||
# Tear down busy overlay explicitly to stop spinner and remove filters
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and shiboken6.isValid(overlay):
|
||||
try:
|
||||
overlay.hide()
|
||||
filt = getattr(overlay, "_filter", None)
|
||||
if filt is not None and shiboken6.isValid(filt):
|
||||
try:
|
||||
self.removeEventFilter(filt)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
|
||||
# Tear down busy overlay explicitly to stop spinner and remove filters
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and shiboken6.isValid(overlay):
|
||||
try:
|
||||
overlay.hide()
|
||||
filt = getattr(overlay, "_filter", None)
|
||||
if filt is not None and shiboken6.isValid(filt):
|
||||
try:
|
||||
self.removeEventFilter(filt)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Failed to remove event filter from busy overlay: {exc}"
|
||||
)
|
||||
|
||||
# Cleanup the overlay widget. This will call cleanup on the custom widget if present.
|
||||
# Cleanup the overlay widget. This will call cleanup on the custom widget if present.
|
||||
|
||||
overlay.cleanup()
|
||||
overlay.deleteLater()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to delete busy overlay: {exc}")
|
||||
overlay.cleanup()
|
||||
overlay.deleteLater()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to delete busy overlay: {exc}")
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
|
||||
+34
-65
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
@@ -21,8 +21,7 @@ logger = bec_logger.logger
|
||||
def get_theme_name():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
return "dark"
|
||||
else:
|
||||
return QApplication.instance().theme.theme
|
||||
return QApplication.instance().theme.theme
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
@@ -58,6 +57,25 @@ def apply_theme(theme: Literal["dark", "light"]):
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
|
||||
|
||||
def theme_color(theme: Any | None, key: str, fallback: QColor | str) -> QColor:
|
||||
"""
|
||||
Return a QColor from a BEC theme, or the fallback when no theme is set.
|
||||
"""
|
||||
|
||||
fallback_color = fallback if isinstance(fallback, QColor) else QColor(str(fallback))
|
||||
if theme is None:
|
||||
return fallback_color
|
||||
return theme.color(key, fallback_color.name())
|
||||
|
||||
|
||||
def rgba(color: QColor | str, alpha: int) -> str:
|
||||
"""
|
||||
Return a QSS-compatible rgba string.
|
||||
"""
|
||||
qcolor = color if isinstance(color, QColor) else QColor(str(color))
|
||||
return f"rgba({qcolor.red()}, {qcolor.green()}, {qcolor.blue()}, {alpha})"
|
||||
|
||||
|
||||
class Colors:
|
||||
@staticmethod
|
||||
def list_available_colormaps() -> list[str]:
|
||||
@@ -150,25 +168,6 @@ class Colors:
|
||||
|
||||
return ge.colorMap()
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
|
||||
Returns:
|
||||
list: List of angles calculated using the golden ratio.
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def set_theme_offset(theme: Literal["light", "dark"] | None = None, offset=0.2) -> tuple:
|
||||
"""
|
||||
@@ -239,20 +238,7 @@ class Colors:
|
||||
else:
|
||||
positions = np.linspace(min_pos, max_pos, num)
|
||||
|
||||
# Sample colors from the colormap at the calculated positions
|
||||
colors = cmap.map(positions, mode="float")
|
||||
color_list = []
|
||||
|
||||
for color in colors:
|
||||
if format.upper() == "HEX":
|
||||
color_list.append(QColor.fromRgbF(*color).name())
|
||||
elif format.upper() == "RGB":
|
||||
color_list.append(tuple((np.array(color) * 255).astype(int)))
|
||||
elif format.upper() == "QCOLOR":
|
||||
color_list.append(QColor.fromRgbF(*color))
|
||||
else:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
return color_list
|
||||
return Colors._format_mapped_colors(cmap.map(positions, mode="float"), format)
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(
|
||||
@@ -288,20 +274,19 @@ class Colors:
|
||||
positions = np.mod(np.arange(num) * golden_angle_conjugate, 1)
|
||||
positions = min_pos + positions * (max_pos - min_pos)
|
||||
|
||||
# Sample colors from the colormap at the calculated positions
|
||||
colors = cmap.map(positions, mode="float")
|
||||
color_list = []
|
||||
return Colors._format_mapped_colors(cmap.map(positions, mode="float"), format)
|
||||
|
||||
for color in colors:
|
||||
if format.upper() == "HEX":
|
||||
color_list.append(QColor.fromRgbF(*color).name())
|
||||
elif format.upper() == "RGB":
|
||||
color_list.append(tuple((np.array(color) * 255).astype(int)))
|
||||
elif format.upper() == "QCOLOR":
|
||||
color_list.append(QColor.fromRgbF(*color))
|
||||
else:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
return color_list
|
||||
@staticmethod
|
||||
def _format_mapped_colors(colors: np.ndarray, format: Literal["QColor", "HEX", "RGB"]) -> list:
|
||||
color_format = format.upper()
|
||||
if color_format not in {"QCOLOR", "HEX", "RGB"}:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
|
||||
if color_format == "QCOLOR":
|
||||
return [QColor.fromRgbF(*color) for color in colors]
|
||||
if color_format == "HEX":
|
||||
return [QColor.fromRgbF(*color).name() for color in colors]
|
||||
return [tuple((np.array(color) * 255).astype(int)) for color in colors]
|
||||
|
||||
@staticmethod
|
||||
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
|
||||
@@ -325,22 +310,6 @@ class Colors:
|
||||
raise ValueError("HEX color must be 6 or 8 characters long.")
|
||||
return (r, g, b, alpha)
|
||||
|
||||
@staticmethod
|
||||
def rgba_to_hex(r: int, g: int, b: int, a: int = 255) -> str:
|
||||
"""
|
||||
Convert RGBA color to HEX.
|
||||
|
||||
Args:
|
||||
r(int): Red value (0-255).
|
||||
g(int): Green value (0-255).
|
||||
b(int): Blue value (0-255).
|
||||
a(int): Alpha value (0-255). Default is 255 (opaque).
|
||||
|
||||
Returns:
|
||||
hec_color(str): HEX color string.
|
||||
"""
|
||||
return "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, a)
|
||||
|
||||
@staticmethod
|
||||
def validate_color(color: tuple | str) -> tuple | str:
|
||||
"""
|
||||
|
||||
+90
-317
@@ -1,12 +1,12 @@
|
||||
"""Module for handling filter I/O operations in BEC Widgets for input fields.
|
||||
These operations include filtering device/signal names and/or device types.
|
||||
"""
|
||||
"""Small helpers for populating editable combo boxes used by device inputs."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QStringListModel
|
||||
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
|
||||
from qtpy.QtCore import QSignalBlocker
|
||||
from qtpy.QtWidgets import QComboBox
|
||||
from typeguard import TypeCheckError
|
||||
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
@@ -14,329 +14,102 @@ from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class WidgetFilterHandler(ABC):
|
||||
"""Abstract base class for widget filter handlers"""
|
||||
def replace_combobox_items(
|
||||
combo_box: QComboBox,
|
||||
items: list[str | tuple],
|
||||
*,
|
||||
preserve_current_text: bool = False,
|
||||
block_signals: bool = False,
|
||||
) -> None:
|
||||
"""Replace all combobox entries.
|
||||
|
||||
@abstractmethod
|
||||
def set_selection(self, widget, selection: list[str | tuple]) -> None:
|
||||
"""Set the filtered_selection for the widget
|
||||
|
||||
Args:
|
||||
widget: Widget instance
|
||||
selection (list[str | tuple]): Filtered selection of items.
|
||||
If tuple, it contains (text, data) pairs.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def check_input(self, widget, text: str) -> bool:
|
||||
"""Check if the input text is in the filtered selection
|
||||
|
||||
Args:
|
||||
widget: Widget instance
|
||||
text (str): Input text
|
||||
|
||||
Returns:
|
||||
bool: True if the input text is in the filtered selection
|
||||
"""
|
||||
|
||||
@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
|
||||
|
||||
def update_with_bec_signal_class(
|
||||
self,
|
||||
signal_class_filter: str | list[str],
|
||||
client,
|
||||
ndim_filter: int | list[int] | None = None,
|
||||
) -> list[tuple[str, str, dict]]:
|
||||
"""Update the selection based on signal classes using device_manager.get_bec_signals.
|
||||
|
||||
Args:
|
||||
signal_class_filter (str|list[str]): List of signal class names to filter.
|
||||
client: BEC client instance.
|
||||
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
|
||||
If provided, only signals with matching ndim will be included.
|
||||
|
||||
Returns:
|
||||
list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples.
|
||||
"""
|
||||
if not client or not hasattr(client, "device_manager"):
|
||||
return []
|
||||
|
||||
try:
|
||||
signals = client.device_manager.get_bec_signals(signal_class_filter)
|
||||
except TypeCheckError as e:
|
||||
logger.warning(f"Error retrieving signals: {e}")
|
||||
return []
|
||||
|
||||
if ndim_filter is None:
|
||||
return signals
|
||||
|
||||
if isinstance(ndim_filter, int):
|
||||
ndim_filter = [ndim_filter]
|
||||
|
||||
filtered_signals = []
|
||||
for device_name, signal_name, signal_config in signals:
|
||||
ndim = None
|
||||
if isinstance(signal_config, dict):
|
||||
ndim = signal_config.get("describe", {}).get("signal_info", {}).get("ndim")
|
||||
|
||||
if ndim in ndim_filter:
|
||||
filtered_signals.append((device_name, signal_name, signal_config))
|
||||
|
||||
return filtered_signals
|
||||
|
||||
|
||||
class LineEditFilterHandler(WidgetFilterHandler):
|
||||
"""Handler for QLineEdit widget"""
|
||||
|
||||
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[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)
|
||||
widget.completer.setModel(QStringListModel(selection, widget))
|
||||
|
||||
def check_input(self, widget: QLineEdit, text: str) -> bool:
|
||||
"""Check if the input text is in the filtered selection
|
||||
|
||||
Args:
|
||||
widget (QLineEdit): The QLineEdit widget
|
||||
text (str): Input text
|
||||
|
||||
Returns:
|
||||
bool: True if the input text is in the filtered selection
|
||||
"""
|
||||
model = widget.completer.model()
|
||||
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
|
||||
return text in model_data
|
||||
|
||||
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[str | tuple]) -> None:
|
||||
"""Set the selection for the widget to the completer model
|
||||
|
||||
Args:
|
||||
widget (QComboBox): The QComboBox widget
|
||||
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
|
||||
"""
|
||||
widget.clear()
|
||||
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
|
||||
|
||||
Args:
|
||||
widget (QComboBox): The QComboBox widget
|
||||
text (str): Input text
|
||||
|
||||
Returns:
|
||||
bool: True if the input text is in the filtered selection
|
||||
"""
|
||||
return text in [widget.itemText(i) for i in range(widget.count())]
|
||||
|
||||
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.
|
||||
It supports the list of widgets stored in class attribute _handlers.
|
||||
Args:
|
||||
combo_box: Combobox whose entries should be replaced.
|
||||
items: Entries to add. String entries are added as display text. Tuple entries are
|
||||
passed to ``QComboBox.addItem`` as ``(text, data)``.
|
||||
preserve_current_text: If True, restore the combobox text after replacing the items.
|
||||
block_signals: If True, block combobox signals while the items are replaced.
|
||||
"""
|
||||
current_text = combo_box.currentText()
|
||||
signal_blocker = QSignalBlocker(combo_box) if block_signals else nullcontext()
|
||||
with signal_blocker:
|
||||
combo_box.clear()
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
combo_box.addItem(item)
|
||||
else:
|
||||
combo_box.addItem(*item)
|
||||
if preserve_current_text:
|
||||
combo_box.setCurrentText(current_text)
|
||||
|
||||
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
|
||||
|
||||
@staticmethod
|
||||
def set_selection(widget, selection: list[str | tuple], ignore_errors=True):
|
||||
"""
|
||||
Retrieve value from the widget instance.
|
||||
def signal_items_for_kind(
|
||||
*, kind: Kind, signal_filter: set[Kind], device_info: dict, device_name: str
|
||||
) -> list[tuple[str, dict]]:
|
||||
"""Build display entries for signals matching a BEC signal kind.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
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)
|
||||
if handler_class:
|
||||
return handler_class().set_selection(widget=widget, selection=selection)
|
||||
if not ignore_errors:
|
||||
raise ValueError(
|
||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
||||
)
|
||||
return None
|
||||
Args:
|
||||
kind: Signal kind to collect.
|
||||
signal_filter: Enabled signal kinds.
|
||||
device_info: Signal metadata from the BEC device info dictionary.
|
||||
device_name: Name of the device owning the signals.
|
||||
|
||||
@staticmethod
|
||||
def check_input(widget, text: str, ignore_errors=True):
|
||||
"""
|
||||
Check if the input text is in the filtered selection.
|
||||
Returns:
|
||||
Combobox entries as ``(display_text, signal_info)`` tuples.
|
||||
"""
|
||||
items: list[tuple[str, dict]] = []
|
||||
for signal_name, signal_info in device_info.items():
|
||||
if kind not in signal_filter or signal_info.get("kind_str") != kind.name:
|
||||
continue
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
text(str): Input text.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
obj_name = signal_info.get("obj_name", "")
|
||||
component_name = signal_info.get("component_name", "")
|
||||
signal_without_device = obj_name.removeprefix(f"{device_name}_")
|
||||
if not signal_without_device:
|
||||
signal_without_device = obj_name
|
||||
|
||||
Returns:
|
||||
bool: True if the input text is in the filtered selection.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().check_input(widget=widget, text=text)
|
||||
if not ignore_errors:
|
||||
raise ValueError(
|
||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
||||
)
|
||||
return None
|
||||
if (
|
||||
signal_without_device != signal_name
|
||||
and component_name.replace(".", "_") != signal_without_device
|
||||
):
|
||||
items.append((f"{signal_without_device} ({signal_name})", signal_info))
|
||||
else:
|
||||
items.append((signal_name, signal_info))
|
||||
return items
|
||||
|
||||
@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.
|
||||
def get_bec_signals_for_classes(
|
||||
*, client, signal_class_filter: str | list[str], ndim_filter: int | list[int] | None = None
|
||||
) -> list[tuple[str, str, dict]]:
|
||||
"""Return BEC signals filtered by signal class and optional dimensionality.
|
||||
|
||||
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}"
|
||||
)
|
||||
Args:
|
||||
client: BEC client that provides ``device_manager.get_bec_signals``.
|
||||
signal_class_filter: Signal class name or class names passed to the device manager.
|
||||
ndim_filter: Optional dimensionality filter. If provided, only signals whose
|
||||
``describe.signal_info.ndim`` is in this value are returned.
|
||||
|
||||
@staticmethod
|
||||
def update_with_signal_class(
|
||||
widget, signal_class_filter: list[str], client, ndim_filter: int | list[int] | None = None
|
||||
) -> list[tuple[str, str, dict]]:
|
||||
"""
|
||||
Update the selection based on signal classes using device_manager.get_bec_signals.
|
||||
Returns:
|
||||
Tuples of ``(device_name, signal_name, signal_config)`` for matching signals.
|
||||
"""
|
||||
if not client or not hasattr(client, "device_manager"):
|
||||
return []
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
signal_class_filter (list[str]): List of signal class names to filter.
|
||||
client: BEC client instance.
|
||||
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
|
||||
If provided, only signals with matching ndim will be included.
|
||||
try:
|
||||
signals = client.device_manager.get_bec_signals(signal_class_filter)
|
||||
except TypeCheckError as exc:
|
||||
logger.warning(f"Error retrieving signals: {exc}")
|
||||
return []
|
||||
|
||||
Returns:
|
||||
list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().update_with_bec_signal_class(
|
||||
signal_class_filter=signal_class_filter, client=client, ndim_filter=ndim_filter
|
||||
)
|
||||
raise ValueError(
|
||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
||||
)
|
||||
if ndim_filter is None:
|
||||
return signals
|
||||
|
||||
@staticmethod
|
||||
def _find_handler(widget):
|
||||
"""
|
||||
Find the appropriate handler for the widget by checking its base classes.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
|
||||
Returns:
|
||||
handler_class: The handler class if found, otherwise None.
|
||||
"""
|
||||
for base in type(widget).__mro__:
|
||||
if base in FilterIO._handlers:
|
||||
return FilterIO._handlers[base]
|
||||
return None
|
||||
accepted_ndim = [ndim_filter] if isinstance(ndim_filter, int) else ndim_filter
|
||||
filtered_signals: list[tuple[str, str, dict]] = []
|
||||
for device_name, signal_name, signal_config in signals:
|
||||
ndim = None
|
||||
if isinstance(signal_config, dict):
|
||||
ndim = signal_config.get("describe", {}).get("signal_info", {}).get("ndim")
|
||||
if ndim in accepted_ndim:
|
||||
filtered_signals.append((device_name, signal_name, signal_config))
|
||||
return filtered_signals
|
||||
|
||||
@@ -150,7 +150,7 @@ class TypedForm(BECWidget, QWidget):
|
||||
self.adjustSize()
|
||||
|
||||
def _new_grid_layout(self):
|
||||
new_grid = QGridLayout(self)
|
||||
new_grid = QGridLayout()
|
||||
new_grid.setContentsMargins(0, 0, 0, 0)
|
||||
return new_grid
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from bec_lib.scan_args import ScanArgument
|
||||
from pydantic import BaseModel
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
from bec_widgets.utils.scan_arg_metadata import ui_config_from_metadata
|
||||
|
||||
NUMERIC_BOUND_KEYS = {"gt", "ge", "lt", "le"}
|
||||
|
||||
|
||||
def pydantic_model_input_configs(model: type[BaseModel]) -> list[dict[str, Any]]:
|
||||
"""Return scan-control-style field items for a Pydantic model."""
|
||||
configs = []
|
||||
for name, info in model.model_fields.items():
|
||||
metadata: dict[str, Any] = {}
|
||||
for entry in info.metadata:
|
||||
if isinstance(entry, ScanArgument):
|
||||
metadata.update(entry.model_dump(exclude_none=True))
|
||||
continue
|
||||
for key in NUMERIC_BOUND_KEYS:
|
||||
value = getattr(entry, key, None)
|
||||
if value is not None:
|
||||
metadata.setdefault(key, value)
|
||||
|
||||
if isinstance(info.json_schema_extra, Mapping):
|
||||
metadata.update(dict(info.json_schema_extra))
|
||||
|
||||
if info.description and metadata.get("description") is None:
|
||||
metadata["description"] = info.description
|
||||
|
||||
default: Any
|
||||
if info.default is not PydanticUndefined:
|
||||
default = info.default
|
||||
elif info.default_factory is not None:
|
||||
default = info.get_default(call_default_factory=True)
|
||||
else:
|
||||
default = None
|
||||
|
||||
display_name = metadata.get("display_name") or info.title
|
||||
if display_name is None:
|
||||
display_name = name.replace("_", " ").capitalize()
|
||||
|
||||
item = ui_config_from_metadata(
|
||||
name=name, metadata=metadata, default=default, display_name=display_name
|
||||
)
|
||||
item.update({key: value for key, value in metadata.items() if key not in item})
|
||||
configs.append(item)
|
||||
|
||||
return configs
|
||||
@@ -0,0 +1,815 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import NoneType
|
||||
from typing import Any, Literal, get_args, get_origin
|
||||
|
||||
from bec_lib.device import DeviceBase, Signal
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic.fields import FieldInfo
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtCore import Signal as QtSignal
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QSpinBox,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.forms_from_types.pydantic_model_info_adapter import (
|
||||
NUMERIC_BOUND_KEYS,
|
||||
pydantic_model_input_configs,
|
||||
)
|
||||
from bec_widgets.utils.scan_arg_metadata import (
|
||||
apply_numeric_limits,
|
||||
apply_numeric_precision,
|
||||
apply_unit_metadata,
|
||||
device_units,
|
||||
)
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
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.utility.spinbox.decimal_spinbox import BECSpinBox
|
||||
|
||||
|
||||
class OptionalValueWidget(QWidget):
|
||||
"""Wrap a value widget with an enable checkbox for optional Pydantic fields.
|
||||
|
||||
Attributes:
|
||||
value_changed: Signal emitted with the current value whenever the checkbox
|
||||
state or wrapped widget value changes.
|
||||
"""
|
||||
|
||||
value_changed = QtSignal(object)
|
||||
|
||||
def __init__(self, value_widget: QWidget, parent: QWidget | None = None) -> None:
|
||||
"""Create an optional-value wrapper.
|
||||
|
||||
Args:
|
||||
value_widget: Input widget used when the optional value is enabled.
|
||||
parent: Optional parent widget.
|
||||
"""
|
||||
super().__init__(parent=parent)
|
||||
self._value_widget = value_widget
|
||||
self._checkbox = QCheckBox(self)
|
||||
self._checkbox.setToolTip("Enable value")
|
||||
self._value_widget.setParent(self)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
layout.addWidget(self._checkbox)
|
||||
layout.addWidget(self._value_widget, 1)
|
||||
|
||||
self._checkbox.toggled.connect(self._on_enabled_changed)
|
||||
WidgetIO.connect_widget_change_signal(self._value_widget, self._emit_current_value)
|
||||
self._on_enabled_changed(False)
|
||||
|
||||
@property
|
||||
def value_widget(self) -> QWidget:
|
||||
"""Return the wrapped input widget.
|
||||
|
||||
Returns:
|
||||
The widget that edits the non-``None`` value.
|
||||
"""
|
||||
return self._value_widget
|
||||
|
||||
@property
|
||||
def checkbox(self) -> QCheckBox:
|
||||
"""Return the checkbox controlling whether the value is enabled.
|
||||
|
||||
Returns:
|
||||
The enable checkbox.
|
||||
"""
|
||||
return self._checkbox
|
||||
|
||||
def value(self) -> Any:
|
||||
"""Return the current optional value.
|
||||
|
||||
Returns:
|
||||
``None`` when the checkbox is unchecked; otherwise the wrapped widget value.
|
||||
"""
|
||||
if not self._checkbox.isChecked():
|
||||
return None
|
||||
return WidgetIO.get_value(self._value_widget)
|
||||
|
||||
def set_value(self, value: Any) -> None:
|
||||
"""Set the optional value.
|
||||
|
||||
Args:
|
||||
value: Value to set on the wrapped widget. ``None`` disables the value.
|
||||
"""
|
||||
enabled = value is not None
|
||||
self._checkbox.setChecked(enabled)
|
||||
self._value_widget.setEnabled(enabled)
|
||||
if enabled:
|
||||
WidgetIO.set_value(self._value_widget, value)
|
||||
|
||||
def _on_enabled_changed(self, enabled: bool) -> None:
|
||||
self._value_widget.setEnabled(enabled)
|
||||
self.value_changed.emit(self.value())
|
||||
|
||||
def _emit_current_value(self, *_args) -> None:
|
||||
self.value_changed.emit(self.value())
|
||||
|
||||
|
||||
class PydanticWidgetForm(QWidget):
|
||||
"""Generate a Qt form from a Pydantic model.
|
||||
|
||||
The form maps Pydantic field annotations to Qt widgets, applies supported
|
||||
field metadata, and exposes typed and raw data accessors for the generated
|
||||
fields.
|
||||
|
||||
Attributes:
|
||||
changed: Signal emitted whenever a generated input widget changes.
|
||||
validity_changed: Signal emitted by :meth:`validate` with the current
|
||||
validation result.
|
||||
"""
|
||||
|
||||
changed = QtSignal()
|
||||
validity_changed = QtSignal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: type[BaseModel],
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
data: BaseModel | dict[str, Any] | None = None,
|
||||
read_only_fields: set[str] | None = None,
|
||||
client=None,
|
||||
) -> None:
|
||||
"""Create a generated form for a Pydantic model.
|
||||
|
||||
Args:
|
||||
model: Pydantic model class used to generate fields and validate data.
|
||||
parent: Optional parent widget.
|
||||
data: Optional initial model instance or raw field-value mapping.
|
||||
read_only_fields: Field names that should be displayed but not editable.
|
||||
client: Optional BEC client passed to domain-specific widgets such as
|
||||
device and signal combo boxes.
|
||||
"""
|
||||
super().__init__(parent=parent)
|
||||
self._model = model
|
||||
self._client = client
|
||||
self._read_only_fields = set(read_only_fields or set())
|
||||
self._widgets: dict[str, QWidget] = {}
|
||||
self._field_configs: dict[str, dict[str, Any]] = {}
|
||||
self._baseline: dict[str, Any] = {}
|
||||
|
||||
self._layout = QFormLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setHorizontalSpacing(10)
|
||||
self._layout.setVerticalSpacing(8)
|
||||
self._layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
self._layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._populate()
|
||||
if data is not None:
|
||||
self.set_data(data)
|
||||
self.mark_clean()
|
||||
|
||||
@property
|
||||
def model(self) -> type[BaseModel]:
|
||||
"""Return the active Pydantic model class.
|
||||
|
||||
Returns:
|
||||
The model class currently used by this form.
|
||||
"""
|
||||
return self._model
|
||||
|
||||
@property
|
||||
def widgets(self) -> dict[str, QWidget]:
|
||||
"""Return generated field widgets keyed by model field name.
|
||||
|
||||
Returns:
|
||||
A shallow copy of the field-widget mapping. Optional fields return
|
||||
their outer :class:`OptionalValueWidget`.
|
||||
"""
|
||||
return dict(self._widgets)
|
||||
|
||||
def field_widget(self, name: str) -> QWidget:
|
||||
"""Return the generated widget for a field.
|
||||
|
||||
Args:
|
||||
name: Model field name.
|
||||
|
||||
Returns:
|
||||
The generated field widget. Optional fields return their outer
|
||||
:class:`OptionalValueWidget`.
|
||||
|
||||
Raises:
|
||||
KeyError: If no widget exists for ``name``.
|
||||
"""
|
||||
return self._widgets[name]
|
||||
|
||||
def input_widget(self, name: str) -> QWidget:
|
||||
"""Return the direct input widget for a field.
|
||||
|
||||
Args:
|
||||
name: Model field name.
|
||||
|
||||
Returns:
|
||||
The editable input widget. Optional fields return the wrapped value
|
||||
widget instead of the outer optional wrapper.
|
||||
|
||||
Raises:
|
||||
KeyError: If no widget exists for ``name``.
|
||||
"""
|
||||
widget = self._widgets[name]
|
||||
if isinstance(widget, OptionalValueWidget):
|
||||
return widget.value_widget
|
||||
return widget
|
||||
|
||||
def input_widgets(self) -> dict[str, QWidget]:
|
||||
"""Return direct input widgets keyed by model field name.
|
||||
|
||||
Returns:
|
||||
Mapping of field names to editable input widgets.
|
||||
"""
|
||||
return {name: self.input_widget(name) for name in self._widgets}
|
||||
|
||||
def input_widgets_by_type(self, widget_type: type[QWidget]) -> list[QWidget]:
|
||||
"""Return direct input widgets matching a widget type.
|
||||
|
||||
Args:
|
||||
widget_type: Qt widget class to match with ``isinstance``.
|
||||
|
||||
Returns:
|
||||
List of input widgets matching ``widget_type``.
|
||||
"""
|
||||
return [
|
||||
widget for widget in self.input_widgets().values() if isinstance(widget, widget_type)
|
||||
]
|
||||
|
||||
def set_model(self, model: type[BaseModel], data: dict[str, Any] | None = None) -> None:
|
||||
"""Replace the active model and rebuild the form.
|
||||
|
||||
Args:
|
||||
model: New Pydantic model class.
|
||||
data: Optional initial data for the new model. When omitted, values
|
||||
from fields shared with the previous model are preserved.
|
||||
"""
|
||||
old_data = self.raw_data()
|
||||
self.cleanup()
|
||||
self._model = model
|
||||
self._populate()
|
||||
if data is None:
|
||||
data = {key: value for key, value in old_data.items() if key in model.model_fields}
|
||||
self.set_partial_data(data)
|
||||
self.mark_clean()
|
||||
|
||||
def set_data(self, data: BaseModel | dict[str, Any]) -> None:
|
||||
"""Set form values from a model instance or mapping.
|
||||
|
||||
Args:
|
||||
data: Pydantic model instance or raw field-value mapping.
|
||||
"""
|
||||
values = data.model_dump() if isinstance(data, BaseModel) else dict(data)
|
||||
self.set_partial_data(values)
|
||||
|
||||
def set_partial_data(self, data: dict[str, Any]) -> None:
|
||||
"""Set values for fields present in the form.
|
||||
|
||||
Unknown keys are ignored, which allows callers to pass larger model
|
||||
dumps or backend payloads safely.
|
||||
|
||||
Args:
|
||||
data: Field-value mapping to apply.
|
||||
"""
|
||||
for name, value in data.items():
|
||||
if name not in self._widgets:
|
||||
continue
|
||||
self._set_widget_value(name, value)
|
||||
self._refresh_reference_units()
|
||||
self.changed.emit()
|
||||
|
||||
def raw_data(self) -> dict[str, Any]:
|
||||
"""Return current widget values without Pydantic validation.
|
||||
|
||||
Returns:
|
||||
Mapping of model field names to raw widget values.
|
||||
"""
|
||||
return {name: self._read_widget_value(name) for name in self._widgets}
|
||||
|
||||
def get_data(self) -> dict[str, Any]:
|
||||
"""Return current data after Pydantic validation.
|
||||
|
||||
Returns:
|
||||
Validated model data as a dictionary.
|
||||
|
||||
Raises:
|
||||
ValidationError: If Pydantic validation fails.
|
||||
ValueError: If domain widget validation fails.
|
||||
"""
|
||||
return self.model_instance().model_dump()
|
||||
|
||||
def model_instance(self) -> BaseModel:
|
||||
"""Return the current values as a Pydantic model instance.
|
||||
|
||||
Returns:
|
||||
Validated instance of the active model class.
|
||||
|
||||
Raises:
|
||||
ValidationError: If Pydantic validation fails.
|
||||
ValueError: If domain widget validation fails.
|
||||
"""
|
||||
self._validate_domain_widgets()
|
||||
return self._model.model_validate(self.raw_data())
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Validate the current form values.
|
||||
|
||||
Returns:
|
||||
``True`` when current values validate successfully, otherwise ``False``.
|
||||
"""
|
||||
try:
|
||||
self.get_data()
|
||||
except (ValidationError, ValueError):
|
||||
self.validity_changed.emit(False)
|
||||
return False
|
||||
self.validity_changed.emit(True)
|
||||
return True
|
||||
|
||||
def dirty_fields(self) -> set[str]:
|
||||
"""Return fields whose raw values differ from the clean baseline.
|
||||
|
||||
Returns:
|
||||
Set of dirty field names.
|
||||
"""
|
||||
current = self.raw_data()
|
||||
fields = set(current) | set(self._baseline)
|
||||
return {field for field in fields if current.get(field) != self._baseline.get(field)}
|
||||
|
||||
def mark_clean(self) -> None:
|
||||
"""Store the current raw values as the clean baseline."""
|
||||
self._baseline = self.raw_data()
|
||||
|
||||
def reset_to_baseline(self) -> None:
|
||||
"""Restore the form values to the current clean baseline."""
|
||||
self.set_partial_data(self._baseline)
|
||||
|
||||
def editable_data(self) -> dict[str, Any]:
|
||||
"""Return validated data excluding read-only fields.
|
||||
|
||||
Returns:
|
||||
Validated editable field values.
|
||||
|
||||
Raises:
|
||||
ValidationError: If Pydantic validation fails.
|
||||
ValueError: If domain widget validation fails.
|
||||
"""
|
||||
return {
|
||||
key: value
|
||||
for key, value in self.get_data().items()
|
||||
if key not in self._read_only_fields
|
||||
}
|
||||
|
||||
def raw_editable_data(self) -> dict[str, Any]:
|
||||
"""Return raw widget data excluding read-only fields.
|
||||
|
||||
Returns:
|
||||
Raw editable field values.
|
||||
"""
|
||||
return {
|
||||
key: value
|
||||
for key, value in self.raw_data().items()
|
||||
if key not in self._read_only_fields
|
||||
}
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Close and schedule deletion of all generated field widgets."""
|
||||
while self._layout.rowCount():
|
||||
row = self._layout.takeRow(0)
|
||||
for item in (row.labelItem, row.fieldItem):
|
||||
widget = item.widget() if item is not None else None
|
||||
if widget is not None:
|
||||
widget.close()
|
||||
# Detach before deleteLater: a child pending deletion that still has a
|
||||
# signal connection into this form crashes if the form is garbage
|
||||
# collected before the deferred delete is processed.
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
self._widgets.clear()
|
||||
self._field_configs.clear()
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
self.cleanup()
|
||||
super().closeEvent(event)
|
||||
|
||||
def _populate(self) -> None:
|
||||
for config in pydantic_model_input_configs(self._model):
|
||||
name = config["name"]
|
||||
info = self._model.model_fields[name]
|
||||
widget = self._create_widget(name, info)
|
||||
label_text = config["display_name"]
|
||||
self._layout.addRow(label_text, widget)
|
||||
label = self._layout.labelForField(widget)
|
||||
if label is not None:
|
||||
label.setProperty("_model_field_name", name)
|
||||
if config.get("tooltip") and label is not None:
|
||||
label.setToolTip(config["tooltip"])
|
||||
widget.setEnabled(name not in self._read_only_fields)
|
||||
self._widgets[name] = widget
|
||||
self._field_configs[name] = config
|
||||
self._set_widget_value(name, config["default"])
|
||||
self._apply_field_metadata(name)
|
||||
self._connect_widget(widget)
|
||||
|
||||
self._connect_device_signal_widgets()
|
||||
self._connect_reference_unit_widgets()
|
||||
self._refresh_reference_units()
|
||||
|
||||
def _create_widget(self, name: str, info: FieldInfo) -> QWidget:
|
||||
annotation = info.annotation
|
||||
args = get_args(annotation)
|
||||
optional = NoneType in args
|
||||
non_none_args = tuple(arg for arg in args if arg is not NoneType)
|
||||
value_annotation = non_none_args[0] if len(non_none_args) == 1 else annotation
|
||||
|
||||
widget = self._create_value_widget(name, value_annotation)
|
||||
numeric = value_annotation in (int, float) or any(
|
||||
arg in (int, float) for arg in get_args(value_annotation)
|
||||
)
|
||||
if optional and (numeric or value_annotation is bool):
|
||||
return OptionalValueWidget(widget, parent=self)
|
||||
return widget
|
||||
|
||||
def _create_value_widget(self, name: str, annotation: Any) -> QWidget:
|
||||
args = get_args(annotation)
|
||||
if (
|
||||
isinstance(annotation, type)
|
||||
and issubclass(annotation, Signal)
|
||||
or any(isinstance(arg, type) and issubclass(arg, Signal) for arg in args)
|
||||
):
|
||||
return SignalComboBox(
|
||||
parent=self,
|
||||
client=self._client,
|
||||
require_device=self._model_has_device_field(),
|
||||
arg_name=name,
|
||||
)
|
||||
if (
|
||||
isinstance(annotation, type)
|
||||
and issubclass(annotation, DeviceBase)
|
||||
or any(isinstance(arg, type) and issubclass(arg, DeviceBase) for arg in args)
|
||||
):
|
||||
return DeviceComboBox(parent=self, client=self._client, arg_name=name)
|
||||
if get_origin(annotation) is Literal:
|
||||
widget = QComboBox(self)
|
||||
widget.addItems([str(value) for value in get_args(annotation)])
|
||||
return widget
|
||||
if annotation is bool:
|
||||
return QCheckBox(self)
|
||||
if annotation is int:
|
||||
spin_box = QSpinBox(self)
|
||||
spin_box.setRange(-2147483647, 2147483647)
|
||||
return spin_box
|
||||
if annotation is float:
|
||||
spin_box = BECSpinBox(self)
|
||||
spin_box.setRange(-1_000_000_000, 1_000_000_000)
|
||||
return spin_box
|
||||
return QLineEdit(self)
|
||||
|
||||
def _apply_field_metadata(self, name: str) -> None:
|
||||
config = self._field_configs[name]
|
||||
field_widget = self._widgets[name]
|
||||
input_widget = self.input_widget(name)
|
||||
|
||||
if config.get("precision") is not None:
|
||||
apply_numeric_precision(input_widget, config)
|
||||
if any(config.get(key) is not None for key in NUMERIC_BOUND_KEYS):
|
||||
apply_numeric_limits(input_widget, config)
|
||||
|
||||
apply_unit_metadata(field_widget, config)
|
||||
if input_widget is not field_widget:
|
||||
apply_unit_metadata(input_widget, config)
|
||||
|
||||
def _connect_widget(self, widget: QWidget) -> None:
|
||||
if isinstance(widget, OptionalValueWidget):
|
||||
widget.value_changed.connect(lambda _value: self.changed.emit())
|
||||
return
|
||||
WidgetIO.connect_widget_change_signal(widget, lambda *_args: self.changed.emit())
|
||||
|
||||
def _connect_device_signal_widgets(self) -> None:
|
||||
devices = [
|
||||
widget for widget in self._widgets.values() if isinstance(widget, DeviceComboBox)
|
||||
]
|
||||
signals = [
|
||||
widget for widget in self._widgets.values() if isinstance(widget, SignalComboBox)
|
||||
]
|
||||
if not devices or not signals:
|
||||
return
|
||||
device_widget = devices[0]
|
||||
for signal_widget in signals:
|
||||
device_widget.device_selected.connect(signal_widget.set_device)
|
||||
device_widget.device_reset.connect(lambda w=signal_widget: w.set_device(None))
|
||||
if device_widget.currentText().strip():
|
||||
signal_widget.set_device(device_widget.currentText().strip())
|
||||
|
||||
def _connect_reference_unit_widgets(self) -> None:
|
||||
for name, widget in self.input_widgets().items():
|
||||
if not isinstance(widget, DeviceComboBox):
|
||||
continue
|
||||
widget.device_selected.connect(
|
||||
lambda _device_name, field_name=name: self._update_reference_units(field_name)
|
||||
)
|
||||
widget.device_reset.connect(
|
||||
lambda field_name=name: self._apply_reference_units(field_name, None)
|
||||
)
|
||||
widget.currentTextChanged.connect(
|
||||
lambda text, field_name=name: self._handle_reference_device_text(field_name, text)
|
||||
)
|
||||
|
||||
def _refresh_reference_units(self) -> None:
|
||||
for name, widget in self.input_widgets().items():
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
self._update_reference_units(name)
|
||||
|
||||
def _update_reference_units(self, source_name: str) -> None:
|
||||
widget = self.input_widget(source_name)
|
||||
if not isinstance(widget, DeviceComboBox) or not widget.is_valid_input:
|
||||
self._apply_reference_units(source_name, None)
|
||||
return
|
||||
self._apply_reference_units(source_name, device_units(widget.get_current_device()))
|
||||
|
||||
def _apply_reference_units(self, source_name: str, units: str | None) -> None:
|
||||
for field_name, config in self._field_configs.items():
|
||||
if config.get("reference_units") != source_name:
|
||||
continue
|
||||
field_widget = self.field_widget(field_name)
|
||||
input_widget = self.input_widget(field_name)
|
||||
apply_unit_metadata(field_widget, config, units)
|
||||
if input_widget is not field_widget:
|
||||
apply_unit_metadata(input_widget, config, units)
|
||||
|
||||
def _handle_reference_device_text(self, source_name: str, device_name: str) -> None:
|
||||
widget = self.input_widget(source_name)
|
||||
if isinstance(widget, DeviceComboBox) and not widget.validate_device(device_name):
|
||||
self._apply_reference_units(source_name, None)
|
||||
|
||||
def _validate_domain_widgets(self) -> None:
|
||||
for widget in self._widgets.values():
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
device = widget.currentText().strip()
|
||||
if not device:
|
||||
raise ValueError("Device is required.")
|
||||
if not widget.is_valid_input:
|
||||
raise ValueError(f"Device '{device}' is not available.")
|
||||
if isinstance(widget, SignalComboBox):
|
||||
signal = widget.get_signal_name().strip()
|
||||
if signal and not widget.is_valid_input:
|
||||
raise ValueError(f"Signal '{signal}' is not available.")
|
||||
|
||||
def _read_widget_value(self, name: str) -> Any:
|
||||
widget = self._widgets[name]
|
||||
info = self._model.model_fields[name]
|
||||
if isinstance(widget, OptionalValueWidget):
|
||||
return widget.value()
|
||||
if isinstance(widget, QLineEdit):
|
||||
value = WidgetIO.get_value(widget)
|
||||
return None if NoneType in get_args(info.annotation) and value == "" else value
|
||||
if isinstance(widget, QComboBox) and get_origin(info.annotation) is Literal:
|
||||
return WidgetIO.get_value(widget, as_string=True)
|
||||
return WidgetIO.get_value(widget)
|
||||
|
||||
def _set_widget_value(self, name: str, value: Any) -> None:
|
||||
widget = self._widgets[name]
|
||||
if isinstance(widget, OptionalValueWidget):
|
||||
widget.set_value(value)
|
||||
return
|
||||
if value is None:
|
||||
if isinstance(widget, QLineEdit):
|
||||
value = ""
|
||||
elif isinstance(widget, QCheckBox):
|
||||
value = False
|
||||
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
|
||||
value = 0
|
||||
WidgetIO.set_value(widget, value)
|
||||
|
||||
def _model_has_device_field(self) -> bool:
|
||||
for field in self._model.model_fields.values():
|
||||
annotation = field.annotation
|
||||
args = get_args(annotation)
|
||||
has_device = (
|
||||
isinstance(annotation, type)
|
||||
and issubclass(annotation, DeviceBase)
|
||||
or any(isinstance(arg, type) and issubclass(arg, DeviceBase) for arg in args)
|
||||
)
|
||||
has_signal = (
|
||||
isinstance(annotation, type)
|
||||
and issubclass(annotation, Signal)
|
||||
or any(isinstance(arg, type) and issubclass(arg, Signal) for arg in args)
|
||||
)
|
||||
if has_device and not has_signal:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import json
|
||||
import sys
|
||||
|
||||
from bec_lib.scan_args import ScanArgument
|
||||
from pydantic import Field
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QPushButton, QTabWidget, QTextEdit, QVBoxLayout
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
class BasicScanConfig(BaseModel):
|
||||
"""Plain Pydantic fields without GUI metadata."""
|
||||
|
||||
sample_name: str
|
||||
enabled: bool = True
|
||||
repeats: int = 3
|
||||
|
||||
class LimitConfig(BaseModel):
|
||||
"""Normal Pydantic Field metadata."""
|
||||
|
||||
mode: Literal["monitor", "scan", "calibration"] = "scan"
|
||||
low_limit: (
|
||||
float | None
|
||||
) # example of the field without additional metadata, still works in form
|
||||
high_limit: float | None = Field(
|
||||
default=10.0,
|
||||
title="High limit",
|
||||
description="Optional upper allowed value.",
|
||||
json_schema_extra={"precision": 4},
|
||||
)
|
||||
tolerance: float = Field(
|
||||
default=0.1,
|
||||
title="Tolerance",
|
||||
description="Warning tolerance around configured limits.",
|
||||
json_schema_extra={"precision": 4},
|
||||
)
|
||||
|
||||
class ScanArgumentConfig(BaseModel):
|
||||
"""ScanArgument metadata applied through Field extras."""
|
||||
|
||||
settling_time: float = Field(
|
||||
default=0.0,
|
||||
**ScanArgument(
|
||||
display_name="Settling time",
|
||||
description="Time to wait after moving.",
|
||||
units="s",
|
||||
precision=3,
|
||||
ge=0,
|
||||
).model_dump(),
|
||||
)
|
||||
frames: int = Field(
|
||||
default=1,
|
||||
**ScanArgument(
|
||||
display_name="Frames", description="Number of frames per trigger.", ge=1
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
class DeviceSignalLimitsConfig(BaseModel):
|
||||
"""Device, signal, and numeric fields whose units follow the selected device."""
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
device: DeviceBase | str = Field(
|
||||
default="",
|
||||
**ScanArgument(display_name="Device", description="Positioner device.").model_dump(),
|
||||
)
|
||||
signal: Signal | str | None = Field(
|
||||
default=None,
|
||||
**ScanArgument(display_name="Signal", description="Device signal.").model_dump(),
|
||||
)
|
||||
low_limit: float | None = Field(
|
||||
default=None,
|
||||
**ScanArgument(
|
||||
display_name="Low limit",
|
||||
description="Optional lower limit.",
|
||||
reference_units="device",
|
||||
precision=4,
|
||||
).model_dump(),
|
||||
)
|
||||
high_limit: float | None = Field(
|
||||
default=None,
|
||||
**ScanArgument(
|
||||
display_name="High limit",
|
||||
description="Optional upper limit.",
|
||||
reference_units="device",
|
||||
precision=4,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
class DisplayConfig(BaseModel):
|
||||
title: str | None = Field(
|
||||
default=None, title="Title", description="Optional display title."
|
||||
)
|
||||
show_grid: bool = Field(default=True, title="Show grid")
|
||||
refresh_interval: int = Field(
|
||||
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
|
||||
)
|
||||
|
||||
class DeviceAndSignalConfig(BaseModel):
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
title: str | None = Field(
|
||||
default=None, title="Title", description="Optional display title."
|
||||
)
|
||||
device: DeviceBase | str = Field(
|
||||
default="", title="Device", description="BEC device selection."
|
||||
)
|
||||
signal: Signal | str | None = Field(
|
||||
default=None,
|
||||
title="Signal",
|
||||
description="Signal selection scoped to the selected device.",
|
||||
)
|
||||
refresh_interval: int = Field(
|
||||
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
|
||||
)
|
||||
|
||||
class DeviceOnlyConfig(BaseModel):
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
title: str | None = Field(
|
||||
default=None, title="Title", description="Optional display title."
|
||||
)
|
||||
device: DeviceBase | str = Field(
|
||||
default="", title="Device", description="BEC device selection."
|
||||
)
|
||||
refresh_interval: int = Field(
|
||||
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
|
||||
)
|
||||
|
||||
class SignalOnlyConfig(BaseModel):
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
title: str | None = Field(
|
||||
default=None, title="Title", description="Optional display title."
|
||||
)
|
||||
signal: Signal | str | None = Field(
|
||||
default=None,
|
||||
title="Signal",
|
||||
description="Global BEC signal selection without a device field.",
|
||||
)
|
||||
refresh_interval: int = Field(
|
||||
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
|
||||
)
|
||||
|
||||
class ExampleWindow(QWidget):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setWindowTitle("PydanticWidgetForm example")
|
||||
|
||||
self._tabs = QTabWidget(self)
|
||||
self._output = QTextEdit(self)
|
||||
self._output.setReadOnly(True)
|
||||
self._output.setPlaceholderText("Validated form data appears here.")
|
||||
self._forms: list[PydanticWidgetForm] = []
|
||||
|
||||
self._add_form("Basic", PydanticWidgetForm(BasicScanConfig))
|
||||
self._add_form("Limits", PydanticWidgetForm(LimitConfig))
|
||||
self._add_form("ScanArgument", PydanticWidgetForm(ScanArgumentConfig))
|
||||
self._add_form("Display", PydanticWidgetForm(DisplayConfig))
|
||||
self._add_form("Device + signal", PydanticWidgetForm(DeviceAndSignalConfig))
|
||||
self._add_form("Device limits", PydanticWidgetForm(DeviceSignalLimitsConfig))
|
||||
self._add_form("Device only", PydanticWidgetForm(DeviceOnlyConfig))
|
||||
self._add_form("Signal only", PydanticWidgetForm(SignalOnlyConfig))
|
||||
|
||||
show_data = QPushButton("Show current tab data", self)
|
||||
show_data.clicked.connect(self._show_current_data)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(QLabel("Generated forms from Pydantic models", self))
|
||||
layout.addWidget(self._tabs)
|
||||
layout.addWidget(show_data)
|
||||
layout.addWidget(self._output)
|
||||
|
||||
def _add_form(self, title: str, form: PydanticWidgetForm) -> None:
|
||||
form.changed.connect(lambda _form=form: self._on_form_changed(_form))
|
||||
self._forms.append(form)
|
||||
self._tabs.addTab(form, title)
|
||||
|
||||
def _show_current_data(self, _checked: bool = False, *, validate: bool = True) -> None:
|
||||
form = self._forms[self._tabs.currentIndex()]
|
||||
if validate:
|
||||
try:
|
||||
data = form.get_data()
|
||||
except (ValidationError, ValueError) as exc:
|
||||
self._output.setPlainText(str(exc))
|
||||
return
|
||||
key = "data"
|
||||
else:
|
||||
data = form.raw_data()
|
||||
key = "raw_data"
|
||||
self._output.setPlainText(
|
||||
json.dumps(
|
||||
{key: data, "dirty_fields": sorted(form.dirty_fields())}, indent=2, default=str
|
||||
)
|
||||
)
|
||||
|
||||
def _on_form_changed(self, form: PydanticWidgetForm) -> None:
|
||||
if form is self._forms[self._tabs.currentIndex()]:
|
||||
self._show_current_data(validate=False)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
window = ExampleWindow()
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def elapsed_seconds(start: float | int | None, stop: float) -> float | None:
|
||||
if start is None:
|
||||
return None
|
||||
try:
|
||||
return max(0.0, stop - float(start))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def format_elapsed(elapsed: float | None) -> str:
|
||||
if elapsed is None:
|
||||
return "unknown"
|
||||
return f"{elapsed:.3f}"
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import time
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
@@ -11,13 +12,14 @@ from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.utils.rpc_logging import elapsed_seconds, format_elapsed
|
||||
from bec_widgets.utils.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.screen_utils import apply_window_geometry
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
@@ -115,27 +117,107 @@ class RPCServer:
|
||||
if request_id is None:
|
||||
logger.error("Received RPC instruction without request_id")
|
||||
return
|
||||
method = msg.get("action")
|
||||
parameter = msg.get("parameter", {})
|
||||
args = parameter.get("args", [])
|
||||
kwargs = parameter.get("kwargs", {})
|
||||
target_gui_id = parameter.get("gui_id")
|
||||
sent_at = metadata.get("sent_at")
|
||||
deadline = metadata.get("deadline")
|
||||
timeout = metadata.get("timeout")
|
||||
received_at = time.time()
|
||||
receive_latency = elapsed_seconds(sent_at, received_at)
|
||||
stale_on_receive = deadline is not None and received_at > deadline
|
||||
logger.info(
|
||||
"GUI RPC server received request "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"target_gui_id={target_gui_id} timeout={timeout} "
|
||||
f"receive_latency_s={format_elapsed(receive_latency)} "
|
||||
f"stale_on_receive={stale_on_receive}"
|
||||
)
|
||||
if stale_on_receive:
|
||||
logger.warning(
|
||||
"GUI RPC server received request after client timeout deadline "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"target_gui_id={target_gui_id} timeout={timeout} "
|
||||
f"receive_latency_s={format_elapsed(receive_latency)}"
|
||||
)
|
||||
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
|
||||
|
||||
# Shutdown must acknowledge before teardown starts. The generic RPC path
|
||||
# below publishes successful responses through QTimer.singleShot(0);
|
||||
# for system.shutdown that would race with the queued app quit and
|
||||
# dispatcher shutdown scheduled by _shutdown_gui_server().
|
||||
if method == "system.shutdown":
|
||||
execution_start = time.perf_counter()
|
||||
try:
|
||||
self.run_system_rpc(method, args, kwargs)
|
||||
except Exception:
|
||||
execution_duration = time.perf_counter() - execution_start
|
||||
content = traceback.format_exc()
|
||||
logger.error(
|
||||
"GUI RPC server shutdown request failed "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"execution_duration_s={execution_duration:.3f}\n{content}"
|
||||
)
|
||||
self.send_response(request_id, False, {"error": content})
|
||||
else:
|
||||
execution_duration = time.perf_counter() - execution_start
|
||||
logger.info(
|
||||
"GUI RPC server acknowledged shutdown request "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"execution_duration_s={execution_duration:.3f}"
|
||||
)
|
||||
self.send_response(request_id, True, {"result": None})
|
||||
return
|
||||
|
||||
execution_start = time.perf_counter()
|
||||
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
|
||||
try:
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
if method.startswith("system."):
|
||||
res = self.run_system_rpc(method, args, kwargs)
|
||||
else:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
obj = self.get_object_from_config(parameter)
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception:
|
||||
execution_duration = time.perf_counter() - execution_start
|
||||
content = traceback.format_exc()
|
||||
logger.error(f"Error while executing RPC instruction: {content}")
|
||||
logger.error(
|
||||
"GUI RPC server execution failed "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"target_gui_id={target_gui_id} execution_duration_s={execution_duration:.3f}\n"
|
||||
f"{content}"
|
||||
)
|
||||
self.send_response(request_id, False, {"error": content})
|
||||
else:
|
||||
execution_duration = time.perf_counter() - execution_start
|
||||
response_stale = deadline is not None and time.time() > deadline
|
||||
logger.info(
|
||||
"GUI RPC server executed request "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"target_gui_id={target_gui_id} execution_duration_s={execution_duration:.3f} "
|
||||
f"response_after_client_deadline={response_stale}"
|
||||
)
|
||||
if response_stale:
|
||||
logger.warning(
|
||||
"GUI RPC server response is late for client timeout "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"target_gui_id={target_gui_id} timeout={timeout} "
|
||||
f"execution_duration_s={execution_duration:.3f}"
|
||||
)
|
||||
logger.debug(f"RPC instruction executed successfully: {res}")
|
||||
self._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat()
|
||||
QTimer.singleShot(0, lambda: self.serialize_result_and_send(request_id, res))
|
||||
|
||||
def send_response(self, request_id: str, accepted: bool, msg: dict):
|
||||
log_message = (
|
||||
"GUI RPC server publishing response "
|
||||
f"request_id={request_id} gui_id={self.gui_id} accepted={accepted}"
|
||||
)
|
||||
if accepted:
|
||||
logger.info(log_message)
|
||||
else:
|
||||
logger.error(log_message)
|
||||
self.client.connector.set_and_publish(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
messages.RequestResponseMessage(accepted=accepted, message=msg),
|
||||
@@ -236,10 +318,23 @@ class RPCServer:
|
||||
def run_system_rpc(self, method: str, args: list, kwargs: dict):
|
||||
if method == "system.launch_dock_area":
|
||||
return self._launch_dock_area(*args, **kwargs)
|
||||
if method == "system.shutdown":
|
||||
return self._shutdown_gui_server()
|
||||
if method == "system.list_capabilities":
|
||||
return {"system.launch_dock_area": True}
|
||||
return {"system.launch_dock_area": True, "system.shutdown": True}
|
||||
raise ValueError(f"Unknown system RPC method: {method}")
|
||||
|
||||
@staticmethod
|
||||
def _shutdown_gui_server() -> None:
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
gui_server = getattr(app, "gui_server", None)
|
||||
if gui_server is not None and hasattr(gui_server, "request_shutdown"):
|
||||
QTimer.singleShot(0, gui_server.request_shutdown)
|
||||
return
|
||||
QTimer.singleShot(0, app.quit)
|
||||
|
||||
@staticmethod
|
||||
def _launch_dock_area(
|
||||
name: str | None = None,
|
||||
@@ -297,7 +392,14 @@ class RPCServer:
|
||||
res = self.serialize_object(res)
|
||||
except RegistryNotReadyError:
|
||||
try:
|
||||
self._rpc_singleshot_repeats[request_id] += retry_delay
|
||||
repeat = self._rpc_singleshot_repeats[request_id]
|
||||
repeat += retry_delay
|
||||
logger.warning(
|
||||
"GUI RPC result serialization delayed; retrying "
|
||||
f"request_id={request_id} retry_delay_ms={retry_delay} "
|
||||
f"accumulated_delay_ms={repeat.accumulated_delay} "
|
||||
f"max_delay_ms={repeat.max_delay}"
|
||||
)
|
||||
QTimer.singleShot(
|
||||
retry_delay, lambda: self.serialize_result_and_send(request_id, res)
|
||||
)
|
||||
@@ -407,8 +509,9 @@ class RPCServer:
|
||||
container_proxy = parent.gui_id
|
||||
else:
|
||||
container_proxy = None
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
container_proxy = None
|
||||
logger.error(f"Error while serializing RPC result: {e}")
|
||||
|
||||
if wait and not self.rpc_register.object_is_registered(connector):
|
||||
raise RegistryNotReadyError(f"Connector {connector} not registered yet")
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from qtpy.QtWidgets import QDoubleSpinBox, QSpinBox, QWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
UNIT_TOOLTIP_PREFIXES = ("Units:", "Units from:")
|
||||
|
||||
|
||||
def format_display_name(name: str) -> str:
|
||||
"""Convert a raw argument name into a user-facing label."""
|
||||
parts = re.split(r"(_|\d+)", name)
|
||||
return " ".join(part.capitalize() for part in parts if part.isalnum()).strip()
|
||||
|
||||
|
||||
def resolve_tooltip(scan_argument: Mapping[str, Any]) -> str | None:
|
||||
"""Resolve explicit tooltip text, falling back to the description."""
|
||||
return scan_argument.get("tooltip") or scan_argument.get("description")
|
||||
|
||||
|
||||
def ui_config_from_metadata(
|
||||
name: str,
|
||||
metadata: Mapping[str, Any],
|
||||
*,
|
||||
default: Any = None,
|
||||
input_type: Any = None,
|
||||
arg: bool = False,
|
||||
display_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the normalized scan-input item consumed by form widgets."""
|
||||
return {
|
||||
"arg": arg,
|
||||
"name": name,
|
||||
"type": input_type,
|
||||
"display_name": display_name or metadata.get("display_name") or format_display_name(name),
|
||||
"tooltip": resolve_tooltip(metadata),
|
||||
"default": default,
|
||||
"expert": metadata.get("expert", False),
|
||||
"hidden": metadata.get("hidden", False),
|
||||
"precision": metadata.get("precision"),
|
||||
"units": metadata.get("units"),
|
||||
"reference_units": metadata.get("reference_units"),
|
||||
"reference_limits": metadata.get("reference_limits"),
|
||||
"gt": metadata.get("gt"),
|
||||
"ge": metadata.get("ge"),
|
||||
"lt": metadata.get("lt"),
|
||||
"le": metadata.get("le"),
|
||||
"alternative_group": metadata.get("alternative_group"),
|
||||
}
|
||||
|
||||
|
||||
def unit_tooltip(item: Mapping[str, Any], units: str | None = None) -> str | None:
|
||||
"""Build tooltip text from scan argument unit metadata."""
|
||||
tooltip = item.get("tooltip")
|
||||
reference_units = item.get("reference_units")
|
||||
units = units or item.get("units")
|
||||
|
||||
tooltip_parts = [tooltip] if tooltip else []
|
||||
if units:
|
||||
tooltip_parts.append(f"Units: {units}")
|
||||
elif reference_units:
|
||||
tooltip_parts.append(f"Units from: {reference_units}")
|
||||
if tooltip_parts:
|
||||
return "\n".join(str(part) for part in tooltip_parts)
|
||||
return None
|
||||
|
||||
|
||||
def strip_unit_tooltip(tooltip: str) -> str:
|
||||
"""Remove unit lines added by :func:`apply_unit_metadata`."""
|
||||
return "\n".join(
|
||||
line for line in tooltip.splitlines() if not line.startswith(UNIT_TOOLTIP_PREFIXES)
|
||||
).strip()
|
||||
|
||||
|
||||
def apply_unit_metadata(widget: QWidget, item: Mapping[str, Any], units: str | None = None) -> None:
|
||||
"""Apply unit tooltip text and numeric suffix metadata to a widget."""
|
||||
units = units or item.get("units")
|
||||
tooltip = unit_tooltip(item, units)
|
||||
existing_tooltip = strip_unit_tooltip(widget.toolTip())
|
||||
base_tooltip = item.get("tooltip")
|
||||
if base_tooltip and existing_tooltip == base_tooltip:
|
||||
existing_tooltip = ""
|
||||
|
||||
if tooltip:
|
||||
widget.setToolTip(f"{existing_tooltip}\n{tooltip}" if existing_tooltip else tooltip)
|
||||
else:
|
||||
widget.setToolTip(existing_tooltip)
|
||||
|
||||
if hasattr(widget, "setSuffix"):
|
||||
widget.setSuffix(f" {units}" if units else "")
|
||||
|
||||
|
||||
def device_units(device: object) -> str | None:
|
||||
"""Return engineering units from a BEC device object when available."""
|
||||
egu = getattr(device, "egu", None)
|
||||
if not callable(egu):
|
||||
return None
|
||||
try:
|
||||
return egu()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch engineering units from device %s", device)
|
||||
return None
|
||||
|
||||
|
||||
def apply_numeric_precision(widget: QWidget, item: Mapping[str, Any]) -> None:
|
||||
"""Apply decimal precision metadata to spinboxes supporting ``setDecimals``."""
|
||||
if not hasattr(widget, "setDecimals"):
|
||||
return
|
||||
|
||||
precision = item.get("precision")
|
||||
if precision is None:
|
||||
return
|
||||
|
||||
try:
|
||||
widget.setDecimals(max(0, int(precision)))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Ignoring invalid precision %r for parameter %s", precision, item.get("name")
|
||||
)
|
||||
|
||||
|
||||
def apply_numeric_limits(widget: QWidget, item: Mapping[str, Any]) -> None:
|
||||
"""Apply ``gt/ge/lt/le`` numeric bounds to Qt spinboxes."""
|
||||
if isinstance(widget, QSpinBox) and not isinstance(widget, QDoubleSpinBox):
|
||||
minimum = -2147483647
|
||||
maximum = 2147483647
|
||||
if item.get("ge") is not None:
|
||||
minimum = int(item["ge"])
|
||||
if item.get("gt") is not None:
|
||||
minimum = int(item["gt"]) + 1
|
||||
if item.get("le") is not None:
|
||||
maximum = int(item["le"])
|
||||
if item.get("lt") is not None:
|
||||
maximum = int(item["lt"]) - 1
|
||||
widget.setRange(minimum, maximum)
|
||||
return
|
||||
|
||||
if isinstance(widget, QDoubleSpinBox):
|
||||
minimum = -float("inf")
|
||||
maximum = float("inf")
|
||||
step = 10 ** (-widget.decimals())
|
||||
if item.get("ge") is not None:
|
||||
minimum = float(item["ge"])
|
||||
if item.get("gt") is not None:
|
||||
minimum = float(item["gt"]) + step
|
||||
if item.get("le") is not None:
|
||||
maximum = float(item["le"])
|
||||
if item.get("lt") is not None:
|
||||
maximum = float(item["lt"]) - step
|
||||
widget.setRange(minimum, maximum)
|
||||
@@ -0,0 +1,63 @@
|
||||
from pathlib import Path
|
||||
|
||||
from qtpy.QtCore import QUrl
|
||||
from qtpy.QtMultimedia import QAudioOutput, QMediaPlayer
|
||||
from qtpy.QtWidgets import QApplication, QComboBox, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class SoundPlayerWidget(QWidget):
|
||||
"""Simple widget to preview bundled sound assets."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.setWindowTitle("Sound Player")
|
||||
|
||||
self._sounds_dir = Path(__file__).resolve().parent.parent / "assets" / "sounds"
|
||||
self._player = QMediaPlayer(self)
|
||||
self._audio_output = QAudioOutput(self)
|
||||
self._player.setAudioOutput(self._audio_output)
|
||||
|
||||
self.sound_combo_box = QComboBox(self)
|
||||
self.play_button = QPushButton("Play", self)
|
||||
|
||||
self._populate_sounds()
|
||||
self.play_button.clicked.connect(self.play_selected_sound)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.sound_combo_box)
|
||||
layout.addWidget(self.play_button)
|
||||
|
||||
self.resize(420, 100)
|
||||
|
||||
def _populate_sounds(self) -> None:
|
||||
"""Load bundled sound assets into the combo box."""
|
||||
sound_files = sorted(self._sounds_dir.glob("*.mp3"))
|
||||
for sound_file in sound_files:
|
||||
self.sound_combo_box.addItem(sound_file.stem, str(sound_file))
|
||||
|
||||
self.play_button.setEnabled(bool(sound_files))
|
||||
if not sound_files:
|
||||
self.sound_combo_box.addItem("No sounds found")
|
||||
|
||||
def play_selected_sound(self) -> None:
|
||||
"""Play the currently selected sound asset."""
|
||||
sound_path = self.sound_combo_box.currentData()
|
||||
if not sound_path:
|
||||
return
|
||||
|
||||
self._player.setSource(QUrl.fromLocalFile(sound_path))
|
||||
self._player.stop()
|
||||
self._player.play()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
|
||||
widget = SoundPlayerWidget()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -27,8 +27,10 @@ from qtpy.QtWidgets import (
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.toolbars.splitter import ResizableSpacer
|
||||
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.control.device_input.device_combobox.device_combobox import (
|
||||
BECDeviceFilter,
|
||||
DeviceComboBox,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@@ -85,7 +85,11 @@ class ComboBoxHandler(WidgetHandler):
|
||||
|
||||
def set_value(self, widget: QComboBox, value: int | str) -> None:
|
||||
if isinstance(value, str):
|
||||
value = widget.findText(value)
|
||||
index = widget.findText(value)
|
||||
if index < 0 and widget.isEditable():
|
||||
widget.setCurrentText(value)
|
||||
return
|
||||
value = index
|
||||
if isinstance(value, int):
|
||||
widget.setCurrentIndex(value)
|
||||
|
||||
@@ -95,6 +99,45 @@ class ComboBoxHandler(WidgetHandler):
|
||||
widget.currentIndexChanged.connect(lambda idx, w=widget: slot(w, self.get_value(w)))
|
||||
|
||||
|
||||
class DeviceComboBoxHandler(ComboBoxHandler):
|
||||
"""Handler for BEC device comboboxes. The widget value is the device name."""
|
||||
|
||||
def get_value(self, widget, **kwargs) -> str:
|
||||
return widget.currentText().strip()
|
||||
|
||||
def set_value(self, widget, value: str | None) -> None:
|
||||
device = "" if value is None else str(value)
|
||||
if not device:
|
||||
widget.setCurrentText("")
|
||||
return
|
||||
widget.set_device(device)
|
||||
if widget.currentText() != device:
|
||||
widget.setCurrentText(device)
|
||||
|
||||
def connect_change_signal(self, widget, slot):
|
||||
widget.currentTextChanged.connect(lambda text, w=widget: slot(w, text.strip()))
|
||||
|
||||
|
||||
class SignalComboBoxHandler(ComboBoxHandler):
|
||||
"""Handler for BEC signal comboboxes. The widget value is the signal object name."""
|
||||
|
||||
def get_value(self, widget, **kwargs) -> str | None:
|
||||
signal = widget.get_signal_name().strip()
|
||||
return signal or None
|
||||
|
||||
def set_value(self, widget, value: str | None) -> None:
|
||||
signal = "" if value is None else str(value)
|
||||
if not signal:
|
||||
widget.setCurrentText("")
|
||||
return
|
||||
widget.set_signal(signal)
|
||||
if widget.currentText() != signal and widget.get_signal_name() != signal:
|
||||
widget.setCurrentText(signal)
|
||||
|
||||
def connect_change_signal(self, widget, slot):
|
||||
widget.currentTextChanged.connect(lambda _text, w=widget: slot(w, self.get_value(w)))
|
||||
|
||||
|
||||
class TableWidgetHandler(WidgetHandler):
|
||||
"""Handler for QTableWidget widgets."""
|
||||
|
||||
@@ -203,6 +246,28 @@ class WidgetIO:
|
||||
ToggleSwitch: ToggleSwitchHandler,
|
||||
QSlider: SlideHandler,
|
||||
}
|
||||
_deferred_handlers_registered = False
|
||||
|
||||
@classmethod
|
||||
def _register_deferred_handlers(cls) -> None:
|
||||
"""
|
||||
Register handlers for widgets that import this module themselves and therefore
|
||||
cannot be imported here at module level without a circular import. The import is
|
||||
deferred to the first handler lookup, when all modules are fully initialized.
|
||||
"""
|
||||
if cls._deferred_handlers_registered:
|
||||
return
|
||||
cls._deferred_handlers_registered = True
|
||||
# pylint: disable=import-outside-toplevel
|
||||
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,
|
||||
)
|
||||
|
||||
cls._handlers[DeviceComboBox] = DeviceComboBoxHandler
|
||||
cls._handlers[SignalComboBox] = SignalComboBoxHandler
|
||||
|
||||
@staticmethod
|
||||
def get_value(widget, ignore_errors=False, **kwargs):
|
||||
@@ -278,6 +343,7 @@ class WidgetIO:
|
||||
Returns:
|
||||
handler_class: The handler class if found, otherwise None.
|
||||
"""
|
||||
WidgetIO._register_deferred_handlers()
|
||||
for base in type(widget).__mro__:
|
||||
if base in WidgetIO._handlers:
|
||||
return WidgetIO._handlers[base]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Literal, Mapping, Sequence
|
||||
|
||||
import slugify
|
||||
@@ -20,7 +21,12 @@ from qtpy.QtWidgets import (
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
from bec_widgets.cli.designer_plugins import widget_icons
|
||||
from bec_widgets.utils.bec_plugin_helper import (
|
||||
get_plugin_rpc_widget_registry,
|
||||
get_plugin_widget_icons,
|
||||
)
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.plugin_utils import get_rpc_widget_registry
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
from bec_widgets.utils.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
@@ -74,6 +80,19 @@ PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_
|
||||
StartupProfile = Literal["restore", "skip"] | str | None
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _plugin_toolbar_actions() -> dict[str, tuple[str, str, str]]:
|
||||
plugin_registry = get_plugin_rpc_widget_registry()
|
||||
internal_registry = get_rpc_widget_registry()
|
||||
plugin_icons = get_plugin_widget_icons()
|
||||
|
||||
return {
|
||||
widget_name: (plugin_icons.get(widget_name, "widgets"), f"Add {widget_name}", widget_name)
|
||||
for widget_name in sorted(plugin_registry)
|
||||
if widget_name not in internal_registry
|
||||
}
|
||||
|
||||
|
||||
class BECDockArea(DockAreaWidget):
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
@@ -366,6 +385,11 @@ class BECDockArea(DockAreaWidget):
|
||||
"bec_shell": (widget_icons["BECShell"], "Add BEC Shell", "BECShell"),
|
||||
"sbb_monitor": (widget_icons["SBBMonitor"], "Add SBB Monitor", "SBBMonitor"),
|
||||
"log_panel": (widget_icons["LogPanel"], "Add LogPanel", "LogPanel"),
|
||||
"beamline_state_manager": (
|
||||
widget_icons["BeamlineStateManager"],
|
||||
"Add Beamline State Manager",
|
||||
"BeamlineStateManager",
|
||||
),
|
||||
}
|
||||
|
||||
# Create expandable menu actions (original behavior)
|
||||
@@ -390,6 +414,10 @@ class BECDockArea(DockAreaWidget):
|
||||
_build_menu("menu_devices", "Add Device Control ", device_actions)
|
||||
_build_menu("menu_utils", "Add Utils ", util_actions)
|
||||
|
||||
plugin_actions = _plugin_toolbar_actions()
|
||||
if plugin_actions:
|
||||
_build_menu("menu_plugins", "Add Plugins ", plugin_actions)
|
||||
|
||||
# Create flat toolbar bundles for each widget type
|
||||
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
|
||||
bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components)
|
||||
@@ -460,14 +488,16 @@ class BECDockArea(DockAreaWidget):
|
||||
bda.add_action("dark_mode")
|
||||
self.toolbar.add_bundle(bda)
|
||||
|
||||
self._apply_toolbar_layout()
|
||||
|
||||
# Store mappings on self for use in _hook_toolbar
|
||||
# Store mappings on self for use in _hook_toolbar and _apply_toolbar_layout
|
||||
self._ACTION_MAPPINGS = {
|
||||
"menu_plots": plot_actions,
|
||||
"menu_devices": device_actions,
|
||||
"menu_utils": util_actions,
|
||||
}
|
||||
if plugin_actions:
|
||||
self._ACTION_MAPPINGS["menu_plugins"] = plugin_actions
|
||||
|
||||
self._apply_toolbar_layout()
|
||||
|
||||
def _hook_toolbar(self):
|
||||
def _connect_menu(menu_key: str):
|
||||
@@ -476,7 +506,8 @@ class BECDockArea(DockAreaWidget):
|
||||
|
||||
# first two items not needed for this part
|
||||
for key, (_, _, widget_type) in mapping.items():
|
||||
act = menu.actions[key].action
|
||||
toolbar_action = menu.actions[key]
|
||||
act = toolbar_action.action
|
||||
if key == "terminal":
|
||||
act.triggered.connect(
|
||||
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
|
||||
@@ -487,12 +518,18 @@ class BECDockArea(DockAreaWidget):
|
||||
widget=t, closable=True, show_settings_action=False
|
||||
)
|
||||
)
|
||||
elif menu_key == "menu_plugins":
|
||||
act.triggered.connect(
|
||||
lambda _, t=widget_type, a=toolbar_action: self._new_plugin_widget(t, a)
|
||||
)
|
||||
else:
|
||||
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
|
||||
|
||||
_connect_menu("menu_plots")
|
||||
_connect_menu("menu_devices")
|
||||
_connect_menu("menu_utils")
|
||||
if "menu_plugins" in self._ACTION_MAPPINGS:
|
||||
_connect_menu("menu_plugins")
|
||||
|
||||
def _connect_flat_actions(mapping: dict[str, tuple[str, str, str]]):
|
||||
for action_id, (_, _, widget_type) in mapping.items():
|
||||
@@ -507,6 +544,10 @@ class BECDockArea(DockAreaWidget):
|
||||
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
|
||||
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
|
||||
|
||||
def _new_plugin_widget(self, widget_type: str, toolbar_action: MaterialIconAction) -> None:
|
||||
# Created as helper method for simple tests
|
||||
self.new(widget=widget_type, dock_icon=toolbar_action.get_icon())
|
||||
|
||||
def _set_editable(self, editable: bool) -> None:
|
||||
self.workspace_is_locked = not editable
|
||||
self._editable = editable
|
||||
@@ -1108,14 +1149,10 @@ class BECDockArea(DockAreaWidget):
|
||||
if mode_key == "user":
|
||||
bundles = ["spacer_bundle", "workspace", "dock_actions"]
|
||||
elif mode_key == "creator":
|
||||
bundles = [
|
||||
"menu_plots",
|
||||
"menu_devices",
|
||||
"menu_utils",
|
||||
"spacer_bundle",
|
||||
"workspace",
|
||||
"dock_actions",
|
||||
]
|
||||
bundles = ["menu_plots", "menu_devices", "menu_utils"]
|
||||
if "menu_plugins" in getattr(self, "_ACTION_MAPPINGS", {}):
|
||||
bundles.append("menu_plugins")
|
||||
bundles += ["spacer_bundle", "workspace", "dock_actions"]
|
||||
elif mode_key == "plot":
|
||||
bundles = ["flat_plots", "spacer_bundle", "workspace", "dock_actions"]
|
||||
elif mode_key == "device":
|
||||
|
||||
+47
-34
@@ -11,7 +11,6 @@ Intended for use in desktop applications to provide user feedback, warnings, and
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
@@ -21,6 +20,7 @@ from uuid import uuid4
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.alarm_handler import Alarms # external enum
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ErrorInfo
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
@@ -29,9 +29,11 @@ from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidg
|
||||
|
||||
from bec_widgets import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.colors import apply_theme, get_theme_name
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class SeverityKind(str, Enum):
|
||||
INFO = "info"
|
||||
@@ -148,11 +150,14 @@ class NotificationToast(QFrame):
|
||||
body_lbl.setWordWrap(True)
|
||||
|
||||
self.time_lbl = QtWidgets.QLabel()
|
||||
self._showing_absolute = False
|
||||
self._update_relative_time()
|
||||
# enable absolute timestamp on hover
|
||||
self.time_lbl.setCursor(QtCore.Qt.PointingHandCursor)
|
||||
self.time_lbl.installEventFilter(self)
|
||||
self._showing_absolute = False
|
||||
|
||||
# shared ID assigned by NotificationCentre.add_notification
|
||||
self.notification_id: str | None = None
|
||||
|
||||
self.close_btn = QtWidgets.QPushButton("✕")
|
||||
self.close_btn.setObjectName("toastCloseBtn")
|
||||
@@ -245,21 +250,22 @@ class NotificationToast(QFrame):
|
||||
# lifetime progress animation
|
||||
self._lifetime = max(0, lifetime_ms) # 0 → never expire
|
||||
self._progress_anim: QtCore.QPropertyAnimation | None = None
|
||||
# flag to indicate this toast has fully expired (progress bar finished)
|
||||
self._expired = False
|
||||
|
||||
if self._lifetime > 0:
|
||||
self._start_progress_animation()
|
||||
else:
|
||||
self.progress.hide()
|
||||
|
||||
# flag to indicate this toast has fully expired (progress bar finished)
|
||||
self._expired = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect this toast to the global theme‑updated signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self.apply_theme)
|
||||
if hasattr(qapp, "theme"):
|
||||
qapp.theme.theme_changed.connect(self.apply_theme)
|
||||
else:
|
||||
logger.warning("Theme could not be fetched form QApplication object.")
|
||||
|
||||
# helper methods -----------------------------------------------------
|
||||
def _current_inner_width(self) -> int:
|
||||
@@ -333,9 +339,6 @@ class NotificationToast(QFrame):
|
||||
}}
|
||||
""")
|
||||
self.apply_theme(self._theme)
|
||||
# keep injected gradient in sync
|
||||
if getattr(self, "_hg_enabled", False):
|
||||
self._hg_cols[0] = self._accent_color
|
||||
|
||||
@SafeProperty(str)
|
||||
def traceback(self):
|
||||
@@ -354,11 +357,9 @@ class NotificationToast(QFrame):
|
||||
Args:
|
||||
theme(str | None): "light" or "dark". If None, auto-detects from QApplication.
|
||||
"""
|
||||
# determine effective theme
|
||||
if theme is None:
|
||||
app = QApplication.instance()
|
||||
theme = getattr(getattr(app, "theme", None), "theme", "dark")
|
||||
theme = theme.lower()
|
||||
theme = str(theme or get_theme_name()).lower()
|
||||
if theme not in {"light", "dark"}:
|
||||
theme = "dark"
|
||||
self._theme = theme
|
||||
palette = DARK_PALETTE if theme == "dark" else LIGHT_PALETTE
|
||||
|
||||
@@ -403,11 +404,18 @@ class NotificationToast(QFrame):
|
||||
#NotificationToast QPushButton:hover {{ color: {btn_hover}; }}
|
||||
""")
|
||||
# traceback panel colours
|
||||
trace_bg = "#1e1e1e" if theme == "dark" else "#f0f0f0"
|
||||
if theme == "dark": # FIXME Unify stylesheets and move them to BECQThemes issue #1189
|
||||
trace_bg = "#1e1e1e"
|
||||
trace_fg = palette["body"]
|
||||
trace_border = "rgba(255,255,255,48)"
|
||||
else:
|
||||
trace_bg = "#ffffff"
|
||||
trace_fg = palette["body"]
|
||||
trace_border = "rgba(15,23,42,54)"
|
||||
self.trace_view.setStyleSheet(f"""
|
||||
background:{trace_bg};
|
||||
color:{palette['body']};
|
||||
border:none;
|
||||
color:{trace_fg};
|
||||
border: 1px solid {trace_border};
|
||||
border-radius:8px;
|
||||
""")
|
||||
|
||||
@@ -438,8 +446,8 @@ class NotificationToast(QFrame):
|
||||
}}
|
||||
""")
|
||||
|
||||
# stronger accent wash in light mode, slightly stronger in dark too
|
||||
self._accent_alpha = 110 if theme == "light" else 60
|
||||
self._accent_alpha = 6 if theme == "light" else 60
|
||||
self._gradient_width_factor = 1.0 if theme == "light" else 0.70
|
||||
self.update()
|
||||
|
||||
########################################
|
||||
@@ -447,7 +455,7 @@ class NotificationToast(QFrame):
|
||||
########################################
|
||||
|
||||
def _update_relative_time(self) -> None:
|
||||
if getattr(self, "_showing_absolute", False):
|
||||
if self._showing_absolute:
|
||||
return # don't overwrite while user is viewing absolute time
|
||||
seconds = int((datetime.now() - self.created).total_seconds())
|
||||
if seconds < 10:
|
||||
@@ -471,6 +479,8 @@ class NotificationToast(QFrame):
|
||||
# Event Filters
|
||||
########################################
|
||||
def eventFilter(self, watched, event):
|
||||
if not isinstance(event, QtCore.QEvent):
|
||||
return False
|
||||
# timestamp label → toggle absolute time
|
||||
if watched is self.time_lbl:
|
||||
if event.type() == QtCore.QEvent.Enter and not self._showing_absolute:
|
||||
@@ -486,7 +496,7 @@ class NotificationToast(QFrame):
|
||||
Pause the countdown while the cursor is over the toast, and reset the
|
||||
elapsed time and progress bar to full width.
|
||||
"""
|
||||
if getattr(self, "_expired", False):
|
||||
if self._expired:
|
||||
return super().enterEvent(event)
|
||||
self._hover = True
|
||||
if self._progress_anim is not None:
|
||||
@@ -500,10 +510,10 @@ class NotificationToast(QFrame):
|
||||
Resume the countdown when the cursor leaves, continuing from the
|
||||
paused progress rather than restarting.
|
||||
"""
|
||||
if getattr(self, "_expired", False):
|
||||
if self._expired:
|
||||
return super().leaveEvent(event)
|
||||
self._hover = False
|
||||
if self._lifetime > 0 and not self._expired:
|
||||
if self._lifetime > 0:
|
||||
self._start_progress_animation()
|
||||
super().leaveEvent(event)
|
||||
|
||||
@@ -519,11 +529,11 @@ class NotificationToast(QFrame):
|
||||
painter.fillPath(path, self._base_color)
|
||||
|
||||
# accent gradient, fades to transparent
|
||||
grad = QtGui.QLinearGradient(0, 0, self.width() * 0.7, 0)
|
||||
grad = QtGui.QLinearGradient(0, 0, self.width() * self._gradient_width_factor, 0)
|
||||
accent = QtGui.QColor(self._accent_color)
|
||||
if getattr(self, "_theme", "dark") == "light":
|
||||
if self._theme == "light":
|
||||
accent = accent.darker(115)
|
||||
accent.setAlpha(getattr(self, "_accent_alpha", 50))
|
||||
accent.setAlpha(self._accent_alpha)
|
||||
grad.setColorAt(0.0, accent)
|
||||
fade = QtGui.QColor(self._accent_color)
|
||||
fade.setAlpha(0)
|
||||
@@ -543,7 +553,7 @@ class NotificationToast(QFrame):
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed.emit()
|
||||
QtWidgets.QApplication.instance().removeEventFilter(self)
|
||||
self.time_lbl.removeEventFilter(self)
|
||||
super().close() # this will remove the widget from its parent
|
||||
|
||||
|
||||
@@ -577,8 +587,7 @@ class NotificationCentre(QScrollArea):
|
||||
def __init__(self, parent=None, *, fixed_width: int = 420, margin: int = 16):
|
||||
super().__init__(parent=parent)
|
||||
self.setObjectName("NotificationCentre")
|
||||
app = QApplication.instance()
|
||||
self._theme = getattr(getattr(app, "theme", None), "theme", "dark").lower()
|
||||
self._theme = get_theme_name()
|
||||
|
||||
self.setWidgetResizable(True)
|
||||
# transparent background so only the toast cards are visible
|
||||
@@ -673,8 +682,10 @@ class NotificationCentre(QScrollArea):
|
||||
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.apply_theme)
|
||||
if hasattr(qapp, "theme"):
|
||||
qapp.theme.theme_changed.connect(self.apply_theme)
|
||||
else:
|
||||
logger.warning("Theme could not be fetched form QApplication object.")
|
||||
|
||||
# public API
|
||||
def add_notification(
|
||||
@@ -741,7 +752,7 @@ class NotificationCentre(QScrollArea):
|
||||
def remove_notification(self, notification_id: str) -> None:
|
||||
"""Close a specific notification in this centre if present."""
|
||||
for toast in list(self.toasts):
|
||||
if getattr(toast, "notification_id", None) == notification_id:
|
||||
if toast.notification_id == notification_id:
|
||||
self._hide_notification(toast)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -888,6 +899,8 @@ class NotificationCentre(QScrollArea):
|
||||
self.setFixedHeight(min(content_h, avail))
|
||||
|
||||
def eventFilter(self, watched, event):
|
||||
if not isinstance(event, QtCore.QEvent):
|
||||
return False
|
||||
if watched is self.parent() and event.type() == QtCore.QEvent.Resize:
|
||||
self._adjust_height()
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
|
||||
from qtpy.QtCore import QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -46,8 +46,8 @@ logger = bec_logger.logger
|
||||
class BECMainWindow(BECWidget, QMainWindow):
|
||||
RPC = True
|
||||
PLUGIN = True
|
||||
SCAN_PROGRESS_WIDTH = 100 # px
|
||||
SCAN_PROGRESS_HEIGHT = 12 # px
|
||||
SCAN_PROGRESS_WIDTH = 120 # px
|
||||
SCAN_PROGRESS_HEIGHT = 20 # px
|
||||
|
||||
def __init__(self, parent=None, window_title: str = "BEC", **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
@@ -197,7 +197,11 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
|
||||
# Setting HoverWidget for the scan progress bar - minimal and full version
|
||||
self._scan_progress_bar_simple = ScanProgressBar(
|
||||
self, one_line_design=True, rpc_exposed=False, rpc_passthrough_children=False
|
||||
self,
|
||||
one_line_design=True,
|
||||
rpc_exposed=False,
|
||||
rpc_passthrough_children=False,
|
||||
enable_dynamic_stylesheet=True,
|
||||
)
|
||||
self._scan_progress_bar_simple.show_elapsed_time = False
|
||||
self._scan_progress_bar_simple.show_remaining_time = False
|
||||
@@ -205,8 +209,9 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self._scan_progress_bar_simple.progressbar.label_template = ""
|
||||
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
|
||||
# This one do not need dynamic styling on hover ScanProgressBar since user will hover on it probably later, when progress bar is big enough
|
||||
self._scan_progress_bar_full = ScanProgressBar(
|
||||
self, rpc_exposed=False, rpc_passthrough_children=False
|
||||
self, rpc_exposed=False, rpc_passthrough_children=False, enable_dynamic_stylesheet=False
|
||||
)
|
||||
self._scan_progress_hover = HoverWidget(
|
||||
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
|
||||
@@ -233,8 +238,8 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
|
||||
# The actual line
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.VLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
line.setFrameShape(QFrame.Shape.VLine)
|
||||
line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
line.setFixedHeight(status_bar.sizeHint().height() - 2)
|
||||
|
||||
# Wrapper to center the line vertically -> work around for QFrame not being able to center itself
|
||||
@@ -242,7 +247,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
vbox = QVBoxLayout(wrapper)
|
||||
vbox.setContentsMargins(0, 0, 0, 0)
|
||||
vbox.addStretch()
|
||||
vbox.addWidget(line, alignment=Qt.AlignHCenter)
|
||||
vbox.addWidget(line, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
vbox.addStretch()
|
||||
wrapper.setFixedWidth(line.sizeHint().width())
|
||||
|
||||
@@ -412,11 +417,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
"""
|
||||
apply_theme(theme) # emits theme_updated and applies palette globally
|
||||
|
||||
def event(self, event):
|
||||
if event.type() == QEvent.Type.StatusTip:
|
||||
return True
|
||||
return super().event(event)
|
||||
|
||||
def _show_widget_hierarchy_dialog(self):
|
||||
if self._widget_hierarchy_dialog is None:
|
||||
dialog = WidgetHierarchyDialog(root_widget=None, parent=self)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
|
||||
@@ -5,6 +8,8 @@ from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class AbortButton(BECWidget, QWidget):
|
||||
"""A button that abort the scan."""
|
||||
@@ -55,7 +60,7 @@ class AbortButton(BECWidget, QWidget):
|
||||
scan_id(str|None): The scan id to abort. If None, the current scan will be aborted.
|
||||
"""
|
||||
if self.scan_id is not None:
|
||||
print(f"Aborting scan with scan_id: {self.scan_id}")
|
||||
logger.info(f"Aborting scan with scan_id: {self.scan_id}")
|
||||
self.queue.request_scan_abortion(scan_id=self.scan_id)
|
||||
else:
|
||||
self.queue.request_scan_abortion()
|
||||
|
||||
+1
-1
@@ -429,7 +429,7 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
|
||||
@SafeSlot()
|
||||
def on_stop(self):
|
||||
self._stop_device(f"{self.device_hor} or {self.device_ver}")
|
||||
self._stop_device([self.device_hor, self.device_ver])
|
||||
|
||||
@SafeProperty(float)
|
||||
def step_size_hor(self):
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import uuid
|
||||
from abc import abstractmethod
|
||||
from typing import Callable, TypedDict
|
||||
from typing import Callable, Sequence, TypedDict
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanQueueMessage
|
||||
from bec_lib.messages import VariableMessage
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDoubleSpinBox,
|
||||
@@ -21,9 +20,9 @@ from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
|
||||
PositionIndicator,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||
BECDeviceFilter,
|
||||
DeviceComboBox,
|
||||
)
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
@@ -116,17 +115,16 @@ class PositionerBoxBase(BECWidget, QWidget):
|
||||
else:
|
||||
ui["units"].setVisible(False)
|
||||
|
||||
def _stop_device(self, device: str):
|
||||
def _stop_device(self, device: str | Sequence[str]):
|
||||
"""Stop call"""
|
||||
request_id = str(uuid.uuid4())
|
||||
params = {"device": device, "rpc_id": request_id, "func": "stop", "args": [], "kwargs": {}}
|
||||
msg = ScanQueueMessage(
|
||||
scan_type="device_rpc",
|
||||
parameter=params,
|
||||
queue="emergency",
|
||||
metadata={"RID": request_id, "response": False},
|
||||
)
|
||||
self.client.connector.send(MessageEndpoints.scan_queue_request(self.client.username), msg)
|
||||
devices = [device] if isinstance(device, str) else list(device)
|
||||
devices = [dev for dev in devices if dev]
|
||||
if not devices:
|
||||
logger.warning("Stop requested without a valid device.")
|
||||
return
|
||||
|
||||
msg = VariableMessage(value=devices)
|
||||
self.client.connector.send(MessageEndpoints.stop_devices(), msg)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _on_device_readback(
|
||||
@@ -257,10 +255,10 @@ class PositionerBoxBase(BECWidget, QWidget):
|
||||
self._dialog = QDialog(self)
|
||||
self._dialog.setWindowTitle("Positioner Selection")
|
||||
layout = QVBoxLayout()
|
||||
line_edit = DeviceLineEdit(
|
||||
line_edit = DeviceComboBox(
|
||||
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
|
||||
)
|
||||
line_edit.textChanged.connect(set_positioner)
|
||||
line_edit.currentTextChanged.connect(set_positioner)
|
||||
layout.addWidget(line_edit)
|
||||
close_button = QPushButton("Close")
|
||||
close_button.clicked.connect(self._dialog.accept)
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
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 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.utils.filter_io import FilterIO
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECDeviceFilter(enum.Enum):
|
||||
"""Filter for the device classes."""
|
||||
|
||||
DEVICE = "Device"
|
||||
POSITIONER = "Positioner"
|
||||
SIGNAL = "Signal"
|
||||
COMPUTED_SIGNAL = "ComputedSignal"
|
||||
|
||||
|
||||
class DeviceInputConfig(ConnectionConfig):
|
||||
device_filter: list[str] = []
|
||||
readout_filter: list[str] = []
|
||||
devices: list[str] = []
|
||||
default: str | None = None
|
||||
arg_name: str | None = None
|
||||
apply_filter: bool = True
|
||||
signal_class_filter: list[str] = []
|
||||
|
||||
@field_validator("device_filter")
|
||||
@classmethod
|
||||
def check_device_filter(cls, v, values):
|
||||
valid_device_filters = [entry.value for entry in BECDeviceFilter]
|
||||
for filt in v:
|
||||
if filt not in valid_device_filters:
|
||||
raise ValueError(
|
||||
f"Device filter {filt} is not a valid device filter {valid_device_filters}."
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("readout_filter")
|
||||
@classmethod
|
||||
def check_readout_filter(cls, v, values):
|
||||
valid_device_filters = [entry.value for entry in ReadoutPriority]
|
||||
for filt in v:
|
||||
if filt not in valid_device_filters:
|
||||
raise ValueError(
|
||||
f"Device filter {filt} is not a valid device filter {valid_device_filters}."
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class DeviceInputBase(BECWidget):
|
||||
"""
|
||||
Mixin base class for device input widgets.
|
||||
It allows to filter devices from BEC based on
|
||||
device class and readout priority.
|
||||
"""
|
||||
|
||||
_device_handler = {
|
||||
BECDeviceFilter.DEVICE: Device,
|
||||
BECDeviceFilter.POSITIONER: Positioner,
|
||||
BECDeviceFilter.SIGNAL: BECSignal,
|
||||
BECDeviceFilter.COMPUTED_SIGNAL: ComputedSignal,
|
||||
}
|
||||
|
||||
_filter_handler = {
|
||||
BECDeviceFilter.DEVICE: "filter_to_device",
|
||||
BECDeviceFilter.POSITIONER: "filter_to_positioner",
|
||||
BECDeviceFilter.SIGNAL: "filter_to_signal",
|
||||
BECDeviceFilter.COMPUTED_SIGNAL: "filter_to_computed_signal",
|
||||
ReadoutPriority.MONITORED: "readout_monitored",
|
||||
ReadoutPriority.BASELINE: "readout_baseline",
|
||||
ReadoutPriority.ASYNC: "readout_async",
|
||||
ReadoutPriority.CONTINUOUS: "readout_continuous",
|
||||
ReadoutPriority.ON_REQUEST: "readout_on_request",
|
||||
}
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id: str | None = None, **kwargs):
|
||||
|
||||
if config is None:
|
||||
config = DeviceInputConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DeviceInputConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(
|
||||
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs
|
||||
)
|
||||
self.get_bec_shortcuts()
|
||||
self._device_filter = []
|
||||
self._readout_filter = []
|
||||
self._devices = []
|
||||
|
||||
### QtSlots ###
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_device(self, device: str):
|
||||
"""
|
||||
Set the device.
|
||||
|
||||
Args:
|
||||
device (str): Default name.
|
||||
"""
|
||||
if self.validate_device(device) is True:
|
||||
WidgetIO.set_value(widget=self, value=device)
|
||||
self.config.default = device
|
||||
else:
|
||||
logger.warning(
|
||||
f"Device {device} is not in the filtered selection of {self}: {self.devices}."
|
||||
)
|
||||
|
||||
@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,
|
||||
it will not apply the filters, store the filter settings and return.
|
||||
"""
|
||||
current_device = WidgetIO.get_value(widget=self, as_string=True)
|
||||
self.config.device_filter = self.device_filter
|
||||
self.config.readout_filter = self.readout_filter
|
||||
self.config.signal_class_filter = self.signal_class_filter
|
||||
if self.apply_filter is False:
|
||||
return
|
||||
all_dev = self.dev.enabled_devices
|
||||
devs = self._filter_devices_by_signal_class(all_dev)
|
||||
# Filter based on device class
|
||||
devs = [dev for dev in devs if self._check_device_filter(dev)]
|
||||
# Filter based on readout priority
|
||||
devs = [dev for dev in devs if self._check_readout_filter(dev)]
|
||||
self.devices = [device.name for device in devs]
|
||||
if current_device != "":
|
||||
self.set_device(current_device)
|
||||
|
||||
@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.
|
||||
|
||||
Args:
|
||||
devices (list[str]): List of devices.
|
||||
"""
|
||||
self.apply_filter = False
|
||||
self.devices = devices
|
||||
|
||||
### QtProperties ###
|
||||
|
||||
@SafeProperty(
|
||||
"QStringList",
|
||||
doc="List of devices. If updated, it will disable the apply filters property.",
|
||||
)
|
||||
def devices(self) -> list[str]:
|
||||
"""
|
||||
Get the list of devices for the applied filters.
|
||||
|
||||
Returns:
|
||||
list[str]: List of devices.
|
||||
"""
|
||||
return self._devices
|
||||
|
||||
@devices.setter
|
||||
def devices(self, value: list):
|
||||
self._devices = value
|
||||
self.config.devices = value
|
||||
FilterIO.set_selection(widget=self, selection=value)
|
||||
|
||||
@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
|
||||
|
||||
@default.setter
|
||||
def default(self, value: str):
|
||||
if self.validate_device(value) is False:
|
||||
return
|
||||
self.config.default = value
|
||||
WidgetIO.set_value(widget=self, value=value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def apply_filter(self):
|
||||
"""Apply the filters on the devices."""
|
||||
return self.config.apply_filter
|
||||
|
||||
@apply_filter.setter
|
||||
def apply_filter(self, value: bool):
|
||||
self.config.apply_filter = value
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty("QStringList")
|
||||
def signal_class_filter(self) -> list[str]:
|
||||
"""
|
||||
Get the signal class filter for devices.
|
||||
|
||||
Returns:
|
||||
list[str]: List of signal class names used for filtering devices.
|
||||
"""
|
||||
return self.config.signal_class_filter
|
||||
|
||||
@signal_class_filter.setter
|
||||
def signal_class_filter(self, value: list[str] | None):
|
||||
"""
|
||||
Set the signal class filter and update the device list.
|
||||
|
||||
Args:
|
||||
value (list[str] | None): List of signal class names to filter by.
|
||||
"""
|
||||
self.config.signal_class_filter = value or []
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def filter_to_device(self):
|
||||
"""Include devices in filters."""
|
||||
return BECDeviceFilter.DEVICE in self.device_filter
|
||||
|
||||
@filter_to_device.setter
|
||||
def filter_to_device(self, value: bool):
|
||||
if value is True and BECDeviceFilter.DEVICE not in self.device_filter:
|
||||
self._device_filter.append(BECDeviceFilter.DEVICE)
|
||||
if value is False and BECDeviceFilter.DEVICE in self.device_filter:
|
||||
self._device_filter.remove(BECDeviceFilter.DEVICE)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def filter_to_positioner(self):
|
||||
"""Include devices of type Positioner in filters."""
|
||||
return BECDeviceFilter.POSITIONER in self.device_filter
|
||||
|
||||
@filter_to_positioner.setter
|
||||
def filter_to_positioner(self, value: bool):
|
||||
if value is True and BECDeviceFilter.POSITIONER not in self.device_filter:
|
||||
self._device_filter.append(BECDeviceFilter.POSITIONER)
|
||||
if value is False and BECDeviceFilter.POSITIONER in self.device_filter:
|
||||
self._device_filter.remove(BECDeviceFilter.POSITIONER)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def filter_to_signal(self):
|
||||
"""Include devices of type Signal in filters."""
|
||||
return BECDeviceFilter.SIGNAL in self.device_filter
|
||||
|
||||
@filter_to_signal.setter
|
||||
def filter_to_signal(self, value: bool):
|
||||
if value is True and BECDeviceFilter.SIGNAL not in self.device_filter:
|
||||
self._device_filter.append(BECDeviceFilter.SIGNAL)
|
||||
if value is False and BECDeviceFilter.SIGNAL in self.device_filter:
|
||||
self._device_filter.remove(BECDeviceFilter.SIGNAL)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def filter_to_computed_signal(self):
|
||||
"""Include devices of type ComputedSignal in filters."""
|
||||
return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter
|
||||
|
||||
@filter_to_computed_signal.setter
|
||||
def filter_to_computed_signal(self, value: bool):
|
||||
if value is True and BECDeviceFilter.COMPUTED_SIGNAL not in self.device_filter:
|
||||
self._device_filter.append(BECDeviceFilter.COMPUTED_SIGNAL)
|
||||
if value is False and BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter:
|
||||
self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def readout_monitored(self):
|
||||
"""Include devices with readout priority Monitored in filters."""
|
||||
return ReadoutPriority.MONITORED in self.readout_filter
|
||||
|
||||
@readout_monitored.setter
|
||||
def readout_monitored(self, value: bool):
|
||||
if value is True and ReadoutPriority.MONITORED not in self.readout_filter:
|
||||
self._readout_filter.append(ReadoutPriority.MONITORED)
|
||||
if value is False and ReadoutPriority.MONITORED in self.readout_filter:
|
||||
self._readout_filter.remove(ReadoutPriority.MONITORED)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def readout_baseline(self):
|
||||
"""Include devices with readout priority Baseline in filters."""
|
||||
return ReadoutPriority.BASELINE in self.readout_filter
|
||||
|
||||
@readout_baseline.setter
|
||||
def readout_baseline(self, value: bool):
|
||||
if value is True and ReadoutPriority.BASELINE not in self.readout_filter:
|
||||
self._readout_filter.append(ReadoutPriority.BASELINE)
|
||||
if value is False and ReadoutPriority.BASELINE in self.readout_filter:
|
||||
self._readout_filter.remove(ReadoutPriority.BASELINE)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def readout_async(self):
|
||||
"""Include devices with readout priority Async in filters."""
|
||||
return ReadoutPriority.ASYNC in self.readout_filter
|
||||
|
||||
@readout_async.setter
|
||||
def readout_async(self, value: bool):
|
||||
if value is True and ReadoutPriority.ASYNC not in self.readout_filter:
|
||||
self._readout_filter.append(ReadoutPriority.ASYNC)
|
||||
if value is False and ReadoutPriority.ASYNC in self.readout_filter:
|
||||
self._readout_filter.remove(ReadoutPriority.ASYNC)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def readout_continuous(self):
|
||||
"""Include devices with readout priority continuous in filters."""
|
||||
return ReadoutPriority.CONTINUOUS in self.readout_filter
|
||||
|
||||
@readout_continuous.setter
|
||||
def readout_continuous(self, value: bool):
|
||||
if value is True and ReadoutPriority.CONTINUOUS not in self.readout_filter:
|
||||
self._readout_filter.append(ReadoutPriority.CONTINUOUS)
|
||||
if value is False and ReadoutPriority.CONTINUOUS in self.readout_filter:
|
||||
self._readout_filter.remove(ReadoutPriority.CONTINUOUS)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def readout_on_request(self):
|
||||
"""Include devices with readout priority OnRequest in filters."""
|
||||
return ReadoutPriority.ON_REQUEST in self.readout_filter
|
||||
|
||||
@readout_on_request.setter
|
||||
def readout_on_request(self, value: bool):
|
||||
if value is True and ReadoutPriority.ON_REQUEST not in self.readout_filter:
|
||||
self._readout_filter.append(ReadoutPriority.ON_REQUEST)
|
||||
if value is False and ReadoutPriority.ON_REQUEST in self.readout_filter:
|
||||
self._readout_filter.remove(ReadoutPriority.ON_REQUEST)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
### Python Methods and Properties ###
|
||||
|
||||
@property
|
||||
def device_filter(self) -> list[object]:
|
||||
"""Get the list of filters to apply on the devices."""
|
||||
return self._device_filter
|
||||
|
||||
@property
|
||||
def readout_filter(self) -> list[str]:
|
||||
"""Get the list of filters to apply on the devices"""
|
||||
return self._readout_filter
|
||||
|
||||
def get_available_filters(self) -> list:
|
||||
"""Get the available filters."""
|
||||
return [entry for entry in BECDeviceFilter]
|
||||
|
||||
def get_readout_priority_filters(self) -> list:
|
||||
"""Get the available readout priority filters."""
|
||||
return [entry for entry in ReadoutPriority]
|
||||
|
||||
def set_device_filter(
|
||||
self, filter_selection: str | BECDeviceFilter | list[str] | list[BECDeviceFilter]
|
||||
):
|
||||
"""
|
||||
Set the device filter. If None is passed, no filters are applied and all devices included.
|
||||
|
||||
Args:
|
||||
filter_selection (str | list[str]): Device filters. It is recommended to make an enum for the filters.
|
||||
"""
|
||||
filters = None
|
||||
if isinstance(filter_selection, list):
|
||||
filters = [self._filter_handler.get(entry) for entry in filter_selection]
|
||||
if isinstance(filter_selection, str) or isinstance(filter_selection, BECDeviceFilter):
|
||||
filters = [self._filter_handler.get(filter_selection)]
|
||||
if filters is None or any([entry is None for entry in filters]):
|
||||
logger.warning(f"Device filter {filter_selection} is not in the device filter list.")
|
||||
return
|
||||
for entry in filters:
|
||||
setattr(self, entry, True)
|
||||
|
||||
def set_readout_priority_filter(
|
||||
self, filter_selection: str | ReadoutPriority | list[str] | list[ReadoutPriority]
|
||||
):
|
||||
"""
|
||||
Set the readout priority filter. If None is passed, all filters are included.
|
||||
|
||||
Args:
|
||||
filter_selection (str | list[str]): Readout priority filters.
|
||||
"""
|
||||
filters = None
|
||||
if isinstance(filter_selection, list):
|
||||
filters = [self._filter_handler.get(entry) for entry in filter_selection]
|
||||
if isinstance(filter_selection, str) or isinstance(filter_selection, ReadoutPriority):
|
||||
filters = [self._filter_handler.get(filter_selection)]
|
||||
if filters is None or any([entry is None for entry in filters]):
|
||||
logger.warning(
|
||||
f"Readout priority filter {filter_selection} is not in the readout priority list."
|
||||
)
|
||||
return
|
||||
for entry in filters:
|
||||
setattr(self, entry, True)
|
||||
|
||||
def _check_device_filter(
|
||||
self, device: Device | BECSignal | ComputedSignal | Positioner
|
||||
) -> bool:
|
||||
"""Check if filter for device type is applied or not.
|
||||
|
||||
Args:
|
||||
device(Device | Signal | ComputedSignal | Positioner): Device object.
|
||||
"""
|
||||
return all(isinstance(device, self._device_handler[entry]) for entry in self.device_filter)
|
||||
|
||||
def _filter_devices_by_signal_class(
|
||||
self, devices: list[Device | BECSignal | ComputedSignal | Positioner]
|
||||
) -> list[Device | BECSignal | ComputedSignal | Positioner]:
|
||||
"""Filter devices by signal class, if a signal class filter is set."""
|
||||
if not self.config.signal_class_filter:
|
||||
return devices
|
||||
if not self.client or not hasattr(self.client, "device_manager"):
|
||||
return []
|
||||
signals = FilterIO.update_with_signal_class(
|
||||
widget=self, signal_class_filter=self.config.signal_class_filter, client=self.client
|
||||
)
|
||||
allowed_devices = {device_name for device_name, _, _ in signals}
|
||||
return [dev for dev in devices if dev.name in allowed_devices]
|
||||
|
||||
def _check_readout_filter(
|
||||
self, device: Device | BECSignal | ComputedSignal | Positioner
|
||||
) -> bool:
|
||||
"""Check if filter for readout priority is applied or not.
|
||||
|
||||
Args:
|
||||
device(Device | Signal | ComputedSignal | Positioner): Device object.
|
||||
"""
|
||||
return device.readout_priority in self.readout_filter
|
||||
|
||||
def get_device_object(self, device: str) -> object:
|
||||
"""
|
||||
Get the device object based on the device name.
|
||||
|
||||
Args:
|
||||
device(str): Device name.
|
||||
|
||||
Returns:
|
||||
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
|
||||
"""
|
||||
self.validate_device(device)
|
||||
dev = getattr(self.dev, device, None)
|
||||
if dev is None:
|
||||
raise ValueError(
|
||||
f"Device {device} is not found in the device manager {self.dev} as enabled device."
|
||||
)
|
||||
return dev
|
||||
|
||||
def validate_device(self, device: str) -> bool:
|
||||
"""
|
||||
Validate the device if it is present in the filtered device selection.
|
||||
|
||||
Args:
|
||||
device(str): Device to validate.
|
||||
"""
|
||||
all_devs = [dev.name for dev in self.dev.enabled_devices]
|
||||
if device in self.devices and device in all_devs:
|
||||
return True
|
||||
return False
|
||||
@@ -1,301 +0,0 @@
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.device import Signal
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property
|
||||
|
||||
from bec_widgets.utils.bec_connector 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.ophyd_kind_util import Kind
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceSignalInputBaseConfig(ConnectionConfig):
|
||||
"""Configuration class for DeviceSignalInputBase."""
|
||||
|
||||
signal_filter: str | list[str] | None = None
|
||||
signal_class_filter: list[str] | None = None
|
||||
ndim_filter: int | list[int] | None = None
|
||||
default: str | None = None
|
||||
arg_name: str | None = None
|
||||
device: str | None = None
|
||||
signals: list[str] | None = None
|
||||
|
||||
|
||||
class DeviceSignalInputBase(BECWidget):
|
||||
"""
|
||||
Mixin base class for device signal input widgets.
|
||||
Mixin class for device signal input widgets. This class provides methods to get the device signal list and device
|
||||
signal object based on the current text of the widget.
|
||||
"""
|
||||
|
||||
RPC = False
|
||||
_filter_handler = {
|
||||
Kind.hinted: "include_hinted_signals",
|
||||
Kind.normal: "include_normal_signals",
|
||||
Kind.config: "include_config_signals",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
config: DeviceSignalInputBaseConfig | dict | None = None,
|
||||
gui_id: str = None,
|
||||
**kwargs,
|
||||
):
|
||||
|
||||
self.config = self._process_config_input(config)
|
||||
super().__init__(client=client, config=self.config, gui_id=gui_id, **kwargs)
|
||||
|
||||
self._device = None
|
||||
self.get_bec_shortcuts()
|
||||
self._signal_filter = set()
|
||||
self._signals = []
|
||||
self._hinted_signals = []
|
||||
self._normal_signals = []
|
||||
self._config_signals = []
|
||||
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.update_signals_from_filters
|
||||
)
|
||||
|
||||
### Qt Slots ###
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_signal(self, signal: str):
|
||||
"""
|
||||
Set the signal.
|
||||
|
||||
Args:
|
||||
signal (str): signal name.
|
||||
"""
|
||||
if self.validate_signal(signal):
|
||||
WidgetIO.set_value(widget=self, value=signal)
|
||||
self.config.default = signal
|
||||
else:
|
||||
logger.warning(
|
||||
f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}."
|
||||
)
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_device(self, device: str | None):
|
||||
"""
|
||||
Set the device. If device is not valid, device will be set to None which happens
|
||||
|
||||
Args:
|
||||
device(str): device name.
|
||||
"""
|
||||
if self.validate_device(device) is False:
|
||||
self._device = None
|
||||
else:
|
||||
self._device = device
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
@SafeSlot()
|
||||
def update_signals_from_filters(
|
||||
self, content: dict | None = None, metadata: dict | None = None
|
||||
):
|
||||
"""Update the filters for the device signals based on list in self.signal_filter.
|
||||
In addition, store the hinted, normal and config signals in separate lists to allow
|
||||
customisation within QLineEdit.
|
||||
|
||||
Note:
|
||||
Signal and ComputedSignals have no signals. The naming convention follows the device name.
|
||||
"""
|
||||
self.config.signal_filter = self.signal_filter
|
||||
# pylint: disable=protected-access
|
||||
if not self.validate_device(self._device):
|
||||
self._device = None
|
||||
self.config.device = 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 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)
|
||||
self._config_signals = _update(Kind.config)
|
||||
|
||||
self._signals = self._hinted_signals + self._normal_signals + self._config_signals
|
||||
FilterIO.set_selection(widget=self, selection=self.signals)
|
||||
|
||||
### Qt Properties ###
|
||||
|
||||
@Property(str)
|
||||
def device(self) -> str:
|
||||
"""Get the selected device."""
|
||||
if self._device is None:
|
||||
return ""
|
||||
return self._device
|
||||
|
||||
@device.setter
|
||||
def device(self, value: str):
|
||||
"""Set the device and update the filters, only allow devices present in the devicemanager."""
|
||||
self._device = value
|
||||
self.config.device = value
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
def include_hinted_signals(self):
|
||||
"""Include hinted signals in filters."""
|
||||
return Kind.hinted in self.signal_filter
|
||||
|
||||
@include_hinted_signals.setter
|
||||
def include_hinted_signals(self, value: bool):
|
||||
if value:
|
||||
self._signal_filter.add(Kind.hinted)
|
||||
else:
|
||||
self._signal_filter.discard(Kind.hinted)
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
def include_normal_signals(self):
|
||||
"""Include normal signals in filters."""
|
||||
return Kind.normal in self.signal_filter
|
||||
|
||||
@include_normal_signals.setter
|
||||
def include_normal_signals(self, value: bool):
|
||||
if value:
|
||||
self._signal_filter.add(Kind.normal)
|
||||
else:
|
||||
self._signal_filter.discard(Kind.normal)
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
def include_config_signals(self):
|
||||
"""Include config signals in filters."""
|
||||
return Kind.config in self.signal_filter
|
||||
|
||||
@include_config_signals.setter
|
||||
def include_config_signals(self, value: bool):
|
||||
if value:
|
||||
self._signal_filter.add(Kind.config)
|
||||
else:
|
||||
self._signal_filter.discard(Kind.config)
|
||||
self.update_signals_from_filters()
|
||||
|
||||
### Properties and Methods ###
|
||||
|
||||
@property
|
||||
def signals(self) -> list[str]:
|
||||
"""
|
||||
Get the list of device signals for the applied filters.
|
||||
|
||||
Returns:
|
||||
list[str]: List of device signals.
|
||||
"""
|
||||
return self._signals
|
||||
|
||||
@signals.setter
|
||||
def signals(self, value: list[str]):
|
||||
self._signals = value
|
||||
self.config.signals = value
|
||||
FilterIO.set_selection(widget=self, selection=value)
|
||||
|
||||
@property
|
||||
def signal_filter(self) -> list[str]:
|
||||
"""Get the list of filters to apply on the device signals."""
|
||||
return self._signal_filter
|
||||
|
||||
def get_available_filters(self) -> list[str]:
|
||||
"""Get the available filters."""
|
||||
return [entry for entry in self._filter_handler]
|
||||
|
||||
def set_filter(self, filter_selection: str | list[str]):
|
||||
"""
|
||||
Set the device filter. If None, all devices are included.
|
||||
|
||||
Args:
|
||||
filter_selection (str | list[str]): Device filters from BECDeviceFilter and BECReadoutPriority.
|
||||
"""
|
||||
filters = None
|
||||
if isinstance(filter_selection, list):
|
||||
filters = [self._filter_handler.get(entry) for entry in filter_selection]
|
||||
if isinstance(filter_selection, str):
|
||||
filters = [self._filter_handler.get(filter_selection)]
|
||||
if filters is None:
|
||||
return
|
||||
for entry in filters:
|
||||
setattr(self, entry, True)
|
||||
|
||||
def get_device_object(self, device: str) -> object | None:
|
||||
"""
|
||||
Get the device object based on the device name.
|
||||
|
||||
Args:
|
||||
device(str): Device name.
|
||||
|
||||
Returns:
|
||||
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
|
||||
"""
|
||||
self.validate_device(device)
|
||||
dev = getattr(self.dev, device, None)
|
||||
if dev is None:
|
||||
logger.warning(f"Device {device} not found in devicemanager.")
|
||||
return None
|
||||
return dev
|
||||
|
||||
def validate_device(self, device: str | None, raise_on_false: bool = False) -> bool:
|
||||
"""
|
||||
Validate the device if it is present in current BEC instance.
|
||||
|
||||
Args:
|
||||
device(str): Device to validate.
|
||||
raise_on_false(bool): Raise ValueError if device is not found.
|
||||
"""
|
||||
if device in self.dev:
|
||||
return True
|
||||
if raise_on_false is True:
|
||||
raise ValueError(f"Device {device} not found in devicemanager.")
|
||||
return False
|
||||
|
||||
def validate_signal(self, signal: str) -> bool:
|
||||
"""
|
||||
Validate the signal if it is present in the device signals.
|
||||
|
||||
Args:
|
||||
signal(str): Signal to validate.
|
||||
"""
|
||||
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()
|
||||
@@ -1,32 +1,121 @@
|
||||
"""Editable combobox for selecting BEC devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from qtpy.QtCore import QSize, Signal, Slot
|
||||
from qtpy.QtWidgets import QComboBox, QSizePolicy
|
||||
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, field_validator
|
||||
from qtpy.QtCore import QSize, QStringListModel, Signal, Slot
|
||||
from qtpy.QtWidgets import QComboBox, QCompleter, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
|
||||
BECDeviceFilter,
|
||||
DeviceInputBase,
|
||||
DeviceInputConfig,
|
||||
)
|
||||
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.utils.filter_io import get_bec_signals_for_classes, replace_combobox_items
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
class BECDeviceFilter(enum.Enum):
|
||||
"""Device class filters accepted by :class:`DeviceComboBox`."""
|
||||
|
||||
DEVICE = "Device"
|
||||
POSITIONER = "Positioner"
|
||||
SIGNAL = "Signal"
|
||||
COMPUTED_SIGNAL = "ComputedSignal"
|
||||
|
||||
|
||||
class DeviceInputConfig(ConnectionConfig):
|
||||
"""Serializable configuration for :class:`DeviceComboBox`.
|
||||
|
||||
Attributes:
|
||||
device_filter: Enabled device class filters as ``BECDeviceFilter.value`` strings.
|
||||
readout_filter: Enabled readout priority filters as ``ReadoutPriority.value`` strings.
|
||||
devices: Explicit device names shown by the combobox.
|
||||
default: Device selected by default.
|
||||
arg_name: Optional argument name used by scan/input widgets.
|
||||
apply_filter: Whether the combobox should refresh devices from the BEC device manager.
|
||||
signal_class_filter: Signal class names used to restrict listed devices.
|
||||
autocomplete: Whether to use the explicit completer model instead of Qt's default
|
||||
editable-combobox completer.
|
||||
"""
|
||||
Combobox widget for device input with autocomplete for device names.
|
||||
|
||||
device_filter: list[str] = Field(default_factory=list)
|
||||
readout_filter: list[str] = Field(default_factory=list)
|
||||
devices: list[str] = Field(default_factory=list)
|
||||
default: str | None = None
|
||||
arg_name: str | None = None
|
||||
apply_filter: bool = True
|
||||
signal_class_filter: list[str] = Field(default_factory=list)
|
||||
autocomplete: bool = False
|
||||
|
||||
@field_validator("device_filter")
|
||||
@classmethod
|
||||
def check_device_filter(cls, value):
|
||||
"""Validate configured device class filters.
|
||||
|
||||
Args:
|
||||
value: Device class filter values from the persisted widget configuration.
|
||||
|
||||
Returns:
|
||||
The validated filter values.
|
||||
|
||||
Raises:
|
||||
ValueError: If any configured filter is not a valid ``BECDeviceFilter`` value.
|
||||
"""
|
||||
valid_filters = [entry.value for entry in BECDeviceFilter]
|
||||
for device_filter in value:
|
||||
if device_filter not in valid_filters:
|
||||
raise ValueError(
|
||||
f"Device filter {device_filter} is not a valid device filter {valid_filters}."
|
||||
)
|
||||
return value
|
||||
|
||||
@field_validator("readout_filter")
|
||||
@classmethod
|
||||
def check_readout_filter(cls, value):
|
||||
"""Validate configured readout priority filters.
|
||||
|
||||
Args:
|
||||
value: Readout priority filter values from the persisted widget configuration.
|
||||
|
||||
Returns:
|
||||
The validated filter values.
|
||||
|
||||
Raises:
|
||||
ValueError: If any configured filter is not a valid ``ReadoutPriority`` value.
|
||||
"""
|
||||
valid_filters = [entry.value for entry in ReadoutPriority]
|
||||
for readout_filter in value:
|
||||
if readout_filter not in valid_filters:
|
||||
raise ValueError(
|
||||
f"Readout filter {readout_filter} is not a valid readout filter {valid_filters}."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class DeviceComboBox(BECWidget, QComboBox):
|
||||
"""Editable combobox for selecting a BEC device.
|
||||
|
||||
Args:
|
||||
parent: Parent widget.
|
||||
client: BEC client object.
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
||||
readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
||||
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
|
||||
default: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
signal_class_filter: List of signal classes to filter the devices by. Only devices with signals of these classes will be shown.
|
||||
parent: Optional parent widget.
|
||||
client: Optional BEC client object.
|
||||
config: Device input configuration as a ``DeviceInputConfig`` instance or dictionary.
|
||||
gui_id: Optional GUI identifier.
|
||||
device_filter: Device class filter or filters from ``BECDeviceFilter``.
|
||||
readout_priority_filter: Readout priority filter or filters from ``ReadoutPriority``.
|
||||
available_devices: Explicit device names to show. Passing this disables automatic
|
||||
BEC filtering.
|
||||
default: Device name selected during initialization.
|
||||
arg_name: Optional argument name used by scan/input widgets.
|
||||
signal_class_filter: Signal class names used to restrict listed devices.
|
||||
autocomplete: If True, use the explicit line-edit style completer. If False, keep
|
||||
Qt's default editable-combobox completion behavior.
|
||||
**kwargs: Additional keyword arguments passed to ``BECWidget``.
|
||||
"""
|
||||
|
||||
ICON_NAME = "list_alt"
|
||||
@@ -37,62 +126,96 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
device_reset = Signal()
|
||||
device_config_update = Signal()
|
||||
|
||||
_device_handler = {
|
||||
BECDeviceFilter.DEVICE: Device,
|
||||
BECDeviceFilter.POSITIONER: Positioner,
|
||||
BECDeviceFilter.SIGNAL: BECSignal,
|
||||
BECDeviceFilter.COMPUTED_SIGNAL: ComputedSignal,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config: DeviceInputConfig = None,
|
||||
config: DeviceInputConfig | dict | None = None,
|
||||
gui_id: str | None = None,
|
||||
device_filter: BECDeviceFilter | list[BECDeviceFilter] | None = None,
|
||||
readout_priority_filter: (
|
||||
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
|
||||
) = None,
|
||||
device_filter: BECDeviceFilter | str | list[BECDeviceFilter | str] | None = None,
|
||||
readout_priority_filter: str | ReadoutPriority | list[str | ReadoutPriority] | None = None,
|
||||
available_devices: list[str] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
signal_class_filter: list[str] | None = None,
|
||||
autocomplete: bool | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self.config = self._process_config(config)
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
client=client,
|
||||
config=self.config,
|
||||
gui_id=gui_id,
|
||||
theme_update=True,
|
||||
**kwargs,
|
||||
)
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self._device_filter: list[BECDeviceFilter] = []
|
||||
self._readout_filter: list[ReadoutPriority] = []
|
||||
self._devices: list[str] = []
|
||||
self._callback_id = None
|
||||
self._is_valid_input = False
|
||||
self._set_first_element_as_empty = False
|
||||
self._completer_model = QStringListModel(self)
|
||||
|
||||
self.setEditable(True)
|
||||
self.setInsertPolicy(QComboBox.NoInsert)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.setMinimumSize(QSize(100, 0))
|
||||
|
||||
if arg_name is not None:
|
||||
self.config.arg_name = arg_name
|
||||
self.arg_name = arg_name
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.setMinimumSize(QSize(100, 0))
|
||||
self._callback_id = None
|
||||
self._is_valid_input = False
|
||||
self._accent_colors = get_accent_colors()
|
||||
self._set_first_element_as_empty = False
|
||||
|
||||
# We do not consider the config that is passed here, this produced problems
|
||||
# with QtDesigner, since config and input arguments may differ and resolve properly
|
||||
# Implementing this logic and config recoverage is postponed.
|
||||
# Set available devices if passed
|
||||
if available_devices is None and self.config.devices:
|
||||
available_devices = self.config.devices
|
||||
if device_filter is None and self.config.device_filter:
|
||||
device_filter = self.config.device_filter
|
||||
if readout_priority_filter is None and self.config.readout_filter:
|
||||
readout_priority_filter = self.config.readout_filter
|
||||
if signal_class_filter is None and self.config.signal_class_filter:
|
||||
signal_class_filter = self.config.signal_class_filter
|
||||
if default is None and self.config.default:
|
||||
default = self.config.default
|
||||
if autocomplete is not None:
|
||||
self.config.autocomplete = autocomplete
|
||||
if self.config.autocomplete:
|
||||
self.autocomplete = True
|
||||
|
||||
if available_devices is not None:
|
||||
self.set_available_devices(available_devices)
|
||||
# Set readout priority filter default is all
|
||||
if readout_priority_filter is not None:
|
||||
self.set_readout_priority_filter(readout_priority_filter)
|
||||
else:
|
||||
self.set_readout_priority_filter(
|
||||
[
|
||||
ReadoutPriority.MONITORED,
|
||||
ReadoutPriority.BASELINE,
|
||||
ReadoutPriority.ASYNC,
|
||||
ReadoutPriority.CONTINUOUS,
|
||||
ReadoutPriority.ON_REQUEST,
|
||||
]
|
||||
)
|
||||
# Device filter default is None
|
||||
|
||||
self.set_readout_priority_filter(
|
||||
readout_priority_filter
|
||||
or [
|
||||
ReadoutPriority.MONITORED,
|
||||
ReadoutPriority.BASELINE,
|
||||
ReadoutPriority.ASYNC,
|
||||
ReadoutPriority.CONTINUOUS,
|
||||
ReadoutPriority.ON_REQUEST,
|
||||
]
|
||||
)
|
||||
|
||||
if device_filter is not None:
|
||||
self.set_device_filter(device_filter)
|
||||
|
||||
if signal_class_filter is not None:
|
||||
self.signal_class_filter = signal_class_filter
|
||||
|
||||
# Set default device if passed
|
||||
if default is not None:
|
||||
self.set_device(default)
|
||||
else:
|
||||
self.setCurrentText("")
|
||||
|
||||
self._callback_id = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.on_device_update
|
||||
)
|
||||
@@ -100,100 +223,416 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
self.currentTextChanged.connect(self.check_validity)
|
||||
self.check_validity(self.currentText())
|
||||
|
||||
@staticmethod
|
||||
def _process_config(config: DeviceInputConfig | dict | None) -> DeviceInputConfig:
|
||||
"""Normalize user-provided configuration.
|
||||
|
||||
Args:
|
||||
config: Existing configuration, configuration dictionary, or None.
|
||||
|
||||
Returns:
|
||||
A validated ``DeviceInputConfig`` instance.
|
||||
"""
|
||||
if config is None:
|
||||
return DeviceInputConfig(widget_class="DeviceComboBox")
|
||||
return DeviceInputConfig.model_validate(config)
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_device(self, device: str):
|
||||
"""Set the current device if it is valid for the current filters.
|
||||
|
||||
Args:
|
||||
device: Device name to select.
|
||||
"""
|
||||
if self.validate_device(device):
|
||||
self.setCurrentText(device)
|
||||
self.config.default = device
|
||||
else:
|
||||
logger.warning(
|
||||
f"Device {device} is not in the filtered selection of {self}: {self.devices}."
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def update_devices_from_filters(self):
|
||||
"""Refresh the available device list from current device/readout/signal filters."""
|
||||
self.config.device_filter = [entry.value for entry in self.device_filter]
|
||||
self.config.readout_filter = [entry.value for entry in self.readout_filter]
|
||||
self.config.signal_class_filter = self.signal_class_filter
|
||||
if not self.apply_filter:
|
||||
return
|
||||
|
||||
devices = self._filter_devices_by_signal_class(self.dev.enabled_devices)
|
||||
devices = [device for device in devices if self._check_device_filter(device)]
|
||||
devices = [device for device in devices if self._check_readout_filter(device)]
|
||||
self.devices = [device.name for device in devices]
|
||||
|
||||
@SafeSlot(list)
|
||||
def set_available_devices(self, devices: list[str]):
|
||||
"""Use an explicit device list and disable automatic BEC filtering.
|
||||
|
||||
Args:
|
||||
devices: Device names to show in the combobox.
|
||||
"""
|
||||
self.apply_filter = False
|
||||
self.devices = devices
|
||||
|
||||
@SafeProperty("QStringList")
|
||||
def devices(self) -> list[str]:
|
||||
"""Devices available after filtering."""
|
||||
return self._devices
|
||||
|
||||
@devices.setter
|
||||
def devices(self, value: list[str]):
|
||||
self._devices = value
|
||||
self.config.devices = value
|
||||
self._replace_items(value)
|
||||
|
||||
@SafeProperty(str)
|
||||
def default(self):
|
||||
"""Default selected device."""
|
||||
return self.config.default
|
||||
|
||||
@default.setter
|
||||
def default(self, value: str):
|
||||
self.set_device(value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def apply_filter(self):
|
||||
"""Whether BEC filters are applied to the device list."""
|
||||
return self.config.apply_filter
|
||||
|
||||
@apply_filter.setter
|
||||
def apply_filter(self, value: bool):
|
||||
self.config.apply_filter = value
|
||||
if value:
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty("QStringList")
|
||||
def signal_class_filter(self) -> list[str]:
|
||||
"""Signal class names used to restrict devices."""
|
||||
return self.config.signal_class_filter
|
||||
|
||||
@signal_class_filter.setter
|
||||
def signal_class_filter(self, value: list[str] | None):
|
||||
self.config.signal_class_filter = value or []
|
||||
self.update_devices_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def filter_to_device(self):
|
||||
"""Include generic Device objects."""
|
||||
return BECDeviceFilter.DEVICE in self.device_filter
|
||||
|
||||
@filter_to_device.setter
|
||||
def filter_to_device(self, value: bool):
|
||||
self._set_device_filter_enabled(BECDeviceFilter.DEVICE, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def filter_to_positioner(self):
|
||||
"""Include Positioner devices."""
|
||||
return BECDeviceFilter.POSITIONER in self.device_filter
|
||||
|
||||
@filter_to_positioner.setter
|
||||
def filter_to_positioner(self, value: bool):
|
||||
self._set_device_filter_enabled(BECDeviceFilter.POSITIONER, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def filter_to_signal(self):
|
||||
"""Include Signal devices."""
|
||||
return BECDeviceFilter.SIGNAL in self.device_filter
|
||||
|
||||
@filter_to_signal.setter
|
||||
def filter_to_signal(self, value: bool):
|
||||
self._set_device_filter_enabled(BECDeviceFilter.SIGNAL, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def filter_to_computed_signal(self):
|
||||
"""Include ComputedSignal devices."""
|
||||
return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter
|
||||
|
||||
@filter_to_computed_signal.setter
|
||||
def filter_to_computed_signal(self, value: bool):
|
||||
self._set_device_filter_enabled(BECDeviceFilter.COMPUTED_SIGNAL, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def readout_monitored(self):
|
||||
"""Include monitored devices."""
|
||||
return ReadoutPriority.MONITORED in self.readout_filter
|
||||
|
||||
@readout_monitored.setter
|
||||
def readout_monitored(self, value: bool):
|
||||
self._set_readout_filter_enabled(ReadoutPriority.MONITORED, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def readout_baseline(self):
|
||||
"""Include baseline devices."""
|
||||
return ReadoutPriority.BASELINE in self.readout_filter
|
||||
|
||||
@readout_baseline.setter
|
||||
def readout_baseline(self, value: bool):
|
||||
self._set_readout_filter_enabled(ReadoutPriority.BASELINE, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def readout_async(self):
|
||||
"""Include async devices."""
|
||||
return ReadoutPriority.ASYNC in self.readout_filter
|
||||
|
||||
@readout_async.setter
|
||||
def readout_async(self, value: bool):
|
||||
self._set_readout_filter_enabled(ReadoutPriority.ASYNC, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def readout_continuous(self):
|
||||
"""Include continuous devices."""
|
||||
return ReadoutPriority.CONTINUOUS in self.readout_filter
|
||||
|
||||
@readout_continuous.setter
|
||||
def readout_continuous(self, value: bool):
|
||||
self._set_readout_filter_enabled(ReadoutPriority.CONTINUOUS, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def readout_on_request(self):
|
||||
"""Include on-request devices."""
|
||||
return ReadoutPriority.ON_REQUEST in self.readout_filter
|
||||
|
||||
@readout_on_request.setter
|
||||
def readout_on_request(self, value: bool):
|
||||
self._set_readout_filter_enabled(ReadoutPriority.ON_REQUEST, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def set_first_element_as_empty(self) -> bool:
|
||||
"""
|
||||
Whether the first element in the combobox should be empty.
|
||||
This is useful to allow the user to select a device from the list.
|
||||
"""
|
||||
"""Whether an empty choice is inserted as the first item."""
|
||||
return self._set_first_element_as_empty
|
||||
|
||||
@set_first_element_as_empty.setter
|
||||
def set_first_element_as_empty(self, value: bool) -> None:
|
||||
"""
|
||||
Set whether the first element in the combobox should be empty.
|
||||
This is useful to allow the user to select a device from the list.
|
||||
|
||||
Args:
|
||||
value (bool): True if the first element should be empty, False otherwise.
|
||||
"""
|
||||
self._set_first_element_as_empty = value
|
||||
if self._set_first_element_as_empty:
|
||||
self.insertItem(0, "")
|
||||
current_text = self.currentText()
|
||||
if value:
|
||||
if self.count() == 0 or self.itemText(0) != "":
|
||||
self.insertItem(0, "")
|
||||
self.setCurrentIndex(0)
|
||||
elif self.count() > 0 and self.itemText(0) == "":
|
||||
self.removeItem(0)
|
||||
if not current_text:
|
||||
self.setCurrentText("")
|
||||
|
||||
@SafeProperty(bool)
|
||||
def autocomplete(self) -> bool:
|
||||
"""Whether autocomplete suggestions are enabled while editing."""
|
||||
return self.config.autocomplete
|
||||
|
||||
@autocomplete.setter
|
||||
def autocomplete(self, value: bool) -> None:
|
||||
self.config.autocomplete = value
|
||||
if value:
|
||||
self.setCompleter(QCompleter(self._completer_model, self))
|
||||
else:
|
||||
if self.count() > 0 and self.itemText(0) == "":
|
||||
self.removeItem(0)
|
||||
self.setCompleter(QCompleter(self.model(), self))
|
||||
|
||||
def on_device_update(self, action: str, content: dict) -> None:
|
||||
"""
|
||||
Callback for device update events. Triggers the device_update signal.
|
||||
@property
|
||||
def device_filter(self) -> list[BECDeviceFilter]:
|
||||
"""Device class filters."""
|
||||
return self._device_filter
|
||||
|
||||
Args:
|
||||
action (str): The action that triggered the event.
|
||||
content (dict): The content of the config update.
|
||||
"""
|
||||
if action in ["add", "remove", "reload"]:
|
||||
self.device_config_update.emit()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
if self._callback_id is not None:
|
||||
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
|
||||
super().cleanup()
|
||||
|
||||
def get_current_device(self) -> object:
|
||||
"""
|
||||
Get the current device object based on the current value.
|
||||
|
||||
Returns:
|
||||
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
|
||||
"""
|
||||
dev_name = self.currentText()
|
||||
return self.get_device_object(dev_name)
|
||||
|
||||
@Slot(str)
|
||||
def check_validity(self, input_text: str) -> None:
|
||||
"""
|
||||
Check if the current value is a valid device name.
|
||||
"""
|
||||
if self.validate_device(input_text) is True:
|
||||
self._is_valid_input = True
|
||||
self.device_selected.emit(input_text)
|
||||
self.setStyleSheet("border: 1px solid transparent;")
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
self.device_reset.emit()
|
||||
if self.isEnabled():
|
||||
self.setStyleSheet("border: 1px solid red;")
|
||||
|
||||
def validate_device(self, device: str) -> bool: # type: ignore[override]
|
||||
"""
|
||||
Extend validation so that preview‑signal pseudo‑devices (labels like
|
||||
``"eiger_preview"``) are accepted as valid choices.
|
||||
|
||||
The validation run only on device not on the preview‑signal.
|
||||
|
||||
Args:
|
||||
device: The text currently entered/selected.
|
||||
|
||||
Returns:
|
||||
True if the device is a genuine BEC device *or* one of the
|
||||
whitelisted preview‑signal entries.
|
||||
"""
|
||||
idx = self.findText(device)
|
||||
if idx >= 0 and isinstance(self.itemData(idx), tuple):
|
||||
device = self.itemData(idx)[0] # type: ignore[assignment]
|
||||
return super().validate_device(device)
|
||||
@property
|
||||
def readout_filter(self) -> list[ReadoutPriority]:
|
||||
"""Readout priority filters."""
|
||||
return self._readout_filter
|
||||
|
||||
@property
|
||||
def is_valid_input(self) -> bool:
|
||||
"""Whether the current text represents a valid device selection."""
|
||||
return self._is_valid_input
|
||||
|
||||
def setEnabled(self, enabled: bool) -> None: # noqa: N802
|
||||
super().setEnabled(enabled)
|
||||
self._update_validity_style(self._is_valid_input)
|
||||
|
||||
def set_device_filter(
|
||||
self, filter_selection: BECDeviceFilter | str | list[BECDeviceFilter | str]
|
||||
):
|
||||
"""Enable one or more device class filters.
|
||||
|
||||
Args:
|
||||
filter_selection: Filter or filters to enable. Strings must match
|
||||
``BECDeviceFilter.value``.
|
||||
"""
|
||||
for device_filter in self._as_list(filter_selection):
|
||||
normalized = self._normalize_device_filter(device_filter)
|
||||
if normalized is None:
|
||||
logger.warning(f"Device filter {device_filter} is not in the device filter list.")
|
||||
continue
|
||||
self._set_device_filter_enabled(normalized, True)
|
||||
|
||||
def set_readout_priority_filter(
|
||||
self, filter_selection: ReadoutPriority | str | list[ReadoutPriority | str]
|
||||
):
|
||||
"""Enable one or more readout priority filters.
|
||||
|
||||
Args:
|
||||
filter_selection: Readout priority filter or filters to enable. Strings must match
|
||||
``ReadoutPriority.value``.
|
||||
"""
|
||||
for readout_filter in self._as_list(filter_selection):
|
||||
normalized = self._normalize_readout_filter(readout_filter)
|
||||
if normalized is None:
|
||||
logger.warning(
|
||||
f"Readout priority filter {readout_filter} is not in the readout priority list."
|
||||
)
|
||||
continue
|
||||
self._set_readout_filter_enabled(normalized, True)
|
||||
|
||||
def on_device_update(self, action: str, content: dict) -> None:
|
||||
"""Refresh filters when the BEC device configuration changes.
|
||||
|
||||
Args:
|
||||
action: Device update action emitted by BEC.
|
||||
content: Device update payload. Currently unused.
|
||||
"""
|
||||
if self._callback_id is None or getattr(self, "_destroyed", False):
|
||||
return
|
||||
if action in ["add", "remove", "reload"]:
|
||||
self.device_config_update.emit()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
if self._callback_id is not None:
|
||||
callback_id = self._callback_id
|
||||
self._callback_id = None
|
||||
self.bec_dispatcher.client.callbacks.remove(callback_id)
|
||||
super().cleanup()
|
||||
|
||||
def get_current_device(self) -> object:
|
||||
"""Return the current BEC device object.
|
||||
|
||||
Returns:
|
||||
Device object for the current combobox text.
|
||||
"""
|
||||
return self.get_device_object(self._device_name_from_text(self.currentText()))
|
||||
|
||||
@Slot(str)
|
||||
def check_validity(self, input_text: str) -> None:
|
||||
"""Validate current text and update visual state.
|
||||
|
||||
Args:
|
||||
input_text: Current combobox text.
|
||||
"""
|
||||
if self.validate_device(input_text):
|
||||
self._is_valid_input = True
|
||||
self.device_selected.emit(input_text)
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
self.device_reset.emit()
|
||||
self._update_validity_style(self._is_valid_input)
|
||||
|
||||
def validate_device(self, device: str | None) -> bool:
|
||||
"""Validate a device against the current filtered device selection.
|
||||
|
||||
Args:
|
||||
device: Device name or displayed device text to validate.
|
||||
|
||||
Returns:
|
||||
True if the device exists in the current BEC device manager and is present in the
|
||||
filtered combobox list.
|
||||
"""
|
||||
if not device:
|
||||
return False
|
||||
device_name = self._device_name_from_text(device)
|
||||
all_devices = [dev.name for dev in self.dev.enabled_devices]
|
||||
return device_name in self.devices and device_name in all_devices
|
||||
|
||||
def get_device_object(self, device: str) -> object:
|
||||
"""Return a device object by name.
|
||||
|
||||
Args:
|
||||
device: Device name.
|
||||
|
||||
Returns:
|
||||
BEC device object.
|
||||
|
||||
Raises:
|
||||
ValueError: If the device is not available in the device manager.
|
||||
"""
|
||||
dev = getattr(self.dev, device, None)
|
||||
if dev is None:
|
||||
raise ValueError(
|
||||
f"Device {device} is not found in the device manager {self.dev} as enabled device."
|
||||
)
|
||||
return dev
|
||||
|
||||
@staticmethod
|
||||
def _as_list(value):
|
||||
return value if isinstance(value, list) else [value]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_device_filter(value: BECDeviceFilter | str) -> BECDeviceFilter | None:
|
||||
if isinstance(value, BECDeviceFilter):
|
||||
return value
|
||||
return BECDeviceFilter._value2member_map_.get(value)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_readout_filter(value: ReadoutPriority | str) -> ReadoutPriority | None:
|
||||
if isinstance(value, ReadoutPriority):
|
||||
return value
|
||||
return ReadoutPriority._value2member_map_.get(value)
|
||||
|
||||
def _set_device_filter_enabled(self, device_filter: BECDeviceFilter, enabled: bool):
|
||||
if enabled and device_filter not in self._device_filter:
|
||||
self._device_filter.append(device_filter)
|
||||
elif not enabled and device_filter in self._device_filter:
|
||||
self._device_filter.remove(device_filter)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
def _set_readout_filter_enabled(self, readout_filter: ReadoutPriority, enabled: bool):
|
||||
if enabled and readout_filter not in self._readout_filter:
|
||||
self._readout_filter.append(readout_filter)
|
||||
elif not enabled and readout_filter in self._readout_filter:
|
||||
self._readout_filter.remove(readout_filter)
|
||||
self.update_devices_from_filters()
|
||||
|
||||
def _check_device_filter(
|
||||
self, device: Device | BECSignal | ComputedSignal | Positioner
|
||||
) -> bool:
|
||||
if not self.device_filter:
|
||||
return True
|
||||
return all(isinstance(device, self._device_handler[entry]) for entry in self.device_filter)
|
||||
|
||||
def _check_readout_filter(
|
||||
self, device: Device | BECSignal | ComputedSignal | Positioner
|
||||
) -> bool:
|
||||
return device.readout_priority in self.readout_filter
|
||||
|
||||
def _update_validity_style(self, is_valid: bool) -> None:
|
||||
if is_valid or not self.isEnabled():
|
||||
self.setStyleSheet("")
|
||||
return
|
||||
self.setStyleSheet("QComboBox { border: 1px solid red; }")
|
||||
|
||||
def _filter_devices_by_signal_class(
|
||||
self, devices: list[Device | BECSignal | ComputedSignal | Positioner]
|
||||
) -> list[Device | BECSignal | ComputedSignal | Positioner]:
|
||||
if not self.config.signal_class_filter:
|
||||
return devices
|
||||
signals = get_bec_signals_for_classes(
|
||||
client=self.client, signal_class_filter=self.config.signal_class_filter
|
||||
)
|
||||
allowed_devices = {device_name for device_name, _, _ in signals}
|
||||
return [device for device in devices if device.name in allowed_devices]
|
||||
|
||||
def _replace_items(self, devices: list[str]):
|
||||
items = [""] + devices if self._set_first_element_as_empty else devices
|
||||
replace_combobox_items(self, items, preserve_current_text=True, block_signals=True)
|
||||
self._completer_model.setStringList(devices)
|
||||
self.check_validity(self.currentText())
|
||||
|
||||
def _device_name_from_text(self, text: str) -> str:
|
||||
index = self.findText(text)
|
||||
if index >= 0 and isinstance(self.itemData(index), tuple):
|
||||
return self.itemData(index)[0]
|
||||
return text
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
@@ -235,10 +674,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
def _apply_filters():
|
||||
raw = class_input.text().strip()
|
||||
if raw:
|
||||
combo.signal_class_filter = [entry.strip() for entry in raw.split(",") if entry.strip()]
|
||||
else:
|
||||
combo.signal_class_filter = []
|
||||
combo.signal_class_filter = [entry.strip() for entry in raw.split(",") if entry.strip()]
|
||||
combo.filter_to_device = filter_device.isChecked()
|
||||
combo.filter_to_positioner = filter_positioner.isChecked()
|
||||
combo.filter_to_signal = filter_signal.isChecked()
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Signal, Slot
|
||||
from qtpy.QtGui import QPainter, QPaintEvent, QPen
|
||||
from qtpy.QtWidgets import QApplication, QCompleter, QLineEdit, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
|
||||
BECDeviceFilter,
|
||||
DeviceInputBase,
|
||||
DeviceInputConfig,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
"""
|
||||
Line edit widget for device input with autocomplete for device names.
|
||||
|
||||
Args:
|
||||
parent: Parent widget.
|
||||
client: BEC client object.
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
||||
readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
||||
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
|
||||
default: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
"""
|
||||
|
||||
device_selected = Signal(str)
|
||||
device_config_update = Signal()
|
||||
|
||||
PLUGIN = True
|
||||
RPC = False
|
||||
ICON_NAME = "edit_note"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config: DeviceInputConfig = None,
|
||||
gui_id: str | None = None,
|
||||
device_filter: BECDeviceFilter | list[BECDeviceFilter] | None = None,
|
||||
readout_priority_filter: (
|
||||
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
|
||||
) = None,
|
||||
available_devices: list[str] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._callback_id = None
|
||||
self.__is_valid_input = False
|
||||
self._accent_colors = get_accent_colors()
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self.completer = QCompleter(self)
|
||||
self.setCompleter(self.completer)
|
||||
|
||||
if arg_name is not None:
|
||||
self.config.arg_name = arg_name
|
||||
self.arg_name = arg_name
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.setMinimumSize(QSize(100, 0))
|
||||
|
||||
# We do not consider the config that is passed here, this produced problems
|
||||
# with QtDesigner, since config and input arguments may differ and resolve properly
|
||||
# Implementing this logic and config recoverage is postponed.
|
||||
# Set available devices if passed
|
||||
if available_devices is not None:
|
||||
self.set_available_devices(available_devices)
|
||||
# Set readout priority filter default is all
|
||||
if readout_priority_filter is not None:
|
||||
self.set_readout_priority_filter(readout_priority_filter)
|
||||
else:
|
||||
self.set_readout_priority_filter(
|
||||
[
|
||||
ReadoutPriority.MONITORED,
|
||||
ReadoutPriority.BASELINE,
|
||||
ReadoutPriority.ASYNC,
|
||||
ReadoutPriority.CONTINUOUS,
|
||||
ReadoutPriority.ON_REQUEST,
|
||||
]
|
||||
)
|
||||
# Device filter default is None
|
||||
if device_filter is not None:
|
||||
self.set_device_filter(device_filter)
|
||||
# Set default device if passed
|
||||
if default is not None:
|
||||
self.set_device(default)
|
||||
self._callback_id = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.on_device_update
|
||||
)
|
||||
self.device_config_update.connect(self.update_devices_from_filters)
|
||||
self.textChanged.connect(self.check_validity)
|
||||
self.check_validity(self.text())
|
||||
|
||||
@property
|
||||
def _is_valid_input(self) -> bool:
|
||||
"""
|
||||
Check if the current value is a valid device name.
|
||||
|
||||
Returns:
|
||||
bool: True if the current value is a valid device name, False otherwise.
|
||||
"""
|
||||
return self.__is_valid_input
|
||||
|
||||
@_is_valid_input.setter
|
||||
def _is_valid_input(self, value: bool) -> None:
|
||||
self.__is_valid_input = value
|
||||
|
||||
def on_device_update(self, action: str, content: dict) -> None:
|
||||
"""
|
||||
Callback for device update events. Triggers the device_update signal.
|
||||
|
||||
Args:
|
||||
action (str): The action that triggered the event.
|
||||
content (dict): The content of the config update.
|
||||
"""
|
||||
if action in ["add", "remove", "reload"]:
|
||||
self.device_config_update.emit()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
if self._callback_id is not None:
|
||||
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
|
||||
super().cleanup()
|
||||
|
||||
def get_current_device(self) -> object:
|
||||
"""
|
||||
Get the current device object based on the current value.
|
||||
|
||||
Returns:
|
||||
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
|
||||
"""
|
||||
dev_name = self.text()
|
||||
return self.get_device_object(dev_name)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
"""Extend the paint event to set the border color based on the validity of the input.
|
||||
|
||||
Args:
|
||||
event (PySide6.QtGui.QPaintEvent) : Paint event.
|
||||
"""
|
||||
# logger.info(f"Received paint event: {event} in {self.__class__}")
|
||||
super().paintEvent(event)
|
||||
|
||||
if self._is_valid_input is False and self.isEnabled() is True:
|
||||
painter = QPainter(self)
|
||||
pen = QPen()
|
||||
pen.setWidth(2)
|
||||
pen.setColor(self._accent_colors.emergency)
|
||||
painter.setPen(pen)
|
||||
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
|
||||
painter.end()
|
||||
|
||||
@Slot(str)
|
||||
def check_validity(self, input_text: str) -> None:
|
||||
"""
|
||||
Check if the current value is a valid device name.
|
||||
"""
|
||||
if self.validate_device(input_text) is True:
|
||||
self._is_valid_input = True
|
||||
self.device_selected.emit(input_text)
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
self.update()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import (
|
||||
SignalComboBox,
|
||||
)
|
||||
|
||||
app = QApplication([])
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
line_edit = DeviceLineEdit()
|
||||
line_edit.filter_to_positioner = True
|
||||
signal_line_edit = SignalComboBox()
|
||||
line_edit.textChanged.connect(signal_line_edit.set_device)
|
||||
line_edit.set_available_devices(["samx", "samy", "samz"])
|
||||
line_edit.set_device("samx")
|
||||
layout.addWidget(line_edit)
|
||||
layout.addWidget(signal_line_edit)
|
||||
widget.show()
|
||||
app.exec_()
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['device_line_edit.py']}
|
||||
@@ -1,37 +1,72 @@
|
||||
"""Editable combobox for selecting BEC device signals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QSize, Qt, Signal
|
||||
from qtpy.QtWidgets import QComboBox, QSizePolicy
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.device import Signal as BECSignal
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field
|
||||
from qtpy.QtCore import Property, QSize, QStringListModel, Qt, Signal, Slot
|
||||
from qtpy.QtWidgets import QComboBox, QCompleter, QSizePolicy
|
||||
|
||||
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.utils.filter_io import ComboBoxFilterHandler, FilterIO
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
||||
DeviceSignalInputBase,
|
||||
DeviceSignalInputBaseConfig,
|
||||
from bec_widgets.utils.filter_io import (
|
||||
get_bec_signals_for_classes,
|
||||
replace_combobox_items,
|
||||
signal_items_for_kind,
|
||||
)
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
class SignalComboBoxConfig(ConnectionConfig):
|
||||
"""Serializable configuration for :class:`SignalComboBox`.
|
||||
|
||||
Attributes:
|
||||
signal_filter: Enabled signal kind filters as ``Kind.name`` strings.
|
||||
signal_class_filter: Signal class names used to build the signal list from BEC.
|
||||
ndim_filter: Optional dimensionality filter for class-based signal lists.
|
||||
default: Signal selected by default.
|
||||
arg_name: Optional argument name used by scan/input widgets.
|
||||
device: Device name used to scope kind-based signal filtering.
|
||||
signals: Signal names available after filtering.
|
||||
autocomplete: Whether to use the explicit completer model instead of Qt's default
|
||||
editable-combobox completer.
|
||||
"""
|
||||
Line edit widget for device input with autocomplete for device names.
|
||||
|
||||
signal_filter: list[str] = Field(default_factory=list)
|
||||
signal_class_filter: list[str] = Field(default_factory=list)
|
||||
ndim_filter: int | list[int] | None = None
|
||||
default: str | None = None
|
||||
arg_name: str | None = None
|
||||
device: str | None = None
|
||||
signals: list[str] = Field(default_factory=list)
|
||||
autocomplete: bool = False
|
||||
|
||||
|
||||
class SignalComboBox(BECWidget, QComboBox):
|
||||
"""Editable combobox for selecting a signal from a BEC device.
|
||||
|
||||
Args:
|
||||
parent: Parent widget.
|
||||
client: BEC client object.
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device: Device name to filter signals from.
|
||||
signal_filter: Signal filter, list of signal kinds from ophyd Kind enum. Check DeviceSignalInputBase for more details.
|
||||
signal_class_filter: List of signal classes to filter the signals by. Only signals of these classes will be shown.
|
||||
ndim_filter: Dimensionality filter, int or list of ints to filter signals by their number of dimensions. If signal do not support ndim, it will be included in the selection anyway.
|
||||
default: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
store_signal_config: Whether to store the full signal config in the combobox item data.
|
||||
require_device: If True, signals are only shown/validated when a device is set.
|
||||
Signals:
|
||||
device_signal_changed: Emitted when the current text represents a valid signal selection.
|
||||
signal_reset: Emitted when validation fails and the selection should be treated as cleared.
|
||||
parent: Optional parent widget.
|
||||
client: Optional BEC client object.
|
||||
config: Signal combobox configuration as a ``SignalComboBoxConfig`` instance or
|
||||
dictionary.
|
||||
gui_id: Optional GUI identifier.
|
||||
device: Device name used to scope kind-based signal filtering.
|
||||
signal_filter: Signal kind filter or filters from ``Kind``.
|
||||
signal_class_filter: Signal class names used to build a class-based signal list.
|
||||
ndim_filter: Dimensionality filter for class-based signal lists.
|
||||
default: Signal selected during initialization.
|
||||
arg_name: Optional argument name used by scan/input widgets.
|
||||
store_signal_config: Whether to store each signal config in the item data.
|
||||
require_device: If True, class-based signal filtering requires a valid selected device.
|
||||
autocomplete: If True, use the explicit line-edit style completer. If False, keep
|
||||
Qt's default editable-combobox completion behavior.
|
||||
**kwargs: Additional keyword arguments passed to ``BECWidget``.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["set_signal", "set_device", "signals", "get_signal_name"]
|
||||
@@ -47,289 +82,453 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config: DeviceSignalInputBaseConfig | None = None,
|
||||
config: SignalComboBoxConfig | dict | None = None,
|
||||
gui_id: str | None = None,
|
||||
device: str | None = None,
|
||||
signal_filter: list[Kind] | None = None,
|
||||
signal_filter: list[Kind | str] | Kind | str | None = None,
|
||||
signal_class_filter: list[str] | None = None,
|
||||
ndim_filter: int | list[int] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
store_signal_config: bool = True,
|
||||
require_device: bool = False,
|
||||
autocomplete: bool | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self.config = self._process_config(config)
|
||||
super().__init__(parent=parent, client=client, config=self.config, gui_id=gui_id, **kwargs)
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self._device: str | None = None
|
||||
self._signal_filter: set[Kind] = set()
|
||||
self._signals: list[str | tuple[str, dict]] = []
|
||||
self._hinted_signals: list[tuple[str, dict]] = []
|
||||
self._normal_signals: list[tuple[str, dict]] = []
|
||||
self._config_signals: list[tuple[str, dict]] = []
|
||||
self._set_first_element_as_empty = False
|
||||
self._signal_class_filter = signal_class_filter or []
|
||||
self._store_signal_config = store_signal_config
|
||||
self._require_device = require_device
|
||||
self._is_valid_input = False
|
||||
self._completer_model = QStringListModel(self)
|
||||
|
||||
if arg_name is not None:
|
||||
self.config.arg_name = arg_name
|
||||
self.arg_name = arg_name
|
||||
if default is not None:
|
||||
self.set_device(default)
|
||||
|
||||
if signal_filter is None and self.config.signal_filter:
|
||||
signal_filter = self.config.signal_filter
|
||||
if signal_class_filter is None and self.config.signal_class_filter:
|
||||
self._signal_class_filter = self.config.signal_class_filter
|
||||
if ndim_filter is None and self.config.ndim_filter is not None:
|
||||
ndim_filter = self.config.ndim_filter
|
||||
if device is None and self.config.device:
|
||||
device = self.config.device
|
||||
if default is None and self.config.default:
|
||||
default = self.config.default
|
||||
if autocomplete is not None:
|
||||
self.config.autocomplete = autocomplete
|
||||
self.config.ndim_filter = ndim_filter
|
||||
|
||||
self.setEditable(True)
|
||||
self.setInsertPolicy(QComboBox.NoInsert)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.setMinimumSize(QSize(100, 0))
|
||||
self._set_first_element_as_empty = True
|
||||
self._signal_class_filter = signal_class_filter or []
|
||||
self._store_signal_config = store_signal_config
|
||||
self.config.ndim_filter = ndim_filter or None
|
||||
self._require_device = require_device
|
||||
self._is_valid_input = False
|
||||
if self.config.autocomplete:
|
||||
self.autocomplete = True
|
||||
|
||||
# Note: Runtime arguments (e.g. device, default, arg_name) intentionally take
|
||||
# precedence over values from the passed-in config. Full reconciliation and
|
||||
# restoration of state between designer-provided config and runtime arguments
|
||||
# is not yet implemented, as earlier attempts caused issues with QtDesigner.
|
||||
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.update_signals_from_filters
|
||||
)
|
||||
self.currentTextChanged.connect(self.on_text_changed)
|
||||
|
||||
# Kind filtering is always applied; class filtering is additive. If signal_filter is None,
|
||||
# we default to hinted+normal, even when signal_class_filter is empty or None. To disable
|
||||
# kinds, pass an explicit signal_filter or toggle include_* after init.
|
||||
if signal_filter is not None:
|
||||
self.set_filter(signal_filter)
|
||||
else:
|
||||
self.set_filter([Kind.hinted, Kind.normal, Kind.config])
|
||||
self.set_filter(signal_filter or [Kind.hinted, Kind.normal, Kind.config])
|
||||
|
||||
if device is not None:
|
||||
self.set_device(device)
|
||||
if default is not None:
|
||||
self.set_signal(default)
|
||||
self.check_validity(self.currentText())
|
||||
|
||||
@staticmethod
|
||||
def _process_config(config: SignalComboBoxConfig | dict | None) -> SignalComboBoxConfig:
|
||||
"""Normalize user-provided configuration.
|
||||
|
||||
Args:
|
||||
config: Existing configuration, configuration dictionary, or None.
|
||||
|
||||
Returns:
|
||||
A validated ``SignalComboBoxConfig`` instance.
|
||||
"""
|
||||
if config is None:
|
||||
return SignalComboBoxConfig(widget_class="SignalComboBox")
|
||||
return SignalComboBoxConfig.model_validate(config)
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_signal(self, signal: str):
|
||||
"""Set the current signal if it is available in the combobox.
|
||||
|
||||
Args:
|
||||
signal: Signal display text, object name, or component name to select.
|
||||
"""
|
||||
display_text = self._display_text_for_signal(signal)
|
||||
if display_text is None:
|
||||
logger.warning(
|
||||
f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}."
|
||||
)
|
||||
return
|
||||
self.setCurrentText(display_text)
|
||||
self.config.default = signal
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_device(self, device: str | None):
|
||||
"""
|
||||
Set the device. When signal_class_filter is active, ensures base-class
|
||||
logic runs and then refreshes the signal list to show only signals from
|
||||
that device matching the signal class filter.
|
||||
"""Set the device that scopes kind-based signal filtering.
|
||||
|
||||
Args:
|
||||
device(str): device name.
|
||||
device: Device name to use for signal filtering. Invalid or empty values clear
|
||||
the current device and signal selection.
|
||||
"""
|
||||
super().set_device(device)
|
||||
|
||||
if self._signal_class_filter:
|
||||
# Refresh the signal list to show only this device's signals
|
||||
self.update_signals_from_signal_classes()
|
||||
previous_device = self._device
|
||||
valid_device = device if self.validate_device(device) else None
|
||||
self._device = valid_device
|
||||
self.config.device = self._device
|
||||
if valid_device is None or valid_device != previous_device:
|
||||
self.setCurrentText("")
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(dict, dict)
|
||||
def update_signals_from_filters(
|
||||
self, content: dict | None = None, metadata: dict | None = None
|
||||
):
|
||||
"""Update the filters for the combobox.
|
||||
When signal_class_filter is active, skip the normal Kind-based filtering.
|
||||
@SafeSlot(str, dict)
|
||||
def update_signals_from_filters(self, action: str | None = None, content: dict | None = None):
|
||||
"""Refresh available signals from the current device and filters.
|
||||
|
||||
Args:
|
||||
content (dict | None): Content dictionary from BEC event.
|
||||
metadata (dict | None): Metadata dictionary from BEC event.
|
||||
action: Optional BEC device update action. If provided, only device list changing
|
||||
actions trigger a refresh.
|
||||
content: Optional callback payload from BEC device updates. Currently unused.
|
||||
"""
|
||||
super().update_signals_from_filters(content, metadata)
|
||||
if self._device_update_register is None or getattr(self, "_destroyed", False):
|
||||
return
|
||||
|
||||
if action is not None and action not in ["add", "remove", "reload"]:
|
||||
return
|
||||
|
||||
self.config.signal_filter = [kind.name for kind in self.signal_filter]
|
||||
|
||||
if self._signal_class_filter:
|
||||
self.update_signals_from_signal_classes()
|
||||
return
|
||||
# pylint: disable=protected-access
|
||||
if FilterIO._find_handler(self) is ComboBoxFilterHandler:
|
||||
if len(self._config_signals) > 0:
|
||||
self.insertItem(
|
||||
len(self._hinted_signals) + len(self._normal_signals), "Config Signals"
|
||||
)
|
||||
self.model().item(len(self._hinted_signals) + len(self._normal_signals)).setEnabled(
|
||||
False
|
||||
)
|
||||
if len(self._normal_signals) > 0:
|
||||
self.insertItem(len(self._hinted_signals), "Normal Signals")
|
||||
self.model().item(len(self._hinted_signals)).setEnabled(False)
|
||||
if len(self._hinted_signals) > 0:
|
||||
self.insertItem(0, "Hinted Signals")
|
||||
self.model().item(0).setEnabled(False)
|
||||
|
||||
if not self.validate_device(self._device):
|
||||
self._device = None
|
||||
self.config.device = None
|
||||
self._set_signal_groups([], [], [])
|
||||
return
|
||||
|
||||
device = self.get_device_object(self._device)
|
||||
device_info = device._info.get("signals", {})
|
||||
|
||||
if isinstance(device, BECSignal):
|
||||
self._set_signal_groups([(self._device, {})], [], [])
|
||||
return
|
||||
|
||||
self._set_signal_groups(
|
||||
signal_items_for_kind(
|
||||
kind=Kind.hinted,
|
||||
signal_filter=self.signal_filter,
|
||||
device_info=device_info,
|
||||
device_name=self._device,
|
||||
),
|
||||
signal_items_for_kind(
|
||||
kind=Kind.normal,
|
||||
signal_filter=self.signal_filter,
|
||||
device_info=device_info,
|
||||
device_name=self._device,
|
||||
),
|
||||
signal_items_for_kind(
|
||||
kind=Kind.config,
|
||||
signal_filter=self.signal_filter,
|
||||
device_info=device_info,
|
||||
device_name=self._device,
|
||||
),
|
||||
)
|
||||
|
||||
@Property(str)
|
||||
def device(self) -> str:
|
||||
"""Selected device."""
|
||||
return self._device or ""
|
||||
|
||||
@device.setter
|
||||
def device(self, value: str):
|
||||
self.set_device(value)
|
||||
|
||||
@Property(bool)
|
||||
def include_hinted_signals(self):
|
||||
"""Include hinted signals."""
|
||||
return Kind.hinted in self.signal_filter
|
||||
|
||||
@include_hinted_signals.setter
|
||||
def include_hinted_signals(self, value: bool):
|
||||
self._set_kind_filter_enabled(Kind.hinted, value)
|
||||
|
||||
@Property(bool)
|
||||
def include_normal_signals(self):
|
||||
"""Include normal signals."""
|
||||
return Kind.normal in self.signal_filter
|
||||
|
||||
@include_normal_signals.setter
|
||||
def include_normal_signals(self, value: bool):
|
||||
self._set_kind_filter_enabled(Kind.normal, value)
|
||||
|
||||
@Property(bool)
|
||||
def include_config_signals(self):
|
||||
"""Include config signals."""
|
||||
return Kind.config in self.signal_filter
|
||||
|
||||
@include_config_signals.setter
|
||||
def include_config_signals(self, value: bool):
|
||||
self._set_kind_filter_enabled(Kind.config, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def set_first_element_as_empty(self) -> bool:
|
||||
"""
|
||||
Whether the first element in the combobox should be empty.
|
||||
This is useful to allow the user to select a device from the list.
|
||||
"""
|
||||
"""Whether an empty choice is inserted as the first item."""
|
||||
return self._set_first_element_as_empty
|
||||
|
||||
@set_first_element_as_empty.setter
|
||||
def set_first_element_as_empty(self, value: bool) -> None:
|
||||
"""
|
||||
Set whether the first element in the combobox should be empty.
|
||||
This is useful to allow the user to select a device from the list.
|
||||
|
||||
Args:
|
||||
value (bool): True if the first element should be empty, False otherwise.
|
||||
"""
|
||||
self._set_first_element_as_empty = value
|
||||
if self._set_first_element_as_empty:
|
||||
self.insertItem(0, "")
|
||||
if value:
|
||||
if self.count() == 0 or self.itemText(0) != "":
|
||||
self.insertItem(0, "")
|
||||
self.setCurrentIndex(0)
|
||||
else:
|
||||
if self.count() > 0 and self.itemText(0) == "":
|
||||
self.removeItem(0)
|
||||
elif self.count() > 0 and self.itemText(0) == "":
|
||||
self.removeItem(0)
|
||||
|
||||
@SafeProperty("QStringList")
|
||||
def signal_class_filter(self) -> list[str]:
|
||||
"""
|
||||
Get the list of signal classes to filter.
|
||||
|
||||
Returns:
|
||||
list[str]: List of signal class names to filter.
|
||||
"""
|
||||
"""Signal class names used to build the signal list."""
|
||||
return self._signal_class_filter
|
||||
|
||||
@signal_class_filter.setter
|
||||
def signal_class_filter(self, value: list[str] | None):
|
||||
"""
|
||||
Set the signal class filter.
|
||||
|
||||
Args:
|
||||
value (list[str] | None): List of signal class names to filter, or None/empty
|
||||
to disable class-based filtering and revert to the default behavior.
|
||||
"""
|
||||
normalized_value = value or []
|
||||
self._signal_class_filter = normalized_value
|
||||
self.config.signal_class_filter = normalized_value
|
||||
if self._signal_class_filter:
|
||||
self.update_signals_from_signal_classes()
|
||||
else:
|
||||
self.update_signals_from_filters()
|
||||
self._signal_class_filter = value or []
|
||||
self.config.signal_class_filter = self._signal_class_filter
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@SafeProperty(int)
|
||||
def ndim_filter(self) -> int:
|
||||
"""Dimensionality filter for signals."""
|
||||
"""Dimensionality filter for signal-class based lists."""
|
||||
return self.config.ndim_filter if isinstance(self.config.ndim_filter, int) else -1
|
||||
|
||||
@ndim_filter.setter
|
||||
def ndim_filter(self, value: int):
|
||||
self.config.ndim_filter = None if value < 0 else value
|
||||
if self._signal_class_filter:
|
||||
self.update_signals_from_signal_classes(ndim_filter=self.config.ndim_filter)
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def require_device(self) -> bool:
|
||||
"""
|
||||
If True, signals are only shown/validated when a device is set.
|
||||
|
||||
Note:
|
||||
This property affects list rebuilding only when a signal_class_filter
|
||||
is active. Without a signal class filter, the available signals are
|
||||
managed by the standard Kind-based filtering.
|
||||
"""
|
||||
"""Whether validation/listing requires a selected device."""
|
||||
return self._require_device
|
||||
|
||||
@require_device.setter
|
||||
def require_device(self, value: bool):
|
||||
self._require_device = value
|
||||
# Rebuild list when toggled, but only when using signal_class_filter
|
||||
if self._signal_class_filter:
|
||||
self.update_signals_from_signal_classes()
|
||||
self.update_signals_from_filters()
|
||||
|
||||
def set_to_obj_name(self, obj_name: str) -> bool:
|
||||
"""
|
||||
Set the combobox to the object name of the signal.
|
||||
@SafeProperty(bool)
|
||||
def autocomplete(self) -> bool:
|
||||
"""Whether autocomplete suggestions are enabled while editing."""
|
||||
return self.config.autocomplete
|
||||
|
||||
@autocomplete.setter
|
||||
def autocomplete(self, value: bool) -> None:
|
||||
self.config.autocomplete = value
|
||||
if value:
|
||||
self.setCompleter(QCompleter(self._completer_model, self))
|
||||
else:
|
||||
self.setCompleter(QCompleter(self.model(), self))
|
||||
|
||||
@property
|
||||
def signals(self) -> list[str | tuple[str, dict]]:
|
||||
"""Available signals after filtering."""
|
||||
return self._signals
|
||||
|
||||
@signals.setter
|
||||
def signals(self, value: list[str | tuple[str, dict]]):
|
||||
self._signals = value
|
||||
self.config.signals = [entry[0] if isinstance(entry, tuple) else entry for entry in value]
|
||||
self._replace_signal_items()
|
||||
|
||||
@property
|
||||
def signal_filter(self) -> set[Kind]:
|
||||
"""Signal kind filters."""
|
||||
return self._signal_filter
|
||||
|
||||
@property
|
||||
def is_valid_input(self) -> bool:
|
||||
"""Whether the current text represents a valid signal selection."""
|
||||
return self._is_valid_input
|
||||
|
||||
def setEnabled(self, enabled: bool) -> None: # noqa: N802
|
||||
super().setEnabled(enabled)
|
||||
self._update_validity_style(self._is_valid_input)
|
||||
|
||||
@property
|
||||
def selected_signal_comp_name(self) -> str:
|
||||
"""Component name for the current signal, falling back to object name."""
|
||||
index = self._find_signal_index(self.currentText())
|
||||
if index < 0:
|
||||
return self.get_signal_name()
|
||||
signal_info = self.itemData(index)
|
||||
if isinstance(signal_info, dict):
|
||||
return signal_info.get("component_name") or self.get_signal_name()
|
||||
return self.get_signal_name()
|
||||
|
||||
def set_filter(self, filter_selection: Kind | str | list[Kind | str] | None):
|
||||
"""Enable one or more signal kind filters.
|
||||
|
||||
Args:
|
||||
obj_name (str): Object name of the signal.
|
||||
filter_selection: Filter or filters to enable. Strings must match ``Kind`` member
|
||||
names.
|
||||
"""
|
||||
if filter_selection is None:
|
||||
return
|
||||
filters = filter_selection if isinstance(filter_selection, list) else [filter_selection]
|
||||
for signal_filter in filters:
|
||||
kind = self._normalize_kind(signal_filter)
|
||||
if kind is not None:
|
||||
self._signal_filter.add(kind)
|
||||
self.update_signals_from_filters()
|
||||
|
||||
def get_device_object(self, device: str) -> object | None:
|
||||
"""Return a BEC device object by name.
|
||||
|
||||
Args:
|
||||
device: Device name.
|
||||
|
||||
Returns:
|
||||
bool: True if the object name was found and set, False otherwise.
|
||||
Device object if it exists in the device manager, otherwise None.
|
||||
"""
|
||||
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
|
||||
dev = getattr(self.dev, device, None)
|
||||
if dev is None:
|
||||
logger.warning(f"Device {device} not found in devicemanager.")
|
||||
return None
|
||||
return dev
|
||||
|
||||
def validate_device(self, device: str | None, raise_on_false: bool = False) -> bool:
|
||||
"""Validate that a device exists in the current device manager.
|
||||
|
||||
Args:
|
||||
device: Device name to validate.
|
||||
raise_on_false: If True, raise instead of returning False for missing devices.
|
||||
|
||||
Returns:
|
||||
True if the device exists in the current device manager.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``raise_on_false`` is True and the device is missing.
|
||||
"""
|
||||
if device in self.dev:
|
||||
return True
|
||||
if raise_on_false:
|
||||
raise ValueError(f"Device {device} not found in devicemanager.")
|
||||
return False
|
||||
|
||||
def set_to_first_enabled(self) -> bool:
|
||||
"""
|
||||
Set the combobox to the first enabled item.
|
||||
def validate_signal(self, signal: str) -> bool:
|
||||
"""Validate a signal by display text, object name, or component name.
|
||||
|
||||
Args:
|
||||
signal: Signal display text, object name, or component name.
|
||||
|
||||
Returns:
|
||||
bool: True if an enabled item was found and set, False otherwise.
|
||||
True if the signal is present in the current filtered signal list.
|
||||
"""
|
||||
for i in range(self.count()):
|
||||
if self.model().item(i).isEnabled():
|
||||
self.setCurrentIndex(i)
|
||||
if not signal:
|
||||
return False
|
||||
return self._display_text_for_signal(signal) is not None
|
||||
|
||||
def set_to_obj_name(self, obj_name: str) -> bool:
|
||||
"""Select the item whose signal config has the given object name.
|
||||
|
||||
Args:
|
||||
obj_name: Signal object name to select.
|
||||
|
||||
Returns:
|
||||
True if a matching signal was selected.
|
||||
"""
|
||||
index = self._find_signal_index(obj_name)
|
||||
if index < 0:
|
||||
return False
|
||||
self.setCurrentIndex(index)
|
||||
return True
|
||||
|
||||
def set_to_first_enabled(self) -> bool:
|
||||
"""Select the first enabled item.
|
||||
|
||||
Returns:
|
||||
True if an enabled item was found and selected.
|
||||
"""
|
||||
for index in range(self.count()):
|
||||
item = self.model().item(index)
|
||||
if item is not None and item.isEnabled():
|
||||
self.setCurrentIndex(index)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_signal_name(self) -> str:
|
||||
"""
|
||||
Get the signal name from the combobox.
|
||||
"""Return the selected signal object name when available.
|
||||
|
||||
Returns:
|
||||
str: The signal name.
|
||||
Signal object name from item data, or the current display text when no item data
|
||||
is available.
|
||||
"""
|
||||
signal_name = self.currentText()
|
||||
index = self.findText(signal_name)
|
||||
if index == -1:
|
||||
return signal_name
|
||||
current_text = self.currentText()
|
||||
index = self._find_signal_index(current_text)
|
||||
if index < 0:
|
||||
return current_text
|
||||
|
||||
signal_info = self.itemData(index)
|
||||
if signal_info:
|
||||
signal_name = signal_info.get("obj_name", signal_name)
|
||||
|
||||
return signal_name if signal_name else ""
|
||||
if isinstance(signal_info, dict):
|
||||
return signal_info.get("obj_name") or current_text
|
||||
return current_text
|
||||
|
||||
def get_signal_config(self) -> dict | None:
|
||||
"""
|
||||
Get the signal config from the combobox for the currently selected signal.
|
||||
"""Return the selected signal config if item-data storage is enabled.
|
||||
|
||||
Returns:
|
||||
dict | None: The signal configuration dictionary or None if not available.
|
||||
Selected signal configuration dictionary, or None when item-data storage is disabled
|
||||
or the current item has no configuration.
|
||||
"""
|
||||
if not self._store_signal_config:
|
||||
return None
|
||||
|
||||
index = self.currentIndex()
|
||||
if index == -1:
|
||||
return None
|
||||
|
||||
signal_info = self.itemData(index)
|
||||
return signal_info if signal_info else None
|
||||
signal_info = self.itemData(self.currentIndex())
|
||||
return signal_info if isinstance(signal_info, dict) else None
|
||||
|
||||
def update_signals_from_signal_classes(self, ndim_filter: int | list[int] | None = None):
|
||||
"""
|
||||
Update the combobox with signals filtered by signal classes and optionally by ndim.
|
||||
Uses device_manager.get_bec_signals() to retrieve signals.
|
||||
If a device is set, only shows signals from that device.
|
||||
"""Refresh signals from ``device_manager.get_bec_signals`` for class-based filtering.
|
||||
|
||||
Args:
|
||||
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
|
||||
If provided, only signals with matching ndim will be included.
|
||||
Can be a single int or a list of ints. Use None to include all dimensions.
|
||||
If not provided, uses the previously set ndim_filter.
|
||||
ndim_filter: Optional dimensionality filter overriding the configured value for
|
||||
this refresh.
|
||||
"""
|
||||
if not self._signal_class_filter:
|
||||
return
|
||||
|
||||
if self._require_device and not self._device:
|
||||
self.clear()
|
||||
self._signals = []
|
||||
FilterIO.set_selection(widget=self, selection=self._signals)
|
||||
self.signals = []
|
||||
return
|
||||
|
||||
# Update stored ndim_filter if a new one is provided
|
||||
if ndim_filter is not None:
|
||||
self.config.ndim_filter = ndim_filter
|
||||
|
||||
self.clear()
|
||||
|
||||
# Get signals with ndim filtering applied at the FilterIO level
|
||||
signals = FilterIO.update_with_signal_class(
|
||||
widget=self,
|
||||
signal_class_filter=self._signal_class_filter,
|
||||
signals = get_bec_signals_for_classes(
|
||||
client=self.client,
|
||||
ndim_filter=self.config.ndim_filter, # Pass ndim_filter to FilterIO
|
||||
signal_class_filter=self._signal_class_filter,
|
||||
ndim_filter=self.config.ndim_filter,
|
||||
)
|
||||
|
||||
# Track signals for validation and FilterIO selection
|
||||
self._signals = []
|
||||
|
||||
combo_items: list[str | tuple[str, dict]] = []
|
||||
item_tooltips: dict[int, str] = {}
|
||||
for device_name, signal_name, signal_config in signals:
|
||||
# Filter by device if one is set
|
||||
if self._device and device_name != self._device:
|
||||
continue
|
||||
if self._signal_filter:
|
||||
@@ -339,76 +538,159 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
}:
|
||||
continue
|
||||
|
||||
# Get storage_name for tooltip
|
||||
storage_name = signal_config.get("storage_name", "")
|
||||
|
||||
# Store the full signal config as item data if requested
|
||||
if self._store_signal_config:
|
||||
self.addItem(signal_name, signal_config)
|
||||
combo_items.append((signal_name, signal_config))
|
||||
else:
|
||||
self.addItem(signal_name)
|
||||
combo_items.append(signal_name)
|
||||
|
||||
# Track for validation
|
||||
self._signals.append(signal_name)
|
||||
|
||||
# Set tooltip to storage_name (Qt.ToolTipRole = 3)
|
||||
storage_name = signal_config.get("storage_name", "")
|
||||
if storage_name:
|
||||
self.setItemData(self.count() - 1, storage_name, Qt.ItemDataRole.ToolTipRole)
|
||||
item_tooltips[len(combo_items) - 1] = storage_name
|
||||
|
||||
# Keep FilterIO selection in sync for validate_signal
|
||||
FilterIO.set_selection(widget=self, selection=self._signals)
|
||||
self.signals = combo_items
|
||||
tooltip_offset = 1 if self._set_first_element_as_empty and self.count() else 0
|
||||
for item_index, tooltip in item_tooltips.items():
|
||||
self.setItemData(item_index + tooltip_offset, tooltip, Qt.ItemDataRole.ToolTipRole)
|
||||
self.check_validity(self.currentText())
|
||||
|
||||
@SafeSlot()
|
||||
def reset_selection(self):
|
||||
"""Reset the selection of the combobox."""
|
||||
self.clear()
|
||||
self.setItemText(0, "Select a device")
|
||||
"""Reset the current selection and refresh available signals."""
|
||||
self.setCurrentText("")
|
||||
self.update_signals_from_filters()
|
||||
self.device_signal_changed.emit("")
|
||||
|
||||
@SafeSlot(str)
|
||||
def on_text_changed(self, text: str):
|
||||
"""Validate and emit only when the signal is valid.
|
||||
For a positioner, the readback value has to be renamed to the device name.
|
||||
When using signal_class_filter, device validation is skipped.
|
||||
"""Validate the current text when edited or selected.
|
||||
|
||||
Args:
|
||||
text: Current combobox text.
|
||||
"""
|
||||
self.check_validity(text)
|
||||
|
||||
@Slot(str)
|
||||
def check_validity(self, input_text: str) -> None:
|
||||
"""Check if the current value is a valid signal and emit only when valid."""
|
||||
"""Validate current text and update visual state.
|
||||
|
||||
Args:
|
||||
input_text: Current combobox text.
|
||||
"""
|
||||
if self._signal_class_filter:
|
||||
if self._require_device and (not self._device or not input_text):
|
||||
is_valid = False
|
||||
else:
|
||||
is_valid = self.validate_signal(input_text)
|
||||
is_valid = not (self._require_device and not self._device) and self.validate_signal(
|
||||
input_text
|
||||
)
|
||||
else:
|
||||
if self._require_device and not self.validate_device(self._device):
|
||||
is_valid = False
|
||||
else:
|
||||
is_valid = self.validate_device(self._device) and self.validate_signal(input_text)
|
||||
is_valid = self.validate_device(self._device) and self.validate_signal(input_text)
|
||||
|
||||
if is_valid:
|
||||
self._is_valid_input = True
|
||||
self.device_signal_changed.emit(input_text)
|
||||
self.setStyleSheet("border: 1px solid transparent;")
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
self.signal_reset.emit()
|
||||
if self.isEnabled():
|
||||
self.setStyleSheet("border: 1px solid red;")
|
||||
self._update_validity_style(self._is_valid_input)
|
||||
|
||||
@property
|
||||
def selected_signal_comp_name(self) -> str:
|
||||
return dict(self.signals).get(self.currentText(), {}).get("component_name", "")
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
if self._device_update_register is not None:
|
||||
callback_id = self._device_update_register
|
||||
self._device_update_register = None
|
||||
self.bec_dispatcher.client.callbacks.remove(callback_id)
|
||||
super().cleanup()
|
||||
|
||||
@property
|
||||
def is_valid_input(self) -> bool:
|
||||
"""Whether the current text represents a valid signal selection."""
|
||||
return self._is_valid_input
|
||||
@staticmethod
|
||||
def _normalize_kind(value: Kind | str) -> Kind | None:
|
||||
if isinstance(value, Kind):
|
||||
return value
|
||||
return Kind.__members__.get(value) or Kind.__members__.get(value.lower())
|
||||
|
||||
def _set_kind_filter_enabled(self, kind: Kind, enabled: bool):
|
||||
if enabled:
|
||||
self._signal_filter.add(kind)
|
||||
else:
|
||||
self._signal_filter.discard(kind)
|
||||
self.update_signals_from_filters()
|
||||
|
||||
def _set_signal_groups(
|
||||
self,
|
||||
hinted: list[tuple[str, dict]],
|
||||
normal: list[tuple[str, dict]],
|
||||
config: list[tuple[str, dict]],
|
||||
) -> None:
|
||||
self._hinted_signals = hinted
|
||||
self._normal_signals = normal
|
||||
self._config_signals = config
|
||||
self.signals = self._hinted_signals + self._normal_signals + self._config_signals
|
||||
self._insert_group_headers()
|
||||
self.check_validity(self.currentText())
|
||||
|
||||
def _update_validity_style(self, is_valid: bool) -> None:
|
||||
if is_valid or not self.isEnabled():
|
||||
self.setStyleSheet("")
|
||||
return
|
||||
self.setStyleSheet("QComboBox { border: 1px solid red; }")
|
||||
|
||||
def _replace_signal_items(self, items: list[str | tuple[str, dict]] | None = None):
|
||||
combo_items = self._signals if items is None else items
|
||||
display_items = [""] + combo_items if self._set_first_element_as_empty else combo_items
|
||||
replace_combobox_items(
|
||||
self, display_items, preserve_current_text=bool(self.currentText()), block_signals=True
|
||||
)
|
||||
self._completer_model.setStringList(
|
||||
[entry if isinstance(entry, str) else entry[0] for entry in combo_items]
|
||||
)
|
||||
|
||||
def _insert_group_headers(self):
|
||||
offset = (
|
||||
1
|
||||
if self._set_first_element_as_empty and self.count() > 0 and self.itemText(0) == ""
|
||||
else 0
|
||||
)
|
||||
if self._config_signals:
|
||||
index = offset + len(self._hinted_signals) + len(self._normal_signals)
|
||||
self.insertItem(index, "Config Signals")
|
||||
self.model().item(index).setEnabled(False)
|
||||
if self._normal_signals:
|
||||
index = offset + len(self._hinted_signals)
|
||||
self.insertItem(index, "Normal Signals")
|
||||
self.model().item(index).setEnabled(False)
|
||||
if self._hinted_signals:
|
||||
index = offset
|
||||
self.insertItem(index, "Hinted Signals")
|
||||
self.model().item(index).setEnabled(False)
|
||||
|
||||
def _display_text_for_signal(self, signal: str) -> str | None:
|
||||
for entry in self._signals:
|
||||
display_text = entry[0] if isinstance(entry, tuple) else entry
|
||||
if display_text == signal:
|
||||
return display_text
|
||||
if isinstance(entry, tuple) and self._signal_info_matches(entry[1], signal):
|
||||
return display_text
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _signal_info_matches(signal_info: dict, signal: str) -> bool:
|
||||
if not signal:
|
||||
return False
|
||||
return signal in {
|
||||
signal_info.get("obj_name"),
|
||||
signal_info.get("component_name"),
|
||||
signal_info.get("component_name", "").replace(".", "_"),
|
||||
}
|
||||
|
||||
def _find_signal_index(self, signal: str) -> int:
|
||||
index = self.findText(signal)
|
||||
if index >= 0:
|
||||
return index
|
||||
for item_index in range(self.count()):
|
||||
signal_info = self.itemData(item_index)
|
||||
if isinstance(signal_info, dict) and self._signal_info_matches(signal_info, signal):
|
||||
return item_index
|
||||
return -1
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
@@ -417,16 +699,14 @@ if __name__ == "__main__": # pragma: no cover
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
layout = QVBoxLayout(widget)
|
||||
box = SignalComboBox(
|
||||
device="waveform",
|
||||
signal_class_filter=["AsyncSignal", "AsyncMultiSignal"],
|
||||
ndim_filter=[1, 2],
|
||||
store_signal_config=True,
|
||||
signal_filter=[Kind.hinted, Kind.normal, Kind.config],
|
||||
) # change signal filter class to test
|
||||
box.setEditable(True)
|
||||
)
|
||||
layout.addWidget(box)
|
||||
widget.show()
|
||||
app.exec_()
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit_plugin import (
|
||||
SignalLineEditPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(SignalLineEditPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,169 +0,0 @@
|
||||
from bec_lib.device import Positioner
|
||||
from qtpy.QtCore import QSize, Signal, Slot
|
||||
from qtpy.QtGui import QPainter, QPaintEvent, QPen
|
||||
from qtpy.QtWidgets import QCompleter, QLineEdit, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
||||
DeviceSignalInputBase,
|
||||
)
|
||||
|
||||
|
||||
class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
|
||||
"""
|
||||
Line edit widget for device input with autocomplete for device names.
|
||||
|
||||
Args:
|
||||
parent: Parent widget.
|
||||
client: BEC client object.
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
||||
default: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["_is_valid_input", "set_signal", "set_device", "signals"]
|
||||
|
||||
device_signal_changed = Signal(str)
|
||||
|
||||
PLUGIN = True
|
||||
RPC = False
|
||||
ICON_NAME = "vital_signs"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config: DeviceSignalInputBase = None,
|
||||
gui_id: str | None = None,
|
||||
device: str | None = None,
|
||||
signal_filter: str | list[str] | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self.__is_valid_input = False
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self._accent_colors = get_accent_colors()
|
||||
self.completer = QCompleter(self)
|
||||
self.setCompleter(self.completer)
|
||||
if arg_name is not None:
|
||||
self.config.arg_name = arg_name
|
||||
self.arg_name = arg_name
|
||||
if default is not None:
|
||||
self.set_device(default)
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.setMinimumSize(QSize(100, 0))
|
||||
# We do not consider the config that is passed here, this produced problems
|
||||
# with QtDesigner, since config and input arguments may differ and resolve properly
|
||||
# Implementing this logic and config recoverage is postponed.
|
||||
if signal_filter is not None:
|
||||
self.set_filter(signal_filter)
|
||||
else:
|
||||
self.set_filter([Kind.hinted, Kind.normal, Kind.config])
|
||||
if device is not None:
|
||||
self.set_device(device)
|
||||
if default is not None:
|
||||
self.set_signal(default)
|
||||
self.textChanged.connect(self.check_validity)
|
||||
self.check_validity(self.text())
|
||||
|
||||
@property
|
||||
def _is_valid_input(self) -> bool:
|
||||
"""
|
||||
Check if the current value is a valid device name.
|
||||
|
||||
Returns:
|
||||
bool: True if the current value is a valid device name, False otherwise.
|
||||
"""
|
||||
return self.__is_valid_input
|
||||
|
||||
@_is_valid_input.setter
|
||||
def _is_valid_input(self, value: bool) -> None:
|
||||
self.__is_valid_input = value
|
||||
|
||||
def get_current_device(self) -> object:
|
||||
"""
|
||||
Get the current device object based on the current value.
|
||||
|
||||
Returns:
|
||||
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
|
||||
"""
|
||||
dev_name = self.text()
|
||||
return self.get_device_object(dev_name)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
"""Extend the paint event to set the border color based on the validity of the input.
|
||||
|
||||
Args:
|
||||
event (PySide6.QtGui.QPaintEvent) : Paint event.
|
||||
"""
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
pen = QPen()
|
||||
pen.setWidth(2)
|
||||
|
||||
if self._is_valid_input is False and self.isEnabled() is True:
|
||||
pen.setColor(self._accent_colors.emergency)
|
||||
painter.setPen(pen)
|
||||
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
|
||||
|
||||
@Slot(str)
|
||||
def check_validity(self, input_text: str) -> None:
|
||||
"""
|
||||
Check if the current value is a valid device name.
|
||||
"""
|
||||
if self.validate_signal(input_text) is True:
|
||||
self._is_valid_input = True
|
||||
self.on_text_changed(input_text)
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
self.update()
|
||||
|
||||
@Slot(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.
|
||||
For a positioner, the readback value has to be renamed to the device name.
|
||||
|
||||
Args:
|
||||
text (str): Text in the combobox.
|
||||
"""
|
||||
print("test")
|
||||
if self.validate_device(self.device) is False:
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||
DeviceComboBox,
|
||||
)
|
||||
|
||||
app = QApplication([])
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
device_line_edit = DeviceComboBox()
|
||||
device_line_edit.filter_to_positioner = True
|
||||
signal_line_edit = SignalLineEdit()
|
||||
device_line_edit.device_selected.connect(signal_line_edit.set_device)
|
||||
|
||||
layout.addWidget(device_line_edit)
|
||||
layout.addWidget(signal_line_edit)
|
||||
widget.show()
|
||||
app.exec_()
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['signal_line_edit.py']}
|
||||
@@ -1,59 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit import (
|
||||
SignalLineEdit,
|
||||
)
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='SignalLineEdit' name='signal_line_edit'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = SignalLineEdit(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Input Widgets"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(SignalLineEdit.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "signal_line_edit"
|
||||
|
||||
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 "SignalLineEdit"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -14,7 +14,6 @@ from qtpy.QtWidgets import (
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -25,8 +24,8 @@ from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
|
||||
from bec_widgets.widgets.control.scan_control.scan_info_adapter import ScanInfoAdapter
|
||||
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
|
||||
class ScanParameterConfig(BaseModel):
|
||||
@@ -84,7 +83,6 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.kwarg_boxes = []
|
||||
self.expert_mode = False # TODO implement in the future versions
|
||||
self.previous_scan = None
|
||||
self.last_scan_found = None
|
||||
|
||||
# Widget Default Parameters
|
||||
self.config.default_scan = default_scan
|
||||
@@ -97,6 +95,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
self._hide_scan_control_buttons = False
|
||||
self._hide_metadata = False
|
||||
self._hide_scan_selection_combobox = False
|
||||
self._scan_info_adapter = ScanInfoAdapter()
|
||||
|
||||
# Create and set main layout
|
||||
self._init_UI()
|
||||
@@ -123,17 +122,12 @@ class ScanControl(BECWidget, QWidget):
|
||||
scan_selection_layout.addWidget(self.comboBox_scan_selection, 1)
|
||||
self.scan_selection_group.layout().addLayout(scan_selection_layout)
|
||||
|
||||
# Label to reload the last scan parameters within scan selection group box
|
||||
self.toggle_layout = QHBoxLayout()
|
||||
self.toggle_layout.addSpacerItem(
|
||||
QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
# Button to reload the last scan parameters on demand.
|
||||
self.last_scan_button = QPushButton(
|
||||
"Restore last scan parameters", self.scan_selection_group
|
||||
)
|
||||
self.last_scan_label = QLabel("Restore last scan parameters", self.scan_selection_group)
|
||||
self.toggle = ToggleSwitch(parent=self.scan_selection_group, checked=False)
|
||||
self.toggle.enabled.connect(self.request_last_executed_scan_parameters)
|
||||
self.toggle_layout.addWidget(self.last_scan_label)
|
||||
self.toggle_layout.addWidget(self.toggle)
|
||||
self.scan_selection_group.layout().addLayout(self.toggle_layout)
|
||||
self.last_scan_button.clicked.connect(self.request_last_executed_scan_parameters)
|
||||
self.scan_selection_group.layout().addWidget(self.last_scan_button)
|
||||
self.scan_selection_group.setSizePolicy(
|
||||
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
@@ -191,12 +185,17 @@ class ScanControl(BECWidget, QWidget):
|
||||
MessageEndpoints.available_scans()
|
||||
).resource
|
||||
if self.config.allowed_scans is None:
|
||||
supported_scans = ["ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase"]
|
||||
allowed_scans = [
|
||||
scan_name
|
||||
for scan_name, scan_info in self.available_scans.items()
|
||||
if scan_info["base_class"] in supported_scans and len(scan_info["gui_config"]) > 0
|
||||
]
|
||||
supported_scans = ["ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase", "ScanBaseV4"]
|
||||
|
||||
def _is_scan_supported(scan_name):
|
||||
scan_info = self.available_scans[scan_name]
|
||||
return (
|
||||
scan_info.get("base_class") in supported_scans
|
||||
and self._scan_info_adapter.has_scan_ui_config(scan_info)
|
||||
and not scan_name.startswith("_")
|
||||
)
|
||||
|
||||
allowed_scans = filter(_is_scan_supported, self.available_scans.keys())
|
||||
|
||||
else:
|
||||
allowed_scans = self.config.allowed_scans
|
||||
@@ -206,7 +205,6 @@ class ScanControl(BECWidget, QWidget):
|
||||
"""Callback for scan selection combo box"""
|
||||
selected_scan_name = self.comboBox_scan_selection.currentText()
|
||||
self.scan_selected.emit(selected_scan_name)
|
||||
self.request_last_executed_scan_parameters()
|
||||
self.restore_scan_parameters(selected_scan_name)
|
||||
|
||||
@SafeSlot()
|
||||
@@ -215,10 +213,6 @@ class ScanControl(BECWidget, QWidget):
|
||||
"""
|
||||
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
|
||||
"""
|
||||
self.last_scan_found = False
|
||||
if not self.toggle.checked:
|
||||
return
|
||||
|
||||
current_scan = self.comboBox_scan_selection.currentText()
|
||||
history = (
|
||||
self.client.connector.xread(
|
||||
@@ -246,8 +240,6 @@ class ScanControl(BECWidget, QWidget):
|
||||
if merged and self.kwarg_boxes:
|
||||
for box in self.kwarg_boxes:
|
||||
box.set_parameters(merged)
|
||||
|
||||
self.last_scan_found = True
|
||||
break
|
||||
|
||||
@SafeProperty(str)
|
||||
@@ -390,14 +382,14 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.reset_layout()
|
||||
selected_scan_info = self.available_scans.get(scan_name, {})
|
||||
|
||||
gui_config = selected_scan_info.get("gui_config", {})
|
||||
self.arg_group = gui_config.get("arg_group", None)
|
||||
self.kwarg_groups = gui_config.get("kwarg_groups", None)
|
||||
gui_config = self._scan_info_adapter.build_scan_ui_config(selected_scan_info)
|
||||
arg_group = gui_config.get("arg_group", None)
|
||||
kwarg_groups = gui_config.get("kwarg_groups", [])
|
||||
|
||||
if bool(self.arg_group["arg_inputs"]):
|
||||
self.add_arg_group(self.arg_group)
|
||||
if len(self.kwarg_groups) > 0:
|
||||
self.add_kwargs_boxes(self.kwarg_groups)
|
||||
if arg_group and bool(arg_group.get("arg_inputs")):
|
||||
self.add_arg_group(arg_group)
|
||||
if kwarg_groups:
|
||||
self.add_kwargs_boxes(kwarg_groups)
|
||||
|
||||
self.update()
|
||||
self.adjustSize()
|
||||
@@ -428,6 +420,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
position = self.ARG_BOX_POSITION + (1 if self.arg_box is not None else 0)
|
||||
for group in groups:
|
||||
box = ScanGroupBox(box_type="kwargs", config=group)
|
||||
box.reference_units_changed.connect(self._apply_reference_units_to_other_boxes)
|
||||
box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
self.layout.insertWidget(position + len(self.kwarg_boxes), box)
|
||||
self.kwarg_boxes.append(box)
|
||||
@@ -441,11 +434,30 @@ class ScanControl(BECWidget, QWidget):
|
||||
"""
|
||||
self.arg_box = ScanGroupBox(box_type="args", config=group)
|
||||
self.arg_box.device_selected.connect(self.emit_device_selected)
|
||||
self.arg_box.reference_units_changed.connect(self._apply_reference_units_to_other_boxes)
|
||||
self.arg_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons
|
||||
self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box)
|
||||
self.arg_box.setVisible(not self._hide_arg_box)
|
||||
|
||||
def _scan_group_boxes(self) -> list[ScanGroupBox]:
|
||||
boxes = []
|
||||
if self.arg_box is not None:
|
||||
boxes.append(self.arg_box)
|
||||
boxes.extend(self.kwarg_boxes)
|
||||
return boxes
|
||||
|
||||
def _apply_reference_units_to_other_boxes(
|
||||
self, source_box: ScanGroupBox, reference_name: str, units: str | None
|
||||
) -> None:
|
||||
"""
|
||||
Propagate device-derived units to scan fields that reference a device in another group.
|
||||
"""
|
||||
for box in self._scan_group_boxes():
|
||||
if box is source_box:
|
||||
continue
|
||||
box.apply_reference_units(reference_name, units)
|
||||
|
||||
@SafeSlot(str)
|
||||
def emit_device_selected(self, dev_names):
|
||||
"""
|
||||
@@ -496,8 +508,6 @@ class ScanControl(BECWidget, QWidget):
|
||||
Args:
|
||||
scan_name(str): Name of the scan to restore the parameters for.
|
||||
"""
|
||||
if self.last_scan_found is True:
|
||||
return
|
||||
scan_params = self.config.scans.get(scan_name, None)
|
||||
if scan_params is None and self.previous_scan is None:
|
||||
return
|
||||
|
||||
@@ -20,10 +20,16 @@ from qtpy.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.scan_arg_metadata import (
|
||||
apply_numeric_limits,
|
||||
apply_numeric_precision,
|
||||
apply_unit_metadata,
|
||||
device_units,
|
||||
)
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||
BECDeviceFilter,
|
||||
DeviceComboBox,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -164,8 +170,8 @@ class ScanCheckBox(QCheckBox):
|
||||
|
||||
class ScanGroupBox(QGroupBox):
|
||||
WIDGET_HANDLER = {
|
||||
ScanArgType.DEVICE: DeviceLineEdit,
|
||||
ScanArgType.DEVICEBASE: DeviceLineEdit,
|
||||
ScanArgType.DEVICE: DeviceComboBox,
|
||||
ScanArgType.DEVICEBASE: DeviceComboBox,
|
||||
ScanArgType.FLOAT: ScanDoubleSpinBox,
|
||||
ScanArgType.INT: ScanSpinBox,
|
||||
ScanArgType.BOOL: ScanCheckBox,
|
||||
@@ -174,6 +180,7 @@ class ScanGroupBox(QGroupBox):
|
||||
}
|
||||
|
||||
device_selected = Signal(str)
|
||||
reference_units_changed = Signal(object, str, object)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -191,7 +198,7 @@ class ScanGroupBox(QGroupBox):
|
||||
vbox_layout = QVBoxLayout(self)
|
||||
hbox_layout = QHBoxLayout()
|
||||
vbox_layout.addLayout(hbox_layout)
|
||||
self.layout = QGridLayout(self)
|
||||
self.layout = QGridLayout()
|
||||
vbox_layout.addLayout(self.layout)
|
||||
|
||||
# Add bundle button
|
||||
@@ -209,6 +216,8 @@ class ScanGroupBox(QGroupBox):
|
||||
|
||||
self.labels = []
|
||||
self.widgets = []
|
||||
self._widget_configs = {}
|
||||
self._column_labels = {}
|
||||
self.selected_devices = {}
|
||||
|
||||
self.init_box(self.config)
|
||||
@@ -247,6 +256,7 @@ class ScanGroupBox(QGroupBox):
|
||||
label = QLabel(text=display_name)
|
||||
self.layout.addWidget(label, row, column_index)
|
||||
self.labels.append(label)
|
||||
self._column_labels[column_index] = label
|
||||
|
||||
def add_input_widgets(self, group_inputs: dict, row) -> None:
|
||||
"""
|
||||
@@ -271,22 +281,41 @@ class ScanGroupBox(QGroupBox):
|
||||
continue
|
||||
if default == "_empty":
|
||||
default = None
|
||||
widget = widget_class(parent=self.parent(), arg_name=arg_name, default=default)
|
||||
if isinstance(widget, DeviceLineEdit):
|
||||
widget.set_device_filter(BECDeviceFilter.DEVICE)
|
||||
if widget_class is DeviceComboBox:
|
||||
widget = widget_class(
|
||||
parent=self.parent(),
|
||||
arg_name=arg_name,
|
||||
default=default,
|
||||
device_filter=BECDeviceFilter.DEVICE,
|
||||
autocomplete=True,
|
||||
)
|
||||
else:
|
||||
widget = widget_class(parent=self.parent(), arg_name=arg_name, default=default)
|
||||
apply_numeric_precision(widget, item)
|
||||
apply_numeric_limits(widget, item)
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
self.selected_devices[widget] = ""
|
||||
widget.device_selected.connect(self.emit_device_selected)
|
||||
widget.currentTextChanged.connect(
|
||||
lambda text, device_widget=widget: self._handle_device_text_changed(
|
||||
device_widget, text
|
||||
)
|
||||
)
|
||||
if isinstance(widget, ScanLiteralsComboBox):
|
||||
widget.set_literals(item["type"].get("Literal", []))
|
||||
tooltip = item.get("tooltip", None)
|
||||
if tooltip is not None:
|
||||
widget.setToolTip(item["tooltip"])
|
||||
self._widget_configs[widget] = item
|
||||
apply_unit_metadata(widget, item)
|
||||
self.layout.addWidget(widget, row, column_index)
|
||||
self.widgets.append(widget)
|
||||
|
||||
@Slot(str)
|
||||
def emit_device_selected(self, device_name):
|
||||
self.selected_devices[self.sender()] = device_name.strip()
|
||||
sender = self.sender()
|
||||
self.selected_devices[sender] = device_name.strip()
|
||||
if isinstance(sender, DeviceComboBox):
|
||||
units = device_units(sender.get_current_device())
|
||||
self._update_reference_units(sender, units)
|
||||
self._emit_reference_units_changed(sender, units)
|
||||
selected_devices_str = " ".join(self.selected_devices.values())
|
||||
self.device_selected.emit(selected_devices_str)
|
||||
|
||||
@@ -311,8 +340,9 @@ class ScanGroupBox(QGroupBox):
|
||||
return
|
||||
|
||||
for widget in self.widgets[-len(self.inputs) :]:
|
||||
if isinstance(widget, DeviceLineEdit):
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
self.selected_devices[widget] = ""
|
||||
self._widget_configs.pop(widget, None)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
self.widgets = self.widgets[: -len(self.inputs)]
|
||||
@@ -323,8 +353,9 @@ class ScanGroupBox(QGroupBox):
|
||||
def remove_all_widget_bundles(self):
|
||||
"""Remove every widget bundle from the scan control layout."""
|
||||
for widget in list(self.widgets):
|
||||
if isinstance(widget, DeviceLineEdit):
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
self.selected_devices.pop(widget, None)
|
||||
self._widget_configs.pop(widget, None)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
self.layout.removeWidget(widget)
|
||||
@@ -360,8 +391,10 @@ class ScanGroupBox(QGroupBox):
|
||||
for j in range(self.layout.columnCount()):
|
||||
try: # In case that the bundle size changes
|
||||
widget = self.layout.itemAtPosition(i, j).widget()
|
||||
if isinstance(widget, DeviceLineEdit) and device_object:
|
||||
if isinstance(widget, DeviceComboBox) and device_object:
|
||||
value = widget.get_current_device()
|
||||
elif isinstance(widget, DeviceComboBox):
|
||||
value = widget.currentText()
|
||||
else:
|
||||
value = WidgetIO.get_value(widget)
|
||||
args.append(value)
|
||||
@@ -373,8 +406,10 @@ class ScanGroupBox(QGroupBox):
|
||||
kwargs = {}
|
||||
for i in range(self.layout.columnCount()):
|
||||
widget = self.layout.itemAtPosition(1, i).widget()
|
||||
if isinstance(widget, DeviceLineEdit) and device_object:
|
||||
if isinstance(widget, DeviceComboBox) and device_object:
|
||||
value = widget.get_current_device().name
|
||||
elif isinstance(widget, DeviceComboBox):
|
||||
value = widget.currentText()
|
||||
elif isinstance(widget, ScanLiteralsComboBox):
|
||||
value = widget.get_value()
|
||||
else:
|
||||
@@ -390,7 +425,7 @@ class ScanGroupBox(QGroupBox):
|
||||
if item is not None:
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
if isinstance(widget, DeviceLineEdit):
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
widget_rows += 1
|
||||
return widget_rows
|
||||
|
||||
@@ -423,3 +458,67 @@ class ScanGroupBox(QGroupBox):
|
||||
if widget.arg_name == key:
|
||||
WidgetIO.set_value(widget, value)
|
||||
break
|
||||
|
||||
def _refresh_column_label(self, column: int, item: dict) -> None:
|
||||
if column not in self._column_labels:
|
||||
return
|
||||
self._column_labels[column].setText(item.get("display_name", item.get("name", None)))
|
||||
|
||||
def _widget_position(self, widget) -> tuple[int, int] | None:
|
||||
for row in range(self.layout.rowCount()):
|
||||
for column in range(self.layout.columnCount()):
|
||||
item = self.layout.itemAtPosition(row, column)
|
||||
if item is not None and item.widget() is widget:
|
||||
return row, column
|
||||
return None
|
||||
|
||||
def _update_reference_units(self, device_widget: DeviceComboBox, units: str | None) -> None:
|
||||
position = self._widget_position(device_widget)
|
||||
if position is None:
|
||||
return
|
||||
source_row, _ = position
|
||||
source_name = device_widget.arg_name
|
||||
|
||||
for widget in self.widgets:
|
||||
item = self._widget_configs.get(widget, {})
|
||||
if item.get("reference_units") != source_name:
|
||||
continue
|
||||
widget_position = self._widget_position(widget)
|
||||
if widget_position is None:
|
||||
continue
|
||||
row, column = widget_position
|
||||
if self.box_type == "args" and row != source_row:
|
||||
continue
|
||||
apply_unit_metadata(widget, item, units)
|
||||
self._refresh_column_label(column, item)
|
||||
|
||||
def apply_reference_units(self, reference_name: str, units: str | None) -> None:
|
||||
"""
|
||||
Apply units to widgets that reference an argument owned by another group box.
|
||||
|
||||
Cross-box references only have one widget row, so row scoping is intentionally handled by
|
||||
the source group before this method is called.
|
||||
"""
|
||||
for widget in self.widgets:
|
||||
item = self._widget_configs.get(widget, {})
|
||||
if item.get("reference_units") != reference_name:
|
||||
continue
|
||||
apply_unit_metadata(widget, item, units)
|
||||
position = self._widget_position(widget)
|
||||
if position is not None:
|
||||
_, column = position
|
||||
self._refresh_column_label(column, item)
|
||||
|
||||
def _emit_reference_units_changed(
|
||||
self, device_widget: DeviceComboBox, units: str | None
|
||||
) -> None:
|
||||
reference_name = getattr(device_widget, "arg_name", None)
|
||||
if not reference_name:
|
||||
return
|
||||
self.reference_units_changed.emit(self, reference_name, units)
|
||||
|
||||
def _handle_device_text_changed(self, device_widget: DeviceComboBox, device_name: str) -> None:
|
||||
if not device_widget.validate_device(device_name):
|
||||
self.selected_devices[device_widget] = ""
|
||||
self._update_reference_units(device_widget, None)
|
||||
self._emit_reference_units_changed(device_widget, None)
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Helpers for translating BEC scan metadata into ScanControl UI configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from bec_widgets.utils.scan_arg_metadata import format_display_name as format_scan_display_name
|
||||
from bec_widgets.utils.scan_arg_metadata import resolve_tooltip as resolve_scan_tooltip
|
||||
from bec_widgets.utils.scan_arg_metadata import ui_config_from_metadata
|
||||
|
||||
AnnotationValue = str | dict[str, Any] | list[Any] | None
|
||||
ScanArgumentMetadata = dict[str, Any]
|
||||
SignatureEntry = dict[str, Any]
|
||||
ScanInputConfig = dict[str, Any]
|
||||
ScanInfo = dict[str, Any]
|
||||
ScanUIConfig = dict[str, Any]
|
||||
|
||||
SUPPORTED_SCAN_INPUT_TYPES = {"device", "DeviceBase", "float", "int", "bool", "str"}
|
||||
|
||||
|
||||
class ScanInfoAdapter:
|
||||
"""Normalize available-scan payloads into the structure consumed by ``ScanControl``."""
|
||||
|
||||
@staticmethod
|
||||
def has_scan_ui_config(scan_info: ScanInfo) -> bool:
|
||||
"""Check whether a scan exposes enough metadata to build a UI.
|
||||
|
||||
Args:
|
||||
scan_info (ScanInfo): Available-scan payload for one scan.
|
||||
|
||||
Returns:
|
||||
bool: ``True`` when a supported GUI metadata field is present.
|
||||
"""
|
||||
if not (
|
||||
scan_info.get("gui_visibility")
|
||||
or scan_info.get("gui_config")
|
||||
or scan_info.get("gui_visualization")
|
||||
or scan_info.get("signature")
|
||||
):
|
||||
return False
|
||||
|
||||
gui_config = ScanInfoAdapter().build_scan_ui_config(scan_info)
|
||||
return not ScanInfoAdapter.unsupported_inputs(gui_config)
|
||||
|
||||
@staticmethod
|
||||
def is_supported_input_type(input_type: AnnotationValue) -> bool:
|
||||
"""Return whether ``ScanGroupBox`` has a widget for this serialized type."""
|
||||
return (
|
||||
isinstance(input_type, str)
|
||||
and input_type in SUPPORTED_SCAN_INPUT_TYPES
|
||||
or isinstance(input_type, dict)
|
||||
and "Literal" in input_type
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def unsupported_inputs(gui_config: ScanUIConfig) -> list[ScanInputConfig]:
|
||||
"""Return input configs that cannot be rendered by ``ScanGroupBox``."""
|
||||
inputs = []
|
||||
arg_group = gui_config.get("arg_group")
|
||||
if arg_group:
|
||||
inputs.extend(arg_group.get("inputs", []))
|
||||
for group in gui_config.get("kwarg_groups", []):
|
||||
inputs.extend(group.get("inputs", []))
|
||||
return [
|
||||
input_config
|
||||
for input_config in inputs
|
||||
if not ScanInfoAdapter.is_supported_input_type(input_config.get("type"))
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def format_display_name(name: str) -> str:
|
||||
"""Convert a parameter name into a user-facing label.
|
||||
|
||||
Args:
|
||||
name (str): Raw parameter name.
|
||||
|
||||
Returns:
|
||||
str: Formatted display label such as ``Exp Time``.
|
||||
"""
|
||||
return format_scan_display_name(name)
|
||||
|
||||
@staticmethod
|
||||
def resolve_tooltip(scan_argument: ScanArgumentMetadata) -> str | None:
|
||||
"""Resolve the tooltip text from parsed ``ScanArgument`` metadata.
|
||||
|
||||
Args:
|
||||
scan_argument (ScanArgumentMetadata): Parsed ``ScanArgument`` metadata.
|
||||
|
||||
Returns:
|
||||
str | None: Explicit tooltip text if provided, otherwise the description fallback.
|
||||
"""
|
||||
return resolve_scan_tooltip(scan_argument)
|
||||
|
||||
@staticmethod
|
||||
def parse_annotation(
|
||||
annotation: AnnotationValue,
|
||||
) -> tuple[AnnotationValue, ScanArgumentMetadata]:
|
||||
"""Extract the serialized base annotation and ``ScanArgument`` metadata.
|
||||
|
||||
Args:
|
||||
annotation (AnnotationValue): Serialized annotation payload from BEC.
|
||||
|
||||
Returns:
|
||||
tuple[AnnotationValue, ScanArgumentMetadata]: The unwrapped annotation and parsed
|
||||
``ScanArgument`` metadata.
|
||||
"""
|
||||
scan_argument: ScanArgumentMetadata = {}
|
||||
if isinstance(annotation, list):
|
||||
annotation = next(
|
||||
(entry for entry in annotation if entry != "NoneType"),
|
||||
annotation[0] if annotation else "_empty",
|
||||
)
|
||||
if isinstance(annotation, dict) and "Annotated" in annotation:
|
||||
annotated = annotation["Annotated"]
|
||||
annotation = annotated.get("type", "_empty")
|
||||
scan_argument = annotated.get("metadata", {}).get("ScanArgument", {}) or {}
|
||||
return annotation, scan_argument
|
||||
|
||||
@staticmethod
|
||||
def scan_arg_type_from_annotation(annotation: AnnotationValue) -> AnnotationValue:
|
||||
"""Normalize an annotation value to the widget type expected by ``ScanControl``.
|
||||
|
||||
Args:
|
||||
annotation (AnnotationValue): Serialized or parsed annotation value.
|
||||
|
||||
Returns:
|
||||
AnnotationValue: The normalized type identifier used by the widget layer.
|
||||
"""
|
||||
if isinstance(annotation, dict):
|
||||
return annotation
|
||||
if annotation in ("_empty", None):
|
||||
return "str"
|
||||
return annotation
|
||||
|
||||
def scan_input_from_signature(
|
||||
self, param: SignatureEntry, arg: bool = False
|
||||
) -> ScanInputConfig:
|
||||
"""Build one ScanControl input description from a signature entry.
|
||||
|
||||
Args:
|
||||
param (SignatureEntry): Serialized signature entry.
|
||||
arg (bool): Whether the parameter belongs to the positional arg bundle.
|
||||
|
||||
Returns:
|
||||
ScanInputConfig: Normalized input configuration for ``ScanControl``.
|
||||
"""
|
||||
annotation, scan_argument = self.parse_annotation(param.get("annotation"))
|
||||
return self._build_scan_input(
|
||||
name=param["name"],
|
||||
annotation=annotation,
|
||||
scan_argument=scan_argument,
|
||||
arg=arg,
|
||||
default=None if arg else param.get("default", None),
|
||||
)
|
||||
|
||||
def scan_input_from_arg_input(
|
||||
self, name: str, item_type: AnnotationValue, signature_by_name: dict[str, SignatureEntry]
|
||||
) -> ScanInputConfig:
|
||||
"""Build one arg-bundle input description from ``arg_input`` metadata.
|
||||
|
||||
Args:
|
||||
name (str): Argument name from ``arg_input``.
|
||||
item_type (AnnotationValue): Serialized argument type from ``arg_input``.
|
||||
signature_by_name (dict[str, SignatureEntry]): Signature entries indexed by
|
||||
parameter name.
|
||||
|
||||
Returns:
|
||||
ScanInputConfig: Normalized input configuration for one arg-bundle field.
|
||||
"""
|
||||
if name in signature_by_name:
|
||||
scan_input = self.scan_input_from_signature(signature_by_name[name], arg=True)
|
||||
scan_input["type"] = self.scan_arg_type_from_annotation(
|
||||
self.parse_annotation(signature_by_name[name].get("annotation"))[0]
|
||||
)
|
||||
else:
|
||||
annotation, scan_argument = self.parse_annotation(item_type)
|
||||
scan_input = self._build_scan_input(
|
||||
name=name,
|
||||
annotation=annotation,
|
||||
scan_argument=scan_argument,
|
||||
arg=True,
|
||||
default=None,
|
||||
)
|
||||
if scan_input["type"] in ("_empty", None):
|
||||
scan_input["type"] = item_type
|
||||
return scan_input
|
||||
|
||||
def _build_scan_input(
|
||||
self,
|
||||
name: str,
|
||||
annotation: AnnotationValue,
|
||||
scan_argument: ScanArgumentMetadata,
|
||||
*,
|
||||
arg: bool,
|
||||
default: Any,
|
||||
) -> ScanInputConfig:
|
||||
"""Build one normalized ScanControl input configuration.
|
||||
|
||||
Args:
|
||||
name (str): Parameter name.
|
||||
annotation (AnnotationValue): Parsed annotation value.
|
||||
scan_argument (ScanArgumentMetadata): Parsed ``ScanArgument`` metadata.
|
||||
arg (bool): Whether the parameter belongs to the positional arg bundle.
|
||||
default (Any): Default value for the parameter.
|
||||
|
||||
Returns:
|
||||
ScanInputConfig: Normalized input configuration.
|
||||
"""
|
||||
return ui_config_from_metadata(
|
||||
name=name,
|
||||
metadata=scan_argument,
|
||||
input_type=self.scan_arg_type_from_annotation(annotation),
|
||||
default=default,
|
||||
arg=arg,
|
||||
)
|
||||
|
||||
def build_scan_ui_config(self, scan_info: ScanInfo) -> ScanUIConfig:
|
||||
"""Normalize one available-scan entry into the widget UI configuration.
|
||||
|
||||
Args:
|
||||
scan_info (ScanInfo): Available-scan payload for one scan.
|
||||
|
||||
Returns:
|
||||
ScanUIConfig: Legacy group structure consumed by ``ScanControl`` and
|
||||
``ScanGroupBox``.
|
||||
"""
|
||||
gui_visualization = (
|
||||
scan_info.get("gui_visualization") or scan_info.get("gui_visibility") or {}
|
||||
)
|
||||
if not gui_visualization and scan_info.get("gui_config"):
|
||||
return scan_info["gui_config"]
|
||||
|
||||
signature = scan_info.get("signature", [])
|
||||
signature_by_name = {entry["name"]: entry for entry in signature}
|
||||
|
||||
arg_group = None
|
||||
arg_input = scan_info.get("arg_input", {})
|
||||
if isinstance(arg_input, dict) and arg_input:
|
||||
bundle_size = scan_info.get("arg_bundle_size", {})
|
||||
inputs = [
|
||||
self.scan_input_from_arg_input(name, item_type, signature_by_name)
|
||||
for name, item_type in arg_input.items()
|
||||
]
|
||||
arg_group = {
|
||||
"name": "Scan Arguments",
|
||||
"bundle": bundle_size.get("bundle"),
|
||||
"arg_inputs": arg_input,
|
||||
"inputs": inputs,
|
||||
"min": bundle_size.get("min"),
|
||||
"max": bundle_size.get("max"),
|
||||
}
|
||||
|
||||
kwarg_groups = []
|
||||
arg_names = set(arg_input) if isinstance(arg_input, dict) else set()
|
||||
visible_kwarg_names = set()
|
||||
for group_name, input_names in gui_visualization.items():
|
||||
inputs = []
|
||||
for input_name in input_names:
|
||||
if input_name in arg_names or input_name not in signature_by_name:
|
||||
continue
|
||||
if input_name in visible_kwarg_names:
|
||||
continue
|
||||
param = signature_by_name[input_name]
|
||||
if param.get("kind") in ("VAR_POSITIONAL", "VAR_KEYWORD"):
|
||||
continue
|
||||
scan_input = self.scan_input_from_signature(param)
|
||||
if scan_input.get("hidden"):
|
||||
continue
|
||||
inputs.append(scan_input)
|
||||
visible_kwarg_names.add(input_name)
|
||||
if inputs:
|
||||
kwarg_groups.append({"name": group_name, "inputs": inputs})
|
||||
|
||||
return {
|
||||
"scan_class_name": scan_info.get("class"),
|
||||
"arg_group": arg_group,
|
||||
"kwarg_groups": kwarg_groups,
|
||||
}
|
||||
@@ -460,7 +460,7 @@ class ImageBase(PlotBase):
|
||||
self._color_bar = None
|
||||
|
||||
def disable_autorange():
|
||||
print("Disabling autorange")
|
||||
logger.info("Disabling autorange")
|
||||
self.setProperty("autorange", False)
|
||||
|
||||
if style == "simple":
|
||||
@@ -928,7 +928,7 @@ class ImageBase(PlotBase):
|
||||
# if sync:
|
||||
self._sync_colorbar_levels()
|
||||
self._sync_autorange_switch()
|
||||
print(f"Autorange set to {enabled}")
|
||||
logger.info(f"Autorange set to {enabled}")
|
||||
|
||||
@SafeProperty(str)
|
||||
def autorange_mode(self) -> str:
|
||||
|
||||
@@ -3,8 +3,10 @@ from qtpy.QtWidgets import QHBoxLayout, QSizePolicy, QWidget
|
||||
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
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.control.device_input.device_combobox.device_combobox import (
|
||||
BECDeviceFilter,
|
||||
DeviceComboBox,
|
||||
)
|
||||
|
||||
|
||||
class MotorSelection(QWidget):
|
||||
|
||||
@@ -6,8 +6,10 @@ from bec_widgets.utils.error_popups import SafeSlot
|
||||
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.control.device_input.device_combobox.device_combobox import (
|
||||
BECDeviceFilter,
|
||||
DeviceComboBox,
|
||||
)
|
||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||
|
||||
|
||||
|
||||
+9
-9
@@ -58,7 +58,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="device_x"/>
|
||||
<widget class="DeviceComboBox" name="device_x"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
@@ -87,7 +87,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="device_y"/>
|
||||
<widget class="DeviceComboBox" name="device_y"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_4">
|
||||
@@ -116,7 +116,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="device_z"/>
|
||||
<widget class="DeviceComboBox" name="device_z"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_6">
|
||||
@@ -135,9 +135,9 @@
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>DeviceLineEdit</class>
|
||||
<extends>QLineEdit</extends>
|
||||
<header>device_line_edit</header>
|
||||
<class>DeviceComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>device_combo_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
@@ -154,7 +154,7 @@
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>device_x</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>signal_x</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
@@ -170,7 +170,7 @@
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_y</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>signal_y</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
@@ -186,7 +186,7 @@
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_z</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>signal_z</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
|
||||
@@ -79,7 +79,7 @@ class CurveRow(QTreeWidgetItem):
|
||||
|
||||
Columns:
|
||||
0: Actions (delete or "Add DAP" if source=device)
|
||||
1..2: DeviceLineEdit and QLineEdit if source=device, or "Model" label and DapComboBox if source=dap
|
||||
1..2: DeviceComboBox and QLineEdit if source=device, or "Model" label and DapComboBox if source=dap
|
||||
3: ColorButton
|
||||
4: Style QComboBox
|
||||
5: Pen width QSpinBox
|
||||
|
||||
@@ -2449,7 +2449,7 @@ class Waveform(PlotBase):
|
||||
first_key = next(iter(info))
|
||||
mem_bytes = info[first_key]["value"]["mem_size"]
|
||||
size_mb = mem_bytes / (1024 * 1024)
|
||||
print(f"Dataset size: {size_mb:.1f} MB")
|
||||
logger.info(f"Dataset size: {size_mb:.1f} MB")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(f"Unable to evaluate dataset size: {exc}")
|
||||
return True
|
||||
|
||||
@@ -2,50 +2,41 @@ import sys
|
||||
from enum import Enum
|
||||
from string import Template
|
||||
|
||||
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 qtpy.QtCore import QTimer
|
||||
from qtpy.QtGui import QPalette
|
||||
from qtpy.QtWidgets import QApplication, QProgressBar, QSizePolicy, 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 ProgressState(Enum):
|
||||
NORMAL = "normal"
|
||||
PAUSED = "paused"
|
||||
WARNING = "warning"
|
||||
INTERRUPTED = "interrupted"
|
||||
COMPLETED = "completed"
|
||||
|
||||
|
||||
class BECProgressBar(BECWidget, QWidget):
|
||||
"""
|
||||
A custom progress bar with smooth transitions. The displayed text can be customized using a template.
|
||||
A BEC progress bar backed by Qt's native QProgressBar.
|
||||
|
||||
The displayed text can be customized using a template with $value, $maximum,
|
||||
and $percentage placeholders.
|
||||
|
||||
Args:
|
||||
parent: Parent Qt widget.
|
||||
client: Optional BEC client instance.
|
||||
config: Optional widget configuration.
|
||||
gui_id: Optional GUI identifier used by the BEC widget infrastructure.
|
||||
enable_dynamic_stylesheet: If True, adjust the chunk border radius while the
|
||||
filled chunk is still too narrow for the target radius. This avoids Qt
|
||||
stylesheet over-rounding artifacts on small progress values. Once the
|
||||
target radius is usable, normal value updates no longer rebuild the
|
||||
stylesheet.
|
||||
**kwargs: Additional keyword arguments forwarded to BECWidget.
|
||||
"""
|
||||
|
||||
PLUGIN = True
|
||||
@@ -61,7 +52,15 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
]
|
||||
ICON_NAME = "page_control"
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config=None,
|
||||
gui_id=None,
|
||||
enable_dynamic_stylesheet: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
|
||||
)
|
||||
@@ -71,7 +70,6 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
# internal values
|
||||
self._oversampling_factor = 50
|
||||
self._value = 0
|
||||
self._target_value = 0
|
||||
self._maximum = 100 * self._oversampling_factor
|
||||
|
||||
# User values
|
||||
@@ -80,46 +78,38 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
self._user_maximum = 100
|
||||
self._label_template = "$value / $maximum - $percentage %"
|
||||
|
||||
# Color settings
|
||||
self._background_color = QColor(30, 30, 30)
|
||||
self._progress_color = accent_colors.highlight
|
||||
|
||||
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
|
||||
self._corner_radius = 8
|
||||
|
||||
# Progress‑bar state handling
|
||||
self._state = ProgressState.NORMAL
|
||||
|
||||
self._state_colors = {
|
||||
ProgressState.NORMAL: accent_colors.default,
|
||||
ProgressState.PAUSED: accent_colors.warning,
|
||||
ProgressState.PAUSED: accent_colors.highlight,
|
||||
ProgressState.WARNING: accent_colors.warning,
|
||||
ProgressState.INTERRUPTED: accent_colors.emergency,
|
||||
ProgressState.COMPLETED: accent_colors.success,
|
||||
}
|
||||
|
||||
# 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)
|
||||
self._chunk_radius = None
|
||||
self._enable_dynamic_stylesheet = enable_dynamic_stylesheet
|
||||
|
||||
# label on top of the progress bar
|
||||
self.center_label = QLabel(self)
|
||||
self.center_label.setAlignment(Qt.AlignHCenter)
|
||||
self.center_label.setMinimumSize(0, 0)
|
||||
self.center_label.setStyleSheet("background: transparent; color: white;")
|
||||
self.progressbar = QProgressBar(self)
|
||||
self.progressbar.setTextVisible(True)
|
||||
self.progressbar.setRange(0, self._maximum)
|
||||
self.progressbar.setMinimumHeight(0)
|
||||
self.progressbar.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Ignored)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
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._layout = QVBoxLayout(self)
|
||||
self._layout.setContentsMargins(self._padding_left_right, 0, self._padding_left_right, 0)
|
||||
self._layout.setSpacing(0)
|
||||
self._layout.addWidget(self.progressbar)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self.update()
|
||||
self._adjust_label_width()
|
||||
self._sync_progressbar()
|
||||
self._apply_state_style()
|
||||
|
||||
@SafeProperty(
|
||||
str, doc="The template for the center label. Use $value, $maximum, and $percentage."
|
||||
@@ -140,17 +130,18 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
accent_colors = get_accent_colors()
|
||||
self._state_colors = {
|
||||
ProgressState.NORMAL: accent_colors.default,
|
||||
ProgressState.PAUSED: accent_colors.warning,
|
||||
ProgressState.PAUSED: accent_colors.highlight,
|
||||
ProgressState.WARNING: accent_colors.warning,
|
||||
ProgressState.INTERRUPTED: accent_colors.emergency,
|
||||
ProgressState.COMPLETED: accent_colors.success,
|
||||
}
|
||||
self._chunk_radius = None
|
||||
self._apply_state_style()
|
||||
|
||||
@label_template.setter
|
||||
def label_template(self, template):
|
||||
self._label_template = template
|
||||
self._adjust_label_width()
|
||||
self.set_value(self._user_value)
|
||||
self.update()
|
||||
self._sync_progressbar()
|
||||
|
||||
@SafeProperty(float, designable=False)
|
||||
def _progressbar_value(self):
|
||||
@@ -162,28 +153,16 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
@_progressbar_value.setter
|
||||
def _progressbar_value(self, val):
|
||||
self._value = val
|
||||
self.update()
|
||||
self.progressbar.setValue(int(round(val)))
|
||||
|
||||
def _update_template(self):
|
||||
template = Template(self._label_template)
|
||||
return template.safe_substitute(
|
||||
value=self._user_value,
|
||||
maximum=self._user_maximum,
|
||||
percentage=int((self.map_value(self._user_value) / self._maximum) * 100),
|
||||
percentage=int(self._percentage(self._user_value)),
|
||||
)
|
||||
|
||||
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):
|
||||
@@ -193,21 +172,35 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
Args:
|
||||
value (float): The value to set.
|
||||
"""
|
||||
if value > self._user_maximum:
|
||||
value = self._user_maximum
|
||||
elif value < self._user_minimum:
|
||||
value = self._user_minimum
|
||||
self._target_value = self.map_value(value)
|
||||
self._user_value = value
|
||||
self.center_label.setText(self._update_template())
|
||||
previous_visual_state = self._current_visual_state()
|
||||
previous_value = self._value
|
||||
self._user_value = self._clamp_value(value)
|
||||
self._value = self.map_value(self._user_value)
|
||||
if self._enable_dynamic_stylesheet and self._value < previous_value:
|
||||
self._chunk_radius = None
|
||||
# Update state automatically unless paused or interrupted
|
||||
if self._state not in (ProgressState.PAUSED, ProgressState.INTERRUPTED):
|
||||
if self._state not in (
|
||||
ProgressState.PAUSED,
|
||||
ProgressState.WARNING,
|
||||
ProgressState.INTERRUPTED,
|
||||
):
|
||||
self._state = (
|
||||
ProgressState.COMPLETED
|
||||
if self._user_value >= self._user_maximum
|
||||
else ProgressState.NORMAL
|
||||
)
|
||||
self.animate_progress()
|
||||
self._sync_progressbar()
|
||||
visual_state_changed = self._current_visual_state() is not previous_visual_state
|
||||
if visual_state_changed:
|
||||
self._chunk_radius = None
|
||||
if (
|
||||
self._enable_dynamic_stylesheet
|
||||
and not visual_state_changed
|
||||
and (self._chunk_radius is None or self._chunk_radius != self._target_chunk_radius())
|
||||
):
|
||||
self._update_chunk_radius()
|
||||
if visual_state_changed:
|
||||
self._apply_state_style()
|
||||
|
||||
@SafeProperty(object, doc="Current visual state of the progress bar.")
|
||||
def state(self):
|
||||
@@ -226,7 +219,8 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
if not isinstance(state, ProgressState):
|
||||
raise ValueError("state must be a ProgressState or its value")
|
||||
self._state = state
|
||||
self.update()
|
||||
self._chunk_radius = None
|
||||
self._apply_state_style()
|
||||
|
||||
@SafeProperty(float, doc="Base corner radius in pixels (auto‑scaled down on small bars).")
|
||||
def corner_radius(self) -> float:
|
||||
@@ -235,7 +229,18 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
@corner_radius.setter
|
||||
def corner_radius(self, radius: float):
|
||||
self._corner_radius = max(0.0, radius)
|
||||
self.update()
|
||||
self._chunk_radius = None
|
||||
self._apply_state_style()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enable_dynamic_stylesheet(self) -> bool:
|
||||
return self._enable_dynamic_stylesheet
|
||||
|
||||
@enable_dynamic_stylesheet.setter
|
||||
def enable_dynamic_stylesheet(self, enabled: bool):
|
||||
self._enable_dynamic_stylesheet = bool(enabled)
|
||||
self._chunk_radius = None
|
||||
self._apply_state_style()
|
||||
|
||||
@SafeProperty(float)
|
||||
def padding_left_right(self) -> float:
|
||||
@@ -244,60 +249,12 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
@padding_left_right.setter
|
||||
def padding_left_right(self, padding: float):
|
||||
self._padding_left_right = padding
|
||||
self.update()
|
||||
self._layout.setContentsMargins(int(round(padding)), 0, int(round(padding)), 0)
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
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, radius, radius) # Rounded corners
|
||||
|
||||
# Draw border
|
||||
painter.setBrush(Qt.NoBrush)
|
||||
painter.setPen(self._border_color)
|
||||
painter.drawRoundedRect(rect, radius, radius)
|
||||
|
||||
# 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._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), radius, radius
|
||||
) # Clip to the background's rounded corners
|
||||
painter.setClipPath(clip_path)
|
||||
|
||||
# Draw progress bar
|
||||
painter.setBrush(current_color)
|
||||
painter.drawRect(progress_rect) # Less rounded, no additional rounding
|
||||
|
||||
painter.end()
|
||||
|
||||
def animate_progress(self):
|
||||
"""
|
||||
Animate the progress bar from the current value to the target value.
|
||||
"""
|
||||
self._value_animation.stop()
|
||||
self._value_animation.setStartValue(self._value)
|
||||
self._value_animation.setEndValue(self._target_value)
|
||||
self._value_animation.start()
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._chunk_radius = None
|
||||
self._update_chunk_radius()
|
||||
|
||||
@SafeProperty(float)
|
||||
def maximum(self):
|
||||
@@ -343,10 +300,11 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
Args:
|
||||
maximum (float): The maximum value.
|
||||
"""
|
||||
previous_maximum = self._user_maximum
|
||||
self._user_maximum = maximum
|
||||
self._adjust_label_width()
|
||||
if self._enable_dynamic_stylesheet and maximum != previous_maximum:
|
||||
self._chunk_radius = None
|
||||
self.set_value(self._user_value) # Update the value to fit the new range
|
||||
self.update()
|
||||
|
||||
@SafeSlot(float)
|
||||
def set_minimum(self, minimum: float):
|
||||
@@ -356,40 +314,126 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
Args:
|
||||
minimum (float): The minimum value.
|
||||
"""
|
||||
previous_minimum = self._user_minimum
|
||||
self._user_minimum = minimum
|
||||
if self._enable_dynamic_stylesheet and minimum != previous_minimum:
|
||||
self._chunk_radius = None
|
||||
self.set_value(self._user_value) # Update the value to fit the new range
|
||||
self.update()
|
||||
|
||||
def map_value(self, value: float):
|
||||
"""
|
||||
Map the user value to the range [0, 100*self._oversampling_factor] for the progress
|
||||
"""
|
||||
return (
|
||||
(value - self._user_minimum) / (self._user_maximum - self._user_minimum) * self._maximum
|
||||
)
|
||||
span = self._user_maximum - self._user_minimum
|
||||
if span <= 0:
|
||||
return float(self._maximum if value >= self._user_maximum else 0)
|
||||
mapped_value = (value - self._user_minimum) / span * self._maximum
|
||||
return min(float(self._maximum), max(0.0, mapped_value))
|
||||
|
||||
def _percentage(self, value: float) -> float:
|
||||
return (self.map_value(value) / self._maximum) * 100 if self._maximum else 0.0
|
||||
|
||||
def _clamp_value(self, value: float) -> float:
|
||||
if self._user_maximum <= self._user_minimum:
|
||||
return self._user_maximum
|
||||
return min(self._user_maximum, max(self._user_minimum, value))
|
||||
|
||||
def _sync_progressbar(self) -> None:
|
||||
self.progressbar.setRange(0, int(self._maximum))
|
||||
self.progressbar.setValue(int(round(self._value)))
|
||||
self.progressbar.setFormat(self._update_template())
|
||||
|
||||
def _setup_style_sheet(self, *, chunk_radius: int) -> None:
|
||||
radius = int(round(self._corner_radius))
|
||||
chunk_color = self._state_colors[self._current_visual_state()].name()
|
||||
self.progressbar.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background-color: palette(mid);
|
||||
border: none;
|
||||
border-radius: {radius}px;
|
||||
color: palette(text);
|
||||
text-align: center;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background-color: {chunk_color};
|
||||
border-radius: {chunk_radius}px;
|
||||
}}
|
||||
""")
|
||||
|
||||
def _update_chunk_radius(self) -> None:
|
||||
chunk_radius = self._current_chunk_radius()
|
||||
if chunk_radius != self._chunk_radius:
|
||||
self._chunk_radius = chunk_radius
|
||||
self._setup_style_sheet(chunk_radius=chunk_radius)
|
||||
self._apply_state_palette()
|
||||
|
||||
def _apply_state_style(self) -> None:
|
||||
if self._chunk_radius is None:
|
||||
self._chunk_radius = self._current_chunk_radius()
|
||||
self._setup_style_sheet(chunk_radius=self._chunk_radius)
|
||||
self._apply_state_palette()
|
||||
|
||||
def _apply_state_palette(self) -> None:
|
||||
color = self._state_colors[self._current_visual_state()]
|
||||
palette = self.progressbar.palette()
|
||||
palette.setColor(QPalette.ColorRole.Highlight, color)
|
||||
palette.setColor(QPalette.ColorRole.HighlightedText, palette.color(QPalette.ColorRole.Text))
|
||||
self.progressbar.setPalette(palette)
|
||||
|
||||
def _current_chunk_radius(self) -> int:
|
||||
target_radius = self._target_chunk_radius()
|
||||
if not self._enable_dynamic_stylesheet:
|
||||
return target_radius
|
||||
return self._calculate_chunk_radius(target_radius)
|
||||
|
||||
def _target_chunk_radius(self) -> int:
|
||||
radius = int(round(self._corner_radius))
|
||||
return max(0, radius - 1)
|
||||
|
||||
def _calculate_chunk_radius(self, target_radius: int) -> int:
|
||||
"""
|
||||
Scale the chunk radius down while the filled part is narrower than the target radius.
|
||||
Qt stylesheets otherwise over-round very small chunks.
|
||||
"""
|
||||
if target_radius <= 0 or self._maximum <= 0:
|
||||
return 0
|
||||
fill_width = self.progressbar.width() * min(1.0, max(0.0, self._value / self._maximum))
|
||||
if fill_width <= 0:
|
||||
return 0
|
||||
return min(target_radius, max(1, int(fill_width / 2)))
|
||||
|
||||
def _current_visual_state(self) -> ProgressState:
|
||||
if self._state in (ProgressState.PAUSED, ProgressState.WARNING, ProgressState.INTERRUPTED):
|
||||
return self._state
|
||||
if self._state == ProgressState.COMPLETED or self._value >= self._maximum:
|
||||
return ProgressState.COMPLETED
|
||||
return ProgressState.NORMAL
|
||||
|
||||
def _get_label(self) -> str:
|
||||
"""Return the label text. mostly used for testing rpc."""
|
||||
return self.center_label.text()
|
||||
return self.progressbar.text()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
progressBar = BECProgressBar()
|
||||
progressBar.show()
|
||||
progressBar.set_minimum(-100)
|
||||
progressBar.set_maximum(0)
|
||||
progress_bar = BECProgressBar()
|
||||
progress_bar.setWindowTitle("BEC Progress Bar")
|
||||
progress_bar.resize(360, 48)
|
||||
progress_bar.set_minimum(-100)
|
||||
progress_bar.set_maximum(0)
|
||||
progress_bar.set_value(-100)
|
||||
progress_bar.show()
|
||||
|
||||
# Example of setting values
|
||||
def update_progress():
|
||||
value = progressBar._user_value + 2.5
|
||||
if value > progressBar._user_maximum:
|
||||
value = -100 # progressBar._maximum / progressBar._upsampling_factor
|
||||
progressBar.set_value(value)
|
||||
value = progress_bar._user_value + 2.5
|
||||
if value > progress_bar._user_maximum:
|
||||
value = progress_bar._user_minimum
|
||||
progress_bar.set_value(value)
|
||||
|
||||
timer = QTimer()
|
||||
timer = QTimer(progress_bar)
|
||||
timer.timeout.connect(update_progress)
|
||||
timer.start(200) # Update every half second
|
||||
timer.start(200)
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import QObject, QTimer, Signal
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProgressSnapshot:
|
||||
value: float
|
||||
max_value: float
|
||||
done: bool
|
||||
status: Literal["open", "paused", "aborted", "halted", "closed", "user_completed"]
|
||||
scan_id: str | None = None
|
||||
scan_number: int | None = None
|
||||
rid: str | None = None
|
||||
is_new_scan: bool = False
|
||||
|
||||
|
||||
class ProgressTask(QObject):
|
||||
"""
|
||||
Class to store progress information.
|
||||
Inspired by https://github.com/Textualize/rich/blob/master/rich/progress.py
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, parent: QObject | None, value: float = 0, max_value: float = 0, done: bool = False
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.start_time = time.monotonic()
|
||||
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(1000)
|
||||
|
||||
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 second by a QTimer.
|
||||
"""
|
||||
self._elapsed_time = max(0.0, time.monotonic() - self.start_time)
|
||||
|
||||
@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:
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
def _format_time(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 BECProgressTracker(QObject):
|
||||
"""
|
||||
Shared backend for BEC scan progress messages.
|
||||
"""
|
||||
|
||||
progress_started = Signal(object)
|
||||
progress_updated = Signal(object)
|
||||
progress_finished = Signal(object)
|
||||
progress_cleared = Signal()
|
||||
|
||||
def __init__(self, bec_dispatcher, parent: QObject | None = None):
|
||||
super().__init__(parent=parent)
|
||||
self.bec_dispatcher = bec_dispatcher
|
||||
self._connected = False
|
||||
self.task: ProgressTask | None = None
|
||||
self.scan_number: int | None = None
|
||||
self._active_scan_id: str | None = None
|
||||
self._active_rid: str | None = None
|
||||
self._last_reset_scan_id: str | None = None
|
||||
self._last_progress_scan_id: str | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._connected:
|
||||
return
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.process_progress_message, MessageEndpoints.scan_progress()
|
||||
)
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.process_scan_status_message, MessageEndpoints.scan_status()
|
||||
)
|
||||
self._connected = True
|
||||
|
||||
def _start_task(self, scan_id: str | None, rid: str | None = None) -> None:
|
||||
if self.task is not None:
|
||||
self.task.timer.stop()
|
||||
self.task.deleteLater()
|
||||
self.task = ProgressTask(parent=self)
|
||||
self._active_scan_id = scan_id
|
||||
self._active_rid = rid
|
||||
self.progress_started.emit(
|
||||
ProgressSnapshot(
|
||||
value=0,
|
||||
max_value=100,
|
||||
done=False,
|
||||
status="open",
|
||||
scan_id=self._active_scan_id,
|
||||
scan_number=self.scan_number,
|
||||
rid=self._active_rid,
|
||||
)
|
||||
)
|
||||
|
||||
def clear_task(self, *, emit_finished: bool = True) -> None:
|
||||
if self.task is None:
|
||||
self._active_scan_id = None
|
||||
self._active_rid = None
|
||||
self.progress_cleared.emit()
|
||||
return
|
||||
self.task.timer.stop()
|
||||
self.task.deleteLater()
|
||||
self.task = None
|
||||
self._active_scan_id = None
|
||||
self._active_rid = None
|
||||
self.progress_cleared.emit()
|
||||
if emit_finished:
|
||||
self.progress_finished.emit(
|
||||
ProgressSnapshot(
|
||||
value=0,
|
||||
max_value=100,
|
||||
done=True,
|
||||
status="open",
|
||||
scan_id=self._active_scan_id,
|
||||
scan_number=self.scan_number,
|
||||
rid=self._active_rid,
|
||||
)
|
||||
)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def process_progress_message(
|
||||
self, msg_content: dict, metadata: dict
|
||||
) -> ProgressSnapshot | None:
|
||||
done = msg_content.get("done", False)
|
||||
value = msg_content.get("value", 0)
|
||||
max_value = msg_content.get("max_value", 100)
|
||||
status: Literal["open", "paused", "aborted", "halted", "closed", "user_completed"] = (
|
||||
metadata.get("status", "open")
|
||||
)
|
||||
scan_id = metadata.get("scan_id") or metadata.get("RID")
|
||||
rid = metadata.get("RID")
|
||||
scan_number = metadata.get("scan_number")
|
||||
if scan_number is not None:
|
||||
self.scan_number = scan_number
|
||||
if scan_id is not None:
|
||||
self._last_progress_scan_id = scan_id
|
||||
is_new_scan = False
|
||||
previous_scan_id = self._active_scan_id
|
||||
previous_rid = self._active_rid
|
||||
identity_changed = (
|
||||
(scan_id is not None and scan_id != previous_scan_id)
|
||||
or (rid is not None and rid != previous_rid)
|
||||
or (previous_scan_id is None and previous_rid is None)
|
||||
)
|
||||
|
||||
if self.task is None:
|
||||
self._start_task(scan_id, rid=rid)
|
||||
is_new_scan = identity_changed
|
||||
elif scan_id is not None and scan_id != self._active_scan_id:
|
||||
self._start_task(scan_id, rid=rid)
|
||||
is_new_scan = True
|
||||
elif rid is not None and rid != self._active_rid:
|
||||
self._start_task(scan_id or self._active_scan_id, rid=rid)
|
||||
is_new_scan = True
|
||||
|
||||
if self.task is None:
|
||||
return None
|
||||
|
||||
self.task.update(value, max_value, done)
|
||||
snapshot = ProgressSnapshot(
|
||||
value=value,
|
||||
max_value=max_value,
|
||||
done=done,
|
||||
status=status,
|
||||
scan_id=self._active_scan_id,
|
||||
scan_number=self.scan_number,
|
||||
rid=self._active_rid,
|
||||
is_new_scan=is_new_scan,
|
||||
)
|
||||
self.progress_updated.emit(snapshot)
|
||||
if done:
|
||||
self.clear_task()
|
||||
return snapshot
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def process_scan_status_message(
|
||||
self, msg_content: dict, metadata: dict
|
||||
) -> ProgressSnapshot | None:
|
||||
if msg_content.get("status") != "open":
|
||||
return None
|
||||
scan_id = msg_content.get("scan_id") or metadata.get("scan_id") or metadata.get("RID")
|
||||
if scan_id is None or scan_id == self._last_reset_scan_id:
|
||||
return None
|
||||
if scan_id == self._last_progress_scan_id:
|
||||
self._last_reset_scan_id = scan_id
|
||||
return None
|
||||
|
||||
self.clear_task(emit_finished=False)
|
||||
self._last_reset_scan_id = scan_id
|
||||
self.scan_number = msg_content.get("scan_number")
|
||||
snapshot = ProgressSnapshot(
|
||||
value=0,
|
||||
max_value=100,
|
||||
done=False,
|
||||
status="open",
|
||||
scan_id=scan_id,
|
||||
scan_number=self.scan_number,
|
||||
rid=metadata.get("RID"),
|
||||
is_new_scan=True,
|
||||
)
|
||||
self.progress_updated.emit(snapshot)
|
||||
return snapshot
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.clear_task(emit_finished=False)
|
||||
if self._connected:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.process_progress_message, MessageEndpoints.scan_progress()
|
||||
)
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.process_scan_status_message, MessageEndpoints.scan_status()
|
||||
)
|
||||
self._connected = False
|
||||
self._last_reset_scan_id = None
|
||||
self._last_progress_scan_id = None
|
||||
@@ -13,6 +13,7 @@ from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.progress.progress_backend import BECProgressTracker, ProgressSnapshot
|
||||
|
||||
logger = bec_logger.logger
|
||||
if TYPE_CHECKING:
|
||||
@@ -81,6 +82,8 @@ class Ring(BECWidget, QWidget):
|
||||
self._color: QColor = self.convert_color(self.config.color)
|
||||
self._background_color: QColor = self.convert_color(self.config.background_color)
|
||||
self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None
|
||||
self.progress_tracker = BECProgressTracker(self.bec_dispatcher, parent=self)
|
||||
self.progress_tracker.progress_updated.connect(self._on_progress_snapshot)
|
||||
self.RID = None
|
||||
self._gap = 5
|
||||
self._hovered = False
|
||||
@@ -219,35 +222,32 @@ class Ring(BECWidget, QWidget):
|
||||
case "manual":
|
||||
if self.config.mode == "manual":
|
||||
return
|
||||
if self.registered_slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
|
||||
self._disconnect_registered_update()
|
||||
self.config.mode = "manual"
|
||||
self.registered_slot = None
|
||||
case "scan":
|
||||
if self.config.mode == "scan":
|
||||
return
|
||||
if self.registered_slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
|
||||
self._disconnect_registered_update()
|
||||
self.config.mode = "scan"
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_scan_progress, MessageEndpoints.scan_progress()
|
||||
)
|
||||
self.registered_slot = (self.on_scan_progress, MessageEndpoints.scan_progress())
|
||||
self.progress_tracker.start()
|
||||
case "device":
|
||||
if self.registered_slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
|
||||
self._disconnect_registered_update()
|
||||
self.config.mode = "device"
|
||||
if device == "":
|
||||
self.registered_slot = None
|
||||
return
|
||||
self.config.device = device
|
||||
# self.config.signal = self._get_signal_from_device(device, signal)
|
||||
signal = self._update_device_connection(device, signal)
|
||||
self.config.signal = signal
|
||||
|
||||
case _:
|
||||
raise ValueError(f"Unsupported mode: {mode}")
|
||||
|
||||
def _disconnect_registered_update(self):
|
||||
if self.registered_slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
|
||||
self.registered_slot = None
|
||||
self.progress_tracker.cleanup()
|
||||
|
||||
def set_precision(self, precision: int):
|
||||
"""
|
||||
Set the precision for the ring widget.
|
||||
@@ -270,13 +270,13 @@ class Ring(BECWidget, QWidget):
|
||||
|
||||
def _get_signals_for_device(self, device: str) -> dict[str, list[str]]:
|
||||
"""
|
||||
Get the signals for the device.
|
||||
Get the appropriate signals for the device to be used in the ring widget, based on the signal infos from the device manager.
|
||||
|
||||
Args:
|
||||
device(str): Device name for the device
|
||||
device(str): Device name for the device readback mode
|
||||
|
||||
Returns:
|
||||
dict[str, list[str]]: Dictionary with the signals for the device
|
||||
dict[str, list[str]]: Signal infos for the device to be used in the ring widget
|
||||
"""
|
||||
dm = self.bec_dispatcher.client.device_manager
|
||||
if not dm:
|
||||
@@ -285,24 +285,25 @@ class Ring(BECWidget, QWidget):
|
||||
if dev_obj is None:
|
||||
raise ValueError(f"Device '{device}' not found in device manager.")
|
||||
|
||||
signal_infos = getattr(dev_obj, "_info", {}).get("signals", {})
|
||||
progress_signals = [
|
||||
obj["component_name"]
|
||||
for obj in dev_obj._info["signals"].values()
|
||||
if obj["signal_class"] == "ProgressSignal"
|
||||
for obj in signal_infos.values()
|
||||
if obj.get("signal_class") == "ProgressSignal"
|
||||
]
|
||||
hinted_signals = [
|
||||
obj["obj_name"]
|
||||
for obj in dev_obj._info["signals"].values()
|
||||
if obj["kind_str"] == "hinted"
|
||||
and obj["signal_class"]
|
||||
for obj in signal_infos.values()
|
||||
if obj.get("kind_str") == "hinted"
|
||||
and obj.get("signal_class")
|
||||
not in ["ProgressSignal", "AsyncSignal", "AsyncMultiSignal", "DynamicSignal"]
|
||||
]
|
||||
|
||||
normal_signals = [
|
||||
obj["component_name"]
|
||||
for obj in dev_obj._info["signals"].values()
|
||||
if obj["kind_str"] == "normal"
|
||||
for obj in signal_infos.values()
|
||||
if obj.get("kind_str") == "normal"
|
||||
]
|
||||
|
||||
return {
|
||||
"progress_signals": progress_signals,
|
||||
"hinted_signals": hinted_signals,
|
||||
@@ -311,21 +312,15 @@ class Ring(BECWidget, QWidget):
|
||||
|
||||
def _update_device_connection(self, device: str, signal: str | None) -> str:
|
||||
"""
|
||||
Update the device connection for the ring widget.
|
||||
Subscribe device mode to the endpoint matching the selected signal.
|
||||
|
||||
In general, we support two modes here:
|
||||
- If signal is provided, we use that directly.
|
||||
- If signal is not provided, we try to get the signal from the device manager.
|
||||
We first check for progress signals, then for hinted signals, and finally for normal signals.
|
||||
|
||||
Depending on what type of signal we get (progress or hinted/normal), we subscribe to different endpoints.
|
||||
|
||||
Args:
|
||||
device(str): Device name for the device mode
|
||||
signal(str): Signal name for the device mode
|
||||
When no signal is provided, the ring selects the first available progress
|
||||
signal, then the first hinted readback signal, then the first normal
|
||||
readback signal. Progress signals use the device_progress endpoint;
|
||||
readback signals use the device_readback endpoint.
|
||||
|
||||
Returns:
|
||||
str: The selected signal name for the device mode
|
||||
The selected signal name, or an empty string if the device is not known.
|
||||
"""
|
||||
logger.info(f"Updating device connection for device '{device}' and signal '{signal}'")
|
||||
dm = self.bec_dispatcher.client.device_manager
|
||||
@@ -341,18 +336,17 @@ class Ring(BECWidget, QWidget):
|
||||
normal_signals = signals["normal_signals"]
|
||||
|
||||
if not signal:
|
||||
# If signal is not provided, we try to get it from the device manager
|
||||
if len(progress_signals) > 0:
|
||||
if progress_signals:
|
||||
signal = progress_signals[0]
|
||||
logger.info(
|
||||
f"Using progress signal '{signal}' for device '{device}' in ring progress bar."
|
||||
)
|
||||
elif len(hinted_signals) > 0:
|
||||
elif hinted_signals:
|
||||
signal = hinted_signals[0]
|
||||
logger.info(
|
||||
f"Using hinted signal '{signal}' for device '{device}' in ring progress bar."
|
||||
)
|
||||
elif len(normal_signals) > 0:
|
||||
elif normal_signals:
|
||||
signal = normal_signals[0]
|
||||
logger.info(
|
||||
f"Using normal signal '{signal}' for device '{device}' in ring progress bar."
|
||||
@@ -366,26 +360,18 @@ class Ring(BECWidget, QWidget):
|
||||
self.bec_dispatcher.connect_slot(self.on_device_progress, endpoint)
|
||||
self.registered_slot = (self.on_device_progress, endpoint)
|
||||
return signal
|
||||
|
||||
if signal in hinted_signals or signal in normal_signals:
|
||||
endpoint = MessageEndpoints.device_readback(device)
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoint)
|
||||
self.registered_slot = (self.on_device_readback, endpoint)
|
||||
return signal
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_scan_progress(self, msg, meta):
|
||||
"""
|
||||
Update the ring widget with the scan progress.
|
||||
|
||||
Args:
|
||||
msg(dict): Message with the scan progress
|
||||
meta(dict): Metadata for the message
|
||||
"""
|
||||
current_RID = meta.get("RID", None)
|
||||
if current_RID != self.RID:
|
||||
self.set_min_max_values(0, msg.get("max_value", 100))
|
||||
self.set_value(msg.get("value", 0))
|
||||
self.update()
|
||||
raise ValueError(
|
||||
f"Signal '{signal}' is not usable for ring progress device mode. "
|
||||
f"Available progress signals: {progress_signals}; "
|
||||
f"available readback signals: {hinted_signals + normal_signals}."
|
||||
)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback(self, msg, meta):
|
||||
@@ -408,30 +394,31 @@ class Ring(BECWidget, QWidget):
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_progress(self, msg, meta):
|
||||
"""
|
||||
Update the ring widget with the device progress.
|
||||
|
||||
Args:
|
||||
msg(dict): Message with the device progress
|
||||
meta(dict): Metadata for the message
|
||||
"""
|
||||
device = self.config.device
|
||||
if device is None:
|
||||
return
|
||||
max_val = msg.get("max_value", 100)
|
||||
self.set_min_max_values(0, max_val)
|
||||
value = msg.get("value", 0)
|
||||
if msg.get("done"):
|
||||
value = max_val
|
||||
self.set_value(value)
|
||||
self.set_value(max_val if msg.get("done") else msg.get("value", 0))
|
||||
self.update()
|
||||
|
||||
def _on_progress_snapshot(self, snapshot: ProgressSnapshot):
|
||||
if snapshot.is_new_scan:
|
||||
self.set_min_max_values(0, snapshot.max_value)
|
||||
self.RID = snapshot.rid
|
||||
self.set_value(snapshot.value)
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
if not self.progress_container:
|
||||
return
|
||||
painter = QtGui.QPainter(self)
|
||||
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
|
||||
size = min(self.width(), self.height())
|
||||
if size <= 0 or not self.isVisible():
|
||||
return
|
||||
painter = QtGui.QPainter(self)
|
||||
if not painter.isActive():
|
||||
return
|
||||
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Center the ring
|
||||
x_offset = (self.width() - size) // 2
|
||||
@@ -509,15 +496,6 @@ class Ring(BECWidget, QWidget):
|
||||
return QtGui.QColor(*color)
|
||||
raise ValueError(f"Unsupported color format: {color}")
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the ring widget.
|
||||
Disconnect any registered slots.
|
||||
"""
|
||||
if self.registered_slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
|
||||
self.registered_slot = None
|
||||
|
||||
###############################################
|
||||
####### QProperties ###########################
|
||||
###############################################
|
||||
@@ -666,6 +644,7 @@ class Ring(BECWidget, QWidget):
|
||||
if self.registered_slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
|
||||
self.registered_slot = None
|
||||
self.progress_tracker.cleanup()
|
||||
self._hover_animation.stop()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user