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

Compare commits

...

137 Commits

Author SHA1 Message Date
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
09c5a443aa fix(waveform): fix waveform categorisation for aborted scans 2025-06-30 13:52:19 +02:00
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
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
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
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
e841468892 refactor(curve settings): move signal logic to SignalCombobox 2025-06-26 15:02:31 +02:00
48a0e5831f fix(curve_settings): larger minimalWidth for the x device combobox selection 2025-06-26 15:02:31 +02:00
1e9dd4cd25 test(curve settings): add curve tree elements to the dialog test 2025-06-26 15:02:31 +02:00
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
bc3085ab8c fix(curve tree): remove manual interception of the close event; call parent cleanup 2025-06-26 09:12:35 +02:00
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
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
9f3dcc3ab3 build: bec_lib 3.44 required 2025-06-23 16:17:59 +02:00
57f75bd4d5 refactor(scan_control): request_last_executed_scan_parameters logic adjusted 2025-06-23 16:17:59 +02:00
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
7484f5160c fix(launch_window): number of remaining connections extended to 4 2025-06-23 16:06:27 +02:00
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
d5a40dabc7 fix(ci): extend check for pyside import to tests 2025-06-23 14:54:06 +02:00
f3da6e959e feat: (#494) add signal display to device browser 2025-06-23 14:54:06 +02:00
3a103410e7 feat: (#494) display device signals 2025-06-23 14:54:06 +02:00
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
6e2f2cea91 refactor(device input): refactor to SafeProperty and SafeSlot 2025-06-22 19:39:19 +02:00
eea5f7ebbd feat(curve settings): add combobox selection for device and signal 2025-06-22 19:39:19 +02:00
a9708f6d8f fix(curve settings): add initial size hint 2025-06-22 19:39:19 +02:00
b51de1a00e feat(signal combobox): add reset_selection slot 2025-06-22 19:39:19 +02:00
8e8acd672c feat(FilterIO): add support for item data 2025-06-22 19:39:19 +02:00
4c2c0c5525 feat(device combobox): emit reset event if validation fails 2025-06-22 19:39:19 +02:00
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
a4274ff8cd build: update min dependency of bec to 3.42.4 2025-06-22 15:32:45 +02:00
b2a46e284d test(scan progress): add test for queue update logic 2025-06-22 15:32:45 +02:00
9ff170660e feat(main_window): timer to show hide scan progress when it is relevant only 2025-06-22 15:32:45 +02:00
6c04eac18c test(scan_progress): tests extended 2025-06-22 15:32:45 +02:00
aca6efb567 fix(main_window): labels and sizing of scan progress adopted 2025-06-22 15:32:45 +02:00
88b42e49e3 fix(scan_progressbar): mapping of bec progress states to the progressbar enums 2025-06-22 15:32:45 +02:00
d3a9e0903a feat(progressbar): state setting and dynamic corner radius 2025-06-22 15:32:45 +02:00
3bbb8daa24 fix(launch_window): number of remaining connections increase to 2 to include the ScanProgressBar 2025-06-22 15:32:45 +02:00
e8ae9725fa fix(scan_progressbar): cleanup adjusted 2025-06-22 15:32:45 +02:00
497e394deb feat(main_window): added scan progress bar to BECMainWindow status bar 2025-06-22 15:32:45 +02:00
d5ca7b8433 feat(scan_progressbar): added oneline design for compact applications 2025-06-22 15:32:45 +02:00
b02c870dbf fix(bec_progressbar): layout and sizing adjustments 2025-06-22 15:32:45 +02:00
92d0ffee65 refactor(progressbar): change slot / property to safeslot / safeproperty 2025-06-22 15:32:45 +02:00
c4b85381a4 feat(scan_progressbar): added progressbar with hooks to scan progress and device progress 2025-06-22 15:32:45 +02:00
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
3146d98c57 test(utils): DMMock can fetch get_bec_signals method 2025-06-20 14:25:27 +02:00
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
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
11131ef14c fix: adjust height of list widget 2025-06-17 15:32:24 +01:00
5e4c129af6 fix: parse config on submission and reload after 2025-06-17 15:32:24 +01:00
4d8c07cdd1 fix: make website test robust 2025-06-17 15:32:24 +01:00
8f4c8e45b3 fix: tidy up form widget formatting 2025-06-17 15:32:24 +01:00
5623547e92 fix: reset dict table properly 2025-06-17 15:32:24 +01:00
be73349c70 feat: add set form item 2025-06-17 15:32:24 +01:00
1a350c3b16 fix: put waiting in thread 2025-06-17 15:32:24 +01:00
138d4cabbd feat: generate combobox for literal str 2025-06-17 15:32:24 +01:00
b0d03c0648 refactor: rename field widgets 2025-06-17 15:32:24 +01:00
a9613a07b0 test: add tests for config dialog 2025-06-17 15:32:24 +01:00
886964bb54 feat: allow editing device config from browser 2025-06-17 15:32:24 +01:00
7fc85bac7f feat: add a widget to edit lists in forms 2025-06-17 15:32:24 +01:00
d626caae3d perf: replace wait with waitUntil 2025-06-17 15:32:24 +01:00
dea2568de3 fix: scale dict widget height 2025-06-17 15:32:24 +01:00
a55f561971 fix: pass on kwargs from PydanticModelForm 2025-06-17 15:32:24 +01:00
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
187bf493a5 fix(main_window): added expiration timer for scroll label for ClientInfoMessage 2025-06-16 17:18:52 +02:00
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
30acc4c236 test(main_window): BECMainWindow tests extended 2025-06-15 12:38:56 +02:00
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
57b9a57a63 refactor(main_window): app id is displayed as QLabel instead of message 2025-06-15 12:38:56 +02:00
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
f0d48a0508 refactor(image_roi_tree): shape switch logic adjusted to reduce code repetition 2025-06-13 18:20:37 +02:00
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
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
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
594185dde9 feat(image_roi_tree): lock/unlock rois possible through the ROIPropertyTree 2025-06-10 17:17:31 +02:00
46d7e3f517 feat(roi): rois can be lock to be not moved by mouse 2025-06-10 17:17:31 +02:00
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
9ef418bf55 fix(image_roi): coordinates are emitted correctly when handles are inverted; closes #672 2025-06-10 16:41:59 +02:00
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
3740ac8e32 build: update min dependency of bec to 3.38 2025-06-05 21:06:32 +02:00
edfac87868 fix(crosshair): use objectName instead of config for retrieving the monitor name 2025-06-05 21:06:32 +02:00
271116453d fix(image): preview signals can be used in Image widget; update logic adjusted; closes #683 2025-06-05 21:06:32 +02:00
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
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
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
718116afc3 fix: exclude metadata from RPC 2025-06-04 21:50:54 +02:00
2dda58f7d2 feat: add clickable label util 2025-06-04 21:50:54 +02:00
594912136e fix: grid formatting in TypedForm 2025-06-04 21:50:54 +02:00
5188b38c86 feat: (#493) device browser to display config 2025-06-04 21:50:54 +02:00
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
e0e26c205b fix(device browser): mocks and utils for tests 2025-06-04 21:50:54 +02:00
92d1d6435d feat: (#493) add dict to dynamic form types 2025-06-04 21:50:54 +02:00
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
7eb2f54e0e fix(image layer): add layer main if it does not exist 2025-06-04 14:11:46 +02:00
92b89e7275 refactor(image_base): move default color map to image layer 2025-06-04 14:11:46 +02:00
a4f3117941 refactor(image_item): emit object name with removed signal 2025-06-04 14:11:46 +02:00
3e789ca35b refactor(image_item): removed outdated image item config 2025-06-04 14:11:46 +02:00
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
4a343b2041 feat(image_layer): add default name for image layers 2025-06-04 14:11:46 +02:00
c2b0c8c433 refactor(image): move image item creation to layer manager 2025-06-04 14:11:46 +02:00
8a299a8268 refactor(image): disconnect when layer is removed 2025-06-04 14:11:46 +02:00
99ecf6a18f refactor(image): removed access to image item config 2025-06-04 14:11:46 +02:00
4c0bd977fc fix(image_item): do not disconnect the monitor from within the image item 2025-06-04 14:11:46 +02:00
7c47505c5a test: improve error message for widgets that are not properly cleaned up 2025-06-04 14:11:46 +02:00
e211e4d716 fix(image item): propagate remove call to parent class 2025-06-04 14:11:46 +02:00
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
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
1df6c1925b fix: remove unnecessary PySide imports 2025-06-03 13:56:35 +02:00
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

View File

@@ -14,10 +14,15 @@ jobs:
- name: Run black and isort
run: |
pip install black isort
pip install -e .[dev]
pip install uv
uv pip install --system black isort
uv pip install --system -e .[dev]
black --check --diff --color .
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:
runs-on: ubuntu-latest

15
.github/workflows/stale-issues.yml vendored Normal file
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

View File

@@ -1,6 +1,534 @@
# 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)
### Bug Fixes

View File

@@ -542,7 +542,7 @@ class LaunchWindow(BECMainWindow):
remaining_connections = [
connection for connection in connections.values() if connection.parent_id != self.gui_id
]
return len(remaining_connections) <= 1
return len(remaining_connections) <= 4
def _turn_off_the_lights(self, connections: dict):
"""

View File

@@ -49,6 +49,7 @@ _Widgets = {
"ResetButton": "ResetButton",
"ResumeButton": "ResumeButton",
"RingProgressBar": "RingProgressBar",
"SBBMonitor": "SBBMonitor",
"ScanControl": "ScanControl",
"ScatterWaveform": "ScatterWaveform",
"SignalComboBox": "SignalComboBox",
@@ -474,6 +475,20 @@ class BECProgressBar(RPCBase):
>>> progressbar.label_template = "$value / $percentage %"
"""
@property
@rpc_call
def state(self):
"""
None
"""
@state.setter
@rpc_call
def state(self):
"""
None
"""
@rpc_call
def _get_label(self) -> str:
"""
@@ -530,6 +545,26 @@ class BaseROI(RPCBase):
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":
@@ -639,6 +674,26 @@ class CircularROI(RPCBase):
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":
@@ -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):
"""Image widget for displaying 2D data."""
@@ -1252,16 +1429,16 @@ class Image(RPCBase):
@property
@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
def vrange(self) -> "tuple":
def v_range(self) -> "QPointF":
"""
Get the vrange of the image.
Set the v_range of the main image.
"""
@property
@@ -1459,12 +1636,12 @@ class Image(RPCBase):
@rpc_call
def image(
self,
monitor: "str | None" = None,
monitor: "str | tuple | None" = None,
monitor_type: "Literal['auto', '1d', '2d']" = "auto",
color_map: "str | None" = None,
color_bar: "Literal['simple', 'full'] | None" = None,
vrange: "tuple[int, int] | None" = None,
) -> "ImageItem":
) -> "ImageItem | None":
"""
Set the image source and update the image.
@@ -1489,11 +1666,12 @@ class Image(RPCBase):
@rpc_call
def add_roi(
self,
kind: "Literal['rect', 'circle']" = "rect",
kind: "Literal['rect', 'circle', 'ellipse']" = "rect",
name: "str | None" = None,
line_width: "int | None" = 5,
pos: "tuple[float, float] | None" = (10, 10),
size: "tuple[float, float] | None" = (50, 50),
movable: "bool" = True,
**pg_kwargs,
) -> "RectangularROI | CircularROI":
"""
@@ -1505,6 +1683,7 @@ class Image(RPCBase):
line_width(int): The line width of the ROI.
pos(tuple): The position of the ROI.
size(tuple): The size of the ROI.
movable(bool): Whether the ROI is movable.
**pg_kwargs: Additional arguments for the ROI.
Returns:
@@ -2664,6 +2843,26 @@ class RectangularROI(RPCBase):
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":
@@ -3051,6 +3250,12 @@ class RingProgressBar(RPCBase):
"""
class SBBMonitor(RPCBase):
"""A widget to display the SBB monitor website."""
...
class ScanControl(RPCBase):
"""Widget to submit new scans to the queue."""
@@ -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):
"""Scatter curve item for the scatter waveform widget."""

View File

@@ -19,7 +19,7 @@ class FakeDevice(BECDevice):
"readoutPriority": "baseline",
"deviceClass": "ophyd.Device",
"deviceConfig": {},
"deviceTags": ["user device"],
"deviceTags": {"user device"},
"enabled": enabled,
"readOnly": False,
"name": self.name,
@@ -89,16 +89,28 @@ class FakePositioner(BECPositioner):
"readoutPriority": "baseline",
"deviceClass": "ophyd_devices.SimPositioner",
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
"deviceTags": ["user motors"],
"deviceTags": {"user motors"},
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
self._info = {
"signals": {
"readback": {"kind_str": "hinted"}, # hinted
"setpoint": {"kind_str": "normal"}, # normal
"velocity": {"kind_str": "config"}, # config
"readback": {
"kind_str": "hinted",
"component_name": "readback",
"obj_name": self.name,
}, # hinted
"setpoint": {
"kind_str": "normal",
"component_name": "setpoint",
"obj_name": f"{self.name}_setpoint",
}, # normal
"velocity": {
"kind_str": "config",
"component_name": "velocity",
"obj_name": f"{self.name}_velocity",
}, # config
}
}
self.signals = {
@@ -184,8 +196,8 @@ class FakePositioner(BECPositioner):
class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0):
super().__init__(name, limits, read_value)
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
class Device(FakeDevice):
@@ -210,6 +222,39 @@ class DMMock:
for device in devices:
self.devices[device.name] = device
def get_bec_signals(self, signal_class_name: str):
"""
Emulate DeviceManager.get_bec_signals for unit-tests.
For “AsyncSignal” we list every device whose readout_priority is
ReadoutPriority.ASYNC and build a minimal tuple
(device_name, signal_name, signal_info_dict) that matches the real
API shape used by Waveform._check_async_signal_found.
"""
signals: list[tuple[str, str, dict]] = []
if signal_class_name != "AsyncSignal":
return signals
for device in self.devices.values():
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
device_name = device.name
signal_name = device.name # primary signal in our mocks
signal_info = {
"component_name": signal_name,
"obj_name": signal_name,
"kind_str": "hinted",
"signal_class": signal_class_name,
"metadata": {
"connected": True,
"precision": None,
"read_access": True,
"timestamp": 0.0,
"write_access": True,
},
}
signals.append((device_name, signal_name, signal_info))
return signals
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),

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)

View File

@@ -15,12 +15,15 @@ if TYPE_CHECKING: # pragma: no cover
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"):
theme = "dark"
return "dark"
else:
theme = QApplication.instance().theme.theme
return bec_qthemes.load_palette(theme)
return QApplication.instance().theme.theme
def get_theme_palette():
return bec_qthemes.load_palette(get_theme_name())
def get_accent_colors() -> AccentColors | None:

View File

@@ -312,7 +312,7 @@ class Crosshair(QObject):
y_values[name] = closest_y
x_values[name] = closest_x
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
if image_2d is None:
continue
@@ -400,7 +400,7 @@ class Crosshair(QObject):
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
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]
if x is None or y is None:
continue
@@ -458,7 +458,7 @@ class Crosshair(QObject):
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
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]
if x is None or y is None:
continue

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
@@ -12,15 +14,20 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.clickable_label import ClickableLabel
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class ExpandableGroupFrame(QFrame):
expansion_state_changed = Signal()
EXPANDED_ICON_NAME: str = "collapse_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)
self._expanded = expanded
@@ -29,19 +36,33 @@ class ExpandableGroupFrame(QFrame):
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
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._create_title_layout(title, icon)
self._contents = QWidget(self)
self._layout.addWidget(self._contents)
self._expansion_button.clicked.connect(self.switch_expanded_state)
self.expanded = self._expanded # type: ignore
self.expansion_state_changed.emit()
def _create_title_layout(self, title: str, icon: str):
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
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:
self._contents.setLayout(layout)
@@ -50,7 +71,8 @@ class ExpandableGroupFrame(QFrame):
@SafeSlot()
def switch_expanded_state(self):
self.expanded = not self.expanded # type: ignore
self._update_icon()
self._update_expansion_icon()
self.expansion_state_changed.emit()
@SafeProperty(bool)
def expanded(self): # type: ignore
@@ -61,8 +83,9 @@ class ExpandableGroupFrame(QFrame):
self._expanded = expanded
self._contents.setVisible(expanded)
self.updateGeometry()
self.adjustSize()
def _update_icon(self):
def _update_expansion_icon(self):
self._expansion_button.setIcon(
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
if self.expanded
@@ -70,3 +93,36 @@ class ExpandableGroupFrame(QFrame):
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()

View File

@@ -8,6 +8,8 @@ from bec_lib.logger import bec_logger
from qtpy.QtCore import QStringListModel
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
from bec_widgets.utils.ophyd_kind_util import Kind
logger = bec_logger.logger
@@ -15,11 +17,13 @@ class WidgetFilterHandler(ABC):
"""Abstract base class for widget filter handlers"""
@abstractmethod
def set_selection(self, widget, selection: list) -> None:
def set_selection(self, widget, selection: list[str | tuple]) -> None:
"""Set the filtered_selection for the widget
Args:
selection (list): Filtered selection of items
widget: Widget instance
selection (list[str | tuple]): Filtered selection of items.
If tuple, it contains (text, data) pairs.
"""
@abstractmethod
@@ -34,17 +38,37 @@ class WidgetFilterHandler(ABC):
bool: True if the input text is in the filtered selection
"""
@abstractmethod
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
# This method should be implemented in subclasses or extended as needed
class LineEditFilterHandler(WidgetFilterHandler):
"""Handler for QLineEdit widget"""
def set_selection(self, widget: QLineEdit, selection: list) -> None:
def set_selection(self, widget: QLineEdit, selection: list[str | tuple]) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QLineEdit): The QLineEdit widget
selection (list): Filtered selection of items
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
"""
if isinstance(selection, tuple):
# If selection is a tuple, it contains (text, data) pairs
selection = [text for text, _ in selection]
if not isinstance(widget.completer, QCompleter):
completer = QCompleter(widget)
widget.setCompleter(completer)
@@ -64,19 +88,47 @@ class LineEditFilterHandler(WidgetFilterHandler):
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
return text in model_data
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
return [
signal
for signal, signal_info in device_info.items()
if kind in signal_filter and (signal_info.get("kind_str", None) == str(kind.name))
]
class ComboBoxFilterHandler(WidgetFilterHandler):
"""Handler for QComboBox widget"""
def set_selection(self, widget: QComboBox, selection: list) -> None:
def set_selection(self, widget: QComboBox, selection: list[str | tuple]) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QComboBox): The QComboBox widget
selection (list): Filtered selection of items
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
"""
widget.clear()
widget.addItems(selection)
if len(selection) == 0:
return
for element in selection:
if isinstance(element, str):
widget.addItem(element)
elif isinstance(element, tuple):
# If element is a tuple, it contains (text, data) pairs
widget.addItem(*element)
def check_input(self, widget: QComboBox, text: str) -> bool:
"""Check if the input text is in the filtered selection
@@ -90,6 +142,40 @@ class ComboBoxFilterHandler(WidgetFilterHandler):
"""
return text in [widget.itemText(i) for i in range(widget.count())]
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
out = []
for signal, signal_info in device_info.items():
if kind not in signal_filter or (signal_info.get("kind_str", None) != str(kind.name)):
continue
obj_name = signal_info.get("obj_name", "")
component_name = signal_info.get("component_name", "")
signal_wo_device = obj_name.removeprefix(f"{device_name}_")
if not signal_wo_device:
signal_wo_device = obj_name
if signal_wo_device != signal and component_name.replace(".", "_") != signal_wo_device:
# If the object name is not the same as the signal name, we use the object name
# to display in the combobox.
out.append((f"{signal_wo_device} ({signal})", signal_info))
else:
# If the object name is the same as the signal name, we do not change it.
out.append((signal, signal_info))
return out
class FilterIO:
"""Public interface to set filters for input widgets.
@@ -99,13 +185,14 @@ class FilterIO:
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
@staticmethod
def set_selection(widget, selection: list, ignore_errors=True):
def set_selection(widget, selection: list[str | tuple], ignore_errors=True):
"""
Retrieve value from the widget instance.
Args:
widget: Widget instance.
selection(list): List of filtered selection items.
selection (list[str | tuple]): Filtered selection of items.
If tuple, it contains (text, data) pairs.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = FilterIO._find_handler(widget)
@@ -139,6 +226,35 @@ class FilterIO:
)
return None
@staticmethod
def update_with_kind(
widget, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""
Update the selection based on the kind of signal.
Args:
widget: Widget instance.
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().update_with_kind(
kind=kind,
signal_filter=signal_filter,
device_info=device_info,
device_name=device_name,
)
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
@staticmethod
def _find_handler(widget):
"""

View File

@@ -1,76 +1,107 @@
from __future__ import annotations
from decimal import Decimal
from types import NoneType
from typing import NamedTuple
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ValidationError
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QVBoxLayout, QWidget
from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.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
class GridRow(NamedTuple):
i: int
label: QLabel
widget: DynamicFormItem
class TypedForm(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "list_alt"
value_changed = Signal()
RPC = False
RPC = True
USER_ACCESS = ["enabled", "enabled.setter"]
def __init__(
self,
parent=None,
items: list[tuple[str, type]] | None = None,
form_item_specs: list[FormItemSpec] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None,
**kwargs,
):
"""Widget with a list of form items based on a list of types.
Args:
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
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be
supplied.
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
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be
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 (
items is None and form_item_specs is None
):
raise ValueError("Must specify one and only one of items and form_item_specs")
if items is not None and form_item_specs is not None:
logger.error(
"Must specify one and only one of items and form_item_specs! Ignoring `items`."
)
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)
self._items = (
form_item_specs
if form_item_specs is not None
else [
FormItemSpec(name=name, item_type=item_type)
for name, item_type in items # type: ignore
]
)
self._items = form_item_specs or [
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
for name, item_type in items # type: ignore
]
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._enabled: bool = enabled
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.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout())
self._widget_types: dict | None = None
self._widget_from_type = widget_from_type
self._post_init()
def _post_init(self):
"""Override this if a subclass should do things after super().__init__ and before populate()"""
self.populate()
self.enabled = self._enabled # type: ignore # QProperty
def populate(self):
self._clear_grid()
for r, item in enumerate(self._items):
self._add_griditem(item, r)
gl: QGridLayout = self._form_grid.layout()
gl.setRowStretch(gl.rowCount(), 1)
def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout()
@@ -78,19 +109,22 @@ class TypedForm(BECWidget, QWidget):
label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0)
widget = widget_from_type(item.item_type)(parent=self, spec=item)
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
widget.valueChanged.connect(self.value_changed)
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
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
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 {
grid.itemAtPosition(i, 0)
.widget()
.property("_model_field_name"): grid.itemAtPosition(i, 1)
.widget()
.getValue() # type: ignore # we only add 'DynamicFormItem's here
for i in range(grid.rowCount())
row.label.property("_model_field_name"): row.widget.getValue()
for row in self.enumerate_form_widgets()
}
def _clear_grid(self):
@@ -103,10 +137,13 @@ class TypedForm(BECWidget, QWidget):
old_layout.deleteLater()
self._form_grid.deleteLater()
self._form_grid = QWidget()
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid)
self.update_size()
def update_size(self):
self._form_grid.adjustSize()
self._form_grid_container.adjustSize()
self.adjustSize()
@@ -114,23 +151,56 @@ class TypedForm(BECWidget, QWidget):
def _new_grid_layout(self):
new_grid = QGridLayout()
new_grid.setContentsMargins(0, 0, 0, 0)
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
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):
metadata_updated = Signal(dict)
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.
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
super().__init__(parent=parent, form_item_specs=self._form_item_specs(), client=client)
self._pretty_display = pretty_display
self._md_schema = data_model
super().__init__(
parent=parent,
form_item_specs=self._form_item_specs(),
enabled=enabled,
client=client,
**kwargs,
)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
@@ -143,13 +213,40 @@ class PydanticModelForm(TypedForm):
self._layout.addWidget(self._validity)
self.value_changed.connect(self.validate_form)
self._connect_to_theme_change()
def set_pretty_display_theme(self, theme: str = "dark"):
if self._pretty_display:
self.setStyleSheet(styles.pretty_display_theme(theme))
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
def set_schema(self, schema: type[BaseModel]):
self._md_schema = schema
self.populate()
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):
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()
]

View File

@@ -1,31 +1,44 @@
from __future__ import annotations
import typing
from abc import abstractmethod
from decimal import Decimal
from types import UnionType
from typing import Callable, Protocol
from types import GenericAlias, UnionType
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic.fields import FieldInfo
from qtpy.QtCore import Signal # type: ignore
from pydantic_core import PydanticUndefined
from qtpy import QtCore
from qtpy.QtCore import QSize, Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics
from qtpy.QtWidgets import (
QApplication,
QButtonGroup,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QGridLayout,
QHBoxLayout,
QLabel,
QLayout,
QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
QRadioButton,
QSizePolicy,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
from bec_widgets.widgets.editors.scan_metadata._util import (
clearable_required,
field_default,
@@ -34,6 +47,7 @@ from bec_widgets.widgets.editors.scan_metadata._util import (
field_minlen,
field_precision,
)
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
logger = bec_logger.logger
@@ -46,9 +60,36 @@ class FormItemSpec(BaseModel):
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType
item_type: type | UnionType | GenericAlias
name: str
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):
@@ -94,10 +135,20 @@ class ClearableBoolEntry(QWidget):
self._false.setToolTip(tooltip)
DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None
class DynamicFormItem(QWidget):
valueChanged = Signal()
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)
self._spec = spec
self._layout = QHBoxLayout()
@@ -107,11 +158,17 @@ class DynamicFormItem(QWidget):
self._desc = self._spec.info.description
self.setLayout(self._layout)
self._add_main_widget()
if clearable_required(spec.info):
self._add_clear_button()
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
if not spec.pretty_display:
if clearable_required(spec.info):
self._add_clear_button()
else:
self._set_pretty_display()
@abstractmethod
def getValue(self): ...
def getValue(self) -> DynamicFormItemType: ...
@abstractmethod
def setValue(self, value): ...
@@ -121,6 +178,11 @@ class DynamicFormItem(QWidget):
"""Add the main data entry widget to self._main_widget and appply any
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=" "):
return pad + (self._desc if self._desc else "")
@@ -138,7 +200,7 @@ class DynamicFormItem(QWidget):
self.valueChanged.emit()
class StrMetadataField(DynamicFormItem):
class StrFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
@@ -163,11 +225,11 @@ class StrMetadataField(DynamicFormItem):
def setValue(self, value: str):
if value is None:
self._main_widget.setText("")
self._main_widget.setText(value)
return self._main_widget.setText("")
self._main_widget.setText(str(value))
class IntMetadataField(DynamicFormItem):
class IntFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
@@ -196,18 +258,18 @@ class IntMetadataField(DynamicFormItem):
self._main_widget.setValue(value)
class FloatDecimalMetadataField(DynamicFormItem):
class FloatDecimalFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
precision = field_precision(self._spec.info)
self._main_widget = QDoubleSpinBox()
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.setMaximum(max_)
precision = field_precision(self._spec.info)
if precision:
self._main_widget.setDecimals(precision)
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._main_widget.value()
def setValue(self, value: float):
def setValue(self, value: float | Decimal):
if value is None:
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:
super().__init__(parent=parent, spec=spec)
self._main_widget.stateChanged.connect(self._value_changed)
@@ -251,36 +313,300 @@ class BoolMetadataField(DynamicFormItem):
self._main_widget.setChecked(value)
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
if annotation in [str, str | None]:
return StrMetadataField
if annotation in [int, int | None]:
return IntMetadataField
if annotation in [float, float | None, Decimal, Decimal | None]:
return FloatDecimalMetadataField
if annotation in [bool, bool | None]:
return BoolMetadataField
else:
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
return StrMetadataField
class BoolToggleFormItem(BoolFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
if spec.info.default is PydanticUndefined:
spec.info.default = False
super().__init__(parent=parent, spec=spec)
def _add_main_widget(self) -> None:
self._main_widget = ToggleSwitch()
self._layout.addWidget(self._main_widget)
self._main_widget.setToolTip(self._describe(""))
if self._default is not None:
self._main_widget.setChecked(self._default)
class DictFormItem(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.data_changed.connect(self._value_changed)
if spec.info.default is not PydanticUndefined:
self._main_widget.set_default(spec.info.default)
def _set_pretty_display(self):
self._main_widget.set_button_visibility(False)
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
class TestModel(BaseModel):
value0: set = Field(set(["a", "b"]))
value1: str | None = Field(None)
value2: bool | None = Field(None)
value3: bool = Field(True)
value4: int = Field(123)
value5: int | None = Field()
value6: list[int] = Field()
value7: list = Field()
app = QApplication([])
w = QWidget()
layout = QGridLayout()
w.setLayout(layout)
items = []
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
layout.addWidget(QLabel(field_name), i, 0)
layout.addWidget(widget_from_type(info.annotation)(info), i, 1)
widg = widget_from_type(spec)(spec=spec)
items.append(widg)
layout.addWidget(widg, i, 1)
items[6].setValue([1, 2, 3, 4])
items[7].setValue(["1", "2", "asdfg", "qwerty"])
w.show()
app.exec()

View File

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

View File

@@ -8,6 +8,9 @@ from qtpy.QtCore import QObject
from bec_widgets.utils.name_utils import pascal_to_snake
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):
@@ -90,34 +93,20 @@ class DesignerPluginGenerator:
# Check if the widget class calls the super constructor with parent argument
init_source = inspect.getsource(self.widget.__init__)
cls_init_found = (
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
)
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)
class_re = re.compile(base_cls[0].__name__ + _SELF_PARENT_ARG_REGEX, re.MULTILINE)
cls_init_found = class_re.search(init_source) is not None
super_self_re = re.compile(
rf"super\({base_cls[0].__name__}, self\)" + _PARENT_ARG_REGEX, re.MULTILINE
)
super_init_found = super_self_re.search(init_source) is not None
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
super_init_found = (
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)
)
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
# 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)
num_inits = re.findall(r"__init__", init_source)
if len(num_inits) == 2 and not super_init_found:
super_init_found = bool(
init_source.find("super().__init__(parent=parent") > 0
or init_source.find("super().__init__(parent,") > 0
or init_source.find("super().__init__(parent)") > 0
)
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
if not cls_init_found and not super_init_found:
raise ValueError(

View File

@@ -1,5 +1,5 @@
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 bec_widgets.utils.error_popups import SafeSlot

View File

@@ -9,7 +9,7 @@ from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger
if PYSIDE6:
from PySide6.QtUiTools import QUiLoader
from qtpy.QtUiTools import QUiLoader
class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance, custom_widgets: dict | None = None):

View File

@@ -169,6 +169,9 @@ class BECDockArea(BECWidget, QWidget):
tooltip="Add LogPanel - Disabled",
filled=True,
),
"sbb_monitor": MaterialIconAction(
icon_name="train", tooltip="Add SBB Monitor", filled=True
),
},
),
"separator_2": SeparatorAction(),
@@ -238,6 +241,9 @@ class BECDockArea(BECWidget, QWidget):
# self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
# lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
# )
self.toolbar.widgets["menu_utils"].widgets["sbb_monitor"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor")
)
# Icons
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)

View File

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

View File

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

View File

@@ -1,17 +1,31 @@
from __future__ import annotations
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.QtWidgets import QApplication, QMainWindow, QStyle
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QStyle,
QVBoxLayout,
QWidget,
)
import bec_widgets
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
@@ -19,6 +33,8 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECMainWindow(BECWidget, QMainWindow):
RPC = False
PLUGIN = False
SCAN_PROGRESS_WIDTH = 100 # px
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
def __init__(
self,
@@ -32,10 +48,19 @@ class BECMainWindow(BECWidget, QMainWindow):
super().__init__(parent=parent, gui_id=gui_id, **kwargs)
self.app = QApplication.instance()
self.status_bar = self.statusBar()
self.setWindowTitle(window_title)
self._init_ui()
self._connect_to_theme_change()
# Connections to BEC Notifications
self.bec_dispatcher.connect_slot(
self.display_client_message, MessageEndpoints.client_info()
)
################################################################################
# MainWindow Elements Initialization
################################################################################
def _init_ui(self):
# Set the icon
@@ -43,40 +68,189 @@ class BECMainWindow(BECWidget, QMainWindow):
# Set Menu and Status bar
self._setup_menu_bar()
self._init_status_bar_widgets()
# BEC Specific UI
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):
icon = self.app.windowIcon()
if icon.isNull():
print("No icon is set, setting default icon")
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.app.setWindowIcon(icon)
else:
print("An icon is set")
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
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:
return self.app.theme.theme
@@ -164,14 +338,64 @@ class BECMainWindow(BECWidget, QMainWindow):
help_menu.addAction(widgets_docs)
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)
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)
def event(self, event):
if event.type() == QEvent.Type.StatusTip:
return True
return super().event(event)
def cleanup(self):
central_widget = self.centralWidget()
central_widget.close()
central_widget.deleteLater()
if central_widget is not None:
central_widget.close()
central_widget.deleteLater()
if not isinstance(central_widget, BECWidget):
# if the central widget is not a BECWidget, we need to call the cleanup method
# of all widgets whose parent is the current BECMainWindow
@@ -182,8 +406,39 @@ class BECMainWindow(BECWidget, QMainWindow):
child.cleanup()
child.close()
child.deleteLater()
# Timer cleanup
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop()
if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
########################################
# Status bar widgets cleanup
# Client info label cleanup
self._client_info_label.cleanup()
self._client_info_hover.close()
self._client_info_hover.deleteLater()
# Scan progress bar cleanup
self._scan_progress_bar_simple.close()
self._scan_progress_bar_simple.deleteLater()
self._scan_progress_bar_full.close()
self._scan_progress_bar_full.deleteLater()
self._scan_progress_hover.close()
self._scan_progress_hover.deleteLater()
super().cleanup()
class UILaunchWindow(BECMainWindow):
RPC = True
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
main_window = UILaunchWindow()
main_window.show()
main_window.resize(800, 600)
sys.exit(app.exec())

View File

@@ -6,10 +6,10 @@ from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger
from pydantic import field_validator
from qtpy.QtCore import Property, Signal, Slot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.widget_io import WidgetIO
@@ -100,7 +100,7 @@ class DeviceInputBase(BECWidget):
### QtSlots ###
@Slot(str)
@SafeSlot(str)
def set_device(self, device: str):
"""
Set the device.
@@ -114,7 +114,7 @@ class DeviceInputBase(BECWidget):
else:
logger.warning(f"Device {device} is not in the filtered selection.")
@Slot()
@SafeSlot()
def update_devices_from_filters(self):
"""Update the devices based on the current filter selection
in self.device_filter and self.readout_filter. If apply_filter is False,
@@ -133,7 +133,7 @@ class DeviceInputBase(BECWidget):
self.devices = [device.name for device in devs]
self.set_device(current_device)
@Slot(list)
@SafeSlot(list)
def set_available_devices(self, devices: list[str]):
"""
Set the devices. If a device in the list is not valid, it will not be considered.
@@ -146,7 +146,7 @@ class DeviceInputBase(BECWidget):
### QtProperties ###
@Property(
@SafeProperty(
"QStringList",
doc="List of devices. If updated, it will disable the apply filters property.",
)
@@ -165,7 +165,7 @@ class DeviceInputBase(BECWidget):
self.config.devices = value
FilterIO.set_selection(widget=self, selection=value)
@Property(str)
@SafeProperty(str)
def default(self):
"""Get the default device name. If set through this property, it will update only if the device is within the filtered selection."""
return self.config.default
@@ -177,7 +177,7 @@ class DeviceInputBase(BECWidget):
self.config.default = value
WidgetIO.set_value(widget=self, value=value)
@Property(bool)
@SafeProperty(bool)
def apply_filter(self):
"""Apply the filters on the devices."""
return self.config.apply_filter
@@ -187,7 +187,7 @@ class DeviceInputBase(BECWidget):
self.config.apply_filter = value
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def filter_to_device(self):
"""Include devices in filters."""
return BECDeviceFilter.DEVICE in self.device_filter
@@ -200,7 +200,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.DEVICE)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def filter_to_positioner(self):
"""Include devices of type Positioner in filters."""
return BECDeviceFilter.POSITIONER in self.device_filter
@@ -213,7 +213,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.POSITIONER)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def filter_to_signal(self):
"""Include devices of type Signal in filters."""
return BECDeviceFilter.SIGNAL in self.device_filter
@@ -226,7 +226,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.SIGNAL)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def filter_to_computed_signal(self):
"""Include devices of type ComputedSignal in filters."""
return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter
@@ -239,7 +239,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def readout_monitored(self):
"""Include devices with readout priority Monitored in filters."""
return ReadoutPriority.MONITORED in self.readout_filter
@@ -252,7 +252,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.MONITORED)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def readout_baseline(self):
"""Include devices with readout priority Baseline in filters."""
return ReadoutPriority.BASELINE in self.readout_filter
@@ -265,7 +265,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.BASELINE)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def readout_async(self):
"""Include devices with readout priority Async in filters."""
return ReadoutPriority.ASYNC in self.readout_filter
@@ -278,7 +278,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.ASYNC)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def readout_continuous(self):
"""Include devices with readout priority continuous in filters."""
return ReadoutPriority.CONTINUOUS in self.readout_filter
@@ -291,7 +291,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.CONTINUOUS)
self.update_devices_from_filters()
@Property(bool)
@SafeProperty(bool)
def readout_on_request(self):
"""Include devices with readout priority OnRequest in filters."""
return ReadoutPriority.ON_REQUEST in self.readout_filter

View File

@@ -6,7 +6,7 @@ from qtpy.QtCore import Property
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.filter_io import FilterIO, LineEditFilterHandler
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.utils.widget_io import WidgetIO
@@ -55,7 +55,7 @@ class DeviceSignalInputBase(BECWidget):
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
self.bec_dispatcher.client.callbacks.register(
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.update_signals_from_filters
)
@@ -108,25 +108,32 @@ class DeviceSignalInputBase(BECWidget):
if not self.validate_device(self._device):
self._device = None
self.config.device = self._device
return
device = self.get_device_object(self._device)
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [self._device]
self._hinted_signals = [self._device]
self._signals = []
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
device = self.get_device_object(self._device)
device_info = device._info.get("signals", {})
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [(self._device, {})]
self._hinted_signals = [(self._device, {})]
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
def _update(kind: Kind):
return [
signal
for signal, signal_info in device_info.items()
if kind in self.signal_filter
and (signal_info.get("kind_str", None) == str(kind.name))
]
return FilterIO.update_with_kind(
widget=self,
kind=kind,
signal_filter=self.signal_filter,
device_info=device_info,
device_name=self._device,
)
self._hinted_signals = _update(Kind.hinted)
self._normal_signals = _update(Kind.normal)
@@ -271,11 +278,21 @@ class DeviceSignalInputBase(BECWidget):
Args:
signal(str): Signal to validate.
"""
if signal in self.signals:
return True
for entry in self.signals:
if isinstance(entry, tuple):
entry = entry[0]
if entry == signal:
return True
return False
def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None):
if config is None:
return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
return DeviceSignalInputBaseConfig.model_validate(config)
def cleanup(self):
"""
Cleanup the widget.
"""
self.bec_dispatcher.client.callbacks.remove(self._device_update_register)
super().cleanup()

View File

@@ -34,6 +34,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
PLUGIN = True
device_selected = Signal(str)
device_reset = Signal()
device_config_update = Signal()
def __init__(
@@ -147,8 +148,28 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self.device_selected.emit(input_text)
else:
self._is_valid_input = False
self.device_reset.emit()
self.update()
def validate_device(self, device: str) -> bool: # type: ignore[override]
"""
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
# pylint: disable=import-outside-toplevel

View File

@@ -90,6 +90,44 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False)
def set_to_obj_name(self, obj_name: str) -> bool:
"""
Set the combobox to the object name of the signal.
Args:
obj_name (str): Object name of the signal.
Returns:
bool: True if the object name was found and set, False otherwise.
"""
for i in range(self.count()):
signal_data = self.itemData(i)
if signal_data and signal_data.get("obj_name") == obj_name:
self.setCurrentIndex(i)
return True
return False
def set_to_first_enabled(self) -> bool:
"""
Set the combobox to the first enabled item.
Returns:
bool: True if an enabled item was found and set, False otherwise.
"""
for i in range(self.count()):
if self.model().item(i).isEnabled():
self.setCurrentIndex(i)
return True
return False
@SafeSlot()
def reset_selection(self):
"""Reset the selection of the combobox."""
self.clear()
self.setItemText(0, "Select a device")
self.update_signals_from_filters()
self.device_signal_changed.emit("")
@SafeSlot(str)
def on_text_changed(self, text: str):
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
@@ -102,11 +140,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
return
if self.validate_signal(text) is False:
return
if text == "readback" and isinstance(self.get_device_object(self.device), Positioner):
device_signal = self.device
else:
device_signal = f"{self.device}_{text}"
self.device_signal_changed.emit(device_signal)
self.device_signal_changed.emit(text)
if __name__ == "__main__": # pragma: no cover

View File

@@ -89,6 +89,7 @@ class ScanControl(BECWidget, QWidget):
self.config.allowed_scans = allowed_scans
self._scan_metadata: dict | None = None
self._metadata_form = ScanMetadata(parent=self)
# Create and set main layout
self._init_UI()
@@ -165,7 +166,6 @@ class ScanControl(BECWidget, QWidget):
self.layout.addStretch()
def _add_metadata_form(self):
self._metadata_form = ScanMetadata(parent=self)
self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
@@ -203,35 +203,40 @@ class ScanControl(BECWidget, QWidget):
"""
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
"""
enabled = self.toggle.checked
current_scan = self.comboBox_scan_selection.currentText()
if enabled:
history = self.client.connector.lrange(MessageEndpoints.scan_queue_history(), 0, -1)
self.last_scan_found = False
if not self.toggle.checked:
return
for scan in history:
scan_name = scan.content["info"]["request_blocks"][-1]["msg"].content["scan_type"]
if scan_name == current_scan:
args_dict = scan.content["info"]["request_blocks"][-1]["msg"].content[
"parameter"
]["args"]
args_list = []
for key, value in args_dict.items():
args_list.append(key)
args_list.extend(value)
if len(args_list) > 1 and self.arg_box is not None:
self.arg_box.set_parameters(args_list)
kwargs = scan.content["info"]["request_blocks"][-1]["msg"].content["parameter"][
"kwargs"
]
if kwargs and self.kwarg_boxes:
for box in self.kwarg_boxes:
box.set_parameters(kwargs)
self.last_scan_found = True
break
else:
self.last_scan_found = False
else:
self.last_scan_found = False
current_scan = self.comboBox_scan_selection.currentText()
history = (
self.client.connector.xread(
MessageEndpoints.scan_history(), from_start=True, user_id=self.object_name
)
or []
)
for scan in reversed(history):
scan_data = scan.get("data")
if not scan_data:
continue
if scan_data.scan_name != current_scan:
continue
ri = getattr(scan_data, "request_inputs", {}) or {}
args_list = ri.get("arg_bundle", [])
if args_list and self.arg_box:
self.arg_box.set_parameters(args_list)
inputs = ri.get("inputs", {})
kwargs = ri.get("kwargs", {})
merged = {**inputs, **kwargs}
if merged and self.kwarg_boxes:
for box in self.kwarg_boxes:
box.set_parameters(merged)
self.last_scan_found = True
break
@SafeProperty(str)
def current_scan(self):

View File

@@ -2,7 +2,9 @@ from __future__ import annotations
from typing import Any
from qtpy import QtWidgets
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics
from qtpy.QtWidgets import (
QApplication,
QHBoxLayout,
@@ -13,7 +15,9 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
_NOT_SET = object()
class DictBackedTableModel(QAbstractTableModel):
@@ -25,6 +29,7 @@ class DictBackedTableModel(QAbstractTableModel):
data (list[list[str]]): list of key-value pairs to initialise with"""
super().__init__()
self._data: list[list[str]] = data
self._default = _NOT_SET
self._disallowed_keys: list[str] = []
# pylint: disable=missing-function-docstring
@@ -45,8 +50,15 @@ class DictBackedTableModel(QAbstractTableModel):
def data(self, index, role=Qt.ItemDataRole):
if index.isValid():
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
return str(self._data[index.row()][index.column()])
if role in [
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):
if role == Qt.ItemDataRole.EditRole:
@@ -57,6 +69,12 @@ class DictBackedTableModel(QAbstractTableModel):
return True
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]):
"""Set the list of keys which may not be used.
@@ -66,7 +84,7 @@ class DictBackedTableModel(QAbstractTableModel):
for i, item in enumerate(self._data):
if item[0] in self._disallowed_keys:
self._data[i][0] = ""
self.dataChanged.emit(self.index(i, 0), self.index(i, 0))
self.dataChanged.emit(self.index(i, 0), self.index(i, 1))
def _other_keys(self, row: int):
return [r[0] for r in self._data[:row] + self._data[row + 1 :]]
@@ -95,45 +113,74 @@ class DictBackedTableModel(QAbstractTableModel):
@SafeSlot()
def add_row(self):
self.insertRow(self.rowCount())
self.dataChanged.emit(self.index(self.rowCount(), 0), self.index(self.rowCount(), 1), 0)
@SafeSlot(list)
def delete_rows(self, rows: list[int]):
# delete from the end so indices stay correct
for row in sorted(rows, reverse=True):
self.dataChanged.emit(self.index(row, 0), self.index(row, 1), 0)
self.removeRows(row, 1, QModelIndex())
def set_default(self, value: dict | None):
self._default = value
def dump_dict(self):
if self._data == [[]]:
if self._data in [[], [[]], [["", ""]]]:
if self._default is not _NOT_SET:
return self._default
return {}
return dict(self._data)
def length(self):
return len(self._data)
class DictBackedTable(QWidget):
delete_rows = Signal(list)
data_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
which can be extracted as a dict.
Args:
initial_data (list[list[str]]): list of key-value pairs to initialise with
"""
super().__init__()
super().__init__(parent)
self._layout = QHBoxLayout()
self.setLayout(self._layout)
self._layout.setContentsMargins(0, 0, 0, 0)
self._table_model = DictBackedTableModel(initial_data)
self._table_view = QTreeView()
self._table_view.setModel(self._table_model)
self._min_lines = 3
self.set_height_in_lines(len(initial_data))
self._table_view.setSizePolicy(
QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
)
self._table_view.setAlternatingRowColors(True)
self._table_view.setUniformRowHeights(True)
self._table_view.setWordWrap(False)
self._table_view.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
self._table_view.header().setSectionResizeMode(5, QtWidgets.QHeaderView.Stretch)
self.autoscale = autoscale_to_data
if self.autoscale:
self.data_changed.connect(self.scale_to_data)
self._layout.addWidget(self._table_view)
self._button_holder = QWidget()
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.setToolTip("add a new row")
self._remove_button = QPushButton("-")
@@ -143,11 +190,21 @@ class DictBackedTable(QWidget):
self._add_button.clicked.connect(self._table_model.add_row)
self._remove_button.clicked.connect(self.delete_selected_rows)
self.delete_rows.connect(self._table_model.delete_rows)
self._table_model.dataChanged.connect(self._emit_data_updated)
def _emit_data_updated(self, *args, **kwargs):
"""Just to swallow the args"""
self.data_updated.emit()
self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict()))
def set_default(self, value: dict | None):
self._table_model.set_default(value)
def set_button_visibility(self, value: bool):
self._button_holder.setVisible(value)
@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):
"""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."""
self._table_model.update_disallowed_keys(keys)
def set_height_in_lines(self, lines: int):
self._table_view.setMaximumHeight(
int(QFontMetrics(self._table_view.font()).height() * max(lines + 2, self._min_lines))
)
@SafeSlot()
@SafeSlot(dict)
def scale_to_data(self, *_):
self.set_height_in_lines(self._table_model.length())
@SafeProperty(bool)
def autoscale(self): # type: ignore
return self._autoscale
@autoscale.setter
def autoscale(self, autoscale: bool):
self._autoscale = autoscale
if self._autoscale:
self.scale_to_data()
self.data_changed.connect(self.scale_to_data)
else:
self.data_changed.disconnect(self.scale_to_data)
if __name__ == "__main__": # pragma: no cover
from bec_widgets.utils.colors import set_theme
@@ -174,6 +254,6 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication([])
set_theme("dark")
window = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window.show()
app.exec()

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.editors.sbb_monitor.sbb_monitor_plugin import SBBMonitorPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(SBBMonitorPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,15 @@
from bec_widgets.widgets.editors.website.website import WebsiteWidget
class SBBMonitor(WebsiteWidget):
"""
A widget to display the SBB monitor website.
"""
PLUGIN = True
ICON_NAME = "train"
USER_ACCESS = []
def __init__(self, parent=None, **kwargs):
url = "https://free.oevplus.ch/monitor/?viewType=splitView&layout=1&showClock=true&showPerron=true&stationGroup1Title=Villigen%2C%20PSI%20West&stationGroup2Title=Siggenthal-Würenlingen&station_1_id=85%3A3592&station_1_name=Villigen%2C%20PSI%20West&station_1_quantity=5&station_1_group=1&station_2_id=85%3A3502&station_2_name=Siggenthal-Würenlingen&station_2_quantity=5&station_2_group=2"
super().__init__(parent=parent, url=url, **kwargs)

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import sys
from decimal import Decimal
from math import inf, nextafter
from math import copysign, inf, nextafter
from typing import TYPE_CHECKING, TypeVar, get_args
from annotated_types import Ge, Gt, Le, Lt
@@ -23,16 +23,19 @@ _MAXFLOAT = sys.float_info.max
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
_max = _MAXINT if type_ is int else _MAXFLOAT
for md in info.metadata:
if isinstance(md, Ge):
_min = type_(md.ge) # type: ignore
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):
_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):
_max = type_(md.le) # type: ignore
return _min, _max # type: ignore
@@ -64,4 +67,6 @@ def field_default(info: FieldInfo):
def clearable_required(info: FieldInfo):
return type(None) in get_args(info.annotation) or info.is_required()
return type(None) in get_args(info.annotation) or (
info.is_required() and info.default is PydanticUndefined
)

View File

@@ -16,6 +16,9 @@ logger = bec_logger.logger
class ScanMetadata(PydanticModelForm):
RPC = False
def __init__(
self,
parent=None,
@@ -36,16 +39,18 @@ class ScanMetadata(PydanticModelForm):
# self.populate() gets called in super().__init__
# 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.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._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._additional_md_box_layout.addWidget(self._additional_metadata)
@@ -127,6 +132,7 @@ if __name__ == "__main__": # pragma: no cover
w.setLayout(layout)
scan_metadata = ScanMetadata(
parent=w,
scan_name="grid_scan",
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

View File

@@ -21,9 +21,6 @@ logger = bec_logger.logger
# noinspection PyDataclass
class ImageItemConfig(ConnectionConfig): # TODO review config
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.")
downsample: bool | None = Field(True, description="Whether to downsample 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):
RPC = True
USER_ACCESS = [
"color_map",
@@ -69,12 +67,13 @@ class ImageItem(BECConnector, pg.ImageItem):
]
vRangeChangedManually = Signal(tuple)
removed = Signal(str)
def __init__(
self,
config: Optional[ImageItemConfig] = None,
gui_id: Optional[str] = None,
parent_image=None,
parent_image=None, # FIXME: rename to parent
**kwargs,
):
if config is None:
@@ -274,6 +273,8 @@ class ImageItem(BECConnector, pg.ImageItem):
self.buffer = []
self.max_len = 0
def remove(self):
self.parent().disconnect_monitor(self.config.monitor)
def remove(self, emit: bool = True):
self.clear()
super().remove()
if emit:
self.removed.emit(self.objectName())

View File

@@ -8,6 +8,7 @@ from qtpy.QtCore import QEvent, Qt
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QColorDialog,
QHBoxLayout,
QHeaderView,
QSpinBox,
QToolButton,
@@ -23,6 +24,7 @@ from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.plots.roi.image_roi import (
BaseROI,
CircularROI,
EllipticalROI,
RectangularROI,
ROIController,
)
@@ -35,6 +37,28 @@ if TYPE_CHECKING:
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):
"""
Two-column tree: [ROI] [Properties]
@@ -98,11 +122,21 @@ class ROIPropertyTree(BECWidget, QWidget):
# --------------------------------------------------------------------- UI
def _init_toolbar(self):
tb = ModularToolBar(self, self, orientation="horizontal")
self._draw_actions: dict[str, MaterialIconAction] = {}
# --- ROI draw actions (toggleable) ---
self.add_rect_action = MaterialIconAction("add_box", "Add Rect ROI", True, self)
self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self)
tb.add_action("Add Rect ROI", self.add_rect_action, self)
self._draw_actions["rect"] = self.add_rect_action
self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self)
tb.add_action("Add Circle ROI", self.add_circle_action, self)
self._draw_actions["circle"] = self.add_circle_action
# --- Ellipse ROI draw action ---
self.add_ellipse_action = MaterialIconAction("vignette", "Add Ellipse ROI", True, self)
tb.add_action("Add Ellipse ROI", self.add_ellipse_action, self)
self._draw_actions["ellipse"] = self.add_ellipse_action
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
self.expand_toggle = MaterialIconAction(
@@ -124,6 +158,24 @@ class ROIPropertyTree(BECWidget, QWidget):
self.expand_toggle.action.toggled.connect(_exp_toggled)
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
self.cmap = BECColorMapWidget(cmap=self.controller.colormap)
tb.addWidget(QWidget()) # spacer
@@ -133,17 +185,9 @@ class ROIPropertyTree(BECWidget, QWidget):
self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap))
# 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._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
self.plot.scene().installEventFilter(self)
@@ -173,16 +217,12 @@ class ROIPropertyTree(BECWidget, QWidget):
return str(value)
def _set_roi_draw_mode(self, mode: str | None):
# Ensure only the selected action is toggled on
if mode == "rect":
self.add_rect_action.action.setChecked(True)
self.add_circle_action.action.setChecked(False)
elif mode == "circle":
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)
# Update toolbar actions so that only the selected mode is checked
for m, act in self._draw_actions.items():
act.action.blockSignals(True)
act.action.setChecked(m == mode)
act.action.blockSignals(False)
self._roi_draw_mode = mode
self._roi_start_pos = None
# remove any unfinished temp ROI
@@ -190,6 +230,15 @@ class ROIPropertyTree(BECWidget, QWidget):
self.plot.removeItem(self._temp_roi)
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):
if self._roi_draw_mode is None:
return super().eventFilter(obj, event)
@@ -202,12 +251,18 @@ class ROIPropertyTree(BECWidget, QWidget):
parent_image=self.image_widget,
resize_handles=False,
)
if self._roi_draw_mode == "circle":
elif self._roi_draw_mode == "circle":
self._temp_roi = CircularROI(
pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5],
size=[5, 5],
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)
return True
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":
self._temp_roi.setSize([dx, dy])
if self._roi_draw_mode == "circle":
elif self._roi_draw_mode == "circle":
r = max(
1, math.hypot(dx, dy)
) # radius never smaller than 1 for safety of handle mapping, otherwise SEGFAULT
d = 2 * r # diameter
self._temp_roi.setPos(self._roi_start_pos.x() - r, self._roi_start_pos.y() - r)
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
elif (
event.type() == QEvent.GraphicsSceneMouseRelease
@@ -235,18 +296,30 @@ class ROIPropertyTree(BECWidget, QWidget):
self._temp_roi = None
self._set_roi_draw_mode(None)
# register via controller
final_roi.add_scale_handle()
self.controller.add_roi(final_roi)
return True
return super().eventFilter(obj, event)
# --------------------------------------------------------- controller slots
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 = QTreeWidgetItem(self.tree, ["", "", ""])
parent.setText(self.COL_ROI, roi.label)
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()
delete_icon = material_icon(
"delete",
@@ -256,8 +329,11 @@ class ROIPropertyTree(BECWidget, QWidget):
color=self.DELETE_BUTTON_COLOR,
)
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))
actions_layout.addWidget(del_btn)
# install composite widget into the tree
self.tree.setItemWidget(parent, self.COL_ACTION, actions_widget)
# color button
color_btn = ColorButtonNative(parent=self, color=roi.line_color)
self.tree.setItemWidget(parent, self.COL_PROPS, color_btn)
@@ -309,6 +385,12 @@ class ROIPropertyTree(BECWidget, QWidget):
for c in range(3):
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):
item = self.roi_items.pop(roi, None)
if item:
@@ -345,7 +427,7 @@ if __name__ == "__main__": # pragma: no cover
import sys
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

View File

@@ -1,5 +1,5 @@
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate
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))
# Connect slots, a device will be connected upon change of any combobox
self.device_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
self.dim_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
self.device_combo_box.currentTextChanged.connect(self.connect_monitor)
self.dim_combo_box.currentTextChanged.connect(self.connect_monitor)
QTimer.singleShot(0, self._adjust_and_connect)
def _adjust_and_connect(self):
"""
Adjust the size of the device combo box and populate it with preview signals.
Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing.
"""
self._populate_preview_signals()
self._reverse_device_items()
self.device_combo_box.setCurrentText("") # set again default to empty string
def _populate_preview_signals(self) -> None:
"""
Populate the device combo box with previewsignal devices in the
format '<device>_<signal>' and store the tuple(device, signal) in
the item's userData for later use.
"""
preview_signals = self.target_widget.client.device_manager.get_bec_signals("PreviewSignal")
for device, signal, signal_config in preview_signals:
label = signal_config.get("obj_name", f"{device}_{signal}")
self.device_combo_box.addItem(label, (device, signal, signal_config))
def _reverse_device_items(self) -> None:
"""
Reverse the current order of items in the device combo box while
keeping their userData and restoring the previous selection.
"""
current_text = self.device_combo_box.currentText()
items = [
(self.device_combo_box.itemText(i), self.device_combo_box.itemData(i))
for i in range(self.device_combo_box.count())
]
self.device_combo_box.clear()
for text, data in reversed(items):
self.device_combo_box.addItem(text, data)
if current_text:
self.device_combo_box.setCurrentText(current_text)
@SafeSlot()
def connect_monitor(self):
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()
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)

View File

@@ -104,9 +104,12 @@ class BaseROI(BECConnector):
nameChanged = Signal(str)
penChanged = Signal()
movableChanged = Signal(bool)
USER_ACCESS = [
"label",
"label.setter",
"movable",
"movable.setter",
"line_color",
"line_color.setter",
"line_width",
@@ -127,6 +130,7 @@ class BaseROI(BECConnector):
label: str | None = None,
line_color: str | None = None,
line_width: int = 5,
movable: bool = True,
# all remaining pg.*ROI kwargs (pos, size, pen, …)
**pg_kwargs,
):
@@ -155,6 +159,7 @@ class BaseROI(BECConnector):
gui_id=gui_id,
removable=True,
invertible=True,
movable=movable,
**pg_kwargs,
)
@@ -162,8 +167,14 @@ class BaseROI(BECConnector):
self._line_color = line_color or "#ffffff"
self._line_width = line_width
self._description = True
self._movable = movable
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):
"""
Sets the parent image for this ROI.
@@ -182,6 +193,40 @@ class BaseROI(BECConnector):
"""
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
def label(self) -> str:
"""
@@ -337,8 +382,18 @@ class BaseROI(BECConnector):
)
def add_scale_handle(self):
"""Add scale handles to the ROI."""
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):
"""
Sets the position of the ROI.
@@ -355,12 +410,7 @@ class BaseROI(BECConnector):
if controller and self in controller.rois:
controller.remove_roi(self)
return # controller will call back into this method once deregistered
handles = self.handles
for i in range(len(handles)):
try:
self.removeHandle(0)
except IndexError:
continue
self.remove_scale_handles()
self.rpc_register.remove_rpc(self)
self.parent_image.plot_item.removeItem(self)
viewBox = self.parent_plot_item.vb
@@ -399,6 +449,7 @@ class RectangularROI(BaseROI, pg.RectROI):
label: str | None = None,
line_color: str | None = None,
line_width: int = 5,
movable: bool = True,
resize_handles: bool = True,
**extra_pg,
):
@@ -429,6 +480,7 @@ class RectangularROI(BaseROI, pg.RectROI):
pos=pos,
size=size,
pen=pen,
movable=movable,
**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.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):
"""
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([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):
"""
Handles ROI region change events.
Handles changes to the ROI's region.
This method is called whenever the ROI's position or size changes.
It calculates the new corner coordinates and emits the edgesChanged signal
with the updated coordinates.
"""
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
self.edgesChanged.emit(x0, y0, x0 + w, y0 + h)
viewBox = self.parent_plot_item.vb
viewBox.update()
x_left, y_bottom, x_right, y_top = self._normalized_edges()
self.edgesChanged.emit(x_left, y_bottom, x_right, y_top)
self.parent_plot_item.vb.update()
def mouseDragEvent(self, ev):
"""
@@ -489,9 +551,8 @@ class RectangularROI(BaseROI, pg.RectROI):
"""
super().mouseDragEvent(ev)
if ev.isFinish():
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
self.edgesReleased.emit(x0, y0, x0 + w, y0 + h)
x_left, y_bottom, x_right, y_top = self._normalized_edges()
self.edgesReleased.emit(x_left, y_bottom, x_right, y_top)
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
"""
@@ -510,17 +571,16 @@ class RectangularROI(BaseROI, pg.RectROI):
if typed is None:
typed = self.description
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
x1, y1 = x0 + w, y0 + h
x_left, y_bottom, x_right, y_top = self._normalized_edges()
if typed:
return {
"bottom_left": (x0, y0),
"bottom_right": (x1, y0),
"top_left": (x0, y1),
"top_right": (x1, y1),
"bottom_left": (x_left, y_bottom),
"bottom_right": (x_right, y_bottom),
"top_left": (x_left, y_top),
"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):
"""
@@ -568,6 +628,7 @@ class CircularROI(BaseROI, pg.CircleROI):
label: str | None = None,
line_color: str | None = None,
line_width: int = 5,
movable: bool = True,
**extra_pg,
):
"""
@@ -599,10 +660,19 @@ class CircularROI(BaseROI, pg.CircleROI):
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):
"""
Adds scale handles to the circular ROI.
"""
self._addHandles() # wrapper around pg.CircleROI._addHandles
def _on_region_changed(self):
"""
@@ -654,7 +724,7 @@ class CircularROI(BaseROI, pg.CircleROI):
if typed is None:
typed = self.description
d = self.state["size"][0]
d = abs(self.state["size"][0])
cx = self.pos().x() + d / 2
cy = self.pos().y() + d / 2
@@ -680,6 +750,92 @@ class CircularROI(BaseROI, pg.CircleROI):
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):
"""Manages a collection of ROIs (Regions of Interest) with palette-assigned colors.

View File

@@ -141,6 +141,14 @@
<header>bec_color_map_widget</header>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>x_name</tabstop>
<tabstop>x_entry</tabstop>
<tabstop>y_name</tabstop>
<tabstop>y_entry</tabstop>
<tabstop>z_name</tabstop>
<tabstop>z_entry</tabstop>
</tabstops>
<resources/>
<connections>
<connection>

View File

@@ -2,12 +2,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import (
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QSizePolicy,
QVBoxLayout,
QWidget,
@@ -15,9 +15,8 @@ from qtpy.QtWidgets import (
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import CurveTree
if TYPE_CHECKING: # pragma: no cover
@@ -29,13 +28,18 @@ class CurveSetting(SettingWidget):
super().__init__(parent=parent, *args, **kwargs)
self.setProperty("skip_settings", True)
self.target_widget = target_widget
self._x_settings_connected = False
self.layout = QVBoxLayout(self)
self._init_x_box()
self._init_y_box()
self.setFixedWidth(580) # TODO height is still debate
def sizeHint(self) -> QSize:
"""
Returns the size hint for the settings widget.
"""
return QSize(800, 500)
def _init_x_box(self):
self.x_axis_box = QGroupBox("X Axis")
@@ -46,15 +50,23 @@ class CurveSetting(SettingWidget):
self.mode_combo_label = QLabel("Mode")
self.mode_combo = QComboBox()
self.mode_combo.addItems(["auto", "index", "timestamp", "device"])
self.mode_combo.setMinimumWidth(120)
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.device_x_label = QLabel("Device")
self.device_x = DeviceLineEdit(parent=self)
self.device_x = DeviceComboBox(parent=self)
self.device_x.insertItem(0, "")
self.device_x.setEditable(True)
self.device_x.setMinimumWidth(180)
self.signal_x_label = QLabel("Signal")
self.signal_x = QLineEdit()
self.signal_x = SignalComboBox(parent=self)
self.signal_x.include_config_signals = False
self.signal_x.insertItem(0, "")
self.signal_x.setEditable(True)
self.signal_x.setMinimumWidth(180)
self._get_x_mode_from_waveform()
self.switch_x_device_selection()
@@ -80,11 +92,32 @@ class CurveSetting(SettingWidget):
def switch_x_device_selection(self):
if self.mode_combo.currentText() == "device":
self._x_settings_connected = True
self.device_x.currentTextChanged.connect(self.signal_x.set_device)
self.device_x.device_reset.connect(self.signal_x.reset_selection)
self.device_x.setEnabled(True)
self.device_x.setText(self.target_widget.x_axis_mode["name"])
self.signal_x.setText(self.target_widget.x_axis_mode["entry"])
self.signal_x.setEnabled(True)
item = self.device_x.findText(self.target_widget.x_axis_mode["name"])
self.device_x.setCurrentIndex(item if item != -1 else 0)
signal_x = self.target_widget.x_axis_mode.get("entry", "")
if signal_x:
self.signal_x.set_to_obj_name(signal_x)
else:
# If no match is found, set to the first enabled item
if not self.signal_x.set_to_first_enabled():
# If no enabled item is found, set to the first item
self.signal_x.setCurrentIndex(0)
else:
self.device_x.setEnabled(False)
self.signal_x.setEnabled(False)
self.device_x.setCurrentIndex(0)
self.signal_x.setCurrentIndex(0)
if self._x_settings_connected:
self._x_settings_connected = False
self.device_x.currentTextChanged.disconnect(self.signal_x.set_device)
self.device_x.device_reset.disconnect(self.signal_x.reset_selection)
def _init_y_box(self):
self.y_axis_box = QGroupBox("Y Axis")
@@ -97,16 +130,17 @@ class CurveSetting(SettingWidget):
self.layout.addWidget(self.y_axis_box)
@SafeSlot()
@SafeSlot(popup_error=True)
def accept_changes(self):
"""
Accepts the changes made in the settings widget and applies them to the target widget.
"""
if self.mode_combo.currentText() == "device":
self.target_widget.x_mode = self.device_x.text()
signal_x = self.signal_x.text()
self.target_widget.x_mode = self.device_x.currentText()
signal_x = self.signal_x.currentText()
signal_data = self.signal_x.itemData(self.signal_x.currentIndex())
if signal_x != "":
self.target_widget.x_entry = signal_x
self.target_widget.x_entry = signal_data.get("obj_name", signal_x)
else:
self.target_widget.x_mode = self.mode_combo.currentText()
self.curve_manager.send_curve_json()
@@ -121,5 +155,7 @@ class CurveSetting(SettingWidget):
"""Cleanup the widget."""
self.device_x.close()
self.device_x.deleteLater()
self.signal_x.close()
self.signal_x.deleteLater()
self.curve_manager.close()
self.curve_manager.deleteLater()

View File

@@ -5,13 +5,12 @@ from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtGui import QColor
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QColorDialog,
QComboBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QSpinBox,
@@ -22,13 +21,13 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets import SafeSlot
from bec_widgets.utils import ConnectionConfig, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from bec_widgets.widgets.plots.waveform.curve import CurveConfig, DeviceSignal
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
@@ -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."""
if self.source == "device":
# Device row: columns 1..2 are device line edits
self.device_edit = DeviceLineEdit(parent=self.tree)
self.entry_edit = QLineEdit(parent=self.tree) # TODO in future will be signal line edit
self.device_edit = DeviceComboBox(parent=self.tree)
self.device_edit.insertItem(0, "")
self.device_edit.setEditable(True)
self.entry_edit = SignalComboBox(parent=self.tree)
self.entry_edit.include_config_signals = False
self.entry_edit.insertItem(0, "")
self.entry_edit.setEditable(True)
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
if self.config.signal:
self.device_edit.setText(self.config.signal.name or "")
self.entry_edit.setText(self.config.signal.entry or "")
device_index = self.device_edit.findText(self.config.signal.name or "")
if device_index >= 0:
self.device_edit.setCurrentIndex(device_index)
# Force the entry_edit to update based on the device name
self.device_edit.currentTextChanged.emit(self.device_edit.currentText())
else:
# If the device name is not found, set the first enabled item
self.device_edit.setCurrentIndex(0)
if not self.entry_edit.set_to_obj_name(self.config.signal.entry):
# If the entry is not found, try to set it to the first enabled item
if not self.entry_edit.set_to_first_enabled():
# If no enabled item is found, set to the first item
self.entry_edit.setCurrentIndex(0)
self.tree.setItemWidget(self, 1, self.device_edit)
self.tree.setItemWidget(self, 2, self.entry_edit)
@@ -154,7 +172,7 @@ class CurveRow(QTreeWidgetItem):
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
# Color in col 3
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)
# Style in col 4
@@ -177,20 +195,16 @@ class CurveRow(QTreeWidgetItem):
self.symbol_spin.setValue(self.config.symbol_size)
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
related configuration properties based on the chosen color.
Update configuration when the color button emits a change.
Args:
button: The button widget whose color is being modified.
new_color (str): The new color in hex format.
"""
current_color = QColor(button.color())
chosen_color = QColorDialog.getColor(current_color, self.tree, "Select Curve Color")
if chosen_color.isValid():
button.set_color(chosen_color)
self.config.color = chosen_color.name()
self.config.symbol_color = chosen_color.name()
self.config.color = new_color
self.config.symbol_color = new_color
def add_dap_row(self):
"""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 = None
if getattr(self, "entry_edit", None) is not None:
self.entry_edit.close()
self.entry_edit.deleteLater()
self.entry_edit = None
if getattr(self, "dap_combo", None) is not None:
self.dap_combo.close()
self.dap_combo.deleteLater()
@@ -271,13 +290,22 @@ class CurveRow(QTreeWidgetItem):
# Gather device name/entry
device_name = ""
device_entry = ""
## TODO: Move this to itemData
if hasattr(self, "device_edit"):
device_name = self.device_edit.text()
device_name = self.device_edit.currentText()
if hasattr(self, "entry_edit"):
device_entry = self.entry_validator.validate_signal(
name=device_name, entry=self.entry_edit.text()
)
self.entry_edit.setText(device_entry)
device_entry = self.entry_edit.currentText()
index = self.entry_edit.findText(device_entry)
if index > -1:
device_entry_info = self.entry_edit.itemData(index)
if device_entry_info:
device_entry = device_entry_info.get("obj_name", device_entry)
else:
device_entry = self.entry_validator.validate_signal(
name=device_name, entry=device_entry
)
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
self.config.source = "device"
self.config.label = f"{device_name}-{device_entry}"
@@ -393,13 +421,20 @@ class CurveTree(BECWidget, QWidget):
self.tree = QTreeWidget()
self.tree.setColumnCount(7)
self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"])
header = self.tree.header()
for idx in range(self.tree.columnCount()):
if idx in (1, 2): # Device name and entry should stretch
header.setSectionResizeMode(idx, QHeaderView.Stretch)
else:
header.setSectionResizeMode(idx, QHeaderView.Fixed)
header.setStretchLastSection(False)
self.tree.setColumnWidth(0, 90)
self.tree.setColumnWidth(1, 100)
self.tree.setColumnWidth(2, 100)
self.tree.setColumnWidth(3, 70)
self.tree.setColumnWidth(4, 80)
self.tree.setColumnWidth(5, 40)
self.tree.setColumnWidth(6, 40)
self.tree.setColumnWidth(5, 50)
self.tree.setColumnWidth(6, 50)
self.layout.addWidget(self.tree)
def _init_color_buffer(self, size: int):
@@ -535,7 +570,4 @@ class CurveTree(BECWidget, QWidget):
all_items = list(self.all_items)
for item in all_items:
item.remove_self()
def closeEvent(self, event):
self.cleanup()
return super().closeEvent(event)
super().cleanup()

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import json
from typing import Literal
from typing import Any, Literal
import lmfit
import numpy as np
@@ -163,7 +163,7 @@ class Waveform(PlotBase):
self._async_curves = []
self._slice_index = None
self._dap_curves = []
self._mode: Literal["none", "sync", "async", "mixed"] = "none"
self._mode = None
# Scan data
self._scan_done = True # means scan is not running
@@ -330,7 +330,6 @@ class Waveform(PlotBase):
self.curve_settings_dialog = SettingsDialog(
self, settings_widget=curve_setting, window_title="Curve Settings", modal=False
)
self.curve_settings_dialog.setFixedWidth(580)
# When the dialog is closed, update the toolbar icon and clear the reference
self.curve_settings_dialog.finished.connect(self._curve_settings_closed)
self.curve_settings_dialog.show()
@@ -1139,7 +1138,7 @@ class Waveform(PlotBase):
QTimer.singleShot(100, 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
and return the appropriate data dict and access key.
@@ -1153,7 +1152,7 @@ class Waveform(PlotBase):
self.update_with_scan_history(-1)
if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.")
return "none", "none"
return None, None
if hasattr(self.scan_item, "live_data"):
# Live scan
@@ -1169,7 +1168,7 @@ class Waveform(PlotBase):
"""
if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.")
return "none"
return
data, access_key = self._fetch_scan_data_and_access()
for curve in self._sync_curves:
device_name = curve.config.signal.name
@@ -1177,9 +1176,8 @@ class Waveform(PlotBase):
if access_key == "val":
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
else:
device_data = (
data.get(device_name, {}).get(device_entry, {}).read().get("value", None)
)
entry_obj = data.get(device_name, {}).get(device_entry)
device_data = entry_obj.read()["value"] if entry_obj else None
x_data = self._get_x_data(device_name, device_entry)
if x_data is not None:
if len(x_data) == 1:
@@ -1217,7 +1215,8 @@ class Waveform(PlotBase):
if self._skip_large_dataset_check is False:
if not self._check_dataset_size_and_confirm(dataset_obj, device_entry):
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 len(np.shape(device_data)) > 1:
@@ -1246,6 +1245,23 @@ class Waveform(PlotBase):
self.request_dap_update.emit()
def _check_async_signal_found(self, name: str, signal: str) -> bool:
"""
Check if the async signal is found in the BEC device manager.
Args:
name(str): The name of the async signal.
signal(str): The entry of the async signal.
Returns:
bool: True if the async signal is found, False otherwise.
"""
bec_async_signals = self.client.device_manager.get_bec_signals("AsyncSignal")
for entry_name, _, entry_data in bec_async_signals:
if entry_name == name and entry_data.get("obj_name") == signal:
return True
return False
def _setup_async_curve(self, curve: Curve):
"""
Setup async curve.
@@ -1254,20 +1270,40 @@ class Waveform(PlotBase):
curve(Curve): The curve to set up.
"""
name = curve.config.signal.name
self.bec_dispatcher.disconnect_slot(
self.on_async_readback, MessageEndpoints.device_async_readback(self.old_scan_id, name)
)
signal = curve.config.signal.entry
async_signal_found = self._check_async_signal_found(name, signal)
try:
curve.clear_data()
except KeyError:
logger.warning(f"Curve {name} not found in plot item.")
pass
self.bec_dispatcher.connect_slot(
self.on_async_readback,
MessageEndpoints.device_async_readback(self.scan_id, name),
from_start=True,
cb_info={"scan_id": self.scan_id},
)
# New endpoint for async signals
if async_signal_found:
self.bec_dispatcher.disconnect_slot(
self.on_async_readback,
MessageEndpoints.device_async_signal(self.old_scan_id, name, signal),
)
self.bec_dispatcher.connect_slot(
self.on_async_readback,
MessageEndpoints.device_async_signal(self.scan_id, name, signal),
from_start=True,
cb_info={"scan_id": self.scan_id},
)
# old endpoint
else:
self.bec_dispatcher.disconnect_slot(
self.on_async_readback,
MessageEndpoints.device_async_readback(self.old_scan_id, name),
)
self.bec_dispatcher.connect_slot(
self.on_async_readback,
MessageEndpoints.device_async_readback(self.scan_id, name),
from_start=True,
cb_info={"scan_id": self.scan_id},
)
logger.info(f"Setup async curve {name}")
@SafeSlot(dict, dict, verify_sender=True)
@@ -1549,15 +1585,21 @@ class Waveform(PlotBase):
if access_key == "val": # live data
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, [0])
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})"
# 2 User wants timestamp
if self.x_axis_mode["name"] == "timestamp":
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
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
new_suffix = " (timestamp)"
@@ -1584,7 +1626,8 @@ class Waveform(PlotBase):
if access_key == "val":
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
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})"
self._update_x_label_suffix(new_suffix)
return x_data
@@ -1637,12 +1680,17 @@ class Waveform(PlotBase):
self.update_with_scan_history(-1)
if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.")
return "none"
return None
if hasattr(self.scan_item, "live_data"):
readout_priority = self.scan_item.status_message.info["readout_priority"] # live data
readout_priority = self.scan_item.status_message.info.get(
"readout_priority"
) # live data
else:
readout_priority = self.scan_item.metadata["bec"]["readout_priority"] # history
readout_priority = self.scan_item.metadata["bec"].get("readout_priority") # history
if readout_priority is None:
return None
# Reset sync/async curve lists
self._async_curves.clear()

View File

@@ -1,12 +1,46 @@
import sys
from enum import Enum
from string import Template
from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer, Slot
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer
from qtpy.QtGui import QColor, QPainter, QPainterPath
class ProgressState(Enum):
NORMAL = "normal"
PAUSED = "paused"
INTERRUPTED = "interrupted"
COMPLETED = "completed"
@classmethod
def from_bec_status(cls, status: str) -> "ProgressState":
"""
Map a BEC status string (open, paused, aborted, halted, closed)
to the corresponding ProgressState.
Any unknown status falls back to NORMAL.
"""
mapping = {
"open": cls.NORMAL,
"paused": cls.PAUSED,
"aborted": cls.INTERRUPTED,
"halted": cls.PAUSED,
"closed": cls.COMPLETED,
}
return mapping.get(status.lower(), cls.NORMAL)
PROGRESS_STATE_COLORS = {
ProgressState.NORMAL: QColor("#2979ff"), # blue normal progress
ProgressState.PAUSED: QColor("#ffca28"), # orange/amber paused
ProgressState.INTERRUPTED: QColor("#ff5252"), # red interrupted
ProgressState.COMPLETED: QColor("#00e676"), # green finished
}
from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class BECProgressBar(BECWidget, QWidget):
@@ -21,6 +55,8 @@ class BECProgressBar(BECWidget, QWidget):
"set_minimum",
"label_template",
"label_template.setter",
"state",
"state.setter",
"_get_label",
]
ICON_NAME = "page_control"
@@ -48,27 +84,38 @@ class BECProgressBar(BECWidget, QWidget):
self._completed_color = accent_colors.success
self._border_color = QColor(50, 50, 50)
# 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
self._padding_left_right = 10
self._value_animation = QPropertyAnimation(self, b"_progressbar_value")
self._value_animation.setDuration(200)
self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
# label on top of the progress bar
self.center_label = QLabel(self)
self.center_label.setAlignment(Qt.AlignCenter)
self.center_label.setAlignment(Qt.AlignHCenter)
self.center_label.setStyleSheet("color: white;")
self.center_label.setMinimumSize(0, 0)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setContentsMargins(10, 0, 10, 0)
layout.setSpacing(0)
layout.addWidget(self.center_label)
layout.setAlignment(self.center_label, Qt.AlignCenter)
self.setLayout(layout)
self.update()
self._adjust_label_width()
@Property(str, doc="The template for the center label. Use $value, $maximum, and $percentage.")
@SafeProperty(
str, doc="The template for the center label. Use $value, $maximum, and $percentage."
)
def label_template(self):
"""
The template for the center label. Use $value, $maximum, and $percentage to insert the values.
@@ -83,10 +130,11 @@ class BECProgressBar(BECWidget, QWidget):
@label_template.setter
def label_template(self, template):
self._label_template = template
self._adjust_label_width()
self.set_value(self._user_value)
self.update()
@Property(float, designable=False)
@SafeProperty(float, designable=False)
def _progressbar_value(self):
"""
The current value of the progress bar.
@@ -106,8 +154,20 @@ class BECProgressBar(BECWidget, QWidget):
percentage=int((self.map_value(self._user_value) / self._maximum) * 100),
)
@Slot(float)
@Slot(int)
def _adjust_label_width(self):
"""
Reserve enough horizontal space for the center label so the widget
doesn't resize as the text grows during progress.
"""
template = Template(self._label_template)
sample_text = template.safe_substitute(
value=self._user_maximum, maximum=self._user_maximum, percentage=100
)
width = self.center_label.fontMetrics().horizontalAdvance(sample_text)
self.center_label.setFixedWidth(width)
@SafeSlot(float)
@SafeSlot(int)
def set_value(self, value):
"""
Set the value of the progress bar.
@@ -122,35 +182,88 @@ class BECProgressBar(BECWidget, QWidget):
self._target_value = self.map_value(value)
self._user_value = value
self.center_label.setText(self._update_template())
# Update state automatically unless paused or interrupted
if self._state not in (ProgressState.PAUSED, ProgressState.INTERRUPTED):
self._state = (
ProgressState.COMPLETED
if self._user_value >= self._user_maximum
else ProgressState.NORMAL
)
self.animate_progress()
@SafeProperty(object, doc="Current visual state of the progress bar.")
def state(self):
return self._state
@state.setter
def state(self, state):
"""
Set the visual state of the progress bar.
Args:
state(ProgressState | str): The state to set. Can be one of the
"""
if isinstance(state, str):
state = ProgressState(state.lower())
if not isinstance(state, ProgressState):
raise ValueError("state must be a ProgressState or its value")
self._state = state
self.update()
@SafeProperty(float, doc="Base corner radius in pixels (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):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect().adjusted(10, 0, -10, -1)
rect = self.rect().adjusted(self._padding_left_right, 0, -self._padding_left_right, -1)
# Corner radius adapts to widget height so it never exceeds half the bars thickness
radius = min(self._corner_radius, rect.height() / 2)
# Draw background
painter.setBrush(self._background_color)
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(rect, 10, 10) # Rounded corners
painter.drawRoundedRect(rect, radius, radius) # Rounded corners
# Draw border
painter.setBrush(Qt.NoBrush)
painter.setPen(self._border_color)
painter.drawRoundedRect(rect, 10, 10)
painter.drawRoundedRect(rect, radius, radius)
# Determine progress color based on completion
if self._value >= self._maximum:
current_color = self._completed_color
# Determine progress colour based on current state
if self._state == ProgressState.PAUSED:
current_color = self._state_colors[ProgressState.PAUSED]
elif self._state == ProgressState.INTERRUPTED:
current_color = self._state_colors[ProgressState.INTERRUPTED]
elif self._state == ProgressState.COMPLETED or self._value >= self._maximum:
current_color = self._state_colors[ProgressState.COMPLETED]
else:
current_color = self._progress_color
current_color = self._state_colors[ProgressState.NORMAL]
# Set clipping region to preserve the background's rounded corners
progress_rect = rect.adjusted(
0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0
)
clip_path = QPainterPath()
clip_path.addRoundedRect(QRectF(rect), 10, 10) # Clip to the background's rounded corners
clip_path.addRoundedRect(
QRectF(rect), radius, radius
) # Clip to the background's rounded corners
painter.setClipPath(clip_path)
# Draw progress bar
@@ -168,7 +281,7 @@ class BECProgressBar(BECWidget, QWidget):
self._value_animation.setEndValue(self._target_value)
self._value_animation.start()
@Property(float)
@SafeProperty(float)
def maximum(self):
"""
The maximum value of the progress bar.
@@ -182,7 +295,7 @@ class BECProgressBar(BECWidget, QWidget):
"""
self.set_maximum(maximum)
@Property(float)
@SafeProperty(float)
def minimum(self):
"""
The minimum value of the progress bar.
@@ -193,7 +306,7 @@ class BECProgressBar(BECWidget, QWidget):
def minimum(self, minimum: float):
self.set_minimum(minimum)
@Property(float)
@SafeProperty(float)
def initial_value(self):
"""
The initial value of the progress bar.
@@ -204,7 +317,7 @@ class BECProgressBar(BECWidget, QWidget):
def initial_value(self, value: float):
self.set_value(value)
@Slot(float)
@SafeSlot(float)
def set_maximum(self, maximum: float):
"""
Set the maximum value of the progress bar.
@@ -213,10 +326,11 @@ class BECProgressBar(BECWidget, QWidget):
maximum (float): The maximum value.
"""
self._user_maximum = maximum
self._adjust_label_width()
self.set_value(self._user_value) # Update the value to fit the new range
self.update()
@Slot(float)
@SafeSlot(float)
def set_minimum(self, minimum: float):
"""
Set the minimum value of the progress bar.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,22 @@
import os
import re
from typing import Optional
from functools import partial
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 qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QListWidgetItem, QVBoxLayout, QWidget
from qtpy.QtCore import QSize, Signal
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.error_popups import SafeSlot
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon
logger = bec_logger.logger
class DeviceBrowser(BECWidget, QWidget):
@@ -23,18 +30,18 @@ class DeviceBrowser(BECWidget, QWidget):
def __init__(
self,
parent: Optional[QWidget] = None,
parent: QWidget | None = None,
config=None,
client=None,
gui_id: Optional[str] = None,
gui_id: str | None = None,
**kwargs,
) -> None:
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self.ui = None
self.ini_ui()
self.dev_list: QListWidget = self.ui.device_list
self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
self.proxy_device_update = SignalProxy(
self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list
)
@@ -43,6 +50,7 @@ class DeviceBrowser(BECWidget, QWidget):
)
self.device_update.connect(self.update_device_list)
self.init_device_list()
self.update_device_list()
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.
"""
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
ui_file_path = os.path.join(os.path.dirname(__file__), "device_browser.ui")
self.ui = UILoader(self).loader(ui_file_path)
layout.addWidget(self.ui)
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.
@@ -68,8 +74,42 @@ class DeviceBrowser(BECWidget, QWidget):
if action in ["add", "remove", "reload"]:
self.device_update.emit()
@Slot()
def update_device_list(self) -> None:
def init_device_list(self):
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.
There are two ways to trigger this function:
@@ -80,23 +120,14 @@ class DeviceBrowser(BECWidget, QWidget):
"""
filter_text = self.ui.filter_input.text()
try:
regex = re.compile(filter_text, re.IGNORECASE)
self.regex = re.compile(filter_text, re.IGNORECASE)
except re.error:
regex = None # Invalid regex, disable filtering
dev_list = self.ui.device_list
dev_list.clear()
self.regex = None # Invalid regex, disable filtering
for device in self.dev:
self._device_items[device].setHidden(False)
return
for device in self.dev:
if regex is None or 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)
self._device_items[device].setHidden(not self.regex.search(device))
if __name__ == "__main__": # pragma: no cover
@@ -104,10 +135,10 @@ if __name__ == "__main__": # pragma: no cover
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)
apply_theme("light")
set_theme("light")
widget = DeviceBrowser()
widget.show()
sys.exit(app.exec_())

View File

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

View File

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

View File

@@ -2,37 +2,110 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.devicemanager import DeviceContainer
from bec_lib.logger import bec_logger
from 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.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
from qtpy.QtGui import QMouseEvent
logger = bec_logger.logger
class DeviceItem(QWidget):
def __init__(self, device: str) -> None:
super().__init__()
class DeviceItem(ExpandableGroupFrame):
broadcast_size_hint = Signal(QSize)
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._expanded_first_time = False
self._data = None
self.device = device
layout = QHBoxLayout()
layout.setContentsMargins(10, 2, 10, 2)
self.label = QLabel(device)
layout.addWidget(self.label)
self.setLayout(layout)
self.setStyleSheet(
"""
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
"""
self._layout = QHBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._tab_widget = QTabWidget(tabShape=QTabWidget.TabShape.Rounded)
self._tab_widget.setDocumentMode(True)
self._layout.addWidget(self._tab_widget)
self.set_layout(self._layout)
self._form_page = QWidget()
self._form_page_layout = QVBoxLayout()
self._form_page.setLayout(self._form_page_layout)
self._signal_page = QWidget()
self._signal_page_layout = QVBoxLayout()
self._signal_page.setLayout(self._signal_page_layout)
self._tab_widget.addTab(self._form_page, "Configuration")
self._tab_widget.addTab(self._signal_page, "Signals")
self.adjustSize()
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:
super().mousePressEvent(event)
@@ -59,10 +132,33 @@ class DeviceItem(QWidget):
if __name__ == "__main__": # pragma: no cover
import sys
from unittest.mock import MagicMock
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QApplication(sys.argv)
widget = 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()
sys.exit(app.exec_())

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ from qtpy.QtWidgets import (
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QToolButton,
QVBoxLayout,
QWidget,
@@ -180,6 +179,7 @@ class SignalLabel(BECWidget, QWidget):
self._custom_units: str = custom_units
self._show_default_units: bool = show_default_units
self._decimal_places = 3
self._dtype = None
self._show_hinted_signals: bool = True
self._show_normal_signals: bool = False
@@ -241,8 +241,10 @@ class SignalLabel(BECWidget, QWidget):
"""Subscribe to the Redis topic for the device to display"""
if not self._connected and self._device and self._device in self.dev:
self._connected = True
self._readback_endpoint = MessageEndpoints.device_readback(self._device)
self.bec_dispatcher.connect_slot(self.on_device_readback, self._readback_endpoint)
self._read_endpoint = MessageEndpoints.device_read(self._device)
self._read_config_endpoint = MessageEndpoints.device_read_configuration(self._device)
self.bec_dispatcher.connect_slot(self.on_device_readback, self._read_endpoint)
self.bec_dispatcher.connect_slot(self.on_device_readback, self._read_config_endpoint)
self._manual_read()
self.set_display_value(self._value)
@@ -250,7 +252,8 @@ class SignalLabel(BECWidget, QWidget):
"""Unsubscribe from the Redis topic for the device to display"""
if self._connected:
self._connected = False
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._readback_endpoint)
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._read_endpoint)
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._read_config_endpoint)
def _manual_read(self):
if self._device is None or not isinstance(
@@ -259,8 +262,13 @@ class SignalLabel(BECWidget, QWidget):
self._units = ""
self._value = "__"
return
signal: Signal = (
getattr(device, self.signal, None) if isinstance(device, Device) else device
signal, info = (
(
getattr(device, self.signal, None),
device._info.get("signals", {}).get(self._signal, {}).get("describe", {}),
)
if isinstance(device, Device)
else (device, device.describe().get(self._device))
)
if not isinstance(signal, Signal): # Avoid getting other attributes of device, e.g. methods
signal = None
@@ -269,7 +277,8 @@ class SignalLabel(BECWidget, QWidget):
self._value = "__"
return
self._value = signal.get()
self._units = signal.get_device_config().get("egu", "")
self._units = info.get("egu", "")
self._dtype = info.get("dtype", "float")
@SafeSlot(dict, dict)
def on_device_readback(self, msg: dict, metadata: dict) -> None:
@@ -278,8 +287,10 @@ class SignalLabel(BECWidget, QWidget):
"""
try:
signal_to_read = self._patch_hinted_signal()
self._value = msg["signals"][signal_to_read]["value"]
self.set_display_value(self._value)
_value = msg["signals"].get(signal_to_read, {}).get("value")
if _value is not None:
self._value = _value
self.set_display_value(self._value)
except Exception as e:
self._display.setText("ERROR!")
self._display.setToolTip(
@@ -401,7 +412,10 @@ class SignalLabel(BECWidget, QWidget):
if self._decimal_places == 0:
return value
try:
return f"{float(value):0.{self._decimal_places}f}"
if self._dtype in ("integer", "float"):
return f"{float(value):0.{self._decimal_places}f}"
else:
return str(value)
except ValueError:
return value

View File

@@ -10,6 +10,7 @@ class ToggleSwitch(QWidget):
A simple toggle.
"""
stateChanged = Signal(bool)
enabled = Signal(bool)
ICON_NAME = "toggle_on"
PLUGIN = True
@@ -42,11 +43,19 @@ class ToggleSwitch(QWidget):
@checked.setter
def checked(self, state):
if self._checked != state:
self.stateChanged.emit(state)
self._checked = state
self.update_colors()
self.set_thumb_pos_to_state()
self.enabled.emit(self._checked)
def setChecked(self, state: bool):
self.checked = state
def isChecked(self):
return self.checked
@Property(QPointF)
def thumb_pos(self):
return self._thumb_pos

View File

@@ -1,5 +1,8 @@
from __future__ import annotations
from qtpy.QtCore import Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QPushButton
from qtpy.QtWidgets import QColorDialog, QPushButton
from bec_widgets import BECWidget, SafeProperty, SafeSlot
@@ -12,6 +15,8 @@ class ColorButtonNative(BECWidget, QPushButton):
to guarantee good readability.
"""
color_changed = Signal(str)
RPC = False
PLUGIN = True
ICON_NAME = "colors"
@@ -25,9 +30,10 @@ class ColorButtonNative(BECWidget, QPushButton):
"""
super().__init__(parent=parent, **kwargs)
self.set_color(color)
self.clicked.connect(self._open_color_dialog)
@SafeSlot()
def set_color(self, color):
def set_color(self, color: str | QColor):
"""Set the button's color and update its appearance.
Args:
@@ -38,6 +44,7 @@ class ColorButtonNative(BECWidget, QPushButton):
else:
self._color = color
self._update_appearance()
self.color_changed.emit(self._color)
@SafeProperty("QColor")
def color(self):
@@ -56,3 +63,11 @@ class ColorButtonNative(BECWidget, QPushButton):
text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
self.setStyleSheet(f"background-color: {self._color}; color: {text_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)

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.10.1"
version = "2.21.2"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -13,8 +13,8 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
"bec_ipython_client>=2.21.4, <=4.0", # needed for jupyter console
"bec_lib>=3.29, <=4.0",
"bec_ipython_client>=3.42.4, <=4.0", # needed for jupyter console
"bec_lib>=3.44, <=4.0",
"bec_qthemes~=0.7, >=0.7",
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli

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
qtbot.waitUntil(check_docks_registered, timeout=5000)
qtbot.wait(500)
assert len(dock.panels) == 3
assert hasattr(gui.bec, "dock_0")

View File

@@ -79,7 +79,7 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
gui = connected_client_gui_obj
dock_area = gui.bec
# Number of top level widgets, should be 4
top_level_widgets_count = 4
top_level_widgets_count = 12
assert len(gui._server_registry) == top_level_widgets_count
# Number of widgets with parent_id == None, should be 2
widgets = [
@@ -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
# 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
widgets = [
widget

View File

@@ -69,7 +69,7 @@ def test_scan_metadata_for_custom_scan(
def do_test():
# Set the metadata
grid: QGridLayout = scan_control._metadata_form._form_grid.layout()
for i in range(grid.rowCount()): # type: ignore
for i in range(grid.rowCount() - 1): # type: ignore
field_name = grid.itemAtPosition(i, 0).widget().property("_model_field_name")
if (value_to_set := md.pop(field_name, None)) is not None:
grid.itemAtPosition(i, 1).widget().setValue(value_to_set)

View File

@@ -12,12 +12,11 @@ may not be created immediately after the rpc call is made.
from __future__ import annotations
import random
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
import numpy as np
import pytest
from bec_widgets.cli.client import BECDockArea
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
PYTEST_TIMEOUT = 50
@@ -321,20 +320,20 @@ def test_widgets_e2e_signal_combobox(qtbot, connected_client_gui_obj, random_gen
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
dock: client.BECDock
_, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
widget: client.SignalComboBox
widget.set_device("samx")
info = bec.device_manager.devices.samx._info["signals"]
assert widget.signals == [
"readback",
"setpoint",
"motor_is_moving",
"velocity",
"acceleration",
"tolerance",
["samx (readback)", info.get("readback")],
["setpoint", info.get("setpoint")],
["motor_is_moving", info.get("motor_is_moving")],
["velocity", info.get("velocity")],
["acceleration", info.get("acceleration")],
["tolerance", info.get("tolerance")],
]
widget.set_signal("readback")
widget.set_signal("samx (readback)")
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)

View File

@@ -168,11 +168,16 @@ def test_accept_changes(axis_settings_fixture, qtbot):
axis_settings.ui.x_grid.checked = True
axis_settings.accept_changes()
qtbot.wait(200)
assert plot_base.title == "New Title"
assert plot_base.x_min == 10
assert plot_base.x_max == 20
assert plot_base.x_label == "New X Label"
assert plot_base.x_log is True
assert plot_base.x_grid is True
qtbot.waitUntil(
lambda: all(
[
plot_base.title == "New Title",
plot_base.x_min == 10,
plot_base.x_max == 20,
plot_base.x_label == "New X Label",
plot_base.x_log is True,
plot_base.x_grid is True,
]
),
timeout=200,
)

View File

@@ -47,24 +47,21 @@ def test_bec_dock_area_add_remove_dock(bec_dock_area, qtbot):
# Remove docks
d0_name = d0.name()
bec_dock_area.delete(d0_name)
qtbot.wait(200)
d1.remove()
qtbot.wait(200)
assert len(bec_dock_area.dock_area.docks) == initial_count + 1
qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == initial_count + 1, timeout=200)
assert d0.name() not in dict(bec_dock_area.dock_area.docks)
assert d1.name() not in dict(bec_dock_area.dock_area.docks)
assert d2.name() in dict(bec_dock_area.dock_area.docks)
def test_close_docks(bec_dock_area, qtbot):
d0 = bec_dock_area.new(name="dock_0")
d1 = bec_dock_area.new(name="dock_1")
d2 = bec_dock_area.new(name="dock_2")
_ = bec_dock_area.new(name="dock_0")
_ = bec_dock_area.new(name="dock_1")
_ = bec_dock_area.new(name="dock_2")
bec_dock_area.delete_all()
qtbot.wait(200)
assert len(bec_dock_area.dock_area.docks) == 0
qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == 0)
def test_undock_and_dock_docks(bec_dock_area, qtbot):

View File

@@ -1,7 +1,10 @@
import numpy as np
import pytest
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
BECProgressBar,
ProgressState,
)
@pytest.fixture
@@ -33,3 +36,23 @@ def test_progressbar_label(progressbar):
progressbar.label_template = "Test: $value"
progressbar.set_value(50)
assert progressbar.center_label.text() == "Test: 50"
def test_progress_state_from_bec_status():
"""ProgressState.from_bec_status() maps BEC literals correctly."""
mapping = {
"open": ProgressState.NORMAL,
"paused": ProgressState.PAUSED,
"aborted": ProgressState.INTERRUPTED,
"halted": ProgressState.PAUSED,
"closed": ProgressState.COMPLETED,
"UNKNOWN": ProgressState.NORMAL, # fallback
}
for text, expected in mapping.items():
assert ProgressState.from_bec_status(text) is expected
def test_progressbar_state_setter(progressbar):
"""Setting .state reflects internally."""
progressbar.state = ProgressState.PAUSED
assert progressbar.state is ProgressState.PAUSED

View File

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

View File

@@ -29,7 +29,6 @@ def image_widget_with_crosshair(qtbot):
image_item = pg.ImageItem()
image_item.setImage(np.random.rand(100, 100))
image_item.config = type("obj", (object,), {"monitor": "test"})
widget.addItem(image_item)
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):
crosshair, plot_item = image_widget_with_crosshair
image_item = plot_item.items[0]
emitted_values_2D = []
@@ -113,7 +113,7 @@ def test_mouse_moved_signals_2D(image_widget_with_crosshair):
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):

View File

@@ -93,7 +93,7 @@ def test_curve_setting_switch_device_mode(curve_setting_fixture, qtbot):
assert curve_setting.device_x.isEnabled()
# This line edit should reflect the waveform.x_axis_mode["name"], or be blank if none
assert curve_setting.device_x.text() == wf.x_axis_mode["name"]
assert curve_setting.device_x.currentText() == ""
def test_curve_setting_refresh(curve_setting_fixture, qtbot):
@@ -127,8 +127,8 @@ def test_change_device_from_target_widget(curve_setting_fixture, qtbot):
assert curve_setting.mode_combo.currentText() == "device"
assert curve_setting.device_x.isEnabled()
assert curve_setting.device_x.text() == wf.x_axis_mode["name"]
assert curve_setting.signal_x.text() == wf.x_axis_mode["entry"]
assert curve_setting.device_x.currentText() == wf.x_axis_mode["name"]
assert curve_setting.signal_x.currentText() == f"{wf.x_axis_mode['entry']} (readback)"
##################################################

View File

@@ -3,20 +3,31 @@ from unittest import mock
import pytest
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QTabWidget
from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
from .client_mocks import mocked_client
if TYPE_CHECKING: # pragma: no cover
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
def device_browser(qtbot, mocked_client):
dev_browser = DeviceBrowser(client=mocked_client)
dev_browser.dev["samx"].read_configuration = mock.MagicMock()
qtbot.addWidget(dev_browser)
qtbot.waitExposed(dev_browser)
yield dev_browser
@@ -30,22 +41,24 @@ def test_device_browser_init_with_devices(device_browser):
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.
"""
device_list = device_browser.ui.device_list
device_browser.ui.filter_input.setText("sam")
qtbot.wait(1000)
assert device_list.count() == 3
expected = expected_num_visible if expected_num_visible >= 0 else len(device_browser.dev)
device_browser.ui.filter_input.setText("nonexistent")
qtbot.wait(1000)
assert device_list.count() == 0
def num_visible(item_dict):
return len(list(filter(lambda i: not i.isHidden(), item_dict.values())))
device_browser.ui.filter_input.setText("")
qtbot.wait(1000)
assert device_list.count() == len(device_browser.dev)
device_browser.ui.filter_input.setText(search_term)
qtbot.wait(100)
assert num_visible(device_browser._device_items) == expected
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
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
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):
@@ -67,7 +116,7 @@ def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qt
device_name = widget.device
with mock.patch("qtpy.QtGui.QDrag.exec_") as mock_exec:
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.mouseRelease(widget, Qt.MouseButton.LeftButton)
mock_set_mimedata.assert_called_once()

View File

@@ -0,0 +1,98 @@
from unittest.mock import MagicMock, patch
import pytest
from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
DeviceConfigDialog,
)
_BASIC_CONFIG = {
"name": "test_device",
"enabled": True,
"deviceClass": "TestDevice",
"readoutPriority": "monitored",
}
@pytest.fixture
def dialog(qtbot):
"""Fixture to create a DeviceConfigDialog instance."""
mock_device = MagicMock(_config=DeviceConfigModel.model_validate(_BASIC_CONFIG).model_dump())
mock_client = MagicMock()
mock_client.device_manager.devices = {"test_device": mock_device}
dialog = DeviceConfigDialog(device="test_device", config_helper=MagicMock(), client=mock_client)
qtbot.addWidget(dialog)
return dialog
def test_initialization(dialog):
assert dialog._device == "test_device"
assert dialog._container.count() == 2
def test_fill_form(dialog):
with patch.object(dialog._form, "set_data") as mock_set_data:
dialog._fill_form()
mock_set_data.assert_called_once_with(DeviceConfigModel.model_validate(_BASIC_CONFIG))
def test_updated_config(dialog):
"""Test that updated_config returns the correct changes."""
dialog._initial_config = {"key1": "value1", "key2": "value2"}
with patch.object(
dialog._form, "get_form_data", return_value={"key1": "value1", "key2": "new_value"}
):
updated = dialog.updated_config()
assert updated == {"key2": "new_value"}
def test_apply(dialog):
with patch.object(dialog, "_process_update_action") as mock_process_update:
dialog.apply()
mock_process_update.assert_called_once()
def test_accept(dialog):
with (
patch.object(dialog, "_process_update_action") as mock_process_update,
patch("qtpy.QtWidgets.QDialog.accept") as mock_parent_accept,
):
dialog.accept()
mock_process_update.assert_called_once()
mock_parent_accept.assert_called_once()
def test_waiting_display(dialog, qtbot):
with (
patch.object(dialog._spinner, "start") as mock_spinner_start,
patch.object(dialog._spinner, "stop") as mock_spinner_stop,
):
dialog.show()
dialog._start_waiting_display()
qtbot.waitUntil(dialog._overlay_widget.isVisible, timeout=100)
mock_spinner_start.assert_called_once()
mock_spinner_stop.assert_not_called()
dialog._stop_waiting_display()
qtbot.waitUntil(lambda: not dialog._overlay_widget.isVisible(), timeout=100)
mock_spinner_stop.assert_called_once()
def test_update_cycle(dialog, qtbot):
update = {"enabled": False, "readoutPriority": "baseline", "deviceTags": {"tag"}}
def _mock_send(action="update", config=None, wait_for_response=True, timeout_s=None):
dialog.client.device_manager.devices["test_device"]._config = config["test_device"] # type: ignore
dialog._config_helper.send_config_request = MagicMock(side_effect=_mock_send)
for item in dialog._form.enumerate_form_widgets():
if (val := update.get(item.label.property("_model_field_name"))) is not None:
item.widget.setValue(val)
assert dialog.updated_config() == update
dialog.apply()
qtbot.waitUntil(lambda: dialog._config_helper.send_config_request.call_count == 1, timeout=100)
dialog._config_helper.send_config_request.assert_called_with(
action="update", config={"test_device": update}, wait_for_response=False
)

View File

@@ -94,18 +94,6 @@ def test_device_signal_qproperties(device_signal_base):
assert device_signal_base._signal_filter == {Kind.config, Kind.normal}
def test_device_signal_set_device(device_signal_base):
"""Test if the set_device method works correctly"""
device_signal_base.include_hinted_signals = True
device_signal_base.set_device("samx")
assert device_signal_base.device == "samx"
assert device_signal_base.signals == ["readback"]
device_signal_base.include_normal_signals = True
assert device_signal_base.signals == ["readback", "setpoint"]
device_signal_base.include_config_signals = True
assert device_signal_base.signals == ["readback", "setpoint", "velocity"]
def test_signal_combobox(qtbot, device_signal_combobox):
"""Test the signal_combobox"""
container = []
@@ -120,17 +108,25 @@ def test_signal_combobox(qtbot, device_signal_combobox):
device_signal_combobox.include_config_signals = True
assert device_signal_combobox.signals == []
device_signal_combobox.set_device("samx")
assert device_signal_combobox.signals == ["readback", "setpoint", "velocity"]
samx = device_signal_combobox.dev.samx
assert device_signal_combobox.signals == [
("samx (readback)", samx._info["signals"].get("readback")),
("setpoint", samx._info["signals"].get("setpoint")),
("velocity", samx._info["signals"].get("velocity")),
]
qtbot.wait(100)
assert container == ["samx"]
assert container == ["samx (readback)"]
# Set the type of class from the FakeDevice to Signal
fake_signal = FakeSignal(name="fake_signal")
fake_signal = FakeSignal(name="fake_signal", info={"device_info": {"signals": {}}})
device_signal_combobox.client.device_manager.add_devices([fake_signal])
device_signal_combobox.set_device("fake_signal")
assert device_signal_combobox.signals == ["fake_signal"]
fake_signal = device_signal_combobox.dev.fake_signal
assert device_signal_combobox.signals == [
("fake_signal", fake_signal._info["signals"].get("fake_signal", {}))
]
assert device_signal_combobox._config_signals == []
assert device_signal_combobox._normal_signals == []
assert device_signal_combobox._hinted_signals == ["fake_signal"]
assert device_signal_combobox._hinted_signals == [("fake_signal", {})]
def test_signal_lineedit(device_signal_line_edit):
@@ -148,3 +144,12 @@ def test_signal_lineedit(device_signal_line_edit):
assert device_signal_line_edit._is_valid_input is True
device_signal_line_edit.setText("invalid")
assert device_signal_line_edit._is_valid_input is False
def test_device_signal_input_base_cleanup(qtbot, mocked_client):
widget = DeviceInputWidget(client=mocked_client)
widget.close()
widget.deleteLater()
mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register)

View File

@@ -48,12 +48,22 @@ class MyWidget(QWidget):
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super(QWidget, self).__init__(parent)"""
super(QWidget, self).__init__(parent)
""",
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
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,
)
""",
]
)

View File

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

View File

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

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

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)
qtbot.keyClicks(roi_tree.tree.viewport().focusWidget(), "new_name")
qtbot.keyClick(roi_tree.tree.viewport().focusWidget(), Qt.Key_Return)
qtbot.wait(200)
# Check the ROI name was updated
assert roi.label == "new_name"
assert item.text(roi_tree.COL_ROI) == "new_name"
qtbot.waitUntil(
lambda: all([roi.label == "new_name", item.text(roi_tree.COL_ROI) == "new_name"]),
timeout=200,
)
def test_roi_width_edit(roi_tree, image_widget, qtbot):
@@ -138,9 +138,8 @@ def test_roi_width_edit(roi_tree, image_widget, qtbot):
# Change the width
width_spin.setValue(25)
qtbot.wait(200)
# Check the ROI width was updated
assert roi.line_width == 25
qtbot.waitUntil(lambda: roi.line_width == 25, timeout=200)
def test_delete_roi_button(roi_tree, image_widget, qtbot):
@@ -148,16 +147,17 @@ def test_delete_roi_button(roi_tree, image_widget, qtbot):
roi = image_widget.add_roi(kind="rect", name="to_delete")
item = roi_tree.roi_items[roi]
# Get the delete button
del_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
action_widget = 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()
qtbot.wait(200)
# Verify ROI was removed
assert roi not in roi_tree.roi_items
assert roi not in image_widget.roi_controller.rois
qtbot.waitUntil(
lambda: all([roi not in roi_tree.roi_items, roi not in image_widget.roi_controller.rois]),
timeout=200,
)
def test_roi_color_change_from_roi(roi_tree, image_widget):
@@ -331,3 +331,67 @@ def test_add_roi_from_toolbar(qtbot, mocked_client):
# Verify it's a circle ROI
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)

View File

@@ -6,16 +6,21 @@ import numpy as np
import pytest
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.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):
"""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
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
# 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):
@@ -98,7 +108,7 @@ def test_color_uniqueness_across_multiple_rois(qtbot, mocked_client):
widget: Image = create_widget(qtbot, Image, client=mocked_client)
# 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)
@@ -205,3 +215,29 @@ def test_roi_set_position(bec_image_widget_with_roi):
pos = roi.pos()
assert int(pos.x()) == 10
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

View File

@@ -1,6 +1,7 @@
import numpy as np
import pyqtgraph as pg
import pytest
from qtpy.QtCore import QPointF
from bec_widgets.widgets.plots.image.image import Image
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):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.vrange = (10, 100)
assert bec_image_view.vrange == (10, 100)
bec_image_view.v_range = (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.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 bec_image_view.enable_full_colorbar is True
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._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):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.image(monitor="eiger", monitor_type="2d")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "device_monitor_2d"
assert bec_image_view.main_image.config.monitor_type == "2d"
assert bec_image_view.subscriptions["main"].source == "device_monitor_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.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.image(monitor="eiger", monitor_type="1d")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "device_monitor_1d"
assert bec_image_view.main_image.config.monitor_type == "1d"
assert bec_image_view.subscriptions["main"].source == "device_monitor_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.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.image(monitor="eiger", monitor_type="auto")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "auto"
assert bec_image_view.main_image.config.monitor_type == "auto"
assert bec_image_view.subscriptions["main"].source == "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.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)
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):
@@ -160,10 +230,14 @@ def test_image_data_update_1d(qtbot, mocked_client):
metadata = {"scan_id": "scan_test"}
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)
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):
@@ -207,8 +281,8 @@ def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type):
elif colorbar_type == "full":
bec_image_view.enable_full_colorbar = True
bec_image_view.vrange = (0, 100)
assert bec_image_view.vrange == (0, 100)
bec_image_view.v_range = (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.config.v_range == (0, 100)
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")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.main_image.config.source == "device_monitor_2d"
assert bec_image_view.main_image.config.monitor_type == "2d"
assert bec_image_view.subscriptions["main"].source == "device_monitor_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.image is None
@@ -434,19 +508,31 @@ def test_crosshair_roi_panels_visibility(qtbot, mocked_client):
# Enable ROI crosshair
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(500)
# Panels must be visible
assert bec_image_view.side_panel_x.panel_height > 0
assert bec_image_view.side_panel_y.panel_width > 0
qtbot.waitUntil(
lambda: all(
[
bec_image_view.side_panel_x.panel_height > 0,
bec_image_view.side_panel_y.panel_width > 0,
]
),
timeout=500,
)
# Disable ROI crosshair
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(500)
# Panels hidden again
assert bec_image_view.side_panel_x.panel_height == 0
assert bec_image_view.side_panel_y.panel_width == 0
qtbot.waitUntil(
lambda: all(
[
bec_image_view.side_panel_x.panel_height == 0,
bec_image_view.side_panel_y.panel_width == 0,
]
),
timeout=500,
)
def test_roi_plot_data_from_image(qtbot, mocked_client):
@@ -483,3 +569,96 @@ def test_roi_plot_data_from_image(qtbot, mocked_client):
# Horizontal slice (row)
h_slice, _ = y_items[0].getData()
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() == ""

View File

@@ -102,7 +102,34 @@ def test_launch_window_launch_plugin_auto_update(bec_launch_window):
[
({}, False),
({"launcher": mock.MagicMock()}, False),
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, True),
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, False),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress": mock.MagicMock(),
},
False,
),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress_simple": mock.MagicMock(),
"scan_progress_full": mock.MagicMock(),
},
False,
),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress_simple": mock.MagicMock(),
"scan_progress_full": mock.MagicMock(),
"hover_widget": mock.MagicMock(),
},
True,
),
],
)
def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide):
@@ -132,7 +159,34 @@ def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide):
[
({}, True),
({"launcher": mock.MagicMock()}, True),
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, False),
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, True),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress": mock.MagicMock(),
},
True,
),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress_simple": mock.MagicMock(),
"scan_progress_full": mock.MagicMock(),
},
True,
),
(
{
"launcher": mock.MagicMock(),
"dock_area": mock.MagicMock(),
"scan_progress_simple": mock.MagicMock(),
"scan_progress_full": mock.MagicMock(),
"hover_widget": mock.MagicMock(),
},
False,
),
],
)
def test_launch_window_closes(bec_launch_window, connections, close_called):

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

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.
"""
assert gui_server._get_service_config().config is ServiceConfig().config
assert gui_server._get_service_config().config == ServiceConfig().config

View File

@@ -4,10 +4,10 @@ from unittest.mock import MagicMock, patch
import pytest
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 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.widgets.control.scan_control import ScanControl
@@ -221,82 +221,36 @@ available_scans_message = AvailableResourceMessage(
}
)
scan_history = ScanQueueHistoryMessage(
scan_history = ScanHistoryMessage(
metadata={},
status="COMPLETED",
queue_id="94d7cb39-aa70-4060-92de-addcfb64e3c0",
info={
"queue_id": "94d7cb39-aa70-4060-92de-addcfb64e3c0",
"scan_id": ["bc2aa11f-24f6-44d6-8717-95e97fb43015"],
"is_scan": [True],
"request_blocks": [
{
"msg": ScanQueueMessage(
metadata={
"file_suffix": None,
"file_directory": None,
"user_metadata": {},
"RID": "99321ef7-00ac-4e0c-9120-ce689bd88a4d",
},
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",
),
"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,
scan_id="79cbef20-9ebe-45bb-a44c-f518be27a25c",
scan_number=1,
dataset_number=1,
file_path="/somepath/scan_1.h5",
exit_status="closed",
start_time=1750618470.936856,
end_time=1750618473.668227,
scan_name="line_scan",
num_points=100,
request_inputs={
"arg_bundle": ["samx", 0.0, 2.0],
"inputs": {},
"kwargs": {
"steps": 10,
"exp_time": 2,
"relative": False,
"system_config": {"file_suffix": None, "file_directory": None},
},
},
queue="primary",
)
@pytest.fixture(scope="function")
def scan_control(qtbot, mocked_client): # , mock_dev):
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)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
@@ -570,7 +524,7 @@ def test_scan_metadata_is_connected(scan_control):
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
assert scan_control._metadata_form._scan_name == "grid_scan"
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")
scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY

View File

@@ -1,4 +1,5 @@
from decimal import Decimal
from typing import Set
import pytest
from bec_lib.metadata_schema import BasicScanMetadata
@@ -7,11 +8,12 @@ from pydantic.types import Json
from qtpy.QtCore import QItemSelectionModel, QPoint, Qt
from bec_widgets.utils.forms_from_types.items import (
BoolMetadataField,
BoolFormItem,
DictFormItem,
DynamicFormItem,
FloatDecimalMetadataField,
IntMetadataField,
StrMetadataField,
FloatDecimalFormItem,
IntFormItem,
StrFormItem,
)
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
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)
float_nodefault: float
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 = {
@@ -47,8 +50,9 @@ TEST_DICT = {
"int_default": 21,
"int_nodefault_optional": -10,
"float_nodefault": pytest.approx(0.1),
"decimal_dp_limits_nodefault": pytest.approx(34),
"unsupported_class": '{"key": "value"}',
"decimal_dp_limits_nodefault": pytest.approx(34.5),
"dict_default": {"test_dict": "values"},
"unsupported_class": '["set", "item"]',
}
@@ -58,12 +62,11 @@ def example_md():
@pytest.fixture
def empty_metadata_widget():
def empty_metadata_widget(qtbot):
widget = ScanMetadata()
widget._additional_metadata._table_model._data = [["extra_field", "extra_data"]]
qtbot.addWidget(widget)
yield widget
widget._clear_grid()
widget.deleteLater()
@pytest.fixture
@@ -82,7 +85,8 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
int_nodefault_optional = widget._form_grid.layout().itemAtPosition(7, 1).widget()
float_nodefault = widget._form_grid.layout().itemAtPosition(8, 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 (
widget,
@@ -97,6 +101,7 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
"int_nodefault_optional": int_nodefault_optional,
"float_nodefault": float_nodefault,
"decimal_dp_limits_nodefault": decimal_dp_limits_nodefault,
"dict_default": dict_default,
"unsupported_class": unsupported_class,
},
)
@@ -112,24 +117,26 @@ def fill_commponents(components: dict[str, DynamicFormItem]):
components["int_nodefault_optional"].setValue(-10)
components["float_nodefault"].setValue(0.1)
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(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
_, components = metadata_widget
assert isinstance(components["sample_name"], StrMetadataField)
assert isinstance(components["str_optional"], StrMetadataField)
assert isinstance(components["str_required"], StrMetadataField)
assert isinstance(components["bool_optional"], BoolMetadataField)
assert isinstance(components["bool_required_default"], BoolMetadataField)
assert isinstance(components["bool_required_nodefault"], BoolMetadataField)
assert isinstance(components["int_default"], IntMetadataField)
assert isinstance(components["int_nodefault_optional"], IntMetadataField)
assert isinstance(components["float_nodefault"], FloatDecimalMetadataField)
assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalMetadataField)
assert isinstance(components["unsupported_class"], StrMetadataField)
assert isinstance(components["sample_name"], StrFormItem)
assert isinstance(components["str_optional"], StrFormItem)
assert isinstance(components["str_required"], StrFormItem)
assert isinstance(components["bool_optional"], BoolFormItem)
assert isinstance(components["bool_required_default"], BoolFormItem)
assert isinstance(components["bool_required_nodefault"], BoolFormItem)
assert isinstance(components["int_default"], IntFormItem)
assert isinstance(components["int_nodefault_optional"], IntFormItem)
assert isinstance(components["float_nodefault"], FloatDecimalFormItem)
assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalFormItem)
assert isinstance(components["dict_default"], DictFormItem)
assert isinstance(components["unsupported_class"], StrFormItem)
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)
components["decimal_dp_limits_nodefault"].setValue(-56)
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(1.01)
widget.validate_form()
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(2)
assert widget._validity_message.text() == "No errors!"
@pytest.fixture
def table():
table = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
table = DictBackedTable(
initial_data=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]
)
yield table
table._table_model.deleteLater()
table._table_view.deleteLater()

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

View File

@@ -121,11 +121,13 @@ def test_custom_label(signal_label: SignalLabel, qtbot):
def test_units_in_display(signal_label: SignalLabel, qtbot):
signal_label._value = "1.8"
signal_label._dtype = "float"
signal_label.custom_units = "Mfurlong μfortnight⁻¹"
assert signal_label._display.text() == "1.800 Mfurlong μfortnight⁻¹"
def test_decimal_places(signal_label: SignalLabel, qtbot):
signal_label._dtype = "float"
signal_label.decimal_places = 2
signal_label.set_display_value("123.456")
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.signal = "readback"
signal_label.custom_units = "μm"
signal_label._dtype = "float"
signal_label.on_device_readback({"random": {"stuff": "in", "corrupted": "reading"}}, {})
assert signal_label._display.text() == "ERROR!"
assert "Error processing incoming reading" in signal_label._display.toolTip()

View File

@@ -806,6 +806,13 @@ def test_show_curve_settings_popup(qtbot, mocked_client):
assert wf.curve_settings_dialog.isVisible()
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()
assert wf.curve_settings_dialog is None
assert not curve_action.isChecked(), "Should be unchecked after closing dialog"

View File

@@ -23,4 +23,7 @@ def test_website_widget_set_url(website_widget):
website_widget.set_url("https://google.com")
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/")