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

Compare commits

..

2 Commits

99 changed files with 2573 additions and 8004 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ class FakeDevice(BECDevice):
"readoutPriority": "baseline",
"deviceClass": "ophyd.Device",
"deviceConfig": {},
"deviceTags": {"user device"},
"deviceTags": ["user device"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
@@ -89,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),

View File

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

View File

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

View File

@@ -312,7 +312,7 @@ class Crosshair(QObject):
y_values[name] = closest_y
x_values[name] = closest_x
elif isinstance(item, pg.ImageItem): # 2D plot
name = item.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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
# delaybeforescroll timer (singleshot)
self._delay_timer = QTimer(self)
self._delay_timer.setSingleShot(True)
self._delay_timer.setInterval(delay_ms)
self._delay_timer.timeout.connect(self._timer.start)
self._step_px = step_px
def setText(self, text):
"""
Overridden to ensure that new text replaces the current one
immediately.
If the label was already scrolling (or in its delay phase),
the next message starts **without** the extra delay.
"""
# Determine whether the widget was already in a scrolling cycle
was_scrolling = self._timer.isActive() or self._delay_timer.isActive()
super().setText(text)
fm = QFontMetrics(self.font())
self._text_width = fm.horizontalAdvance(text)
self._offset = 0
# Skip the delay when we were already scrolling
self._update_timer(skip_delay=was_scrolling)
def resizeEvent(self, event):
super().resizeEvent(event)
self._update_timer()
def _update_timer(self, *, skip_delay: bool = False):
"""
Decide whether to start or stop scrolling.
If the text is wider than the visible area, start a singleshot
delay timer (2s by default). Scrolling begins only after this
delay. Any change (resize or new text) restarts the logic.
"""
needs_scroll = self._text_width > self.width()
if needs_scroll:
# Reset any running timers
if self._timer.isActive():
self._timer.stop()
if self._delay_timer.isActive():
self._delay_timer.stop()
self._offset = 0
# Start scrolling immediately when we should skip the delay,
# otherwise apply the configured delay_ms interval
if skip_delay:
self._timer.start()
else:
self._delay_timer.start()
else:
if self._delay_timer.isActive():
self._delay_timer.stop()
if self._timer.isActive():
self._timer.stop()
self.update()
def _scroll(self):
self._offset += self._step_px
if self._offset >= self._text_width:
self._offset = 0
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.TextAntialiasing)
text = self.text()
if not text:
return
fm = QFontMetrics(self.font())
y = (self.height() + fm.ascent() - fm.descent()) // 2
if self._text_width <= self.width():
painter.drawText(0, y, text)
else:
x = -self._offset
gap = 50 # space between repeating text blocks
while x < self.width():
painter.drawText(x, y, text)
x += self._text_width + gap
def cleanup(self):
"""Stop all timers to prevent memory leaks."""
if self._timer.isActive():
self._timer.stop()
if self._delay_timer.isActive():
self._delay_timer.stop()

View File

@@ -1,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: AppID label
self._app_id_label = QLabel()
self._app_id_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
self.status_bar.addWidget(self._app_id_label)
# Add a separator after the app ID label
self._add_separator()
# Centre: Clientinfo label (stretch=1 so it expands)
self._add_client_info_label()
# Add scan_progress bar with display logic
self._add_scan_progress_bar()
################################################################################
# Client message status bar widget helpers
def _add_client_info_label(self):
"""
Add a client info label to the status bar.
This label will display messages from the BEC dispatcher.
"""
# Scroll label for client info in Status Bar
self._client_info_label = ScrollLabel(self)
self._client_info_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
# Full label used in the hover widget
self._client_info_label_full = QLabel(self)
self._client_info_label_full.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
# Hover widget to show the full client info label
self._client_info_hover = HoverWidget(
self, simple=self._client_info_label, full=self._client_info_label_full
)
self.status_bar.addWidget(self._client_info_hover, 1)
# Timer to automatically clear client messages once they expire
self._client_info_expire_timer = QTimer(self)
self._client_info_expire_timer.setSingleShot(True)
self._client_info_expire_timer.timeout.connect(lambda: self._client_info_label.setText(""))
self._client_info_expire_timer.timeout.connect(
lambda: self._client_info_label_full.setText("")
)
################################################################################
# Progressbar helpers
def _add_scan_progress_bar(self):
# Setting HoverWidget for the scan progress bar - minimal and full version
self._scan_progress_bar_simple = ScanProgressBar(self, one_line_design=True)
self._scan_progress_bar_simple.show_elapsed_time = False
self._scan_progress_bar_simple.show_remaining_time = False
self._scan_progress_bar_simple.show_source_label = False
self._scan_progress_bar_simple.progressbar.label_template = ""
self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
self._scan_progress_bar_full = ScanProgressBar(self)
self._scan_progress_hover = HoverWidget(
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
)
# Bundle the progress bar with a separator
separator = self._add_separator(separate_object=True)
self._scan_progress_bar_with_separator = QWidget()
self._scan_progress_bar_with_separator.layout = QHBoxLayout(
self._scan_progress_bar_with_separator
)
self._scan_progress_bar_with_separator.layout.setContentsMargins(0, 0, 0, 0)
self._scan_progress_bar_with_separator.layout.setSpacing(0)
self._scan_progress_bar_with_separator.layout.addWidget(separator)
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
# Set Size
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
self._scan_progress_bar_with_separator.setMaximumWidth(self._scan_progress_bar_target_width)
self.status_bar.addWidget(self._scan_progress_bar_with_separator)
# Visibility logic
self._scan_progress_bar_with_separator.hide()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
# Timer for hiding logic
self._scan_progress_hide_timer = QTimer(self)
self._scan_progress_hide_timer.setSingleShot(True)
self._scan_progress_hide_timer.setInterval(self.STATUS_BAR_WIDGETS_EXPIRE_TIME)
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
# Show / hide behaviour
self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar)
self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar)
def _show_scan_progress_bar(self):
if self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
if self._scan_progress_bar_with_separator.isVisible():
return
# Make visible and reset width
self._scan_progress_bar_with_separator.show()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
self._show_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._show_container_anim.setDuration(300)
self._show_container_anim.setStartValue(0)
self._show_container_anim.setEndValue(self._scan_progress_bar_target_width)
self._show_container_anim.setEasingCurve(QEasingCurve.OutCubic)
self._show_container_anim.start()
def _delay_hide_scan_progress_bar(self):
"""Start the countdown to hide the scan progress bar."""
if hasattr(self, "_scan_progress_hide_timer"):
self._scan_progress_hide_timer.start()
def _animate_hide_scan_progress_bar(self):
"""Shrink container to the right, then hide."""
self._hide_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._hide_container_anim.setDuration(300)
self._hide_container_anim.setStartValue(self._scan_progress_bar_with_separator.width())
self._hide_container_anim.setEndValue(0)
self._hide_container_anim.setEasingCurve(QEasingCurve.InCubic)
self._hide_container_anim.finished.connect(self._scan_progress_bar_with_separator.hide)
self._hide_container_anim.start()
def _add_separator(self, separate_object: bool = False) -> QWidget | None:
"""
Add a vertically centred separator to the status bar or just return it as a separate object.
"""
status_bar = self.statusBar()
# The actual line
line = QFrame()
line.setFrameShape(QFrame.VLine)
line.setFrameShadow(QFrame.Sunken)
line.setFixedHeight(status_bar.sizeHint().height() - 2)
# Wrapper to center the line vertically -> work around for QFrame not being able to center itself
wrapper = QWidget()
vbox = QVBoxLayout(wrapper)
vbox.setContentsMargins(0, 0, 0, 0)
vbox.addStretch()
vbox.addWidget(line, alignment=Qt.AlignHCenter)
vbox.addStretch()
wrapper.setFixedWidth(line.sizeHint().width())
if separate_object:
return wrapper
status_bar.addWidget(wrapper)
def _init_bec_icon(self):
icon = self.app.windowIcon()
if icon.isNull():
print("No icon is set, setting default icon")
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.app.setWindowIcon(icon)
else:
print("An icon is set")
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
def display_app_id(self):
"""
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
else:
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self.statusBar().showMessage(status_message)
def _fetch_theme(self) -> str:
return self.app.theme.theme
@@ -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())

View File

@@ -6,10 +6,10 @@ from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger
from pydantic import field_validator
from qtpy.QtCore import Property, Signal, Slot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.widget_io import WidgetIO
@@ -100,7 +100,7 @@ class DeviceInputBase(BECWidget):
### QtSlots ###
@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

View File

@@ -6,7 +6,7 @@ from qtpy.QtCore import Property
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO, 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()

View File

@@ -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 previewsignal pseudodevices (labels like
``"eiger_preview"``) are accepted as valid choices.
The validation run only on device not on the previewsignal.
Args:
device: The text currently entered/selected.
Returns:
True if the device is a genuine BEC device *or* one of the
whitelisted previewsignal entries.
"""
idx = self.findText(device)
if idx >= 0 and isinstance(self.itemData(idx), tuple):
device = self.itemData(idx)[0] # type: ignore[assignment]
return super().validate_device(device)
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 previewsignal devices in the
format '<device>_<signal>' and store the tuple(device, signal) in
the item's userData for later use.
"""
preview_signals = self.target_widget.client.device_manager.get_bec_signals("PreviewSignal")
for device, signal, signal_config in preview_signals:
label = signal_config.get("obj_name", f"{device}_{signal}")
self.device_combo_box.addItem(label, (device, signal, signal_config))
def _reverse_device_items(self) -> None:
"""
Reverse the current order of items in the device combo box while
keeping their userData and restoring the previous selection.
"""
current_text = self.device_combo_box.currentText()
items = [
(self.device_combo_box.itemText(i), self.device_combo_box.itemData(i))
for i in range(self.device_combo_box.count())
]
self.device_combo_box.clear()
for text, data in reversed(items):
self.device_combo_box.addItem(text, data)
if current_text:
self.device_combo_box.setCurrentText(current_text)
# 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)

View File

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

View File

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

View File

@@ -2,12 +2,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import (
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QSizePolicy,
QVBoxLayout,
QWidget,
@@ -15,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()

View File

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

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import json
from typing import Any, Literal
from typing import Literal
import lmfit
import numpy as np
@@ -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
# Largedataset 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")

View File

@@ -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)
# Cornerrounding: base radius in pixels (autoreduced if bar is small)
self._corner_radius = 10
# Progressbar state handling
self._state = ProgressState.NORMAL
self._state_colors = dict(PROGRESS_STATE_COLORS)
# layout settings
self._padding_left_right = 10
self._value_animation = QPropertyAnimation(self, b"_progressbar_value")
self._value_animation.setDuration(200)
self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
# label on top of the progress bar
self.center_label = QLabel(self)
self.center_label.setAlignment(Qt.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 (autoscaled down on small bars).")
def corner_radius(self) -> float:
return self._corner_radius
@corner_radius.setter
def corner_radius(self, radius: float):
self._corner_radius = max(0.0, radius)
self.update()
@SafeProperty(float)
def padding_left_right(self) -> float:
return self._padding_left_right
@padding_left_right.setter
def padding_left_right(self, padding: float):
self._padding_left_right = padding
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect().adjusted(self._padding_left_right, 0, -self._padding_left_right, -1)
# Corner radius adapts to widget height so it never exceeds half the bars thickness
radius = min(self._corner_radius, rect.height() / 2)
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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -79,7 +79,7 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
gui = connected_client_gui_obj
dock_area = gui.bec
# Number of top level widgets, should be 4
top_level_widgets_count = 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

View File

@@ -69,7 +69,7 @@ def test_scan_metadata_for_custom_scan(
def do_test():
# Set the metadata
grid: QGridLayout = scan_control._metadata_form._form_grid.layout()
for i in range(grid.rowCount() - 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

@@ -93,7 +93,7 @@ def test_curve_setting_switch_device_mode(curve_setting_fixture, qtbot):
assert curve_setting.device_x.isEnabled()
# This line edit should reflect the waveform.x_axis_mode["name"], or be blank if none
assert curve_setting.device_x.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"]
##################################################

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -120,11 +120,11 @@ def test_roi_name_edit(roi_tree, image_widget, qtbot):
roi_tree.tree.editItem(item, roi_tree.COL_ROI)
qtbot.keyClicks(roi_tree.tree.viewport().focusWidget(), "new_name")
qtbot.keyClick(roi_tree.tree.viewport().focusWidget(), Qt.Key_Return)
qtbot.wait(200)
# Check the ROI name was updated
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)

View File

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

View File

@@ -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
##############################################
# Previewsignal update mechanism
def test_image_setup_preview_signal_1d(qtbot, mocked_client, monkeypatch):
"""
Ensure that calling .image() with a (device, signal, config) tuple representing
a 1D PreviewSignal connects using the 1D path and updates correctly.
"""
import numpy as np
view = create_widget(qtbot, Image, client=mocked_client)
signal_config = {
"obj_name": "waveform1d_img",
"signal_class": "PreviewSignal",
"describe": {"signal_info": {"ndim": 1}},
}
# Set the image monitor to the preview signal
view.image(monitor=("waveform1d", "img", signal_config))
# Subscriptions should indicate 1D preview connection
sub = view.subscriptions["main"]
assert sub.source == "device_monitor_1d"
assert sub.monitor_type == "1d"
assert sub.monitor == ("waveform1d", "img", signal_config)
# Simulate a waveform update from the dispatcher
waveform = np.arange(25, dtype=float)
view.on_image_update_1d({"data": waveform}, {"scan_id": "scan_test"})
assert view.main_image.raw_data.shape == (1, 25)
np.testing.assert_array_equal(view.main_image.raw_data[0], waveform)
def test_image_setup_preview_signal_2d(qtbot, mocked_client, monkeypatch):
"""
Ensure that calling .image() with a (device, signal, config) tuple representing
a 2D PreviewSignal connects using the 2D path and updates correctly.
"""
import numpy as np
view = create_widget(qtbot, Image, client=mocked_client)
signal_config = {
"obj_name": "eiger_img2d",
"signal_class": "PreviewSignal",
"describe": {"signal_info": {"ndim": 2}},
}
# Set the image monitor to the preview signal
view.image(monitor=("eiger", "img2d", signal_config))
# Subscriptions should indicate 2D preview connection
sub = view.subscriptions["main"]
assert sub.source == "device_monitor_2d"
assert sub.monitor_type == "2d"
assert sub.monitor == ("eiger", "img2d", signal_config)
# Simulate a 2D image update
test_data = np.arange(16, dtype=float).reshape(4, 4)
view.on_image_update_2d({"data": test_data}, {})
np.testing.assert_array_equal(view.main_image.image, test_data)
##############################################
# Device monitor endpoint update mechanism
def test_image_setup_image_2d(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.image(monitor="eiger", monitor_type="2d")
assert bec_image_view.monitor == "eiger"
assert bec_image_view.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 combobox while preserving the current selection.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
combo = bundle.device_combo_box
# Replace existing items with a deterministic list
combo.clear()
combo.addItem("samx", 1)
combo.addItem("samy", 2)
combo.addItem("samz", 3)
combo.setCurrentText("samy")
# Reverse the items
bundle._reverse_device_items()
# Order should be reversed and selection preserved
assert [combo.itemText(i) for i in range(combo.count())] == ["samz", "samy", "samx"]
assert combo.currentText() == "samy"
def test_monitor_selection_populate_preview_signals(qtbot, mocked_client, monkeypatch):
"""
Verify that _populate_preview_signals adds previewsignal devices to the combobox
with the correct userData.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
# Provide a deterministic fake device_manager with get_bec_signals
class _FakeDM:
def get_bec_signals(self, _filter):
return [
("eiger", "img", {"obj_name": "eiger_img"}),
("async_device", "img2", {"obj_name": "async_device_img2"}),
]
monkeypatch.setattr(view.client, "device_manager", _FakeDM())
initial_count = bundle.device_combo_box.count()
bundle._populate_preview_signals()
# Two new entries should have been added
assert bundle.device_combo_box.count() == initial_count + 2
# The first newly added item should carry tuple userData describing the device/signal
data = bundle.device_combo_box.itemData(initial_count)
assert isinstance(data, tuple) and data[0] == "eiger"
def test_monitor_selection_adjust_and_connect(qtbot, mocked_client, monkeypatch):
"""
Verify that _adjust_and_connect performs the full setup:
fills the combobox with preview signals,
reverses their order,
and resets the currentText to an empty string.
"""
view = create_widget(qtbot, Image, client=mocked_client)
bundle = view.selection_bundle
# Deterministic fake device_manager
class _FakeDM:
def get_bec_signals(self, _filter):
return [("eiger", "img", {"obj_name": "eiger_img"})]
monkeypatch.setattr(view.client, "device_manager", _FakeDM())
combo = bundle.device_combo_box
# Start from a clean state
combo.clear()
combo.addItem("", None)
combo.setCurrentText("")
# Execute the method under test
bundle._adjust_and_connect()
# Expect exactly two items: preview label followed by the empty default
assert combo.count() == 2
# Because of the reversal, the preview label comes first
assert combo.itemText(0) == "eiger_img"
# Current selection remains empty
assert combo.currentText() == ""

View File

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

View File

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

View File

@@ -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 clientinfo label.
"""
test_msg = "Client connected successfully"
bec_main_window.display_client_message({"message": test_msg}, {})
qtbot.wait(200)
assert bec_main_window._client_info_label.text() == test_msg
def test_status_bar_has_separator(bec_main_window):
"""Ensure the status bar contains at least one vertical separator."""
status_bar = bec_main_window.statusBar()
separators = [w for w in status_bar.findChildren(QFrame) if w.frameShape() == QFrame.VLine]
assert separators, "Expected at least one QFrame separator in the status bar."
#################################################################
# Tests for BECMainWindow Addons
#################################################################
#################################################################
# Tests for ScrollLabel behaviour
def test_scroll_label_does_not_scroll_when_text_fits(qtbot):
"""Label with short text should not activate scrolling timer."""
lbl = create_widget(qtbot, ScrollLabel) # shorten delay for test speed
qtbot.addWidget(lbl)
lbl.resize(200, 20)
lbl.setText("Short text")
# Process events to allow timer logic to run
qtbot.wait(200)
assert not lbl._timer.isActive()
assert not lbl._delay_timer.isActive()
def test_scroll_label_starts_scrolling(qtbot):
"""Label with long text should start _delay_timer; later _timer becomes active."""
lbl = create_widget(qtbot, ScrollLabel, delay_ms=100)
lbl.resize(150, 20)
long_text = "This is a very long piece of text that should definitely overflow the label width"
lbl.setText(long_text)
# Immediately after setText, only delaytimer should be active
assert lbl._delay_timer.isActive()
assert not lbl._timer.isActive()
# Wait until scrolling timer becomes active
qtbot.waitUntil(lambda: lbl._timer.isActive(), timeout=2000)
assert lbl._timer.isActive()
def test_scroll_label_scroll_method(qtbot):
"""Directly exercise _scroll to ensure offset advances and paintEvent is invoked."""
lbl = create_widget(qtbot, ScrollLabel, step_px=5) # shorten delay for test speed
qtbot.addWidget(lbl)
lbl.resize(120, 20)
lbl.setText("x" * 200) # long text to guarantee overflow
qtbot.wait(200) # let timers configure themselves
# Capture current offset and force a manual scroll tick
old_offset = lbl._offset
lbl._scroll()
assert lbl._offset == old_offset + 5
def test_scroll_label_paint_event(qtbot):
"""
Grab the widget as a pixmap; this calls paintEvent under the hood
and ensures no exceptions occur during rendering.
"""
lbl = create_widget(qtbot, ScrollLabel) # shorten delay for test speed
qtbot.addWidget(lbl)
lbl.resize(180, 20)
lbl.setText("Rendering check")
lbl.show()
qtbot.wait(200) # allow Qt to schedule a paint
pixmap = lbl.grab()
assert not pixmap.isNull()
def test_display_client_message_with_expiration(qtbot, bec_main_window):
"""
A message with a finite 'expire' value should disappear once the timer
fires.
"""
test_msg = "This message should vanish fast"
expire_sec = 0.2
bec_main_window.display_client_message({"message": test_msg, "expire": expire_sec}, {})
assert bec_main_window._client_info_expire_timer.isActive()
assert bec_main_window._client_info_label.text() == test_msg
qtbot.waitUntil(lambda: not bec_main_window._client_info_expire_timer.isActive(), timeout=1000)
assert bec_main_window._client_info_label.text() == ""
def test_display_client_message_no_expiration(qtbot, bec_main_window):
"""
A message with 'expire' == 0 must persist and never start the timer.
"""
test_msg = "Persistent status message"
bec_main_window.display_client_message({"message": test_msg, "expire": 0}, {})
assert not bec_main_window._client_info_expire_timer.isActive()
assert bec_main_window._client_info_label.text() == test_msg
qtbot.wait(500)
assert bec_main_window._client_info_label.text() == test_msg
def test_display_client_message_overwrite_resets_timer(qtbot, bec_main_window):
"""
Sending a second message while the expiration timer is active should
overwrite the first and stop the timer if the second one is persistent.
"""
first_msg = "First (temporary)"
second_msg = "Second (persistent)"
bec_main_window.display_client_message({"message": first_msg, "expire": 0.3}, {})
qtbot.wait(200)
assert bec_main_window._client_info_expire_timer.isActive()
bec_main_window.display_client_message({"message": second_msg, "expire": 0}, {})
assert not bec_main_window._client_info_expire_timer.isActive()
assert bec_main_window._client_info_label.text() == second_msg
qtbot.wait(400)
assert bec_main_window._client_info_label.text() == second_msg
#################################################################
# Tests for BECWebLinksMixin (webbrowser opening)
def test_bec_weblinks(monkeypatch):
opened_urls = []
def fake_open(url):
opened_urls.append(url)
monkeypatch.setattr(webbrowser, "open", fake_open)
BECWebLinksMixin.open_bec_docs()
BECWebLinksMixin.open_bec_widgets_docs()
BECWebLinksMixin.open_bec_bug_report()
assert opened_urls == [
"https://beamline-experiment-control.readthedocs.io/en/latest/",
"https://bec.readthedocs.io/projects/bec-widgets/en/latest/",
"https://gitlab.psi.ch/groups/bec/-/issues/",
]
#################################################################
# Tests for scanprogress bar animations
def test_scan_progress_bar_show_animation(qtbot, bec_main_window):
"""
_show_scan_progress_bar should animate the container's maximumWidth
from 0 to the configured target width.
"""
container = bec_main_window._scan_progress_bar_with_separator
# Precondition: collapsed
assert container.maximumWidth() == 0
bec_main_window._show_scan_progress_bar()
target = bec_main_window._scan_progress_bar_target_width
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
assert container.maximumWidth() == target
def test_scan_progress_bar_hide_animation(qtbot, bec_main_window):
"""
_animate_hide_scan_progress_bar should collapse the container back to 0 width.
"""
container = bec_main_window._scan_progress_bar_with_separator
# First expand it
bec_main_window._show_scan_progress_bar()
target = bec_main_window._scan_progress_bar_target_width
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
# Trigger hide animation
bec_main_window._animate_hide_scan_progress_bar()
qtbot.waitUntil(lambda: container.maximumWidth() == 0, timeout=2000)
assert container.maximumWidth() == 0
#################################################################
# Tests for hover widget and tooltip behaviour
def test_hover_widget_tooltip(qtbot):
"""
After a HoverWidget is closed, its WidgetTooltip must be gone.
"""
simple = QLabel("Hover me")
full = QLabel("Full details")
hover = create_widget(qtbot, HoverWidget, simple=simple, full=full)
assert hover._simple is simple
assert hover._full is full
assert hover._tooltip is None
def test_widget_tooltip_show_and_hide(qtbot):
"""
WidgetTooltip should appear when show_above is called and hide on Leave.
"""
full_lbl = QLabel("Standalone tooltip content")
tooltip = create_widget(qtbot, WidgetTooltip, content=full_lbl)
# Show above an arbitrary point
pos = QPoint(200, 200)
tooltip.show_above(pos)
assert tooltip.isVisible()
# Send a synthetic Leave event
QApplication.sendEvent(tooltip, QEvent(QEvent.Leave))
qtbot.waitUntil(lambda: not tooltip.isVisible(), timeout=500)
assert not tooltip.isVisible()
def test_hover_widget_mouse_events(qtbot):
"""
Verify that HoverWidget responds correctly to Enter, MouseMove, and Leave
events, keeping the tooltip visible only while the pointer is inside.
"""
simple = QLabel("Hovertarget")
full = QLabel("Fullview")
hover = create_widget(qtbot, HoverWidget, simple=simple, full=full)
local = QPointF(hover.rect().center()) # inside widget
scene = QPointF(hover.mapTo(hover.window(), local.toPoint()))
global_ = QPointF(hover.mapToGlobal(local.toPoint()))
enter_event = QEnterEvent(local, scene, global_)
hover.enterEvent(event=enter_event)
qtbot.wait(200)
assert hover._tooltip is not None
assert hover._tooltip.isVisible()
assert hover._tooltip.content is full

View File

@@ -29,4 +29,4 @@ def test_gui_server_get_service_config(gui_server):
"""
Test that the server is started with the correct arguments.
"""
assert gui_server._get_service_config().config == ServiceConfig().config
assert gui_server._get_service_config().config is ServiceConfig().config

View File

@@ -4,10 +4,10 @@ from unittest.mock import MagicMock, patch
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import AvailableResourceMessage, 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

View File

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

View File

@@ -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 dont want the timer ticking in tests
task._elapsed_time = 10 # simulate 10 s elapsed
# 50 / 100 ⇒ 50 %
assert task.percentage == 50
# speed = value / elapsed = 5 steps / s
assert np.isclose(task.speed, 5)
# remaining steps = 50 ; time_remaining ≈ 10 s
assert task.remaining == 50
assert task.time_remaining == "00:00:10"
# time_elapsed formatting
assert task.time_elapsed == "00:00:10"
def test_scan_progressbar_initialization(scan_progressbar):
assert isinstance(scan_progressbar, ScanProgressBar)
assert isinstance(scan_progressbar.progressbar, BECProgressBar)
def test_update_labels_content(scan_progressbar):
"""update_labels() reflects ProgressTask time strings on the UI."""
# fabricate a task with known timings
task = ProgressTask(parent=scan_progressbar, value=30, max_value=100, done=False)
task.timer.stop()
task._elapsed_time = 50
scan_progressbar.task = task
scan_progressbar.update_labels()
assert scan_progressbar.ui.elapsed_time_label.text() == "00:00:50"
assert scan_progressbar.ui.remaining_time_label.text() == "00:01:57"
def test_on_progress_update(qtbot, scan_progressbar):
"""
on_progress_update() should forward new values to the embedded
BECProgressBar and keep ProgressTask in sync.
"""
task = ProgressTask(parent=scan_progressbar, value=0, max_value=100, done=False)
task.timer.stop()
scan_progressbar.task = task
msg = {"value": 20, "max_value": 100, "done": False}
scan_progressbar.on_progress_update(msg, metadata={"status": "open"})
qtbot.wait(200)
bar = scan_progressbar.progressbar
assert bar._user_value == 20
assert bar._user_maximum == 100
# state reflects BEC status
assert bar.state is ProgressState.NORMAL
@pytest.mark.parametrize(
"status, value, max_val, expected_state",
[
("open", 10, 100, ProgressState.NORMAL),
("paused", 25, 100, ProgressState.PAUSED),
("aborted", 30, 100, ProgressState.INTERRUPTED),
("halted", 40, 100, ProgressState.PAUSED),
("closed", 100, 100, ProgressState.COMPLETED),
],
)
def test_state_mapping_during_updates(
qtbot, scan_progressbar, status, value, max_val, expected_state
):
"""ScanProgressBar should translate BEC status → ProgressState consistently."""
task = ProgressTask(parent=scan_progressbar, value=0, max_value=max_val, done=False)
task.timer.stop()
scan_progressbar.task = task
scan_progressbar.on_progress_update(
{"value": value, "max_value": max_val, "done": status == "closed"},
metadata={"status": status},
)
assert scan_progressbar.progressbar.state is expected_state
def test_source_label_updates(scan_progressbar):
"""update_source_label() renders correct text for both progress sources."""
# device progress
scan_progressbar.update_source_label(ProgressSource.DEVICE_PROGRESS, device="motor")
assert scan_progressbar.ui.source_label.text() == "Device motor"
# scan progress (needs a scan_number for deterministic text)
scan_progressbar.scan_number = 5
scan_progressbar.update_source_label(ProgressSource.SCAN_PROGRESS)
assert scan_progressbar.ui.source_label.text() == "Scan 5"
def test_set_progress_source_connections(scan_progressbar, monkeypatch):
""" """
from bec_lib.endpoints import MessageEndpoints
connect_calls = []
disconnect_calls = []
def fake_connect(slot, endpoint):
connect_calls.append(endpoint)
def fake_disconnect(slot, endpoint):
disconnect_calls.append(endpoint)
# Patch dispatcher methods
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "connect_slot", fake_connect)
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "disconnect_slot", fake_disconnect)
# switch to SCAN_PROGRESS
scan_progressbar.scan_number = 7
scan_progressbar.set_progress_source(ProgressSource.SCAN_PROGRESS)
assert scan_progressbar._progress_source == ProgressSource.SCAN_PROGRESS
assert scan_progressbar.ui.source_label.text() == "Scan 7"
assert connect_calls[-1] == MessageEndpoints.scan_progress()
assert disconnect_calls == []
# switch to DEVICE_PROGRESS
device = "motor"
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
assert scan_progressbar._progress_source == ProgressSource.DEVICE_PROGRESS
assert scan_progressbar.ui.source_label.text() == f"Device {device}"
assert connect_calls[-1] == MessageEndpoints.device_progress(device=device)
assert disconnect_calls == [MessageEndpoints.scan_progress()]
# calling again with the SAME source should not add new connect calls
prev_connect_count = len(connect_calls)
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
assert len(connect_calls) == prev_connect_count, "No extra connect made for same source"
def test_progressbar_queue_update(scan_progressbar):
"""
Test that an empty queue update does not change the progress source.
"""
msg = messages.ScanQueueStatusMessage(queue={"primary": {"info": [], "status": "RUNNING"}})
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_not_called()
def test_progressbar_queue_update_with_scan(scan_progressbar):
"""
Test that a queue update with a scan changes the progress source to SCAN_PROGRESS.
"""
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": {
"info": [
{
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
"status": "RUNNING",
"active_request_block": {
"msg": messages.ScanQueueMessage(
metadata={
"file_suffix": None,
"file_directory": None,
"user_metadata": {"sample_name": ""},
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
},
scan_type="line_scan",
parameter={
"args": {"samx": [-10.0, 10.0]},
"kwargs": {
"steps": 20,
"relative": False,
"exp_time": 0.1,
"burst_at_each_point": 1,
"system_config": {
"file_suffix": None,
"file_directory": None,
},
},
},
queue="primary",
),
"scan_number": 1,
"report_instructions": [{"scan_progress": 20}],
},
}
],
"status": "RUNNING",
}
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_called_once_with(ProgressSource.SCAN_PROGRESS)
def test_progressbar_queue_update_with_device(scan_progressbar):
"""
Test that a queue update with a device changes the progress source to DEVICE_PROGRESS.
"""
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": {
"info": [
{
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
"status": "RUNNING",
"active_request_block": {
"msg": messages.ScanQueueMessage(
metadata={
"file_suffix": None,
"file_directory": None,
"user_metadata": {"sample_name": ""},
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
},
scan_type="line_scan",
parameter={
"args": {"samx": [-10.0, 10.0]},
"kwargs": {
"steps": 20,
"relative": False,
"exp_time": 0.1,
"burst_at_each_point": 1,
"system_config": {
"file_suffix": None,
"file_directory": None,
},
},
},
queue="primary",
),
"scan_number": 1,
"report_instructions": [{"device_progress": ["samx"]}],
},
}
],
"status": "RUNNING",
}
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_called_once_with(ProgressSource.DEVICE_PROGRESS, device="samx")
def test_progressbar_queue_update_with_no_scan_or_device(scan_progressbar):
"""
Test that a queue update with neither scan nor device does not change the progress source.
"""
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": {
"info": [
{
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
"status": "RUNNING",
"active_request_block": {
"msg": messages.ScanQueueMessage(
metadata={
"file_suffix": None,
"file_directory": None,
"user_metadata": {"sample_name": ""},
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
},
scan_type="line_scan",
parameter={
"args": {"samx": [-10.0, 10.0]},
"kwargs": {
"steps": 20,
"relative": False,
"exp_time": 0.1,
"burst_at_each_point": 1,
"system_config": {
"file_suffix": None,
"file_directory": None,
},
},
},
queue="primary",
),
"scan_number": 1,
},
}
],
"status": "RUNNING",
}
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_not_called()

View File

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

View File

@@ -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 monkeypatching)
##################################################
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):
"""
Endtoend: 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.6MiB)
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):
"""
Endtoend: 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

View File

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