mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-07 09:17:53 +02:00
Compare commits
2 Commits
test/738_d
...
fix/flag-f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9076a1731e | |||
| d3f2c3bd6b |
10
.github/workflows/end2end-conda.yml
vendored
10
.github/workflows/end2end-conda.yml
vendored
@@ -47,12 +47,4 @@ jobs:
|
||||
source ./bin/install_bec_dev.sh -t
|
||||
cd ../
|
||||
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
|
||||
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
|
||||
- name: Upload logs if job fails
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-logs
|
||||
path: ./logs/*.log
|
||||
retention-days: 7
|
||||
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
9
.github/workflows/formatter.yml
vendored
9
.github/workflows/formatter.yml
vendored
@@ -14,15 +14,10 @@ jobs:
|
||||
|
||||
- name: Run black and isort
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip install --system black isort
|
||||
uv pip install --system -e .[dev]
|
||||
pip install black isort
|
||||
pip install -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
15
.github/workflows/stale-issues.yml
vendored
@@ -1,15 +0,0 @@
|
||||
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
|
||||
584
CHANGELOG.md
584
CHANGELOG.md
@@ -1,590 +1,6 @@
|
||||
# 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
|
||||
|
||||
- **console**: Qt console widget deleted
|
||||
([`cd4e90a`](https://github.com/bec-project/bec_widgets/commit/cd4e90a79fcdbc96f4ec23db22375d05a48731db))
|
||||
|
||||
### Build System
|
||||
|
||||
- Pyte removed from dependencies
|
||||
([`a64cf0d`](https://github.com/bec-project/bec_widgets/commit/a64cf0dd871c1419e02d3803c74cc45966baac19))
|
||||
|
||||
|
||||
## v2.10.0 (2025-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **waveform**: Waveform only update async data when scan is currently running
|
||||
([`f90150d`](https://github.com/bec-project/bec_widgets/commit/f90150d1c708331d4ee78f82ebf5ef23cd81fd17))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Add job logs to e2e test
|
||||
([`d12bd9f`](https://github.com/bec-project/bec_widgets/commit/d12bd9fe1a010babc94dc86405d1b75a2b07534c))
|
||||
|
||||
- Fix artifact version
|
||||
([`2b4454a`](https://github.com/bec-project/bec_widgets/commit/2b4454a291bc69399ddd08780c44e1339825fb36))
|
||||
|
||||
### Features
|
||||
|
||||
- **waveform**: Large async dataset warning popup
|
||||
([`d0c1ac0`](https://github.com/bec-project/bec_widgets/commit/d0c1ac0cf5d421d14c9e050ccf5832cd30ca0764))
|
||||
|
||||
|
||||
## v2.9.2 (2025-05-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Logpanel error cycle
|
||||
([`d9dc60e`](https://github.com/bec-project/bec_widgets/commit/d9dc60ee9974e2e6e6005378cc17ef088a4ded2c))
|
||||
|
||||
- Move log panel to bec connector and add rate limiter
|
||||
([`7322cd1`](https://github.com/bec-project/bec_widgets/commit/7322cd194fcf7f56d41c86ecbcd97a5d8bd60c3e))
|
||||
|
||||
- **log_panel**: Removed lambda callback method
|
||||
([`9112616`](https://github.com/bec-project/bec_widgets/commit/91126168b62f3e1623521ceb205dd854287cfef7))
|
||||
|
||||
|
||||
## v2.9.1 (2025-05-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Make registry update log message debug level
|
||||
([`12f8c82`](https://github.com/bec-project/bec_widgets/commit/12f8c82eb59ed6a7273b57126efe340bf37b65cc))
|
||||
|
||||
|
||||
## v2.9.0 (2025-05-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -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) <= 4
|
||||
return len(remaining_connections) <= 1
|
||||
|
||||
def _turn_off_the_lights(self, connections: dict):
|
||||
"""
|
||||
|
||||
@@ -49,7 +49,6 @@ _Widgets = {
|
||||
"ResetButton": "ResetButton",
|
||||
"ResumeButton": "ResumeButton",
|
||||
"RingProgressBar": "RingProgressBar",
|
||||
"SBBMonitor": "SBBMonitor",
|
||||
"ScanControl": "ScanControl",
|
||||
"ScatterWaveform": "ScatterWaveform",
|
||||
"SignalComboBox": "SignalComboBox",
|
||||
@@ -475,20 +474,6 @@ 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:
|
||||
"""
|
||||
@@ -545,26 +530,6 @@ 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":
|
||||
@@ -674,26 +639,6 @@ 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":
|
||||
@@ -1059,128 +1004,6 @@ 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."""
|
||||
|
||||
@@ -1429,16 +1252,16 @@ class Image(RPCBase):
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def v_range(self) -> "QPointF":
|
||||
def vrange(self) -> "tuple":
|
||||
"""
|
||||
Set the v_range of the main image.
|
||||
Get the vrange of the image.
|
||||
"""
|
||||
|
||||
@v_range.setter
|
||||
@vrange.setter
|
||||
@rpc_call
|
||||
def v_range(self) -> "QPointF":
|
||||
def vrange(self) -> "tuple":
|
||||
"""
|
||||
Set the v_range of the main image.
|
||||
Get the vrange of the image.
|
||||
"""
|
||||
|
||||
@property
|
||||
@@ -1636,12 +1459,12 @@ class Image(RPCBase):
|
||||
@rpc_call
|
||||
def image(
|
||||
self,
|
||||
monitor: "str | tuple | None" = None,
|
||||
monitor: "str | 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 | None":
|
||||
) -> "ImageItem":
|
||||
"""
|
||||
Set the image source and update the image.
|
||||
|
||||
@@ -1666,12 +1489,11 @@ class Image(RPCBase):
|
||||
@rpc_call
|
||||
def add_roi(
|
||||
self,
|
||||
kind: "Literal['rect', 'circle', 'ellipse']" = "rect",
|
||||
kind: "Literal['rect', 'circle']" = "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":
|
||||
"""
|
||||
@@ -1683,7 +1505,6 @@ 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:
|
||||
@@ -2843,26 +2664,6 @@ 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":
|
||||
@@ -3250,12 +3051,6 @@ 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."""
|
||||
|
||||
@@ -3266,16 +3061,6 @@ 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."""
|
||||
|
||||
@@ -4185,48 +3970,6 @@ class Waveform(RPCBase):
|
||||
The color palette of the figure widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def skip_large_dataset_warning(self) -> "bool":
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
|
||||
@skip_large_dataset_warning.setter
|
||||
@rpc_call
|
||||
def skip_large_dataset_warning(self) -> "bool":
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def skip_large_dataset_check(self) -> "bool":
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
|
||||
@skip_large_dataset_check.setter
|
||||
@rpc_call
|
||||
def skip_large_dataset_check(self) -> "bool":
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def max_dataset_size_mb(self) -> "float":
|
||||
"""
|
||||
The maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
|
||||
"""
|
||||
|
||||
@max_dataset_size_mb.setter
|
||||
@rpc_call
|
||||
def max_dataset_size_mb(self) -> "float":
|
||||
"""
|
||||
The maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def plot(
|
||||
self,
|
||||
|
||||
@@ -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,28 +89,16 @@ 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",
|
||||
"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
|
||||
"readback": {"kind_str": "hinted"}, # hinted
|
||||
"setpoint": {"kind_str": "normal"}, # normal
|
||||
"velocity": {"kind_str": "config"}, # config
|
||||
}
|
||||
}
|
||||
self.signals = {
|
||||
@@ -196,8 +184,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, enabled=True):
|
||||
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
|
||||
def __init__(self, name="test", limits=None, read_value=1.0):
|
||||
super().__init__(name, limits, read_value)
|
||||
|
||||
|
||||
class Device(FakeDevice):
|
||||
@@ -222,39 +210,6 @@ 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),
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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)
|
||||
@@ -15,15 +15,12 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_qthemes._main import AccentColors
|
||||
|
||||
|
||||
def get_theme_name():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
return "dark"
|
||||
else:
|
||||
return QApplication.instance().theme.theme
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
return bec_qthemes.load_palette(get_theme_name())
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
theme = "dark"
|
||||
else:
|
||||
theme = QApplication.instance().theme.theme
|
||||
return bec_qthemes.load_palette(theme)
|
||||
|
||||
|
||||
def get_accent_colors() -> AccentColors | None:
|
||||
|
||||
@@ -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.objectName() or str(id(item))
|
||||
name = item.config.monitor 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.objectName() or str(id(item))
|
||||
name = item.config.monitor 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.objectName() or str(id(item))
|
||||
name = item.config.monitor or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
@@ -14,20 +12,15 @@ 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, parent: QWidget | None = None, title: str = "", expanded: bool = True, icon: str = ""
|
||||
) -> None:
|
||||
def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self._expanded = expanded
|
||||
|
||||
@@ -36,33 +29,19 @@ class ExpandableGroupFrame(QFrame):
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._create_title_layout(title, icon)
|
||||
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._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)
|
||||
@@ -71,8 +50,7 @@ class ExpandableGroupFrame(QFrame):
|
||||
@SafeSlot()
|
||||
def switch_expanded_state(self):
|
||||
self.expanded = not self.expanded # type: ignore
|
||||
self._update_expansion_icon()
|
||||
self.expansion_state_changed.emit()
|
||||
self._update_icon()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def expanded(self): # type: ignore
|
||||
@@ -83,9 +61,8 @@ class ExpandableGroupFrame(QFrame):
|
||||
self._expanded = expanded
|
||||
self._contents.setVisible(expanded)
|
||||
self.updateGeometry()
|
||||
self.adjustSize()
|
||||
|
||||
def _update_expansion_icon(self):
|
||||
def _update_icon(self):
|
||||
self._expansion_button.setIcon(
|
||||
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
|
||||
if self.expanded
|
||||
@@ -93,36 +70,3 @@ 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()
|
||||
|
||||
@@ -8,8 +8,6 @@ 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
|
||||
|
||||
|
||||
@@ -17,13 +15,11 @@ class WidgetFilterHandler(ABC):
|
||||
"""Abstract base class for widget filter handlers"""
|
||||
|
||||
@abstractmethod
|
||||
def set_selection(self, widget, selection: list[str | tuple]) -> None:
|
||||
def set_selection(self, widget, selection: list) -> None:
|
||||
"""Set the filtered_selection for the widget
|
||||
|
||||
Args:
|
||||
widget: Widget instance
|
||||
selection (list[str | tuple]): Filtered selection of items.
|
||||
If tuple, it contains (text, data) pairs.
|
||||
selection (list): Filtered selection of items
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@@ -38,37 +34,17 @@ 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[str | tuple]) -> None:
|
||||
def set_selection(self, widget: QLineEdit, selection: list) -> None:
|
||||
"""Set the selection for the widget to the completer model
|
||||
|
||||
Args:
|
||||
widget (QLineEdit): The QLineEdit widget
|
||||
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
|
||||
selection (list): Filtered selection of items
|
||||
"""
|
||||
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)
|
||||
@@ -88,47 +64,19 @@ 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[str | tuple]) -> None:
|
||||
def set_selection(self, widget: QComboBox, selection: list) -> None:
|
||||
"""Set the selection for the widget to the completer model
|
||||
|
||||
Args:
|
||||
widget (QComboBox): The QComboBox widget
|
||||
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
|
||||
selection (list): Filtered selection of items
|
||||
"""
|
||||
widget.clear()
|
||||
if len(selection) == 0:
|
||||
return
|
||||
for element in selection:
|
||||
if isinstance(element, str):
|
||||
widget.addItem(element)
|
||||
elif isinstance(element, tuple):
|
||||
# If element is a tuple, it contains (text, data) pairs
|
||||
widget.addItem(*element)
|
||||
widget.addItems(selection)
|
||||
|
||||
def check_input(self, widget: QComboBox, text: str) -> bool:
|
||||
"""Check if the input text is in the filtered selection
|
||||
@@ -142,40 +90,6 @@ 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.
|
||||
@@ -185,14 +99,13 @@ class FilterIO:
|
||||
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
|
||||
|
||||
@staticmethod
|
||||
def set_selection(widget, selection: list[str | tuple], ignore_errors=True):
|
||||
def set_selection(widget, selection: list, ignore_errors=True):
|
||||
"""
|
||||
Retrieve value from the widget instance.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
selection (list[str | tuple]): Filtered selection of items.
|
||||
If tuple, it contains (text, data) pairs.
|
||||
selection(list): List of filtered selection items.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
@@ -226,35 +139,6 @@ 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):
|
||||
"""
|
||||
|
||||
@@ -1,107 +1,76 @@
|
||||
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 QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
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,
|
||||
)
|
||||
from bec_widgets.utils.forms_from_types.items import 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 = True
|
||||
USER_ACCESS = ["enabled", "enabled.setter"]
|
||||
RPC = False
|
||||
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
||||
"""
|
||||
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 = []
|
||||
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")
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
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._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._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()
|
||||
@@ -109,22 +78,19 @@ 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 = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
|
||||
widget = widget_from_type(item.item_type)(parent=self, spec=item)
|
||||
widget.valueChanged.connect(self.value_changed)
|
||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
grid.addWidget(widget, row, 1)
|
||||
|
||||
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"""
|
||||
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
|
||||
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 {
|
||||
row.label.property("_model_field_name"): row.widget.getValue()
|
||||
for row in self.enumerate_form_widgets()
|
||||
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())
|
||||
}
|
||||
|
||||
def _clear_grid(self):
|
||||
@@ -137,13 +103,10 @@ 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()
|
||||
@@ -151,56 +114,23 @@ 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,
|
||||
data_model: type[BaseModel] | None = None,
|
||||
enabled: bool = True,
|
||||
pretty_display: bool = False,
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
def __init__(self, parent=None, metadata_model: type[BaseModel] = None, client=None, **kwargs):
|
||||
"""
|
||||
A form generated from a pydantic model.
|
||||
|
||||
Args:
|
||||
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.
|
||||
|
||||
metadata_model (type[BaseModel]): the model class for which to generate a form.
|
||||
"""
|
||||
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._md_schema = metadata_model
|
||||
super().__init__(parent=parent, form_item_specs=self._form_item_specs(), client=client)
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
self._validity.compact_view = True # type: ignore
|
||||
@@ -213,40 +143,13 @@ 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, pretty_display=self._pretty_display
|
||||
)
|
||||
FormItemSpec(name=name, info=info, item_type=info.annotation)
|
||||
for name, info in self._md_schema.model_fields.items()
|
||||
]
|
||||
|
||||
|
||||
@@ -1,44 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from types import GenericAlias, UnionType
|
||||
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args
|
||||
from types import UnionType
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.fields import FieldInfo
|
||||
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.QtCore import Signal # type: ignore
|
||||
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,
|
||||
@@ -47,7 +34,6 @@ 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
|
||||
|
||||
@@ -60,36 +46,9 @@ class FormItemSpec(BaseModel):
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
item_type: type | UnionType | GenericAlias
|
||||
item_type: type | UnionType
|
||||
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):
|
||||
@@ -135,20 +94,10 @@ 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()
|
||||
@@ -158,17 +107,11 @@ class DynamicFormItem(QWidget):
|
||||
self._desc = self._spec.info.description
|
||||
self.setLayout(self._layout)
|
||||
self._add_main_widget()
|
||||
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()
|
||||
if clearable_required(spec.info):
|
||||
self._add_clear_button()
|
||||
|
||||
@abstractmethod
|
||||
def getValue(self) -> DynamicFormItemType: ...
|
||||
def getValue(self): ...
|
||||
|
||||
@abstractmethod
|
||||
def setValue(self, value): ...
|
||||
@@ -178,11 +121,6 @@ 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 "")
|
||||
|
||||
@@ -200,7 +138,7 @@ class DynamicFormItem(QWidget):
|
||||
self.valueChanged.emit()
|
||||
|
||||
|
||||
class StrFormItem(DynamicFormItem):
|
||||
class StrMetadataField(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)
|
||||
@@ -225,11 +163,11 @@ class StrFormItem(DynamicFormItem):
|
||||
|
||||
def setValue(self, value: str):
|
||||
if value is None:
|
||||
return self._main_widget.setText("")
|
||||
self._main_widget.setText(str(value))
|
||||
self._main_widget.setText("")
|
||||
self._main_widget.setText(value)
|
||||
|
||||
|
||||
class IntFormItem(DynamicFormItem):
|
||||
class IntMetadataField(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)
|
||||
@@ -258,18 +196,18 @@ class IntFormItem(DynamicFormItem):
|
||||
self._main_widget.setValue(value)
|
||||
|
||||
|
||||
class FloatDecimalFormItem(DynamicFormItem):
|
||||
class FloatDecimalMetadataField(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, float, precision)
|
||||
min_, max_ = field_limits(self._spec.info, int)
|
||||
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}"
|
||||
@@ -286,13 +224,13 @@ class FloatDecimalFormItem(DynamicFormItem):
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: float | Decimal):
|
||||
def setValue(self, value: float):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(float(value))
|
||||
self._main_widget.setValue(value)
|
||||
|
||||
|
||||
class BoolFormItem(DynamicFormItem):
|
||||
class BoolMetadataField(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)
|
||||
@@ -313,300 +251,36 @@ class BoolFormItem(DynamicFormItem):
|
||||
self._main_widget.setChecked(value)
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
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"])
|
||||
layout.addWidget(widget_from_type(info.annotation)(info), i, 1)
|
||||
|
||||
w.show()
|
||||
app.exec()
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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())
|
||||
@@ -8,9 +8,6 @@ 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):
|
||||
@@ -93,20 +90,34 @@ class DesignerPluginGenerator:
|
||||
|
||||
# Check if the widget class calls the super constructor with parent argument
|
||||
init_source = inspect.getsource(self.widget.__init__)
|
||||
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
|
||||
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)
|
||||
)
|
||||
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 = SUPER_INIT_REGEX.search(init_source) is not None
|
||||
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)
|
||||
)
|
||||
|
||||
# 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 = SUPER_INIT_REGEX.search(init_source) is not None
|
||||
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
|
||||
)
|
||||
|
||||
if not cls_init_found and not super_init_found:
|
||||
raise ValueError(
|
||||
|
||||
@@ -195,7 +195,7 @@ class RPCServer:
|
||||
return
|
||||
self._broadcasted_data = data
|
||||
|
||||
logger.debug(f"Broadcasting registry update: {data} for {self.gui_id}")
|
||||
logger.info(f"Broadcasting registry update: {data} for {self.gui_id}")
|
||||
self.client.connector.xadd(
|
||||
MessageEndpoints.gui_registry_state(self.gui_id),
|
||||
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtGui import QCloseEvent
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
@@ -9,7 +9,7 @@ from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
logger = bec_logger.logger
|
||||
|
||||
if PYSIDE6:
|
||||
from qtpy.QtUiTools import QUiLoader
|
||||
from PySide6.QtUiTools import QUiLoader
|
||||
|
||||
class CustomUiLoader(QUiLoader):
|
||||
def __init__(self, baseinstance, custom_widgets: dict | None = None):
|
||||
|
||||
@@ -169,9 +169,6 @@ 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(),
|
||||
@@ -241,9 +238,6 @@ 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)
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
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_())
|
||||
@@ -1,110 +0,0 @@
|
||||
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)
|
||||
|
||||
# delay‑before‑scroll timer (single‑shot)
|
||||
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 single‑shot
|
||||
delay timer (2s by default). Scrolling begins only after this
|
||||
delay. Any change (resize or new text) restarts the logic.
|
||||
"""
|
||||
needs_scroll = self._text_width > self.width()
|
||||
|
||||
if needs_scroll:
|
||||
# Reset any running timers
|
||||
if self._timer.isActive():
|
||||
self._timer.stop()
|
||||
if self._delay_timer.isActive():
|
||||
self._delay_timer.stop()
|
||||
|
||||
self._offset = 0
|
||||
|
||||
# Start scrolling immediately when we should skip the delay,
|
||||
# otherwise apply the configured delay_ms interval
|
||||
if skip_delay:
|
||||
self._timer.start()
|
||||
else:
|
||||
self._delay_timer.start()
|
||||
else:
|
||||
if self._delay_timer.isActive():
|
||||
self._delay_timer.stop()
|
||||
if self._timer.isActive():
|
||||
self._timer.stop()
|
||||
self.update()
|
||||
|
||||
def _scroll(self):
|
||||
self._offset += self._step_px
|
||||
if self._offset >= self._text_width:
|
||||
self._offset = 0
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.TextAntialiasing)
|
||||
text = self.text()
|
||||
if not text:
|
||||
return
|
||||
fm = QFontMetrics(self.font())
|
||||
y = (self.height() + fm.ascent() - fm.descent()) // 2
|
||||
if self._text_width <= self.width():
|
||||
painter.drawText(0, y, text)
|
||||
else:
|
||||
x = -self._offset
|
||||
gap = 50 # space between repeating text blocks
|
||||
while x < self.width():
|
||||
painter.drawText(x, y, text)
|
||||
x += self._text_width + gap
|
||||
|
||||
def cleanup(self):
|
||||
"""Stop all timers to prevent memory leaks."""
|
||||
if self._timer.isActive():
|
||||
self._timer.stop()
|
||||
if self._delay_timer.isActive():
|
||||
self._delay_timer.stop()
|
||||
@@ -1,31 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QStyle,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
|
||||
|
||||
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__)
|
||||
|
||||
@@ -33,8 +19,6 @@ 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,
|
||||
@@ -48,19 +32,10 @@ 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
|
||||
@@ -68,189 +43,40 @@ 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: App‑ID 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: Client‑info 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("")
|
||||
)
|
||||
|
||||
################################################################################
|
||||
# Progress‑bar 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
|
||||
|
||||
@@ -338,64 +164,14 @@ 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()
|
||||
if central_widget is not None:
|
||||
central_widget.close()
|
||||
central_widget.deleteLater()
|
||||
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
|
||||
@@ -406,39 +182,8 @@ 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())
|
||||
|
||||
@@ -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 ###
|
||||
|
||||
@SafeSlot(str)
|
||||
@Slot(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.")
|
||||
|
||||
@SafeSlot()
|
||||
@Slot()
|
||||
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)
|
||||
|
||||
@SafeSlot(list)
|
||||
@Slot(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 ###
|
||||
|
||||
@SafeProperty(
|
||||
@Property(
|
||||
"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)
|
||||
|
||||
@SafeProperty(str)
|
||||
@Property(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)
|
||||
|
||||
@SafeProperty(bool)
|
||||
@Property(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()
|
||||
|
||||
@SafeProperty(bool)
|
||||
@Property(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()
|
||||
|
||||
@SafeProperty(bool)
|
||||
@Property(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()
|
||||
|
||||
@SafeProperty(bool)
|
||||
@Property(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()
|
||||
|
||||
@SafeProperty(bool)
|
||||
@Property(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()
|
||||
|
||||
@SafeProperty(bool)
|
||||
@Property(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()
|
||||
|
||||
@SafeProperty(bool)
|
||||
@Property(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()
|
||||
|
||||
@SafeProperty(bool)
|
||||
@Property(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()
|
||||
|
||||
@SafeProperty(bool)
|
||||
@Property(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()
|
||||
|
||||
@SafeProperty(bool)
|
||||
@Property(bool)
|
||||
def readout_on_request(self):
|
||||
"""Include devices with readout priority OnRequest in filters."""
|
||||
return ReadoutPriority.ON_REQUEST in self.readout_filter
|
||||
|
||||
@@ -6,7 +6,7 @@ from qtpy.QtCore import Property
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.filter_io import FilterIO, LineEditFilterHandler
|
||||
from bec_widgets.utils.filter_io import FilterIO
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
@@ -55,7 +55,7 @@ class DeviceSignalInputBase(BECWidget):
|
||||
self._hinted_signals = []
|
||||
self._normal_signals = []
|
||||
self._config_signals = []
|
||||
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
|
||||
self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.update_signals_from_filters
|
||||
)
|
||||
|
||||
@@ -108,32 +108,25 @@ class DeviceSignalInputBase(BECWidget):
|
||||
if not self.validate_device(self._device):
|
||||
self._device = None
|
||||
self.config.device = self._device
|
||||
self._signals = []
|
||||
self._hinted_signals = []
|
||||
self._normal_signals = []
|
||||
self._config_signals = []
|
||||
FilterIO.set_selection(widget=self, selection=self._signals)
|
||||
return
|
||||
device = self.get_device_object(self._device)
|
||||
device_info = device._info.get("signals", {})
|
||||
|
||||
# See above convention for Signals and ComputedSignals
|
||||
if isinstance(device, Signal):
|
||||
self._signals = [(self._device, {})]
|
||||
self._hinted_signals = [(self._device, {})]
|
||||
self._signals = [self._device]
|
||||
self._hinted_signals = [self._device]
|
||||
self._normal_signals = []
|
||||
self._config_signals = []
|
||||
FilterIO.set_selection(widget=self, selection=self._signals)
|
||||
return
|
||||
device_info = device._info.get("signals", {})
|
||||
|
||||
def _update(kind: Kind):
|
||||
return FilterIO.update_with_kind(
|
||||
widget=self,
|
||||
kind=kind,
|
||||
signal_filter=self.signal_filter,
|
||||
device_info=device_info,
|
||||
device_name=self._device,
|
||||
)
|
||||
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))
|
||||
]
|
||||
|
||||
self._hinted_signals = _update(Kind.hinted)
|
||||
self._normal_signals = _update(Kind.normal)
|
||||
@@ -278,21 +271,11 @@ class DeviceSignalInputBase(BECWidget):
|
||||
Args:
|
||||
signal(str): Signal to validate.
|
||||
"""
|
||||
for entry in self.signals:
|
||||
if isinstance(entry, tuple):
|
||||
entry = entry[0]
|
||||
if entry == signal:
|
||||
return True
|
||||
if signal in self.signals:
|
||||
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()
|
||||
|
||||
@@ -34,7 +34,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
PLUGIN = True
|
||||
|
||||
device_selected = Signal(str)
|
||||
device_reset = Signal()
|
||||
device_config_update = Signal()
|
||||
|
||||
def __init__(
|
||||
@@ -148,28 +147,8 @@ 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 preview‑signal pseudo‑devices (labels like
|
||||
``"eiger_preview"``) are accepted as valid choices.
|
||||
|
||||
The validation run only on device not on the preview‑signal.
|
||||
|
||||
Args:
|
||||
device: The text currently entered/selected.
|
||||
|
||||
Returns:
|
||||
True if the device is a genuine BEC device *or* one of the
|
||||
whitelisted preview‑signal entries.
|
||||
"""
|
||||
idx = self.findText(device)
|
||||
if idx >= 0 and isinstance(self.itemData(idx), tuple):
|
||||
device = self.itemData(idx)[0] # type: ignore[assignment]
|
||||
return super().validate_device(device)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
@@ -90,44 +90,6 @@ 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.
|
||||
@@ -140,7 +102,11 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
return
|
||||
if self.validate_signal(text) is False:
|
||||
return
|
||||
self.device_signal_changed.emit(text)
|
||||
if text == "readback" and isinstance(self.get_device_object(self.device), Positioner):
|
||||
device_signal = self.device
|
||||
else:
|
||||
device_signal = f"{self.device}_{text}"
|
||||
self.device_signal_changed.emit(device_signal)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -89,7 +89,6 @@ 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()
|
||||
@@ -166,6 +165,7 @@ 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,40 +203,35 @@ class ScanControl(BECWidget, QWidget):
|
||||
"""
|
||||
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
|
||||
"""
|
||||
self.last_scan_found = False
|
||||
if not self.toggle.checked:
|
||||
return
|
||||
|
||||
enabled = self.toggle.checked
|
||||
current_scan = self.comboBox_scan_selection.currentText()
|
||||
history = (
|
||||
self.client.connector.xread(
|
||||
MessageEndpoints.scan_history(), from_start=True, user_id=self.object_name
|
||||
)
|
||||
or []
|
||||
)
|
||||
if enabled:
|
||||
history = self.client.connector.lrange(MessageEndpoints.scan_queue_history(), 0, -1)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@SafeProperty(str)
|
||||
def current_scan(self):
|
||||
|
||||
870
bec_widgets/widgets/editors/console/console.py
Normal file
870
bec_widgets/widgets/editors/console/console.py
Normal file
@@ -0,0 +1,870 @@
|
||||
"""
|
||||
BECConsole is a Qt widget that runs a Bash shell.
|
||||
|
||||
BECConsole VT100 emulation is powered by Pyte,
|
||||
(https://github.com/selectel/pyte).
|
||||
"""
|
||||
|
||||
import collections
|
||||
import fcntl
|
||||
import html
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pyte
|
||||
from pygments.token import Token
|
||||
from pyte.screens import History
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
from qtpy.QtCore import Property as pyqtProperty
|
||||
from qtpy.QtCore import QSize, QSocketNotifier, Qt, QTimer
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtGui import QClipboard, QColor, QPalette, QTextCursor
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot as Slot
|
||||
|
||||
ansi_colors = {
|
||||
"black": "#000000",
|
||||
"red": "#CD0000",
|
||||
"green": "#00CD00",
|
||||
"brown": "#996633", # Brown, replacing the yellow
|
||||
"blue": "#0000EE",
|
||||
"magenta": "#CD00CD",
|
||||
"cyan": "#00CDCD",
|
||||
"white": "#E5E5E5",
|
||||
"brightblack": "#7F7F7F",
|
||||
"brightred": "#FF0000",
|
||||
"brightgreen": "#00FF00",
|
||||
"brightyellow": "#FFFF00",
|
||||
"brightblue": "#5C5CFF",
|
||||
"brightmagenta": "#FF00FF",
|
||||
"brightcyan": "#00FFFF",
|
||||
"brightwhite": "#FFFFFF",
|
||||
}
|
||||
|
||||
control_keys_mapping = {
|
||||
QtCore.Qt.Key_A: b"\x01", # Ctrl-A
|
||||
QtCore.Qt.Key_B: b"\x02", # Ctrl-B
|
||||
QtCore.Qt.Key_C: b"\x03", # Ctrl-C
|
||||
QtCore.Qt.Key_D: b"\x04", # Ctrl-D
|
||||
QtCore.Qt.Key_E: b"\x05", # Ctrl-E
|
||||
QtCore.Qt.Key_F: b"\x06", # Ctrl-F
|
||||
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
|
||||
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
|
||||
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
|
||||
QtCore.Qt.Key_J: b"\x0a", # Ctrl-J (Line Feed)
|
||||
QtCore.Qt.Key_K: b"\x0b", # Ctrl-K (Vertical Tab)
|
||||
QtCore.Qt.Key_L: b"\x0c", # Ctrl-L (Form Feed)
|
||||
QtCore.Qt.Key_M: b"\x0d", # Ctrl-M (Carriage Return)
|
||||
QtCore.Qt.Key_N: b"\x0e", # Ctrl-N
|
||||
QtCore.Qt.Key_O: b"\x0f", # Ctrl-O
|
||||
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
|
||||
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
|
||||
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
|
||||
QtCore.Qt.Key_S: b"\x13", # Ctrl-S
|
||||
QtCore.Qt.Key_T: b"\x14", # Ctrl-T
|
||||
QtCore.Qt.Key_U: b"\x15", # Ctrl-U
|
||||
QtCore.Qt.Key_V: b"\x16", # Ctrl-V
|
||||
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
|
||||
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
|
||||
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
|
||||
QtCore.Qt.Key_Z: b"\x1a", # Ctrl-Z
|
||||
QtCore.Qt.Key_Escape: b"\x1b", # Ctrl-Escape
|
||||
QtCore.Qt.Key_Backslash: b"\x1c", # Ctrl-\
|
||||
QtCore.Qt.Key_Underscore: b"\x1f", # Ctrl-_
|
||||
}
|
||||
|
||||
normal_keys_mapping = {
|
||||
QtCore.Qt.Key_Return: b"\n",
|
||||
QtCore.Qt.Key_Space: b" ",
|
||||
QtCore.Qt.Key_Enter: b"\n",
|
||||
QtCore.Qt.Key_Tab: b"\t",
|
||||
QtCore.Qt.Key_Backspace: b"\x08",
|
||||
QtCore.Qt.Key_Home: b"\x47",
|
||||
QtCore.Qt.Key_End: b"\x4f",
|
||||
QtCore.Qt.Key_Left: b"\x02",
|
||||
QtCore.Qt.Key_Up: b"\x10",
|
||||
QtCore.Qt.Key_Right: b"\x06",
|
||||
QtCore.Qt.Key_Down: b"\x0e",
|
||||
QtCore.Qt.Key_PageUp: b"\x49",
|
||||
QtCore.Qt.Key_PageDown: b"\x51",
|
||||
QtCore.Qt.Key_F1: b"\x1b\x31",
|
||||
QtCore.Qt.Key_F2: b"\x1b\x32",
|
||||
QtCore.Qt.Key_F3: b"\x1b\x33",
|
||||
QtCore.Qt.Key_F4: b"\x1b\x34",
|
||||
QtCore.Qt.Key_F5: b"\x1b\x35",
|
||||
QtCore.Qt.Key_F6: b"\x1b\x36",
|
||||
QtCore.Qt.Key_F7: b"\x1b\x37",
|
||||
QtCore.Qt.Key_F8: b"\x1b\x38",
|
||||
QtCore.Qt.Key_F9: b"\x1b\x39",
|
||||
QtCore.Qt.Key_F10: b"\x1b\x30",
|
||||
QtCore.Qt.Key_F11: b"\x45",
|
||||
QtCore.Qt.Key_F12: b"\x46",
|
||||
}
|
||||
|
||||
|
||||
def QtKeyToAscii(event):
|
||||
"""
|
||||
Convert the Qt key event to the corresponding ASCII sequence for
|
||||
the terminal. This works fine for standard alphanumerical characters, but
|
||||
most other characters require terminal specific control sequences.
|
||||
|
||||
The conversion below works for TERM="linux" terminals.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
# special case for MacOS
|
||||
# /!\ Qt maps ControlModifier to CMD
|
||||
# CMD-C, CMD-V for copy/paste
|
||||
# CTRL-C and other modifiers -> key mapping
|
||||
if event.modifiers() == QtCore.Qt.MetaModifier:
|
||||
if event.key() == Qt.Key_Backspace:
|
||||
return control_keys_mapping.get(Qt.Key_W)
|
||||
return control_keys_mapping.get(event.key())
|
||||
elif event.modifiers() == QtCore.Qt.ControlModifier:
|
||||
if event.key() == Qt.Key_C:
|
||||
# copy
|
||||
return "copy"
|
||||
elif event.key() == Qt.Key_V:
|
||||
# paste
|
||||
return "paste"
|
||||
return None
|
||||
else:
|
||||
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
|
||||
if event.modifiers() == QtCore.Qt.ControlModifier:
|
||||
return control_keys_mapping.get(event.key())
|
||||
else:
|
||||
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
|
||||
|
||||
|
||||
class Screen(pyte.HistoryScreen):
|
||||
def __init__(self, stdin_fd, cols, rows, historyLength):
|
||||
super().__init__(cols, rows, historyLength, ratio=1 / rows)
|
||||
self._fd = stdin_fd
|
||||
|
||||
def write_process_input(self, data):
|
||||
"""Response to CPR request (for example),
|
||||
this can be for other requests
|
||||
"""
|
||||
try:
|
||||
os.write(self._fd, data.encode("utf-8"))
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
|
||||
def resize(self, lines, columns):
|
||||
lines = lines or self.lines
|
||||
columns = columns or self.columns
|
||||
|
||||
if lines == self.lines and columns == self.columns:
|
||||
return # No changes.
|
||||
|
||||
self.dirty.clear()
|
||||
self.dirty.update(range(lines))
|
||||
|
||||
self.save_cursor()
|
||||
if lines < self.lines:
|
||||
if lines <= self.cursor.y:
|
||||
nlines_to_move_up = self.lines - lines
|
||||
for i in range(nlines_to_move_up):
|
||||
line = self.buffer[i] # .pop(0)
|
||||
self.history.top.append(line)
|
||||
self.cursor_position(0, 0)
|
||||
self.delete_lines(nlines_to_move_up)
|
||||
self.restore_cursor()
|
||||
self.cursor.y -= nlines_to_move_up
|
||||
else:
|
||||
self.restore_cursor()
|
||||
|
||||
self.lines, self.columns = lines, columns
|
||||
self.history = History(
|
||||
self.history.top,
|
||||
self.history.bottom,
|
||||
1 / self.lines,
|
||||
self.history.size,
|
||||
self.history.position,
|
||||
)
|
||||
self.set_margins()
|
||||
|
||||
|
||||
class Backend(QtCore.QObject):
|
||||
"""
|
||||
Poll Bash.
|
||||
|
||||
This class will run as a qsocketnotifier (started in ``_TerminalWidget``) and poll the
|
||||
file descriptor of the Bash terminal.
|
||||
"""
|
||||
|
||||
# Signals to communicate with ``_TerminalWidget``.
|
||||
dataReady = pyqtSignal(object)
|
||||
processExited = pyqtSignal()
|
||||
|
||||
def __init__(self, fd, cols, rows):
|
||||
super().__init__()
|
||||
|
||||
# File descriptor that connects to Bash process.
|
||||
self.fd = fd
|
||||
|
||||
# Setup Pyte (hard coded display size for now).
|
||||
self.screen = Screen(self.fd, cols, rows, 10000)
|
||||
self.stream = pyte.ByteStream()
|
||||
self.stream.attach(self.screen)
|
||||
|
||||
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
|
||||
self.notifier.activated.connect(self._fd_readable)
|
||||
|
||||
def _fd_readable(self):
|
||||
"""
|
||||
Poll the Bash output, run it through Pyte, and notify
|
||||
"""
|
||||
# Read the shell output until the file descriptor is closed.
|
||||
try:
|
||||
out = os.read(self.fd, 2**16)
|
||||
except OSError:
|
||||
self.processExited.emit()
|
||||
self.notifier.setEnabled(False)
|
||||
return
|
||||
|
||||
# Feed output into Pyte's state machine and send the new screen
|
||||
# output to the GUI
|
||||
self.stream.feed(out)
|
||||
self.dataReady.emit(self.screen)
|
||||
|
||||
|
||||
class BECConsole(QtWidgets.QWidget):
|
||||
"""Container widget for the terminal text area"""
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "terminal"
|
||||
|
||||
prompt = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, parent=None, cols=132):
|
||||
super().__init__(parent)
|
||||
|
||||
self.term = _TerminalWidget(self, cols, rows=43)
|
||||
self.term.prompt.connect(self.prompt) # forward signal from term to this widget
|
||||
|
||||
self.scroll_bar = QScrollBar(Qt.Vertical, self)
|
||||
# self.scroll_bar.hide()
|
||||
layout = QHBoxLayout(self)
|
||||
layout.addWidget(self.term)
|
||||
layout.addWidget(self.scroll_bar)
|
||||
layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding)
|
||||
|
||||
pal = QPalette()
|
||||
self.set_bgcolor(pal.window().color())
|
||||
self.set_fgcolor(pal.windowText().color())
|
||||
self.term.set_scroll_bar(self.scroll_bar)
|
||||
self.set_cmd("bec --nogui")
|
||||
|
||||
self._check_designer_timer = QTimer()
|
||||
self._check_designer_timer.timeout.connect(self.check_designer)
|
||||
self._check_designer_timer.start(1000)
|
||||
|
||||
def minimumSizeHint(self):
|
||||
size = self.term.sizeHint()
|
||||
size.setWidth(size.width() + self.scroll_bar.width())
|
||||
return size
|
||||
|
||||
def sizeHint(self):
|
||||
return self.minimumSizeHint()
|
||||
|
||||
def check_designer(self, calls={"n": 0}):
|
||||
calls["n"] += 1
|
||||
if self.term.fd is not None:
|
||||
# already started
|
||||
self._check_designer_timer.stop()
|
||||
elif self.window().windowTitle().endswith("[Preview]"):
|
||||
# assuming Designer preview -> start
|
||||
self._check_designer_timer.stop()
|
||||
self.term.start()
|
||||
elif calls["n"] >= 3:
|
||||
# assuming not in Designer -> stop checking
|
||||
self._check_designer_timer.stop()
|
||||
|
||||
def get_rows(self):
|
||||
return self.term.rows
|
||||
|
||||
def set_rows(self, rows):
|
||||
self.term.rows = rows
|
||||
self.adjustSize()
|
||||
self.updateGeometry()
|
||||
|
||||
def get_cols(self):
|
||||
return self.term.cols
|
||||
|
||||
def set_cols(self, cols):
|
||||
self.term.cols = cols
|
||||
self.adjustSize()
|
||||
self.updateGeometry()
|
||||
|
||||
def get_bgcolor(self):
|
||||
return QColor.fromString(self.term.bg_color)
|
||||
|
||||
def set_bgcolor(self, color):
|
||||
self.term.bg_color = color.name(QColor.HexRgb)
|
||||
|
||||
def get_fgcolor(self):
|
||||
return QColor.fromString(self.term.fg_color)
|
||||
|
||||
def set_fgcolor(self, color):
|
||||
self.term.fg_color = color.name(QColor.HexRgb)
|
||||
|
||||
def get_cmd(self):
|
||||
return self.term._cmd
|
||||
|
||||
def set_cmd(self, cmd):
|
||||
self.term._cmd = cmd
|
||||
if self.term.fd is None:
|
||||
# not started yet
|
||||
self.term.clear()
|
||||
self.term.appendHtml(f"<h2>BEC Console - {repr(cmd)}</h2>")
|
||||
|
||||
def start(self, deactivate_ctrl_d=True):
|
||||
self.term.start(deactivate_ctrl_d=deactivate_ctrl_d)
|
||||
|
||||
def push(self, text, hit_return=False):
|
||||
"""Push some text to the terminal"""
|
||||
return self.term.push(text, hit_return=hit_return)
|
||||
|
||||
def execute_command(self, command):
|
||||
self.push(command, hit_return=True)
|
||||
|
||||
def set_prompt_tokens(self, *tokens):
|
||||
"""Prepare regexp to identify prompt, based on tokens
|
||||
|
||||
Tokens are returned from get_ipython().prompts.in_prompt_tokens()
|
||||
"""
|
||||
regex_parts = []
|
||||
for token_type, token_value in tokens:
|
||||
if token_type == Token.PromptNum: # Handle dynamic prompt number
|
||||
regex_parts.append(r"[\d\?]+") # Match one or more digits or '?'
|
||||
else:
|
||||
# Escape other prompt parts (e.g., "In [", "]: ")
|
||||
if not token_value:
|
||||
regex_parts.append(".+?") # arbitrary string
|
||||
else:
|
||||
regex_parts.append(re.escape(token_value))
|
||||
|
||||
# Combine into a single regex
|
||||
prompt_pattern = "".join(regex_parts)
|
||||
self.term._prompt_re = re.compile(prompt_pattern + r"\s*$")
|
||||
|
||||
def terminate(self, timeout=10):
|
||||
self.term.stop(timeout=timeout)
|
||||
|
||||
def send_ctrl_c(self, timeout=None):
|
||||
self.term.send_ctrl_c(timeout)
|
||||
|
||||
cols = pyqtProperty(int, get_cols, set_cols)
|
||||
rows = pyqtProperty(int, get_rows, set_rows)
|
||||
bgcolor = pyqtProperty(QColor, get_bgcolor, set_bgcolor)
|
||||
fgcolor = pyqtProperty(QColor, get_fgcolor, set_fgcolor)
|
||||
cmd = pyqtProperty(str, get_cmd, set_cmd)
|
||||
|
||||
|
||||
class _TerminalWidget(QtWidgets.QPlainTextEdit):
|
||||
"""
|
||||
Start ``Backend`` process and render Pyte output as text.
|
||||
"""
|
||||
|
||||
prompt = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, parent, cols=125, rows=50, **kwargs):
|
||||
# regexp to match prompt
|
||||
self._prompt_re = None
|
||||
# last prompt
|
||||
self._prompt_str = None
|
||||
# process pid
|
||||
self.pid = None
|
||||
# file descriptor to communicate with the subprocess
|
||||
self.fd = None
|
||||
self.backend = None
|
||||
# command to execute
|
||||
self._cmd = ""
|
||||
# should ctrl-d be deactivated ? (prevent Python exit)
|
||||
self._deactivate_ctrl_d = False
|
||||
|
||||
# Default colors
|
||||
pal = QPalette()
|
||||
self._fg_color = pal.text().color().name()
|
||||
self._bg_color = pal.base().color().name()
|
||||
|
||||
# Specify the terminal size in terms of lines and columns.
|
||||
self._rows = rows
|
||||
self._cols = cols
|
||||
self.output = collections.deque()
|
||||
|
||||
super().__init__(parent)
|
||||
|
||||
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Expanding)
|
||||
|
||||
# Disable default scrollbars (we use our own, to be set via .set_scroll_bar())
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.scroll_bar = None
|
||||
|
||||
# Use Monospace fonts and disable line wrapping.
|
||||
self.setFont(QtGui.QFont("Courier", 9))
|
||||
self.setFont(QtGui.QFont("Monospace"))
|
||||
self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
|
||||
fmt = QtGui.QFontMetrics(self.font())
|
||||
char_width = fmt.width("w")
|
||||
self.setCursorWidth(char_width)
|
||||
|
||||
self.adjustSize()
|
||||
self.updateGeometry()
|
||||
self.update_stylesheet()
|
||||
|
||||
@property
|
||||
def bg_color(self):
|
||||
return self._bg_color
|
||||
|
||||
@bg_color.setter
|
||||
def bg_color(self, hexcolor):
|
||||
self._bg_color = hexcolor
|
||||
self.update_stylesheet()
|
||||
|
||||
@property
|
||||
def fg_color(self):
|
||||
return self._fg_color
|
||||
|
||||
@fg_color.setter
|
||||
def fg_color(self, hexcolor):
|
||||
self._fg_color = hexcolor
|
||||
self.update_stylesheet()
|
||||
|
||||
def update_stylesheet(self):
|
||||
self.setStyleSheet(
|
||||
f"QPlainTextEdit {{ border: 0; color: {self._fg_color}; background-color: {self._bg_color}; }} "
|
||||
)
|
||||
|
||||
@property
|
||||
def rows(self):
|
||||
return self._rows
|
||||
|
||||
@rows.setter
|
||||
def rows(self, rows: int):
|
||||
if self.backend is None:
|
||||
# not initialized yet, ok to change
|
||||
self._rows = rows
|
||||
self.adjustSize()
|
||||
self.updateGeometry()
|
||||
else:
|
||||
raise RuntimeError("Cannot change rows after console is started.")
|
||||
|
||||
@property
|
||||
def cols(self):
|
||||
return self._cols
|
||||
|
||||
@cols.setter
|
||||
def cols(self, cols: int):
|
||||
if self.fd is None:
|
||||
# not initialized yet, ok to change
|
||||
self._cols = cols
|
||||
self.adjustSize()
|
||||
self.updateGeometry()
|
||||
else:
|
||||
raise RuntimeError("Cannot change cols after console is started.")
|
||||
|
||||
def start(self, deactivate_ctrl_d: bool = False):
|
||||
self._deactivate_ctrl_d = deactivate_ctrl_d
|
||||
|
||||
self.update_term_size()
|
||||
|
||||
# Start the Bash process
|
||||
self.pid, self.fd = self.fork_shell()
|
||||
|
||||
if self.fd:
|
||||
# Create the ``Backend`` object
|
||||
self.backend = Backend(self.fd, self.cols, self.rows)
|
||||
self.backend.dataReady.connect(self.data_ready)
|
||||
self.backend.processExited.connect(self.process_exited)
|
||||
else:
|
||||
self.process_exited()
|
||||
|
||||
def process_exited(self):
|
||||
self.fd = None
|
||||
self.clear()
|
||||
self.appendHtml(f"<br><h2>{repr(self._cmd)} - Process exited.</h2>")
|
||||
self.setReadOnly(True)
|
||||
|
||||
def send_ctrl_c(self, wait_prompt=True, timeout=None):
|
||||
"""Send CTRL-C to the process
|
||||
|
||||
If wait_prompt=True (default), wait for a new prompt after CTRL-C
|
||||
If no prompt is displayed after 'timeout' seconds, TimeoutError is raised
|
||||
"""
|
||||
os.kill(self.pid, signal.SIGINT)
|
||||
if wait_prompt:
|
||||
timeout_error = False
|
||||
if timeout:
|
||||
|
||||
def set_timeout_error():
|
||||
nonlocal timeout_error
|
||||
timeout_error = True
|
||||
|
||||
timeout_timer = QTimer()
|
||||
timeout_timer.singleShot(timeout * 1000, set_timeout_error)
|
||||
while self._prompt_str is None:
|
||||
QApplication.instance().process_events()
|
||||
if timeout_error:
|
||||
raise TimeoutError(
|
||||
f"CTRL-C: could not get back to prompt after {timeout} seconds."
|
||||
)
|
||||
|
||||
def _is_running(self):
|
||||
if os.waitpid(self.pid, os.WNOHANG) == (0, 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
def stop(self, kill=True, timeout=None):
|
||||
"""Stop the running process
|
||||
|
||||
SIGTERM is the default signal for terminating processes.
|
||||
|
||||
If kill=True (default), SIGKILL will be sent if the process does not exit after timeout
|
||||
"""
|
||||
# try to exit gracefully
|
||||
os.kill(self.pid, signal.SIGTERM)
|
||||
|
||||
# wait until process is truly dead
|
||||
t0 = time.perf_counter()
|
||||
while self._is_running():
|
||||
time.sleep(1)
|
||||
if timeout is not None and time.perf_counter() - t0 > timeout:
|
||||
# still alive after 'timeout' seconds
|
||||
if kill:
|
||||
# send SIGKILL and make a last check in loop
|
||||
os.kill(self.pid, signal.SIGKILL)
|
||||
kill = False
|
||||
else:
|
||||
# still running after timeout...
|
||||
raise TimeoutError(
|
||||
f"Could not terminate process with pid: {self.pid} within timeout"
|
||||
)
|
||||
self.process_exited()
|
||||
|
||||
def data_ready(self, screen):
|
||||
"""Handle new screen: redraw, set scroll bar max and slider, move cursor to its position
|
||||
|
||||
This method is triggered via a signal from ``Backend``.
|
||||
"""
|
||||
self.redraw_screen()
|
||||
self.adjust_scroll_bar()
|
||||
self.move_cursor()
|
||||
|
||||
def minimumSizeHint(self):
|
||||
"""Return minimum size for current cols and rows"""
|
||||
fmt = QtGui.QFontMetrics(self.font())
|
||||
char_width = fmt.width("w")
|
||||
char_height = fmt.height()
|
||||
width = char_width * self.cols
|
||||
height = char_height * self.rows
|
||||
return QSize(width, height)
|
||||
|
||||
def sizeHint(self):
|
||||
return self.minimumSizeHint()
|
||||
|
||||
def set_scroll_bar(self, scroll_bar):
|
||||
self.scroll_bar = scroll_bar
|
||||
self.scroll_bar.setMinimum(0)
|
||||
self.scroll_bar.valueChanged.connect(self.scroll_value_change)
|
||||
|
||||
def scroll_value_change(self, value, old={"value": -1}):
|
||||
if self.backend is None:
|
||||
return
|
||||
if old["value"] == -1:
|
||||
old["value"] = self.scroll_bar.maximum()
|
||||
if value <= old["value"]:
|
||||
# scroll up
|
||||
# value is number of lines from the start
|
||||
nlines = old["value"] - value
|
||||
# history ratio gives prev_page == 1 line
|
||||
for i in range(nlines):
|
||||
self.backend.screen.prev_page()
|
||||
else:
|
||||
# scroll down
|
||||
nlines = value - old["value"]
|
||||
for i in range(nlines):
|
||||
self.backend.screen.next_page()
|
||||
old["value"] = value
|
||||
self.redraw_screen()
|
||||
|
||||
def adjust_scroll_bar(self):
|
||||
sb = self.scroll_bar
|
||||
sb.valueChanged.disconnect(self.scroll_value_change)
|
||||
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
|
||||
sb.setMaximum(tmp if tmp > 0 else 0)
|
||||
sb.setSliderPosition(tmp if tmp > 0 else 0)
|
||||
# if tmp > 0:
|
||||
# # show scrollbar, but delayed - prevent recursion with widget size change
|
||||
# QTimer.singleShot(0, scrollbar.show)
|
||||
# else:
|
||||
# QTimer.singleShot(0, scrollbar.hide)
|
||||
sb.valueChanged.connect(self.scroll_value_change)
|
||||
|
||||
def write(self, data):
|
||||
try:
|
||||
os.write(self.fd, data)
|
||||
except (IOError, OSError):
|
||||
self.process_exited()
|
||||
|
||||
@Slot(object)
|
||||
def keyPressEvent(self, event):
|
||||
"""
|
||||
Redirect all keystrokes to the terminal process.
|
||||
"""
|
||||
if self.fd is None:
|
||||
# not started
|
||||
return
|
||||
# Convert the Qt key to the correct ASCII code.
|
||||
if (
|
||||
self._deactivate_ctrl_d
|
||||
and event.modifiers() == QtCore.Qt.ControlModifier
|
||||
and event.key() == QtCore.Qt.Key_D
|
||||
):
|
||||
return None
|
||||
|
||||
code = QtKeyToAscii(event)
|
||||
if code == "copy":
|
||||
# MacOS only: CMD-C handling
|
||||
self.copy()
|
||||
elif code == "paste":
|
||||
# MacOS only: CMD-V handling
|
||||
self._push_clipboard()
|
||||
elif code is not None:
|
||||
self.write(code)
|
||||
|
||||
def push(self, text, hit_return=False):
|
||||
"""
|
||||
Write 'text' to terminal
|
||||
"""
|
||||
self.write(text.encode("utf-8"))
|
||||
if hit_return:
|
||||
self.write(b"\n")
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
if self.fd is None:
|
||||
return
|
||||
menu = self.createStandardContextMenu()
|
||||
for action in menu.actions():
|
||||
# remove all actions except copy and paste
|
||||
if "opy" in action.text():
|
||||
# redefine text without shortcut
|
||||
# since it probably clashes with control codes (like CTRL-C etc)
|
||||
action.setText("Copy")
|
||||
continue
|
||||
if "aste" in action.text():
|
||||
# redefine text without shortcut
|
||||
action.setText("Paste")
|
||||
# paste -> have to insert with self.push
|
||||
action.triggered.connect(self._push_clipboard)
|
||||
continue
|
||||
menu.removeAction(action)
|
||||
menu.exec_(event.globalPos())
|
||||
|
||||
def _push_clipboard(self):
|
||||
clipboard = QApplication.instance().clipboard()
|
||||
self.push(clipboard.text())
|
||||
|
||||
def move_cursor(self):
|
||||
textCursor = self.textCursor()
|
||||
textCursor.setPosition(0)
|
||||
textCursor.movePosition(
|
||||
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
|
||||
)
|
||||
textCursor.movePosition(
|
||||
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
|
||||
)
|
||||
self.setTextCursor(textCursor)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if self.fd is None:
|
||||
return
|
||||
if event.button() == Qt.MiddleButton:
|
||||
# push primary selection buffer ("mouse clipboard") to terminal
|
||||
clipboard = QApplication.instance().clipboard()
|
||||
if clipboard.supportsSelection():
|
||||
self.push(clipboard.text(QClipboard.Selection))
|
||||
return None
|
||||
elif event.button() == Qt.LeftButton:
|
||||
# left button click
|
||||
textCursor = self.textCursor()
|
||||
if textCursor.selectedText():
|
||||
# mouse was used to select text -> nothing to do
|
||||
pass
|
||||
else:
|
||||
# a simple 'click', move scrollbar to end
|
||||
self.scroll_bar.setSliderPosition(self.scroll_bar.maximum())
|
||||
self.move_cursor()
|
||||
return None
|
||||
return super().mouseReleaseEvent(event)
|
||||
|
||||
def redraw_screen(self):
|
||||
"""
|
||||
Render the screen as formatted text into the widget.
|
||||
"""
|
||||
screen = self.backend.screen
|
||||
|
||||
# Clear the widget
|
||||
if screen.dirty:
|
||||
self.clear()
|
||||
while len(self.output) < (max(screen.dirty) + 1):
|
||||
self.output.append("")
|
||||
while len(self.output) > (max(screen.dirty) + 1):
|
||||
self.output.pop()
|
||||
|
||||
# Prepare the HTML output
|
||||
for line_no in screen.dirty:
|
||||
line = text = ""
|
||||
style = old_style = ""
|
||||
old_idx = 0
|
||||
for idx, ch in screen.buffer[line_no].items():
|
||||
text += " " * (idx - old_idx - 1)
|
||||
old_idx = idx
|
||||
style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}"
|
||||
if style != old_style:
|
||||
if old_style:
|
||||
line += f"<span style={repr(old_style)}>{html.escape(text, quote=True)}</span>"
|
||||
else:
|
||||
line += html.escape(text, quote=True)
|
||||
text = ""
|
||||
old_style = style
|
||||
text += ch.data
|
||||
if style:
|
||||
line += f"<span style={repr(style)}>{html.escape(text, quote=True)}</span>"
|
||||
else:
|
||||
line += html.escape(text, quote=True)
|
||||
# do a check at the cursor position:
|
||||
# it is possible x pos > output line length,
|
||||
# for example if last escape codes are "cursor forward" past end of text,
|
||||
# like IPython does for "..." prompt (in a block, like "for" loop or "while" for example)
|
||||
# In this case, cursor is at 12 but last text output is at 8 -> insert spaces
|
||||
if line_no == screen.cursor.y:
|
||||
llen = len(screen.buffer[line_no])
|
||||
if llen < screen.cursor.x:
|
||||
line += " " * (screen.cursor.x - llen)
|
||||
self.output[line_no] = line
|
||||
# fill the text area with HTML contents in one go
|
||||
self.appendHtml(f"<pre>{chr(10).join(self.output)}</pre>")
|
||||
|
||||
if self._prompt_re is not None:
|
||||
text_buf = self.toPlainText()
|
||||
prompt = self._prompt_re.search(text_buf)
|
||||
if prompt is None:
|
||||
if self._prompt_str:
|
||||
self.prompt.emit(False)
|
||||
self._prompt_str = None
|
||||
else:
|
||||
prompt_str = prompt.string.rstrip()
|
||||
if prompt_str != self._prompt_str:
|
||||
self._prompt_str = prompt_str
|
||||
self.prompt.emit(True)
|
||||
|
||||
# did updates, all clean
|
||||
screen.dirty.clear()
|
||||
|
||||
def update_term_size(self):
|
||||
fmt = QtGui.QFontMetrics(self.font())
|
||||
char_width = fmt.width("w")
|
||||
char_height = fmt.height()
|
||||
self._cols = int(self.width() / char_width)
|
||||
self._rows = int(self.height() / char_height)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
self.update_term_size()
|
||||
if self.fd:
|
||||
self.backend.screen.resize(self._rows, self._cols)
|
||||
self.redraw_screen()
|
||||
self.adjust_scroll_bar()
|
||||
self.move_cursor()
|
||||
|
||||
def wheelEvent(self, event):
|
||||
if not self.fd:
|
||||
return
|
||||
y = event.angleDelta().y()
|
||||
if y > 0:
|
||||
self.backend.screen.prev_page()
|
||||
else:
|
||||
self.backend.screen.next_page()
|
||||
self.redraw_screen()
|
||||
|
||||
def fork_shell(self):
|
||||
"""
|
||||
Fork the current process and execute bec in shell.
|
||||
"""
|
||||
try:
|
||||
pid, fd = pty.fork()
|
||||
except (IOError, OSError):
|
||||
return False
|
||||
if pid == 0:
|
||||
try:
|
||||
ls = os.environ["LANG"].split(".")
|
||||
except KeyError:
|
||||
ls = []
|
||||
if len(ls) < 2:
|
||||
ls = ["en_US", "UTF-8"]
|
||||
os.putenv("COLUMNS", str(self.cols))
|
||||
os.putenv("LINES", str(self.rows))
|
||||
os.putenv("TERM", "linux")
|
||||
os.putenv("LANG", ls[0] + ".UTF-8")
|
||||
if not self._cmd:
|
||||
self._cmd = os.environ["SHELL"]
|
||||
cmd = self._cmd
|
||||
if isinstance(cmd, str):
|
||||
cmd = cmd.split()
|
||||
try:
|
||||
os.execvp(cmd[0], cmd)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
os._exit(0)
|
||||
else:
|
||||
# We are in the parent process.
|
||||
# Set file control
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
|
||||
return pid, fd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy import QtGui, QtWidgets
|
||||
|
||||
# Create the Qt application and console.
|
||||
app = QtWidgets.QApplication([])
|
||||
mainwin = QtWidgets.QMainWindow()
|
||||
title = "BECConsole"
|
||||
mainwin.setWindowTitle(title)
|
||||
|
||||
console = BECConsole(mainwin)
|
||||
mainwin.setCentralWidget(console)
|
||||
|
||||
def check_prompt(at_prompt):
|
||||
if at_prompt:
|
||||
print("NEW PROMPT")
|
||||
else:
|
||||
print("EXECUTING SOMETHING...")
|
||||
|
||||
console.set_prompt_tokens(
|
||||
(Token.OutPromptNum, "•"),
|
||||
(Token.Prompt, ""), # will match arbitrary string,
|
||||
(Token.Prompt, " ["),
|
||||
(Token.PromptNum, "3"),
|
||||
(Token.Prompt, "/"),
|
||||
(Token.PromptNum, "1"),
|
||||
(Token.Prompt, "] "),
|
||||
(Token.Prompt, "❯❯"),
|
||||
)
|
||||
console.prompt.connect(check_prompt)
|
||||
console.start()
|
||||
|
||||
# Show widget and launch Qt's event loop.
|
||||
mainwin.show()
|
||||
sys.exit(app.exec_())
|
||||
1
bec_widgets/widgets/editors/console/console.pyproject
Normal file
1
bec_widgets/widgets/editors/console/console.pyproject
Normal file
@@ -0,0 +1 @@
|
||||
{'files': ['console.py']}
|
||||
@@ -1,39 +1,43 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.editors.sbb_monitor.sbb_monitor import SBBMonitor
|
||||
from bec_widgets.widgets.editors.console.console import BECConsole
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='SBBMonitor' name='sbb_monitor'>
|
||||
<widget class='BECConsole' name='bec_console'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
class SBBMonitorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
|
||||
class BECConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = SBBMonitor(parent)
|
||||
t = BECConsole(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
return "BEC Console"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(SBBMonitor.ICON_NAME)
|
||||
return designer_material_icon(BECConsole.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "sbb_monitor"
|
||||
return "bec_console"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -45,10 +49,10 @@ class SBBMonitorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "SBBMonitor"
|
||||
return "BECConsole"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
return "A terminal-like vt100 widget."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -6,9 +6,9 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.editors.sbb_monitor.sbb_monitor_plugin import SBBMonitorPlugin
|
||||
from bec_widgets.widgets.editors.console.console_plugin import BECConsolePlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(SBBMonitorPlugin())
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECConsolePlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
@@ -2,9 +2,7 @@ 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,
|
||||
@@ -15,9 +13,7 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
_NOT_SET = object()
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class DictBackedTableModel(QAbstractTableModel):
|
||||
@@ -29,7 +25,6 @@ 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
|
||||
@@ -50,15 +45,8 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
|
||||
def data(self, index, role=Qt.ItemDataRole):
|
||||
if index.isValid():
|
||||
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
|
||||
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
|
||||
return str(self._data[index.row()][index.column()])
|
||||
|
||||
def setData(self, index, value, role):
|
||||
if role == Qt.ItemDataRole.EditRole:
|
||||
@@ -69,12 +57,6 @@ 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.
|
||||
|
||||
@@ -84,7 +66,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, 1))
|
||||
self.dataChanged.emit(self.index(i, 0), self.index(i, 0))
|
||||
|
||||
def _other_keys(self, row: int):
|
||||
return [r[0] for r in self._data[:row] + self._data[row + 1 :]]
|
||||
@@ -113,74 +95,45 @@ 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 in [[], [[]], [["", ""]]]:
|
||||
if self._default is not _NOT_SET:
|
||||
return self._default
|
||||
if self._data == [[]]:
|
||||
return {}
|
||||
return dict(self._data)
|
||||
|
||||
def length(self):
|
||||
return len(self._data)
|
||||
|
||||
|
||||
class DictBackedTable(QWidget):
|
||||
delete_rows = Signal(list)
|
||||
data_changed = Signal(dict)
|
||||
data_updated = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
initial_data: list[list[str]] = [],
|
||||
autoscale_to_data: bool = True,
|
||||
):
|
||||
def __init__(self, initial_data: list[list[str]]):
|
||||
"""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__(parent)
|
||||
super().__init__()
|
||||
|
||||
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.Expanding, QSizePolicy.Policy.Minimum)
|
||||
QSizePolicy(QSizePolicy.Policy.Minimum, 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._button_holder.setLayout(self._buttons)
|
||||
self._layout.addWidget(self._button_holder)
|
||||
self._layout.addLayout(self._buttons)
|
||||
self._add_button = QPushButton("+")
|
||||
self._add_button.setToolTip("add a new row")
|
||||
self._remove_button = QPushButton("-")
|
||||
@@ -190,21 +143,11 @@ 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)
|
||||
|
||||
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 _emit_data_updated(self, *args, **kwargs):
|
||||
"""Just to swallow the args"""
|
||||
self.data_updated.emit()
|
||||
|
||||
def delete_selected_rows(self):
|
||||
"""Delete rows which are part of the selection model"""
|
||||
@@ -224,29 +167,6 @@ 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
|
||||
@@ -254,6 +174,6 @@ if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
|
||||
window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
|
||||
window = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
|
||||
window.show()
|
||||
app.exec()
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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)
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['sbb_monitor.py']}
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from math import copysign, inf, nextafter
|
||||
from math import inf, nextafter
|
||||
from typing import TYPE_CHECKING, TypeVar, get_args
|
||||
|
||||
from annotated_types import Ge, Gt, Le, Lt
|
||||
@@ -23,19 +23,16 @@ _MAXFLOAT = sys.float_info.max
|
||||
T = TypeVar("T", int, float, Decimal)
|
||||
|
||||
|
||||
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))
|
||||
|
||||
def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]:
|
||||
_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
|
||||
@@ -67,6 +64,4 @@ def field_default(info: FieldInfo):
|
||||
|
||||
|
||||
def clearable_required(info: FieldInfo):
|
||||
return type(None) in get_args(info.annotation) or (
|
||||
info.is_required() and info.default is PydanticUndefined
|
||||
)
|
||||
return type(None) in get_args(info.annotation) or info.is_required()
|
||||
|
||||
@@ -16,9 +16,6 @@ logger = bec_logger.logger
|
||||
|
||||
|
||||
class ScanMetadata(PydanticModelForm):
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
@@ -39,26 +36,20 @@ class ScanMetadata(PydanticModelForm):
|
||||
|
||||
# self.populate() gets called in super().__init__
|
||||
# so make sure self._additional_metadata exists
|
||||
self._scan_name = scan_name or ""
|
||||
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
|
||||
super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs)
|
||||
|
||||
self._additional_md_box = ExpandableGroupFrame(self, "Additional metadata", expanded=False)
|
||||
self._additional_md_box = ExpandableGroupFrame("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(self, initial_extras or [])
|
||||
self._additional_metadata.data_changed.connect(self.validate_form)
|
||||
self._additional_metadata = DictBackedTable(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)
|
||||
|
||||
super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs)
|
||||
|
||||
self._layout.addWidget(self._additional_md_box)
|
||||
self._additional_md_box_layout.addWidget(self._additional_metadata)
|
||||
|
||||
self.populate()
|
||||
self.enabled = self._enabled
|
||||
|
||||
def _post_init(self):
|
||||
return
|
||||
|
||||
@SafeSlot(str)
|
||||
def update_with_new_scan(self, scan_name: str):
|
||||
self.set_schema_from_scan(scan_name)
|
||||
@@ -136,7 +127,6 @@ 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
@@ -21,6 +21,9 @@ 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.")
|
||||
@@ -40,7 +43,6 @@ class ImageItemConfig(ConnectionConfig): # TODO review config
|
||||
|
||||
|
||||
class ImageItem(BECConnector, pg.ImageItem):
|
||||
|
||||
RPC = True
|
||||
USER_ACCESS = [
|
||||
"color_map",
|
||||
@@ -67,13 +69,12 @@ 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, # FIXME: rename to parent
|
||||
parent_image=None,
|
||||
**kwargs,
|
||||
):
|
||||
if config is None:
|
||||
@@ -273,8 +274,6 @@ class ImageItem(BECConnector, pg.ImageItem):
|
||||
self.buffer = []
|
||||
self.max_len = 0
|
||||
|
||||
def remove(self, emit: bool = True):
|
||||
def remove(self):
|
||||
self.parent().disconnect_monitor(self.config.monitor)
|
||||
self.clear()
|
||||
super().remove()
|
||||
if emit:
|
||||
self.removed.emit(self.objectName())
|
||||
|
||||
@@ -8,7 +8,6 @@ from qtpy.QtCore import QEvent, Qt
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QColorDialog,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
@@ -24,7 +23,6 @@ from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||
BaseROI,
|
||||
CircularROI,
|
||||
EllipticalROI,
|
||||
RectangularROI,
|
||||
ROIController,
|
||||
)
|
||||
@@ -37,28 +35,6 @@ 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]
|
||||
@@ -122,21 +98,11 @@ 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)
|
||||
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 Rect ROI", self.add_rect_action, self)
|
||||
tb.add_action("Add Circle ROI", self.add_circle_action, self)
|
||||
self._draw_actions["circle"] = self.add_circle_action
|
||||
# --- Ellipse ROI draw action ---
|
||||
self.add_ellipse_action = MaterialIconAction("vignette", "Add Ellipse ROI", True, self)
|
||||
tb.add_action("Add Ellipse ROI", self.add_ellipse_action, self)
|
||||
self._draw_actions["ellipse"] = self.add_ellipse_action
|
||||
|
||||
for mode, act in self._draw_actions.items():
|
||||
act.action.toggled.connect(lambda on, m=mode: self._on_draw_action_toggled(m, on))
|
||||
|
||||
# Expand/Collapse toggle
|
||||
self.expand_toggle = MaterialIconAction(
|
||||
@@ -158,24 +124,6 @@ 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
|
||||
@@ -185,9 +133,17 @@ 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' | 'ellipse' | None
|
||||
self._roi_draw_mode = None # 'rect' | 'circle' | 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)
|
||||
|
||||
@@ -217,12 +173,16 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
return str(value)
|
||||
|
||||
def _set_roi_draw_mode(self, mode: str | None):
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
self._roi_draw_mode = mode
|
||||
self._roi_start_pos = None
|
||||
# remove any unfinished temp ROI
|
||||
@@ -230,15 +190,6 @@ 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)
|
||||
@@ -251,18 +202,12 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
parent_image=self.image_widget,
|
||||
resize_handles=False,
|
||||
)
|
||||
elif self._roi_draw_mode == "circle":
|
||||
if 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:
|
||||
@@ -272,19 +217,13 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
|
||||
if self._roi_draw_mode == "rect":
|
||||
self._temp_roi.setSize([dx, dy])
|
||||
elif self._roi_draw_mode == "circle":
|
||||
if 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
|
||||
@@ -296,30 +235,18 @@ 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)
|
||||
# --- 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
|
||||
# --- delete button in actions column ---
|
||||
del_btn = QToolButton()
|
||||
delete_icon = material_icon(
|
||||
"delete",
|
||||
@@ -329,11 +256,8 @@ 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)
|
||||
@@ -385,12 +309,6 @@ 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:
|
||||
@@ -427,7 +345,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout
|
||||
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
@@ -50,58 +50,11 @@ class MonitorSelectionToolbarBundle(ToolbarBundle):
|
||||
|
||||
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False))
|
||||
|
||||
self.device_combo_box.currentTextChanged.connect(self.connect_monitor)
|
||||
self.dim_combo_box.currentTextChanged.connect(self.connect_monitor)
|
||||
|
||||
QTimer.singleShot(0, self._adjust_and_connect)
|
||||
|
||||
def _adjust_and_connect(self):
|
||||
"""
|
||||
Adjust the size of the device combo box and populate it with preview signals.
|
||||
Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing.
|
||||
"""
|
||||
self._populate_preview_signals()
|
||||
self._reverse_device_items()
|
||||
self.device_combo_box.setCurrentText("") # set again default to empty string
|
||||
|
||||
def _populate_preview_signals(self) -> None:
|
||||
"""
|
||||
Populate the device combo box with preview‑signal devices in the
|
||||
format '<device>_<signal>' and store the tuple(device, signal) in
|
||||
the item's userData for later use.
|
||||
"""
|
||||
preview_signals = self.target_widget.client.device_manager.get_bec_signals("PreviewSignal")
|
||||
for device, signal, signal_config in preview_signals:
|
||||
label = signal_config.get("obj_name", f"{device}_{signal}")
|
||||
self.device_combo_box.addItem(label, (device, signal, signal_config))
|
||||
|
||||
def _reverse_device_items(self) -> None:
|
||||
"""
|
||||
Reverse the current order of items in the device combo box while
|
||||
keeping their userData and restoring the previous selection.
|
||||
"""
|
||||
current_text = self.device_combo_box.currentText()
|
||||
items = [
|
||||
(self.device_combo_box.itemText(i), self.device_combo_box.itemData(i))
|
||||
for i in range(self.device_combo_box.count())
|
||||
]
|
||||
self.device_combo_box.clear()
|
||||
for text, data in reversed(items):
|
||||
self.device_combo_box.addItem(text, data)
|
||||
if current_text:
|
||||
self.device_combo_box.setCurrentText(current_text)
|
||||
# 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())
|
||||
|
||||
@SafeSlot()
|
||||
def connect_monitor(self, *args, **kwargs):
|
||||
"""
|
||||
Connect the target widget to the selected monitor based on the current device and dimension.
|
||||
|
||||
If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor.
|
||||
"""
|
||||
def connect_monitor(self):
|
||||
dim = self.dim_combo_box.currentText()
|
||||
data = self.device_combo_box.currentData()
|
||||
|
||||
if isinstance(data, tuple):
|
||||
self.target_widget.image(monitor=data, monitor_type="auto")
|
||||
else:
|
||||
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)
|
||||
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)
|
||||
|
||||
@@ -104,12 +104,9 @@ 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",
|
||||
@@ -130,7 +127,6 @@ 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,
|
||||
):
|
||||
@@ -159,7 +155,6 @@ class BaseROI(BECConnector):
|
||||
gui_id=gui_id,
|
||||
removable=True,
|
||||
invertible=True,
|
||||
movable=movable,
|
||||
**pg_kwargs,
|
||||
)
|
||||
|
||||
@@ -167,14 +162,8 @@ 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.
|
||||
@@ -193,40 +182,6 @@ 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:
|
||||
"""
|
||||
@@ -382,18 +337,8 @@ 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.
|
||||
@@ -410,7 +355,12 @@ class BaseROI(BECConnector):
|
||||
if controller and self in controller.rois:
|
||||
controller.remove_roi(self)
|
||||
return # controller will call back into this method once deregistered
|
||||
self.remove_scale_handles()
|
||||
handles = self.handles
|
||||
for i in range(len(handles)):
|
||||
try:
|
||||
self.removeHandle(0)
|
||||
except IndexError:
|
||||
continue
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.parent_image.plot_item.removeItem(self)
|
||||
viewBox = self.parent_plot_item.vb
|
||||
@@ -449,7 +399,6 @@ 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,
|
||||
):
|
||||
@@ -480,7 +429,6 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
pos=pos,
|
||||
size=size,
|
||||
pen=pen,
|
||||
movable=movable,
|
||||
**extra_pg,
|
||||
)
|
||||
|
||||
@@ -489,23 +437,6 @@ 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.
|
||||
@@ -527,17 +458,24 @@ 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 changes to the ROI's region.
|
||||
Handles ROI region change events.
|
||||
|
||||
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.
|
||||
"""
|
||||
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()
|
||||
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()
|
||||
|
||||
def mouseDragEvent(self, ev):
|
||||
"""
|
||||
@@ -551,8 +489,9 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
"""
|
||||
super().mouseDragEvent(ev)
|
||||
if ev.isFinish():
|
||||
x_left, y_bottom, x_right, y_top = self._normalized_edges()
|
||||
self.edgesReleased.emit(x_left, y_bottom, x_right, y_top)
|
||||
x0, y0 = self.pos().x(), self.pos().y()
|
||||
w, h = self.state["size"]
|
||||
self.edgesReleased.emit(x0, y0, x0 + w, y0 + h)
|
||||
|
||||
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
|
||||
"""
|
||||
@@ -571,16 +510,17 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
if typed is None:
|
||||
typed = self.description
|
||||
|
||||
x_left, y_bottom, x_right, y_top = self._normalized_edges()
|
||||
|
||||
x0, y0 = self.pos().x(), self.pos().y()
|
||||
w, h = self.state["size"]
|
||||
x1, y1 = x0 + w, y0 + h
|
||||
if typed:
|
||||
return {
|
||||
"bottom_left": (x_left, y_bottom),
|
||||
"bottom_right": (x_right, y_bottom),
|
||||
"top_left": (x_left, y_top),
|
||||
"top_right": (x_right, y_top),
|
||||
"bottom_left": (x0, y0),
|
||||
"bottom_right": (x1, y0),
|
||||
"top_left": (x0, y1),
|
||||
"top_right": (x1, y1),
|
||||
}
|
||||
return (x_left, y_bottom), (x_right, y_bottom), (x_left, y_top), (x_right, y_top)
|
||||
return ((x0, y0), (x1, y0), (x0, y1), (x1, y1))
|
||||
|
||||
def _lookup_scene_image(self):
|
||||
"""
|
||||
@@ -628,7 +568,6 @@ class CircularROI(BaseROI, pg.CircleROI):
|
||||
label: str | None = None,
|
||||
line_color: str | None = None,
|
||||
line_width: int = 5,
|
||||
movable: bool = True,
|
||||
**extra_pg,
|
||||
):
|
||||
"""
|
||||
@@ -660,19 +599,10 @@ 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):
|
||||
"""
|
||||
@@ -724,7 +654,7 @@ class CircularROI(BaseROI, pg.CircleROI):
|
||||
if typed is None:
|
||||
typed = self.description
|
||||
|
||||
d = abs(self.state["size"][0])
|
||||
d = self.state["size"][0]
|
||||
cx = self.pos().x() + d / 2
|
||||
cy = self.pos().y() + d / 2
|
||||
|
||||
@@ -750,92 +680,6 @@ 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.
|
||||
|
||||
|
||||
@@ -141,14 +141,6 @@
|
||||
<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>
|
||||
|
||||
@@ -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,8 +15,9 @@ 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_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import CurveTree
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -28,18 +29,13 @@ 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()
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
"""
|
||||
Returns the size hint for the settings widget.
|
||||
"""
|
||||
return QSize(800, 500)
|
||||
self.setFixedWidth(580) # TODO height is still debate
|
||||
|
||||
def _init_x_box(self):
|
||||
self.x_axis_box = QGroupBox("X Axis")
|
||||
@@ -50,23 +46,15 @@ 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 = DeviceComboBox(parent=self)
|
||||
self.device_x.insertItem(0, "")
|
||||
self.device_x.setEditable(True)
|
||||
self.device_x.setMinimumWidth(180)
|
||||
self.device_x = DeviceLineEdit(parent=self)
|
||||
|
||||
self.signal_x_label = QLabel("Signal")
|
||||
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.signal_x = QLineEdit()
|
||||
|
||||
self._get_x_mode_from_waveform()
|
||||
self.switch_x_device_selection()
|
||||
@@ -92,32 +80,11 @@ 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.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)
|
||||
self.device_x.setText(self.target_widget.x_axis_mode["name"])
|
||||
self.signal_x.setText(self.target_widget.x_axis_mode["entry"])
|
||||
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")
|
||||
@@ -130,17 +97,16 @@ class CurveSetting(SettingWidget):
|
||||
|
||||
self.layout.addWidget(self.y_axis_box)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
@SafeSlot()
|
||||
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.currentText()
|
||||
signal_x = self.signal_x.currentText()
|
||||
signal_data = self.signal_x.itemData(self.signal_x.currentIndex())
|
||||
self.target_widget.x_mode = self.device_x.text()
|
||||
signal_x = self.signal_x.text()
|
||||
if signal_x != "":
|
||||
self.target_widget.x_entry = signal_data.get("obj_name", signal_x)
|
||||
self.target_widget.x_entry = signal_x
|
||||
else:
|
||||
self.target_widget.x_mode = self.mode_combo.currentText()
|
||||
self.curve_manager.send_curve_json()
|
||||
@@ -155,7 +121,5 @@ 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()
|
||||
|
||||
@@ -5,12 +5,13 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
@@ -21,13 +22,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_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
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 (
|
||||
@@ -123,30 +124,11 @@ 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 = 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)
|
||||
self.device_edit = DeviceLineEdit(parent=self.tree)
|
||||
self.entry_edit = QLineEdit(parent=self.tree) # TODO in future will be signal line edit
|
||||
if self.config.signal:
|
||||
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.device_edit.setText(self.config.signal.name or "")
|
||||
self.entry_edit.setText(self.config.signal.entry or "")
|
||||
|
||||
self.tree.setItemWidget(self, 1, self.device_edit)
|
||||
self.tree.setItemWidget(self, 2, self.entry_edit)
|
||||
@@ -172,7 +154,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.color_changed.connect(self._on_color_changed)
|
||||
self.color_button.clicked.connect(lambda: self._select_color(self.color_button))
|
||||
self.tree.setItemWidget(self, 3, self.color_button)
|
||||
|
||||
# Style in col 4
|
||||
@@ -195,16 +177,20 @@ class CurveRow(QTreeWidgetItem):
|
||||
self.symbol_spin.setValue(self.config.symbol_size)
|
||||
self.tree.setItemWidget(self, 6, self.symbol_spin)
|
||||
|
||||
@SafeSlot(str, verify_sender=True)
|
||||
def _on_color_changed(self, new_color: str):
|
||||
def _select_color(self, button):
|
||||
"""
|
||||
Update configuration when the color button emits a change.
|
||||
Selects a new color using a color dialog and applies it to the specified button. Updates
|
||||
related configuration properties based on the chosen color.
|
||||
|
||||
Args:
|
||||
new_color (str): The new color in hex format.
|
||||
button: The button widget whose color is being modified.
|
||||
"""
|
||||
self.config.color = new_color
|
||||
self.config.symbol_color = new_color
|
||||
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()
|
||||
|
||||
def add_dap_row(self):
|
||||
"""Create a new DAP row as a child. Only valid if source='device'."""
|
||||
@@ -253,11 +239,6 @@ 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()
|
||||
@@ -290,22 +271,13 @@ 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.currentText()
|
||||
device_name = self.device_edit.text()
|
||||
if hasattr(self, "entry_edit"):
|
||||
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
|
||||
)
|
||||
|
||||
device_entry = self.entry_validator.validate_signal(
|
||||
name=device_name, entry=self.entry_edit.text()
|
||||
)
|
||||
self.entry_edit.setText(device_entry)
|
||||
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
|
||||
self.config.source = "device"
|
||||
self.config.label = f"{device_name}-{device_entry}"
|
||||
@@ -421,20 +393,13 @@ 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, 50)
|
||||
self.tree.setColumnWidth(6, 50)
|
||||
|
||||
self.tree.setColumnWidth(5, 40)
|
||||
self.tree.setColumnWidth(6, 40)
|
||||
self.layout.addWidget(self.tree)
|
||||
|
||||
def _init_color_buffer(self, size: int):
|
||||
@@ -570,4 +535,7 @@ class CurveTree(BECWidget, QWidget):
|
||||
all_items = list(self.all_items)
|
||||
for item in all_items:
|
||||
item.remove_self()
|
||||
super().cleanup()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.cleanup()
|
||||
return super().closeEvent(event)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Literal
|
||||
from typing import Literal
|
||||
|
||||
import lmfit
|
||||
import numpy as np
|
||||
@@ -9,19 +9,8 @@ import pyqtgraph as pg
|
||||
from bec_lib import bec_logger, messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import Qt, QTimer, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtWidgets import QApplication, QDialog, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
||||
@@ -44,11 +33,6 @@ class WaveformConfig(ConnectionConfig):
|
||||
color_palette: str | None = Field(
|
||||
"plasma", description="The color palette of the figure widget.", validate_default=True
|
||||
)
|
||||
max_dataset_size_mb: float = Field(
|
||||
10,
|
||||
description="Maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.",
|
||||
validate_default=True,
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
_validate_color_palette = field_validator("color_palette")(Colors.validate_color_map)
|
||||
@@ -112,12 +96,6 @@ class Waveform(PlotBase):
|
||||
"x_entry.setter",
|
||||
"color_palette",
|
||||
"color_palette.setter",
|
||||
"skip_large_dataset_warning",
|
||||
"skip_large_dataset_warning.setter",
|
||||
"skip_large_dataset_check",
|
||||
"skip_large_dataset_check.setter",
|
||||
"max_dataset_size_mb",
|
||||
"max_dataset_size_mb.setter",
|
||||
"plot",
|
||||
"add_dap_curve",
|
||||
"remove_curve",
|
||||
@@ -163,7 +141,7 @@ class Waveform(PlotBase):
|
||||
self._async_curves = []
|
||||
self._slice_index = None
|
||||
self._dap_curves = []
|
||||
self._mode = None
|
||||
self._mode: Literal["none", "sync", "async", "mixed"] = "none"
|
||||
|
||||
# Scan data
|
||||
self._scan_done = True # means scan is not running
|
||||
@@ -186,10 +164,6 @@ class Waveform(PlotBase):
|
||||
self._init_curve_dialog()
|
||||
self.curve_settings_dialog = None
|
||||
|
||||
# Large‑dataset guard
|
||||
self._skip_large_dataset_warning = False # session flag
|
||||
self._skip_large_dataset_check = False # per-plot flag, to skip the warning for this plot
|
||||
|
||||
# Scan status update loop
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status())
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
|
||||
@@ -330,6 +304,7 @@ 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()
|
||||
@@ -587,59 +562,6 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
return [item for item in self.plot_item.curves if isinstance(item, Curve)]
|
||||
|
||||
@SafeProperty(bool)
|
||||
def skip_large_dataset_check(self) -> bool:
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
return self._skip_large_dataset_check
|
||||
|
||||
@skip_large_dataset_check.setter
|
||||
def skip_large_dataset_check(self, value: bool):
|
||||
"""
|
||||
Set whether to skip the large dataset warning when fetching async data.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to skip the large dataset warning.
|
||||
"""
|
||||
self._skip_large_dataset_check = value
|
||||
|
||||
@SafeProperty(bool)
|
||||
def skip_large_dataset_warning(self) -> bool:
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
return self._skip_large_dataset_warning
|
||||
|
||||
@skip_large_dataset_warning.setter
|
||||
def skip_large_dataset_warning(self, value: bool):
|
||||
"""
|
||||
Set whether to skip the large dataset warning when fetching async data.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to skip the large dataset warning.
|
||||
"""
|
||||
self._skip_large_dataset_warning = value
|
||||
|
||||
@SafeProperty(float)
|
||||
def max_dataset_size_mb(self) -> float:
|
||||
"""
|
||||
The maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
|
||||
"""
|
||||
return self.config.max_dataset_size_mb
|
||||
|
||||
@max_dataset_size_mb.setter
|
||||
def max_dataset_size_mb(self, value: float):
|
||||
"""
|
||||
Set the maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
|
||||
|
||||
Args:
|
||||
value(float): The maximum dataset size in MB.
|
||||
"""
|
||||
if value <= 0:
|
||||
raise ValueError("Maximum dataset size must be greater than 0.")
|
||||
self.config.max_dataset_size_mb = value
|
||||
|
||||
################################################################################
|
||||
# High Level methods for API
|
||||
################################################################################
|
||||
@@ -886,6 +808,8 @@ class Waveform(PlotBase):
|
||||
if config.source == "device":
|
||||
if self.scan_item is None:
|
||||
self.update_with_scan_history(-1)
|
||||
if curve in self._async_curves:
|
||||
self._setup_async_curve(curve)
|
||||
self.async_signal_update.emit()
|
||||
self.sync_signal_update.emit()
|
||||
if config.source == "dap":
|
||||
@@ -1138,7 +1062,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) -> tuple[dict, str] | tuple[None, None]:
|
||||
def _fetch_scan_data_and_access(self):
|
||||
"""
|
||||
Decide whether the widget is in live or historical mode
|
||||
and return the appropriate data dict and access key.
|
||||
@@ -1152,7 +1076,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
|
||||
@@ -1168,7 +1092,7 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
if self.scan_item is None:
|
||||
logger.info("No scan executed so far; skipping device curves categorisation.")
|
||||
return
|
||||
return "none"
|
||||
data, access_key = self._fetch_scan_data_and_access()
|
||||
for curve in self._sync_curves:
|
||||
device_name = curve.config.signal.name
|
||||
@@ -1176,8 +1100,9 @@ class Waveform(PlotBase):
|
||||
if access_key == "val":
|
||||
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
|
||||
else:
|
||||
entry_obj = data.get(device_name, {}).get(device_entry)
|
||||
device_data = entry_obj.read()["value"] if entry_obj else None
|
||||
device_data = (
|
||||
data.get(device_name, {}).get(device_entry, {}).read().get("value", None)
|
||||
)
|
||||
x_data = self._get_x_data(device_name, device_entry)
|
||||
if x_data is not None:
|
||||
if len(x_data) == 1:
|
||||
@@ -1211,12 +1136,9 @@ class Waveform(PlotBase):
|
||||
if access_key == "val": # live access
|
||||
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
|
||||
else: # history access
|
||||
dataset_obj = data.get(device_name, {})
|
||||
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
|
||||
entry_obj = dataset_obj.get(device_entry, None)
|
||||
device_data = entry_obj.read()["value"] if entry_obj else None
|
||||
device_data = (
|
||||
data.get(device_name, {}).get(device_entry, {}).read().get("value", None)
|
||||
)
|
||||
|
||||
# if shape is 2D cast it into 1D and take the last waveform
|
||||
if len(np.shape(device_data)) > 1:
|
||||
@@ -1245,23 +1167,6 @@ 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.
|
||||
@@ -1270,40 +1175,20 @@ class Waveform(PlotBase):
|
||||
curve(Curve): The curve to set up.
|
||||
"""
|
||||
name = curve.config.signal.name
|
||||
signal = curve.config.signal.entry
|
||||
async_signal_found = self._check_async_signal_found(name, signal)
|
||||
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_async_readback, MessageEndpoints.device_async_readback(self.old_scan_id, name)
|
||||
)
|
||||
try:
|
||||
curve.clear_data()
|
||||
except KeyError:
|
||||
logger.warning(f"Curve {name} not found in plot item.")
|
||||
pass
|
||||
|
||||
# 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},
|
||||
)
|
||||
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)
|
||||
@@ -1325,6 +1210,9 @@ class Waveform(PlotBase):
|
||||
msg(dict): Message with the async data.
|
||||
metadata(dict): Metadata of the message.
|
||||
"""
|
||||
if self._scan_done:
|
||||
# logger.info("Scan is done, ignoring async readback.")
|
||||
return
|
||||
sender = self.sender()
|
||||
if not hasattr(sender, "cb_info"):
|
||||
logger.info(f"Sender {sender} has no cb_info.")
|
||||
@@ -1585,21 +1473,15 @@ 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
|
||||
entry_obj = data.get(x_name, {}).get(x_entry)
|
||||
x_data = entry_obj.read()["value"] if entry_obj else [0]
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", [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
|
||||
x_data = data.get(device_name, {}).get(device_entry, None)
|
||||
if x_data is None:
|
||||
return None
|
||||
else:
|
||||
timestamps = x_data.timestamps
|
||||
timestamps = data[device_name][device_entry].timestamps
|
||||
else: # history data
|
||||
entry_obj = data.get(device_name, {}).get(device_entry)
|
||||
timestamps = entry_obj.read()["timestamp"] if entry_obj else [0]
|
||||
timestamps = data[device_name][device_entry].read().get("timestamp", [0])
|
||||
x_data = timestamps
|
||||
new_suffix = " (timestamp)"
|
||||
|
||||
@@ -1626,8 +1508,7 @@ class Waveform(PlotBase):
|
||||
if access_key == "val":
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
|
||||
else:
|
||||
entry_obj = data.get(x_name, {}).get(x_entry)
|
||||
x_data = entry_obj.read()["value"] if entry_obj else None
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
|
||||
new_suffix = f" (auto: {x_name}-{x_entry})"
|
||||
self._update_x_label_suffix(new_suffix)
|
||||
return x_data
|
||||
@@ -1680,17 +1561,12 @@ 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.get(
|
||||
"readout_priority"
|
||||
) # live data
|
||||
readout_priority = self.scan_item.status_message.info["readout_priority"] # live data
|
||||
else:
|
||||
readout_priority = self.scan_item.metadata["bec"].get("readout_priority") # history
|
||||
|
||||
if readout_priority is None:
|
||||
return None
|
||||
readout_priority = self.scan_item.metadata["bec"]["readout_priority"] # history
|
||||
|
||||
# Reset sync/async curve lists
|
||||
self._async_curves.clear()
|
||||
@@ -1709,8 +1585,6 @@ class Waveform(PlotBase):
|
||||
dev_name = curve.config.signal.name
|
||||
if dev_name in readout_priority_async:
|
||||
self._async_curves.append(curve)
|
||||
if hasattr(self.scan_item, "live_data"):
|
||||
self._setup_async_curve(curve)
|
||||
found_async = True
|
||||
elif dev_name in readout_priority_sync:
|
||||
self._sync_curves.append(curve)
|
||||
@@ -1787,106 +1661,6 @@ class Waveform(PlotBase):
|
||||
################################################################################
|
||||
# Utility Methods
|
||||
################################################################################
|
||||
|
||||
# Large dataset handling helpers
|
||||
def _check_dataset_size_and_confirm(self, dataset_obj, device_entry: str) -> bool:
|
||||
"""
|
||||
Check the size of the dataset and confirm with the user if it exceeds the limit.
|
||||
|
||||
Args:
|
||||
dataset_obj: The dataset object containing the information.
|
||||
device_entry( str): The specific device entry to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the dataset is within the size limit or user confirmed to load it,
|
||||
False if the dataset exceeds the size limit and user declined to load it.
|
||||
"""
|
||||
try:
|
||||
info = dataset_obj._info
|
||||
mem_bytes = info.get(device_entry, {}).get("value", {}).get("mem_size", 0)
|
||||
# Fallback – grab first entry if lookup failed
|
||||
if mem_bytes == 0 and info:
|
||||
first_key = next(iter(info))
|
||||
mem_bytes = info[first_key]["value"]["mem_size"]
|
||||
size_mb = mem_bytes / (1024 * 1024)
|
||||
print(f"Dataset size: {size_mb:.1f} MB")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(f"Unable to evaluate dataset size: {exc}")
|
||||
return True
|
||||
|
||||
if size_mb <= self.config.max_dataset_size_mb:
|
||||
return True
|
||||
logger.warning(
|
||||
f"Attempt to load large dataset: {size_mb:.1f} MB "
|
||||
f"(limit {self.config.max_dataset_size_mb} MB)"
|
||||
)
|
||||
if self._skip_large_dataset_warning:
|
||||
logger.info("Skipping large dataset warning dialog.")
|
||||
return False
|
||||
return self._confirm_large_dataset(size_mb)
|
||||
|
||||
def _confirm_large_dataset(self, size_mb: float) -> bool:
|
||||
"""
|
||||
Confirm with the user whether to load a large dataset with dialog popup.
|
||||
Also allows the user to adjust the maximum dataset size limit and if user
|
||||
wants to see this popup again during session.
|
||||
|
||||
Args:
|
||||
size_mb(float): Size of the dataset in MB.
|
||||
|
||||
Returns:
|
||||
bool: True if the user confirmed to load the dataset, False otherwise.
|
||||
"""
|
||||
if self._skip_large_dataset_warning:
|
||||
return True
|
||||
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Large dataset detected")
|
||||
main_dialog_layout = QVBoxLayout(dialog)
|
||||
|
||||
# Limit adjustment widgets
|
||||
limit_adjustment_layout = QHBoxLayout()
|
||||
limit_adjustment_layout.addWidget(QLabel("New limit (MB):"))
|
||||
spin = QDoubleSpinBox()
|
||||
spin.setRange(0.001, 4096)
|
||||
spin.setDecimals(3)
|
||||
spin.setSingleStep(0.01)
|
||||
spin.setValue(self.config.max_dataset_size_mb)
|
||||
spin.valueChanged.connect(lambda value: setattr(self.config, "max_dataset_size_mb", value))
|
||||
limit_adjustment_layout.addWidget(spin)
|
||||
|
||||
# Don't show again checkbox
|
||||
checkbox = QCheckBox("Don't show this again for this session")
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.Yes | QDialogButtonBox.No, Qt.Horizontal, dialog
|
||||
)
|
||||
buttons.accepted.connect(dialog.accept) # Yes
|
||||
buttons.rejected.connect(dialog.reject) # No
|
||||
|
||||
# widget layout
|
||||
main_dialog_layout.addWidget(
|
||||
QLabel(
|
||||
f"The selected dataset is {size_mb:.1f} MB which exceeds the "
|
||||
f"current limit of {self.config.max_dataset_size_mb} MB.\n"
|
||||
)
|
||||
)
|
||||
main_dialog_layout.addLayout(limit_adjustment_layout)
|
||||
main_dialog_layout.addWidget(checkbox)
|
||||
main_dialog_layout.addWidget(QLabel("Would you like to display dataset anyway?"))
|
||||
main_dialog_layout.addWidget(buttons)
|
||||
|
||||
result = dialog.exec() # modal; waits for user choice
|
||||
|
||||
# Respect the “don't show again” checkbox for *either* choice
|
||||
if checkbox.isChecked():
|
||||
self._skip_large_dataset_warning = True
|
||||
|
||||
if result == QDialog.Accepted:
|
||||
self.config.max_dataset_size_mb = spin.value()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _ensure_str_list(self, entries: list | tuple | np.ndarray):
|
||||
"""
|
||||
Convert a variety of possible inputs (string, bytes, list/tuple/ndarray of either)
|
||||
@@ -2017,7 +1791,7 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
self.setCentralWidget(self.main_widget)
|
||||
|
||||
self.waveform_popup = Waveform(popups=True)
|
||||
self.waveform_popup.plot(y_name="waveform")
|
||||
self.waveform_popup.plot(y_name="monitor_async")
|
||||
|
||||
self.waveform_side = Waveform(popups=False)
|
||||
self.waveform_side.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
|
||||
|
||||
@@ -1,46 +1,12 @@
|
||||
import sys
|
||||
from enum import Enum
|
||||
from string import Template
|
||||
|
||||
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer
|
||||
from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer, Slot
|
||||
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):
|
||||
@@ -55,8 +21,6 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
"set_minimum",
|
||||
"label_template",
|
||||
"label_template.setter",
|
||||
"state",
|
||||
"state.setter",
|
||||
"_get_label",
|
||||
]
|
||||
ICON_NAME = "page_control"
|
||||
@@ -84,38 +48,27 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
|
||||
self._completed_color = accent_colors.success
|
||||
self._border_color = QColor(50, 50, 50)
|
||||
# Corner‑rounding: base radius in pixels (auto‑reduced if bar is small)
|
||||
self._corner_radius = 10
|
||||
|
||||
# Progress‑bar 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.AlignHCenter)
|
||||
self.center_label.setAlignment(Qt.AlignCenter)
|
||||
self.center_label.setStyleSheet("color: white;")
|
||||
self.center_label.setMinimumSize(0, 0)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(10, 0, 10, 0)
|
||||
layout.setContentsMargins(0, 0, 0, 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()
|
||||
|
||||
@SafeProperty(
|
||||
str, doc="The template for the center label. Use $value, $maximum, and $percentage."
|
||||
)
|
||||
@Property(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.
|
||||
@@ -130,11 +83,10 @@ 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()
|
||||
|
||||
@SafeProperty(float, designable=False)
|
||||
@Property(float, designable=False)
|
||||
def _progressbar_value(self):
|
||||
"""
|
||||
The current value of the progress bar.
|
||||
@@ -154,20 +106,8 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
percentage=int((self.map_value(self._user_value) / self._maximum) * 100),
|
||||
)
|
||||
|
||||
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)
|
||||
@Slot(float)
|
||||
@Slot(int)
|
||||
def set_value(self, value):
|
||||
"""
|
||||
Set the value of the progress bar.
|
||||
@@ -182,88 +122,35 @@ 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 (auto‑scaled 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(self._padding_left_right, 0, -self._padding_left_right, -1)
|
||||
|
||||
# Corner radius adapts to widget height so it never exceeds half the bar’s thickness
|
||||
radius = min(self._corner_radius, rect.height() / 2)
|
||||
rect = self.rect().adjusted(10, 0, -10, -1)
|
||||
|
||||
# Draw background
|
||||
painter.setBrush(self._background_color)
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.drawRoundedRect(rect, radius, radius) # Rounded corners
|
||||
painter.drawRoundedRect(rect, 10, 10) # Rounded corners
|
||||
|
||||
# Draw border
|
||||
painter.setBrush(Qt.NoBrush)
|
||||
painter.setPen(self._border_color)
|
||||
painter.drawRoundedRect(rect, radius, radius)
|
||||
painter.drawRoundedRect(rect, 10, 10)
|
||||
|
||||
# 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]
|
||||
# Determine progress color based on completion
|
||||
if self._value >= self._maximum:
|
||||
current_color = self._completed_color
|
||||
else:
|
||||
current_color = self._state_colors[ProgressState.NORMAL]
|
||||
current_color = self._progress_color
|
||||
|
||||
# Set clipping region to preserve the background's rounded corners
|
||||
progress_rect = rect.adjusted(
|
||||
0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0
|
||||
)
|
||||
clip_path = QPainterPath()
|
||||
clip_path.addRoundedRect(
|
||||
QRectF(rect), radius, radius
|
||||
) # Clip to the background's rounded corners
|
||||
clip_path.addRoundedRect(QRectF(rect), 10, 10) # Clip to the background's rounded corners
|
||||
painter.setClipPath(clip_path)
|
||||
|
||||
# Draw progress bar
|
||||
@@ -281,7 +168,7 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
self._value_animation.setEndValue(self._target_value)
|
||||
self._value_animation.start()
|
||||
|
||||
@SafeProperty(float)
|
||||
@Property(float)
|
||||
def maximum(self):
|
||||
"""
|
||||
The maximum value of the progress bar.
|
||||
@@ -295,7 +182,7 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
"""
|
||||
self.set_maximum(maximum)
|
||||
|
||||
@SafeProperty(float)
|
||||
@Property(float)
|
||||
def minimum(self):
|
||||
"""
|
||||
The minimum value of the progress bar.
|
||||
@@ -306,7 +193,7 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
def minimum(self, minimum: float):
|
||||
self.set_minimum(minimum)
|
||||
|
||||
@SafeProperty(float)
|
||||
@Property(float)
|
||||
def initial_value(self):
|
||||
"""
|
||||
The initial value of the progress bar.
|
||||
@@ -317,7 +204,7 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
def initial_value(self, value: float):
|
||||
self.set_value(value)
|
||||
|
||||
@SafeSlot(float)
|
||||
@Slot(float)
|
||||
def set_maximum(self, maximum: float):
|
||||
"""
|
||||
Set the maximum value of the progress bar.
|
||||
@@ -326,11 +213,10 @@ 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()
|
||||
|
||||
@SafeSlot(float)
|
||||
@Slot(float)
|
||||
def set_minimum(self, minimum: float):
|
||||
"""
|
||||
Set the minimum value of the progress bar.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.progress.scan_progressbar.scan_progress_bar_plugin import (
|
||||
ScanProgressBarPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(ScanProgressBarPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['scan_progressbar.py']}
|
||||
@@ -1,54 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from 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()
|
||||
@@ -1,320 +0,0 @@
|
||||
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_()
|
||||
@@ -1,141 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,124 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>328</width>
|
||||
<height>24</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>24</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>24</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2" stretch="0,1,0">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="source_label">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Scan</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECProgressBar" name="progressbar">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>30</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="padding_left_right" stdset="0">
|
||||
<double>5.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="remaining_time_label">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>0:00:00</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="dash">
|
||||
<property name="text">
|
||||
<string>-</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="elapsed_time_label">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>0:00:00</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>BECProgressBar</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_progress_bar</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,22 +1,15 @@
|
||||
import os
|
||||
import re
|
||||
from functools import partial
|
||||
from typing import Optional
|
||||
|
||||
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 QSize, Signal
|
||||
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QVBoxLayout, QWidget
|
||||
from qtpy.QtCore import Signal, Slot
|
||||
from qtpy.QtWidgets import 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):
|
||||
@@ -30,18 +23,18 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
parent: Optional[QWidget] = None,
|
||||
config=None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
gui_id: Optional[str] = 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
|
||||
)
|
||||
@@ -50,7 +43,6 @@ 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:
|
||||
@@ -58,12 +50,14 @@ 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: ConfigAction, content: dict) -> None:
|
||||
def on_device_update(self, action: str, content: dict) -> None:
|
||||
"""
|
||||
Callback for device update events. Triggers the device_update signal.
|
||||
|
||||
@@ -74,42 +68,8 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
if action in ["add", "remove", "reload"]:
|
||||
self.device_update.emit()
|
||||
|
||||
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:
|
||||
@Slot()
|
||||
def update_device_list(self) -> None:
|
||||
"""
|
||||
Update the device list based on the filter input.
|
||||
There are two ways to trigger this function:
|
||||
@@ -120,14 +80,23 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
"""
|
||||
filter_text = self.ui.filter_input.text()
|
||||
try:
|
||||
self.regex = re.compile(filter_text, re.IGNORECASE)
|
||||
regex = re.compile(filter_text, re.IGNORECASE)
|
||||
except re.error:
|
||||
self.regex = None # Invalid regex, disable filtering
|
||||
for device in self.dev:
|
||||
self._device_items[device].setHidden(False)
|
||||
return
|
||||
regex = None # Invalid regex, disable filtering
|
||||
|
||||
dev_list = self.ui.device_list
|
||||
dev_list.clear()
|
||||
for device in self.dev:
|
||||
self._device_items[device].setHidden(not self.regex.search(device))
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
@@ -135,10 +104,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
apply_theme("light")
|
||||
widget = DeviceBrowser()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
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()
|
||||
@@ -1,60 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_name
|
||||
from bec_widgets.utils.forms_from_types import styles
|
||||
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
DEFAULT_WIDGET_TYPES,
|
||||
BoolFormItem,
|
||||
BoolToggleFormItem,
|
||||
)
|
||||
|
||||
|
||||
class DeviceConfigForm(PydanticModelForm):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(self, parent=None, client=None, pretty_display=False, **kwargs):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
data_model=DeviceConfigModel,
|
||||
pretty_display=pretty_display,
|
||||
client=client,
|
||||
**kwargs,
|
||||
)
|
||||
self._widget_types = DEFAULT_WIDGET_TYPES.copy()
|
||||
self._widget_types["bool"] = (lambda spec: spec.item_type is bool, BoolToggleFormItem)
|
||||
self._widget_types["optional_bool"] = (
|
||||
lambda spec: spec.item_type == bool | None,
|
||||
BoolFormItem,
|
||||
)
|
||||
self._validity.setVisible(False)
|
||||
self._connect_to_theme_change()
|
||||
self.populate()
|
||||
|
||||
def _post_init(self): ...
|
||||
|
||||
def set_pretty_display_theme(self, theme: str | None = None):
|
||||
if theme is None:
|
||||
theme = get_theme_name()
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
|
||||
def get_form_data(self):
|
||||
"""Get the entered metadata as a dict."""
|
||||
return self._md_schema.model_validate(super().get_form_data()).model_dump()
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
|
||||
|
||||
def set_schema(self, schema: type[BaseModel]):
|
||||
raise TypeError("This class doesn't support changing the schema")
|
||||
|
||||
def set_data(self, data: DeviceConfigModel): # type: ignore # This class locks the type
|
||||
super().set_data(data)
|
||||
@@ -2,110 +2,37 @@ 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 bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QMimeData, QSize, Qt, Signal
|
||||
from qtpy.QtCore import QMimeData, Qt
|
||||
from qtpy.QtGui import QDrag
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QTabWidget, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
|
||||
from bec_widgets.widgets.services.device_browser.device_item.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,
|
||||
)
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtGui import QMouseEvent
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceItem(ExpandableGroupFrame):
|
||||
broadcast_size_hint = Signal(QSize)
|
||||
class DeviceItem(QWidget):
|
||||
def __init__(self, device: str) -> None:
|
||||
super().__init__()
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent, device: str, devices: DeviceContainer, icon: str = "") -> None:
|
||||
super().__init__(parent, title=device, expanded=False, icon=icon)
|
||||
self.dev = devices
|
||||
self._drag_pos = None
|
||||
self._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._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.setStyleSheet(
|
||||
"""
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
"""
|
||||
)
|
||||
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)
|
||||
@@ -132,33 +59,10 @@ class DeviceItem(ExpandableGroupFrame):
|
||||
|
||||
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 = 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 = DeviceItem("Device")
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
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_())
|
||||
@@ -1,11 +0,0 @@
|
||||
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"
|
||||
@@ -11,11 +11,12 @@ from re import Pattern
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.connector import ConnectorBase
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import LogLevel, bec_logger
|
||||
from bec_lib.messages import LogMessage, StatusMessage
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QDateTime, QObject, Qt, Signal
|
||||
from PySide6.QtCore import QObject
|
||||
from qtpy.QtCore import QDateTime, Qt, Signal
|
||||
from qtpy.QtGui import QFont
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -34,7 +35,6 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.colors import get_theme_palette, set_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.editors.text_box.text_box import TextBox
|
||||
@@ -69,22 +69,22 @@ DEFAULT_LOG_COLORS = {
|
||||
}
|
||||
|
||||
|
||||
class BecLogsQueue(BECConnector, QObject):
|
||||
class BecLogsQueue(QObject):
|
||||
"""Manages getting logs from BEC Redis and formatting them for display"""
|
||||
|
||||
RPC = False
|
||||
new_message = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QObject | None,
|
||||
conn: ConnectorBase,
|
||||
maxlen: int = 1000,
|
||||
line_formatter: LineFormatter = noop_format,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
super().__init__(parent=parent)
|
||||
self._timestamp_start: QDateTime | None = None
|
||||
self._timestamp_end: QDateTime | None = None
|
||||
self._conn = conn
|
||||
self._max_length = maxlen
|
||||
self._data: deque[LogMessage] = deque([], self._max_length)
|
||||
self._display_queue: deque[str] = deque([], self._max_length)
|
||||
@@ -92,26 +92,20 @@ class BecLogsQueue(BECConnector, QObject):
|
||||
self._search_query: Pattern | str | None = None
|
||||
self._selected_services: set[str] | None = None
|
||||
self._set_formatter_and_update_filter(line_formatter)
|
||||
# instance attribute still accessible after c++ object is deleted, so the callback can be unregistered
|
||||
self.bec_dispatcher.connect_slot(self._process_incoming_log_msg, MessageEndpoints.log())
|
||||
self._conn.register([MessageEndpoints.log()], None, self._process_incoming_log_msg)
|
||||
|
||||
def cleanup(self, *_):
|
||||
def unsub_from_redis(self):
|
||||
"""Stop listening to the Redis log stream"""
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self._process_incoming_log_msg, [MessageEndpoints.log()]
|
||||
)
|
||||
self._conn.unregister([MessageEndpoints.log()], None, self._process_incoming_log_msg)
|
||||
|
||||
@SafeSlot(verify_sender=True)
|
||||
def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
|
||||
def _process_incoming_log_msg(self, msg: dict):
|
||||
try:
|
||||
_msg = LogMessage(**msg)
|
||||
_msg: LogMessage = msg["data"]
|
||||
self._data.append(_msg)
|
||||
if self.filter is None or self.filter(_msg):
|
||||
self._display_queue.append(self._line_formatter(_msg))
|
||||
self.new_message.emit()
|
||||
except Exception as e:
|
||||
if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
|
||||
return
|
||||
logger.warning(f"Error in LogPanel incoming message callback: {e}")
|
||||
|
||||
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
|
||||
@@ -208,7 +202,7 @@ class BecLogsQueue(BECConnector, QObject):
|
||||
"""Fetch all available messages from Redis"""
|
||||
self._data = deque(
|
||||
item["data"]
|
||||
for item in self.bec_dispatcher.client.connector.xread(
|
||||
for item in self._conn.xread(
|
||||
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length
|
||||
)
|
||||
)
|
||||
@@ -402,6 +396,7 @@ class LogPanel(TextBox):
|
||||
"""Displays a log panel"""
|
||||
|
||||
ICON_NAME = "terminal"
|
||||
_new_messages = Signal()
|
||||
service_list_update = Signal(dict, set)
|
||||
|
||||
def __init__(
|
||||
@@ -412,17 +407,17 @@ class LogPanel(TextBox):
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the LogPanel widget."""
|
||||
super().__init__(parent=parent, client=client, config={"text": ""}, **kwargs)
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
self._update_colors()
|
||||
self._service_status = service_status or BECServiceStatusMixin(self, client=self.client) # type: ignore
|
||||
self._log_manager = BecLogsQueue(
|
||||
parent=self, line_formatter=partial(simple_color_format, colors=self._colors)
|
||||
)
|
||||
self._proxy_update = SignalProxy(
|
||||
self._log_manager.new_message, rateLimit=1, slot=self._on_append
|
||||
parent,
|
||||
self.client.connector, # type: ignore
|
||||
line_formatter=partial(simple_color_format, colors=self._colors),
|
||||
)
|
||||
self._log_manager.new_message.connect(self._new_messages)
|
||||
|
||||
self.toolbar = LogPanelToolbar(parent=self)
|
||||
self.toolbar = LogPanelToolbar(parent=parent)
|
||||
self.toolbar_area = QScrollArea()
|
||||
self.toolbar_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.toolbar_area.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents)
|
||||
@@ -436,6 +431,7 @@ class LogPanel(TextBox):
|
||||
self.toolbar.search_textbox.returnPressed.connect(self._on_re_update)
|
||||
self.toolbar.regex_enabled.checkStateChanged.connect(self._on_re_update)
|
||||
self.toolbar.filter_level_dropdown.currentTextChanged.connect(self._set_level_filter)
|
||||
self._new_messages.connect(self._on_append)
|
||||
|
||||
self.toolbar.timerange_button.clicked.connect(self._choose_datetime)
|
||||
self._service_status.services_update.connect(self._update_service_list)
|
||||
@@ -487,10 +483,10 @@ class LogPanel(TextBox):
|
||||
self.set_html_text(self._log_manager.display_all())
|
||||
self._cursor_to_end()
|
||||
|
||||
@SafeSlot(verify_sender=True)
|
||||
def _on_append(self, *_):
|
||||
self.text_box_text_edit.insertHtml(self._log_manager.format_new())
|
||||
@SafeSlot()
|
||||
def _on_append(self):
|
||||
self._cursor_to_end()
|
||||
self.text_box_text_edit.insertHtml(self._log_manager.format_new())
|
||||
|
||||
@SafeSlot()
|
||||
def _on_clear(self):
|
||||
@@ -533,8 +529,9 @@ class LogPanel(TextBox):
|
||||
|
||||
def cleanup(self):
|
||||
self._service_status.cleanup()
|
||||
self._log_manager.cleanup()
|
||||
self._log_manager.deleteLater()
|
||||
self._log_manager.unsub_from_redis()
|
||||
self._log_manager.new_message.disconnect(self._new_messages)
|
||||
self._new_messages.disconnect(self._on_append)
|
||||
super().cleanup()
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from qtpy.QtWidgets import (
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -179,7 +180,6 @@ 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,10 +241,8 @@ 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._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._readback_endpoint = MessageEndpoints.device_readback(self._device)
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, self._readback_endpoint)
|
||||
self._manual_read()
|
||||
self.set_display_value(self._value)
|
||||
|
||||
@@ -252,8 +250,7 @@ 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._read_endpoint)
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._read_config_endpoint)
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._readback_endpoint)
|
||||
|
||||
def _manual_read(self):
|
||||
if self._device is None or not isinstance(
|
||||
@@ -262,13 +259,8 @@ class SignalLabel(BECWidget, QWidget):
|
||||
self._units = ""
|
||||
self._value = "__"
|
||||
return
|
||||
signal, info = (
|
||||
(
|
||||
getattr(device, self.signal, None),
|
||||
device._info.get("signals", {}).get(self._signal, {}).get("describe", {}),
|
||||
)
|
||||
if isinstance(device, Device)
|
||||
else (device, device.describe().get(self._device))
|
||||
signal: Signal = (
|
||||
getattr(device, self.signal, None) if isinstance(device, Device) else device
|
||||
)
|
||||
if not isinstance(signal, Signal): # Avoid getting other attributes of device, e.g. methods
|
||||
signal = None
|
||||
@@ -277,8 +269,7 @@ class SignalLabel(BECWidget, QWidget):
|
||||
self._value = "__"
|
||||
return
|
||||
self._value = signal.get()
|
||||
self._units = info.get("egu", "")
|
||||
self._dtype = info.get("dtype", "float")
|
||||
self._units = signal.get_device_config().get("egu", "")
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback(self, msg: dict, metadata: dict) -> None:
|
||||
@@ -287,10 +278,8 @@ class SignalLabel(BECWidget, QWidget):
|
||||
"""
|
||||
try:
|
||||
signal_to_read = self._patch_hinted_signal()
|
||||
_value = msg["signals"].get(signal_to_read, {}).get("value")
|
||||
if _value is not None:
|
||||
self._value = _value
|
||||
self.set_display_value(self._value)
|
||||
self._value = msg["signals"][signal_to_read]["value"]
|
||||
self.set_display_value(self._value)
|
||||
except Exception as e:
|
||||
self._display.setText("ERROR!")
|
||||
self._display.setToolTip(
|
||||
@@ -412,10 +401,7 @@ class SignalLabel(BECWidget, QWidget):
|
||||
if self._decimal_places == 0:
|
||||
return value
|
||||
try:
|
||||
if self._dtype in ("integer", "float"):
|
||||
return f"{float(value):0.{self._decimal_places}f}"
|
||||
else:
|
||||
return str(value)
|
||||
return f"{float(value):0.{self._decimal_places}f}"
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ class ToggleSwitch(QWidget):
|
||||
A simple toggle.
|
||||
"""
|
||||
|
||||
stateChanged = Signal(bool)
|
||||
enabled = Signal(bool)
|
||||
ICON_NAME = "toggle_on"
|
||||
PLUGIN = True
|
||||
@@ -43,19 +42,11 @@ 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
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QColorDialog, QPushButton
|
||||
from qtpy.QtWidgets import QPushButton
|
||||
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
|
||||
@@ -15,8 +12,6 @@ class ColorButtonNative(BECWidget, QPushButton):
|
||||
to guarantee good readability.
|
||||
"""
|
||||
|
||||
color_changed = Signal(str)
|
||||
|
||||
RPC = False
|
||||
PLUGIN = True
|
||||
ICON_NAME = "colors"
|
||||
@@ -30,10 +25,9 @@ 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: str | QColor):
|
||||
def set_color(self, color):
|
||||
"""Set the button's color and update its appearance.
|
||||
|
||||
Args:
|
||||
@@ -44,7 +38,6 @@ class ColorButtonNative(BECWidget, QPushButton):
|
||||
else:
|
||||
self._color = color
|
||||
self._update_appearance()
|
||||
self.color_changed.emit(self._color)
|
||||
|
||||
@SafeProperty("QColor")
|
||||
def color(self):
|
||||
@@ -63,11 +56,3 @@ 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)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.21.2"
|
||||
version = "2.9.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -13,14 +13,15 @@ classifiers = [
|
||||
"Topic :: Scientific/Engineering",
|
||||
]
|
||||
dependencies = [
|
||||
"bec_ipython_client>=3.42.4, <=4.0", # needed for jupyter console
|
||||
"bec_lib>=3.44, <=4.0",
|
||||
"bec_ipython_client>=2.21.4, <=4.0", # needed for jupyter console
|
||||
"bec_lib>=3.29, <=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
|
||||
"pydantic~=2.0",
|
||||
"pyqtgraph~=0.13",
|
||||
"PySide6~=6.8.2",
|
||||
"pyte", # needed for vt100 console
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
]
|
||||
|
||||
@@ -51,6 +51,7 @@ 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")
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import pytest
|
||||
from qtpy.QtCore import Qt
|
||||
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dock_area(qtbot):
|
||||
|
||||
dock = BECDockArea()
|
||||
qtbot.addWidget(dock)
|
||||
qtbot.waitExposed(dock)
|
||||
dock.show()
|
||||
qtbot.wait(500)
|
||||
|
||||
yield dock
|
||||
|
||||
dock.remove()
|
||||
dock.deleteLater()
|
||||
|
||||
|
||||
def test_sequence_of_add_and_remove(qtbot, dock_area):
|
||||
|
||||
add_sc_action = dock_area.toolbar.widgets["menu_devices"].widgets["scan_control"]
|
||||
add_pb_action = dock_area.toolbar.widgets["menu_devices"].widgets["positioner_box"]
|
||||
|
||||
add_pb_action.trigger()
|
||||
qtbot.wait(1000)
|
||||
|
||||
add_sc_action.trigger()
|
||||
qtbot.wait(1000)
|
||||
|
||||
assert all(w in dock_area.panels for w in ["positioner_box_0", "scan_control_0"])
|
||||
|
||||
dock_area.panels["positioner_box_0"].remove()
|
||||
qtbot.wait(1000)
|
||||
|
||||
dock_area.panels["scan_control_0"].remove()
|
||||
qtbot.wait(1000)
|
||||
|
||||
add_pb_action.trigger()
|
||||
qtbot.wait(1000)
|
||||
@@ -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 = 12
|
||||
top_level_widgets_count = 4
|
||||
assert len(gui._server_registry) == top_level_widgets_count
|
||||
# Number of widgets with parent_id == None, should be 2
|
||||
widgets = [
|
||||
@@ -145,14 +145,7 @@ 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
|
||||
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
|
||||
qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count)
|
||||
# Number of widgets with parent_id == None, should be 2
|
||||
widgets = [
|
||||
widget
|
||||
|
||||
@@ -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() - 1): # type: ignore
|
||||
for i in range(grid.rowCount()): # 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)
|
||||
|
||||
@@ -12,11 +12,12 @@ may not be created immediately after the rpc call is made.
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
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
|
||||
@@ -320,20 +321,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
|
||||
_, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
|
||||
dock: client.BECDock
|
||||
widget: client.SignalComboBox
|
||||
|
||||
widget.set_device("samx")
|
||||
info = bec.device_manager.devices.samx._info["signals"]
|
||||
assert widget.signals == [
|
||||
["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")],
|
||||
"readback",
|
||||
"setpoint",
|
||||
"motor_is_moving",
|
||||
"velocity",
|
||||
"acceleration",
|
||||
"tolerance",
|
||||
]
|
||||
widget.set_signal("samx (readback)")
|
||||
widget.set_signal("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)
|
||||
|
||||
@@ -168,16 +168,11 @@ def test_accept_changes(axis_settings_fixture, qtbot):
|
||||
axis_settings.ui.x_grid.checked = True
|
||||
|
||||
axis_settings.accept_changes()
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -47,21 +47,24 @@ 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)
|
||||
|
||||
qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == initial_count + 1, timeout=200)
|
||||
assert len(bec_dock_area.dock_area.docks) == initial_count + 1
|
||||
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):
|
||||
_ = bec_dock_area.new(name="dock_0")
|
||||
_ = bec_dock_area.new(name="dock_1")
|
||||
_ = bec_dock_area.new(name="dock_2")
|
||||
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.delete_all()
|
||||
qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == 0)
|
||||
qtbot.wait(200)
|
||||
assert len(bec_dock_area.dock_area.docks) == 0
|
||||
|
||||
|
||||
def test_undock_and_dock_docks(bec_dock_area, qtbot):
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
|
||||
BECProgressBar,
|
||||
ProgressState,
|
||||
)
|
||||
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -36,23 +33,3 @@ 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
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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()
|
||||
65
tests/unit_tests/test_console.py
Normal file
65
tests/unit_tests/test_console.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from pygments.token import Token
|
||||
from qtpy.QtCore import QEventLoop
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.editors.console.console import BECConsole
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_widget(qtbot):
|
||||
apply_theme("light")
|
||||
console = BECConsole()
|
||||
console.set_cmd(sys.executable) # will launch Python interpreter
|
||||
console.set_prompt_tokens((Token.Prompt, ">>>"))
|
||||
qtbot.addWidget(console)
|
||||
console.show()
|
||||
qtbot.waitExposed(console)
|
||||
yield console
|
||||
console.terminate()
|
||||
|
||||
|
||||
def test_console_widget(console_widget, qtbot, tmp_path):
|
||||
def wait_prompt(command_to_execute=None, busy=False):
|
||||
signal_waiter = QEventLoop()
|
||||
|
||||
def exit_loop(idle):
|
||||
if busy and not idle:
|
||||
signal_waiter.quit()
|
||||
elif not busy and idle:
|
||||
signal_waiter.quit()
|
||||
|
||||
console_widget.prompt.connect(exit_loop)
|
||||
if command_to_execute:
|
||||
if callable(command_to_execute):
|
||||
command_to_execute()
|
||||
else:
|
||||
console_widget.execute_command(command_to_execute)
|
||||
signal_waiter.exec_()
|
||||
|
||||
console_widget.start()
|
||||
wait_prompt()
|
||||
|
||||
# use console to write something to a tmp file
|
||||
tmp_filename = str(tmp_path / "console_test.txt")
|
||||
wait_prompt(f"f = open('{tmp_filename}', 'wt'); f.write('HELLO CONSOLE'); f.close()")
|
||||
# check the code has been executed by console, by checking the tmp file contents
|
||||
with open(tmp_filename, "rt") as f:
|
||||
assert f.read() == "HELLO CONSOLE"
|
||||
|
||||
# execute a sleep
|
||||
t0 = time.perf_counter()
|
||||
wait_prompt("import time; time.sleep(1)")
|
||||
assert time.perf_counter() - t0 >= 1
|
||||
|
||||
# test ctrl-c
|
||||
t0 = time.perf_counter()
|
||||
wait_prompt("time.sleep(5)", busy=True)
|
||||
wait_prompt(console_widget.send_ctrl_c)
|
||||
assert (
|
||||
time.perf_counter() - t0 < 1
|
||||
) # in reality it will be almost immediate, but ok we can say less than 1 second compared to 5
|
||||
@@ -29,6 +29,7 @@ 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()
|
||||
@@ -98,7 +99,6 @@ 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 == [(str(id(image_item)), 21, 55)]
|
||||
assert emitted_values_2D == [("test", 21, 55)]
|
||||
|
||||
|
||||
def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair):
|
||||
|
||||
@@ -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.currentText() == ""
|
||||
assert curve_setting.device_x.text() == wf.x_axis_mode["name"]
|
||||
|
||||
|
||||
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.currentText() == wf.x_axis_mode["name"]
|
||||
assert curve_setting.signal_x.currentText() == f"{wf.x_axis_mode['entry']} (readback)"
|
||||
assert curve_setting.device_x.text() == wf.x_axis_mode["name"]
|
||||
assert curve_setting.signal_x.text() == wf.x_axis_mode["entry"]
|
||||
|
||||
|
||||
##################################################
|
||||
|
||||
@@ -3,31 +3,20 @@ 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.device_item import DeviceItem
|
||||
|
||||
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=missing-function-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
# pylint: disable=protected-access
|
||||
from bec_widgets.widgets.services.device_browser import DeviceItem
|
||||
|
||||
|
||||
@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
|
||||
@@ -41,24 +30,22 @@ def test_device_browser_init_with_devices(device_browser):
|
||||
assert device_list.count() == len(device_browser.dev)
|
||||
|
||||
|
||||
@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
|
||||
):
|
||||
def test_device_browser_filtering(qtbot, device_browser):
|
||||
"""
|
||||
Test that the device browser is able to filter the device list.
|
||||
"""
|
||||
expected = expected_num_visible if expected_num_visible >= 0 else len(device_browser.dev)
|
||||
device_list = device_browser.ui.device_list
|
||||
device_browser.ui.filter_input.setText("sam")
|
||||
qtbot.wait(1000)
|
||||
assert device_list.count() == 3
|
||||
|
||||
def num_visible(item_dict):
|
||||
return len(list(filter(lambda i: not i.isHidden(), item_dict.values())))
|
||||
device_browser.ui.filter_input.setText("nonexistent")
|
||||
qtbot.wait(1000)
|
||||
assert device_list.count() == 0
|
||||
|
||||
device_browser.ui.filter_input.setText(search_term)
|
||||
qtbot.wait(100)
|
||||
assert num_visible(device_browser._device_items) == expected
|
||||
device_browser.ui.filter_input.setText("")
|
||||
qtbot.wait(1000)
|
||||
assert device_list.count() == len(device_browser.dev)
|
||||
|
||||
|
||||
def test_device_item_mouse_press_event(device_browser, qtbot):
|
||||
@@ -68,43 +55,7 @@ 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._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)
|
||||
qtbot.mouseClick(widget.label, Qt.MouseButton.LeftButton)
|
||||
|
||||
|
||||
def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qtbot):
|
||||
@@ -116,7 +67,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._title, Qt.MouseButton.LeftButton, pos=QPoint(0, 0))
|
||||
qtbot.mousePress(widget.label, 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()
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -94,6 +94,18 @@ 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 = []
|
||||
@@ -108,25 +120,17 @@ 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")
|
||||
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")),
|
||||
]
|
||||
assert device_signal_combobox.signals == ["readback", "setpoint", "velocity"]
|
||||
qtbot.wait(100)
|
||||
assert container == ["samx (readback)"]
|
||||
assert container == ["samx"]
|
||||
# Set the type of class from the FakeDevice to Signal
|
||||
fake_signal = FakeSignal(name="fake_signal", info={"device_info": {"signals": {}}})
|
||||
fake_signal = FakeSignal(name="fake_signal")
|
||||
device_signal_combobox.client.device_manager.add_devices([fake_signal])
|
||||
device_signal_combobox.set_device("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.signals == ["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):
|
||||
@@ -144,12 +148,3 @@ 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)
|
||||
|
||||
@@ -48,22 +48,12 @@ 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,
|
||||
)
|
||||
""",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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]
|
||||
@@ -1,123 +0,0 @@
|
||||
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}")
|
||||
@@ -1,80 +0,0 @@
|
||||
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"]
|
||||
@@ -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
|
||||
qtbot.waitUntil(
|
||||
lambda: all([roi.label == "new_name", item.text(roi_tree.COL_ROI) == "new_name"]),
|
||||
timeout=200,
|
||||
)
|
||||
assert roi.label == "new_name"
|
||||
assert item.text(roi_tree.COL_ROI) == "new_name"
|
||||
|
||||
|
||||
def test_roi_width_edit(roi_tree, image_widget, qtbot):
|
||||
@@ -138,8 +138,9 @@ 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
|
||||
qtbot.waitUntil(lambda: roi.line_width == 25, timeout=200)
|
||||
assert roi.line_width == 25
|
||||
|
||||
|
||||
def test_delete_roi_button(roi_tree, image_widget, qtbot):
|
||||
@@ -147,17 +148,16 @@ 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]
|
||||
|
||||
action_widget = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
|
||||
layout = action_widget.layout()
|
||||
# Get the delete button
|
||||
del_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
|
||||
|
||||
del_btn = layout.itemAt(1).widget()
|
||||
# Click the delete button
|
||||
del_btn.click()
|
||||
qtbot.wait(200)
|
||||
|
||||
# Verify ROI was removed
|
||||
qtbot.waitUntil(
|
||||
lambda: all([roi not in roi_tree.roi_items, roi not in image_widget.roi_controller.rois]),
|
||||
timeout=200,
|
||||
)
|
||||
assert roi not in roi_tree.roi_items
|
||||
assert roi not in image_widget.roi_controller.rois
|
||||
|
||||
|
||||
def test_roi_color_change_from_roi(roi_tree, image_widget):
|
||||
@@ -331,67 +331,3 @@ 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)
|
||||
|
||||
@@ -6,21 +6,16 @@ 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,
|
||||
EllipticalROI,
|
||||
RectangularROI,
|
||||
ROIController,
|
||||
)
|
||||
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI, ROIController
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
|
||||
@pytest.fixture(params=["rect", "circle", "ellipse"])
|
||||
@pytest.fixture(params=["rect", "circle"])
|
||||
def bec_image_widget_with_roi(qtbot, request, mocked_client):
|
||||
"""Return (widget, roi, shape_label) for each ROI class."""
|
||||
|
||||
roi_type: Literal["rect", "circle", "ellipse"] = request.param
|
||||
roi_type: Literal["rect", "circle"] = request.param
|
||||
|
||||
# Build an Image widget with a trivial 100×100 zeros array
|
||||
widget: Image = create_widget(qtbot, Image, client=mocked_client)
|
||||
@@ -44,12 +39,7 @@ def test_default_properties(bec_image_widget_with_roi):
|
||||
assert roi.line_width == 5
|
||||
|
||||
# concrete subclass type
|
||||
if roi_type == "rect":
|
||||
assert isinstance(roi, RectangularROI)
|
||||
elif roi_type == "circle":
|
||||
assert isinstance(roi, CircularROI)
|
||||
elif roi_type == "ellipse":
|
||||
assert isinstance(roi, EllipticalROI)
|
||||
assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI)
|
||||
|
||||
|
||||
def test_coordinate_structures(bec_image_widget_with_roi):
|
||||
@@ -108,7 +98,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", "ellipse"):
|
||||
for _kind in ("rect", "circle"):
|
||||
widget.add_roi(kind=_kind)
|
||||
widget.add_roi(kind=_kind)
|
||||
|
||||
@@ -215,29 +205,3 @@ 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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -62,8 +61,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.v_range = (10, 100)
|
||||
assert bec_image_view.v_range == QPointF(10, 100)
|
||||
bec_image_view.vrange = (10, 100)
|
||||
assert bec_image_view.vrange == (10, 100)
|
||||
assert bec_image_view.main_image.levels == (10, 100)
|
||||
assert bec_image_view.main_image.config.v_range == (10, 100)
|
||||
|
||||
@@ -108,86 +107,17 @@ 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.v_range == QPointF(0, 100)
|
||||
assert bec_image_view.vrange == (0, 100)
|
||||
assert bec_image_view.main_image.levels == (0, 100)
|
||||
assert bec_image_view._color_bar is not None
|
||||
|
||||
|
||||
##############################################
|
||||
# Preview‑signal 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 1‑D PreviewSignal connects using the 1‑D 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 1‑D 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 2‑D PreviewSignal connects using the 2‑D 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 2‑D 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 2‑D 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.subscriptions["main"].source == "device_monitor_2d"
|
||||
assert bec_image_view.subscriptions["main"].monitor_type == "2d"
|
||||
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.main_image.raw_data is None
|
||||
assert bec_image_view.main_image.image is None
|
||||
|
||||
@@ -196,8 +126,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.subscriptions["main"].source == "device_monitor_1d"
|
||||
assert bec_image_view.subscriptions["main"].monitor_type == "1d"
|
||||
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.main_image.raw_data is None
|
||||
assert bec_image_view.main_image.image is None
|
||||
|
||||
@@ -206,8 +136,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.subscriptions["main"].source == "auto"
|
||||
assert bec_image_view.subscriptions["main"].monitor_type == "auto"
|
||||
assert bec_image_view.main_image.config.source == "auto"
|
||||
assert bec_image_view.main_image.config.monitor_type == "auto"
|
||||
assert bec_image_view.main_image.raw_data is None
|
||||
assert bec_image_view.main_image.image is None
|
||||
|
||||
@@ -220,7 +150,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):
|
||||
@@ -230,14 +160,10 @@ 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)
|
||||
|
||||
|
||||
##############################################
|
||||
# Toolbar and Actions Tests
|
||||
assert bec_image_view._main_image.raw_data.shape == (2, 60)
|
||||
|
||||
|
||||
def test_toolbar_actions_presence(qtbot, mocked_client):
|
||||
@@ -281,8 +207,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.v_range = (0, 100)
|
||||
assert bec_image_view.v_range == QPointF(0, 100)
|
||||
bec_image_view.vrange = (0, 100)
|
||||
assert bec_image_view.vrange == (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
|
||||
@@ -308,8 +234,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.subscriptions["main"].source == "device_monitor_2d"
|
||||
assert bec_image_view.subscriptions["main"].monitor_type == "2d"
|
||||
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.main_image.raw_data is None
|
||||
assert bec_image_view.main_image.image is None
|
||||
|
||||
@@ -508,31 +434,19 @@ 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
|
||||
qtbot.waitUntil(
|
||||
lambda: all(
|
||||
[
|
||||
bec_image_view.side_panel_x.panel_height > 0,
|
||||
bec_image_view.side_panel_y.panel_width > 0,
|
||||
]
|
||||
),
|
||||
timeout=500,
|
||||
)
|
||||
assert bec_image_view.side_panel_x.panel_height > 0
|
||||
assert bec_image_view.side_panel_y.panel_width > 0
|
||||
|
||||
# Disable ROI crosshair
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(500)
|
||||
|
||||
# Panels hidden again
|
||||
qtbot.waitUntil(
|
||||
lambda: all(
|
||||
[
|
||||
bec_image_view.side_panel_x.panel_height == 0,
|
||||
bec_image_view.side_panel_y.panel_width == 0,
|
||||
]
|
||||
),
|
||||
timeout=500,
|
||||
)
|
||||
assert bec_image_view.side_panel_x.panel_height == 0
|
||||
assert bec_image_view.side_panel_y.panel_width == 0
|
||||
|
||||
|
||||
def test_roi_plot_data_from_image(qtbot, mocked_client):
|
||||
@@ -569,96 +483,3 @@ 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 combo‑box 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 preview‑signal devices to the combo‑box
|
||||
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 set‑up:
|
||||
‑ fills the combo‑box 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() == ""
|
||||
|
||||
@@ -102,34 +102,7 @@ def test_launch_window_launch_plugin_auto_update(bec_launch_window):
|
||||
[
|
||||
({}, False),
|
||||
({"launcher": mock.MagicMock()}, False),
|
||||
({"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,
|
||||
),
|
||||
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, True),
|
||||
],
|
||||
)
|
||||
def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide):
|
||||
@@ -159,34 +132,7 @@ 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()}, 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,
|
||||
),
|
||||
({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, False),
|
||||
],
|
||||
)
|
||||
def test_launch_window_closes(bec_launch_window, connections, close_called):
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from collections import deque
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from bec_lib.messages import LogMessage
|
||||
from bec_lib.redis_connector import StreamMessage
|
||||
from qtpy.QtCore import QDateTime
|
||||
from qtpy.QtCore import QDateTime, Qt, Signal # type: ignore
|
||||
|
||||
from bec_widgets.widgets.utility.logpanel._util import (
|
||||
log_time,
|
||||
@@ -66,6 +65,7 @@ def log_panel(qtbot, mocked_client: MagicMock):
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
widget.cleanup()
|
||||
|
||||
|
||||
def test_log_panel_init(log_panel: LogPanel):
|
||||
@@ -97,13 +97,14 @@ def test_logpanel_output(qtbot, log_panel: LogPanel):
|
||||
return len(log_panel._log_manager._display_queue) == 0
|
||||
|
||||
next_text = "datetime | error | test log message"
|
||||
msg = LogMessage(
|
||||
metadata={},
|
||||
log_type="error",
|
||||
log_msg={"text": next_text, "record": {}, "service_name": "ScanServer"},
|
||||
)
|
||||
log_panel._log_manager._process_incoming_log_msg(
|
||||
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
|
||||
{
|
||||
"data": LogMessage(
|
||||
metadata={},
|
||||
log_type="error",
|
||||
log_msg={"text": next_text, "record": {}, "service_name": "ScanServer"},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
qtbot.waitUntil(display_queue_empty, timeout=5000)
|
||||
@@ -135,35 +136,3 @@ def test_timestamp_filter(log_panel: LogPanel):
|
||||
assert not filter_(TEST_LOG_MESSAGES[0])
|
||||
assert filter_(TEST_LOG_MESSAGES[1])
|
||||
assert not filter_(TEST_LOG_MESSAGES[2])
|
||||
|
||||
|
||||
def test_error_handling_in_callback(log_panel: LogPanel):
|
||||
log_panel._log_manager.new_message = MagicMock()
|
||||
|
||||
with patch("bec_widgets.widgets.utility.logpanel.logpanel.logger") as logger:
|
||||
# generally errors should be logged
|
||||
log_panel._log_manager.new_message.emit = MagicMock(
|
||||
side_effect=ValueError("Something went wrong")
|
||||
)
|
||||
msg = LogMessage(
|
||||
metadata={},
|
||||
log_type="debug",
|
||||
log_msg={
|
||||
"text": "datetime | debug | test log message",
|
||||
"record": {"time": {"timestamp": 123456789.000}},
|
||||
"service_name": "ScanServer",
|
||||
},
|
||||
)
|
||||
log_panel._log_manager._process_incoming_log_msg(
|
||||
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
|
||||
)
|
||||
logger.warning.assert_called_once()
|
||||
|
||||
# this specific error should be ignored and not relogged
|
||||
log_panel._log_manager.new_message.emit = MagicMock(
|
||||
side_effect=RuntimeError("Internal C++ object (BecLogsQueue) already deleted.")
|
||||
)
|
||||
log_panel._log_manager._process_incoming_log_msg(
|
||||
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
|
||||
)
|
||||
logger.warning.assert_called_once()
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
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 client‑info 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 delay‑timer 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 scan‑progress 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
|
||||
|
||||
# Pre‑condition: 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("Hover‑target")
|
||||
full = QLabel("Full‑view")
|
||||
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
|
||||
@@ -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 == ServiceConfig().config
|
||||
assert gui_server._get_service_config().config is ServiceConfig().config
|
||||
|
||||
@@ -4,10 +4,10 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.messages import AvailableResourceMessage, ScanHistoryMessage
|
||||
from bec_lib.messages import AvailableResourceMessage, ScanQueueHistoryMessage, ScanQueueMessage
|
||||
from qtpy.QtCore import QModelIndex, Qt
|
||||
|
||||
from bec_widgets.utils.forms_from_types.items import StrFormItem
|
||||
from bec_widgets.utils.forms_from_types.items import StrMetadataField
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
|
||||
@@ -221,36 +221,82 @@ available_scans_message = AvailableResourceMessage(
|
||||
}
|
||||
)
|
||||
|
||||
scan_history = ScanHistoryMessage(
|
||||
scan_history = ScanQueueHistoryMessage(
|
||||
metadata={},
|
||||
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},
|
||||
},
|
||||
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,
|
||||
},
|
||||
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.xadd(
|
||||
topic=MessageEndpoints.scan_history(), msg_dict={"data": scan_history}
|
||||
)
|
||||
mocked_client.connector.lpush(MessageEndpoints.scan_queue_history(), scan_history)
|
||||
widget = ScanControl(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
@@ -524,7 +570,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, StrFormItem)
|
||||
assert isinstance(sample_name, StrMetadataField)
|
||||
sample_name._main_widget.setText("Test Sample")
|
||||
|
||||
scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from decimal import Decimal
|
||||
from typing import Set
|
||||
|
||||
import pytest
|
||||
from bec_lib.metadata_schema import BasicScanMetadata
|
||||
@@ -8,12 +7,11 @@ from pydantic.types import Json
|
||||
from qtpy.QtCore import QItemSelectionModel, QPoint, Qt
|
||||
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
BoolFormItem,
|
||||
DictFormItem,
|
||||
BoolMetadataField,
|
||||
DynamicFormItem,
|
||||
FloatDecimalFormItem,
|
||||
IntFormItem,
|
||||
StrFormItem,
|
||||
FloatDecimalMetadataField,
|
||||
IntMetadataField,
|
||||
StrMetadataField,
|
||||
)
|
||||
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
|
||||
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
|
||||
@@ -36,8 +34,7 @@ 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)
|
||||
dict_default: dict = Field(default_factory=dict)
|
||||
unsupported_class: Json = Field(default=set())
|
||||
unsupported_class: Json = Field(default_factory=dict)
|
||||
|
||||
|
||||
TEST_DICT = {
|
||||
@@ -50,9 +47,8 @@ TEST_DICT = {
|
||||
"int_default": 21,
|
||||
"int_nodefault_optional": -10,
|
||||
"float_nodefault": pytest.approx(0.1),
|
||||
"decimal_dp_limits_nodefault": pytest.approx(34.5),
|
||||
"dict_default": {"test_dict": "values"},
|
||||
"unsupported_class": '["set", "item"]',
|
||||
"decimal_dp_limits_nodefault": pytest.approx(34),
|
||||
"unsupported_class": '{"key": "value"}',
|
||||
}
|
||||
|
||||
|
||||
@@ -62,11 +58,12 @@ def example_md():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_metadata_widget(qtbot):
|
||||
def empty_metadata_widget():
|
||||
widget = ScanMetadata()
|
||||
widget._additional_metadata._table_model._data = [["extra_field", "extra_data"]]
|
||||
qtbot.addWidget(widget)
|
||||
yield widget
|
||||
widget._clear_grid()
|
||||
widget.deleteLater()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -85,8 +82,7 @@ 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()
|
||||
dict_default = widget._form_grid.layout().itemAtPosition(10, 1).widget()
|
||||
unsupported_class = widget._form_grid.layout().itemAtPosition(11, 1).widget()
|
||||
unsupported_class = widget._form_grid.layout().itemAtPosition(10, 1).widget()
|
||||
|
||||
yield (
|
||||
widget,
|
||||
@@ -101,7 +97,6 @@ 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,
|
||||
},
|
||||
)
|
||||
@@ -117,26 +112,24 @@ 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["dict_default"].setValue({"test_dict": "values"})
|
||||
components["unsupported_class"].setValue(r'["set", "item"]')
|
||||
components["unsupported_class"].setValue(r'{"key": "value"}')
|
||||
|
||||
|
||||
def test_griditems_are_correct_class(
|
||||
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
|
||||
):
|
||||
_, components = metadata_widget
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]):
|
||||
@@ -175,16 +168,14 @@ 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(
|
||||
initial_data=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]
|
||||
)
|
||||
table = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
|
||||
yield table
|
||||
table._table_model.deleteLater()
|
||||
table._table_view.deleteLater()
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
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 don’t 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()
|
||||
@@ -121,13 +121,11 @@ 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"
|
||||
@@ -228,7 +226,6 @@ 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()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
@@ -9,15 +7,6 @@ import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import pytest
|
||||
from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QSpinBox,
|
||||
)
|
||||
|
||||
from bec_widgets.widgets.plots.plot_base import UIMode
|
||||
from bec_widgets.widgets.plots.waveform.curve import DeviceSignal
|
||||
@@ -544,7 +533,6 @@ def test_on_async_readback_add_update(qtbot, mocked_client):
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.scan_item = create_dummy_scan_item()
|
||||
wf._scan_done = False # simulate a live scan
|
||||
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
||||
wf._async_curves = [c]
|
||||
# Suppose existing data
|
||||
@@ -806,13 +794,6 @@ 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"
|
||||
@@ -838,227 +819,3 @@ def test_show_dap_summary_popup(qtbot, mocked_client):
|
||||
wf.dap_summary_dialog.close()
|
||||
assert wf.dap_summary_dialog is None
|
||||
assert fit_action.isChecked() is False
|
||||
|
||||
|
||||
#####################################################
|
||||
# The following tests are for the async dataset guard
|
||||
#####################################################
|
||||
|
||||
|
||||
def test_skip_large_dataset_warning_property(qtbot, mocked_client):
|
||||
"""
|
||||
Verify the getter and setter of skip_large_dataset_warning work correctly.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
# Default should be False
|
||||
assert wf.skip_large_dataset_warning is False
|
||||
|
||||
# Set to True
|
||||
wf.skip_large_dataset_warning = True
|
||||
assert wf.skip_large_dataset_warning is True
|
||||
|
||||
# Toggle back to False
|
||||
wf.skip_large_dataset_warning = False
|
||||
assert wf.skip_large_dataset_warning is False
|
||||
|
||||
|
||||
def test_max_dataset_size_mb_property(qtbot, mocked_client):
|
||||
"""
|
||||
Verify getter, setter, and validation of max_dataset_size_mb.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
# Default from WaveformConfig is 1 MB
|
||||
assert wf.max_dataset_size_mb == 10
|
||||
|
||||
# Set to a valid new value
|
||||
wf.max_dataset_size_mb = 5.5
|
||||
assert wf.max_dataset_size_mb == 5.5
|
||||
# Ensure the config is updated too
|
||||
assert wf.config.max_dataset_size_mb == 5.5
|
||||
|
||||
|
||||
def _dummy_dataset(mem_bytes: int, entry: str = "waveform_waveform"):
|
||||
"""
|
||||
Return an object that mimics the BEC dataset structure:
|
||||
it has exactly one attribute `_info` with the expected layout.
|
||||
"""
|
||||
return SimpleNamespace(_info={entry: {"value": {"mem_size": mem_bytes}}})
|
||||
|
||||
|
||||
def test_dataset_guard_under_limit(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Dataset below the limit should load without triggering the dialog.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.max_dataset_size_mb = 1 # 1 MiB
|
||||
|
||||
# If the dialog is called, we flip this flag – it must stay False.
|
||||
called = {"dlg": False}
|
||||
monkeypatch.setattr(
|
||||
Waveform, "_confirm_large_dataset", lambda self, size_mb: called.__setitem__("dlg", True)
|
||||
)
|
||||
|
||||
dataset = _dummy_dataset(mem_bytes=512_000) # ≈0.49 MiB
|
||||
assert wf._check_dataset_size_and_confirm(dataset, "waveform_waveform") is True
|
||||
assert called["dlg"] is False
|
||||
|
||||
|
||||
def test_dataset_guard_over_limit_accept(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Dataset above the limit where user presses *Yes*.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.max_dataset_size_mb = 1 # 1 MiB
|
||||
|
||||
# Pretend the user clicked “Yes”
|
||||
monkeypatch.setattr(Waveform, "_confirm_large_dataset", lambda *_: True)
|
||||
|
||||
dataset = _dummy_dataset(mem_bytes=2_000_000) # ≈1.9 MiB
|
||||
assert wf._check_dataset_size_and_confirm(dataset, "waveform_waveform") is True
|
||||
|
||||
|
||||
def test_dataset_guard_over_limit_reject(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Dataset above the limit where user presses *No*.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.max_dataset_size_mb = 1 # 1 MiB
|
||||
|
||||
# Pretend the user clicked “No”
|
||||
monkeypatch.setattr(Waveform, "_confirm_large_dataset", lambda *_: False)
|
||||
|
||||
dataset = _dummy_dataset(mem_bytes=2_000_000) # ≈1.9 MiB
|
||||
assert wf._check_dataset_size_and_confirm(dataset, "waveform_waveform") is False
|
||||
|
||||
|
||||
##################################################
|
||||
# Dialog propagation behaviour
|
||||
##################################################
|
||||
|
||||
|
||||
def test_dialog_accept_updates_limit(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Simulate clicking 'Yes' in the dialog *after* changing the spinner value.
|
||||
Verify max_dataset_size_mb is updated and dataset loads.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.max_dataset_size_mb = 1 # start small
|
||||
|
||||
def fake_confirm(self, size_mb):
|
||||
# Simulate user typing '5' in the spinbox then pressing Yes
|
||||
self.config.max_dataset_size_mb = 5
|
||||
return True # Yes pressed
|
||||
|
||||
monkeypatch.setattr(Waveform, "_confirm_large_dataset", fake_confirm)
|
||||
|
||||
big_dataset = _dummy_dataset(mem_bytes=4_800_000) # ≈4.6 MiB
|
||||
accepted = wf._check_dataset_size_and_confirm(big_dataset, "waveform_waveform")
|
||||
|
||||
# The load should be accepted and the limit must reflect the new value
|
||||
assert accepted is True
|
||||
assert wf.max_dataset_size_mb == 5
|
||||
assert wf.config.max_dataset_size_mb == 5
|
||||
|
||||
|
||||
def test_dialog_cancel_sets_skip(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Simulate clicking 'No' but ticking 'Don't show again'.
|
||||
Verify skip_large_dataset_warning becomes True and dataset is skipped.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
assert wf.skip_large_dataset_warning is False
|
||||
|
||||
def fake_confirm(self, size_mb):
|
||||
# Mimic ticking the checkbox then pressing No
|
||||
self._skip_large_dataset_warning = True
|
||||
return False # No pressed
|
||||
|
||||
monkeypatch.setattr(Waveform, "_confirm_large_dataset", fake_confirm)
|
||||
|
||||
big_dataset = _dummy_dataset(mem_bytes=11_000_000)
|
||||
accepted = wf._check_dataset_size_and_confirm(big_dataset, "waveform_waveform")
|
||||
|
||||
# Dataset must not load, but future warnings are suppressed
|
||||
assert accepted is False
|
||||
assert wf.skip_large_dataset_warning is True
|
||||
|
||||
|
||||
##################################################
|
||||
# Live dialog interaction (no monkey‑patching)
|
||||
##################################################
|
||||
|
||||
|
||||
def _open_dialog_and_click(handler):
|
||||
"""
|
||||
Utility that schedules *handler* to run as soon as a modal
|
||||
dialog is shown. Returns a function suitable for QTimer.singleShot.
|
||||
"""
|
||||
|
||||
def _cb():
|
||||
# Locate the active modal dialog
|
||||
dlg = QApplication.activeModalWidget()
|
||||
assert isinstance(dlg, QDialog), "No active modal dialog found"
|
||||
handler(dlg)
|
||||
|
||||
return _cb
|
||||
|
||||
|
||||
def test_dialog_accept_real_interaction(qtbot, mocked_client):
|
||||
"""
|
||||
End‑to‑end: user changes the limit spinner to 5 MiB, ticks
|
||||
'don't show again', then presses YES.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.max_dataset_size_mb = 1
|
||||
|
||||
# Prepare a large dataset (≈4.6 MiB)
|
||||
big_dataset = _dummy_dataset(mem_bytes=4_800_000)
|
||||
|
||||
def handler(dlg):
|
||||
spin: QDoubleSpinBox = dlg.findChild(QDoubleSpinBox)
|
||||
chk: QCheckBox = dlg.findChild(QCheckBox)
|
||||
btns: QDialogButtonBox = dlg.findChild(QDialogButtonBox)
|
||||
|
||||
# # Interact with widgets
|
||||
spin.setValue(5)
|
||||
chk.setChecked(True)
|
||||
|
||||
yes_btn = btns.button(QDialogButtonBox.Yes)
|
||||
yes_btn.click()
|
||||
|
||||
# Schedule the handler right before invoking the check
|
||||
QTimer.singleShot(0, _open_dialog_and_click(handler))
|
||||
|
||||
accepted = wf._check_dataset_size_and_confirm(big_dataset, "waveform_waveform")
|
||||
assert accepted is True
|
||||
assert wf.max_dataset_size_mb == 5
|
||||
assert wf.skip_large_dataset_warning is True
|
||||
|
||||
|
||||
def test_dialog_reject_real_interaction(qtbot, mocked_client):
|
||||
"""
|
||||
End‑to‑end: user leaves spinner unchanged, ticks 'don't show again',
|
||||
and presses NO.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.max_dataset_size_mb = 1
|
||||
|
||||
big_dataset = _dummy_dataset(mem_bytes=4_800_000)
|
||||
|
||||
def handler(dlg):
|
||||
chk: QCheckBox = dlg.findChild(QCheckBox)
|
||||
btns: QDialogButtonBox = dlg.findChild(QDialogButtonBox)
|
||||
|
||||
chk.setChecked(True)
|
||||
no_btn = btns.button(QDialogButtonBox.No)
|
||||
no_btn.click()
|
||||
|
||||
QTimer.singleShot(0, _open_dialog_and_click(handler))
|
||||
|
||||
accepted = wf._check_dataset_size_and_confirm(big_dataset, "waveform_waveform")
|
||||
assert accepted is False
|
||||
assert wf.skip_large_dataset_warning is True
|
||||
# Limit remains unchanged
|
||||
assert wf.max_dataset_size_mb == 1
|
||||
|
||||
@@ -23,7 +23,4 @@ def test_website_widget_set_url(website_widget):
|
||||
|
||||
website_widget.set_url("https://google.com")
|
||||
website_widget.wait_until_loaded()
|
||||
# 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/")
|
||||
assert website_widget.get_url() == "https://www.google.com/"
|
||||
|
||||
Reference in New Issue
Block a user