1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-05-01 12:32:30 +02:00

Compare commits

...

137 Commits

Author SHA1 Message Date
wyzula_j 2f1526182b docs(waveform): plotting ruleset 2025-07-02 23:03:01 +02:00
semantic-release f10140e0f3 2.21.2
Automatically generated by python-semantic-release
2025-06-30 11:53:00 +00:00
wakonig_k 09c5a443aa fix(waveform): fix waveform categorisation for aborted scans 2025-06-30 13:52:19 +02:00
wakonig_k 3f5ab142a3 test: assert config for equality, not identity 2025-06-29 11:52:14 +02:00
semantic-release 422d06d141 2.21.1
Automatically generated by python-semantic-release
2025-06-29 09:49:32 +00:00
wakonig_k 371bc485d0 fix(sbb monitor): add missing pyproject file 2025-06-29 11:48:47 +02:00
semantic-release 70970ecf00 2.21.0
Automatically generated by python-semantic-release
2025-06-28 17:36:16 +00:00
wakonig_k 3d59c25aa9 feat(sbb monitor): add sbb monitor widget 2025-06-28 19:35:36 +02:00
semantic-release 70a06c5fd1 2.20.1
Automatically generated by python-semantic-release
2025-06-28 14:23:36 +00:00
wakonig_k 7ba8863d6a fix(signal input base): unregister callback to avoid accessing deleted qt objects 2025-06-28 16:22:55 +02:00
semantic-release 00ea8bb6c6 2.20.0
Automatically generated by python-semantic-release
2025-06-26 13:03:28 +00:00
wakonig_k e841468892 refactor(curve settings): move signal logic to SignalCombobox 2025-06-26 15:02:31 +02:00
wyzula_j 48a0e5831f fix(curve_settings): larger minimalWidth for the x device combobox selection 2025-06-26 15:02:31 +02:00
wakonig_k 1e9dd4cd25 test(curve settings): add curve tree elements to the dialog test 2025-06-26 15:02:31 +02:00
wakonig_k d10328cb5c feat(waveform): move x axis selection to a combobox 2025-06-26 15:02:31 +02:00
semantic-release 6b248e93f5 2.19.4
Automatically generated by python-semantic-release
2025-06-26 07:13:15 +00:00
wakonig_k bc3085ab8c fix(curve tree): remove manual interception of the close event; call parent cleanup 2025-06-26 09:12:35 +02:00
wakonig_k 9cba696afd fix(waveform): curve tree elements must clean up signal combobox 2025-06-26 09:12:35 +02:00
semantic-release 881b7a7e9d 2.19.3
Automatically generated by python-semantic-release
2025-06-25 14:53:56 +00:00
wyzula_j 29a26b19f9 fix(scan_control): safeguard against empty history; reversed history to fetch the newest scan 2025-06-25 16:53:10 +02:00
semantic-release cba4d47f76 2.19.2
Automatically generated by python-semantic-release
2025-06-23 14:18:46 +00:00
wyzula_j 9f3dcc3ab3 build: bec_lib 3.44 required 2025-06-23 16:17:59 +02:00
wyzula_j 57f75bd4d5 refactor(scan_control): request_last_executed_scan_parameters logic adjusted 2025-06-23 16:17:59 +02:00
wyzula_j 4456297beb fix(scan_control): scan parameters fetched from the scan_history, fix #707 2025-06-23 16:17:59 +02:00
semantic-release ae26b43fb1 2.19.1
Automatically generated by python-semantic-release
2025-06-23 14:07:09 +00:00
wyzula_j 7484f5160c fix(launch_window): number of remaining connections extended to 4 2025-06-23 16:06:27 +02:00
wyzula_j 6421050116 feat(hover_widget) widget enables to display different widget upon hover; applied to scan progress and client info message in status bar of BECMainWindow 2025-06-23 16:06:27 +02:00
semantic-release 5a137d1219 2.19.0
Automatically generated by python-semantic-release
2025-06-23 12:54:48 +00:00
perl_d d5a40dabc7 fix(ci): extend check for pyside import to tests 2025-06-23 14:54:06 +02:00
perl_d f3da6e959e feat: (#494) add signal display to device browser 2025-06-23 14:54:06 +02:00
perl_d 3a103410e7 feat: (#494) display device signals 2025-06-23 14:54:06 +02:00
perl_d 3378051250 feat: (#494) add tabbed layout for device item 2025-06-23 14:54:06 +02:00
semantic-release 77db658f3d 2.18.0
Automatically generated by python-semantic-release
2025-06-22 17:40:06 +00:00
wakonig_k 6e2f2cea91 refactor(device input): refactor to SafeProperty and SafeSlot 2025-06-22 19:39:19 +02:00
wakonig_k eea5f7ebbd feat(curve settings): add combobox selection for device and signal 2025-06-22 19:39:19 +02:00
wakonig_k a9708f6d8f fix(curve settings): add initial size hint 2025-06-22 19:39:19 +02:00
wakonig_k b51de1a00e feat(signal combobox): add reset_selection slot 2025-06-22 19:39:19 +02:00
wakonig_k 8e8acd672c feat(FilterIO): add support for item data 2025-06-22 19:39:19 +02:00
wakonig_k 4c2c0c5525 feat(device combobox): emit reset event if validation fails 2025-06-22 19:39:19 +02:00
wakonig_k 5a564a5f3f fix: make settings dialog resizable 2025-06-22 19:39:19 +02:00
semantic-release 43ad207aa8 2.17.0
Automatically generated by python-semantic-release
2025-06-22 13:33:32 +00:00
wakonig_k a4274ff8cd build: update min dependency of bec to 3.42.4 2025-06-22 15:32:45 +02:00
wakonig_k b2a46e284d test(scan progress): add test for queue update logic 2025-06-22 15:32:45 +02:00
wyzula_j 9ff170660e feat(main_window): timer to show hide scan progress when it is relevant only 2025-06-22 15:32:45 +02:00
wyzula_j 6c04eac18c test(scan_progress): tests extended 2025-06-22 15:32:45 +02:00
wyzula_j aca6efb567 fix(main_window): labels and sizing of scan progress adopted 2025-06-22 15:32:45 +02:00
wyzula_j 88b42e49e3 fix(scan_progressbar): mapping of bec progress states to the progressbar enums 2025-06-22 15:32:45 +02:00
wyzula_j d3a9e0903a feat(progressbar): state setting and dynamic corner radius 2025-06-22 15:32:45 +02:00
wyzula_j 3bbb8daa24 fix(launch_window): number of remaining connections increase to 2 to include the ScanProgressBar 2025-06-22 15:32:45 +02:00
wyzula_j e8ae9725fa fix(scan_progressbar): cleanup adjusted 2025-06-22 15:32:45 +02:00
wakonig_k 497e394deb feat(main_window): added scan progress bar to BECMainWindow status bar 2025-06-22 15:32:45 +02:00
wyzula_j d5ca7b8433 feat(scan_progressbar): added oneline design for compact applications 2025-06-22 15:32:45 +02:00
wyzula_j b02c870dbf fix(bec_progressbar): layout and sizing adjustments 2025-06-22 15:32:45 +02:00
wakonig_k 92d0ffee65 refactor(progressbar): change slot / property to safeslot / safeproperty 2025-06-22 15:32:45 +02:00
wakonig_k c4b85381a4 feat(scan_progressbar): added progressbar with hooks to scan progress and device progress 2025-06-22 15:32:45 +02:00
wakonig_k a451625a5a feat(progressbar): added padding as designer property 2025-06-22 15:32:45 +02:00
semantic-release 54dd0a9913 2.16.2
Automatically generated by python-semantic-release
2025-06-20 12:26:07 +00:00
wyzula_j 3146d98c57 test(utils): DMMock can fetch get_bec_signals method 2025-06-20 14:25:27 +02:00
wyzula_j a3ffcefe80 fix(waveform): AsyncSignal are handled with the same update mechanism as async readback 2025-06-20 14:25:27 +02:00
semantic-release 1a7052073d 2.16.1
Automatically generated by python-semantic-release
2025-06-20 06:40:07 +00:00
wakonig_k 235aabf307 fix(scatter): fix tab order 2025-06-20 08:39:28 +02:00
semantic-release c1cb69b0e8 2.16.0
Automatically generated by python-semantic-release
2025-06-17 14:33:15 +00:00
perl_d 11131ef14c fix: adjust height of list widget 2025-06-17 15:32:24 +01:00
perl_d 5e4c129af6 fix: parse config on submission and reload after 2025-06-17 15:32:24 +01:00
perl_d 4d8c07cdd1 fix: make website test robust 2025-06-17 15:32:24 +01:00
perl_d 8f4c8e45b3 fix: tidy up form widget formatting 2025-06-17 15:32:24 +01:00
perl_d 5623547e92 fix: reset dict table properly 2025-06-17 15:32:24 +01:00
perl_d be73349c70 feat: add set form item 2025-06-17 15:32:24 +01:00
perl_d 1a350c3b16 fix: put waiting in thread 2025-06-17 15:32:24 +01:00
perl_d 138d4cabbd feat: generate combobox for literal str 2025-06-17 15:32:24 +01:00
perl_d b0d03c0648 refactor: rename field widgets 2025-06-17 15:32:24 +01:00
perl_d a9613a07b0 test: add tests for config dialog 2025-06-17 15:32:24 +01:00
perl_d 886964bb54 feat: allow editing device config from browser 2025-06-17 15:32:24 +01:00
perl_d 7fc85bac7f feat: add a widget to edit lists in forms 2025-06-17 15:32:24 +01:00
perl_d d626caae3d perf: replace wait with waitUntil 2025-06-17 15:32:24 +01:00
perl_d dea2568de3 fix: scale dict widget height 2025-06-17 15:32:24 +01:00
perl_d a55f561971 fix: pass on kwargs from PydanticModelForm 2025-06-17 15:32:24 +01:00
perl_d 9ce31c9833 refactor: move device config form to module 2025-06-17 15:32:24 +01:00
semantic-release 95ce98c622 2.15.1
Automatically generated by python-semantic-release
2025-06-16 15:19:40 +00:00
wyzula_j 187bf493a5 fix(main_window): added expiration timer for scroll label for ClientInfoMessage 2025-06-16 17:18:52 +02:00
wyzula_j 1612933dd9 fix(scroll_label): updating label during scrolling is done imminently, regardless scrolling 2025-06-16 17:18:52 +02:00
semantic-release 8c3d6334f6 2.15.0
Automatically generated by python-semantic-release
2025-06-15 10:39:36 +00:00
wyzula_j 30acc4c236 test(main_window): BECMainWindow tests extended 2025-06-15 12:38:56 +02:00
wyzula_j 0dec78afba feat(main_window): main window can display the messages from the send_client_info as a scrolling horizontal text; closes #700 2025-06-15 12:38:56 +02:00
wyzula_j 57b9a57a63 refactor(main_window): app id is displayed as QLabel instead of message 2025-06-15 12:38:56 +02:00
wyzula_j 644be621f2 fix(main_window): central widget cleanup check to not remove None 2025-06-15 12:38:56 +02:00
semantic-release d07265b86d 2.14.0
Automatically generated by python-semantic-release
2025-06-13 16:21:17 +00:00
wyzula_j f0d48a0508 refactor(image_roi_tree): shape switch logic adjusted to reduce code repetition 2025-06-13 18:20:37 +02:00
wyzula_j af8db0bede feat(image_roi): added EllipticalROI 2025-06-13 18:20:37 +02:00
semantic-release 0ae4b652a4 2.13.2
Automatically generated by python-semantic-release
2025-06-13 16:17:37 +00:00
perl_d 32fd959e67 fix: allow sets in generated form types 2025-06-13 18:16:56 +02:00
semantic-release 73b1886bb8 2.13.1
Automatically generated by python-semantic-release
2025-06-12 12:51:59 +00:00
wyzula_j 9f853b0864 fix(main_window): event filter applied on QEvent.Type.StatusTip; closes #698 2025-06-12 14:51:14 +02:00
semantic-release 18636e723a 2.13.0
Automatically generated by python-semantic-release
2025-06-10 15:18:29 +00:00
wyzula_j 594185dde9 feat(image_roi_tree): lock/unlock rois possible through the ROIPropertyTree 2025-06-10 17:17:31 +02:00
wyzula_j 46d7e3f517 feat(roi): rois can be lock to be not moved by mouse 2025-06-10 17:17:31 +02:00
wyzula_j f9044996f6 fix(roi): removed roi handle adding/removing inconsistencies 2025-06-10 17:17:31 +02:00
semantic-release 03474cf7f7 2.12.4
Automatically generated by python-semantic-release
2025-06-10 14:42:40 +00:00
wyzula_j 9ef418bf55 fix(image_roi): coordinates are emitted correctly when handles are inverted; closes #672 2025-06-10 16:41:59 +02:00
wakonig_k b3ce68070d ci: add stale issue job 2025-06-06 14:48:10 +02:00
semantic-release 784b54af6e 2.12.3
Automatically generated by python-semantic-release
2025-06-05 19:07:20 +00:00
wakonig_k 3740ac8e32 build: update min dependency of bec to 3.38 2025-06-05 21:06:32 +02:00
wakonig_k edfac87868 fix(crosshair): use objectName instead of config for retrieving the monitor name 2025-06-05 21:06:32 +02:00
wyzula_j 271116453d fix(image): preview signals can be used in Image widget; update logic adjusted; closes #683 2025-06-05 21:06:32 +02:00
wyzula_j 12f5233745 fix(device_combobox): tuple entries of preview signals are checked in DeviceComboBoxes just for the relevant device 2025-06-05 21:06:32 +02:00
semantic-release 392ddf9d1a 2.12.2
Automatically generated by python-semantic-release
2025-06-05 13:27:05 +00:00
wyzula_j 85705383e4 fix(waveform): safeguard for history data access, closes #571; removed return values "none" 2025-06-05 15:26:19 +02:00
semantic-release 224863569f 2.12.1
Automatically generated by python-semantic-release
2025-06-05 12:07:35 +00:00
wyzula_j 3e2544e52a fix(crosshair): emitted name from crosshair 2D is objectName of image or its id 2025-06-05 14:04:44 +02:00
semantic-release 4d5daf6557 2.12.0
Automatically generated by python-semantic-release
2025-06-04 19:51:34 +00:00
perl_d 718116afc3 fix: exclude metadata from RPC 2025-06-04 21:50:54 +02:00
perl_d 2dda58f7d2 feat: add clickable label util 2025-06-04 21:50:54 +02:00
perl_d 594912136e fix: grid formatting in TypedForm 2025-06-04 21:50:54 +02:00
perl_d 5188b38c86 feat: (#493) device browser to display config 2025-06-04 21:50:54 +02:00
perl_d a10e6f7820 fix: make generate plugin robust to multiline init
instead of str.find, use multiline regex with whitespace
2025-06-04 21:50:54 +02:00
perl_d e0e26c205b fix(device browser): mocks and utils for tests 2025-06-04 21:50:54 +02:00
perl_d 92d1d6435d feat: (#493) add dict to dynamic form types 2025-06-04 21:50:54 +02:00
perl_d a25c1a8039 feat: (#493) add helpers to dynamic form widgets 2025-06-04 21:50:54 +02:00
semantic-release fed068f857 2.11.0
Automatically generated by python-semantic-release
2025-06-04 12:12:27 +00:00
wakonig_k 7eb2f54e0e fix(image layer): add layer main if it does not exist 2025-06-04 14:11:46 +02:00
wakonig_k 92b89e7275 refactor(image_base): move default color map to image layer 2025-06-04 14:11:46 +02:00
wakonig_k a4f3117941 refactor(image_item): emit object name with removed signal 2025-06-04 14:11:46 +02:00
wakonig_k 3e789ca35b refactor(image_item): removed outdated image item config 2025-06-04 14:11:46 +02:00
wakonig_k 92dade0950 refactor(image_base): renamed layers to layer_manager and added public methods for accessing the layer manager 2025-06-04 14:11:46 +02:00
wakonig_k 4a343b2041 feat(image_layer): add default name for image layers 2025-06-04 14:11:46 +02:00
wakonig_k c2b0c8c433 refactor(image): move image item creation to layer manager 2025-06-04 14:11:46 +02:00
wakonig_k 8a299a8268 refactor(image): disconnect when layer is removed 2025-06-04 14:11:46 +02:00
wakonig_k 99ecf6a18f refactor(image): removed access to image item config 2025-06-04 14:11:46 +02:00
wakonig_k 4c0bd977fc fix(image_item): do not disconnect the monitor from within the image item 2025-06-04 14:11:46 +02:00
wakonig_k 7c47505c5a test: improve error message for widgets that are not properly cleaned up 2025-06-04 14:11:46 +02:00
wakonig_k e211e4d716 fix(image item): propagate remove call to parent class 2025-06-04 14:11:46 +02:00
wakonig_k 10f292def9 refactor(image): introduce image base and image layer; rename vrange to v_range 2025-06-04 14:11:46 +02:00
semantic-release d111ded737 2.10.3
Automatically generated by python-semantic-release
2025-06-04 09:00:59 +00:00
wyzula_j 2d0ed94f3f fix(color_button_native): popup logic to choose color moved to ColorButtonNative 2025-06-04 11:00:21 +02:00
semantic-release f68f072da3 2.10.2
Automatically generated by python-semantic-release
2025-06-03 11:57:23 +00:00
perl_d 1df6c1925b fix: remove unnecessary PySide imports 2025-06-03 13:56:35 +02:00
perl_d 6b939ac34d ci: check for disallowed imports from PySide 2025-06-03 13:56:35 +02:00
92 changed files with 7507 additions and 1572 deletions
+7 -2
View File
@@ -14,10 +14,15 @@ jobs:
- name: Run black and isort - name: Run black and isort
run: | run: |
pip install black isort pip install uv
pip install -e .[dev] uv pip install --system black isort
uv pip install --system -e .[dev]
black --check --diff --color . black --check --diff --color .
isort --check --diff ./ isort --check --diff ./
- name: Check for disallowed imports from PySide
run: '! grep -re "from PySide6\." bec_widgets/ tests/ | grep -v -e "PySide6.QtDesigner" -e "PySide6.scripts"'
Pylint: Pylint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+15
View File
@@ -0,0 +1,15 @@
name: 'Close stale issues and PRs'
on:
schedule:
- cron: '00 10 * * *'
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
days-before-stale: 60
days-before-close: 7
+528
View File
@@ -1,6 +1,534 @@
# CHANGELOG # CHANGELOG
## v2.21.2 (2025-06-30)
### Bug Fixes
- **waveform**: Fix waveform categorisation for aborted scans
([`09c5a44`](https://github.com/bec-project/bec_widgets/commit/09c5a443aac675f02fa1e38179deb9863af152e2))
### Testing
- Assert config for equality, not identity
([`3f5ab14`](https://github.com/bec-project/bec_widgets/commit/3f5ab142a3cb5446261c4faebdc7b13f10ef4a80))
## v2.21.1 (2025-06-29)
### Bug Fixes
- **sbb monitor**: Add missing pyproject file
([`371bc48`](https://github.com/bec-project/bec_widgets/commit/371bc485d060404433082c9e3e00780961ce6ae3))
## v2.21.0 (2025-06-28)
### Features
- **sbb monitor**: Add sbb monitor widget
([`3d59c25`](https://github.com/bec-project/bec_widgets/commit/3d59c25aa93590a62ab4d31a4ab08589402bf407))
## v2.20.1 (2025-06-28)
### Bug Fixes
- **signal input base**: Unregister callback to avoid accessing deleted qt objects
([`7ba8863`](https://github.com/bec-project/bec_widgets/commit/7ba8863d6a0c21f772e4ef8a5d4180c2a7ab49cb))
## v2.20.0 (2025-06-26)
### Bug Fixes
- **curve_settings**: Larger minimalWidth for the x device combobox selection
([`48a0e58`](https://github.com/bec-project/bec_widgets/commit/48a0e5831feccd30f24218821bbc9d73f8c47933))
### Features
- **waveform**: Move x axis selection to a combobox
([`d10328c`](https://github.com/bec-project/bec_widgets/commit/d10328cb5c775a9b7b40ed4e9f2889e63eb039ff))
### Refactoring
- **curve settings**: Move signal logic to SignalCombobox
([`e841468`](https://github.com/bec-project/bec_widgets/commit/e84146889210165de1c4e63eb20b39f30cc5c623))
### Testing
- **curve settings**: Add curve tree elements to the dialog test
([`1e9dd4c`](https://github.com/bec-project/bec_widgets/commit/1e9dd4cd2561d37bdda1cd86b511295c259b2831))
## v2.19.4 (2025-06-26)
### Bug Fixes
- **curve tree**: Remove manual interception of the close event; call parent cleanup
([`bc3085a`](https://github.com/bec-project/bec_widgets/commit/bc3085ab8cb6688da358df4a7c07fc213a99f2df))
- **waveform**: Curve tree elements must clean up signal combobox
([`9cba696`](https://github.com/bec-project/bec_widgets/commit/9cba696afd3300a76678dfdc4226604696cc3696))
## v2.19.3 (2025-06-25)
### Bug Fixes
- **scan_control**: Safeguard against empty history; reversed history to fetch the newest scan
([`29a26b1`](https://github.com/bec-project/bec_widgets/commit/29a26b19f9ab829b0d877c3233613a0936db0a12))
## v2.19.2 (2025-06-23)
### Bug Fixes
- **scan_control**: Scan parameters fetched from the scan_history, fix #707
([`4456297`](https://github.com/bec-project/bec_widgets/commit/4456297beb940b147882f96caee6fb19aaf93c73))
### Build System
- Bec_lib 3.44 required
([`9f3dcc3`](https://github.com/bec-project/bec_widgets/commit/9f3dcc3ab30a2c238ffffa8d594735ccaf6f1ca4))
### Refactoring
- **scan_control**: Request_last_executed_scan_parameters logic adjusted
([`57f75bd`](https://github.com/bec-project/bec_widgets/commit/57f75bd4d506ca4d8dc982f3051d0d4c29b0d41c))
## v2.19.1 (2025-06-23)
### Bug Fixes
- **launch_window**: Number of remaining connections extended to 4
([`7484f51`](https://github.com/bec-project/bec_widgets/commit/7484f5160c8c6d632fd27996035ff6c0dda2e657))
## v2.19.0 (2025-06-23)
### Bug Fixes
- **ci**: Extend check for pyside import to tests
([`d5a40da`](https://github.com/bec-project/bec_widgets/commit/d5a40dabc74753acad05e3eb6b121499fc1e03d7))
### Features
- (#494) add signal display to device browser
([`f3da6e9`](https://github.com/bec-project/bec_widgets/commit/f3da6e959e0416827ee5d02e34e6ad0ecfc8e5e7))
- (#494) add tabbed layout for device item
([`3378051`](https://github.com/bec-project/bec_widgets/commit/337805125098c3e028a17b74ef6d9ae4b9ba3d6d))
- (#494) display device signals
([`3a10341`](https://github.com/bec-project/bec_widgets/commit/3a103410e7448256a56b59bb3276fee056ec42a0))
## v2.18.0 (2025-06-22)
### Bug Fixes
- Make settings dialog resizable
([`5a564a5`](https://github.com/bec-project/bec_widgets/commit/5a564a5f3f3229e6407ea52a59d3e63319dc214a))
- **curve settings**: Add initial size hint
([`a9708f6`](https://github.com/bec-project/bec_widgets/commit/a9708f6d8f15c42b142488da1e392a8f3179932a))
### Features
- **curve settings**: Add combobox selection for device and signal
([`eea5f7e`](https://github.com/bec-project/bec_widgets/commit/eea5f7ebbd2b477b3ed19c7efcc76390dd391f26))
- **device combobox**: Emit reset event if validation fails
([`4c2c0c5`](https://github.com/bec-project/bec_widgets/commit/4c2c0c5525d593d8ec7fd554336cb11adbe32de2))
- **FilterIO**: Add support for item data
([`8e8acd6`](https://github.com/bec-project/bec_widgets/commit/8e8acd672c0deb8dcd928886fb574452ac956de7))
- **signal combobox**: Add reset_selection slot
([`b51de1a`](https://github.com/bec-project/bec_widgets/commit/b51de1a00e4b17c44cab23e5097391c6fa8ea0e2))
### Refactoring
- **device input**: Refactor to SafeProperty and SafeSlot
([`6e2f2ce`](https://github.com/bec-project/bec_widgets/commit/6e2f2cea91ba3af33e9891532506f4b0b65b90c8))
## v2.17.0 (2025-06-22)
### Bug Fixes
- **bec_progressbar**: Layout and sizing adjustments
([`b02c870`](https://github.com/bec-project/bec_widgets/commit/b02c870dbfecb4bc6921ec4c915dac0e67beb9b4))
- **launch_window**: Number of remaining connections increase to 2 to include the ScanProgressBar
([`3bbb8da`](https://github.com/bec-project/bec_widgets/commit/3bbb8daa24348613f62bde667a446d37dcec8fb0))
- **main_window**: Labels and sizing of scan progress adopted
([`aca6efb`](https://github.com/bec-project/bec_widgets/commit/aca6efb567528eb3c68521a59b4f9903a5616c6f))
- **scan_progressbar**: Cleanup adjusted
([`e8ae972`](https://github.com/bec-project/bec_widgets/commit/e8ae9725fa86b7db52a147ca5a2acc62fa2ccf43))
- **scan_progressbar**: Mapping of bec progress states to the progressbar enums
([`88b42e4`](https://github.com/bec-project/bec_widgets/commit/88b42e49e30a0aa0edc2de4d970408f4be5bde6b))
### Build System
- Update min dependency of bec to 3.42.4
([`a4274ff`](https://github.com/bec-project/bec_widgets/commit/a4274ff8cd9f3e73a61b2eaf902c172c028d21b0))
### Features
- **main_window**: Added scan progress bar to BECMainWindow status bar
([`497e394`](https://github.com/bec-project/bec_widgets/commit/497e394deb5cfe36c8fc4f769fef26f109fd1c1f))
- **main_window**: Timer to show hide scan progress when it is relevant only
([`9ff1706`](https://github.com/bec-project/bec_widgets/commit/9ff170660edd9e03f99eccee60b5e20fc1cf5a8d))
- **progressbar**: Added padding as designer property
([`a451625`](https://github.com/bec-project/bec_widgets/commit/a451625a5ab804ca8259f9c9f83c4f9ebbea4a5b))
- **progressbar**: State setting and dynamic corner radius
([`d3a9e09`](https://github.com/bec-project/bec_widgets/commit/d3a9e0903a263d735ecab3a2ad9319c9d5e86092))
- **scan_progressbar**: Added oneline design for compact applications
([`d5ca7b8`](https://github.com/bec-project/bec_widgets/commit/d5ca7b84337cf60aa66f961d357ae66994f53c7a))
- **scan_progressbar**: Added progressbar with hooks to scan progress and device progress
([`c4b8538`](https://github.com/bec-project/bec_widgets/commit/c4b85381a41e4742567680864668ee83d498b1d1))
### Refactoring
- **progressbar**: Change slot / property to safeslot / safeproperty
([`92d0ffe`](https://github.com/bec-project/bec_widgets/commit/92d0ffee65babc718fafd60131d0a4f291e5ca2b))
### Testing
- **scan progress**: Add test for queue update logic
([`b2a46e2`](https://github.com/bec-project/bec_widgets/commit/b2a46e284d45e97dd9853d1a3c8e95de7e530267))
- **scan_progress**: Tests extended
([`6c04eac`](https://github.com/bec-project/bec_widgets/commit/6c04eac18c887526b333f58fc1118c3b4029abd8))
## v2.16.2 (2025-06-20)
### Bug Fixes
- **waveform**: Asyncsignal are handled with the same update mechanism as async readback
([`a3ffcef`](https://github.com/bec-project/bec_widgets/commit/a3ffcefe8085fa1a88d679f8ef6adfdff786492e))
### Testing
- **utils**: Dmmock can fetch get_bec_signals method
([`3146d98`](https://github.com/bec-project/bec_widgets/commit/3146d98c572ff2bb8ab77f71b75d9612e364ffe0))
## v2.16.1 (2025-06-20)
### Bug Fixes
- **scatter**: Fix tab order
([`235aabf`](https://github.com/bec-project/bec_widgets/commit/235aabf307ef0c01a51a5cd8be4eb53915ed360c))
## v2.16.0 (2025-06-17)
### Bug Fixes
- Adjust height of list widget
([`11131ef`](https://github.com/bec-project/bec_widgets/commit/11131ef14c7e8714a4eaf70256da9e5835d60810))
- Make website test robust
([`4d8c07c`](https://github.com/bec-project/bec_widgets/commit/4d8c07cdd142bab4c0d8224c43e66517a02da7c1))
- Parse config on submission and reload after
([`5e4c129`](https://github.com/bec-project/bec_widgets/commit/5e4c129af6ae6644e4bb94f4129c6770fd26542d))
- Pass on kwargs from PydanticModelForm
([`a55f561`](https://github.com/bec-project/bec_widgets/commit/a55f561971a9ce2295cd835cd5cb6ce436d6c693))
- Put waiting in thread
([`1a350c3`](https://github.com/bec-project/bec_widgets/commit/1a350c3b16da0d990afd53d14934040e5e063177))
- Reset dict table properly
([`5623547`](https://github.com/bec-project/bec_widgets/commit/5623547e926b86eeb5e2164fa6ec9e36b99b8f63))
- Scale dict widget height
([`dea2568`](https://github.com/bec-project/bec_widgets/commit/dea2568de370450ca871fe7bf3573eec9acf8122))
- Tidy up form widget formatting
([`8f4c8e4`](https://github.com/bec-project/bec_widgets/commit/8f4c8e45b3d4a15c67e36cd52d475c3117eca1d3))
### Features
- Add a widget to edit lists in forms
([`7fc85ba`](https://github.com/bec-project/bec_widgets/commit/7fc85bac7fff8555b73d28eefe9a538540d574b9))
- Add set form item
([`be73349`](https://github.com/bec-project/bec_widgets/commit/be73349c706582c144813f70dbc477372057de86))
- Allow editing device config from browser
([`886964b`](https://github.com/bec-project/bec_widgets/commit/886964bb54d2f3923fb6baf198652bb05cf28eb2))
- Generate combobox for literal str
([`138d4ca`](https://github.com/bec-project/bec_widgets/commit/138d4cabbd50e3c86ab18e9cdc25bbb5cdabc511))
### Performance Improvements
- Replace wait with waitUntil
([`d626caa`](https://github.com/bec-project/bec_widgets/commit/d626caae3dc71683134cc47073bc131eba4820f5))
### Refactoring
- Move device config form to module
([`9ce31c9`](https://github.com/bec-project/bec_widgets/commit/9ce31c9833ae38721b2246cdcac50f1154fba99d))
- Rename field widgets
([`b0d03c0`](https://github.com/bec-project/bec_widgets/commit/b0d03c0648cd365143dfed27d4755d6f5b9c7a45))
### Testing
- Add tests for config dialog
([`a9613a0`](https://github.com/bec-project/bec_widgets/commit/a9613a07b0cd9cd9455fd996d124c77218c9388f))
## v2.15.1 (2025-06-16)
### Bug Fixes
- **main_window**: Added expiration timer for scroll label for ClientInfoMessage
([`187bf49`](https://github.com/bec-project/bec_widgets/commit/187bf493a5b18299a10939901b9ed7e308435092))
- **scroll_label**: Updating label during scrolling is done imminently, regardless scrolling
([`1612933`](https://github.com/bec-project/bec_widgets/commit/1612933dd9689f2bf480ad81811c051201a9ff70))
## v2.15.0 (2025-06-15)
### Bug Fixes
- **main_window**: Central widget cleanup check to not remove None
([`644be62`](https://github.com/bec-project/bec_widgets/commit/644be621f20cf09037da763f6217df9d1e4642bc))
### Features
- **main_window**: Main window can display the messages from the send_client_info as a scrolling
horizontal text; closes #700
([`0dec78a`](https://github.com/bec-project/bec_widgets/commit/0dec78afbaddbef98d20949d3a0ba4e0dc8529df))
### Refactoring
- **main_window**: App id is displayed as QLabel instead of message
([`57b9a57`](https://github.com/bec-project/bec_widgets/commit/57b9a57a631f267a8cb3622bf73035ffb15510e6))
### Testing
- **main_window**: Becmainwindow tests extended
([`30acc4c`](https://github.com/bec-project/bec_widgets/commit/30acc4c236bfbfed19f56512b264a52b4359e6c1))
## v2.14.0 (2025-06-13)
### Features
- **image_roi**: Added EllipticalROI
([`af8db0b`](https://github.com/bec-project/bec_widgets/commit/af8db0bede32dd10ad72671a8c2978ca884f4994))
### Refactoring
- **image_roi_tree**: Shape switch logic adjusted to reduce code repetition
([`f0d48a0`](https://github.com/bec-project/bec_widgets/commit/f0d48a05085bb8c628e516d4a976d776ee63c7c3))
## v2.13.2 (2025-06-13)
### Bug Fixes
- Allow sets in generated form types
([`32fd959`](https://github.com/bec-project/bec_widgets/commit/32fd959e675108265f35139b44d02ba966bd37e2))
## v2.13.1 (2025-06-12)
### Bug Fixes
- **main_window**: Event filter applied on QEvent.Type.StatusTip; closes #698
([`9f853b0`](https://github.com/bec-project/bec_widgets/commit/9f853b08640f0ffff9f5b59c6d5e0dd3e210d4f6))
## v2.13.0 (2025-06-10)
### Bug Fixes
- **roi**: Removed roi handle adding/removing inconsistencies
([`f904499`](https://github.com/bec-project/bec_widgets/commit/f9044996f6d62cdbb693149934b09625fb39fd55))
### Features
- **image_roi_tree**: Lock/unlock rois possible through the ROIPropertyTree
([`594185d`](https://github.com/bec-project/bec_widgets/commit/594185dde9c73991489f2154507f1c3d3822c5b4))
- **roi**: Rois can be lock to be not moved by mouse
([`46d7e3f`](https://github.com/bec-project/bec_widgets/commit/46d7e3f5170a5f8b444043bc49651921816f7003))
## v2.12.4 (2025-06-10)
### Bug Fixes
- **image_roi**: Coordinates are emitted correctly when handles are inverted; closes #672
([`9ef418b`](https://github.com/bec-project/bec_widgets/commit/9ef418bf5597d4be77adc3c0c88c1c1619c9aa2f))
### Continuous Integration
- Add stale issue job
([`b3ce680`](https://github.com/bec-project/bec_widgets/commit/b3ce68070d58cdd76559cbd7db04cdbcc6c1f075))
## v2.12.3 (2025-06-05)
### Bug Fixes
- **crosshair**: Use objectName instead of config for retrieving the monitor name
([`edfac87`](https://github.com/bec-project/bec_widgets/commit/edfac87868605b4b755f7732b2841673de53bc3f))
- **device_combobox**: Tuple entries of preview signals are checked in DeviceComboBoxes just for the
relevant device
([`12f5233`](https://github.com/bec-project/bec_widgets/commit/12f523374586d55499f80baf56a50b6ef486cd43))
- **image**: Preview signals can be used in Image widget; update logic adjusted; closes #683
([`2711164`](https://github.com/bec-project/bec_widgets/commit/271116453d1ef5316b19457d04613b6ddc939cdb))
### Build System
- Update min dependency of bec to 3.38
([`3740ac8`](https://github.com/bec-project/bec_widgets/commit/3740ac8e325a489d59faca648896ffcea29e1a02))
## v2.12.2 (2025-06-05)
### Bug Fixes
- **waveform**: Safeguard for history data access, closes #571; removed return values "none"
([`8570538`](https://github.com/bec-project/bec_widgets/commit/85705383e4aff2f83f76d342db0a13380aeca42f))
## v2.12.1 (2025-06-05)
### Bug Fixes
- **crosshair**: Emitted name from crosshair 2D is objectName of image or its id
([`3e2544e`](https://github.com/bec-project/bec_widgets/commit/3e2544e52a84b30a5acb4a7874025fa359a3c58d))
## v2.12.0 (2025-06-04)
### Bug Fixes
- Exclude metadata from RPC
([`718116a`](https://github.com/bec-project/bec_widgets/commit/718116afc3a724658c4cd57b76e93249a66a9ebd))
- Grid formatting in TypedForm
([`5949121`](https://github.com/bec-project/bec_widgets/commit/594912136e2118de1a4de5213c2f668952f28a84))
- Make generate plugin robust to multiline init
([`a10e6f7`](https://github.com/bec-project/bec_widgets/commit/a10e6f7820309d590e832f2bca44ca1db8ef72a1))
instead of str.find, use multiline regex with whitespace
- **device browser**: Mocks and utils for tests
([`e0e26c2`](https://github.com/bec-project/bec_widgets/commit/e0e26c205bf930d680e01910f87489decc7fbcdb))
### Features
- (#493) add dict to dynamic form types
([`92d1d64`](https://github.com/bec-project/bec_widgets/commit/92d1d6435d6e8c05851804eb76605a4abeec01bb))
- (#493) add helpers to dynamic form widgets
([`a25c1a8`](https://github.com/bec-project/bec_widgets/commit/a25c1a8039078c92789b717b3f8a553c75814c33))
- (#493) device browser to display config
([`5188b38`](https://github.com/bec-project/bec_widgets/commit/5188b38c86f543d2abc742411b64fa127c6c0c16))
- Add clickable label util
([`2dda58f`](https://github.com/bec-project/bec_widgets/commit/2dda58f7d2adf1f41c6ce4fad02d55bd9aa200fa))
## v2.11.0 (2025-06-04)
### Bug Fixes
- **image item**: Propagate remove call to parent class
([`e211e4d`](https://github.com/bec-project/bec_widgets/commit/e211e4d7161cc4fc4b2f7cd18f058e070f5b4b7a))
- **image layer**: Add layer main if it does not exist
([`7eb2f54`](https://github.com/bec-project/bec_widgets/commit/7eb2f54e0ed556e0c30a4e14ded75e32dcf3d531))
- **image_item**: Do not disconnect the monitor from within the image item
([`4c0bd97`](https://github.com/bec-project/bec_widgets/commit/4c0bd977fc2b82680bbace763f5ffb19ed664f72))
### Features
- **image_layer**: Add default name for image layers
([`4a343b2`](https://github.com/bec-project/bec_widgets/commit/4a343b204112c53e593e9bb43642d21f268dfa85))
### Refactoring
- **image**: Disconnect when layer is removed
([`8a299a8`](https://github.com/bec-project/bec_widgets/commit/8a299a8268f3c21bbdc6629ad1f1f50a0aa0948b))
- **image**: Introduce image base and image layer; rename vrange to v_range
([`10f292d`](https://github.com/bec-project/bec_widgets/commit/10f292def9d1551bca0d8f63c0a94799c08ff507))
- **image**: Move image item creation to layer manager
([`c2b0c8c`](https://github.com/bec-project/bec_widgets/commit/c2b0c8c4336302ec4a7807c31b3f3b78a413c1aa))
- **image**: Removed access to image item config
([`99ecf6a`](https://github.com/bec-project/bec_widgets/commit/99ecf6a18f2e87d68f3de3abf56d97f7e6467912))
- **image_base**: Move default color map to image layer
([`92b89e7`](https://github.com/bec-project/bec_widgets/commit/92b89e72750fc0ab72ea51f865032133c49a7f18))
- **image_base**: Renamed layers to layer_manager and added public methods for accessing the layer
manager
([`92dade0`](https://github.com/bec-project/bec_widgets/commit/92dade09508ff3940e0b5dc99917302d61b03bc8))
- **image_item**: Emit object name with removed signal
([`a4f3117`](https://github.com/bec-project/bec_widgets/commit/a4f311794132c6c24370cb2f5b5e0725b12587fd))
- **image_item**: Removed outdated image item config
([`3e789ca`](https://github.com/bec-project/bec_widgets/commit/3e789ca35b6d0cf2d8ae9677bc65b7f0ca4eabc7))
### Testing
- Improve error message for widgets that are not properly cleaned up
([`7c47505`](https://github.com/bec-project/bec_widgets/commit/7c47505c5a147885ca2e854e13c1eb3fddaf5489))
## v2.10.3 (2025-06-04)
### Bug Fixes
- **color_button_native**: Popup logic to choose color moved to ColorButtonNative
([`2d0ed94`](https://github.com/bec-project/bec_widgets/commit/2d0ed94f3feb38dfc9645f2c3b9d6a06b92637bb))
## v2.10.2 (2025-06-03)
### Bug Fixes
- Remove unnecessary PySide imports
([`1df6c19`](https://github.com/bec-project/bec_widgets/commit/1df6c1925b6ec88df8d7a1a5a79a5ddc6b1161b5))
### Continuous Integration
- Check for disallowed imports from PySide
([`6b939ac`](https://github.com/bec-project/bec_widgets/commit/6b939ac34d01cdbb0e8e32a0bd4e56cad032e75b))
## v2.10.1 (2025-06-02) ## v2.10.1 (2025-06-02)
### Bug Fixes ### Bug Fixes
+1 -1
View File
@@ -542,7 +542,7 @@ class LaunchWindow(BECMainWindow):
remaining_connections = [ remaining_connections = [
connection for connection in connections.values() if connection.parent_id != self.gui_id connection for connection in connections.values() if connection.parent_id != self.gui_id
] ]
return len(remaining_connections) <= 1 return len(remaining_connections) <= 4
def _turn_off_the_lights(self, connections: dict): def _turn_off_the_lights(self, connections: dict):
""" """
+223 -8
View File
@@ -49,6 +49,7 @@ _Widgets = {
"ResetButton": "ResetButton", "ResetButton": "ResetButton",
"ResumeButton": "ResumeButton", "ResumeButton": "ResumeButton",
"RingProgressBar": "RingProgressBar", "RingProgressBar": "RingProgressBar",
"SBBMonitor": "SBBMonitor",
"ScanControl": "ScanControl", "ScanControl": "ScanControl",
"ScatterWaveform": "ScatterWaveform", "ScatterWaveform": "ScatterWaveform",
"SignalComboBox": "SignalComboBox", "SignalComboBox": "SignalComboBox",
@@ -474,6 +475,20 @@ class BECProgressBar(RPCBase):
>>> progressbar.label_template = "$value / $percentage %" >>> progressbar.label_template = "$value / $percentage %"
""" """
@property
@rpc_call
def state(self):
"""
None
"""
@state.setter
@rpc_call
def state(self):
"""
None
"""
@rpc_call @rpc_call
def _get_label(self) -> str: def _get_label(self) -> str:
""" """
@@ -530,6 +545,26 @@ class BaseROI(RPCBase):
str: The current name of the ROI. str: The current name of the ROI.
""" """
@property
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@movable.setter
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@property @property
@rpc_call @rpc_call
def line_color(self) -> "str": def line_color(self) -> "str":
@@ -639,6 +674,26 @@ class CircularROI(RPCBase):
str: The current name of the ROI. str: The current name of the ROI.
""" """
@property
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@movable.setter
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@property @property
@rpc_call @rpc_call
def line_color(self) -> "str": def line_color(self) -> "str":
@@ -1004,6 +1059,128 @@ class DeviceLineEdit(RPCBase):
""" """
class EllipticalROI(RPCBase):
"""Elliptical Region of Interest with centre/width/height tracking and auto-labelling."""
@property
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@label.setter
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@property
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@movable.setter
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@property
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@line_color.setter
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@property
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@line_width.setter
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@rpc_call
def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
"""
Return the ellipse's centre and size.
Args:
typed (bool | None): If True returns dict; otherwise tuple.
"""
@rpc_call
def get_data_from_image(
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
):
"""
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
Args:
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
the first `ImageItem` in the same GraphicsScene as this ROI.
returnMappedCoords (bool): If True, also returns the coordinate array generated by
*getArrayRegion*.
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
such as `axes`, `order`, `shape`, etc.
Returns:
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
"""
@rpc_call
def set_position(self, x: "float", y: "float"):
"""
Sets the position of the ROI.
Args:
x (float): The x-coordinate of the new position.
y (float): The y-coordinate of the new position.
"""
class Image(RPCBase): class Image(RPCBase):
"""Image widget for displaying 2D data.""" """Image widget for displaying 2D data."""
@@ -1252,16 +1429,16 @@ class Image(RPCBase):
@property @property
@rpc_call @rpc_call
def vrange(self) -> "tuple": def v_range(self) -> "QPointF":
""" """
Get the vrange of the image. Set the v_range of the main image.
""" """
@vrange.setter @v_range.setter
@rpc_call @rpc_call
def vrange(self) -> "tuple": def v_range(self) -> "QPointF":
""" """
Get the vrange of the image. Set the v_range of the main image.
""" """
@property @property
@@ -1459,12 +1636,12 @@ class Image(RPCBase):
@rpc_call @rpc_call
def image( def image(
self, self,
monitor: "str | None" = None, monitor: "str | tuple | None" = None,
monitor_type: "Literal['auto', '1d', '2d']" = "auto", monitor_type: "Literal['auto', '1d', '2d']" = "auto",
color_map: "str | None" = None, color_map: "str | None" = None,
color_bar: "Literal['simple', 'full'] | None" = None, color_bar: "Literal['simple', 'full'] | None" = None,
vrange: "tuple[int, int] | None" = None, vrange: "tuple[int, int] | None" = None,
) -> "ImageItem": ) -> "ImageItem | None":
""" """
Set the image source and update the image. Set the image source and update the image.
@@ -1489,11 +1666,12 @@ class Image(RPCBase):
@rpc_call @rpc_call
def add_roi( def add_roi(
self, self,
kind: "Literal['rect', 'circle']" = "rect", kind: "Literal['rect', 'circle', 'ellipse']" = "rect",
name: "str | None" = None, name: "str | None" = None,
line_width: "int | None" = 5, line_width: "int | None" = 5,
pos: "tuple[float, float] | None" = (10, 10), pos: "tuple[float, float] | None" = (10, 10),
size: "tuple[float, float] | None" = (50, 50), size: "tuple[float, float] | None" = (50, 50),
movable: "bool" = True,
**pg_kwargs, **pg_kwargs,
) -> "RectangularROI | CircularROI": ) -> "RectangularROI | CircularROI":
""" """
@@ -1505,6 +1683,7 @@ class Image(RPCBase):
line_width(int): The line width of the ROI. line_width(int): The line width of the ROI.
pos(tuple): The position of the ROI. pos(tuple): The position of the ROI.
size(tuple): The size of the ROI. size(tuple): The size of the ROI.
movable(bool): Whether the ROI is movable.
**pg_kwargs: Additional arguments for the ROI. **pg_kwargs: Additional arguments for the ROI.
Returns: Returns:
@@ -2664,6 +2843,26 @@ class RectangularROI(RPCBase):
str: The current name of the ROI. str: The current name of the ROI.
""" """
@property
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@movable.setter
@rpc_call
def movable(self) -> "bool":
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
@property @property
@rpc_call @rpc_call
def line_color(self) -> "str": def line_color(self) -> "str":
@@ -3051,6 +3250,12 @@ class RingProgressBar(RPCBase):
""" """
class SBBMonitor(RPCBase):
"""A widget to display the SBB monitor website."""
...
class ScanControl(RPCBase): class ScanControl(RPCBase):
"""Widget to submit new scans to the queue.""" """Widget to submit new scans to the queue."""
@@ -3061,6 +3266,16 @@ class ScanControl(RPCBase):
""" """
class ScanProgressBar(RPCBase):
"""Widget to display a progress bar that is hooked up to the scan progress of a scan."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class ScatterCurve(RPCBase): class ScatterCurve(RPCBase):
"""Scatter curve item for the scatter waveform widget.""" """Scatter curve item for the scatter waveform widget."""
+52 -7
View File
@@ -19,7 +19,7 @@ class FakeDevice(BECDevice):
"readoutPriority": "baseline", "readoutPriority": "baseline",
"deviceClass": "ophyd.Device", "deviceClass": "ophyd.Device",
"deviceConfig": {}, "deviceConfig": {},
"deviceTags": ["user device"], "deviceTags": {"user device"},
"enabled": enabled, "enabled": enabled,
"readOnly": False, "readOnly": False,
"name": self.name, "name": self.name,
@@ -89,16 +89,28 @@ class FakePositioner(BECPositioner):
"readoutPriority": "baseline", "readoutPriority": "baseline",
"deviceClass": "ophyd_devices.SimPositioner", "deviceClass": "ophyd_devices.SimPositioner",
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400}, "deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
"deviceTags": ["user motors"], "deviceTags": {"user motors"},
"enabled": enabled, "enabled": enabled,
"readOnly": False, "readOnly": False,
"name": self.name, "name": self.name,
} }
self._info = { self._info = {
"signals": { "signals": {
"readback": {"kind_str": "hinted"}, # hinted "readback": {
"setpoint": {"kind_str": "normal"}, # normal "kind_str": "hinted",
"velocity": {"kind_str": "config"}, # config "component_name": "readback",
"obj_name": self.name,
}, # hinted
"setpoint": {
"kind_str": "normal",
"component_name": "setpoint",
"obj_name": f"{self.name}_setpoint",
}, # normal
"velocity": {
"kind_str": "config",
"component_name": "velocity",
"obj_name": f"{self.name}_velocity",
}, # config
} }
} }
self.signals = { self.signals = {
@@ -184,8 +196,8 @@ class FakePositioner(BECPositioner):
class Positioner(FakePositioner): class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox""" """just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0): def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
super().__init__(name, limits, read_value) super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
class Device(FakeDevice): class Device(FakeDevice):
@@ -210,6 +222,39 @@ class DMMock:
for device in devices: for device in devices:
self.devices[device.name] = device self.devices[device.name] = device
def get_bec_signals(self, signal_class_name: str):
"""
Emulate DeviceManager.get_bec_signals for unit-tests.
For “AsyncSignal” we list every device whose readout_priority is
ReadoutPriority.ASYNC and build a minimal tuple
(device_name, signal_name, signal_info_dict) that matches the real
API shape used by Waveform._check_async_signal_found.
"""
signals: list[tuple[str, str, dict]] = []
if signal_class_name != "AsyncSignal":
return signals
for device in self.devices.values():
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
device_name = device.name
signal_name = device.name # primary signal in our mocks
signal_info = {
"component_name": signal_name,
"obj_name": signal_name,
"kind_str": "hinted",
"signal_class": signal_class_name,
"metadata": {
"connected": True,
"precision": None,
"read_access": True,
"timestamp": 0.0,
"write_access": True,
},
}
signals.append((device_name, signal_name, signal_info))
return signals
DEVICES = [ DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0), FakePositioner("samx", limits=[-10, 10], read_value=2.0),
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from qtpy.QtCore import Signal
from qtpy.QtGui import QMouseEvent
from qtpy.QtWidgets import QLabel
class ClickableLabel(QLabel):
clicked = Signal()
def mouseReleaseEvent(self, ev: QMouseEvent) -> None:
self.clicked.emit()
return super().mouseReleaseEvent(ev)
+7 -4
View File
@@ -15,12 +15,15 @@ if TYPE_CHECKING: # pragma: no cover
from bec_qthemes._main import AccentColors from bec_qthemes._main import AccentColors
def get_theme_palette(): def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"): if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
theme = "dark" return "dark"
else: else:
theme = QApplication.instance().theme.theme return QApplication.instance().theme.theme
return bec_qthemes.load_palette(theme)
def get_theme_palette():
return bec_qthemes.load_palette(get_theme_name())
def get_accent_colors() -> AccentColors | None: def get_accent_colors() -> AccentColors | None:
+3 -3
View File
@@ -312,7 +312,7 @@ class Crosshair(QObject):
y_values[name] = closest_y y_values[name] = closest_y
x_values[name] = closest_x x_values[name] = closest_x
elif isinstance(item, pg.ImageItem): # 2D plot elif isinstance(item, pg.ImageItem): # 2D plot
name = item.config.monitor or str(id(item)) name = item.objectName() or str(id(item))
image_2d = item.image image_2d = item.image
if image_2d is None: if image_2d is None:
continue continue
@@ -400,7 +400,7 @@ class Crosshair(QObject):
) )
self.coordinatesChanged1D.emit(coordinate_to_emit) self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem): elif isinstance(item, pg.ImageItem):
name = item.config.monitor or str(id(item)) name = item.objectName() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name] x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None: if x is None or y is None:
continue continue
@@ -458,7 +458,7 @@ class Crosshair(QObject):
) )
self.coordinatesClicked1D.emit(coordinate_to_emit) self.coordinatesClicked1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem): elif isinstance(item, pg.ImageItem):
name = item.config.monitor or str(id(item)) name = item.objectName() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name] x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None: if x is None or y is None:
continue continue
+66 -10
View File
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
from bec_qthemes import material_icon from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@@ -12,15 +14,20 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.utils.clickable_label import ClickableLabel
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class ExpandableGroupFrame(QFrame): class ExpandableGroupFrame(QFrame):
expansion_state_changed = Signal()
EXPANDED_ICON_NAME: str = "collapse_all" EXPANDED_ICON_NAME: str = "collapse_all"
COLLAPSED_ICON_NAME: str = "expand_all" COLLAPSED_ICON_NAME: str = "expand_all"
def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None: def __init__(
self, parent: QWidget | None = None, title: str = "", expanded: bool = True, icon: str = ""
) -> None:
super().__init__(parent=parent) super().__init__(parent=parent)
self._expanded = expanded self._expanded = expanded
@@ -29,19 +36,33 @@ class ExpandableGroupFrame(QFrame):
self._layout = QVBoxLayout() self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout) self.setLayout(self._layout)
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout) self._create_title_layout(title, icon)
self._expansion_button = QToolButton()
self._update_icon()
self._title = QLabel(f"<b>{title}</b>")
self._title_layout.addWidget(self._expansion_button)
self._title_layout.addWidget(self._title)
self._contents = QWidget(self) self._contents = QWidget(self)
self._layout.addWidget(self._contents) self._layout.addWidget(self._contents)
self._expansion_button.clicked.connect(self.switch_expanded_state) self._expansion_button.clicked.connect(self.switch_expanded_state)
self.expanded = self._expanded # type: ignore self.expanded = self._expanded # type: ignore
self.expansion_state_changed.emit()
def _create_title_layout(self, title: str, icon: str):
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
self._title = ClickableLabel(f"<b>{title}</b>")
self._title_icon = ClickableLabel()
self._title_layout.addWidget(self._title_icon)
self._title_layout.addWidget(self._title)
self.icon_name = icon
self._title.clicked.connect(self.switch_expanded_state)
self._title_icon.clicked.connect(self.switch_expanded_state)
self._title_layout.addStretch(1)
self._expansion_button = QToolButton()
self._update_expansion_icon()
self._title_layout.addWidget(self._expansion_button, stretch=1)
def set_layout(self, layout: QLayout) -> None: def set_layout(self, layout: QLayout) -> None:
self._contents.setLayout(layout) self._contents.setLayout(layout)
@@ -50,7 +71,8 @@ class ExpandableGroupFrame(QFrame):
@SafeSlot() @SafeSlot()
def switch_expanded_state(self): def switch_expanded_state(self):
self.expanded = not self.expanded # type: ignore self.expanded = not self.expanded # type: ignore
self._update_icon() self._update_expansion_icon()
self.expansion_state_changed.emit()
@SafeProperty(bool) @SafeProperty(bool)
def expanded(self): # type: ignore def expanded(self): # type: ignore
@@ -61,8 +83,9 @@ class ExpandableGroupFrame(QFrame):
self._expanded = expanded self._expanded = expanded
self._contents.setVisible(expanded) self._contents.setVisible(expanded)
self.updateGeometry() self.updateGeometry()
self.adjustSize()
def _update_icon(self): def _update_expansion_icon(self):
self._expansion_button.setIcon( self._expansion_button.setIcon(
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False) material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
if self.expanded if self.expanded
@@ -70,3 +93,36 @@ class ExpandableGroupFrame(QFrame):
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
) )
) )
@SafeProperty(str)
def icon_name(self): # type: ignore
return self._title_icon_name
@icon_name.setter
def icon_name(self, icon_name: str):
self._title_icon_name = icon_name
self._set_title_icon(self._title_icon_name)
def _set_title_icon(self, icon_name: str):
if icon_name:
self._title_icon.setVisible(True)
self._title_icon.setPixmap(
material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=True)
)
else:
self._title_icon.setVisible(False)
# Application example
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
frame = ExpandableGroupFrame()
layout = QVBoxLayout()
frame.set_layout(layout)
layout.addWidget(QLabel("test1"))
layout.addWidget(QLabel("test2"))
layout.addWidget(QLabel("test3"))
frame.show()
app.exec()
+125 -9
View File
@@ -8,6 +8,8 @@ from bec_lib.logger import bec_logger
from qtpy.QtCore import QStringListModel from qtpy.QtCore import QStringListModel
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
from bec_widgets.utils.ophyd_kind_util import Kind
logger = bec_logger.logger logger = bec_logger.logger
@@ -15,11 +17,13 @@ class WidgetFilterHandler(ABC):
"""Abstract base class for widget filter handlers""" """Abstract base class for widget filter handlers"""
@abstractmethod @abstractmethod
def set_selection(self, widget, selection: list) -> None: def set_selection(self, widget, selection: list[str | tuple]) -> None:
"""Set the filtered_selection for the widget """Set the filtered_selection for the widget
Args: Args:
selection (list): Filtered selection of items widget: Widget instance
selection (list[str | tuple]): Filtered selection of items.
If tuple, it contains (text, data) pairs.
""" """
@abstractmethod @abstractmethod
@@ -34,17 +38,37 @@ class WidgetFilterHandler(ABC):
bool: True if the input text is in the filtered selection bool: True if the input text is in the filtered selection
""" """
@abstractmethod
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
# This method should be implemented in subclasses or extended as needed
class LineEditFilterHandler(WidgetFilterHandler): class LineEditFilterHandler(WidgetFilterHandler):
"""Handler for QLineEdit widget""" """Handler for QLineEdit widget"""
def set_selection(self, widget: QLineEdit, selection: list) -> None: def set_selection(self, widget: QLineEdit, selection: list[str | tuple]) -> None:
"""Set the selection for the widget to the completer model """Set the selection for the widget to the completer model
Args: Args:
widget (QLineEdit): The QLineEdit widget widget (QLineEdit): The QLineEdit widget
selection (list): Filtered selection of items selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
""" """
if isinstance(selection, tuple):
# If selection is a tuple, it contains (text, data) pairs
selection = [text for text, _ in selection]
if not isinstance(widget.completer, QCompleter): if not isinstance(widget.completer, QCompleter):
completer = QCompleter(widget) completer = QCompleter(widget)
widget.setCompleter(completer) widget.setCompleter(completer)
@@ -64,19 +88,47 @@ class LineEditFilterHandler(WidgetFilterHandler):
model_data = [model.data(model.index(i)) for i in range(model.rowCount())] model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
return text in model_data return text in model_data
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
return [
signal
for signal, signal_info in device_info.items()
if kind in signal_filter and (signal_info.get("kind_str", None) == str(kind.name))
]
class ComboBoxFilterHandler(WidgetFilterHandler): class ComboBoxFilterHandler(WidgetFilterHandler):
"""Handler for QComboBox widget""" """Handler for QComboBox widget"""
def set_selection(self, widget: QComboBox, selection: list) -> None: def set_selection(self, widget: QComboBox, selection: list[str | tuple]) -> None:
"""Set the selection for the widget to the completer model """Set the selection for the widget to the completer model
Args: Args:
widget (QComboBox): The QComboBox widget widget (QComboBox): The QComboBox widget
selection (list): Filtered selection of items selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
""" """
widget.clear() widget.clear()
widget.addItems(selection) if len(selection) == 0:
return
for element in selection:
if isinstance(element, str):
widget.addItem(element)
elif isinstance(element, tuple):
# If element is a tuple, it contains (text, data) pairs
widget.addItem(*element)
def check_input(self, widget: QComboBox, text: str) -> bool: def check_input(self, widget: QComboBox, text: str) -> bool:
"""Check if the input text is in the filtered selection """Check if the input text is in the filtered selection
@@ -90,6 +142,40 @@ class ComboBoxFilterHandler(WidgetFilterHandler):
""" """
return text in [widget.itemText(i) for i in range(widget.count())] return text in [widget.itemText(i) for i in range(widget.count())]
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
out = []
for signal, signal_info in device_info.items():
if kind not in signal_filter or (signal_info.get("kind_str", None) != str(kind.name)):
continue
obj_name = signal_info.get("obj_name", "")
component_name = signal_info.get("component_name", "")
signal_wo_device = obj_name.removeprefix(f"{device_name}_")
if not signal_wo_device:
signal_wo_device = obj_name
if signal_wo_device != signal and component_name.replace(".", "_") != signal_wo_device:
# If the object name is not the same as the signal name, we use the object name
# to display in the combobox.
out.append((f"{signal_wo_device} ({signal})", signal_info))
else:
# If the object name is the same as the signal name, we do not change it.
out.append((signal, signal_info))
return out
class FilterIO: class FilterIO:
"""Public interface to set filters for input widgets. """Public interface to set filters for input widgets.
@@ -99,13 +185,14 @@ class FilterIO:
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler} _handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
@staticmethod @staticmethod
def set_selection(widget, selection: list, ignore_errors=True): def set_selection(widget, selection: list[str | tuple], ignore_errors=True):
""" """
Retrieve value from the widget instance. Retrieve value from the widget instance.
Args: Args:
widget: Widget instance. widget: Widget instance.
selection(list): List of filtered selection items. selection (list[str | tuple]): Filtered selection of items.
If tuple, it contains (text, data) pairs.
ignore_errors(bool, optional): Whether to ignore if no handler is found. ignore_errors(bool, optional): Whether to ignore if no handler is found.
""" """
handler_class = FilterIO._find_handler(widget) handler_class = FilterIO._find_handler(widget)
@@ -139,6 +226,35 @@ class FilterIO:
) )
return None return None
@staticmethod
def update_with_kind(
widget, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""
Update the selection based on the kind of signal.
Args:
widget: Widget instance.
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().update_with_kind(
kind=kind,
signal_filter=signal_filter,
device_info=device_info,
device_name=device_name,
)
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
@staticmethod @staticmethod
def _find_handler(widget): def _find_handler(widget):
""" """
+134 -37
View File
@@ -1,76 +1,107 @@
from __future__ import annotations from __future__ import annotations
from decimal import Decimal
from types import NoneType from types import NoneType
from typing import NamedTuple
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_qthemes import material_icon from bec_qthemes import material_icon
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from qtpy.QtCore import Signal # type: ignore from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QVBoxLayout, QWidget from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.forms_from_types.items import FormItemSpec, widget_from_type from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.forms_from_types import styles
from bec_widgets.utils.forms_from_types.items import (
DynamicFormItem,
DynamicFormItemType,
FormItemSpec,
widget_from_type,
)
logger = bec_logger.logger logger = bec_logger.logger
class GridRow(NamedTuple):
i: int
label: QLabel
widget: DynamicFormItem
class TypedForm(BECWidget, QWidget): class TypedForm(BECWidget, QWidget):
PLUGIN = True PLUGIN = True
ICON_NAME = "list_alt" ICON_NAME = "list_alt"
value_changed = Signal() value_changed = Signal()
RPC = False RPC = True
USER_ACCESS = ["enabled", "enabled.setter"]
def __init__( def __init__(
self, self,
parent=None, parent=None,
items: list[tuple[str, type]] | None = None, items: list[tuple[str, type]] | None = None,
form_item_specs: list[FormItemSpec] | None = None, form_item_specs: list[FormItemSpec] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None, client=None,
**kwargs, **kwargs,
): ):
"""Widget with a list of form items based on a list of types. """Widget with a list of form items based on a list of types.
Args: Args:
items (list[tuple[str, type]]): list of tuples of a name for the field and its type. items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
Should be a type supported by the logic in items.py Should be a type supported by the logic in items.py
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items. form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be only one of items or form_item_specs should be
supplied. supplied.
enabled (bool, optional): whether fields are enabled for editing.
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
""" """
if (items is not None and form_item_specs is not None) or ( if items is not None and form_item_specs is not None:
items is None and form_item_specs is None logger.error(
): "Must specify one and only one of items and form_item_specs! Ignoring `items`."
raise ValueError("Must specify one and only one of items and form_item_specs") )
items = None
if items is None and form_item_specs is None:
logger.error("Must specify one and only one of items and form_item_specs!")
items = []
super().__init__(parent=parent, client=client, **kwargs) super().__init__(parent=parent, client=client, **kwargs)
self._items = ( self._items = form_item_specs or [
form_item_specs FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
if form_item_specs is not None for name, item_type in items # type: ignore
else [ ]
FormItemSpec(name=name, item_type=item_type) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
for name, item_type in items # type: ignore
]
)
self._layout = QVBoxLayout() self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout) self.setLayout(self._layout)
self._enabled: bool = enabled
self._form_grid_container = QWidget(parent=self) self._form_grid_container = QWidget(parent=self)
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid = QWidget(parent=self._form_grid_container) self._form_grid = QWidget(parent=self._form_grid_container)
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._layout.addWidget(self._form_grid_container) self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout()) self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout()) self._form_grid.setLayout(self._new_grid_layout())
self._widget_types: dict | None = None
self._widget_from_type = widget_from_type
self._post_init()
def _post_init(self):
"""Override this if a subclass should do things after super().__init__ and before populate()"""
self.populate() self.populate()
self.enabled = self._enabled # type: ignore # QProperty
def populate(self): def populate(self):
self._clear_grid() self._clear_grid()
for r, item in enumerate(self._items): for r, item in enumerate(self._items):
self._add_griditem(item, r) self._add_griditem(item, r)
gl: QGridLayout = self._form_grid.layout()
gl.setRowStretch(gl.rowCount(), 1)
def _add_griditem(self, item: FormItemSpec, row: int): def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout() grid = self._form_grid.layout()
@@ -78,19 +109,22 @@ class TypedForm(BECWidget, QWidget):
label.setProperty("_model_field_name", item.name) label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name) label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0) grid.addWidget(label, row, 0)
widget = widget_from_type(item.item_type)(parent=self, spec=item) widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
widget.valueChanged.connect(self.value_changed) widget.valueChanged.connect(self.value_changed)
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
grid.addWidget(widget, row, 1) grid.addWidget(widget, row, 1)
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]: def enumerate_form_widgets(self):
"""Return a generator over the rows of the form, with the row number, the label widget (to
which the field name is attached as a property "_model_field_name"), and the entry widget"""
grid: QGridLayout = self._form_grid.layout() # type: ignore grid: QGridLayout = self._form_grid.layout() # type: ignore
for i in range(grid.rowCount() - 1): # One extra row for stretch
yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget())
def _dict_from_grid(self) -> dict[str, DynamicFormItemType]:
return { return {
grid.itemAtPosition(i, 0) row.label.property("_model_field_name"): row.widget.getValue()
.widget() for row in self.enumerate_form_widgets()
.property("_model_field_name"): grid.itemAtPosition(i, 1)
.widget()
.getValue() # type: ignore # we only add 'DynamicFormItem's here
for i in range(grid.rowCount())
} }
def _clear_grid(self): def _clear_grid(self):
@@ -103,10 +137,13 @@ class TypedForm(BECWidget, QWidget):
old_layout.deleteLater() old_layout.deleteLater()
self._form_grid.deleteLater() self._form_grid.deleteLater()
self._form_grid = QWidget() self._form_grid = QWidget()
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid.setLayout(self._new_grid_layout()) self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid) self._form_grid_container.layout().addWidget(self._form_grid)
self.update_size()
def update_size(self):
self._form_grid.adjustSize() self._form_grid.adjustSize()
self._form_grid_container.adjustSize() self._form_grid_container.adjustSize()
self.adjustSize() self.adjustSize()
@@ -114,23 +151,56 @@ class TypedForm(BECWidget, QWidget):
def _new_grid_layout(self): def _new_grid_layout(self):
new_grid = QGridLayout() new_grid = QGridLayout()
new_grid.setContentsMargins(0, 0, 0, 0) new_grid.setContentsMargins(0, 0, 0, 0)
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
return new_grid return new_grid
@property
def widget_dict(self):
return {
row.label.property("_model_field_name"): row.widget
for row in self.enumerate_form_widgets()
}
@SafeProperty(bool)
def enabled(self):
return self._enabled
@enabled.setter
def enabled(self, value: bool):
self._enabled = value
self.setEnabled(value)
class PydanticModelForm(TypedForm): class PydanticModelForm(TypedForm):
metadata_updated = Signal(dict) metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType) metadata_cleared = Signal(NoneType)
def __init__(self, parent=None, metadata_model: type[BaseModel] = None, client=None, **kwargs): def __init__(
self,
parent=None,
data_model: type[BaseModel] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None,
**kwargs,
):
""" """
A form generated from a pydantic model. A form generated from a pydantic model.
Args: Args:
metadata_model (type[BaseModel]): the model class for which to generate a form. data_model (type[BaseModel]): the model class for which to generate a form.
enabled (bool, optional): whether fields are enabled for editing.
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
""" """
self._md_schema = metadata_model self._pretty_display = pretty_display
super().__init__(parent=parent, form_item_specs=self._form_item_specs(), client=client) self._md_schema = data_model
super().__init__(
parent=parent,
form_item_specs=self._form_item_specs(),
enabled=enabled,
client=client,
**kwargs,
)
self._validity = CompactPopupWidget() self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore self._validity.compact_view = True # type: ignore
@@ -143,13 +213,40 @@ class PydanticModelForm(TypedForm):
self._layout.addWidget(self._validity) self._layout.addWidget(self._validity)
self.value_changed.connect(self.validate_form) self.value_changed.connect(self.validate_form)
self._connect_to_theme_change()
def set_pretty_display_theme(self, theme: str = "dark"):
if self._pretty_display:
self.setStyleSheet(styles.pretty_display_theme(theme))
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
def set_schema(self, schema: type[BaseModel]): def set_schema(self, schema: type[BaseModel]):
self._md_schema = schema self._md_schema = schema
self.populate() self.populate()
def set_data(self, data: BaseModel):
"""Fill the data for the form.
Args:
data (BaseModel): the data to enter into the form. Must be the same type as the
currently set schema, raises TypeError otherwise."""
if not self._md_schema:
raise ValueError("Schema not set - can't set data")
if not isinstance(data, self._md_schema):
raise TypeError(f"Supplied data {data} not of type {self._md_schema}")
for form_item in self.enumerate_form_widgets():
form_item.widget.setValue(getattr(data, form_item.label.property("_model_field_name")))
def _form_item_specs(self): def _form_item_specs(self):
return [ return [
FormItemSpec(name=name, info=info, item_type=info.annotation) FormItemSpec(
name=name, info=info, item_type=info.annotation, pretty_display=self._pretty_display
)
for name, info in self._md_schema.model_fields.items() for name, info in self._md_schema.model_fields.items()
] ]
+357 -31
View File
@@ -1,31 +1,44 @@
from __future__ import annotations from __future__ import annotations
import typing
from abc import abstractmethod from abc import abstractmethod
from decimal import Decimal from decimal import Decimal
from types import UnionType from types import GenericAlias, UnionType
from typing import Callable, Protocol from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_qthemes import material_icon from bec_qthemes import material_icon
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic.fields import FieldInfo from pydantic.fields import FieldInfo
from qtpy.QtCore import Signal # type: ignore from pydantic_core import PydanticUndefined
from qtpy import QtCore
from qtpy.QtCore import QSize, Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication, QApplication,
QButtonGroup, QButtonGroup,
QCheckBox, QCheckBox,
QComboBox,
QDoubleSpinBox, QDoubleSpinBox,
QGridLayout, QGridLayout,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLayout, QLayout,
QLineEdit, QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
QRadioButton, QRadioButton,
QSizePolicy,
QSpinBox, QSpinBox,
QToolButton, QToolButton,
QVBoxLayout,
QWidget, QWidget,
) )
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
from bec_widgets.widgets.editors.scan_metadata._util import ( from bec_widgets.widgets.editors.scan_metadata._util import (
clearable_required, clearable_required,
field_default, field_default,
@@ -34,6 +47,7 @@ from bec_widgets.widgets.editors.scan_metadata._util import (
field_minlen, field_minlen,
field_precision, field_precision,
) )
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
logger = bec_logger.logger logger = bec_logger.logger
@@ -46,9 +60,36 @@ class FormItemSpec(BaseModel):
""" """
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType
item_type: type | UnionType | GenericAlias
name: str name: str
info: FieldInfo = FieldInfo() info: FieldInfo = FieldInfo()
pretty_display: bool = Field(
default=False,
description="Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.",
)
@field_validator("item_type", mode="before")
@classmethod
def _validate_type(cls, v):
allowed_primitives = [str, int, float, bool]
if isinstance(v, (type, UnionType)):
return v
if isinstance(v, GenericAlias):
if v.__origin__ in [list, dict, set] and all(
arg in allowed_primitives for arg in v.__args__
):
return v
raise ValueError(
f"Generics of type {v} are not supported - only lists, dicts and sets of primitive types {allowed_primitives}"
)
if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing
arg_types = set(type(arg) for arg in v.__args__)
if len(arg_types) != 1:
raise ValueError("Mixtures of literal types are not supported!")
if (t := arg_types.pop()) in allowed_primitives:
return t
raise ValueError(f"Literals of type {t} are not supported")
class ClearableBoolEntry(QWidget): class ClearableBoolEntry(QWidget):
@@ -94,10 +135,20 @@ class ClearableBoolEntry(QWidget):
self._false.setToolTip(tooltip) self._false.setToolTip(tooltip)
DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None
class DynamicFormItem(QWidget): class DynamicFormItem(QWidget):
valueChanged = Signal() valueChanged = Signal()
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
"""
Initializes the form item widget.
Args:
parent (QWidget | None, optional): The parent widget. Defaults to None.
spec (FormItemSpec): The specification for the form item.
"""
super().__init__(parent) super().__init__(parent)
self._spec = spec self._spec = spec
self._layout = QHBoxLayout() self._layout = QHBoxLayout()
@@ -107,11 +158,17 @@ class DynamicFormItem(QWidget):
self._desc = self._spec.info.description self._desc = self._spec.info.description
self.setLayout(self._layout) self.setLayout(self._layout)
self._add_main_widget() self._add_main_widget()
if clearable_required(spec.info): assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
self._add_clear_button() self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
if not spec.pretty_display:
if clearable_required(spec.info):
self._add_clear_button()
else:
self._set_pretty_display()
@abstractmethod @abstractmethod
def getValue(self): ... def getValue(self) -> DynamicFormItemType: ...
@abstractmethod @abstractmethod
def setValue(self, value): ... def setValue(self, value): ...
@@ -121,6 +178,11 @@ class DynamicFormItem(QWidget):
"""Add the main data entry widget to self._main_widget and appply any """Add the main data entry widget to self._main_widget and appply any
constraints from the field info""" constraints from the field info"""
def _set_pretty_display(self):
self.setEnabled(False)
if button := getattr(self, "_clear_button", None):
button.setVisible(False)
def _describe(self, pad=" "): def _describe(self, pad=" "):
return pad + (self._desc if self._desc else "") return pad + (self._desc if self._desc else "")
@@ -138,7 +200,7 @@ class DynamicFormItem(QWidget):
self.valueChanged.emit() self.valueChanged.emit()
class StrMetadataField(DynamicFormItem): class StrFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec) super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed) self._main_widget.textChanged.connect(self._value_changed)
@@ -163,11 +225,11 @@ class StrMetadataField(DynamicFormItem):
def setValue(self, value: str): def setValue(self, value: str):
if value is None: if value is None:
self._main_widget.setText("") return self._main_widget.setText("")
self._main_widget.setText(value) self._main_widget.setText(str(value))
class IntMetadataField(DynamicFormItem): class IntFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec) super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed) self._main_widget.textChanged.connect(self._value_changed)
@@ -196,18 +258,18 @@ class IntMetadataField(DynamicFormItem):
self._main_widget.setValue(value) self._main_widget.setValue(value)
class FloatDecimalMetadataField(DynamicFormItem): class FloatDecimalFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec) super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed) self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None: def _add_main_widget(self) -> None:
precision = field_precision(self._spec.info)
self._main_widget = QDoubleSpinBox() self._main_widget = QDoubleSpinBox()
self._layout.addWidget(self._main_widget) self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._spec.info, int) min_, max_ = field_limits(self._spec.info, float, precision)
self._main_widget.setMinimum(min_) self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_) self._main_widget.setMaximum(max_)
precision = field_precision(self._spec.info)
if precision: if precision:
self._main_widget.setDecimals(precision) self._main_widget.setDecimals(precision)
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}" minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
@@ -224,13 +286,13 @@ class FloatDecimalMetadataField(DynamicFormItem):
return self._default return self._default
return self._main_widget.value() return self._main_widget.value()
def setValue(self, value: float): def setValue(self, value: float | Decimal):
if value is None: if value is None:
self._main_widget.clear() self._main_widget.clear()
self._main_widget.setValue(value) self._main_widget.setValue(float(value))
class BoolMetadataField(DynamicFormItem): class BoolFormItem(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None: def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec) super().__init__(parent=parent, spec=spec)
self._main_widget.stateChanged.connect(self._value_changed) self._main_widget.stateChanged.connect(self._value_changed)
@@ -251,36 +313,300 @@ class BoolMetadataField(DynamicFormItem):
self._main_widget.setChecked(value) self._main_widget.setChecked(value)
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]: class BoolToggleFormItem(BoolFormItem):
if annotation in [str, str | None]: def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
return StrMetadataField if spec.info.default is PydanticUndefined:
if annotation in [int, int | None]: spec.info.default = False
return IntMetadataField super().__init__(parent=parent, spec=spec)
if annotation in [float, float | None, Decimal, Decimal | None]:
return FloatDecimalMetadataField def _add_main_widget(self) -> None:
if annotation in [bool, bool | None]: self._main_widget = ToggleSwitch()
return BoolMetadataField self._layout.addWidget(self._main_widget)
else: self._main_widget.setToolTip(self._describe(""))
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.") if self._default is not None:
return StrMetadataField self._main_widget.setChecked(self._default)
class DictFormItem(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.data_changed.connect(self._value_changed)
if spec.info.default is not PydanticUndefined:
self._main_widget.set_default(spec.info.default)
def _set_pretty_display(self):
self._main_widget.set_button_visibility(False)
super()._set_pretty_display()
def _add_main_widget(self) -> None:
self._main_widget = DictBackedTable(self, [])
self._layout.addWidget(self._main_widget)
self._main_widget.setToolTip(self._describe(""))
def getValue(self):
return self._main_widget.dump_dict()
def setValue(self, value):
self._main_widget.replace_data(value)
class _ItemAndWidgetType(NamedTuple):
# TODO: this should be generic but not supported in 3.10
item: type[int | float | str]
widget: type[QWidget]
default: int | float | str
class ListFormItem(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
if spec.info.annotation is list:
self._types = _ItemAndWidgetType(str, QLineEdit, "")
elif isinstance(spec.info.annotation, GenericAlias):
args = set(typing.get_args(spec.info.annotation))
if args == {str}:
self._types = _ItemAndWidgetType(str, QLineEdit, "")
if args == {int}:
self._types = _ItemAndWidgetType(int, QSpinBox, 0)
if args == {float} or args == {int, float}:
self._types = _ItemAndWidgetType(float, QDoubleSpinBox, 0.0)
else:
self._types = _ItemAndWidgetType(str, QLineEdit, "")
super().__init__(parent=parent, spec=spec)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self._main_widget: QListWidget
self._data = []
self._min_lines = 2 if spec.pretty_display else 4
self._repop(self._data)
def sizeHint(self):
default = super().sizeHint()
return QSize(default.width(), QFontMetrics(self.font()).height() * 6)
def _add_main_widget(self) -> None:
self._main_widget = QListWidget()
self._layout.addWidget(self._main_widget)
self._layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self._add_buttons()
def _add_buttons(self):
self._button_holder = QWidget()
self._buttons = QVBoxLayout()
self._button_holder.setLayout(self._buttons)
self._layout.addWidget(self._button_holder)
self._add_button = QPushButton("+")
self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-")
self._remove_button.setToolTip("delete the focused row (if any)")
self._add_button.clicked.connect(self._add_row)
self._remove_button.clicked.connect(self._delete_row)
self._buttons.addWidget(self._add_button)
self._buttons.addWidget(self._remove_button)
def _set_pretty_display(self):
super()._set_pretty_display()
self._button_holder.setHidden(True)
def _repop(self, data):
self._main_widget.clear()
for val in data:
self._add_list_item(val)
self.scale_to_data()
def _add_data_item(self, val=None):
val = val or self._types.default
self._data.append(val)
self._add_list_item(val)
self._repop(self._data)
def _add_list_item(self, val):
item = QListWidgetItem(self._main_widget)
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable)
item_widget = self._types.widget(parent=self)
WidgetIO.set_value(item_widget, val)
self._main_widget.setItemWidget(item, item_widget)
self._main_widget.addItem(item)
WidgetIO.connect_widget_change_signal(item_widget, self._update)
return item_widget
def _update(self, _, value, *args):
self._data[self._main_widget.currentRow()] = value
@SafeSlot()
def _add_row(self):
self._add_data_item(self._types.default)
self._repop(self._data)
@SafeSlot()
def _delete_row(self):
if selected := self._main_widget.currentItem():
self._main_widget.removeItemWidget(selected)
row = self._main_widget.currentRow()
self._main_widget.takeItem(row)
self._data.pop(row)
self._repop(self._data)
@SafeSlot()
def clear(self):
self._repop([])
def getValue(self):
return self._data
def setValue(self, value: Iterable):
if set(map(type, value)) | {self._types.item} != {self._types.item}:
raise ValueError(f"This widget only accepts items of type {self._types.item}")
self._data = list(value)
self._repop(self._data)
def _line_height(self):
return QFontMetrics(self._main_widget.font()).height()
def set_max_height_in_lines(self, lines: int):
outer_inc = 1 if self._spec.pretty_display else 3
self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines))
self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1))
self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc))
def scale_to_data(self, *_):
self.set_max_height_in_lines(self._main_widget.count() + 1)
class SetFormItem(ListFormItem):
def _add_main_widget(self) -> None:
super()._add_main_widget()
self._add_item_field = self._types.widget()
self._buttons.addWidget(QLabel("Add new:"))
self._buttons.addWidget(self._add_item_field)
self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Minimum)
@SafeSlot()
def _add_row(self):
self._add_data_item(WidgetIO.get_value(self._add_item_field))
self._repop(self._data)
def _update(self, _, value, *args):
if value in self._data:
return
return super()._update(_, value, *args)
def _add_data_item(self, val=None):
val = val or self._types.default
if val == self._types.default or val in self._data:
return
self._data.append(val)
self._add_list_item(val)
def _add_list_item(self, val):
item_widget = super()._add_list_item(val)
if isinstance(item_widget, QLineEdit):
item_widget.setReadOnly(True)
return item_widget
def getValue(self):
return set(self._data)
def setValue(self, value: set):
return super().setValue(set(value))
class StrLiteralFormItem(DynamicFormItem):
def _add_main_widget(self) -> None:
self._main_widget = QComboBox()
self._options = get_args(self._spec.info.annotation)
for opt in self._options:
self._main_widget.addItem(opt)
self._layout.addWidget(self._main_widget)
def getValue(self):
return self._main_widget.currentText()
def setValue(self, value: str | None):
if value is None:
self.clear()
for i in range(self._main_widget.count()):
if self._main_widget.itemText(i) == value:
self._main_widget.setCurrentIndex(i)
return
raise ValueError(f"Cannot set value: {value}, options are: {self._options}")
def clear(self):
self._main_widget.setCurrentIndex(-1)
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
# and delete/insert keys or change the order
"literal_str": (
lambda spec: type(spec.info.annotation) is type(Literal[""])
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str},
StrLiteralFormItem,
),
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
"float_decimal": (
lambda spec: spec.item_type in [float, float | None, Decimal, Decimal | None],
FloatDecimalFormItem,
),
"bool": (lambda spec: spec.item_type in [bool, bool | None], BoolFormItem),
"dict": (
lambda spec: spec.item_type in [dict, dict | None]
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is dict),
DictFormItem,
),
"list": (
lambda spec: spec.item_type in [list, list | None]
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is list),
ListFormItem,
),
"set": (
lambda spec: spec.item_type in [set, set | None]
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is set),
SetFormItem,
),
}
def widget_from_type(
spec: FormItemSpec, widget_types: WidgetTypeRegistry | None = None
) -> type[DynamicFormItem]:
widget_types = widget_types or DEFAULT_WIDGET_TYPES
for predicate, widget_type in widget_types.values():
if predicate(spec):
return widget_type
logger.warning(
f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation."
)
return StrFormItem
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
class TestModel(BaseModel): class TestModel(BaseModel):
value0: set = Field(set(["a", "b"]))
value1: str | None = Field(None) value1: str | None = Field(None)
value2: bool | None = Field(None) value2: bool | None = Field(None)
value3: bool = Field(True) value3: bool = Field(True)
value4: int = Field(123) value4: int = Field(123)
value5: int | None = Field() value5: int | None = Field()
value6: list[int] = Field()
value7: list = Field()
app = QApplication([]) app = QApplication([])
w = QWidget() w = QWidget()
layout = QGridLayout() layout = QGridLayout()
w.setLayout(layout) w.setLayout(layout)
items = []
for i, (field_name, info) in enumerate(TestModel.model_fields.items()): for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
layout.addWidget(QLabel(field_name), i, 0) layout.addWidget(QLabel(field_name), i, 0)
layout.addWidget(widget_from_type(info.annotation)(info), i, 1) widg = widget_from_type(spec)(spec=spec)
items.append(widg)
layout.addWidget(widg, i, 1)
items[6].setValue([1, 2, 3, 4])
items[7].setValue(["1", "2", "asdfg", "qwerty"])
w.show() w.show()
app.exec() app.exec()
@@ -0,0 +1,21 @@
import bec_qthemes
def pretty_display_theme(theme: str = "dark"):
palette = bec_qthemes.load_palette(theme)
foreground = palette.text().color().name()
background = palette.base().color().name()
border = palette.shadow().color().name()
accent = palette.accent().color().name()
return f"""
QWidget {{color: {foreground}; background-color: {background}}}
QLabel {{ font-weight: bold; }}
QLineEdit,QLabel,QTreeView {{ border-style: solid; border-width: 2px; border-color: {border} }}
QRadioButton {{ color: {foreground}; }}
QRadioButton::indicator::checked {{ color: {accent}; }}
QCheckBox {{ color: {accent}; }}
"""
if __name__ == "__main__":
print(pretty_display_theme())
+10 -21
View File
@@ -8,6 +8,9 @@ from qtpy.QtCore import QObject
from bec_widgets.utils.name_utils import pascal_to_snake from bec_widgets.utils.name_utils import pascal_to_snake
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"] EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
_PARENT_ARG_REGEX = r".__init__\(\s*(?:parent\)|parent=parent,?|parent,?)"
_SELF_PARENT_ARG_REGEX = r".__init__\(\s*self,\s*(?:parent\)|parent=parent,?|parent,?)"
SUPER_INIT_REGEX = re.compile(r"super\(\)" + _PARENT_ARG_REGEX, re.MULTILINE)
class PluginFilenames(NamedTuple): class PluginFilenames(NamedTuple):
@@ -90,34 +93,20 @@ class DesignerPluginGenerator:
# Check if the widget class calls the super constructor with parent argument # Check if the widget class calls the super constructor with parent argument
init_source = inspect.getsource(self.widget.__init__) init_source = inspect.getsource(self.widget.__init__)
cls_init_found = ( class_re = re.compile(base_cls[0].__name__ + _SELF_PARENT_ARG_REGEX, re.MULTILINE)
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0) cls_init_found = class_re.search(init_source) is not None
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0) super_self_re = re.compile(
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0) rf"super\({base_cls[0].__name__}, self\)" + _PARENT_ARG_REGEX, re.MULTILINE
)
super_init_found = (
bool(
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
) )
super_init_found = super_self_re.search(init_source) is not None
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found: if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
super_init_found = ( super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
bool(init_source.find("super().__init__(parent=parent") > 0)
or bool(init_source.find("super().__init__(parent,") > 0)
or bool(init_source.find("super().__init__(parent)") > 0)
)
# for the new style classes, we only have one super call. We can therefore check if the # for the new style classes, we only have one super call. We can therefore check if the
# number of __init__ calls is 2 (the class itself and the super class) # number of __init__ calls is 2 (the class itself and the super class)
num_inits = re.findall(r"__init__", init_source) num_inits = re.findall(r"__init__", init_source)
if len(num_inits) == 2 and not super_init_found: if len(num_inits) == 2 and not super_init_found:
super_init_found = bool( super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
init_source.find("super().__init__(parent=parent") > 0
or init_source.find("super().__init__(parent,") > 0
or init_source.find("super().__init__(parent)") > 0
)
if not cls_init_found and not super_init_found: if not cls_init_found and not super_init_found:
raise ValueError( raise ValueError(
+1 -1
View File
@@ -1,5 +1,5 @@
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from PySide6.QtGui import QCloseEvent from qtpy.QtGui import QCloseEvent
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
+1 -1
View File
@@ -9,7 +9,7 @@ from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger logger = bec_logger.logger
if PYSIDE6: if PYSIDE6:
from PySide6.QtUiTools import QUiLoader from qtpy.QtUiTools import QUiLoader
class CustomUiLoader(QUiLoader): class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance, custom_widgets: dict | None = None): def __init__(self, baseinstance, custom_widgets: dict | None = None):
@@ -169,6 +169,9 @@ class BECDockArea(BECWidget, QWidget):
tooltip="Add LogPanel - Disabled", tooltip="Add LogPanel - Disabled",
filled=True, filled=True,
), ),
"sbb_monitor": MaterialIconAction(
icon_name="train", tooltip="Add SBB Monitor", filled=True
),
}, },
), ),
"separator_2": SeparatorAction(), "separator_2": SeparatorAction(),
@@ -238,6 +241,9 @@ class BECDockArea(BECWidget, QWidget):
# self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect( # self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
# lambda: self._create_widget_from_toolbar(widget_name="LogPanel") # lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
# ) # )
self.toolbar.widgets["menu_utils"].widgets["sbb_monitor"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor")
)
# Icons # Icons
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all) self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)
@@ -0,0 +1,115 @@
import sys
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
class WidgetTooltip(QWidget):
"""Frameless, always-on-top window that behaves like a tooltip."""
def __init__(self, content: QWidget) -> None:
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_ShowWithoutActivating)
self.setMouseTracking(True)
self.content = content
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.addWidget(self.content)
self.adjustSize()
def leaveEvent(self, _event) -> None:
self.hide()
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
self.adjustSize()
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
screen_geo = screen.availableGeometry()
geom = self.geometry()
x = global_pos.x() - geom.width() // 2
y = global_pos.y() - geom.height() - offset
x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
self.move(x, y)
self.show()
class HoverWidget(QWidget):
def __init__(self, parent: QWidget | None = None, *, simple: QWidget, full: QWidget):
super().__init__(parent)
self._simple = simple
self._full = full
self._full.setVisible(False)
self._tooltip = None
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(simple)
def enterEvent(self, event):
# suppress empty-label tooltips for labels
if isinstance(self._full, QLabel) and not self._full.text():
return
if self._tooltip is None: # first time only
self._tooltip = WidgetTooltip(self._full)
self._full.setVisible(True)
centre = self.mapToGlobal(self.rect().center())
self._tooltip.show_above(centre)
super().enterEvent(event)
def leaveEvent(self, event):
if self._tooltip and self._tooltip.isVisible():
self._tooltip.hide()
super().leaveEvent(event)
def close(self):
if self._tooltip:
self._tooltip.close()
self._tooltip.deleteLater()
self._tooltip = None
super().close()
################################################################################
# Demo
# Just a simple example to show how the HoverWidget can be used to display
# a tooltip with a full widget inside (two different widgets are used
# for the simple and full versions).
################################################################################
class DemoSimpleWidget(QLabel): # pragma: no cover
"""A simple widget to be used as a trigger for the tooltip."""
def __init__(self) -> None:
super().__init__()
self.setText("Hover me for a preview!")
class DemoFullWidget(QProgressBar): # pragma: no cover
"""A full widget to be shown in the tooltip."""
def __init__(self) -> None:
super().__init__()
self.setRange(0, 100)
self.setValue(75)
self.setFixedWidth(320)
self.setFixedHeight(30)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = QWidget()
window.layout = QHBoxLayout(window)
hover_widget = HoverWidget(simple=DemoSimpleWidget(), full=DemoFullWidget())
window.layout.addWidget(hover_widget)
window.show()
sys.exit(app.exec_())
@@ -0,0 +1,110 @@
from qtpy.QtCore import QTimer
from qtpy.QtGui import QFontMetrics, QPainter
from qtpy.QtWidgets import QLabel
class ScrollLabel(QLabel):
"""A QLabel that scrolls its text horizontally across the widget."""
def __init__(self, parent=None, speed_ms=30, step_px=1, delay_ms=2000):
super().__init__(parent=parent)
self._offset = 0
self._text_width = 0
# scrolling timer (runs continuously once started)
self._timer = QTimer(self)
self._timer.setInterval(speed_ms)
self._timer.timeout.connect(self._scroll)
# delaybeforescroll timer (singleshot)
self._delay_timer = QTimer(self)
self._delay_timer.setSingleShot(True)
self._delay_timer.setInterval(delay_ms)
self._delay_timer.timeout.connect(self._timer.start)
self._step_px = step_px
def setText(self, text):
"""
Overridden to ensure that new text replaces the current one
immediately.
If the label was already scrolling (or in its delay phase),
the next message starts **without** the extra delay.
"""
# Determine whether the widget was already in a scrolling cycle
was_scrolling = self._timer.isActive() or self._delay_timer.isActive()
super().setText(text)
fm = QFontMetrics(self.font())
self._text_width = fm.horizontalAdvance(text)
self._offset = 0
# Skip the delay when we were already scrolling
self._update_timer(skip_delay=was_scrolling)
def resizeEvent(self, event):
super().resizeEvent(event)
self._update_timer()
def _update_timer(self, *, skip_delay: bool = False):
"""
Decide whether to start or stop scrolling.
If the text is wider than the visible area, start a singleshot
delay timer (2s by default). Scrolling begins only after this
delay. Any change (resize or new text) restarts the logic.
"""
needs_scroll = self._text_width > self.width()
if needs_scroll:
# Reset any running timers
if self._timer.isActive():
self._timer.stop()
if self._delay_timer.isActive():
self._delay_timer.stop()
self._offset = 0
# Start scrolling immediately when we should skip the delay,
# otherwise apply the configured delay_ms interval
if skip_delay:
self._timer.start()
else:
self._delay_timer.start()
else:
if self._delay_timer.isActive():
self._delay_timer.stop()
if self._timer.isActive():
self._timer.stop()
self.update()
def _scroll(self):
self._offset += self._step_px
if self._offset >= self._text_width:
self._offset = 0
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.TextAntialiasing)
text = self.text()
if not text:
return
fm = QFontMetrics(self.font())
y = (self.height() + fm.ascent() - fm.descent()) // 2
if self._text_width <= self.width():
painter.drawText(0, y, text)
else:
x = -self._offset
gap = 50 # space between repeating text blocks
while x < self.width():
painter.drawText(x, y, text)
x += self._text_width + gap
def cleanup(self):
"""Stop all timers to prevent memory leaks."""
if self._timer.isActive():
self._timer.stop()
if self._delay_timer.isActive():
self._delay_timer.stop()
@@ -1,17 +1,31 @@
from __future__ import annotations
import os import os
from qtpy.QtCore import QSize from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
from qtpy.QtGui import QAction, QActionGroup, QIcon from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QStyle,
QVBoxLayout,
QWidget,
)
import bec_widgets import bec_widgets
from bec_widgets.utils import UILoader from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
MODULE_PATH = os.path.dirname(bec_widgets.__file__) MODULE_PATH = os.path.dirname(bec_widgets.__file__)
@@ -19,6 +33,8 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECMainWindow(BECWidget, QMainWindow): class BECMainWindow(BECWidget, QMainWindow):
RPC = False RPC = False
PLUGIN = False PLUGIN = False
SCAN_PROGRESS_WIDTH = 100 # px
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
def __init__( def __init__(
self, self,
@@ -32,10 +48,19 @@ class BECMainWindow(BECWidget, QMainWindow):
super().__init__(parent=parent, gui_id=gui_id, **kwargs) super().__init__(parent=parent, gui_id=gui_id, **kwargs)
self.app = QApplication.instance() self.app = QApplication.instance()
self.status_bar = self.statusBar()
self.setWindowTitle(window_title) self.setWindowTitle(window_title)
self._init_ui() self._init_ui()
self._connect_to_theme_change() self._connect_to_theme_change()
# Connections to BEC Notifications
self.bec_dispatcher.connect_slot(
self.display_client_message, MessageEndpoints.client_info()
)
################################################################################
# MainWindow Elements Initialization
################################################################################
def _init_ui(self): def _init_ui(self):
# Set the icon # Set the icon
@@ -43,40 +68,189 @@ class BECMainWindow(BECWidget, QMainWindow):
# Set Menu and Status bar # Set Menu and Status bar
self._setup_menu_bar() self._setup_menu_bar()
self._init_status_bar_widgets()
# BEC Specific UI # BEC Specific UI
self.display_app_id() self.display_app_id()
def _init_status_bar_widgets(self):
"""
Prepare the BEC specific widgets in the status bar.
"""
# Left: AppID label
self._app_id_label = QLabel()
self._app_id_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
self.status_bar.addWidget(self._app_id_label)
# Add a separator after the app ID label
self._add_separator()
# Centre: Clientinfo label (stretch=1 so it expands)
self._add_client_info_label()
# Add scan_progress bar with display logic
self._add_scan_progress_bar()
################################################################################
# Client message status bar widget helpers
def _add_client_info_label(self):
"""
Add a client info label to the status bar.
This label will display messages from the BEC dispatcher.
"""
# Scroll label for client info in Status Bar
self._client_info_label = ScrollLabel(self)
self._client_info_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
# Full label used in the hover widget
self._client_info_label_full = QLabel(self)
self._client_info_label_full.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
# Hover widget to show the full client info label
self._client_info_hover = HoverWidget(
self, simple=self._client_info_label, full=self._client_info_label_full
)
self.status_bar.addWidget(self._client_info_hover, 1)
# Timer to automatically clear client messages once they expire
self._client_info_expire_timer = QTimer(self)
self._client_info_expire_timer.setSingleShot(True)
self._client_info_expire_timer.timeout.connect(lambda: self._client_info_label.setText(""))
self._client_info_expire_timer.timeout.connect(
lambda: self._client_info_label_full.setText("")
)
################################################################################
# Progressbar helpers
def _add_scan_progress_bar(self):
# Setting HoverWidget for the scan progress bar - minimal and full version
self._scan_progress_bar_simple = ScanProgressBar(self, one_line_design=True)
self._scan_progress_bar_simple.show_elapsed_time = False
self._scan_progress_bar_simple.show_remaining_time = False
self._scan_progress_bar_simple.show_source_label = False
self._scan_progress_bar_simple.progressbar.label_template = ""
self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
self._scan_progress_bar_full = ScanProgressBar(self)
self._scan_progress_hover = HoverWidget(
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
)
# Bundle the progress bar with a separator
separator = self._add_separator(separate_object=True)
self._scan_progress_bar_with_separator = QWidget()
self._scan_progress_bar_with_separator.layout = QHBoxLayout(
self._scan_progress_bar_with_separator
)
self._scan_progress_bar_with_separator.layout.setContentsMargins(0, 0, 0, 0)
self._scan_progress_bar_with_separator.layout.setSpacing(0)
self._scan_progress_bar_with_separator.layout.addWidget(separator)
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
# Set Size
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
self._scan_progress_bar_with_separator.setMaximumWidth(self._scan_progress_bar_target_width)
self.status_bar.addWidget(self._scan_progress_bar_with_separator)
# Visibility logic
self._scan_progress_bar_with_separator.hide()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
# Timer for hiding logic
self._scan_progress_hide_timer = QTimer(self)
self._scan_progress_hide_timer.setSingleShot(True)
self._scan_progress_hide_timer.setInterval(self.STATUS_BAR_WIDGETS_EXPIRE_TIME)
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
# Show / hide behaviour
self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar)
self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar)
def _show_scan_progress_bar(self):
if self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
if self._scan_progress_bar_with_separator.isVisible():
return
# Make visible and reset width
self._scan_progress_bar_with_separator.show()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
self._show_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._show_container_anim.setDuration(300)
self._show_container_anim.setStartValue(0)
self._show_container_anim.setEndValue(self._scan_progress_bar_target_width)
self._show_container_anim.setEasingCurve(QEasingCurve.OutCubic)
self._show_container_anim.start()
def _delay_hide_scan_progress_bar(self):
"""Start the countdown to hide the scan progress bar."""
if hasattr(self, "_scan_progress_hide_timer"):
self._scan_progress_hide_timer.start()
def _animate_hide_scan_progress_bar(self):
"""Shrink container to the right, then hide."""
self._hide_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._hide_container_anim.setDuration(300)
self._hide_container_anim.setStartValue(self._scan_progress_bar_with_separator.width())
self._hide_container_anim.setEndValue(0)
self._hide_container_anim.setEasingCurve(QEasingCurve.InCubic)
self._hide_container_anim.finished.connect(self._scan_progress_bar_with_separator.hide)
self._hide_container_anim.start()
def _add_separator(self, separate_object: bool = False) -> QWidget | None:
"""
Add a vertically centred separator to the status bar or just return it as a separate object.
"""
status_bar = self.statusBar()
# The actual line
line = QFrame()
line.setFrameShape(QFrame.VLine)
line.setFrameShadow(QFrame.Sunken)
line.setFixedHeight(status_bar.sizeHint().height() - 2)
# Wrapper to center the line vertically -> work around for QFrame not being able to center itself
wrapper = QWidget()
vbox = QVBoxLayout(wrapper)
vbox.setContentsMargins(0, 0, 0, 0)
vbox.addStretch()
vbox.addWidget(line, alignment=Qt.AlignHCenter)
vbox.addStretch()
wrapper.setFixedWidth(line.sizeHint().width())
if separate_object:
return wrapper
status_bar.addWidget(wrapper)
def _init_bec_icon(self): def _init_bec_icon(self):
icon = self.app.windowIcon() icon = self.app.windowIcon()
if icon.isNull(): if icon.isNull():
print("No icon is set, setting default icon")
icon = QIcon() icon = QIcon()
icon.addFile( icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"), os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48), size=QSize(48, 48),
) )
self.app.setWindowIcon(icon) self.app.setWindowIcon(icon)
else:
print("An icon is set")
def load_ui(self, ui_file): def load_ui(self, ui_file):
loader = UILoader(self) loader = UILoader(self)
self.ui = loader.loader(ui_file) self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui) self.setCentralWidget(self.ui)
def display_app_id(self):
"""
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
else:
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self.statusBar().showMessage(status_message)
def _fetch_theme(self) -> str: def _fetch_theme(self) -> str:
return self.app.theme.theme return self.app.theme.theme
@@ -164,14 +338,64 @@ class BECMainWindow(BECWidget, QMainWindow):
help_menu.addAction(widgets_docs) help_menu.addAction(widgets_docs)
help_menu.addAction(bug_report) help_menu.addAction(bug_report)
################################################################################
# Status Bar Addons
################################################################################
def display_app_id(self):
"""
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
else:
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self._app_id_label.setText(status_message)
@SafeSlot(dict, dict)
def display_client_message(self, msg: dict, meta: dict):
"""
Display a client message in the status bar.
Args:
msg(dict): The message to display, should contain:
meta(dict): Metadata about the message, usually empty.
"""
message = msg.get("message", "")
expiration = msg.get("expire", 0) # 0 → never expire
self._client_info_label.setText(message)
self._client_info_label_full.setText(message)
# Restart the expiration timer if necessary
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop()
if expiration and expiration > 0:
self._client_info_expire_timer.start(int(expiration * 1000))
################################################################################
# General and Cleanup Methods
################################################################################
@SafeSlot(str) @SafeSlot(str)
def change_theme(self, theme: str): def change_theme(self, theme: str):
"""
Change the theme of the application.
Args:
theme(str): The theme to apply, either "light" or "dark".
"""
apply_theme(theme) apply_theme(theme)
def event(self, event):
if event.type() == QEvent.Type.StatusTip:
return True
return super().event(event)
def cleanup(self): def cleanup(self):
central_widget = self.centralWidget() central_widget = self.centralWidget()
central_widget.close() if central_widget is not None:
central_widget.deleteLater() central_widget.close()
central_widget.deleteLater()
if not isinstance(central_widget, BECWidget): if not isinstance(central_widget, BECWidget):
# if the central widget is not a BECWidget, we need to call the cleanup method # if the central widget is not a BECWidget, we need to call the cleanup method
# of all widgets whose parent is the current BECMainWindow # of all widgets whose parent is the current BECMainWindow
@@ -182,8 +406,39 @@ class BECMainWindow(BECWidget, QMainWindow):
child.cleanup() child.cleanup()
child.close() child.close()
child.deleteLater() child.deleteLater()
# Timer cleanup
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop()
if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
########################################
# Status bar widgets cleanup
# Client info label cleanup
self._client_info_label.cleanup()
self._client_info_hover.close()
self._client_info_hover.deleteLater()
# Scan progress bar cleanup
self._scan_progress_bar_simple.close()
self._scan_progress_bar_simple.deleteLater()
self._scan_progress_bar_full.close()
self._scan_progress_bar_full.deleteLater()
self._scan_progress_hover.close()
self._scan_progress_hover.deleteLater()
super().cleanup() super().cleanup()
class UILaunchWindow(BECMainWindow): class UILaunchWindow(BECMainWindow):
RPC = True RPC = True
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
main_window = UILaunchWindow()
main_window.show()
main_window.resize(800, 600)
sys.exit(app.exec())
@@ -6,10 +6,10 @@ from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
from bec_lib.device import Signal as BECSignal from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from pydantic import field_validator from pydantic import field_validator
from qtpy.QtCore import Property, Signal, Slot
from bec_widgets.utils import ConnectionConfig from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.filter_io import FilterIO from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
@@ -100,7 +100,7 @@ class DeviceInputBase(BECWidget):
### QtSlots ### ### QtSlots ###
@Slot(str) @SafeSlot(str)
def set_device(self, device: str): def set_device(self, device: str):
""" """
Set the device. Set the device.
@@ -114,7 +114,7 @@ class DeviceInputBase(BECWidget):
else: else:
logger.warning(f"Device {device} is not in the filtered selection.") logger.warning(f"Device {device} is not in the filtered selection.")
@Slot() @SafeSlot()
def update_devices_from_filters(self): def update_devices_from_filters(self):
"""Update the devices based on the current filter selection """Update the devices based on the current filter selection
in self.device_filter and self.readout_filter. If apply_filter is False, in self.device_filter and self.readout_filter. If apply_filter is False,
@@ -133,7 +133,7 @@ class DeviceInputBase(BECWidget):
self.devices = [device.name for device in devs] self.devices = [device.name for device in devs]
self.set_device(current_device) self.set_device(current_device)
@Slot(list) @SafeSlot(list)
def set_available_devices(self, devices: list[str]): def set_available_devices(self, devices: list[str]):
""" """
Set the devices. If a device in the list is not valid, it will not be considered. Set the devices. If a device in the list is not valid, it will not be considered.
@@ -146,7 +146,7 @@ class DeviceInputBase(BECWidget):
### QtProperties ### ### QtProperties ###
@Property( @SafeProperty(
"QStringList", "QStringList",
doc="List of devices. If updated, it will disable the apply filters property.", doc="List of devices. If updated, it will disable the apply filters property.",
) )
@@ -165,7 +165,7 @@ class DeviceInputBase(BECWidget):
self.config.devices = value self.config.devices = value
FilterIO.set_selection(widget=self, selection=value) FilterIO.set_selection(widget=self, selection=value)
@Property(str) @SafeProperty(str)
def default(self): def default(self):
"""Get the default device name. If set through this property, it will update only if the device is within the filtered selection.""" """Get the default device name. If set through this property, it will update only if the device is within the filtered selection."""
return self.config.default return self.config.default
@@ -177,7 +177,7 @@ class DeviceInputBase(BECWidget):
self.config.default = value self.config.default = value
WidgetIO.set_value(widget=self, value=value) WidgetIO.set_value(widget=self, value=value)
@Property(bool) @SafeProperty(bool)
def apply_filter(self): def apply_filter(self):
"""Apply the filters on the devices.""" """Apply the filters on the devices."""
return self.config.apply_filter return self.config.apply_filter
@@ -187,7 +187,7 @@ class DeviceInputBase(BECWidget):
self.config.apply_filter = value self.config.apply_filter = value
self.update_devices_from_filters() self.update_devices_from_filters()
@Property(bool) @SafeProperty(bool)
def filter_to_device(self): def filter_to_device(self):
"""Include devices in filters.""" """Include devices in filters."""
return BECDeviceFilter.DEVICE in self.device_filter return BECDeviceFilter.DEVICE in self.device_filter
@@ -200,7 +200,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.DEVICE) self._device_filter.remove(BECDeviceFilter.DEVICE)
self.update_devices_from_filters() self.update_devices_from_filters()
@Property(bool) @SafeProperty(bool)
def filter_to_positioner(self): def filter_to_positioner(self):
"""Include devices of type Positioner in filters.""" """Include devices of type Positioner in filters."""
return BECDeviceFilter.POSITIONER in self.device_filter return BECDeviceFilter.POSITIONER in self.device_filter
@@ -213,7 +213,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.POSITIONER) self._device_filter.remove(BECDeviceFilter.POSITIONER)
self.update_devices_from_filters() self.update_devices_from_filters()
@Property(bool) @SafeProperty(bool)
def filter_to_signal(self): def filter_to_signal(self):
"""Include devices of type Signal in filters.""" """Include devices of type Signal in filters."""
return BECDeviceFilter.SIGNAL in self.device_filter return BECDeviceFilter.SIGNAL in self.device_filter
@@ -226,7 +226,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.SIGNAL) self._device_filter.remove(BECDeviceFilter.SIGNAL)
self.update_devices_from_filters() self.update_devices_from_filters()
@Property(bool) @SafeProperty(bool)
def filter_to_computed_signal(self): def filter_to_computed_signal(self):
"""Include devices of type ComputedSignal in filters.""" """Include devices of type ComputedSignal in filters."""
return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter
@@ -239,7 +239,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL) self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL)
self.update_devices_from_filters() self.update_devices_from_filters()
@Property(bool) @SafeProperty(bool)
def readout_monitored(self): def readout_monitored(self):
"""Include devices with readout priority Monitored in filters.""" """Include devices with readout priority Monitored in filters."""
return ReadoutPriority.MONITORED in self.readout_filter return ReadoutPriority.MONITORED in self.readout_filter
@@ -252,7 +252,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.MONITORED) self._readout_filter.remove(ReadoutPriority.MONITORED)
self.update_devices_from_filters() self.update_devices_from_filters()
@Property(bool) @SafeProperty(bool)
def readout_baseline(self): def readout_baseline(self):
"""Include devices with readout priority Baseline in filters.""" """Include devices with readout priority Baseline in filters."""
return ReadoutPriority.BASELINE in self.readout_filter return ReadoutPriority.BASELINE in self.readout_filter
@@ -265,7 +265,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.BASELINE) self._readout_filter.remove(ReadoutPriority.BASELINE)
self.update_devices_from_filters() self.update_devices_from_filters()
@Property(bool) @SafeProperty(bool)
def readout_async(self): def readout_async(self):
"""Include devices with readout priority Async in filters.""" """Include devices with readout priority Async in filters."""
return ReadoutPriority.ASYNC in self.readout_filter return ReadoutPriority.ASYNC in self.readout_filter
@@ -278,7 +278,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.ASYNC) self._readout_filter.remove(ReadoutPriority.ASYNC)
self.update_devices_from_filters() self.update_devices_from_filters()
@Property(bool) @SafeProperty(bool)
def readout_continuous(self): def readout_continuous(self):
"""Include devices with readout priority continuous in filters.""" """Include devices with readout priority continuous in filters."""
return ReadoutPriority.CONTINUOUS in self.readout_filter return ReadoutPriority.CONTINUOUS in self.readout_filter
@@ -291,7 +291,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.CONTINUOUS) self._readout_filter.remove(ReadoutPriority.CONTINUOUS)
self.update_devices_from_filters() self.update_devices_from_filters()
@Property(bool) @SafeProperty(bool)
def readout_on_request(self): def readout_on_request(self):
"""Include devices with readout priority OnRequest in filters.""" """Include devices with readout priority OnRequest in filters."""
return ReadoutPriority.ON_REQUEST in self.readout_filter return ReadoutPriority.ON_REQUEST in self.readout_filter
@@ -6,7 +6,7 @@ from qtpy.QtCore import Property
from bec_widgets.utils import ConnectionConfig from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO from bec_widgets.utils.filter_io import FilterIO, LineEditFilterHandler
from bec_widgets.utils.ophyd_kind_util import Kind from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
@@ -55,7 +55,7 @@ class DeviceSignalInputBase(BECWidget):
self._hinted_signals = [] self._hinted_signals = []
self._normal_signals = [] self._normal_signals = []
self._config_signals = [] self._config_signals = []
self.bec_dispatcher.client.callbacks.register( self._device_update_register = self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.update_signals_from_filters EventType.DEVICE_UPDATE, self.update_signals_from_filters
) )
@@ -108,25 +108,32 @@ class DeviceSignalInputBase(BECWidget):
if not self.validate_device(self._device): if not self.validate_device(self._device):
self._device = None self._device = None
self.config.device = self._device self.config.device = self._device
return self._signals = []
device = self.get_device_object(self._device) self._hinted_signals = []
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [self._device]
self._hinted_signals = [self._device]
self._normal_signals = [] self._normal_signals = []
self._config_signals = [] self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals) FilterIO.set_selection(widget=self, selection=self._signals)
return return
device = self.get_device_object(self._device)
device_info = device._info.get("signals", {}) device_info = device._info.get("signals", {})
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [(self._device, {})]
self._hinted_signals = [(self._device, {})]
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
def _update(kind: Kind): def _update(kind: Kind):
return [ return FilterIO.update_with_kind(
signal widget=self,
for signal, signal_info in device_info.items() kind=kind,
if kind in self.signal_filter signal_filter=self.signal_filter,
and (signal_info.get("kind_str", None) == str(kind.name)) device_info=device_info,
] device_name=self._device,
)
self._hinted_signals = _update(Kind.hinted) self._hinted_signals = _update(Kind.hinted)
self._normal_signals = _update(Kind.normal) self._normal_signals = _update(Kind.normal)
@@ -271,11 +278,21 @@ class DeviceSignalInputBase(BECWidget):
Args: Args:
signal(str): Signal to validate. signal(str): Signal to validate.
""" """
if signal in self.signals: for entry in self.signals:
return True if isinstance(entry, tuple):
entry = entry[0]
if entry == signal:
return True
return False return False
def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None): def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None):
if config is None: if config is None:
return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__) return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
return DeviceSignalInputBaseConfig.model_validate(config) return DeviceSignalInputBaseConfig.model_validate(config)
def cleanup(self):
"""
Cleanup the widget.
"""
self.bec_dispatcher.client.callbacks.remove(self._device_update_register)
super().cleanup()
@@ -34,6 +34,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
PLUGIN = True PLUGIN = True
device_selected = Signal(str) device_selected = Signal(str)
device_reset = Signal()
device_config_update = Signal() device_config_update = Signal()
def __init__( def __init__(
@@ -147,8 +148,28 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self.device_selected.emit(input_text) self.device_selected.emit(input_text)
else: else:
self._is_valid_input = False self._is_valid_input = False
self.device_reset.emit()
self.update() self.update()
def validate_device(self, device: str) -> bool: # type: ignore[override]
"""
Extend validation so that previewsignal pseudodevices (labels like
``"eiger_preview"``) are accepted as valid choices.
The validation run only on device not on the previewsignal.
Args:
device: The text currently entered/selected.
Returns:
True if the device is a genuine BEC device *or* one of the
whitelisted previewsignal entries.
"""
idx = self.findText(device)
if idx >= 0 and isinstance(self.itemData(idx), tuple):
device = self.itemData(idx)[0] # type: ignore[assignment]
return super().validate_device(device)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel # pylint: disable=import-outside-toplevel
@@ -90,6 +90,44 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals") self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False) self.model().item(0).setEnabled(False)
def set_to_obj_name(self, obj_name: str) -> bool:
"""
Set the combobox to the object name of the signal.
Args:
obj_name (str): Object name of the signal.
Returns:
bool: True if the object name was found and set, False otherwise.
"""
for i in range(self.count()):
signal_data = self.itemData(i)
if signal_data and signal_data.get("obj_name") == obj_name:
self.setCurrentIndex(i)
return True
return False
def set_to_first_enabled(self) -> bool:
"""
Set the combobox to the first enabled item.
Returns:
bool: True if an enabled item was found and set, False otherwise.
"""
for i in range(self.count()):
if self.model().item(i).isEnabled():
self.setCurrentIndex(i)
return True
return False
@SafeSlot()
def reset_selection(self):
"""Reset the selection of the combobox."""
self.clear()
self.setItemText(0, "Select a device")
self.update_signals_from_filters()
self.device_signal_changed.emit("")
@SafeSlot(str) @SafeSlot(str)
def on_text_changed(self, text: str): def on_text_changed(self, text: str):
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal. """Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
@@ -102,11 +140,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
return return
if self.validate_signal(text) is False: if self.validate_signal(text) is False:
return return
if text == "readback" and isinstance(self.get_device_object(self.device), Positioner): self.device_signal_changed.emit(text)
device_signal = self.device
else:
device_signal = f"{self.device}_{text}"
self.device_signal_changed.emit(device_signal)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
@@ -89,6 +89,7 @@ class ScanControl(BECWidget, QWidget):
self.config.allowed_scans = allowed_scans self.config.allowed_scans = allowed_scans
self._scan_metadata: dict | None = None self._scan_metadata: dict | None = None
self._metadata_form = ScanMetadata(parent=self)
# Create and set main layout # Create and set main layout
self._init_UI() self._init_UI()
@@ -165,7 +166,6 @@ class ScanControl(BECWidget, QWidget):
self.layout.addStretch() self.layout.addStretch()
def _add_metadata_form(self): def _add_metadata_form(self):
self._metadata_form = ScanMetadata(parent=self)
self.layout.addWidget(self._metadata_form) self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText()) self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan) self.scan_selected.connect(self._metadata_form.update_with_new_scan)
@@ -203,35 +203,40 @@ class ScanControl(BECWidget, QWidget):
""" """
Requests the last executed scan parameters from BEC and restores them to the scan control widget. Requests the last executed scan parameters from BEC and restores them to the scan control widget.
""" """
enabled = self.toggle.checked self.last_scan_found = False
current_scan = self.comboBox_scan_selection.currentText() if not self.toggle.checked:
if enabled: return
history = self.client.connector.lrange(MessageEndpoints.scan_queue_history(), 0, -1)
for scan in history: current_scan = self.comboBox_scan_selection.currentText()
scan_name = scan.content["info"]["request_blocks"][-1]["msg"].content["scan_type"] history = (
if scan_name == current_scan: self.client.connector.xread(
args_dict = scan.content["info"]["request_blocks"][-1]["msg"].content[ MessageEndpoints.scan_history(), from_start=True, user_id=self.object_name
"parameter" )
]["args"] or []
args_list = [] )
for key, value in args_dict.items():
args_list.append(key) for scan in reversed(history):
args_list.extend(value) scan_data = scan.get("data")
if len(args_list) > 1 and self.arg_box is not None: if not scan_data:
self.arg_box.set_parameters(args_list) continue
kwargs = scan.content["info"]["request_blocks"][-1]["msg"].content["parameter"][
"kwargs" if scan_data.scan_name != current_scan:
] continue
if kwargs and self.kwarg_boxes:
for box in self.kwarg_boxes: ri = getattr(scan_data, "request_inputs", {}) or {}
box.set_parameters(kwargs) args_list = ri.get("arg_bundle", [])
self.last_scan_found = True if args_list and self.arg_box:
break self.arg_box.set_parameters(args_list)
else:
self.last_scan_found = False inputs = ri.get("inputs", {})
else: kwargs = ri.get("kwargs", {})
self.last_scan_found = False merged = {**inputs, **kwargs}
if merged and self.kwarg_boxes:
for box in self.kwarg_boxes:
box.set_parameters(merged)
self.last_scan_found = True
break
@SafeProperty(str) @SafeProperty(str)
def current_scan(self): def current_scan(self):
@@ -2,7 +2,9 @@ from __future__ import annotations
from typing import Any from typing import Any
from qtpy import QtWidgets
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication, QApplication,
QHBoxLayout, QHBoxLayout,
@@ -13,7 +15,9 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
_NOT_SET = object()
class DictBackedTableModel(QAbstractTableModel): class DictBackedTableModel(QAbstractTableModel):
@@ -25,6 +29,7 @@ class DictBackedTableModel(QAbstractTableModel):
data (list[list[str]]): list of key-value pairs to initialise with""" data (list[list[str]]): list of key-value pairs to initialise with"""
super().__init__() super().__init__()
self._data: list[list[str]] = data self._data: list[list[str]] = data
self._default = _NOT_SET
self._disallowed_keys: list[str] = [] self._disallowed_keys: list[str] = []
# pylint: disable=missing-function-docstring # pylint: disable=missing-function-docstring
@@ -45,8 +50,15 @@ class DictBackedTableModel(QAbstractTableModel):
def data(self, index, role=Qt.ItemDataRole): def data(self, index, role=Qt.ItemDataRole):
if index.isValid(): if index.isValid():
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole: if role in [
return str(self._data[index.row()][index.column()]) Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.EditRole,
Qt.ItemDataRole.ToolTipRole,
]:
try:
return str(self._data[index.row()][index.column()])
except IndexError:
return None
def setData(self, index, value, role): def setData(self, index, value, role):
if role == Qt.ItemDataRole.EditRole: if role == Qt.ItemDataRole.EditRole:
@@ -57,6 +69,12 @@ class DictBackedTableModel(QAbstractTableModel):
return True return True
return False return False
def replaceData(self, data: dict):
self.delete_rows(list(range(len(self._data))))
self.resetInternalData()
self._data = [[str(k), str(v)] for k, v in data.items()]
self.dataChanged.emit(self.index(0, 0), self.index(len(self._data), 1))
def update_disallowed_keys(self, keys: list[str]): def update_disallowed_keys(self, keys: list[str]):
"""Set the list of keys which may not be used. """Set the list of keys which may not be used.
@@ -66,7 +84,7 @@ class DictBackedTableModel(QAbstractTableModel):
for i, item in enumerate(self._data): for i, item in enumerate(self._data):
if item[0] in self._disallowed_keys: if item[0] in self._disallowed_keys:
self._data[i][0] = "" self._data[i][0] = ""
self.dataChanged.emit(self.index(i, 0), self.index(i, 0)) self.dataChanged.emit(self.index(i, 0), self.index(i, 1))
def _other_keys(self, row: int): def _other_keys(self, row: int):
return [r[0] for r in self._data[:row] + self._data[row + 1 :]] return [r[0] for r in self._data[:row] + self._data[row + 1 :]]
@@ -95,45 +113,74 @@ class DictBackedTableModel(QAbstractTableModel):
@SafeSlot() @SafeSlot()
def add_row(self): def add_row(self):
self.insertRow(self.rowCount()) self.insertRow(self.rowCount())
self.dataChanged.emit(self.index(self.rowCount(), 0), self.index(self.rowCount(), 1), 0)
@SafeSlot(list) @SafeSlot(list)
def delete_rows(self, rows: list[int]): def delete_rows(self, rows: list[int]):
# delete from the end so indices stay correct # delete from the end so indices stay correct
for row in sorted(rows, reverse=True): for row in sorted(rows, reverse=True):
self.dataChanged.emit(self.index(row, 0), self.index(row, 1), 0)
self.removeRows(row, 1, QModelIndex()) self.removeRows(row, 1, QModelIndex())
def set_default(self, value: dict | None):
self._default = value
def dump_dict(self): def dump_dict(self):
if self._data == [[]]: if self._data in [[], [[]], [["", ""]]]:
if self._default is not _NOT_SET:
return self._default
return {} return {}
return dict(self._data) return dict(self._data)
def length(self):
return len(self._data)
class DictBackedTable(QWidget): class DictBackedTable(QWidget):
delete_rows = Signal(list) delete_rows = Signal(list)
data_updated = Signal() data_changed = Signal(dict)
def __init__(self, initial_data: list[list[str]]): def __init__(
self,
parent: QWidget | None = None,
initial_data: list[list[str]] = [],
autoscale_to_data: bool = True,
):
"""Widget which uses a DictBackedTableModel to display an editable table """Widget which uses a DictBackedTableModel to display an editable table
which can be extracted as a dict. which can be extracted as a dict.
Args: Args:
initial_data (list[list[str]]): list of key-value pairs to initialise with initial_data (list[list[str]]): list of key-value pairs to initialise with
""" """
super().__init__() super().__init__(parent)
self._layout = QHBoxLayout() self._layout = QHBoxLayout()
self.setLayout(self._layout) self.setLayout(self._layout)
self._layout.setContentsMargins(0, 0, 0, 0)
self._table_model = DictBackedTableModel(initial_data) self._table_model = DictBackedTableModel(initial_data)
self._table_view = QTreeView() self._table_view = QTreeView()
self._table_view.setModel(self._table_model) self._table_view.setModel(self._table_model)
self._min_lines = 3
self.set_height_in_lines(len(initial_data))
self._table_view.setSizePolicy( self._table_view.setSizePolicy(
QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
) )
self._table_view.setAlternatingRowColors(True) self._table_view.setAlternatingRowColors(True)
self._table_view.setUniformRowHeights(True)
self._table_view.setWordWrap(False)
self._table_view.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
self._table_view.header().setSectionResizeMode(5, QtWidgets.QHeaderView.Stretch)
self.autoscale = autoscale_to_data
if self.autoscale:
self.data_changed.connect(self.scale_to_data)
self._layout.addWidget(self._table_view) self._layout.addWidget(self._table_view)
self._button_holder = QWidget()
self._buttons = QVBoxLayout() self._buttons = QVBoxLayout()
self._layout.addLayout(self._buttons) self._button_holder.setLayout(self._buttons)
self._layout.addWidget(self._button_holder)
self._add_button = QPushButton("+") self._add_button = QPushButton("+")
self._add_button.setToolTip("add a new row") self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-") self._remove_button = QPushButton("-")
@@ -143,11 +190,21 @@ class DictBackedTable(QWidget):
self._add_button.clicked.connect(self._table_model.add_row) self._add_button.clicked.connect(self._table_model.add_row)
self._remove_button.clicked.connect(self.delete_selected_rows) self._remove_button.clicked.connect(self.delete_selected_rows)
self.delete_rows.connect(self._table_model.delete_rows) self.delete_rows.connect(self._table_model.delete_rows)
self._table_model.dataChanged.connect(self._emit_data_updated)
def _emit_data_updated(self, *args, **kwargs): self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict()))
"""Just to swallow the args"""
self.data_updated.emit() def set_default(self, value: dict | None):
self._table_model.set_default(value)
def set_button_visibility(self, value: bool):
self._button_holder.setVisible(value)
@SafeSlot()
def clear(self):
self._table_model.replaceData({})
def replace_data(self, data: dict | None):
self._table_model.replaceData(data or {})
def delete_selected_rows(self): def delete_selected_rows(self):
"""Delete rows which are part of the selection model""" """Delete rows which are part of the selection model"""
@@ -167,6 +224,29 @@ class DictBackedTable(QWidget):
keys (list[str]): list of keys which are forbidden.""" keys (list[str]): list of keys which are forbidden."""
self._table_model.update_disallowed_keys(keys) self._table_model.update_disallowed_keys(keys)
def set_height_in_lines(self, lines: int):
self._table_view.setMaximumHeight(
int(QFontMetrics(self._table_view.font()).height() * max(lines + 2, self._min_lines))
)
@SafeSlot()
@SafeSlot(dict)
def scale_to_data(self, *_):
self.set_height_in_lines(self._table_model.length())
@SafeProperty(bool)
def autoscale(self): # type: ignore
return self._autoscale
@autoscale.setter
def autoscale(self, autoscale: bool):
self._autoscale = autoscale
if self._autoscale:
self.scale_to_data()
self.data_changed.connect(self.scale_to_data)
else:
self.data_changed.disconnect(self.scale_to_data)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
from bec_widgets.utils.colors import set_theme from bec_widgets.utils.colors import set_theme
@@ -174,6 +254,6 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication([]) app = QApplication([])
set_theme("dark") set_theme("dark")
window = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]) window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window.show() window.show()
app.exec() app.exec()
@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.editors.sbb_monitor.sbb_monitor_plugin import SBBMonitorPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(SBBMonitorPlugin())
if __name__ == "__main__": # pragma: no cover
main()
@@ -0,0 +1,15 @@
from bec_widgets.widgets.editors.website.website import WebsiteWidget
class SBBMonitor(WebsiteWidget):
"""
A widget to display the SBB monitor website.
"""
PLUGIN = True
ICON_NAME = "train"
USER_ACCESS = []
def __init__(self, parent=None, **kwargs):
url = "https://free.oevplus.ch/monitor/?viewType=splitView&layout=1&showClock=true&showPerron=true&stationGroup1Title=Villigen%2C%20PSI%20West&stationGroup2Title=Siggenthal-Würenlingen&station_1_id=85%3A3592&station_1_name=Villigen%2C%20PSI%20West&station_1_quantity=5&station_1_group=1&station_2_id=85%3A3502&station_2_name=Siggenthal-Würenlingen&station_2_quantity=5&station_2_group=2"
super().__init__(parent=parent, url=url, **kwargs)
@@ -0,0 +1 @@
{'files': ['sbb_monitor.py']}
@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.sbb_monitor.sbb_monitor import SBBMonitor
DOM_XML = """
<ui language='c++'>
<widget class='SBBMonitor' name='sbb_monitor'>
</widget>
</ui>
"""
class SBBMonitorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = SBBMonitor(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(SBBMonitor.ICON_NAME)
def includeFile(self):
return "sbb_monitor"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "SBBMonitor"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()
@@ -2,7 +2,7 @@ from __future__ import annotations
import sys import sys
from decimal import Decimal from decimal import Decimal
from math import inf, nextafter from math import copysign, inf, nextafter
from typing import TYPE_CHECKING, TypeVar, get_args from typing import TYPE_CHECKING, TypeVar, get_args
from annotated_types import Ge, Gt, Le, Lt from annotated_types import Ge, Gt, Le, Lt
@@ -23,16 +23,19 @@ _MAXFLOAT = sys.float_info.max
T = TypeVar("T", int, float, Decimal) T = TypeVar("T", int, float, Decimal)
def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]: def field_limits(info: FieldInfo, type_: type[T], prec: int | None = None) -> tuple[T, T]:
def _nextafter(x, y):
return nextafter(x, y) if prec is None else x + (10 ** (-prec)) * (copysign(1, y))
_min = _MININT if type_ is int else _MINFLOAT _min = _MININT if type_ is int else _MINFLOAT
_max = _MAXINT if type_ is int else _MAXFLOAT _max = _MAXINT if type_ is int else _MAXFLOAT
for md in info.metadata: for md in info.metadata:
if isinstance(md, Ge): if isinstance(md, Ge):
_min = type_(md.ge) # type: ignore _min = type_(md.ge) # type: ignore
if isinstance(md, Gt): if isinstance(md, Gt):
_min = type_(md.gt) + 1 if type_ is int else nextafter(type_(md.gt), inf) # type: ignore _min = type_(md.gt) + 1 if type_ is int else _nextafter(type_(md.gt), inf) # type: ignore
if isinstance(md, Lt): if isinstance(md, Lt):
_max = type_(md.lt) - 1 if type_ is int else nextafter(type_(md.lt), -inf) # type: ignore _max = type_(md.lt) - 1 if type_ is int else _nextafter(type_(md.lt), -inf) # type: ignore
if isinstance(md, Le): if isinstance(md, Le):
_max = type_(md.le) # type: ignore _max = type_(md.le) # type: ignore
return _min, _max # type: ignore return _min, _max # type: ignore
@@ -64,4 +67,6 @@ def field_default(info: FieldInfo):
def clearable_required(info: FieldInfo): def clearable_required(info: FieldInfo):
return type(None) in get_args(info.annotation) or info.is_required() return type(None) in get_args(info.annotation) or (
info.is_required() and info.default is PydanticUndefined
)
@@ -16,6 +16,9 @@ logger = bec_logger.logger
class ScanMetadata(PydanticModelForm): class ScanMetadata(PydanticModelForm):
RPC = False
def __init__( def __init__(
self, self,
parent=None, parent=None,
@@ -36,16 +39,18 @@ class ScanMetadata(PydanticModelForm):
# self.populate() gets called in super().__init__ # self.populate() gets called in super().__init__
# so make sure self._additional_metadata exists # so make sure self._additional_metadata exists
self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False) self._additional_md_box = ExpandableGroupFrame(
parent, "Additional metadata", expanded=False
)
self._additional_md_box_layout = QHBoxLayout() self._additional_md_box_layout = QHBoxLayout()
self._additional_md_box.set_layout(self._additional_md_box_layout) self._additional_md_box.set_layout(self._additional_md_box_layout)
self._additional_metadata = DictBackedTable(initial_extras or []) self._additional_metadata = DictBackedTable(parent, initial_extras or [])
self._scan_name = scan_name or "" self._scan_name = scan_name or ""
self._md_schema = get_metadata_schema_for_scan(self._scan_name) self._md_schema = get_metadata_schema_for_scan(self._scan_name)
self._additional_metadata.data_updated.connect(self.validate_form) self._additional_metadata.data_changed.connect(self.validate_form)
super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs) super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs)
self._layout.addWidget(self._additional_md_box) self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout.addWidget(self._additional_metadata) self._additional_md_box_layout.addWidget(self._additional_metadata)
@@ -127,6 +132,7 @@ if __name__ == "__main__": # pragma: no cover
w.setLayout(layout) w.setLayout(layout)
scan_metadata = ScanMetadata( scan_metadata = ScanMetadata(
parent=w,
scan_name="grid_scan", scan_name="grid_scan",
initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]], initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]],
) )
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -21,9 +21,6 @@ logger = bec_logger.logger
# noinspection PyDataclass # noinspection PyDataclass
class ImageItemConfig(ConnectionConfig): # TODO review config class ImageItemConfig(ConnectionConfig): # TODO review config
parent_id: str | None = Field(None, description="The parent plot of the image.") parent_id: str | None = Field(None, description="The parent plot of the image.")
monitor: str | None = Field(None, description="The name of the monitor.")
monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.")
source: str | None = Field(None, description="The source of the curve.")
color_map: str | None = Field("plasma", description="The color map of the image.") color_map: str | None = Field("plasma", description="The color map of the image.")
downsample: bool | None = Field(True, description="Whether to downsample the image.") downsample: bool | None = Field(True, description="Whether to downsample the image.")
opacity: float | None = Field(1.0, description="The opacity of the image.") opacity: float | None = Field(1.0, description="The opacity of the image.")
@@ -43,6 +40,7 @@ class ImageItemConfig(ConnectionConfig): # TODO review config
class ImageItem(BECConnector, pg.ImageItem): class ImageItem(BECConnector, pg.ImageItem):
RPC = True RPC = True
USER_ACCESS = [ USER_ACCESS = [
"color_map", "color_map",
@@ -69,12 +67,13 @@ class ImageItem(BECConnector, pg.ImageItem):
] ]
vRangeChangedManually = Signal(tuple) vRangeChangedManually = Signal(tuple)
removed = Signal(str)
def __init__( def __init__(
self, self,
config: Optional[ImageItemConfig] = None, config: Optional[ImageItemConfig] = None,
gui_id: Optional[str] = None, gui_id: Optional[str] = None,
parent_image=None, parent_image=None, # FIXME: rename to parent
**kwargs, **kwargs,
): ):
if config is None: if config is None:
@@ -274,6 +273,8 @@ class ImageItem(BECConnector, pg.ImageItem):
self.buffer = [] self.buffer = []
self.max_len = 0 self.max_len = 0
def remove(self): def remove(self, emit: bool = True):
self.parent().disconnect_monitor(self.config.monitor)
self.clear() self.clear()
super().remove()
if emit:
self.removed.emit(self.objectName())
@@ -8,6 +8,7 @@ from qtpy.QtCore import QEvent, Qt
from qtpy.QtGui import QColor from qtpy.QtGui import QColor
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QColorDialog, QColorDialog,
QHBoxLayout,
QHeaderView, QHeaderView,
QSpinBox, QSpinBox,
QToolButton, QToolButton,
@@ -23,6 +24,7 @@ from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.plots.roi.image_roi import ( from bec_widgets.widgets.plots.roi.image_roi import (
BaseROI, BaseROI,
CircularROI, CircularROI,
EllipticalROI,
RectangularROI, RectangularROI,
ROIController, ROIController,
) )
@@ -35,6 +37,28 @@ if TYPE_CHECKING:
from bec_widgets.widgets.plots.image.image import Image from bec_widgets.widgets.plots.image.image import Image
class ROILockButton(QToolButton):
"""Keeps its icon and checked state in sync with a single ROI."""
def __init__(self, roi: BaseROI, parent=None):
super().__init__(parent)
self.setCheckable(True)
self._roi = roi
self.clicked.connect(self._toggle)
roi.movableChanged.connect(lambda _: self._sync())
self._sync()
def _toggle(self):
# checked -> locked -> movable = False
self._roi.movable = not self.isChecked()
def _sync(self):
movable = self._roi.movable
self.setChecked(not movable)
icon = "lock_open_right" if movable else "lock"
self.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
class ROIPropertyTree(BECWidget, QWidget): class ROIPropertyTree(BECWidget, QWidget):
""" """
Two-column tree: [ROI] [Properties] Two-column tree: [ROI] [Properties]
@@ -98,11 +122,21 @@ class ROIPropertyTree(BECWidget, QWidget):
# --------------------------------------------------------------------- UI # --------------------------------------------------------------------- UI
def _init_toolbar(self): def _init_toolbar(self):
tb = ModularToolBar(self, self, orientation="horizontal") tb = ModularToolBar(self, self, orientation="horizontal")
self._draw_actions: dict[str, MaterialIconAction] = {}
# --- ROI draw actions (toggleable) --- # --- ROI draw actions (toggleable) ---
self.add_rect_action = MaterialIconAction("add_box", "Add Rect ROI", True, self) self.add_rect_action = MaterialIconAction("add_box", "Add Rect ROI", True, self)
self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self)
tb.add_action("Add Rect ROI", self.add_rect_action, 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) 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
for mode, act in self._draw_actions.items():
act.action.toggled.connect(lambda on, m=mode: self._on_draw_action_toggled(m, on))
# Expand/Collapse toggle # Expand/Collapse toggle
self.expand_toggle = MaterialIconAction( self.expand_toggle = MaterialIconAction(
@@ -124,6 +158,24 @@ class ROIPropertyTree(BECWidget, QWidget):
self.expand_toggle.action.toggled.connect(_exp_toggled) self.expand_toggle.action.toggled.connect(_exp_toggled)
self.expand_toggle.action.setChecked(False) self.expand_toggle.action.setChecked(False)
# Lock/Unlock all ROIs
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)
def _lock_all(checked: bool):
# checked -> everything locked (movable = False)
for r in self.controller.rois:
r.movable = not checked
new_icon = material_icon(
"lock" if checked else "lock_open_right", size=(20, 20), convert_to_pixmap=False
)
self.lock_all_action.action.setIcon(new_icon)
self.lock_all_action.action.toggled.connect(_lock_all)
# colormap widget # colormap widget
self.cmap = BECColorMapWidget(cmap=self.controller.colormap) self.cmap = BECColorMapWidget(cmap=self.controller.colormap)
tb.addWidget(QWidget()) # spacer tb.addWidget(QWidget()) # spacer
@@ -133,17 +185,9 @@ class ROIPropertyTree(BECWidget, QWidget):
self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap)) self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap))
# ROI drawing state # ROI drawing state
self._roi_draw_mode = None # 'rect' | 'circle' | None self._roi_draw_mode = None # 'rect' | 'circle' | 'ellipse' | None
self._roi_start_pos = None # QPointF in image coords self._roi_start_pos = None # QPointF in image coords
self._temp_roi = None # live ROI being resized while dragging self._temp_roi = None # live ROI being resized while dragging
# toggle handlers
self.add_rect_action.action.toggled.connect(
lambda on: self._set_roi_draw_mode("rect" if on else None)
)
self.add_circle_action.action.toggled.connect(
lambda on: self._set_roi_draw_mode("circle" if on else None)
)
# capture mouse events on the plot scene # capture mouse events on the plot scene
self.plot.scene().installEventFilter(self) self.plot.scene().installEventFilter(self)
@@ -173,16 +217,12 @@ class ROIPropertyTree(BECWidget, QWidget):
return str(value) return str(value)
def _set_roi_draw_mode(self, mode: str | None): def _set_roi_draw_mode(self, mode: str | None):
# Ensure only the selected action is toggled on # Update toolbar actions so that only the selected mode is checked
if mode == "rect": for m, act in self._draw_actions.items():
self.add_rect_action.action.setChecked(True) act.action.blockSignals(True)
self.add_circle_action.action.setChecked(False) act.action.setChecked(m == mode)
elif mode == "circle": act.action.blockSignals(False)
self.add_rect_action.action.setChecked(False)
self.add_circle_action.action.setChecked(True)
else:
self.add_rect_action.action.setChecked(False)
self.add_circle_action.action.setChecked(False)
self._roi_draw_mode = mode self._roi_draw_mode = mode
self._roi_start_pos = None self._roi_start_pos = None
# remove any unfinished temp ROI # remove any unfinished temp ROI
@@ -190,6 +230,15 @@ class ROIPropertyTree(BECWidget, QWidget):
self.plot.removeItem(self._temp_roi) self.plot.removeItem(self._temp_roi)
self._temp_roi = None self._temp_roi = None
def _on_draw_action_toggled(self, mode: str, checked: bool):
if checked:
# Activate selected mode
self._set_roi_draw_mode(mode)
else:
# If the active mode is being unchecked, clear mode
if self._roi_draw_mode == mode:
self._set_roi_draw_mode(None)
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
if self._roi_draw_mode is None: if self._roi_draw_mode is None:
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
@@ -202,12 +251,18 @@ class ROIPropertyTree(BECWidget, QWidget):
parent_image=self.image_widget, parent_image=self.image_widget,
resize_handles=False, resize_handles=False,
) )
if self._roi_draw_mode == "circle": elif self._roi_draw_mode == "circle":
self._temp_roi = CircularROI( self._temp_roi = CircularROI(
pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5], pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5],
size=[5, 5], size=[5, 5],
parent_image=self.image_widget, parent_image=self.image_widget,
) )
elif self._roi_draw_mode == "ellipse":
self._temp_roi = EllipticalROI(
pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5],
size=[5, 5],
parent_image=self.image_widget,
)
self.plot.addItem(self._temp_roi) self.plot.addItem(self._temp_roi)
return True return True
elif event.type() == QEvent.GraphicsSceneMouseMove and self._temp_roi is not None: elif event.type() == QEvent.GraphicsSceneMouseMove and self._temp_roi is not None:
@@ -217,13 +272,19 @@ class ROIPropertyTree(BECWidget, QWidget):
if self._roi_draw_mode == "rect": if self._roi_draw_mode == "rect":
self._temp_roi.setSize([dx, dy]) self._temp_roi.setSize([dx, dy])
if self._roi_draw_mode == "circle": elif self._roi_draw_mode == "circle":
r = max( r = max(
1, math.hypot(dx, dy) 1, math.hypot(dx, dy)
) # radius never smaller than 1 for safety of handle mapping, otherwise SEGFAULT ) # radius never smaller than 1 for safety of handle mapping, otherwise SEGFAULT
d = 2 * r # diameter d = 2 * r # diameter
self._temp_roi.setPos(self._roi_start_pos.x() - r, self._roi_start_pos.y() - r) self._temp_roi.setPos(self._roi_start_pos.x() - r, self._roi_start_pos.y() - r)
self._temp_roi.setSize([d, d]) self._temp_roi.setSize([d, d])
elif self._roi_draw_mode == "ellipse":
# Safeguard: enforce a minimum ellipse width/height of 2 px
min_dim = 2.0
w = dx if abs(dx) >= min_dim else math.copysign(min_dim, dx or 1.0)
h = dy if abs(dy) >= min_dim else math.copysign(min_dim, dy or 1.0)
self._temp_roi.setSize([w, h])
return True return True
elif ( elif (
event.type() == QEvent.GraphicsSceneMouseRelease event.type() == QEvent.GraphicsSceneMouseRelease
@@ -235,18 +296,30 @@ class ROIPropertyTree(BECWidget, QWidget):
self._temp_roi = None self._temp_roi = None
self._set_roi_draw_mode(None) self._set_roi_draw_mode(None)
# register via controller # register via controller
final_roi.add_scale_handle()
self.controller.add_roi(final_roi) self.controller.add_roi(final_roi)
return True return True
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
# --------------------------------------------------------- controller slots # --------------------------------------------------------- controller slots
def _on_roi_added(self, roi: BaseROI): def _on_roi_added(self, roi: BaseROI):
# check the global setting from the toolbar
if self.lock_all_action.action.isChecked():
roi.movable = False
# parent row with blank action column, name in ROI column # parent row with blank action column, name in ROI column
parent = QTreeWidgetItem(self.tree, ["", "", ""]) parent = QTreeWidgetItem(self.tree, ["", "", ""])
parent.setText(self.COL_ROI, roi.label) parent.setText(self.COL_ROI, roi.label)
parent.setFlags(parent.flags() | Qt.ItemIsEditable) parent.setFlags(parent.flags() | Qt.ItemIsEditable)
# --- delete button in actions column --- # --- actions widget (lock/unlock + delete) ---
actions_widget = QWidget()
actions_layout = QHBoxLayout(actions_widget)
actions_layout.setContentsMargins(0, 0, 0, 0)
actions_layout.setSpacing(3)
# lock / unlock toggle
lock_btn = ROILockButton(roi, parent=self)
actions_layout.addWidget(lock_btn)
# delete button
del_btn = QToolButton() del_btn = QToolButton()
delete_icon = material_icon( delete_icon = material_icon(
"delete", "delete",
@@ -256,8 +329,11 @@ class ROIPropertyTree(BECWidget, QWidget):
color=self.DELETE_BUTTON_COLOR, color=self.DELETE_BUTTON_COLOR,
) )
del_btn.setIcon(delete_icon) del_btn.setIcon(delete_icon)
self.tree.setItemWidget(parent, self.COL_ACTION, del_btn)
del_btn.clicked.connect(lambda _=None, r=roi: self._delete_roi(r)) del_btn.clicked.connect(lambda _=None, r=roi: self._delete_roi(r))
actions_layout.addWidget(del_btn)
# install composite widget into the tree
self.tree.setItemWidget(parent, self.COL_ACTION, actions_widget)
# color button # color button
color_btn = ColorButtonNative(parent=self, color=roi.line_color) color_btn = ColorButtonNative(parent=self, color=roi.line_color)
self.tree.setItemWidget(parent, self.COL_PROPS, color_btn) self.tree.setItemWidget(parent, self.COL_PROPS, color_btn)
@@ -309,6 +385,12 @@ class ROIPropertyTree(BECWidget, QWidget):
for c in range(3): for c in range(3):
self.tree.resizeColumnToContents(c) self.tree.resizeColumnToContents(c)
def _toggle_movable(self, roi: BaseROI):
"""
Toggle the `movable` property of the given ROI.
"""
roi.movable = not roi.movable
def _on_roi_removed(self, roi: BaseROI): def _on_roi_removed(self, roi: BaseROI):
item = self.roi_items.pop(roi, None) item = self.roi_items.pop(roi, None)
if item: if item:
@@ -345,7 +427,7 @@ if __name__ == "__main__": # pragma: no cover
import sys import sys
import numpy as np import numpy as np
from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.plots.image.image import Image from bec_widgets.widgets.plots.image.image import Image
@@ -1,5 +1,5 @@
from bec_lib.device import ReadoutPriority from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate from qtpy.QtWidgets import QComboBox, QStyledItemDelegate
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
@@ -50,11 +50,58 @@ class MonitorSelectionToolbarBundle(ToolbarBundle):
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False)) self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False))
# Connect slots, a device will be connected upon change of any combobox self.device_combo_box.currentTextChanged.connect(self.connect_monitor)
self.device_combo_box.currentTextChanged.connect(lambda: self.connect_monitor()) self.dim_combo_box.currentTextChanged.connect(self.connect_monitor)
self.dim_combo_box.currentTextChanged.connect(lambda: 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() @SafeSlot()
def connect_monitor(self): 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() dim = self.dim_combo_box.currentText()
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim) 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)
+185 -29
View File
@@ -104,9 +104,12 @@ class BaseROI(BECConnector):
nameChanged = Signal(str) nameChanged = Signal(str)
penChanged = Signal() penChanged = Signal()
movableChanged = Signal(bool)
USER_ACCESS = [ USER_ACCESS = [
"label", "label",
"label.setter", "label.setter",
"movable",
"movable.setter",
"line_color", "line_color",
"line_color.setter", "line_color.setter",
"line_width", "line_width",
@@ -127,6 +130,7 @@ class BaseROI(BECConnector):
label: str | None = None, label: str | None = None,
line_color: str | None = None, line_color: str | None = None,
line_width: int = 5, line_width: int = 5,
movable: bool = True,
# all remaining pg.*ROI kwargs (pos, size, pen, …) # all remaining pg.*ROI kwargs (pos, size, pen, …)
**pg_kwargs, **pg_kwargs,
): ):
@@ -155,6 +159,7 @@ class BaseROI(BECConnector):
gui_id=gui_id, gui_id=gui_id,
removable=True, removable=True,
invertible=True, invertible=True,
movable=movable,
**pg_kwargs, **pg_kwargs,
) )
@@ -162,8 +167,14 @@ class BaseROI(BECConnector):
self._line_color = line_color or "#ffffff" self._line_color = line_color or "#ffffff"
self._line_width = line_width self._line_width = line_width
self._description = True self._description = True
self._movable = movable
self.setPen(mkPen(self._line_color, width=self._line_width)) self.setPen(mkPen(self._line_color, width=self._line_width))
# Reset Handles to avoid inherited handles from pyqtgraph
self.remove_scale_handles() # remove any existing handles from pyqtgraph.RectROI
if movable:
self.add_scale_handle() # add custom scale handles
def set_parent(self, parent: Image): def set_parent(self, parent: Image):
""" """
Sets the parent image for this ROI. Sets the parent image for this ROI.
@@ -182,6 +193,40 @@ class BaseROI(BECConnector):
""" """
return self.parent_image return self.parent_image
@property
def movable(self) -> bool:
"""
Gets whether this ROI is movable.
Returns:
bool: True if the ROI can be moved, False otherwise.
"""
return self._movable
@movable.setter
def movable(self, value: bool):
"""
Sets whether this ROI is movable.
If the new value is different from the current value, this method updates
the internal state and emits the penChanged signal.
Args:
value (bool): True to make the ROI movable, False to make it fixed.
"""
if value != self._movable:
self._movable = value
# All relevant properties from pyqtgraph to block movement
self.translatable = value
self.rotatable = value
self.resizable = value
self.removable = value
if value:
self.add_scale_handle() # add custom scale handles
else:
self.remove_scale_handles() # remove custom scale handles
self.movableChanged.emit(value)
@property @property
def label(self) -> str: def label(self) -> str:
""" """
@@ -337,8 +382,18 @@ class BaseROI(BECConnector):
) )
def add_scale_handle(self): def add_scale_handle(self):
"""Add scale handles to the ROI."""
return return
def remove_scale_handles(self):
"""Remove all scale handles from the ROI."""
handles = self.handles
for i in range(len(handles)):
try:
self.removeHandle(0)
except IndexError:
continue
def set_position(self, x: float, y: float): def set_position(self, x: float, y: float):
""" """
Sets the position of the ROI. Sets the position of the ROI.
@@ -355,12 +410,7 @@ class BaseROI(BECConnector):
if controller and self in controller.rois: if controller and self in controller.rois:
controller.remove_roi(self) controller.remove_roi(self)
return # controller will call back into this method once deregistered return # controller will call back into this method once deregistered
handles = self.handles self.remove_scale_handles()
for i in range(len(handles)):
try:
self.removeHandle(0)
except IndexError:
continue
self.rpc_register.remove_rpc(self) self.rpc_register.remove_rpc(self)
self.parent_image.plot_item.removeItem(self) self.parent_image.plot_item.removeItem(self)
viewBox = self.parent_plot_item.vb viewBox = self.parent_plot_item.vb
@@ -399,6 +449,7 @@ class RectangularROI(BaseROI, pg.RectROI):
label: str | None = None, label: str | None = None,
line_color: str | None = None, line_color: str | None = None,
line_width: int = 5, line_width: int = 5,
movable: bool = True,
resize_handles: bool = True, resize_handles: bool = True,
**extra_pg, **extra_pg,
): ):
@@ -429,6 +480,7 @@ class RectangularROI(BaseROI, pg.RectROI):
pos=pos, pos=pos,
size=size, size=size,
pen=pen, pen=pen,
movable=movable,
**extra_pg, **extra_pg,
) )
@@ -437,6 +489,23 @@ class RectangularROI(BaseROI, pg.RectROI):
self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine) self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
self.handleHoverPen = fn.mkPen("lime", width=4) self.handleHoverPen = fn.mkPen("lime", width=4)
def _normalized_edges(self) -> tuple[float, float, float, float]:
"""
Return rectangle edges as (left, bottom, right, top) with consistent
ordering even when the ROI has been inverted by its scale handles.
Returns:
tuple: A tuple containing the left, bottom, right, and top edges
of the ROI rectangle in normalized coordinates.
"""
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
x_left = min(x0, x0 + w)
x_right = max(x0, x0 + w)
y_bottom = min(y0, y0 + h)
y_top = max(y0, y0 + h)
return x_left, y_bottom, x_right, y_top
def add_scale_handle(self): def add_scale_handle(self):
""" """
Add scale handles at every corner and edge of the ROI. Add scale handles at every corner and edge of the ROI.
@@ -458,24 +527,17 @@ class RectangularROI(BaseROI, pg.RectROI):
self.addScaleHandle([0, 0.5], [1, 0.5]) # left edge self.addScaleHandle([0, 0.5], [1, 0.5]) # left edge
self.addScaleHandle([1, 0.5], [0, 0.5]) # right edge self.addScaleHandle([1, 0.5], [0, 0.5]) # right edge
self.handlePen = fn.mkPen("#ffff00", width=5) # bright yellow outline
self.handleHoverPen = fn.mkPen("#00ffff", width=4) # cyan, thicker when hovered
self.handleBrush = (200, 200, 0, 120) # semi-transparent fill
self.handleHoverBrush = (0, 255, 255, 160)
def _on_region_changed(self): def _on_region_changed(self):
""" """
Handles ROI region change events. Handles changes to the ROI's region.
This method is called whenever the ROI's position or size changes. This method is called whenever the ROI's position or size changes.
It calculates the new corner coordinates and emits the edgesChanged signal It calculates the new corner coordinates and emits the edgesChanged signal
with the updated coordinates. with the updated coordinates.
""" """
x0, y0 = self.pos().x(), self.pos().y() x_left, y_bottom, x_right, y_top = self._normalized_edges()
w, h = self.state["size"] self.edgesChanged.emit(x_left, y_bottom, x_right, y_top)
self.edgesChanged.emit(x0, y0, x0 + w, y0 + h) self.parent_plot_item.vb.update()
viewBox = self.parent_plot_item.vb
viewBox.update()
def mouseDragEvent(self, ev): def mouseDragEvent(self, ev):
""" """
@@ -489,9 +551,8 @@ class RectangularROI(BaseROI, pg.RectROI):
""" """
super().mouseDragEvent(ev) super().mouseDragEvent(ev)
if ev.isFinish(): if ev.isFinish():
x0, y0 = self.pos().x(), self.pos().y() x_left, y_bottom, x_right, y_top = self._normalized_edges()
w, h = self.state["size"] self.edgesReleased.emit(x_left, y_bottom, x_right, y_top)
self.edgesReleased.emit(x0, y0, x0 + w, y0 + h)
def get_coordinates(self, typed: bool | None = None) -> dict | tuple: def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
""" """
@@ -510,17 +571,16 @@ class RectangularROI(BaseROI, pg.RectROI):
if typed is None: if typed is None:
typed = self.description typed = self.description
x0, y0 = self.pos().x(), self.pos().y() x_left, y_bottom, x_right, y_top = self._normalized_edges()
w, h = self.state["size"]
x1, y1 = x0 + w, y0 + h
if typed: if typed:
return { return {
"bottom_left": (x0, y0), "bottom_left": (x_left, y_bottom),
"bottom_right": (x1, y0), "bottom_right": (x_right, y_bottom),
"top_left": (x0, y1), "top_left": (x_left, y_top),
"top_right": (x1, y1), "top_right": (x_right, y_top),
} }
return ((x0, y0), (x1, y0), (x0, y1), (x1, y1)) return (x_left, y_bottom), (x_right, y_bottom), (x_left, y_top), (x_right, y_top)
def _lookup_scene_image(self): def _lookup_scene_image(self):
""" """
@@ -568,6 +628,7 @@ class CircularROI(BaseROI, pg.CircleROI):
label: str | None = None, label: str | None = None,
line_color: str | None = None, line_color: str | None = None,
line_width: int = 5, line_width: int = 5,
movable: bool = True,
**extra_pg, **extra_pg,
): ):
""" """
@@ -599,10 +660,19 @@ class CircularROI(BaseROI, pg.CircleROI):
pos=pos, pos=pos,
size=size, size=size,
pen=pen, pen=pen,
movable=movable,
**extra_pg, **extra_pg,
) )
self.sigRegionChanged.connect(self._on_region_changed) self.sigRegionChanged.connect(self._on_region_changed)
self._adorner = LabelAdorner(self) self._adorner = LabelAdorner(self)
self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
self.handleHoverPen = fn.mkPen("lime", width=4)
def add_scale_handle(self):
"""
Adds scale handles to the circular ROI.
"""
self._addHandles() # wrapper around pg.CircleROI._addHandles
def _on_region_changed(self): def _on_region_changed(self):
""" """
@@ -654,7 +724,7 @@ class CircularROI(BaseROI, pg.CircleROI):
if typed is None: if typed is None:
typed = self.description typed = self.description
d = self.state["size"][0] d = abs(self.state["size"][0])
cx = self.pos().x() + d / 2 cx = self.pos().x() + d / 2
cy = self.pos().y() + d / 2 cy = self.pos().y() + d / 2
@@ -680,6 +750,92 @@ class CircularROI(BaseROI, pg.CircleROI):
return None return None
class EllipticalROI(BaseROI, pg.EllipseROI):
"""
Elliptical Region of Interest with centre/width/height tracking and auto-labelling.
Mirrors the behaviour of ``CircularROI`` but supports independent
horizontal and vertical radii.
"""
centerChanged = Signal(float, float, float, float) # cx, cy, width, height
centerReleased = Signal(float, float, float, float)
def __init__(
self,
*,
pos,
size,
pen=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
parent_image: Image | None = None,
label: str | None = None,
line_color: str | None = None,
line_width: int = 5,
movable: bool = True,
**extra_pg,
):
super().__init__(
config=config,
gui_id=gui_id,
parent_image=parent_image,
label=label,
line_color=line_color,
line_width=line_width,
pos=pos,
size=size,
pen=pen,
movable=movable,
**extra_pg,
)
self.sigRegionChanged.connect(self._on_region_changed)
self._adorner = LabelAdorner(self)
self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
self.handleHoverPen = fn.mkPen("lime", width=4)
def add_scale_handle(self):
"""Add scale handles to the elliptical ROI."""
self._addHandles() # delegates to pg.EllipseROI
def _on_region_changed(self):
w = abs(self.state["size"][0])
h = abs(self.state["size"][1])
cx = self.pos().x() + w / 2
cy = self.pos().y() + h / 2
self.centerChanged.emit(cx, cy, w, h)
self.parent_plot_item.vb.update()
def mouseDragEvent(self, ev):
super().mouseDragEvent(ev)
if ev.isFinish():
w = abs(self.state["size"][0])
h = abs(self.state["size"][1])
cx = self.pos().x() + w / 2
cy = self.pos().y() + h / 2
self.centerReleased.emit(cx, cy, w, h)
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
"""
Return the ellipse's centre and size.
Args:
typed (bool | None): If True returns dict; otherwise tuple.
"""
if typed is None:
typed = self.description
w, h = map(abs, self.state["size"]) # raw diameters
major, minor = (w, h) if w >= h else (h, w)
cx = self.pos().x() + w / 2
cy = self.pos().y() + h / 2
if typed:
return {"center_x": cx, "center_y": cy, "major_axis": major, "minor_axis": minor}
return (cx, cy, major, minor)
class ROIController(QObject): class ROIController(QObject):
"""Manages a collection of ROIs (Regions of Interest) with palette-assigned colors. """Manages a collection of ROIs (Regions of Interest) with palette-assigned colors.
@@ -141,6 +141,14 @@
<header>bec_color_map_widget</header> <header>bec_color_map_widget</header>
</customwidget> </customwidget>
</customwidgets> </customwidgets>
<tabstops>
<tabstop>x_name</tabstop>
<tabstop>x_entry</tabstop>
<tabstop>y_name</tabstop>
<tabstop>y_entry</tabstop>
<tabstop>z_name</tabstop>
<tabstop>z_entry</tabstop>
</tabstops>
<resources/> <resources/>
<connections> <connections>
<connection> <connection>
@@ -2,12 +2,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QComboBox, QComboBox,
QGroupBox, QGroupBox,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit,
QSizePolicy, QSizePolicy,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@@ -15,9 +15,8 @@ from qtpy.QtWidgets import (
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import ( from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
DeviceLineEdit, from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
)
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import CurveTree from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import CurveTree
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
@@ -29,13 +28,18 @@ class CurveSetting(SettingWidget):
super().__init__(parent=parent, *args, **kwargs) super().__init__(parent=parent, *args, **kwargs)
self.setProperty("skip_settings", True) self.setProperty("skip_settings", True)
self.target_widget = target_widget self.target_widget = target_widget
self._x_settings_connected = False
self.layout = QVBoxLayout(self) self.layout = QVBoxLayout(self)
self._init_x_box() self._init_x_box()
self._init_y_box() self._init_y_box()
self.setFixedWidth(580) # TODO height is still debate def sizeHint(self) -> QSize:
"""
Returns the size hint for the settings widget.
"""
return QSize(800, 500)
def _init_x_box(self): def _init_x_box(self):
self.x_axis_box = QGroupBox("X Axis") self.x_axis_box = QGroupBox("X Axis")
@@ -46,15 +50,23 @@ class CurveSetting(SettingWidget):
self.mode_combo_label = QLabel("Mode") self.mode_combo_label = QLabel("Mode")
self.mode_combo = QComboBox() self.mode_combo = QComboBox()
self.mode_combo.addItems(["auto", "index", "timestamp", "device"]) self.mode_combo.addItems(["auto", "index", "timestamp", "device"])
self.mode_combo.setMinimumWidth(120)
self.spacer = QWidget() self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.device_x_label = QLabel("Device") self.device_x_label = QLabel("Device")
self.device_x = DeviceLineEdit(parent=self) self.device_x = DeviceComboBox(parent=self)
self.device_x.insertItem(0, "")
self.device_x.setEditable(True)
self.device_x.setMinimumWidth(180)
self.signal_x_label = QLabel("Signal") self.signal_x_label = QLabel("Signal")
self.signal_x = QLineEdit() self.signal_x = SignalComboBox(parent=self)
self.signal_x.include_config_signals = False
self.signal_x.insertItem(0, "")
self.signal_x.setEditable(True)
self.signal_x.setMinimumWidth(180)
self._get_x_mode_from_waveform() self._get_x_mode_from_waveform()
self.switch_x_device_selection() self.switch_x_device_selection()
@@ -80,11 +92,32 @@ class CurveSetting(SettingWidget):
def switch_x_device_selection(self): def switch_x_device_selection(self):
if self.mode_combo.currentText() == "device": if self.mode_combo.currentText() == "device":
self._x_settings_connected = True
self.device_x.currentTextChanged.connect(self.signal_x.set_device)
self.device_x.device_reset.connect(self.signal_x.reset_selection)
self.device_x.setEnabled(True) self.device_x.setEnabled(True)
self.device_x.setText(self.target_widget.x_axis_mode["name"]) self.signal_x.setEnabled(True)
self.signal_x.setText(self.target_widget.x_axis_mode["entry"]) item = self.device_x.findText(self.target_widget.x_axis_mode["name"])
self.device_x.setCurrentIndex(item if item != -1 else 0)
signal_x = self.target_widget.x_axis_mode.get("entry", "")
if signal_x:
self.signal_x.set_to_obj_name(signal_x)
else:
# If no match is found, set to the first enabled item
if not self.signal_x.set_to_first_enabled():
# If no enabled item is found, set to the first item
self.signal_x.setCurrentIndex(0)
else: else:
self.device_x.setEnabled(False) self.device_x.setEnabled(False)
self.signal_x.setEnabled(False)
self.device_x.setCurrentIndex(0)
self.signal_x.setCurrentIndex(0)
if self._x_settings_connected:
self._x_settings_connected = False
self.device_x.currentTextChanged.disconnect(self.signal_x.set_device)
self.device_x.device_reset.disconnect(self.signal_x.reset_selection)
def _init_y_box(self): def _init_y_box(self):
self.y_axis_box = QGroupBox("Y Axis") self.y_axis_box = QGroupBox("Y Axis")
@@ -97,16 +130,17 @@ class CurveSetting(SettingWidget):
self.layout.addWidget(self.y_axis_box) self.layout.addWidget(self.y_axis_box)
@SafeSlot() @SafeSlot(popup_error=True)
def accept_changes(self): def accept_changes(self):
""" """
Accepts the changes made in the settings widget and applies them to the target widget. Accepts the changes made in the settings widget and applies them to the target widget.
""" """
if self.mode_combo.currentText() == "device": if self.mode_combo.currentText() == "device":
self.target_widget.x_mode = self.device_x.text() self.target_widget.x_mode = self.device_x.currentText()
signal_x = self.signal_x.text() signal_x = self.signal_x.currentText()
signal_data = self.signal_x.itemData(self.signal_x.currentIndex())
if signal_x != "": if signal_x != "":
self.target_widget.x_entry = signal_x self.target_widget.x_entry = signal_data.get("obj_name", signal_x)
else: else:
self.target_widget.x_mode = self.mode_combo.currentText() self.target_widget.x_mode = self.mode_combo.currentText()
self.curve_manager.send_curve_json() self.curve_manager.send_curve_json()
@@ -121,5 +155,7 @@ class CurveSetting(SettingWidget):
"""Cleanup the widget.""" """Cleanup the widget."""
self.device_x.close() self.device_x.close()
self.device_x.deleteLater() self.device_x.deleteLater()
self.signal_x.close()
self.signal_x.deleteLater()
self.curve_manager.close() self.curve_manager.close()
self.curve_manager.deleteLater() self.curve_manager.deleteLater()
@@ -5,13 +5,12 @@ from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtGui import QColor from qtpy.QtCore import Qt
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QColorDialog,
QComboBox, QComboBox,
QHBoxLayout, QHBoxLayout,
QHeaderView,
QLabel, QLabel,
QLineEdit,
QPushButton, QPushButton,
QSizePolicy, QSizePolicy,
QSpinBox, QSpinBox,
@@ -22,13 +21,13 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets import SafeSlot
from bec_widgets.utils import ConnectionConfig, EntryValidator from bec_widgets.utils import ConnectionConfig, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors from bec_widgets.utils.colors import Colors
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import ( from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
DeviceLineEdit, from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
)
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from bec_widgets.widgets.plots.waveform.curve import CurveConfig, DeviceSignal from bec_widgets.widgets.plots.waveform.curve import CurveConfig, DeviceSignal
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import ( from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
@@ -124,11 +123,30 @@ class CurveRow(QTreeWidgetItem):
"""Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo.""" """Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo."""
if self.source == "device": if self.source == "device":
# Device row: columns 1..2 are device line edits # Device row: columns 1..2 are device line edits
self.device_edit = DeviceLineEdit(parent=self.tree) self.device_edit = DeviceComboBox(parent=self.tree)
self.entry_edit = QLineEdit(parent=self.tree) # TODO in future will be signal line edit self.device_edit.insertItem(0, "")
self.device_edit.setEditable(True)
self.entry_edit = SignalComboBox(parent=self.tree)
self.entry_edit.include_config_signals = False
self.entry_edit.insertItem(0, "")
self.entry_edit.setEditable(True)
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
if self.config.signal: if self.config.signal:
self.device_edit.setText(self.config.signal.name or "") device_index = self.device_edit.findText(self.config.signal.name or "")
self.entry_edit.setText(self.config.signal.entry or "") if device_index >= 0:
self.device_edit.setCurrentIndex(device_index)
# Force the entry_edit to update based on the device name
self.device_edit.currentTextChanged.emit(self.device_edit.currentText())
else:
# If the device name is not found, set the first enabled item
self.device_edit.setCurrentIndex(0)
if not self.entry_edit.set_to_obj_name(self.config.signal.entry):
# If the entry is not found, try to set it to the first enabled item
if not self.entry_edit.set_to_first_enabled():
# If no enabled item is found, set to the first item
self.entry_edit.setCurrentIndex(0)
self.tree.setItemWidget(self, 1, self.device_edit) self.tree.setItemWidget(self, 1, self.device_edit)
self.tree.setItemWidget(self, 2, self.entry_edit) self.tree.setItemWidget(self, 2, self.entry_edit)
@@ -154,7 +172,7 @@ class CurveRow(QTreeWidgetItem):
"""Create columns 3..6: color button, style combo, width spin, symbol spin.""" """Create columns 3..6: color button, style combo, width spin, symbol spin."""
# Color in col 3 # Color in col 3
self.color_button = ColorButtonNative(color=self.config.color) self.color_button = ColorButtonNative(color=self.config.color)
self.color_button.clicked.connect(lambda: self._select_color(self.color_button)) self.color_button.color_changed.connect(self._on_color_changed)
self.tree.setItemWidget(self, 3, self.color_button) self.tree.setItemWidget(self, 3, self.color_button)
# Style in col 4 # Style in col 4
@@ -177,20 +195,16 @@ class CurveRow(QTreeWidgetItem):
self.symbol_spin.setValue(self.config.symbol_size) self.symbol_spin.setValue(self.config.symbol_size)
self.tree.setItemWidget(self, 6, self.symbol_spin) self.tree.setItemWidget(self, 6, self.symbol_spin)
def _select_color(self, button): @SafeSlot(str, verify_sender=True)
def _on_color_changed(self, new_color: str):
""" """
Selects a new color using a color dialog and applies it to the specified button. Updates Update configuration when the color button emits a change.
related configuration properties based on the chosen color.
Args: Args:
button: The button widget whose color is being modified. new_color (str): The new color in hex format.
""" """
current_color = QColor(button.color()) self.config.color = new_color
chosen_color = QColorDialog.getColor(current_color, self.tree, "Select Curve Color") self.config.symbol_color = new_color
if chosen_color.isValid():
button.set_color(chosen_color)
self.config.color = chosen_color.name()
self.config.symbol_color = chosen_color.name()
def add_dap_row(self): def add_dap_row(self):
"""Create a new DAP row as a child. Only valid if source='device'.""" """Create a new DAP row as a child. Only valid if source='device'."""
@@ -239,6 +253,11 @@ class CurveRow(QTreeWidgetItem):
self.device_edit.deleteLater() self.device_edit.deleteLater()
self.device_edit = None self.device_edit = None
if getattr(self, "entry_edit", None) is not None:
self.entry_edit.close()
self.entry_edit.deleteLater()
self.entry_edit = None
if getattr(self, "dap_combo", None) is not None: if getattr(self, "dap_combo", None) is not None:
self.dap_combo.close() self.dap_combo.close()
self.dap_combo.deleteLater() self.dap_combo.deleteLater()
@@ -271,13 +290,22 @@ class CurveRow(QTreeWidgetItem):
# Gather device name/entry # Gather device name/entry
device_name = "" device_name = ""
device_entry = "" device_entry = ""
## TODO: Move this to itemData
if hasattr(self, "device_edit"): if hasattr(self, "device_edit"):
device_name = self.device_edit.text() device_name = self.device_edit.currentText()
if hasattr(self, "entry_edit"): if hasattr(self, "entry_edit"):
device_entry = self.entry_validator.validate_signal( device_entry = self.entry_edit.currentText()
name=device_name, entry=self.entry_edit.text() index = self.entry_edit.findText(device_entry)
) if index > -1:
self.entry_edit.setText(device_entry) device_entry_info = self.entry_edit.itemData(index)
if device_entry_info:
device_entry = device_entry_info.get("obj_name", device_entry)
else:
device_entry = self.entry_validator.validate_signal(
name=device_name, entry=device_entry
)
self.config.signal = DeviceSignal(name=device_name, entry=device_entry) self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
self.config.source = "device" self.config.source = "device"
self.config.label = f"{device_name}-{device_entry}" self.config.label = f"{device_name}-{device_entry}"
@@ -393,13 +421,20 @@ class CurveTree(BECWidget, QWidget):
self.tree = QTreeWidget() self.tree = QTreeWidget()
self.tree.setColumnCount(7) self.tree.setColumnCount(7)
self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"]) self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"])
header = self.tree.header()
for idx in range(self.tree.columnCount()):
if idx in (1, 2): # Device name and entry should stretch
header.setSectionResizeMode(idx, QHeaderView.Stretch)
else:
header.setSectionResizeMode(idx, QHeaderView.Fixed)
header.setStretchLastSection(False)
self.tree.setColumnWidth(0, 90) self.tree.setColumnWidth(0, 90)
self.tree.setColumnWidth(1, 100)
self.tree.setColumnWidth(2, 100)
self.tree.setColumnWidth(3, 70) self.tree.setColumnWidth(3, 70)
self.tree.setColumnWidth(4, 80) self.tree.setColumnWidth(4, 80)
self.tree.setColumnWidth(5, 40) self.tree.setColumnWidth(5, 50)
self.tree.setColumnWidth(6, 40) self.tree.setColumnWidth(6, 50)
self.layout.addWidget(self.tree) self.layout.addWidget(self.tree)
def _init_color_buffer(self, size: int): def _init_color_buffer(self, size: int):
@@ -535,7 +570,4 @@ class CurveTree(BECWidget, QWidget):
all_items = list(self.all_items) all_items = list(self.all_items)
for item in all_items: for item in all_items:
item.remove_self() item.remove_self()
super().cleanup()
def closeEvent(self, event):
self.cleanup()
return super().closeEvent(event)
+74 -26
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Literal from typing import Any, Literal
import lmfit import lmfit
import numpy as np import numpy as np
@@ -163,7 +163,7 @@ class Waveform(PlotBase):
self._async_curves = [] self._async_curves = []
self._slice_index = None self._slice_index = None
self._dap_curves = [] self._dap_curves = []
self._mode: Literal["none", "sync", "async", "mixed"] = "none" self._mode = None
# Scan data # Scan data
self._scan_done = True # means scan is not running self._scan_done = True # means scan is not running
@@ -330,7 +330,6 @@ class Waveform(PlotBase):
self.curve_settings_dialog = SettingsDialog( self.curve_settings_dialog = SettingsDialog(
self, settings_widget=curve_setting, window_title="Curve Settings", modal=False self, settings_widget=curve_setting, window_title="Curve Settings", modal=False
) )
self.curve_settings_dialog.setFixedWidth(580)
# When the dialog is closed, update the toolbar icon and clear the reference # When the dialog is closed, update the toolbar icon and clear the reference
self.curve_settings_dialog.finished.connect(self._curve_settings_closed) self.curve_settings_dialog.finished.connect(self._curve_settings_closed)
self.curve_settings_dialog.show() self.curve_settings_dialog.show()
@@ -1139,7 +1138,7 @@ class Waveform(PlotBase):
QTimer.singleShot(100, self.update_sync_curves) QTimer.singleShot(100, self.update_sync_curves)
QTimer.singleShot(300, self.update_sync_curves) QTimer.singleShot(300, self.update_sync_curves)
def _fetch_scan_data_and_access(self): def _fetch_scan_data_and_access(self) -> tuple[dict, str] | tuple[None, None]:
""" """
Decide whether the widget is in live or historical mode Decide whether the widget is in live or historical mode
and return the appropriate data dict and access key. and return the appropriate data dict and access key.
@@ -1153,7 +1152,7 @@ class Waveform(PlotBase):
self.update_with_scan_history(-1) self.update_with_scan_history(-1)
if self.scan_item is None: if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.") logger.info("No scan executed so far; skipping device curves categorisation.")
return "none", "none" return None, None
if hasattr(self.scan_item, "live_data"): if hasattr(self.scan_item, "live_data"):
# Live scan # Live scan
@@ -1169,7 +1168,7 @@ class Waveform(PlotBase):
""" """
if self.scan_item is None: if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.") logger.info("No scan executed so far; skipping device curves categorisation.")
return "none" return
data, access_key = self._fetch_scan_data_and_access() data, access_key = self._fetch_scan_data_and_access()
for curve in self._sync_curves: for curve in self._sync_curves:
device_name = curve.config.signal.name device_name = curve.config.signal.name
@@ -1177,9 +1176,8 @@ class Waveform(PlotBase):
if access_key == "val": if access_key == "val":
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None) device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
else: else:
device_data = ( entry_obj = data.get(device_name, {}).get(device_entry)
data.get(device_name, {}).get(device_entry, {}).read().get("value", None) device_data = entry_obj.read()["value"] if entry_obj else None
)
x_data = self._get_x_data(device_name, device_entry) x_data = self._get_x_data(device_name, device_entry)
if x_data is not None: if x_data is not None:
if len(x_data) == 1: if len(x_data) == 1:
@@ -1217,7 +1215,8 @@ class Waveform(PlotBase):
if self._skip_large_dataset_check is False: if self._skip_large_dataset_check is False:
if not self._check_dataset_size_and_confirm(dataset_obj, device_entry): if not self._check_dataset_size_and_confirm(dataset_obj, device_entry):
continue # user declined to load; skip this curve continue # user declined to load; skip this curve
device_data = dataset_obj.get(device_entry, {}).read().get("value", None) entry_obj = dataset_obj.get(device_entry, None)
device_data = entry_obj.read()["value"] if entry_obj else None
# if shape is 2D cast it into 1D and take the last waveform # if shape is 2D cast it into 1D and take the last waveform
if len(np.shape(device_data)) > 1: if len(np.shape(device_data)) > 1:
@@ -1246,6 +1245,23 @@ class Waveform(PlotBase):
self.request_dap_update.emit() self.request_dap_update.emit()
def _check_async_signal_found(self, name: str, signal: str) -> bool:
"""
Check if the async signal is found in the BEC device manager.
Args:
name(str): The name of the async signal.
signal(str): The entry of the async signal.
Returns:
bool: True if the async signal is found, False otherwise.
"""
bec_async_signals = self.client.device_manager.get_bec_signals("AsyncSignal")
for entry_name, _, entry_data in bec_async_signals:
if entry_name == name and entry_data.get("obj_name") == signal:
return True
return False
def _setup_async_curve(self, curve: Curve): def _setup_async_curve(self, curve: Curve):
""" """
Setup async curve. Setup async curve.
@@ -1254,20 +1270,40 @@ class Waveform(PlotBase):
curve(Curve): The curve to set up. curve(Curve): The curve to set up.
""" """
name = curve.config.signal.name name = curve.config.signal.name
self.bec_dispatcher.disconnect_slot( signal = curve.config.signal.entry
self.on_async_readback, MessageEndpoints.device_async_readback(self.old_scan_id, name) async_signal_found = self._check_async_signal_found(name, signal)
)
try: try:
curve.clear_data() curve.clear_data()
except KeyError: except KeyError:
logger.warning(f"Curve {name} not found in plot item.") logger.warning(f"Curve {name} not found in plot item.")
pass pass
self.bec_dispatcher.connect_slot(
self.on_async_readback, # New endpoint for async signals
MessageEndpoints.device_async_readback(self.scan_id, name), if async_signal_found:
from_start=True, self.bec_dispatcher.disconnect_slot(
cb_info={"scan_id": self.scan_id}, self.on_async_readback,
) MessageEndpoints.device_async_signal(self.old_scan_id, name, signal),
)
self.bec_dispatcher.connect_slot(
self.on_async_readback,
MessageEndpoints.device_async_signal(self.scan_id, name, signal),
from_start=True,
cb_info={"scan_id": self.scan_id},
)
# old endpoint
else:
self.bec_dispatcher.disconnect_slot(
self.on_async_readback,
MessageEndpoints.device_async_readback(self.old_scan_id, name),
)
self.bec_dispatcher.connect_slot(
self.on_async_readback,
MessageEndpoints.device_async_readback(self.scan_id, name),
from_start=True,
cb_info={"scan_id": self.scan_id},
)
logger.info(f"Setup async curve {name}") logger.info(f"Setup async curve {name}")
@SafeSlot(dict, dict, verify_sender=True) @SafeSlot(dict, dict, verify_sender=True)
@@ -1549,15 +1585,21 @@ class Waveform(PlotBase):
if access_key == "val": # live data if access_key == "val": # live data
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, [0]) x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, [0])
else: # history data else: # history data
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", [0]) entry_obj = data.get(x_name, {}).get(x_entry)
x_data = entry_obj.read()["value"] if entry_obj else [0]
new_suffix = f" (custom: {x_name}-{x_entry})" new_suffix = f" (custom: {x_name}-{x_entry})"
# 2 User wants timestamp # 2 User wants timestamp
if self.x_axis_mode["name"] == "timestamp": if self.x_axis_mode["name"] == "timestamp":
if access_key == "val": # live if access_key == "val": # live
timestamps = data[device_name][device_entry].timestamps x_data = data.get(device_name, {}).get(device_entry, None)
if x_data is None:
return None
else:
timestamps = x_data.timestamps
else: # history data else: # history data
timestamps = data[device_name][device_entry].read().get("timestamp", [0]) entry_obj = data.get(device_name, {}).get(device_entry)
timestamps = entry_obj.read()["timestamp"] if entry_obj else [0]
x_data = timestamps x_data = timestamps
new_suffix = " (timestamp)" new_suffix = " (timestamp)"
@@ -1584,7 +1626,8 @@ class Waveform(PlotBase):
if access_key == "val": if access_key == "val":
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None) x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
else: else:
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None) entry_obj = data.get(x_name, {}).get(x_entry)
x_data = entry_obj.read()["value"] if entry_obj else None
new_suffix = f" (auto: {x_name}-{x_entry})" new_suffix = f" (auto: {x_name}-{x_entry})"
self._update_x_label_suffix(new_suffix) self._update_x_label_suffix(new_suffix)
return x_data return x_data
@@ -1637,12 +1680,17 @@ class Waveform(PlotBase):
self.update_with_scan_history(-1) self.update_with_scan_history(-1)
if self.scan_item is None: if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.") logger.info("No scan executed so far; skipping device curves categorisation.")
return "none" return None
if hasattr(self.scan_item, "live_data"): if hasattr(self.scan_item, "live_data"):
readout_priority = self.scan_item.status_message.info["readout_priority"] # live data readout_priority = self.scan_item.status_message.info.get(
"readout_priority"
) # live data
else: else:
readout_priority = self.scan_item.metadata["bec"]["readout_priority"] # history readout_priority = self.scan_item.metadata["bec"].get("readout_priority") # history
if readout_priority is None:
return None
# Reset sync/async curve lists # Reset sync/async curve lists
self._async_curves.clear() self._async_curves.clear()
@@ -1,12 +1,46 @@
import sys import sys
from enum import Enum
from string import Template from string import Template
from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer, Slot from qtpy.QtCore import QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer
from qtpy.QtGui import QColor, QPainter, QPainterPath from qtpy.QtGui import QColor, QPainter, QPainterPath
class ProgressState(Enum):
NORMAL = "normal"
PAUSED = "paused"
INTERRUPTED = "interrupted"
COMPLETED = "completed"
@classmethod
def from_bec_status(cls, status: str) -> "ProgressState":
"""
Map a BEC status string (open, paused, aborted, halted, closed)
to the corresponding ProgressState.
Any unknown status falls back to NORMAL.
"""
mapping = {
"open": cls.NORMAL,
"paused": cls.PAUSED,
"aborted": cls.INTERRUPTED,
"halted": cls.PAUSED,
"closed": cls.COMPLETED,
}
return mapping.get(status.lower(), cls.NORMAL)
PROGRESS_STATE_COLORS = {
ProgressState.NORMAL: QColor("#2979ff"), # blue normal progress
ProgressState.PAUSED: QColor("#ffca28"), # orange/amber paused
ProgressState.INTERRUPTED: QColor("#ff5252"), # red interrupted
ProgressState.COMPLETED: QColor("#00e676"), # green finished
}
from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class BECProgressBar(BECWidget, QWidget): class BECProgressBar(BECWidget, QWidget):
@@ -21,6 +55,8 @@ class BECProgressBar(BECWidget, QWidget):
"set_minimum", "set_minimum",
"label_template", "label_template",
"label_template.setter", "label_template.setter",
"state",
"state.setter",
"_get_label", "_get_label",
] ]
ICON_NAME = "page_control" ICON_NAME = "page_control"
@@ -48,27 +84,38 @@ class BECProgressBar(BECWidget, QWidget):
self._completed_color = accent_colors.success self._completed_color = accent_colors.success
self._border_color = QColor(50, 50, 50) self._border_color = QColor(50, 50, 50)
# Cornerrounding: base radius in pixels (autoreduced if bar is small)
self._corner_radius = 10
# Progressbar state handling
self._state = ProgressState.NORMAL
self._state_colors = dict(PROGRESS_STATE_COLORS)
# layout settings # layout settings
self._padding_left_right = 10
self._value_animation = QPropertyAnimation(self, b"_progressbar_value") self._value_animation = QPropertyAnimation(self, b"_progressbar_value")
self._value_animation.setDuration(200) self._value_animation.setDuration(200)
self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic) self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
# label on top of the progress bar # label on top of the progress bar
self.center_label = QLabel(self) self.center_label = QLabel(self)
self.center_label.setAlignment(Qt.AlignCenter) self.center_label.setAlignment(Qt.AlignHCenter)
self.center_label.setStyleSheet("color: white;") self.center_label.setStyleSheet("color: white;")
self.center_label.setMinimumSize(0, 0) self.center_label.setMinimumSize(0, 0)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(10, 0, 10, 0)
layout.setSpacing(0) layout.setSpacing(0)
layout.addWidget(self.center_label) layout.addWidget(self.center_label)
layout.setAlignment(self.center_label, Qt.AlignCenter)
self.setLayout(layout) self.setLayout(layout)
self.update() self.update()
self._adjust_label_width()
@Property(str, doc="The template for the center label. Use $value, $maximum, and $percentage.") @SafeProperty(
str, doc="The template for the center label. Use $value, $maximum, and $percentage."
)
def label_template(self): def label_template(self):
""" """
The template for the center label. Use $value, $maximum, and $percentage to insert the values. The template for the center label. Use $value, $maximum, and $percentage to insert the values.
@@ -83,10 +130,11 @@ class BECProgressBar(BECWidget, QWidget):
@label_template.setter @label_template.setter
def label_template(self, template): def label_template(self, template):
self._label_template = template self._label_template = template
self._adjust_label_width()
self.set_value(self._user_value) self.set_value(self._user_value)
self.update() self.update()
@Property(float, designable=False) @SafeProperty(float, designable=False)
def _progressbar_value(self): def _progressbar_value(self):
""" """
The current value of the progress bar. The current value of the progress bar.
@@ -106,8 +154,20 @@ class BECProgressBar(BECWidget, QWidget):
percentage=int((self.map_value(self._user_value) / self._maximum) * 100), percentage=int((self.map_value(self._user_value) / self._maximum) * 100),
) )
@Slot(float) def _adjust_label_width(self):
@Slot(int) """
Reserve enough horizontal space for the center label so the widget
doesn't resize as the text grows during progress.
"""
template = Template(self._label_template)
sample_text = template.safe_substitute(
value=self._user_maximum, maximum=self._user_maximum, percentage=100
)
width = self.center_label.fontMetrics().horizontalAdvance(sample_text)
self.center_label.setFixedWidth(width)
@SafeSlot(float)
@SafeSlot(int)
def set_value(self, value): def set_value(self, value):
""" """
Set the value of the progress bar. Set the value of the progress bar.
@@ -122,35 +182,88 @@ class BECProgressBar(BECWidget, QWidget):
self._target_value = self.map_value(value) self._target_value = self.map_value(value)
self._user_value = value self._user_value = value
self.center_label.setText(self._update_template()) self.center_label.setText(self._update_template())
# Update state automatically unless paused or interrupted
if self._state not in (ProgressState.PAUSED, ProgressState.INTERRUPTED):
self._state = (
ProgressState.COMPLETED
if self._user_value >= self._user_maximum
else ProgressState.NORMAL
)
self.animate_progress() self.animate_progress()
@SafeProperty(object, doc="Current visual state of the progress bar.")
def state(self):
return self._state
@state.setter
def state(self, state):
"""
Set the visual state of the progress bar.
Args:
state(ProgressState | str): The state to set. Can be one of the
"""
if isinstance(state, str):
state = ProgressState(state.lower())
if not isinstance(state, ProgressState):
raise ValueError("state must be a ProgressState or its value")
self._state = state
self.update()
@SafeProperty(float, doc="Base corner radius in pixels (autoscaled down on small bars).")
def corner_radius(self) -> float:
return self._corner_radius
@corner_radius.setter
def corner_radius(self, radius: float):
self._corner_radius = max(0.0, radius)
self.update()
@SafeProperty(float)
def padding_left_right(self) -> float:
return self._padding_left_right
@padding_left_right.setter
def padding_left_right(self, padding: float):
self._padding_left_right = padding
self.update()
def paintEvent(self, event): def paintEvent(self, event):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing) painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect().adjusted(10, 0, -10, -1) rect = self.rect().adjusted(self._padding_left_right, 0, -self._padding_left_right, -1)
# Corner radius adapts to widget height so it never exceeds half the bars thickness
radius = min(self._corner_radius, rect.height() / 2)
# Draw background # Draw background
painter.setBrush(self._background_color) painter.setBrush(self._background_color)
painter.setPen(Qt.NoPen) painter.setPen(Qt.NoPen)
painter.drawRoundedRect(rect, 10, 10) # Rounded corners painter.drawRoundedRect(rect, radius, radius) # Rounded corners
# Draw border # Draw border
painter.setBrush(Qt.NoBrush) painter.setBrush(Qt.NoBrush)
painter.setPen(self._border_color) painter.setPen(self._border_color)
painter.drawRoundedRect(rect, 10, 10) painter.drawRoundedRect(rect, radius, radius)
# Determine progress color based on completion # Determine progress colour based on current state
if self._value >= self._maximum: if self._state == ProgressState.PAUSED:
current_color = self._completed_color current_color = self._state_colors[ProgressState.PAUSED]
elif self._state == ProgressState.INTERRUPTED:
current_color = self._state_colors[ProgressState.INTERRUPTED]
elif self._state == ProgressState.COMPLETED or self._value >= self._maximum:
current_color = self._state_colors[ProgressState.COMPLETED]
else: else:
current_color = self._progress_color current_color = self._state_colors[ProgressState.NORMAL]
# Set clipping region to preserve the background's rounded corners # Set clipping region to preserve the background's rounded corners
progress_rect = rect.adjusted( progress_rect = rect.adjusted(
0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0 0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0
) )
clip_path = QPainterPath() clip_path = QPainterPath()
clip_path.addRoundedRect(QRectF(rect), 10, 10) # Clip to the background's rounded corners clip_path.addRoundedRect(
QRectF(rect), radius, radius
) # Clip to the background's rounded corners
painter.setClipPath(clip_path) painter.setClipPath(clip_path)
# Draw progress bar # Draw progress bar
@@ -168,7 +281,7 @@ class BECProgressBar(BECWidget, QWidget):
self._value_animation.setEndValue(self._target_value) self._value_animation.setEndValue(self._target_value)
self._value_animation.start() self._value_animation.start()
@Property(float) @SafeProperty(float)
def maximum(self): def maximum(self):
""" """
The maximum value of the progress bar. The maximum value of the progress bar.
@@ -182,7 +295,7 @@ class BECProgressBar(BECWidget, QWidget):
""" """
self.set_maximum(maximum) self.set_maximum(maximum)
@Property(float) @SafeProperty(float)
def minimum(self): def minimum(self):
""" """
The minimum value of the progress bar. The minimum value of the progress bar.
@@ -193,7 +306,7 @@ class BECProgressBar(BECWidget, QWidget):
def minimum(self, minimum: float): def minimum(self, minimum: float):
self.set_minimum(minimum) self.set_minimum(minimum)
@Property(float) @SafeProperty(float)
def initial_value(self): def initial_value(self):
""" """
The initial value of the progress bar. The initial value of the progress bar.
@@ -204,7 +317,7 @@ class BECProgressBar(BECWidget, QWidget):
def initial_value(self, value: float): def initial_value(self, value: float):
self.set_value(value) self.set_value(value)
@Slot(float) @SafeSlot(float)
def set_maximum(self, maximum: float): def set_maximum(self, maximum: float):
""" """
Set the maximum value of the progress bar. Set the maximum value of the progress bar.
@@ -213,10 +326,11 @@ class BECProgressBar(BECWidget, QWidget):
maximum (float): The maximum value. maximum (float): The maximum value.
""" """
self._user_maximum = maximum self._user_maximum = maximum
self._adjust_label_width()
self.set_value(self._user_value) # Update the value to fit the new range self.set_value(self._user_value) # Update the value to fit the new range
self.update() self.update()
@Slot(float) @SafeSlot(float)
def set_minimum(self, minimum: float): def set_minimum(self, minimum: float):
""" """
Set the minimum value of the progress bar. Set the minimum value of the progress bar.
@@ -0,0 +1,17 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.progress.scan_progressbar.scan_progress_bar_plugin import (
ScanProgressBarPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(ScanProgressBarPlugin())
if __name__ == "__main__": # pragma: no cover
main()
@@ -0,0 +1 @@
{'files': ['scan_progressbar.py']}
@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
DOM_XML = """
<ui language='c++'>
<widget class='ScanProgressBar' name='scan_progress_bar'>
</widget>
</ui>
"""
class ScanProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = ScanProgressBar(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
def icon(self):
return designer_material_icon(ScanProgressBar.ICON_NAME)
def includeFile(self):
return "scan_progress_bar"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "ScanProgressBar"
def toolTip(self):
return "A progress bar that is hooked up to the scan progress of a scan."
def whatsThis(self):
return self.toolTip()
@@ -0,0 +1,320 @@
from __future__ import annotations
import enum
import os
import time
from typing import Literal
import numpy as np
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject, QTimer, Signal
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import ProgressState
logger = bec_logger.logger
class ProgressSource(enum.Enum):
"""
Enum to define the source of the progress.
"""
SCAN_PROGRESS = "scan_progress"
DEVICE_PROGRESS = "device_progress"
class ProgressTask(QObject):
"""
Class to store progress information.
Inspired by https://github.com/Textualize/rich/blob/master/rich/progress.py
"""
def __init__(self, parent: QWidget, value: float = 0, max_value: float = 0, done: bool = False):
super().__init__(parent=parent)
self.start_time = time.time()
self.done = done
self.value = value
self.max_value = max_value
self._elapsed_time = 0
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_elapsed_time)
self.timer.start(100) # update the elapsed time every 100 ms
def update(self, value: float, max_value: float, done: bool = False):
"""
Update the progress.
"""
self.max_value = max_value
self.done = done
self.value = value
if done:
self.timer.stop()
def update_elapsed_time(self):
"""
Update the time estimates. This is called every 100 ms by a QTimer.
"""
self._elapsed_time += 0.1
@property
def percentage(self) -> float:
"""float: Get progress of task as a percentage. If a None total was set, returns 0"""
if not self.max_value:
return 0.0
completed = (self.value / self.max_value) * 100.0
completed = min(100.0, max(0.0, completed))
return completed
@property
def speed(self) -> float:
"""Get the estimated speed in steps per second."""
if self._elapsed_time == 0:
return 0.0
return self.value / self._elapsed_time
@property
def frequency(self) -> float:
"""Get the estimated frequency in steps per second."""
if self.speed == 0:
return 0.0
return 1 / self.speed
@property
def time_elapsed(self) -> str:
# format the elapsed time to a string in the format HH:MM:SS
return self._format_time(int(self._elapsed_time))
@property
def remaining(self) -> float:
"""Get the estimated remaining steps."""
if self.done:
return 0.0
remaining = self.max_value - self.value
return remaining
@property
def time_remaining(self) -> str:
"""
Get the estimated remaining time in the format HH:MM:SS.
"""
if self.done or not self.speed or not self.remaining:
return self._format_time(0)
estimate = int(np.round(self.remaining / self.speed))
return self._format_time(estimate)
def _format_time(self, seconds: float) -> str:
"""
Format the time in seconds to a string in the format HH:MM:SS.
"""
return f"{seconds // 3600:02}:{(seconds // 60) % 60:02}:{seconds % 60:02}"
class ScanProgressBar(BECWidget, QWidget):
"""
Widget to display a progress bar that is hooked up to the scan progress of a scan.
If you want to manually set the progress, it is recommended to use the BECProgressbar or QProgressbar directly.
"""
ICON_NAME = "timelapse"
progress_started = Signal()
progress_finished = Signal()
def __init__(self, parent=None, client=None, config=None, gui_id=None, one_line_design=False):
super().__init__(parent=parent, client=client, config=config, gui_id=gui_id)
self.get_bec_shortcuts()
ui_file = os.path.join(
os.path.dirname(__file__),
"scan_progressbar_one_line.ui" if one_line_design else "scan_progressbar.ui",
)
self.ui = UILoader(self).loader(ui_file)
self.layout = QVBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.ui)
self.setLayout(self.layout)
self.progressbar = self.ui.progressbar
self.connect_to_queue()
self._progress_source = None
self.task = None
self.scan_number = None
self.progress_started.connect(lambda: print("Scan progress started"))
def connect_to_queue(self):
"""
Connect to the queue status signal.
"""
self.bec_dispatcher.connect_slot(self.on_queue_update, MessageEndpoints.scan_queue_status())
def set_progress_source(self, source: ProgressSource, device=None):
"""
Set the source of the progress.
"""
if self._progress_source == source:
self.update_source_label(source, device=device)
return
if self._progress_source is not None:
self.bec_dispatcher.disconnect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if self._progress_source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=device)
),
)
self._progress_source = source
self.bec_dispatcher.connect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=device)
),
)
self.update_source_label(source, device=device)
# self.progress_started.emit()
def update_source_label(self, source: ProgressSource, device=None):
scan_text = f"Scan {self.scan_number}" if self.scan_number is not None else "Scan"
text = scan_text if source == ProgressSource.SCAN_PROGRESS else f"Device {device}"
logger.info(f"Set progress source to {text}")
self.ui.source_label.setText(text)
@SafeSlot(dict, dict)
def on_progress_update(self, msg_content: dict, metadata: dict):
"""
Update the progress bar based on the progress message.
"""
value = msg_content["value"]
max_value = msg_content.get("max_value", 100)
done = msg_content.get("done", False)
status: Literal["open", "paused", "aborted", "halted", "closed"] = metadata.get(
"status", "open"
)
if self.task is None:
return
self.task.update(value, max_value, done)
self.update_labels()
self.progressbar.set_maximum(self.task.max_value)
self.progressbar.state = ProgressState.from_bec_status(status)
self.progressbar.set_value(self.task.value)
if done:
self.task = None
self.progress_finished.emit()
return
@SafeProperty(bool)
def show_elapsed_time(self):
return self.ui.elapsed_time_label.isVisible()
@show_elapsed_time.setter
def show_elapsed_time(self, value):
self.ui.elapsed_time_label.setVisible(value)
if hasattr(self.ui, "dash"):
self.ui.dash.setVisible(value)
@SafeProperty(bool)
def show_remaining_time(self):
return self.ui.remaining_time_label.isVisible()
@show_remaining_time.setter
def show_remaining_time(self, value):
self.ui.remaining_time_label.setVisible(value)
if hasattr(self.ui, "dash"):
self.ui.dash.setVisible(value)
@SafeProperty(bool)
def show_source_label(self):
return self.ui.source_label.isVisible()
@show_source_label.setter
def show_source_label(self, value):
self.ui.source_label.setVisible(value)
def update_labels(self):
"""
Update the labels based on the progress task.
"""
if self.task is None:
return
self.ui.elapsed_time_label.setText(self.task.time_elapsed)
self.ui.remaining_time_label.setText(self.task.time_remaining)
@SafeSlot(dict, dict, verify_sender=True)
def on_queue_update(self, msg_content, metadata):
"""
Update the progress bar based on the queue status.
"""
if not "queue" in msg_content:
return
primary_queue_info = msg_content["queue"].get("primary", {}).get("info", [])
if len(primary_queue_info) == 0:
return
scan_info = primary_queue_info[0]
if scan_info is None:
return
if scan_info.get("status").lower() == "running" and self.task is None:
self.task = ProgressTask(parent=self)
self.progress_started.emit()
active_request_block = scan_info.get("active_request_block", {})
if active_request_block is None:
return
self.scan_number = active_request_block.get("scan_number")
report_instructions = active_request_block.get("report_instructions", [])
if not report_instructions:
return
# for now, let's just use the first instruction
instruction = report_instructions[0]
if "scan_progress" in instruction:
self.set_progress_source(ProgressSource.SCAN_PROGRESS)
elif "device_progress" in instruction:
device = instruction["device_progress"][0]
self.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
def cleanup(self):
if self.task is not None:
self.task.timer.stop()
self.close()
self.deleteLater()
if self._progress_source is not None:
self.bec_dispatcher.disconnect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if self._progress_source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=self._progress_source.value)
),
)
self.progressbar.close()
self.progressbar.deleteLater()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
bec_logger.disabled_modules = ["bec_lib"]
app = QApplication([])
widget = ScanProgressBar()
widget.show()
app.exec_()
@@ -0,0 +1,141 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>211</width>
<height>60</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>60</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>1</number>
</property>
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<layout class="QHBoxLayout" name="source_layout">
<item>
<widget class="QLabel" name="source_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>Scan</string>
</property>
</widget>
</item>
<item>
<spacer name="source_spacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>10</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="BECProgressBar" name="progressbar">
<property name="padding_left_right" stdset="0">
<double>2.000000000000000</double>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="timer_layout">
<item>
<widget class="QLabel" name="remaining_time_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>0:00:00</string>
</property>
</widget>
</item>
<item>
<spacer name="timer_spacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="elapsed_time_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>0:00:00</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BECProgressBar</class>
<extends>QWidget</extends>
<header>bec_progress_bar</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>
@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>328</width>
<height>24</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>24</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>24</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2" stretch="0,1,0">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>2</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="QLabel" name="source_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>Scan</string>
</property>
</widget>
</item>
<item>
<widget class="BECProgressBar" name="progressbar">
<property name="minimumSize">
<size>
<width>30</width>
<height>0</height>
</size>
</property>
<property name="padding_left_right" stdset="0">
<double>5.000000000000000</double>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="remaining_time_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>0:00:00</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="dash">
<property name="text">
<string>-</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="elapsed_time_label">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>20</height>
</size>
</property>
<property name="text">
<string>0:00:00</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BECProgressBar</class>
<extends>QWidget</extends>
<header>bec_progress_bar</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>
@@ -1,15 +1,22 @@
import os import os
import re import re
from typing import Optional from functools import partial
from bec_lib.callback_handler import EventType from bec_lib.callback_handler import EventType
from bec_lib.logger import bec_logger
from bec_lib.messages import ConfigAction
from pyqtgraph import SignalProxy from pyqtgraph import SignalProxy
from qtpy.QtCore import Signal, Slot from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import QListWidgetItem, QVBoxLayout, QWidget from qtpy.QtWidgets import QListWidget, QListWidgetItem, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_widget import BECWidget 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.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 import DeviceItem
from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon
logger = bec_logger.logger
class DeviceBrowser(BECWidget, QWidget): class DeviceBrowser(BECWidget, QWidget):
@@ -23,18 +30,18 @@ class DeviceBrowser(BECWidget, QWidget):
def __init__( def __init__(
self, self,
parent: Optional[QWidget] = None, parent: QWidget | None = None,
config=None, config=None,
client=None, client=None,
gui_id: Optional[str] = None, gui_id: str | None = None,
**kwargs, **kwargs,
) -> None: ) -> None:
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, **kwargs)
self.get_bec_shortcuts() self.get_bec_shortcuts()
self.ui = None self.ui = None
self.ini_ui() self.ini_ui()
self.dev_list: QListWidget = self.ui.device_list
self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
self.proxy_device_update = SignalProxy( self.proxy_device_update = SignalProxy(
self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list
) )
@@ -43,6 +50,7 @@ class DeviceBrowser(BECWidget, QWidget):
) )
self.device_update.connect(self.update_device_list) self.device_update.connect(self.update_device_list)
self.init_device_list()
self.update_device_list() self.update_device_list()
def ini_ui(self) -> None: def ini_ui(self) -> None:
@@ -50,14 +58,12 @@ class DeviceBrowser(BECWidget, QWidget):
Initialize the UI by loading the UI file and setting the layout. Initialize the UI by loading the UI file and setting the layout.
""" """
layout = QVBoxLayout() layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
ui_file_path = os.path.join(os.path.dirname(__file__), "device_browser.ui") ui_file_path = os.path.join(os.path.dirname(__file__), "device_browser.ui")
self.ui = UILoader(self).loader(ui_file_path) self.ui = UILoader(self).loader(ui_file_path)
layout.addWidget(self.ui) layout.addWidget(self.ui)
self.setLayout(layout) self.setLayout(layout)
def on_device_update(self, action: str, content: dict) -> None: def on_device_update(self, action: ConfigAction, content: dict) -> None:
""" """
Callback for device update events. Triggers the device_update signal. Callback for device update events. Triggers the device_update signal.
@@ -68,8 +74,42 @@ class DeviceBrowser(BECWidget, QWidget):
if action in ["add", "remove", "reload"]: if action in ["add", "remove", "reload"]:
self.device_update.emit() self.device_update.emit()
@Slot() def init_device_list(self):
def update_device_list(self) -> None: self.dev_list.clear()
self._device_items: dict[str, QListWidgetItem] = {}
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())
self.dev_list.setItemWidget(item, device_item)
self.dev_list.addItem(item)
self._device_items[device] = item
@SafeSlot()
def reset_device_list(self) -> None:
self.init_device_list()
self.update_device_list()
@SafeSlot()
@SafeSlot(str)
def update_device_list(self, *_) -> None:
""" """
Update the device list based on the filter input. Update the device list based on the filter input.
There are two ways to trigger this function: There are two ways to trigger this function:
@@ -80,23 +120,14 @@ class DeviceBrowser(BECWidget, QWidget):
""" """
filter_text = self.ui.filter_input.text() filter_text = self.ui.filter_input.text()
try: try:
regex = re.compile(filter_text, re.IGNORECASE) self.regex = re.compile(filter_text, re.IGNORECASE)
except re.error: except re.error:
regex = None # Invalid regex, disable filtering self.regex = None # Invalid regex, disable filtering
for device in self.dev:
dev_list = self.ui.device_list self._device_items[device].setHidden(False)
dev_list.clear() return
for device in self.dev: for device in self.dev:
if regex is None or regex.search(device): self._device_items[device].setHidden(not self.regex.search(device))
item = QListWidgetItem(dev_list)
device_item = DeviceItem(device)
# pylint: disable=protected-access
tooltip = self.dev[device]._config.get("description", "")
device_item.setToolTip(tooltip)
item.setSizeHint(device_item.sizeHint())
dev_list.setItemWidget(item, device_item)
dev_list.addItem(item)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
@@ -104,10 +135,10 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import set_theme
app = QApplication(sys.argv) app = QApplication(sys.argv)
apply_theme("light") set_theme("light")
widget = DeviceBrowser() widget = DeviceBrowser()
widget.show() widget.show()
sys.exit(app.exec_()) sys.exit(app.exec_())
@@ -0,0 +1,254 @@
from ast import literal_eval
from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS
from bec_lib.config_helper import ConfigHelper
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal
from qtpy.QtWidgets import (
QApplication,
QDialog,
QDialogButtonBox,
QLabel,
QStackedLayout,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
logger = bec_logger.logger
class _CommSignals(QObject):
error = Signal(Exception)
done = Signal()
class _CommunicateUpdate(QRunnable):
def __init__(self, config_helper: ConfigHelper, device: str, config: dict) -> None:
super().__init__()
self.config_helper = config_helper
self.device = device
self.config = config
self.signals = _CommSignals()
@SafeSlot()
def run(self):
try:
timeout = self.config_helper.suggested_timeout_s(self.config)
RID = self.config_helper.send_config_request(
action="update", config={self.device: self.config}, wait_for_response=False
)
logger.info("Waiting for config reply")
reply = self.config_helper.wait_for_config_reply(RID, timeout=timeout)
self.config_helper.handle_update_reply(reply, RID, timeout)
logger.info("Done updating config!")
except Exception as e:
self.signals.error.emit(e)
finally:
self.signals.done.emit()
class DeviceConfigDialog(BECWidget, QDialog):
RPC = False
applied = Signal()
def __init__(
self,
parent=None,
device: str | None = None,
config_helper: ConfigHelper | None = None,
**kwargs,
):
super().__init__(parent=parent, **kwargs)
self._config_helper = config_helper or ConfigHelper(
self.client.connector, self.client._service_name
)
self.threadpool = QThreadPool()
self._device = device
self.setWindowTitle(f"Edit config for: {device}")
self._container = QStackedLayout()
self._container.setStackingMode(QStackedLayout.StackAll)
self._layout = QVBoxLayout()
user_warning = QLabel(
"Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n"
"Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc."
)
user_warning.setWordWrap(True)
user_warning.setStyleSheet("QLabel { color: red; }")
self._layout.addWidget(user_warning)
self._add_form()
self._add_overlay()
self._add_buttons()
self.setLayout(self._container)
self._overlay_widget.setVisible(False)
def _add_form(self):
self._form_widget = QWidget()
self._form_widget.setLayout(self._layout)
self._form = DeviceConfigForm()
self._layout.addWidget(self._form)
for row in self._form.enumerate_form_widgets():
if row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE:
row.widget._set_pretty_display()
self._fetch_config()
self._fill_form()
self._container.addWidget(self._form_widget)
def _add_overlay(self):
self._overlay_widget = QWidget()
self._overlay_widget.setStyleSheet("background-color:rgba(128,128,128,128);")
self._overlay_widget.setAutoFillBackground(True)
self._overlay_layout = QVBoxLayout()
self._overlay_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._overlay_widget.setLayout(self._overlay_layout)
self._spinner = SpinnerWidget(parent=self)
self._spinner.setMinimumSize(QSize(100, 100))
self._overlay_layout.addWidget(self._spinner)
self._container.addWidget(self._overlay_widget)
def _add_buttons(self):
button_box = QDialogButtonBox(
QDialogButtonBox.Apply | QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
self._layout.addWidget(button_box)
def _fetch_config(self):
self._initial_config = {}
if (
self.client.device_manager is not None
and self._device in self.client.device_manager.devices
):
self._initial_config = self.client.device_manager.devices.get(self._device)._config
def _fill_form(self):
self._form.set_data(DeviceConfigModel.model_validate(self._initial_config))
def updated_config(self):
new_config = self._form.get_form_data()
diff = {
k: v for k, v in new_config.items() if self._initial_config.get(k) != new_config.get(k)
}
if diff.get("deviceConfig") is not None:
# TODO: special cased in some parts of device manager but not others, should
# be removed in config update as with below issue
diff["deviceConfig"].pop("device_access", None)
# TODO: replace when https://github.com/bec-project/bec/issues/528 is resolved
diff["deviceConfig"] = {
k: literal_eval(str(v)) for k, v in diff["deviceConfig"].items()
}
return diff
@SafeSlot()
def apply(self):
self._process_update_action()
self.applied.emit()
@SafeSlot()
def accept(self):
self._process_update_action()
return super().accept()
def _process_update_action(self):
updated_config = self.updated_config()
if (device_name := updated_config.get("name")) == "":
logger.warning("Can't create a device with no name!")
elif set(updated_config.keys()) & set(DEVICE_CONF_KEYS.NON_UPDATABLE):
logger.info(
f"Removing old device {self._device} and adding new device {device_name or self._device} with modified config: {updated_config}"
)
else:
self._update_device_config(updated_config)
def _update_device_config(self, config: dict):
if self._device is None:
return
if config == {}:
logger.info("No changes made to device config")
return
logger.info(f"Sending request to update device config: {config}")
self._start_waiting_display()
communicate_update = _CommunicateUpdate(self._config_helper, self._device, config)
communicate_update.signals.error.connect(self.update_error)
communicate_update.signals.done.connect(self.update_done)
self.threadpool.start(communicate_update)
@SafeSlot()
def update_done(self):
self._stop_waiting_display()
self._fetch_config()
self._fill_form()
@SafeSlot(Exception, popup_error=True)
def update_error(self, e: Exception):
raise RuntimeError("Failed to update device configuration") from e
def _start_waiting_display(self):
self._overlay_widget.setVisible(True)
self._spinner.start()
QApplication.processEvents()
def _stop_waiting_display(self):
self._overlay_widget.setVisible(False)
self._spinner.stop()
QApplication.processEvents()
def main(): # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication, QLineEdit, QPushButton, QWidget
from bec_widgets.utils.colors import set_theme
dialog = None
app = QApplication(sys.argv)
set_theme("light")
widget = QWidget()
widget.setLayout(QVBoxLayout())
device = QLineEdit()
widget.layout().addWidget(device)
def _destroy_dialog(*_):
nonlocal dialog
dialog = None
def accept(*args):
logger.success(f"submitted device config form {dialog} {args}")
_destroy_dialog()
def _show_dialog(*_):
nonlocal dialog
if dialog is None:
dialog = DeviceConfigDialog(device=device.text())
dialog.accepted.connect(accept)
dialog.rejected.connect(_destroy_dialog)
dialog.open()
button = QPushButton("Show device dialog")
widget.layout().addWidget(button)
button.clicked.connect(_show_dialog)
widget.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
@@ -0,0 +1,60 @@
from __future__ import annotations
from bec_lib.atlas_models import Device as DeviceConfigModel
from pydantic import BaseModel
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import get_theme_name
from bec_widgets.utils.forms_from_types import styles
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
from bec_widgets.utils.forms_from_types.items import (
DEFAULT_WIDGET_TYPES,
BoolFormItem,
BoolToggleFormItem,
)
class DeviceConfigForm(PydanticModelForm):
RPC = False
PLUGIN = False
def __init__(self, parent=None, client=None, pretty_display=False, **kwargs):
super().__init__(
parent=parent,
data_model=DeviceConfigModel,
pretty_display=pretty_display,
client=client,
**kwargs,
)
self._widget_types = DEFAULT_WIDGET_TYPES.copy()
self._widget_types["bool"] = (lambda spec: spec.item_type is bool, BoolToggleFormItem)
self._widget_types["optional_bool"] = (
lambda spec: spec.item_type == bool | None,
BoolFormItem,
)
self._validity.setVisible(False)
self._connect_to_theme_change()
self.populate()
def _post_init(self): ...
def set_pretty_display_theme(self, theme: str | None = None):
if theme is None:
theme = get_theme_name()
self.setStyleSheet(styles.pretty_display_theme(theme))
def get_form_data(self):
"""Get the entered metadata as a dict."""
return self._md_schema.model_validate(super().get_form_data()).model_dump()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
def set_schema(self, schema: type[BaseModel]):
raise TypeError("This class doesn't support changing the schema")
def set_data(self, data: DeviceConfigModel): # type: ignore # This class locks the type
super().set_data(data)
@@ -2,37 +2,110 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.devicemanager import DeviceContainer
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtCore import QMimeData, Qt from bec_qthemes import material_icon
from qtpy.QtCore import QMimeData, QSize, Qt, Signal
from qtpy.QtGui import QDrag from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget 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.device_config_dialog import (
DeviceConfigDialog,
)
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
from bec_widgets.widgets.services.device_browser.device_item.device_signal_display import (
SignalDisplay,
)
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from qtpy.QtGui import QMouseEvent from qtpy.QtGui import QMouseEvent
logger = bec_logger.logger logger = bec_logger.logger
class DeviceItem(QWidget): class DeviceItem(ExpandableGroupFrame):
def __init__(self, device: str) -> None: broadcast_size_hint = Signal(QSize)
super().__init__()
RPC = False
def __init__(self, parent, device: str, devices: DeviceContainer, icon: str = "") -> None:
super().__init__(parent, title=device, expanded=False, icon=icon)
self.dev = devices
self._drag_pos = None self._drag_pos = None
self._expanded_first_time = False
self._data = None
self.device = device self.device = device
layout = QHBoxLayout()
layout.setContentsMargins(10, 2, 10, 2)
self.label = QLabel(device)
layout.addWidget(self.label)
self.setLayout(layout)
self.setStyleSheet( self._layout = QHBoxLayout()
""" self._layout.setContentsMargins(0, 0, 0, 0)
border: 1px solid #ddd; self._tab_widget = QTabWidget(tabShape=QTabWidget.TabShape.Rounded)
border-radius: 5px; self._tab_widget.setDocumentMode(True)
padding: 10px; self._layout.addWidget(self._tab_widget)
"""
self.set_layout(self._layout)
self._form_page = QWidget()
self._form_page_layout = QVBoxLayout()
self._form_page.setLayout(self._form_page_layout)
self._signal_page = QWidget()
self._signal_page_layout = QVBoxLayout()
self._signal_page.setLayout(self._signal_page_layout)
self._tab_widget.addTab(self._form_page, "Configuration")
self._tab_widget.addTab(self._signal_page, "Signals")
self.adjustSize()
def _create_title_layout(self, title: str, icon: str):
super()._create_title_layout(title, icon)
self.edit_button = QToolButton()
self.edit_button.setIcon(
material_icon(icon_name="edit", size=(10, 10), convert_to_pixmap=False)
) )
self._title_layout.insertWidget(self._title_layout.count() - 1, self.edit_button)
self.edit_button.clicked.connect(self._create_edit_dialog)
def _create_edit_dialog(self):
dialog = DeviceConfigDialog(parent=self, device=self.device)
dialog.accepted.connect(self._reload_config)
dialog.applied.connect(self._reload_config)
dialog.open()
@SafeSlot()
def switch_expanded_state(self):
if not self.expanded and not self._expanded_first_time:
self._expanded_first_time = True
self.form = DeviceConfigForm(parent=self, pretty_display=True)
self._form_page_layout.addWidget(self.form)
self.signals = SignalDisplay(parent=self, device=self.device)
self._signal_page_layout.addWidget(self.signals)
self._reload_config()
self.broadcast_size_hint.emit(self.sizeHint())
super().switch_expanded_state()
if self._expanded_first_time:
self.form.adjustSize()
self.updateGeometry()
if self._expanded:
self.form.set_pretty_display_theme()
self.adjustSize()
self.broadcast_size_hint.emit(self.sizeHint())
@SafeSlot(popup_error=True)
def _reload_config(self, *_):
self.set_display_config(self.dev[self.device]._config)
def set_display_config(self, config_dict: dict):
"""Set the displayed information from a device config dict, which must conform to the
bec_lib.atlas_models.Device config model."""
self._data = DeviceConfigModel.model_validate(config_dict)
if self._expanded_first_time:
self.form.set_data(self._data)
def mousePressEvent(self, event: QMouseEvent) -> None: def mousePressEvent(self, event: QMouseEvent) -> None:
super().mousePressEvent(event) super().mousePressEvent(event)
@@ -59,10 +132,33 @@ class DeviceItem(QWidget):
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
import sys import sys
from unittest.mock import MagicMock
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QApplication(sys.argv) app = QApplication(sys.argv)
widget = DeviceItem("Device") widget = QWidget()
layout = QHBoxLayout()
widget.setLayout(layout)
mock_config = {
"name": "Test Device",
"enabled": True,
"deviceClass": "FakeDeviceClass",
"deviceConfig": {"kwarg1": "value1"},
"readoutPriority": "baseline",
"description": "A device for testing out a widget",
"readOnly": True,
"softwareTrigger": False,
"deviceTags": {"tag1", "tag2", "tag3"},
"userParameter": {"some_setting": "some_ value"},
}
item = DeviceItem(widget, "Device", {"Device": MagicMock(enabled=True, _config=mock_config)})
layout.addWidget(DarkModeButton())
layout.addWidget(item)
widget.show() widget.show()
sys.exit(app.exec_()) sys.exit(app.exec_())
@@ -0,0 +1,102 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QLabel, QToolButton, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel
class SignalDisplay(BECWidget, QWidget):
RPC = False
def __init__(
self,
client=None,
device: str = "",
config: ConnectionConfig = None,
gui_id: str | None = None,
theme_update: bool = False,
parent_dock: BECDock | None = None,
**kwargs,
):
"""A widget to display all the signals from a given device, and allow getting
a fresh reading."""
super().__init__(client, config, gui_id, theme_update, parent_dock, **kwargs)
self.get_bec_shortcuts()
self._layout = QVBoxLayout()
self.setLayout(self._layout)
self._content = QWidget()
self._layout.addWidget(self._content)
self._device = device
self.device = device
@SafeSlot()
def _refresh(self):
if self.device in self.dev:
self.dev.get(self.device).read(cached=False)
self.dev.get(self.device).read_configuration(cached=False)
def _add_refresh_button(self):
button_holder = QWidget()
button_holder.setLayout(QHBoxLayout())
button_holder.layout().setAlignment(Qt.AlignmentFlag.AlignRight)
button_holder.layout().setContentsMargins(0, 0, 0, 0)
refresh_button = QToolButton()
refresh_button.setIcon(
material_icon(icon_name="refresh", size=(20, 20), convert_to_pixmap=False)
)
refresh_button.clicked.connect(self._refresh)
button_holder.layout().addWidget(refresh_button)
self._content_layout.addWidget(button_holder)
def _populate(self):
self._content.deleteLater()
self._content = QWidget()
self._layout.addWidget(self._content)
self._content_layout = QVBoxLayout()
self._content_layout.setContentsMargins(0, 0, 0, 0)
self._content.setLayout(self._content_layout)
self._add_refresh_button()
if self._device in self.dev:
for sig in self.dev[self.device]._info.get("signals", {}).keys():
self._content_layout.addWidget(
SignalLabel(
device=self._device,
signal=sig,
show_select_button=False,
show_default_units=True,
)
)
self._content_layout.addStretch(1)
else:
self._content_layout.addWidget(
QLabel(f"Device {self.device} not found in device manager!")
)
@SafeProperty(str)
def device(self):
return self._device
@device.setter
def device(self, value: str):
self._device = value
self._populate()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
app = QApplication(sys.argv)
set_theme("light")
widget = SignalDisplay(device="samx")
widget.show()
sys.exit(app.exec_())
@@ -0,0 +1,11 @@
from bec_lib.device import Device
def map_device_type_to_icon(device_obj: Device) -> str:
"""Associate device types with material icon names"""
match device_obj._info.get("device_base_class", "").lower():
case "positioner":
return "precision_manufacturing"
case "signal":
return "vital_signs"
return "deployed_code"
@@ -16,7 +16,6 @@ from qtpy.QtWidgets import (
QGroupBox, QGroupBox,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit,
QToolButton, QToolButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@@ -180,6 +179,7 @@ class SignalLabel(BECWidget, QWidget):
self._custom_units: str = custom_units self._custom_units: str = custom_units
self._show_default_units: bool = show_default_units self._show_default_units: bool = show_default_units
self._decimal_places = 3 self._decimal_places = 3
self._dtype = None
self._show_hinted_signals: bool = True self._show_hinted_signals: bool = True
self._show_normal_signals: bool = False self._show_normal_signals: bool = False
@@ -241,8 +241,10 @@ class SignalLabel(BECWidget, QWidget):
"""Subscribe to the Redis topic for the device to display""" """Subscribe to the Redis topic for the device to display"""
if not self._connected and self._device and self._device in self.dev: if not self._connected and self._device and self._device in self.dev:
self._connected = True self._connected = True
self._readback_endpoint = MessageEndpoints.device_readback(self._device) self._read_endpoint = MessageEndpoints.device_read(self._device)
self.bec_dispatcher.connect_slot(self.on_device_readback, self._readback_endpoint) 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._manual_read()
self.set_display_value(self._value) self.set_display_value(self._value)
@@ -250,7 +252,8 @@ class SignalLabel(BECWidget, QWidget):
"""Unsubscribe from the Redis topic for the device to display""" """Unsubscribe from the Redis topic for the device to display"""
if self._connected: if self._connected:
self._connected = False self._connected = False
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._readback_endpoint) self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._read_endpoint)
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._read_config_endpoint)
def _manual_read(self): def _manual_read(self):
if self._device is None or not isinstance( if self._device is None or not isinstance(
@@ -259,8 +262,13 @@ class SignalLabel(BECWidget, QWidget):
self._units = "" self._units = ""
self._value = "__" self._value = "__"
return return
signal: Signal = ( signal, info = (
getattr(device, self.signal, None) if isinstance(device, Device) else device (
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 if not isinstance(signal, Signal): # Avoid getting other attributes of device, e.g. methods
signal = None signal = None
@@ -269,7 +277,8 @@ class SignalLabel(BECWidget, QWidget):
self._value = "__" self._value = "__"
return return
self._value = signal.get() self._value = signal.get()
self._units = signal.get_device_config().get("egu", "") self._units = info.get("egu", "")
self._dtype = info.get("dtype", "float")
@SafeSlot(dict, dict) @SafeSlot(dict, dict)
def on_device_readback(self, msg: dict, metadata: dict) -> None: def on_device_readback(self, msg: dict, metadata: dict) -> None:
@@ -278,8 +287,10 @@ class SignalLabel(BECWidget, QWidget):
""" """
try: try:
signal_to_read = self._patch_hinted_signal() signal_to_read = self._patch_hinted_signal()
self._value = msg["signals"][signal_to_read]["value"] _value = msg["signals"].get(signal_to_read, {}).get("value")
self.set_display_value(self._value) if _value is not None:
self._value = _value
self.set_display_value(self._value)
except Exception as e: except Exception as e:
self._display.setText("ERROR!") self._display.setText("ERROR!")
self._display.setToolTip( self._display.setToolTip(
@@ -401,7 +412,10 @@ class SignalLabel(BECWidget, QWidget):
if self._decimal_places == 0: if self._decimal_places == 0:
return value return value
try: try:
return f"{float(value):0.{self._decimal_places}f}" if self._dtype in ("integer", "float"):
return f"{float(value):0.{self._decimal_places}f}"
else:
return str(value)
except ValueError: except ValueError:
return value return value
@@ -10,6 +10,7 @@ class ToggleSwitch(QWidget):
A simple toggle. A simple toggle.
""" """
stateChanged = Signal(bool)
enabled = Signal(bool) enabled = Signal(bool)
ICON_NAME = "toggle_on" ICON_NAME = "toggle_on"
PLUGIN = True PLUGIN = True
@@ -42,11 +43,19 @@ class ToggleSwitch(QWidget):
@checked.setter @checked.setter
def checked(self, state): def checked(self, state):
if self._checked != state:
self.stateChanged.emit(state)
self._checked = state self._checked = state
self.update_colors() self.update_colors()
self.set_thumb_pos_to_state() self.set_thumb_pos_to_state()
self.enabled.emit(self._checked) self.enabled.emit(self._checked)
def setChecked(self, state: bool):
self.checked = state
def isChecked(self):
return self.checked
@Property(QPointF) @Property(QPointF)
def thumb_pos(self): def thumb_pos(self):
return self._thumb_pos return self._thumb_pos
@@ -1,5 +1,8 @@
from __future__ import annotations
from qtpy.QtCore import Signal
from qtpy.QtGui import QColor from qtpy.QtGui import QColor
from qtpy.QtWidgets import QPushButton from qtpy.QtWidgets import QColorDialog, QPushButton
from bec_widgets import BECWidget, SafeProperty, SafeSlot from bec_widgets import BECWidget, SafeProperty, SafeSlot
@@ -12,6 +15,8 @@ class ColorButtonNative(BECWidget, QPushButton):
to guarantee good readability. to guarantee good readability.
""" """
color_changed = Signal(str)
RPC = False RPC = False
PLUGIN = True PLUGIN = True
ICON_NAME = "colors" ICON_NAME = "colors"
@@ -25,9 +30,10 @@ class ColorButtonNative(BECWidget, QPushButton):
""" """
super().__init__(parent=parent, **kwargs) super().__init__(parent=parent, **kwargs)
self.set_color(color) self.set_color(color)
self.clicked.connect(self._open_color_dialog)
@SafeSlot() @SafeSlot()
def set_color(self, color): def set_color(self, color: str | QColor):
"""Set the button's color and update its appearance. """Set the button's color and update its appearance.
Args: Args:
@@ -38,6 +44,7 @@ class ColorButtonNative(BECWidget, QPushButton):
else: else:
self._color = color self._color = color
self._update_appearance() self._update_appearance()
self.color_changed.emit(self._color)
@SafeProperty("QColor") @SafeProperty("QColor")
def color(self): def color(self):
@@ -56,3 +63,11 @@ class ColorButtonNative(BECWidget, QPushButton):
text_color = "#000000" if brightness > 0.5 else "#FFFFFF" text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
self.setStyleSheet(f"background-color: {self._color}; color: {text_color};") self.setStyleSheet(f"background-color: {self._color}; color: {text_color};")
self.setText(self._color) self.setText(self._color)
@SafeSlot()
def _open_color_dialog(self):
"""Open a QColorDialog and apply the selected color."""
current_color = QColor(self._color)
chosen_color = QColorDialog.getColor(current_color, self, "Select Curve Color")
if chosen_color.isValid():
self.set_color(chosen_color)
@@ -0,0 +1,94 @@
# 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.
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "bec_widgets" name = "bec_widgets"
version = "2.10.1" version = "2.21.2"
description = "BEC Widgets" description = "BEC Widgets"
requires-python = ">=3.10" requires-python = ">=3.10"
classifiers = [ classifiers = [
@@ -13,8 +13,8 @@ classifiers = [
"Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering",
] ]
dependencies = [ dependencies = [
"bec_ipython_client>=2.21.4, <=4.0", # needed for jupyter console "bec_ipython_client>=3.42.4, <=4.0", # needed for jupyter console
"bec_lib>=3.29, <=4.0", "bec_lib>=3.44, <=4.0",
"bec_qthemes~=0.7, >=0.7", "bec_qthemes~=0.7, >=0.7",
"black~=25.0", # needed for bw-generate-cli "black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli "isort~=5.13, >=5.13.2", # needed for bw-generate-cli
-1
View File
@@ -51,7 +51,6 @@ def test_rpc_add_dock_with_plots_e2e(qtbot, bec_client_lib, connected_client_gui
# Waii until docks are registered # Waii until docks are registered
qtbot.waitUntil(check_docks_registered, timeout=5000) qtbot.waitUntil(check_docks_registered, timeout=5000)
qtbot.wait(500)
assert len(dock.panels) == 3 assert len(dock.panels) == 3
assert hasattr(gui.bec, "dock_0") assert hasattr(gui.bec, "dock_0")
+9 -2
View File
@@ -79,7 +79,7 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
gui = connected_client_gui_obj gui = connected_client_gui_obj
dock_area = gui.bec dock_area = gui.bec
# Number of top level widgets, should be 4 # Number of top level widgets, should be 4
top_level_widgets_count = 4 top_level_widgets_count = 12
assert len(gui._server_registry) == top_level_widgets_count assert len(gui._server_registry) == top_level_widgets_count
# Number of widgets with parent_id == None, should be 2 # Number of widgets with parent_id == None, should be 2
widgets = [ widgets = [
@@ -145,7 +145,14 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
# Check that the number of top level widgets is still the same. As the cleanup is done by the # Check that the number of top level widgets is still the same. As the cleanup is done by the
# qt event loop, we need to wait for the qtbot to finish the cleanup # qt event loop, we need to wait for the qtbot to finish the cleanup
qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count) try:
qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count)
except Exception as exc:
raise RuntimeError(
f"Widget {object_name} was not removed properly. The number of top level widgets "
f"is {len(gui._server_registry)} instead of {top_level_widgets_count}. The following "
f"widgets are still registered: {list(gui._server_registry.keys())}."
) from exc
# Number of widgets with parent_id == None, should be 2 # Number of widgets with parent_id == None, should be 2
widgets = [ widgets = [
widget widget
+1 -1
View File
@@ -69,7 +69,7 @@ def test_scan_metadata_for_custom_scan(
def do_test(): def do_test():
# Set the metadata # Set the metadata
grid: QGridLayout = scan_control._metadata_form._form_grid.layout() grid: QGridLayout = scan_control._metadata_form._form_grid.layout()
for i in range(grid.rowCount()): # type: ignore for i in range(grid.rowCount() - 1): # type: ignore
field_name = grid.itemAtPosition(i, 0).widget().property("_model_field_name") field_name = grid.itemAtPosition(i, 0).widget().property("_model_field_name")
if (value_to_set := md.pop(field_name, None)) is not None: if (value_to_set := md.pop(field_name, None)) is not None:
grid.itemAtPosition(i, 1).widget().setValue(value_to_set) grid.itemAtPosition(i, 1).widget().setValue(value_to_set)
@@ -12,12 +12,11 @@ may not be created immediately after the rpc call is made.
from __future__ import annotations from __future__ import annotations
import random import random
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING
import numpy as np import numpy as np
import pytest import pytest
from bec_widgets.cli.client import BECDockArea
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
PYTEST_TIMEOUT = 50 PYTEST_TIMEOUT = 50
@@ -321,20 +320,20 @@ def test_widgets_e2e_signal_combobox(qtbot, connected_client_gui_obj, random_gen
gui = connected_client_gui_obj gui = connected_client_gui_obj
bec = gui._client bec = gui._client
# Create dock_area, dock, widget # Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox) _, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
dock: client.BECDock
widget: client.SignalComboBox widget: client.SignalComboBox
widget.set_device("samx") widget.set_device("samx")
info = bec.device_manager.devices.samx._info["signals"]
assert widget.signals == [ assert widget.signals == [
"readback", ["samx (readback)", info.get("readback")],
"setpoint", ["setpoint", info.get("setpoint")],
"motor_is_moving", ["motor_is_moving", info.get("motor_is_moving")],
"velocity", ["velocity", info.get("velocity")],
"acceleration", ["acceleration", info.get("acceleration")],
"tolerance", ["tolerance", info.get("tolerance")],
] ]
widget.set_signal("readback") widget.set_signal("samx (readback)")
# Test removing the widget, or leaving it open for the next test # Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
+13 -8
View File
@@ -168,11 +168,16 @@ def test_accept_changes(axis_settings_fixture, qtbot):
axis_settings.ui.x_grid.checked = True axis_settings.ui.x_grid.checked = True
axis_settings.accept_changes() axis_settings.accept_changes()
qtbot.wait(200) qtbot.waitUntil(
lambda: all(
assert plot_base.title == "New Title" [
assert plot_base.x_min == 10 plot_base.title == "New Title",
assert plot_base.x_max == 20 plot_base.x_min == 10,
assert plot_base.x_label == "New X Label" plot_base.x_max == 20,
assert plot_base.x_log is True plot_base.x_label == "New X Label",
assert plot_base.x_grid is True plot_base.x_log is True,
plot_base.x_grid is True,
]
),
timeout=200,
)
+5 -8
View File
@@ -47,24 +47,21 @@ def test_bec_dock_area_add_remove_dock(bec_dock_area, qtbot):
# Remove docks # Remove docks
d0_name = d0.name() d0_name = d0.name()
bec_dock_area.delete(d0_name) bec_dock_area.delete(d0_name)
qtbot.wait(200)
d1.remove() d1.remove()
qtbot.wait(200)
assert len(bec_dock_area.dock_area.docks) == initial_count + 1 qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == initial_count + 1, timeout=200)
assert d0.name() not in dict(bec_dock_area.dock_area.docks) assert d0.name() not in dict(bec_dock_area.dock_area.docks)
assert d1.name() not in dict(bec_dock_area.dock_area.docks) assert d1.name() not in dict(bec_dock_area.dock_area.docks)
assert d2.name() in dict(bec_dock_area.dock_area.docks) assert d2.name() in dict(bec_dock_area.dock_area.docks)
def test_close_docks(bec_dock_area, qtbot): def test_close_docks(bec_dock_area, qtbot):
d0 = bec_dock_area.new(name="dock_0") _ = bec_dock_area.new(name="dock_0")
d1 = bec_dock_area.new(name="dock_1") _ = bec_dock_area.new(name="dock_1")
d2 = bec_dock_area.new(name="dock_2") _ = bec_dock_area.new(name="dock_2")
bec_dock_area.delete_all() bec_dock_area.delete_all()
qtbot.wait(200) qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == 0)
assert len(bec_dock_area.dock_area.docks) == 0
def test_undock_and_dock_docks(bec_dock_area, qtbot): def test_undock_and_dock_docks(bec_dock_area, qtbot):
+24 -1
View File
@@ -1,7 +1,10 @@
import numpy as np import numpy as np
import pytest import pytest
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
BECProgressBar,
ProgressState,
)
@pytest.fixture @pytest.fixture
@@ -33,3 +36,23 @@ def test_progressbar_label(progressbar):
progressbar.label_template = "Test: $value" progressbar.label_template = "Test: $value"
progressbar.set_value(50) progressbar.set_value(50)
assert progressbar.center_label.text() == "Test: 50" assert progressbar.center_label.text() == "Test: 50"
def test_progress_state_from_bec_status():
"""ProgressState.from_bec_status() maps BEC literals correctly."""
mapping = {
"open": ProgressState.NORMAL,
"paused": ProgressState.PAUSED,
"aborted": ProgressState.INTERRUPTED,
"halted": ProgressState.PAUSED,
"closed": ProgressState.COMPLETED,
"UNKNOWN": ProgressState.NORMAL, # fallback
}
for text, expected in mapping.items():
assert ProgressState.from_bec_status(text) is expected
def test_progressbar_state_setter(progressbar):
"""Setting .state reflects internally."""
progressbar.state = ProgressState.PAUSED
assert progressbar.state is ProgressState.PAUSED
@@ -0,0 +1,87 @@
from qtpy.QtCore import Qt
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QColorDialog
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
ColorButtonNative,
)
from .conftest import create_widget
def test_color_button_native(qtbot):
cb = create_widget(qtbot, ColorButtonNative)
# Check if the instance is created successfully
assert cb is not None
# Check if the button has a default color
assert cb.color is not None
# Check if the button can change color
new_color = QColor(255, 0, 0) # Red
cb.set_color(new_color)
assert cb.color == new_color.name()
def test_color_dialog_applies_chosen_color(qtbot, monkeypatch):
"""Clicking the button should open the dialog and apply the selected color."""
cb = create_widget(qtbot, ColorButtonNative)
chosen_color = QColor(0, 255, 0) # Green
# Force QColorDialog.getColor to return our chosen color
monkeypatch.setattr(QColorDialog, "getColor", lambda *args, **kwargs: chosen_color)
# Expect the color_changed signal during the click
with qtbot.waitSignal(cb.color_changed, timeout=1000):
qtbot.mouseClick(cb, Qt.LeftButton)
assert cb.color == chosen_color.name()
def test_color_dialog_cancel_keeps_color(qtbot, monkeypatch):
"""If the dialog returns an invalid color, the button color should stay the same."""
cb = create_widget(qtbot, ColorButtonNative)
original_color = cb.color
# Simulate cancel: return an invalid QColor
monkeypatch.setattr(QColorDialog, "getColor", lambda *args, **kwargs: QColor())
qtbot.mouseClick(cb, Qt.LeftButton)
# No signal emitted, color unchanged
assert cb.color == original_color
# Additional tests for color property getter/setter
def test_color_property_getter_setter_hex(qtbot):
"""Verify the color property works correctly with hex strings."""
cb = create_widget(qtbot, ColorButtonNative)
# Confirm default value is a valid hex string
default_color = cb.color
assert (
isinstance(default_color, str) and default_color.startswith("#") and len(default_color) == 7
)
# Use property setter with a new hex color
new_color_hex = "#123456"
with qtbot.waitSignal(cb.color_changed, timeout=1000):
cb.color = new_color_hex
# Getter should reflect the new value
assert cb.color == new_color_hex
# Button text should update as well
assert cb.text() == new_color_hex
def test_color_property_setter_qcolor(qtbot):
"""Verify the color property accepts QColor and emits the signal."""
cb = create_widget(qtbot, ColorButtonNative)
q_color = QColor(200, 100, 50)
with qtbot.waitSignal(cb.color_changed, timeout=1000):
cb.color = q_color
assert cb.color == q_color.name()
assert cb.text() == q_color.name()
+2 -2
View File
@@ -29,7 +29,6 @@ def image_widget_with_crosshair(qtbot):
image_item = pg.ImageItem() image_item = pg.ImageItem()
image_item.setImage(np.random.rand(100, 100)) image_item.setImage(np.random.rand(100, 100))
image_item.config = type("obj", (object,), {"monitor": "test"})
widget.addItem(image_item) widget.addItem(image_item)
plot_item = widget.getPlotItem() plot_item = widget.getPlotItem()
@@ -99,6 +98,7 @@ def test_mouse_moved_signals_outside(plot_widget_with_crosshair):
def test_mouse_moved_signals_2D(image_widget_with_crosshair): def test_mouse_moved_signals_2D(image_widget_with_crosshair):
crosshair, plot_item = image_widget_with_crosshair crosshair, plot_item = image_widget_with_crosshair
image_item = plot_item.items[0]
emitted_values_2D = [] emitted_values_2D = []
@@ -113,7 +113,7 @@ def test_mouse_moved_signals_2D(image_widget_with_crosshair):
crosshair.mouse_moved(event_mock) crosshair.mouse_moved(event_mock)
assert emitted_values_2D == [("test", 21, 55)] assert emitted_values_2D == [(str(id(image_item)), 21, 55)]
def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair): def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair):
+3 -3
View File
@@ -93,7 +93,7 @@ def test_curve_setting_switch_device_mode(curve_setting_fixture, qtbot):
assert curve_setting.device_x.isEnabled() assert curve_setting.device_x.isEnabled()
# This line edit should reflect the waveform.x_axis_mode["name"], or be blank if none # This line edit should reflect the waveform.x_axis_mode["name"], or be blank if none
assert curve_setting.device_x.text() == wf.x_axis_mode["name"] assert curve_setting.device_x.currentText() == ""
def test_curve_setting_refresh(curve_setting_fixture, qtbot): def test_curve_setting_refresh(curve_setting_fixture, qtbot):
@@ -127,8 +127,8 @@ def test_change_device_from_target_widget(curve_setting_fixture, qtbot):
assert curve_setting.mode_combo.currentText() == "device" assert curve_setting.mode_combo.currentText() == "device"
assert curve_setting.device_x.isEnabled() assert curve_setting.device_x.isEnabled()
assert curve_setting.device_x.text() == wf.x_axis_mode["name"] assert curve_setting.device_x.currentText() == wf.x_axis_mode["name"]
assert curve_setting.signal_x.text() == wf.x_axis_mode["entry"] assert curve_setting.signal_x.currentText() == f"{wf.x_axis_mode['entry']} (readback)"
################################################## ##################################################
+63 -14
View File
@@ -3,20 +3,31 @@ from unittest import mock
import pytest import pytest
from qtpy.QtCore import QPoint, Qt from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QTabWidget
from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
from .client_mocks import mocked_client from .client_mocks import mocked_client
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QListWidgetItem from qtpy.QtWidgets import QListWidgetItem
from bec_widgets.widgets.services.device_browser import DeviceItem from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
# pylint: disable=no-member
# pylint: disable=missing-function-docstring
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
@pytest.fixture @pytest.fixture
def device_browser(qtbot, mocked_client): def device_browser(qtbot, mocked_client):
dev_browser = DeviceBrowser(client=mocked_client) dev_browser = DeviceBrowser(client=mocked_client)
dev_browser.dev["samx"].read_configuration = mock.MagicMock()
qtbot.addWidget(dev_browser) qtbot.addWidget(dev_browser)
qtbot.waitExposed(dev_browser) qtbot.waitExposed(dev_browser)
yield dev_browser yield dev_browser
@@ -30,22 +41,24 @@ def test_device_browser_init_with_devices(device_browser):
assert device_list.count() == len(device_browser.dev) assert device_list.count() == len(device_browser.dev)
def test_device_browser_filtering(qtbot, device_browser): @pytest.mark.parametrize(
["search_term", "expected_num_visible"],
[("sam", 3), ("nonexistent", 0), ("", -1), (r"(\)", -1)],
)
def test_device_browser_filtering(
qtbot, device_browser, search_term: str, expected_num_visible: int
):
""" """
Test that the device browser is able to filter the device list. Test that the device browser is able to filter the device list.
""" """
device_list = device_browser.ui.device_list expected = expected_num_visible if expected_num_visible >= 0 else len(device_browser.dev)
device_browser.ui.filter_input.setText("sam")
qtbot.wait(1000)
assert device_list.count() == 3
device_browser.ui.filter_input.setText("nonexistent") def num_visible(item_dict):
qtbot.wait(1000) return len(list(filter(lambda i: not i.isHidden(), item_dict.values())))
assert device_list.count() == 0
device_browser.ui.filter_input.setText("") device_browser.ui.filter_input.setText(search_term)
qtbot.wait(1000) qtbot.wait(100)
assert device_list.count() == len(device_browser.dev) assert num_visible(device_browser._device_items) == expected
def test_device_item_mouse_press_event(device_browser, qtbot): def test_device_item_mouse_press_event(device_browser, qtbot):
@@ -55,7 +68,43 @@ def test_device_item_mouse_press_event(device_browser, qtbot):
# Simulate a left mouse press event on the device item # Simulate a left mouse press event on the device item
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
qtbot.mouseClick(widget.label, Qt.MouseButton.LeftButton) qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton)
def test_update_event_captured(device_browser, qtbot):
device_browser.update_device_list = mock.MagicMock()
device_browser.update_device_list.assert_not_called()
device_browser.on_device_update("remove", {})
device_browser.update_device_list.assert_called_once()
device_browser.on_device_update("", {})
def test_device_item_expansion(device_browser, qtbot):
"""
Test that the form is displayed when the item is expanded, and that the expansion is triggered
by clicking on the expansion button, the title, or the device icon
"""
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
tab_widget: QTabWidget = widget._contents.layout().itemAt(0).widget()
qtbot.waitUntil(lambda: tab_widget.widget(0) is not None, timeout=100)
qtbot.waitUntil(
lambda: isinstance(tab_widget.widget(0).layout().itemAt(0).widget(), DeviceConfigForm),
timeout=100,
)
form = tab_widget.widget(0).layout().itemAt(0).widget()
assert widget.expanded
assert (name_field := form.widget_dict.get("name")) is not None
qtbot.waitUntil(lambda: name_field.getValue() == "samx", timeout=500)
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
assert not widget.expanded
qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton)
qtbot.waitUntil(lambda: widget.expanded, timeout=500)
qtbot.mouseClick(widget._title_icon, Qt.MouseButton.LeftButton)
qtbot.waitUntil(lambda: not widget.expanded, timeout=500)
def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qtbot): def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qtbot):
@@ -67,7 +116,7 @@ def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qt
device_name = widget.device device_name = widget.device
with mock.patch("qtpy.QtGui.QDrag.exec_") as mock_exec: with mock.patch("qtpy.QtGui.QDrag.exec_") as mock_exec:
with mock.patch("qtpy.QtGui.QDrag.setMimeData") as mock_set_mimedata: with mock.patch("qtpy.QtGui.QDrag.setMimeData") as mock_set_mimedata:
qtbot.mousePress(widget.label, Qt.MouseButton.LeftButton, pos=QPoint(0, 0)) qtbot.mousePress(widget._title, Qt.MouseButton.LeftButton, pos=QPoint(0, 0))
qtbot.mouseMove(widget, pos=QPoint(10, 10)) qtbot.mouseMove(widget, pos=QPoint(10, 10))
qtbot.mouseRelease(widget, Qt.MouseButton.LeftButton) qtbot.mouseRelease(widget, Qt.MouseButton.LeftButton)
mock_set_mimedata.assert_called_once() mock_set_mimedata.assert_called_once()
@@ -0,0 +1,98 @@
from unittest.mock import MagicMock, patch
import pytest
from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
DeviceConfigDialog,
)
_BASIC_CONFIG = {
"name": "test_device",
"enabled": True,
"deviceClass": "TestDevice",
"readoutPriority": "monitored",
}
@pytest.fixture
def dialog(qtbot):
"""Fixture to create a DeviceConfigDialog instance."""
mock_device = MagicMock(_config=DeviceConfigModel.model_validate(_BASIC_CONFIG).model_dump())
mock_client = MagicMock()
mock_client.device_manager.devices = {"test_device": mock_device}
dialog = DeviceConfigDialog(device="test_device", config_helper=MagicMock(), client=mock_client)
qtbot.addWidget(dialog)
return dialog
def test_initialization(dialog):
assert dialog._device == "test_device"
assert dialog._container.count() == 2
def test_fill_form(dialog):
with patch.object(dialog._form, "set_data") as mock_set_data:
dialog._fill_form()
mock_set_data.assert_called_once_with(DeviceConfigModel.model_validate(_BASIC_CONFIG))
def test_updated_config(dialog):
"""Test that updated_config returns the correct changes."""
dialog._initial_config = {"key1": "value1", "key2": "value2"}
with patch.object(
dialog._form, "get_form_data", return_value={"key1": "value1", "key2": "new_value"}
):
updated = dialog.updated_config()
assert updated == {"key2": "new_value"}
def test_apply(dialog):
with patch.object(dialog, "_process_update_action") as mock_process_update:
dialog.apply()
mock_process_update.assert_called_once()
def test_accept(dialog):
with (
patch.object(dialog, "_process_update_action") as mock_process_update,
patch("qtpy.QtWidgets.QDialog.accept") as mock_parent_accept,
):
dialog.accept()
mock_process_update.assert_called_once()
mock_parent_accept.assert_called_once()
def test_waiting_display(dialog, qtbot):
with (
patch.object(dialog._spinner, "start") as mock_spinner_start,
patch.object(dialog._spinner, "stop") as mock_spinner_stop,
):
dialog.show()
dialog._start_waiting_display()
qtbot.waitUntil(dialog._overlay_widget.isVisible, timeout=100)
mock_spinner_start.assert_called_once()
mock_spinner_stop.assert_not_called()
dialog._stop_waiting_display()
qtbot.waitUntil(lambda: not dialog._overlay_widget.isVisible(), timeout=100)
mock_spinner_stop.assert_called_once()
def test_update_cycle(dialog, qtbot):
update = {"enabled": False, "readoutPriority": "baseline", "deviceTags": {"tag"}}
def _mock_send(action="update", config=None, wait_for_response=True, timeout_s=None):
dialog.client.device_manager.devices["test_device"]._config = config["test_device"] # type: ignore
dialog._config_helper.send_config_request = MagicMock(side_effect=_mock_send)
for item in dialog._form.enumerate_form_widgets():
if (val := update.get(item.label.property("_model_field_name"))) is not None:
item.widget.setValue(val)
assert dialog.updated_config() == update
dialog.apply()
qtbot.waitUntil(lambda: dialog._config_helper.send_config_request.call_count == 1, timeout=100)
dialog._config_helper.send_config_request.assert_called_with(
action="update", config={"test_device": update}, wait_for_response=False
)
+22 -17
View File
@@ -94,18 +94,6 @@ def test_device_signal_qproperties(device_signal_base):
assert device_signal_base._signal_filter == {Kind.config, Kind.normal} assert device_signal_base._signal_filter == {Kind.config, Kind.normal}
def test_device_signal_set_device(device_signal_base):
"""Test if the set_device method works correctly"""
device_signal_base.include_hinted_signals = True
device_signal_base.set_device("samx")
assert device_signal_base.device == "samx"
assert device_signal_base.signals == ["readback"]
device_signal_base.include_normal_signals = True
assert device_signal_base.signals == ["readback", "setpoint"]
device_signal_base.include_config_signals = True
assert device_signal_base.signals == ["readback", "setpoint", "velocity"]
def test_signal_combobox(qtbot, device_signal_combobox): def test_signal_combobox(qtbot, device_signal_combobox):
"""Test the signal_combobox""" """Test the signal_combobox"""
container = [] container = []
@@ -120,17 +108,25 @@ def test_signal_combobox(qtbot, device_signal_combobox):
device_signal_combobox.include_config_signals = True device_signal_combobox.include_config_signals = True
assert device_signal_combobox.signals == [] assert device_signal_combobox.signals == []
device_signal_combobox.set_device("samx") device_signal_combobox.set_device("samx")
assert device_signal_combobox.signals == ["readback", "setpoint", "velocity"] samx = device_signal_combobox.dev.samx
assert device_signal_combobox.signals == [
("samx (readback)", samx._info["signals"].get("readback")),
("setpoint", samx._info["signals"].get("setpoint")),
("velocity", samx._info["signals"].get("velocity")),
]
qtbot.wait(100) qtbot.wait(100)
assert container == ["samx"] assert container == ["samx (readback)"]
# Set the type of class from the FakeDevice to Signal # Set the type of class from the FakeDevice to Signal
fake_signal = FakeSignal(name="fake_signal") fake_signal = FakeSignal(name="fake_signal", info={"device_info": {"signals": {}}})
device_signal_combobox.client.device_manager.add_devices([fake_signal]) device_signal_combobox.client.device_manager.add_devices([fake_signal])
device_signal_combobox.set_device("fake_signal") device_signal_combobox.set_device("fake_signal")
assert device_signal_combobox.signals == ["fake_signal"] fake_signal = device_signal_combobox.dev.fake_signal
assert device_signal_combobox.signals == [
("fake_signal", fake_signal._info["signals"].get("fake_signal", {}))
]
assert device_signal_combobox._config_signals == [] assert device_signal_combobox._config_signals == []
assert device_signal_combobox._normal_signals == [] assert device_signal_combobox._normal_signals == []
assert device_signal_combobox._hinted_signals == ["fake_signal"] assert device_signal_combobox._hinted_signals == [("fake_signal", {})]
def test_signal_lineedit(device_signal_line_edit): def test_signal_lineedit(device_signal_line_edit):
@@ -148,3 +144,12 @@ def test_signal_lineedit(device_signal_line_edit):
assert device_signal_line_edit._is_valid_input is True assert device_signal_line_edit._is_valid_input is True
device_signal_line_edit.setText("invalid") device_signal_line_edit.setText("invalid")
assert device_signal_line_edit._is_valid_input is False assert device_signal_line_edit._is_valid_input is False
def test_device_signal_input_base_cleanup(qtbot, mocked_client):
widget = DeviceInputWidget(client=mocked_client)
widget.close()
widget.deleteLater()
mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register)
+11 -1
View File
@@ -48,12 +48,22 @@ class MyWidget(QWidget):
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
class MyWidget(QWidget): class MyWidget(QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super(QWidget, self).__init__(parent)""" super(QWidget, self).__init__(parent)
""",
""" """
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
class MyWidget(QWidget): class MyWidget(QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super(QWidget, self).__init__(parent=parent) super(QWidget, self).__init__(parent=parent)
""",
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super(QWidget, self).__init__(
parent=parent,
other=arguments,
)
""", """,
] ]
) )
@@ -0,0 +1,76 @@
from decimal import Decimal
import pytest
from pydantic import BaseModel, Field
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, TypedForm
from bec_widgets.utils.forms_from_types.items import FloatDecimalFormItem, IntFormItem, StrFormItem
# pylint: disable=no-member
# pylint: disable=missing-function-docstring
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
class ExampleSchema(BaseModel):
str_optional: str | None = Field(
None, title="Optional string", description="an optional string", max_length=23
)
str_required: str
bool_optional: bool | None = Field(None)
bool_required_default: bool = Field(True)
bool_required_nodefault: bool = Field()
int_default: int = Field(123)
int_nodefault_optional: int | None = Field(lt=-1, ge=-44)
float_nodefault: float
decimal_dp_limits_nodefault: Decimal = Field(decimal_places=2, gt=1, le=34.5)
TEST_DICT = {
"sample_name": "test name",
"str_optional": "None",
"str_required": "something",
"bool_optional": None,
"bool_required_default": True,
"bool_required_nodefault": False,
"int_default": 21,
"int_nodefault_optional": -10,
"float_nodefault": 123.456,
"decimal_dp_limits_nodefault": 34.5,
}
@pytest.fixture
def example_md():
return ExampleSchema.model_validate(TEST_DICT)
@pytest.fixture
def model_widget(qtbot):
widget = PydanticModelForm(data_model=ExampleSchema)
widget.populate()
qtbot.addWidget(widget)
yield widget
def test_widget_dict(model_widget: PydanticModelForm):
assert isinstance(model_widget.widget_dict["str_optional"], StrFormItem)
assert isinstance(model_widget.widget_dict["float_nodefault"], FloatDecimalFormItem)
assert isinstance(model_widget.widget_dict["int_default"], IntFormItem)
def test_widget_set_data(model_widget: PydanticModelForm):
data = ExampleSchema.model_validate(TEST_DICT)
model_widget.set_data(data)
for key in [
"str_optional",
"str_required",
"bool_optional",
"bool_required_default",
"bool_required_nodefault",
"int_default",
"int_nodefault_optional",
"float_nodefault",
"decimal_dp_limits_nodefault",
]:
assert model_widget.widget_dict[key].getValue() == TEST_DICT[key]
@@ -0,0 +1,123 @@
import sys
from typing import Any, Literal, get_args
import pytest
from pydantic import ValidationError
from pydantic.fields import FieldInfo
from bec_widgets.utils.forms_from_types.items import FormItemSpec, ListFormItem
from bec_widgets.utils.widget_io import WidgetIO
@pytest.mark.skipif(sys.version_info < (3, 11), reason="Generic types don't support this in 3.10")
@pytest.mark.parametrize(
["input", "validity"],
[
({}, False),
({"item_type": int, "name": "test", "info": FieldInfo(), "pretty_display": True}, True),
(
{
"item_type": dict[dict, dict],
"name": "test",
"info": FieldInfo(),
"pretty_display": True,
},
False,
),
(
{
"item_type": dict[str, str],
"name": "test",
"info": FieldInfo(),
"pretty_display": True,
},
True,
),
(
{
"item_type": Literal["a", "b"],
"name": "test",
"info": FieldInfo(),
"pretty_display": True,
},
True,
),
(
{
"item_type": Literal["a", 2],
"name": "test",
"info": FieldInfo(),
"pretty_display": True,
},
False,
),
],
)
def test_form_item_spec(input, validity):
if validity:
assert FormItemSpec.model_validate(input)
else:
with pytest.raises(ValidationError):
FormItemSpec.model_validate(input)
@pytest.fixture(
params=[
{"type": list[int], "value": [1, 2, 3], "extra": 79},
{"type": list[str], "value": ["a", "b", "c"], "extra": "string"},
{"type": list[float], "value": [0.1, 0.2, 0.3], "extra": 79.0},
]
)
def list_field_and_values(request, qtbot):
itype, vals, extra = (
request.param.get("type"),
request.param.get("value"),
request.param.get("extra"),
)
spec = FormItemSpec(item_type=itype, name="test_list", info=FieldInfo(annotation=itype))
(widget := ListFormItem(parent=None, spec=spec)).setValue(vals)
qtbot.addWidget(widget)
yield widget, vals, extra, get_args(itype)[0]
def test_list_metadata_field(list_field_and_values: tuple[ListFormItem, list, Any, type]):
list_field, vals, extra, _ = list_field_and_values
assert list_field.getValue() == vals
assert list_field._main_widget.count() == 3
list_field._add_button.click()
assert len(list_field.getValue()) == 4
assert list_field._main_widget.count() == 4
list_field._main_widget.setCurrentRow(-1)
list_field._remove_button.click()
assert len(list_field.getValue()) == 4
assert list_field._main_widget.count() == 4
list_field._main_widget.setCurrentRow(2)
list_field._remove_button.click()
assert list_field.getValue() == vals[:2] + [list_field._types.default]
assert list_field._main_widget.count() == 3
list_field._main_widget.setCurrentRow(1)
WidgetIO.set_value(list_field._main_widget.itemWidget(list_field._main_widget.item(1)), extra)
assert list_field._main_widget.count() == 3
assert list_field.getValue() == [vals[0], extra, list_field._types.default]
list_field._add_data_item(extra)
assert list_field._main_widget.count() == 4
assert list_field.getValue() == [vals[0], extra, list_field._types.default, extra]
def test_list_field_value_acceptance(list_field_and_values: tuple[ListFormItem, list, Any, type]):
class _WrongType(object): ...
list_field, _, _, t = list_field_and_values
list_field.setValue([])
assert list_field._main_widget.count() == 0
list_field.setValue([t(), t(), t()])
assert list_field._main_widget.count() == 3
with pytest.raises(ValueError) as e:
list_field.setValue([_WrongType()])
assert list_field._main_widget.count() == 3
assert e.match(f"This widget only accepts items of type {t}")
+80
View File
@@ -0,0 +1,80 @@
from unittest import mock
import pyqtgraph as pg
import pytest
from bec_widgets.widgets.plots.image.image_base import ImageLayerManager
from bec_widgets.widgets.plots.image.image_item import ImageItem
@pytest.fixture()
def image_layer_manager():
"""Fixture to create an instance of ImageLayer."""
layer = ImageLayerManager(parent=None, plot_item=mock.MagicMock(spec=pg.PlotItem))
yield layer
layer.clear()
def test_image_layer_manager_initialization(image_layer_manager):
"""Test the initialization of the ImageLayer."""
assert isinstance(image_layer_manager, ImageLayerManager)
assert image_layer_manager.plot_item is not None
def test_add_image_layer(image_layer_manager):
"""Test adding an image layer to the ImageLayerManager."""
layer = image_layer_manager.add(name="Test Layer")
assert layer.image.zValue() == 0
layer2 = image_layer_manager.add(name="Test Layer 2")
assert layer2.image.zValue() == 1
layer3 = image_layer_manager.add(name="Test Layer 3", z_position="top")
assert layer3.image.zValue() == 2
layer4 = image_layer_manager.add(name="Test Layer 4", z_position="bottom")
assert layer4.image.zValue() == -1
def test_remove_image_layer(image_layer_manager):
"""Test removing an image layer from the ImageLayerManager."""
layer = image_layer_manager.add(name="Test Layer")
assert len(image_layer_manager) == 1
image_layer_manager.remove(layer)
assert len(image_layer_manager) == 0
def test_clear_image_layers(image_layer_manager):
"""Test clearing all image layers from the ImageLayerManager."""
layer = image_layer_manager.add(name="Test Layer")
assert len(image_layer_manager) == 1
image_layer_manager.clear()
assert len(image_layer_manager) == 0
def test_image_layer_manager_getitem(image_layer_manager):
"""Test getting an image layer by index."""
layer = image_layer_manager.add(name="Test Layer")
assert image_layer_manager["Test Layer"] == layer
with pytest.raises(TypeError):
_ = image_layer_manager[1]
image_layer_manager.remove("Test Layer")
assert len(image_layer_manager) == 0
def test_image_layer_iteration(image_layer_manager):
"""Test iterating over image layers."""
layer = image_layer_manager.add(name="Test Layer")
assert list(image_layer_manager) == [layer]
layer2 = image_layer_manager.add(name="Test Layer 2")
assert list(image_layer_manager) == [layer, layer2]
layer3 = image_layer_manager.add()
assert list(image_layer_manager) == [layer, layer2, layer3]
names = list(image_layer_manager.layers.keys())
assert names == ["Test Layer", "Test Layer 2", "image_layer_0"]
+76 -12
View File
@@ -120,11 +120,11 @@ def test_roi_name_edit(roi_tree, image_widget, qtbot):
roi_tree.tree.editItem(item, roi_tree.COL_ROI) roi_tree.tree.editItem(item, roi_tree.COL_ROI)
qtbot.keyClicks(roi_tree.tree.viewport().focusWidget(), "new_name") qtbot.keyClicks(roi_tree.tree.viewport().focusWidget(), "new_name")
qtbot.keyClick(roi_tree.tree.viewport().focusWidget(), Qt.Key_Return) qtbot.keyClick(roi_tree.tree.viewport().focusWidget(), Qt.Key_Return)
qtbot.wait(200)
# Check the ROI name was updated # Check the ROI name was updated
assert roi.label == "new_name" qtbot.waitUntil(
assert item.text(roi_tree.COL_ROI) == "new_name" lambda: all([roi.label == "new_name", item.text(roi_tree.COL_ROI) == "new_name"]),
timeout=200,
)
def test_roi_width_edit(roi_tree, image_widget, qtbot): def test_roi_width_edit(roi_tree, image_widget, qtbot):
@@ -138,9 +138,8 @@ def test_roi_width_edit(roi_tree, image_widget, qtbot):
# Change the width # Change the width
width_spin.setValue(25) width_spin.setValue(25)
qtbot.wait(200)
# Check the ROI width was updated # Check the ROI width was updated
assert roi.line_width == 25 qtbot.waitUntil(lambda: roi.line_width == 25, timeout=200)
def test_delete_roi_button(roi_tree, image_widget, qtbot): def test_delete_roi_button(roi_tree, image_widget, qtbot):
@@ -148,16 +147,17 @@ def test_delete_roi_button(roi_tree, image_widget, qtbot):
roi = image_widget.add_roi(kind="rect", name="to_delete") roi = image_widget.add_roi(kind="rect", name="to_delete")
item = roi_tree.roi_items[roi] item = roi_tree.roi_items[roi]
# Get the delete button action_widget = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
del_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION) layout = action_widget.layout()
# Click the delete button del_btn = layout.itemAt(1).widget()
del_btn.click() del_btn.click()
qtbot.wait(200)
# Verify ROI was removed # Verify ROI was removed
assert roi not in roi_tree.roi_items qtbot.waitUntil(
assert roi not in image_widget.roi_controller.rois lambda: all([roi not in roi_tree.roi_items, roi not in image_widget.roi_controller.rois]),
timeout=200,
)
def test_roi_color_change_from_roi(roi_tree, image_widget): def test_roi_color_change_from_roi(roi_tree, image_widget):
@@ -331,3 +331,67 @@ def test_add_roi_from_toolbar(qtbot, mocked_client):
# Verify it's a circle ROI # Verify it's a circle ROI
assert isinstance(new_roi, CircularROI) assert isinstance(new_roi, CircularROI)
def test_roi_lock_button(roi_tree, image_widget, qtbot):
"""Verify the individual lock button toggles ROI.movable."""
roi = image_widget.add_roi(kind="rect", name="lock_test")
item = roi_tree.roi_items[roi]
# Lock button is the first widget in the Actions layout
action_widget = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
lock_btn = action_widget.layout().itemAt(0).widget()
# Initially unlocked
assert roi.movable
assert not lock_btn.isChecked()
# Lock it
lock_btn.click()
qtbot.wait(200)
assert not roi.movable
assert lock_btn.isChecked()
# Unlock again
lock_btn.click()
qtbot.wait(200)
assert roi.movable
assert not lock_btn.isChecked()
def test_global_lock_all_button(roi_tree, image_widget, qtbot):
"""Verify the toolbar lock-all action locks/unlocks every ROI."""
roi1 = image_widget.add_roi(kind="rect", name="g1")
roi2 = image_widget.add_roi(kind="circle", name="g2")
lock_all = roi_tree.lock_all_action.action
# Start unlocked
assert roi1.movable and roi2.movable
assert not lock_all.isChecked()
# Toggle → lock everything
lock_all.trigger()
qtbot.wait(200)
assert lock_all.isChecked()
assert not roi1.movable and not roi2.movable
# Toggle again → unlock everything
lock_all.trigger()
qtbot.wait(200)
assert not lock_all.isChecked()
assert roi1.movable and roi2.movable
def test_new_roi_respects_global_lock(roi_tree, image_widget, qtbot):
"""When the global lock-all toggle is active, newly added ROIs start locked."""
# Enable global lock
roi_tree.lock_all_action.action.setChecked(True)
qtbot.wait(100)
# Add ROI after lock enabled
roi = image_widget.add_roi(kind="rect", name="new_locked")
assert not roi.movable
# Disable global lock again
roi_tree.lock_all_action.action.setChecked(False)
+41 -5
View File
@@ -6,16 +6,21 @@ import numpy as np
import pytest import pytest
from bec_widgets.widgets.plots.image.image import Image from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI, ROIController from bec_widgets.widgets.plots.roi.image_roi import (
CircularROI,
EllipticalROI,
RectangularROI,
ROIController,
)
from tests.unit_tests.client_mocks import mocked_client from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget from tests.unit_tests.conftest import create_widget
@pytest.fixture(params=["rect", "circle"]) @pytest.fixture(params=["rect", "circle", "ellipse"])
def bec_image_widget_with_roi(qtbot, request, mocked_client): def bec_image_widget_with_roi(qtbot, request, mocked_client):
"""Return (widget, roi, shape_label) for each ROI class.""" """Return (widget, roi, shape_label) for each ROI class."""
roi_type: Literal["rect", "circle"] = request.param roi_type: Literal["rect", "circle", "ellipse"] = request.param
# Build an Image widget with a trivial 100×100 zeros array # Build an Image widget with a trivial 100×100 zeros array
widget: Image = create_widget(qtbot, Image, client=mocked_client) widget: Image = create_widget(qtbot, Image, client=mocked_client)
@@ -39,7 +44,12 @@ def test_default_properties(bec_image_widget_with_roi):
assert roi.line_width == 5 assert roi.line_width == 5
# concrete subclass type # concrete subclass type
assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI) if roi_type == "rect":
assert isinstance(roi, RectangularROI)
elif roi_type == "circle":
assert isinstance(roi, CircularROI)
elif roi_type == "ellipse":
assert isinstance(roi, EllipticalROI)
def test_coordinate_structures(bec_image_widget_with_roi): def test_coordinate_structures(bec_image_widget_with_roi):
@@ -98,7 +108,7 @@ def test_color_uniqueness_across_multiple_rois(qtbot, mocked_client):
widget: Image = create_widget(qtbot, Image, client=mocked_client) widget: Image = create_widget(qtbot, Image, client=mocked_client)
# add two of each ROI type # add two of each ROI type
for _kind in ("rect", "circle"): for _kind in ("rect", "circle", "ellipse"):
widget.add_roi(kind=_kind) widget.add_roi(kind=_kind)
widget.add_roi(kind=_kind) widget.add_roi(kind=_kind)
@@ -205,3 +215,29 @@ def test_roi_set_position(bec_image_widget_with_roi):
pos = roi.pos() pos = roi.pos()
assert int(pos.x()) == 10 assert int(pos.x()) == 10
assert int(pos.y()) == 15 assert int(pos.y()) == 15
def test_roi_movable_property(bec_image_widget_with_roi, qtbot):
"""Verify BaseROI.movable toggles flags, handles, and emits a signal."""
_widget, roi, _ = bec_image_widget_with_roi
# defaults ROI is movable
assert roi.movable
assert roi.translatable and roi.rotatable and roi.resizable and roi.removable
assert len(roi.handles) > 0
# lock it
with qtbot.waitSignal(roi.movableChanged) as blocker:
roi.movable = False
assert blocker.args == [False]
assert not roi.movable
assert not (roi.translatable or roi.rotatable or roi.resizable or roi.removable)
assert len(roi.handles) == 0
# unlock again
with qtbot.waitSignal(roi.movableChanged) as blocker:
roi.movable = True
assert blocker.args == [True]
assert roi.movable
assert roi.translatable and roi.rotatable and roi.resizable and roi.removable
assert len(roi.handles) > 0
+201 -22
View File
@@ -1,6 +1,7 @@
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
import pytest import pytest
from qtpy.QtCore import QPointF
from bec_widgets.widgets.plots.image.image import Image from bec_widgets.widgets.plots.image.image import Image
from tests.unit_tests.client_mocks import mocked_client from tests.unit_tests.client_mocks import mocked_client
@@ -61,8 +62,8 @@ def test_lock_aspect_ratio(qtbot, mocked_client):
def test_set_vrange(qtbot, mocked_client): def test_set_vrange(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client) bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.vrange = (10, 100) bec_image_view.v_range = (10, 100)
assert bec_image_view.vrange == (10, 100) assert bec_image_view.v_range == QPointF(10, 100)
assert bec_image_view.main_image.levels == (10, 100) assert bec_image_view.main_image.levels == (10, 100)
assert bec_image_view.main_image.config.v_range == (10, 100) assert bec_image_view.main_image.config.v_range == (10, 100)
@@ -107,17 +108,86 @@ def test_enable_colorbar_with_vrange(qtbot, mocked_client, colorbar_type):
assert isinstance(bec_image_view._color_bar, pg.HistogramLUTItem) assert isinstance(bec_image_view._color_bar, pg.HistogramLUTItem)
assert bec_image_view.enable_full_colorbar is True assert bec_image_view.enable_full_colorbar is True
assert bec_image_view.config.color_bar == colorbar_type assert bec_image_view.config.color_bar == colorbar_type
assert bec_image_view.vrange == (0, 100) assert bec_image_view.v_range == QPointF(0, 100)
assert bec_image_view.main_image.levels == (0, 100) assert bec_image_view.main_image.levels == (0, 100)
assert bec_image_view._color_bar is not None assert bec_image_view._color_bar is not None
##############################################
# Previewsignal update mechanism
def test_image_setup_preview_signal_1d(qtbot, mocked_client, monkeypatch):
"""
Ensure that calling .image() with a (device, signal, config) tuple representing
a 1D PreviewSignal connects using the 1D path and updates correctly.
"""
import numpy as np
view = create_widget(qtbot, Image, client=mocked_client)
signal_config = {
"obj_name": "waveform1d_img",
"signal_class": "PreviewSignal",
"describe": {"signal_info": {"ndim": 1}},
}
# Set the image monitor to the preview signal
view.image(monitor=("waveform1d", "img", signal_config))
# Subscriptions should indicate 1D preview connection
sub = view.subscriptions["main"]
assert sub.source == "device_monitor_1d"
assert sub.monitor_type == "1d"
assert sub.monitor == ("waveform1d", "img", signal_config)
# Simulate a waveform update from the dispatcher
waveform = np.arange(25, dtype=float)
view.on_image_update_1d({"data": waveform}, {"scan_id": "scan_test"})
assert view.main_image.raw_data.shape == (1, 25)
np.testing.assert_array_equal(view.main_image.raw_data[0], waveform)
def test_image_setup_preview_signal_2d(qtbot, mocked_client, monkeypatch):
"""
Ensure that calling .image() with a (device, signal, config) tuple representing
a 2D PreviewSignal connects using the 2D path and updates correctly.
"""
import numpy as np
view = create_widget(qtbot, Image, client=mocked_client)
signal_config = {
"obj_name": "eiger_img2d",
"signal_class": "PreviewSignal",
"describe": {"signal_info": {"ndim": 2}},
}
# Set the image monitor to the preview signal
view.image(monitor=("eiger", "img2d", signal_config))
# Subscriptions should indicate 2D preview connection
sub = view.subscriptions["main"]
assert sub.source == "device_monitor_2d"
assert sub.monitor_type == "2d"
assert sub.monitor == ("eiger", "img2d", signal_config)
# Simulate a 2D image update
test_data = np.arange(16, dtype=float).reshape(4, 4)
view.on_image_update_2d({"data": test_data}, {})
np.testing.assert_array_equal(view.main_image.image, test_data)
##############################################
# Device monitor endpoint update mechanism
def test_image_setup_image_2d(qtbot, mocked_client): def test_image_setup_image_2d(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client) bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.image(monitor="eiger", monitor_type="2d") bec_image_view.image(monitor="eiger", monitor_type="2d")
assert bec_image_view.monitor == "eiger" assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "device_monitor_2d" assert bec_image_view.subscriptions["main"].source == "device_monitor_2d"
assert bec_image_view.main_image.config.monitor_type == "2d" assert bec_image_view.subscriptions["main"].monitor_type == "2d"
assert bec_image_view.main_image.raw_data is None assert bec_image_view.main_image.raw_data is None
assert bec_image_view.main_image.image is None assert bec_image_view.main_image.image is None
@@ -126,8 +196,8 @@ def test_image_setup_image_1d(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client) bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.image(monitor="eiger", monitor_type="1d") bec_image_view.image(monitor="eiger", monitor_type="1d")
assert bec_image_view.monitor == "eiger" assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "device_monitor_1d" assert bec_image_view.subscriptions["main"].source == "device_monitor_1d"
assert bec_image_view.main_image.config.monitor_type == "1d" assert bec_image_view.subscriptions["main"].monitor_type == "1d"
assert bec_image_view.main_image.raw_data is None assert bec_image_view.main_image.raw_data is None
assert bec_image_view.main_image.image is None assert bec_image_view.main_image.image is None
@@ -136,8 +206,8 @@ def test_image_setup_image_auto(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client) bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.image(monitor="eiger", monitor_type="auto") bec_image_view.image(monitor="eiger", monitor_type="auto")
assert bec_image_view.monitor == "eiger" assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "auto" assert bec_image_view.subscriptions["main"].source == "auto"
assert bec_image_view.main_image.config.monitor_type == "auto" assert bec_image_view.subscriptions["main"].monitor_type == "auto"
assert bec_image_view.main_image.raw_data is None assert bec_image_view.main_image.raw_data is None
assert bec_image_view.main_image.image is None assert bec_image_view.main_image.image is None
@@ -150,7 +220,7 @@ def test_image_data_update_2d(qtbot, mocked_client):
bec_image_view.on_image_update_2d(message, metadata) bec_image_view.on_image_update_2d(message, metadata)
np.testing.assert_array_equal(bec_image_view._main_image.image, test_data) np.testing.assert_array_equal(bec_image_view.main_image.image, test_data)
def test_image_data_update_1d(qtbot, mocked_client): def test_image_data_update_1d(qtbot, mocked_client):
@@ -160,10 +230,14 @@ def test_image_data_update_1d(qtbot, mocked_client):
metadata = {"scan_id": "scan_test"} metadata = {"scan_id": "scan_test"}
bec_image_view.on_image_update_1d({"data": waveform1}, metadata) bec_image_view.on_image_update_1d({"data": waveform1}, metadata)
assert bec_image_view._main_image.raw_data.shape == (1, 50) assert bec_image_view.main_image.raw_data.shape == (1, 50)
bec_image_view.on_image_update_1d({"data": waveform2}, metadata) bec_image_view.on_image_update_1d({"data": waveform2}, metadata)
assert bec_image_view._main_image.raw_data.shape == (2, 60) assert bec_image_view.main_image.raw_data.shape == (2, 60)
##############################################
# Toolbar and Actions Tests
def test_toolbar_actions_presence(qtbot, mocked_client): def test_toolbar_actions_presence(qtbot, mocked_client):
@@ -207,8 +281,8 @@ def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type):
elif colorbar_type == "full": elif colorbar_type == "full":
bec_image_view.enable_full_colorbar = True bec_image_view.enable_full_colorbar = True
bec_image_view.vrange = (0, 100) bec_image_view.v_range = (0, 100)
assert bec_image_view.vrange == (0, 100) assert bec_image_view.v_range == QPointF(0, 100)
assert bec_image_view.main_image.levels == (0, 100) assert bec_image_view.main_image.levels == (0, 100)
assert bec_image_view.main_image.config.v_range == (0, 100) assert bec_image_view.main_image.config.v_range == (0, 100)
assert bec_image_view.v_min == 0 assert bec_image_view.v_min == 0
@@ -234,8 +308,8 @@ def test_setup_image_from_toolbar(qtbot, mocked_client):
bec_image_view.selection_bundle.dim_combo_box.setCurrentText("2d") bec_image_view.selection_bundle.dim_combo_box.setCurrentText("2d")
assert bec_image_view.monitor == "eiger" assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "device_monitor_2d" assert bec_image_view.subscriptions["main"].source == "device_monitor_2d"
assert bec_image_view.main_image.config.monitor_type == "2d" assert bec_image_view.subscriptions["main"].monitor_type == "2d"
assert bec_image_view.main_image.raw_data is None assert bec_image_view.main_image.raw_data is None
assert bec_image_view.main_image.image is None assert bec_image_view.main_image.image is None
@@ -434,19 +508,31 @@ def test_crosshair_roi_panels_visibility(qtbot, mocked_client):
# Enable ROI crosshair # Enable ROI crosshair
switch.actions["crosshair_roi"].action.trigger() switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(500)
# Panels must be visible # Panels must be visible
assert bec_image_view.side_panel_x.panel_height > 0 qtbot.waitUntil(
assert bec_image_view.side_panel_y.panel_width > 0 lambda: all(
[
bec_image_view.side_panel_x.panel_height > 0,
bec_image_view.side_panel_y.panel_width > 0,
]
),
timeout=500,
)
# Disable ROI crosshair # Disable ROI crosshair
switch.actions["crosshair_roi"].action.trigger() switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(500)
# Panels hidden again # Panels hidden again
assert bec_image_view.side_panel_x.panel_height == 0 qtbot.waitUntil(
assert bec_image_view.side_panel_y.panel_width == 0 lambda: all(
[
bec_image_view.side_panel_x.panel_height == 0,
bec_image_view.side_panel_y.panel_width == 0,
]
),
timeout=500,
)
def test_roi_plot_data_from_image(qtbot, mocked_client): def test_roi_plot_data_from_image(qtbot, mocked_client):
@@ -483,3 +569,96 @@ def test_roi_plot_data_from_image(qtbot, mocked_client):
# Horizontal slice (row) # Horizontal slice (row)
h_slice, _ = y_items[0].getData() h_slice, _ = y_items[0].getData()
np.testing.assert_array_equal(h_slice, test_data[2]) np.testing.assert_array_equal(h_slice, test_data[2])
##############################################
# MonitorSelectionToolbarBundle specific tests
##############################################
def test_monitor_selection_reverse_device_items(qtbot, mocked_client):
"""
Verify that _reverse_device_items correctly reverses the order of items in the
device combobox while preserving the current selection.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
combo = bundle.device_combo_box
# Replace existing items with a deterministic list
combo.clear()
combo.addItem("samx", 1)
combo.addItem("samy", 2)
combo.addItem("samz", 3)
combo.setCurrentText("samy")
# Reverse the items
bundle._reverse_device_items()
# Order should be reversed and selection preserved
assert [combo.itemText(i) for i in range(combo.count())] == ["samz", "samy", "samx"]
assert combo.currentText() == "samy"
def test_monitor_selection_populate_preview_signals(qtbot, mocked_client, monkeypatch):
"""
Verify that _populate_preview_signals adds previewsignal devices to the combobox
with the correct userData.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
# Provide a deterministic fake device_manager with get_bec_signals
class _FakeDM:
def get_bec_signals(self, _filter):
return [
("eiger", "img", {"obj_name": "eiger_img"}),
("async_device", "img2", {"obj_name": "async_device_img2"}),
]
monkeypatch.setattr(view.client, "device_manager", _FakeDM())
initial_count = bundle.device_combo_box.count()
bundle._populate_preview_signals()
# Two new entries should have been added
assert bundle.device_combo_box.count() == initial_count + 2
# The first newly added item should carry tuple userData describing the device/signal
data = bundle.device_combo_box.itemData(initial_count)
assert isinstance(data, tuple) and data[0] == "eiger"
def test_monitor_selection_adjust_and_connect(qtbot, mocked_client, monkeypatch):
"""
Verify that _adjust_and_connect performs the full setup:
fills the combobox with preview signals,
reverses their order,
and resets the currentText to an empty string.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
# Deterministic fake device_manager
class _FakeDM:
def get_bec_signals(self, _filter):
return [("eiger", "img", {"obj_name": "eiger_img"})]
monkeypatch.setattr(view.client, "device_manager", _FakeDM())
combo = bundle.device_combo_box
# Start from a clean state
combo.clear()
combo.addItem("", None)
combo.setCurrentText("")
# Execute the method under test
bundle._adjust_and_connect()
# Expect exactly two items: preview label followed by the empty default
assert combo.count() == 2
# Because of the reversal, the preview label comes first
assert combo.itemText(0) == "eiger_img"
# Current selection remains empty
assert combo.currentText() == ""
+56 -2
View File
@@ -102,7 +102,34 @@ def test_launch_window_launch_plugin_auto_update(bec_launch_window):
[ [
({}, False), ({}, False),
({"launcher": mock.MagicMock()}, False), ({"launcher": mock.MagicMock()}, False),
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, True), ({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, False),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress": mock.MagicMock(),
},
False,
),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress_simple": mock.MagicMock(),
"scan_progress_full": mock.MagicMock(),
},
False,
),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress_simple": mock.MagicMock(),
"scan_progress_full": mock.MagicMock(),
"hover_widget": mock.MagicMock(),
},
True,
),
], ],
) )
def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide): def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide):
@@ -132,7 +159,34 @@ def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide):
[ [
({}, True), ({}, True),
({"launcher": mock.MagicMock()}, True), ({"launcher": mock.MagicMock()}, True),
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, False), ({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, True),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress": mock.MagicMock(),
},
True,
),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress_simple": mock.MagicMock(),
"scan_progress_full": mock.MagicMock(),
},
True,
),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress_simple": mock.MagicMock(),
"scan_progress_full": mock.MagicMock(),
"hover_widget": mock.MagicMock(),
},
False,
),
], ],
) )
def test_launch_window_closes(bec_launch_window, connections, close_called): def test_launch_window_closes(bec_launch_window, connections, close_called):
+293
View File
@@ -0,0 +1,293 @@
import webbrowser
import pytest
from qtpy.QtCore import QEvent, QPoint, QPointF
from qtpy.QtGui import QEnterEvent
from qtpy.QtWidgets import QApplication, QFrame, QLabel
from bec_widgets.widgets.containers.main_window.addons.hover_widget import (
HoverWidget,
WidgetTooltip,
)
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.containers.main_window.main_window import BECMainWindow
from .client_mocks import mocked_client
from .conftest import create_widget
@pytest.fixture
def bec_main_window(qtbot, mocked_client):
widget = BECMainWindow(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
#################################################################
# Tests for BECMainWindow Initialization and Functionality
#################################################################
def test_bec_main_window_initialization(bec_main_window):
assert isinstance(bec_main_window, BECMainWindow)
assert bec_main_window.windowTitle() == "BEC"
assert bec_main_window.app is not None
assert bec_main_window.statusBar() is not None
assert bec_main_window._app_id_label is not None
def test_bec_main_window_display_client_message(qtbot, bec_main_window):
"""
Verify that display_client_message updates the clientinfo label.
"""
test_msg = "Client connected successfully"
bec_main_window.display_client_message({"message": test_msg}, {})
qtbot.wait(200)
assert bec_main_window._client_info_label.text() == test_msg
def test_status_bar_has_separator(bec_main_window):
"""Ensure the status bar contains at least one vertical separator."""
status_bar = bec_main_window.statusBar()
separators = [w for w in status_bar.findChildren(QFrame) if w.frameShape() == QFrame.VLine]
assert separators, "Expected at least one QFrame separator in the status bar."
#################################################################
# Tests for BECMainWindow Addons
#################################################################
#################################################################
# Tests for ScrollLabel behaviour
def test_scroll_label_does_not_scroll_when_text_fits(qtbot):
"""Label with short text should not activate scrolling timer."""
lbl = create_widget(qtbot, ScrollLabel) # shorten delay for test speed
qtbot.addWidget(lbl)
lbl.resize(200, 20)
lbl.setText("Short text")
# Process events to allow timer logic to run
qtbot.wait(200)
assert not lbl._timer.isActive()
assert not lbl._delay_timer.isActive()
def test_scroll_label_starts_scrolling(qtbot):
"""Label with long text should start _delay_timer; later _timer becomes active."""
lbl = create_widget(qtbot, ScrollLabel, delay_ms=100)
lbl.resize(150, 20)
long_text = "This is a very long piece of text that should definitely overflow the label width"
lbl.setText(long_text)
# Immediately after setText, only delaytimer should be active
assert lbl._delay_timer.isActive()
assert not lbl._timer.isActive()
# Wait until scrolling timer becomes active
qtbot.waitUntil(lambda: lbl._timer.isActive(), timeout=2000)
assert lbl._timer.isActive()
def test_scroll_label_scroll_method(qtbot):
"""Directly exercise _scroll to ensure offset advances and paintEvent is invoked."""
lbl = create_widget(qtbot, ScrollLabel, step_px=5) # shorten delay for test speed
qtbot.addWidget(lbl)
lbl.resize(120, 20)
lbl.setText("x" * 200) # long text to guarantee overflow
qtbot.wait(200) # let timers configure themselves
# Capture current offset and force a manual scroll tick
old_offset = lbl._offset
lbl._scroll()
assert lbl._offset == old_offset + 5
def test_scroll_label_paint_event(qtbot):
"""
Grab the widget as a pixmap; this calls paintEvent under the hood
and ensures no exceptions occur during rendering.
"""
lbl = create_widget(qtbot, ScrollLabel) # shorten delay for test speed
qtbot.addWidget(lbl)
lbl.resize(180, 20)
lbl.setText("Rendering check")
lbl.show()
qtbot.wait(200) # allow Qt to schedule a paint
pixmap = lbl.grab()
assert not pixmap.isNull()
def test_display_client_message_with_expiration(qtbot, bec_main_window):
"""
A message with a finite 'expire' value should disappear once the timer
fires.
"""
test_msg = "This message should vanish fast"
expire_sec = 0.2
bec_main_window.display_client_message({"message": test_msg, "expire": expire_sec}, {})
assert bec_main_window._client_info_expire_timer.isActive()
assert bec_main_window._client_info_label.text() == test_msg
qtbot.waitUntil(lambda: not bec_main_window._client_info_expire_timer.isActive(), timeout=1000)
assert bec_main_window._client_info_label.text() == ""
def test_display_client_message_no_expiration(qtbot, bec_main_window):
"""
A message with 'expire' == 0 must persist and never start the timer.
"""
test_msg = "Persistent status message"
bec_main_window.display_client_message({"message": test_msg, "expire": 0}, {})
assert not bec_main_window._client_info_expire_timer.isActive()
assert bec_main_window._client_info_label.text() == test_msg
qtbot.wait(500)
assert bec_main_window._client_info_label.text() == test_msg
def test_display_client_message_overwrite_resets_timer(qtbot, bec_main_window):
"""
Sending a second message while the expiration timer is active should
overwrite the first and stop the timer if the second one is persistent.
"""
first_msg = "First (temporary)"
second_msg = "Second (persistent)"
bec_main_window.display_client_message({"message": first_msg, "expire": 0.3}, {})
qtbot.wait(200)
assert bec_main_window._client_info_expire_timer.isActive()
bec_main_window.display_client_message({"message": second_msg, "expire": 0}, {})
assert not bec_main_window._client_info_expire_timer.isActive()
assert bec_main_window._client_info_label.text() == second_msg
qtbot.wait(400)
assert bec_main_window._client_info_label.text() == second_msg
#################################################################
# Tests for BECWebLinksMixin (webbrowser opening)
def test_bec_weblinks(monkeypatch):
opened_urls = []
def fake_open(url):
opened_urls.append(url)
monkeypatch.setattr(webbrowser, "open", fake_open)
BECWebLinksMixin.open_bec_docs()
BECWebLinksMixin.open_bec_widgets_docs()
BECWebLinksMixin.open_bec_bug_report()
assert opened_urls == [
"https://beamline-experiment-control.readthedocs.io/en/latest/",
"https://bec.readthedocs.io/projects/bec-widgets/en/latest/",
"https://gitlab.psi.ch/groups/bec/-/issues/",
]
#################################################################
# Tests for scanprogress bar animations
def test_scan_progress_bar_show_animation(qtbot, bec_main_window):
"""
_show_scan_progress_bar should animate the container's maximumWidth
from 0 to the configured target width.
"""
container = bec_main_window._scan_progress_bar_with_separator
# Precondition: collapsed
assert container.maximumWidth() == 0
bec_main_window._show_scan_progress_bar()
target = bec_main_window._scan_progress_bar_target_width
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
assert container.maximumWidth() == target
def test_scan_progress_bar_hide_animation(qtbot, bec_main_window):
"""
_animate_hide_scan_progress_bar should collapse the container back to 0 width.
"""
container = bec_main_window._scan_progress_bar_with_separator
# First expand it
bec_main_window._show_scan_progress_bar()
target = bec_main_window._scan_progress_bar_target_width
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
# Trigger hide animation
bec_main_window._animate_hide_scan_progress_bar()
qtbot.waitUntil(lambda: container.maximumWidth() == 0, timeout=2000)
assert container.maximumWidth() == 0
#################################################################
# Tests for hover widget and tooltip behaviour
def test_hover_widget_tooltip(qtbot):
"""
After a HoverWidget is closed, its WidgetTooltip must be gone.
"""
simple = QLabel("Hover me")
full = QLabel("Full details")
hover = create_widget(qtbot, HoverWidget, simple=simple, full=full)
assert hover._simple is simple
assert hover._full is full
assert hover._tooltip is None
def test_widget_tooltip_show_and_hide(qtbot):
"""
WidgetTooltip should appear when show_above is called and hide on Leave.
"""
full_lbl = QLabel("Standalone tooltip content")
tooltip = create_widget(qtbot, WidgetTooltip, content=full_lbl)
# Show above an arbitrary point
pos = QPoint(200, 200)
tooltip.show_above(pos)
assert tooltip.isVisible()
# Send a synthetic Leave event
QApplication.sendEvent(tooltip, QEvent(QEvent.Leave))
qtbot.waitUntil(lambda: not tooltip.isVisible(), timeout=500)
assert not tooltip.isVisible()
def test_hover_widget_mouse_events(qtbot):
"""
Verify that HoverWidget responds correctly to Enter, MouseMove, and Leave
events, keeping the tooltip visible only while the pointer is inside.
"""
simple = QLabel("Hovertarget")
full = QLabel("Fullview")
hover = create_widget(qtbot, HoverWidget, simple=simple, full=full)
local = QPointF(hover.rect().center()) # inside widget
scene = QPointF(hover.mapTo(hover.window(), local.toPoint()))
global_ = QPointF(hover.mapToGlobal(local.toPoint()))
enter_event = QEnterEvent(local, scene, global_)
hover.enterEvent(event=enter_event)
qtbot.wait(200)
assert hover._tooltip is not None
assert hover._tooltip.isVisible()
assert hover._tooltip.content is full
+1 -1
View File
@@ -29,4 +29,4 @@ def test_gui_server_get_service_config(gui_server):
""" """
Test that the server is started with the correct arguments. Test that the server is started with the correct arguments.
""" """
assert gui_server._get_service_config().config is ServiceConfig().config assert gui_server._get_service_config().config == ServiceConfig().config
+25 -71
View File
@@ -4,10 +4,10 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import AvailableResourceMessage, ScanQueueHistoryMessage, ScanQueueMessage from bec_lib.messages import AvailableResourceMessage, ScanHistoryMessage
from qtpy.QtCore import QModelIndex, Qt from qtpy.QtCore import QModelIndex, Qt
from bec_widgets.utils.forms_from_types.items import StrMetadataField from bec_widgets.utils.forms_from_types.items import StrFormItem
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.scan_control import ScanControl from bec_widgets.widgets.control.scan_control import ScanControl
@@ -221,82 +221,36 @@ available_scans_message = AvailableResourceMessage(
} }
) )
scan_history = ScanQueueHistoryMessage( scan_history = ScanHistoryMessage(
metadata={}, metadata={},
status="COMPLETED", scan_id="79cbef20-9ebe-45bb-a44c-f518be27a25c",
queue_id="94d7cb39-aa70-4060-92de-addcfb64e3c0", scan_number=1,
info={ dataset_number=1,
"queue_id": "94d7cb39-aa70-4060-92de-addcfb64e3c0", file_path="/somepath/scan_1.h5",
"scan_id": ["bc2aa11f-24f6-44d6-8717-95e97fb43015"], exit_status="closed",
"is_scan": [True], start_time=1750618470.936856,
"request_blocks": [ end_time=1750618473.668227,
{ scan_name="line_scan",
"msg": ScanQueueMessage( num_points=100,
metadata={ request_inputs={
"file_suffix": None, "arg_bundle": ["samx", 0.0, 2.0],
"file_directory": None, "inputs": {},
"user_metadata": {}, "kwargs": {
"RID": "99321ef7-00ac-4e0c-9120-ce689bd88a4d", "steps": 10,
}, "exp_time": 2,
scan_type="line_scan", "relative": False,
parameter={ "system_config": {"file_suffix": None, "file_directory": None},
"args": {"samx": [0.0, 2.0]}, },
"kwargs": {
"steps": 10,
"relative": False,
"exp_time": 2.0,
"burst_at_each_point": 1,
"system_config": {"file_suffix": None, "file_directory": None},
},
},
queue="primary",
),
"RID": "99321ef7-00ac-4e0c-9120-ce689bd88a4d",
"scan_motors": ["samx"],
"readout_priority": {
"monitored": ["samx"],
"baseline": [],
"on_request": [],
"async": [],
},
"is_scan": True,
"scan_number": 176,
"scan_id": "bc2aa11f-24f6-44d6-8717-95e97fb43015",
"metadata": {
"file_suffix": None,
"file_directory": None,
"user_metadata": {},
"RID": "99321ef7-00ac-4e0c-9120-ce689bd88a4d",
},
"content": {
"scan_type": "line_scan",
"parameter": {
"args": {"samx": [0.0, 2.0]},
"kwargs": {
"steps": 10,
"relative": False,
"exp_time": 2.0,
"burst_at_each_point": 1,
"system_config": {"file_suffix": None, "file_directory": None},
},
},
"queue": "primary",
},
"report_instructions": [{"scan_progress": 10}],
}
],
"scan_number": [176],
"status": "COMPLETED",
"active_request_block": None,
}, },
queue="primary",
) )
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def scan_control(qtbot, mocked_client): # , mock_dev): def scan_control(qtbot, mocked_client): # , mock_dev):
mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message) mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message)
mocked_client.connector.lpush(MessageEndpoints.scan_queue_history(), scan_history) mocked_client.connector.xadd(
topic=MessageEndpoints.scan_history(), msg_dict={"data": scan_history}
)
widget = ScanControl(client=mocked_client) widget = ScanControl(client=mocked_client)
qtbot.addWidget(widget) qtbot.addWidget(widget)
qtbot.waitExposed(widget) qtbot.waitExposed(widget)
@@ -570,7 +524,7 @@ def test_scan_metadata_is_connected(scan_control):
scan_control.comboBox_scan_selection.setCurrentText("grid_scan") scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
assert scan_control._metadata_form._scan_name == "grid_scan" assert scan_control._metadata_form._scan_name == "grid_scan"
sample_name = scan_control._metadata_form._form_grid.layout().itemAtPosition(0, 1).widget() sample_name = scan_control._metadata_form._form_grid.layout().itemAtPosition(0, 1).widget()
assert isinstance(sample_name, StrMetadataField) assert isinstance(sample_name, StrFormItem)
sample_name._main_widget.setText("Test Sample") sample_name._main_widget.setText("Test Sample")
scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY
+34 -25
View File
@@ -1,4 +1,5 @@
from decimal import Decimal from decimal import Decimal
from typing import Set
import pytest import pytest
from bec_lib.metadata_schema import BasicScanMetadata from bec_lib.metadata_schema import BasicScanMetadata
@@ -7,11 +8,12 @@ from pydantic.types import Json
from qtpy.QtCore import QItemSelectionModel, QPoint, Qt from qtpy.QtCore import QItemSelectionModel, QPoint, Qt
from bec_widgets.utils.forms_from_types.items import ( from bec_widgets.utils.forms_from_types.items import (
BoolMetadataField, BoolFormItem,
DictFormItem,
DynamicFormItem, DynamicFormItem,
FloatDecimalMetadataField, FloatDecimalFormItem,
IntMetadataField, IntFormItem,
StrMetadataField, StrFormItem,
) )
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
@@ -34,7 +36,8 @@ class ExampleSchema(BasicScanMetadata):
int_nodefault_optional: int | None = Field(lt=-1, ge=-44) int_nodefault_optional: int | None = Field(lt=-1, ge=-44)
float_nodefault: float float_nodefault: float
decimal_dp_limits_nodefault: Decimal = Field(Decimal(1.23), decimal_places=2, gt=1, le=34.5) decimal_dp_limits_nodefault: Decimal = Field(Decimal(1.23), decimal_places=2, gt=1, le=34.5)
unsupported_class: Json = Field(default_factory=dict) dict_default: dict = Field(default_factory=dict)
unsupported_class: Json = Field(default=set())
TEST_DICT = { TEST_DICT = {
@@ -47,8 +50,9 @@ TEST_DICT = {
"int_default": 21, "int_default": 21,
"int_nodefault_optional": -10, "int_nodefault_optional": -10,
"float_nodefault": pytest.approx(0.1), "float_nodefault": pytest.approx(0.1),
"decimal_dp_limits_nodefault": pytest.approx(34), "decimal_dp_limits_nodefault": pytest.approx(34.5),
"unsupported_class": '{"key": "value"}', "dict_default": {"test_dict": "values"},
"unsupported_class": '["set", "item"]',
} }
@@ -58,12 +62,11 @@ def example_md():
@pytest.fixture @pytest.fixture
def empty_metadata_widget(): def empty_metadata_widget(qtbot):
widget = ScanMetadata() widget = ScanMetadata()
widget._additional_metadata._table_model._data = [["extra_field", "extra_data"]] widget._additional_metadata._table_model._data = [["extra_field", "extra_data"]]
qtbot.addWidget(widget)
yield widget yield widget
widget._clear_grid()
widget.deleteLater()
@pytest.fixture @pytest.fixture
@@ -82,7 +85,8 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
int_nodefault_optional = widget._form_grid.layout().itemAtPosition(7, 1).widget() int_nodefault_optional = widget._form_grid.layout().itemAtPosition(7, 1).widget()
float_nodefault = widget._form_grid.layout().itemAtPosition(8, 1).widget() float_nodefault = widget._form_grid.layout().itemAtPosition(8, 1).widget()
decimal_dp_limits_nodefault = widget._form_grid.layout().itemAtPosition(9, 1).widget() decimal_dp_limits_nodefault = widget._form_grid.layout().itemAtPosition(9, 1).widget()
unsupported_class = widget._form_grid.layout().itemAtPosition(10, 1).widget() dict_default = widget._form_grid.layout().itemAtPosition(10, 1).widget()
unsupported_class = widget._form_grid.layout().itemAtPosition(11, 1).widget()
yield ( yield (
widget, widget,
@@ -97,6 +101,7 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
"int_nodefault_optional": int_nodefault_optional, "int_nodefault_optional": int_nodefault_optional,
"float_nodefault": float_nodefault, "float_nodefault": float_nodefault,
"decimal_dp_limits_nodefault": decimal_dp_limits_nodefault, "decimal_dp_limits_nodefault": decimal_dp_limits_nodefault,
"dict_default": dict_default,
"unsupported_class": unsupported_class, "unsupported_class": unsupported_class,
}, },
) )
@@ -112,24 +117,26 @@ def fill_commponents(components: dict[str, DynamicFormItem]):
components["int_nodefault_optional"].setValue(-10) components["int_nodefault_optional"].setValue(-10)
components["float_nodefault"].setValue(0.1) components["float_nodefault"].setValue(0.1)
components["decimal_dp_limits_nodefault"].setValue(456.789) components["decimal_dp_limits_nodefault"].setValue(456.789)
components["unsupported_class"].setValue(r'{"key": "value"}') components["dict_default"].setValue({"test_dict": "values"})
components["unsupported_class"].setValue(r'["set", "item"]')
def test_griditems_are_correct_class( def test_griditems_are_correct_class(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]], metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
): ):
_, components = metadata_widget _, components = metadata_widget
assert isinstance(components["sample_name"], StrMetadataField) assert isinstance(components["sample_name"], StrFormItem)
assert isinstance(components["str_optional"], StrMetadataField) assert isinstance(components["str_optional"], StrFormItem)
assert isinstance(components["str_required"], StrMetadataField) assert isinstance(components["str_required"], StrFormItem)
assert isinstance(components["bool_optional"], BoolMetadataField) assert isinstance(components["bool_optional"], BoolFormItem)
assert isinstance(components["bool_required_default"], BoolMetadataField) assert isinstance(components["bool_required_default"], BoolFormItem)
assert isinstance(components["bool_required_nodefault"], BoolMetadataField) assert isinstance(components["bool_required_nodefault"], BoolFormItem)
assert isinstance(components["int_default"], IntMetadataField) assert isinstance(components["int_default"], IntFormItem)
assert isinstance(components["int_nodefault_optional"], IntMetadataField) assert isinstance(components["int_nodefault_optional"], IntFormItem)
assert isinstance(components["float_nodefault"], FloatDecimalMetadataField) assert isinstance(components["float_nodefault"], FloatDecimalFormItem)
assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalMetadataField) assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalFormItem)
assert isinstance(components["unsupported_class"], StrMetadataField) assert isinstance(components["dict_default"], DictFormItem)
assert isinstance(components["unsupported_class"], StrFormItem)
def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]): def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]):
@@ -168,14 +175,16 @@ def test_numbers_clipped_to_limits(
fill_commponents(components) fill_commponents(components)
components["decimal_dp_limits_nodefault"].setValue(-56) components["decimal_dp_limits_nodefault"].setValue(-56)
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(1.01)
widget.validate_form() widget.validate_form()
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(2)
assert widget._validity_message.text() == "No errors!" assert widget._validity_message.text() == "No errors!"
@pytest.fixture @pytest.fixture
def table(): def table():
table = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]) table = DictBackedTable(
initial_data=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]
)
yield table yield table
table._table_model.deleteLater() table._table_model.deleteLater()
table._table_view.deleteLater() table._table_view.deleteLater()
+336
View File
@@ -0,0 +1,336 @@
from unittest import mock
import numpy as np
import pytest
from bec_lib import messages
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
BECProgressBar,
ProgressState,
)
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import (
ProgressSource,
ProgressTask,
ScanProgressBar,
)
from .client_mocks import mocked_client
@pytest.fixture
def scan_progressbar(qtbot, mocked_client):
widget = ScanProgressBar(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_progress_task_basic():
"""percentage, remaining, and formatted time helpers behave as expected."""
task = ProgressTask(parent=None, value=50, max_value=100, done=False)
task.timer.stop() # we dont want the timer ticking in tests
task._elapsed_time = 10 # simulate 10 s elapsed
# 50 / 100 ⇒ 50 %
assert task.percentage == 50
# speed = value / elapsed = 5 steps / s
assert np.isclose(task.speed, 5)
# remaining steps = 50 ; time_remaining ≈ 10 s
assert task.remaining == 50
assert task.time_remaining == "00:00:10"
# time_elapsed formatting
assert task.time_elapsed == "00:00:10"
def test_scan_progressbar_initialization(scan_progressbar):
assert isinstance(scan_progressbar, ScanProgressBar)
assert isinstance(scan_progressbar.progressbar, BECProgressBar)
def test_update_labels_content(scan_progressbar):
"""update_labels() reflects ProgressTask time strings on the UI."""
# fabricate a task with known timings
task = ProgressTask(parent=scan_progressbar, value=30, max_value=100, done=False)
task.timer.stop()
task._elapsed_time = 50
scan_progressbar.task = task
scan_progressbar.update_labels()
assert scan_progressbar.ui.elapsed_time_label.text() == "00:00:50"
assert scan_progressbar.ui.remaining_time_label.text() == "00:01:57"
def test_on_progress_update(qtbot, scan_progressbar):
"""
on_progress_update() should forward new values to the embedded
BECProgressBar and keep ProgressTask in sync.
"""
task = ProgressTask(parent=scan_progressbar, value=0, max_value=100, done=False)
task.timer.stop()
scan_progressbar.task = task
msg = {"value": 20, "max_value": 100, "done": False}
scan_progressbar.on_progress_update(msg, metadata={"status": "open"})
qtbot.wait(200)
bar = scan_progressbar.progressbar
assert bar._user_value == 20
assert bar._user_maximum == 100
# state reflects BEC status
assert bar.state is ProgressState.NORMAL
@pytest.mark.parametrize(
"status, value, max_val, expected_state",
[
("open", 10, 100, ProgressState.NORMAL),
("paused", 25, 100, ProgressState.PAUSED),
("aborted", 30, 100, ProgressState.INTERRUPTED),
("halted", 40, 100, ProgressState.PAUSED),
("closed", 100, 100, ProgressState.COMPLETED),
],
)
def test_state_mapping_during_updates(
qtbot, scan_progressbar, status, value, max_val, expected_state
):
"""ScanProgressBar should translate BEC status → ProgressState consistently."""
task = ProgressTask(parent=scan_progressbar, value=0, max_value=max_val, done=False)
task.timer.stop()
scan_progressbar.task = task
scan_progressbar.on_progress_update(
{"value": value, "max_value": max_val, "done": status == "closed"},
metadata={"status": status},
)
assert scan_progressbar.progressbar.state is expected_state
def test_source_label_updates(scan_progressbar):
"""update_source_label() renders correct text for both progress sources."""
# device progress
scan_progressbar.update_source_label(ProgressSource.DEVICE_PROGRESS, device="motor")
assert scan_progressbar.ui.source_label.text() == "Device motor"
# scan progress (needs a scan_number for deterministic text)
scan_progressbar.scan_number = 5
scan_progressbar.update_source_label(ProgressSource.SCAN_PROGRESS)
assert scan_progressbar.ui.source_label.text() == "Scan 5"
def test_set_progress_source_connections(scan_progressbar, monkeypatch):
""" """
from bec_lib.endpoints import MessageEndpoints
connect_calls = []
disconnect_calls = []
def fake_connect(slot, endpoint):
connect_calls.append(endpoint)
def fake_disconnect(slot, endpoint):
disconnect_calls.append(endpoint)
# Patch dispatcher methods
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "connect_slot", fake_connect)
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "disconnect_slot", fake_disconnect)
# switch to SCAN_PROGRESS
scan_progressbar.scan_number = 7
scan_progressbar.set_progress_source(ProgressSource.SCAN_PROGRESS)
assert scan_progressbar._progress_source == ProgressSource.SCAN_PROGRESS
assert scan_progressbar.ui.source_label.text() == "Scan 7"
assert connect_calls[-1] == MessageEndpoints.scan_progress()
assert disconnect_calls == []
# switch to DEVICE_PROGRESS
device = "motor"
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
assert scan_progressbar._progress_source == ProgressSource.DEVICE_PROGRESS
assert scan_progressbar.ui.source_label.text() == f"Device {device}"
assert connect_calls[-1] == MessageEndpoints.device_progress(device=device)
assert disconnect_calls == [MessageEndpoints.scan_progress()]
# calling again with the SAME source should not add new connect calls
prev_connect_count = len(connect_calls)
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
assert len(connect_calls) == prev_connect_count, "No extra connect made for same source"
def test_progressbar_queue_update(scan_progressbar):
"""
Test that an empty queue update does not change the progress source.
"""
msg = messages.ScanQueueStatusMessage(queue={"primary": {"info": [], "status": "RUNNING"}})
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_not_called()
def test_progressbar_queue_update_with_scan(scan_progressbar):
"""
Test that a queue update with a scan changes the progress source to SCAN_PROGRESS.
"""
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": {
"info": [
{
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
"status": "RUNNING",
"active_request_block": {
"msg": messages.ScanQueueMessage(
metadata={
"file_suffix": None,
"file_directory": None,
"user_metadata": {"sample_name": ""},
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
},
scan_type="line_scan",
parameter={
"args": {"samx": [-10.0, 10.0]},
"kwargs": {
"steps": 20,
"relative": False,
"exp_time": 0.1,
"burst_at_each_point": 1,
"system_config": {
"file_suffix": None,
"file_directory": None,
},
},
},
queue="primary",
),
"scan_number": 1,
"report_instructions": [{"scan_progress": 20}],
},
}
],
"status": "RUNNING",
}
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_called_once_with(ProgressSource.SCAN_PROGRESS)
def test_progressbar_queue_update_with_device(scan_progressbar):
"""
Test that a queue update with a device changes the progress source to DEVICE_PROGRESS.
"""
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": {
"info": [
{
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
"status": "RUNNING",
"active_request_block": {
"msg": messages.ScanQueueMessage(
metadata={
"file_suffix": None,
"file_directory": None,
"user_metadata": {"sample_name": ""},
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
},
scan_type="line_scan",
parameter={
"args": {"samx": [-10.0, 10.0]},
"kwargs": {
"steps": 20,
"relative": False,
"exp_time": 0.1,
"burst_at_each_point": 1,
"system_config": {
"file_suffix": None,
"file_directory": None,
},
},
},
queue="primary",
),
"scan_number": 1,
"report_instructions": [{"device_progress": ["samx"]}],
},
}
],
"status": "RUNNING",
}
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_called_once_with(ProgressSource.DEVICE_PROGRESS, device="samx")
def test_progressbar_queue_update_with_no_scan_or_device(scan_progressbar):
"""
Test that a queue update with neither scan nor device does not change the progress source.
"""
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": {
"info": [
{
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
"status": "RUNNING",
"active_request_block": {
"msg": messages.ScanQueueMessage(
metadata={
"file_suffix": None,
"file_directory": None,
"user_metadata": {"sample_name": ""},
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
},
scan_type="line_scan",
parameter={
"args": {"samx": [-10.0, 10.0]},
"kwargs": {
"steps": 20,
"relative": False,
"exp_time": 0.1,
"burst_at_each_point": 1,
"system_config": {
"file_suffix": None,
"file_directory": None,
},
},
},
queue="primary",
),
"scan_number": 1,
},
}
],
"status": "RUNNING",
}
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_not_called()
+3
View File
@@ -121,11 +121,13 @@ def test_custom_label(signal_label: SignalLabel, qtbot):
def test_units_in_display(signal_label: SignalLabel, qtbot): def test_units_in_display(signal_label: SignalLabel, qtbot):
signal_label._value = "1.8" signal_label._value = "1.8"
signal_label._dtype = "float"
signal_label.custom_units = "Mfurlong μfortnight⁻¹" signal_label.custom_units = "Mfurlong μfortnight⁻¹"
assert signal_label._display.text() == "1.800 Mfurlong μfortnight⁻¹" assert signal_label._display.text() == "1.800 Mfurlong μfortnight⁻¹"
def test_decimal_places(signal_label: SignalLabel, qtbot): def test_decimal_places(signal_label: SignalLabel, qtbot):
signal_label._dtype = "float"
signal_label.decimal_places = 2 signal_label.decimal_places = 2
signal_label.set_display_value("123.456") signal_label.set_display_value("123.456")
assert signal_label._display.text() == "123.46 m/s" assert signal_label._display.text() == "123.46 m/s"
@@ -226,6 +228,7 @@ def test_handle_readback(signal_label: SignalLabel, qtbot):
signal_label.device = "samx" signal_label.device = "samx"
signal_label.signal = "readback" signal_label.signal = "readback"
signal_label.custom_units = "μm" signal_label.custom_units = "μm"
signal_label._dtype = "float"
signal_label.on_device_readback({"random": {"stuff": "in", "corrupted": "reading"}}, {}) signal_label.on_device_readback({"random": {"stuff": "in", "corrupted": "reading"}}, {})
assert signal_label._display.text() == "ERROR!" assert signal_label._display.text() == "ERROR!"
assert "Error processing incoming reading" in signal_label._display.toolTip() assert "Error processing incoming reading" in signal_label._display.toolTip()
@@ -806,6 +806,13 @@ def test_show_curve_settings_popup(qtbot, mocked_client):
assert wf.curve_settings_dialog.isVisible() assert wf.curve_settings_dialog.isVisible()
assert curve_action.isChecked() assert curve_action.isChecked()
# add a new row to the curve tree
wf.curve_settings_dialog.widget.curve_manager.toolbar.widgets["add"].action.trigger()
wf.curve_settings_dialog.widget.curve_manager.toolbar.widgets["add"].action.trigger()
qtbot.wait(100)
# Check that the new row is added
assert wf.curve_settings_dialog.widget.curve_manager.tree.model().rowCount() == 2
wf.curve_settings_dialog.close() wf.curve_settings_dialog.close()
assert wf.curve_settings_dialog is None assert wf.curve_settings_dialog is None
assert not curve_action.isChecked(), "Should be unchecked after closing dialog" assert not curve_action.isChecked(), "Should be unchecked after closing dialog"
+4 -1
View File
@@ -23,4 +23,7 @@ def test_website_widget_set_url(website_widget):
website_widget.set_url("https://google.com") website_widget.set_url("https://google.com")
website_widget.wait_until_loaded() website_widget.wait_until_loaded()
assert website_widget.get_url() == "https://www.google.com/" # in case we get https://www.google.com/sorry/index?continue=https://google.com/&q=...
# because of rate limiting or ddos protections etc
# e.g. https://github.com/bec-project/bec_widgets/actions/runs/15675153971/job/44172519713?pr=686
assert website_widget.get_url().startswith("https://www.google.com/")