1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 09:47:52 +02:00

Compare commits

..

80 Commits

Author SHA1 Message Date
semantic-release
c8aa770de3 2.30.5
Automatically generated by python-semantic-release
2025-07-25 17:44:39 +00:00
4d5df9608a refactor(positioner-box): cleanup, accept float precision 2025-07-25 19:43:52 +02:00
b718b438ba fix(positioner-box): Test to fix handling of none integer values for precision 2025-07-25 19:43:52 +02:00
semantic-release
2f978c93c4 2.30.4
Automatically generated by python-semantic-release
2025-07-25 10:18:28 +00:00
b4e0664011 fix(cli): remove stderr from cli output when not using rpc 2025-07-25 12:17:44 +02:00
semantic-release
45fbf4015d 2.30.3
Automatically generated by python-semantic-release
2025-07-23 08:01:36 +00:00
David Perl
0d81bdd4dd fix: cleanup subscriptions in device browser 2025-07-23 10:00:43 +02:00
semantic-release
bb4c30ad80 2.30.2
Automatically generated by python-semantic-release
2025-07-23 06:57:35 +00:00
3fd09fceef test(test_plotting_framework_e2e): added test for waveform with passing device from dev container 2025-07-23 08:56:52 +02:00
8eb8225a7f fix: factor out device name function and add test 2025-07-23 08:56:52 +02:00
491d04467c fix(rpc_base): rpc_call wrapper passes full_name for Devices indeed of name 2025-07-23 08:56:52 +02:00
semantic-release
3bcff75107 2.30.1
Automatically generated by python-semantic-release
2025-07-22 18:19:10 +00:00
608590c542 fix: ignore KeyError in SignalLabel 2025-07-22 20:18:28 +02:00
semantic-release
012f7cf970 2.30.0
Automatically generated by python-semantic-release
2025-07-22 14:24:47 +00:00
cd17a4aad9 fix(signal_label): rewrite reading selection logic 2025-07-22 15:24:03 +01:00
f0dc992586 fix(signal_label): use read() instead of get() for init 2025-07-22 15:24:03 +01:00
fd1f9941e0 chore: update client.py 2025-07-22 15:24:03 +01:00
3384ca02bd fix(device_browser): display signal for signals 2025-07-22 15:24:03 +01:00
959cedbbd5 fix(signal_label): update signal from dialog correctly 2025-07-22 15:24:03 +01:00
ca4f97503b feat(signal_label): property to display array data or not 2025-07-22 15:24:03 +01:00
22beadcad0 fix(signal_label): show all signals by default 2025-07-22 15:24:03 +01:00
b9af36a4f1 fix(device_signal_display): don't read omitted 2025-07-22 15:24:03 +01:00
semantic-release
bdff736aa2 2.29.0
Automatically generated by python-semantic-release
2025-07-22 11:39:06 +00:00
7cda2ed846 refactor(notification_banner): BECNotificationBroker done as singleton to sync all windows in the session 2025-07-22 13:38:23 +02:00
cd9d22d0b4 feat(notification_banner): notification centre for alarms implemented into BECMainWindow 2025-07-22 13:38:23 +02:00
semantic-release
37b80e16a0 2.28.0
Automatically generated by python-semantic-release
2025-07-21 12:23:48 +00:00
7f0098f153 feat: save and load config from devicebrowser 2025-07-21 14:23:01 +02:00
8489ef4a69 feat: remove and readd device for config changes 2025-07-21 14:23:01 +02:00
13976557fb feat: disable editing while scan active 2025-07-21 14:23:01 +02:00
semantic-release
06ad87ce0a 2.27.1
Automatically generated by python-semantic-release
2025-07-17 13:22:03 +00:00
00e3713181 fix(image_roi_tree): rois signals are disconnected when roi tree widget is closed 2025-07-17 15:21:11 +02:00
semantic-release
62020f9965 2.27.0
Automatically generated by python-semantic-release
2025-07-17 13:03:53 +00:00
2373c7e996 feat: add monaco editor 2025-07-17 15:02:01 +02:00
semantic-release
1f3566c105 2.26.0
Automatically generated by python-semantic-release
2025-07-17 12:44:47 +00:00
b8ae7b2e96 fix(config label): reset offset when toggling the label action 2025-07-17 14:44:06 +02:00
23674ccf59 fix(performance_bundle): fix performance bundle cleanup 2025-07-17 14:44:06 +02:00
1d8069e391 feat(heatmap): add interpolation and oversampling UI components 2025-07-17 14:44:06 +02:00
44cc06137c test(history): add history message helper methods to conftest 2025-07-17 14:44:06 +02:00
46a91784d2 refactor(image_base): cleanup 2025-07-17 14:44:06 +02:00
debd347b64 feat(device combobox): add option to insert an empty element 2025-07-17 14:44:06 +02:00
semantic-release
a13c3c44c8 2.25.0
Automatically generated by python-semantic-release
2025-07-17 09:27:51 +00:00
25b2737aac refactor: cleanup, add compact popup view for scan_history_browser and update tests 2025-07-17 11:26:57 +02:00
cf97cc1805 refactor: add additional components for history metadata, device view and popup ui 2025-07-17 11:26:57 +02:00
694a6c4960 fix(bec-progressbar): add flag for theme update 2025-07-17 11:26:57 +02:00
9caae4cf40 feat(scan-history-browser): Add history browser and history metadata viewer 2025-07-17 11:26:57 +02:00
2b06e34ecf ci(plugin): add plugin repository test to BW ci 2025-07-15 15:09:53 +02:00
a9c8995ac0 ci(bec): add child_repos test for bec (unit and e2e tests) 2025-07-15 15:09:53 +02:00
semantic-release
1262c66fd6 2.24.1
Automatically generated by python-semantic-release
2025-07-15 09:24:58 +00:00
bde523806f fix: update signal label for device_edit changes 2025-07-15 11:24:12 +02:00
semantic-release
16bca25d9c 2.24.0
Automatically generated by python-semantic-release
2025-07-15 08:30:13 +00:00
130cc24b35 feat(device_browser): connect update to item refresh 2025-07-15 10:29:31 +02:00
8b2d6052e8 fix(device_browser): un-nest exception 2025-07-15 10:29:31 +02:00
530797a556 fix: hide validity LED, show message as tooltip 2025-07-15 10:29:31 +02:00
c660e5141f fix: validate some config data 2025-07-15 10:29:31 +02:00
900153bc0b feat(#495): add validation against existing device names 2025-07-15 10:29:31 +02:00
8dc72656ef feat(device_browser): device deletion from config 2025-07-15 10:29:31 +02:00
170be0c7d3 feat: (#495) add devices through browser 2025-07-15 10:29:31 +02:00
1925e6ac7f docs: docstring for config dialog 2025-07-15 10:29:31 +02:00
semantic-release
b6cef2d27b 2.23.0
Automatically generated by python-semantic-release
2025-07-11 16:44:57 +00:00
a9fce175b7 feat(widget_finder): widget to fetch any other widget by class from currently running app 2025-07-11 18:44:08 +02:00
783d042e8c feat(widget_io): utility function to find widget in the app by class 2025-07-11 18:44:08 +02:00
semantic-release
319a4206f2 2.22.2
Automatically generated by python-semantic-release
2025-07-11 12:43:39 +00:00
76439866c1 fix(plot_base): autorange takes into account only visible curves 2025-07-11 14:42:54 +02:00
semantic-release
ca600b057e 2.22.1
Automatically generated by python-semantic-release
2025-07-11 11:57:47 +00:00
6c494258f8 fix(heatmap): fix pixel size calculation for arbitrary shapes 2025-07-11 13:57:01 +02:00
63a8da680d fix(crosshair): crosshair mouse_moved can be set manually 2025-07-11 13:57:01 +02:00
semantic-release
0f2bde1a0a 2.22.0
Automatically generated by python-semantic-release
2025-07-10 12:23:05 +00:00
0c76b0c495 feat: add heatmap widget 2025-07-10 14:22:15 +02:00
e594de3ca3 fix(image): reset crosshair on new scan 2025-07-10 14:22:15 +02:00
adaad4f4d5 fix(crosshair): add slot to reset mouse markers 2025-07-10 14:22:15 +02:00
39c316d6ea fix(image item): fix processor for nans in images 2025-07-10 14:22:15 +02:00
3ba0fc4b44 fix(crosshair): fix crosshair support for transformations 2025-07-10 14:22:15 +02:00
a6fc7993a3 fix(image_processor): support for nans in nd arrays 2025-07-10 14:22:15 +02:00
324a5bd3d9 feat(image_item): add support for qtransform 2025-07-10 14:22:15 +02:00
8929778f07 fix(image_base): move cbar init to image base 2025-07-10 14:22:15 +02:00
semantic-release
72b5c46912 2.21.4
Automatically generated by python-semantic-release
2025-07-08 09:57:41 +00:00
244bca4e1e fix(image_roi_tree): changing color dialog from ColorButtonNative is open once 2025-07-08 11:57:00 +02:00
semantic-release
c50ace5818 2.21.3
Automatically generated by python-semantic-release
2025-07-03 15:24:12 +00:00
25f28c47e3 fix(connector): remove safeslot for now 2025-07-03 17:23:26 +02:00
db720e8fa4 refactor(toolbar): split toolbar into components, bundles and connections 2025-07-03 17:23:26 +02:00
124 changed files with 12073 additions and 2974 deletions

64
.github/workflows/child_repos.yml vendored Normal file
View 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'

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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":

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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.

View File

@@ -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 crosshair 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()

View File

@@ -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:

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View 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()

View 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()

View 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.
"""

View 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

View 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_())

View File

@@ -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 ##################

View File

@@ -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:

View File

@@ -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]
"""
Reimplement 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:

View File

@@ -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))

View File

@@ -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()

View File

@@ -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:

View File

@@ -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.

View File

@@ -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

View File

@@ -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):

View 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_()

View File

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

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.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()

View 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()

View 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_())

View File

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

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.plots.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()

View 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()

View 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()

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()

View File

@@ -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

View File

@@ -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:

View File

@@ -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:
"""

View File

@@ -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()

View File

@@ -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 previewsignal 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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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.

View File

@@ -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_())

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View 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

View 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
)

View File

@@ -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()

View File

@@ -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

View File

@@ -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):
# Progressbar 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

View File

@@ -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)

View File

@@ -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

View File

@@ -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>

View File

@@ -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!")

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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"]

View File

@@ -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_()

View File

@@ -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")

View File

@@ -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 perscan 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_()

View File

@@ -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_()

View File

@@ -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:

View 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())

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

View 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
```
````

View File

@@ -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 curves 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 curves 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.

View File

@@ -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

View File

@@ -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",
]

View File

@@ -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