mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 09:47:52 +02:00
Compare commits
80 Commits
docs/wavef
...
v2.30.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8aa770de3 | ||
| 4d5df9608a | |||
| b718b438ba | |||
|
|
2f978c93c4 | ||
| b4e0664011 | |||
|
|
45fbf4015d | ||
|
|
0d81bdd4dd | ||
|
|
bb4c30ad80 | ||
| 3fd09fceef | |||
| 8eb8225a7f | |||
| 491d04467c | |||
|
|
3bcff75107 | ||
| 608590c542 | |||
|
|
012f7cf970 | ||
| cd17a4aad9 | |||
| f0dc992586 | |||
| fd1f9941e0 | |||
| 3384ca02bd | |||
| 959cedbbd5 | |||
| ca4f97503b | |||
| 22beadcad0 | |||
| b9af36a4f1 | |||
|
|
bdff736aa2 | ||
| 7cda2ed846 | |||
| cd9d22d0b4 | |||
|
|
37b80e16a0 | ||
| 7f0098f153 | |||
| 8489ef4a69 | |||
| 13976557fb | |||
|
|
06ad87ce0a | ||
| 00e3713181 | |||
|
|
62020f9965 | ||
| 2373c7e996 | |||
|
|
1f3566c105 | ||
| b8ae7b2e96 | |||
| 23674ccf59 | |||
| 1d8069e391 | |||
| 44cc06137c | |||
| 46a91784d2 | |||
| debd347b64 | |||
|
|
a13c3c44c8 | ||
| 25b2737aac | |||
| cf97cc1805 | |||
| 694a6c4960 | |||
| 9caae4cf40 | |||
| 2b06e34ecf | |||
| a9c8995ac0 | |||
|
|
1262c66fd6 | ||
| bde523806f | |||
|
|
16bca25d9c | ||
| 130cc24b35 | |||
| 8b2d6052e8 | |||
| 530797a556 | |||
| c660e5141f | |||
| 900153bc0b | |||
| 8dc72656ef | |||
| 170be0c7d3 | |||
| 1925e6ac7f | |||
|
|
b6cef2d27b | ||
| a9fce175b7 | |||
| 783d042e8c | |||
|
|
319a4206f2 | ||
| 76439866c1 | |||
|
|
ca600b057e | ||
| 6c494258f8 | |||
| 63a8da680d | |||
|
|
0f2bde1a0a | ||
| 0c76b0c495 | |||
| e594de3ca3 | |||
| adaad4f4d5 | |||
| 39c316d6ea | |||
| 3ba0fc4b44 | |||
| a6fc7993a3 | |||
| 324a5bd3d9 | |||
| 8929778f07 | |||
|
|
72b5c46912 | ||
| 244bca4e1e | |||
|
|
c50ace5818 | ||
| 25f28c47e3 | |||
| db720e8fa4 |
64
.github/workflows/child_repos.yml
vendored
Normal file
64
.github/workflows/child_repos.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Run Pytest with Coverage
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch for BEC Core'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch for Ophyd Devices'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch for BEC Widgets'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
|
||||
jobs:
|
||||
bec:
|
||||
name: BEC Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -el {0}
|
||||
|
||||
steps:
|
||||
- name: Checkout BEC
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec
|
||||
ref: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
|
||||
- name: Install BEC and dependencies
|
||||
uses: ./.github/actions/bec_install
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
PYTHON_VERSION: '3.11'
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
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
|
||||
bec-e2e-test:
|
||||
name: BEC End2End Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout BEC
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec
|
||||
ref: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
|
||||
- name: Run E2E Tests
|
||||
uses: ./.github/actions/bec_e2e_install
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
PYTHON_VERSION: '3.11'
|
||||
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@@ -57,4 +57,24 @@ jobs:
|
||||
end2end-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/end2end-conda.yml
|
||||
uses: ./.github/workflows/end2end-conda.yml
|
||||
|
||||
child-repos:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/child_repos.yml
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
|
||||
plugin_repos:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
|
||||
secrets:
|
||||
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
|
||||
2
.github/workflows/pytest-matrix.yml
vendored
2
.github/workflows/pytest-matrix.yml
vendored
@@ -56,4 +56,4 @@ jobs:
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
pip install pytest pytest-random-order
|
||||
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
|
||||
311
CHANGELOG.md
311
CHANGELOG.md
@@ -1,6 +1,317 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.30.5 (2025-07-25)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **positioner-box**: Test to fix handling of none integer values for precision
|
||||
([`b718b43`](https://github.com/bec-project/bec_widgets/commit/b718b438bacff6eb6cd6015f1a67dcf75c05dce4))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **positioner-box**: Cleanup, accept float precision
|
||||
([`4d5df96`](https://github.com/bec-project/bec_widgets/commit/4d5df9608a9438b9f6d7508c323eb3772e53f37d))
|
||||
|
||||
|
||||
## v2.30.4 (2025-07-25)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Remove stderr from cli output when not using rpc
|
||||
([`b4e0664`](https://github.com/bec-project/bec_widgets/commit/b4e0664011682cae9966aa2632210a6b60e11714))
|
||||
|
||||
|
||||
## v2.30.3 (2025-07-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Cleanup subscriptions in device browser
|
||||
([`0d81bdd`](https://github.com/bec-project/bec_widgets/commit/0d81bdd4ddb4ec474a414b107cbc7fc865253934))
|
||||
|
||||
|
||||
## v2.30.2 (2025-07-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Factor out device name function and add test
|
||||
([`8eb8225`](https://github.com/bec-project/bec_widgets/commit/8eb8225a7f56014d6093aa142b3a5d071837982e))
|
||||
|
||||
- **rpc_base**: Rpc_call wrapper passes full_name for Devices indeed of name
|
||||
([`491d044`](https://github.com/bec-project/bec_widgets/commit/491d04467c8ce4e116d61e614895d1dcc6b4b201))
|
||||
|
||||
### Testing
|
||||
|
||||
- **test_plotting_framework_e2e**: Added test for waveform with passing device from dev container
|
||||
([`3fd09fc`](https://github.com/bec-project/bec_widgets/commit/3fd09fceef2ffa7e7c3eee20176304bafb00d0db))
|
||||
|
||||
|
||||
## v2.30.1 (2025-07-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Ignore KeyError in SignalLabel
|
||||
([`608590c`](https://github.com/bec-project/bec_widgets/commit/608590c5421368d5bba0e4b0f5187d90cac323be))
|
||||
|
||||
|
||||
## v2.30.0 (2025-07-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **device_browser**: Display signal for signals
|
||||
([`3384ca0`](https://github.com/bec-project/bec_widgets/commit/3384ca02bdb5a2798ad3339ecf3e2ba7c121e28f))
|
||||
|
||||
- **device_signal_display**: Don't read omitted
|
||||
([`b9af36a`](https://github.com/bec-project/bec_widgets/commit/b9af36a4f1c91e910d4fc738b17b90e92287a7e3))
|
||||
|
||||
- **signal_label**: Rewrite reading selection logic
|
||||
([`cd17a4a`](https://github.com/bec-project/bec_widgets/commit/cd17a4aad905296eb0460ecc27e5920f5c2e8fe5))
|
||||
|
||||
- **signal_label**: Show all signals by default
|
||||
([`22beadc`](https://github.com/bec-project/bec_widgets/commit/22beadcad061b328c986414f30fef57b64bad693))
|
||||
|
||||
- **signal_label**: Update signal from dialog correctly
|
||||
([`959cedb`](https://github.com/bec-project/bec_widgets/commit/959cedbbd5a123eef5f3370287bf6476c48caab9))
|
||||
|
||||
- **signal_label**: Use read() instead of get() for init
|
||||
([`f0dc992`](https://github.com/bec-project/bec_widgets/commit/f0dc99258607a5cc8af51686d01f7fd54ae2779f))
|
||||
|
||||
### Chores
|
||||
|
||||
- Update client.py
|
||||
([`fd1f994`](https://github.com/bec-project/bec_widgets/commit/fd1f9941e046b7ae1e247dde39c20bcbc37ac189))
|
||||
|
||||
### Features
|
||||
|
||||
- **signal_label**: Property to display array data or not
|
||||
([`ca4f975`](https://github.com/bec-project/bec_widgets/commit/ca4f97503bf06363e8e8a5d494a9857223da4104))
|
||||
|
||||
|
||||
## v2.29.0 (2025-07-22)
|
||||
|
||||
### Features
|
||||
|
||||
- **notification_banner**: Notification centre for alarms implemented into BECMainWindow
|
||||
([`cd9d22d`](https://github.com/bec-project/bec_widgets/commit/cd9d22d0b40d633af76cb1188b57feb7b6a5dbf2))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **notification_banner**: Becnotificationbroker done as singleton to sync all windows in the
|
||||
session
|
||||
([`7cda2ed`](https://github.com/bec-project/bec_widgets/commit/7cda2ed846d3c27799f4f15f6c5c667631b1ca55))
|
||||
|
||||
|
||||
## v2.28.0 (2025-07-21)
|
||||
|
||||
### Features
|
||||
|
||||
- Disable editing while scan active
|
||||
([`1397655`](https://github.com/bec-project/bec_widgets/commit/13976557fbdb71a1161029521d81a655d25dd134))
|
||||
|
||||
- Remove and readd device for config changes
|
||||
([`8489ef4`](https://github.com/bec-project/bec_widgets/commit/8489ef4a69d69b39648b1a9270012f14f95c6121))
|
||||
|
||||
- Save and load config from devicebrowser
|
||||
([`7f0098f`](https://github.com/bec-project/bec_widgets/commit/7f0098f1533d419cc75801c4d6cbea485c7bbf94))
|
||||
|
||||
|
||||
## v2.27.1 (2025-07-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image_roi_tree**: Rois signals are disconnected when roi tree widget is closed
|
||||
([`00e3713`](https://github.com/bec-project/bec_widgets/commit/00e3713181916a432e4e9dec8a0d80205914cf77))
|
||||
|
||||
|
||||
## v2.27.0 (2025-07-17)
|
||||
|
||||
### Features
|
||||
|
||||
- Add monaco editor
|
||||
([`2373c7e`](https://github.com/bec-project/bec_widgets/commit/2373c7e996566a5b84c5a50e1c3e69de885713db))
|
||||
|
||||
|
||||
## v2.26.0 (2025-07-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **config label**: Reset offset when toggling the label action
|
||||
([`b8ae7b2`](https://github.com/bec-project/bec_widgets/commit/b8ae7b2e96071b6dc59dae7ffa72bbedc6aaea23))
|
||||
|
||||
- **performance_bundle**: Fix performance bundle cleanup
|
||||
([`23674cc`](https://github.com/bec-project/bec_widgets/commit/23674ccf592a2caa0b57ae64ad1499c270b7d469))
|
||||
|
||||
### Features
|
||||
|
||||
- **device combobox**: Add option to insert an empty element
|
||||
([`debd347`](https://github.com/bec-project/bec_widgets/commit/debd347b64a3d2ca07ddcd5ef3a3394d1ffb67e3))
|
||||
|
||||
- **heatmap**: Add interpolation and oversampling UI components
|
||||
([`1d8069e`](https://github.com/bec-project/bec_widgets/commit/1d8069e391412e3096a3c1e7181398dd4e609650))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **image_base**: Cleanup
|
||||
([`46a9178`](https://github.com/bec-project/bec_widgets/commit/46a91784d237137128965ad585e38085e931e5d4))
|
||||
|
||||
### Testing
|
||||
|
||||
- **history**: Add history message helper methods to conftest
|
||||
([`44cc061`](https://github.com/bec-project/bec_widgets/commit/44cc06137ccfbc087bdd3005156ff28effe05f23))
|
||||
|
||||
|
||||
## v2.25.0 (2025-07-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **bec-progressbar**: Add flag for theme update
|
||||
([`694a6c4`](https://github.com/bec-project/bec_widgets/commit/694a6c49608b68e25dc0c76b58855b96f3f0ef0b))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- **bec**: Add child_repos test for bec (unit and e2e tests)
|
||||
([`a9c8995`](https://github.com/bec-project/bec_widgets/commit/a9c8995ac0b39f6bc327887f43f7d4d6e6e89db2))
|
||||
|
||||
- **plugin**: Add plugin repository test to BW ci
|
||||
([`2b06e34`](https://github.com/bec-project/bec_widgets/commit/2b06e34ecff8c0a92a2b235f375e837729736b2a))
|
||||
|
||||
### Features
|
||||
|
||||
- **scan-history-browser**: Add history browser and history metadata viewer
|
||||
([`9caae4c`](https://github.com/bec-project/bec_widgets/commit/9caae4cf40d3876175b827abb735ae227ae0bcea))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Add additional components for history metadata, device view and popup ui
|
||||
([`cf97cc1`](https://github.com/bec-project/bec_widgets/commit/cf97cc1805e16073c7849d1f9375e2ebd2176b70))
|
||||
|
||||
- Cleanup, add compact popup view for scan_history_browser and update tests
|
||||
([`25b2737`](https://github.com/bec-project/bec_widgets/commit/25b2737aacfaa45f255afb6ebf467d5781165a8e))
|
||||
|
||||
|
||||
## v2.24.1 (2025-07-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Update signal label for device_edit changes
|
||||
([`bde5238`](https://github.com/bec-project/bec_widgets/commit/bde523806fdb6ab224b485f65b615f89dfe20b7b))
|
||||
|
||||
|
||||
## v2.24.0 (2025-07-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Hide validity LED, show message as tooltip
|
||||
([`530797a`](https://github.com/bec-project/bec_widgets/commit/530797a5568957dde9f47f417310f5c4d2493906))
|
||||
|
||||
- Validate some config data
|
||||
([`c660e51`](https://github.com/bec-project/bec_widgets/commit/c660e5141f191a782c224ee1b83536793639eecb))
|
||||
|
||||
- **device_browser**: Un-nest exception
|
||||
([`8b2d605`](https://github.com/bec-project/bec_widgets/commit/8b2d6052e808f8b4063e5f45c40e4460524f044e))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Docstring for config dialog
|
||||
([`1925e6a`](https://github.com/bec-project/bec_widgets/commit/1925e6ac7f98875eb5980637ae3293e22b459e28))
|
||||
|
||||
### Features
|
||||
|
||||
- (#495) add devices through browser
|
||||
([`170be0c`](https://github.com/bec-project/bec_widgets/commit/170be0c7d3bb1f6e5f2305958909ef68cd987fbd))
|
||||
|
||||
- **#495**: Add validation against existing device names
|
||||
([`900153b`](https://github.com/bec-project/bec_widgets/commit/900153bc0b8cec7bad82e23b3772c66e84900a17))
|
||||
|
||||
- **device_browser**: Connect update to item refresh
|
||||
([`130cc24`](https://github.com/bec-project/bec_widgets/commit/130cc24b351684358558ab81c0111f10f9abb11f))
|
||||
|
||||
- **device_browser**: Device deletion from config
|
||||
([`8dc7265`](https://github.com/bec-project/bec_widgets/commit/8dc72656ef46ae7be886f9da59beb768f5381b4f))
|
||||
|
||||
|
||||
## v2.23.0 (2025-07-11)
|
||||
|
||||
### Features
|
||||
|
||||
- **widget_finder**: Widget to fetch any other widget by class from currently running app
|
||||
([`a9fce17`](https://github.com/bec-project/bec_widgets/commit/a9fce175b720ad85a5cefcab99d79fbcb971ff4a))
|
||||
|
||||
- **widget_io**: Utility function to find widget in the app by class
|
||||
([`783d042`](https://github.com/bec-project/bec_widgets/commit/783d042e8c469774fc8407921462a99c96f6d408))
|
||||
|
||||
|
||||
## v2.22.2 (2025-07-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **plot_base**: Autorange takes into account only visible curves
|
||||
([`7643986`](https://github.com/bec-project/bec_widgets/commit/76439866c1fd09cb7d9d48dfccdc7b1943bfbc0f))
|
||||
|
||||
|
||||
## v2.22.1 (2025-07-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **crosshair**: Crosshair mouse_moved can be set manually
|
||||
([`63a8da6`](https://github.com/bec-project/bec_widgets/commit/63a8da680d263a50102aacf463ec6f6252562f9d))
|
||||
|
||||
- **heatmap**: Fix pixel size calculation for arbitrary shapes
|
||||
([`6c49425`](https://github.com/bec-project/bec_widgets/commit/6c494258f82059a2472f43bb8287390ce1aba704))
|
||||
|
||||
|
||||
## v2.22.0 (2025-07-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **crosshair**: Add slot to reset mouse markers
|
||||
([`adaad4f`](https://github.com/bec-project/bec_widgets/commit/adaad4f4d5ebf775a337e23a944ba9eb289d01a0))
|
||||
|
||||
- **crosshair**: Fix crosshair support for transformations
|
||||
([`3ba0fc4`](https://github.com/bec-project/bec_widgets/commit/3ba0fc4b442e5926f27a13f09d628c30987f2cf8))
|
||||
|
||||
- **image**: Reset crosshair on new scan
|
||||
([`e594de3`](https://github.com/bec-project/bec_widgets/commit/e594de3ca39970f91f5842693eeb1fac393eaa34))
|
||||
|
||||
- **image item**: Fix processor for nans in images
|
||||
([`39c316d`](https://github.com/bec-project/bec_widgets/commit/39c316d6eadfdfbd483661b67720a7e224a46712))
|
||||
|
||||
- **image_base**: Move cbar init to image base
|
||||
([`8929778`](https://github.com/bec-project/bec_widgets/commit/8929778f073c40a9eabba7eda2415fc9af1072bb))
|
||||
|
||||
- **image_processor**: Support for nans in nd arrays
|
||||
([`a6fc799`](https://github.com/bec-project/bec_widgets/commit/a6fc7993a3d22cfd086310c8e6dad3f9f3d1e9fe))
|
||||
|
||||
### Features
|
||||
|
||||
- Add heatmap widget
|
||||
([`0c76b0c`](https://github.com/bec-project/bec_widgets/commit/0c76b0c49598d1456aab266b483de327788028fd))
|
||||
|
||||
- **image_item**: Add support for qtransform
|
||||
([`324a5bd`](https://github.com/bec-project/bec_widgets/commit/324a5bd3d9ed278495c6ba62453b02061900ae32))
|
||||
|
||||
|
||||
## v2.21.4 (2025-07-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image_roi_tree**: Changing color dialog from ColorButtonNative is open once
|
||||
([`244bca4`](https://github.com/bec-project/bec_widgets/commit/244bca4e1ec7c00109534b9f503ff2eb125c1ffe))
|
||||
|
||||
|
||||
## v2.21.3 (2025-07-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **connector**: Remove safeslot for now
|
||||
([`25f28c4`](https://github.com/bec-project/bec_widgets/commit/25f28c47e32af1be7778803dc27d8c2a367172ed))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **toolbar**: Split toolbar into components, bundles and connections
|
||||
([`db720e8`](https://github.com/bec-project/bec_widgets/commit/db720e8fa46bb2fb10c73afa1b4f039cd256d68b))
|
||||
|
||||
|
||||
## v2.21.2 (2025-06-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -27,7 +27,7 @@ from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.utils.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
@@ -37,9 +37,11 @@ _Widgets = {
|
||||
"DeviceBrowser": "DeviceBrowser",
|
||||
"DeviceComboBox": "DeviceComboBox",
|
||||
"DeviceLineEdit": "DeviceLineEdit",
|
||||
"Heatmap": "Heatmap",
|
||||
"Image": "Image",
|
||||
"LogPanel": "LogPanel",
|
||||
"Minesweeper": "Minesweeper",
|
||||
"MonacoWidget": "MonacoWidget",
|
||||
"MotorMap": "MotorMap",
|
||||
"MultiWaveform": "MultiWaveform",
|
||||
"PositionIndicator": "PositionIndicator",
|
||||
@@ -1181,6 +1183,544 @@ class EllipticalROI(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class Heatmap(RPCBase):
|
||||
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
"""
|
||||
Show Toolbar.
|
||||
"""
|
||||
|
||||
@enable_toolbar.setter
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
"""
|
||||
Show Toolbar.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_side_panel(self) -> "bool":
|
||||
"""
|
||||
Show Side Panel
|
||||
"""
|
||||
|
||||
@enable_side_panel.setter
|
||||
@rpc_call
|
||||
def enable_side_panel(self) -> "bool":
|
||||
"""
|
||||
Show Side Panel
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_fps_monitor(self) -> "bool":
|
||||
"""
|
||||
Enable the FPS monitor.
|
||||
"""
|
||||
|
||||
@enable_fps_monitor.setter
|
||||
@rpc_call
|
||||
def enable_fps_monitor(self) -> "bool":
|
||||
"""
|
||||
Enable the FPS monitor.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set(self, **kwargs):
|
||||
"""
|
||||
Set the properties of the plot widget.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
|
||||
Possible properties:
|
||||
- title: str
|
||||
- x_label: str
|
||||
- y_label: str
|
||||
- x_scale: Literal["linear", "log"]
|
||||
- y_scale: Literal["linear", "log"]
|
||||
- x_lim: tuple
|
||||
- y_lim: tuple
|
||||
- legend_label_size: int
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def title(self) -> "str":
|
||||
"""
|
||||
Set title of the plot.
|
||||
"""
|
||||
|
||||
@title.setter
|
||||
@rpc_call
|
||||
def title(self) -> "str":
|
||||
"""
|
||||
Set title of the plot.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_label(self) -> "str":
|
||||
"""
|
||||
The set label for the x-axis.
|
||||
"""
|
||||
|
||||
@x_label.setter
|
||||
@rpc_call
|
||||
def x_label(self) -> "str":
|
||||
"""
|
||||
The set label for the x-axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def y_label(self) -> "str":
|
||||
"""
|
||||
The set label for the y-axis.
|
||||
"""
|
||||
|
||||
@y_label.setter
|
||||
@rpc_call
|
||||
def y_label(self) -> "str":
|
||||
"""
|
||||
The set label for the y-axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_limits(self) -> "QPointF":
|
||||
"""
|
||||
Get the x limits of the plot.
|
||||
"""
|
||||
|
||||
@x_limits.setter
|
||||
@rpc_call
|
||||
def x_limits(self) -> "QPointF":
|
||||
"""
|
||||
Get the x limits of the plot.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def y_limits(self) -> "QPointF":
|
||||
"""
|
||||
Get the y limits of the plot.
|
||||
"""
|
||||
|
||||
@y_limits.setter
|
||||
@rpc_call
|
||||
def y_limits(self) -> "QPointF":
|
||||
"""
|
||||
Get the y limits of the plot.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_grid(self) -> "bool":
|
||||
"""
|
||||
Show grid on the x-axis.
|
||||
"""
|
||||
|
||||
@x_grid.setter
|
||||
@rpc_call
|
||||
def x_grid(self) -> "bool":
|
||||
"""
|
||||
Show grid on the x-axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def y_grid(self) -> "bool":
|
||||
"""
|
||||
Show grid on the y-axis.
|
||||
"""
|
||||
|
||||
@y_grid.setter
|
||||
@rpc_call
|
||||
def y_grid(self) -> "bool":
|
||||
"""
|
||||
Show grid on the y-axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def inner_axes(self) -> "bool":
|
||||
"""
|
||||
Show inner axes of the plot widget.
|
||||
"""
|
||||
|
||||
@inner_axes.setter
|
||||
@rpc_call
|
||||
def inner_axes(self) -> "bool":
|
||||
"""
|
||||
Show inner axes of the plot widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def outer_axes(self) -> "bool":
|
||||
"""
|
||||
Show the outer axes of the plot widget.
|
||||
"""
|
||||
|
||||
@outer_axes.setter
|
||||
@rpc_call
|
||||
def outer_axes(self) -> "bool":
|
||||
"""
|
||||
Show the outer axes of the plot widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
"""
|
||||
Set auto range for the x-axis.
|
||||
"""
|
||||
|
||||
@auto_range_x.setter
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
"""
|
||||
Set auto range for the x-axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_y(self) -> "bool":
|
||||
"""
|
||||
Set auto range for the y-axis.
|
||||
"""
|
||||
|
||||
@auto_range_y.setter
|
||||
@rpc_call
|
||||
def auto_range_y(self) -> "bool":
|
||||
"""
|
||||
Set auto range for the y-axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@minimal_crosshair_precision.setter
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def color_map(self) -> "str":
|
||||
"""
|
||||
Set the color map of the image.
|
||||
"""
|
||||
|
||||
@color_map.setter
|
||||
@rpc_call
|
||||
def color_map(self) -> "str":
|
||||
"""
|
||||
Set the color map of the image.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def v_range(self) -> "QPointF":
|
||||
"""
|
||||
Set the v_range of the main image.
|
||||
"""
|
||||
|
||||
@v_range.setter
|
||||
@rpc_call
|
||||
def v_range(self) -> "QPointF":
|
||||
"""
|
||||
Set the v_range of the main image.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def v_min(self) -> "float":
|
||||
"""
|
||||
Get the minimum value of the v_range.
|
||||
"""
|
||||
|
||||
@v_min.setter
|
||||
@rpc_call
|
||||
def v_min(self) -> "float":
|
||||
"""
|
||||
Get the minimum value of the v_range.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def v_max(self) -> "float":
|
||||
"""
|
||||
Get the maximum value of the v_range.
|
||||
"""
|
||||
|
||||
@v_max.setter
|
||||
@rpc_call
|
||||
def v_max(self) -> "float":
|
||||
"""
|
||||
Get the maximum value of the v_range.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@lock_aspect_ratio.setter
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def autorange(self) -> "bool":
|
||||
"""
|
||||
Whether autorange is enabled.
|
||||
"""
|
||||
|
||||
@autorange.setter
|
||||
@rpc_call
|
||||
def autorange(self) -> "bool":
|
||||
"""
|
||||
Whether autorange is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def autorange_mode(self) -> "str":
|
||||
"""
|
||||
Autorange mode.
|
||||
|
||||
Options:
|
||||
- "max": Use the maximum value of the image for autoranging.
|
||||
- "mean": Use the mean value of the image for autoranging.
|
||||
"""
|
||||
|
||||
@autorange_mode.setter
|
||||
@rpc_call
|
||||
def autorange_mode(self) -> "str":
|
||||
"""
|
||||
Autorange mode.
|
||||
|
||||
Options:
|
||||
- "max": Use the maximum value of the image for autoranging.
|
||||
- "mean": Use the mean value of the image for autoranging.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def enable_colorbar(
|
||||
self,
|
||||
enabled: "bool",
|
||||
style: "Literal['full', 'simple']" = "full",
|
||||
vrange: "tuple[int, int] | None" = None,
|
||||
):
|
||||
"""
|
||||
Enable the colorbar and switch types of colorbars.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable the colorbar.
|
||||
style(Literal["full", "simple"]): The type of colorbar to enable.
|
||||
vrange(tuple): The range of values to use for the colorbar.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_simple_colorbar(self) -> "bool":
|
||||
"""
|
||||
Enable the simple colorbar.
|
||||
"""
|
||||
|
||||
@enable_simple_colorbar.setter
|
||||
@rpc_call
|
||||
def enable_simple_colorbar(self) -> "bool":
|
||||
"""
|
||||
Enable the simple colorbar.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_full_colorbar(self) -> "bool":
|
||||
"""
|
||||
Enable the full colorbar.
|
||||
"""
|
||||
|
||||
@enable_full_colorbar.setter
|
||||
@rpc_call
|
||||
def enable_full_colorbar(self) -> "bool":
|
||||
"""
|
||||
Enable the full colorbar.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def interpolation_method(self) -> "str":
|
||||
"""
|
||||
The interpolation method used for the heatmap.
|
||||
"""
|
||||
|
||||
@interpolation_method.setter
|
||||
@rpc_call
|
||||
def interpolation_method(self) -> "str":
|
||||
"""
|
||||
The interpolation method used for the heatmap.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def oversampling_factor(self) -> "float":
|
||||
"""
|
||||
The oversampling factor for grid resolution.
|
||||
"""
|
||||
|
||||
@oversampling_factor.setter
|
||||
@rpc_call
|
||||
def oversampling_factor(self) -> "float":
|
||||
"""
|
||||
The oversampling factor for grid resolution.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enforce_interpolation(self) -> "bool":
|
||||
"""
|
||||
Whether to enforce interpolation even for grid scans.
|
||||
"""
|
||||
|
||||
@enforce_interpolation.setter
|
||||
@rpc_call
|
||||
def enforce_interpolation(self) -> "bool":
|
||||
"""
|
||||
Whether to enforce interpolation even for grid scans.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def fft(self) -> "bool":
|
||||
"""
|
||||
Whether FFT postprocessing is enabled.
|
||||
"""
|
||||
|
||||
@fft.setter
|
||||
@rpc_call
|
||||
def fft(self) -> "bool":
|
||||
"""
|
||||
Whether FFT postprocessing is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def log(self) -> "bool":
|
||||
"""
|
||||
Whether logarithmic scaling is applied.
|
||||
"""
|
||||
|
||||
@log.setter
|
||||
@rpc_call
|
||||
def log(self) -> "bool":
|
||||
"""
|
||||
Whether logarithmic scaling is applied.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def main_image(self) -> "ImageItem":
|
||||
"""
|
||||
Access the main image item.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def add_roi(
|
||||
self,
|
||||
kind: "Literal['rect', 'circle', 'ellipse']" = "rect",
|
||||
name: "str | None" = None,
|
||||
line_width: "int | None" = 5,
|
||||
pos: "tuple[float, float] | None" = (10, 10),
|
||||
size: "tuple[float, float] | None" = (50, 50),
|
||||
movable: "bool" = True,
|
||||
**pg_kwargs,
|
||||
) -> "RectangularROI | CircularROI":
|
||||
"""
|
||||
Add a ROI to the image.
|
||||
|
||||
Args:
|
||||
kind(str): The type of ROI to add. Options are "rect" or "circle".
|
||||
name(str): The name of the ROI.
|
||||
line_width(int): The line width of the ROI.
|
||||
pos(tuple): The position of the ROI.
|
||||
size(tuple): The size of the ROI.
|
||||
movable(bool): Whether the ROI is movable.
|
||||
**pg_kwargs: Additional arguments for the ROI.
|
||||
|
||||
Returns:
|
||||
RectangularROI | CircularROI: The created ROI object.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove_roi(self, roi: "int | str"):
|
||||
"""
|
||||
Remove an ROI by index or label via the ROIController.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def rois(self) -> "list[BaseROI]":
|
||||
"""
|
||||
Get the list of ROIs.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def plot(
|
||||
self,
|
||||
x_name: "str",
|
||||
y_name: "str",
|
||||
z_name: "str",
|
||||
x_entry: "None | str" = None,
|
||||
y_entry: "None | str" = None,
|
||||
z_entry: "None | str" = None,
|
||||
color_map: "str | None" = "plasma",
|
||||
validate_bec: "bool" = True,
|
||||
interpolation: "Literal['linear', 'nearest'] | None" = None,
|
||||
enforce_interpolation: "bool | None" = None,
|
||||
oversampling_factor: "float | None" = None,
|
||||
lock_aspect_ratio: "bool | None" = None,
|
||||
show_config_label: "bool | None" = None,
|
||||
reload: "bool" = False,
|
||||
):
|
||||
"""
|
||||
Plot the heatmap with the given x, y, and z data.
|
||||
|
||||
Args:
|
||||
x_name (str): The name of the x-axis signal.
|
||||
y_name (str): The name of the y-axis signal.
|
||||
z_name (str): The name of the z-axis signal.
|
||||
x_entry (str | None): The entry for the x-axis signal.
|
||||
y_entry (str | None): The entry for the y-axis signal.
|
||||
z_entry (str | None): The entry for the z-axis signal.
|
||||
color_map (str | None): The color map to use for the heatmap.
|
||||
validate_bec (bool): Whether to validate the entries against BEC signals.
|
||||
interpolation (Literal["linear", "nearest"] | None): The interpolation method to use.
|
||||
enforce_interpolation (bool | None): Whether to enforce interpolation even for grid scans.
|
||||
oversampling_factor (float | None): Factor to oversample the grid resolution.
|
||||
lock_aspect_ratio (bool | None): Whether to lock the aspect ratio of the image.
|
||||
show_config_label (bool | None): Whether to show the configuration label in the heatmap.
|
||||
reload (bool): Whether to reload the heatmap with new data.
|
||||
"""
|
||||
|
||||
|
||||
class Image(RPCBase):
|
||||
"""Image widget for displaying 2D data."""
|
||||
|
||||
@@ -1879,6 +2419,98 @@ class LogPanel(RPCBase):
|
||||
class Minesweeper(RPCBase): ...
|
||||
|
||||
|
||||
class MonacoWidget(RPCBase):
|
||||
"""A simple Monaco editor widget"""
|
||||
|
||||
@rpc_call
|
||||
def set_text(self, text: str) -> None:
|
||||
"""
|
||||
Set the text in the Monaco editor.
|
||||
|
||||
Args:
|
||||
text (str): The text to set in the editor.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_text(self) -> str:
|
||||
"""
|
||||
Get the current text from the Monaco editor.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_language(self, language: str) -> None:
|
||||
"""
|
||||
Set the programming language for syntax highlighting in the Monaco editor.
|
||||
|
||||
Args:
|
||||
language (str): The programming language to set (e.g., "python", "javascript").
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_language(self) -> str:
|
||||
"""
|
||||
Get the current programming language set in the Monaco editor.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_theme(self, theme: str) -> None:
|
||||
"""
|
||||
Set the theme for the Monaco editor.
|
||||
|
||||
Args:
|
||||
theme (str): The theme to set (e.g., "vs-dark", "light").
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_theme(self) -> str:
|
||||
"""
|
||||
Get the current theme of the Monaco editor.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_readonly(self, read_only: bool) -> None:
|
||||
"""
|
||||
Set the Monaco editor to read-only mode.
|
||||
|
||||
Args:
|
||||
read_only (bool): If True, the editor will be read-only.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_cursor(
|
||||
self,
|
||||
line: int,
|
||||
column: int = 1,
|
||||
move_to_position: Literal[None, "center", "top", "position"] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Set the cursor position in the Monaco editor.
|
||||
|
||||
Args:
|
||||
line (int): Line number (1-based).
|
||||
column (int): Column number (1-based), defaults to 1.
|
||||
move_to_position (Literal[None, "center", "top", "position"], optional): Position to move the cursor to.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def current_cursor(self) -> dict[str, int]:
|
||||
"""
|
||||
Get the current cursor position in the Monaco editor.
|
||||
|
||||
Returns:
|
||||
dict[str, int]: A dictionary with keys 'line' and 'column' representing the cursor position.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_minimap_enabled(self, enabled: bool) -> None:
|
||||
"""
|
||||
Enable or disable the minimap in the Monaco editor.
|
||||
|
||||
Args:
|
||||
enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled.
|
||||
"""
|
||||
|
||||
|
||||
class MotorMap(RPCBase):
|
||||
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
|
||||
|
||||
@@ -3746,6 +4378,62 @@ class SignalLabel(RPCBase):
|
||||
Show the button to select the signal to display
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def show_hinted_signals(self) -> "bool":
|
||||
"""
|
||||
In the signal selection menu, show hinted signals
|
||||
"""
|
||||
|
||||
@show_hinted_signals.setter
|
||||
@rpc_call
|
||||
def show_hinted_signals(self) -> "bool":
|
||||
"""
|
||||
In the signal selection menu, show hinted signals
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def show_normal_signals(self) -> "bool":
|
||||
"""
|
||||
In the signal selection menu, show normal signals
|
||||
"""
|
||||
|
||||
@show_normal_signals.setter
|
||||
@rpc_call
|
||||
def show_normal_signals(self) -> "bool":
|
||||
"""
|
||||
In the signal selection menu, show normal signals
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def show_config_signals(self) -> "bool":
|
||||
"""
|
||||
In the signal selection menu, show config signals
|
||||
"""
|
||||
|
||||
@show_config_signals.setter
|
||||
@rpc_call
|
||||
def show_config_signals(self) -> "bool":
|
||||
"""
|
||||
In the signal selection menu, show config signals
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def display_array_data(self) -> "bool":
|
||||
"""
|
||||
Displays the full data from array signals if set to True.
|
||||
"""
|
||||
|
||||
@display_array_data.setter
|
||||
@rpc_call
|
||||
def display_array_data(self) -> "bool":
|
||||
"""
|
||||
Displays the full data from array signals if set to True.
|
||||
"""
|
||||
|
||||
|
||||
class SignalLineEdit(RPCBase):
|
||||
"""Line edit widget for device input with autocomplete for device names."""
|
||||
@@ -4077,6 +4765,15 @@ class Waveform(RPCBase):
|
||||
Set auto range for the y-axis.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
|
||||
@@ -51,7 +51,7 @@ def _filter_output(output: str) -> str:
|
||||
|
||||
|
||||
def _get_output(process, logger) -> None:
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.info}
|
||||
stream_buffer = {process.stdout: [], process.stderr: []}
|
||||
try:
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
|
||||
@@ -7,6 +7,7 @@ from functools import wraps
|
||||
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.utils.import_utils import lazy_import, lazy_import_from
|
||||
|
||||
@@ -24,6 +25,20 @@ else:
|
||||
# pylint: disable=protected-access
|
||||
|
||||
|
||||
def _name_arg(arg):
|
||||
if isinstance(arg, DeviceBaseWithConfig):
|
||||
# if dev.<device> is passed to GUI, it passes full_name
|
||||
if hasattr(arg, "full_name"):
|
||||
return arg.full_name
|
||||
elif hasattr(arg, "name"):
|
||||
return arg.name
|
||||
return arg
|
||||
|
||||
|
||||
def _transform_args_kwargs(args, kwargs) -> tuple[tuple, dict]:
|
||||
return tuple(_name_arg(arg) for arg in args), {k: _name_arg(v) for k, v in kwargs.items()}
|
||||
|
||||
|
||||
def rpc_call(func):
|
||||
"""
|
||||
A decorator for calling a function on the server.
|
||||
@@ -47,15 +62,7 @@ def rpc_call(func):
|
||||
return None # func(*args, **kwargs)
|
||||
caller_frame = caller_frame.f_back
|
||||
|
||||
out = []
|
||||
for arg in args:
|
||||
if hasattr(arg, "name"):
|
||||
arg = arg.name
|
||||
out.append(arg)
|
||||
args = tuple(out)
|
||||
for key, val in kwargs.items():
|
||||
if hasattr(val, "name"):
|
||||
kwargs[key] = val.name
|
||||
args, kwargs = _transform_args_kwargs(args, kwargs)
|
||||
if not self._root._gui_is_alive():
|
||||
raise RuntimeError("GUI is not alive")
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
|
||||
@@ -9,6 +9,7 @@ from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from qtmonaco.pylsp_provider import pylsp_server
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
@@ -142,6 +143,8 @@ class GUIServer:
|
||||
"""
|
||||
Shutdown the GUI server.
|
||||
"""
|
||||
if pylsp_server.is_running():
|
||||
pylsp_server.stop()
|
||||
if self.dispatcher:
|
||||
self.dispatcher.stop_cli_server()
|
||||
self.dispatcher.disconnect_all()
|
||||
|
||||
@@ -186,7 +186,6 @@ class BECConnector:
|
||||
except:
|
||||
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
|
||||
|
||||
@SafeSlot()
|
||||
def _run_cleanup_on_deleted_parent(self) -> None:
|
||||
"""
|
||||
Run cleanup on the deleted parent.
|
||||
|
||||
@@ -5,9 +5,13 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, Qt, Signal, Slot
|
||||
from qtpy.QtCore import QObject, QPointF, Qt, Signal
|
||||
from qtpy.QtGui import QCursor, QTransform
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
|
||||
|
||||
class CrosshairScatterItem(pg.ScatterPlotItem):
|
||||
def setDownsampling(self, ds=None, auto=None, method=None):
|
||||
@@ -160,7 +164,7 @@ class Crosshair(QObject):
|
||||
qapp.theme_signal.theme_updated.connect(self._update_theme)
|
||||
self._update_theme()
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def _update_theme(self, theme: str | None = None):
|
||||
"""Update the theme."""
|
||||
if theme is None:
|
||||
@@ -187,7 +191,7 @@ class Crosshair(QObject):
|
||||
self.coord_label.fill = pg.mkBrush(label_bg_color)
|
||||
self.coord_label.border = pg.mkPen(None)
|
||||
|
||||
@Slot(int)
|
||||
@SafeSlot(int)
|
||||
def update_highlighted_curve(self, curve_index: int):
|
||||
"""
|
||||
Update the highlighted curve in the case of multiple curves in a plot item.
|
||||
@@ -265,15 +269,47 @@ class Crosshair(QObject):
|
||||
[0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False
|
||||
)
|
||||
self.marker_2d_row.skip_auto_range = True
|
||||
if item.image_transform is not None:
|
||||
self.marker_2d_row.setTransform(item.image_transform)
|
||||
self.plot_item.addItem(self.marker_2d_row)
|
||||
|
||||
# Create vertical ROI for column highlighting
|
||||
self.marker_2d_col = pg.ROI(
|
||||
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
|
||||
)
|
||||
if item.image_transform is not None:
|
||||
self.marker_2d_col.setTransform(item.image_transform)
|
||||
self.marker_2d_col.skip_auto_range = True
|
||||
self.plot_item.addItem(self.marker_2d_col)
|
||||
|
||||
@SafeSlot()
|
||||
def update_markers_on_image_change(self):
|
||||
"""
|
||||
Update markers when the image changes, e.g. when the
|
||||
image shape or transformation changes.
|
||||
"""
|
||||
for item in self.items:
|
||||
if not isinstance(item, pg.ImageItem):
|
||||
continue
|
||||
if self.marker_2d_row is not None:
|
||||
self.marker_2d_row.setSize([item.image.shape[0], 1])
|
||||
self.marker_2d_row.setTransform(item.image_transform)
|
||||
if self.marker_2d_col is not None:
|
||||
self.marker_2d_col.setSize([1, item.image.shape[1]])
|
||||
self.marker_2d_col.setTransform(item.image_transform)
|
||||
# Get the current mouse position
|
||||
views = self.plot_item.vb.scene().views()
|
||||
if not views:
|
||||
return
|
||||
view = views[0]
|
||||
global_pos = QCursor.pos()
|
||||
view_pos = view.mapFromGlobal(global_pos)
|
||||
scene_pos = view.mapToScene(view_pos)
|
||||
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
|
||||
plot_pt = self.plot_item.vb.mapSceneToView(scene_pos)
|
||||
self.mouse_moved(manual_pos=(plot_pt.x(), plot_pt.y()))
|
||||
|
||||
def snap_to_data(
|
||||
self, x: float, y: float
|
||||
) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]:
|
||||
@@ -316,9 +352,25 @@ class Crosshair(QObject):
|
||||
image_2d = item.image
|
||||
if image_2d is None:
|
||||
continue
|
||||
# Clip the x and y values to the image dimensions to avoid out of bounds errors
|
||||
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
|
||||
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
|
||||
# Map scene coordinates (plot units) back to image pixel coordinates
|
||||
if item.image_transform is not None:
|
||||
inv_transform, _ = item.image_transform.inverted()
|
||||
xy_trans = inv_transform.map(QPointF(x, y))
|
||||
else:
|
||||
xy_trans = QPointF(x, y)
|
||||
|
||||
# Define valid pixel coordinate bounds
|
||||
min_x_px, min_y_px = 0, 0
|
||||
max_x_px = image_2d.shape[0] - 1 # columns
|
||||
max_y_px = image_2d.shape[1] - 1 # rows
|
||||
|
||||
# Clip the mapped coordinates to the image bounds
|
||||
px = int(np.clip(xy_trans.x(), min_x_px, max_x_px))
|
||||
py = int(np.clip(xy_trans.y(), min_y_px, max_y_px))
|
||||
|
||||
# Store snapped pixel positions
|
||||
x_values[name] = px
|
||||
y_values[name] = py
|
||||
|
||||
if x_values and y_values:
|
||||
if all(v is None for v in x_values.values()) or all(
|
||||
@@ -358,60 +410,74 @@ class Crosshair(QObject):
|
||||
|
||||
return list_x[original_index], list_y[original_index]
|
||||
|
||||
def mouse_moved(self, event):
|
||||
"""Handles the mouse moved event, updating the crosshair position and emitting signals.
|
||||
@SafeSlot(object, tuple)
|
||||
def mouse_moved(self, event=None, manual_pos=None):
|
||||
"""
|
||||
Handles the mouse moved event, updating the crosshair position and emitting signals.
|
||||
|
||||
Args:
|
||||
event: The mouse moved event
|
||||
event(object): The mouse moved event, which contains the scene position.
|
||||
manual_pos(tuple, optional): A tuple containing the (x, y) coordinates to manually set the crosshair position.
|
||||
"""
|
||||
pos = event[0]
|
||||
# Determine target (x, y) in *plot* coordinates
|
||||
if manual_pos is not None:
|
||||
x, y = manual_pos
|
||||
else:
|
||||
if event is None:
|
||||
return # nothing to do
|
||||
scene_pos = event[0] # SignalProxy bundle
|
||||
if not self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
|
||||
return
|
||||
view_pos = self.plot_item.vb.mapSceneToView(scene_pos)
|
||||
x, y = view_pos.x(), view_pos.y()
|
||||
|
||||
# Update cross‑hair visuals
|
||||
self.v_line.setPos(x)
|
||||
self.h_line.setPos(y)
|
||||
|
||||
self.update_markers()
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(pos):
|
||||
mouse_point = self.plot_item.vb.mapSceneToView(pos)
|
||||
x, y = mouse_point.x(), mouse_point.y()
|
||||
self.v_line.setPos(x)
|
||||
self.h_line.setPos(y)
|
||||
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
|
||||
self.crosshairChanged.emit((scaled_x, scaled_y))
|
||||
self.positionChanged.emit((x, y))
|
||||
scaled_x, scaled_y = self.scale_emitted_coordinates(x, y)
|
||||
self.crosshairChanged.emit((scaled_x, scaled_y))
|
||||
self.positionChanged.emit((x, y))
|
||||
|
||||
x_snap_values, y_snap_values = self.snap_to_data(x, y)
|
||||
if x_snap_values is None or y_snap_values is None:
|
||||
return
|
||||
if all(v is None for v in x_snap_values.values()) or all(
|
||||
v is None for v in y_snap_values.values()
|
||||
):
|
||||
# not sure how we got here, but just to be safe...
|
||||
return
|
||||
snap_x_vals, snap_y_vals = self.snap_to_data(x, y)
|
||||
if snap_x_vals is None or snap_y_vals is None:
|
||||
return
|
||||
if all(v is None for v in snap_x_vals.values()) or all(
|
||||
v is None for v in snap_y_vals.values()
|
||||
):
|
||||
return
|
||||
|
||||
precision = self._current_precision()
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_moved_1d[name].setData([x], [y])
|
||||
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
|
||||
coordinate_to_emit = (
|
||||
name,
|
||||
round(x_snapped_scaled, precision),
|
||||
round(y_snapped_scaled, precision),
|
||||
)
|
||||
self.coordinatesChanged1D.emit(coordinate_to_emit)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.objectName() or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
# Set position of horizontal ROI (row)
|
||||
self.marker_2d_row.setPos([0, y])
|
||||
# Set position of vertical ROI (column)
|
||||
self.marker_2d_col.setPos([x, 0])
|
||||
coordinate_to_emit = (name, x, y)
|
||||
self.coordinatesChanged2D.emit(coordinate_to_emit)
|
||||
else:
|
||||
precision = self._current_precision()
|
||||
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
sx, sy = snap_x_vals[name], snap_y_vals[name]
|
||||
if sx is None or sy is None:
|
||||
continue
|
||||
self.marker_moved_1d[name].setData([sx], [sy])
|
||||
sx_s, sy_s = self.scale_emitted_coordinates(sx, sy)
|
||||
self.coordinatesChanged1D.emit(
|
||||
(name, round(sx_s, precision), round(sy_s, precision))
|
||||
)
|
||||
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.objectName() or str(id(item))
|
||||
px, py = snap_x_vals[name], snap_y_vals[name]
|
||||
if px is None or py is None:
|
||||
continue
|
||||
|
||||
# Respect image transforms
|
||||
if isinstance(item, ImageItem) and item.image_transform is not None:
|
||||
row, col = self._get_transformed_position(px, py, item.image_transform)
|
||||
self.marker_2d_row.setPos(row)
|
||||
self.marker_2d_col.setPos(col)
|
||||
else:
|
||||
self.marker_2d_row.setPos([0, py])
|
||||
self.marker_2d_col.setPos([px, 0])
|
||||
|
||||
self.coordinatesChanged2D.emit((name, px, py))
|
||||
|
||||
def mouse_clicked(self, event):
|
||||
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
|
||||
@@ -462,15 +528,35 @@ class Crosshair(QObject):
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
# Set position of horizontal ROI (row)
|
||||
self.marker_2d_row.setPos([0, y])
|
||||
# Set position of vertical ROI (column)
|
||||
self.marker_2d_col.setPos([x, 0])
|
||||
|
||||
if isinstance(item, ImageItem) and item.image_transform is not None:
|
||||
row, col = self._get_transformed_position(x, y, item.image_transform)
|
||||
self.marker_2d_row.setPos(row)
|
||||
self.marker_2d_col.setPos(col)
|
||||
else:
|
||||
self.marker_2d_row.setPos([0, y])
|
||||
self.marker_2d_col.setPos([x, 0])
|
||||
|
||||
coordinate_to_emit = (name, x, y)
|
||||
self.coordinatesClicked2D.emit(coordinate_to_emit)
|
||||
else:
|
||||
continue
|
||||
|
||||
def _get_transformed_position(
|
||||
self, x: float, y: float, transform: QTransform
|
||||
) -> tuple[QPointF, QPointF]:
|
||||
"""
|
||||
Maps the given x and y coordinates to the transformed position using the provided transform.
|
||||
Args:
|
||||
x (float): The x-coordinate to transform.
|
||||
y (float): The y-coordinate to transform.
|
||||
transform (QTransform): The transformation to apply.
|
||||
"""
|
||||
origin = transform.map(QPointF(0, 0))
|
||||
row = transform.map(QPointF(0, y)) - origin
|
||||
col = transform.map(QPointF(x, 0)) - origin
|
||||
return row, col
|
||||
|
||||
def clear_markers(self):
|
||||
"""Clears the markers from the plot."""
|
||||
for marker in self.marker_moved_1d.values():
|
||||
@@ -512,8 +598,18 @@ class Crosshair(QObject):
|
||||
image = item.image
|
||||
if image is None:
|
||||
continue
|
||||
ix = int(np.clip(x, 0, image.shape[0] - 1))
|
||||
iy = int(np.clip(y, 0, image.shape[1] - 1))
|
||||
|
||||
if item.image_transform is not None:
|
||||
inv_transform, _ = item.image_transform.inverted()
|
||||
pt = inv_transform.map(QPointF(x, y))
|
||||
px, py = pt.x(), pt.y()
|
||||
else:
|
||||
px, py = x, y
|
||||
|
||||
# Clip to valid pixel indices
|
||||
ix = int(np.clip(px, 0, image.shape[0] - 1)) # column
|
||||
iy = int(np.clip(py, 0, image.shape[1] - 1)) # row
|
||||
|
||||
intensity = image[ix, iy]
|
||||
text += f"\nIntensity: {intensity:.{precision}f}"
|
||||
break
|
||||
@@ -533,15 +629,19 @@ class Crosshair(QObject):
|
||||
self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked()
|
||||
self.clear_markers()
|
||||
|
||||
def cleanup(self):
|
||||
@SafeSlot()
|
||||
def reset(self):
|
||||
"""Resets the crosshair to its initial state."""
|
||||
if self.marker_2d_row is not None:
|
||||
self.plot_item.removeItem(self.marker_2d_row)
|
||||
self.marker_2d_row = None
|
||||
if self.marker_2d_col is not None:
|
||||
self.plot_item.removeItem(self.marker_2d_col)
|
||||
self.marker_2d_col = None
|
||||
self.clear_markers()
|
||||
|
||||
def cleanup(self):
|
||||
self.reset()
|
||||
self.plot_item.removeItem(self.v_line)
|
||||
self.plot_item.removeItem(self.h_line)
|
||||
self.plot_item.removeItem(self.coord_label)
|
||||
|
||||
self.clear_markers()
|
||||
|
||||
@@ -28,6 +28,10 @@ class EntryValidator:
|
||||
if not available_entries:
|
||||
available_entries = [name]
|
||||
|
||||
# edge case for if name is passed instead of full_name, should not happen
|
||||
if entry in signals_dict:
|
||||
entry = signals_dict[entry].get("obj_name", entry)
|
||||
|
||||
if entry is None or entry == "":
|
||||
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
|
||||
if entry not in available_entries:
|
||||
|
||||
@@ -171,8 +171,9 @@ class TypedForm(BECWidget, QWidget):
|
||||
|
||||
|
||||
class PydanticModelForm(TypedForm):
|
||||
metadata_updated = Signal(dict)
|
||||
metadata_cleared = Signal(NoneType)
|
||||
form_data_updated = Signal(dict)
|
||||
form_data_cleared = Signal(NoneType)
|
||||
validity_proc = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -204,7 +205,7 @@ class PydanticModelForm(TypedForm):
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
self._validity.compact_view = True # type: ignore
|
||||
self._validity.label = "Metadata validity" # type: ignore
|
||||
self._validity.label = "Validity" # type: ignore
|
||||
self._validity.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
@@ -264,16 +265,18 @@ class PydanticModelForm(TypedForm):
|
||||
def validate_form(self, *_) -> bool:
|
||||
"""validate the currently entered metadata against the pydantic schema.
|
||||
If successful, returns on metadata_emitted and returns true.
|
||||
Otherwise, emits on metadata_cleared and returns false."""
|
||||
Otherwise, emits on form_data_cleared and returns false."""
|
||||
try:
|
||||
metadata_dict = self.get_form_data()
|
||||
self._md_schema.model_validate(metadata_dict)
|
||||
self._validity.set_global_state("success")
|
||||
self._validity_message.setText("No errors!")
|
||||
self.metadata_updated.emit(metadata_dict)
|
||||
self.form_data_updated.emit(metadata_dict)
|
||||
self.validity_proc.emit(True)
|
||||
return True
|
||||
except ValidationError as e:
|
||||
self._validity.set_global_state("emergency")
|
||||
self._validity_message.setText(str(e))
|
||||
self.metadata_cleared.emit(None)
|
||||
self.form_data_cleared.emit(None)
|
||||
self.validity_proc.emit(False)
|
||||
return False
|
||||
|
||||
@@ -390,17 +390,29 @@ class ListFormItem(DynamicFormItem):
|
||||
|
||||
def _add_buttons(self):
|
||||
self._button_holder = QWidget()
|
||||
self._button_holder.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self._buttons = QVBoxLayout()
|
||||
self._buttons.setContentsMargins(0, 0, 0, 0)
|
||||
self._button_holder.setLayout(self._buttons)
|
||||
self._layout.addWidget(self._button_holder)
|
||||
|
||||
self._add_remove_button_holder = QWidget()
|
||||
self._add_remove_button_layout = QHBoxLayout()
|
||||
self._add_remove_button_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._add_remove_button_holder.setLayout(self._add_remove_button_layout)
|
||||
|
||||
self._add_button = QPushButton("+")
|
||||
self._add_button.setMinimumHeight(15)
|
||||
self._add_button.setToolTip("add a new row")
|
||||
self._remove_button = QPushButton("-")
|
||||
self._remove_button.setMinimumHeight(15)
|
||||
self._remove_button.setToolTip("delete the focused row (if any)")
|
||||
self._add_button.clicked.connect(self._add_row)
|
||||
self._remove_button.clicked.connect(self._delete_row)
|
||||
self._buttons.addWidget(self._add_button)
|
||||
self._buttons.addWidget(self._remove_button)
|
||||
|
||||
self._buttons.addWidget(self._add_remove_button_holder)
|
||||
self._add_remove_button_layout.addWidget(self._add_button)
|
||||
self._add_remove_button_layout.addWidget(self._remove_button)
|
||||
|
||||
def _set_pretty_display(self):
|
||||
super()._set_pretty_display()
|
||||
|
||||
@@ -16,7 +16,8 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||
|
||||
|
||||
class SidePanel(QWidget):
|
||||
@@ -61,7 +62,7 @@ class SidePanel(QWidget):
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="vertical")
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="vertical")
|
||||
|
||||
self.container = QWidget()
|
||||
self.container.layout = QVBoxLayout(self.container)
|
||||
@@ -92,7 +93,7 @@ class SidePanel(QWidget):
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
|
||||
|
||||
self.container = QWidget()
|
||||
self.container.layout = QVBoxLayout(self.container)
|
||||
@@ -288,8 +289,16 @@ class SidePanel(QWidget):
|
||||
|
||||
# Add an action to the toolbar if action_id, icon_name, and tooltip are provided
|
||||
if action_id is not None and icon_name is not None and tooltip is not None:
|
||||
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
|
||||
self.toolbar.add_action(action_id, action, target_widget=self)
|
||||
action = MaterialIconAction(
|
||||
icon_name=icon_name, tooltip=tooltip, checkable=True, parent=self
|
||||
)
|
||||
self.toolbar.components.add_safe(action_id, action)
|
||||
bundle = ToolbarBundle(action_id, self.toolbar.components)
|
||||
bundle.add_action(action_id)
|
||||
self.toolbar.add_bundle(bundle)
|
||||
shown_bundles = self.toolbar.shown_bundles
|
||||
shown_bundles.append(action_id)
|
||||
self.toolbar.show_bundles(shown_bundles)
|
||||
|
||||
def on_action_toggled(checked: bool):
|
||||
if self.switching_actions:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
524
bec_widgets/utils/toolbars/actions.py
Normal file
524
bec_widgets/utils/toolbars/actions.py
Normal file
@@ -0,0 +1,524 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict, Literal
|
||||
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from qtpy.QtCore import QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QColor, QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QSizePolicy,
|
||||
QStyledItemDelegate,
|
||||
QToolBar,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class NoCheckDelegate(QStyledItemDelegate):
|
||||
"""To reduce space in combo boxes by removing the checkmark."""
|
||||
|
||||
def initStyleOption(self, option, index):
|
||||
super().initStyleOption(option, index)
|
||||
# Remove any check indicator
|
||||
option.checkState = Qt.Unchecked
|
||||
|
||||
|
||||
class LongPressToolButton(QToolButton):
|
||||
def __init__(self, *args, long_press_threshold=500, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.long_press_threshold = long_press_threshold
|
||||
self._long_press_timer = QTimer(self)
|
||||
self._long_press_timer.setSingleShot(True)
|
||||
self._long_press_timer.timeout.connect(self.handleLongPress)
|
||||
self._pressed = False
|
||||
self._longPressed = False
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self._pressed = True
|
||||
self._longPressed = False
|
||||
self._long_press_timer.start(self.long_press_threshold)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
self._pressed = False
|
||||
if self._longPressed:
|
||||
self._longPressed = False
|
||||
self._long_press_timer.stop()
|
||||
event.accept() # Prevent normal click action after a long press
|
||||
return
|
||||
self._long_press_timer.stop()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def handleLongPress(self):
|
||||
if self._pressed:
|
||||
self._longPressed = True
|
||||
self.showMenu()
|
||||
|
||||
|
||||
class ToolBarAction(ABC):
|
||||
"""
|
||||
Abstract base class for toolbar actions.
|
||||
|
||||
Args:
|
||||
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
|
||||
tooltip (str, optional): The tooltip for the action. Defaults to None.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||
"""
|
||||
|
||||
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
|
||||
self.icon_path = (
|
||||
os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None
|
||||
)
|
||||
self.tooltip = tooltip
|
||||
self.checkable = checkable
|
||||
self.action = None
|
||||
|
||||
@abstractmethod
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""Adds an action or widget to a toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the action or widget to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleans up the action, if necessary."""
|
||||
pass
|
||||
|
||||
|
||||
class SeparatorAction(ToolBarAction):
|
||||
"""Separator action for the toolbar."""
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
toolbar.addSeparator()
|
||||
|
||||
|
||||
class QtIconAction(ToolBarAction):
|
||||
def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None):
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.standard_icon = standard_icon
|
||||
self.icon = QApplication.style().standardIcon(standard_icon)
|
||||
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
|
||||
self.action.setCheckable(self.checkable)
|
||||
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
return self.icon
|
||||
|
||||
|
||||
class MaterialIconAction(ToolBarAction):
|
||||
"""
|
||||
Action with a Material icon for the toolbar.
|
||||
|
||||
Args:
|
||||
icon_name (str, optional): The name of the Material icon. Defaults to None.
|
||||
tooltip (str, optional): The tooltip for the action. Defaults to None.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||
filled (bool, optional): Whether the icon is filled. Defaults to False.
|
||||
color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon.
|
||||
Defaults to None.
|
||||
parent (QWidget or None, optional): Parent widget for the underlying QAction.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
icon_name: str = None,
|
||||
tooltip: str = None,
|
||||
checkable: bool = False,
|
||||
filled: bool = False,
|
||||
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.icon_name = icon_name
|
||||
self.filled = filled
|
||||
self.color = color
|
||||
# Generate the icon using the material_icon helper
|
||||
self.icon = material_icon(
|
||||
self.icon_name,
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
filled=self.filled,
|
||||
color=self.color,
|
||||
)
|
||||
if parent is None:
|
||||
logger.warning(
|
||||
"MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues."
|
||||
)
|
||||
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
|
||||
self.action.setCheckable(self.checkable)
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the action to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar(QToolBar): The toolbar to add the action to.
|
||||
target(QWidget): The target widget for the action.
|
||||
"""
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
"""
|
||||
Returns the icon for the action.
|
||||
|
||||
Returns:
|
||||
QIcon: The icon for the action.
|
||||
"""
|
||||
return self.icon
|
||||
|
||||
|
||||
class DeviceSelectionAction(ToolBarAction):
|
||||
"""
|
||||
Action for selecting a device in a combobox.
|
||||
|
||||
Args:
|
||||
label (str): The label for the combobox.
|
||||
device_combobox (DeviceComboBox): The combobox for selecting the device.
|
||||
"""
|
||||
|
||||
def __init__(self, label: str | None = None, device_combobox=None):
|
||||
super().__init__()
|
||||
self.label = label
|
||||
self.device_combobox = device_combobox
|
||||
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
|
||||
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
widget = QWidget(parent=target)
|
||||
layout = QHBoxLayout(widget)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
if self.label is not None:
|
||||
label = QLabel(text=f"{self.label}", parent=target)
|
||||
layout.addWidget(label)
|
||||
if self.device_combobox is not None:
|
||||
layout.addWidget(self.device_combobox)
|
||||
toolbar.addWidget(widget)
|
||||
|
||||
def set_combobox_style(self, color: str):
|
||||
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
|
||||
|
||||
|
||||
class SwitchableToolBarAction(ToolBarAction):
|
||||
"""
|
||||
A split toolbar action that combines a main action and a drop-down menu for additional actions.
|
||||
|
||||
The main button displays the currently selected action's icon and tooltip. Clicking on the main button
|
||||
triggers that action. Clicking on the drop-down arrow displays a menu with alternative actions. When an
|
||||
alternative action is selected, it becomes the new default and its callback is immediately executed.
|
||||
|
||||
This design mimics the behavior seen in Adobe Photoshop or Affinity Designer toolbars.
|
||||
|
||||
Args:
|
||||
actions (dict): A dictionary mapping a unique key to a ToolBarAction instance.
|
||||
initial_action (str, optional): The key of the initial default action. If not provided, the first action is used.
|
||||
tooltip (str, optional): An optional tooltip for the split action; if provided, it overrides the default action's tooltip.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to True.
|
||||
parent (QWidget, optional): Parent widget for the underlying QAction.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
actions: Dict[str, ToolBarAction],
|
||||
initial_action: str = None,
|
||||
tooltip: str = None,
|
||||
checkable: bool = True,
|
||||
default_state_checked: bool = False,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.actions = actions
|
||||
self.current_key = initial_action if initial_action is not None else next(iter(actions))
|
||||
self.parent = parent
|
||||
self.checkable = checkable
|
||||
self.default_state_checked = default_state_checked
|
||||
self.main_button = None
|
||||
self.menu_actions: Dict[str, QAction] = {}
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the split action to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the action to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
self.main_button = LongPressToolButton(toolbar)
|
||||
self.main_button.setPopupMode(QToolButton.MenuButtonPopup)
|
||||
self.main_button.setCheckable(self.checkable)
|
||||
default_action = self.actions[self.current_key]
|
||||
self.main_button.setIcon(default_action.get_icon())
|
||||
self.main_button.setToolTip(default_action.tooltip)
|
||||
self.main_button.clicked.connect(self._trigger_current_action)
|
||||
menu = QMenu(self.main_button)
|
||||
for key, action_obj in self.actions.items():
|
||||
menu_action = QAction(
|
||||
icon=action_obj.get_icon(), text=action_obj.tooltip, parent=self.main_button
|
||||
)
|
||||
menu_action.setIconVisibleInMenu(True)
|
||||
menu_action.setCheckable(self.checkable)
|
||||
menu_action.setChecked(key == self.current_key)
|
||||
menu_action.triggered.connect(lambda checked, k=key: self.set_default_action(k))
|
||||
menu.addAction(menu_action)
|
||||
self.main_button.setMenu(menu)
|
||||
if self.default_state_checked:
|
||||
self.main_button.setChecked(True)
|
||||
self.action = toolbar.addWidget(self.main_button)
|
||||
|
||||
def _trigger_current_action(self):
|
||||
"""
|
||||
Triggers the current action associated with the main button.
|
||||
"""
|
||||
action_obj = self.actions[self.current_key]
|
||||
action_obj.action.trigger()
|
||||
|
||||
def set_default_action(self, key: str):
|
||||
"""
|
||||
Sets the default action for the split action.
|
||||
|
||||
Args:
|
||||
key(str): The key of the action to set as default.
|
||||
"""
|
||||
if self.main_button is None:
|
||||
return
|
||||
self.current_key = key
|
||||
new_action = self.actions[self.current_key]
|
||||
self.main_button.setIcon(new_action.get_icon())
|
||||
self.main_button.setToolTip(new_action.tooltip)
|
||||
# Update check state of menu items
|
||||
for k, menu_act in self.actions.items():
|
||||
menu_act.action.setChecked(False)
|
||||
new_action.action.trigger()
|
||||
# Active action chosen from menu is always checked, uncheck through main button
|
||||
if self.checkable:
|
||||
new_action.action.setChecked(True)
|
||||
self.main_button.setChecked(True)
|
||||
|
||||
def block_all_signals(self, block: bool = True):
|
||||
"""
|
||||
Blocks or unblocks all signals for the actions in the toolbar.
|
||||
|
||||
Args:
|
||||
block (bool): Whether to block signals. Defaults to True.
|
||||
"""
|
||||
if self.main_button is not None:
|
||||
self.main_button.blockSignals(block)
|
||||
|
||||
for action in self.actions.values():
|
||||
action.action.blockSignals(block)
|
||||
|
||||
@contextmanager
|
||||
def signal_blocker(self):
|
||||
"""
|
||||
Context manager to block signals for all actions in the toolbar.
|
||||
"""
|
||||
self.block_all_signals(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.block_all_signals(False)
|
||||
|
||||
def set_state_all(self, state: bool):
|
||||
"""
|
||||
Uncheck all actions in the toolbar.
|
||||
"""
|
||||
for action in self.actions.values():
|
||||
action.action.setChecked(state)
|
||||
if self.main_button is None:
|
||||
return
|
||||
self.main_button.setChecked(state)
|
||||
|
||||
def get_icon(self) -> QIcon:
|
||||
return self.actions[self.current_key].get_icon()
|
||||
|
||||
|
||||
class WidgetAction(ToolBarAction):
|
||||
"""
|
||||
Action for adding any widget to the toolbar.
|
||||
Please note that the injected widget must be life-cycled by the parent widget,
|
||||
i.e., the widget must be properly cleaned up outside of this action. The WidgetAction
|
||||
will not perform any cleanup on the widget itself, only on the container that holds it.
|
||||
|
||||
Args:
|
||||
label (str|None): The label for the widget.
|
||||
widget (QWidget): The widget to be added to the toolbar.
|
||||
adjust_size (bool): Whether to adjust the size of the widget based on its contents. Defaults to True.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: str | None = None,
|
||||
widget: QWidget = None,
|
||||
adjust_size: bool = True,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(icon_path=None, tooltip=label, checkable=False)
|
||||
self.label = label
|
||||
self.widget = widget
|
||||
self.container = None
|
||||
self.adjust_size = adjust_size
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the widget to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the widget to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
self.container = QWidget(parent=target)
|
||||
layout = QHBoxLayout(self.container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
if self.label is not None:
|
||||
label_widget = QLabel(text=f"{self.label}", parent=target)
|
||||
label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
|
||||
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
|
||||
layout.addWidget(label_widget)
|
||||
|
||||
if isinstance(self.widget, QComboBox) and self.adjust_size:
|
||||
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||
|
||||
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.widget.setSizePolicy(size_policy)
|
||||
|
||||
self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget))
|
||||
|
||||
layout.addWidget(self.widget)
|
||||
|
||||
toolbar.addWidget(self.container)
|
||||
# Store the container as the action to allow toggling visibility.
|
||||
self.action = self.container
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the action by closing and deleting the container widget.
|
||||
This method will be called automatically when the toolbar is cleaned up.
|
||||
"""
|
||||
if self.container is not None:
|
||||
self.container.close()
|
||||
self.container.deleteLater()
|
||||
return super().cleanup()
|
||||
|
||||
@staticmethod
|
||||
def calculate_minimum_width(combo_box: QComboBox) -> int:
|
||||
font_metrics = combo_box.fontMetrics()
|
||||
max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count()))
|
||||
return max_width + 60
|
||||
|
||||
|
||||
class ExpandableMenuAction(ToolBarAction):
|
||||
"""
|
||||
Action for an expandable menu in the toolbar.
|
||||
|
||||
Args:
|
||||
label (str): The label for the menu.
|
||||
actions (dict): A dictionary of actions to populate the menu.
|
||||
icon_path (str, optional): The path to the icon file. Defaults to None.
|
||||
"""
|
||||
|
||||
def __init__(self, label: str, actions: dict, icon_path: str = None):
|
||||
super().__init__(icon_path, label)
|
||||
self.actions = actions
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
button = QToolButton(toolbar)
|
||||
if self.icon_path:
|
||||
button.setIcon(QIcon(self.icon_path))
|
||||
button.setText(self.tooltip)
|
||||
button.setPopupMode(QToolButton.InstantPopup)
|
||||
button.setStyleSheet(
|
||||
"""
|
||||
QToolButton {
|
||||
font-size: 14px;
|
||||
}
|
||||
QMenu {
|
||||
font-size: 14px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
menu = QMenu(button)
|
||||
for action_container in self.actions.values():
|
||||
action: QAction = action_container.action
|
||||
action.setIconVisibleInMenu(True)
|
||||
if action_container.icon_path:
|
||||
icon = QIcon()
|
||||
icon.addFile(action_container.icon_path, size=QSize(20, 20))
|
||||
action.setIcon(icon)
|
||||
elif hasattr(action, "get_icon") and callable(action_container.get_icon):
|
||||
sub_icon = action_container.get_icon()
|
||||
if sub_icon and not sub_icon.isNull():
|
||||
action.setIcon(sub_icon)
|
||||
action.setCheckable(action_container.checkable)
|
||||
menu.addAction(action)
|
||||
button.setMenu(menu)
|
||||
toolbar.addWidget(button)
|
||||
|
||||
|
||||
class DeviceComboBoxAction(WidgetAction):
|
||||
"""
|
||||
Action for a device selection combobox in the toolbar.
|
||||
|
||||
Args:
|
||||
label (str): The label for the combobox.
|
||||
device_combobox (QComboBox): The combobox for selecting the device.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target_widget: QWidget,
|
||||
device_filter: list[BECDeviceFilter] | None = None,
|
||||
readout_priority_filter: (
|
||||
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
|
||||
) = None,
|
||||
tooltip: str | None = None,
|
||||
add_empty_item: bool = False,
|
||||
no_check_delegate: bool = False,
|
||||
):
|
||||
self.combobox = DeviceComboBox(
|
||||
parent=target_widget,
|
||||
device_filter=device_filter,
|
||||
readout_priority_filter=readout_priority_filter,
|
||||
)
|
||||
super().__init__(widget=self.combobox, adjust_size=False)
|
||||
|
||||
if add_empty_item:
|
||||
self.combobox.addItem("", None)
|
||||
self.combobox.setCurrentText("")
|
||||
if tooltip is not None:
|
||||
self.combobox.setToolTip(tooltip)
|
||||
if no_check_delegate:
|
||||
self.combobox.setItemDelegate(NoCheckDelegate(self.combobox))
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the action by closing and deleting the combobox widget.
|
||||
This method will be called automatically when the toolbar is cleaned up.
|
||||
"""
|
||||
if self.combobox is not None:
|
||||
self.combobox.close()
|
||||
self.combobox.deleteLater()
|
||||
return super().cleanup()
|
||||
244
bec_widgets/utils/toolbars/bundles.py
Normal file
244
bec_widgets/utils/toolbars/bundles.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, DefaultDict
|
||||
from weakref import ReferenceType
|
||||
|
||||
import louie
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bec_widgets.utils.toolbars.actions import SeparatorAction, ToolBarAction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ActionInfo(BaseModel):
|
||||
action: ToolBarAction
|
||||
toolbar_bundle: ToolbarBundle | None = None
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
|
||||
class ToolbarComponents:
|
||||
def __init__(self, toolbar: ModularToolBar):
|
||||
"""
|
||||
Initializes the toolbar components.
|
||||
|
||||
Args:
|
||||
toolbar (ModularToolBar): The toolbar to which the components will be added.
|
||||
"""
|
||||
self.toolbar = toolbar
|
||||
|
||||
self._components: dict[str, ActionInfo] = {}
|
||||
self.add("separator", SeparatorAction())
|
||||
|
||||
def add(self, name: str, component: ToolBarAction):
|
||||
"""
|
||||
Adds a component to the toolbar.
|
||||
|
||||
Args:
|
||||
component (ToolBarAction): The component to add.
|
||||
"""
|
||||
if name in self._components:
|
||||
raise ValueError(f"Component with name '{name}' already exists.")
|
||||
self._components[name] = ActionInfo(action=component, toolbar_bundle=None)
|
||||
|
||||
def add_safe(self, name: str, component: ToolBarAction):
|
||||
"""
|
||||
Adds a component to the toolbar, ensuring it does not already exist.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component.
|
||||
component (ToolBarAction): The component to add.
|
||||
"""
|
||||
if self.exists(name):
|
||||
logger.info(f"Component with name '{name}' already exists. Skipping addition.")
|
||||
return
|
||||
self.add(name, component)
|
||||
|
||||
def exists(self, name: str) -> bool:
|
||||
"""
|
||||
Checks if a component exists in the toolbar.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the component exists, False otherwise.
|
||||
"""
|
||||
return name in self._components
|
||||
|
||||
def get_action_reference(self, name: str) -> ReferenceType[ToolBarAction]:
|
||||
"""
|
||||
Retrieves a component by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component to retrieve.
|
||||
|
||||
"""
|
||||
if not self.exists(name):
|
||||
raise KeyError(f"Component with name '{name}' does not exist.")
|
||||
return louie.saferef.safe_ref(self._components[name].action)
|
||||
|
||||
def get_action(self, name: str) -> ToolBarAction:
|
||||
"""
|
||||
Retrieves a component by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component to retrieve.
|
||||
|
||||
Returns:
|
||||
ToolBarAction: The action associated with the given name.
|
||||
"""
|
||||
if not self.exists(name):
|
||||
raise KeyError(
|
||||
f"Component with name '{name}' does not exist. The following components are available: {list(self._components.keys())}"
|
||||
)
|
||||
return self._components[name].action
|
||||
|
||||
def set_bundle(self, name: str, bundle: ToolbarBundle):
|
||||
"""
|
||||
Sets the bundle for a component.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component.
|
||||
bundle (ToolbarBundle): The bundle to set.
|
||||
"""
|
||||
if not self.exists(name):
|
||||
raise KeyError(f"Component with name '{name}' does not exist.")
|
||||
comp = self._components[name]
|
||||
if comp.toolbar_bundle is not None:
|
||||
logger.info(
|
||||
f"Component '{name}' already has a bundle ({comp.toolbar_bundle.name}). Setting it to {bundle.name}."
|
||||
)
|
||||
comp.toolbar_bundle.bundle_actions.pop(name, None)
|
||||
comp.toolbar_bundle = bundle
|
||||
|
||||
def remove_action(self, name: str):
|
||||
"""
|
||||
Removes a component from the toolbar.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component to remove.
|
||||
"""
|
||||
if not self.exists(name):
|
||||
raise KeyError(f"Action with ID '{name}' does not exist.")
|
||||
action_info = self._components.pop(name)
|
||||
if action_info.toolbar_bundle:
|
||||
action_info.toolbar_bundle.bundle_actions.pop(name, None)
|
||||
self.toolbar.refresh()
|
||||
action_info.toolbar_bundle = None
|
||||
if hasattr(action_info.action, "cleanup"):
|
||||
# Call cleanup if the action has a cleanup method
|
||||
action_info.action.cleanup()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the toolbar components by removing all actions and bundles.
|
||||
"""
|
||||
for action_info in self._components.values():
|
||||
if hasattr(action_info.action, "cleanup"):
|
||||
# Call cleanup if the action has a cleanup method
|
||||
action_info.action.cleanup()
|
||||
self._components.clear()
|
||||
|
||||
|
||||
class ToolbarBundle:
|
||||
def __init__(self, name: str, components: ToolbarComponents):
|
||||
"""
|
||||
Initializes a new bundle component.
|
||||
|
||||
Args:
|
||||
bundle_name (str): Unique identifier for the bundle.
|
||||
"""
|
||||
self.name = name
|
||||
self.components = components
|
||||
self.bundle_actions: DefaultDict[str, ReferenceType[ToolBarAction]] = defaultdict()
|
||||
self._connections: dict[str, BundleConnection] = {}
|
||||
|
||||
def add_action(self, name: str):
|
||||
"""
|
||||
Adds an action to the bundle.
|
||||
|
||||
Args:
|
||||
name (str): Unique identifier for the action.
|
||||
action (ToolBarAction): The action to add.
|
||||
"""
|
||||
if name in self.bundle_actions:
|
||||
raise ValueError(f"Action with name '{name}' already exists in bundle '{self.name}'.")
|
||||
if not self.components.exists(name):
|
||||
raise ValueError(
|
||||
f"Component with name '{name}' does not exist in the toolbar. Please add it first using the `ToolbarComponents.add` method."
|
||||
)
|
||||
self.bundle_actions[name] = self.components.get_action_reference(name)
|
||||
self.components.set_bundle(name, self)
|
||||
|
||||
def remove_action(self, name: str):
|
||||
"""
|
||||
Removes an action from the bundle.
|
||||
|
||||
Args:
|
||||
name (str): The name of the action to remove.
|
||||
"""
|
||||
if name not in self.bundle_actions:
|
||||
raise KeyError(f"Action with name '{name}' does not exist in bundle '{self.name}'.")
|
||||
del self.bundle_actions[name]
|
||||
|
||||
def add_separator(self):
|
||||
"""
|
||||
Adds a separator action to the bundle.
|
||||
"""
|
||||
self.add_action("separator")
|
||||
|
||||
def add_connection(self, name: str, connection):
|
||||
"""
|
||||
Adds a connection to the bundle.
|
||||
|
||||
Args:
|
||||
name (str): Unique identifier for the connection.
|
||||
connection: The connection to add.
|
||||
"""
|
||||
if name in self._connections:
|
||||
raise ValueError(
|
||||
f"Connection with name '{name}' already exists in bundle '{self.name}'."
|
||||
)
|
||||
self._connections[name] = connection
|
||||
|
||||
def remove_connection(self, name: str):
|
||||
"""
|
||||
Removes a connection from the bundle.
|
||||
|
||||
Args:
|
||||
name (str): The name of the connection to remove.
|
||||
"""
|
||||
if name not in self._connections:
|
||||
raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.")
|
||||
self._connections[name].disconnect()
|
||||
del self._connections[name]
|
||||
|
||||
def get_connection(self, name: str):
|
||||
"""
|
||||
Retrieves a connection by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the connection to retrieve.
|
||||
|
||||
Returns:
|
||||
The connection associated with the given name.
|
||||
"""
|
||||
if name not in self._connections:
|
||||
raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.")
|
||||
return self._connections[name]
|
||||
|
||||
def disconnect(self):
|
||||
"""
|
||||
Disconnects all connections in the bundle.
|
||||
"""
|
||||
for connection in self._connections.values():
|
||||
connection.disconnect()
|
||||
self._connections.clear()
|
||||
23
bec_widgets/utils/toolbars/connections.py
Normal file
23
bec_widgets/utils/toolbars/connections.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
|
||||
class BundleConnection(QObject):
|
||||
bundle_name: str
|
||||
|
||||
@abstractmethod
|
||||
def connect(self):
|
||||
"""
|
||||
Connects the bundle to the target widget or application.
|
||||
This method should be implemented by subclasses to define how the bundle interacts with the target.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self):
|
||||
"""
|
||||
Disconnects the bundle from the target widget or application.
|
||||
This method should be implemented by subclasses to define how to clean up connections.
|
||||
"""
|
||||
64
bec_widgets/utils/toolbars/performance.py
Normal file
64
bec_widgets/utils/toolbars/performance.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.toolbars.toolbar import ToolbarComponents
|
||||
|
||||
|
||||
def performance_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a performance toolbar bundle.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The performance toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"fps_monitor",
|
||||
MaterialIconAction(
|
||||
icon_name="speed", tooltip="Show FPS Monitor", checkable=True, parent=components.toolbar
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("performance", components)
|
||||
bundle.add_action("fps_monitor")
|
||||
return bundle
|
||||
|
||||
|
||||
class PerformanceConnection(BundleConnection):
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "performance"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if not hasattr(self.target_widget, "enable_fps_monitor"):
|
||||
raise AttributeError("Target widget must implement 'enable_fps_monitor'.")
|
||||
super().__init__()
|
||||
self._connected = False
|
||||
|
||||
@SafeSlot(bool)
|
||||
def set_fps_monitor(self, enabled: bool):
|
||||
setattr(self.target_widget, "enable_fps_monitor", enabled)
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action_reference("fps_monitor")().action.toggled.connect(
|
||||
self.set_fps_monitor
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action_reference("fps_monitor")().action.toggled.disconnect(
|
||||
self.set_fps_monitor
|
||||
)
|
||||
self._connected = False
|
||||
513
bec_widgets/utils/toolbars/toolbar.py
Normal file
513
bec_widgets/utils/toolbars/toolbar.py
Normal file
@@ -0,0 +1,513 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from typing import DefaultDict, Literal
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QAction, QColor
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_name, set_theme
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# Ensure that icons are shown in menus (especially on macOS)
|
||||
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
|
||||
|
||||
|
||||
class ModularToolBar(QToolBar):
|
||||
"""Modular toolbar with optional automatic initialization.
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
|
||||
actions (dict, optional): A dictionary of action creators to populate the toolbar. Defaults to None.
|
||||
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
|
||||
orientation (Literal["horizontal", "vertical"], optional): The initial orientation of the toolbar. Defaults to "horizontal".
|
||||
background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)".
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
orientation: Literal["horizontal", "vertical"] = "horizontal",
|
||||
background_color: str = "rgba(0, 0, 0, 0)",
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.background_color = background_color
|
||||
self.set_background_color(self.background_color)
|
||||
|
||||
# Set the initial orientation
|
||||
self.set_orientation(orientation)
|
||||
|
||||
self.components = ToolbarComponents(self)
|
||||
|
||||
# Initialize bundles
|
||||
self.bundles: dict[str, ToolbarBundle] = {}
|
||||
self.shown_bundles: list[str] = []
|
||||
|
||||
#########################
|
||||
# outdated items... remove
|
||||
self.available_widgets: DefaultDict[str, ToolBarAction] = defaultdict()
|
||||
########################
|
||||
|
||||
def new_bundle(self, name: str) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a new bundle component.
|
||||
|
||||
Args:
|
||||
name (str): Unique identifier for the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The new bundle component.
|
||||
"""
|
||||
if name in self.bundles:
|
||||
raise ValueError(f"Bundle with name '{name}' already exists.")
|
||||
bundle = ToolbarBundle(name=name, components=self.components)
|
||||
self.bundles[name] = bundle
|
||||
return bundle
|
||||
|
||||
def add_bundle(self, bundle: ToolbarBundle):
|
||||
"""
|
||||
Adds a bundle component to the toolbar.
|
||||
|
||||
Args:
|
||||
bundle (ToolbarBundle): The bundle component to add.
|
||||
"""
|
||||
if bundle.name in self.bundles:
|
||||
raise ValueError(f"Bundle with name '{bundle.name}' already exists.")
|
||||
self.bundles[bundle.name] = bundle
|
||||
if not bundle.bundle_actions:
|
||||
logger.warning(f"Bundle '{bundle.name}' has no actions.")
|
||||
|
||||
def remove_bundle(self, name: str):
|
||||
"""
|
||||
Removes a bundle component by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the bundle to remove.
|
||||
"""
|
||||
if name not in self.bundles:
|
||||
raise KeyError(f"Bundle with name '{name}' does not exist.")
|
||||
del self.bundles[name]
|
||||
if name in self.shown_bundles:
|
||||
self.shown_bundles.remove(name)
|
||||
logger.info(f"Bundle '{name}' removed from the toolbar.")
|
||||
|
||||
def get_bundle(self, name: str) -> ToolbarBundle:
|
||||
"""
|
||||
Retrieves a bundle component by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the bundle to retrieve.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The bundle component.
|
||||
"""
|
||||
if name not in self.bundles:
|
||||
raise KeyError(
|
||||
f"Bundle with name '{name}' does not exist. Available bundles: {list(self.bundles.keys())}"
|
||||
)
|
||||
return self.bundles[name]
|
||||
|
||||
def show_bundles(self, bundle_names: list[str]):
|
||||
"""
|
||||
Sets the bundles to be shown for the toolbar.
|
||||
|
||||
Args:
|
||||
bundle_names (list[str]): A list of bundle names to show. If a bundle is not in this list, its actions will be hidden.
|
||||
"""
|
||||
self.clear()
|
||||
for requested_bundle in bundle_names:
|
||||
bundle = self.get_bundle(requested_bundle)
|
||||
for bundle_action in bundle.bundle_actions.values():
|
||||
action = bundle_action()
|
||||
if action is None:
|
||||
logger.warning(
|
||||
f"Action for bundle '{requested_bundle}' has been deleted. Skipping."
|
||||
)
|
||||
continue
|
||||
action.add_to_toolbar(self, self.parent())
|
||||
separator = self.components.get_action_reference("separator")()
|
||||
if separator is not None:
|
||||
separator.add_to_toolbar(self, self.parent())
|
||||
self.update_separators() # Ensure separators are updated after showing bundles
|
||||
self.shown_bundles = bundle_names
|
||||
|
||||
def add_action(self, action_name: str, action: ToolBarAction):
|
||||
"""
|
||||
Adds a single action to the toolbar. It will create a new bundle
|
||||
with the same name as the action.
|
||||
|
||||
Args:
|
||||
action_name (str): Unique identifier for the action.
|
||||
action (ToolBarAction): The action to add.
|
||||
"""
|
||||
self.components.add_safe(action_name, action)
|
||||
bundle = ToolbarBundle(name=action_name, components=self.components)
|
||||
bundle.add_action(action_name)
|
||||
self.add_bundle(bundle)
|
||||
|
||||
def hide_action(self, action_name: str):
|
||||
"""
|
||||
Hides a specific action in the toolbar.
|
||||
|
||||
Args:
|
||||
action_name (str): Unique identifier for the action to hide.
|
||||
"""
|
||||
action = self.components.get_action(action_name)
|
||||
if hasattr(action, "action") and action.action is not None:
|
||||
action.action.setVisible(False)
|
||||
self.update_separators()
|
||||
|
||||
def show_action(self, action_name: str):
|
||||
"""
|
||||
Shows a specific action in the toolbar.
|
||||
|
||||
Args:
|
||||
action_name (str): Unique identifier for the action to show.
|
||||
"""
|
||||
action = self.components.get_action(action_name)
|
||||
if hasattr(action, "action") and action.action is not None:
|
||||
action.action.setVisible(True)
|
||||
self.update_separators()
|
||||
|
||||
@property
|
||||
def toolbar_actions(self) -> list[ToolBarAction]:
|
||||
"""
|
||||
Returns a list of all actions currently in the toolbar.
|
||||
|
||||
Returns:
|
||||
list[ToolBarAction]: List of actions in the toolbar.
|
||||
"""
|
||||
actions = []
|
||||
for bundle in self.shown_bundles:
|
||||
if bundle not in self.bundles:
|
||||
continue
|
||||
for action in self.bundles[bundle].bundle_actions.values():
|
||||
action_instance = action()
|
||||
if action_instance is not None:
|
||||
actions.append(action_instance)
|
||||
return actions
|
||||
|
||||
def refresh(self):
|
||||
"""Refreshes the toolbar by clearing and re-populating it."""
|
||||
self.clear()
|
||||
self.show_bundles(self.shown_bundles)
|
||||
|
||||
def connect_bundle(self, connection_name: str, connector: BundleConnection):
|
||||
"""
|
||||
Connects a bundle to a target widget or application.
|
||||
|
||||
Args:
|
||||
bundle_name (str): The name of the bundle to connect.
|
||||
connector (BundleConnection): The connector instance that implements the connection logic.
|
||||
"""
|
||||
bundle_name = connector.bundle_name
|
||||
if bundle_name not in self.bundles:
|
||||
raise KeyError(f"Bundle with name '{bundle_name}' does not exist.")
|
||||
connector.connect()
|
||||
self.bundles[bundle_name].add_connection(connection_name, connector)
|
||||
|
||||
def disconnect_bundle(self, bundle_name: str, connection_name: str | None = None):
|
||||
"""
|
||||
Disconnects a bundle connection.
|
||||
|
||||
Args:
|
||||
bundle_name (str): The name of the bundle to disconnect.
|
||||
connection_name (str): The name of the connection to disconnect. If None, disconnects all connections for the bundle.
|
||||
"""
|
||||
if bundle_name not in self.bundles:
|
||||
raise KeyError(f"Bundle with name '{bundle_name}' does not exist.")
|
||||
bundle = self.bundles[bundle_name]
|
||||
if connection_name is None:
|
||||
# Disconnect all connections in the bundle
|
||||
bundle.disconnect()
|
||||
else:
|
||||
bundle.remove_connection(name=connection_name)
|
||||
|
||||
def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"):
|
||||
"""
|
||||
Sets the background color and other appearance settings.
|
||||
|
||||
Args:
|
||||
color (str): The background color of the toolbar.
|
||||
"""
|
||||
self.setIconSize(QSize(20, 20))
|
||||
self.setMovable(False)
|
||||
self.setFloatable(False)
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self.background_color = color
|
||||
self.setStyleSheet(f"QToolBar {{ background-color: {color}; border: none; }}")
|
||||
|
||||
def set_orientation(self, orientation: Literal["horizontal", "vertical"]):
|
||||
"""Sets the orientation of the toolbar.
|
||||
|
||||
Args:
|
||||
orientation (Literal["horizontal", "vertical"]): The desired orientation of the toolbar.
|
||||
"""
|
||||
if orientation == "horizontal":
|
||||
self.setOrientation(Qt.Horizontal)
|
||||
elif orientation == "vertical":
|
||||
self.setOrientation(Qt.Vertical)
|
||||
else:
|
||||
raise ValueError("Orientation must be 'horizontal' or 'vertical'.")
|
||||
|
||||
def update_material_icon_colors(self, new_color: str | tuple | QColor):
|
||||
"""
|
||||
Updates the color of all MaterialIconAction icons.
|
||||
|
||||
Args:
|
||||
new_color (str | tuple | QColor): The new color.
|
||||
"""
|
||||
for action in self.available_widgets.values():
|
||||
if isinstance(action, MaterialIconAction):
|
||||
action.color = new_color
|
||||
updated_icon = action.get_icon()
|
||||
action.action.setIcon(updated_icon)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
"""
|
||||
Overrides the context menu event to show toolbar actions with checkboxes and icons.
|
||||
|
||||
Args:
|
||||
event (QContextMenuEvent): The context menu event.
|
||||
"""
|
||||
menu = QMenu(self)
|
||||
theme = get_theme_name()
|
||||
if theme == "dark":
|
||||
menu.setStyleSheet(
|
||||
"""
|
||||
QMenu {
|
||||
background-color: rgba(50, 50, 50, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: rgba(0, 0, 255, 0.2);
|
||||
}
|
||||
"""
|
||||
)
|
||||
else:
|
||||
# Light theme styling
|
||||
menu.setStyleSheet(
|
||||
"""
|
||||
QMenu {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: rgba(0, 0, 255, 0.2);
|
||||
}
|
||||
"""
|
||||
)
|
||||
for ii, bundle in enumerate(self.shown_bundles):
|
||||
self.handle_bundle_context_menu(menu, bundle)
|
||||
if ii < len(self.shown_bundles) - 1:
|
||||
menu.addSeparator()
|
||||
menu.triggered.connect(self.handle_menu_triggered)
|
||||
menu.exec_(event.globalPos())
|
||||
|
||||
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
|
||||
"""
|
||||
Adds bundle actions to the context menu.
|
||||
|
||||
Args:
|
||||
menu (QMenu): The context menu.
|
||||
bundle_id (str): The bundle identifier.
|
||||
"""
|
||||
bundle = self.bundles.get(bundle_id)
|
||||
if not bundle:
|
||||
return
|
||||
for act_id in bundle.bundle_actions:
|
||||
toolbar_action = self.components.get_action(act_id)
|
||||
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(
|
||||
toolbar_action, "action"
|
||||
):
|
||||
continue
|
||||
qaction = toolbar_action.action
|
||||
if not isinstance(qaction, QAction):
|
||||
continue
|
||||
self._add_qaction_to_menu(menu, qaction, toolbar_action, act_id)
|
||||
|
||||
def _add_qaction_to_menu(
|
||||
self, menu: QMenu, qaction: QAction, toolbar_action: ToolBarAction, act_id: str
|
||||
):
|
||||
display_name = qaction.text() or toolbar_action.tooltip or act_id
|
||||
menu_action = QAction(display_name, self)
|
||||
menu_action.setCheckable(True)
|
||||
menu_action.setChecked(qaction.isVisible())
|
||||
menu_action.setData(act_id) # Store the action_id
|
||||
|
||||
# Set the icon if available
|
||||
if qaction.icon() and not qaction.icon().isNull():
|
||||
menu_action.setIcon(qaction.icon())
|
||||
menu.addAction(menu_action)
|
||||
|
||||
def handle_action_context_menu(self, menu: QMenu, action_id: str):
|
||||
"""
|
||||
Adds a single toolbar action to the context menu.
|
||||
|
||||
Args:
|
||||
menu (QMenu): The context menu to which the action is added.
|
||||
action_id (str): Unique identifier for the action.
|
||||
"""
|
||||
toolbar_action = self.available_widgets.get(action_id)
|
||||
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(toolbar_action, "action"):
|
||||
return
|
||||
qaction = toolbar_action.action
|
||||
if not isinstance(qaction, QAction):
|
||||
return
|
||||
display_name = qaction.text() or toolbar_action.tooltip or action_id
|
||||
menu_action = QAction(display_name, self)
|
||||
menu_action.setCheckable(True)
|
||||
menu_action.setChecked(qaction.isVisible())
|
||||
menu_action.setIconVisibleInMenu(True)
|
||||
menu_action.setData(action_id) # Store the action_id
|
||||
|
||||
# Set the icon if available
|
||||
if qaction.icon() and not qaction.icon().isNull():
|
||||
menu_action.setIcon(qaction.icon())
|
||||
|
||||
menu.addAction(menu_action)
|
||||
|
||||
def handle_menu_triggered(self, action):
|
||||
"""
|
||||
Handles the triggered signal from the context menu.
|
||||
|
||||
Args:
|
||||
action: Action triggered.
|
||||
"""
|
||||
action_id = action.data()
|
||||
if action_id:
|
||||
self.toggle_action_visibility(action_id)
|
||||
|
||||
def toggle_action_visibility(self, action_id: str, visible: bool | None = None):
|
||||
"""
|
||||
Toggles the visibility of a specific action.
|
||||
|
||||
Args:
|
||||
action_id (str): Unique identifier.
|
||||
visible (bool): Whether the action should be visible. If None, toggles the current visibility.
|
||||
"""
|
||||
if not self.components.exists(action_id):
|
||||
return
|
||||
tool_action = self.components.get_action(action_id)
|
||||
if hasattr(tool_action, "action") and tool_action.action is not None:
|
||||
if visible is None:
|
||||
visible = not tool_action.action.isVisible()
|
||||
tool_action.action.setVisible(visible)
|
||||
self.update_separators()
|
||||
|
||||
def update_separators(self):
|
||||
"""
|
||||
Hide separators that are adjacent to another separator or have no non-separator actions between them.
|
||||
"""
|
||||
toolbar_actions = self.actions()
|
||||
# First pass: set visibility based on surrounding non-separator actions.
|
||||
for i, action in enumerate(toolbar_actions):
|
||||
if not action.isSeparator():
|
||||
continue
|
||||
prev_visible = None
|
||||
for j in range(i - 1, -1, -1):
|
||||
if toolbar_actions[j].isVisible():
|
||||
prev_visible = toolbar_actions[j]
|
||||
break
|
||||
next_visible = None
|
||||
for j in range(i + 1, len(toolbar_actions)):
|
||||
if toolbar_actions[j].isVisible():
|
||||
next_visible = toolbar_actions[j]
|
||||
break
|
||||
if (prev_visible is None or prev_visible.isSeparator()) and (
|
||||
next_visible is None or next_visible.isSeparator()
|
||||
):
|
||||
action.setVisible(False)
|
||||
else:
|
||||
action.setVisible(True)
|
||||
# Second pass: ensure no two visible separators are adjacent.
|
||||
prev = None
|
||||
for action in toolbar_actions:
|
||||
if action.isVisible() and action.isSeparator():
|
||||
if prev and prev.isSeparator():
|
||||
action.setVisible(False)
|
||||
else:
|
||||
prev = action
|
||||
else:
|
||||
if action.isVisible():
|
||||
prev = action
|
||||
|
||||
if not toolbar_actions:
|
||||
return
|
||||
|
||||
# Make sure the first visible action is not a separator
|
||||
for i, action in enumerate(toolbar_actions):
|
||||
if action.isVisible():
|
||||
if action.isSeparator():
|
||||
action.setVisible(False)
|
||||
break
|
||||
|
||||
# Make sure the last visible action is not a separator
|
||||
for i, action in enumerate(reversed(toolbar_actions)):
|
||||
if action.isVisible():
|
||||
if action.isSeparator():
|
||||
action.setVisible(False)
|
||||
break
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the toolbar by removing all actions and bundles.
|
||||
"""
|
||||
# First, disconnect all bundles
|
||||
for bundle_name in list(self.bundles.keys()):
|
||||
self.disconnect_bundle(bundle_name)
|
||||
|
||||
# Clear all components
|
||||
self.components.cleanup()
|
||||
self.bundles.clear()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle
|
||||
from bec_widgets.widgets.plots.toolbar_components.plot_export import plot_export_bundle
|
||||
|
||||
class MainWindow(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
|
||||
self.central_widget = QWidget()
|
||||
self.setCentralWidget(self.central_widget)
|
||||
self.test_label = QLabel(text="This is a test label.")
|
||||
self.central_widget.layout = QVBoxLayout(self.central_widget)
|
||||
self.central_widget.layout.addWidget(self.test_label)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.addToolBar(self.toolbar)
|
||||
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
|
||||
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
|
||||
self.toolbar.connect_bundle(
|
||||
"base", PerformanceConnection(self.toolbar.components, self)
|
||||
)
|
||||
self.toolbar.show_bundles(["performance", "plot_export"])
|
||||
self.toolbar.get_bundle("performance").add_action("save")
|
||||
self.toolbar.refresh()
|
||||
|
||||
def enable_fps_monitor(self, enabled: bool):
|
||||
"""
|
||||
Example method to enable or disable FPS monitoring.
|
||||
This method should be implemented in the target widget.
|
||||
"""
|
||||
if enabled:
|
||||
self.test_label.setText("FPS Monitor Enabled")
|
||||
else:
|
||||
self.test_label.setText("FPS Monitor Disabled")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
main_window = MainWindow()
|
||||
main_window.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -264,6 +264,48 @@ class WidgetIO:
|
||||
return WidgetIO._handlers[base]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def find_widgets(widget_class: QWidget | str, recursive: bool = True) -> list[QWidget]:
|
||||
"""
|
||||
Return widgets matching the given class (or class-name string).
|
||||
|
||||
Args:
|
||||
widget_class: Either a QWidget subclass or its class-name as a string.
|
||||
recursive: If True (default), traverse all top-level widgets and their children;
|
||||
if False, scan app.allWidgets() for a flat list.
|
||||
|
||||
Returns:
|
||||
List of QWidget instances matching the class or class-name.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
raise RuntimeError("No QApplication instance found.")
|
||||
|
||||
# Match by class-name string
|
||||
if isinstance(widget_class, str):
|
||||
name = widget_class
|
||||
if recursive:
|
||||
result: list[QWidget] = []
|
||||
for top in app.topLevelWidgets():
|
||||
if top.__class__.__name__ == name:
|
||||
result.append(top)
|
||||
result.extend(
|
||||
w for w in top.findChildren(QWidget) if w.__class__.__name__ == name
|
||||
)
|
||||
return result
|
||||
return [w for w in app.allWidgets() if w.__class__.__name__ == name]
|
||||
|
||||
# Match by actual class
|
||||
if recursive:
|
||||
result: list[QWidget] = []
|
||||
for top in app.topLevelWidgets():
|
||||
if isinstance(top, widget_class):
|
||||
result.append(top)
|
||||
result.extend(top.findChildren(widget_class))
|
||||
return result
|
||||
|
||||
return [w for w in app.allWidgets() if isinstance(w, widget_class)]
|
||||
|
||||
|
||||
################## for exporting and importing widget hierarchies ##################
|
||||
|
||||
|
||||
@@ -15,18 +15,20 @@ from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
from bec_widgets.utils.toolbar import (
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
ExpandableMenuAction,
|
||||
MaterialIconAction,
|
||||
ModularToolBar,
|
||||
SeparatorAction,
|
||||
WidgetAction,
|
||||
)
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
||||
@@ -104,151 +106,233 @@ class BECDockArea(BECWidget, QWidget):
|
||||
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self.dock_area = DockArea(parent=self)
|
||||
self.toolbar = ModularToolBar(
|
||||
parent=self,
|
||||
actions={
|
||||
"menu_plots": ExpandableMenuAction(
|
||||
label="Add Plot ",
|
||||
actions={
|
||||
"waveform": MaterialIconAction(
|
||||
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
|
||||
),
|
||||
"scatter_waveform": MaterialIconAction(
|
||||
icon_name=ScatterWaveform.ICON_NAME,
|
||||
tooltip="Add Scatter Waveform",
|
||||
filled=True,
|
||||
),
|
||||
"multi_waveform": MaterialIconAction(
|
||||
icon_name=MultiWaveform.ICON_NAME,
|
||||
tooltip="Add Multi Waveform",
|
||||
filled=True,
|
||||
),
|
||||
"image": MaterialIconAction(
|
||||
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True
|
||||
),
|
||||
"motor_map": MaterialIconAction(
|
||||
icon_name=MotorMap.ICON_NAME, tooltip="Add Motor Map", filled=True
|
||||
),
|
||||
},
|
||||
),
|
||||
"separator_0": SeparatorAction(),
|
||||
"menu_devices": ExpandableMenuAction(
|
||||
label="Add Device Control ",
|
||||
actions={
|
||||
"scan_control": MaterialIconAction(
|
||||
icon_name=ScanControl.ICON_NAME, tooltip="Add Scan Control", filled=True
|
||||
),
|
||||
"positioner_box": MaterialIconAction(
|
||||
icon_name=PositionerBox.ICON_NAME, tooltip="Add Device Box", filled=True
|
||||
),
|
||||
},
|
||||
),
|
||||
"separator_1": SeparatorAction(),
|
||||
"menu_utils": ExpandableMenuAction(
|
||||
label="Add Utils ",
|
||||
actions={
|
||||
"queue": MaterialIconAction(
|
||||
icon_name=BECQueue.ICON_NAME, tooltip="Add Scan Queue", filled=True
|
||||
),
|
||||
"vs_code": MaterialIconAction(
|
||||
icon_name=VSCodeEditor.ICON_NAME, tooltip="Add VS Code", filled=True
|
||||
),
|
||||
"status": MaterialIconAction(
|
||||
icon_name=BECStatusBox.ICON_NAME,
|
||||
tooltip="Add BEC Status Box",
|
||||
filled=True,
|
||||
),
|
||||
"progress_bar": MaterialIconAction(
|
||||
icon_name=RingProgressBar.ICON_NAME,
|
||||
tooltip="Add Circular ProgressBar",
|
||||
filled=True,
|
||||
),
|
||||
# FIXME temporarily disabled -> issue #644
|
||||
"log_panel": MaterialIconAction(
|
||||
icon_name=LogPanel.ICON_NAME,
|
||||
tooltip="Add LogPanel - Disabled",
|
||||
filled=True,
|
||||
),
|
||||
"sbb_monitor": MaterialIconAction(
|
||||
icon_name="train", tooltip="Add SBB Monitor", filled=True
|
||||
),
|
||||
},
|
||||
),
|
||||
"separator_2": SeparatorAction(),
|
||||
"attach_all": MaterialIconAction(
|
||||
icon_name="zoom_in_map", tooltip="Attach all floating docks"
|
||||
),
|
||||
"save_state": MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State"),
|
||||
"restore_state": MaterialIconAction(
|
||||
icon_name="frame_reload", tooltip="Restore Dock State"
|
||||
),
|
||||
},
|
||||
target_widget=self,
|
||||
)
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self._setup_toolbar()
|
||||
|
||||
self.layout.addWidget(self.toolbar)
|
||||
self.layout.addWidget(self.dock_area)
|
||||
self.spacer = QWidget(parent=self)
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
self.toolbar.addWidget(self.dark_mode_button)
|
||||
|
||||
self._hook_toolbar()
|
||||
self.toolbar.show_bundles(
|
||||
["menu_plots", "menu_devices", "menu_utils", "dock_actions", "dark_mode"]
|
||||
)
|
||||
|
||||
def minimumSizeHint(self):
|
||||
return QSize(800, 600)
|
||||
|
||||
def _setup_toolbar(self):
|
||||
|
||||
# Add plot menu
|
||||
self.toolbar.components.add_safe(
|
||||
"menu_plots",
|
||||
ExpandableMenuAction(
|
||||
label="Add Plot ",
|
||||
actions={
|
||||
"waveform": MaterialIconAction(
|
||||
icon_name=Waveform.ICON_NAME,
|
||||
tooltip="Add Waveform",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"scatter_waveform": MaterialIconAction(
|
||||
icon_name=ScatterWaveform.ICON_NAME,
|
||||
tooltip="Add Scatter Waveform",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"multi_waveform": MaterialIconAction(
|
||||
icon_name=MultiWaveform.ICON_NAME,
|
||||
tooltip="Add Multi Waveform",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"image": MaterialIconAction(
|
||||
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True, parent=self
|
||||
),
|
||||
"motor_map": MaterialIconAction(
|
||||
icon_name=MotorMap.ICON_NAME,
|
||||
tooltip="Add Motor Map",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"heatmap": MaterialIconAction(
|
||||
icon_name=Heatmap.ICON_NAME, tooltip="Add Heatmap", filled=True, parent=self
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("menu_plots", self.toolbar.components)
|
||||
bundle.add_action("menu_plots")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
# Add control menu
|
||||
self.toolbar.components.add_safe(
|
||||
"menu_devices",
|
||||
ExpandableMenuAction(
|
||||
label="Add Device Control ",
|
||||
actions={
|
||||
"scan_control": MaterialIconAction(
|
||||
icon_name=ScanControl.ICON_NAME,
|
||||
tooltip="Add Scan Control",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"positioner_box": MaterialIconAction(
|
||||
icon_name=PositionerBox.ICON_NAME,
|
||||
tooltip="Add Device Box",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("menu_devices", self.toolbar.components)
|
||||
bundle.add_action("menu_devices")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
# Add utils menu
|
||||
self.toolbar.components.add_safe(
|
||||
"menu_utils",
|
||||
ExpandableMenuAction(
|
||||
label="Add Utils ",
|
||||
actions={
|
||||
"queue": MaterialIconAction(
|
||||
icon_name=BECQueue.ICON_NAME,
|
||||
tooltip="Add Scan Queue",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"vs_code": MaterialIconAction(
|
||||
icon_name=VSCodeEditor.ICON_NAME,
|
||||
tooltip="Add VS Code",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"status": MaterialIconAction(
|
||||
icon_name=BECStatusBox.ICON_NAME,
|
||||
tooltip="Add BEC Status Box",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"progress_bar": MaterialIconAction(
|
||||
icon_name=RingProgressBar.ICON_NAME,
|
||||
tooltip="Add Circular ProgressBar",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
# FIXME temporarily disabled -> issue #644
|
||||
"log_panel": MaterialIconAction(
|
||||
icon_name=LogPanel.ICON_NAME,
|
||||
tooltip="Add LogPanel - Disabled",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"sbb_monitor": MaterialIconAction(
|
||||
icon_name="train", tooltip="Add SBB Monitor", filled=True, parent=self
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("menu_utils", self.toolbar.components)
|
||||
bundle.add_action("menu_utils")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
########## Dock Actions ##########
|
||||
spacer = QWidget(parent=self)
|
||||
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False)
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("dark_mode", self.toolbar.components)
|
||||
bundle.add_action("spacer")
|
||||
bundle.add_action("dark_mode")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"attach_all",
|
||||
MaterialIconAction(
|
||||
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
|
||||
),
|
||||
)
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"save_state",
|
||||
MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State", parent=self),
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"restore_state",
|
||||
MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self),
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("dock_actions", self.toolbar.components)
|
||||
bundle.add_action("attach_all")
|
||||
bundle.add_action("save_state")
|
||||
bundle.add_action("restore_state")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
def _hook_toolbar(self):
|
||||
# Menu Plot
|
||||
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
|
||||
menu_plots = self.toolbar.components.get_action("menu_plots")
|
||||
menu_devices = self.toolbar.components.get_action("menu_devices")
|
||||
menu_utils = self.toolbar.components.get_action("menu_utils")
|
||||
|
||||
menu_plots.actions["waveform"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].triggered.connect(
|
||||
|
||||
menu_plots.actions["scatter_waveform"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
|
||||
menu_plots.actions["multi_waveform"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
|
||||
menu_plots.actions["image"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Image")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
|
||||
menu_plots.actions["motor_map"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
|
||||
)
|
||||
menu_plots.actions["heatmap"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Heatmap")
|
||||
)
|
||||
|
||||
# Menu Devices
|
||||
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
|
||||
menu_devices.actions["scan_control"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
|
||||
)
|
||||
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
|
||||
menu_devices.actions["positioner_box"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
|
||||
)
|
||||
|
||||
# Menu Utils
|
||||
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
|
||||
menu_utils.actions["queue"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
|
||||
menu_utils.actions["status"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
|
||||
menu_utils.actions["vs_code"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
|
||||
menu_utils.actions["progress_bar"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
|
||||
)
|
||||
# FIXME temporarily disabled -> issue #644
|
||||
self.toolbar.widgets["menu_utils"].widgets["log_panel"].setEnabled(False)
|
||||
# self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
|
||||
# lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
|
||||
# )
|
||||
self.toolbar.widgets["menu_utils"].widgets["sbb_monitor"].triggered.connect(
|
||||
menu_utils.actions["log_panel"].action.setEnabled(False)
|
||||
|
||||
menu_utils.actions["sbb_monitor"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor")
|
||||
)
|
||||
|
||||
# Icons
|
||||
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)
|
||||
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
|
||||
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
|
||||
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
|
||||
self.toolbar.components.get_action("save_state").action.triggered.connect(self.save_state)
|
||||
self.toolbar.components.get_action("restore_state").action.triggered.connect(
|
||||
self.restore_state
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _create_widget_from_toolbar(self, widget_name: str) -> None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,10 +19,15 @@ from qtpy.QtWidgets import (
|
||||
import bec_widgets
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.colors import apply_theme, set_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
|
||||
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
|
||||
BECNotificationBroker,
|
||||
NotificationCentre,
|
||||
NotificationIndicator,
|
||||
)
|
||||
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
|
||||
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
|
||||
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
|
||||
@@ -50,6 +55,14 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self.app = QApplication.instance()
|
||||
self.status_bar = self.statusBar()
|
||||
self.setWindowTitle(window_title)
|
||||
|
||||
# Notification Centre overlay
|
||||
self.notification_centre = NotificationCentre(parent=self) # Notification layer
|
||||
self.notification_broker = BECNotificationBroker()
|
||||
self._nc_margin = 16
|
||||
self._position_notification_centre()
|
||||
|
||||
# Init ui
|
||||
self._init_ui()
|
||||
self._connect_to_theme_change()
|
||||
|
||||
@@ -58,6 +71,34 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self.display_client_message, MessageEndpoints.client_info()
|
||||
)
|
||||
|
||||
def setCentralWidget(self, widget: QWidget, qt_default: bool = False): # type: ignore[override]
|
||||
"""
|
||||
Re‑implement QMainWindow.setCentralWidget so that the *main content*
|
||||
widget always lives on the lower layer of the stacked layout that
|
||||
hosts our notification overlays.
|
||||
|
||||
Args:
|
||||
widget: The widget that should become the new central content.
|
||||
qt_default: When *True* the call is forwarded to the base class so
|
||||
that Qt behaves exactly as the original implementation (used
|
||||
during __init__ when we first install ``self._full_content``).
|
||||
"""
|
||||
super().setCentralWidget(widget)
|
||||
self.notification_centre.raise_()
|
||||
self.statusBar().raise_()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._position_notification_centre()
|
||||
|
||||
def _position_notification_centre(self):
|
||||
"""Keep the notification panel at a fixed margin top-right."""
|
||||
if not hasattr(self, "notification_centre"):
|
||||
return
|
||||
margin = getattr(self, "_nc_margin", 16) # px
|
||||
nc = self.notification_centre
|
||||
nc.move(self.width() - nc.width() - margin, margin)
|
||||
|
||||
################################################################################
|
||||
# MainWindow Elements Initialization
|
||||
################################################################################
|
||||
@@ -94,6 +135,26 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
# Add scan_progress bar with display logic
|
||||
self._add_scan_progress_bar()
|
||||
|
||||
# Setup NotificationIndicator to bottom right of the status bar
|
||||
self._add_notification_indicator()
|
||||
|
||||
################################################################################
|
||||
# Notification indicator and Notification Centre helpers
|
||||
|
||||
def _add_notification_indicator(self):
|
||||
"""
|
||||
Add the notification indicator to the status bar and hook the signals.
|
||||
"""
|
||||
# Add the notification indicator to the status bar
|
||||
self.notification_indicator = NotificationIndicator(self)
|
||||
self.status_bar.addPermanentWidget(self.notification_indicator)
|
||||
|
||||
# Connect the notification broker to the indicator
|
||||
self.notification_centre.counts_updated.connect(self.notification_indicator.update_counts)
|
||||
self.notification_indicator.filter_changed.connect(self.notification_centre.apply_filter)
|
||||
self.notification_indicator.show_all_requested.connect(self.notification_centre.show_all)
|
||||
self.notification_indicator.hide_all_requested.connect(self.notification_centre.hide_all)
|
||||
|
||||
################################################################################
|
||||
# Client message status bar widget helpers
|
||||
|
||||
@@ -379,12 +440,12 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
@SafeSlot(str)
|
||||
def change_theme(self, theme: str):
|
||||
"""
|
||||
Change the theme of the application.
|
||||
Change the theme of the application and propagate it to widgets.
|
||||
|
||||
Args:
|
||||
theme(str): The theme to apply, either "light" or "dark".
|
||||
theme(str): Either "light" or "dark".
|
||||
"""
|
||||
apply_theme(theme)
|
||||
set_theme(theme) # emits theme_updated and applies palette globally
|
||||
|
||||
def event(self, event):
|
||||
if event.type() == QEvent.Type.StatusTip:
|
||||
|
||||
@@ -138,7 +138,11 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
signals = msg_content.get("signals", {})
|
||||
# pylint: disable=protected-access
|
||||
hinted_signals = self.dev[device]._hints
|
||||
precision = self.dev[device].precision
|
||||
precision = getattr(self.dev[device], "precision", 8)
|
||||
try:
|
||||
precision = int(precision)
|
||||
except (TypeError, ValueError):
|
||||
precision = int(8)
|
||||
|
||||
spinner = ui_components["spinner"]
|
||||
position_indicator = ui_components["position_indicator"]
|
||||
@@ -178,11 +182,13 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
spinner.setVisible(False)
|
||||
|
||||
if readback_val is not None:
|
||||
readback.setText(f"{readback_val:.{precision}f}")
|
||||
text = f"{readback_val:.{precision}f}"
|
||||
readback.setText(text)
|
||||
position_emit(readback_val)
|
||||
|
||||
if setpoint_val is not None:
|
||||
setpoint.setText(f"{setpoint_val:.{precision}f}")
|
||||
text = f"{setpoint_val:.{precision}f}"
|
||||
setpoint.setText(text)
|
||||
|
||||
limits = self.dev[device].limits
|
||||
limit_update(limits)
|
||||
@@ -205,10 +211,13 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
ui["readback"].setToolTip(f"{device} readback")
|
||||
ui["setpoint"].setToolTip(f"{device} setpoint")
|
||||
ui["step_size"].setToolTip(f"Step size for {device}")
|
||||
precision = self.dev[device].precision
|
||||
if precision is not None:
|
||||
ui["step_size"].setDecimals(precision)
|
||||
ui["step_size"].setValue(10**-precision * 10)
|
||||
precision = getattr(self.dev[device], "precision", 8)
|
||||
try:
|
||||
precision = int(precision)
|
||||
except (TypeError, ValueError):
|
||||
precision = int(8)
|
||||
ui["step_size"].setDecimals(precision)
|
||||
ui["step_size"].setValue(10**-precision * 10)
|
||||
|
||||
def _swap_readback_signal_connection(self, slot, old_device, new_device):
|
||||
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
|
||||
|
||||
@@ -45,7 +45,12 @@ class PositionerGroupBox(QGroupBox):
|
||||
|
||||
def _on_position_update(self, pos: float):
|
||||
self.position_update.emit(pos)
|
||||
self.widget.label = f"%.{self.widget.dev[self.widget.device].precision}f" % pos
|
||||
precision = getattr(self.widget.dev[self.widget.device], "precision", 8)
|
||||
try:
|
||||
precision = int(precision)
|
||||
except (TypeError, ValueError):
|
||||
precision = int(8)
|
||||
self.widget.label = f"{pos:.{precision}f}"
|
||||
|
||||
def close(self):
|
||||
self.widget.close()
|
||||
|
||||
@@ -69,7 +69,7 @@ class DeviceSignalInputBase(BECWidget):
|
||||
Args:
|
||||
signal (str): signal name.
|
||||
"""
|
||||
if self.validate_signal(signal) is True:
|
||||
if self.validate_signal(signal):
|
||||
WidgetIO.set_value(widget=self, value=signal)
|
||||
self.config.default = signal
|
||||
else:
|
||||
|
||||
@@ -5,6 +5,7 @@ from qtpy.QtGui import QPainter, QPaintEvent, QPen
|
||||
from qtpy.QtWidgets import QComboBox, 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,
|
||||
@@ -61,6 +62,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
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.
|
||||
@@ -93,6 +95,31 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
self.currentTextChanged.connect(self.check_validity)
|
||||
self.check_validity(self.currentText())
|
||||
|
||||
@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.
|
||||
"""
|
||||
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, "")
|
||||
self.setCurrentIndex(0)
|
||||
else:
|
||||
if self.count() > 0 and self.itemText(0) == "":
|
||||
self.removeItem(0)
|
||||
|
||||
def on_device_update(self, action: str, content: dict) -> None:
|
||||
"""
|
||||
Callback for device update events. Triggers the device_update signal.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtWidgets import QComboBox, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
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 (
|
||||
@@ -54,6 +56,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.setMinimumSize(QSize(100, 0))
|
||||
self._set_first_element_as_empty = True
|
||||
# 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.
|
||||
@@ -90,6 +93,31 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
self.insertItem(0, "Hinted Signals")
|
||||
self.model().item(0).setEnabled(False)
|
||||
|
||||
@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.
|
||||
"""
|
||||
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, "")
|
||||
self.setCurrentIndex(0)
|
||||
else:
|
||||
if self.count() > 0 and self.itemText(0) == "":
|
||||
self.removeItem(0)
|
||||
|
||||
def set_to_obj_name(self, obj_name: str) -> bool:
|
||||
"""
|
||||
Set the combobox to the object name of the signal.
|
||||
@@ -142,6 +170,10 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
return
|
||||
self.device_signal_changed.emit(text)
|
||||
|
||||
@property
|
||||
def selected_signal_comp_name(self) -> str:
|
||||
return dict(self.signals).get(self.currentText(), {}).get("component_name", "")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
@@ -169,8 +169,8 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.layout.addWidget(self._metadata_form)
|
||||
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
|
||||
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
|
||||
self._metadata_form.metadata_updated.connect(self.update_scan_metadata)
|
||||
self._metadata_form.metadata_cleared.connect(self.update_scan_metadata)
|
||||
self._metadata_form.form_data_updated.connect(self.update_scan_metadata)
|
||||
self._metadata_form.form_data_cleared.connect(self.update_scan_metadata)
|
||||
self._metadata_form.validate_form()
|
||||
|
||||
def populate_scans(self):
|
||||
|
||||
188
bec_widgets/widgets/editors/monaco/monaco_widget.py
Normal file
188
bec_widgets/widgets/editors/monaco/monaco_widget.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from typing import Literal
|
||||
|
||||
import qtmonaco
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_theme_name
|
||||
|
||||
|
||||
class MonacoWidget(BECWidget, QWidget):
|
||||
"""
|
||||
A simple Monaco editor widget
|
||||
"""
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "code"
|
||||
USER_ACCESS = [
|
||||
"set_text",
|
||||
"get_text",
|
||||
"set_language",
|
||||
"get_language",
|
||||
"set_theme",
|
||||
"get_theme",
|
||||
"set_readonly",
|
||||
"set_cursor",
|
||||
"current_cursor",
|
||||
"set_minimap_enabled",
|
||||
]
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
super().__init__(
|
||||
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
|
||||
)
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.editor = qtmonaco.Monaco(self)
|
||||
layout.addWidget(self.editor)
|
||||
self.setLayout(layout)
|
||||
self.editor.initialized.connect(self.apply_theme)
|
||||
|
||||
def apply_theme(self, theme: str | None = None) -> None:
|
||||
"""
|
||||
Apply the current theme to the Monaco editor.
|
||||
|
||||
Args:
|
||||
theme (str, optional): The theme to apply. If None, the current theme will be used.
|
||||
"""
|
||||
if theme is None:
|
||||
theme = get_theme_name()
|
||||
editor_theme = "vs" if theme == "light" else "vs-dark"
|
||||
self.set_theme(editor_theme)
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
"""
|
||||
Set the text in the Monaco editor.
|
||||
|
||||
Args:
|
||||
text (str): The text to set in the editor.
|
||||
"""
|
||||
self.editor.set_text(text)
|
||||
|
||||
def get_text(self) -> str:
|
||||
"""
|
||||
Get the current text from the Monaco editor.
|
||||
"""
|
||||
return self.editor.get_text()
|
||||
|
||||
def set_cursor(
|
||||
self,
|
||||
line: int,
|
||||
column: int = 1,
|
||||
move_to_position: Literal[None, "center", "top", "position"] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Set the cursor position in the Monaco editor.
|
||||
|
||||
Args:
|
||||
line (int): Line number (1-based).
|
||||
column (int): Column number (1-based), defaults to 1.
|
||||
move_to_position (Literal[None, "center", "top", "position"], optional): Position to move the cursor to.
|
||||
"""
|
||||
self.editor.set_cursor(line, column, move_to_position)
|
||||
|
||||
def current_cursor(self) -> dict[str, int]:
|
||||
"""
|
||||
Get the current cursor position in the Monaco editor.
|
||||
|
||||
Returns:
|
||||
dict[str, int]: A dictionary with keys 'line' and 'column' representing the cursor position.
|
||||
"""
|
||||
return self.editor.current_cursor
|
||||
|
||||
def set_language(self, language: str) -> None:
|
||||
"""
|
||||
Set the programming language for syntax highlighting in the Monaco editor.
|
||||
|
||||
Args:
|
||||
language (str): The programming language to set (e.g., "python", "javascript").
|
||||
"""
|
||||
self.editor.set_language(language)
|
||||
|
||||
def get_language(self) -> str:
|
||||
"""
|
||||
Get the current programming language set in the Monaco editor.
|
||||
"""
|
||||
return self.editor.get_language()
|
||||
|
||||
def set_readonly(self, read_only: bool) -> None:
|
||||
"""
|
||||
Set the Monaco editor to read-only mode.
|
||||
|
||||
Args:
|
||||
read_only (bool): If True, the editor will be read-only.
|
||||
"""
|
||||
self.editor.set_readonly(read_only)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
"""
|
||||
Set the theme for the Monaco editor.
|
||||
|
||||
Args:
|
||||
theme (str): The theme to set (e.g., "vs-dark", "light").
|
||||
"""
|
||||
self.editor.set_theme(theme)
|
||||
|
||||
def get_theme(self) -> str:
|
||||
"""
|
||||
Get the current theme of the Monaco editor.
|
||||
"""
|
||||
return self.editor.get_theme()
|
||||
|
||||
def set_minimap_enabled(self, enabled: bool) -> None:
|
||||
"""
|
||||
Enable or disable the minimap in the Monaco editor.
|
||||
|
||||
Args:
|
||||
enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled.
|
||||
"""
|
||||
self.editor.set_minimap_enabled(enabled)
|
||||
|
||||
def set_highlighted_lines(self, start_line: int, end_line: int) -> None:
|
||||
"""
|
||||
Highlight a range of lines in the Monaco editor.
|
||||
|
||||
Args:
|
||||
start_line (int): The starting line number (1-based).
|
||||
end_line (int): The ending line number (1-based).
|
||||
"""
|
||||
self.editor.set_highlighted_lines(start_line, end_line)
|
||||
|
||||
def clear_highlighted_lines(self) -> None:
|
||||
"""
|
||||
Clear any highlighted lines in the Monaco editor.
|
||||
"""
|
||||
self.editor.clear_highlighted_lines()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
qapp = QApplication([])
|
||||
widget = MonacoWidget()
|
||||
# set the default size
|
||||
widget.resize(800, 600)
|
||||
widget.set_language("python")
|
||||
widget.set_theme("vs-dark")
|
||||
widget.editor.set_minimap_enabled(False)
|
||||
widget.set_text(
|
||||
"""
|
||||
import numpy as np
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.devicemanager import DeviceContainer
|
||||
from bec_lib.scans import Scans
|
||||
dev: DeviceContainer
|
||||
scans: Scans
|
||||
|
||||
#######################################
|
||||
########## User Script #####################
|
||||
#######################################
|
||||
|
||||
# This is a comment
|
||||
def hello_world():
|
||||
print("Hello, world!")
|
||||
"""
|
||||
)
|
||||
widget.set_highlighted_lines(1, 3)
|
||||
widget.show()
|
||||
qapp.exec_()
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['monaco_widget.py']}
|
||||
54
bec_widgets/widgets/editors/monaco/monaco_widget_plugin.py
Normal file
54
bec_widgets/widgets/editors/monaco/monaco_widget_plugin.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='MonacoWidget' name='monaco_widget'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class MonacoWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = MonacoWidget(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Developer"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(MonacoWidget.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "monaco_widget"
|
||||
|
||||
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 "MonacoWidget"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
15
bec_widgets/widgets/editors/monaco/register_monaco_widget.py
Normal file
15
bec_widgets/widgets/editors/monaco/register_monaco_widget.py
Normal file
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget_plugin import MonacoWidgetPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(MonacoWidgetPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
989
bec_widgets/widgets/plots/heatmap/heatmap.py
Normal file
989
bec_widgets/widgets/plots/heatmap/heatmap.py
Normal file
@@ -0,0 +1,989 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger, messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtGui import QTransform
|
||||
from scipy.interpolate import (
|
||||
CloughTocher2DInterpolator,
|
||||
LinearNDInterpolator,
|
||||
NearestNDInterpolator,
|
||||
)
|
||||
from scipy.spatial import cKDTree
|
||||
from toolz import partition
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.widgets.plots.heatmap.settings.heatmap_setting import HeatmapSettings
|
||||
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class HeatmapDeviceSignal(BaseModel):
|
||||
"""The configuration of a signal in the scatter waveform widget."""
|
||||
|
||||
name: str
|
||||
entry: str
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
class HeatmapConfig(ConnectionConfig):
|
||||
parent_id: str | None = Field(None, description="The parent plot of the curve.")
|
||||
color_map: str | None = Field(
|
||||
"plasma", description="The color palette of the heatmap widget.", validate_default=True
|
||||
)
|
||||
color_bar: Literal["full", "simple"] | None = Field(
|
||||
None, description="The type of the color bar."
|
||||
)
|
||||
interpolation: Literal["linear", "nearest", "clough"] = Field(
|
||||
"linear", description="The interpolation method for the heatmap."
|
||||
)
|
||||
oversampling_factor: float = Field(
|
||||
1.0,
|
||||
description="Factor to oversample the grid resolution (1.0 = no oversampling, 2.0 = 2x resolution).",
|
||||
)
|
||||
show_config_label: bool = Field(
|
||||
True, description="Whether to show the configuration label in the heatmap."
|
||||
)
|
||||
enforce_interpolation: bool = Field(
|
||||
False, description="Whether to use the interpolation mode even for grid scans."
|
||||
)
|
||||
lock_aspect_ratio: bool = Field(
|
||||
False, description="Whether to lock the aspect ratio of the image."
|
||||
)
|
||||
x_device: HeatmapDeviceSignal | None = Field(
|
||||
None, description="The x device signal of the heatmap."
|
||||
)
|
||||
y_device: HeatmapDeviceSignal | None = Field(
|
||||
None, description="The y device signal of the heatmap."
|
||||
)
|
||||
z_device: HeatmapDeviceSignal | None = Field(
|
||||
None, description="The z device signal of the heatmap."
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
_validate_color_palette = field_validator("color_map")(Colors.validate_color_map)
|
||||
|
||||
|
||||
class Heatmap(ImageBase):
|
||||
"""
|
||||
Heatmap widget for visualizing 2d grid data with color mapping for the z-axis.
|
||||
"""
|
||||
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
"color_map.setter",
|
||||
"v_range",
|
||||
"v_range.setter",
|
||||
"v_min",
|
||||
"v_min.setter",
|
||||
"v_max",
|
||||
"v_max.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"autorange",
|
||||
"autorange.setter",
|
||||
"autorange_mode",
|
||||
"autorange_mode.setter",
|
||||
"enable_colorbar",
|
||||
"enable_simple_colorbar",
|
||||
"enable_simple_colorbar.setter",
|
||||
"enable_full_colorbar",
|
||||
"enable_full_colorbar.setter",
|
||||
"interpolation_method",
|
||||
"interpolation_method.setter",
|
||||
"oversampling_factor",
|
||||
"oversampling_factor.setter",
|
||||
"enforce_interpolation",
|
||||
"enforce_interpolation.setter",
|
||||
"fft",
|
||||
"fft.setter",
|
||||
"log",
|
||||
"log.setter",
|
||||
"main_image",
|
||||
"add_roi",
|
||||
"remove_roi",
|
||||
"rois",
|
||||
"plot",
|
||||
]
|
||||
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
ICON_NAME = "dataset"
|
||||
|
||||
new_scan = Signal()
|
||||
new_scan_id = Signal(str)
|
||||
sync_signal_update = Signal()
|
||||
heatmap_property_changed = Signal()
|
||||
|
||||
def __init__(self, parent=None, config: HeatmapConfig | None = None, **kwargs):
|
||||
if config is None:
|
||||
config = HeatmapConfig(
|
||||
widget_class=self.__class__.__name__,
|
||||
parent_id=None,
|
||||
color_map="plasma",
|
||||
color_bar=None,
|
||||
interpolation="linear",
|
||||
oversampling_factor=1.0,
|
||||
lock_aspect_ratio=False,
|
||||
x_device=None,
|
||||
y_device=None,
|
||||
z_device=None,
|
||||
)
|
||||
super().__init__(parent=parent, config=config, theme_update=True, **kwargs)
|
||||
self._image_config = config
|
||||
self.scan_id = None
|
||||
self.old_scan_id = None
|
||||
self.scan_item = None
|
||||
self.status_message = None
|
||||
self._grid_index = None
|
||||
self.heatmap_dialog = None
|
||||
bg_color = pg.mkColor((240, 240, 240, 150))
|
||||
self.config_label = pg.LegendItem(
|
||||
labelTextColor=(0, 0, 0), offset=(-30, 1), brush=pg.mkBrush(bg_color), horSpacing=0
|
||||
)
|
||||
self.config_label.setParentItem(self.plot_item.vb)
|
||||
self.config_label.setVisible(False)
|
||||
self.reload = False
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status())
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
|
||||
self.heatmap_property_changed.connect(lambda: self.sync_signal_update.emit())
|
||||
|
||||
self.proxy_update_sync = pg.SignalProxy(
|
||||
self.sync_signal_update, rateLimit=5, slot=self.update_plot
|
||||
)
|
||||
self._init_toolbar_heatmap()
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"heatmap_settings",
|
||||
"plot_export",
|
||||
"image_crosshair",
|
||||
"mouse_interaction",
|
||||
"image_autorange",
|
||||
"image_colorbar",
|
||||
"image_processing",
|
||||
"axis_popup",
|
||||
"interpolation_info",
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def main_image(self) -> ImageItem:
|
||||
"""Access the main image item."""
|
||||
return self.layer_manager["main"].image
|
||||
|
||||
################################################################################
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
|
||||
@SafeSlot(str)
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Apply the current theme to the heatmap widget.
|
||||
"""
|
||||
super().apply_theme(theme)
|
||||
if theme == "dark":
|
||||
brush = pg.mkBrush(pg.mkColor(50, 50, 50, 150))
|
||||
color = pg.mkColor(255, 255, 255)
|
||||
else:
|
||||
brush = pg.mkBrush(pg.mkColor(240, 240, 240, 150))
|
||||
color = pg.mkColor(0, 0, 0)
|
||||
if hasattr(self, "config_label"):
|
||||
self.config_label.setBrush(brush)
|
||||
self.config_label.setLabelTextColor(color)
|
||||
self.redraw_config_label()
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def plot(
|
||||
self,
|
||||
x_name: str,
|
||||
y_name: str,
|
||||
z_name: str,
|
||||
x_entry: None | str = None,
|
||||
y_entry: None | str = None,
|
||||
z_entry: None | str = None,
|
||||
color_map: str | None = "plasma",
|
||||
validate_bec: bool = True,
|
||||
interpolation: Literal["linear", "nearest"] | None = None,
|
||||
enforce_interpolation: bool | None = None,
|
||||
oversampling_factor: float | None = None,
|
||||
lock_aspect_ratio: bool | None = None,
|
||||
show_config_label: bool | None = None,
|
||||
reload: bool = False,
|
||||
):
|
||||
"""
|
||||
Plot the heatmap with the given x, y, and z data.
|
||||
|
||||
Args:
|
||||
x_name (str): The name of the x-axis signal.
|
||||
y_name (str): The name of the y-axis signal.
|
||||
z_name (str): The name of the z-axis signal.
|
||||
x_entry (str | None): The entry for the x-axis signal.
|
||||
y_entry (str | None): The entry for the y-axis signal.
|
||||
z_entry (str | None): The entry for the z-axis signal.
|
||||
color_map (str | None): The color map to use for the heatmap.
|
||||
validate_bec (bool): Whether to validate the entries against BEC signals.
|
||||
interpolation (Literal["linear", "nearest"] | None): The interpolation method to use.
|
||||
enforce_interpolation (bool | None): Whether to enforce interpolation even for grid scans.
|
||||
oversampling_factor (float | None): Factor to oversample the grid resolution.
|
||||
lock_aspect_ratio (bool | None): Whether to lock the aspect ratio of the image.
|
||||
show_config_label (bool | None): Whether to show the configuration label in the heatmap.
|
||||
reload (bool): Whether to reload the heatmap with new data.
|
||||
"""
|
||||
if validate_bec:
|
||||
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
|
||||
y_entry = self.entry_validator.validate_signal(y_name, y_entry)
|
||||
z_entry = self.entry_validator.validate_signal(z_name, z_entry)
|
||||
|
||||
if x_entry is None or y_entry is None or z_entry is None:
|
||||
raise ValueError("x, y, and z entries must be provided.")
|
||||
if x_name is None or y_name is None or z_name is None:
|
||||
raise ValueError("x, y, and z names must be provided.")
|
||||
|
||||
if interpolation is None:
|
||||
interpolation = self._image_config.interpolation
|
||||
|
||||
if oversampling_factor is None:
|
||||
oversampling_factor = self._image_config.oversampling_factor
|
||||
|
||||
if enforce_interpolation is None:
|
||||
enforce_interpolation = self._image_config.enforce_interpolation
|
||||
|
||||
if lock_aspect_ratio is None:
|
||||
lock_aspect_ratio = self._image_config.lock_aspect_ratio
|
||||
|
||||
if show_config_label is None:
|
||||
show_config_label = self._image_config.show_config_label
|
||||
|
||||
self._image_config = HeatmapConfig(
|
||||
parent_id=self.gui_id,
|
||||
x_device=HeatmapDeviceSignal(name=x_name, entry=x_entry),
|
||||
y_device=HeatmapDeviceSignal(name=y_name, entry=y_entry),
|
||||
z_device=HeatmapDeviceSignal(name=z_name, entry=z_entry),
|
||||
color_map=color_map,
|
||||
color_bar=None,
|
||||
interpolation=interpolation,
|
||||
oversampling_factor=oversampling_factor,
|
||||
enforce_interpolation=enforce_interpolation,
|
||||
lock_aspect_ratio=lock_aspect_ratio,
|
||||
show_config_label=show_config_label,
|
||||
)
|
||||
self.color_map = color_map
|
||||
self.reload = reload
|
||||
self.update_labels()
|
||||
|
||||
self._fetch_running_scan()
|
||||
self.sync_signal_update.emit()
|
||||
|
||||
def _fetch_running_scan(self):
|
||||
scan = self.client.queue.scan_storage.current_scan
|
||||
if scan is not None:
|
||||
self.scan_item = scan
|
||||
self.scan_id = scan.scan_id
|
||||
elif self.client.history and len(self.client.history) > 0:
|
||||
self.scan_item = self.client.history[-1]
|
||||
self.scan_id = self.client.history._scan_ids[-1]
|
||||
self.old_scan_id = None
|
||||
|
||||
def update_labels(self):
|
||||
"""
|
||||
Update the labels of the x, y, and z axes.
|
||||
"""
|
||||
if self._image_config is None:
|
||||
return
|
||||
x_name = self._image_config.x_device.name
|
||||
y_name = self._image_config.y_device.name
|
||||
z_name = self._image_config.z_device.name
|
||||
|
||||
if x_name is not None:
|
||||
self.x_label = x_name # type: ignore
|
||||
x_dev = self.dev.get(x_name)
|
||||
if x_dev and hasattr(x_dev, "egu"):
|
||||
self.x_label_units = x_dev.egu()
|
||||
if y_name is not None:
|
||||
self.y_label = y_name # type: ignore
|
||||
y_dev = self.dev.get(y_name)
|
||||
if y_dev and hasattr(y_dev, "egu"):
|
||||
self.y_label_units = y_dev.egu()
|
||||
if z_name is not None:
|
||||
self.title = z_name
|
||||
|
||||
def _init_toolbar_heatmap(self):
|
||||
"""
|
||||
Initialize the toolbar for the heatmap widget, adding actions for heatmap settings.
|
||||
"""
|
||||
self.toolbar.add_action(
|
||||
"heatmap_settings",
|
||||
MaterialIconAction(
|
||||
icon_name="scatter_plot",
|
||||
tooltip="Show Heatmap Settings",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
),
|
||||
)
|
||||
|
||||
self.toolbar.components.get_action("heatmap_settings").action.triggered.connect(
|
||||
self.show_heatmap_settings
|
||||
)
|
||||
|
||||
# disable all processing actions except for the fft and log
|
||||
bundle = self.toolbar.get_bundle("image_processing")
|
||||
for name, action in bundle.bundle_actions.items():
|
||||
if name not in ["image_processing_fft", "image_processing_log"]:
|
||||
action().action.setVisible(False)
|
||||
|
||||
self.toolbar.add_action(
|
||||
"interpolation_info",
|
||||
MaterialIconAction(
|
||||
icon_name="info", tooltip="Show Interpolation Info", checkable=True, parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.components.get_action("interpolation_info").action.triggered.connect(
|
||||
self.toggle_interpolation_info
|
||||
)
|
||||
self.toolbar.components.get_action("interpolation_info").action.setChecked(
|
||||
self._image_config.show_config_label
|
||||
)
|
||||
|
||||
def show_heatmap_settings(self):
|
||||
"""
|
||||
Show the heatmap settings dialog.
|
||||
"""
|
||||
heatmap_settings_action = self.toolbar.components.get_action("heatmap_settings").action
|
||||
if self.heatmap_dialog is None or not self.heatmap_dialog.isVisible():
|
||||
heatmap_settings = HeatmapSettings(parent=self, target_widget=self, popup=True)
|
||||
self.heatmap_dialog = SettingsDialog(
|
||||
self, settings_widget=heatmap_settings, window_title="Heatmap Settings", modal=False
|
||||
)
|
||||
self.heatmap_dialog.resize(700, 350)
|
||||
# When the dialog is closed, update the toolbar icon and clear the reference
|
||||
self.heatmap_dialog.finished.connect(self._heatmap_dialog_closed)
|
||||
self.heatmap_dialog.show()
|
||||
heatmap_settings_action.setChecked(True)
|
||||
else:
|
||||
# If already open, bring it to the front
|
||||
self.heatmap_dialog.raise_()
|
||||
self.heatmap_dialog.activateWindow()
|
||||
heatmap_settings_action.setChecked(True) # keep it toggled
|
||||
|
||||
def toggle_interpolation_info(self):
|
||||
"""
|
||||
Toggle the visibility of the interpolation info label.
|
||||
"""
|
||||
self._image_config.show_config_label = not self._image_config.show_config_label
|
||||
self.toolbar.components.get_action("interpolation_info").action.setChecked(
|
||||
self._image_config.show_config_label
|
||||
)
|
||||
self.redraw_config_label()
|
||||
|
||||
def _heatmap_dialog_closed(self):
|
||||
"""
|
||||
Slot for when the heatmap settings dialog is closed.
|
||||
"""
|
||||
self.heatmap_dialog = None
|
||||
self.toolbar.components.get_action("heatmap_settings").action.setChecked(False)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_scan_status(self, msg: dict, meta: dict):
|
||||
"""
|
||||
Initial scan status message handler, which is triggered at the begging and end of scan.
|
||||
|
||||
Args:
|
||||
msg(dict): The message content.
|
||||
meta(dict): The message metadata.
|
||||
"""
|
||||
current_scan_id = msg.get("scan_id", None)
|
||||
if current_scan_id is None:
|
||||
return
|
||||
if current_scan_id != self.scan_id:
|
||||
self.reset()
|
||||
self.new_scan.emit()
|
||||
self.new_scan_id.emit(current_scan_id)
|
||||
self.old_scan_id = self.scan_id
|
||||
self.scan_id = current_scan_id
|
||||
self.scan_item = self.queue.scan_storage.find_scan_by_ID(self.scan_id) # type: ignore
|
||||
|
||||
# First trigger to update the scan curves
|
||||
self.sync_signal_update.emit()
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_scan_progress(self, msg: dict, meta: dict):
|
||||
self.sync_signal_update.emit()
|
||||
status = msg.get("done")
|
||||
if status:
|
||||
QTimer.singleShot(100, self.update_plot)
|
||||
QTimer.singleShot(300, self.update_plot)
|
||||
|
||||
@SafeSlot(verify_sender=True)
|
||||
def update_plot(self, _=None) -> None:
|
||||
"""
|
||||
Update the plot with the current data.
|
||||
"""
|
||||
if self.scan_item is None:
|
||||
logger.info("No scan executed so far; skipping update.")
|
||||
return
|
||||
data, access_key = self._fetch_scan_data_and_access()
|
||||
if data == "none":
|
||||
logger.info("No scan executed so far; skipping update.")
|
||||
return
|
||||
|
||||
if self._image_config is None:
|
||||
return
|
||||
try:
|
||||
x_name = self._image_config.x_device.name
|
||||
x_entry = self._image_config.x_device.entry
|
||||
y_name = self._image_config.y_device.name
|
||||
y_entry = self._image_config.y_device.entry
|
||||
z_name = self._image_config.z_device.name
|
||||
z_entry = self._image_config.z_device.entry
|
||||
except AttributeError:
|
||||
return
|
||||
|
||||
if access_key == "val":
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
|
||||
y_data = data.get(y_name, {}).get(y_entry, {}).get(access_key, None)
|
||||
z_data = data.get(z_name, {}).get(z_entry, {}).get(access_key, None)
|
||||
else:
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
|
||||
y_data = data.get(y_name, {}).get(y_entry, {}).read().get("value", None)
|
||||
z_data = data.get(z_name, {}).get(z_entry, {}).read().get("value", None)
|
||||
|
||||
if not isinstance(x_data, list):
|
||||
x_data = x_data.tolist() if isinstance(x_data, np.ndarray) else None
|
||||
if not isinstance(y_data, list):
|
||||
y_data = y_data.tolist() if isinstance(y_data, np.ndarray) else None
|
||||
if not isinstance(z_data, list):
|
||||
z_data = z_data.tolist() if isinstance(z_data, np.ndarray) else None
|
||||
|
||||
if x_data is None or y_data is None or z_data is None:
|
||||
logger.warning("x, y, or z data is None; skipping update.")
|
||||
return
|
||||
if len(x_data) != len(y_data) or len(x_data) != len(z_data):
|
||||
logger.warning(
|
||||
"x, y, and z data lengths do not match; skipping update. "
|
||||
f"Lengths: x={len(x_data)}, y={len(y_data)}, z={len(z_data)}"
|
||||
)
|
||||
return
|
||||
|
||||
if hasattr(self.scan_item, "status_message"):
|
||||
scan_msg = self.scan_item.status_message
|
||||
elif hasattr(self.scan_item, "metadata"):
|
||||
metadata = self.scan_item.metadata["bec"]
|
||||
status = metadata["exit_status"]
|
||||
scan_id = metadata["scan_id"]
|
||||
scan_name = metadata["scan_name"]
|
||||
scan_type = metadata["scan_type"]
|
||||
scan_number = metadata["scan_number"]
|
||||
request_inputs = metadata["request_inputs"]
|
||||
if "arg_bundle" in request_inputs and isinstance(request_inputs["arg_bundle"], str):
|
||||
# Convert the arg_bundle from a JSON string to a dictionary
|
||||
request_inputs["arg_bundle"] = json.loads(request_inputs["arg_bundle"])
|
||||
positions = metadata.get("positions", [])
|
||||
positions = positions.tolist() if isinstance(positions, np.ndarray) else positions
|
||||
|
||||
scan_msg = messages.ScanStatusMessage(
|
||||
status=status,
|
||||
scan_id=scan_id,
|
||||
scan_name=scan_name,
|
||||
scan_number=scan_number,
|
||||
scan_type=scan_type,
|
||||
request_inputs=request_inputs,
|
||||
info={"positions": positions},
|
||||
)
|
||||
else:
|
||||
scan_msg = None
|
||||
|
||||
if scan_msg is None:
|
||||
logger.warning("Scan message is None; skipping update.")
|
||||
return
|
||||
self.status_message = scan_msg
|
||||
|
||||
if self._image_config.show_config_label:
|
||||
self.redraw_config_label()
|
||||
|
||||
img, transform = self.get_image_data(x_data=x_data, y_data=y_data, z_data=z_data)
|
||||
if img is None:
|
||||
logger.warning("Image data is None; skipping update.")
|
||||
return
|
||||
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(True)
|
||||
self.main_image.set_data(img, transform=transform)
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(False)
|
||||
self.image_updated.emit()
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.update_markers_on_image_change()
|
||||
|
||||
def redraw_config_label(self):
|
||||
scan_msg = self.status_message
|
||||
if scan_msg is None:
|
||||
return
|
||||
if not self._image_config.show_config_label:
|
||||
self.config_label.setVisible(False)
|
||||
return
|
||||
|
||||
self.config_label.setOffset((-30, 1))
|
||||
self.config_label.setVisible(True)
|
||||
self.config_label.clear()
|
||||
self.config_label.addItem(self.plot_item, f"Scan: {scan_msg.scan_number}")
|
||||
self.config_label.addItem(self.plot_item, f"Scan Name: {scan_msg.scan_name}")
|
||||
if scan_msg.scan_name != "grid_scan" or self._image_config.enforce_interpolation:
|
||||
self.config_label.addItem(
|
||||
self.plot_item, f"Interpolation: {self._image_config.interpolation}"
|
||||
)
|
||||
self.config_label.addItem(
|
||||
self.plot_item, f"Oversampling: {self._image_config.oversampling_factor}x"
|
||||
)
|
||||
|
||||
def get_image_data(
|
||||
self,
|
||||
x_data: list[float] | None = None,
|
||||
y_data: list[float] | None = None,
|
||||
z_data: list[float] | None = None,
|
||||
) -> tuple[np.ndarray | None, QTransform | None]:
|
||||
"""
|
||||
Get the image data for the heatmap. Depending on the scan type, it will
|
||||
either pre-allocate the grid (grid_scan) or interpolate the data (step scan).
|
||||
|
||||
Args:
|
||||
x_data (np.ndarray): The x data.
|
||||
y_data (np.ndarray): The y data.
|
||||
z_data (np.ndarray): The z data.
|
||||
msg (messages.ScanStatusMessage): The scan status message.
|
||||
|
||||
Returns:
|
||||
tuple[np.ndarray, QTransform]: The image data and the QTransform.
|
||||
"""
|
||||
msg = self.status_message
|
||||
if x_data is None or y_data is None or z_data is None or msg is None:
|
||||
logger.warning("x, y, or z data is None; skipping update.")
|
||||
return None, None
|
||||
|
||||
if msg.scan_name == "grid_scan" and not self._image_config.enforce_interpolation:
|
||||
# We only support the grid scan mode if both scanning motors
|
||||
# are configured in the heatmap config.
|
||||
device_x = self._image_config.x_device.entry
|
||||
device_y = self._image_config.y_device.entry
|
||||
if (
|
||||
device_x in msg.request_inputs["arg_bundle"]
|
||||
and device_y in msg.request_inputs["arg_bundle"]
|
||||
):
|
||||
return self.get_grid_scan_image(z_data, msg)
|
||||
if len(z_data) < 4:
|
||||
# LinearNDInterpolator requires at least 4 points to interpolate
|
||||
return None, None
|
||||
return self.get_step_scan_image(x_data, y_data, z_data, msg)
|
||||
|
||||
def get_grid_scan_image(
|
||||
self, z_data: list[float], msg: messages.ScanStatusMessage
|
||||
) -> tuple[np.ndarray, QTransform]:
|
||||
"""
|
||||
Get the image data for a grid scan.
|
||||
Args:
|
||||
z_data (np.ndarray): The z data.
|
||||
msg (messages.ScanStatusMessage): The scan status message.
|
||||
|
||||
Returns:
|
||||
tuple[np.ndarray, QTransform]: The image data and the QTransform.
|
||||
"""
|
||||
|
||||
args = self.arg_bundle_to_dict(4, msg.request_inputs["arg_bundle"])
|
||||
|
||||
shape = (
|
||||
args[self._image_config.x_device.entry][-1],
|
||||
args[self._image_config.y_device.entry][-1],
|
||||
)
|
||||
|
||||
data = self.main_image.raw_data
|
||||
|
||||
if data is None or data.shape != shape:
|
||||
data = np.empty(shape)
|
||||
data.fill(np.nan)
|
||||
|
||||
def _get_grid_data(axis, snaked=True):
|
||||
x_grid, y_grid = np.meshgrid(axis[0], axis[1])
|
||||
if snaked:
|
||||
y_grid.T[::2] = np.fliplr(y_grid.T[::2])
|
||||
x_flat = x_grid.T.ravel()
|
||||
y_flat = y_grid.T.ravel()
|
||||
positions = np.vstack((x_flat, y_flat)).T
|
||||
return positions
|
||||
|
||||
snaked = msg.request_inputs["kwargs"].get("snaked", True)
|
||||
|
||||
# If the scan's fast axis is x, we need to swap the x and y axes
|
||||
swap = bool(msg.request_inputs["arg_bundle"][4] == self._image_config.x_device.entry)
|
||||
|
||||
# calculate the QTransform to put (0,0) at the axis origin
|
||||
scan_pos = np.asarray(msg.info["positions"])
|
||||
x_min = min(scan_pos[:, 0])
|
||||
x_max = max(scan_pos[:, 0])
|
||||
y_min = min(scan_pos[:, 1])
|
||||
y_max = max(scan_pos[:, 1])
|
||||
|
||||
x_range = x_max - x_min
|
||||
y_range = y_max - y_min
|
||||
|
||||
pixel_size_x = x_range / (shape[0] - 1)
|
||||
pixel_size_y = y_range / (shape[1] - 1)
|
||||
|
||||
transform = QTransform()
|
||||
if swap:
|
||||
transform.scale(pixel_size_y, pixel_size_x)
|
||||
transform.translate(y_min / pixel_size_y - 0.5, x_min / pixel_size_x - 0.5)
|
||||
else:
|
||||
transform.scale(pixel_size_x, pixel_size_y)
|
||||
transform.translate(x_min / pixel_size_x - 0.5, y_min / pixel_size_y - 0.5)
|
||||
|
||||
target_positions = _get_grid_data(
|
||||
(np.arange(shape[int(swap)]), np.arange(shape[int(not swap)])), snaked=snaked
|
||||
)
|
||||
|
||||
# Fill the data array with the z values
|
||||
if self._grid_index is None or self.reload:
|
||||
self._grid_index = 0
|
||||
self.reload = False
|
||||
|
||||
for i in range(self._grid_index, len(z_data)):
|
||||
data[target_positions[i, int(swap)], target_positions[i, int(not swap)]] = z_data[i]
|
||||
self._grid_index = len(z_data)
|
||||
return data, transform
|
||||
|
||||
def get_step_scan_image(
|
||||
self,
|
||||
x_data: list[float],
|
||||
y_data: list[float],
|
||||
z_data: list[float],
|
||||
msg: messages.ScanStatusMessage,
|
||||
) -> tuple[np.ndarray, QTransform]:
|
||||
"""
|
||||
Get the image data for an arbitrary step scan.
|
||||
|
||||
Args:
|
||||
x_data (list[float]): The x data.
|
||||
y_data (list[float]): The y data.
|
||||
z_data (list[float]): The z data.
|
||||
msg (messages.ScanStatusMessage): The scan status message.
|
||||
|
||||
Returns:
|
||||
tuple[np.ndarray, QTransform]: The image data and the QTransform.
|
||||
"""
|
||||
xy_data = np.column_stack((x_data, y_data))
|
||||
grid_x, grid_y, transform = self.get_image_grid(xy_data)
|
||||
|
||||
# Interpolate the z data onto the grid
|
||||
if self._image_config.interpolation == "linear":
|
||||
interp = LinearNDInterpolator(xy_data, z_data)
|
||||
elif self._image_config.interpolation == "nearest":
|
||||
interp = NearestNDInterpolator(xy_data, z_data)
|
||||
elif self._image_config.interpolation == "clough":
|
||||
interp = CloughTocher2DInterpolator(xy_data, z_data)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Interpolation method must be either 'linear', 'nearest', or 'clough'."
|
||||
)
|
||||
grid_z = interp(grid_x, grid_y)
|
||||
|
||||
return grid_z, transform
|
||||
|
||||
def get_image_grid(self, positions) -> tuple[np.ndarray, np.ndarray, QTransform]:
|
||||
"""
|
||||
LRU-cached calculation of the grid for the image. The lru cache is indexed by the scan_id
|
||||
to avoid recalculating the grid for the same scan.
|
||||
|
||||
Args:
|
||||
_scan_id (str): The scan ID. Needed for caching but not used in the function.
|
||||
|
||||
Returns:
|
||||
tuple[np.ndarray, np.ndarray, QTransform]: The grid x and y coordinates and the QTransform.
|
||||
"""
|
||||
base_width, base_height = self.estimate_image_resolution(positions)
|
||||
|
||||
# Apply oversampling factor
|
||||
factor = self._image_config.oversampling_factor
|
||||
|
||||
# Apply oversampling
|
||||
width = int(base_width * factor)
|
||||
height = int(base_height * factor)
|
||||
|
||||
# Create grid
|
||||
grid_x, grid_y = np.mgrid[
|
||||
min(positions[:, 0]) : max(positions[:, 0]) : width * 1j,
|
||||
min(positions[:, 1]) : max(positions[:, 1]) : height * 1j,
|
||||
]
|
||||
|
||||
# Calculate transform
|
||||
x_min, x_max = min(positions[:, 0]), max(positions[:, 0])
|
||||
y_min, y_max = min(positions[:, 1]), max(positions[:, 1])
|
||||
x_range = x_max - x_min
|
||||
y_range = y_max - y_min
|
||||
x_scale = x_range / width
|
||||
y_scale = y_range / height
|
||||
|
||||
transform = QTransform()
|
||||
transform.scale(x_scale, y_scale)
|
||||
transform.translate(x_min / x_scale - 0.5, y_min / y_scale - 0.5)
|
||||
|
||||
return grid_x, grid_y, transform
|
||||
|
||||
@staticmethod
|
||||
def estimate_image_resolution(coords: np.ndarray) -> tuple[int, int]:
|
||||
"""
|
||||
Estimate the number of pixels needed for the image based on the coordinates.
|
||||
|
||||
Args:
|
||||
coords (np.ndarray): The coordinates of the points.
|
||||
|
||||
Returns:
|
||||
tuple[int, int]: The estimated width and height of the image."""
|
||||
if coords.ndim != 2 or coords.shape[1] != 2:
|
||||
raise ValueError("Input must be an (m x 2) array of (x, y) coordinates.")
|
||||
|
||||
x_min, x_max = coords[:, 0].min(), coords[:, 0].max()
|
||||
y_min, y_max = coords[:, 1].min(), coords[:, 1].max()
|
||||
|
||||
tree = cKDTree(coords)
|
||||
distances, _ = tree.query(coords, k=2)
|
||||
distances = distances[:, 1] # Get the second nearest neighbor distance
|
||||
avg_distance = np.mean(distances)
|
||||
|
||||
width_extent = x_max - x_min
|
||||
height_extent = y_max - y_min
|
||||
|
||||
# Calculate the number of pixels needed based on the average distance
|
||||
width_pixels = int(np.ceil(width_extent / avg_distance))
|
||||
height_pixels = int(np.ceil(height_extent / avg_distance))
|
||||
|
||||
return max(1, width_pixels), max(1, height_pixels)
|
||||
|
||||
def arg_bundle_to_dict(self, bundle_size: int, args: list) -> dict:
|
||||
"""
|
||||
Convert the argument bundle to a dictionary.
|
||||
|
||||
Args:
|
||||
args (list): The argument bundle.
|
||||
|
||||
Returns:
|
||||
dict: The dictionary representation of the argument bundle.
|
||||
"""
|
||||
params = {}
|
||||
for cmds in partition(bundle_size, args):
|
||||
params[cmds[0]] = list(cmds[1:])
|
||||
return params
|
||||
|
||||
def _fetch_scan_data_and_access(self):
|
||||
"""
|
||||
Decide whether the widget is in live or historical mode
|
||||
and return the appropriate data dict and access key.
|
||||
|
||||
Returns:
|
||||
data_dict (dict): The data structure for the current scan.
|
||||
access_key (str): Either 'val' (live) or 'value' (history).
|
||||
"""
|
||||
if self.scan_item is None:
|
||||
# Optionally fetch the latest from history if nothing is set
|
||||
# self.update_with_scan_history(-1)
|
||||
if self.scan_item is None:
|
||||
logger.info("No scan executed so far; skipping update.")
|
||||
return "none", "none"
|
||||
|
||||
if hasattr(self.scan_item, "live_data"):
|
||||
# Live scan
|
||||
return self.scan_item.live_data, "val"
|
||||
|
||||
# Historical
|
||||
scan_devices = self.scan_item.devices
|
||||
return scan_devices, "value"
|
||||
|
||||
def reset(self):
|
||||
self._grid_index = None
|
||||
self.main_image.clear()
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.reset()
|
||||
super().reset()
|
||||
|
||||
@SafeProperty(str)
|
||||
def interpolation_method(self) -> str:
|
||||
"""
|
||||
The interpolation method used for the heatmap.
|
||||
"""
|
||||
return self._image_config.interpolation
|
||||
|
||||
@interpolation_method.setter
|
||||
def interpolation_method(self, value: str):
|
||||
"""
|
||||
Set the interpolation method for the heatmap.
|
||||
Args:
|
||||
value(str): The interpolation method, either 'linear' or 'nearest'.
|
||||
"""
|
||||
if value not in ["linear", "nearest"]:
|
||||
raise ValueError("Interpolation method must be either 'linear' or 'nearest'.")
|
||||
self._image_config.interpolation = value
|
||||
self.heatmap_property_changed.emit()
|
||||
|
||||
@SafeProperty(float)
|
||||
def oversampling_factor(self) -> float:
|
||||
"""
|
||||
The oversampling factor for grid resolution.
|
||||
"""
|
||||
return self._image_config.oversampling_factor
|
||||
|
||||
@oversampling_factor.setter
|
||||
def oversampling_factor(self, value: float):
|
||||
"""
|
||||
Set the oversampling factor for grid resolution.
|
||||
Args:
|
||||
value(float): The oversampling factor (1.0 = no oversampling, 2.0 = 2x resolution).
|
||||
"""
|
||||
if value <= 0:
|
||||
raise ValueError("Oversampling factor must be greater than 0.")
|
||||
self._image_config.oversampling_factor = value
|
||||
self.heatmap_property_changed.emit()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enforce_interpolation(self) -> bool:
|
||||
"""
|
||||
Whether to enforce interpolation even for grid scans.
|
||||
"""
|
||||
return self._image_config.enforce_interpolation
|
||||
|
||||
@enforce_interpolation.setter
|
||||
def enforce_interpolation(self, value: bool):
|
||||
"""
|
||||
Set whether to enforce interpolation even for grid scans.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to enforce interpolation.
|
||||
"""
|
||||
self._image_config.enforce_interpolation = value
|
||||
self.heatmap_property_changed.emit()
|
||||
|
||||
################################################################################
|
||||
# Post Processing
|
||||
################################################################################
|
||||
|
||||
@SafeProperty(bool)
|
||||
def fft(self) -> bool:
|
||||
"""
|
||||
Whether FFT postprocessing is enabled.
|
||||
"""
|
||||
return self.main_image.fft
|
||||
|
||||
@fft.setter
|
||||
def fft(self, enable: bool):
|
||||
"""
|
||||
Set FFT postprocessing.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to enable FFT postprocessing.
|
||||
"""
|
||||
self.main_image.fft = enable
|
||||
|
||||
@SafeProperty(bool)
|
||||
def log(self) -> bool:
|
||||
"""
|
||||
Whether logarithmic scaling is applied.
|
||||
"""
|
||||
return self.main_image.log
|
||||
|
||||
@log.setter
|
||||
def log(self, enable: bool):
|
||||
"""
|
||||
Set logarithmic scaling.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to enable logarithmic scaling.
|
||||
"""
|
||||
self.main_image.log = enable
|
||||
|
||||
@SafeProperty(int)
|
||||
def num_rotation_90(self) -> int:
|
||||
"""
|
||||
The number of 90° rotations to apply counterclockwise.
|
||||
"""
|
||||
return self.main_image.num_rotation_90
|
||||
|
||||
@num_rotation_90.setter
|
||||
def num_rotation_90(self, value: int):
|
||||
"""
|
||||
Set the number of 90° rotations to apply counterclockwise.
|
||||
|
||||
Args:
|
||||
value(int): The number of 90° rotations to apply.
|
||||
"""
|
||||
self.main_image.num_rotation_90 = value
|
||||
|
||||
@SafeProperty(bool)
|
||||
def transpose(self) -> bool:
|
||||
"""
|
||||
Whether the image is transposed.
|
||||
"""
|
||||
return self.main_image.transpose
|
||||
|
||||
@transpose.setter
|
||||
def transpose(self, enable: bool):
|
||||
"""
|
||||
Set the image to be transposed.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to enable transposing the image.
|
||||
"""
|
||||
self.main_image.transpose = enable
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
heatmap = Heatmap()
|
||||
heatmap.plot(x_name="samx", y_name="samy", z_name="bpm4i", oversampling_factor=5.0)
|
||||
heatmap.show()
|
||||
sys.exit(app.exec_())
|
||||
1
bec_widgets/widgets/plots/heatmap/heatmap.pyproject
Normal file
1
bec_widgets/widgets/plots/heatmap/heatmap.pyproject
Normal file
@@ -0,0 +1 @@
|
||||
{'files': ['heatmap.py']}
|
||||
54
bec_widgets/widgets/plots/heatmap/heatmap_plugin.py
Normal file
54
bec_widgets/widgets/plots/heatmap/heatmap_plugin.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='Heatmap' name='heatmap'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class HeatmapPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = Heatmap(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "Plot Widgets"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(Heatmap.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "heatmap"
|
||||
|
||||
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 "Heatmap"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
15
bec_widgets/widgets/plots/heatmap/register_heatmap.py
Normal file
15
bec_widgets/widgets/plots/heatmap/register_heatmap.py
Normal file
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap_plugin import HeatmapPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(HeatmapPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
188
bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py
Normal file
188
bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import (
|
||||
SignalComboBox,
|
||||
)
|
||||
|
||||
|
||||
class HeatmapSettings(SettingWidget):
|
||||
def __init__(self, parent=None, target_widget=None, popup=False, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
# This is a settings widget that depends on the target widget
|
||||
# and should mirror what is in the target widget.
|
||||
# Saving settings for this widget could result in recursively setting the target widget.
|
||||
self.setProperty("skip_settings", True)
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
if popup:
|
||||
form = UILoader().load_ui(
|
||||
os.path.join(current_path, "heatmap_settings_horizontal.ui"), self
|
||||
)
|
||||
else:
|
||||
form = UILoader().load_ui(
|
||||
os.path.join(current_path, "heatmap_settings_vertical.ui"), self
|
||||
)
|
||||
|
||||
self.target_widget = target_widget
|
||||
self.popup = popup
|
||||
|
||||
# # Scroll area
|
||||
self.scroll_area = QScrollArea(self)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShape(QFrame.NoFrame)
|
||||
self.scroll_area.setWidget(form)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.addWidget(self.scroll_area)
|
||||
self.ui = form
|
||||
|
||||
self.fetch_all_properties()
|
||||
|
||||
self.target_widget.heatmap_property_changed.connect(self.fetch_all_properties)
|
||||
if popup is False:
|
||||
self.ui.button_apply.clicked.connect(self.accept_changes)
|
||||
|
||||
self.ui.x_name.setFocus()
|
||||
|
||||
@SafeSlot()
|
||||
def fetch_all_properties(self):
|
||||
"""
|
||||
Fetch all properties from the target widget and update the settings widget.
|
||||
"""
|
||||
if not self.target_widget:
|
||||
return
|
||||
|
||||
# Get properties from the target widget
|
||||
color_map = getattr(self.target_widget, "color_map", None)
|
||||
|
||||
# Default values for device properties
|
||||
x_name, x_entry = None, None
|
||||
y_name, y_entry = None, None
|
||||
z_name, z_entry = None, None
|
||||
|
||||
# Safely access device properties
|
||||
if hasattr(self.target_widget, "_image_config") and self.target_widget._image_config:
|
||||
config = self.target_widget._image_config
|
||||
|
||||
if hasattr(config, "x_device") and config.x_device:
|
||||
x_name = getattr(config.x_device, "name", None)
|
||||
x_entry = getattr(config.x_device, "entry", None)
|
||||
|
||||
if hasattr(config, "y_device") and config.y_device:
|
||||
y_name = getattr(config.y_device, "name", None)
|
||||
y_entry = getattr(config.y_device, "entry", None)
|
||||
|
||||
if hasattr(config, "z_device") and config.z_device:
|
||||
z_name = getattr(config.z_device, "name", None)
|
||||
z_entry = getattr(config.z_device, "entry", None)
|
||||
|
||||
# Apply the properties to the settings widget
|
||||
if hasattr(self.ui, "color_map"):
|
||||
self.ui.color_map.colormap = color_map
|
||||
|
||||
if hasattr(self.ui, "x_name"):
|
||||
self.ui.x_name.set_device(x_name)
|
||||
if hasattr(self.ui, "x_entry") and x_entry is not None:
|
||||
self.ui.x_entry.set_to_obj_name(x_entry)
|
||||
|
||||
if hasattr(self.ui, "y_name"):
|
||||
self.ui.y_name.set_device(y_name)
|
||||
if hasattr(self.ui, "y_entry") and y_entry is not None:
|
||||
self.ui.y_entry.set_to_obj_name(y_entry)
|
||||
|
||||
if hasattr(self.ui, "z_name"):
|
||||
self.ui.z_name.set_device(z_name)
|
||||
if hasattr(self.ui, "z_entry") and z_entry is not None:
|
||||
self.ui.z_entry.set_to_obj_name(z_entry)
|
||||
|
||||
if hasattr(self.ui, "interpolation"):
|
||||
self.ui.interpolation.setCurrentText(
|
||||
getattr(self.target_widget._image_config, "interpolation", "linear")
|
||||
)
|
||||
if hasattr(self.ui, "oversampling_factor"):
|
||||
self.ui.oversampling_factor.setValue(
|
||||
getattr(self.target_widget._image_config, "oversampling_factor", 1.0)
|
||||
)
|
||||
if hasattr(self.ui, "enforce_interpolation"):
|
||||
self.ui.enforce_interpolation.setChecked(
|
||||
getattr(self.target_widget._image_config, "enforce_interpolation", False)
|
||||
)
|
||||
|
||||
def _get_signal_name(self, signal: SignalComboBox) -> str:
|
||||
"""
|
||||
Get the signal name from the signal combobox.
|
||||
Args:
|
||||
signal (SignalComboBox): The signal combobox to get the name from.
|
||||
Returns:
|
||||
str: The signal name.
|
||||
"""
|
||||
device_entry = signal.currentText()
|
||||
index = signal.findText(device_entry)
|
||||
if index == -1:
|
||||
return device_entry
|
||||
|
||||
device_entry_info = signal.itemData(index)
|
||||
if device_entry_info:
|
||||
device_entry = device_entry_info.get("obj_name", device_entry)
|
||||
|
||||
return device_entry if device_entry else ""
|
||||
|
||||
@SafeSlot()
|
||||
def accept_changes(self):
|
||||
"""
|
||||
Apply all properties from the settings widget to the target widget.
|
||||
"""
|
||||
x_name = self.ui.x_name.currentText()
|
||||
x_entry = self._get_signal_name(self.ui.x_entry)
|
||||
y_name = self.ui.y_name.currentText()
|
||||
y_entry = self._get_signal_name(self.ui.y_entry)
|
||||
z_name = self.ui.z_name.currentText()
|
||||
z_entry = self._get_signal_name(self.ui.z_entry)
|
||||
validate_bec = self.ui.validate_bec.checked
|
||||
color_map = self.ui.color_map.colormap
|
||||
interpolation = self.ui.interpolation.currentText()
|
||||
oversampling_factor = self.ui.oversampling_factor.value()
|
||||
enforce_interpolation = self.ui.enforce_interpolation.isChecked()
|
||||
|
||||
self.target_widget.plot(
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
color_map=color_map,
|
||||
validate_bec=validate_bec,
|
||||
interpolation=interpolation,
|
||||
oversampling_factor=oversampling_factor,
|
||||
enforce_interpolation=enforce_interpolation,
|
||||
reload=True,
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
self.ui.x_name.close()
|
||||
self.ui.x_name.deleteLater()
|
||||
self.ui.x_entry.close()
|
||||
self.ui.x_entry.deleteLater()
|
||||
self.ui.y_name.close()
|
||||
self.ui.y_name.deleteLater()
|
||||
self.ui.y_entry.close()
|
||||
self.ui.y_entry.deleteLater()
|
||||
self.ui.z_name.close()
|
||||
self.ui.z_name.deleteLater()
|
||||
self.ui.z_entry.close()
|
||||
self.ui.z_entry.deleteLater()
|
||||
self.ui.interpolation.close()
|
||||
self.ui.interpolation.deleteLater()
|
||||
@@ -0,0 +1,433 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>826</width>
|
||||
<height>300</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_4">
|
||||
<property name="title">
|
||||
<string>Interpolation</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="1" column="2" alignment="Qt::AlignmentFlag::AlignRight">
|
||||
<widget class="ToggleSwitch" name="enforce_interpolation">
|
||||
<property name="toolTip">
|
||||
<string>Use the interpolation mode even for grid scans</string>
|
||||
</property>
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLabel" name="label_9">
|
||||
<property name="toolTip">
|
||||
<string>Use the interpolation mode even for grid scans</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Enforce Interpolation</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="2">
|
||||
<widget class="QDoubleSpinBox" name="oversampling_factor">
|
||||
<property name="decimals">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>10.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QComboBox" name="interpolation">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>linear</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>nearest</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>clough</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="1">
|
||||
<widget class="QLabel" name="label_10">
|
||||
<property name="text">
|
||||
<string>Oversampling</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QLabel" name="label_8">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>50</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Interpolation Method</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>16</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item alignment="Qt::AlignmentFlag::AlignRight">
|
||||
<widget class="QGroupBox" name="groupBox_5">
|
||||
<property name="title">
|
||||
<string>General</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_5">
|
||||
<item row="2" column="1">
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>50</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Validate BEC</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3" alignment="Qt::AlignmentFlag::AlignRight">
|
||||
<widget class="ToggleSwitch" name="validate_bec"/>
|
||||
</item>
|
||||
<item row="3" column="3" alignment="Qt::AlignmentFlag::AlignRight">
|
||||
<widget class="BECColorMapWidget" name="color_map">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>50</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QLabel" name="label_11">
|
||||
<property name="text">
|
||||
<string>Colormap</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="line">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>X Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="x_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="set_first_element_as_empty" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="x_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Y Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_4">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="y_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="set_first_element_as_empty" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="y_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_3">
|
||||
<property name="title">
|
||||
<string>Z Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="z_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="set_first_element_as_empty" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="z_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>DeviceComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>device_combobox</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>SignalComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>signal_combo_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>toggle_switch</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECColorMapWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_color_map_widget</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<tabstops>
|
||||
<tabstop>x_name</tabstop>
|
||||
<tabstop>y_name</tabstop>
|
||||
<tabstop>z_name</tabstop>
|
||||
<tabstop>x_entry</tabstop>
|
||||
<tabstop>y_entry</tabstop>
|
||||
<tabstop>z_entry</tabstop>
|
||||
<tabstop>interpolation</tabstop>
|
||||
<tabstop>oversampling_factor</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>x_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>254</x>
|
||||
<y>226</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>254</x>
|
||||
<y>267</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>x_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>254</x>
|
||||
<y>226</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>254</x>
|
||||
<y>267</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>y_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>526</x>
|
||||
<y>226</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>526</x>
|
||||
<y>267</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>y_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>526</x>
|
||||
<y>226</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>526</x>
|
||||
<y>267</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>z_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>798</x>
|
||||
<y>226</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>798</x>
|
||||
<y>267</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>z_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>798</x>
|
||||
<y>226</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>798</x>
|
||||
<y>267</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
@@ -0,0 +1,374 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>305</width>
|
||||
<height>629</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>629</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="button_apply">
|
||||
<property name="text">
|
||||
<string>Apply</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECColorMapWidget" name="color_map"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>Validate BEC</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ToggleSwitch" name="validate_bec">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>X Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="x_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="set_first_element_as_empty" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="x_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Y Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_4">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="y_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="set_first_element_as_empty" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="y_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_3">
|
||||
<property name="title">
|
||||
<string>Z Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="z_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="set_first_element_as_empty" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="z_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="line">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_4">
|
||||
<property name="title">
|
||||
<string>Interpolation</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_8">
|
||||
<property name="text">
|
||||
<string>Interpolation Method</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="interpolation">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>linear</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>nearest</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_9">
|
||||
<property name="text">
|
||||
<string>Enforce Interpolation</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1" alignment="Qt::AlignmentFlag::AlignRight">
|
||||
<widget class="ToggleSwitch" name="enforce_interpolation">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_10">
|
||||
<property name="text">
|
||||
<string>Oversampling</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QDoubleSpinBox" name="oversampling_factor">
|
||||
<property name="minimum">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>10.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>DeviceComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>device_combobox</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>SignalComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>signal_combo_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>toggle_switch</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECColorMapWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_color_map_widget</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<tabstops>
|
||||
<tabstop>x_name</tabstop>
|
||||
<tabstop>y_name</tabstop>
|
||||
<tabstop>z_name</tabstop>
|
||||
<tabstop>button_apply</tabstop>
|
||||
<tabstop>x_entry</tabstop>
|
||||
<tabstop>y_entry</tabstop>
|
||||
<tabstop>z_entry</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>x_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>113</x>
|
||||
<y>178</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>110</x>
|
||||
<y>183</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>x_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>160</x>
|
||||
<y>178</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>159</x>
|
||||
<y>188</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>y_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>92</x>
|
||||
<y>278</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>92</x>
|
||||
<y>287</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>y_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>136</x>
|
||||
<y>277</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>135</x>
|
||||
<y>290</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>z_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>106</x>
|
||||
<y>376</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>112</x>
|
||||
<y>397</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>z_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>164</x>
|
||||
<y>376</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>168</x>
|
||||
<y>389</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
@@ -7,11 +7,19 @@ import numpy as np
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate, QWidget
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
|
||||
BECDeviceFilter,
|
||||
ReadoutPriority,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
|
||||
@@ -132,16 +140,125 @@ class Image(ImageBase):
|
||||
if config is None:
|
||||
config = ImageConfig(widget_class=self.__class__.__name__)
|
||||
self.gui_id = config.gui_id
|
||||
self._color_bar = None
|
||||
self.subscriptions: defaultdict[str, ImageLayerConfig] = defaultdict(
|
||||
lambda: ImageLayerConfig(monitor=None, monitor_type="auto", source="auto")
|
||||
)
|
||||
super().__init__(
|
||||
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
|
||||
)
|
||||
self._init_toolbar_image()
|
||||
self.layer_removed.connect(self._on_layer_removed)
|
||||
self.scan_id = None
|
||||
|
||||
##################################
|
||||
### Toolbar Initialization
|
||||
##################################
|
||||
|
||||
def _init_toolbar_image(self):
|
||||
"""
|
||||
Initializes the toolbar for the image widget.
|
||||
"""
|
||||
self.device_combo_box = DeviceComboBox(
|
||||
parent=self,
|
||||
device_filter=BECDeviceFilter.DEVICE,
|
||||
readout_priority_filter=[ReadoutPriority.ASYNC],
|
||||
)
|
||||
self.device_combo_box.addItem("", None)
|
||||
self.device_combo_box.setCurrentText("")
|
||||
self.device_combo_box.setToolTip("Select Device")
|
||||
self.device_combo_box.setFixedWidth(150)
|
||||
self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box))
|
||||
|
||||
self.dim_combo_box = QComboBox(parent=self)
|
||||
self.dim_combo_box.addItems(["auto", "1d", "2d"])
|
||||
self.dim_combo_box.setCurrentText("auto")
|
||||
self.dim_combo_box.setToolTip("Monitor Dimension")
|
||||
self.dim_combo_box.setFixedWidth(100)
|
||||
self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box))
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"image_device_combo", WidgetAction(widget=self.device_combo_box, adjust_size=False)
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"image_dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False)
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("monitor_selection", self.toolbar.components)
|
||||
bundle.add_action("image_device_combo")
|
||||
bundle.add_action("image_dim_combo")
|
||||
|
||||
self.toolbar.add_bundle(bundle)
|
||||
self.device_combo_box.currentTextChanged.connect(self.connect_monitor)
|
||||
self.dim_combo_box.currentTextChanged.connect(self.connect_monitor)
|
||||
|
||||
crosshair_bundle = self.toolbar.get_bundle("image_crosshair")
|
||||
crosshair_bundle.add_action("image_autorange")
|
||||
crosshair_bundle.add_action("image_colorbar_switch")
|
||||
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"monitor_selection",
|
||||
"plot_export",
|
||||
"mouse_interaction",
|
||||
"image_crosshair",
|
||||
"image_processing",
|
||||
"axis_popup",
|
||||
]
|
||||
)
|
||||
|
||||
QTimer.singleShot(0, self._adjust_and_connect)
|
||||
|
||||
def _adjust_and_connect(self):
|
||||
"""
|
||||
Adjust the size of the device combo box and populate it with preview signals.
|
||||
Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing.
|
||||
"""
|
||||
self._populate_preview_signals()
|
||||
self._reverse_device_items()
|
||||
self.device_combo_box.setCurrentText("") # set again default to empty string
|
||||
|
||||
def _populate_preview_signals(self) -> None:
|
||||
"""
|
||||
Populate the device combo box with preview-signal devices in the
|
||||
format '<device>_<signal>' and store the tuple(device, signal) in
|
||||
the item's userData for later use.
|
||||
"""
|
||||
preview_signals = self.client.device_manager.get_bec_signals("PreviewSignal")
|
||||
for device, signal, signal_config in preview_signals:
|
||||
label = signal_config.get("obj_name", f"{device}_{signal}")
|
||||
self.device_combo_box.addItem(label, (device, signal, signal_config))
|
||||
|
||||
def _reverse_device_items(self) -> None:
|
||||
"""
|
||||
Reverse the current order of items in the device combo box while
|
||||
keeping their userData and restoring the previous selection.
|
||||
"""
|
||||
current_text = self.device_combo_box.currentText()
|
||||
items = [
|
||||
(self.device_combo_box.itemText(i), self.device_combo_box.itemData(i))
|
||||
for i in range(self.device_combo_box.count())
|
||||
]
|
||||
self.device_combo_box.clear()
|
||||
for text, data in reversed(items):
|
||||
self.device_combo_box.addItem(text, data)
|
||||
if current_text:
|
||||
self.device_combo_box.setCurrentText(current_text)
|
||||
|
||||
@SafeSlot()
|
||||
def connect_monitor(self, *args, **kwargs):
|
||||
"""
|
||||
Connect the target widget to the selected monitor based on the current device and dimension.
|
||||
|
||||
If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor.
|
||||
"""
|
||||
dim = self.dim_combo_box.currentText()
|
||||
data = self.device_combo_box.currentData()
|
||||
|
||||
if isinstance(data, tuple):
|
||||
self.image(monitor=data, monitor_type="auto")
|
||||
else:
|
||||
self.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)
|
||||
|
||||
################################################################################
|
||||
# Data Acquisition
|
||||
|
||||
@@ -227,35 +344,21 @@ class Image(ImageBase):
|
||||
"""
|
||||
config = self.subscriptions["main"]
|
||||
if config.monitor is not None:
|
||||
for combo in (
|
||||
self.selection_bundle.device_combo_box,
|
||||
self.selection_bundle.dim_combo_box,
|
||||
):
|
||||
for combo in (self.device_combo_box, self.dim_combo_box):
|
||||
combo.blockSignals(True)
|
||||
if isinstance(config.monitor, tuple):
|
||||
self.selection_bundle.device_combo_box.setCurrentText(
|
||||
f"{config.monitor[0]}_{config.monitor[1]}"
|
||||
)
|
||||
self.device_combo_box.setCurrentText(f"{config.monitor[0]}_{config.monitor[1]}")
|
||||
else:
|
||||
self.selection_bundle.device_combo_box.setCurrentText(config.monitor)
|
||||
self.selection_bundle.dim_combo_box.setCurrentText(config.monitor_type)
|
||||
for combo in (
|
||||
self.selection_bundle.device_combo_box,
|
||||
self.selection_bundle.dim_combo_box,
|
||||
):
|
||||
self.device_combo_box.setCurrentText(config.monitor)
|
||||
self.dim_combo_box.setCurrentText(config.monitor_type)
|
||||
for combo in (self.device_combo_box, self.dim_combo_box):
|
||||
combo.blockSignals(False)
|
||||
else:
|
||||
for combo in (
|
||||
self.selection_bundle.device_combo_box,
|
||||
self.selection_bundle.dim_combo_box,
|
||||
):
|
||||
for combo in (self.device_combo_box, self.dim_combo_box):
|
||||
combo.blockSignals(True)
|
||||
self.selection_bundle.device_combo_box.setCurrentText("")
|
||||
self.selection_bundle.dim_combo_box.setCurrentText("auto")
|
||||
for combo in (
|
||||
self.selection_bundle.device_combo_box,
|
||||
self.selection_bundle.dim_combo_box,
|
||||
):
|
||||
self.device_combo_box.setCurrentText("")
|
||||
self.dim_combo_box.setCurrentText("auto")
|
||||
for combo in (self.device_combo_box, self.dim_combo_box):
|
||||
combo.blockSignals(False)
|
||||
|
||||
################################################################################
|
||||
@@ -462,6 +565,8 @@ class Image(ImageBase):
|
||||
self.main_image.clear()
|
||||
self.main_image.buffer = []
|
||||
self.main_image.max_len = 0
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.reset()
|
||||
image_buffer = self.adjust_image_buffer(self.main_image, data)
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(True)
|
||||
@@ -554,8 +659,10 @@ class Image(ImageBase):
|
||||
self.subscriptions.clear()
|
||||
|
||||
# Toolbar cleanup
|
||||
self.toolbar.widgets["monitor"].widget.close()
|
||||
self.toolbar.widgets["monitor"].widget.deleteLater()
|
||||
self.device_combo_box.close()
|
||||
self.device_combo_box.deleteLater()
|
||||
self.dim_combo_box.close()
|
||||
self.dim_combo_box.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
@@ -570,10 +677,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
ml = QHBoxLayout(win)
|
||||
|
||||
image_popup = Image(popups=True)
|
||||
image_side_panel = Image(popups=False)
|
||||
# image_side_panel = Image(popups=False)
|
||||
|
||||
ml.addWidget(image_popup)
|
||||
ml.addWidget(image_side_panel)
|
||||
# ml.addWidget(image_side_panel)
|
||||
|
||||
win.resize(1500, 800)
|
||||
win.show()
|
||||
|
||||
@@ -12,14 +12,19 @@ from qtpy.QtWidgets import QDialog, QVBoxLayout
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.side_panel import SidePanel
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
from bec_widgets.widgets.plots.image.image_roi_plot import ImageROIPlot
|
||||
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
|
||||
from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
|
||||
MonitorSelectionToolbarBundle,
|
||||
from bec_widgets.widgets.plots.image.toolbar_components.image_base_actions import (
|
||||
ImageColorbarConnection,
|
||||
ImageProcessingConnection,
|
||||
ImageRoiConnection,
|
||||
image_autorange,
|
||||
image_colorbar,
|
||||
image_processing,
|
||||
image_roi_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.image.toolbar_bundles.processing import ImageProcessingToolbarBundle
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||
BaseROI,
|
||||
@@ -255,7 +260,9 @@ class ImageBase(PlotBase):
|
||||
"""
|
||||
self.x_roi = None
|
||||
self.y_roi = None
|
||||
self._color_bar = None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.roi_controller = ROIController(colormap="viridis")
|
||||
|
||||
# Headless controller keeps the canonical list.
|
||||
@@ -264,6 +271,7 @@ class ImageBase(PlotBase):
|
||||
self, plot_item=self.plot_item, on_add=self.layer_added, on_remove=self.layer_removed
|
||||
)
|
||||
self.layer_manager.add("main")
|
||||
self._init_image_base_toolbar()
|
||||
|
||||
self.autorange = True
|
||||
self.autorange_mode = "mean"
|
||||
@@ -274,6 +282,16 @@ class ImageBase(PlotBase):
|
||||
# Refresh theme for ROI plots
|
||||
self._update_theme()
|
||||
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"image_crosshair",
|
||||
"mouse_interaction",
|
||||
"image_autorange",
|
||||
"image_colorbar",
|
||||
"image_processing",
|
||||
]
|
||||
)
|
||||
|
||||
################################################################################
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
@@ -318,135 +336,66 @@ class ImageBase(PlotBase):
|
||||
"""
|
||||
return list(self.layer_manager.layers.values())
|
||||
|
||||
def _init_toolbar(self):
|
||||
def _init_image_base_toolbar(self):
|
||||
|
||||
try:
|
||||
# add to the first position
|
||||
self.selection_bundle = MonitorSelectionToolbarBundle(
|
||||
bundle_id="selection", target_widget=self
|
||||
)
|
||||
self.toolbar.add_bundle(self.selection_bundle, self)
|
||||
|
||||
super()._init_toolbar()
|
||||
|
||||
# Image specific changes to PlotBase toolbar
|
||||
self.toolbar.widgets["reset_legend"].action.setVisible(False)
|
||||
|
||||
# ROI Bundle replacement with switchable crosshair
|
||||
self.toolbar.remove_bundle("roi")
|
||||
crosshair = MaterialIconAction(
|
||||
icon_name="point_scan", tooltip="Show Crosshair", checkable=True, parent=self
|
||||
)
|
||||
crosshair_roi = MaterialIconAction(
|
||||
icon_name="my_location",
|
||||
tooltip="Show Crosshair with ROI plots",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
crosshair_roi.action.toggled.connect(self.toggle_roi_panels)
|
||||
crosshair.action.toggled.connect(self.toggle_crosshair)
|
||||
switch_crosshair = SwitchableToolBarAction(
|
||||
actions={"crosshair_simple": crosshair, "crosshair_roi": crosshair_roi},
|
||||
initial_action="crosshair_simple",
|
||||
tooltip="Crosshair",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action(
|
||||
action_id="switch_crosshair", action=switch_crosshair, target_widget=self
|
||||
# ROI Actions
|
||||
self.toolbar.add_bundle(image_roi_bundle(self.toolbar.components))
|
||||
self.toolbar.connect_bundle(
|
||||
"image_base", ImageRoiConnection(self.toolbar.components, target_widget=self)
|
||||
)
|
||||
|
||||
# Lock aspect ratio button
|
||||
self.lock_aspect_ratio_action = MaterialIconAction(
|
||||
# Lock Aspect Ratio Action
|
||||
lock_aspect_ratio_action = MaterialIconAction(
|
||||
icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="mouse_interaction",
|
||||
action_id="lock_aspect_ratio",
|
||||
action=self.lock_aspect_ratio_action,
|
||||
target_widget=self,
|
||||
)
|
||||
self.lock_aspect_ratio_action.action.toggled.connect(
|
||||
self.toolbar.components.add_safe("lock_aspect_ratio", lock_aspect_ratio_action)
|
||||
self.toolbar.get_bundle("mouse_interaction").add_action("lock_aspect_ratio")
|
||||
lock_aspect_ratio_action.action.toggled.connect(
|
||||
lambda checked: self.setProperty("lock_aspect_ratio", checked)
|
||||
)
|
||||
self.lock_aspect_ratio_action.action.setChecked(True)
|
||||
lock_aspect_ratio_action.action.setChecked(True)
|
||||
|
||||
self._init_autorange_action()
|
||||
self._init_colorbar_action()
|
||||
|
||||
# Processing Bundle
|
||||
self.processing_bundle = ImageProcessingToolbarBundle(
|
||||
bundle_id="processing", target_widget=self
|
||||
# Autorange Action
|
||||
self.toolbar.add_bundle(image_autorange(self.toolbar.components))
|
||||
action = self.toolbar.components.get_action("image_autorange")
|
||||
action.actions["mean"].action.toggled.connect(
|
||||
lambda checked: self.toggle_autorange(checked, mode="mean")
|
||||
)
|
||||
action.actions["max"].action.toggled.connect(
|
||||
lambda checked: self.toggle_autorange(checked, mode="max")
|
||||
)
|
||||
|
||||
# Colorbar Actions
|
||||
self.toolbar.add_bundle(image_colorbar(self.toolbar.components))
|
||||
|
||||
self.toolbar.connect_bundle(
|
||||
"image_colorbar",
|
||||
ImageColorbarConnection(self.toolbar.components, target_widget=self),
|
||||
)
|
||||
|
||||
# Image Processing Actions
|
||||
self.toolbar.add_bundle(image_processing(self.toolbar.components))
|
||||
self.toolbar.connect_bundle(
|
||||
"image_processing",
|
||||
ImageProcessingConnection(self.toolbar.components, target_widget=self),
|
||||
)
|
||||
|
||||
# ROI Manager Action
|
||||
self.toolbar.components.add_safe(
|
||||
"roi_mgr",
|
||||
MaterialIconAction(
|
||||
icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.get_bundle("axis_popup").add_action("roi_mgr")
|
||||
self.toolbar.components.get_action("roi_mgr").action.triggered.connect(
|
||||
self.show_roi_manager_popup
|
||||
)
|
||||
self.toolbar.add_bundle(self.processing_bundle, target_widget=self)
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing toolbar: {e}")
|
||||
|
||||
def _init_autorange_action(self):
|
||||
|
||||
self.autorange_mean_action = MaterialIconAction(
|
||||
icon_name="hdr_auto", tooltip="Enable Auto Range (Mean)", checkable=True, parent=self
|
||||
)
|
||||
self.autorange_max_action = MaterialIconAction(
|
||||
icon_name="hdr_auto",
|
||||
tooltip="Enable Auto Range (Max)",
|
||||
checkable=True,
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.autorange_switch = SwitchableToolBarAction(
|
||||
actions={
|
||||
"auto_range_mean": self.autorange_mean_action,
|
||||
"auto_range_max": self.autorange_max_action,
|
||||
},
|
||||
initial_action="auto_range_mean",
|
||||
tooltip="Enable Auto Range",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.toolbar.add_action(
|
||||
action_id="autorange_image", action=self.autorange_switch, target_widget=self
|
||||
)
|
||||
|
||||
self.autorange_mean_action.action.toggled.connect(
|
||||
lambda checked: self.toggle_autorange(checked, mode="mean")
|
||||
)
|
||||
self.autorange_max_action.action.toggled.connect(
|
||||
lambda checked: self.toggle_autorange(checked, mode="max")
|
||||
)
|
||||
|
||||
def _init_colorbar_action(self):
|
||||
self.full_colorbar_action = MaterialIconAction(
|
||||
icon_name="edgesensor_low", tooltip="Enable Full Colorbar", checkable=True, parent=self
|
||||
)
|
||||
self.simple_colorbar_action = MaterialIconAction(
|
||||
icon_name="smartphone", tooltip="Enable Simple Colorbar", checkable=True, parent=self
|
||||
)
|
||||
|
||||
self.colorbar_switch = SwitchableToolBarAction(
|
||||
actions={
|
||||
"full_colorbar": self.full_colorbar_action,
|
||||
"simple_colorbar": self.simple_colorbar_action,
|
||||
},
|
||||
initial_action="full_colorbar",
|
||||
tooltip="Enable Full Colorbar",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.toolbar.add_action(
|
||||
action_id="switch_colorbar", action=self.colorbar_switch, target_widget=self
|
||||
)
|
||||
|
||||
self.simple_colorbar_action.action.toggled.connect(
|
||||
lambda checked: self.enable_colorbar(checked, style="simple")
|
||||
)
|
||||
self.full_colorbar_action.action.toggled.connect(
|
||||
lambda checked: self.enable_colorbar(checked, style="full")
|
||||
)
|
||||
|
||||
########################################
|
||||
# ROI Gui Manager
|
||||
def add_side_menus(self):
|
||||
@@ -461,20 +410,8 @@ class ImageBase(PlotBase):
|
||||
title="ROI Manager",
|
||||
)
|
||||
|
||||
def add_popups(self):
|
||||
super().add_popups() # keep Axis Settings
|
||||
|
||||
roi_action = MaterialIconAction(
|
||||
icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self
|
||||
)
|
||||
# self.popup_bundle.add_action("roi_mgr", roi_action)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="popup_bundle", action_id="roi_mgr", action=roi_action, target_widget=self
|
||||
)
|
||||
self.toolbar.widgets["roi_mgr"].action.triggered.connect(self.show_roi_manager_popup)
|
||||
|
||||
def show_roi_manager_popup(self):
|
||||
roi_action = self.toolbar.widgets["roi_mgr"].action
|
||||
roi_action = self.toolbar.components.get_action("roi_mgr").action
|
||||
if self.roi_manager_dialog is None or not self.roi_manager_dialog.isVisible():
|
||||
self.roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
|
||||
self.roi_manager_dialog = QDialog(modal=False)
|
||||
@@ -494,7 +431,7 @@ class ImageBase(PlotBase):
|
||||
self.roi_manager_dialog.close()
|
||||
self.roi_manager_dialog.deleteLater()
|
||||
self.roi_manager_dialog = None
|
||||
self.toolbar.widgets["roi_mgr"].action.setChecked(False)
|
||||
self.toolbar.components.get_action("roi_mgr").action.setChecked(False)
|
||||
|
||||
def enable_colorbar(
|
||||
self,
|
||||
@@ -518,12 +455,11 @@ class ImageBase(PlotBase):
|
||||
self.plot_widget.removeItem(self._color_bar)
|
||||
self._color_bar = None
|
||||
|
||||
def disable_autorange():
|
||||
print("Disabling autorange")
|
||||
self.setProperty("autorange", False)
|
||||
|
||||
if style == "simple":
|
||||
|
||||
def disable_autorange():
|
||||
print("Disabling autorange")
|
||||
self.setProperty("autorange", False)
|
||||
|
||||
self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
|
||||
self._color_bar.setImageItem(self.layer_manager["main"].image)
|
||||
self._color_bar.sigLevelsChangeFinished.connect(disable_autorange)
|
||||
@@ -532,9 +468,7 @@ class ImageBase(PlotBase):
|
||||
self._color_bar = pg.HistogramLUTItem()
|
||||
self._color_bar.setImageItem(self.layer_manager["main"].image)
|
||||
self._color_bar.gradient.loadPreset(self.config.color_map)
|
||||
self._color_bar.sigLevelsChanged.connect(
|
||||
lambda: self.setProperty("autorange", False)
|
||||
)
|
||||
self._color_bar.sigLevelsChanged.connect(disable_autorange)
|
||||
|
||||
self.plot_widget.addItem(self._color_bar, row=0, col=1)
|
||||
self.config.color_bar = style
|
||||
@@ -633,7 +567,9 @@ class ImageBase(PlotBase):
|
||||
"""
|
||||
# Create ROI plot widgets
|
||||
self.x_roi = ImageROIPlot(parent=self)
|
||||
self.x_roi.plot_item.setXLink(self.plot_item)
|
||||
self.y_roi = ImageROIPlot(parent=self)
|
||||
self.y_roi.plot_item.setYLink(self.plot_item)
|
||||
self.x_roi.apply_theme("dark")
|
||||
self.y_roi.apply_theme("dark")
|
||||
|
||||
@@ -704,7 +640,8 @@ class ImageBase(PlotBase):
|
||||
else:
|
||||
x = coordinates[1]
|
||||
y = coordinates[2]
|
||||
image = self.layer_manager["main"].image.image
|
||||
image_item = self.layer_manager["main"].image
|
||||
image = image_item.image
|
||||
if image is None:
|
||||
return
|
||||
max_row, max_col = image.shape[0] - 1, image.shape[1] - 1
|
||||
@@ -713,14 +650,27 @@ class ImageBase(PlotBase):
|
||||
return
|
||||
# Horizontal slice
|
||||
h_slice = image[:, col]
|
||||
x_axis = np.arange(h_slice.shape[0])
|
||||
x_pixel_indices = np.arange(h_slice.shape[0])
|
||||
if image_item.image_transform is None:
|
||||
h_world_x = np.arange(h_slice.shape[0])
|
||||
else:
|
||||
h_world_x = [
|
||||
image_item.image_transform.map(xi + 0.5, col + 0.5)[0] for xi in x_pixel_indices
|
||||
]
|
||||
self.x_roi.plot_item.clear()
|
||||
self.x_roi.plot_item.plot(x_axis, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3))
|
||||
self.x_roi.plot_item.plot(h_world_x, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3))
|
||||
|
||||
# Vertical slice
|
||||
v_slice = image[row, :]
|
||||
y_axis = np.arange(v_slice.shape[0])
|
||||
y_pixel_indices = np.arange(v_slice.shape[0])
|
||||
if image_item.image_transform is None:
|
||||
v_world_y = np.arange(v_slice.shape[0])
|
||||
else:
|
||||
v_world_y = [
|
||||
image_item.image_transform.map(row + 0.5, yi + 0.5)[1] for yi in y_pixel_indices
|
||||
]
|
||||
self.y_roi.plot_item.clear()
|
||||
self.y_roi.plot_item.plot(v_slice, y_axis, pen=pg.mkPen(self.y_roi.curve_color, width=3))
|
||||
self.y_roi.plot_item.plot(v_slice, v_world_y, pen=pg.mkPen(self.y_roi.curve_color, width=3))
|
||||
|
||||
################################################################################
|
||||
# Widget Specific Properties
|
||||
@@ -827,6 +777,9 @@ class ImageBase(PlotBase):
|
||||
Args:
|
||||
value(tuple | list | QPointF): The range of values to set.
|
||||
"""
|
||||
self._set_vrange(value, disable_autorange=True)
|
||||
|
||||
def _set_vrange(self, value: tuple | list | QPointF, disable_autorange: bool = True):
|
||||
if isinstance(value, (tuple, list)):
|
||||
value = self._tuple_to_qpointf(value)
|
||||
|
||||
@@ -835,7 +788,7 @@ class ImageBase(PlotBase):
|
||||
for layer in self.layer_manager:
|
||||
if not layer.sync.v_range:
|
||||
continue
|
||||
layer.image.v_range = (vmin, vmax)
|
||||
layer.image.set_v_range((vmin, vmax), disable_autorange=disable_autorange)
|
||||
|
||||
# propagate to colorbar if exists
|
||||
if self._color_bar:
|
||||
@@ -845,7 +798,7 @@ class ImageBase(PlotBase):
|
||||
self._color_bar.setLevels(min=vmin, max=vmax)
|
||||
self._color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
|
||||
|
||||
self.autorange_switch.set_state_all(False)
|
||||
# self.toolbar.components.get_action("image_autorange").set_state_all(False)
|
||||
|
||||
@property
|
||||
def v_min(self) -> float:
|
||||
@@ -919,14 +872,26 @@ class ImageBase(PlotBase):
|
||||
Args:
|
||||
enabled(bool): Whether to enable autorange.
|
||||
"""
|
||||
self._set_autorange(enabled)
|
||||
|
||||
def _set_autorange(self, enabled: bool, sync: bool = True):
|
||||
"""
|
||||
Set the autorange for all layers.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable autorange.
|
||||
sync(bool): Whether to synchronize the autorange state across all layers.
|
||||
"""
|
||||
for layer in self.layer_manager:
|
||||
if not layer.sync.autorange:
|
||||
continue
|
||||
layer.image.autorange = enabled
|
||||
if enabled and layer.image.raw_data is not None:
|
||||
layer.image.apply_autorange()
|
||||
# if sync:
|
||||
self._sync_colorbar_levels()
|
||||
self._sync_autorange_switch()
|
||||
print(f"Autorange set to {enabled}")
|
||||
|
||||
@SafeProperty(str)
|
||||
def autorange_mode(self) -> str:
|
||||
@@ -969,7 +934,6 @@ class ImageBase(PlotBase):
|
||||
"""
|
||||
if not self.layer_manager:
|
||||
return
|
||||
|
||||
for layer in self.layer_manager:
|
||||
if layer.sync.autorange:
|
||||
layer.image.autorange = enabled
|
||||
@@ -981,19 +945,16 @@ class ImageBase(PlotBase):
|
||||
# We only need to apply autorange if we enabled it
|
||||
layer.image.apply_autorange()
|
||||
|
||||
if enabled:
|
||||
self._sync_colorbar_levels()
|
||||
self._sync_colorbar_levels()
|
||||
|
||||
def _sync_autorange_switch(self):
|
||||
"""
|
||||
Synchronize the autorange switch with the current autorange state and mode if changed from outside.
|
||||
"""
|
||||
self.autorange_switch.block_all_signals(True)
|
||||
self.autorange_switch.set_default_action(
|
||||
f"auto_range_{self.layer_manager['main'].image.autorange_mode}"
|
||||
)
|
||||
self.autorange_switch.set_state_all(self.layer_manager["main"].image.autorange)
|
||||
self.autorange_switch.block_all_signals(False)
|
||||
action: SwitchableToolBarAction = self.toolbar.components.get_action("image_autorange") # type: ignore
|
||||
with action.signal_blocker():
|
||||
action.set_default_action(f"{self.layer_manager['main'].image.autorange_mode}")
|
||||
action.set_state_all(self.layer_manager["main"].image.autorange)
|
||||
|
||||
def _sync_colorbar_levels(self):
|
||||
"""Immediately propagate current levels to the active colorbar."""
|
||||
@@ -1009,20 +970,22 @@ class ImageBase(PlotBase):
|
||||
total_vrange = (min(total_vrange[0], img.v_min), max(total_vrange[1], img.v_max))
|
||||
|
||||
self._color_bar.blockSignals(True)
|
||||
self.v_range = total_vrange # type: ignore
|
||||
self._set_vrange(total_vrange, disable_autorange=False) # type: ignore
|
||||
self._color_bar.blockSignals(False)
|
||||
|
||||
def _sync_colorbar_actions(self):
|
||||
"""
|
||||
Synchronize the colorbar actions with the current colorbar state.
|
||||
"""
|
||||
self.colorbar_switch.block_all_signals(True)
|
||||
if self._color_bar is not None:
|
||||
self.colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar")
|
||||
self.colorbar_switch.set_state_all(True)
|
||||
else:
|
||||
self.colorbar_switch.set_state_all(False)
|
||||
self.colorbar_switch.block_all_signals(False)
|
||||
colorbar_switch: SwitchableToolBarAction = self.toolbar.components.get_action(
|
||||
"image_colorbar_switch"
|
||||
)
|
||||
with colorbar_switch.signal_blocker():
|
||||
if self._color_bar is not None:
|
||||
colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar")
|
||||
colorbar_switch.set_state_all(True)
|
||||
else:
|
||||
colorbar_switch.set_state_all(False)
|
||||
|
||||
@staticmethod
|
||||
def cleanup_histogram_lut_item(histogram_lut_item: pg.HistogramLUTItem):
|
||||
@@ -1044,6 +1007,7 @@ class ImageBase(PlotBase):
|
||||
"""
|
||||
Cleanup the widget.
|
||||
"""
|
||||
self.toolbar.cleanup()
|
||||
|
||||
# Remove all ROIs
|
||||
rois = self.rois
|
||||
|
||||
@@ -7,6 +7,7 @@ import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QTransform
|
||||
|
||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
||||
from bec_widgets.widgets.plots.image.image_processor import (
|
||||
@@ -85,6 +86,7 @@ class ImageItem(BECConnector, pg.ImageItem):
|
||||
self.set_parent(parent_image)
|
||||
else:
|
||||
self.parent_image = None
|
||||
self.image_transform = None
|
||||
super().__init__(config=config, gui_id=gui_id, **kwargs)
|
||||
|
||||
self.raw_data = None
|
||||
@@ -100,8 +102,9 @@ class ImageItem(BECConnector, pg.ImageItem):
|
||||
def parent(self):
|
||||
return self.parent_image
|
||||
|
||||
def set_data(self, data: np.ndarray):
|
||||
def set_data(self, data: np.ndarray, transform: QTransform | None = None):
|
||||
self.raw_data = data
|
||||
self.image_transform = transform
|
||||
self._process_image()
|
||||
|
||||
################################################################################
|
||||
@@ -210,12 +213,19 @@ class ImageItem(BECConnector, pg.ImageItem):
|
||||
"""
|
||||
Reprocess the current raw data and update the image display.
|
||||
"""
|
||||
if self.raw_data is not None:
|
||||
autorange = self.config.autorange
|
||||
self._image_processor.set_config(self.config.processing)
|
||||
processed_data = self._image_processor.process_image(self.raw_data)
|
||||
self.setImage(processed_data, autoLevels=False)
|
||||
self.autorange = autorange
|
||||
if self.raw_data is None:
|
||||
return
|
||||
|
||||
if np.all(np.isnan(self.raw_data)):
|
||||
return
|
||||
|
||||
autorange = self.config.autorange
|
||||
self._image_processor.set_config(self.config.processing)
|
||||
processed_data = self._image_processor.process_image(self.raw_data)
|
||||
self.setImage(processed_data, autoLevels=False)
|
||||
if self.image_transform is not None:
|
||||
self.setTransform(self.image_transform)
|
||||
self.autorange = autorange
|
||||
|
||||
@property
|
||||
def fft(self) -> bool:
|
||||
|
||||
@@ -27,7 +27,12 @@ class ImageStats:
|
||||
Returns:
|
||||
ImageStats: The statistics of the image data.
|
||||
"""
|
||||
return cls(maximum=np.max(data), minimum=np.min(data), mean=np.mean(data), std=np.std(data))
|
||||
return cls(
|
||||
maximum=np.nanmax(data),
|
||||
minimum=np.nanmin(data),
|
||||
mean=np.nanmean(data),
|
||||
std=np.nanstd(data),
|
||||
)
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
@@ -81,7 +86,7 @@ class ImageProcessor(QObject):
|
||||
Returns:
|
||||
np.ndarray: The processed data.
|
||||
"""
|
||||
return np.abs(np.fft.fftshift(np.fft.fft2(data)))
|
||||
return np.abs(np.fft.fftshift(np.fft.fft2(np.nan_to_num(data))))
|
||||
|
||||
def rotation(self, data: np.ndarray, rotate_90: int) -> np.ndarray:
|
||||
"""
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QEvent, Qt
|
||||
from qtpy.QtGui import QColor
|
||||
@@ -20,7 +21,9 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils import BECDispatcher, ConnectionConfig
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||
BaseROI,
|
||||
CircularROI,
|
||||
@@ -37,6 +40,9 @@ if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ROILockButton(QToolButton):
|
||||
"""Keeps its icon and checked state in sync with a single ROI."""
|
||||
|
||||
@@ -121,20 +127,33 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
|
||||
# --------------------------------------------------------------------- UI
|
||||
def _init_toolbar(self):
|
||||
tb = ModularToolBar(self, self, orientation="horizontal")
|
||||
tb = self.toolbar = ModularToolBar(self, orientation="horizontal")
|
||||
self._draw_actions: dict[str, MaterialIconAction] = {}
|
||||
# --- ROI draw actions (toggleable) ---
|
||||
self.add_rect_action = MaterialIconAction("add_box", "Add Rect ROI", True, self)
|
||||
tb.add_action("Add Rect ROI", self.add_rect_action, self)
|
||||
self._draw_actions["rect"] = self.add_rect_action
|
||||
self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self)
|
||||
tb.add_action("Add Circle ROI", self.add_circle_action, self)
|
||||
self._draw_actions["circle"] = self.add_circle_action
|
||||
# --- Ellipse ROI draw action ---
|
||||
self.add_ellipse_action = MaterialIconAction("vignette", "Add Ellipse ROI", True, self)
|
||||
tb.add_action("Add Ellipse ROI", self.add_ellipse_action, self)
|
||||
self._draw_actions["ellipse"] = self.add_ellipse_action
|
||||
|
||||
tb.components.add_safe(
|
||||
"roi_rectangle",
|
||||
MaterialIconAction("add_box", "Add Rect ROI", checkable=True, parent=self),
|
||||
)
|
||||
tb.components.add_safe(
|
||||
"roi_circle",
|
||||
MaterialIconAction("add_circle", "Add Circle ROI", checkable=True, parent=self),
|
||||
)
|
||||
tb.components.add_safe(
|
||||
"roi_ellipse",
|
||||
MaterialIconAction("vignette", "Add Ellipse ROI", checkable=True, parent=self),
|
||||
)
|
||||
bundle = ToolbarBundle("roi_draw", tb.components)
|
||||
bundle.add_action("roi_rectangle")
|
||||
bundle.add_action("roi_circle")
|
||||
bundle.add_action("roi_ellipse")
|
||||
tb.add_bundle(bundle)
|
||||
|
||||
self._draw_actions = {
|
||||
"rect": tb.components.get_action("roi_rectangle"),
|
||||
"circle": tb.components.get_action("roi_circle"),
|
||||
"ellipse": tb.components.get_action("roi_ellipse"),
|
||||
}
|
||||
for mode, act in self._draw_actions.items():
|
||||
act.action.toggled.connect(lambda on, m=mode: self._on_draw_action_toggled(m, on))
|
||||
|
||||
@@ -142,7 +161,7 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
self.expand_toggle = MaterialIconAction(
|
||||
"unfold_more", "Expand/Collapse", checkable=True, parent=self # icon when collapsed
|
||||
)
|
||||
tb.add_action("Expand/Collapse", self.expand_toggle, self)
|
||||
tb.components.add_safe("expand_toggle", self.expand_toggle)
|
||||
|
||||
def _exp_toggled(on: bool):
|
||||
if on:
|
||||
@@ -163,7 +182,7 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
self.lock_all_action = MaterialIconAction(
|
||||
"lock_open_right", "Lock/Unlock all ROIs", checkable=True, parent=self
|
||||
)
|
||||
tb.add_action("Lock/Unlock all ROIs", self.lock_all_action, self)
|
||||
tb.components.add_safe("lock_unlock_all", self.lock_all_action)
|
||||
|
||||
def _lock_all(checked: bool):
|
||||
# checked -> everything locked (movable = False)
|
||||
@@ -178,12 +197,23 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
|
||||
# colormap widget
|
||||
self.cmap = BECColorMapWidget(cmap=self.controller.colormap)
|
||||
tb.addWidget(QWidget()) # spacer
|
||||
tb.addWidget(self.cmap)
|
||||
|
||||
tb.components.add_safe("roi_tree_spacer", WidgetAction(widget=QWidget()))
|
||||
tb.components.add_safe("roi_tree_cmap", WidgetAction(widget=self.cmap))
|
||||
|
||||
self.cmap.colormap_changed_signal.connect(self.controller.set_colormap)
|
||||
self.layout.addWidget(tb)
|
||||
self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap))
|
||||
|
||||
bundle = ToolbarBundle("roi_tools", tb.components)
|
||||
bundle.add_action("expand_toggle")
|
||||
bundle.add_action("lock_unlock_all")
|
||||
bundle.add_action("roi_tree_spacer")
|
||||
bundle.add_action("roi_tree_cmap")
|
||||
tb.add_bundle(bundle)
|
||||
|
||||
tb.show_bundles(["roi_draw", "roi_tools"])
|
||||
|
||||
# ROI drawing state
|
||||
self._roi_draw_mode = None # 'rect' | 'circle' | 'ellipse' | None
|
||||
self._roi_start_pos = None # QPointF in image coords
|
||||
@@ -337,7 +367,9 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
# color button
|
||||
color_btn = ColorButtonNative(parent=self, color=roi.line_color)
|
||||
self.tree.setItemWidget(parent, self.COL_PROPS, color_btn)
|
||||
color_btn.clicked.connect(lambda: self._pick_color(roi, color_btn))
|
||||
color_btn.color_changed.connect(
|
||||
lambda new_color, r=roi: setattr(r, "line_color", new_color)
|
||||
)
|
||||
|
||||
# child rows (3 columns: action, ROI, properties)
|
||||
QTreeWidgetItem(parent, ["", "Type", roi.__class__.__name__])
|
||||
@@ -419,6 +451,18 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
def cleanup(self):
|
||||
self.cmap.close()
|
||||
self.cmap.deleteLater()
|
||||
if self.controller and hasattr(self.controller, "rois"):
|
||||
for roi in self.controller.rois: # disconnect all signals from ROIs
|
||||
try:
|
||||
if isinstance(roi, RectangularROI):
|
||||
roi.edgesChanged.disconnect()
|
||||
else:
|
||||
roi.centerChanged.disconnect()
|
||||
roi.penChanged.disconnect()
|
||||
roi.nameChanged.disconnect()
|
||||
except (RuntimeError, TypeError) as e:
|
||||
logger.error(f"Failed to disconnect roi qt signal: {e}")
|
||||
|
||||
super().cleanup()
|
||||
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
|
||||
class NoCheckDelegate(QStyledItemDelegate):
|
||||
"""To reduce space in combo boxes by removing the checkmark."""
|
||||
|
||||
def initStyleOption(self, option, index):
|
||||
super().initStyleOption(option, index)
|
||||
# Remove any check indicator
|
||||
option.checkState = Qt.Unchecked
|
||||
|
||||
|
||||
class MonitorSelectionToolbarBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions for a toolbar that controls monitor selection on a plot.
|
||||
"""
|
||||
|
||||
def __init__(self, bundle_id="device_selection", target_widget=None, **kwargs):
|
||||
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
|
||||
self.target_widget = target_widget
|
||||
|
||||
# 1) Device combo box
|
||||
self.device_combo_box = DeviceComboBox(
|
||||
parent=self.target_widget,
|
||||
device_filter=BECDeviceFilter.DEVICE,
|
||||
readout_priority_filter=[ReadoutPriority.ASYNC],
|
||||
)
|
||||
self.device_combo_box.addItem("", None)
|
||||
self.device_combo_box.setCurrentText("")
|
||||
self.device_combo_box.setToolTip("Select Device")
|
||||
self.device_combo_box.setFixedWidth(150)
|
||||
self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box))
|
||||
|
||||
self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=False))
|
||||
|
||||
# 2) Dimension combo box
|
||||
self.dim_combo_box = QComboBox(parent=self.target_widget)
|
||||
self.dim_combo_box.addItems(["auto", "1d", "2d"])
|
||||
self.dim_combo_box.setCurrentText("auto")
|
||||
self.dim_combo_box.setToolTip("Monitor Dimension")
|
||||
self.dim_combo_box.setFixedWidth(100)
|
||||
self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box))
|
||||
|
||||
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False))
|
||||
|
||||
self.device_combo_box.currentTextChanged.connect(self.connect_monitor)
|
||||
self.dim_combo_box.currentTextChanged.connect(self.connect_monitor)
|
||||
|
||||
QTimer.singleShot(0, self._adjust_and_connect)
|
||||
|
||||
def _adjust_and_connect(self):
|
||||
"""
|
||||
Adjust the size of the device combo box and populate it with preview signals.
|
||||
Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing.
|
||||
"""
|
||||
self._populate_preview_signals()
|
||||
self._reverse_device_items()
|
||||
self.device_combo_box.setCurrentText("") # set again default to empty string
|
||||
|
||||
def _populate_preview_signals(self) -> None:
|
||||
"""
|
||||
Populate the device combo box with preview‑signal devices in the
|
||||
format '<device>_<signal>' and store the tuple(device, signal) in
|
||||
the item's userData for later use.
|
||||
"""
|
||||
preview_signals = self.target_widget.client.device_manager.get_bec_signals("PreviewSignal")
|
||||
for device, signal, signal_config in preview_signals:
|
||||
label = signal_config.get("obj_name", f"{device}_{signal}")
|
||||
self.device_combo_box.addItem(label, (device, signal, signal_config))
|
||||
|
||||
def _reverse_device_items(self) -> None:
|
||||
"""
|
||||
Reverse the current order of items in the device combo box while
|
||||
keeping their userData and restoring the previous selection.
|
||||
"""
|
||||
current_text = self.device_combo_box.currentText()
|
||||
items = [
|
||||
(self.device_combo_box.itemText(i), self.device_combo_box.itemData(i))
|
||||
for i in range(self.device_combo_box.count())
|
||||
]
|
||||
self.device_combo_box.clear()
|
||||
for text, data in reversed(items):
|
||||
self.device_combo_box.addItem(text, data)
|
||||
if current_text:
|
||||
self.device_combo_box.setCurrentText(current_text)
|
||||
|
||||
@SafeSlot()
|
||||
def connect_monitor(self, *args, **kwargs):
|
||||
"""
|
||||
Connect the target widget to the selected monitor based on the current device and dimension.
|
||||
|
||||
If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor.
|
||||
"""
|
||||
dim = self.dim_combo_box.currentText()
|
||||
data = self.device_combo_box.currentData()
|
||||
|
||||
if isinstance(data, tuple):
|
||||
self.target_widget.image(monitor=data, monitor_type="auto")
|
||||
else:
|
||||
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)
|
||||
@@ -1,92 +0,0 @@
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ToolbarBundle
|
||||
|
||||
|
||||
class ImageProcessingToolbarBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions for a toolbar that controls processing of monitor.
|
||||
"""
|
||||
|
||||
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
|
||||
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
|
||||
self.target_widget = target_widget
|
||||
|
||||
self.fft = MaterialIconAction(
|
||||
icon_name="fft", tooltip="Toggle FFT", checkable=True, parent=self.target_widget
|
||||
)
|
||||
self.log = MaterialIconAction(
|
||||
icon_name="log_scale", tooltip="Toggle Log", checkable=True, parent=self.target_widget
|
||||
)
|
||||
self.transpose = MaterialIconAction(
|
||||
icon_name="transform",
|
||||
tooltip="Transpose Image",
|
||||
checkable=True,
|
||||
parent=self.target_widget,
|
||||
)
|
||||
self.right = MaterialIconAction(
|
||||
icon_name="rotate_right",
|
||||
tooltip="Rotate image clockwise by 90 deg",
|
||||
parent=self.target_widget,
|
||||
)
|
||||
self.left = MaterialIconAction(
|
||||
icon_name="rotate_left",
|
||||
tooltip="Rotate image counterclockwise by 90 deg",
|
||||
parent=self.target_widget,
|
||||
)
|
||||
self.reset = MaterialIconAction(
|
||||
icon_name="reset_settings", tooltip="Reset Image Settings", parent=self.target_widget
|
||||
)
|
||||
|
||||
self.add_action("fft", self.fft)
|
||||
self.add_action("log", self.log)
|
||||
self.add_action("transpose", self.transpose)
|
||||
self.add_action("rotate_right", self.right)
|
||||
self.add_action("rotate_left", self.left)
|
||||
self.add_action("reset", self.reset)
|
||||
|
||||
self.fft.action.triggered.connect(self.toggle_fft)
|
||||
self.log.action.triggered.connect(self.toggle_log)
|
||||
self.transpose.action.triggered.connect(self.toggle_transpose)
|
||||
self.right.action.triggered.connect(self.rotate_right)
|
||||
self.left.action.triggered.connect(self.rotate_left)
|
||||
self.reset.action.triggered.connect(self.reset_settings)
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_fft(self):
|
||||
checked = self.fft.action.isChecked()
|
||||
self.target_widget.fft = checked
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_log(self):
|
||||
checked = self.log.action.isChecked()
|
||||
self.target_widget.log = checked
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_transpose(self):
|
||||
checked = self.transpose.action.isChecked()
|
||||
self.target_widget.transpose = checked
|
||||
|
||||
@SafeSlot()
|
||||
def rotate_right(self):
|
||||
if self.target_widget.num_rotation_90 is None:
|
||||
return
|
||||
rotation = (self.target_widget.num_rotation_90 - 1) % 4
|
||||
self.target_widget.num_rotation_90 = rotation
|
||||
|
||||
@SafeSlot()
|
||||
def rotate_left(self):
|
||||
if self.target_widget.num_rotation_90 is None:
|
||||
return
|
||||
rotation = (self.target_widget.num_rotation_90 + 1) % 4
|
||||
self.target_widget.num_rotation_90 = rotation
|
||||
|
||||
@SafeSlot()
|
||||
def reset_settings(self):
|
||||
self.target_widget.fft = False
|
||||
self.target_widget.log = False
|
||||
self.target_widget.transpose = False
|
||||
self.target_widget.num_rotation_90 = 0
|
||||
|
||||
self.fft.action.setChecked(False)
|
||||
self.log.action.setChecked(False)
|
||||
self.transpose.action.setChecked(False)
|
||||
@@ -0,0 +1,390 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
|
||||
def image_roi_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a toolbar bundle for ROI and crosshair interaction.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The ROI toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"image_crosshair",
|
||||
MaterialIconAction(
|
||||
icon_name="point_scan",
|
||||
tooltip="Show Crosshair",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_crosshair_roi",
|
||||
MaterialIconAction(
|
||||
icon_name="my_location",
|
||||
tooltip="Show Crosshair with ROI plots",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_switch_crosshair",
|
||||
SwitchableToolBarAction(
|
||||
actions={
|
||||
"crosshair": components.get_action_reference("image_crosshair")(),
|
||||
"crosshair_roi": components.get_action_reference("image_crosshair_roi")(),
|
||||
},
|
||||
initial_action="crosshair",
|
||||
tooltip="Crosshair",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("image_crosshair", components)
|
||||
bundle.add_action("image_switch_crosshair")
|
||||
return bundle
|
||||
|
||||
|
||||
class ImageRoiConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for the ROI toolbar bundle.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "roi"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if not hasattr(self.target_widget, "toggle_roi_panels") or not hasattr(
|
||||
self.target_widget, "toggle_crosshair"
|
||||
):
|
||||
raise AttributeError(
|
||||
"Target widget must implement 'toggle_roi_panels' and 'toggle_crosshair'."
|
||||
)
|
||||
super().__init__()
|
||||
self._connected = False
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action("image_crosshair").action.toggled.connect(
|
||||
self.target_widget.toggle_crosshair
|
||||
)
|
||||
self.components.get_action("image_crosshair_roi").action.triggered.connect(
|
||||
self.target_widget.toggle_roi_panels
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action("image_crosshair").action.toggled.disconnect(
|
||||
self.target_widget.toggle_crosshair
|
||||
)
|
||||
self.components.get_action("image_crosshair_roi").action.triggered.disconnect(
|
||||
self.target_widget.toggle_roi_panels
|
||||
)
|
||||
|
||||
|
||||
def image_autorange(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a toolbar bundle for image autorange functionality.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The autorange toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"image_autorange_mean",
|
||||
MaterialIconAction(
|
||||
icon_name="hdr_auto",
|
||||
tooltip="Enable Auto Range (Mean)",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_autorange_max",
|
||||
MaterialIconAction(
|
||||
icon_name="hdr_auto",
|
||||
tooltip="Enable Auto Range (Max)",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
filled=True,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_autorange",
|
||||
SwitchableToolBarAction(
|
||||
actions={
|
||||
"mean": components.get_action_reference("image_autorange_mean")(),
|
||||
"max": components.get_action_reference("image_autorange_max")(),
|
||||
},
|
||||
initial_action="mean",
|
||||
tooltip="Autorange",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
default_state_checked=True,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("image_autorange", components)
|
||||
bundle.add_action("image_autorange")
|
||||
return bundle
|
||||
|
||||
|
||||
def image_colorbar(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a toolbar bundle for image colorbar functionality.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The colorbar toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"image_full_colorbar",
|
||||
MaterialIconAction(
|
||||
icon_name="edgesensor_low",
|
||||
tooltip="Enable Full Colorbar",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_simple_colorbar",
|
||||
MaterialIconAction(
|
||||
icon_name="smartphone",
|
||||
tooltip="Enable Simple Colorbar",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_colorbar_switch",
|
||||
SwitchableToolBarAction(
|
||||
actions={
|
||||
"full_colorbar": components.get_action_reference("image_full_colorbar")(),
|
||||
"simple_colorbar": components.get_action_reference("image_simple_colorbar")(),
|
||||
},
|
||||
initial_action="full_colorbar",
|
||||
tooltip="Colorbar",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("image_colorbar", components)
|
||||
bundle.add_action("image_colorbar_switch")
|
||||
return bundle
|
||||
|
||||
|
||||
class ImageColorbarConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for the image colorbar toolbar bundle.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "image_colorbar"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if not hasattr(self.target_widget, "enable_colorbar"):
|
||||
raise AttributeError("Target widget must implement 'enable_colorbar' method.")
|
||||
super().__init__()
|
||||
self._connected = False
|
||||
|
||||
def _enable_full_colorbar(self, checked: bool):
|
||||
"""
|
||||
Enable or disable the full colorbar based on the checked state.
|
||||
"""
|
||||
self.target_widget.enable_colorbar(checked, style="full")
|
||||
|
||||
def _enable_simple_colorbar(self, checked: bool):
|
||||
"""
|
||||
Enable or disable the simple colorbar based on the checked state.
|
||||
"""
|
||||
self.target_widget.enable_colorbar(checked, style="simple")
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action("image_full_colorbar").action.toggled.connect(
|
||||
self._enable_full_colorbar
|
||||
)
|
||||
self.components.get_action("image_simple_colorbar").action.toggled.connect(
|
||||
self._enable_simple_colorbar
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action("image_full_colorbar").action.toggled.disconnect(
|
||||
self._enable_full_colorbar
|
||||
)
|
||||
self.components.get_action("image_simple_colorbar").action.toggled.disconnect(
|
||||
self._enable_simple_colorbar
|
||||
)
|
||||
|
||||
|
||||
def image_processing(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a toolbar bundle for image processing functionality.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The image processing toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"image_processing_fft",
|
||||
MaterialIconAction(
|
||||
icon_name="fft", tooltip="Toggle FFT", checkable=True, parent=components.toolbar
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_processing_log",
|
||||
MaterialIconAction(
|
||||
icon_name="log_scale", tooltip="Toggle Log", checkable=True, parent=components.toolbar
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_processing_transpose",
|
||||
MaterialIconAction(
|
||||
icon_name="transform",
|
||||
tooltip="Transpose Image",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_processing_rotate_right",
|
||||
MaterialIconAction(
|
||||
icon_name="rotate_right",
|
||||
tooltip="Rotate image clockwise by 90 deg",
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_processing_rotate_left",
|
||||
MaterialIconAction(
|
||||
icon_name="rotate_left",
|
||||
tooltip="Rotate image counterclockwise by 90 deg",
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"image_processing_reset",
|
||||
MaterialIconAction(
|
||||
icon_name="reset_settings", tooltip="Reset Image Settings", parent=components.toolbar
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("image_processing", components)
|
||||
bundle.add_action("image_processing_fft")
|
||||
bundle.add_action("image_processing_log")
|
||||
bundle.add_action("image_processing_transpose")
|
||||
bundle.add_action("image_processing_rotate_right")
|
||||
bundle.add_action("image_processing_rotate_left")
|
||||
bundle.add_action("image_processing_reset")
|
||||
return bundle
|
||||
|
||||
|
||||
class ImageProcessingConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for the image processing toolbar bundle.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "image_processing"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if (
|
||||
not hasattr(self.target_widget, "fft")
|
||||
or not hasattr(self.target_widget, "log")
|
||||
or not hasattr(self.target_widget, "transpose")
|
||||
or not hasattr(self.target_widget, "num_rotation_90")
|
||||
):
|
||||
raise AttributeError(
|
||||
"Target widget must implement 'fft', 'log', 'transpose', and 'num_rotation_90' attributes."
|
||||
)
|
||||
super().__init__()
|
||||
self.fft = components.get_action("image_processing_fft")
|
||||
self.log = components.get_action("image_processing_log")
|
||||
self.transpose = components.get_action("image_processing_transpose")
|
||||
self.right = components.get_action("image_processing_rotate_right")
|
||||
self.left = components.get_action("image_processing_rotate_left")
|
||||
self.reset = components.get_action("image_processing_reset")
|
||||
self._connected = False
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_fft(self):
|
||||
checked = self.fft.action.isChecked()
|
||||
self.target_widget.fft = checked
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_log(self):
|
||||
checked = self.log.action.isChecked()
|
||||
self.target_widget.log = checked
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_transpose(self):
|
||||
checked = self.transpose.action.isChecked()
|
||||
self.target_widget.transpose = checked
|
||||
|
||||
@SafeSlot()
|
||||
def rotate_right(self):
|
||||
if self.target_widget.num_rotation_90 is None:
|
||||
return
|
||||
rotation = (self.target_widget.num_rotation_90 - 1) % 4
|
||||
self.target_widget.num_rotation_90 = rotation
|
||||
|
||||
@SafeSlot()
|
||||
def rotate_left(self):
|
||||
if self.target_widget.num_rotation_90 is None:
|
||||
return
|
||||
rotation = (self.target_widget.num_rotation_90 + 1) % 4
|
||||
self.target_widget.num_rotation_90 = rotation
|
||||
|
||||
@SafeSlot()
|
||||
def reset_settings(self):
|
||||
self.target_widget.fft = False
|
||||
self.target_widget.log = False
|
||||
self.target_widget.transpose = False
|
||||
self.target_widget.num_rotation_90 = 0
|
||||
|
||||
self.fft.action.setChecked(False)
|
||||
self.log.action.setChecked(False)
|
||||
self.transpose.action.setChecked(False)
|
||||
|
||||
def connect(self):
|
||||
"""
|
||||
Connect the actions to the target widget's methods.
|
||||
"""
|
||||
self._connected = True
|
||||
self.fft.action.triggered.connect(self.toggle_fft)
|
||||
self.log.action.triggered.connect(self.toggle_log)
|
||||
self.transpose.action.triggered.connect(self.toggle_transpose)
|
||||
self.right.action.triggered.connect(self.rotate_right)
|
||||
self.left.action.triggered.connect(self.rotate_left)
|
||||
self.reset.action.triggered.connect(self.reset_settings)
|
||||
|
||||
def disconnect(self):
|
||||
"""
|
||||
Disconnect the actions from the target widget's methods.
|
||||
"""
|
||||
if not self._connected:
|
||||
return
|
||||
self.fft.action.triggered.disconnect(self.toggle_fft)
|
||||
self.log.action.triggered.disconnect(self.toggle_log)
|
||||
self.transpose.action.triggered.disconnect(self.toggle_transpose)
|
||||
self.right.action.triggered.disconnect(self.rotate_right)
|
||||
self.left.action.triggered.disconnect(self.rotate_left)
|
||||
self.reset.action.triggered.disconnect(self.reset_settings)
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
@@ -15,12 +14,12 @@ from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
from bec_widgets.widgets.plots.motor_map.settings.motor_map_settings import MotorMapSettings
|
||||
from bec_widgets.widgets.plots.motor_map.toolbar_bundles.motor_selection import (
|
||||
MotorSelectionToolbarBundle,
|
||||
from bec_widgets.widgets.plots.motor_map.toolbar_components.motor_selection import (
|
||||
MotorSelectionAction,
|
||||
)
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -182,33 +181,60 @@ class MotorMap(PlotBase):
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self._update_plot
|
||||
)
|
||||
self._init_motor_map_toolbar()
|
||||
self._add_motor_map_settings()
|
||||
|
||||
################################################################################
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
|
||||
def _init_toolbar(self):
|
||||
def _init_motor_map_toolbar(self):
|
||||
"""
|
||||
Initialize the toolbar for the motor map widget.
|
||||
"""
|
||||
self.motor_selection_bundle = MotorSelectionToolbarBundle(
|
||||
bundle_id="motor_selection", target_widget=self
|
||||
)
|
||||
self.toolbar.add_bundle(self.motor_selection_bundle, target_widget=self)
|
||||
super()._init_toolbar()
|
||||
self.toolbar.widgets["reset_legend"].action.setVisible(False)
|
||||
motor_selection = MotorSelectionAction(parent=self)
|
||||
self.toolbar.add_action("motor_selection", motor_selection)
|
||||
|
||||
self.reset_legend_action = MaterialIconAction(
|
||||
icon_name="history", tooltip="Reset the position of legend."
|
||||
motor_selection.motor_x.currentTextChanged.connect(self.on_motor_selection_changed)
|
||||
motor_selection.motor_y.currentTextChanged.connect(self.on_motor_selection_changed)
|
||||
|
||||
self.toolbar.components.get_action("reset_legend").action.setVisible(False)
|
||||
|
||||
reset_legend = MaterialIconAction(
|
||||
icon_name="history",
|
||||
tooltip="Reset the position of legend.",
|
||||
checkable=False,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="roi",
|
||||
action_id="motor_map_history",
|
||||
action=self.reset_legend_action,
|
||||
target_widget=self,
|
||||
self.toolbar.components.add_safe("reset_motor_map_legend", reset_legend)
|
||||
self.toolbar.get_bundle("roi").add_action("reset_motor_map_legend")
|
||||
reset_legend.action.triggered.connect(self.reset_history)
|
||||
|
||||
settings_brightness = MaterialIconAction(
|
||||
icon_name="settings_brightness",
|
||||
tooltip="Show Motor Map Settings",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.reset_legend_action.action.triggered.connect(self.reset_history)
|
||||
self.toolbar.components.add_safe("motor_map_settings", settings_brightness)
|
||||
self.toolbar.get_bundle("axis_popup").add_action("motor_map_settings")
|
||||
|
||||
settings_brightness.action.triggered.connect(self.show_motor_map_settings)
|
||||
|
||||
bundles = ["motor_selection", "plot_export", "mouse_interaction", "roi"]
|
||||
if self.ui_mode == UIMode.POPUP:
|
||||
bundles.append("axis_popup")
|
||||
self.toolbar.show_bundles(bundles)
|
||||
|
||||
@SafeSlot()
|
||||
def on_motor_selection_changed(self, _):
|
||||
action: MotorSelectionAction = self.toolbar.components.get_action("motor_selection")
|
||||
motor_x = action.motor_x.currentText()
|
||||
motor_y = action.motor_y.currentText()
|
||||
|
||||
if motor_x != "" and motor_y != "":
|
||||
if motor_x != self.config.x_motor.name or motor_y != self.config.y_motor.name:
|
||||
self.map(motor_x, motor_y)
|
||||
|
||||
def _add_motor_map_settings(self):
|
||||
"""Add the motor map settings to the side panel."""
|
||||
@@ -221,32 +247,11 @@ class MotorMap(PlotBase):
|
||||
title="Motor Map Settings",
|
||||
)
|
||||
|
||||
def add_popups(self):
|
||||
"""
|
||||
Add popups to the ScatterWaveform widget.
|
||||
"""
|
||||
super().add_popups()
|
||||
scatter_curve_setting_action = MaterialIconAction(
|
||||
icon_name="settings_brightness",
|
||||
tooltip="Show Motor Map Settings",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="popup_bundle",
|
||||
action_id="motor_map_settings",
|
||||
action=scatter_curve_setting_action,
|
||||
target_widget=self,
|
||||
)
|
||||
self.toolbar.widgets["motor_map_settings"].action.triggered.connect(
|
||||
self.show_motor_map_settings
|
||||
)
|
||||
|
||||
def show_motor_map_settings(self):
|
||||
"""
|
||||
Show the DAP summary popup.
|
||||
"""
|
||||
action = self.toolbar.widgets["motor_map_settings"].action
|
||||
action = self.toolbar.components.get_action("motor_map_settings").action
|
||||
if self.motor_map_settings is None or not self.motor_map_settings.isVisible():
|
||||
motor_map_settings = MotorMapSettings(parent=self, target_widget=self, popup=True)
|
||||
self.motor_map_settings = SettingsDialog(
|
||||
@@ -272,7 +277,7 @@ class MotorMap(PlotBase):
|
||||
"""
|
||||
self.motor_map_settings.deleteLater()
|
||||
self.motor_map_settings = None
|
||||
self.toolbar.widgets["motor_map_settings"].action.setChecked(False)
|
||||
self.toolbar.components.get_action("motor_map_settings").action.setChecked(False)
|
||||
|
||||
################################################################################
|
||||
# Widget Specific Properties
|
||||
@@ -766,20 +771,21 @@ class MotorMap(PlotBase):
|
||||
"""
|
||||
Sync the motor map selection toolbar with the current motor map.
|
||||
"""
|
||||
if self.motor_selection_bundle is not None:
|
||||
motor_x = self.motor_selection_bundle.motor_x.currentText()
|
||||
motor_y = self.motor_selection_bundle.motor_y.currentText()
|
||||
motor_selection = self.toolbar.components.get_action("motor_selection")
|
||||
|
||||
if motor_x != self.config.x_motor.name:
|
||||
self.motor_selection_bundle.motor_x.blockSignals(True)
|
||||
self.motor_selection_bundle.motor_x.set_device(self.config.x_motor.name)
|
||||
self.motor_selection_bundle.motor_x.check_validity(self.config.x_motor.name)
|
||||
self.motor_selection_bundle.motor_x.blockSignals(False)
|
||||
if motor_y != self.config.y_motor.name:
|
||||
self.motor_selection_bundle.motor_y.blockSignals(True)
|
||||
self.motor_selection_bundle.motor_y.set_device(self.config.y_motor.name)
|
||||
self.motor_selection_bundle.motor_y.check_validity(self.config.y_motor.name)
|
||||
self.motor_selection_bundle.motor_y.blockSignals(False)
|
||||
motor_x = motor_selection.motor_x.currentText()
|
||||
motor_y = motor_selection.motor_y.currentText()
|
||||
|
||||
if motor_x != self.config.x_motor.name:
|
||||
motor_selection.motor_x.blockSignals(True)
|
||||
motor_selection.motor_x.set_device(self.config.x_motor.name)
|
||||
motor_selection.motor_x.check_validity(self.config.x_motor.name)
|
||||
motor_selection.motor_x.blockSignals(False)
|
||||
if motor_y != self.config.y_motor.name:
|
||||
motor_selection.motor_y.blockSignals(True)
|
||||
motor_selection.motor_y.set_device(self.config.y_motor.name)
|
||||
motor_selection.motor_y.check_validity(self.config.y_motor.name)
|
||||
motor_selection.motor_y.blockSignals(False)
|
||||
|
||||
################################################################################
|
||||
# Export Methods
|
||||
@@ -795,10 +801,6 @@ class MotorMap(PlotBase):
|
||||
data = {"x": self._buffer["x"], "y": self._buffer["y"]}
|
||||
return data
|
||||
|
||||
def cleanup(self):
|
||||
self.motor_selection_bundle.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
class DemoApp(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QStyledItemDelegate
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
|
||||
class NoCheckDelegate(QStyledItemDelegate):
|
||||
"""To reduce space in combo boxes by removing the checkmark."""
|
||||
|
||||
def initStyleOption(self, option, index):
|
||||
super().initStyleOption(option, index)
|
||||
# Remove any check indicator
|
||||
option.checkState = Qt.Unchecked
|
||||
|
||||
|
||||
class MotorSelectionToolbarBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions for a toolbar that selects motors.
|
||||
"""
|
||||
|
||||
def __init__(self, bundle_id="motor_selection", target_widget=None, **kwargs):
|
||||
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
|
||||
self.target_widget = target_widget
|
||||
|
||||
# Motor X
|
||||
self.motor_x = DeviceComboBox(
|
||||
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
|
||||
)
|
||||
self.motor_x.addItem("", None)
|
||||
self.motor_x.setCurrentText("")
|
||||
self.motor_x.setToolTip("Select Motor X")
|
||||
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
|
||||
|
||||
# Motor X
|
||||
self.motor_y = DeviceComboBox(
|
||||
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
|
||||
)
|
||||
self.motor_y.addItem("", None)
|
||||
self.motor_y.setCurrentText("")
|
||||
self.motor_y.setToolTip("Select Motor Y")
|
||||
self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y))
|
||||
|
||||
self.add_action("motor_x", WidgetAction(widget=self.motor_x, adjust_size=False))
|
||||
self.add_action("motor_y", WidgetAction(widget=self.motor_y, adjust_size=False))
|
||||
|
||||
# Connect slots, a device will be connected upon change of any combobox
|
||||
self.motor_x.currentTextChanged.connect(lambda: self.connect_motors())
|
||||
self.motor_y.currentTextChanged.connect(lambda: self.connect_motors())
|
||||
|
||||
@SafeSlot()
|
||||
def connect_motors(self):
|
||||
motor_x = self.motor_x.currentText()
|
||||
motor_y = self.motor_y.currentText()
|
||||
|
||||
if motor_x != "" and motor_y != "":
|
||||
if (
|
||||
motor_x != self.target_widget.config.x_motor.name
|
||||
or motor_y != self.target_widget.config.y_motor.name
|
||||
):
|
||||
self.target_widget.map(motor_x, motor_y)
|
||||
|
||||
def cleanup(self):
|
||||
self.motor_x.close()
|
||||
self.motor_x.deleteLater()
|
||||
self.motor_y.close()
|
||||
self.motor_y.deleteLater()
|
||||
@@ -0,0 +1,51 @@
|
||||
from qtpy.QtWidgets import QHBoxLayout, QToolBar, QWidget
|
||||
|
||||
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, ToolBarAction
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
|
||||
class MotorSelectionAction(ToolBarAction):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(icon_path=None, tooltip=None, checkable=False)
|
||||
self.motor_x = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER])
|
||||
self.motor_x.addItem("", None)
|
||||
self.motor_x.setCurrentText("")
|
||||
self.motor_x.setToolTip("Select Motor X")
|
||||
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
|
||||
self.motor_y = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER])
|
||||
self.motor_y.addItem("", None)
|
||||
self.motor_y.setCurrentText("")
|
||||
self.motor_y.setToolTip("Select Motor Y")
|
||||
self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y))
|
||||
|
||||
self.container = QWidget(parent)
|
||||
layout = QHBoxLayout(self.container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.motor_x)
|
||||
layout.addWidget(self.motor_y)
|
||||
self.container.setLayout(layout)
|
||||
self.action = self.container
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the widget to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the widget to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
|
||||
toolbar.addWidget(self.container)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the action, if necessary.
|
||||
"""
|
||||
self.motor_x.close()
|
||||
self.motor_x.deleteLater()
|
||||
self.motor_y.close()
|
||||
self.motor_y.deleteLater()
|
||||
self.container.close()
|
||||
self.container.deleteLater()
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
@@ -12,13 +13,15 @@ from qtpy.QtWidgets import QWidget
|
||||
from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.side_panel import SidePanel
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.plots.multi_waveform.settings.control_panel import (
|
||||
MultiWaveformControlPanel,
|
||||
)
|
||||
from bec_widgets.widgets.plots.multi_waveform.toolbar_bundles.monitor_selection import (
|
||||
MultiWaveformSelectionToolbarBundle,
|
||||
from bec_widgets.widgets.plots.multi_waveform.toolbar_components.monitor_selection import (
|
||||
monitor_selection_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -141,33 +144,54 @@ class MultiWaveform(PlotBase):
|
||||
self.visible_curves = []
|
||||
self.number_of_visible_curves = 0
|
||||
|
||||
self._init_control_panel()
|
||||
self._init_multiwaveform_toolbar()
|
||||
|
||||
################################################################################
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
def _init_toolbar(self):
|
||||
self.monitor_selection_bundle = MultiWaveformSelectionToolbarBundle(
|
||||
bundle_id="motor_selection", target_widget=self
|
||||
def _init_multiwaveform_toolbar(self):
|
||||
self.toolbar.add_bundle(
|
||||
monitor_selection_bundle(self.toolbar.components, target_widget=self)
|
||||
)
|
||||
self.toolbar.add_bundle(self.monitor_selection_bundle, target_widget=self)
|
||||
super()._init_toolbar()
|
||||
self.toolbar.widgets["reset_legend"].action.setVisible(False)
|
||||
self.toolbar.toggle_action_visibility("reset_legend", visible=False)
|
||||
|
||||
combobox = self.toolbar.components.get_action("monitor_selection").widget
|
||||
combobox.currentTextChanged.connect(self.connect_monitor)
|
||||
|
||||
cmap = self.toolbar.components.get_action("color_map").widget
|
||||
cmap.colormap_changed_signal.connect(self.change_colormap)
|
||||
|
||||
bundles = self.toolbar.shown_bundles
|
||||
bundles.insert(0, "monitor_selection")
|
||||
self.toolbar.show_bundles(bundles)
|
||||
|
||||
self._init_control_panel()
|
||||
|
||||
def _init_control_panel(self):
|
||||
self.control_panel = SidePanel(self, orientation="top", panel_max_width=90)
|
||||
self.layout_manager.add_widget_relative(
|
||||
self.control_panel, self.round_plot_widget, "bottom"
|
||||
)
|
||||
control_panel = SidePanel(self, orientation="top", panel_max_width=90)
|
||||
self.layout_manager.add_widget_relative(control_panel, self.round_plot_widget, "bottom")
|
||||
self.controls = MultiWaveformControlPanel(parent=self, target_widget=self)
|
||||
self.control_panel.add_menu(
|
||||
control_panel.add_menu(
|
||||
action_id="control",
|
||||
icon_name="tune",
|
||||
tooltip="Show Control panel",
|
||||
widget=self.controls,
|
||||
title=None,
|
||||
)
|
||||
self.control_panel.toolbar.widgets["control"].action.trigger()
|
||||
control_panel.toolbar.components.get_action("control").action.trigger()
|
||||
|
||||
@SafeSlot()
|
||||
def connect_monitor(self, _):
|
||||
combobox = self.toolbar.components.get_action("monitor_selection").widget
|
||||
monitor = combobox.currentText()
|
||||
|
||||
if monitor != "":
|
||||
if monitor != self.config.monitor:
|
||||
self.config.monitor = monitor
|
||||
|
||||
@SafeSlot(str)
|
||||
def change_colormap(self, colormap: str):
|
||||
self.color_palette = colormap
|
||||
|
||||
################################################################################
|
||||
# Widget Specific Properties
|
||||
@@ -488,23 +512,30 @@ class MultiWaveform(PlotBase):
|
||||
"""
|
||||
Sync the motor map selection toolbar with the current motor map.
|
||||
"""
|
||||
if self.monitor_selection_bundle is not None:
|
||||
monitor = self.monitor_selection_bundle.monitor.currentText()
|
||||
color_palette = self.monitor_selection_bundle.colormap_widget.colormap
|
||||
|
||||
if monitor != self.config.monitor:
|
||||
self.monitor_selection_bundle.monitor.blockSignals(True)
|
||||
self.monitor_selection_bundle.monitor.set_device(self.config.monitor)
|
||||
self.monitor_selection_bundle.monitor.check_validity(self.config.monitor)
|
||||
self.monitor_selection_bundle.monitor.blockSignals(False)
|
||||
combobox_widget: DeviceComboBox = cast(
|
||||
DeviceComboBox, self.toolbar.components.get_action("monitor_selection").widget
|
||||
)
|
||||
cmap_widget: BECColorMapWidget = cast(
|
||||
BECColorMapWidget, self.toolbar.components.get_action("color_map").widget
|
||||
)
|
||||
|
||||
if color_palette != self.config.color_palette:
|
||||
self.monitor_selection_bundle.colormap_widget.blockSignals(True)
|
||||
self.monitor_selection_bundle.colormap_widget.colormap = self.config.color_palette
|
||||
self.monitor_selection_bundle.colormap_widget.blockSignals(False)
|
||||
monitor = combobox_widget.currentText()
|
||||
color_palette = cmap_widget.colormap
|
||||
|
||||
if monitor != self.config.monitor:
|
||||
combobox_widget.setCurrentText(monitor)
|
||||
combobox_widget.blockSignals(True)
|
||||
combobox_widget.set_device(self.config.monitor)
|
||||
combobox_widget.check_validity(self.config.monitor)
|
||||
combobox_widget.blockSignals(False)
|
||||
|
||||
if color_palette != self.config.color_palette:
|
||||
cmap_widget.blockSignals(True)
|
||||
cmap_widget.colormap = self.config.color_palette
|
||||
cmap_widget.blockSignals(False)
|
||||
|
||||
def cleanup(self):
|
||||
self._disconnect_monitor()
|
||||
self.clear_curves()
|
||||
self.monitor_selection_bundle.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QStyledItemDelegate
|
||||
from qtpy.QtWidgets import QStyledItemDelegate, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction
|
||||
from bec_widgets.utils.toolbars.actions import DeviceComboBoxAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.toolbar import ToolbarBundle
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||
@@ -18,6 +20,37 @@ class NoCheckDelegate(QStyledItemDelegate):
|
||||
option.checkState = Qt.Unchecked
|
||||
|
||||
|
||||
def monitor_selection_bundle(
|
||||
components: ToolbarComponents, target_widget: QWidget
|
||||
) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a monitor selection toolbar bundle.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The monitor selection toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"monitor_selection",
|
||||
DeviceComboBoxAction(
|
||||
target_widget=target_widget,
|
||||
device_filter=[BECDeviceFilter.DEVICE],
|
||||
readout_priority_filter=ReadoutPriority.ASYNC,
|
||||
add_empty_item=True,
|
||||
no_check_delegate=True,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"color_map", WidgetAction(widget=BECColorMapWidget(cmap="plasma"), adjust_size=False)
|
||||
)
|
||||
bundle = ToolbarBundle("monitor_selection", components)
|
||||
bundle.add_action("monitor_selection")
|
||||
bundle.add_action("color_map")
|
||||
return bundle
|
||||
|
||||
|
||||
class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions for a toolbar that selects motors.
|
||||
@@ -14,17 +14,25 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.fps_counter import FPSCounter
|
||||
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.side_panel import SidePanel
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar, ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.widget_state_manager import WidgetStateManager
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
from bec_widgets.widgets.plots.setting_menus.axis_settings import AxisSettings
|
||||
from bec_widgets.widgets.plots.toolbar_bundles.mouse_interactions import (
|
||||
MouseInteractionToolbarBundle,
|
||||
from bec_widgets.widgets.plots.toolbar_components.axis_settings_popup import (
|
||||
AxisSettingsPopupConnection,
|
||||
axis_popup_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.toolbar_bundles.plot_export import PlotExportBundle
|
||||
from bec_widgets.widgets.plots.toolbar_bundles.roi_bundle import ROIBundle
|
||||
from bec_widgets.widgets.plots.toolbar_components.mouse_interactions import (
|
||||
MouseInteractionConnection,
|
||||
mouse_interaction_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.toolbar_components.plot_export import (
|
||||
PlotExportConnection,
|
||||
plot_export_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.toolbar_components.roi import RoiConnection, roi_bundle
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -102,8 +110,6 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
|
||||
self.plot_widget.addItem(self.plot_item)
|
||||
self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
|
||||
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
|
||||
self._init_toolbar()
|
||||
|
||||
# PlotItem Addons
|
||||
self.plot_item.addLegend()
|
||||
@@ -122,6 +128,9 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
|
||||
self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
|
||||
self._init_toolbar()
|
||||
|
||||
self._init_ui()
|
||||
|
||||
self._connect_to_theme_change()
|
||||
@@ -146,36 +155,33 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.plot_item.vb.sigStateChanged.connect(self.viewbox_state_changed)
|
||||
|
||||
def _init_toolbar(self):
|
||||
self.popup_bundle = None
|
||||
self.performance_bundle = ToolbarBundle("performance")
|
||||
self.plot_export_bundle = PlotExportBundle("plot_export", target_widget=self)
|
||||
self.mouse_bundle = MouseInteractionToolbarBundle("mouse_interaction", target_widget=self)
|
||||
# self.state_export_bundle = SaveStateBundle("state_export", target_widget=self) #TODO ATM disabled, cannot be used in DockArea, which is exposed to the user
|
||||
self.roi_bundle = ROIBundle("roi", target_widget=self)
|
||||
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
|
||||
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
|
||||
self.toolbar.add_bundle(mouse_interaction_bundle(self.toolbar.components))
|
||||
self.toolbar.add_bundle(roi_bundle(self.toolbar.components))
|
||||
self.toolbar.add_bundle(axis_popup_bundle(self.toolbar.components))
|
||||
|
||||
# Add elements to toolbar
|
||||
self.toolbar.add_bundle(self.plot_export_bundle, target_widget=self)
|
||||
# self.toolbar.add_bundle(self.state_export_bundle, target_widget=self) #TODO ATM disabled, cannot be used in DockArea, which is exposed to the user
|
||||
self.toolbar.add_bundle(self.mouse_bundle, target_widget=self)
|
||||
self.toolbar.add_bundle(self.roi_bundle, target_widget=self)
|
||||
|
||||
self.performance_bundle.add_action(
|
||||
"fps_monitor",
|
||||
MaterialIconAction(
|
||||
icon_name="speed", tooltip="Show FPS Monitor", checkable=True, parent=self
|
||||
),
|
||||
self.toolbar.connect_bundle(
|
||||
"plot_base", PlotExportConnection(self.toolbar.components, self)
|
||||
)
|
||||
self.toolbar.add_bundle(self.performance_bundle, target_widget=self)
|
||||
|
||||
self.toolbar.widgets["fps_monitor"].action.toggled.connect(
|
||||
lambda checked: setattr(self, "enable_fps_monitor", checked)
|
||||
self.toolbar.connect_bundle(
|
||||
"plot_base", PerformanceConnection(self.toolbar.components, self)
|
||||
)
|
||||
self.toolbar.connect_bundle(
|
||||
"plot_base", MouseInteractionConnection(self.toolbar.components, self)
|
||||
)
|
||||
self.toolbar.connect_bundle("plot_base", RoiConnection(self.toolbar.components, self))
|
||||
self.toolbar.connect_bundle(
|
||||
"plot_base", AxisSettingsPopupConnection(self.toolbar.components, self)
|
||||
)
|
||||
|
||||
# hide some options by default
|
||||
self.toolbar.toggle_action_visibility("fps_monitor", False)
|
||||
|
||||
# Get default viewbox state
|
||||
self.mouse_bundle.get_viewbox_mode()
|
||||
self.toolbar.show_bundles(
|
||||
["plot_export", "mouse_interaction", "roi", "performance", "axis_popup"]
|
||||
)
|
||||
|
||||
def add_side_menus(self):
|
||||
"""Adds multiple menus to the side panel."""
|
||||
@@ -192,45 +198,6 @@ class PlotBase(BECWidget, QWidget):
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
def add_popups(self):
|
||||
"""
|
||||
Add popups to the toolbar.
|
||||
"""
|
||||
self.popup_bundle = ToolbarBundle("popup_bundle")
|
||||
settings = MaterialIconAction(
|
||||
icon_name="settings", tooltip="Show Axis Settings", checkable=True, parent=self
|
||||
)
|
||||
self.popup_bundle.add_action("axis", settings)
|
||||
self.toolbar.add_bundle(self.popup_bundle, target_widget=self)
|
||||
self.toolbar.widgets["axis"].action.triggered.connect(self.show_axis_settings_popup)
|
||||
|
||||
def show_axis_settings_popup(self):
|
||||
"""
|
||||
Show the axis settings dialog.
|
||||
"""
|
||||
settings_action = self.toolbar.widgets["axis"].action
|
||||
if self.axis_settings_dialog is None or not self.axis_settings_dialog.isVisible():
|
||||
axis_setting = AxisSettings(parent=self, target_widget=self, popup=True)
|
||||
self.axis_settings_dialog = SettingsDialog(
|
||||
self, settings_widget=axis_setting, window_title="Axis Settings", modal=False
|
||||
)
|
||||
# When the dialog is closed, update the toolbar icon and clear the reference
|
||||
self.axis_settings_dialog.finished.connect(self._axis_settings_closed)
|
||||
self.axis_settings_dialog.show()
|
||||
settings_action.setChecked(True)
|
||||
else:
|
||||
# If already open, bring it to the front
|
||||
self.axis_settings_dialog.raise_()
|
||||
self.axis_settings_dialog.activateWindow()
|
||||
settings_action.setChecked(True) # keep it toggled
|
||||
|
||||
def _axis_settings_closed(self):
|
||||
"""
|
||||
Slot for when the axis settings dialog is closed.
|
||||
"""
|
||||
self.axis_settings_dialog = None
|
||||
self.toolbar.widgets["axis"].action.setChecked(False)
|
||||
|
||||
def reset_legend(self):
|
||||
"""In the case that the legend is not visible, reset it to be visible to top left corner"""
|
||||
self.plot_item.legend.autoAnchor(50)
|
||||
@@ -257,22 +224,23 @@ class PlotBase(BECWidget, QWidget):
|
||||
raise ValueError("ui_mode must be an instance of UIMode")
|
||||
self._ui_mode = mode
|
||||
|
||||
# First, clear both UI elements:
|
||||
if self.popup_bundle is not None:
|
||||
for action_id in self.toolbar.bundles["popup_bundle"]:
|
||||
self.toolbar.widgets[action_id].action.setVisible(False)
|
||||
if self.axis_settings_dialog is not None and self.axis_settings_dialog.isVisible():
|
||||
self.axis_settings_dialog.close()
|
||||
self.side_panel.hide()
|
||||
|
||||
# Now, apply the new mode:
|
||||
if mode == UIMode.POPUP:
|
||||
if self.popup_bundle is None:
|
||||
self.add_popups()
|
||||
else:
|
||||
for action_id in self.toolbar.bundles["popup_bundle"]:
|
||||
self.toolbar.widgets[action_id].action.setVisible(True)
|
||||
shown_bundles = self.toolbar.shown_bundles
|
||||
if "axis_popup" not in shown_bundles:
|
||||
shown_bundles.append("axis_popup")
|
||||
self.toolbar.show_bundles(shown_bundles)
|
||||
self.side_panel.hide()
|
||||
|
||||
elif mode == UIMode.SIDE:
|
||||
shown_bundles = self.toolbar.shown_bundles
|
||||
if "axis_popup" in shown_bundles:
|
||||
shown_bundles.remove("axis_popup")
|
||||
self.toolbar.show_bundles(shown_bundles)
|
||||
pb_connection = self.toolbar.bundles["axis_popup"].get_connection("plot_base")
|
||||
if pb_connection.axis_settings_dialog is not None:
|
||||
pb_connection.axis_settings_dialog.close()
|
||||
pb_connection.axis_settings_dialog = None
|
||||
self.add_side_menus()
|
||||
self.side_panel.show()
|
||||
|
||||
@@ -913,6 +881,38 @@ class PlotBase(BECWidget, QWidget):
|
||||
"""
|
||||
self.plot_item.enableAutoRange(y=value)
|
||||
|
||||
def auto_range(self, value: bool = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
if not value:
|
||||
self.plot_item.enableAutoRange(x=False, y=False)
|
||||
return
|
||||
self._apply_autorange_only_visible_curves()
|
||||
|
||||
def _fetch_visible_curves(self):
|
||||
"""
|
||||
Fetch all visible curves from the plot item.
|
||||
"""
|
||||
visible_curves = []
|
||||
for curve in self.plot_item.curves:
|
||||
if curve.isVisible():
|
||||
visible_curves.append(curve)
|
||||
return visible_curves
|
||||
|
||||
def _apply_autorange_only_visible_curves(self):
|
||||
"""
|
||||
Apply autorange to the plot item based on the provided curves.
|
||||
|
||||
Args:
|
||||
curves (list): List of curves to apply autorange to.
|
||||
"""
|
||||
visible_curves = self._fetch_visible_curves()
|
||||
self.plot_item.autoRange(items=visible_curves if visible_curves else None)
|
||||
|
||||
@SafeProperty(int, doc="The font size of the legend font.")
|
||||
def legend_label_size(self) -> int:
|
||||
"""
|
||||
@@ -1040,6 +1040,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.crosshair.update_markers()
|
||||
|
||||
def cleanup(self):
|
||||
self.toolbar.cleanup()
|
||||
self.unhook_crosshair()
|
||||
self.unhook_fps_monitor(delete_label=True)
|
||||
self.tick_item.cleanup()
|
||||
@@ -1087,8 +1088,12 @@ if __name__ == "__main__": # pragma: no cover:
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
window = PlotBase()
|
||||
window.show()
|
||||
launch_window = BECMainWindow()
|
||||
pb = PlotBase(popups=False)
|
||||
launch_window.setCentralWidget(pb)
|
||||
launch_window.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -13,7 +13,7 @@ from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_curve import (
|
||||
ScatterCurve,
|
||||
@@ -131,8 +131,8 @@ class ScatterWaveform(PlotBase):
|
||||
self.proxy_update_sync = pg.SignalProxy(
|
||||
self.sync_signal_update, rateLimit=25, slot=self.update_sync_curves
|
||||
)
|
||||
if self.ui_mode == UIMode.SIDE:
|
||||
self._init_scatter_curve_settings()
|
||||
|
||||
self._init_scatter_curve_settings()
|
||||
self.update_with_scan_history(-1)
|
||||
|
||||
################################################################################
|
||||
@@ -143,44 +143,40 @@ class ScatterWaveform(PlotBase):
|
||||
"""
|
||||
Initialize the scatter curve settings menu.
|
||||
"""
|
||||
if self.ui_mode == UIMode.SIDE:
|
||||
self.scatter_curve_settings = ScatterCurveSettings(
|
||||
parent=self, target_widget=self, popup=False
|
||||
)
|
||||
self.side_panel.add_menu(
|
||||
action_id="scatter_curve",
|
||||
icon_name="scatter_plot",
|
||||
tooltip="Show Scatter Curve Settings",
|
||||
widget=self.scatter_curve_settings,
|
||||
title="Scatter Curve Settings",
|
||||
)
|
||||
else:
|
||||
scatter_curve_action = MaterialIconAction(
|
||||
icon_name="scatter_plot",
|
||||
tooltip="Show Scatter Curve Settings",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.components.add_safe("scatter_waveform_settings", scatter_curve_action)
|
||||
self.toolbar.get_bundle("axis_popup").add_action("scatter_waveform_settings")
|
||||
scatter_curve_action.action.triggered.connect(self.show_scatter_curve_settings)
|
||||
|
||||
self.scatter_curve_settings = ScatterCurveSettings(
|
||||
parent=self, target_widget=self, popup=False
|
||||
)
|
||||
self.side_panel.add_menu(
|
||||
action_id="scatter_curve",
|
||||
icon_name="scatter_plot",
|
||||
tooltip="Show Scatter Curve Settings",
|
||||
widget=self.scatter_curve_settings,
|
||||
title="Scatter Curve Settings",
|
||||
)
|
||||
|
||||
def add_popups(self):
|
||||
"""
|
||||
Add popups to the ScatterWaveform widget.
|
||||
"""
|
||||
super().add_popups()
|
||||
scatter_curve_setting_action = MaterialIconAction(
|
||||
icon_name="scatter_plot",
|
||||
tooltip="Show Scatter Curve Settings",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="popup_bundle",
|
||||
action_id="scatter_waveform_settings",
|
||||
action=scatter_curve_setting_action,
|
||||
target_widget=self,
|
||||
)
|
||||
self.toolbar.widgets["scatter_waveform_settings"].action.triggered.connect(
|
||||
self.show_scatter_curve_settings
|
||||
)
|
||||
shown_bundles = self.toolbar.shown_bundles
|
||||
if "performance" in shown_bundles:
|
||||
shown_bundles.remove("performance")
|
||||
self.toolbar.show_bundles(shown_bundles)
|
||||
|
||||
def show_scatter_curve_settings(self):
|
||||
"""
|
||||
Show the scatter curve settings dialog.
|
||||
"""
|
||||
scatter_settings_action = self.toolbar.widgets["scatter_waveform_settings"].action
|
||||
scatter_settings_action = self.toolbar.components.get_action(
|
||||
"scatter_waveform_settings"
|
||||
).action
|
||||
if self.scatter_dialog is None or not self.scatter_dialog.isVisible():
|
||||
scatter_settings = ScatterCurveSettings(parent=self, target_widget=self, popup=True)
|
||||
self.scatter_dialog = SettingsDialog(
|
||||
@@ -205,7 +201,7 @@ class ScatterWaveform(PlotBase):
|
||||
Slot for when the scatter curve settings dialog is closed.
|
||||
"""
|
||||
self.scatter_dialog = None
|
||||
self.toolbar.widgets["scatter_waveform_settings"].action.setChecked(False)
|
||||
self.toolbar.components.get_action("scatter_waveform_settings").action.setChecked(False)
|
||||
|
||||
################################################################################
|
||||
# Widget Specific Properties
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction, ToolbarBundle
|
||||
|
||||
|
||||
class MouseInteractionToolbarBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions that are hooked in this constructor itself,
|
||||
so that you can immediately connect the signals and toggle states.
|
||||
|
||||
This bundle is for a toolbar that controls mouse interactions on a plot.
|
||||
"""
|
||||
|
||||
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
|
||||
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
|
||||
self.target_widget = target_widget
|
||||
self.mouse_mode = None
|
||||
|
||||
# Create each MaterialIconAction with a parent
|
||||
# so the signals can fire even if the toolbar isn't added yet.
|
||||
drag = MaterialIconAction(
|
||||
icon_name="drag_pan",
|
||||
tooltip="Drag Mouse Mode",
|
||||
checkable=True,
|
||||
parent=self.target_widget, # or any valid parent
|
||||
)
|
||||
rect = MaterialIconAction(
|
||||
icon_name="frame_inspect",
|
||||
tooltip="Rectangle Zoom Mode",
|
||||
checkable=True,
|
||||
parent=self.target_widget,
|
||||
)
|
||||
auto = MaterialIconAction(
|
||||
icon_name="open_in_full",
|
||||
tooltip="Autorange Plot",
|
||||
checkable=False,
|
||||
parent=self.target_widget,
|
||||
)
|
||||
|
||||
self.switch_mouse_action = SwitchableToolBarAction(
|
||||
actions={"drag_mode": drag, "rectangle_mode": rect},
|
||||
initial_action="drag_mode",
|
||||
tooltip="Mouse Modes",
|
||||
checkable=True,
|
||||
parent=self.target_widget,
|
||||
)
|
||||
|
||||
# Add them to the bundle
|
||||
self.add_action("switch_mouse", self.switch_mouse_action)
|
||||
self.add_action("auto_range", auto)
|
||||
|
||||
# Immediately connect signals
|
||||
drag.action.toggled.connect(self.enable_mouse_pan_mode)
|
||||
rect.action.toggled.connect(self.enable_mouse_rectangle_mode)
|
||||
auto.action.triggered.connect(self.autorange_plot)
|
||||
|
||||
def get_viewbox_mode(self):
|
||||
"""
|
||||
Returns the current interaction mode of a PyQtGraph ViewBox and sets the corresponding action.
|
||||
"""
|
||||
|
||||
if self.target_widget:
|
||||
viewbox = self.target_widget.plot_item.getViewBox()
|
||||
if viewbox.getState()["mouseMode"] == 3:
|
||||
self.switch_mouse_action.set_default_action("drag_mode")
|
||||
self.switch_mouse_action.main_button.setChecked(True)
|
||||
self.mouse_mode = "PanMode"
|
||||
elif viewbox.getState()["mouseMode"] == 1:
|
||||
self.switch_mouse_action.set_default_action("rectangle_mode")
|
||||
self.switch_mouse_action.main_button.setChecked(True)
|
||||
self.mouse_mode = "RectMode"
|
||||
|
||||
@SafeSlot(bool)
|
||||
def enable_mouse_rectangle_mode(self, checked: bool):
|
||||
"""
|
||||
Enable the rectangle zoom mode on the plot widget.
|
||||
"""
|
||||
if self.mouse_mode == "RectMode":
|
||||
self.switch_mouse_action.main_button.setChecked(True)
|
||||
return
|
||||
self.actions["switch_mouse"].actions["drag_mode"].action.setChecked(not checked)
|
||||
if self.target_widget and checked:
|
||||
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
|
||||
self.mouse_mode = "RectMode"
|
||||
|
||||
@SafeSlot(bool)
|
||||
def enable_mouse_pan_mode(self, checked: bool):
|
||||
"""
|
||||
Enable the pan mode on the plot widget.
|
||||
"""
|
||||
if self.mouse_mode == "PanMode":
|
||||
self.switch_mouse_action.main_button.setChecked(True)
|
||||
return
|
||||
self.actions["switch_mouse"].actions["rectangle_mode"].action.setChecked(not checked)
|
||||
if self.target_widget and checked:
|
||||
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
|
||||
self.mouse_mode = "PanMode"
|
||||
|
||||
@SafeSlot()
|
||||
def autorange_plot(self):
|
||||
"""
|
||||
Enable autorange on the plot widget.
|
||||
"""
|
||||
if self.target_widget:
|
||||
self.target_widget.auto_range_x = True
|
||||
self.target_widget.auto_range_y = True
|
||||
@@ -1,81 +0,0 @@
|
||||
import traceback
|
||||
|
||||
from pyqtgraph.exporters import MatplotlibExporter
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot, WarningPopupUtility
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction, ToolbarBundle
|
||||
|
||||
|
||||
class PlotExportBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions that are hooked in this constructor itself,
|
||||
so that you can immediately connect the signals and toggle states.
|
||||
|
||||
This bundle is for a toolbar that controls exporting a plot.
|
||||
"""
|
||||
|
||||
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
|
||||
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
|
||||
self.target_widget = target_widget
|
||||
|
||||
# Create each MaterialIconAction with a parent
|
||||
# so the signals can fire even if the toolbar isn't added yet.
|
||||
save = MaterialIconAction(
|
||||
icon_name="save", tooltip="Open Export Dialog", parent=self.target_widget
|
||||
)
|
||||
matplotlib = MaterialIconAction(
|
||||
icon_name="photo_library", tooltip="Open Matplotlib Dialog", parent=self.target_widget
|
||||
)
|
||||
|
||||
switch_export_action = SwitchableToolBarAction(
|
||||
actions={"save": save, "matplotlib": matplotlib},
|
||||
initial_action="save",
|
||||
tooltip="Switchable Action",
|
||||
checkable=False,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
# Add them to the bundle
|
||||
self.add_action("export_switch", switch_export_action)
|
||||
|
||||
# Immediately connect signals
|
||||
save.action.triggered.connect(self.export_dialog)
|
||||
matplotlib.action.triggered.connect(self.matplotlib_dialog)
|
||||
|
||||
@SafeSlot()
|
||||
def export_dialog(self):
|
||||
"""
|
||||
Open the export dialog for the plot widget.
|
||||
"""
|
||||
if self.target_widget:
|
||||
scene = self.target_widget.plot_item.scene()
|
||||
scene.contextMenuItem = self.target_widget.plot_item
|
||||
scene.showExportDialog()
|
||||
|
||||
@SafeSlot()
|
||||
def matplotlib_dialog(self):
|
||||
"""
|
||||
Export the plot widget to Matplotlib.
|
||||
"""
|
||||
if self.target_widget:
|
||||
try:
|
||||
import matplotlib as mpl
|
||||
|
||||
MatplotlibExporter(self.target_widget.plot_item).export()
|
||||
except ModuleNotFoundError:
|
||||
warning_util = WarningPopupUtility()
|
||||
warning_util.show_warning(
|
||||
title="Matplotlib not installed",
|
||||
message="Matplotlib is required for this feature.",
|
||||
detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.",
|
||||
)
|
||||
return
|
||||
except TypeError:
|
||||
warning_util = WarningPopupUtility()
|
||||
error_msg = traceback.format_exc()
|
||||
warning_util.show_warning(
|
||||
title="Matplotlib TypeError",
|
||||
message="Matplotlib exporter could not resolve the plot item.",
|
||||
detailed_text=error_msg,
|
||||
)
|
||||
return
|
||||
@@ -1,31 +0,0 @@
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ToolbarBundle
|
||||
|
||||
|
||||
class ROIBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions that are hooked in this constructor itself,
|
||||
so that you can immediately connect the signals and toggle states.
|
||||
|
||||
This bundle is for a toolbar that controls crosshair and ROI interaction.
|
||||
"""
|
||||
|
||||
def __init__(self, bundle_id="roi", target_widget=None, **kwargs):
|
||||
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
|
||||
self.target_widget = target_widget
|
||||
|
||||
# Create each MaterialIconAction with a parent
|
||||
# so the signals can fire even if the toolbar isn't added yet.
|
||||
crosshair = MaterialIconAction(
|
||||
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
|
||||
)
|
||||
reset_legend = MaterialIconAction(
|
||||
icon_name="restart_alt", tooltip="Reset the position of legend.", checkable=False
|
||||
)
|
||||
|
||||
# Add them to the bundle
|
||||
self.add_action("crosshair", crosshair)
|
||||
self.add_action("reset_legend", reset_legend)
|
||||
|
||||
# Immediately connect signals
|
||||
crosshair.action.toggled.connect(self.target_widget.toggle_crosshair)
|
||||
reset_legend.action.triggered.connect(self.target_widget.reset_legend)
|
||||
@@ -1,48 +0,0 @@
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ToolbarBundle
|
||||
|
||||
|
||||
class SaveStateBundle(ToolbarBundle):
|
||||
"""
|
||||
A bundle of actions that are hooked in this constructor itself,
|
||||
so that you can immediately connect the signals and toggle states.
|
||||
|
||||
This bundle is for a toolbar that controls saving the state of the widget.
|
||||
"""
|
||||
|
||||
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
|
||||
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
|
||||
self.target_widget = target_widget
|
||||
|
||||
# Create each MaterialIconAction with a parent
|
||||
# so the signals can fire even if the toolbar isn't added yet.
|
||||
save_state = MaterialIconAction(
|
||||
icon_name="download", tooltip="Save Widget State", parent=self.target_widget
|
||||
)
|
||||
load_state = MaterialIconAction(
|
||||
icon_name="upload", tooltip="Load Widget State", parent=self.target_widget
|
||||
)
|
||||
|
||||
# Add them to the bundle
|
||||
self.add_action("save", save_state)
|
||||
self.add_action("matplotlib", load_state)
|
||||
|
||||
# Immediately connect signals
|
||||
save_state.action.triggered.connect(self.save_state_dialog)
|
||||
load_state.action.triggered.connect(self.load_state_dialog)
|
||||
|
||||
@SafeSlot()
|
||||
def save_state_dialog(self):
|
||||
"""
|
||||
Open the export dialog to save a state of the widget.
|
||||
"""
|
||||
if self.target_widget:
|
||||
self.target_widget.state_manager.save_state()
|
||||
|
||||
@SafeSlot()
|
||||
def load_state_dialog(self):
|
||||
"""
|
||||
Load a saved state of the widget.
|
||||
"""
|
||||
if self.target_widget:
|
||||
self.target_widget.state_manager.load_state()
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.widgets.plots.setting_menus.axis_settings import AxisSettings
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.toolbars.toolbar import ToolbarComponents
|
||||
|
||||
|
||||
def axis_popup_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates an axis popup toolbar bundle.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The axis popup toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"axis_settings_popup",
|
||||
MaterialIconAction(
|
||||
icon_name="settings",
|
||||
tooltip="Show Axis Settings",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("axis_popup", components)
|
||||
bundle.add_action("axis_settings_popup")
|
||||
return bundle
|
||||
|
||||
|
||||
class AxisSettingsPopupConnection(BundleConnection):
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "axis_popup"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
self.axis_settings_dialog = None
|
||||
self._connected = False
|
||||
super().__init__()
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action_reference("axis_settings_popup")().action.triggered.connect(
|
||||
self.show_axis_settings_popup
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action_reference("axis_settings_popup")().action.triggered.disconnect(
|
||||
self.show_axis_settings_popup
|
||||
)
|
||||
|
||||
def show_axis_settings_popup(self):
|
||||
"""
|
||||
Show the axis settings dialog.
|
||||
"""
|
||||
settings_action = self.components.get_action_reference("axis_settings_popup")().action
|
||||
if self.axis_settings_dialog is None or not self.axis_settings_dialog.isVisible():
|
||||
axis_setting = AxisSettings(
|
||||
parent=self.target_widget, target_widget=self.target_widget, popup=True
|
||||
)
|
||||
self.axis_settings_dialog = SettingsDialog(
|
||||
self.target_widget,
|
||||
settings_widget=axis_setting,
|
||||
window_title="Axis Settings",
|
||||
modal=False,
|
||||
)
|
||||
# When the dialog is closed, update the toolbar icon and clear the reference
|
||||
self.axis_settings_dialog.finished.connect(self._axis_settings_closed)
|
||||
self.axis_settings_dialog.show()
|
||||
settings_action.setChecked(True)
|
||||
else:
|
||||
# If already open, bring it to the front
|
||||
self.axis_settings_dialog.raise_()
|
||||
self.axis_settings_dialog.activateWindow()
|
||||
settings_action.setChecked(True) # keep it toggled
|
||||
|
||||
def _axis_settings_closed(self):
|
||||
"""
|
||||
Slot for when the axis settings dialog is closed.
|
||||
"""
|
||||
self.axis_settings_dialog = None
|
||||
self.components.get_action_reference("axis_settings_popup")().action.setChecked(False)
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pyqtgraph as pg
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.toolbars.toolbar import ToolbarComponents
|
||||
|
||||
|
||||
def mouse_interaction_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a mouse interaction toolbar bundle.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The mouse interaction toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"mouse_drag",
|
||||
MaterialIconAction(
|
||||
icon_name="drag_pan",
|
||||
tooltip="Drag Mouse Mode",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"mouse_rect",
|
||||
MaterialIconAction(
|
||||
icon_name="frame_inspect",
|
||||
tooltip="Rectangle Zoom Mode",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"auto_range",
|
||||
MaterialIconAction(
|
||||
icon_name="open_in_full",
|
||||
tooltip="Autorange Plot",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"switch_mouse_mode",
|
||||
SwitchableToolBarAction(
|
||||
actions={
|
||||
"drag_mode": components.get_action_reference("mouse_drag")(),
|
||||
"rectangle_mode": components.get_action_reference("mouse_rect")(),
|
||||
},
|
||||
initial_action="drag_mode",
|
||||
tooltip="Mouse Modes",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
default_state_checked=True,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("mouse_interaction", components)
|
||||
bundle.add_action("switch_mouse_mode")
|
||||
bundle.add_action("auto_range")
|
||||
return bundle
|
||||
|
||||
|
||||
class MouseInteractionConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for mouse interaction toolbar bundle.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "mouse_interaction"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
self.mouse_mode = None
|
||||
if (
|
||||
not hasattr(self.target_widget, "plot_item")
|
||||
or not hasattr(self.target_widget, "auto_range_x")
|
||||
or not hasattr(self.target_widget, "auto_range_y")
|
||||
):
|
||||
raise AttributeError(
|
||||
"Target widget must implement required methods for mouse interactions."
|
||||
)
|
||||
super().__init__()
|
||||
self._connected = False # Track if the connection has been made
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
drag = self.components.get_action_reference("mouse_drag")()
|
||||
rect = self.components.get_action_reference("mouse_rect")()
|
||||
auto = self.components.get_action_reference("auto_range")()
|
||||
|
||||
drag.action.toggled.connect(self.enable_mouse_pan_mode)
|
||||
rect.action.toggled.connect(self.enable_mouse_rectangle_mode)
|
||||
auto.action.triggered.connect(self.autorange_plot)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
drag = self.components.get_action_reference("mouse_drag")()
|
||||
rect = self.components.get_action_reference("mouse_rect")()
|
||||
auto = self.components.get_action_reference("auto_range")()
|
||||
drag.action.toggled.disconnect(self.enable_mouse_pan_mode)
|
||||
rect.action.toggled.disconnect(self.enable_mouse_rectangle_mode)
|
||||
auto.action.triggered.disconnect(self.autorange_plot)
|
||||
|
||||
def get_viewbox_mode(self):
|
||||
"""
|
||||
Returns the current interaction mode of a PyQtGraph ViewBox and sets the corresponding action.
|
||||
"""
|
||||
|
||||
if self.target_widget:
|
||||
viewbox = self.target_widget.plot_item.getViewBox()
|
||||
switch_mouse_action = self.components.get_action_reference("switch_mouse_mode")()
|
||||
if viewbox.getState()["mouseMode"] == 3:
|
||||
switch_mouse_action.set_default_action("drag_mode")
|
||||
switch_mouse_action.main_button.setChecked(True)
|
||||
self.mouse_mode = "PanMode"
|
||||
elif viewbox.getState()["mouseMode"] == 1:
|
||||
switch_mouse_action.set_default_action("rectangle_mode")
|
||||
switch_mouse_action.main_button.setChecked(True)
|
||||
self.mouse_mode = "RectMode"
|
||||
|
||||
@SafeSlot(bool)
|
||||
def enable_mouse_rectangle_mode(self, checked: bool):
|
||||
"""
|
||||
Enable the rectangle zoom mode on the plot widget.
|
||||
"""
|
||||
switch_mouse_action = self.components.get_action_reference("switch_mouse_mode")()
|
||||
if self.mouse_mode == "RectMode":
|
||||
switch_mouse_action.main_button.setChecked(True)
|
||||
return
|
||||
drag_mode = self.components.get_action_reference("mouse_drag")()
|
||||
drag_mode.action.setChecked(not checked)
|
||||
if self.target_widget and checked:
|
||||
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
|
||||
self.mouse_mode = "RectMode"
|
||||
|
||||
@SafeSlot(bool)
|
||||
def enable_mouse_pan_mode(self, checked: bool):
|
||||
"""
|
||||
Enable the pan mode on the plot widget.
|
||||
"""
|
||||
if self.mouse_mode == "PanMode":
|
||||
switch_mouse_action = self.components.get_action_reference("switch_mouse_mode")()
|
||||
switch_mouse_action.main_button.setChecked(True)
|
||||
return
|
||||
rect_mode = self.components.get_action_reference("mouse_rect")()
|
||||
rect_mode.action.setChecked(not checked)
|
||||
if self.target_widget and checked:
|
||||
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
|
||||
self.mouse_mode = "PanMode"
|
||||
|
||||
@SafeSlot()
|
||||
def autorange_plot(self):
|
||||
"""
|
||||
Enable autorange on the plot widget.
|
||||
"""
|
||||
if self.target_widget:
|
||||
self.target_widget.auto_range()
|
||||
123
bec_widgets/widgets/plots/toolbar_components/plot_export.py
Normal file
123
bec_widgets/widgets/plots/toolbar_components/plot_export.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot, WarningPopupUtility
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
|
||||
def plot_export_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a plot export toolbar bundle.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The plot export toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"save",
|
||||
MaterialIconAction(
|
||||
icon_name="save", tooltip="Open Export Dialog", parent=components.toolbar
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"matplotlib",
|
||||
MaterialIconAction(
|
||||
icon_name="photo_library", tooltip="Open Matplotlib Dialog", parent=components.toolbar
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"export_switch",
|
||||
SwitchableToolBarAction(
|
||||
actions={
|
||||
"save": components.get_action_reference("save")(),
|
||||
"matplotlib": components.get_action_reference("matplotlib")(),
|
||||
},
|
||||
initial_action="save",
|
||||
tooltip="Export Plot",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("plot_export", components)
|
||||
bundle.add_action("export_switch")
|
||||
return bundle
|
||||
|
||||
|
||||
def plot_export_connection(components: ToolbarComponents, target_widget=None):
|
||||
"""
|
||||
Connects the plot export actions to the target widget.
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be connected.
|
||||
target_widget: The widget to which the actions will be connected.
|
||||
"""
|
||||
|
||||
|
||||
class PlotExportConnection(BundleConnection):
|
||||
def __init__(self, components: ToolbarComponents, target_widget):
|
||||
super().__init__()
|
||||
self.bundle_name = "plot_export"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
self._connected = False # Track if the connection has been made
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the actions to the target widget
|
||||
self.components.get_action_reference("save")().action.triggered.connect(self.export_dialog)
|
||||
self.components.get_action_reference("matplotlib")().action.triggered.connect(
|
||||
self.matplotlib_dialog
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the actions from the target widget
|
||||
self.components.get_action_reference("save")().action.triggered.disconnect(
|
||||
self.export_dialog
|
||||
)
|
||||
self.components.get_action_reference("matplotlib")().action.triggered.disconnect(
|
||||
self.matplotlib_dialog
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def export_dialog(self):
|
||||
"""
|
||||
Open the export dialog for the plot widget.
|
||||
"""
|
||||
if self.target_widget:
|
||||
scene = self.target_widget.plot_item.scene()
|
||||
scene.contextMenuItem = self.target_widget.plot_item
|
||||
scene.showExportDialog()
|
||||
|
||||
@SafeSlot()
|
||||
def matplotlib_dialog(self):
|
||||
"""
|
||||
Export the plot widget to Matplotlib.
|
||||
"""
|
||||
if self.target_widget:
|
||||
try:
|
||||
import matplotlib as mpl
|
||||
|
||||
MatplotlibExporter(self.target_widget.plot_item).export()
|
||||
except ModuleNotFoundError:
|
||||
warning_util = WarningPopupUtility()
|
||||
warning_util.show_warning(
|
||||
title="Matplotlib not installed",
|
||||
message="Matplotlib is required for this feature.",
|
||||
detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.",
|
||||
)
|
||||
return
|
||||
except TypeError:
|
||||
warning_util = WarningPopupUtility()
|
||||
error_msg = traceback.format_exc()
|
||||
warning_util.show_warning(
|
||||
title="Matplotlib TypeError",
|
||||
message="Matplotlib exporter could not resolve the plot item.",
|
||||
detailed_text=error_msg,
|
||||
)
|
||||
return
|
||||
79
bec_widgets/widgets/plots/toolbar_components/roi.py
Normal file
79
bec_widgets/widgets/plots/toolbar_components/roi.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
|
||||
def roi_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a toolbar bundle for ROI and crosshair interaction.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The ROI toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"crosshair",
|
||||
MaterialIconAction(
|
||||
icon_name="point_scan",
|
||||
tooltip="Show Crosshair",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
components.add_safe(
|
||||
"reset_legend",
|
||||
MaterialIconAction(
|
||||
icon_name="restart_alt",
|
||||
tooltip="Reset the position of legend.",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("roi", components)
|
||||
bundle.add_action("crosshair")
|
||||
bundle.add_action("reset_legend")
|
||||
return bundle
|
||||
|
||||
|
||||
class RoiConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for the ROI toolbar bundle.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "roi"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if not hasattr(self.target_widget, "toggle_crosshair") or not hasattr(
|
||||
self.target_widget, "reset_legend"
|
||||
):
|
||||
raise AttributeError(
|
||||
"Target widget must implement 'toggle_crosshair' and 'reset_legend'."
|
||||
)
|
||||
super().__init__()
|
||||
self._connected = False
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action_reference("crosshair")().action.toggled.connect(
|
||||
self.target_widget.toggle_crosshair
|
||||
)
|
||||
self.components.get_action_reference("reset_legend")().action.triggered.connect(
|
||||
self.target_widget.reset_legend
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action_reference("crosshair")().action.toggled.disconnect(
|
||||
self.target_widget.toggle_crosshair
|
||||
)
|
||||
self.components.get_action_reference("reset_legend")().action.triggered.disconnect(
|
||||
self.target_widget.reset_legend
|
||||
)
|
||||
@@ -25,7 +25,9 @@ from bec_widgets import SafeSlot
|
||||
from bec_widgets.utils import ConnectionConfig, EntryValidator
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
|
||||
@@ -379,43 +381,67 @@ class CurveTree(BECWidget, QWidget):
|
||||
|
||||
def _init_toolbar(self):
|
||||
"""Initialize the toolbar with actions: add, send, refresh, expand, collapse, renormalize."""
|
||||
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
|
||||
add = MaterialIconAction(
|
||||
icon_name="add", tooltip="Add new curve", checkable=False, parent=self
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
|
||||
self.toolbar.components.add_safe(
|
||||
"add",
|
||||
MaterialIconAction(
|
||||
icon_name="add", tooltip="Add new curve", checkable=False, parent=self
|
||||
),
|
||||
)
|
||||
expand = MaterialIconAction(
|
||||
icon_name="unfold_more", tooltip="Expand All DAP", checkable=False, parent=self
|
||||
self.toolbar.components.add_safe(
|
||||
"expand",
|
||||
MaterialIconAction(
|
||||
icon_name="unfold_more", tooltip="Expand All DAP", checkable=False, parent=self
|
||||
),
|
||||
)
|
||||
collapse = MaterialIconAction(
|
||||
icon_name="unfold_less", tooltip="Collapse All DAP", checkable=False, parent=self
|
||||
self.toolbar.components.add_safe(
|
||||
"collapse",
|
||||
MaterialIconAction(
|
||||
icon_name="unfold_less", tooltip="Collapse All DAP", checkable=False, parent=self
|
||||
),
|
||||
)
|
||||
|
||||
self.toolbar.add_action("add", add, self)
|
||||
self.toolbar.add_action("expand_all", expand, self)
|
||||
self.toolbar.add_action("collapse_all", collapse, self)
|
||||
bundle = ToolbarBundle("curve_tree", self.toolbar.components)
|
||||
bundle.add_action("add")
|
||||
bundle.add_action("expand")
|
||||
bundle.add_action("collapse")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
# Add colormap widget (not updating waveform's color_palette until Send is pressed)
|
||||
self.spacer = QWidget()
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
spacer = QWidget()
|
||||
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
|
||||
bundle.add_action("spacer")
|
||||
|
||||
# Renormalize colors button
|
||||
renorm_action = MaterialIconAction(
|
||||
icon_name="palette", tooltip="Normalize All Colors", checkable=False, parent=self
|
||||
self.toolbar.components.add_safe(
|
||||
"renormalize_colors",
|
||||
MaterialIconAction(
|
||||
icon_name="palette", tooltip="Normalize All Colors", checkable=False, parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.add_action("renormalize_colors", renorm_action, self)
|
||||
bundle.add_action("renormalize_colors")
|
||||
renorm_action = self.toolbar.components.get_action("renormalize_colors")
|
||||
renorm_action.action.triggered.connect(lambda checked: self.renormalize_colors())
|
||||
|
||||
self.colormap_widget = BECColorMapWidget(cmap=self.color_palette or "plasma")
|
||||
self.toolbar.addWidget(self.colormap_widget)
|
||||
self.toolbar.components.add_safe(
|
||||
"colormap_widget", WidgetAction(widget=self.colormap_widget)
|
||||
)
|
||||
bundle.add_action("colormap_widget")
|
||||
self.colormap_widget.colormap_changed_signal.connect(self.handle_colormap_changed)
|
||||
|
||||
add = self.toolbar.components.get_action("add")
|
||||
expand = self.toolbar.components.get_action("expand")
|
||||
collapse = self.toolbar.components.get_action("collapse")
|
||||
add.action.triggered.connect(lambda checked: self.add_new_curve())
|
||||
expand.action.triggered.connect(lambda checked: self.expand_all_daps())
|
||||
collapse.action.triggered.connect(lambda checked: self.collapse_all_daps())
|
||||
|
||||
self.layout.addWidget(self.toolbar)
|
||||
|
||||
self.toolbar.show_bundles(["curve_tree"])
|
||||
|
||||
def _init_tree(self):
|
||||
"""Initialize the QTreeWidget with 7 columns and compact widths."""
|
||||
self.tree = QTreeWidget()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Literal
|
||||
from typing import Literal
|
||||
|
||||
import lmfit
|
||||
import numpy as np
|
||||
@@ -29,7 +29,7 @@ from bec_widgets.utils.colors import Colors, set_theme
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal
|
||||
@@ -96,6 +96,7 @@ class Waveform(PlotBase):
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"auto_range",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
@@ -182,6 +183,7 @@ class Waveform(PlotBase):
|
||||
self._init_roi_manager()
|
||||
self.dap_summary = None
|
||||
self.dap_summary_dialog = None
|
||||
self._add_fit_parameters_popup()
|
||||
self._enable_roi_toolbar_action(False) # default state where are no dap curves
|
||||
self._init_curve_dialog()
|
||||
self.curve_settings_dialog = None
|
||||
@@ -214,6 +216,8 @@ class Waveform(PlotBase):
|
||||
# To fix the ViewAll action with clipToView activated
|
||||
self._connect_viewbox_menu_actions()
|
||||
|
||||
self.toolbar.show_bundles(["plot_export", "mouse_interaction", "roi", "axis_popup"])
|
||||
|
||||
def _connect_viewbox_menu_actions(self):
|
||||
"""Connect the viewbox menu action ViewAll to the custom reset_view method."""
|
||||
menu = self.plot_item.vb.menu
|
||||
@@ -247,21 +251,21 @@ class Waveform(PlotBase):
|
||||
super().add_side_menus()
|
||||
self._add_dap_summary_side_menu()
|
||||
|
||||
def add_popups(self):
|
||||
def _add_fit_parameters_popup(self):
|
||||
"""
|
||||
Add popups to the Waveform widget.
|
||||
"""
|
||||
super().add_popups()
|
||||
LMFitDialog_action = MaterialIconAction(
|
||||
icon_name="monitoring", tooltip="Open Fit Parameters", checkable=True, parent=self
|
||||
self.toolbar.components.add_safe(
|
||||
"fit_params",
|
||||
MaterialIconAction(
|
||||
icon_name="monitoring", tooltip="Open Fit Parameters", checkable=True, parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="popup_bundle",
|
||||
action_id="fit_params",
|
||||
action=LMFitDialog_action,
|
||||
target_widget=self,
|
||||
self.toolbar.get_bundle("axis_popup").add_action("fit_params")
|
||||
|
||||
self.toolbar.components.get_action("fit_params").action.triggered.connect(
|
||||
self.show_dap_summary_popup
|
||||
)
|
||||
self.toolbar.widgets["fit_params"].action.triggered.connect(self.show_dap_summary_popup)
|
||||
|
||||
@SafeSlot()
|
||||
def _reset_view(self):
|
||||
@@ -290,14 +294,17 @@ class Waveform(PlotBase):
|
||||
Initialize the ROI manager for the Waveform widget.
|
||||
"""
|
||||
# Add toolbar icon
|
||||
roi = MaterialIconAction(
|
||||
icon_name="align_justify_space_between",
|
||||
tooltip="Add ROI region for DAP",
|
||||
checkable=True,
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="roi", action_id="roi_linear", action=roi, target_widget=self
|
||||
self.toolbar.components.add_safe(
|
||||
"roi_linear",
|
||||
MaterialIconAction(
|
||||
icon_name="align_justify_space_between",
|
||||
tooltip="Add ROI region for DAP",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
),
|
||||
)
|
||||
self.toolbar.get_bundle("roi").add_action("roi_linear")
|
||||
|
||||
self._roi_manager = WaveformROIManager(self.plot_item, parent=self)
|
||||
|
||||
# Connect manager signals -> forward them via Waveform's own signals
|
||||
@@ -307,23 +314,30 @@ class Waveform(PlotBase):
|
||||
# Example: connect ROI changed to re-request DAP
|
||||
self.roi_changed.connect(self._on_roi_changed_for_dap)
|
||||
self._roi_manager.roi_active.connect(self.request_dap_update)
|
||||
self.toolbar.widgets["roi_linear"].action.toggled.connect(self._roi_manager.toggle_roi)
|
||||
self.toolbar.components.get_action("roi_linear").action.toggled.connect(
|
||||
self._roi_manager.toggle_roi
|
||||
)
|
||||
|
||||
def _init_curve_dialog(self):
|
||||
"""
|
||||
Initializes the Curve dialog within the toolbar.
|
||||
"""
|
||||
curve_settings = MaterialIconAction(
|
||||
icon_name="timeline", tooltip="Show Curve dialog.", checkable=True
|
||||
self.toolbar.components.add_safe(
|
||||
"curve",
|
||||
MaterialIconAction(
|
||||
icon_name="timeline", tooltip="Show Curve dialog.", checkable=True, parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.get_bundle("axis_popup").add_action("curve")
|
||||
self.toolbar.components.get_action("curve").action.triggered.connect(
|
||||
self.show_curve_settings_popup
|
||||
)
|
||||
self.toolbar.add_action("curve", curve_settings, target_widget=self)
|
||||
self.toolbar.widgets["curve"].action.triggered.connect(self.show_curve_settings_popup)
|
||||
|
||||
def show_curve_settings_popup(self):
|
||||
"""
|
||||
Displays the curve settings popup to allow users to modify curve-related configurations.
|
||||
"""
|
||||
curve_action = self.toolbar.widgets["curve"].action
|
||||
curve_action = self.toolbar.components.get_action("curve").action
|
||||
|
||||
if self.curve_settings_dialog is None or not self.curve_settings_dialog.isVisible():
|
||||
curve_setting = CurveSetting(parent=self, target_widget=self)
|
||||
@@ -347,7 +361,7 @@ class Waveform(PlotBase):
|
||||
self.curve_settings_dialog.close()
|
||||
self.curve_settings_dialog.deleteLater()
|
||||
self.curve_settings_dialog = None
|
||||
self.toolbar.widgets["curve"].action.setChecked(False)
|
||||
self.toolbar.components.get_action("curve").action.setChecked(False)
|
||||
|
||||
@property
|
||||
def roi_region(self) -> tuple[float, float] | None:
|
||||
@@ -394,9 +408,9 @@ class Waveform(PlotBase):
|
||||
Args:
|
||||
enable(bool): Enable or disable the ROI toolbar action.
|
||||
"""
|
||||
self.toolbar.widgets["roi_linear"].action.setEnabled(enable)
|
||||
self.toolbar.components.get_action("roi_linear").action.setEnabled(enable)
|
||||
if enable is False:
|
||||
self.toolbar.widgets["roi_linear"].action.setChecked(False)
|
||||
self.toolbar.components.get_action("roi_linear").action.setChecked(False)
|
||||
self._roi_manager.toggle_roi(False)
|
||||
|
||||
################################################################################
|
||||
@@ -420,7 +434,7 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
Show the DAP summary popup.
|
||||
"""
|
||||
fit_action = self.toolbar.widgets["fit_params"].action
|
||||
fit_action = self.toolbar.components.get_action("fit_params").action
|
||||
if self.dap_summary_dialog is None or not self.dap_summary_dialog.isVisible():
|
||||
self.dap_summary = LMFitDialog(parent=self)
|
||||
self.dap_summary_dialog = QDialog(modal=False)
|
||||
@@ -446,7 +460,7 @@ class Waveform(PlotBase):
|
||||
self.dap_summary.deleteLater()
|
||||
self.dap_summary_dialog.deleteLater()
|
||||
self.dap_summary_dialog = None
|
||||
self.toolbar.widgets["fit_params"].action.setChecked(False)
|
||||
self.toolbar.components.get_action("fit_params").action.setChecked(False)
|
||||
|
||||
def _get_dap_from_target_widget(self) -> None:
|
||||
"""Get the DAP data from the target widget and update the DAP dialog manually on creation."""
|
||||
@@ -1096,8 +1110,7 @@ class Waveform(PlotBase):
|
||||
self.reset()
|
||||
self.new_scan.emit()
|
||||
self.new_scan_id.emit(current_scan_id)
|
||||
self.auto_range_x = True
|
||||
self.auto_range_y = True
|
||||
self.auto_range(True)
|
||||
self.old_scan_id = self.scan_id
|
||||
self.scan_id = current_scan_id
|
||||
self.scan_item = self.queue.scan_storage.find_scan_by_ID(self.scan_id) # live scan
|
||||
|
||||
@@ -62,7 +62,9 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
ICON_NAME = "page_control"
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
super().__init__(
|
||||
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
|
||||
)
|
||||
|
||||
accent_colors = get_accent_colors()
|
||||
|
||||
@@ -89,7 +91,14 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
|
||||
# Progress‑bar state handling
|
||||
self._state = ProgressState.NORMAL
|
||||
self._state_colors = dict(PROGRESS_STATE_COLORS)
|
||||
# self._state_colors = dict(PROGRESS_STATE_COLORS)
|
||||
|
||||
self._state_colors = {
|
||||
ProgressState.NORMAL: accent_colors.default,
|
||||
ProgressState.PAUSED: accent_colors.warning,
|
||||
ProgressState.INTERRUPTED: accent_colors.emergency,
|
||||
ProgressState.COMPLETED: accent_colors.success,
|
||||
}
|
||||
|
||||
# layout settings
|
||||
self._padding_left_right = 10
|
||||
@@ -127,6 +136,16 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
"""
|
||||
return self._label_template
|
||||
|
||||
def apply_theme(self, theme=None):
|
||||
"""Apply the current theme to the progress bar."""
|
||||
accent_colors = get_accent_colors()
|
||||
self._state_colors = {
|
||||
ProgressState.NORMAL: accent_colors.default,
|
||||
ProgressState.PAUSED: accent_colors.warning,
|
||||
ProgressState.INTERRUPTED: accent_colors.emergency,
|
||||
ProgressState.COMPLETED: accent_colors.success,
|
||||
}
|
||||
|
||||
@label_template.setter
|
||||
def label_template(self, template):
|
||||
self._label_template = template
|
||||
|
||||
@@ -9,7 +9,9 @@ from qtpy.QtWidgets import QHeaderView, QLabel, QTableWidget, QTableWidgetItem,
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils.toolbar import ModularToolBar, SeparatorAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
|
||||
from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton
|
||||
from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton
|
||||
@@ -76,17 +78,26 @@ class BECQueue(BECWidget, CompactPopupWidget):
|
||||
"""
|
||||
widget_label = QLabel(text="Live Queue", parent=self)
|
||||
widget_label.setStyleSheet("font-weight: bold;")
|
||||
self.toolbar = ModularToolBar(
|
||||
parent=self,
|
||||
actions={
|
||||
"widget_label": WidgetAction(widget=widget_label),
|
||||
"separator_1": SeparatorAction(),
|
||||
"resume": WidgetAction(widget=ResumeButton(parent=self, toolbar=False)),
|
||||
"stop": WidgetAction(widget=StopButton(parent=self, toolbar=False)),
|
||||
"reset": WidgetAction(widget=ResetButton(parent=self, toolbar=False)),
|
||||
},
|
||||
target_widget=self,
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.toolbar.components.add_safe("widget_label", WidgetAction(widget=widget_label))
|
||||
bundle = ToolbarBundle("queue_label", self.toolbar.components)
|
||||
bundle.add_action("widget_label")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
self.toolbar.add_action(
|
||||
"resume", WidgetAction(widget=ResumeButton(parent=self, toolbar=True))
|
||||
)
|
||||
self.toolbar.add_action("stop", WidgetAction(widget=StopButton(parent=self, toolbar=True)))
|
||||
self.toolbar.add_action(
|
||||
"reset", WidgetAction(widget=ResetButton(parent=self, toolbar=True))
|
||||
)
|
||||
|
||||
control_bundle = ToolbarBundle("control", self.toolbar.components)
|
||||
control_bundle.add_action("resume")
|
||||
control_bundle.add_action("stop")
|
||||
control_bundle.add_action("reset")
|
||||
self.toolbar.add_bundle(control_bundle)
|
||||
self.toolbar.show_bundles(["queue_label", "control"])
|
||||
|
||||
self.addWidget(self.toolbar)
|
||||
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
import os
|
||||
import re
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
|
||||
import bec_lib
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ConfigAction
|
||||
from bec_lib.messages import ConfigAction, ScanStatusMessage
|
||||
from bec_qthemes import material_icon
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QVBoxLayout, QWidget
|
||||
from qtpy.QtCore import QSize, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QFileDialog,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
DeviceConfigDialog,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -24,7 +39,9 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
DeviceBrowser is a widget that displays all available devices in the current BEC session.
|
||||
"""
|
||||
|
||||
device_update: Signal = Signal()
|
||||
devices_changed: Signal = Signal()
|
||||
editing_enabled: Signal = Signal(bool)
|
||||
device_update: Signal = Signal(str, dict)
|
||||
PLUGIN = True
|
||||
ICON_NAME = "lists"
|
||||
|
||||
@@ -38,22 +55,34 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
) -> None:
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self.get_bec_shortcuts()
|
||||
self._config_helper = ConfigHelper(self.client.connector, self.client._service_name)
|
||||
self._q_threadpool = QThreadPool()
|
||||
self.ui = None
|
||||
self.ini_ui()
|
||||
self.init_ui()
|
||||
self.dev_list: QListWidget = self.ui.device_list
|
||||
self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
|
||||
self.proxy_device_update = SignalProxy(
|
||||
self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list
|
||||
)
|
||||
self.bec_dispatcher.client.callbacks.register(
|
||||
self._device_update_callback_id = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.on_device_update
|
||||
)
|
||||
self.device_update.connect(self.update_device_list)
|
||||
self._scan_status_callback_id = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.SCAN_STATUS, self.scan_status_changed
|
||||
)
|
||||
self._default_config_dir = os.path.abspath(
|
||||
os.path.join(os.path.dirname(bec_lib.__file__), "./configs/")
|
||||
)
|
||||
|
||||
self.devices_changed.connect(self.update_device_list)
|
||||
|
||||
self.init_warning_label()
|
||||
self.init_tool_buttons()
|
||||
|
||||
self.init_device_list()
|
||||
self.update_device_list()
|
||||
|
||||
def ini_ui(self) -> None:
|
||||
def init_ui(self) -> None:
|
||||
"""
|
||||
Initialize the UI by loading the UI file and setting the layout.
|
||||
"""
|
||||
@@ -63,6 +92,31 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
layout.addWidget(self.ui)
|
||||
self.setLayout(layout)
|
||||
|
||||
def init_warning_label(self):
|
||||
self.ui.scan_running_warning.setText("Warning: editing diabled while scan is running!")
|
||||
self.ui.scan_running_warning.setStyleSheet(
|
||||
"background-color: #fcba03; color: rgb(0, 0, 0);"
|
||||
)
|
||||
scan_status = self.bec_dispatcher.client.connector.get(MessageEndpoints.scan_status())
|
||||
initial_status = scan_status.status if scan_status is not None else "closed"
|
||||
self.set_editing_mode(initial_status not in ["open", "paused"])
|
||||
|
||||
def init_tool_buttons(self):
|
||||
def _setup_button(button: QToolButton, icon: str, slot: Callable, tooltip: str = ""):
|
||||
button.clicked.connect(slot)
|
||||
button.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
|
||||
button.setToolTip(tooltip)
|
||||
|
||||
_setup_button(self.ui.add_button, "add", self._create_add_dialog, "add new device")
|
||||
_setup_button(self.ui.save_button, "save", self._save_to_file, "save config to file")
|
||||
_setup_button(
|
||||
self.ui.import_button, "input", self._load_from_file, "append/merge config from file"
|
||||
)
|
||||
|
||||
def _create_add_dialog(self):
|
||||
dialog = DeviceConfigDialog(parent=self, device=None, action="add")
|
||||
dialog.open()
|
||||
|
||||
def on_device_update(self, action: ConfigAction, content: dict) -> None:
|
||||
"""
|
||||
Callback for device update events. Triggers the device_update signal.
|
||||
@@ -72,35 +126,61 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
content (dict): The content of the config update.
|
||||
"""
|
||||
if action in ["add", "remove", "reload"]:
|
||||
self.device_update.emit()
|
||||
self.devices_changed.emit()
|
||||
if action in ["update", "reload"]:
|
||||
self.device_update.emit(action, content)
|
||||
|
||||
def init_device_list(self):
|
||||
self.dev_list.clear()
|
||||
self._device_items: dict[str, QListWidgetItem] = {}
|
||||
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for device, device_obj in self.dev.items():
|
||||
self._add_item_to_list(device, device_obj)
|
||||
|
||||
def _add_item_to_list(self, device: str, device_obj):
|
||||
def _updatesize(item: QListWidgetItem, device_item: DeviceItem):
|
||||
device_item.adjustSize()
|
||||
item.setSizeHint(QSize(device_item.width(), device_item.height()))
|
||||
logger.debug(f"Adjusting {item} size to {device_item.width(), device_item.height()}")
|
||||
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for device, device_obj in self.dev.items():
|
||||
item = QListWidgetItem(self.dev_list)
|
||||
device_item = DeviceItem(
|
||||
parent=self,
|
||||
device=device,
|
||||
devices=self.dev,
|
||||
icon=map_device_type_to_icon(device_obj),
|
||||
)
|
||||
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
|
||||
tooltip = self.dev[device]._config.get("description", "")
|
||||
device_item.setToolTip(tooltip)
|
||||
device_item.broadcast_size_hint.connect(item.setSizeHint)
|
||||
item.setSizeHint(device_item.sizeHint())
|
||||
def _remove_item(item: QListWidgetItem):
|
||||
self.dev_list.takeItem(self.dev_list.row(item))
|
||||
del self._device_items[device]
|
||||
self.dev_list.sortItems()
|
||||
|
||||
self.dev_list.setItemWidget(item, device_item)
|
||||
self.dev_list.addItem(item)
|
||||
self._device_items[device] = item
|
||||
item = QListWidgetItem(self.dev_list)
|
||||
device_item = DeviceItem(
|
||||
parent=self,
|
||||
device=device,
|
||||
devices=self.dev,
|
||||
icon=map_device_type_to_icon(device_obj),
|
||||
config_helper=self._config_helper,
|
||||
q_threadpool=self._q_threadpool,
|
||||
)
|
||||
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
|
||||
device_item.imminent_deletion.connect(partial(_remove_item, item))
|
||||
self.editing_enabled.connect(device_item.set_editable)
|
||||
self.device_update.connect(device_item.config_update)
|
||||
tooltip = self.dev[device]._config.get("description", "")
|
||||
device_item.setToolTip(tooltip)
|
||||
device_item.broadcast_size_hint.connect(item.setSizeHint)
|
||||
item.setSizeHint(device_item.sizeHint())
|
||||
|
||||
self.dev_list.setItemWidget(item, device_item)
|
||||
self.dev_list.addItem(item)
|
||||
self._device_items[device] = item
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def scan_status_changed(self, scan_info: dict, _: dict):
|
||||
"""disable editing when scans are running and enable editing when they are finished"""
|
||||
msg = ScanStatusMessage.model_validate(scan_info)
|
||||
self.set_editing_mode(msg.status not in ["open", "paused"])
|
||||
|
||||
def set_editing_mode(self, enabled: bool):
|
||||
self.ui.add_button.setEnabled(enabled)
|
||||
self.ui.scan_running_warning.setHidden(enabled)
|
||||
self.editing_enabled.emit(enabled)
|
||||
|
||||
@SafeSlot()
|
||||
def reset_device_list(self) -> None:
|
||||
@@ -119,6 +199,10 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
Either way, the function will filter the devices based on the filter input text and update the device list.
|
||||
"""
|
||||
filter_text = self.ui.filter_input.text()
|
||||
for device in self.dev:
|
||||
if device not in self._device_items:
|
||||
# it is possible the device has just been added to the config
|
||||
self._add_item_to_list(device, self.dev[device])
|
||||
try:
|
||||
self.regex = re.compile(filter_text, re.IGNORECASE)
|
||||
except re.error:
|
||||
@@ -129,6 +213,27 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
for device in self.dev:
|
||||
self._device_items[device].setHidden(not self.regex.search(device))
|
||||
|
||||
@SafeSlot()
|
||||
def _load_from_file(self):
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Update config from file", self._default_config_dir, "Config files (*.yml *.yaml)"
|
||||
)
|
||||
if file_path:
|
||||
self._config_helper.update_session_with_file(file_path)
|
||||
|
||||
@SafeSlot()
|
||||
def _save_to_file(self):
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Save config to file", self._default_config_dir, "Config files (*.yml *.yaml)"
|
||||
)
|
||||
if file_path:
|
||||
self._config_helper.save_current_session(file_path)
|
||||
|
||||
def cleanup(self):
|
||||
super().cleanup()
|
||||
self.bec_dispatcher.client.callbacks.remove(self._scan_status_callback_id)
|
||||
self.bec_dispatcher.client.callbacks.remove(self._device_update_callback_id)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
@@ -29,8 +29,57 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="button_box">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QToolButton" name="add_button">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="save_button">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="import_button">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="scan_running_warning">
|
||||
<property name="styleSheet">
|
||||
<string notr="true"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>warning</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QListWidget" name="device_list"/>
|
||||
</item>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ConfigAction
|
||||
from qtpy.QtCore import QObject, QRunnable, Signal
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class _CommSignals(QObject):
|
||||
error = Signal(Exception)
|
||||
done = Signal()
|
||||
|
||||
|
||||
class CommunicateConfigAction(QRunnable):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_helper: ConfigHelper,
|
||||
device: str | None,
|
||||
config: dict | None,
|
||||
action: ConfigAction,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.config_helper = config_helper
|
||||
if action in ["add", "update"] and config is None:
|
||||
raise ValueError("Must supply config to add or update a device")
|
||||
self.device = device
|
||||
self.config = config or {}
|
||||
self.action = action
|
||||
self.signals = _CommSignals()
|
||||
|
||||
@SafeSlot()
|
||||
def run(self):
|
||||
try:
|
||||
if self.action in ["add", "update", "remove"]:
|
||||
if (dev_name := self.device or self.config.get("name")) is None:
|
||||
raise ValueError(
|
||||
"Must be updating a device or be supplied a name for a new device"
|
||||
)
|
||||
if "deviceConfig" not in self.config or self.action in ["add", "remove"]:
|
||||
self.process_simple_action(dev_name)
|
||||
else:
|
||||
# updating an existing device, but need to recreate it for this change
|
||||
self.process_remove_readd(dev_name)
|
||||
else:
|
||||
raise ValueError(f"action {self.action} is not supported")
|
||||
except Exception as e:
|
||||
self.signals.error.emit(e)
|
||||
else:
|
||||
self.signals.done.emit()
|
||||
|
||||
def process_simple_action(self, dev_name: str, action: ConfigAction | None = None):
|
||||
req_args = {
|
||||
"action": action or self.action,
|
||||
"config": {dev_name: self.config},
|
||||
"wait_for_response": False,
|
||||
}
|
||||
timeout = (
|
||||
self.config_helper.suggested_timeout_s(self.config) if self.config is not None else 20
|
||||
)
|
||||
RID = self.config_helper.send_config_request(**req_args)
|
||||
logger.info("Waiting for config reply")
|
||||
reply = self.config_helper.wait_for_config_reply(RID, timeout=timeout)
|
||||
self.config_helper.handle_update_reply(reply, RID, timeout)
|
||||
logger.info("Done updating config!")
|
||||
|
||||
def process_remove_readd(self, dev_name: str):
|
||||
logger.info(f"Removing and readding device: {dev_name}")
|
||||
self.process_simple_action(dev_name, "remove")
|
||||
self.process_simple_action(dev_name, "add")
|
||||
logger.info(f"Reinstated {dev_name} successfully!")
|
||||
@@ -1,10 +1,12 @@
|
||||
from ast import literal_eval
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal
|
||||
from pydantic import ValidationError, field_validator
|
||||
from qtpy.QtCore import QSize, Qt, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
@@ -17,6 +19,9 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
|
||||
DeviceConfigForm,
|
||||
)
|
||||
@@ -25,35 +30,13 @@ from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class _CommSignals(QObject):
|
||||
error = Signal(Exception)
|
||||
done = Signal()
|
||||
|
||||
|
||||
class _CommunicateUpdate(QRunnable):
|
||||
|
||||
def __init__(self, config_helper: ConfigHelper, device: str, config: dict) -> None:
|
||||
super().__init__()
|
||||
self.config_helper = config_helper
|
||||
self.device = device
|
||||
self.config = config
|
||||
self.signals = _CommSignals()
|
||||
|
||||
@SafeSlot()
|
||||
def run(self):
|
||||
try:
|
||||
timeout = self.config_helper.suggested_timeout_s(self.config)
|
||||
RID = self.config_helper.send_config_request(
|
||||
action="update", config={self.device: self.config}, wait_for_response=False
|
||||
)
|
||||
logger.info("Waiting for config reply")
|
||||
reply = self.config_helper.wait_for_config_reply(RID, timeout=timeout)
|
||||
self.config_helper.handle_update_reply(reply, RID, timeout)
|
||||
logger.info("Done updating config!")
|
||||
except Exception as e:
|
||||
self.signals.error.emit(e)
|
||||
finally:
|
||||
self.signals.done.emit()
|
||||
def _try_literal_eval(value: str):
|
||||
if value == "":
|
||||
return ""
|
||||
try:
|
||||
return literal_eval(value)
|
||||
except SyntaxError as e:
|
||||
raise ValueError(f"Entered config value {value} is not a valid python value!") from e
|
||||
|
||||
|
||||
class DeviceConfigDialog(BECWidget, QDialog):
|
||||
@@ -62,17 +45,31 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
parent=None,
|
||||
device: str | None = None,
|
||||
config_helper: ConfigHelper | None = None,
|
||||
action: Literal["update", "add"] = "update",
|
||||
threadpool: QThreadPool | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""A dialog to edit the configuration of a device in BEC. Generated from the pydantic model
|
||||
for device specification in bec_lib.atlas_models.
|
||||
|
||||
Args:
|
||||
parent (QObject): the parent QObject
|
||||
device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries.
|
||||
config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary.
|
||||
action (Literal["update", "add"]): the action which the form should perform on application or acceptance.
|
||||
"""
|
||||
self._initial_config = {}
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._config_helper = config_helper or ConfigHelper(
|
||||
self.client.connector, self.client._service_name
|
||||
)
|
||||
self.threadpool = QThreadPool()
|
||||
self._device = device
|
||||
self._action: Literal["update", "add"] = action
|
||||
self._q_threadpool = threadpool or QThreadPool()
|
||||
self.setWindowTitle(f"Edit config for: {device}")
|
||||
self._container = QStackedLayout()
|
||||
self._container.setStackingMode(QStackedLayout.StackAll)
|
||||
@@ -85,13 +82,37 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
user_warning.setWordWrap(True)
|
||||
user_warning.setStyleSheet("QLabel { color: red; }")
|
||||
self._layout.addWidget(user_warning)
|
||||
self.get_bec_shortcuts()
|
||||
self._add_form()
|
||||
if self._action == "update":
|
||||
self._form._validity.setVisible(False)
|
||||
else:
|
||||
self._set_schema_to_check_devices()
|
||||
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
|
||||
# self._form._validity.setVisible(True)
|
||||
self._form.validity_proc.connect(self.enable_buttons_for_validity)
|
||||
self._add_overlay()
|
||||
self._add_buttons()
|
||||
|
||||
self.setLayout(self._container)
|
||||
self._form.validate_form()
|
||||
self._overlay_widget.setVisible(False)
|
||||
|
||||
def _set_schema_to_check_devices(self):
|
||||
class _NameValidatedConfigModel(DeviceConfigModel):
|
||||
@field_validator("name")
|
||||
@staticmethod
|
||||
def _validate_name(value: str, *_):
|
||||
if not value.isidentifier():
|
||||
raise ValueError(
|
||||
f"Invalid device name: {value}. Device names must be valid Python identifiers."
|
||||
)
|
||||
if value in self.dev:
|
||||
raise ValueError(f"A device with name {value} already exists!")
|
||||
return value
|
||||
|
||||
self._form.set_schema(_NameValidatedConfigModel)
|
||||
|
||||
def _add_form(self):
|
||||
self._form_widget = QWidget()
|
||||
self._form_widget.setLayout(self._layout)
|
||||
@@ -99,11 +120,15 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
self._layout.addWidget(self._form)
|
||||
|
||||
for row in self._form.enumerate_form_widgets():
|
||||
if row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE:
|
||||
if (
|
||||
row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE
|
||||
and self._action == "update"
|
||||
):
|
||||
row.widget._set_pretty_display()
|
||||
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
if self._action == "update" and self._device in self.dev:
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
self._container.addWidget(self._form_widget)
|
||||
|
||||
def _add_overlay(self):
|
||||
@@ -120,16 +145,15 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
self._container.addWidget(self._overlay_widget)
|
||||
|
||||
def _add_buttons(self):
|
||||
button_box = QDialogButtonBox(
|
||||
self.button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.Apply | QDialogButtonBox.Ok | QDialogButtonBox.Cancel
|
||||
)
|
||||
button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
self._layout.addWidget(button_box)
|
||||
self.button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply)
|
||||
self.button_box.accepted.connect(self.accept)
|
||||
self.button_box.rejected.connect(self.reject)
|
||||
self._layout.addWidget(self.button_box)
|
||||
|
||||
def _fetch_config(self):
|
||||
self._initial_config = {}
|
||||
if (
|
||||
self.client.device_manager is not None
|
||||
and self._device in self.client.device_manager.devices
|
||||
@@ -144,60 +168,83 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
diff = {
|
||||
k: v for k, v in new_config.items() if self._initial_config.get(k) != new_config.get(k)
|
||||
}
|
||||
if self._initial_config.get("deviceConfig") in [{}, None] and new_config.get(
|
||||
"deviceConfig"
|
||||
) in [{}, None]:
|
||||
diff.pop("deviceConfig", None)
|
||||
if diff.get("deviceConfig") is not None:
|
||||
# TODO: special cased in some parts of device manager but not others, should
|
||||
# be removed in config update as with below issue
|
||||
diff["deviceConfig"].pop("device_access", None)
|
||||
# TODO: replace when https://github.com/bec-project/bec/issues/528 is resolved
|
||||
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
|
||||
diff["deviceConfig"] = {
|
||||
k: literal_eval(str(v)) for k, v in diff["deviceConfig"].items()
|
||||
k: _try_literal_eval(str(v)) for k, v in diff["deviceConfig"].items() if k != ""
|
||||
}
|
||||
|
||||
# Due to above issues, if deviceConfig changes we must remove and recreate the device - so we need the whole config
|
||||
if "deviceConfig" in diff:
|
||||
new_config["deviceConfig"] = diff["deviceConfig"]
|
||||
return new_config
|
||||
return diff
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def enable_buttons_for_validity(self, valid: bool):
|
||||
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
|
||||
for button in [
|
||||
self.button_box.button(b) for b in [QDialogButtonBox.Apply, QDialogButtonBox.Ok]
|
||||
]:
|
||||
button.setEnabled(valid)
|
||||
button.setToolTip(self._form._validity_message.text())
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def apply(self):
|
||||
self._process_update_action()
|
||||
self._process_action()
|
||||
self.applied.emit()
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(popup_error=True)
|
||||
def accept(self):
|
||||
self._process_update_action()
|
||||
self._process_action()
|
||||
return super().accept()
|
||||
|
||||
def _process_update_action(self):
|
||||
def _process_action(self):
|
||||
updated_config = self.updated_config()
|
||||
if (device_name := updated_config.get("name")) == "":
|
||||
logger.warning("Can't create a device with no name!")
|
||||
elif set(updated_config.keys()) & set(DEVICE_CONF_KEYS.NON_UPDATABLE):
|
||||
logger.info(
|
||||
f"Removing old device {self._device} and adding new device {device_name or self._device} with modified config: {updated_config}"
|
||||
)
|
||||
if self._action == "add":
|
||||
if (name := updated_config.get("name")) in self.dev:
|
||||
raise ValueError(
|
||||
f"Can't create a new device with the same name as already existing device {name}!"
|
||||
)
|
||||
self._proc_device_config_change(updated_config)
|
||||
else:
|
||||
self._update_device_config(updated_config)
|
||||
if updated_config == {}:
|
||||
logger.info("No changes made to device config")
|
||||
return
|
||||
self._proc_device_config_change(updated_config)
|
||||
|
||||
def _update_device_config(self, config: dict):
|
||||
if self._device is None:
|
||||
return
|
||||
if config == {}:
|
||||
logger.info("No changes made to device config")
|
||||
return
|
||||
logger.info(f"Sending request to update device config: {config}")
|
||||
def _proc_device_config_change(self, config: dict):
|
||||
logger.info(f"Sending request to {self._action} device config: {config}")
|
||||
|
||||
self._start_waiting_display()
|
||||
communicate_update = _CommunicateUpdate(self._config_helper, self._device, config)
|
||||
communicate_update = CommunicateConfigAction(
|
||||
self._config_helper, self._device, config, self._action
|
||||
)
|
||||
communicate_update.signals.error.connect(self.update_error)
|
||||
communicate_update.signals.done.connect(self.update_done)
|
||||
self.threadpool.start(communicate_update)
|
||||
self._q_threadpool.start(communicate_update)
|
||||
|
||||
@SafeSlot()
|
||||
def update_done(self):
|
||||
self._stop_waiting_display()
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
if self._action == "update":
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
|
||||
@SafeSlot(Exception, popup_error=True)
|
||||
def update_error(self, e: Exception):
|
||||
raise RuntimeError("Failed to update device configuration") from e
|
||||
self._stop_waiting_display()
|
||||
if self._action == "update":
|
||||
self._fetch_config()
|
||||
self._fill_form()
|
||||
raise e
|
||||
|
||||
def _start_waiting_display(self):
|
||||
self._overlay_widget.setVisible(True)
|
||||
@@ -238,7 +285,8 @@ def main(): # pragma: no cover
|
||||
def _show_dialog(*_):
|
||||
nonlocal dialog
|
||||
if dialog is None:
|
||||
dialog = DeviceConfigDialog(device=device.text())
|
||||
kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"}
|
||||
dialog = DeviceConfigDialog(**kwargs)
|
||||
dialog.accepted.connect(accept)
|
||||
dialog.rejected.connect(_destroy_dialog)
|
||||
dialog.open()
|
||||
|
||||
@@ -42,6 +42,7 @@ class DeviceConfigForm(PydanticModelForm):
|
||||
if theme is None:
|
||||
theme = get_theme_name()
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
self._validity.setVisible(False)
|
||||
|
||||
def get_form_data(self):
|
||||
"""Get the entered metadata as a dict."""
|
||||
@@ -54,7 +55,9 @@ class DeviceConfigForm(PydanticModelForm):
|
||||
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
|
||||
|
||||
def set_schema(self, schema: type[BaseModel]):
|
||||
raise TypeError("This class doesn't support changing the schema")
|
||||
if not issubclass(schema, DeviceConfigModel):
|
||||
raise TypeError("This class doesn't support changing the schema")
|
||||
super().set_schema(schema)
|
||||
|
||||
def set_data(self, data: DeviceConfigModel): # type: ignore # This class locks the type
|
||||
super().set_data(data)
|
||||
|
||||
@@ -3,15 +3,20 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.devicemanager import DeviceContainer
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ConfigAction
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QMimeData, QSize, Qt, Signal
|
||||
from qtpy.QtCore import QMimeData, QSize, Qt, QThreadPool, Signal
|
||||
from qtpy.QtGui import QDrag
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QTabWidget, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
DeviceConfigDialog,
|
||||
)
|
||||
@@ -31,10 +36,20 @@ logger = bec_logger.logger
|
||||
|
||||
class DeviceItem(ExpandableGroupFrame):
|
||||
broadcast_size_hint = Signal(QSize)
|
||||
imminent_deletion = Signal()
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent, device: str, devices: DeviceContainer, icon: str = "") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
parent,
|
||||
device: str,
|
||||
devices: DeviceContainer,
|
||||
icon: str = "",
|
||||
config_helper: ConfigHelper,
|
||||
q_threadpool: QThreadPool | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent, title=device, expanded=False, icon=icon)
|
||||
self.dev = devices
|
||||
self._drag_pos = None
|
||||
@@ -48,35 +63,64 @@ class DeviceItem(ExpandableGroupFrame):
|
||||
self._tab_widget.setDocumentMode(True)
|
||||
self._layout.addWidget(self._tab_widget)
|
||||
|
||||
self.set_layout(self._layout)
|
||||
|
||||
self._form_page = QWidget()
|
||||
self._form_page = QWidget(parent=self)
|
||||
self._form_page_layout = QVBoxLayout()
|
||||
self._form_page.setLayout(self._form_page_layout)
|
||||
|
||||
self._signal_page = QWidget()
|
||||
self._signal_page = QWidget(parent=self)
|
||||
self._signal_page_layout = QVBoxLayout()
|
||||
self._signal_page.setLayout(self._signal_page_layout)
|
||||
|
||||
self._tab_widget.addTab(self._form_page, "Configuration")
|
||||
self._tab_widget.addTab(self._signal_page, "Signals")
|
||||
self._config_helper = config_helper
|
||||
self._q_threadpool = q_threadpool or QThreadPool()
|
||||
|
||||
self.set_layout(self._layout)
|
||||
self.adjustSize()
|
||||
|
||||
def _create_title_layout(self, title: str, icon: str):
|
||||
super()._create_title_layout(title, icon)
|
||||
|
||||
self.edit_button = QToolButton()
|
||||
self.edit_button.setIcon(
|
||||
material_icon(icon_name="edit", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self.edit_button.setIcon(material_icon(icon_name="edit", size=(15, 15)))
|
||||
self._title_layout.insertWidget(self._title_layout.count() - 1, self.edit_button)
|
||||
self.edit_button.clicked.connect(self._create_edit_dialog)
|
||||
|
||||
self.delete_button = QToolButton()
|
||||
self.delete_button.setIcon(material_icon(icon_name="delete", size=(15, 15)))
|
||||
self._title_layout.insertWidget(self._title_layout.count() - 1, self.delete_button)
|
||||
self.delete_button.clicked.connect(self._delete_device)
|
||||
|
||||
@SafeSlot()
|
||||
def _create_edit_dialog(self):
|
||||
dialog = DeviceConfigDialog(parent=self, device=self.device)
|
||||
dialog = DeviceConfigDialog(
|
||||
parent=self,
|
||||
device=self.device,
|
||||
config_helper=self._config_helper,
|
||||
threadpool=self._q_threadpool,
|
||||
)
|
||||
dialog.accepted.connect(self._reload_config)
|
||||
dialog.applied.connect(self._reload_config)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot()
|
||||
def _delete_device(self):
|
||||
self.expanded = False
|
||||
deleter = CommunicateConfigAction(self._config_helper, self.device, None, "remove")
|
||||
deleter.signals.error.connect(self._deletion_error)
|
||||
deleter.signals.done.connect(self._deletion_done)
|
||||
self._q_threadpool.start(deleter)
|
||||
|
||||
@SafeSlot(Exception, popup_error=True)
|
||||
def _deletion_error(self, e: Exception):
|
||||
raise e
|
||||
|
||||
@SafeSlot()
|
||||
def _deletion_done(self):
|
||||
self.imminent_deletion.emit()
|
||||
self.deleteLater()
|
||||
|
||||
@SafeSlot()
|
||||
def switch_expanded_state(self):
|
||||
if not self.expanded and not self._expanded_first_time:
|
||||
@@ -96,6 +140,16 @@ class DeviceItem(ExpandableGroupFrame):
|
||||
self.adjustSize()
|
||||
self.broadcast_size_hint.emit(self.sizeHint())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def set_editable(self, enabled: bool):
|
||||
self.edit_button.setEnabled(enabled)
|
||||
self.delete_button.setEnabled(enabled)
|
||||
|
||||
@SafeSlot(str, dict)
|
||||
def config_update(self, action: ConfigAction, content: dict) -> None:
|
||||
if self.device in content:
|
||||
self._reload_config()
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def _reload_config(self, *_):
|
||||
self.set_display_config(self.dev[self.device]._config)
|
||||
@@ -157,7 +211,12 @@ if __name__ == "__main__": # pragma: no cover
|
||||
"deviceTags": {"tag1", "tag2", "tag3"},
|
||||
"userParameter": {"some_setting": "some_ value"},
|
||||
}
|
||||
item = DeviceItem(widget, "Device", {"Device": MagicMock(enabled=True, _config=mock_config)})
|
||||
item = DeviceItem(
|
||||
parent=widget,
|
||||
device="Device",
|
||||
devices={"Device": MagicMock(enabled=True, _config=mock_config)}, # type: ignore
|
||||
config_helper=ConfigHelper(MagicMock()),
|
||||
)
|
||||
layout.addWidget(DarkModeButton())
|
||||
layout.addWidget(item)
|
||||
widget.show()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from bec_lib.device import Device
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QToolButton, QVBoxLayout, QWidget
|
||||
@@ -5,6 +6,7 @@ from qtpy.QtWidgets import QHBoxLayout, QLabel, QToolButton, QVBoxLayout, QWidge
|
||||
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.ophyd_kind_util import Kind
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock
|
||||
from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel
|
||||
|
||||
@@ -35,9 +37,9 @@ class SignalDisplay(BECWidget, QWidget):
|
||||
|
||||
@SafeSlot()
|
||||
def _refresh(self):
|
||||
if self.device in self.dev:
|
||||
self.dev.get(self.device).read(cached=False)
|
||||
self.dev.get(self.device).read_configuration(cached=False)
|
||||
if (dev := self.dev.get(self.device)) is not None:
|
||||
dev.read()
|
||||
dev.read_configuration()
|
||||
|
||||
def _add_refresh_button(self):
|
||||
button_holder = QWidget()
|
||||
@@ -63,11 +65,26 @@ class SignalDisplay(BECWidget, QWidget):
|
||||
self._add_refresh_button()
|
||||
|
||||
if self._device in self.dev:
|
||||
for sig in self.dev[self.device]._info.get("signals", {}).keys():
|
||||
if isinstance(self.dev[self.device], Device):
|
||||
for sig, info in self.dev[self.device]._info.get("signals", {}).items():
|
||||
if info.get("kind_str") in [
|
||||
Kind.hinted.name,
|
||||
Kind.normal.name,
|
||||
Kind.config.name,
|
||||
]:
|
||||
self._content_layout.addWidget(
|
||||
SignalLabel(
|
||||
device=self._device,
|
||||
signal=sig,
|
||||
show_select_button=False,
|
||||
show_default_units=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self._content_layout.addWidget(
|
||||
SignalLabel(
|
||||
device=self._device,
|
||||
signal=sig,
|
||||
signal=self._device,
|
||||
show_select_button=False,
|
||||
show_default_units=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from .scan_history_device_viewer import ScanHistoryDeviceViewer
|
||||
from .scan_history_metadata_viewer import ScanHistoryMetadataViewer
|
||||
from .scan_history_view import ScanHistoryView
|
||||
|
||||
__all__ = ["ScanHistoryDeviceViewer", "ScanHistoryMetadataViewer", "ScanHistoryView"]
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Module for displaying scan history devices in a viewer widget."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanHistoryMessage
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.messages import _StoredDataInfo
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class SignalModel(QtCore.QAbstractListModel):
|
||||
"""Custom model for displaying scan history signals in a combo box."""
|
||||
|
||||
def __init__(self, parent=None, signals: dict[str, _StoredDataInfo] = None):
|
||||
super().__init__(parent)
|
||||
if signals is None:
|
||||
signals = {}
|
||||
self._signals: list[tuple[str, _StoredDataInfo]] = sorted(
|
||||
signals.items(), key=lambda x: -x[1].shape[0]
|
||||
)
|
||||
|
||||
@property
|
||||
def signals(self) -> list[tuple[str, _StoredDataInfo]]:
|
||||
"""Return the list of devices."""
|
||||
return self._signals
|
||||
|
||||
@signals.setter
|
||||
def signals(self, value: dict[str, _StoredDataInfo]):
|
||||
self.beginResetModel()
|
||||
self._signals = sorted(value.items(), key=lambda x: -x[1].shape[0])
|
||||
self.endResetModel()
|
||||
|
||||
def rowCount(self, parent=QtCore.QModelIndex()):
|
||||
return len(self._signals)
|
||||
|
||||
def data(self, index, role=QtCore.Qt.DisplayRole):
|
||||
if not index.isValid():
|
||||
return None
|
||||
name, info = self.signals[index.row()]
|
||||
if role == QtCore.Qt.DisplayRole:
|
||||
return f"{name} {info.shape}" # fallback display
|
||||
elif role == QtCore.Qt.UserRole:
|
||||
return name
|
||||
elif role == QtCore.Qt.UserRole + 1:
|
||||
return info.shape
|
||||
return None
|
||||
|
||||
|
||||
# Custom delegate for better formatting
|
||||
class SignalDelegate(QtWidgets.QStyledItemDelegate):
|
||||
"""Custom delegate for displaying device names and points in the combo box."""
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
name = index.data(QtCore.Qt.UserRole)
|
||||
points = index.data(QtCore.Qt.UserRole + 1)
|
||||
|
||||
painter.save()
|
||||
painter.drawText(
|
||||
option.rect.adjusted(5, 0, -5, 0), QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft, name
|
||||
)
|
||||
painter.drawText(
|
||||
option.rect.adjusted(5, 0, -5, 0),
|
||||
QtCore.Qt.AlignVCenter | QtCore.Qt.AlignRight,
|
||||
str(points),
|
||||
)
|
||||
painter.restore()
|
||||
|
||||
def sizeHint(self, option, index):
|
||||
return QtCore.QSize(200, 24)
|
||||
|
||||
|
||||
class ScanHistoryDeviceViewer(BECWidget, QtWidgets.QWidget):
|
||||
"""ScanHistoryTree is a widget that displays the scan history in a tree format."""
|
||||
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
request_history_plot = QtCore.Signal(str, str, str) # (scan_id, device_name, signal_name)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget = None,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str = None,
|
||||
theme_update: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
client=client,
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
theme_update=theme_update,
|
||||
**kwargs,
|
||||
)
|
||||
# Current scan history message
|
||||
self.scan_history_msg: ScanHistoryMessage | None = None
|
||||
self._last_device_name: str | None = None
|
||||
self._last_signal_name: str | None = None
|
||||
# Init layout
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
self.setLayout(layout)
|
||||
# Init widgets
|
||||
self.device_combo = QtWidgets.QComboBox(parent=self)
|
||||
self.signal_combo = QtWidgets.QComboBox(parent=self)
|
||||
colors = get_accent_colors()
|
||||
self.request_plotting_button = QtWidgets.QPushButton(
|
||||
material_icon("play_arrow", size=(24, 24), color=colors.success),
|
||||
"Request Plotting",
|
||||
self,
|
||||
)
|
||||
self.signal_model = SignalModel(parent=self.signal_combo)
|
||||
self.signal_combo.setModel(self.signal_model)
|
||||
self.signal_combo.setItemDelegate(SignalDelegate())
|
||||
self._init_layout()
|
||||
# Connect signals
|
||||
self.request_plotting_button.clicked.connect(self._on_request_plotting_clicked)
|
||||
self.device_combo.currentTextChanged.connect(self._signal_combo_update)
|
||||
|
||||
def _init_layout(self):
|
||||
"""Initialize the layout for the device viewer."""
|
||||
main_layout = self.layout()
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
# horzizontal layout for device combo and signal combo boxes
|
||||
widget = QtWidgets.QWidget(self)
|
||||
hor_layout = QtWidgets.QHBoxLayout()
|
||||
hor_layout.setContentsMargins(0, 0, 0, 0)
|
||||
hor_layout.setSpacing(0)
|
||||
widget.setLayout(hor_layout)
|
||||
hor_layout.addWidget(self.device_combo)
|
||||
hor_layout.addWidget(self.signal_combo)
|
||||
main_layout.addWidget(widget)
|
||||
main_layout.addWidget(self.request_plotting_button)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def update_devices_from_scan_history(self, msg: dict, metadata: dict | None = None) -> None:
|
||||
"""Update the device combo box with the scan history message.
|
||||
|
||||
Args:
|
||||
msg (ScanHistoryMessage): The scan history message containing device data.
|
||||
"""
|
||||
msg = ScanHistoryMessage(**msg)
|
||||
if metadata is not None:
|
||||
msg.metadata = metadata
|
||||
# Keep track of current device name
|
||||
self._last_device_name = self.device_combo.currentText()
|
||||
|
||||
current_signal_index = self.signal_combo.currentIndex()
|
||||
self._last_signal_name = self.signal_combo.model().data(
|
||||
self.signal_combo.model().index(current_signal_index, 0), QtCore.Qt.UserRole
|
||||
)
|
||||
# Update the scan history message
|
||||
self.scan_history_msg = msg
|
||||
self.device_combo.clear()
|
||||
self.device_combo.addItems(msg.stored_data_info.keys())
|
||||
index = self.device_combo.findData(self._last_device_name, role=QtCore.Qt.DisplayRole)
|
||||
if index != -1:
|
||||
self.device_combo.setCurrentIndex(index)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _signal_combo_update(self, device_name: str) -> None:
|
||||
"""Update the signal combo box based on the selected device."""
|
||||
if not self.scan_history_msg:
|
||||
logger.info("No scan history message available to update signals.")
|
||||
return
|
||||
if not device_name:
|
||||
return
|
||||
signal_data = self.scan_history_msg.stored_data_info.get(device_name, None)
|
||||
if signal_data is None:
|
||||
logger.info(f"No signal data found for device {device_name}.")
|
||||
return
|
||||
self.signal_model.signals = signal_data
|
||||
if self._last_signal_name is not None:
|
||||
# Try to restore the last selected signal
|
||||
index = self.signal_combo.findData(self._last_signal_name, role=QtCore.Qt.UserRole)
|
||||
if index != -1:
|
||||
self.signal_combo.setCurrentIndex(index)
|
||||
|
||||
@SafeSlot()
|
||||
def clear_view(self) -> None:
|
||||
"""Clear the device combo box."""
|
||||
self.scan_history_msg = None
|
||||
self.signal_model.signals = {}
|
||||
self.device_combo.clear()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_request_plotting_clicked(self):
|
||||
"""Handle the request plotting button click."""
|
||||
if self.scan_history_msg is None:
|
||||
logger.info("No scan history message available for plotting.")
|
||||
return
|
||||
device_name = self.device_combo.currentText()
|
||||
|
||||
signal_index = self.signal_combo.currentIndex()
|
||||
signal_name = self.signal_combo.model().data(
|
||||
self.device_combo.model().index(signal_index, 0), QtCore.Qt.UserRole
|
||||
)
|
||||
logger.info(
|
||||
f"Requesting plotting clicked: Scan ID:{self.scan_history_msg.scan_id}, device name: {device_name} with signal name: {signal_name}."
|
||||
)
|
||||
self.request_history_plot.emit(self.scan_history_msg.scan_id, device_name, signal_name)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
|
||||
main_window = QtWidgets.QMainWindow()
|
||||
central_widget = QtWidgets.QWidget()
|
||||
main_window.setCentralWidget(central_widget)
|
||||
ly = QtWidgets.QVBoxLayout(central_widget)
|
||||
ly.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
viewer = ScanHistoryDeviceViewer()
|
||||
ly.addWidget(viewer)
|
||||
main_window.show()
|
||||
app.exec_()
|
||||
app.exec_()
|
||||
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanHistoryMessage
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtGui, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ScanHistoryMetadataViewer(BECWidget, QtWidgets.QGroupBox):
|
||||
"""ScanHistoryView is a widget to display the metadata of a ScanHistoryMessage in a structured format."""
|
||||
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget | None = None,
|
||||
client=None,
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = True,
|
||||
scan_history_msg: ScanHistoryMessage | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the ScanHistoryMetadataViewer widget.
|
||||
|
||||
Args:
|
||||
parent (QtWidgets.QWidget, optional): The parent widget.
|
||||
client: The BEC client.
|
||||
config (ConnectionConfig, optional): The connection configuration.
|
||||
gui_id (str, optional): The GUI ID.
|
||||
theme_update (bool, optional): Whether to subscribe to theme updates. Defaults to True.
|
||||
scan_history_msg (ScanHistoryMessage, optional): The scan history message to display. Defaults
|
||||
"""
|
||||
super().__init__(
|
||||
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=theme_update
|
||||
)
|
||||
self._scan_history_msg_labels = {
|
||||
"scan_id": "Scan ID",
|
||||
"dataset_number": "Dataset Nr",
|
||||
"file_path": "File Path",
|
||||
"start_time": "Start Time",
|
||||
"end_time": "End Time",
|
||||
"elapsed_time": "Elapsed Time",
|
||||
"exit_status": "Status",
|
||||
"scan_name": "Scan Name",
|
||||
"num_points": "Nr of Points",
|
||||
}
|
||||
self.setTitle("No Scan Selected")
|
||||
layout = QtWidgets.QGridLayout()
|
||||
self.setLayout(layout)
|
||||
self._init_grid_layout()
|
||||
self.scan_history_msg = scan_history_msg
|
||||
if scan_history_msg is not None:
|
||||
self.update_view(self.scan_history_msg.content, self.scan_history_msg.metadata)
|
||||
self.apply_theme()
|
||||
|
||||
def apply_theme(self, theme: str | None = None):
|
||||
"""Apply the theme to the widget."""
|
||||
colors = get_theme_palette()
|
||||
palette = QtGui.QPalette()
|
||||
palette.setColor(self.backgroundRole(), colors.midlight().color())
|
||||
self.setPalette(palette)
|
||||
|
||||
def _init_grid_layout(self):
|
||||
"""Initialize the layout of the widget."""
|
||||
layout: QtWidgets.QGridLayout = self.layout()
|
||||
layout.setContentsMargins(10, 10, 10, 10)
|
||||
layout.setHorizontalSpacing(0)
|
||||
layout.setVerticalSpacing(0)
|
||||
layout.setColumnStretch(0, 0)
|
||||
layout.setColumnStretch(1, 1)
|
||||
layout.setColumnStretch(2, 0)
|
||||
|
||||
def setup_content_widget_label(self) -> None:
|
||||
"""Setup the labels for the content widget for the scan history view."""
|
||||
layout = self.layout()
|
||||
for row, k in enumerate(self._scan_history_msg_labels.keys()):
|
||||
v = self._scan_history_msg_labels[k]
|
||||
# Label for the key
|
||||
label = QtWidgets.QLabel(f"{v}:")
|
||||
layout.addWidget(label, row, 0)
|
||||
# Value field
|
||||
value_field = QtWidgets.QLabel("")
|
||||
value_field.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Preferred
|
||||
)
|
||||
layout.addWidget(value_field, row, 1)
|
||||
# Copy button
|
||||
copy_button = QtWidgets.QToolButton()
|
||||
copy_button.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum)
|
||||
copy_button.setContentsMargins(0, 0, 0, 0)
|
||||
copy_button.setStyleSheet("padding: 0px; margin: 0px; border: none;")
|
||||
copy_button.setIcon(material_icon(icon_name="content_copy", size=(16, 16)))
|
||||
copy_button.setToolTip("Copy to clipboard")
|
||||
copy_button.setVisible(False)
|
||||
copy_button.setEnabled(False)
|
||||
copy_button.clicked.connect(
|
||||
lambda _, field=value_field: QtWidgets.QApplication.clipboard().setText(
|
||||
field.text()
|
||||
)
|
||||
)
|
||||
layout.addWidget(copy_button, row, 2)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def update_view(self, msg: dict, metadata: dict | None = None) -> None:
|
||||
"""
|
||||
Update the view with the given ScanHistoryMessage.
|
||||
|
||||
Args:
|
||||
msg (ScanHistoryMessage): The message containing scan metadata.
|
||||
"""
|
||||
msg = ScanHistoryMessage(**msg)
|
||||
if metadata is not None:
|
||||
msg.metadata = metadata
|
||||
if msg == self.scan_history_msg:
|
||||
return
|
||||
self.scan_history_msg = msg
|
||||
layout = self.layout()
|
||||
if layout.count() == 0:
|
||||
self.setup_content_widget_label()
|
||||
self.setTitle(f"Metadata - Scan {msg.scan_number}")
|
||||
for row, k in enumerate(self._scan_history_msg_labels.keys()):
|
||||
if k == "elapsed_time":
|
||||
value = (
|
||||
f"{(msg.end_time - msg.start_time):.3f}s"
|
||||
if msg.start_time and msg.end_time
|
||||
else None
|
||||
)
|
||||
else:
|
||||
value = getattr(msg, k, None)
|
||||
if k in ["start_time", "end_time"]:
|
||||
value = (
|
||||
datetime.fromtimestamp(value).strftime("%a %b %d %H:%M:%S %Y")
|
||||
if value
|
||||
else None
|
||||
)
|
||||
if value is None:
|
||||
logger.warning(f"ScanHistoryMessage missing value for {k} and msg {msg}.")
|
||||
continue
|
||||
layout.itemAtPosition(row, 1).widget().setText(str(value))
|
||||
if k in ["file_path", "scan_id"]: # Enable copy for file path and scan ID
|
||||
layout.itemAtPosition(row, 2).widget().setVisible(True)
|
||||
layout.itemAtPosition(row, 2).widget().setEnabled(True)
|
||||
else:
|
||||
layout.itemAtPosition(row, 2).widget().setText("")
|
||||
layout.itemAtPosition(row, 2).widget().setToolTip("")
|
||||
|
||||
@SafeSlot()
|
||||
def clear_view(self):
|
||||
"""
|
||||
Clear the view by resetting the labels and values.
|
||||
"""
|
||||
layout = self.layout()
|
||||
lauout_counts = layout.count()
|
||||
for i in range(lauout_counts):
|
||||
item = layout.itemAt(i)
|
||||
if item.widget():
|
||||
item.widget().close()
|
||||
item.widget().deleteLater()
|
||||
self.scan_history_msg = None
|
||||
self.setTitle("No Scan Selected")
|
||||
@@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanHistoryMessage
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.client import BECClient
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECHistoryManager(QtCore.QObject):
|
||||
"""History manager for scan history operations. This class
|
||||
is responsible for emitting signals when the scan history is updated.
|
||||
"""
|
||||
|
||||
# ScanHistoryMessage.model_dump() (dict)
|
||||
scan_history_updated = QtCore.Signal(dict)
|
||||
|
||||
def __init__(self, parent, client: BECClient):
|
||||
super().__init__(parent)
|
||||
self.client = client
|
||||
self._cb_id = self.client.callbacks.register(
|
||||
event_type=EventType.SCAN_HISTORY_UPDATE, callback=self._on_scan_history_update
|
||||
)
|
||||
|
||||
def refresh_scan_history(self) -> None:
|
||||
"""Refresh the scan history from the client."""
|
||||
for scan_id in self.client.history._scan_ids: # pylint: disable=protected-access
|
||||
history_msg = self.client.history._scan_data.get(scan_id, None)
|
||||
if history_msg is None:
|
||||
logger.info(f"Scan history message for scan_id {scan_id} not found.")
|
||||
continue
|
||||
self.scan_history_updated.emit(history_msg.model_dump())
|
||||
|
||||
def _on_scan_history_update(self, history_msg: ScanHistoryMessage) -> None:
|
||||
"""Handle scan history updates from the client."""
|
||||
self.scan_history_updated.emit(history_msg.model_dump())
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up the manager by disconnecting callbacks."""
|
||||
self.client.callbacks.remove(self._cb_id)
|
||||
self.scan_history_updated.disconnect()
|
||||
|
||||
|
||||
class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
|
||||
"""ScanHistoryTree is a widget that displays the scan history in a tree format."""
|
||||
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
# ScanHistoryMessage.content, ScanHistoryMessage.metadata
|
||||
scan_selected = QtCore.Signal(dict, dict)
|
||||
no_scan_selected = QtCore.Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget = None,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str = None,
|
||||
max_length: int = 100,
|
||||
theme_update: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
client=client,
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
theme_update=theme_update,
|
||||
**kwargs,
|
||||
)
|
||||
colors = get_accent_colors()
|
||||
self.status_colors = {
|
||||
"closed": colors.success,
|
||||
"halted": colors.warning,
|
||||
"aborted": colors.emergency,
|
||||
}
|
||||
# self.status_colors = {"closed": "#00e676", "halted": "#ffca28", "aborted": "#ff5252"}
|
||||
self.column_header = ["Scan Nr", "Scan Name", "Status"]
|
||||
self.scan_history: list[ScanHistoryMessage] = [] # newest at index 0
|
||||
self.max_length = max_length # Maximum number of scan history entries to keep
|
||||
self.bec_scan_history_manager = BECHistoryManager(parent=self, client=self.client)
|
||||
self._set_policies()
|
||||
self.apply_theme()
|
||||
self.currentItemChanged.connect(self._current_item_changed)
|
||||
header = self.header()
|
||||
header.setToolTip(f"Last {self.max_length} scans in history.")
|
||||
self.bec_scan_history_manager.scan_history_updated.connect(self.update_history)
|
||||
self.refresh()
|
||||
|
||||
def _set_policies(self):
|
||||
"""Set the policies for the tree widget."""
|
||||
self.setColumnCount(len(self.column_header))
|
||||
self.setHeaderLabels(self.column_header)
|
||||
self.setRootIsDecorated(False) # allow expand arrow for per‑scan details
|
||||
self.setUniformRowHeights(True)
|
||||
self.setFocusPolicy(QtCore.Qt.StrongFocus)
|
||||
self.setAlternatingRowColors(True)
|
||||
self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
|
||||
self.setIndentation(12)
|
||||
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||||
self.setAnimated(True)
|
||||
|
||||
header = self.header()
|
||||
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
|
||||
for column in range(1, self.columnCount()):
|
||||
header.setSectionResizeMode(column, QtWidgets.QHeaderView.ResizeMode.Stretch)
|
||||
|
||||
def apply_theme(self, theme: str | None = None):
|
||||
"""Apply the theme to the widget."""
|
||||
colors = get_accent_colors()
|
||||
self.status_colors = {
|
||||
"closed": colors.success,
|
||||
"halted": colors.warning,
|
||||
"aborted": colors.emergency,
|
||||
}
|
||||
self.repaint()
|
||||
|
||||
def _current_item_changed(
|
||||
self, current: QtWidgets.QTreeWidgetItem, previous: QtWidgets.QTreeWidgetItem
|
||||
):
|
||||
"""
|
||||
Handle current item change events in the tree widget.
|
||||
|
||||
Args:
|
||||
current (QtWidgets.QTreeWidgetItem): The currently selected item.
|
||||
previous (QtWidgets.QTreeWidgetItem): The previously selected item.
|
||||
"""
|
||||
if not current:
|
||||
return
|
||||
index = self.indexOfTopLevelItem(current)
|
||||
self.scan_selected.emit(self.scan_history[index].content, self.scan_history[index].metadata)
|
||||
|
||||
@SafeSlot()
|
||||
def refresh(self):
|
||||
"""Refresh the scan history view."""
|
||||
while len(self.scan_history) > 0:
|
||||
self.remove_scan(index=0)
|
||||
self.bec_scan_history_manager.refresh_scan_history()
|
||||
|
||||
@SafeSlot(dict)
|
||||
def update_history(self, msg_dump: dict):
|
||||
"""Update the scan history with new scan data."""
|
||||
msg = ScanHistoryMessage(**msg_dump)
|
||||
self.add_scan(msg)
|
||||
self.ensure_history_max_length()
|
||||
|
||||
def ensure_history_max_length(self) -> None:
|
||||
"""
|
||||
Method to ensure the scan history does not exceed the maximum length.
|
||||
If the length exceeds the maximum, it removes the oldest entry.
|
||||
This is called after adding a new scan to the history.
|
||||
"""
|
||||
while len(self.scan_history) > self.max_length:
|
||||
logger.warning(
|
||||
f"Removing oldest scan history entry to maintain max length of {self.max_length}."
|
||||
)
|
||||
self.remove_scan(index=-1)
|
||||
|
||||
def add_scan(self, msg: ScanHistoryMessage):
|
||||
"""
|
||||
Add a scan entry to the tree widget.
|
||||
|
||||
Args:
|
||||
msg (ScanHistoryMessage): The scan history message containing scan details.
|
||||
"""
|
||||
if msg.stored_data_info is None:
|
||||
logger.info(
|
||||
f"Old scan history entry fo scan {msg.scan_id} without stored_data_info, skipping."
|
||||
)
|
||||
return
|
||||
if msg in self.scan_history:
|
||||
logger.info(f"Scan {msg.scan_id} already in history, skipping.")
|
||||
return
|
||||
self.scan_history.insert(0, msg)
|
||||
tree_item = QtWidgets.QTreeWidgetItem([str(msg.scan_number), msg.scan_name, ""])
|
||||
color = QtGui.QColor(self.status_colors.get(msg.exit_status, "#b0bec5"))
|
||||
pix = QtGui.QPixmap(10, 10)
|
||||
pix.fill(QtCore.Qt.transparent)
|
||||
with QtGui.QPainter(pix) as p:
|
||||
p.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
p.setPen(QtCore.Qt.NoPen)
|
||||
p.setBrush(color)
|
||||
p.drawEllipse(0, 0, 10, 10)
|
||||
tree_item.setIcon(2, QtGui.QIcon(pix))
|
||||
tree_item.setForeground(2, QtGui.QBrush(color))
|
||||
for col in range(tree_item.columnCount()):
|
||||
tree_item.setToolTip(col, f"Status: {msg.exit_status}")
|
||||
self.insertTopLevelItem(0, tree_item)
|
||||
tree_item.setExpanded(False)
|
||||
|
||||
def remove_scan(self, index: int):
|
||||
"""
|
||||
Remove a scan entry from the tree widget.
|
||||
We supoprt negative indexing where -1, -2, etc.
|
||||
|
||||
Args:
|
||||
index (int): The index of the scan entry to remove.
|
||||
"""
|
||||
if index < 0:
|
||||
index = len(self.scan_history) + index
|
||||
try:
|
||||
msg = self.scan_history.pop(index)
|
||||
self.no_scan_selected.emit()
|
||||
except IndexError:
|
||||
logger.warning(f"Invalid index {index} for removing scan entry from history.")
|
||||
return
|
||||
self.takeTopLevelItem(index)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget"""
|
||||
self.bec_scan_history_manager.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
from bec_widgets.widgets.services.scan_history_browser.components import (
|
||||
ScanHistoryDeviceViewer,
|
||||
ScanHistoryMetadataViewer,
|
||||
)
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QtWidgets.QApplication([])
|
||||
|
||||
main_window = QtWidgets.QMainWindow()
|
||||
central_widget = QtWidgets.QWidget()
|
||||
button = DarkModeButton()
|
||||
layout = QtWidgets.QVBoxLayout(central_widget)
|
||||
main_window.setCentralWidget(central_widget)
|
||||
|
||||
# Create a ScanHistoryBrowser instance
|
||||
browser = ScanHistoryView()
|
||||
|
||||
# Create a ScanHistoryView instance
|
||||
view = ScanHistoryMetadataViewer()
|
||||
device_viewer = ScanHistoryDeviceViewer()
|
||||
|
||||
layout.addWidget(button)
|
||||
layout.addWidget(browser)
|
||||
layout.addWidget(view)
|
||||
layout.addWidget(device_viewer)
|
||||
browser.scan_selected.connect(view.update_view)
|
||||
browser.scan_selected.connect(device_viewer.update_devices_from_scan_history)
|
||||
browser.no_scan_selected.connect(view.clear_view)
|
||||
browser.no_scan_selected.connect(device_viewer.clear_view)
|
||||
|
||||
main_window.show()
|
||||
app.exec_()
|
||||
@@ -0,0 +1,115 @@
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
|
||||
from bec_widgets.widgets.services.scan_history_browser.components import (
|
||||
ScanHistoryDeviceViewer,
|
||||
ScanHistoryMetadataViewer,
|
||||
ScanHistoryView,
|
||||
)
|
||||
|
||||
|
||||
class ScanHistoryBrowser(BECWidget, QtWidgets.QWidget):
|
||||
"""
|
||||
ScanHistoryBrowser is a widget combining the scan history view, metadata viewer, and device viewer.
|
||||
|
||||
Target is to provide a popup view for the Waveform Widget to browse the scan history.
|
||||
"""
|
||||
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget | None = None,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Initialize the ScanHistoryBrowser widget.
|
||||
|
||||
Args:
|
||||
parent (QtWidgets.QWidget, optional): The parent widget.
|
||||
client: The BEC client.
|
||||
config (ConnectionConfig, optional): The connection configuration.
|
||||
gui_id (str, optional): The GUI ID.
|
||||
theme_update (bool, optional): Whether to subscribe to theme updates. Defaults to False.
|
||||
"""
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
client=client,
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
theme_update=theme_update,
|
||||
**kwargs,
|
||||
)
|
||||
layout = QtWidgets.QHBoxLayout()
|
||||
self.setLayout(layout)
|
||||
|
||||
self.scan_history_view = ScanHistoryView(
|
||||
parent=self, client=client, config=config, gui_id=gui_id, theme_update=theme_update
|
||||
)
|
||||
self.scan_history_metadata_viewer = ScanHistoryMetadataViewer(
|
||||
parent=self, client=client, config=config, gui_id=gui_id, theme_update=theme_update
|
||||
)
|
||||
self.scan_history_device_viewer = ScanHistoryDeviceViewer(
|
||||
parent=self, client=client, config=config, gui_id=gui_id, theme_update=theme_update
|
||||
)
|
||||
|
||||
self.init_layout()
|
||||
self.connect_signals()
|
||||
|
||||
def init_layout(self):
|
||||
"""Initialize compact layout for the widget."""
|
||||
# Add Scan history view
|
||||
layout: QtWidgets.QHBoxLayout = self.layout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.scan_history_view)
|
||||
# Add metadata and device viewers in a vertical layout
|
||||
widget = QtWidgets.QWidget(self)
|
||||
vertical_layout = QtWidgets.QVBoxLayout()
|
||||
vertical_layout.setContentsMargins(0, 0, 0, 0)
|
||||
vertical_layout.setSpacing(0)
|
||||
vertical_layout.addWidget(self.scan_history_metadata_viewer)
|
||||
vertical_layout.addWidget(self.scan_history_device_viewer)
|
||||
widget.setLayout(vertical_layout)
|
||||
# Add the vertical layout widget to the main layout
|
||||
layout.addWidget(widget)
|
||||
|
||||
def connect_signals(self):
|
||||
"""Connect signals from scan history components."""
|
||||
self.scan_history_view.scan_selected.connect(self.scan_history_metadata_viewer.update_view)
|
||||
self.scan_history_view.scan_selected.connect(
|
||||
self.scan_history_device_viewer.update_devices_from_scan_history
|
||||
)
|
||||
self.scan_history_view.no_scan_selected.connect(
|
||||
self.scan_history_metadata_viewer.clear_view
|
||||
)
|
||||
self.scan_history_view.no_scan_selected.connect(self.scan_history_device_viewer.clear_view)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QApplication([])
|
||||
main_window = QtWidgets.QMainWindow()
|
||||
|
||||
central_widget = QtWidgets.QWidget()
|
||||
button = DarkModeButton()
|
||||
layout = QtWidgets.QVBoxLayout(central_widget)
|
||||
main_window.setCentralWidget(central_widget)
|
||||
# Create a ScanHistoryBrowser instance
|
||||
browser = ScanHistoryBrowser() # type: ignore
|
||||
layout.addWidget(button)
|
||||
layout.addWidget(browser)
|
||||
main_window.setWindowTitle("Scan History Browser")
|
||||
main_window.resize(800, 400)
|
||||
main_window.show()
|
||||
app.exec_()
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from bec_lib.device import Device, Signal
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
@@ -10,7 +10,6 @@ from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal as QSignal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QGroupBox,
|
||||
@@ -121,7 +120,9 @@ class ChoiceDialog(QDialog):
|
||||
self._signal_field.clear()
|
||||
|
||||
def accept(self):
|
||||
self.accepted_output.emit(self._device_field.text(), self._signal_field.currentText())
|
||||
self.accepted_output.emit(
|
||||
self._device_field.text(), self._signal_field.selected_signal_comp_name
|
||||
)
|
||||
return super().accept()
|
||||
|
||||
|
||||
@@ -142,6 +143,14 @@ class SignalLabel(BECWidget, QWidget):
|
||||
"show_default_units.setter",
|
||||
"show_select_button",
|
||||
"show_select_button.setter",
|
||||
"show_hinted_signals",
|
||||
"show_hinted_signals.setter",
|
||||
"show_normal_signals",
|
||||
"show_normal_signals.setter",
|
||||
"show_config_signals",
|
||||
"show_config_signals.setter",
|
||||
"display_array_data",
|
||||
"display_array_data.setter",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
@@ -182,8 +191,9 @@ class SignalLabel(BECWidget, QWidget):
|
||||
self._dtype = None
|
||||
|
||||
self._show_hinted_signals: bool = True
|
||||
self._show_normal_signals: bool = False
|
||||
self._show_config_signals: bool = False
|
||||
self._show_normal_signals: bool = True
|
||||
self._show_config_signals: bool = True
|
||||
self._display_array_data: bool = False
|
||||
|
||||
self._outer_layout = QHBoxLayout()
|
||||
self._layout = QHBoxLayout()
|
||||
@@ -196,7 +206,7 @@ class SignalLabel(BECWidget, QWidget):
|
||||
self._update_label()
|
||||
self._label.setLayout(self._layout)
|
||||
|
||||
self._value: str = ""
|
||||
self._value: Any = ""
|
||||
self._display = QLabel()
|
||||
self._layout.addWidget(self._display)
|
||||
|
||||
@@ -209,6 +219,8 @@ class SignalLabel(BECWidget, QWidget):
|
||||
|
||||
self._select_button.clicked.connect(self.show_choice_dialog)
|
||||
self.get_bec_shortcuts()
|
||||
self._device_obj = self.dev.get(self._device)
|
||||
self._signal_key, self._signal_info = "", {}
|
||||
|
||||
self._connected: bool = False
|
||||
self.connect_device()
|
||||
@@ -225,6 +237,7 @@ class SignalLabel(BECWidget, QWidget):
|
||||
|
||||
@SafeSlot()
|
||||
def _process_dialog(self, device: str, signal: str):
|
||||
signal = signal or device
|
||||
self.disconnect_device()
|
||||
self.device = device
|
||||
self.signal = signal
|
||||
@@ -240,45 +253,34 @@ class SignalLabel(BECWidget, QWidget):
|
||||
def connect_device(self):
|
||||
"""Subscribe to the Redis topic for the device to display"""
|
||||
if not self._connected and self._device and self._device in self.dev:
|
||||
self._connected = True
|
||||
self._read_endpoint = MessageEndpoints.device_read(self._device)
|
||||
self._signal_key, self._signal_info = self._signal_key_and_info()
|
||||
self._manual_read()
|
||||
self._read_endpoint = MessageEndpoints.device_readback(self._device)
|
||||
self._read_config_endpoint = MessageEndpoints.device_read_configuration(self._device)
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, self._read_endpoint)
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, self._read_config_endpoint)
|
||||
self._manual_read()
|
||||
self._connected = True
|
||||
self.set_display_value(self._value)
|
||||
|
||||
def disconnect_device(self):
|
||||
"""Unsubscribe from the Redis topic for the device to display"""
|
||||
if self._connected:
|
||||
self._connected = False
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._read_endpoint)
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._read_config_endpoint)
|
||||
self._connected = False
|
||||
|
||||
def _manual_read(self):
|
||||
if self._device is None or not isinstance(
|
||||
(device := self.dev.get(self._device)), Device | Signal
|
||||
):
|
||||
self._units = ""
|
||||
self._value = "__"
|
||||
if not isinstance(self._device_obj, Device | Signal):
|
||||
self._value, self._units = "__", ""
|
||||
return
|
||||
signal, info = (
|
||||
(
|
||||
getattr(device, self.signal, None),
|
||||
device._info.get("signals", {}).get(self._signal, {}).get("describe", {}),
|
||||
)
|
||||
if isinstance(device, Device)
|
||||
else (device, device.describe().get(self._device))
|
||||
)
|
||||
if not isinstance(signal, Signal): # Avoid getting other attributes of device, e.g. methods
|
||||
signal = None
|
||||
if signal is None:
|
||||
self._units = ""
|
||||
self._value = "__"
|
||||
reading = (self._device_obj.read() or {}) | (self._device_obj.read_configuration() or {})
|
||||
value = reading.get(self._signal_key, {}).get("value")
|
||||
if value is None:
|
||||
self._value, self._units = "__", ""
|
||||
return
|
||||
self._value = signal.get()
|
||||
self._units = info.get("egu", "")
|
||||
self._dtype = info.get("dtype", "float")
|
||||
self._value = value
|
||||
self._units = self._signal_info.get("egu", "")
|
||||
self._dtype = self._signal_info.get("dtype", "float")
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback(self, msg: dict, metadata: dict) -> None:
|
||||
@@ -286,8 +288,7 @@ class SignalLabel(BECWidget, QWidget):
|
||||
Update the display with the new value.
|
||||
"""
|
||||
try:
|
||||
signal_to_read = self._patch_hinted_signal()
|
||||
_value = msg["signals"].get(signal_to_read, {}).get("value")
|
||||
_value = msg["signals"].get(self._signal_key, {}).get("value")
|
||||
if _value is not None:
|
||||
self._value = _value
|
||||
self.set_display_value(self._value)
|
||||
@@ -297,13 +298,19 @@ class SignalLabel(BECWidget, QWidget):
|
||||
f"Error processing incoming reading: {msg}, handled with exception: {''.join(traceback.format_exception(e))}"
|
||||
)
|
||||
|
||||
def _patch_hinted_signal(self):
|
||||
if self.dev[self._device]._info["signals"] == {}:
|
||||
return self._signal
|
||||
signal_info = self.dev[self._device]._info["signals"][self._signal]
|
||||
return (
|
||||
signal_info["obj_name"] if signal_info["kind_str"] == Kind.hinted.name else self._signal
|
||||
)
|
||||
def _signal_key_and_info(self) -> tuple[str, dict]:
|
||||
if isinstance(self._device_obj, Device):
|
||||
try:
|
||||
signal_info = self._device_obj._info["signals"][self._signal]
|
||||
except KeyError:
|
||||
return "", {}
|
||||
if signal_info["kind_str"] == Kind.hinted.name:
|
||||
return signal_info["obj_name"], signal_info
|
||||
else:
|
||||
return f"{self._device}_{self._signal}", signal_info
|
||||
elif isinstance(self._device_obj, Signal):
|
||||
return self._device, self._device_obj._info["describe_configuration"]
|
||||
return "", {}
|
||||
|
||||
@SafeProperty(str)
|
||||
def device(self) -> str:
|
||||
@@ -314,6 +321,7 @@ class SignalLabel(BECWidget, QWidget):
|
||||
def device(self, value: str) -> None:
|
||||
self.disconnect_device()
|
||||
self._device = value
|
||||
self._device_obj = self.dev.get(self._device)
|
||||
self._config.device = value
|
||||
self.connect_device()
|
||||
self._update_label()
|
||||
@@ -381,6 +389,16 @@ class SignalLabel(BECWidget, QWidget):
|
||||
self._decimal_places = value
|
||||
self._update_label()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def display_array_data(self) -> bool:
|
||||
"""Displays the full data from array signals if set to True."""
|
||||
return self._display_array_data
|
||||
|
||||
@display_array_data.setter
|
||||
def display_array_data(self, value: bool) -> None:
|
||||
self._display_array_data = value
|
||||
self.set_display_value(self._value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_hinted_signals(self) -> bool:
|
||||
"""In the signal selection menu, show hinted signals"""
|
||||
@@ -408,7 +426,9 @@ class SignalLabel(BECWidget, QWidget):
|
||||
def show_normal_signals(self, value: bool) -> None:
|
||||
self._show_normal_signals = value
|
||||
|
||||
def _format_value(self, value: str):
|
||||
def _format_value(self, value: Any):
|
||||
if self._dtype == "array" and not self.display_array_data:
|
||||
return "ARRAY DATA"
|
||||
if self._decimal_places == 0:
|
||||
return value
|
||||
try:
|
||||
|
||||
244
bec_widgets/widgets/utility/widget_finder/widget_finder.py
Normal file
244
bec_widgets/widgets/utility/widget_finder/widget_finder.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QPropertyAnimation, QRect, QSequentialAnimationGroup, Qt, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import SafeProperty
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class WidgetFinderComboBox(QComboBox):
|
||||
|
||||
def __init__(self, parent=None, widget_class: type[QWidget] | str | None = None):
|
||||
super().__init__(parent)
|
||||
self.widget_class = widget_class
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
|
||||
self.setMinimumWidth(200)
|
||||
# find button inside combobox
|
||||
self.find_button = QToolButton(self)
|
||||
self.find_button.setIcon(material_icon("frame_inspect"))
|
||||
self.find_button.setCursor(Qt.PointingHandCursor)
|
||||
self.find_button.setFocusPolicy(Qt.NoFocus)
|
||||
self.find_button.setToolTip("Highlight selected widget")
|
||||
self.find_button.setStyleSheet("QToolButton { border: none; padding: 0px; }")
|
||||
self.find_button.clicked.connect(self.inspect_widget)
|
||||
|
||||
# refresh button inside combobox
|
||||
self.refresh_button = QToolButton(self)
|
||||
self.refresh_button.setIcon(material_icon("refresh"))
|
||||
self.refresh_button.setCursor(Qt.PointingHandCursor)
|
||||
self.refresh_button.setFocusPolicy(Qt.NoFocus)
|
||||
self.refresh_button.setToolTip("Refresh widget list")
|
||||
self.refresh_button.setStyleSheet("QToolButton { border: none; padding: 0px; }")
|
||||
self.refresh_button.clicked.connect(self.refresh_list)
|
||||
|
||||
# Purple Highlighter
|
||||
self.highlighter = None
|
||||
|
||||
# refresh items - delay to fetch widgets after UI is ready in next event loop
|
||||
QTimer.singleShot(0, self.refresh_list)
|
||||
|
||||
def _init_highlighter(self):
|
||||
"""
|
||||
Initialize the highlighter frame that will be used to highlight the inspected widget.
|
||||
"""
|
||||
self.highlighter = QFrame(self, Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||
self.highlighter.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||||
self.highlighter.setStyleSheet(
|
||||
"border: 2px solid #FF00FF; border-radius: 6px; background: transparent;"
|
||||
)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
btn_size = 16
|
||||
arrow_width = 24
|
||||
x = self.width() - arrow_width - btn_size - 2
|
||||
y = (self.height() - btn_size) // 2 - 2
|
||||
# position find_button first
|
||||
self.find_button.setFixedSize(btn_size, btn_size)
|
||||
self.find_button.move(x, y)
|
||||
# position refresh_button to the left of find_button
|
||||
refresh_x = x - btn_size - 2
|
||||
self.refresh_button.setFixedSize(btn_size, btn_size)
|
||||
self.refresh_button.move(refresh_x, y)
|
||||
|
||||
def refresh_list(self):
|
||||
"""
|
||||
Refresh the list of widgets in the combobox based on the specified widget class.
|
||||
"""
|
||||
self.clear()
|
||||
if self.widget_class is None:
|
||||
return
|
||||
widgets = WidgetIO.find_widgets(self.widget_class, recursive=True)
|
||||
# Build display names with counts for duplicates
|
||||
name_counts: dict[str, int] = {}
|
||||
for w in widgets:
|
||||
base_name = w.objectName() or w.__class__.__name__
|
||||
count = name_counts.get(base_name, 0) + 1
|
||||
name_counts[base_name] = count
|
||||
display_name = base_name if count == 1 else f"{base_name} ({count})"
|
||||
self.addItem(display_name, w)
|
||||
|
||||
def showPopup(self):
|
||||
"""
|
||||
Refresh list each time the popup opens to reflect dynamic widget changes.
|
||||
"""
|
||||
self.refresh_list()
|
||||
super().showPopup()
|
||||
|
||||
def inspect_widget(self):
|
||||
"""
|
||||
Inspect the currently selected widget in the combobox.
|
||||
"""
|
||||
target = self.currentData()
|
||||
if not target:
|
||||
return
|
||||
# ensure highlighter exists, avoid calling methods on deleted C++ object
|
||||
if not getattr(self, "highlighter", None):
|
||||
self._init_highlighter()
|
||||
else:
|
||||
self.highlighter.hide()
|
||||
# draw new
|
||||
geom = target.frameGeometry()
|
||||
pos = target.mapToGlobal(target.rect().topLeft())
|
||||
self.highlighter.setGeometry(pos.x(), pos.y(), geom.width(), geom.height())
|
||||
self.highlighter.show()
|
||||
# Pulse and fade animation to draw attention
|
||||
start_rect = QRect(pos.x() - 5, pos.y() - 5, geom.width() + 10, geom.height() + 10)
|
||||
pulse = QPropertyAnimation(self.highlighter, b"geometry")
|
||||
pulse.setDuration(300)
|
||||
pulse.setStartValue(start_rect)
|
||||
pulse.setEndValue(QRect(pos.x(), pos.y(), geom.width(), geom.height()))
|
||||
|
||||
fade = QPropertyAnimation(self.highlighter, b"windowOpacity")
|
||||
fade.setDuration(2000)
|
||||
fade.setStartValue(1.0)
|
||||
fade.setEndValue(0.0)
|
||||
fade.finished.connect(self.highlighter.hide)
|
||||
|
||||
group = QSequentialAnimationGroup(self)
|
||||
group.addAnimation(pulse)
|
||||
group.addAnimation(fade)
|
||||
group.start()
|
||||
|
||||
@SafeProperty(str)
|
||||
def widget_class_name(self) -> str:
|
||||
"""
|
||||
Get or set the target widget class by name.
|
||||
"""
|
||||
return (
|
||||
self.widget_class if isinstance(self.widget_class, str) else self.widget_class.__name__
|
||||
)
|
||||
|
||||
@widget_class_name.setter
|
||||
def widget_class_name(self, name: str):
|
||||
self.widget_class = name
|
||||
self.refresh_list()
|
||||
|
||||
@property
|
||||
def selected_widget(self):
|
||||
"""
|
||||
The currently selected QWidget instance (or None if not found).
|
||||
"""
|
||||
try:
|
||||
return self.currentData()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the highlighter frame when the combobox is deleted.
|
||||
"""
|
||||
if self.highlighter:
|
||||
self.highlighter.close()
|
||||
self.highlighter.deleteLater()
|
||||
self.highlighter = None
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""
|
||||
Override closeEvent to clean up the highlighter frame.
|
||||
"""
|
||||
self.cleanup()
|
||||
event.accept()
|
||||
|
||||
|
||||
class InspectorMainWindow(BECMainWindow): # pragma: no cover
|
||||
"""
|
||||
A main window that includes a widget finder combobox to inspect widgets.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Widget Inspector")
|
||||
self.setMinimumSize(800, 600)
|
||||
|
||||
self.central_widget = QWidget(self)
|
||||
self.setCentralWidget(self.central_widget)
|
||||
|
||||
self.central_widget.layout = QGridLayout(self.central_widget)
|
||||
|
||||
# Inspector box
|
||||
self.group_box_inspector = QGroupBox(self.central_widget)
|
||||
self.group_box_inspector.setTitle("Inspector")
|
||||
self.group_box_inspector.layout = QVBoxLayout(self.group_box_inspector)
|
||||
self.inspector_combobox = WidgetFinderComboBox(self.group_box_inspector, Waveform)
|
||||
self.switch_combobox = QComboBox(self.group_box_inspector)
|
||||
self.switch_combobox.addItems(["Waveform", "Image", "QPushButton"])
|
||||
self.switch_combobox.setToolTip("Switch the widget class to inspect")
|
||||
self.switch_combobox.currentTextChanged.connect(
|
||||
lambda text: setattr(self.inspector_combobox, "widget_class_name", text)
|
||||
)
|
||||
self.group_box_inspector.layout.addWidget(self.inspector_combobox)
|
||||
self.group_box_inspector.layout.addWidget(self.switch_combobox)
|
||||
|
||||
# Some bec widgets to inspect
|
||||
self.wf1 = Waveform(self.central_widget)
|
||||
self.wf2 = Waveform(self.central_widget)
|
||||
|
||||
self.im1 = Image(self.central_widget)
|
||||
self.im2 = Image(self.central_widget)
|
||||
|
||||
# Some normal widgets to inspect
|
||||
self.group_box_widgets = QGroupBox(self.central_widget)
|
||||
self.group_box_widgets.setTitle("Widgets ")
|
||||
self.group_box_widgets.layout = QVBoxLayout(self.group_box_widgets)
|
||||
self.btn1 = QPushButton("Button 1", self.group_box_widgets)
|
||||
self.btn1.setObjectName("btn1")
|
||||
self.btn2 = QPushButton("Button 2", self.group_box_widgets)
|
||||
self.btn2.setObjectName("btn1") # Same object name to test duplicate handling
|
||||
self.btn3 = QPushButton("Button 3", self.group_box_widgets)
|
||||
self.btn3.setObjectName("btn3")
|
||||
self.group_box_widgets.layout.addWidget(self.btn1)
|
||||
self.group_box_widgets.layout.addWidget(self.btn2)
|
||||
self.group_box_widgets.layout.addWidget(self.btn3)
|
||||
|
||||
self.central_widget.layout.addWidget(self.group_box_inspector, 0, 0)
|
||||
self.central_widget.layout.addWidget(self.group_box_widgets, 1, 0)
|
||||
self.central_widget.layout.addWidget(self.wf1, 0, 1)
|
||||
self.central_widget.layout.addWidget(self.wf2, 1, 1)
|
||||
self.central_widget.layout.addWidget(self.im1, 0, 2)
|
||||
self.central_widget.layout.addWidget(self.im2, 1, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
main_window = InspectorMainWindow()
|
||||
main_window.show()
|
||||
sys.exit(app.exec())
|
||||
BIN
docs/assets/widget_screenshots/heatmap_widget.png
Normal file
BIN
docs/assets/widget_screenshots/heatmap_widget.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
BIN
docs/user/widgets/heatmap/heatmap_fermat_scan.gif
Normal file
BIN
docs/user/widgets/heatmap/heatmap_fermat_scan.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 MiB |
BIN
docs/user/widgets/heatmap/heatmap_grid_scan.gif
Normal file
BIN
docs/user/widgets/heatmap/heatmap_grid_scan.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 825 KiB |
106
docs/user/widgets/heatmap/heatmap_widget.md
Normal file
106
docs/user/widgets/heatmap/heatmap_widget.md
Normal file
@@ -0,0 +1,106 @@
|
||||
(user.widgets.heatmap_widget)=
|
||||
|
||||
# Heatmap widget
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The Heatmap widget is a specialized plotting tool designed for visualizing 2D grid data with color mapping for the z-axis. It excels at displaying data from grid scans or arbitrary step scans, automatically interpolating scattered data points into a coherent 2D image. Directly integrated with the `BEC` framework, it can display live data streams from scanning experiments within the current `BEC` session.
|
||||
|
||||
## Key Features:
|
||||
- **Flexible Integration**: The widget can be integrated into [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`.
|
||||
- **Live Grid Scan Visualization**: Real-time plotting of grid scan data with automatic positioning and color mapping based on scan parameters.
|
||||
- **Dual Scan Support**: Handles both structured grid scans (with pre-allocated grids) and unstructured step scans (with interpolation).
|
||||
- **Intelligent Data Interpolation**: For arbitrary step scans, the widget automatically interpolates scattered (x, y, z) data points into a smooth 2D heatmap using various interpolation methods.
|
||||
- **Oversampling**: Supports oversampling to enhance the appearance of the heatmap, allowing for smoother transitions and better visual representation of data. Especially useful the for nearest-neighbor interpolation.
|
||||
- **Customizable Color Maps**: Wide variety of color maps available for data visualization, with support for both simple and full color bars.
|
||||
- **Real-time Image Processing**: Apply real-time processing techniques such as FFT and logarithmic scaling to enhance data visualization.
|
||||
- **Interactive Controls**: Comprehensive toolbar with settings for heatmap configuration, crosshair tools, mouse interaction, and data export capabilities.
|
||||
|
||||
|
||||
```{figure} ./heatmap_grid_scan.gif
|
||||
:width: 60%
|
||||
|
||||
Real-time heatmap visualization of a 2D grid scan showing motor positions and detector intensity
|
||||
```
|
||||
|
||||
```{figure} ./heatmap_fermat_scan.gif
|
||||
:width: 80%
|
||||
|
||||
Real-time heatmap visualization of an (not path-optimized) scan following Fermat's spiral pattern. On the left, the heatmap widget is shown with the oversampling option set to 10 and the interpolation method set to nearest neighbor. On the right, the scatter waveform widget is shown with the same data.
|
||||
```
|
||||
|
||||
|
||||
````
|
||||
|
||||
````{tab} Examples - CLI
|
||||
|
||||
`HeatmapWidget` can be embedded in [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`. The command-line API is the same for all cases.
|
||||
|
||||
## Example 1 - Visualizing Grid Scan Data
|
||||
|
||||
In this example, we demonstrate how to add a `HeatmapWidget` to visualize live data from a 2D grid scan with motor positions and detector readout.
|
||||
|
||||
```python
|
||||
# Add a new dock with HeatmapWidget
|
||||
dock_area = gui.new()
|
||||
heatmap_widget = dock_area.new().new(gui.available_widgets.Heatmap)
|
||||
|
||||
# Plot a heatmap with x and y motor positions and z detector signal
|
||||
heatmap_widget.plot(
|
||||
x_name='samx', # X-axis motor
|
||||
y_name='samy', # Y-axis motor
|
||||
z_name='bpm4i', # Z-axis detector signal
|
||||
color_map='plasma'
|
||||
)
|
||||
heatmap_widget.title = "Grid Scan - Sample Position vs BPM Intensity"
|
||||
```
|
||||
|
||||
## Example 2 - Step Scan with Custom Entries
|
||||
|
||||
This example shows how to visualize data from an arbitrary step scan by specifying custom data entries for each axis.
|
||||
|
||||
```python
|
||||
# Add a new dock with HeatmapWidget
|
||||
dock_area = gui.new()
|
||||
heatmap_widget = dock_area.new().new(gui.available_widgets.Heatmap)
|
||||
|
||||
# Plot heatmap with specific data entries
|
||||
heatmap_widget.plot(
|
||||
x_name='motor1',
|
||||
y_name='motor2',
|
||||
z_name='detector1',
|
||||
x_entry='RBV', # Use readback value for x
|
||||
y_entry='RBV', # Use readback value for y
|
||||
z_entry='value', # Use main value for z
|
||||
color_map='viridis',
|
||||
reload=True # Force reload of data
|
||||
)
|
||||
```
|
||||
|
||||
## Example 3 - Real-time Processing and Customization
|
||||
|
||||
The `Heatmap` widget provides real-time processing capabilities and extensive customization options for enhanced data visualization.
|
||||
|
||||
```python
|
||||
# Configure heatmap appearance and processing
|
||||
heatmap_widget.color_map = 'plasma'
|
||||
heatmap_widget.lock_aspect_ratio = True
|
||||
|
||||
# Apply real-time processing
|
||||
heatmap_widget.fft = True # Apply FFT to the data
|
||||
heatmap_widget.log = True # Use logarithmic scaling
|
||||
|
||||
# Configure color bar and range
|
||||
heatmap_widget.enable_full_colorbar = True
|
||||
heatmap_widget.v_min = 0
|
||||
heatmap_widget.v_max = 1000
|
||||
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.widgets.plots.heatmap.heatmap.Heatmap.rst
|
||||
```
|
||||
````
|
||||
@@ -1,94 +0,0 @@
|
||||
# Waveform Widget Plotting Ruleset
|
||||
|
||||
The Waveform widget allows plotting data from scans generated within the BEC framework. Each scan produces a **scan item
|
||||
**, which includes measurement data from various devices, as well as additional metadata (e.g., devices driving the
|
||||
scan, such as `motor_x`, `motor_y`, etc.).
|
||||
|
||||
The rules described below define how data from different devices and scans can be plotted together, depending on the
|
||||
selected `x_mode`.
|
||||
|
||||
---
|
||||
|
||||
## Types of Curves
|
||||
|
||||
- **Live Curves**
|
||||
- Continuously updated from the currently running scan.
|
||||
- Dynamically adapt based on live data.
|
||||
|
||||
- **History Curves**
|
||||
- Static curves representing data from a specific completed scan (e.g., scan 110).
|
||||
- Displayed only if compatible with the currently selected x-axis device.
|
||||
|
||||
---
|
||||
|
||||
## General Compatibility Rules
|
||||
|
||||
- Data from devices within **the same scan item** can always be plotted against each other, provided they have the same
|
||||
number of data points.
|
||||
- Example: plotting `detector_1` against `detector_2` from scan 110 to check signal correlation is valid.
|
||||
|
||||
- Data from **different scan items** cannot be plotted against each other.
|
||||
- Example: plotting x-data from `motor_x` in scan 110 against y-data from `detector_1` in scan 111 is not allowed.
|
||||
|
||||
---
|
||||
|
||||
## Specific Rules for Each `x_mode`
|
||||
|
||||
### 1. `x_mode='auto'` (default)
|
||||
|
||||
- Automatically determines the device for x-axis scaling from the currently running (live) scan.
|
||||
- The primary device used for scaling is the first device listed in the current scan's `scan_report_devices` (usually a
|
||||
positioner in the case of step scans).
|
||||
- **Live Curves**:
|
||||
- Always plotted against the selected x-axis device from the current scan.
|
||||
- **History Curves**:
|
||||
- Each history curve is linked to a specific scan item with scan ID.
|
||||
- Waveform widget checks compatibility with the currently selected x-axis device from the live scan.
|
||||
- If the selected x-axis device data exists in the history curve’s original scan item, the curve is displayed.
|
||||
- If the selected x-axis device data does not exist in the original scan item, the history curve remains **hidden**
|
||||
until a compatible device is selected again.
|
||||
|
||||
_Example Scenario_:
|
||||
|
||||
- The live scan currently uses `motor_x` as the x-axis device. Any history curve will only be displayed if its original
|
||||
scan item contains data for `motor_x`. If not, the history curve is hidden.
|
||||
|
||||
---
|
||||
|
||||
### 2. `x_mode='timestamp'`
|
||||
|
||||
- X-axis scaling is based on timestamps from each data point.
|
||||
- All curves, both live and history, are always compatible, as timestamps provide a universal and absolute reference for
|
||||
the x-axis.
|
||||
- Curves from different scan items can appear simultaneously, regardless of the devices measured.
|
||||
|
||||
---
|
||||
|
||||
### 3. `x_mode='index'`
|
||||
|
||||
- X-axis scaling uses data point indices (0, 1, 2, ..., N-1).
|
||||
- Allows plotting multiple curves of varying lengths in the same view.
|
||||
- All curves are always compatible, as indices represent relative positions, independent of device or timestamp, it is
|
||||
up to the user to interpret the x-axis.
|
||||
|
||||
---
|
||||
|
||||
### 4. `x_mode='device'`
|
||||
|
||||
- User explicitly selects a device to scale the x-axis.
|
||||
- The chosen device must exist in each curve’s respective scan item.
|
||||
- **Live Curves**:
|
||||
- Continuously displayed if the selected device data is being measured in the current scan.
|
||||
- **History Curves**:
|
||||
- Displayed only if the selected device exists in the scan item from which the history curve originates.
|
||||
- Remain **hidden** if the selected device is not present in the original scan item, reappearing only when a
|
||||
compatible device is chosen again.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Points
|
||||
|
||||
- Each curve stores its own independent x and y data sets (as it is defined by `pg.PlotDataItem`, allowing simultaneous
|
||||
plotting of multiple curves with different data lengths.
|
||||
- Compatibility checks ensure that plotting meaningful comparisons is always possible, avoiding combinations of
|
||||
unrelated or non-compatible datasets.
|
||||
@@ -61,6 +61,14 @@ Display a 1D waveforms with a third device on the z-axis.
|
||||
Display signal from 2D detector.
|
||||
```
|
||||
|
||||
```{grid-item-card} Heatmap Widget
|
||||
:link: user.widgets.heatmap_widget
|
||||
:link-type: ref
|
||||
:img-top: /assets/widget_screenshots/heatmap_widget.png
|
||||
|
||||
Display 2D grid data with color mapping.
|
||||
```
|
||||
|
||||
```{grid-item-card} Motor Map Widget
|
||||
:link: user.widgets.motor_map
|
||||
:link-type: ref
|
||||
@@ -275,6 +283,7 @@ waveform/waveform_widget.md
|
||||
scatter_waveform/scatter_waveform.md
|
||||
multi_waveform/multi_waveform.md
|
||||
image/image_widget.md
|
||||
heatmap/heatmap_widget.md
|
||||
motor_map/motor_map.md
|
||||
scan_control/scan_control.md
|
||||
progress_bar/ring_progress_bar.md
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.21.2"
|
||||
version = "2.30.5"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -23,6 +23,7 @@ dependencies = [
|
||||
"PySide6~=6.8.2",
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
"qtmonaco>=0.2.3",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -254,3 +254,35 @@ def test_dap_rpc(qtbot, bec_client_lib, connected_client_gui_obj):
|
||||
res.wait()
|
||||
|
||||
qtbot.waitUntil(wait_for_fit, timeout=10000)
|
||||
|
||||
|
||||
def test_waveform_passing_device(qtbot, bec_client_lib, connected_client_gui_obj):
|
||||
gui = connected_client_gui_obj
|
||||
client = bec_client_lib
|
||||
dev = client.device_manager.devices
|
||||
scans = client.scans
|
||||
|
||||
dock = gui.bec
|
||||
wf = dock.new("wf_dock").new("Waveform")
|
||||
c1 = wf.plot(
|
||||
y_name=dev.samx, y_entry=dev.samx.setpoint
|
||||
) # using setpoint to not use readback signal
|
||||
|
||||
assert c1.object_name == "samx_samx_setpoint"
|
||||
|
||||
status = scans.line_scan(dev.samx, -5, 5, steps=5, exp_time=0.05, relative=False)
|
||||
status.wait()
|
||||
|
||||
# Wait for the scan to finish and the data to be available in history
|
||||
# Wait until scan_id is in history
|
||||
def _wait_for_scan_in_history():
|
||||
if len(client.history) == 0:
|
||||
return False
|
||||
# Once items appear in storage, the last one hast to be the one we just scanned
|
||||
return client.history[-1].metadata.bec["scan_id"] == status.scan.scan_id
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=10000)
|
||||
last_scan_data = client.history[-1]
|
||||
# check plotted data
|
||||
x_data, y_data = c1.get_data()
|
||||
assert np.array_equal(y_data, last_scan_data.devices.samx.samx_setpoint.read().get("value"))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user