mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-10 10:40:55 +02:00
Compare commits
155 Commits
main
...
feature/fe
| Author | SHA1 | Date | |
|---|---|---|---|
| d99d5e1370 | |||
| 402c721279 | |||
| 6883910797 | |||
| 7de228a412 | |||
| c998e3ec48 | |||
| 1e3661c318 | |||
| 007a408e1a | |||
| 1534118f21 | |||
| 572797626c | |||
| 40a666aa18 | |||
| 577ca4301a | |||
| df4082b31b | |||
| aadb3e129a | |||
| 0580b539fa | |||
| b79c4862c5 | |||
| 148b41e238 | |||
| 6e398e8077 | |||
| 8d75c2af1c | |||
| 24dbb885f6 | |||
| 3b7bad85d3 | |||
| de09cc660a | |||
| 4bb8e86509 | |||
| e5b76bc855 | |||
| 99176198ee | |||
| dcfc573052 | |||
| 9290a9a23b | |||
| d48b9d224f | |||
| 43c311782d | |||
| 44f7acaeda | |||
| 0b212c3100 | |||
| d8ebae49ad | |||
| 153fb62a04 | |||
| d67227d20c | |||
| dc1072c247 | |||
| beb337201c | |||
| 75162ef8a8 | |||
| cc89252fb3 | |||
| 36fa0e649c | |||
| 8e173cb17e | |||
| 322655fc5e | |||
| 2b5b7360ae | |||
| b325d1bb4f | |||
| ee6fd5fb9e | |||
| 53fe1ac63d | |||
| 58e57169e8 | |||
| 2b27faf779 | |||
| b1a3403cd3 | |||
| b38d6dc549 | |||
| cc45fed387 | |||
| 5a594925f0 | |||
| e76dea6f69 | |||
| f4c14d66db | |||
| 4ef1344fec | |||
| 5e63814afe | |||
| 6be6dafd7d | |||
| fd1edf8177 | |||
| 8102f31956 | |||
| f9b92dacc3 | |||
| a219de11c1 | |||
| 45e9f03093 | |||
| 48e2a97ece | |||
| 953760c828 | |||
| dc3129357b | |||
| 12746ae4aa | |||
| 7e9cc20e59 | |||
| 5209f4c210 | |||
| 5f30ab5aa2 | |||
| 3926c5c947 | |||
| f71c8c882f | |||
| 04a30ea04c | |||
| cbdeae15a1 | |||
| 6aa33cacfa | |||
| 73cfe8da4c | |||
| 0467d88010 | |||
| c41ef4401d | |||
| 4f2a840c21 | |||
| 91050e88ae | |||
| 028efed5bc | |||
| 80f2ca40cb | |||
| 7c32d47f52 | |||
| bf7299c31e | |||
| f3470b409d | |||
| 3486dd4e44 | |||
| 46fe5498b5 | |||
| e94ce73950 | |||
| 3cc469a3d1 | |||
| b4e1a7927d | |||
| 84950cc651 | |||
| 24cc8c7b98 | |||
| 2132ace01b | |||
| 67650b96a2 | |||
| 6b1d2958c3 | |||
| dab1defc76 | |||
| c02f509867 | |||
| b585a608c7 | |||
| 21862e8021 | |||
| 15ac1c0182 | |||
| da23a47213 | |||
| 1bb0f1a855 | |||
| f121d09baa | |||
| dd7a5e11df | |||
| 2d4eabead0 | |||
| e607d34337 | |||
| 4a2bc9fcd9 | |||
| 2ffe269727 | |||
| de5773662a | |||
| 53b50e3420 | |||
| b16f88b217 | |||
| 063e5d064c | |||
| c354a9b249 | |||
| caa4e449e4 | |||
| afc8c4733e | |||
| a00024c66f | |||
| 5c18b291b5 | |||
| 08dde431a6 | |||
| 7daa25d7c1 | |||
| 8842eb617a | |||
| 1d0634e142 | |||
| dc6946c924 | |||
| 377bad4854 | |||
| 6cdd813734 | |||
| 3f46f7eb7e | |||
| 73f474c7e7 | |||
| 2dfae4d38f | |||
| f7061baf7b | |||
| 5865d0f97d | |||
| c204815c42 | |||
| af8f3911aa | |||
| 73afb5a472 | |||
| 5836f286de | |||
| 5567274f2d | |||
| 7983a4527a | |||
| 0f63543326 | |||
| 01755aba07 | |||
| b4987fe759 | |||
| b0cb048c81 | |||
| e8c062a48f | |||
| dfe914bb7e | |||
| b66353bf6e | |||
| ead1d38b49 | |||
| b2505c6a56 | |||
| 663c00f1a4 | |||
| 3dd688540e | |||
| 092ac915a8 | |||
| 03015a72a6 | |||
| 7dcaf8fe4c | |||
| 02db6307e4 | |||
| 3a10cac7c8 | |||
| 64fecd16dd | |||
| 76639b3e04 | |||
| a767ee8331 | |||
| 5c33f1a6d4 | |||
| af320d812b | |||
| 5bfb50fdc6 | |||
| 5393a84494 |
2
.github/workflows/end2end-conda.yml
vendored
2
.github/workflows/end2end-conda.yml
vendored
@@ -55,5 +55,5 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-logs
|
||||
path: ./bec/logs/*.log
|
||||
path: ./logs/*.log
|
||||
retention-days: 7
|
||||
|
||||
950
CHANGELOG.md
950
CHANGELOG.md
@@ -1,956 +1,6 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v3.4.2 (2026-04-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Allow admin user to pass deployment group check
|
||||
([`e6c8cd0`](https://github.com/bec-project/bec_widgets/commit/e6c8cd0b1a1162302071c93a2ac51880b3cf1b7d))
|
||||
|
||||
- **bec-atlas-admin-view**: Fix atlas_url to bec-atlas-prod.psi.ch
|
||||
([`242f893`](https://github.com/bec-project/bec_widgets/commit/242f8933b246802f5f3a5b9df7de07901f151c82))
|
||||
|
||||
### Testing
|
||||
|
||||
- Add tests for admin access
|
||||
([`2dab16b`](https://github.com/bec-project/bec_widgets/commit/2dab16b68415806f3f291657f394bb2d8654229d))
|
||||
|
||||
|
||||
## v3.4.1 (2026-04-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **hover_widget**: Make it fancy + mouse tracking
|
||||
([`e25b660`](https://github.com/bec-project/bec_widgets/commit/e25b6604d195804bbd6ea6aac395d44dc00d6155))
|
||||
|
||||
- **ring**: Changed inheritance to BECWidget and added cleanup
|
||||
([`2f75aae`](https://github.com/bec-project/bec_widgets/commit/2f75aaea16a178e180e68d702cd1bdf85a768bcf))
|
||||
|
||||
- **ring**: Hook update hover to update method
|
||||
([`90ecd8e`](https://github.com/bec-project/bec_widgets/commit/90ecd8ea87faf06c3f545e3f9241f403b733d7eb))
|
||||
|
||||
- **ring**: Minor general fixes
|
||||
([`6775509`](https://github.com/bec-project/bec_widgets/commit/677550931b28fbf35fd55880bf6e001f7351b99b))
|
||||
|
||||
- **ring_progress_bar**: Added hover mouse effect
|
||||
([`96b5179`](https://github.com/bec-project/bec_widgets/commit/96b5179658c41fb39df7a40f4d96e82092605791))
|
||||
|
||||
### Testing
|
||||
|
||||
- **ring_progress_bar**: Add unit tests for hover behavior
|
||||
([`6e5f6e7`](https://github.com/bec-project/bec_widgets/commit/6e5f6e7fbb6f9680f6d026e105e6840d24c6591c))
|
||||
|
||||
|
||||
## v3.4.0 (2026-03-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **lmfit_dialog**: Compact layout size policy for better alignment panel UX
|
||||
([`31389a3`](https://github.com/bec-project/bec_widgets/commit/31389a3dd0c7b1c671acdf49ae50b08455f466a7))
|
||||
|
||||
- **waveform**: Alignment panel indicators request autoscale if updated
|
||||
([`a292375`](https://github.com/bec-project/bec_widgets/commit/a2923752c27ad7b9749db3d309fe288747b85acb))
|
||||
|
||||
### Features
|
||||
|
||||
- **waveform**: 1d alignment mode panel
|
||||
([`a486c52`](https://github.com/bec-project/bec_widgets/commit/a486c52058b4edbea00ad7bb018f1fa2822fb9c6))
|
||||
|
||||
|
||||
## v3.3.4 (2026-03-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **lmfit_dialog**: Dialog compact adjustment and cleanup of stale methods
|
||||
([`f67b60a`](https://github.com/bec-project/bec_widgets/commit/f67b60ac98cd87ed8391fee8545eb8064a068e67))
|
||||
|
||||
- **lmfit_dialog**: Fix cpp object deleted
|
||||
([`5ec59d5`](https://github.com/bec-project/bec_widgets/commit/5ec59d5dbb75e3a9deb488b0affaf8cb704242b9))
|
||||
|
||||
- **lmfit_dialog**: Fix fit_curve_id type annotation and remove_dap_data selection behavior
|
||||
([`05c38d9`](https://github.com/bec-project/bec_widgets/commit/05c38d9b82cc6dfaec8f5abf8e0ececa5d001524))
|
||||
|
||||
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
|
||||
|
||||
Agent-Logs-Url:
|
||||
https://github.com/bec-project/bec_widgets/sessions/97395c0e-0271-4cdf-b39f-f3117d21bfa3
|
||||
|
||||
|
||||
## v3.3.3 (2026-03-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **positioner_box**: Remove CompactPopupWidget inheritance
|
||||
([`da400d2`](https://github.com/bec-project/bec_widgets/commit/da400d20b672236241ce3a4480481ac6a5df1b2e))
|
||||
|
||||
|
||||
## v3.3.2 (2026-03-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Typos
|
||||
([`3d29a67`](https://github.com/bec-project/bec_widgets/commit/3d29a67c0b2175f2f29b8e5a7befce55f3d28fd3))
|
||||
|
||||
|
||||
## v3.3.1 (2026-03-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **dap_combobox**: Added safeguard for no DAP models
|
||||
([`79af15a`](https://github.com/bec-project/bec_widgets/commit/79af15a88b993cd5b6bf730796f995f20cf6f188))
|
||||
|
||||
- **dap_combobox**: Rewritten as proper combobox
|
||||
([`90222f3`](https://github.com/bec-project/bec_widgets/commit/90222f30821f822eb24b0179401d4e43050e0156))
|
||||
|
||||
|
||||
## v3.3.0 (2026-03-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix black 2026 formatting
|
||||
([`d4ecefd`](https://github.com/bec-project/bec_widgets/commit/d4ecefd80a6ab944b4da51c1ee35e5dea67770f2))
|
||||
|
||||
- **actions**: Allow minimum icon size for actions in toolbar
|
||||
([`de941d1`](https://github.com/bec-project/bec_widgets/commit/de941d1bc565e444f84696b1de046d50b62f3c1b))
|
||||
|
||||
- **admin-view**: Generate RPC interface for AdminView
|
||||
([`137e572`](https://github.com/bec-project/bec_widgets/commit/137e572a942281a9c7478a6be83f815848917e26))
|
||||
|
||||
- **admin-widget**: Cleanup and minor improvements
|
||||
([`0c6f3f8`](https://github.com/bec-project/bec_widgets/commit/0c6f3f8352e7318a4f0579c83066b5b433fd1144))
|
||||
|
||||
- **admin_view**: Minor changes
|
||||
([`48c9c83`](https://github.com/bec-project/bec_widgets/commit/48c9c83bb0c9432905d347b2d2cf46c05e58c098))
|
||||
|
||||
- **bec-atlas-admin-view**: Fix connect_slot for dispatcher
|
||||
([`23c146b`](https://github.com/bec-project/bec_widgets/commit/23c146b3e6bbbabfb35f1892bc8653a65652ae6a))
|
||||
|
||||
- **login-dialog**: Remove login_dialog
|
||||
([`d1a1d85`](https://github.com/bec-project/bec_widgets/commit/d1a1d85abd3331ebab696580c692c69b71482f37))
|
||||
|
||||
- **main-app**: Fix id for main-app init of AdminView
|
||||
([`b14b046`](https://github.com/bec-project/bec_widgets/commit/b14b04688284eb875ea4469765786834e74fceb3))
|
||||
|
||||
- **main-app**: Skip on_enter/exit hooks if darkmodebutton clicked
|
||||
([`f565deb`](https://github.com/bec-project/bec_widgets/commit/f565deb71db8fa5206fa2b4eea436e5055030bbc))
|
||||
|
||||
- **pyproject**: Add PyJWT as dependency
|
||||
([`889e9c0`](https://github.com/bec-project/bec_widgets/commit/889e9c0994a960b93c93143b6dc5845dc96f9f96))
|
||||
|
||||
- **RPC**: Fix rpc access
|
||||
([`8e53ae2`](https://github.com/bec-project/bec_widgets/commit/8e53ae2d3938e9c0a4c11082300156994447faaf))
|
||||
|
||||
### Features
|
||||
|
||||
- **admin-view**: Add admin view to views
|
||||
([`63059a4`](https://github.com/bec-project/bec_widgets/commit/63059a4ef897a919f296c68ada066e0b228f8248))
|
||||
|
||||
- **bec-atlas-admin-view**: Add http service through QNetworkAccessManager
|
||||
([`1770873`](https://github.com/bec-project/bec_widgets/commit/17708730fcff41713638c17d0cc1f5d9d0b75122))
|
||||
|
||||
- **bec-atlas-admin-view**: Add initial admin view
|
||||
([`ec58fbd`](https://github.com/bec-project/bec_widgets/commit/ec58fbd6d859058f518b88ba15670a3a715c3cc3))
|
||||
|
||||
- **bec-atlas-admin-view**: Add login dilaog
|
||||
([`1384a32`](https://github.com/bec-project/bec_widgets/commit/1384a329abf873b5496e540a542088c7f13b7270))
|
||||
|
||||
- **experiment-selection**: Add experiment selection widget
|
||||
([`598c453`](https://github.com/bec-project/bec_widgets/commit/598c453a1876cebc2482d55bf6c2728ec247def0))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Address review comments
|
||||
([`a7a9458`](https://github.com/bec-project/bec_widgets/commit/a7a9458180c18bf2bba652c2ff8a68875af36a22))
|
||||
|
||||
- Cleanup widgets
|
||||
([`895b318`](https://github.com/bec-project/bec_widgets/commit/895b3189904778c269200365b264a32ff15dda21))
|
||||
|
||||
- Fix formatting, running black 2026.1
|
||||
([`ab223d5`](https://github.com/bec-project/bec_widgets/commit/ab223d5fdc00b1a7bc9fd61abce5fabe4409654b))
|
||||
|
||||
- **admin-view**: Refactor experiment selection, http service, admin view, and add main view
|
||||
([`3a17a24`](https://github.com/bec-project/bec_widgets/commit/3a17a249ed179fb8a11591f948c7b6338e10a60d))
|
||||
|
||||
- **atlas-http-service**: Rename AtlasEndpoints
|
||||
([`2b0f575`](https://github.com/bec-project/bec_widgets/commit/2b0f575733412a96e54dff2dca15082d64caf7ee))
|
||||
|
||||
- **fuzzy-search**: Unify is_match for fuzzy search
|
||||
([`d4afcb6`](https://github.com/bec-project/bec_widgets/commit/d4afcb68324f63ac8aea7cc3b2c82e79d2e643ca))
|
||||
|
||||
### Testing
|
||||
|
||||
- **bec-atlas-admin-view**: Complement tests for BECAtlasAdminView, ExperimentSelection,
|
||||
BECAtlasHTTPService
|
||||
([`df44d9b`](https://github.com/bec-project/bec_widgets/commit/df44d9b50eb289a7851579c64a2a8c0e2363b06a))
|
||||
|
||||
- **bec-atlas-http-service**: Add tests for http service
|
||||
([`34e80ee`](https://github.com/bec-project/bec_widgets/commit/34e80ee8f9a2b2373c97ae7cde90690ab6fb37ce))
|
||||
|
||||
|
||||
## v3.2.4 (2026-03-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **main_app**: Setapplicationname("bec")
|
||||
([`28be696`](https://github.com/bec-project/bec_widgets/commit/28be696f7c7d9762c742c6d5fb5b03867d5e92ea))
|
||||
|
||||
|
||||
## v3.2.3 (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Check adding parent for filesystemmodel
|
||||
([`b9145d7`](https://github.com/bec-project/bec_widgets/commit/b9145d762cdf946f184834928a6404f21b4802a9))
|
||||
|
||||
- Refactor client mock with global fakeredis
|
||||
([`37a5dc2`](https://github.com/bec-project/bec_widgets/commit/37a5dc2e9eeb447d174f4d7087051672f308c84c))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Fix path for uploading logs on failure
|
||||
([`1351fcd`](https://github.com/bec-project/bec_widgets/commit/1351fcd47b909c1a33cb389c096041eb1449e3d3))
|
||||
|
||||
|
||||
## v3.2.2 (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image**: Disconnecting of 2d monitor
|
||||
([`4c9d7fd`](https://github.com/bec-project/bec_widgets/commit/4c9d7fddce7aa5b7f13a00ac332bd54b301e3c28))
|
||||
|
||||
|
||||
## v3.2.1 (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **e2e**: Bec dock rpc fixed synchronization
|
||||
([`e061fa3`](https://github.com/bec-project/bec_widgets/commit/e061fa31a9a5e5c00e44337d7cc52c51d8e259b5))
|
||||
|
||||
- **e2e**: Bec shell excluded from e2e testing
|
||||
([`974f259`](https://github.com/bec-project/bec_widgets/commit/974f25997d68d13ff1063026f9e5c4c8dd4d49f3))
|
||||
|
||||
- **e2e**: Timeout for maybe_remove_dock_area
|
||||
([`718f995`](https://github.com/bec-project/bec_widgets/commit/718f99527c3bebb96845d3305aba69434eb83f77))
|
||||
|
||||
|
||||
## v3.2.0 (2026-03-11)
|
||||
|
||||
### Features
|
||||
|
||||
- **curve, waveform**: Add dap_parameters for lmfit customization in DAP requests
|
||||
([`14d51b8`](https://github.com/bec-project/bec_widgets/commit/14d51b80169f5a060dd24287f3a6db9a4b41275a))
|
||||
|
||||
- **waveform**: Composite DAP with multiple models
|
||||
([`b4f6f5a`](https://github.com/bec-project/bec_widgets/commit/b4f6f5aa8bcd0f6091610e8f839ea265c87575e0))
|
||||
|
||||
|
||||
## v3.1.4 (2026-03-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **profile_utils**: Renamed to fetch widgets settings
|
||||
([`53e5ec4`](https://github.com/bec-project/bec_widgets/commit/53e5ec42b8b33397af777f418fbd8601628226a6))
|
||||
|
||||
### Build System
|
||||
|
||||
- Increased minimal version of bec and bec qthemes
|
||||
([`7e0e391`](https://github.com/bec-project/bec_widgets/commit/7e0e391888f2ee4e8528ccb3938e36da4c32f146))
|
||||
|
||||
|
||||
## v3.1.3 (2026-03-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **monaco_dock**: Optimization, removal of QTimer, eventFilter replaced by signal/slot
|
||||
([`278d8de`](https://github.com/bec-project/bec_widgets/commit/278d8de058c2f5c6c9aa7317e1026651d7a4acd3))
|
||||
|
||||
|
||||
## v3.1.2 (2026-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **dock_area**: Remove old AdvancedDockArea references
|
||||
([`4382d5c`](https://github.com/bec-project/bec_widgets/commit/4382d5c9b1fdac4048692eec53dd43127d67467b))
|
||||
|
||||
### Build System
|
||||
|
||||
- **deps**: Update isort requirement
|
||||
([`8463b32`](https://github.com/bec-project/bec_widgets/commit/8463b327923f853cfa1462bc22be1e83d4fd9a75))
|
||||
|
||||
|
||||
## v3.1.1 (2026-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **positioner box**: Include username in scan queue request
|
||||
([`419c01b`](https://github.com/bec-project/bec_widgets/commit/419c01bdd4e80d927761634b03723319b0a58694))
|
||||
|
||||
### Build System
|
||||
|
||||
- Update min bec dependency to 3.106
|
||||
([`e2daf2e`](https://github.com/bec-project/bec_widgets/commit/e2daf2e89cd25d4dcedd4895299dbbdc6b7e354f))
|
||||
|
||||
- **deps**: Upgrade to black 26
|
||||
([`e157f0d`](https://github.com/bec-project/bec_widgets/commit/e157f0d7c9bb5b4d93f63ebe6f9a715a314aa1f4))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **black**: Black 26 applied
|
||||
([`d4e037f`](https://github.com/bec-project/bec_widgets/commit/d4e037f3384765e7bb8fb020cecbf3db24fc7494))
|
||||
|
||||
### Testing
|
||||
|
||||
- Fix import of bec_lib json extended
|
||||
([`ef12331`](https://github.com/bec-project/bec_widgets/commit/ef1233163cb7c3229630543fe88dbceaccd09297))
|
||||
|
||||
|
||||
## v3.1.0 (2026-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **forms**: Use FieldInfo title for label text in _add_griditem method
|
||||
([`5e34c8a`](https://github.com/bec-project/bec_widgets/commit/5e34c8a3518f24722267b3cde2dd9d3494e350b0))
|
||||
|
||||
- **scan metadata**: Set scan_name to current scan if empty in form data
|
||||
([`72e66cf`](https://github.com/bec-project/bec_widgets/commit/72e66cf57f1d47851728448e2e0f776cd8e278f2))
|
||||
|
||||
### Features
|
||||
|
||||
- **bec_queue**: Add tooltip support for user metadata in queue display
|
||||
([`56f16b6`](https://github.com/bec-project/bec_widgets/commit/56f16b63528b5a50f5a2e2d2e9dd93f3993e50e4))
|
||||
|
||||
- **scan control**: Wrap metadata form in a group box for better organization
|
||||
([`e6b41b4`](https://github.com/bec-project/bec_widgets/commit/e6b41b4e92a1ffd0494c2bde6a782347c2364114))
|
||||
|
||||
- **scan queue**: Add scan name to queue
|
||||
([`ab3efdb`](https://github.com/bec-project/bec_widgets/commit/ab3efdbd0a0a80293ba2121e78ea319ddbbd8f82))
|
||||
|
||||
- **StrFormItem**: Set placeholder text from spec description
|
||||
([`ac824f6`](https://github.com/bec-project/bec_widgets/commit/ac824f6b83178e34f015c296008d7a1e21c70878))
|
||||
|
||||
### Testing
|
||||
|
||||
- Adjust metadata assertions to new schema defaults
|
||||
([`75697f5`](https://github.com/bec-project/bec_widgets/commit/75697f5b1faefb5cfcbc1b753d3f505d69339559))
|
||||
|
||||
- Adjust metadata assertions to new schema defaults
|
||||
([`2697496`](https://github.com/bec-project/bec_widgets/commit/26974965151748c57334f350e21f3b610f92e011))
|
||||
|
||||
|
||||
## v3.0.0 (2026-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 'any' type annotations
|
||||
([`9c4a544`](https://github.com/bec-project/bec_widgets/commit/9c4a54493adc94afe5d43db5e8cbb8d565670af2))
|
||||
|
||||
- Add metadata to scan control export
|
||||
([`17e678b`](https://github.com/bec-project/bec_widgets/commit/17e678b0ad1739490e901f3dbf7180d99c96950c))
|
||||
|
||||
- Address copilot review
|
||||
([`a1a400f`](https://github.com/bec-project/bec_widgets/commit/a1a400f5409213ee1ab2f7cc9f8da7a2b612972d))
|
||||
|
||||
- Adjust ring progress bar to ads
|
||||
([`7fd7f67`](https://github.com/bec-project/bec_widgets/commit/7fd7f67857e23b04759cf23993a99f4701121f95))
|
||||
|
||||
- Don't wait forever
|
||||
([`c1d0e43`](https://github.com/bec-project/bec_widgets/commit/c1d0e435d5dd9965dbafd5bf469327c7f7620cfd))
|
||||
|
||||
- Removal of old BECDock import
|
||||
([`92ae5fc`](https://github.com/bec-project/bec_widgets/commit/92ae5fc7fbf3a55068e2b42d3f66134baeb71766))
|
||||
|
||||
- Remove manual stylesheet deletion/override
|
||||
([`8bbd519`](https://github.com/bec-project/bec_widgets/commit/8bbd519559c857cdc9f51e9507994e7aa4b07af1))
|
||||
|
||||
- Remove singleShots from BECConnector and adjustments of dock area logic
|
||||
([`e26a90c`](https://github.com/bec-project/bec_widgets/commit/e26a90c62fa6c176bf4425867d1cb895a6fad7cd))
|
||||
|
||||
- Sanitize name space util for bec connector and ads
|
||||
([`beca23e`](https://github.com/bec-project/bec_widgets/commit/beca23e14e18445f6ee440e8c55b57f4180a36c9))
|
||||
|
||||
- Tooltip logic and disable button on running scan
|
||||
([`fa56fc8`](https://github.com/bec-project/bec_widgets/commit/fa56fc88026521f6f13690c4ec621c79e318f434))
|
||||
|
||||
- **_OverlayEventFilter**: Fix typo
|
||||
([`a9f92cf`](https://github.com/bec-project/bec_widgets/commit/a9f92cf15547d207a614a1ed08b5d763a569fe59))
|
||||
|
||||
- **advanced_dock_area**: Cli API adjustments docs + names
|
||||
([`6883982`](https://github.com/bec-project/bec_widgets/commit/6883982bf67c5fff02d72fbe39425af39bc3a65e))
|
||||
|
||||
- **advanced_dock_area**: Empty profile is always empty
|
||||
([`aba67d3`](https://github.com/bec-project/bec_widgets/commit/aba67d3129581c85467ddd83211a03ea51c157a3))
|
||||
|
||||
- **advanced_dock_area**: Ensure the general profile exists when launched first time
|
||||
([`7d2760e`](https://github.com/bec-project/bec_widgets/commit/7d2760eab8e5494992adb1452705f58619842d30))
|
||||
|
||||
- **advanced_dock_area**: New profiles are saved with quickselect as default
|
||||
([`0d6b94a`](https://github.com/bec-project/bec_widgets/commit/0d6b94aaecb56e51bdc1ff930079b6c5535798de))
|
||||
|
||||
- **advanced_dock_area**: Profile behaviour adjusted, cleanup of the codebase
|
||||
([`22df7bb`](https://github.com/bec-project/bec_widgets/commit/22df7bb5320c3b1808ab21e6354350838f5acb63))
|
||||
|
||||
- **advanced_dock_area**: Remove all widgets when loading new profiles
|
||||
([`b841cfb`](https://github.com/bec-project/bec_widgets/commit/b841cfbc5f5021c1f9bea03e7fe88713506f66a7))
|
||||
|
||||
- **advanced_dock_area**: Remove widget from dock area by object name
|
||||
([`8f44213`](https://github.com/bec-project/bec_widgets/commit/8f44213ecccca882f22b8738baef28b68d99c381))
|
||||
|
||||
- **advanced_dock_area**: Removed non-functional dock_list and dock_map from RPC
|
||||
([`88b6e01`](https://github.com/bec-project/bec_widgets/commit/88b6e015bf1ab3b56db843ec13a6473ad67c4acc))
|
||||
|
||||
- **advanced_dock_area**: Removed the singleShot for load_initial_profile
|
||||
([`3236dfb`](https://github.com/bec-project/bec_widgets/commit/3236dfb07f477fb87bcbcd0ee983781d5281beb6))
|
||||
|
||||
- **advanced_dock_area**: Replace sanitize_namespace with slugify
|
||||
([`013b916`](https://github.com/bec-project/bec_widgets/commit/013b916ca3beb7a47db9009b9e07250ae52979b1))
|
||||
|
||||
- **basic_dock_area**: Delete_all will also delete floating docks
|
||||
([`6b2b42f`](https://github.com/bec-project/bec_widgets/commit/6b2b42f21afa98d4ee5cb9d969aaa21cfc633f4e))
|
||||
|
||||
- **basic_dock_area**: Removed the singleShot usage
|
||||
([`6cff8d7`](https://github.com/bec-project/bec_widgets/commit/6cff8d7a41f6f08908c3dd20fd563ab2612976e3))
|
||||
|
||||
- **bec_connector**: Use RPC register to fetch all connections
|
||||
([`56b1e66`](https://github.com/bec-project/bec_widgets/commit/56b1e6687f4ce56e7c836678d397d1ca0fbec459))
|
||||
|
||||
- **bec_connector**: Widget_removed and name_established signals added
|
||||
([`389a93f`](https://github.com/bec-project/bec_widgets/commit/389a93f8d07d44c17772e6183ee129db7692bd89))
|
||||
|
||||
- **bec_widget**: Improved qt enums; grab safeguard
|
||||
([`f38cd3e`](https://github.com/bec-project/bec_widgets/commit/f38cd3e3a043151ce25f91d9a6b325a6c6ac5103))
|
||||
|
||||
- **bec_widgets**: Adapt to bec_qthemes 1.0; themes can be only applied on living Qt objects
|
||||
([`b0cd619`](https://github.com/bec-project/bec_widgets/commit/b0cd619d7dff8f7ce7bc37ea6acea9473b2273d8))
|
||||
|
||||
- **becconnector**: Ophyd thread killer on exit + in conftest
|
||||
([`0b9e5c1`](https://github.com/bec-project/bec_widgets/commit/0b9e5c15afb8b6f271992cb70c235c2be44c24a8))
|
||||
|
||||
- **becconnector**: Sanitize the setObjectName from qobject inheritance
|
||||
([`7507f27`](https://github.com/bec-project/bec_widgets/commit/7507f27d686300a2b42c80dc06f3c78142c7ef84))
|
||||
|
||||
- **busy-loader**: Adjust busy loader and tests
|
||||
([`94faaba`](https://github.com/bec-project/bec_widgets/commit/94faaba24d45a1ff971879486fa044fce49d2d5c))
|
||||
|
||||
- **CLI**: Change the default behavior of launching the profiles in CLI
|
||||
([`b43b6e8`](https://github.com/bec-project/bec_widgets/commit/b43b6e844b4f178f9636b325aee0ce4fa2152199))
|
||||
|
||||
- **CLI**: Dock_area can be created from CLI with specific profile or empty
|
||||
([`9c66dd5`](https://github.com/bec-project/bec_widgets/commit/9c66dd59914e2c8964f811f4e7e522fd3ae75633))
|
||||
|
||||
- **cli**: Rpc API from any folder
|
||||
([`b29648e`](https://github.com/bec-project/bec_widgets/commit/b29648e10b0ea7931ad216221f231b77ab8998d8))
|
||||
|
||||
- **client**: Abort, reset, stop button removed from RPC access
|
||||
([`c923f79`](https://github.com/bec-project/bec_widgets/commit/c923f7929370c3ac721dfa84d7cafcd0aa406c92))
|
||||
|
||||
- **client**: Client API regenerated
|
||||
([`7083f94`](https://github.com/bec-project/bec_widgets/commit/7083f94f467ad4d40bea57dcdc96c75aa3690910))
|
||||
|
||||
- **client_utils**: Delete is deleting window and its content
|
||||
([`be55bf2`](https://github.com/bec-project/bec_widgets/commit/be55bf20c1295c1e710457638c1bc7154b23011e))
|
||||
|
||||
- **client_utils**: Safeguard for accessing gui.new and launcher if GUIServer not running
|
||||
([`4d41be6`](https://github.com/bec-project/bec_widgets/commit/4d41be61b546931c728b584f190aa4de3f418dd3))
|
||||
|
||||
- **colors**: Added logger to the apply theme
|
||||
([`1f363d9`](https://github.com/bec-project/bec_widgets/commit/1f363d9bd4e6f7a01edcbe5d0049560459d184d0))
|
||||
|
||||
- **colors**: More benevolent fetching of colormap names, avoid hardcoded wrong colormap mapping
|
||||
from GradientWidget from pg
|
||||
([`cd9c7ab`](https://github.com/bec-project/bec_widgets/commit/cd9c7ab079bee1623a93ff63142cac8ebf61facd))
|
||||
|
||||
- **dark_mode_button**: Rpc access disabled
|
||||
([`4fc2522`](https://github.com/bec-project/bec_widgets/commit/4fc252220d3a22f52b1148ba64045f5884d59182))
|
||||
|
||||
- **dark_mode_button**: Skip settings added
|
||||
([`1c18810`](https://github.com/bec-project/bec_widgets/commit/1c18810e5faf0de96bb7381db3d8c4bcd2596596))
|
||||
|
||||
- **developer widget**: Save before executing a scripts
|
||||
([`d085f65`](https://github.com/bec-project/bec_widgets/commit/d085f651532f84e720506745dbd44b80fb05a4be))
|
||||
|
||||
- **device-form-dialog**: Adapt device-form-dialog ophyd validation test
|
||||
([`36be529`](https://github.com/bec-project/bec_widgets/commit/36be5292da1a2c30ef9a8493ad49f361d878c23a))
|
||||
|
||||
- **device-form-dialog**: Adapt DeviceFormDialog to run validation of config upon editing/adding a
|
||||
config, and forward validation results
|
||||
([`7c28364`](https://github.com/bec-project/bec_widgets/commit/7c283645948999f6a6b2e480418e5c8c7f158fb5))
|
||||
|
||||
- **device-init-progress-bar**: Fix ui format for device init progressbar
|
||||
([`caba3a5`](https://github.com/bec-project/bec_widgets/commit/caba3a55f3a7a62a74f8f36b14a960e9c0fe0981))
|
||||
|
||||
- **device-manager**: Fix minor icon synchronization bugs
|
||||
([`1d654bd`](https://github.com/bec-project/bec_widgets/commit/1d654bd8bdaac581a934cb9bab5a64a9021b4972))
|
||||
|
||||
- **device-manager-display-widget**: Fix error message popup on cancelling upload
|
||||
([`fa49322`](https://github.com/bec-project/bec_widgets/commit/fa49322d1fd94ec4235c435dd6ca5e5234cd6bcc))
|
||||
|
||||
- **device-manager-display-widget**: Remove devices from ophyd validation after upload to BEC
|
||||
([`7805c7a`](https://github.com/bec-project/bec_widgets/commit/7805c7a1916d8d153881eaf6b96825a010ad6a9c))
|
||||
|
||||
- **device-progress-bar**: Remove stretch in content layout
|
||||
([`3fe6a00`](https://github.com/bec-project/bec_widgets/commit/3fe6a00708c459595b2eedb2a902c4ca5cae7171))
|
||||
|
||||
- **device_combobox**: Public flag for valid input
|
||||
([`6c73307`](https://github.com/bec-project/bec_widgets/commit/6c73307bb43dfc2ae6181bd4be3854b7e198eb1d))
|
||||
|
||||
- **device_input_widgets**: Removed RPC access
|
||||
([`940face`](https://github.com/bec-project/bec_widgets/commit/940face1187a0d3480ca3d64c061550271ff54e4))
|
||||
|
||||
- **dock_area**: Profile management with empty profile, applied across the whole repo
|
||||
([`963941a`](https://github.com/bec-project/bec_widgets/commit/963941a788c1ce8a5def15b9a9d930ef9c62f41e))
|
||||
|
||||
- **dock_area**: Tabbed dock have correct parent
|
||||
([`a632f35`](https://github.com/bec-project/bec_widgets/commit/a632f35c40e8323378f2464a6a82a484edf4ff33))
|
||||
|
||||
- **dock_area**: The old BECDockArea(pg) removed and replaces by AdvancedDockArea(ADS)
|
||||
([`a6583ad`](https://github.com/bec-project/bec_widgets/commit/a6583ad53f6a1004af1a87904517d97a52801116))
|
||||
|
||||
- **dock_area**: Widget_map and widget_list by default returns only becconnector based widgets
|
||||
([`3a5317b`](https://github.com/bec-project/bec_widgets/commit/3a5317be53d21130203a534b0dbf6bbef2d1a1c8))
|
||||
|
||||
- **editors**: Vscode widget removed
|
||||
([`48387c0`](https://github.com/bec-project/bec_widgets/commit/48387c0ad9234f5f7600644eb12fa12c6d29efa7))
|
||||
|
||||
- **FakeDevice**: Add _info dict
|
||||
([`2992939`](https://github.com/bec-project/bec_widgets/commit/2992939b0fa504418fe06173c11702e9dd4f3ce2))
|
||||
|
||||
- **general_app**: Old general app example removed
|
||||
([`3ebac55`](https://github.com/bec-project/bec_widgets/commit/3ebac55e2d6aabf971d818fddb53430a690a7392))
|
||||
|
||||
- **guided-tour**: Fix skip past invalid step for 'prev' step
|
||||
([`7bcdc31`](https://github.com/bec-project/bec_widgets/commit/7bcdc31f119b7b0996c7eac75008cef0b6e880ff))
|
||||
|
||||
- **heatmap**: Devices are saved as SafeProperties
|
||||
([`6baf196`](https://github.com/bec-project/bec_widgets/commit/6baf1962faa0628ba872790e6cb34565bc7d0d7c))
|
||||
|
||||
- **heatmap**: Interpolation of the image moved to separate thread
|
||||
([`323c8d5`](https://github.com/bec-project/bec_widgets/commit/323c8d5bc00f12b2d032f3da5daa47ef3e4774bc))
|
||||
|
||||
- **heatmap**: Interpolation thread is killed only on exit, logger for dandling thread
|
||||
([`6fc524c`](https://github.com/bec-project/bec_widgets/commit/6fc524c819903eedd690adcb09f7aa70ee4d2248))
|
||||
|
||||
- **launch_window**: Argument to start with the gui class
|
||||
([`3c16909`](https://github.com/bec-project/bec_widgets/commit/3c16909a875337efdec9e984f952c390ce99cfb4))
|
||||
|
||||
- **launch_window**: Launch geometry for widgets launched from launcher to 80% of the primary screen
|
||||
as default
|
||||
([`6459281`](https://github.com/bec-project/bec_widgets/commit/6459281387c8f1287347b9569a77aa1e9444013c))
|
||||
|
||||
- **launch_window**: Logic for showing launcher
|
||||
([`d9b7285`](https://github.com/bec-project/bec_widgets/commit/d9b728584fb7e96ebac1c0f29f713290c0092556))
|
||||
|
||||
- **launch_window**: Processevents removed
|
||||
([`c61d00e`](https://github.com/bec-project/bec_widgets/commit/c61d00e761851a67003921c2ad689238e360ad77))
|
||||
|
||||
- **main_app**: Center the application window on the screen
|
||||
([`96a52a0`](https://github.com/bec-project/bec_widgets/commit/96a52a0cb0fb248e83303ee89182fe4ebeb29e75))
|
||||
|
||||
- **main_app**: Dock area from main app shares the workspace name with the CLI one to reuse the
|
||||
profiles created in the cli companion window
|
||||
([`06745e0`](https://github.com/bec-project/bec_widgets/commit/06745e0511d3ad4e261119118c7767f92bd884a5))
|
||||
|
||||
- **main_app**: Refactor main function and update script entry point in pyproject.toml
|
||||
([`7ccfcc9`](https://github.com/bec-project/bec_widgets/commit/7ccfcc9f52c6ddaf65c350d474bac7260e3dd059))
|
||||
|
||||
- **main_app**: Rpc access refined
|
||||
([`5bcf440`](https://github.com/bec-project/bec_widgets/commit/5bcf440be7172f8c3cadc7cd1d95251c176d33d1))
|
||||
|
||||
- **main_app**: Temporarily disable IDE view
|
||||
([`bfc9f19`](https://github.com/bec-project/bec_widgets/commit/bfc9f1947234b87835d2cde87f961a00b1a0990d))
|
||||
|
||||
- **main_app**: The dock area view implemented as a viewBase
|
||||
([`ab9688d`](https://github.com/bec-project/bec_widgets/commit/ab9688d2b551e4b3525fe9aed76afd772b835b05))
|
||||
|
||||
- **main_window**: Cleanup adjusted with shiboken6
|
||||
([`06cb187`](https://github.com/bec-project/bec_widgets/commit/06cb187d1a030e24d62c5a8e01978ba68f4812df))
|
||||
|
||||
- **main_window**: Delete on close
|
||||
([`522934f`](https://github.com/bec-project/bec_widgets/commit/522934f8cd814c07fde8c62635f2f63ed716e00e))
|
||||
|
||||
- **main_window**: Parent fixed for notification broker
|
||||
([`947bf63`](https://github.com/bec-project/bec_widgets/commit/947bf63e03b3cbdfe2fd8ab803c83175c7bc599b))
|
||||
|
||||
- **main_window**: Removed general forced cleanup
|
||||
([`cab4227`](https://github.com/bec-project/bec_widgets/commit/cab422777c50151b94da71a45a9bda0e1ce2804d))
|
||||
|
||||
- **main_window**: Safeguard of fetching the launcher from the main window if GUIServer is not
|
||||
running
|
||||
([`f8be437`](https://github.com/bec-project/bec_widgets/commit/f8be43741a5c100a976d2f84c3dc7607938c847e))
|
||||
|
||||
- **main_window**: Scan progress bar rpc not exposed
|
||||
([`04b448e`](https://github.com/bec-project/bec_widgets/commit/04b448e1832796616002a1ea26028e3d42aca9b1))
|
||||
|
||||
- **monaco dock**: Update last focused editor when closing
|
||||
([`3631fc2`](https://github.com/bec-project/bec_widgets/commit/3631fc26499853015ff58283c2b8913aa9a36334))
|
||||
|
||||
- **monaco widget**: Reset current_file
|
||||
([`c53d4c0`](https://github.com/bec-project/bec_widgets/commit/c53d4c0ad7b4c423eaa13828e2b38a04751f148e))
|
||||
|
||||
- **monaco_dock**: Update editor metadata handling and improve open_file method
|
||||
([`3136477`](https://github.com/bec-project/bec_widgets/commit/31364772bd7fcccbc118061d0b601a9f1121bcb0))
|
||||
|
||||
- **motor_map**: X/y motor are saved in properties
|
||||
([`96060fc`](https://github.com/bec-project/bec_widgets/commit/96060fca53f3426dbc43f1ae5d8ebdd7acc39100))
|
||||
|
||||
- **ophyd-validation**: Add device_manager_ds argument if available for ophyd validation
|
||||
([`338ff45`](https://github.com/bec-project/bec_widgets/commit/338ff455cccfc1e8a3b0638fdcc4f1d807f0b6ca))
|
||||
|
||||
- **positioner_box**: Layout HV centered and size taken from the ui file
|
||||
([`6113deb`](https://github.com/bec-project/bec_widgets/commit/6113debc6c1d95a50b7522144fdc820380ae2e28))
|
||||
|
||||
- **qt_ads**: Pythons stubs match structure of PySide6QtAds
|
||||
([`2f9d6d5`](https://github.com/bec-project/bec_widgets/commit/2f9d6d59eee32e373acc0df8a38b426d8142562b))
|
||||
|
||||
- **rpc**: Rpc flags adjustment for MainApp and DeveloperWidget
|
||||
([`5b15c75`](https://github.com/bec-project/bec_widgets/commit/5b15c75b88707f450bfa194d9eed3d726e101981))
|
||||
|
||||
- **rpc_register**: Listing only valid connections
|
||||
([`38eb244`](https://github.com/bec-project/bec_widgets/commit/38eb2441cdf677939354c7066f854c22cf261932))
|
||||
|
||||
- **rpc_server**: Add check for rpc_exposed to serialize_object
|
||||
([`0eabd0f`](https://github.com/bec-project/bec_widgets/commit/0eabd0f72be6247073382d0df02776d30c35a1aa))
|
||||
|
||||
- **rpc_server**: Removed unused get _get_becwidget_ancestor
|
||||
([`047ff2b`](https://github.com/bec-project/bec_widgets/commit/047ff2bef77ca14f060b3b0bc21f78b880535faa))
|
||||
|
||||
- **rpc_server**: Use single shot instead of processEvents to avoid dead locks
|
||||
([`84d6653`](https://github.com/bec-project/bec_widgets/commit/84d6653d1993dd4bebb98fcbf0d1a0dd94119502))
|
||||
|
||||
- **scatter waveform**: Fix tab order for settings panel
|
||||
([`08e1985`](https://github.com/bec-project/bec_widgets/commit/08e19858eadb738358465c9f2a202529d1ccbe45))
|
||||
|
||||
- **scatter_waveform**: Devices and entries saved as properties
|
||||
([`7ab8e0c`](https://github.com/bec-project/bec_widgets/commit/7ab8e0c2ed4f1b49e943f7ec64d3984ede6e134a))
|
||||
|
||||
- **scatter_waveform**: Modernization of scatter waveform settings dialog
|
||||
([`dea73a9`](https://github.com/bec-project/bec_widgets/commit/dea73a97c9f78560e9f11290ba442152cc955057))
|
||||
|
||||
- **scatter_waveform**: Remove curve_json from the properties
|
||||
([`f6712e8`](https://github.com/bec-project/bec_widgets/commit/f6712e8bb855566ca0f308ae3d5bf5109d98d792))
|
||||
|
||||
- **screen_utils**: Screen utilities added and fixed sizing for widgets from launch window and main
|
||||
app
|
||||
([`fb55e72`](https://github.com/bec-project/bec_widgets/commit/fb55e72713a2209575c555c9dd8c025a0349e795))
|
||||
|
||||
- **server**: Gui server can reach shutdown, logic moved to becconnector
|
||||
([`0d05839`](https://github.com/bec-project/bec_widgets/commit/0d05839e9e3f4c61fc318aa44721436afcebf06f))
|
||||
|
||||
- **signal-label**: Fix signal label cleanup, missing parent in constructors
|
||||
([`72639e7`](https://github.com/bec-project/bec_widgets/commit/72639e7e5fa01ceac6cc864c01cea73f4ddca441))
|
||||
|
||||
- **signal_combo_box**: Get_signal_name added; remove duplicates from heatmap and scatter waveform
|
||||
settings;
|
||||
([`66a9510`](https://github.com/bec-project/bec_widgets/commit/66a95102dd33dbac5575a3b0d99c4c99c42cce4a))
|
||||
|
||||
- **signal_label**: Dispatcher unsubscribed in the cleanup
|
||||
([`90ba505`](https://github.com/bec-project/bec_widgets/commit/90ba505c10e7ee60d82abb578c7f691cf1125e9a))
|
||||
|
||||
- **toggle**: Move toggle to theme colors
|
||||
([`375d131`](https://github.com/bec-project/bec_widgets/commit/375d131109d37ea7b49aa354b624b0dd8fea89ee))
|
||||
|
||||
- **view**: Based on BECWidgets
|
||||
([`3d049d6`](https://github.com/bec-project/bec_widgets/commit/3d049d67a9303b20862150b3622c4121d4a72b32))
|
||||
|
||||
- **web_console**: Added startup kwarg
|
||||
([`55c8a57`](https://github.com/bec-project/bec_widgets/commit/55c8a57e71653299f3fd66ca7aafca8f32c7aacc))
|
||||
|
||||
- **widget_state_manager**: Added shiboken check
|
||||
([`338b9e1`](https://github.com/bec-project/bec_widgets/commit/338b9e1aa7216d9d38449633fe9d4fffce13ee90))
|
||||
|
||||
- **widget_state_manager**: Filtering of not wanted properties
|
||||
([`7ea4352`](https://github.com/bec-project/bec_widgets/commit/7ea4352a09349e606c97edb72eccf6e683684cf8))
|
||||
|
||||
- **widget_state_manager**: Properties_to_skip are not restored even if in ini file
|
||||
([`84c7360`](https://github.com/bec-project/bec_widgets/commit/84c7360bb8a63426d584a522d6a8969810536d2a))
|
||||
|
||||
- **widget_state_manager**: State manager can save all properties recursively to already existing
|
||||
settings
|
||||
([`98e2979`](https://github.com/bec-project/bec_widgets/commit/98e29792a2620a9e88c770cd69d7cad88cc94252))
|
||||
|
||||
- **widgets**: Processevent removed from widgets using it
|
||||
([`a56bd57`](https://github.com/bec-project/bec_widgets/commit/a56bd572a000e47dd7d1d2a458dac676e67ec21e))
|
||||
|
||||
- **widgets**: Removed isVisible from all SafeProperties
|
||||
([`b72bf4a`](https://github.com/bec-project/bec_widgets/commit/b72bf4a0f9a67c104cd86c66e9160ab9f0a40c01))
|
||||
|
||||
### Build System
|
||||
|
||||
- Pyside6-qtads; bec_qtheme V1; dependencies updated and adjusted
|
||||
([`562001c`](https://github.com/bec-project/bec_widgets/commit/562001c08cdc3ca9fbe28aaed8b6a83921426f97))
|
||||
|
||||
- **deps**: Update bec-qthemes requirement
|
||||
([`4a44ede`](https://github.com/bec-project/bec_widgets/commit/4a44ede8fe02b4c513ec419f85cb447f58dfdf86))
|
||||
|
||||
Updates the requirements on [bec-qthemes](https://github.com/bec-project/bec_qthemes) to permit the
|
||||
latest version. - [Release notes](https://github.com/bec-project/bec_qthemes/releases) -
|
||||
[Changelog](https://github.com/bec-project/bec_qthemes/blob/main/CHANGELOG.md) -
|
||||
[Commits](https://github.com/bec-project/bec_qthemes/compare/v0.7.0...v1.3.3)
|
||||
|
||||
--- updated-dependencies: - dependency-name: bec-qthemes dependency-version: 1.3.3
|
||||
|
||||
dependency-type: direct:production ...
|
||||
|
||||
Signed-off-by: dependabot[bot] <support@github.com>
|
||||
|
||||
### Code Style
|
||||
|
||||
- Wrap progress bar in widget to fix background
|
||||
([`793779d`](https://github.com/bec-project/bec_widgets/commit/793779db68c9725fae767d6cd0096c89a4caa700))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Add artifact upload
|
||||
([`d301fdf`](https://github.com/bec-project/bec_widgets/commit/d301fdfeb237acd61fd579a0e8147f2037df62d5))
|
||||
|
||||
- Cancel previous CI run for PR or branch
|
||||
([`37298c2`](https://github.com/bec-project/bec_widgets/commit/37298c21c3b76667459f2a62453692e99ff8191e))
|
||||
|
||||
- Install ttyd
|
||||
([`b6d70c3`](https://github.com/bec-project/bec_widgets/commit/b6d70c34df29d2f44e7f5da88cb0daaef39ceed1))
|
||||
|
||||
- Use shared issue sync action instead of local version
|
||||
([`c9a8e64`](https://github.com/bec-project/bec_widgets/commit/c9a8e64217d3c2047a4a8f5e2348c0a725a0066a))
|
||||
|
||||
### Features
|
||||
|
||||
- Add export and load settings methods to BECConnector; add SafeProperty safe getter flag
|
||||
([`5435fec`](https://github.com/bec-project/bec_widgets/commit/5435fec68a11caa83e8566cde21ad382729e6792))
|
||||
|
||||
- Add guided tour docs to device-manager-view
|
||||
([`fcb4306`](https://github.com/bec-project/bec_widgets/commit/fcb43066e4abe469e0f06163b4abcce6e0d9250b))
|
||||
|
||||
- Add SafeConnect
|
||||
([`4b5a45c`](https://github.com/bec-project/bec_widgets/commit/4b5a45c320d701e6878d6af7259c530596118053))
|
||||
|
||||
- Attach config cancellation to closeEvent
|
||||
([`c1443fa`](https://github.com/bec-project/bec_widgets/commit/c1443fa27afc63c69c4b56cf8be7eb2792704784))
|
||||
|
||||
- Guided tour for main app
|
||||
([`3ffdf11`](https://github.com/bec-project/bec_widgets/commit/3ffdf11c3e419d71e22c484c618eec51e9168f9d))
|
||||
|
||||
- **actions**: Actions can be created with label text with beside or under alignment
|
||||
([`9c3a6e1`](https://github.com/bec-project/bec_widgets/commit/9c3a6e1691fd02230651a4d871911f365d4a3129))
|
||||
|
||||
- **ads**: Add pyi stub file to provide type hints for ads
|
||||
([`4c4fc25`](https://github.com/bec-project/bec_widgets/commit/4c4fc25a42be9bc8ecce6f550c4f357372233289))
|
||||
|
||||
- **advanced_dock_area**: Added ads based dock area with profiles
|
||||
([`d25314e`](https://github.com/bec-project/bec_widgets/commit/d25314e6eeb6323a6ffcde3c119f7b1bc0ebed16))
|
||||
|
||||
- **advanced_dock_area**: Created DockAreaWidget base class; profile management through namespaces;
|
||||
dock area variants
|
||||
([`58b88ef`](https://github.com/bec-project/bec_widgets/commit/58b88efcb66627f9e9c3c9de65366d55465e1e44))
|
||||
|
||||
- **advanced_dock_area**: Floating docks restore with relative geometry
|
||||
([`440cecd`](https://github.com/bec-project/bec_widgets/commit/440cecddf740a5f320f53771b93a148fb3be544b))
|
||||
|
||||
- **advanced_dock_area**: Instance lock for multiple ads in same session
|
||||
([`bcaf013`](https://github.com/bec-project/bec_widgets/commit/bcaf013d2b5b45830cc37079b7d0f388ead98bc1))
|
||||
|
||||
- **advanced_dock_area**: Ui/ux for profile management improved, saving directories logic adjusted
|
||||
([`7305498`](https://github.com/bec-project/bec_widgets/commit/730549847563b552887a5529b2b0fed308ed8b98))
|
||||
|
||||
- **bec-login**: Add login widget in material design style
|
||||
([`b798ea2`](https://github.com/bec-project/bec_widgets/commit/b798ea2340a6aa8c0325a1cd1995eba028279816))
|
||||
|
||||
- **bec_widget**: Attach/detach method for all widgets + client regenerated
|
||||
([`82dbf31`](https://github.com/bec-project/bec_widgets/commit/82dbf31da54288b7228bc5c7bdc271a8178f8d02))
|
||||
|
||||
- **bec_widget**: Save screenshot to bytes
|
||||
([`ed2651a`](https://github.com/bec-project/bec_widgets/commit/ed2651a914a283dc7cc45a9bf185d2a4e053d307))
|
||||
|
||||
- **becconnector**: Added rpc_passthrough_children flag in addition to rpc_exposed
|
||||
([`010373f`](https://github.com/bec-project/bec_widgets/commit/010373fd5b334c6616efce467608356b36c2130b))
|
||||
|
||||
- **becconnector**: Exposed rpc flag added to the BECConnector
|
||||
([`de6c628`](https://github.com/bec-project/bec_widgets/commit/de6c6284ad6d73b40137e9bba56e748c59a4ade9))
|
||||
|
||||
- **busy_loader**: Busy loader added to bec widget base class
|
||||
([`92c15a7`](https://github.com/bec-project/bec_widgets/commit/92c15a7f829fa3f0b69cf5584ac45a21dce0b01d))
|
||||
|
||||
- **client_utils**: Theme can be changed from the CLI
|
||||
([`c1d4758`](https://github.com/bec-project/bec_widgets/commit/c1d4758e4ca33d094fabdfbd4e024a2836f2fa9a))
|
||||
|
||||
- **color**: Add relative luminance calculation
|
||||
([`a84b924`](https://github.com/bec-project/bec_widgets/commit/a84b924162280fc6b6ca31af511b78c4f5baafc9))
|
||||
|
||||
- **developer_view**: Add developer view
|
||||
([`bdef594`](https://github.com/bec-project/bec_widgets/commit/bdef594b5885b5fab60ef94addbce1ab771c4244))
|
||||
|
||||
- **developer_widget**: Add signal connection for focused editor changes to disable run button for
|
||||
macro files
|
||||
([`fa79179`](https://github.com/bec-project/bec_widgets/commit/fa79179f89f048aeee0a3947350f3a7bc2169d9f))
|
||||
|
||||
- **device-initialization-progress-bar**: Add progress bar for device initialization
|
||||
([`5deafb9`](https://github.com/bec-project/bec_widgets/commit/5deafb97979eb1a2e8bcba3321dfd1a15553a5da))
|
||||
|
||||
- **device-manager**: Add DeviceManager Widget for BEC Widget main applications
|
||||
([`a6357af`](https://github.com/bec-project/bec_widgets/commit/a6357af8ffda640eaee1c1c75c3a4bdf0c5de068))
|
||||
|
||||
- **device_combobox**: Device filter added based on its signal classes
|
||||
([`fbddf4a`](https://github.com/bec-project/bec_widgets/commit/fbddf4a28442dab6e9e4585aa0c3a0131d6bdf7b))
|
||||
|
||||
- **dm-view**: Initial device manager view added
|
||||
([`9e4be38`](https://github.com/bec-project/bec_widgets/commit/9e4be38c0b8b6e654313bf232a597d09978d2436))
|
||||
|
||||
- **generate_cli**: Rpc API from content widget can be merged with the RPC API of the container
|
||||
widget statically
|
||||
([`758956b`](https://github.com/bec-project/bec_widgets/commit/758956be098d6629a0cd641b1525965ebfe19345))
|
||||
|
||||
- **guided_tour**: Add guided tour
|
||||
([`9b753c1`](https://github.com/bec-project/bec_widgets/commit/9b753c1f24419292790ca60e4bd55bb1aa5e1a70))
|
||||
|
||||
- **help-inspector**: Add help inspector widget
|
||||
([`5ac629d`](https://github.com/bec-project/bec_widgets/commit/5ac629de8c7bbdf0e2c07c9a7cf25e430cd031c1))
|
||||
|
||||
- **image**: Modernization of image widget
|
||||
([`80c0dfa`](https://github.com/bec-project/bec_widgets/commit/80c0dfa4f28e3eb2c6f944a517c92f822f51266d))
|
||||
|
||||
- **jupyter_console_window**: Adjustment for general usage
|
||||
([`66f3e51`](https://github.com/bec-project/bec_widgets/commit/66f3e517f0fb8fa1ea678ec09ef852d5b8a63d51))
|
||||
|
||||
- **main_app**: Main app with interactive app switcher
|
||||
([`b30e1e4`](https://github.com/bec-project/bec_widgets/commit/b30e1e4c5e182903721fe7c16a8069f2c95704d3))
|
||||
|
||||
- **motor_map**: Motor selection adopted to splitter action
|
||||
([`168bb3c`](https://github.com/bec-project/bec_widgets/commit/168bb3cb77ca3a270a958f4f941445383c8bec99))
|
||||
|
||||
- **plot_base**: Plot_base, image and heatmap widget adopted to property-toolbar sync
|
||||
([`dd69578`](https://github.com/bec-project/bec_widgets/commit/dd69578b912b44130d33427fa8d5d948889e8c07))
|
||||
|
||||
- **SafeProperty**: Safeproperty emits property_changed signal
|
||||
([`7cce3bd`](https://github.com/bec-project/bec_widgets/commit/7cce3bd54210f82a5cf68e6219ea073e972234d6))
|
||||
|
||||
- **signal_combobox**: Extended that can filter by signal class and dimension of the signal
|
||||
([`cfd6bde`](https://github.com/bec-project/bec_widgets/commit/cfd6bde268cea5bd119354db8b6ab1661b575293))
|
||||
|
||||
- **toolbar**: Splitter action added
|
||||
([`0752f3d`](https://github.com/bec-project/bec_widgets/commit/0752f3d6a9cd9b080bf87464eac9eb05f99f108f))
|
||||
|
||||
- **toolbar**: Toolbar can be synced with the property_changed for toggle actions
|
||||
([`4357d98`](https://github.com/bec-project/bec_widgets/commit/4357d984c8f89fa51bc0c8d9a217b2a2028e3ca9))
|
||||
|
||||
- **web console**: Add support for shared web console sessions
|
||||
([`5e111cf`](https://github.com/bec-project/bec_widgets/commit/5e111cfc54f2771a0ff5080a77bb4ac5b491bc8f))
|
||||
|
||||
- **widget_hierarchy_tree**: Widget displaying parent child hierarchy from the app widgets
|
||||
([`5f46fa0`](https://github.com/bec-project/bec_widgets/commit/5f46fa09943017fdadbe12522b38a2733d5b6001))
|
||||
|
||||
- **widget_highlighter**: Reusable separate widget highlighter
|
||||
([`8b782ac`](https://github.com/bec-project/bec_widgets/commit/8b782ac302b4ccbfe768c066c3c9fbe31fdace75))
|
||||
|
||||
- **widget_io**: Widget hierarchy can grap all bec connectors from the widget recursively
|
||||
([`db83576`](https://github.com/bec-project/bec_widgets/commit/db83576346980eef59b5366bc07258edcbf6333b))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
- **heatmap**: Thread worker optimization
|
||||
([`f98a5de`](https://github.com/bec-project/bec_widgets/commit/f98a5de7e9f154e6e9fc65a257776c9dec74eb84))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Add extra tour steps, add enter button
|
||||
([`2826919`](https://github.com/bec-project/bec_widgets/commit/2826919c5a330e2ba9666cfec1f9561b4cfd4bcf))
|
||||
|
||||
- Global refactoring to use device-signal pair names
|
||||
([`b93fbc5`](https://github.com/bec-project/bec_widgets/commit/b93fbc5cd31dbaa1bf4b18b9d30e3463ea539f72))
|
||||
|
||||
- Improvements to enum access
|
||||
([`19b7310`](https://github.com/bec-project/bec_widgets/commit/19b73104337a100cef39936dd7ec5c32c346f99b))
|
||||
|
||||
- **advanced_dock_area**: Change remove_widget to delete
|
||||
([`eda30e3`](https://github.com/bec-project/bec_widgets/commit/eda30e31396ec1e34c13be047564de334d9a5c6f))
|
||||
|
||||
- **bec_main_window**: Main app theme renamed to View
|
||||
([`37bfad7`](https://github.com/bec-project/bec_widgets/commit/37bfad7174982f7c3489e38cf715615719b34862))
|
||||
|
||||
- **busy-loader**: Refactor busy loader to use custom widget
|
||||
([`332ca20`](https://github.com/bec-project/bec_widgets/commit/332ca205c12c445513472a25366699e870e5a879))
|
||||
|
||||
- **busy-loager**: Improve eventFilter to avoid crashs if target or overlay is None.
|
||||
([`229da62`](https://github.com/bec-project/bec_widgets/commit/229da6244ae2bb2521ff0257db1772e5cceeee59))
|
||||
|
||||
- **developer_view**: Changed to use DockAreaWidget
|
||||
([`4d40918`](https://github.com/bec-project/bec_widgets/commit/4d40918b7c84c833d46287fec365d1810683adec))
|
||||
|
||||
- **developer_widget**: Enhance documentation and add missing imports
|
||||
([`5e0c376`](https://github.com/bec-project/bec_widgets/commit/5e0c3767742bcac8b39972d0972db0580c1863cd))
|
||||
|
||||
- **device-form-dialog**: Use native QDialogButtonBox instead of GroupBox layout
|
||||
([`12b4d3a`](https://github.com/bec-project/bec_widgets/commit/12b4d3a9e0ffe0539d5884bbedf4f14349a5e117))
|
||||
|
||||
- **dock_area**: Change name to BECDockArea
|
||||
([`71ed2d3`](https://github.com/bec-project/bec_widgets/commit/71ed2d353acc0e68eaef1fa55474db0b8e1f1eb9))
|
||||
|
||||
- **guided-tour**: Add support for QTableWidgetItem
|
||||
([`83489b7`](https://github.com/bec-project/bec_widgets/commit/83489b7519f41b75f2d3f2cdcf31b0075e41d52d))
|
||||
|
||||
- **main_app**: Adapted for DockAreaWidget changes
|
||||
([`ac850ec`](https://github.com/bec-project/bec_widgets/commit/ac850ec650695c12a77e0e8e598094d740312a89))
|
||||
|
||||
- **main_app**: Simpler id and object name management
|
||||
([`654aeb7`](https://github.com/bec-project/bec_widgets/commit/654aeb711626f0f85d288cd3c0a85d69ad2826d8))
|
||||
|
||||
- **monaco_dock**: Changed to use DockAreaWidget
|
||||
([`ed0d34a`](https://github.com/bec-project/bec_widgets/commit/ed0d34a60f8348a970da71d77801154ea70c24c6))
|
||||
|
||||
- **ophyd-validation**: Allow option to keep device visible after successful validation
|
||||
([`89d5c5a`](https://github.com/bec-project/bec_widgets/commit/89d5c5abdb0081e29d2c31ae6ded75a3f9abe0ff))
|
||||
|
||||
- **widget_io**: Hierarchy logic generalized
|
||||
([`00bf01c`](https://github.com/bec-project/bec_widgets/commit/00bf01c1290c4ead6d8270942fbfda2cbd7e9873))
|
||||
|
||||
### Testing
|
||||
|
||||
- Fix test
|
||||
([`de835e8`](https://github.com/bec-project/bec_widgets/commit/de835e81d8cf0ec6d3bca9d07ac21d4737666e31))
|
||||
|
||||
- **config-communicator**: Add test for cancel action
|
||||
([`24701c2`](https://github.com/bec-project/bec_widgets/commit/24701c2a270520de739e4615d0f52a6386bbadc0))
|
||||
|
||||
- **device-form-dialog**: Adapt tests
|
||||
([`f827e77`](https://github.com/bec-project/bec_widgets/commit/f827e77e870109b21e10b4cc28d6c09b8f77b2a6))
|
||||
|
||||
- **device-manager**: Use mocked client for tests
|
||||
([`836fedd`](https://github.com/bec-project/bec_widgets/commit/836fedd50e4fdb66bd7614a55c8e0f95a14c3fac))
|
||||
|
||||
- **device-manager-view**: Improve test coverage for device-manager-view
|
||||
([`4edc571`](https://github.com/bec-project/bec_widgets/commit/4edc57158be30d2500ad04d1b015bc8627cfb873))
|
||||
|
||||
- **e2e**: Raise with widget name
|
||||
([`3f76ade`](https://github.com/bec-project/bec_widgets/commit/3f76ade6289a75b76d7a5f67e9d72175378bedbe))
|
||||
|
||||
- **script_tree**: Improve hover event handling with waitUntil
|
||||
([`6296055`](https://github.com/bec-project/bec_widgets/commit/6296055c664070b8caeffda3c7047774bd692691))
|
||||
|
||||
- **widget_io**: Add dedicated unit tests for iter_widget_tree and helper methods
|
||||
([`041afc6`](https://github.com/bec-project/bec_widgets/commit/041afc68b1c7202a4609149e6f0e212fca629c87))
|
||||
|
||||
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
|
||||
|
||||
|
||||
## v2.45.14 (2026-01-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **bec_status**: Adjust bec status widget to info and version signature
|
||||
([`709ffd6`](https://github.com/bec-project/bec_widgets/commit/709ffd6927dceb903cbd0797fc162e56aef378c1))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Use auth.token instead of login_or_token
|
||||
([`0349c87`](https://github.com/bec-project/bec_widgets/commit/0349c872612ab0506e5662b813e78200a76d7590))
|
||||
|
||||
### Testing
|
||||
|
||||
- **device config**: Validate against pydantic
|
||||
([`de8fe3b`](https://github.com/bec-project/bec_widgets/commit/de8fe3b5f503ace17b0064d2ce9f54662b0fb77e))
|
||||
|
||||
- **scan control**: Avoid strict length comparisons
|
||||
([`d577fac`](https://github.com/bec-project/bec_widgets/commit/d577fac02fed11b2b1c44704c04fd111c2fed1d3))
|
||||
|
||||
|
||||
## v2.45.13 (2025-12-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
@@ -11,32 +9,37 @@ logger = bec_logger.logger
|
||||
|
||||
|
||||
def dock_area(
|
||||
object_name: str | None = None, startup_profile: str | Literal["restore", "skip"] | None = None
|
||||
object_name: str | None = None, profile: str | None = None, start_empty: bool = False
|
||||
) -> BECDockArea:
|
||||
"""
|
||||
Create an advanced dock area using Qt Advanced Docking System.
|
||||
|
||||
Args:
|
||||
object_name(str): The name of the advanced dock area.
|
||||
startup_profile(str | Literal["restore", "skip"] | None): Startup mode for
|
||||
the workspace:
|
||||
- None: start empty
|
||||
- "restore": restore last used profile
|
||||
- "skip": do not initialize profile state
|
||||
- "<name>": load specific profile
|
||||
profile(str|None): Optional profile to load; if None the "general" profile is used.
|
||||
start_empty(bool): If True, start with an empty dock area when loading specified profile.
|
||||
|
||||
Returns:
|
||||
BECDockArea: The created advanced dock area.
|
||||
|
||||
Note:
|
||||
The "general" profile is mandatory and will always exist. If manually deleted,
|
||||
it will be automatically recreated.
|
||||
"""
|
||||
# Default to "general" profile when called from CLI without specifying a profile
|
||||
effective_profile = profile if profile is not None else "general"
|
||||
|
||||
widget = BECDockArea(
|
||||
object_name=object_name,
|
||||
restore_initial_profile=True,
|
||||
root_widget=True,
|
||||
profile_namespace="bec",
|
||||
startup_profile=startup_profile,
|
||||
init_profile=effective_profile,
|
||||
start_empty=start_empty,
|
||||
)
|
||||
logger.info(
|
||||
f"Created advanced dock area with profile: {effective_profile}, start_empty: {start_empty}"
|
||||
)
|
||||
logger.info(f"Created advanced dock area with startup_profile: {startup_profile}")
|
||||
return widget
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
|
||||
logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
START_EMPTY_PROFILE_OPTION = "Start Empty (No Profile)"
|
||||
|
||||
|
||||
class LaunchTile(RoundedFrame):
|
||||
@@ -147,7 +146,8 @@ class LaunchTile(RoundedFrame):
|
||||
|
||||
# Action button
|
||||
self.action_button = QPushButton("Open")
|
||||
self.action_button.setStyleSheet("""
|
||||
self.action_button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #007AFF;
|
||||
border: none;
|
||||
@@ -159,7 +159,8 @@ class LaunchTile(RoundedFrame):
|
||||
QPushButton:hover {
|
||||
background-color: #005BB5;
|
||||
}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
self.layout.addWidget(self.action_button, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
|
||||
@@ -353,7 +354,7 @@ class LaunchWindow(BECMainWindow):
|
||||
def _refresh_dock_area_profiles(self, preserve_selection: bool = True) -> None:
|
||||
"""
|
||||
Refresh the dock-area profile selector, optionally preserving the selection.
|
||||
Defaults to Start Empty when no valid selection can be preserved.
|
||||
Sets the combobox to the last used profile or "general" if no selection preserved.
|
||||
|
||||
Args:
|
||||
preserve_selection(bool): Whether to preserve the current selection or not.
|
||||
@@ -368,10 +369,9 @@ class LaunchWindow(BECMainWindow):
|
||||
)
|
||||
|
||||
profiles = list_profiles("bec")
|
||||
selector_items = [START_EMPTY_PROFILE_OPTION, *profiles]
|
||||
selector.blockSignals(True)
|
||||
selector.clear()
|
||||
for profile in selector_items:
|
||||
for profile in profiles:
|
||||
selector.addItem(profile)
|
||||
|
||||
if selected_text:
|
||||
@@ -380,31 +380,21 @@ class LaunchWindow(BECMainWindow):
|
||||
if idx >= 0:
|
||||
selector.setCurrentIndex(idx)
|
||||
else:
|
||||
# Selection no longer exists, fall back to default startup selection.
|
||||
# Selection no longer exists, fall back to last profile or "general"
|
||||
self._set_selector_to_default_profile(selector, profiles)
|
||||
else:
|
||||
# No selection to preserve, use default startup selection.
|
||||
# No selection to preserve, use last profile or "general"
|
||||
self._set_selector_to_default_profile(selector, profiles)
|
||||
selector.blockSignals(False)
|
||||
|
||||
def _set_selector_to_default_profile(self, selector: QComboBox, profiles: list[str]) -> None:
|
||||
"""
|
||||
Set the selector default.
|
||||
|
||||
Preference order:
|
||||
1) Start Empty option (if available)
|
||||
2) Last used profile
|
||||
3) First available profile
|
||||
Set the selector to the last used profile or "general" as fallback.
|
||||
|
||||
Args:
|
||||
selector(QComboBox): The combobox to set.
|
||||
profiles(list[str]): List of available profiles.
|
||||
"""
|
||||
start_empty_idx = selector.findText(START_EMPTY_PROFILE_OPTION, Qt.MatchFlag.MatchExactly)
|
||||
if start_empty_idx >= 0:
|
||||
selector.setCurrentIndex(start_empty_idx)
|
||||
return
|
||||
|
||||
# Try to get last used profile
|
||||
last_profile = get_last_profile(namespace="bec")
|
||||
if last_profile and last_profile in profiles:
|
||||
@@ -413,6 +403,13 @@ class LaunchWindow(BECMainWindow):
|
||||
selector.setCurrentIndex(idx)
|
||||
return
|
||||
|
||||
# Fall back to "general" profile
|
||||
if "general" in profiles:
|
||||
idx = selector.findText("general", Qt.MatchFlag.MatchExactly)
|
||||
if idx >= 0:
|
||||
selector.setCurrentIndex(idx)
|
||||
return
|
||||
|
||||
# If nothing else, select first item
|
||||
if selector.count() > 0:
|
||||
selector.setCurrentIndex(0)
|
||||
@@ -591,14 +588,11 @@ class LaunchWindow(BECMainWindow):
|
||||
"""
|
||||
tile = self.tiles.get("dock_area")
|
||||
if tile is None or tile.selector is None:
|
||||
startup_profile = None
|
||||
profile = None
|
||||
else:
|
||||
selection = tile.selector.currentText().strip()
|
||||
if selection == START_EMPTY_PROFILE_OPTION:
|
||||
startup_profile = None
|
||||
else:
|
||||
startup_profile = selection if selection else None
|
||||
return self.launch("dock_area", startup_profile=startup_profile)
|
||||
profile = selection if selection else None
|
||||
return self.launch("dock_area", profile=profile)
|
||||
|
||||
def _open_widget(self):
|
||||
"""
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtGui import QAction # type: ignore
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
|
||||
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
|
||||
from bec_widgets.applications.views.admin_view.admin_view import AdminView
|
||||
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.guided_tour import GuidedTour
|
||||
from bec_widgets.utils.name_utils import sanitize_namespace
|
||||
from bec_widgets.utils.screen_utils import (
|
||||
apply_centered_size,
|
||||
available_screen_geometry,
|
||||
@@ -55,58 +50,54 @@ class BECMainApp(BECMainWindow):
|
||||
|
||||
self._add_views()
|
||||
|
||||
# Initialize guided tour
|
||||
self.guided_tour = GuidedTour(self)
|
||||
self._setup_guided_tour()
|
||||
|
||||
def _add_views(self):
|
||||
self.add_section("BEC Applications", "bec_apps")
|
||||
self.dock_area = DockAreaView(self)
|
||||
self.device_manager = DeviceManagerView(self)
|
||||
# self.developer_view = DeveloperView(self) #TODO temporary disable until the bugs with BECShell are resolved
|
||||
self.admin_view = AdminView(self)
|
||||
self.developer_view = DeveloperView(self)
|
||||
|
||||
self.add_view(icon="widgets", title="Dock Area", widget=self.dock_area, mini_text="Docks")
|
||||
self.add_view(
|
||||
icon="widgets",
|
||||
title="Dock Area",
|
||||
id="dock_area",
|
||||
widget=self.dock_area,
|
||||
mini_text="Docks",
|
||||
)
|
||||
self.add_view(
|
||||
icon="display_settings",
|
||||
title="Device Manager",
|
||||
id="device_manager",
|
||||
widget=self.device_manager,
|
||||
mini_text="DM",
|
||||
)
|
||||
# TODO temporary disable until the bugs with BECShell are resolved
|
||||
# self.add_view(
|
||||
# icon="code_blocks",
|
||||
# title="IDE",
|
||||
# widget=self.developer_view,
|
||||
# mini_text="IDE",
|
||||
# exclusive=True,
|
||||
# )
|
||||
self.add_view(
|
||||
icon="admin_panel_settings",
|
||||
title="Admin View",
|
||||
widget=self.admin_view,
|
||||
mini_text="Admin",
|
||||
from_top=False,
|
||||
icon="code_blocks",
|
||||
title="IDE",
|
||||
widget=self.developer_view,
|
||||
id="developer_view",
|
||||
exclusive=True,
|
||||
)
|
||||
|
||||
if self._show_examples:
|
||||
self.add_section("Examples", "examples")
|
||||
waveform_view_popup = WaveformViewPopup(
|
||||
parent=self, view_id="waveform_view_popup", title="Waveform Plot"
|
||||
parent=self, id="waveform_view_popup", title="Waveform Plot"
|
||||
)
|
||||
waveform_view_stack = WaveformViewInline(
|
||||
parent=self, view_id="waveform_view_stack", title="Waveform Plot"
|
||||
parent=self, id="waveform_view_stack", title="Waveform Plot"
|
||||
)
|
||||
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform With Popup",
|
||||
id="waveform_popup",
|
||||
widget=waveform_view_popup,
|
||||
mini_text="Popup",
|
||||
)
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform InLine Stack",
|
||||
id="waveform_stack",
|
||||
widget=waveform_view_stack,
|
||||
mini_text="Stack",
|
||||
)
|
||||
@@ -114,9 +105,6 @@ class BECMainApp(BECMainWindow):
|
||||
self.set_current("dock_area")
|
||||
self.sidebar.add_dark_mode_item()
|
||||
|
||||
# Add guided tour to Help menu
|
||||
self._add_guided_tour_to_menu()
|
||||
|
||||
# --- Public API ------------------------------------------------------
|
||||
def add_section(self, title: str, id: str, position: int | None = None):
|
||||
return self.sidebar.add_section(title, id, position)
|
||||
@@ -132,7 +120,7 @@ class BECMainApp(BECMainWindow):
|
||||
*,
|
||||
icon: str,
|
||||
title: str,
|
||||
view_id: str | None = None,
|
||||
id: str,
|
||||
widget: QWidget,
|
||||
mini_text: str | None = None,
|
||||
position: int | None = None,
|
||||
@@ -146,8 +134,7 @@ class BECMainApp(BECMainWindow):
|
||||
Args:
|
||||
icon(str): Icon name for the nav item.
|
||||
title(str): Title for the nav item.
|
||||
view_id(str, optional): Unique ID for the view/item. If omitted, uses mini_text;
|
||||
if mini_text is also omitted, uses title.
|
||||
id(str): Unique ID for the view/item.
|
||||
widget(QWidget): The widget to add to the stack.
|
||||
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
|
||||
position(int, optional): Position to insert the nav item.
|
||||
@@ -160,11 +147,10 @@ class BECMainApp(BECMainWindow):
|
||||
|
||||
|
||||
"""
|
||||
resolved_id = sanitize_namespace(view_id or mini_text or title)
|
||||
item = self.sidebar.add_item(
|
||||
icon=icon,
|
||||
title=title,
|
||||
id=resolved_id,
|
||||
id=id,
|
||||
mini_text=mini_text,
|
||||
position=position,
|
||||
from_top=from_top,
|
||||
@@ -174,15 +160,13 @@ class BECMainApp(BECMainWindow):
|
||||
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
|
||||
if isinstance(widget, ViewBase):
|
||||
view_widget = widget
|
||||
view_widget.view_id = resolved_id
|
||||
view_widget.view_id = id
|
||||
view_widget.view_title = title
|
||||
else:
|
||||
view_widget = ViewBase(content=widget, parent=self, view_id=resolved_id, title=title)
|
||||
|
||||
view_widget.change_object_name(resolved_id)
|
||||
view_widget = ViewBase(content=widget, parent=self, id=id, title=title)
|
||||
|
||||
idx = self.stack.addWidget(view_widget)
|
||||
self._view_index[resolved_id] = idx
|
||||
self._view_index[id] = idx
|
||||
return item
|
||||
|
||||
def set_current(self, id: str) -> None:
|
||||
@@ -191,12 +175,6 @@ class BECMainApp(BECMainWindow):
|
||||
|
||||
# Internal: route sidebar selection to the stack
|
||||
def _on_view_selected(self, vid: str) -> None:
|
||||
# Special handling for views that can not be switched to (e.g. dark mode toggle)
|
||||
# Not registered as proper view with a stack index, so we ignore any logic below
|
||||
# as it will anyways not result in a stack switch.
|
||||
idx = self._view_index.get(vid)
|
||||
if idx is None or not (0 <= idx < self.stack.count()):
|
||||
return
|
||||
# Determine current view
|
||||
current_index = self.stack.currentIndex()
|
||||
current_view = (
|
||||
@@ -222,160 +200,6 @@ class BECMainApp(BECMainWindow):
|
||||
if hasattr(new_view, "on_enter"):
|
||||
new_view.on_enter()
|
||||
|
||||
def _setup_guided_tour(self):
|
||||
"""
|
||||
Setup the guided tour for the main application.
|
||||
Registers key UI components and delegates to views for their internal components.
|
||||
"""
|
||||
tour_steps = []
|
||||
|
||||
# --- General Layout Components ---
|
||||
|
||||
# Register the sidebar toggle button
|
||||
toggle_step = self.guided_tour.register_widget(
|
||||
widget=self.sidebar.toggle,
|
||||
title="Sidebar Toggle",
|
||||
text="Click this button to expand or collapse the sidebar. When expanded, you can see full navigation item titles and section names.",
|
||||
)
|
||||
tour_steps.append(toggle_step)
|
||||
|
||||
# Register the sidebar icons
|
||||
sidebar_dock_area = self.sidebar.components.get("dock_area")
|
||||
if sidebar_dock_area:
|
||||
dock_step = self.guided_tour.register_widget(
|
||||
widget=sidebar_dock_area,
|
||||
title="Dock Area View",
|
||||
text="Click here to access the Dock Area view, where you can manage and arrange your dockable panels.",
|
||||
)
|
||||
tour_steps.append(dock_step)
|
||||
|
||||
sidebar_device_manager = self.sidebar.components.get("device_manager")
|
||||
if sidebar_device_manager:
|
||||
device_manager_step = self.guided_tour.register_widget(
|
||||
widget=sidebar_device_manager,
|
||||
title="Device Manager View",
|
||||
text="Click here to open the Device Manager view, where you can view and manage device configs.",
|
||||
)
|
||||
tour_steps.append(device_manager_step)
|
||||
|
||||
sidebar_developer_view = self.sidebar.components.get("developer_view")
|
||||
if sidebar_developer_view:
|
||||
developer_view_step = self.guided_tour.register_widget(
|
||||
widget=sidebar_developer_view,
|
||||
title="Developer View",
|
||||
text="Click here to access the Developer view to write scripts and macros.",
|
||||
)
|
||||
tour_steps.append(developer_view_step)
|
||||
|
||||
# Register the dark mode toggle
|
||||
dark_mode_item = self.sidebar.components.get("dark_mode")
|
||||
if dark_mode_item:
|
||||
dark_mode_step = self.guided_tour.register_widget(
|
||||
widget=dark_mode_item,
|
||||
title="Theme Toggle",
|
||||
text="Switch between light and dark themes. The theme preference is saved and will be applied when you restart the application.",
|
||||
)
|
||||
tour_steps.append(dark_mode_step)
|
||||
|
||||
# Register the client info label
|
||||
if hasattr(self, "_client_info_hover"):
|
||||
client_info_step = self.guided_tour.register_widget(
|
||||
widget=self._client_info_hover,
|
||||
title="Client Status",
|
||||
text="Displays status messages and information from the BEC Server.",
|
||||
)
|
||||
tour_steps.append(client_info_step)
|
||||
|
||||
# Register the scan progress bar if available
|
||||
if hasattr(self, "_scan_progress_hover"):
|
||||
progress_step = self.guided_tour.register_widget(
|
||||
widget=self._scan_progress_hover,
|
||||
title="Scan Progress",
|
||||
text="Monitor the progress of ongoing scans. Hover over the progress bar to see detailed information including elapsed time and estimated completion.",
|
||||
)
|
||||
tour_steps.append(progress_step)
|
||||
|
||||
# Register the notification indicator in the status bar
|
||||
if hasattr(self, "notification_indicator"):
|
||||
notif_step = self.guided_tour.register_widget(
|
||||
widget=self.notification_indicator,
|
||||
title="Notification Center",
|
||||
text="View system notifications, errors, and status updates. Click to filter notifications by type or expand to see all details.",
|
||||
)
|
||||
tour_steps.append(notif_step)
|
||||
|
||||
# --- View-Specific Components ---
|
||||
|
||||
# Register all views that can extend the tour
|
||||
for view_id, view_index in self._view_index.items():
|
||||
view_widget = self.stack.widget(view_index)
|
||||
if not view_widget or not hasattr(view_widget, "register_tour_steps"):
|
||||
continue
|
||||
|
||||
# Get the view's tour steps
|
||||
view_tour = view_widget.register_tour_steps(self.guided_tour, self)
|
||||
if view_tour is None:
|
||||
if hasattr(view_widget.content, "register_tour_steps"):
|
||||
view_tour = view_widget.content.register_tour_steps(self.guided_tour, self)
|
||||
if view_tour is None:
|
||||
continue
|
||||
|
||||
# Get the corresponding sidebar navigation item
|
||||
nav_item = self.sidebar.components.get(view_id)
|
||||
if not nav_item:
|
||||
continue
|
||||
|
||||
# Use the view's title for the navigation button
|
||||
nav_step = self.guided_tour.register_widget(
|
||||
widget=nav_item,
|
||||
title=view_tour.view_title,
|
||||
text=f"Let's explore the features of the {view_tour.view_title}.",
|
||||
)
|
||||
tour_steps.append(nav_step)
|
||||
tour_steps.extend(view_tour.step_ids)
|
||||
|
||||
# Create the tour with all registered steps
|
||||
if tour_steps:
|
||||
self.guided_tour.create_tour(tour_steps)
|
||||
|
||||
def start_guided_tour(self):
|
||||
"""
|
||||
Public method to start the guided tour.
|
||||
This can be called programmatically or connected to a menu/button action.
|
||||
"""
|
||||
self.guided_tour.start_tour()
|
||||
|
||||
def _add_guided_tour_to_menu(self):
|
||||
"""
|
||||
Add a 'Guided Tour' action to the Help menu.
|
||||
"""
|
||||
|
||||
# Find the Help menu
|
||||
menu_bar = self.menuBar()
|
||||
help_menu = None
|
||||
for action in menu_bar.actions():
|
||||
if action.text() == "Help":
|
||||
help_menu = action.menu()
|
||||
break
|
||||
|
||||
if help_menu:
|
||||
# Add separator before the tour action
|
||||
help_menu.addSeparator()
|
||||
|
||||
# Create and add the guided tour action
|
||||
tour_action = QAction("Start Guided Tour", self)
|
||||
tour_action.setIcon(material_icon("help"))
|
||||
tour_action.triggered.connect(self.start_guided_tour)
|
||||
tour_action.setShortcut("F1") # Add keyboard shortcut
|
||||
help_menu.addAction(tour_action)
|
||||
|
||||
def cleanup(self):
|
||||
for view_id, idx in self._view_index.items():
|
||||
view = self.stack.widget(idx)
|
||||
view.close()
|
||||
view.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
"""
|
||||
@@ -394,7 +218,6 @@ def main(): # pragma: no cover
|
||||
args, qt_args = parser.parse_known_args(sys.argv[1:])
|
||||
|
||||
app = QApplication([sys.argv[0], *qt_args])
|
||||
app.setApplicationName("BEC")
|
||||
apply_theme("dark")
|
||||
w = BECMainApp(show_examples=args.examples)
|
||||
|
||||
|
||||
@@ -127,10 +127,12 @@ class NavigationItem(QWidget):
|
||||
self._icon_size_expanded = QtCore.QSize(26, 26)
|
||||
self.icon_btn.setIconSize(self._icon_size_collapsed)
|
||||
# Remove QToolButton hover/pressed background/outline
|
||||
self.icon_btn.setStyleSheet("""
|
||||
self.icon_btn.setStyleSheet(
|
||||
"""
|
||||
QToolButton:hover { background: transparent; border: none; }
|
||||
QToolButton:pressed { background: transparent; border: none; }
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# Mini label below icon
|
||||
self.mini_lbl = QLabel(self._mini_text, self)
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Module for Admin View."""
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
|
||||
|
||||
|
||||
class AdminView(ViewBase):
|
||||
"""
|
||||
A view for administrators to change the current active experiment, manage messaging
|
||||
services, and more tasks reserved for users with admin privileges.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, view_id=view_id, title=title)
|
||||
self.admin_widget = BECAtlasAdminView(parent=self)
|
||||
self.set_content(self.admin_widget)
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> None:
|
||||
"""Called before the view is hidden.
|
||||
|
||||
Default implementation does nothing. Override in subclasses.
|
||||
"""
|
||||
self.admin_widget.logout()
|
||||
@@ -1,7 +1,7 @@
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
|
||||
from bec_widgets.applications.views.view import ViewBase, ViewTourSteps
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
|
||||
|
||||
class DeveloperView(ViewBase):
|
||||
@@ -14,89 +14,13 @@ class DeveloperView(ViewBase):
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, view_id=view_id, title=title, **kwargs)
|
||||
super().__init__(parent=parent, content=content, id=id, title=title)
|
||||
self.developer_widget = DeveloperWidget(parent=self)
|
||||
self.set_content(self.developer_widget)
|
||||
|
||||
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
|
||||
"""Register Developer View components with the guided tour.
|
||||
|
||||
Args:
|
||||
guided_tour: The GuidedTour instance to register with.
|
||||
main_app: The main application instance (for accessing set_current).
|
||||
|
||||
Returns:
|
||||
ViewTourSteps | None: Model containing view title and step IDs.
|
||||
"""
|
||||
step_ids = []
|
||||
dev_widget = self.developer_widget
|
||||
|
||||
# IDE Toolbar
|
||||
def get_ide_toolbar():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.toolbar, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_toolbar,
|
||||
title="IDE Toolbar",
|
||||
text="Quick access to save files, execute scripts, and configure IDE settings. Use the toolbar to manage your code and execution.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# IDE Explorer
|
||||
def get_ide_explorer():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.explorer_dock.widget(), None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_explorer,
|
||||
title="File Explorer",
|
||||
text="Browse and manage your macro files. Create new files, open existing ones, and organize your scripts.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# IDE Editor
|
||||
def get_ide_editor():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.monaco_dock.widget(), None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_editor,
|
||||
title="Code Editor",
|
||||
text="Write and edit Python code with syntax highlighting, auto-completion, and signature help. Monaco editor provides a modern coding experience.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# IDE Console
|
||||
def get_ide_console():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.console_dock.widget(), None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_console,
|
||||
title="BEC Shell Console",
|
||||
text="Interactive Python console with BEC integration. Execute commands, test code snippets, and interact with the BEC system in real-time.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# IDE Plotting Area
|
||||
def get_ide_plotting():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.plotting_ads, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_plotting,
|
||||
title="Plotting Area",
|
||||
text="View plots and visualizations generated by your scripts. Arrange multiple plots in a flexible layout.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
return ViewTourSteps(view_title="Developer View", step_ids=step_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
@@ -126,11 +50,7 @@ if __name__ == "__main__":
|
||||
_app.resize(width, height)
|
||||
developer_view = DeveloperView()
|
||||
_app.add_view(
|
||||
icon="code_blocks",
|
||||
title="IDE",
|
||||
widget=developer_view,
|
||||
view_id="developer_view",
|
||||
exclusive=True,
|
||||
icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True
|
||||
)
|
||||
_app.show()
|
||||
# developer_view.show()
|
||||
|
||||
@@ -94,11 +94,11 @@ class DeveloperWidget(DockAreaWidget):
|
||||
self.explorer = IDEExplorer(self)
|
||||
self.explorer.setObjectName("Explorer")
|
||||
|
||||
self.console = BECShell(self, rpc_exposed=False)
|
||||
self.console = BECShell(self)
|
||||
self.console.setObjectName("BEC Shell")
|
||||
self.terminal = WebConsole(self, rpc_exposed=False)
|
||||
self.terminal = WebConsole(self)
|
||||
self.terminal.setObjectName("Terminal")
|
||||
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
|
||||
self.monaco = MonacoDock(self)
|
||||
self.monaco.setObjectName("MonacoEditor")
|
||||
self.monaco.save_enabled.connect(self._on_save_enabled_update)
|
||||
self.plotting_ads = BECDockArea(
|
||||
|
||||
@@ -31,7 +31,7 @@ logger = bec_logger.logger
|
||||
class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
|
||||
"""Popup dialog to test Ophyd device configurations interactively."""
|
||||
|
||||
def __init__(self, parent=None, config: dict | None = None): # type: ignore
|
||||
def __init__(self, parent=None, config: dict | None = None): # type:ignore
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Device Manager Ophyd Test")
|
||||
self._config_status = ConfigStatus.UNKNOWN.value
|
||||
@@ -133,7 +133,7 @@ class DeviceFormDialog(QtWidgets.QDialog):
|
||||
# validated: config_status, connection_status
|
||||
accepted_data = QtCore.Signal(dict, int, int, str, str)
|
||||
|
||||
def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type: ignore
|
||||
def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type:ignore
|
||||
super().__init__(parent)
|
||||
# Track old device name if config is edited
|
||||
self._old_device_name: str = ""
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, List, Tuple
|
||||
from typing import TYPE_CHECKING, List, Tuple
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import apply_theme, material_icon
|
||||
|
||||
@@ -103,14 +103,16 @@ class CustomBusyWidget(QWidget):
|
||||
button_width = int(button_height * aspect_ratio)
|
||||
self.cancel_button.setFixedSize(button_width, button_height)
|
||||
color = get_accent_colors()
|
||||
self.cancel_button.setStyleSheet(f"""
|
||||
self.cancel_button.setStyleSheet(
|
||||
f"""
|
||||
QPushButton {{
|
||||
background-color: {color.emergency.name()};
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# Layout
|
||||
content_layout = QVBoxLayout(self)
|
||||
@@ -126,10 +128,12 @@ class CustomBusyWidget(QWidget):
|
||||
bg_color = color._colors.get("BG", None)
|
||||
if bg_color is None: # Fallback if missing
|
||||
bg_color = QColor(50, 50, 50, 255)
|
||||
self.setStyleSheet(f"""
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
background-color: {bg_color.name()};
|
||||
border-radius: 12px;
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
def _ui_scale(self) -> int:
|
||||
parent = self.parent()
|
||||
@@ -169,7 +173,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
self._upload_redis_dialog: UploadRedisDialog | None = None
|
||||
self._dialog_validation_connection: QMetaObject.Connection | None = None
|
||||
|
||||
# NOTE: We need here a separate config helper instance to avoid conflicts with
|
||||
# NOTE: We need here a seperate config helper instance to avoid conflicts with
|
||||
# other communications to REDIS as uploading a config through a CommunicationConfigAction
|
||||
# will block if we use the config_helper from self.client.config._config_helper
|
||||
self._config_helper = config_helper.ConfigHelper(self.client.connector)
|
||||
@@ -607,8 +611,8 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
self.device_table_view._is_config_in_sync_with_redis()
|
||||
)
|
||||
validation_results = self.device_table_view.get_validation_results()
|
||||
for config, config_status, connection_status in validation_results.values():
|
||||
if connection_status == ConnectionStatus.CONNECTED.value:
|
||||
for config, config_status, connnection_status in validation_results.values():
|
||||
if connnection_status == ConnectionStatus.CONNECTED.value:
|
||||
self.device_table_view.update_device_validation(
|
||||
config, config_status, ConnectionStatus.CAN_CONNECT, ""
|
||||
)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Module for Device Manager View."""
|
||||
|
||||
from qtpy.QtCore import QRect
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
|
||||
DeviceManagerWidget,
|
||||
)
|
||||
from bec_widgets.applications.views.view import ViewBase, ViewTourSteps
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
@@ -20,21 +19,11 @@ class DeviceManagerView(ViewBase):
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
content=content,
|
||||
view_id=view_id,
|
||||
title=title,
|
||||
rpc_passthrough_children=False,
|
||||
**kwargs,
|
||||
)
|
||||
self.device_manager_widget = DeviceManagerWidget(
|
||||
parent=self, rpc_exposed=False, rpc_passthrough_children=False
|
||||
)
|
||||
super().__init__(parent=parent, content=content, id=id, title=title)
|
||||
self.device_manager_widget = DeviceManagerWidget(parent=self)
|
||||
self.set_content(self.device_manager_widget)
|
||||
|
||||
@SafeSlot()
|
||||
@@ -45,110 +34,6 @@ class DeviceManagerView(ViewBase):
|
||||
"""
|
||||
self.device_manager_widget.on_enter()
|
||||
|
||||
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
|
||||
"""Register Device Manager components with the guided tour.
|
||||
|
||||
Args:
|
||||
guided_tour: The GuidedTour instance to register with.
|
||||
main_app: The main application instance (for accessing set_current).
|
||||
|
||||
Returns:
|
||||
ViewTourSteps | None: Model containing view title and step IDs.
|
||||
"""
|
||||
step_ids = []
|
||||
dm_widget = self.device_manager_widget
|
||||
|
||||
# The device_manager_widget is not yet initialized, so we will register
|
||||
# tour steps for its uninitialized state.
|
||||
|
||||
# Register Load Current Config button
|
||||
def get_load_current():
|
||||
main_app.set_current("device_manager")
|
||||
if dm_widget._initialized is True:
|
||||
return (None, None)
|
||||
return (dm_widget.button_load_current_config, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_load_current,
|
||||
title="Load Current Config",
|
||||
text="Load the current device configuration from the BEC server.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# Register Load Config From File button
|
||||
def get_load_file():
|
||||
main_app.set_current("device_manager")
|
||||
if dm_widget._initialized is True:
|
||||
return (None, None)
|
||||
return (dm_widget.button_load_config_from_file, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_load_file,
|
||||
title="Load Config From File",
|
||||
text="Load a device configuration from a YAML file on disk.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
## Register steps for the initialized state
|
||||
# Register main device table
|
||||
def get_device_table():
|
||||
main_app.set_current("device_manager")
|
||||
if dm_widget._initialized is False:
|
||||
return (None, None)
|
||||
return (dm_widget.device_manager_display.device_table_view, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_device_table,
|
||||
title="Device Table",
|
||||
text="This table displays the config that is prepared to be uploaded to the BEC server. It allows users to review and modify device config settings, and also validate them before uploading to the BEC server.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
col_text_mapping = {
|
||||
0: "Shows if a device configuration is valid. Automatically validated when adding a new device.",
|
||||
1: "Shows if a device is connectable. Validated on demand.",
|
||||
2: "Device name, unique across all devices within a config.",
|
||||
3: "Device class used to initialize the device on the BEC server.",
|
||||
4: "Defines how BEC treats readings of the device during scans. The options are 'monitored', 'baseline', 'async', 'continuous' or 'on_demand'.",
|
||||
5: "Defines how BEC reacts if a device readback fails. Options are 'raise', 'retry', or 'buffer'.",
|
||||
6: "User-defined tags associated with the device.",
|
||||
7: "A brief description of the device.",
|
||||
8: "Device is enabled when the configuration is loaded.",
|
||||
9: "Device is set to read-only.",
|
||||
10: "This flag allows to configure if the 'trigger' method of the device is called during scans.",
|
||||
}
|
||||
|
||||
# We have at least one device registered
|
||||
def get_device_table_row(column: int):
|
||||
main_app.set_current("device_manager")
|
||||
if dm_widget._initialized is False:
|
||||
return (None, None)
|
||||
table = dm_widget.device_manager_display.device_table_view.table
|
||||
header = table.horizontalHeader()
|
||||
x = header.sectionViewportPosition(column)
|
||||
table.horizontalScrollBar().setValue(x)
|
||||
# Recompute after scrolling
|
||||
x = header.sectionViewportPosition(column)
|
||||
w = header.sectionSize(column)
|
||||
h = header.height()
|
||||
rect = QRect(x, 0, w, h)
|
||||
top_left = header.viewport().mapTo(main_app, rect.topLeft())
|
||||
|
||||
return (QRect(top_left, rect.size()), col_text_mapping.get(column, ""))
|
||||
|
||||
for col, text in col_text_mapping.items():
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=lambda col=col: get_device_table_row(col),
|
||||
title=f"{dm_widget.device_manager_display.device_table_view.table.horizontalHeaderItem(col).text()}",
|
||||
text=text,
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
if not step_ids:
|
||||
return None
|
||||
|
||||
return ViewTourSteps(view_title="Device Manager", step_ids=step_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
@@ -180,7 +65,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
_app.add_view(
|
||||
icon="display_settings",
|
||||
title="Device Manager",
|
||||
view_id="device_manager",
|
||||
id="device_manager",
|
||||
widget=device_manager_view.device_manager_widget,
|
||||
mini_text="DM",
|
||||
)
|
||||
|
||||
@@ -22,8 +22,8 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent=None, client=None, **kwargs):
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(parent=parent, client=client)
|
||||
self.stacked_layout = QtWidgets.QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
|
||||
@@ -9,23 +9,16 @@ class DockAreaView(ViewBase):
|
||||
Modular dock area view for arranging and managing multiple dockable widgets.
|
||||
"""
|
||||
|
||||
RPC_CONTENT_CLASS = BECDockArea
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, view_id=view_id, title=title, **kwargs)
|
||||
super().__init__(parent=parent, content=content, id=id, title=title)
|
||||
self.dock_area = BECDockArea(
|
||||
self,
|
||||
profile_namespace="bec",
|
||||
auto_profile_namespace=False,
|
||||
object_name="DockArea",
|
||||
rpc_exposed=False,
|
||||
self, profile_namespace="bec", auto_profile_namespace=False, object_name="DockArea"
|
||||
)
|
||||
self.set_content(self.dock_area)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtCore import QEventLoop
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
@@ -17,26 +14,13 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class ViewTourSteps(BaseModel):
|
||||
"""Model representing tour steps for a view.
|
||||
|
||||
Attributes:
|
||||
view_title: The human-readable title of the view.
|
||||
step_ids: List of registered step IDs in the order they should appear.
|
||||
"""
|
||||
|
||||
view_title: str
|
||||
step_ids: List[str]
|
||||
|
||||
|
||||
class ViewBase(BECWidget, QWidget):
|
||||
class ViewBase(QWidget):
|
||||
"""Wrapper for a content widget used inside the main app's stacked view.
|
||||
|
||||
Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden.
|
||||
@@ -44,28 +28,21 @@ class ViewBase(BECWidget, QWidget):
|
||||
Args:
|
||||
content (QWidget): The actual view widget to display.
|
||||
parent (QWidget | None): Parent widget.
|
||||
view_id (str | None): Optional view view_id, useful for debugging or introspection.
|
||||
id (str | None): Optional view id, useful for debugging or introspection.
|
||||
title (str | None): Optional human-readable title.
|
||||
"""
|
||||
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
USER_ACCESS = ["activate"]
|
||||
RPC_CONTENT_CLASS: type[QWidget] | None = None
|
||||
RPC_CONTENT_ATTR = "content"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
super().__init__(parent=parent)
|
||||
self.content: QWidget | None = None
|
||||
self.view_id = view_id
|
||||
self.view_id = id
|
||||
self.view_title = title
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
@@ -99,41 +76,6 @@ class ViewBase(BECWidget, QWidget):
|
||||
"""
|
||||
return True
|
||||
|
||||
@SafeSlot()
|
||||
def activate(self) -> None:
|
||||
"""Switch the parent application to this view."""
|
||||
if not self.view_id:
|
||||
raise ValueError("Cannot switch view without a view_id.")
|
||||
|
||||
parent = self.parent()
|
||||
while parent is not None:
|
||||
if hasattr(parent, "set_current"):
|
||||
parent.set_current(self.view_id)
|
||||
return
|
||||
parent = parent.parent()
|
||||
raise RuntimeError("Could not find a parent application with set_current().")
|
||||
|
||||
def cleanup(self):
|
||||
if self.content is not None:
|
||||
self.content.close()
|
||||
self.content.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
|
||||
"""Register this view's components with the guided tour.
|
||||
|
||||
Args:
|
||||
guided_tour: The GuidedTour instance to register with.
|
||||
main_app: The main application instance (for accessing set_current).
|
||||
|
||||
Returns:
|
||||
ViewTourSteps | None: A model containing the view title and step IDs,
|
||||
or None if this view has no tour steps.
|
||||
|
||||
Override this method in subclasses to register view-specific components.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
####################################################################################################
|
||||
# Example views for demonstration/testing purposes
|
||||
@@ -160,17 +102,17 @@ class WaveformViewPopup(ViewBase): # pragma: no cover
|
||||
self.device_edit.insertItem(0, "")
|
||||
self.device_edit.setEditable(True)
|
||||
self.device_edit.setCurrentIndex(0)
|
||||
self.signal_edit = SignalComboBox(parent=self)
|
||||
self.signal_edit.include_config_signals = False
|
||||
self.signal_edit.insertItem(0, "")
|
||||
self.signal_edit.setEditable(True)
|
||||
self.device_edit.currentTextChanged.connect(self.signal_edit.set_device)
|
||||
self.device_edit.device_reset.connect(self.signal_edit.reset_selection)
|
||||
self.entry_edit = SignalComboBox(parent=self)
|
||||
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)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow(label)
|
||||
form.addRow("Device", self.device_edit)
|
||||
form.addRow("Signal", self.signal_edit)
|
||||
form.addRow("Signal", self.entry_edit)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
|
||||
buttons.accepted.connect(dialog.accept)
|
||||
@@ -182,7 +124,7 @@ class WaveformViewPopup(ViewBase): # pragma: no cover
|
||||
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
self.waveform.plot(
|
||||
device_y=self.device_edit.currentText(), signal_y=self.signal_edit.currentText()
|
||||
y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText()
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
@@ -307,7 +249,7 @@ class WaveformViewInline(ViewBase): # pragma: no cover
|
||||
dev = self.device_edit.currentText()
|
||||
sig = self.entry_edit.currentText()
|
||||
if dev and sig:
|
||||
self.waveform.plot(device_y=dev, signal_y=sig)
|
||||
self.waveform.plot(y_name=dev, y_entry=sig)
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def _show_waveform_without_changes(self):
|
||||
|
||||
@@ -21,7 +21,7 @@ logger = bec_logger.logger
|
||||
|
||||
|
||||
class _WidgetsEnumType(str, enum.Enum):
|
||||
"""Enum for the available widgets, to be generated programmatically"""
|
||||
"""Enum for the available widgets, to be generated programatically"""
|
||||
|
||||
...
|
||||
|
||||
@@ -33,6 +33,7 @@ _Widgets = {
|
||||
"BECShell": "BECShell",
|
||||
"BECStatusBox": "BECStatusBox",
|
||||
"DapComboBox": "DapComboBox",
|
||||
"DarkModeButton": "DarkModeButton",
|
||||
"DeviceBrowser": "DeviceBrowser",
|
||||
"Heatmap": "Heatmap",
|
||||
"Image": "Image",
|
||||
@@ -89,16 +90,6 @@ except ImportError as e:
|
||||
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
|
||||
|
||||
|
||||
class AdminView(RPCBase):
|
||||
"""A view for administrators to change the current active experiment, manage messaging"""
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
Switch the parent application to this view.
|
||||
"""
|
||||
|
||||
|
||||
class AutoUpdates(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
@@ -197,7 +188,7 @@ class BECDockArea(RPCBase):
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
such as `AdvancedDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
object_name(str | None): Optional object name to assign to the created widget.
|
||||
@@ -209,21 +200,15 @@ class BECDockArea(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def widget_map(self, bec_widgets_only: "bool" = True) -> "dict[str, QWidget]":
|
||||
def widget_map(self) -> "dict[str, QWidget]":
|
||||
"""
|
||||
Return a dictionary mapping widget names to their corresponding widgets.
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def widget_list(self, bec_widgets_only: "bool" = True) -> "list[QWidget]":
|
||||
def widget_list(self) -> "list[QWidget]":
|
||||
"""
|
||||
Return a list of widgets contained in the dock area.
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
Return a list of all widgets contained in the dock area.
|
||||
"""
|
||||
|
||||
@property
|
||||
@@ -360,7 +345,7 @@ class BECDockArea(RPCBase):
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def load_profile(self, name: "str | None" = None):
|
||||
def load_profile(self, name: "str | None" = None, start_empty: "bool" = False):
|
||||
"""
|
||||
Load a workspace profile.
|
||||
|
||||
@@ -369,6 +354,7 @@ class BECDockArea(RPCBase):
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile to load. If None, prompts the user.
|
||||
start_empty (bool): If True, load a profile without any widgets. Danger of overwriting the dynamic state of that profile.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
@@ -985,7 +971,7 @@ class Curve(RPCBase):
|
||||
|
||||
|
||||
class DapComboBox(RPCBase):
|
||||
"""Editable combobox listing the available DAP models."""
|
||||
"""The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC."""
|
||||
|
||||
@rpc_call
|
||||
def select_y_axis(self, y_axis: str):
|
||||
@@ -1011,17 +997,27 @@ class DapComboBox(RPCBase):
|
||||
Slot to update the fit model.
|
||||
|
||||
Args:
|
||||
fit_name(str): Fit model name.
|
||||
default_device(str): Default device name.
|
||||
"""
|
||||
|
||||
|
||||
class DeveloperView(RPCBase):
|
||||
"""A view for users to write scripts and macros and execute them within the application."""
|
||||
class DarkModeButton(RPCBase):
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
def attach(self):
|
||||
"""
|
||||
Switch the parent application to this view.
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1091,259 +1087,6 @@ class DeviceInputBase(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class DeviceManagerView(RPCBase):
|
||||
"""A view for users to manage devices within the application."""
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
Switch the parent application to this view.
|
||||
"""
|
||||
|
||||
|
||||
class DockAreaView(RPCBase):
|
||||
"""Modular dock area view for arranging and managing multiple dockable widgets."""
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
Switch the parent application to this view.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def new(
|
||||
self,
|
||||
widget: "QWidget | str",
|
||||
*,
|
||||
closable: "bool" = True,
|
||||
floatable: "bool" = True,
|
||||
movable: "bool" = True,
|
||||
start_floating: "bool" = False,
|
||||
where: "Literal['left', 'right', 'top', 'bottom'] | None" = None,
|
||||
tab_with: "CDockWidget | QWidget | str | None" = None,
|
||||
relative_to: "CDockWidget | QWidget | str | None" = None,
|
||||
show_title_bar: "bool | None" = None,
|
||||
title_buttons: "Mapping[str, bool] | Sequence[str] | str | None" = None,
|
||||
show_settings_action: "bool | None" = None,
|
||||
promote_central: "bool" = False,
|
||||
object_name: "str | None" = None,
|
||||
**widget_kwargs,
|
||||
) -> "QWidget | BECWidget":
|
||||
"""
|
||||
Create a new widget (or reuse an instance) and add it as a dock.
|
||||
|
||||
Args:
|
||||
widget(QWidget | str): Instance or registered widget type string.
|
||||
closable(bool): Whether the dock is closable.
|
||||
floatable(bool): Whether the dock is floatable.
|
||||
movable(bool): Whether the dock is movable.
|
||||
start_floating(bool): Whether to start the dock floating.
|
||||
where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when
|
||||
``relative_to`` is provided without an explicit value).
|
||||
tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside.
|
||||
relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor.
|
||||
When supplied and ``where`` is ``None``, the new dock inherits the
|
||||
anchor's current dock area.
|
||||
show_title_bar(bool | None): Explicitly show or hide the dock area's title bar.
|
||||
title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should
|
||||
remain visible. Provide a mapping of button names (``"float"``,
|
||||
``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans,
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
object_name(str | None): Optional object name to assign to the created widget.
|
||||
**widget_kwargs: Additional keyword arguments passed to the widget constructor
|
||||
when creating by type name.
|
||||
|
||||
Returns:
|
||||
BECWidget: The created or reused widget instance.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def widget_map(self, bec_widgets_only: "bool" = True) -> "dict[str, QWidget]":
|
||||
"""
|
||||
Return a dictionary mapping widget names to their corresponding widgets.
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def widget_list(self, bec_widgets_only: "bool" = True) -> "list[QWidget]":
|
||||
"""
|
||||
Return a list of widgets contained in the dock area.
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def workspace_is_locked(self) -> "bool":
|
||||
"""
|
||||
Get or set the lock state of the workspace.
|
||||
|
||||
Returns:
|
||||
bool: True if the workspace is locked, False otherwise.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach_all(self):
|
||||
"""
|
||||
Re-attach floating docks back into the dock manager.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def delete_all(self):
|
||||
"""
|
||||
Delete all docks and their associated widgets.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def delete(self, object_name: "str") -> "bool":
|
||||
"""
|
||||
Remove a widget from the dock area by its object name.
|
||||
|
||||
Args:
|
||||
object_name: The object name of the widget to remove.
|
||||
|
||||
Returns:
|
||||
bool: True if the widget was found and removed, False otherwise.
|
||||
|
||||
Raises:
|
||||
ValueError: If no widget with the given object name is found.
|
||||
|
||||
Example:
|
||||
>>> dock_area.delete("my_widget")
|
||||
True
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_layout_ratios(
|
||||
self,
|
||||
*,
|
||||
horizontal: "Sequence[float] | Mapping[int | str, float] | None" = None,
|
||||
vertical: "Sequence[float] | Mapping[int | str, float] | None" = None,
|
||||
splitter_overrides: "Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None" = None,
|
||||
) -> "None":
|
||||
"""
|
||||
Adjust splitter ratios in the dock layout.
|
||||
|
||||
Args:
|
||||
horizontal: Weights applied to every horizontal splitter encountered.
|
||||
vertical: Weights applied to every vertical splitter encountered.
|
||||
splitter_overrides: Optional overrides targeting specific splitters identified
|
||||
by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based
|
||||
indices following the splitter hierarchy, starting from the root splitter.
|
||||
|
||||
Example:
|
||||
To build three columns with custom per-column ratios::
|
||||
|
||||
area.set_layout_ratios(
|
||||
horizontal=[1, 2, 1], # column widths
|
||||
splitter_overrides={
|
||||
0: [1, 2], # column 0 (two rows)
|
||||
1: [3, 2, 1], # column 1 (three rows)
|
||||
2: [1], # column 2 (single row)
|
||||
},
|
||||
)
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def describe_layout(self) -> "list[dict[str, Any]]":
|
||||
"""
|
||||
Return metadata describing splitter paths, orientations, and contained docks.
|
||||
|
||||
Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def mode(self) -> "str":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@mode.setter
|
||||
@rpc_call
|
||||
def mode(self) -> "str":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def list_profiles(self) -> "list[str]":
|
||||
"""
|
||||
List available workspace profiles in the current namespace.
|
||||
|
||||
Returns:
|
||||
list[str]: List of profile names.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def save_profile(
|
||||
self,
|
||||
name: "str | None" = None,
|
||||
*,
|
||||
show_dialog: "bool" = False,
|
||||
quick_select: "bool | None" = None,
|
||||
):
|
||||
"""
|
||||
Save the current workspace profile.
|
||||
|
||||
On first save of a given name:
|
||||
- writes a default copy to states/default/<name>.ini with tag=default and created_at
|
||||
- writes a user copy to states/user/<name>.ini with tag=user and created_at
|
||||
On subsequent saves of user-owned profiles:
|
||||
- updates both the default and user copies so restore uses the latest snapshot.
|
||||
Read-only bundled profiles cannot be overwritten.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile to save. If None and show_dialog is True,
|
||||
prompts the user.
|
||||
show_dialog (bool): If True, shows the SaveProfileDialog for user interaction.
|
||||
If False (default), saves directly without user interaction (useful for CLI usage).
|
||||
quick_select (bool | None): Whether to include the profile in quick selection.
|
||||
If None (default), uses the existing value or True for new profiles.
|
||||
Only used when show_dialog is False; otherwise the dialog provides the value.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def load_profile(self, name: "str | None" = None):
|
||||
"""
|
||||
Load a workspace profile.
|
||||
|
||||
Before switching, persist the current profile to the user copy.
|
||||
Prefer loading the user copy; fall back to the default copy.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile to load. If None, prompts the user.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def delete_profile(self, name: "str | None" = None, show_dialog: "bool" = False) -> "bool":
|
||||
"""
|
||||
Delete a workspace profile.
|
||||
|
||||
Args:
|
||||
name: The name of the profile to delete. If None, uses the currently
|
||||
selected profile from the toolbar combo box (for UI usage).
|
||||
show_dialog: If True, show confirmation dialog before deletion.
|
||||
Defaults to False for CLI/programmatic usage.
|
||||
|
||||
Returns:
|
||||
bool: True if the profile was deleted, False otherwise.
|
||||
|
||||
Raises:
|
||||
ValueError: If the profile is read-only or doesn't exist (when show_dialog=False).
|
||||
"""
|
||||
|
||||
|
||||
class DockAreaWidget(RPCBase):
|
||||
"""Lightweight dock area that exposes the core Qt ADS docking helpers without any"""
|
||||
|
||||
@@ -1396,7 +1139,7 @@ class DockAreaWidget(RPCBase):
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
such as `AdvancedDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.
|
||||
@@ -1425,21 +1168,15 @@ class DockAreaWidget(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def widget_map(self, bec_widgets_only: "bool" = True) -> "dict[str, QWidget]":
|
||||
def widget_map(self) -> "dict[str, QWidget]":
|
||||
"""
|
||||
Return a dictionary mapping widget names to their corresponding widgets.
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def widget_list(self, bec_widgets_only: "bool" = True) -> "list[QWidget]":
|
||||
def widget_list(self) -> "list[QWidget]":
|
||||
"""
|
||||
Return a list of widgets contained in the dock area.
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
Return a list of all widgets contained in the dock area.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
@@ -2228,12 +1965,12 @@ class Heatmap(RPCBase):
|
||||
@rpc_call
|
||||
def plot(
|
||||
self,
|
||||
device_x: "str",
|
||||
device_y: "str",
|
||||
device_z: "str",
|
||||
signal_x: "None | str" = None,
|
||||
signal_y: "None | str" = None,
|
||||
signal_z: "None | str" = None,
|
||||
x_name: "str",
|
||||
y_name: "str",
|
||||
z_name: "str",
|
||||
x_entry: "None | str" = None,
|
||||
y_entry: "None | str" = None,
|
||||
z_entry: "None | str" = None,
|
||||
color_map: "str | None" = "plasma",
|
||||
validate_bec: "bool" = True,
|
||||
interpolation: "Literal['linear', 'nearest'] | None" = None,
|
||||
@@ -2247,12 +1984,12 @@ class Heatmap(RPCBase):
|
||||
Plot the heatmap with the given x, y, and z data.
|
||||
|
||||
Args:
|
||||
device_x (str): The name of the x-axis device signal.
|
||||
device_y (str): The name of the y-axis device signal.
|
||||
device_z (str): The name of the z-axis device signal.
|
||||
signal_x (str | None): The entry for the x-axis device signal.
|
||||
signal_y (str | None): The entry for the y-axis device signal.
|
||||
signal_z (str | None): The entry for the z-axis device signal.
|
||||
x_name (str): The name of the x-axis signal.
|
||||
y_name (str): The name of the y-axis signal.
|
||||
z_name (str): The name of the z-axis signal.
|
||||
x_entry (str | None): The entry for the x-axis signal.
|
||||
y_entry (str | None): The entry for the y-axis signal.
|
||||
z_entry (str | None): The entry for the z-axis signal.
|
||||
color_map (str | None): The color map to use for the heatmap.
|
||||
validate_bec (bool): Whether to validate the entries against BEC signals.
|
||||
interpolation (Literal["linear", "nearest"] | None): The interpolation method to use.
|
||||
@@ -2265,84 +2002,84 @@ class Heatmap(RPCBase):
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def device_x(self) -> "str":
|
||||
def x_device_name(self) -> "str":
|
||||
"""
|
||||
Device name for the X axis.
|
||||
"""
|
||||
|
||||
@device_x.setter
|
||||
@x_device_name.setter
|
||||
@rpc_call
|
||||
def device_x(self) -> "str":
|
||||
def x_device_name(self) -> "str":
|
||||
"""
|
||||
Device name for the X axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def signal_x(self) -> "str":
|
||||
def x_device_entry(self) -> "str":
|
||||
"""
|
||||
Signal entry for the X axis device.
|
||||
"""
|
||||
|
||||
@signal_x.setter
|
||||
@x_device_entry.setter
|
||||
@rpc_call
|
||||
def signal_x(self) -> "str":
|
||||
def x_device_entry(self) -> "str":
|
||||
"""
|
||||
Signal entry for the X axis device.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def device_y(self) -> "str":
|
||||
def y_device_name(self) -> "str":
|
||||
"""
|
||||
Device name for the Y axis.
|
||||
"""
|
||||
|
||||
@device_y.setter
|
||||
@y_device_name.setter
|
||||
@rpc_call
|
||||
def device_y(self) -> "str":
|
||||
def y_device_name(self) -> "str":
|
||||
"""
|
||||
Device name for the Y axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def signal_y(self) -> "str":
|
||||
def y_device_entry(self) -> "str":
|
||||
"""
|
||||
Signal entry for the Y axis device.
|
||||
"""
|
||||
|
||||
@signal_y.setter
|
||||
@y_device_entry.setter
|
||||
@rpc_call
|
||||
def signal_y(self) -> "str":
|
||||
def y_device_entry(self) -> "str":
|
||||
"""
|
||||
Signal entry for the Y axis device.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def device_z(self) -> "str":
|
||||
def z_device_name(self) -> "str":
|
||||
"""
|
||||
Device name for the Z (color) axis.
|
||||
"""
|
||||
|
||||
@device_z.setter
|
||||
@z_device_name.setter
|
||||
@rpc_call
|
||||
def device_z(self) -> "str":
|
||||
def z_device_name(self) -> "str":
|
||||
"""
|
||||
Device name for the Z (color) axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def signal_z(self) -> "str":
|
||||
def z_device_entry(self) -> "str":
|
||||
"""
|
||||
Signal entry for the Z (color) axis device.
|
||||
"""
|
||||
|
||||
@signal_z.setter
|
||||
@z_device_entry.setter
|
||||
@rpc_call
|
||||
def signal_z(self) -> "str":
|
||||
def z_device_entry(self) -> "str":
|
||||
"""
|
||||
Signal entry for the Z (color) axis device.
|
||||
"""
|
||||
@@ -2764,28 +2501,28 @@ class Image(RPCBase):
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def device(self) -> "str":
|
||||
def device_name(self) -> "str":
|
||||
"""
|
||||
The name of the device to monitor for image data.
|
||||
"""
|
||||
|
||||
@device.setter
|
||||
@device_name.setter
|
||||
@rpc_call
|
||||
def device(self) -> "str":
|
||||
def device_name(self) -> "str":
|
||||
"""
|
||||
The name of the device to monitor for image data.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def signal(self) -> "str":
|
||||
def device_entry(self) -> "str":
|
||||
"""
|
||||
The signal/entry name to monitor on the device.
|
||||
"""
|
||||
|
||||
@signal.setter
|
||||
@device_entry.setter
|
||||
@rpc_call
|
||||
def signal(self) -> "str":
|
||||
def device_entry(self) -> "str":
|
||||
"""
|
||||
The signal/entry name to monitor on the device.
|
||||
"""
|
||||
@@ -2893,8 +2630,8 @@ class Image(RPCBase):
|
||||
@rpc_call
|
||||
def image(
|
||||
self,
|
||||
device: "str | None" = None,
|
||||
signal: "str | None" = None,
|
||||
device_name: "str | None" = None,
|
||||
device_entry: "str | None" = None,
|
||||
color_map: "str | None" = None,
|
||||
color_bar: "Literal['simple', 'full'] | None" = None,
|
||||
vrange: "tuple[int, int] | None" = None,
|
||||
@@ -2903,8 +2640,8 @@ class Image(RPCBase):
|
||||
Set the image source and update the image.
|
||||
|
||||
Args:
|
||||
device(str|None): The name of the device to monitor. If None or empty string, the current monitor will be disconnected.
|
||||
signal(str|None): The signal/entry name to monitor on the device.
|
||||
device_name(str|None): The name of the device to monitor. If None or empty string, the current monitor will be disconnected.
|
||||
device_entry(str|None): The signal/entry name to monitor on the device.
|
||||
color_map(str): The color map to use for the image.
|
||||
color_bar(str): The type of color bar to use. Options are "simple" or "full".
|
||||
vrange(tuple): The range of values to use for the color map.
|
||||
@@ -3202,7 +2939,7 @@ class MonacoDock(RPCBase):
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
such as `AdvancedDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.
|
||||
@@ -3231,21 +2968,15 @@ class MonacoDock(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def widget_map(self, bec_widgets_only: "bool" = True) -> "dict[str, QWidget]":
|
||||
def widget_map(self) -> "dict[str, QWidget]":
|
||||
"""
|
||||
Return a dictionary mapping widget names to their corresponding widgets.
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def widget_list(self, bec_widgets_only: "bool" = True) -> "list[QWidget]":
|
||||
def widget_list(self) -> "list[QWidget]":
|
||||
"""
|
||||
Return a list of widgets contained in the dock area.
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
Return a list of all widgets contained in the dock area.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
@@ -3922,14 +3653,14 @@ class MotorMap(RPCBase):
|
||||
|
||||
@rpc_call
|
||||
def map(
|
||||
self, device_x: "str", device_y: "str", validate_bec: "bool" = True, suppress_errors=False
|
||||
self, x_name: "str", y_name: "str", validate_bec: "bool" = True, suppress_errors=False
|
||||
) -> "None":
|
||||
"""
|
||||
Set the x and y motor names.
|
||||
|
||||
Args:
|
||||
device_x(str): The name of the x motor.
|
||||
device_y(str): The name of the y motor.
|
||||
x_name(str): The name of the x motor.
|
||||
y_name(str): The name of the y motor.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. If the validation fails, the changes are not applied.
|
||||
"""
|
||||
@@ -3951,28 +3682,28 @@ class MotorMap(RPCBase):
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def device_x(self) -> "str":
|
||||
def x_motor(self) -> "str":
|
||||
"""
|
||||
Name of the motor shown on the X axis.
|
||||
"""
|
||||
|
||||
@device_x.setter
|
||||
@x_motor.setter
|
||||
@rpc_call
|
||||
def device_x(self) -> "str":
|
||||
def x_motor(self) -> "str":
|
||||
"""
|
||||
Name of the motor shown on the X axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def device_y(self) -> "str":
|
||||
def y_motor(self) -> "str":
|
||||
"""
|
||||
Name of the motor shown on the Y axis.
|
||||
"""
|
||||
|
||||
@device_y.setter
|
||||
@y_motor.setter
|
||||
@rpc_call
|
||||
def device_y(self) -> "str":
|
||||
def y_motor(self) -> "str":
|
||||
"""
|
||||
Name of the motor shown on the Y axis.
|
||||
"""
|
||||
@@ -5517,12 +5248,12 @@ class ScatterWaveform(RPCBase):
|
||||
@rpc_call
|
||||
def plot(
|
||||
self,
|
||||
device_x: "str",
|
||||
device_y: "str",
|
||||
device_z: "str",
|
||||
signal_x: "None | str" = None,
|
||||
signal_y: "None | str" = None,
|
||||
signal_z: "None | str" = None,
|
||||
x_name: "str",
|
||||
y_name: "str",
|
||||
z_name: "str",
|
||||
x_entry: "None | str" = None,
|
||||
y_entry: "None | str" = None,
|
||||
z_entry: "None | str" = None,
|
||||
color_map: "str | None" = "plasma",
|
||||
label: "str | None" = None,
|
||||
validate_bec: "bool" = True,
|
||||
@@ -5531,12 +5262,12 @@ class ScatterWaveform(RPCBase):
|
||||
Plot the data from the device signals.
|
||||
|
||||
Args:
|
||||
device_x (str): The name of the x device signal.
|
||||
device_y (str): The name of the y device signal.
|
||||
device_z (str): The name of the z device signal.
|
||||
signal_x (None | str): The x entry of the device signal.
|
||||
signal_y (None | str): The y entry of the device signal.
|
||||
signal_z (None | str): The z entry of the device signal.
|
||||
x_name (str): The name of the x device signal.
|
||||
y_name (str): The name of the y device signal.
|
||||
z_name (str): The name of the z device signal.
|
||||
x_entry (None | str): The x entry of the device signal.
|
||||
y_entry (None | str): The y entry of the device signal.
|
||||
z_entry (None | str): The z entry of the device signal.
|
||||
color_map (str | None): The color map of the scatter waveform.
|
||||
label (str | None): The label of the curve.
|
||||
validate_bec (bool): Whether to validate the device signals with current BEC instance.
|
||||
@@ -5564,84 +5295,84 @@ class ScatterWaveform(RPCBase):
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def device_x(self) -> "str":
|
||||
def x_device_name(self) -> "str":
|
||||
"""
|
||||
Device name for the X axis.
|
||||
"""
|
||||
|
||||
@device_x.setter
|
||||
@x_device_name.setter
|
||||
@rpc_call
|
||||
def device_x(self) -> "str":
|
||||
def x_device_name(self) -> "str":
|
||||
"""
|
||||
Device name for the X axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def signal_x(self) -> "str":
|
||||
def x_device_entry(self) -> "str":
|
||||
"""
|
||||
Signal entry for the X axis device.
|
||||
"""
|
||||
|
||||
@signal_x.setter
|
||||
@x_device_entry.setter
|
||||
@rpc_call
|
||||
def signal_x(self) -> "str":
|
||||
def x_device_entry(self) -> "str":
|
||||
"""
|
||||
Signal entry for the X axis device.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def device_y(self) -> "str":
|
||||
def y_device_name(self) -> "str":
|
||||
"""
|
||||
Device name for the Y axis.
|
||||
"""
|
||||
|
||||
@device_y.setter
|
||||
@y_device_name.setter
|
||||
@rpc_call
|
||||
def device_y(self) -> "str":
|
||||
def y_device_name(self) -> "str":
|
||||
"""
|
||||
Device name for the Y axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def signal_y(self) -> "str":
|
||||
def y_device_entry(self) -> "str":
|
||||
"""
|
||||
Signal entry for the Y axis device.
|
||||
"""
|
||||
|
||||
@signal_y.setter
|
||||
@y_device_entry.setter
|
||||
@rpc_call
|
||||
def signal_y(self) -> "str":
|
||||
def y_device_entry(self) -> "str":
|
||||
"""
|
||||
Signal entry for the Y axis device.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def device_z(self) -> "str":
|
||||
def z_device_name(self) -> "str":
|
||||
"""
|
||||
Device name for the Z (color) axis.
|
||||
"""
|
||||
|
||||
@device_z.setter
|
||||
@z_device_name.setter
|
||||
@rpc_call
|
||||
def device_z(self) -> "str":
|
||||
def z_device_name(self) -> "str":
|
||||
"""
|
||||
Device name for the Z (color) axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def signal_z(self) -> "str":
|
||||
def z_device_entry(self) -> "str":
|
||||
"""
|
||||
Signal entry for the Z (color) axis device.
|
||||
"""
|
||||
|
||||
@signal_z.setter
|
||||
@z_device_entry.setter
|
||||
@rpc_call
|
||||
def signal_z(self) -> "str":
|
||||
def z_device_entry(self) -> "str":
|
||||
"""
|
||||
Signal entry for the Z (color) axis device.
|
||||
"""
|
||||
@@ -5811,16 +5542,6 @@ class TextBox(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class ViewBase(RPCBase):
|
||||
"""Wrapper for a content widget used inside the main app's stacked view."""
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
Switch the parent application to this view.
|
||||
"""
|
||||
|
||||
|
||||
class Waveform(RPCBase):
|
||||
"""Widget for plotting waveforms."""
|
||||
|
||||
@@ -6179,14 +5900,14 @@ class Waveform(RPCBase):
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def signal_x(self) -> "str | None":
|
||||
def x_entry(self) -> "str | None":
|
||||
"""
|
||||
The x signal name.
|
||||
"""
|
||||
|
||||
@signal_x.setter
|
||||
@x_entry.setter
|
||||
@rpc_call
|
||||
def signal_x(self) -> "str | None":
|
||||
def x_entry(self) -> "str | None":
|
||||
"""
|
||||
The x signal name.
|
||||
"""
|
||||
@@ -6253,14 +5974,13 @@ class Waveform(RPCBase):
|
||||
arg1: "list | np.ndarray | str | None" = None,
|
||||
y: "list | np.ndarray | None" = None,
|
||||
x: "list | np.ndarray | None" = None,
|
||||
device_x: "str | None" = None,
|
||||
device_y: "str | None" = None,
|
||||
signal_x: "str | None" = None,
|
||||
signal_y: "str | None" = None,
|
||||
x_name: "str | None" = None,
|
||||
y_name: "str | None" = None,
|
||||
x_entry: "str | None" = None,
|
||||
y_entry: "str | None" = None,
|
||||
color: "str | None" = None,
|
||||
label: "str | None" = None,
|
||||
dap: "str | list[str] | None" = None,
|
||||
dap_parameters: "dict | list | lmfit.Parameters | None | object" = None,
|
||||
dap: "str | None" = None,
|
||||
scan_id: "str | None" = None,
|
||||
scan_number: "int | None" = None,
|
||||
**kwargs,
|
||||
@@ -6269,27 +5989,22 @@ class Waveform(RPCBase):
|
||||
Plot a curve to the plot widget.
|
||||
|
||||
Args:
|
||||
arg1(list | np.ndarray | str | None): First argument, which can be x data, y data, or device_y.
|
||||
arg1(list | np.ndarray | str | None): First argument, which can be x data, y data, or y_name.
|
||||
y(list | np.ndarray): Custom y data to plot.
|
||||
x(list | np.ndarray): Custom y data to plot.
|
||||
device_x(str): Name of the x signal.
|
||||
x_name(str): Name of the x signal.
|
||||
- "auto": Use the best effort signal.
|
||||
- "timestamp": Use the timestamp signal.
|
||||
- "index": Use the index signal.
|
||||
- Custom signal name of a device from BEC.
|
||||
device_y(str): The name of the device for the y-axis.
|
||||
signal_x(str): The name of the entry for the x-axis.
|
||||
signal_y(str): The name of the entry for the y-axis.
|
||||
y_name(str): The name of the device for the y-axis.
|
||||
x_entry(str): The name of the entry for the x-axis.
|
||||
y_entry(str): The name of the entry for the y-axis.
|
||||
color(str): The color of the curve.
|
||||
label(str): The label of the curve.
|
||||
dap(str | list[str]): The dap model to use for the curve. When provided, a DAP curve is
|
||||
dap(str): The dap model to use for the curve. When provided, a DAP curve is
|
||||
attached automatically for device, history, or custom data sources. Use
|
||||
the same string as the LMFit model name, or a list of model names to build a composite.
|
||||
dap_parameters(dict | list | lmfit.Parameters | None): Optional lmfit parameter overrides sent to
|
||||
the DAP server. For a single model: values can be numeric (interpreted as fixed parameters)
|
||||
or dicts like `{"value": 1.0, "vary": False}`. For composite models (dap is list), use either
|
||||
a list aligned to the model list (each item is a param dict), or a dict of
|
||||
`{ "ModelName": { "param": {...} } }` when model names are unique.
|
||||
the same string as the LMFit model name.
|
||||
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
|
||||
the y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan resets.
|
||||
@@ -6303,10 +6018,9 @@ class Waveform(RPCBase):
|
||||
def add_dap_curve(
|
||||
self,
|
||||
device_label: "str",
|
||||
dap_name: "str | list[str]",
|
||||
dap_name: "str",
|
||||
color: "str | None" = None,
|
||||
dap_oversample: "int" = 1,
|
||||
dap_parameters: "dict | list | lmfit.Parameters | None" = None,
|
||||
**kwargs,
|
||||
) -> "Curve":
|
||||
"""
|
||||
@@ -6316,11 +6030,9 @@ class Waveform(RPCBase):
|
||||
|
||||
Args:
|
||||
device_label(str): The label of the source curve to add DAP to.
|
||||
dap_name(str | list[str]): The name of the DAP model to use, or a list of model
|
||||
names to build a composite model.
|
||||
dap_name(str): The name of the DAP model to use.
|
||||
color(str): The color of the curve.
|
||||
dap_oversample(int): The oversampling factor for the DAP curve.
|
||||
dap_parameters(dict | list | lmfit.Parameters | None): Optional lmfit parameter overrides sent to the DAP server.
|
||||
**kwargs
|
||||
|
||||
Returns:
|
||||
@@ -6401,22 +6113,6 @@ class Waveform(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class WaveformViewInline(RPCBase):
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
Switch the parent application to this view.
|
||||
"""
|
||||
|
||||
|
||||
class WaveformViewPopup(RPCBase):
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
Switch the parent application to this view.
|
||||
"""
|
||||
|
||||
|
||||
class WebConsole(RPCBase):
|
||||
"""A simple widget to display a website"""
|
||||
|
||||
|
||||
@@ -297,32 +297,14 @@ class BECGuiClient(RPCBase):
|
||||
return self._raise_all()
|
||||
return self._start(wait=wait)
|
||||
|
||||
def change_theme(self, theme: Literal["light", "dark"] | None = None) -> None:
|
||||
"""
|
||||
Apply a GUI theme or toggle between dark and light.
|
||||
|
||||
Args:
|
||||
theme(Literal["light", "dark"] | None): Theme to apply. If None, the current
|
||||
theme is fetched from the GUI and toggled.
|
||||
"""
|
||||
if not self._check_if_server_is_alive():
|
||||
self._start(wait=True)
|
||||
|
||||
with wait_for_server(self):
|
||||
if theme is None:
|
||||
current_theme = self.launcher._run_rpc("fetch_theme")
|
||||
next_theme = "light" if current_theme == "dark" else "dark"
|
||||
else:
|
||||
next_theme = theme
|
||||
self.launcher._run_rpc("change_theme", theme=next_theme)
|
||||
|
||||
def new(
|
||||
self,
|
||||
name: str | None = None,
|
||||
wait: bool = True,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
launch_script: str = "dock_area",
|
||||
startup_profile: str | Literal["restore", "skip"] | None = None,
|
||||
profile: str | None = None,
|
||||
start_empty: bool = False,
|
||||
**kwargs,
|
||||
) -> client.AdvancedDockArea:
|
||||
"""Create a new top-level dock area.
|
||||
@@ -332,81 +314,48 @@ class BECGuiClient(RPCBase):
|
||||
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
|
||||
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h).
|
||||
launch_script(str): The launch script to use. Defaults to "dock_area".
|
||||
startup_profile(str | Literal["restore", "skip"] | None): Startup mode for
|
||||
the dock area:
|
||||
- None: start in transient empty workspace
|
||||
- "restore": restore last-used profile
|
||||
- "skip": skip profile initialization
|
||||
- "<name>": load the named profile
|
||||
profile(str | None): The profile name to load. If None, loads the "general" profile.
|
||||
Use a profile name to load a specific saved profile.
|
||||
start_empty(bool): If True, start with an empty dock area when loading specified profile.
|
||||
**kwargs: Additional keyword arguments passed to the dock area.
|
||||
|
||||
Returns:
|
||||
client.AdvancedDockArea: The new dock area.
|
||||
|
||||
Examples:
|
||||
>>> gui.new() # Start with an empty unsaved workspace
|
||||
>>> gui.new(startup_profile="restore") # Restore last profile
|
||||
>>> gui.new(startup_profile="my_profile") # Load explicit profile
|
||||
"""
|
||||
if "profile" in kwargs or "start_empty" in kwargs:
|
||||
raise TypeError(
|
||||
"gui.new() no longer accepts 'profile' or 'start_empty'. Use 'startup_profile' instead."
|
||||
)
|
||||
Note:
|
||||
The "general" profile is mandatory and will always exist. If manually deleted,
|
||||
it will be automatically recreated.
|
||||
|
||||
Examples:
|
||||
>>> gui.new() # Start with the "general" profile
|
||||
>>> gui.new(profile="my_profile") # Load specific profile, if profile does not exist, the new profile is created empty with specified name
|
||||
>>> gui.new(start_empty=True) # Start with "general" profile but empty dock area
|
||||
>>> gui.new(profile="my_profile", start_empty=True) # Start with "my_profile" profile but empty dock area
|
||||
"""
|
||||
if not self._check_if_server_is_alive():
|
||||
self.start(wait=True)
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
return self._new_impl(
|
||||
name=name,
|
||||
geometry=geometry,
|
||||
widget = self.launcher._run_rpc(
|
||||
"launch",
|
||||
launch_script=launch_script,
|
||||
startup_profile=startup_profile,
|
||||
**kwargs,
|
||||
)
|
||||
return self._new_impl(
|
||||
name=name,
|
||||
geometry=geometry,
|
||||
launch_script=launch_script,
|
||||
startup_profile=startup_profile,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _new_impl(
|
||||
self,
|
||||
*,
|
||||
name: str | None,
|
||||
geometry: tuple[int, int, int, int] | None,
|
||||
launch_script: str,
|
||||
startup_profile: str | Literal["restore", "skip"] | None,
|
||||
**kwargs,
|
||||
):
|
||||
if launch_script == "dock_area":
|
||||
try:
|
||||
return self.launcher._run_rpc(
|
||||
"system.launch_dock_area",
|
||||
name=name,
|
||||
geometry=geometry,
|
||||
startup_profile=startup_profile,
|
||||
profile=profile,
|
||||
start_empty=start_empty,
|
||||
**kwargs,
|
||||
)
|
||||
except ValueError as exc:
|
||||
error = str(exc)
|
||||
if (
|
||||
"Unknown system RPC method: system.launch_dock_area" not in error
|
||||
and "has no attribute 'system.launch_dock_area'" not in error
|
||||
):
|
||||
raise
|
||||
logger.debug("Server does not support system.launch_dock_area; using launcher RPC")
|
||||
|
||||
return self.launcher._run_rpc(
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
widget = self.launcher._run_rpc(
|
||||
"launch",
|
||||
launch_script=launch_script,
|
||||
name=name,
|
||||
geometry=geometry,
|
||||
startup_profile=startup_profile,
|
||||
profile=profile,
|
||||
start_empty=start_empty,
|
||||
**kwargs,
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
"""Delete a dock area and its parent window.
|
||||
|
||||
@@ -94,7 +94,7 @@ logger = bec_logger.logger
|
||||
if self._base:
|
||||
self.content += """
|
||||
class _WidgetsEnumType(str, enum.Enum):
|
||||
\"\"\" Enum for the available widgets, to be generated programmatically \"\"\"
|
||||
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
|
||||
...
|
||||
"""
|
||||
|
||||
@@ -164,13 +164,17 @@ class {class_name}(RPCBase):"""
|
||||
self.content += f"""
|
||||
\"\"\"{class_docs}\"\"\"
|
||||
"""
|
||||
user_access_entries = self._get_user_access_entries(cls)
|
||||
if not user_access_entries:
|
||||
if not cls.USER_ACCESS:
|
||||
self.content += """...
|
||||
"""
|
||||
|
||||
for method_entry in user_access_entries:
|
||||
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
|
||||
for method in cls.USER_ACCESS:
|
||||
is_property_setter = False
|
||||
obj = getattr(cls, method, None)
|
||||
if obj is None:
|
||||
obj = getattr(cls, method.split(".setter")[0], None)
|
||||
is_property_setter = True
|
||||
method = method.split(".setter")[0]
|
||||
if obj is None:
|
||||
raise AttributeError(
|
||||
f"Method {method} not found in class {cls.__name__}. "
|
||||
@@ -212,34 +216,6 @@ class {class_name}(RPCBase):"""
|
||||
{doc}
|
||||
\"\"\""""
|
||||
|
||||
@staticmethod
|
||||
def _get_user_access_entries(cls) -> list[str]:
|
||||
entries = list(getattr(cls, "USER_ACCESS", []))
|
||||
content_cls = getattr(cls, "RPC_CONTENT_CLASS", None)
|
||||
if content_cls is not None:
|
||||
entries.extend(getattr(content_cls, "USER_ACCESS", []))
|
||||
return list(dict.fromkeys(entries))
|
||||
|
||||
@staticmethod
|
||||
def _resolve_method_object(cls, method_entry: str):
|
||||
method_name = method_entry
|
||||
is_property_setter = False
|
||||
|
||||
if method_entry.endswith(".setter"):
|
||||
is_property_setter = True
|
||||
method_name = method_entry.split(".setter")[0]
|
||||
|
||||
candidate_classes = [cls]
|
||||
content_cls = getattr(cls, "RPC_CONTENT_CLASS", None)
|
||||
if content_cls is not None:
|
||||
candidate_classes.append(content_cls)
|
||||
|
||||
for candidate_cls in candidate_classes:
|
||||
obj = getattr(candidate_cls, method_name, None)
|
||||
if obj is not None:
|
||||
return method_name, obj, is_property_setter
|
||||
return method_name, None, is_property_setter
|
||||
|
||||
def _rpc_call(self, timeout_info: dict[str, float | None]):
|
||||
"""
|
||||
Decorator to mark a method as an RPC call.
|
||||
|
||||
@@ -292,11 +292,6 @@ class RPCBase:
|
||||
return {
|
||||
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
|
||||
}
|
||||
rpc_enabled = msg_result.get("__rpc__", True)
|
||||
if rpc_enabled is False:
|
||||
return None
|
||||
|
||||
msg_result = dict(msg_result)
|
||||
cls = msg_result.pop("widget_class", None)
|
||||
msg_result.pop("__rpc__", None)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
import darkdetect
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_qthemes import apply_theme
|
||||
@@ -94,7 +93,6 @@ class GUIServer:
|
||||
"""
|
||||
Run the GUI server.
|
||||
"""
|
||||
logger.info("Starting GUIServer", repr(self))
|
||||
self.app = QApplication(sys.argv)
|
||||
if darkdetect.isDark():
|
||||
apply_theme("dark")
|
||||
@@ -103,11 +101,11 @@ class GUIServer:
|
||||
|
||||
self.app.setApplicationName("BEC")
|
||||
self.app.gui_id = self.gui_id # type: ignore
|
||||
self.app.gui_server = self # type: ignore # make server accessible from QApplication for getattr in widgets
|
||||
self.setup_bec_icon()
|
||||
|
||||
service_config = self._get_service_config()
|
||||
self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id)
|
||||
# self.dispatcher.start_cli_server(gui_id=self.gui_id)
|
||||
|
||||
if self.gui_class:
|
||||
self.launcher_window = LaunchWindow(
|
||||
@@ -120,7 +118,7 @@ class GUIServer:
|
||||
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
|
||||
|
||||
self.app.aboutToQuit.connect(self.shutdown)
|
||||
self.app.setQuitOnLastWindowClosed(True)
|
||||
self.app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
def sigint_handler(*args):
|
||||
# display message, for people to let it terminate gracefully
|
||||
@@ -129,7 +127,8 @@ class GUIServer:
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
|
||||
widget.close()
|
||||
self.shutdown()
|
||||
if self.app:
|
||||
self.app.quit()
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
signal.signal(signal.SIGTERM, sigint_handler)
|
||||
@@ -150,10 +149,9 @@ class GUIServer:
|
||||
self.app.setWindowIcon(icon)
|
||||
|
||||
def shutdown(self):
|
||||
logger.info("Shutdown GUIServer", repr(self))
|
||||
if self.launcher_window and shiboken6.isValid(self.launcher_window):
|
||||
self.launcher_window.close()
|
||||
self.launcher_window.deleteLater()
|
||||
"""
|
||||
Shutdown the GUI server.
|
||||
"""
|
||||
if pylsp_server.is_running():
|
||||
pylsp_server.stop()
|
||||
if self.dispatcher:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# pylint: skip-file
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.device import Device as BECDevice
|
||||
from bec_lib.device import Positioner as BECPositioner
|
||||
from bec_lib.device import ReadoutPriority
|
||||
@@ -220,9 +219,7 @@ class Device(FakeDevice):
|
||||
|
||||
|
||||
class DMMock:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._service = args[0]
|
||||
self.config_helper = ConfigHelper(self._service.connector, self._service._service_name)
|
||||
def __init__(self):
|
||||
self.devices = DeviceContainer()
|
||||
self.enabled_devices = [device for device in self.devices if device.enabled]
|
||||
|
||||
@@ -276,10 +273,6 @@ class DMMock:
|
||||
configs.append(device._config)
|
||||
return configs
|
||||
|
||||
def initialize(*_): ...
|
||||
|
||||
def shutdown(self): ...
|
||||
|
||||
|
||||
DEVICES = [
|
||||
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
|
||||
|
||||
@@ -88,8 +88,6 @@ class BECConnector:
|
||||
gui_id: str | None = None,
|
||||
object_name: str | None = None,
|
||||
root_widget: bool = False,
|
||||
rpc_exposed: bool = True,
|
||||
rpc_passthrough_children: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -101,10 +99,6 @@ class BECConnector:
|
||||
gui_id(str, optional): The GUI ID.
|
||||
object_name(str, optional): The object name.
|
||||
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
||||
rpc_exposed(bool, optional): If set to False, this instance is excluded from RPC registry broadcast and CLI namespace discovery.
|
||||
rpc_passthrough_children(bool, optional): Only relevant when ``rpc_exposed=False``.
|
||||
If True, RPC-visible children rebind to the next visible ancestor.
|
||||
If False (default), children stay hidden behind this widget.
|
||||
**kwargs:
|
||||
"""
|
||||
# Extract object_name from kwargs to not pass it to Qt class
|
||||
@@ -133,13 +127,8 @@ class BECConnector:
|
||||
# the function depends on BECClient, and BECDispatcher
|
||||
@SafeSlot()
|
||||
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
|
||||
app = QApplication.instance()
|
||||
gui_server = getattr(app, "gui_server", None)
|
||||
if gui_server and hasattr(gui_server, "shutdown"):
|
||||
gui_server.shutdown()
|
||||
logger.info("Disconnecting", repr(dispatcher))
|
||||
dispatcher.disconnect_all()
|
||||
dispatcher.stop_cli_server()
|
||||
|
||||
try: # shutdown ophyd threads if any
|
||||
from ophyd._pyepics_shim import _dispatcher
|
||||
@@ -167,7 +156,7 @@ class BECConnector:
|
||||
)
|
||||
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
|
||||
# If the gui_id is passed, it should be respected. However, this should be revisited since
|
||||
# If the gui_id is passed, it should be respected. However, this should be revisted since
|
||||
# the gui_id has to be unique, and may no longer be.
|
||||
if gui_id:
|
||||
self.config.gui_id = gui_id
|
||||
@@ -195,11 +184,6 @@ class BECConnector:
|
||||
|
||||
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
||||
self.root_widget = root_widget
|
||||
# If set to False, this instance is not exposed through RPC at all.
|
||||
self.rpc_exposed = bool(rpc_exposed)
|
||||
# If True on a hidden parent (rpc_exposed=False), children can bubble up to
|
||||
# the next visible RPC ancestor.
|
||||
self.rpc_passthrough_children = bool(rpc_passthrough_children)
|
||||
|
||||
self._update_object_name()
|
||||
|
||||
@@ -208,41 +192,11 @@ class BECConnector:
|
||||
try:
|
||||
if self.root_widget:
|
||||
return None
|
||||
connector_parent = self._get_rpc_parent_ancestor()
|
||||
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
return connector_parent.gui_id if connector_parent else None
|
||||
except:
|
||||
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
|
||||
|
||||
def _get_rpc_parent_ancestor(self) -> BECConnector | None:
|
||||
"""
|
||||
Find the nearest ancestor that is RPC-addressable.
|
||||
|
||||
Rules:
|
||||
- If an ancestor has ``rpc_exposed=False``, it is an explicit visibility
|
||||
boundary unless ``rpc_passthrough_children=True``.
|
||||
- If an ancestor has ``RPC=False`` (but remains rpc_exposed), it is treated
|
||||
as structural and children continue to the next ancestor.
|
||||
- Lookup always happens through ``WidgetHierarchy.get_becwidget_ancestor``
|
||||
so plain ``QWidget`` nodes between connectors are ignored.
|
||||
"""
|
||||
current = self
|
||||
while True:
|
||||
parent = WidgetHierarchy.get_becwidget_ancestor(current)
|
||||
if parent is None:
|
||||
return None
|
||||
|
||||
if not getattr(parent, "rpc_exposed", True):
|
||||
if getattr(parent, "rpc_passthrough_children", False):
|
||||
current = parent
|
||||
continue
|
||||
return parent
|
||||
|
||||
if getattr(parent, "RPC", True):
|
||||
return parent
|
||||
|
||||
current = parent
|
||||
return None
|
||||
|
||||
def change_object_name(self, name: str) -> None:
|
||||
"""
|
||||
Change the object name of the widget. Unregister old name and register the new one.
|
||||
@@ -261,9 +215,8 @@ class BECConnector:
|
||||
"""
|
||||
# 1) Enforce unique objectName among siblings with the same BECConnector parent
|
||||
self._enforce_unique_sibling_name()
|
||||
# 2) Register the object for RPC unless instance-level exposure is disabled.
|
||||
if getattr(self, "rpc_exposed", True):
|
||||
self.rpc_register.add_rpc(self)
|
||||
# 2) Register the object for RPC
|
||||
self.rpc_register.add_rpc(self)
|
||||
try:
|
||||
self.name_established.emit(self.object_name)
|
||||
except RuntimeError as e:
|
||||
@@ -281,7 +234,7 @@ class BECConnector:
|
||||
if not shb.isValid(self):
|
||||
return
|
||||
|
||||
parent_bec = WidgetHierarchy.get_becwidget_ancestor(self)
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
|
||||
if parent_bec:
|
||||
# We have a parent => only compare with siblings under that parent
|
||||
@@ -291,7 +244,7 @@ class BECConnector:
|
||||
# Use RPCRegister to avoid QApplication.allWidgets() during event processing.
|
||||
connections = self.rpc_register.list_all_connections().values()
|
||||
all_bec = [w for w in connections if isinstance(w, BECConnector) and shb.isValid(w)]
|
||||
siblings = [w for w in all_bec if WidgetHierarchy.get_becwidget_ancestor(w) is None]
|
||||
siblings = [w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None]
|
||||
|
||||
# Collect used names among siblings
|
||||
used_names = {sib.objectName() for sib in siblings if sib is not self}
|
||||
@@ -399,7 +352,7 @@ class BECConnector:
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# FIXME some thoughts are required to decide how this should work with rpc registry
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
|
||||
"""
|
||||
Apply the configuration to the widget.
|
||||
@@ -417,7 +370,7 @@ class BECConnector:
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
# FIXME some thoughts are required to decide how this should work with rpc registry
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
def load_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Load the configuration of the widget from YAML.
|
||||
|
||||
@@ -123,16 +123,17 @@ class BECDispatcher:
|
||||
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
|
||||
collections.defaultdict()
|
||||
)
|
||||
self.client = client
|
||||
|
||||
if client is None:
|
||||
if config is not None and not isinstance(config, ServiceConfig):
|
||||
# config is supposed to be a path
|
||||
config = ServiceConfig(config)
|
||||
if self.client is None:
|
||||
if config is not None:
|
||||
if not isinstance(config, ServiceConfig):
|
||||
# config is supposed to be a path
|
||||
config = ServiceConfig(config)
|
||||
self.client = BECClient(
|
||||
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
|
||||
)
|
||||
else:
|
||||
self.client = client
|
||||
if self.client.started:
|
||||
# have to reinitialize client to use proper connector
|
||||
logger.info("Shutting down BECClient to switch to QtRedisConnector")
|
||||
|
||||
@@ -20,13 +20,15 @@ class BECLogin(QWidget):
|
||||
|
||||
title = QLabel("Sign in", parent=self)
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
title.setStyleSheet("""
|
||||
title.setStyleSheet(
|
||||
"""
|
||||
#QLabel
|
||||
{
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
self.username = QLineEdit(parent=self)
|
||||
self.username.setPlaceholderText("Username")
|
||||
@@ -55,11 +57,13 @@ class BECLogin(QWidget):
|
||||
|
||||
self.username.setFocus()
|
||||
|
||||
self.setStyleSheet("""
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QLineEdit {
|
||||
padding: 8px;
|
||||
}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
def _clear_password(self):
|
||||
"""Clear the password field."""
|
||||
|
||||
@@ -43,7 +43,7 @@ class WidgetContainerUtils:
|
||||
if list_of_names is None:
|
||||
list_of_names = []
|
||||
ii = 0
|
||||
while ii < 1000: # 1000 is arbitrary!
|
||||
while ii < 1000: # 1000 is arbritrary!
|
||||
name_candidate = f"{name}_{ii}"
|
||||
if name_candidate not in list_of_names:
|
||||
return name_candidate
|
||||
|
||||
@@ -106,9 +106,7 @@ class TypedForm(BECWidget, QWidget):
|
||||
|
||||
def _add_griditem(self, item: FormItemSpec, row: int):
|
||||
grid = self._form_grid.layout()
|
||||
# Use title from FieldInfo if available, otherwise use the property name
|
||||
label_text = item.info.title if item.info.title else item.name
|
||||
label = QLabel(parent=self._form_grid, text=label_text)
|
||||
label = QLabel(parent=self._form_grid, text=item.name)
|
||||
label.setProperty("_model_field_name", item.name)
|
||||
label.setToolTip(item.info.description or item.name)
|
||||
grid.addWidget(label, row, 0)
|
||||
|
||||
@@ -71,7 +71,7 @@ class FormItemSpec(BaseModel):
|
||||
"""
|
||||
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
|
||||
to store most annotation info, since one of the main purposes is to store data for
|
||||
forms generated from pydantic models, but can also be composed from other sources or by hand.
|
||||
forms genrated from pydantic models, but can also be composed from other sources or by hand.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
@@ -192,7 +192,7 @@ class DynamicFormItem(QWidget):
|
||||
@abstractmethod
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget: QWidget
|
||||
"""Add the main data entry widget to self._main_widget and apply any
|
||||
"""Add the main data entry widget to self._main_widget and appply any
|
||||
constraints from the field info"""
|
||||
|
||||
@SafeSlot()
|
||||
@@ -231,8 +231,6 @@ class StrFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
if spec.info.description:
|
||||
self._main_widget.setPlaceholderText(spec.info.description)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QLineEdit()
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Module providing fuzzy search utilities for the BEC widgets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from thefuzz import fuzz
|
||||
|
||||
FUZZY_SEARCH_THRESHOLD = 80
|
||||
|
||||
|
||||
def is_match(
|
||||
text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the text matches any of the relevant keys in the row data.
|
||||
|
||||
Args:
|
||||
text (str): The text to search for.
|
||||
row_data (dict[str, Any]): The row data to search in.
|
||||
relevant_keys (list[str]): The keys to consider for searching.
|
||||
enable_fuzzy (bool): Whether to use fuzzy matching.
|
||||
Returns:
|
||||
bool: True if a match is found, False otherwise.
|
||||
"""
|
||||
for key in relevant_keys:
|
||||
data = str(row_data.get(key, "") or "")
|
||||
if enable_fuzzy:
|
||||
match_ratio = fuzz.partial_ratio(text.lower(), data.lower())
|
||||
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
|
||||
return True
|
||||
else:
|
||||
if text.lower() in data.lower():
|
||||
return True
|
||||
return False
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import weakref
|
||||
from typing import Callable, Dict, List, Literal, TypedDict
|
||||
from typing import Callable, Dict, List, TypedDict
|
||||
from uuid import uuid4
|
||||
|
||||
import louie
|
||||
@@ -12,18 +12,15 @@ from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from louie import saferef
|
||||
from qtpy.QtCore import QEvent, QObject, QRect, Qt, Signal
|
||||
from qtpy.QtGui import QAction, QColor, QKeySequence, QPainter, QPen, QShortcut
|
||||
from qtpy.QtGui import QAction, QColor, QPainter, QPen
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMenuBar,
|
||||
QPushButton,
|
||||
QTableWidgetItem,
|
||||
QToolBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -43,9 +40,9 @@ class TourStep(TypedDict):
|
||||
widget_ref: (
|
||||
louie.saferef.BoundMethodWeakref
|
||||
| weakref.ReferenceType[
|
||||
QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]
|
||||
QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]
|
||||
]
|
||||
| Callable[[], tuple[QWidget | QAction | QRect, str | None]]
|
||||
| Callable[[], tuple[QWidget | QAction, str | None]]
|
||||
| None
|
||||
)
|
||||
text: str
|
||||
@@ -67,13 +64,15 @@ class TutorialOverlay(QWidget):
|
||||
box = QFrame(self)
|
||||
app = QApplication.instance()
|
||||
bg_color = app.palette().window().color()
|
||||
box.setStyleSheet(f"""
|
||||
box.setStyleSheet(
|
||||
f"""
|
||||
QFrame {{
|
||||
background-color: {bg_color.name()};
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
layout = QVBoxLayout(box)
|
||||
|
||||
# Top layout with close button (left) and step indicator (right)
|
||||
@@ -104,12 +103,10 @@ class TutorialOverlay(QWidget):
|
||||
# Back button with material icon
|
||||
self.back_btn = QPushButton("Back")
|
||||
self.back_btn.setIcon(material_icon("arrow_back"))
|
||||
self.back_btn.setToolTip("Press Backspace to go back")
|
||||
|
||||
# Next button with material icon
|
||||
self.next_btn = QPushButton("Next")
|
||||
self.next_btn.setIcon(material_icon("arrow_forward"))
|
||||
self.next_btn.setToolTip("Press Enter to continue")
|
||||
|
||||
btn_layout.addStretch()
|
||||
btn_layout.addWidget(self.back_btn)
|
||||
@@ -118,15 +115,6 @@ class TutorialOverlay(QWidget):
|
||||
layout.addLayout(top_layout)
|
||||
layout.addWidget(self.label)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
# Escape closes the tour
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Escape), self, activated=self.close_btn.click)
|
||||
# Enter and Return activates the next button
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Return), self, activated=self.next_btn.click)
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Enter), self, activated=self.next_btn.click)
|
||||
# Map Backspace to the back button
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Backspace), self, activated=self.back_btn.click)
|
||||
|
||||
return box
|
||||
|
||||
def paintEvent(self, event): # pylint: disable=unused-argument
|
||||
@@ -235,9 +223,6 @@ class TutorialOverlay(QWidget):
|
||||
self.message_box.show()
|
||||
self.update()
|
||||
|
||||
# Update the focus policy of the buttons
|
||||
self.back_btn.setEnabled(current_step > 1)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if event.type() == QEvent.Type.Resize:
|
||||
self.setGeometry(obj.rect())
|
||||
@@ -277,9 +262,7 @@ class GuidedTour(QObject):
|
||||
def register_widget(
|
||||
self,
|
||||
*,
|
||||
widget: (
|
||||
QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]
|
||||
),
|
||||
widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]],
|
||||
text: str = "",
|
||||
title: str = "",
|
||||
) -> str:
|
||||
@@ -287,7 +270,7 @@ class GuidedTour(QObject):
|
||||
Register a widget with help text for tours.
|
||||
|
||||
Args:
|
||||
widget (QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]): The target widget or a callable that returns the widget and its help text.
|
||||
widget (QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]): The target widget or a callable that returns the widget and its help text.
|
||||
text (str): The help text for the widget. This will be shown during the tour.
|
||||
title (str, optional): A title for the widget (defaults to its class name or action text).
|
||||
|
||||
@@ -310,9 +293,6 @@ class GuidedTour(QObject):
|
||||
|
||||
widget_ref = _resolve_toolbar_button
|
||||
default_title = getattr(widget, "tooltip", "Toolbar Menu")
|
||||
elif isinstance(widget, QRect):
|
||||
widget_ref = widget
|
||||
default_title = "Area"
|
||||
else:
|
||||
widget_ref = saferef.safe_ref(widget)
|
||||
default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget"
|
||||
@@ -347,14 +327,11 @@ class GuidedTour(QObject):
|
||||
if mb and mb not in menubars:
|
||||
menubars.append(mb)
|
||||
menubars += [mb for mb in mw.findChildren(QMenuBar) if mb not in menubars]
|
||||
menubars += [mb for mb in mw.findChildren(QMenu) if mb not in menubars]
|
||||
|
||||
for mb in menubars:
|
||||
if action in mb.actions():
|
||||
ar = mb.actionGeometry(action)
|
||||
top_left = mb.mapTo(mw, ar.topLeft())
|
||||
return QRect(top_left, ar.size())
|
||||
|
||||
return None
|
||||
|
||||
def unregister_widget(self, step_id: str) -> bool:
|
||||
@@ -475,9 +452,9 @@ class GuidedTour(QObject):
|
||||
|
||||
if self._current_index > 0:
|
||||
self._current_index -= 1
|
||||
self._show_current_step(direction="prev")
|
||||
self._show_current_step()
|
||||
|
||||
def _show_current_step(self, direction: Literal["next"] | Literal["prev"] = "next"):
|
||||
def _show_current_step(self):
|
||||
"""Display the current step."""
|
||||
if not self._active or not self.overlay:
|
||||
return
|
||||
@@ -487,9 +464,7 @@ class GuidedTour(QObject):
|
||||
|
||||
target, step_text = self._resolve_step_target(step)
|
||||
if target is None:
|
||||
self._advance_past_invalid_step(
|
||||
step_title, reason="Step target no longer exists.", direction=direction
|
||||
)
|
||||
self._advance_past_invalid_step(step_title, reason="Step target no longer exists.")
|
||||
return
|
||||
|
||||
main_window = self.main_window
|
||||
@@ -498,9 +473,7 @@ class GuidedTour(QObject):
|
||||
self.stop_tour()
|
||||
return
|
||||
|
||||
highlight_rect = self._get_highlight_rect(
|
||||
main_window, target, step_title, direction=direction
|
||||
)
|
||||
highlight_rect = self._get_highlight_rect(main_window, target, step_title)
|
||||
if highlight_rect is None:
|
||||
return
|
||||
|
||||
@@ -510,6 +483,9 @@ class GuidedTour(QObject):
|
||||
|
||||
self.overlay.show_step(highlight_rect, step_title, step_text, current_step, total_steps)
|
||||
|
||||
# Update button states
|
||||
self.overlay.back_btn.setEnabled(self._current_index > 0)
|
||||
|
||||
# Update next button text and state
|
||||
is_last_step = self._current_index >= len(self._tour_steps) - 1
|
||||
if is_last_step:
|
||||
@@ -523,7 +499,7 @@ class GuidedTour(QObject):
|
||||
|
||||
self.step_changed.emit(self._current_index + 1, len(self._tour_steps))
|
||||
|
||||
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | QRect | None, str]:
|
||||
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | None, str]:
|
||||
"""
|
||||
Resolve the target widget/action for the given step.
|
||||
|
||||
@@ -531,7 +507,7 @@ class GuidedTour(QObject):
|
||||
step(TourStep): The tour step to resolve.
|
||||
|
||||
Returns:
|
||||
tuple[QWidget | QAction | QRect | None, str]: The resolved target, optional QRect, and the step text.
|
||||
tuple[QWidget | QAction | None, str]: The resolved target and the step text.
|
||||
"""
|
||||
widget_ref = step.get("widget_ref")
|
||||
step_text = step.get("text", "")
|
||||
@@ -544,7 +520,7 @@ class GuidedTour(QObject):
|
||||
if target is None:
|
||||
return None, step_text
|
||||
|
||||
if callable(target) and not isinstance(target, (QWidget, QAction, QRect)):
|
||||
if callable(target) and not isinstance(target, (QWidget, QAction)):
|
||||
result = target()
|
||||
if isinstance(result, tuple):
|
||||
target, alt_text = result
|
||||
@@ -556,11 +532,7 @@ class GuidedTour(QObject):
|
||||
return target, step_text
|
||||
|
||||
def _get_highlight_rect(
|
||||
self,
|
||||
main_window: QWidget,
|
||||
target: QWidget | QAction | QRect,
|
||||
step_title: str,
|
||||
direction: Literal["next"] | Literal["prev"] = "next",
|
||||
self, main_window: QWidget, target: QWidget | QAction, step_title: str
|
||||
) -> QRect | None:
|
||||
"""
|
||||
Get the QRect in main_window coordinates to highlight for the given target.
|
||||
@@ -573,15 +545,12 @@ class GuidedTour(QObject):
|
||||
Returns:
|
||||
QRect | None: The rectangle to highlight, or None if not found/visible.
|
||||
"""
|
||||
if isinstance(target, QRect):
|
||||
return target
|
||||
if isinstance(target, QAction):
|
||||
rect = self._action_highlight_rect(target)
|
||||
if rect is None:
|
||||
self._advance_past_invalid_step(
|
||||
step_title,
|
||||
reason=f"Could not find visible widget or menu for QAction {target.text()!r}.",
|
||||
direction=direction,
|
||||
)
|
||||
return None
|
||||
return rect
|
||||
@@ -590,60 +559,28 @@ class GuidedTour(QObject):
|
||||
if self._visible_check:
|
||||
if not target.isVisible():
|
||||
self._advance_past_invalid_step(
|
||||
step_title, reason=f"Widget {target!r} is not visible.", direction=direction
|
||||
step_title, reason=f"Widget {target!r} is not visible."
|
||||
)
|
||||
return None
|
||||
rect = target.rect()
|
||||
top_left = target.mapTo(main_window, rect.topLeft())
|
||||
return QRect(top_left, rect.size())
|
||||
|
||||
if isinstance(target, QTableWidgetItem):
|
||||
# NOTE: On header items (which are also QTableWidgetItems), this does not work,
|
||||
# Header items are just used as data containers by Qt, thus, we have to directly
|
||||
# pass the QRect through the method (+ make sure the appropriate header section
|
||||
# is visible). This can be handled in the callable method.)
|
||||
table = target.tableWidget()
|
||||
|
||||
if self._visible_check:
|
||||
if not table.isVisible():
|
||||
self._advance_past_invalid_step(
|
||||
step_title,
|
||||
reason=f"Table widget {table!r} is not visible.",
|
||||
direction=direction,
|
||||
)
|
||||
return None
|
||||
|
||||
# Table item
|
||||
if table.item(target.row(), target.column()) == target:
|
||||
table.scrollToItem(target, QAbstractItemView.ScrollHint.PositionAtCenter)
|
||||
rect = table.visualItemRect(target)
|
||||
top_left = table.viewport().mapTo(main_window, rect.topLeft())
|
||||
return QRect(top_left, rect.size())
|
||||
|
||||
self._advance_past_invalid_step(
|
||||
step_title, reason=f"Unsupported step target type: {type(target)}", direction=direction
|
||||
step_title, reason=f"Unsupported step target type: {type(target)}"
|
||||
)
|
||||
return None
|
||||
|
||||
def _advance_past_invalid_step(
|
||||
self, step_title: str, *, reason: str, direction: Literal["next"] | Literal["prev"] = "next"
|
||||
):
|
||||
def _advance_past_invalid_step(self, step_title: str, *, reason: str):
|
||||
"""
|
||||
Skip the current step (or stop the tour) when the target cannot be visualised.
|
||||
"""
|
||||
logger.warning(f"{reason} Skipping step {step_title!r}.")
|
||||
if direction == "next":
|
||||
if self._current_index < len(self._tour_steps) - 1:
|
||||
self._current_index += 1
|
||||
self._show_current_step()
|
||||
else:
|
||||
self.stop_tour()
|
||||
elif direction == "prev":
|
||||
if self._current_index > 0:
|
||||
self._current_index -= 1
|
||||
self._show_current_step(direction="prev")
|
||||
else:
|
||||
self.stop_tour()
|
||||
logger.warning("%s Skipping step %r.", reason, step_title)
|
||||
if self._current_index < len(self._tour_steps) - 1:
|
||||
self._current_index += 1
|
||||
self._show_current_step()
|
||||
else:
|
||||
self.stop_tour()
|
||||
|
||||
def get_registered_widgets(self) -> Dict[str, TourStep]:
|
||||
"""Get all registered widgets."""
|
||||
@@ -726,33 +663,8 @@ class MainWindow(QMainWindow): # pragma: no cover
|
||||
title="Tools Menu",
|
||||
)
|
||||
|
||||
sub_menu_action = self.tools_menu_actions["notes"].action
|
||||
|
||||
def get_sub_menu_action():
|
||||
# open the tools menu
|
||||
menu_button = self.tools_menu_action._button_ref()
|
||||
if menu_button:
|
||||
menu_button.showMenu()
|
||||
|
||||
return (
|
||||
self.tools_menu_action.actions["notes"].action,
|
||||
"This action allows you to add notes.",
|
||||
)
|
||||
|
||||
sub_menu = self.guided_help.register_widget(
|
||||
widget=get_sub_menu_action,
|
||||
text="This is a sub-action within the tools menu.",
|
||||
title="Add Note Action",
|
||||
)
|
||||
|
||||
# Create tour from registered widgets
|
||||
self.tour_step_ids = [
|
||||
sub_menu,
|
||||
primary_step,
|
||||
secondary_step,
|
||||
toolbar_action_step,
|
||||
tools_menu_step,
|
||||
]
|
||||
self.tour_step_ids = [primary_step, secondary_step, toolbar_action_step, tools_menu_step]
|
||||
widget_ids = self.tour_step_ids
|
||||
self.guided_help.create_tour(widget_ids)
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ class HelpInspector(BECWidget, QtWidgets.QWidget):
|
||||
# TODO check what happens if the HELP Inspector itself is embedded in another BECWidget
|
||||
# I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one
|
||||
if not isinstance(widget, BECWidget):
|
||||
widget = WidgetHierarchy.get_becwidget_ancestor(widget)
|
||||
widget = WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||
if widget:
|
||||
if widget is self:
|
||||
self._toggle_mode(False)
|
||||
|
||||
@@ -15,7 +15,7 @@ class Kind(IFBase):
|
||||
"""
|
||||
This is used in the .kind attribute of all OphydObj (Signals, Devices).
|
||||
|
||||
A Device examines its components' .kind attribute to decide whether to
|
||||
A Device examines its components' .kind atttribute to decide whether to
|
||||
traverse it in read(), read_configuration(), or neither. Additionally, if
|
||||
decides whether to include its name in `hints['fields']`.
|
||||
"""
|
||||
|
||||
@@ -69,11 +69,13 @@ class RoundedFrame(QFrame):
|
||||
"""
|
||||
Update the style of the frame based on the background color.
|
||||
"""
|
||||
self.setStyleSheet(f"""
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QFrame#roundedFrame {{
|
||||
border-radius: {self._radius}px;
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
self.apply_plot_widget_style()
|
||||
|
||||
def apply_plot_widget_style(self, border: str = "none"):
|
||||
|
||||
@@ -4,24 +4,20 @@ import functools
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Callable, Literal, TypeVar
|
||||
from typing import TYPE_CHECKING, Callable, TypeVar
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.utils.screen_utils import apply_window_geometry
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib import messages
|
||||
@@ -118,14 +114,11 @@ class RPCServer:
|
||||
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
|
||||
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
|
||||
try:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
if method.startswith("system."):
|
||||
res = self.run_system_rpc(method, args, kwargs)
|
||||
else:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception:
|
||||
content = traceback.format_exc()
|
||||
logger.error(f"Error while executing RPC instruction: {content}")
|
||||
@@ -156,7 +149,7 @@ class RPCServer:
|
||||
if method == "raise" and hasattr(
|
||||
obj, "setWindowState"
|
||||
): # special case for raising windows, should work even if minimized
|
||||
# this is a special case for raising windows for gnome on Red Hat (RHEL) 9 systems where changing focus is suppressed by default
|
||||
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
|
||||
# The procedure is as follows:
|
||||
# 1. Get the current window state to check if the window is minimized and remove minimized flag
|
||||
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
|
||||
@@ -181,96 +174,18 @@ class RPCServer:
|
||||
obj.show()
|
||||
res = None
|
||||
else:
|
||||
target_obj, method_obj = self._resolve_rpc_target(obj, method)
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
if not args:
|
||||
res = method_obj
|
||||
else:
|
||||
setattr(target_obj, method, args[0])
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
return res
|
||||
|
||||
def _resolve_rpc_target(self, obj, method: str) -> tuple[object, object]:
|
||||
"""
|
||||
Resolve a method/property access target for RPC execution.
|
||||
|
||||
Primary target is the object itself. If not found there and the class defines
|
||||
``RPC_CONTENT_CLASS``, unresolved method names can be delegated to the content
|
||||
widget referenced by ``RPC_CONTENT_ATTR`` (default ``content``), but only when
|
||||
the method is explicitly listed in the content class ``USER_ACCESS``.
|
||||
"""
|
||||
if hasattr(obj, method):
|
||||
return obj, getattr(obj, method)
|
||||
|
||||
content_cls = getattr(type(obj), "RPC_CONTENT_CLASS", None)
|
||||
if content_cls is None:
|
||||
raise AttributeError(f"{type(obj).__name__} has no attribute '{method}'")
|
||||
|
||||
content_user_access = set()
|
||||
for entry in getattr(content_cls, "USER_ACCESS", []):
|
||||
if entry.endswith(".setter"):
|
||||
content_user_access.add(entry.split(".setter")[0])
|
||||
else:
|
||||
content_user_access.add(entry)
|
||||
|
||||
if method not in content_user_access:
|
||||
raise AttributeError(f"{type(obj).__name__} has no attribute '{method}'")
|
||||
|
||||
content_attr = getattr(type(obj), "RPC_CONTENT_ATTR", "content")
|
||||
target_obj = getattr(obj, content_attr, None)
|
||||
if target_obj is None:
|
||||
raise AttributeError(
|
||||
f"{type(obj).__name__} has no content target '{content_attr}' for RPC delegation"
|
||||
)
|
||||
if not isinstance(target_obj, content_cls):
|
||||
raise AttributeError(
|
||||
f"{type(obj).__name__}.{content_attr} is not instance of {content_cls.__name__}"
|
||||
)
|
||||
if not hasattr(target_obj, method):
|
||||
raise AttributeError(f"{content_cls.__name__} has no attribute '{method}'")
|
||||
return target_obj, getattr(target_obj, method)
|
||||
|
||||
def run_system_rpc(self, method: str, args: list, kwargs: dict):
|
||||
if method == "system.launch_dock_area":
|
||||
return self._launch_dock_area(*args, **kwargs)
|
||||
if method == "system.list_capabilities":
|
||||
return {"system.launch_dock_area": True}
|
||||
raise ValueError(f"Unknown system RPC method: {method}")
|
||||
|
||||
@staticmethod
|
||||
def _launch_dock_area(
|
||||
name: str | None = None,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
startup_profile: str | Literal["restore", "skip"] | None = None,
|
||||
) -> QWidget | None:
|
||||
from bec_widgets.applications import bw_launch
|
||||
|
||||
with RPCRegister.delayed_broadcast() as rpc_register:
|
||||
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
|
||||
if name is not None:
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
if name in existing_dock_areas:
|
||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
||||
else:
|
||||
name = WidgetContainerUtils.generate_unique_name("dock_area", existing_dock_areas)
|
||||
|
||||
result_widget = bw_launch.dock_area(object_name=name, startup_profile=startup_profile)
|
||||
result_widget.window().setWindowTitle(f"BEC - {name}")
|
||||
|
||||
if isinstance(result_widget, BECMainWindow):
|
||||
apply_window_geometry(result_widget, geometry)
|
||||
result_widget.show()
|
||||
else:
|
||||
window = BECMainWindowNoRPC()
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
|
||||
apply_window_geometry(window, geometry)
|
||||
window.show()
|
||||
return result_widget
|
||||
|
||||
def serialize_result_and_send(self, request_id: str, res: object):
|
||||
"""
|
||||
Serialize the result of an RPC call and send it back to the client.
|
||||
@@ -340,9 +255,6 @@ class RPCServer:
|
||||
# Respect RPC = False
|
||||
if getattr(obj, "RPC", True) is False:
|
||||
return None
|
||||
# Respect rpc_exposed = False
|
||||
if getattr(obj, "rpc_exposed", True) is False:
|
||||
return None
|
||||
return self._serialize_bec_connector(obj, wait=True)
|
||||
|
||||
def emit_heartbeat(self) -> None:
|
||||
@@ -371,8 +283,6 @@ class RPCServer:
|
||||
continue
|
||||
if not getattr(val, "RPC", True):
|
||||
continue
|
||||
if not getattr(val, "rpc_exposed", True):
|
||||
continue
|
||||
data[key] = self._serialize_bec_connector(val)
|
||||
if self._broadcasted_data == data:
|
||||
return
|
||||
@@ -423,9 +333,23 @@ class RPCServer:
|
||||
"widget_class": widget_class,
|
||||
"config": config_dict,
|
||||
"container_proxy": container_proxy,
|
||||
"__rpc__": getattr(connector, "rpc_exposed", True),
|
||||
"__rpc__": True,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_becwidget_ancestor(widget: QObject) -> BECConnector | None:
|
||||
"""
|
||||
Traverse up the parent chain to find the nearest BECConnector.
|
||||
Returns None if none is found.
|
||||
"""
|
||||
|
||||
parent = widget.parent()
|
||||
while parent is not None:
|
||||
if isinstance(parent, BECConnector):
|
||||
return parent
|
||||
parent = parent.parent()
|
||||
return None
|
||||
|
||||
# Suppose clients register callbacks to receive updates
|
||||
def add_registry_update_callback(self, cb: Callable) -> None:
|
||||
"""
|
||||
@@ -442,5 +366,5 @@ class RPCServer:
|
||||
self.status = messages.BECStatus.IDLE
|
||||
self._heartbeat_timer.stop()
|
||||
self.emit_heartbeat()
|
||||
logger.info("Succeeded in shutting down CLI server")
|
||||
logger.info("Succeded in shutting down CLI server")
|
||||
self.client.shutdown()
|
||||
|
||||
@@ -35,19 +35,16 @@ logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
def create_action_with_text(toolbar_action, toolbar: QToolBar, min_size: QSize | None = None):
|
||||
def create_action_with_text(toolbar_action, toolbar: QToolBar):
|
||||
"""
|
||||
Helper function to create a toolbar button with text beside or under the icon.
|
||||
|
||||
Args:
|
||||
toolbar_action(ToolBarAction): The toolbar action to create the button for.
|
||||
toolbar(ModularToolBar): The toolbar to add the button to.
|
||||
min_size(QSize, optional): The minimum size for the button. Defaults to None.
|
||||
"""
|
||||
|
||||
btn = QToolButton(parent=toolbar)
|
||||
if min_size is not None:
|
||||
btn.setMinimumSize(min_size)
|
||||
if getattr(toolbar_action, "label_text", None):
|
||||
toolbar_action.action.setText(toolbar_action.label_text)
|
||||
if getattr(toolbar_action, "tooltip", None):
|
||||
@@ -602,14 +599,16 @@ class ExpandableMenuAction(ToolBarAction):
|
||||
button.setIcon(QIcon(self.icon_path))
|
||||
button.setText(self.tooltip)
|
||||
button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
button.setStyleSheet("""
|
||||
button.setStyleSheet(
|
||||
"""
|
||||
QToolButton {
|
||||
font-size: 14px;
|
||||
}
|
||||
QMenu {
|
||||
font-size: 14px;
|
||||
}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
menu = QMenu(button)
|
||||
for action_container in self.actions.values():
|
||||
action: QAction = action_container.action
|
||||
|
||||
@@ -106,7 +106,8 @@ class ResizableSpacer(QWidget):
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
|
||||
self.setStyleSheet("""
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
ResizableSpacer {
|
||||
background-color: transparent;
|
||||
margin: 0px;
|
||||
@@ -116,7 +117,8 @@ class ResizableSpacer(QWidget):
|
||||
ResizableSpacer:hover {
|
||||
background-color: rgba(100, 100, 200, 80);
|
||||
}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
|
||||
@@ -291,7 +291,8 @@ class ModularToolBar(QToolBar):
|
||||
menu = QMenu(self)
|
||||
theme = get_theme_name()
|
||||
if theme == "dark":
|
||||
menu.setStyleSheet("""
|
||||
menu.setStyleSheet(
|
||||
"""
|
||||
QMenu {
|
||||
background-color: rgba(50, 50, 50, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
@@ -299,10 +300,12 @@ class ModularToolBar(QToolBar):
|
||||
QMenu::item:selected {
|
||||
background-color: rgba(0, 0, 255, 0.2);
|
||||
}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
else:
|
||||
# Light theme styling
|
||||
menu.setStyleSheet("""
|
||||
menu.setStyleSheet(
|
||||
"""
|
||||
QMenu {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
@@ -310,7 +313,8 @@ class ModularToolBar(QToolBar):
|
||||
QMenu::item:selected {
|
||||
background-color: rgba(0, 0, 255, 0.2);
|
||||
}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
for ii, bundle in enumerate(self.shown_bundles):
|
||||
self.handle_bundle_context_menu(menu, bundle)
|
||||
if ii < len(self.shown_bundles) - 1:
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shiboken6
|
||||
from qtpy.QtCore import QPropertyAnimation, QRect, QSequentialAnimationGroup, Qt
|
||||
from qtpy.QtWidgets import QFrame, QWidget
|
||||
|
||||
|
||||
class WidgetHighlighter:
|
||||
"""
|
||||
Utility that highlights widgets by drawing a temporary frame around them.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
frame_parent: QWidget | None = None,
|
||||
window_flags: Qt.WindowType | Qt.WindowFlags = Qt.WindowType.Tool
|
||||
| Qt.WindowType.FramelessWindowHint
|
||||
| Qt.WindowType.WindowStaysOnTopHint,
|
||||
style_sheet: str = "border: 2px solid #FF00FF; border-radius: 6px; background: transparent;",
|
||||
) -> None:
|
||||
self._frame_parent = frame_parent
|
||||
self._window_flags = window_flags
|
||||
self._style_sheet = style_sheet
|
||||
self._frame: QFrame | None = None
|
||||
self._animation_group: QSequentialAnimationGroup | None = None
|
||||
|
||||
def highlight(self, widget: QWidget | None) -> None:
|
||||
"""
|
||||
Highlight the given widget with a pulsing frame.
|
||||
"""
|
||||
if widget is None or not shiboken6.isValid(widget):
|
||||
return
|
||||
|
||||
frame = self._ensure_frame()
|
||||
frame.hide()
|
||||
|
||||
geom = widget.frameGeometry()
|
||||
top_left = widget.mapToGlobal(widget.rect().topLeft())
|
||||
frame.setGeometry(top_left.x(), top_left.y(), geom.width(), geom.height())
|
||||
frame.setWindowOpacity(1.0)
|
||||
frame.show()
|
||||
|
||||
start_rect = QRect(
|
||||
top_left.x() - 5, top_left.y() - 5, geom.width() + 10, geom.height() + 10
|
||||
)
|
||||
|
||||
pulse = QPropertyAnimation(frame, b"geometry", frame)
|
||||
pulse.setDuration(300)
|
||||
pulse.setStartValue(start_rect)
|
||||
pulse.setEndValue(QRect(top_left.x(), top_left.y(), geom.width(), geom.height()))
|
||||
|
||||
fade = QPropertyAnimation(frame, b"windowOpacity", frame)
|
||||
fade.setDuration(2000)
|
||||
fade.setStartValue(1.0)
|
||||
fade.setEndValue(0.0)
|
||||
fade.finished.connect(frame.hide)
|
||||
|
||||
if self._animation_group is not None:
|
||||
old_group = self._animation_group
|
||||
self._animation_group = None
|
||||
old_group.stop()
|
||||
old_group.deleteLater()
|
||||
|
||||
animation = QSequentialAnimationGroup(frame)
|
||||
animation.addAnimation(pulse)
|
||||
animation.addAnimation(fade)
|
||||
animation.start()
|
||||
|
||||
self._animation_group = animation
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Delete the highlight frame and cancel pending animations.
|
||||
"""
|
||||
if self._animation_group is not None:
|
||||
self._animation_group.stop()
|
||||
self._animation_group.deleteLater()
|
||||
self._animation_group = None
|
||||
if self._frame is not None:
|
||||
self._frame.hide()
|
||||
self._frame.deleteLater()
|
||||
self._frame = None
|
||||
|
||||
@property
|
||||
def frame(self) -> QFrame | None:
|
||||
"""Return the currently allocated highlight frame (if any)."""
|
||||
return self._frame
|
||||
|
||||
def _ensure_frame(self) -> QFrame:
|
||||
if self._frame is None:
|
||||
self._frame = QFrame(self._frame_parent, self._window_flags)
|
||||
self._frame.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
self._frame.setStyleSheet(self._style_sheet)
|
||||
return self._frame
|
||||
@@ -2,12 +2,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Type, TypeVar, cast
|
||||
|
||||
import shiboken6 as shb
|
||||
from bec_lib import bec_logger
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
@@ -33,14 +31,6 @@ logger = bec_logger.logger
|
||||
TAncestor = TypeVar("TAncestor", bound=QWidget)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WidgetTreeNode:
|
||||
widget: QWidget
|
||||
parent: QWidget | None
|
||||
depth: int
|
||||
prefix: str
|
||||
|
||||
|
||||
class WidgetHandler(ABC):
|
||||
"""Abstract base class for all widget handlers."""
|
||||
|
||||
@@ -330,72 +320,6 @@ class WidgetIO:
|
||||
|
||||
|
||||
class WidgetHierarchy:
|
||||
@staticmethod
|
||||
def iter_widget_tree(widget: QWidget, *, exclude_internal_widgets: bool = True):
|
||||
"""
|
||||
Yield WidgetTreeNode entries for the widget hierarchy.
|
||||
"""
|
||||
visited: set[int] = set()
|
||||
yield from WidgetHierarchy._iter_widget_tree_nodes(
|
||||
widget, None, exclude_internal_widgets, visited, [], 0
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _iter_widget_tree_nodes(
|
||||
widget: QWidget,
|
||||
parent: QWidget | None,
|
||||
exclude_internal_widgets: bool,
|
||||
visited: set[int],
|
||||
branch_flags: list[bool],
|
||||
depth: int,
|
||||
):
|
||||
if widget is None or not shb.isValid(widget):
|
||||
return
|
||||
widget_id = id(widget)
|
||||
if widget_id in visited:
|
||||
return
|
||||
visited.add(widget_id)
|
||||
|
||||
prefix = WidgetHierarchy._build_prefix(branch_flags)
|
||||
yield WidgetTreeNode(widget=widget, parent=parent, depth=depth, prefix=prefix)
|
||||
|
||||
children = WidgetHierarchy._filtered_children(widget, exclude_internal_widgets)
|
||||
for idx, child in enumerate(children):
|
||||
is_last = idx == len(children) - 1
|
||||
yield from WidgetHierarchy._iter_widget_tree_nodes(
|
||||
child,
|
||||
widget,
|
||||
exclude_internal_widgets,
|
||||
visited,
|
||||
branch_flags + [is_last],
|
||||
depth + 1,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_prefix(branch_flags: list[bool]) -> str:
|
||||
if not branch_flags:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for flag in branch_flags[:-1]:
|
||||
parts.append(" " if flag else "│ ")
|
||||
parts.append("└─ " if branch_flags[-1] else "├─ ")
|
||||
return "".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _filtered_children(widget: QWidget, exclude_internal_widgets: bool) -> list[QWidget]:
|
||||
children: list[QWidget] = []
|
||||
for child in widget.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
|
||||
if not shb.isValid(child):
|
||||
continue
|
||||
if (
|
||||
exclude_internal_widgets
|
||||
and isinstance(widget, QComboBox)
|
||||
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
|
||||
):
|
||||
continue
|
||||
children.append(child)
|
||||
return children
|
||||
|
||||
@staticmethod
|
||||
def print_widget_hierarchy(
|
||||
widget,
|
||||
@@ -421,33 +345,52 @@ class WidgetHierarchy:
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
for node in WidgetHierarchy.iter_widget_tree(
|
||||
widget, exclude_internal_widgets=exclude_internal_widgets
|
||||
):
|
||||
current = node.widget
|
||||
is_bec = isinstance(current, BECConnector)
|
||||
if only_bec_widgets and not is_bec:
|
||||
# 1) Filter out widgets that are not BECConnectors (if 'only_bec_widgets' is True)
|
||||
is_bec = isinstance(widget, BECConnector)
|
||||
if only_bec_widgets and not is_bec:
|
||||
return
|
||||
|
||||
# 2) Determine and print the parent's info (closest BECConnector)
|
||||
parent_info = ""
|
||||
if show_parent and is_bec:
|
||||
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||
if ancestor:
|
||||
parent_label = ancestor.objectName() or ancestor.__class__.__name__
|
||||
parent_info = f" parent={parent_label}"
|
||||
else:
|
||||
parent_info = " parent=None"
|
||||
|
||||
widget_info = f"{widget.__class__.__name__} ({widget.objectName()}){parent_info}"
|
||||
print(prefix + widget_info)
|
||||
|
||||
# 3) If it's a Waveform, explicitly print the curves
|
||||
if isinstance(widget, Waveform):
|
||||
for curve in widget.curves:
|
||||
curve_prefix = prefix + " └─ "
|
||||
print(
|
||||
f"{curve_prefix}{curve.__class__.__name__} ({curve.objectName()}) "
|
||||
f"parent={widget.objectName()}"
|
||||
)
|
||||
|
||||
# 4) Recursively handle each child if:
|
||||
# - It's a QWidget
|
||||
# - It is a BECConnector (or we don't care about filtering)
|
||||
# - Its closest BECConnector parent is the current widget
|
||||
for child in widget.findChildren(QWidget):
|
||||
if only_bec_widgets and not isinstance(child, BECConnector):
|
||||
continue
|
||||
|
||||
parent_info = ""
|
||||
if show_parent and is_bec:
|
||||
ancestor = WidgetHierarchy.get_becwidget_ancestor(current)
|
||||
if ancestor:
|
||||
parent_label = ancestor.objectName() or ancestor.__class__.__name__
|
||||
parent_info = f" parent={parent_label}"
|
||||
else:
|
||||
parent_info = " parent=None"
|
||||
|
||||
widget_info = f"{current.__class__.__name__} ({current.objectName()}){parent_info}"
|
||||
print(node.prefix + widget_info)
|
||||
|
||||
if isinstance(current, Waveform):
|
||||
for curve in current.curves:
|
||||
curve_prefix = node.prefix + " "
|
||||
print(
|
||||
f"{curve_prefix}└─ {curve.__class__.__name__} ({curve.objectName()}) "
|
||||
f"parent={current.objectName()}"
|
||||
)
|
||||
# if WidgetHierarchy._get_becwidget_ancestor(child) == widget:
|
||||
child_prefix = prefix + " └─ "
|
||||
WidgetHierarchy.print_widget_hierarchy(
|
||||
child,
|
||||
indent=indent + 1,
|
||||
grab_values=grab_values,
|
||||
prefix=child_prefix,
|
||||
exclude_internal_widgets=exclude_internal_widgets,
|
||||
only_bec_widgets=only_bec_widgets,
|
||||
show_parent=show_parent,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def print_becconnector_hierarchy_from_app():
|
||||
@@ -487,7 +430,7 @@ class WidgetHierarchy:
|
||||
# 3) Build a map of (closest BECConnector parent) -> list of children
|
||||
parent_map = defaultdict(list)
|
||||
for w in bec_widgets:
|
||||
parent_bec = WidgetHierarchy.get_becwidget_ancestor(w)
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(w)
|
||||
parent_map[parent_bec].append(w)
|
||||
|
||||
# 4) Define a recursive printer to show each object's children
|
||||
@@ -524,15 +467,10 @@ class WidgetHierarchy:
|
||||
print_tree(root, prefix=" ")
|
||||
|
||||
@staticmethod
|
||||
def get_becwidget_ancestor(widget):
|
||||
def _get_becwidget_ancestor(widget):
|
||||
"""
|
||||
Traverse up the parent chain to find the nearest BECConnector.
|
||||
|
||||
Args:
|
||||
widget: Starting widget to find the ancestor for.
|
||||
|
||||
Returns:
|
||||
The nearest ancestor that is a BECConnector, or None if not found.
|
||||
Returns None if none is found.
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
@@ -642,7 +580,7 @@ class WidgetHierarchy:
|
||||
if isinstance(widget, BECConnector):
|
||||
connectors.append(widget)
|
||||
for child in widget.findChildren(BECConnector):
|
||||
if WidgetHierarchy.get_becwidget_ancestor(child) is widget:
|
||||
if WidgetHierarchy._get_becwidget_ancestor(child) is widget:
|
||||
connectors.append(child)
|
||||
return connectors
|
||||
|
||||
@@ -673,7 +611,7 @@ class WidgetHierarchy:
|
||||
is_bec_target = issubclass(ancestor_class, BECConnector)
|
||||
|
||||
if is_bec_target:
|
||||
ancestor = WidgetHierarchy.get_becwidget_ancestor(widget)
|
||||
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||
return cast(TAncestor, ancestor)
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing BECConnector: {e}")
|
||||
|
||||
@@ -41,12 +41,12 @@ class AutoUpdates(BECMainWindow):
|
||||
parent=self,
|
||||
object_name="dock_area",
|
||||
enable_profile_management=False,
|
||||
startup_profile="skip",
|
||||
restore_initial_profile=False,
|
||||
)
|
||||
self.setCentralWidget(self.dock_area)
|
||||
self._auto_update_selected_device: str | None = None
|
||||
|
||||
self._default_dock = None # type: ignore
|
||||
self._default_dock = None # type:ignore
|
||||
self.current_widget: BECWidget | None = None
|
||||
self.dock_name = None
|
||||
self._enabled = True
|
||||
@@ -63,7 +63,7 @@ class AutoUpdates(BECMainWindow):
|
||||
Disconnect all connections for the auto updates.
|
||||
"""
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self._on_scan_status, MessageEndpoints.scan_status() # type: ignore
|
||||
self._on_scan_status, MessageEndpoints.scan_status() # type:ignore
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -244,10 +244,10 @@ class AutoUpdates(BECMainWindow):
|
||||
wf = self.set_dock_to_widget("Waveform")
|
||||
|
||||
# Get the scan report devices reported by the scan
|
||||
dev_x = info.scan_report_devices[0] # type: ignore
|
||||
dev_x = info.scan_report_devices[0] # type:ignore
|
||||
|
||||
# For the y axis, get the selected device
|
||||
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type: ignore
|
||||
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
|
||||
if not dev_y:
|
||||
return
|
||||
|
||||
@@ -256,8 +256,8 @@ class AutoUpdates(BECMainWindow):
|
||||
# as the label and title
|
||||
wf.clear_all()
|
||||
wf.plot(
|
||||
device_x=dev_x,
|
||||
device_y=dev_y,
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
label=f"Scan {info.scan_number} - {dev_y}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
@@ -265,7 +265,7 @@ class AutoUpdates(BECMainWindow):
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Auto Update [simple_line_scan]: Started plot with: device_x={dev_x}, device_y={dev_y}"
|
||||
f"Auto Update [simple_line_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}"
|
||||
)
|
||||
|
||||
def simple_grid_scan(self, info: ScanStatusMessage) -> None:
|
||||
@@ -279,8 +279,8 @@ class AutoUpdates(BECMainWindow):
|
||||
scatter = self.set_dock_to_widget("ScatterWaveform")
|
||||
|
||||
# Get the scan report devices reported by the scan
|
||||
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type: ignore
|
||||
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type: ignore
|
||||
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type:ignore
|
||||
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
|
||||
|
||||
if None in (dev_x, dev_y, dev_z):
|
||||
return
|
||||
@@ -288,14 +288,11 @@ class AutoUpdates(BECMainWindow):
|
||||
# Clear the scatter waveform widget and plot the data
|
||||
scatter.clear_all()
|
||||
scatter.plot(
|
||||
device_x=dev_x,
|
||||
device_y=dev_y,
|
||||
device_z=dev_z,
|
||||
label=f"Scan {info.scan_number} - {dev_z}",
|
||||
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Auto Update [simple_grid_scan]: Started plot with: device_x={dev_x}, device_y={dev_y}, device_z={dev_z}"
|
||||
f"Auto Update [simple_grid_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}, z_name={dev_z}"
|
||||
)
|
||||
|
||||
def best_effort(self, info: ScanStatusMessage) -> None:
|
||||
@@ -309,8 +306,8 @@ class AutoUpdates(BECMainWindow):
|
||||
# If the scan report devices are empty, there is nothing we can do
|
||||
if not info.scan_report_devices:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0] # type: ignore
|
||||
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type: ignore
|
||||
dev_x = info.scan_report_devices[0] # type:ignore
|
||||
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
|
||||
if not dev_y:
|
||||
return
|
||||
|
||||
@@ -320,17 +317,15 @@ class AutoUpdates(BECMainWindow):
|
||||
# Clear the waveform widget and plot the data
|
||||
wf.clear_all()
|
||||
wf.plot(
|
||||
device_x=dev_x,
|
||||
device_y=dev_y,
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
label=f"Scan {info.scan_number} - {dev_y}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Auto Update [best_effort]: Started plot with: device_x={dev_x}, device_y={dev_y}"
|
||||
)
|
||||
logger.info(f"Auto Update [best_effort]: Started plot with: x_name={dev_x}, y_name={dev_y}")
|
||||
|
||||
#######################################################################
|
||||
################# GUI Callbacks #######################################
|
||||
|
||||
@@ -14,7 +14,6 @@ from shiboken6 import isValid
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets import BECWidget, SafeSlot
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.property_editor import PropertyEditor
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.widgets.containers.qt_ads import (
|
||||
@@ -113,7 +112,6 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
)
|
||||
|
||||
self._root_layout.addWidget(self.dock_manager, 1)
|
||||
self._install_manager_parent_guards()
|
||||
|
||||
################################################################################
|
||||
# Dock Utility Helpers
|
||||
@@ -256,54 +254,6 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
|
||||
return lambda dock: self._default_close_handler(dock, widget)
|
||||
|
||||
def _install_manager_parent_guards(self) -> None:
|
||||
"""
|
||||
Track ADS structural changes so drag/drop-created tab areas keep stable parenting.
|
||||
"""
|
||||
self.dock_manager.dockAreaCreated.connect(self._normalize_all_dock_parents)
|
||||
self.dock_manager.dockWidgetAdded.connect(self._normalize_all_dock_parents)
|
||||
self.dock_manager.stateRestored.connect(self._normalize_all_dock_parents)
|
||||
self.dock_manager.restoringState.connect(self._normalize_all_dock_parents)
|
||||
self.dock_manager.focusedDockWidgetChanged.connect(self._normalize_all_dock_parents)
|
||||
self._normalize_all_dock_parents()
|
||||
|
||||
def _iter_all_dock_areas(self) -> list[CDockAreaWidget]:
|
||||
"""Return all dock areas from all known dock containers."""
|
||||
areas: list[CDockAreaWidget] = []
|
||||
for i in range(self.dock_manager.dockAreaCount()):
|
||||
area = self.dock_manager.dockArea(i)
|
||||
if area is None or not isValid(area):
|
||||
continue
|
||||
areas.append(area)
|
||||
return areas
|
||||
|
||||
def _connect_dock_area_parent_guards(self) -> None:
|
||||
"""Bind area-level tab/view events to parent normalization."""
|
||||
for area in self._iter_all_dock_areas():
|
||||
try:
|
||||
area.currentChanged.connect(
|
||||
self._normalize_all_dock_parents, Qt.ConnectionType.UniqueConnection
|
||||
)
|
||||
area.viewToggled.connect(
|
||||
self._normalize_all_dock_parents, Qt.ConnectionType.UniqueConnection
|
||||
)
|
||||
except TypeError:
|
||||
area.currentChanged.connect(self._normalize_all_dock_parents)
|
||||
area.viewToggled.connect(self._normalize_all_dock_parents)
|
||||
|
||||
def _normalize_all_dock_parents(self, *_args) -> None:
|
||||
"""
|
||||
Ensure each dock has a stable parent after tab switches, re-docking, or restore.
|
||||
"""
|
||||
self._connect_dock_area_parent_guards()
|
||||
for dock in self.dock_list():
|
||||
if dock is None or not isValid(dock):
|
||||
continue
|
||||
area_widget = dock.dockAreaWidget()
|
||||
target_parent = area_widget if area_widget is not None else self.dock_manager
|
||||
if dock.parent() is not target_parent:
|
||||
dock.setParent(target_parent)
|
||||
|
||||
def _make_dock(
|
||||
self,
|
||||
widget: QWidget,
|
||||
@@ -406,7 +356,6 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
self._apply_floating_state_to_dock(dock, floating_state)
|
||||
if resolved_icon is not None:
|
||||
dock.setIcon(resolved_icon)
|
||||
self._normalize_all_dock_parents()
|
||||
return dock
|
||||
|
||||
def _delete_dock(self, dock: CDockWidget) -> None:
|
||||
@@ -1315,7 +1264,7 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
such as `AdvancedDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.
|
||||
@@ -1385,40 +1334,37 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
dock = self._create_dock_from_spec(spec)
|
||||
return dock if return_dock else widget
|
||||
|
||||
def _iter_all_docks(self) -> list[CDockWidget]:
|
||||
"""Return all docks, including those hosted in floating containers."""
|
||||
docks = list(self.dock_manager.dockWidgets())
|
||||
seen = {id(d) for d in docks}
|
||||
for container in self.dock_manager.floatingWidgets():
|
||||
if container is None:
|
||||
continue
|
||||
for dock in container.dockWidgets():
|
||||
if dock is None:
|
||||
continue
|
||||
if id(dock) in seen:
|
||||
continue
|
||||
docks.append(dock)
|
||||
seen.add(id(dock))
|
||||
return docks
|
||||
|
||||
def dock_map(self) -> dict[str, CDockWidget]:
|
||||
"""Return the dock widgets map as dictionary with names as keys."""
|
||||
return self.dock_manager.dockWidgetsMap()
|
||||
return {dock.objectName(): dock for dock in self._iter_all_docks() if dock.objectName()}
|
||||
|
||||
def dock_list(self) -> list[CDockWidget]:
|
||||
"""Return the list of dock widgets."""
|
||||
return list(self.dock_map().values())
|
||||
return self._iter_all_docks()
|
||||
|
||||
def widget_map(self, bec_widgets_only: bool = True) -> dict[str, QWidget]:
|
||||
"""
|
||||
Return a dictionary mapping widget names to their corresponding widgets.
|
||||
def widget_map(self) -> dict[str, QWidget]:
|
||||
"""Return a dictionary mapping widget names to their corresponding widgets."""
|
||||
return {dock.objectName(): dock.widget() for dock in self.dock_list()}
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
"""
|
||||
|
||||
widgets: dict[str, QWidget] = {}
|
||||
for dock in self.dock_list():
|
||||
widget = dock.widget()
|
||||
if not isinstance(widget, QWidget):
|
||||
continue
|
||||
if bec_widgets_only and not isinstance(widget, BECConnector):
|
||||
continue
|
||||
widgets[dock.objectName()] = widget
|
||||
return widgets
|
||||
|
||||
def widget_list(self, bec_widgets_only: bool = True) -> list[QWidget]:
|
||||
"""
|
||||
Return a list of widgets contained in the dock area.
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
"""
|
||||
return list(self.widget_map(bec_widgets_only=bec_widgets_only).values())
|
||||
def widget_list(self) -> list[QWidget]:
|
||||
"""Return a list of all widgets contained in the dock area."""
|
||||
return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)]
|
||||
|
||||
@SafeSlot()
|
||||
def attach_all(self):
|
||||
|
||||
@@ -19,7 +19,6 @@ from qtpy.QtWidgets import (
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
from bec_widgets.applications.views.view import ViewTourSteps
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
@@ -87,7 +86,6 @@ logger = bec_logger.logger
|
||||
_PROFILE_NAMESPACE_UNSET = object()
|
||||
|
||||
PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")}
|
||||
StartupProfile = Literal["restore", "skip"] | str | None
|
||||
|
||||
|
||||
class BECDockArea(DockAreaWidget):
|
||||
@@ -125,7 +123,9 @@ class BECDockArea(DockAreaWidget):
|
||||
instance_id: str | None = None,
|
||||
auto_save_upon_exit: bool = True,
|
||||
enable_profile_management: bool = True,
|
||||
startup_profile: StartupProfile = "restore",
|
||||
restore_initial_profile: bool = True,
|
||||
init_profile: str | None = None,
|
||||
start_empty: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
self._profile_namespace_hint = profile_namespace
|
||||
@@ -134,9 +134,14 @@ class BECDockArea(DockAreaWidget):
|
||||
self._instance_id = slugify.slugify(instance_id, separator="_") if instance_id else None
|
||||
self._auto_save_upon_exit = auto_save_upon_exit
|
||||
self._profile_management_enabled = enable_profile_management
|
||||
self._startup_profile = self._normalize_startup_profile(startup_profile)
|
||||
self._restore_initial_profile = restore_initial_profile
|
||||
self._init_profile = init_profile
|
||||
self._start_empty = start_empty
|
||||
super().__init__(
|
||||
parent, default_add_direction=default_add_direction, title="BEC Dock Area", **kwargs
|
||||
parent,
|
||||
default_add_direction=default_add_direction,
|
||||
title="Advanced Dock Area",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Initialize mode property first (before toolbar setup)
|
||||
@@ -156,16 +161,14 @@ class BECDockArea(DockAreaWidget):
|
||||
self._root_layout.insertWidget(0, self.toolbar)
|
||||
|
||||
# Populate and hook the workspace combo
|
||||
self._refresh_workspace_list()
|
||||
self._current_profile_name = None
|
||||
self._empty_profile_active = False
|
||||
self._empty_profile_consumed = False
|
||||
self._pending_autosave_skip: tuple[str, str] | None = None
|
||||
self._exit_snapshot_written = False
|
||||
self._refresh_workspace_list()
|
||||
|
||||
# State manager
|
||||
self.state_manager = WidgetStateManager(
|
||||
self, serialize_from_root=True, root_id="BECDockArea"
|
||||
self, serialize_from_root=True, root_id="AdvancedDockArea"
|
||||
)
|
||||
|
||||
# Developer mode state
|
||||
@@ -173,85 +176,84 @@ class BECDockArea(DockAreaWidget):
|
||||
# Initialize default editable state based on current lock
|
||||
self._set_editable(True) # default to editable; will sync toolbar toggle below
|
||||
|
||||
if self._ensure_initial_profile():
|
||||
self._refresh_workspace_list()
|
||||
|
||||
# Apply the requested mode after everything is set up
|
||||
self.mode = mode
|
||||
self._fetch_initial_profile()
|
||||
if self._restore_initial_profile:
|
||||
self._fetch_initial_profile()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_startup_profile(startup_profile: StartupProfile) -> StartupProfile:
|
||||
def _ensure_initial_profile(self) -> bool:
|
||||
"""
|
||||
Normalize startup profile values.
|
||||
"""
|
||||
if startup_profile == "":
|
||||
return None
|
||||
return startup_profile
|
||||
Ensure the "general" workspace profile always exists for the current namespace.
|
||||
The "general" profile is mandatory and will be recreated if deleted.
|
||||
If list_profile fails due to file permission or corrupted profiles, no action taken.
|
||||
|
||||
def _resolve_restore_startup_profile(self) -> str | None:
|
||||
Returns:
|
||||
bool: True if a profile was created, False otherwise.
|
||||
"""
|
||||
Resolve the profile name when startup profile is set to "restore".
|
||||
"""
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
namespace = self.profile_namespace
|
||||
try:
|
||||
existing_profiles = list_profiles(namespace)
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.warning(f"Unable to enumerate profiles for namespace '{namespace}': {exc}")
|
||||
return False
|
||||
|
||||
instance_id = self._last_profile_instance_id()
|
||||
if instance_id:
|
||||
inst_profile = get_last_profile(
|
||||
namespace=namespace, instance=instance_id, allow_namespace_fallback=False
|
||||
)
|
||||
if inst_profile and self._profile_exists(inst_profile, namespace):
|
||||
return inst_profile
|
||||
# Always ensure "general" profile exists
|
||||
name = "general"
|
||||
if name in existing_profiles:
|
||||
return False
|
||||
|
||||
last = get_last_profile(namespace=namespace)
|
||||
if last and self._profile_exists(last, namespace):
|
||||
return last
|
||||
logger.info(
|
||||
f"Profile '{name}' not found in namespace '{namespace}'. Creating mandatory '{name}' workspace."
|
||||
)
|
||||
|
||||
combo_text = combo.currentText().strip()
|
||||
if combo_text and self._profile_exists(combo_text, namespace):
|
||||
return combo_text
|
||||
|
||||
return None
|
||||
self._write_profile_settings(name, namespace, save_preview=False)
|
||||
set_quick_select(name, True, namespace=namespace)
|
||||
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
||||
return True
|
||||
|
||||
def _fetch_initial_profile(self):
|
||||
startup_profile = self._startup_profile
|
||||
# Restore last-used profile if available; otherwise fall back to combo selection
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
namespace = self.profile_namespace
|
||||
init_profile = None
|
||||
|
||||
if startup_profile == "skip":
|
||||
logger.debug("Skipping startup profile initialization.")
|
||||
return
|
||||
|
||||
if startup_profile == "restore":
|
||||
restored = self._resolve_restore_startup_profile()
|
||||
if restored:
|
||||
self._load_initial_profile(restored)
|
||||
return
|
||||
self._start_empty_workspace()
|
||||
return
|
||||
|
||||
if startup_profile is None:
|
||||
self._start_empty_workspace()
|
||||
return
|
||||
|
||||
self._load_initial_profile(startup_profile)
|
||||
# First priority: use init_profile if explicitly provided
|
||||
if self._init_profile:
|
||||
init_profile = self._init_profile
|
||||
else:
|
||||
# Try to restore from last used profile
|
||||
instance_id = self._last_profile_instance_id()
|
||||
if instance_id:
|
||||
inst_profile = get_last_profile(
|
||||
namespace=namespace, instance=instance_id, allow_namespace_fallback=False
|
||||
)
|
||||
if inst_profile and self._profile_exists(inst_profile, namespace):
|
||||
init_profile = inst_profile
|
||||
if not init_profile:
|
||||
last = get_last_profile(namespace=namespace)
|
||||
if last and self._profile_exists(last, namespace):
|
||||
init_profile = last
|
||||
else:
|
||||
text = combo.currentText()
|
||||
init_profile = text if text else None
|
||||
if not init_profile:
|
||||
# Fall back to "general" profile which is guaranteed to exist
|
||||
if self._profile_exists("general", namespace):
|
||||
init_profile = "general"
|
||||
if init_profile:
|
||||
self._load_initial_profile(init_profile)
|
||||
|
||||
def _load_initial_profile(self, name: str) -> None:
|
||||
"""Load the initial profile."""
|
||||
self.load_profile(name)
|
||||
self.load_profile(name, start_empty=self._start_empty)
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
combo.blockSignals(True)
|
||||
if not self._empty_profile_active:
|
||||
combo.setCurrentText(name)
|
||||
combo.setCurrentText(name)
|
||||
combo.blockSignals(False)
|
||||
|
||||
def _start_empty_workspace(self) -> None:
|
||||
"""
|
||||
Initialize the dock area in transient empty-profile mode.
|
||||
"""
|
||||
if (
|
||||
getattr(self, "_current_profile_name", None) is None
|
||||
and not self._empty_profile_consumed
|
||||
):
|
||||
self.delete_all()
|
||||
self._enter_empty_profile_state()
|
||||
|
||||
def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None:
|
||||
prefs = getattr(dock, "_dock_preferences", {}) or {}
|
||||
if prefs.get("show_settings_action") is None:
|
||||
@@ -301,7 +303,7 @@ class BECDockArea(DockAreaWidget):
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
such as `AdvancedDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
object_name(str | None): Optional object name to assign to the created widget.
|
||||
@@ -598,6 +600,13 @@ class BECDockArea(DockAreaWidget):
|
||||
"""Namespace used to scope user/default profile files for this dock area."""
|
||||
return self._resolve_profile_namespace()
|
||||
|
||||
def _active_profile_name_or_default(self) -> str:
|
||||
name = getattr(self, "_current_profile_name", None)
|
||||
if not name:
|
||||
name = "general"
|
||||
self._current_profile_name = name
|
||||
return name
|
||||
|
||||
def _profile_exists(self, name: str, namespace: str | None) -> bool:
|
||||
return any(
|
||||
os.path.exists(path) for path in user_profile_candidates(name, namespace)
|
||||
@@ -665,26 +674,12 @@ class BECDockArea(DockAreaWidget):
|
||||
name: The profile name.
|
||||
namespace: The profile namespace.
|
||||
"""
|
||||
self._empty_profile_active = False
|
||||
self._empty_profile_consumed = True
|
||||
self._current_profile_name = name
|
||||
self.profile_changed.emit(name)
|
||||
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
combo.refresh_profiles(active_profile=name)
|
||||
|
||||
def _enter_empty_profile_state(self) -> None:
|
||||
"""
|
||||
Switch to the transient empty workspace state.
|
||||
|
||||
In this mode there is no active profile name, the toolbar shows an
|
||||
explicit blank profile entry, and no autosave on shutdown is performed.
|
||||
"""
|
||||
self._empty_profile_active = True
|
||||
self._current_profile_name = None
|
||||
self._pending_autosave_skip = None
|
||||
self._refresh_workspace_list()
|
||||
|
||||
@SafeSlot()
|
||||
def list_profiles(self) -> list[str]:
|
||||
"""
|
||||
@@ -818,10 +813,10 @@ class BECDockArea(DockAreaWidget):
|
||||
"""
|
||||
self.save_profile(name, show_dialog=True)
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
@SafeSlot(str, bool)
|
||||
@rpc_timeout(None)
|
||||
def load_profile(self, name: str | None = None):
|
||||
def load_profile(self, name: str | None = None, start_empty: bool = False):
|
||||
"""
|
||||
Load a workspace profile.
|
||||
|
||||
@@ -830,10 +825,8 @@ class BECDockArea(DockAreaWidget):
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile to load. If None, prompts the user.
|
||||
start_empty (bool): If True, load a profile without any widgets. Danger of overwriting the dynamic state of that profile.
|
||||
"""
|
||||
if name == "":
|
||||
return
|
||||
|
||||
if not name: # Gui fallback if the name is not provided
|
||||
name, ok = QInputDialog.getText(
|
||||
self, "Load Workspace", "Enter the name of the workspace profile to load:"
|
||||
@@ -865,6 +858,10 @@ class BECDockArea(DockAreaWidget):
|
||||
# Clear existing docks and remove all widgets
|
||||
self.delete_all()
|
||||
|
||||
if start_empty:
|
||||
self._finalize_profile_change(name, namespace)
|
||||
return
|
||||
|
||||
# Rebuild widgets and restore states
|
||||
for item in read_manifest(settings):
|
||||
obj_name = item["object_name"]
|
||||
@@ -1010,36 +1007,25 @@ class BECDockArea(DockAreaWidget):
|
||||
"""
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
active_profile = getattr(self, "_current_profile_name", None)
|
||||
empty_profile_active = bool(getattr(self, "_empty_profile_active", False))
|
||||
namespace = self.profile_namespace
|
||||
if hasattr(combo, "set_quick_profile_provider"):
|
||||
combo.set_quick_profile_provider(lambda ns=namespace: list_quick_profiles(namespace=ns))
|
||||
if hasattr(combo, "refresh_profiles"):
|
||||
if empty_profile_active:
|
||||
combo.refresh_profiles(active_profile, show_empty_profile=True)
|
||||
else:
|
||||
combo.refresh_profiles(active_profile)
|
||||
combo.refresh_profiles(active_profile)
|
||||
else:
|
||||
# Fallback for regular QComboBox
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
quick_profiles = list_quick_profiles(namespace=namespace)
|
||||
items = [""] if empty_profile_active else []
|
||||
items.extend(quick_profiles)
|
||||
items = list(quick_profiles)
|
||||
if active_profile and active_profile not in items:
|
||||
items.insert(0, active_profile)
|
||||
combo.addItems(items)
|
||||
if empty_profile_active:
|
||||
idx = combo.findText("")
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
elif active_profile:
|
||||
if active_profile:
|
||||
idx = combo.findText(active_profile)
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
if empty_profile_active:
|
||||
combo.setToolTip("Unsaved empty workspace")
|
||||
elif active_profile and active_profile not in quick_profiles:
|
||||
if active_profile and active_profile not in quick_profiles:
|
||||
combo.setToolTip("Active profile is not in quick select")
|
||||
else:
|
||||
combo.setToolTip("")
|
||||
@@ -1144,16 +1130,7 @@ class BECDockArea(DockAreaWidget):
|
||||
logger.info("ADS prepare_for_shutdown: skipping (already handled or destroyed)")
|
||||
return
|
||||
|
||||
if getattr(self, "_empty_profile_active", False):
|
||||
logger.info("ADS prepare_for_shutdown: skipping autosave for unsaved empty workspace")
|
||||
self._exit_snapshot_written = True
|
||||
return
|
||||
|
||||
name = getattr(self, "_current_profile_name", None)
|
||||
if not name:
|
||||
logger.info("ADS prepare_for_shutdown: skipping autosave (no active profile)")
|
||||
self._exit_snapshot_written = True
|
||||
return
|
||||
name = self._active_profile_name_or_default()
|
||||
|
||||
namespace = self.profile_namespace
|
||||
settings = open_user_settings(name, namespace=namespace)
|
||||
@@ -1161,33 +1138,6 @@ class BECDockArea(DockAreaWidget):
|
||||
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
||||
self._exit_snapshot_written = True
|
||||
|
||||
def register_tour_steps(self, guided_tour, main_app):
|
||||
"""Register Dock Area components with the guided tour.
|
||||
|
||||
Args:
|
||||
guided_tour: The GuidedTour instance to register with.
|
||||
main_app: The main application instance (for accessing set_current).
|
||||
|
||||
Returns:
|
||||
ViewTourSteps | None: Model containing view title and step IDs.
|
||||
"""
|
||||
|
||||
step_ids = []
|
||||
|
||||
# Register Dock Area toolbar
|
||||
def get_dock_toolbar():
|
||||
main_app.set_current("dock_area")
|
||||
return (self.toolbar, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_dock_toolbar,
|
||||
title="Dock Area Toolbar",
|
||||
text="Use this toolbar to add widgets, manage workspaces, save and load profiles, and control the layout of your workspace.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
return ViewTourSteps(view_title="Dock Area Workspace", step_ids=step_ids)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the dock area.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Utilities for managing BECDockArea profiles stored in INI files.
|
||||
Utilities for managing AdvancedDockArea profiles stored in INI files.
|
||||
|
||||
Policy:
|
||||
- All created/modified profiles are stored under the BEC settings root: <base_path>/profiles/{default,user}
|
||||
@@ -36,12 +36,12 @@ ProfileOrigin = Literal["module", "plugin", "settings", "unknown"]
|
||||
|
||||
def module_profiles_dir() -> str:
|
||||
"""
|
||||
Return the built-in BECDockArea profiles directory bundled with the module.
|
||||
Return the built-in AdvancedDockArea profiles directory bundled with the module.
|
||||
|
||||
Returns:
|
||||
str: Absolute path of the read-only module profiles directory.
|
||||
"""
|
||||
return os.path.join(MODULE_PATH, "containers", "dock_area", "profiles")
|
||||
return os.path.join(MODULE_PATH, "containers", "advanced_dock_area", "profiles")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -115,12 +115,12 @@ def _settings_profiles_root() -> str:
|
||||
str: Absolute path to the profiles root. The directory is created if missing.
|
||||
"""
|
||||
client = BECClient()
|
||||
bec_widgets_settings = client._service_config.config.get("widgets_settings")
|
||||
bec_widgets_settings = client._service_config.config.get("bec_widgets_settings")
|
||||
bec_widgets_setting_path = (
|
||||
bec_widgets_settings.get("base_path") if bec_widgets_settings else None
|
||||
)
|
||||
default_path = os.path.join(bec_widgets_setting_path, "profiles")
|
||||
root = os.path.expanduser(os.environ.get("BECWIDGETS_PROFILE_DIR", default_path))
|
||||
root = os.environ.get("BECWIDGETS_PROFILE_DIR", default_path)
|
||||
os.makedirs(root, exist_ok=True)
|
||||
return root
|
||||
|
||||
@@ -138,7 +138,7 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
|
||||
"""
|
||||
base = os.path.join(_settings_profiles_root(), segment)
|
||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
||||
path = os.path.expanduser(os.path.join(base, ns) if ns else base)
|
||||
path = os.path.join(base, ns) if ns else base
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ class WorkSpaceManager(BECWidget, QWidget):
|
||||
return
|
||||
|
||||
self.target_widget.save_profile_dialog()
|
||||
# BECDockArea will emit profile_changed which will trigger table refresh,
|
||||
# AdvancedDockArea will emit profile_changed which will trigger table refresh,
|
||||
# but ensure the UI stays in sync even if the signal is delayed.
|
||||
self.render_table()
|
||||
current = getattr(self.target_widget, "_current_profile_name", None)
|
||||
|
||||
@@ -24,15 +24,12 @@ class ProfileComboBox(QComboBox):
|
||||
def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None:
|
||||
self._quick_provider = provider
|
||||
|
||||
def refresh_profiles(
|
||||
self, active_profile: str | None = None, show_empty_profile: bool = False
|
||||
) -> None:
|
||||
def refresh_profiles(self, active_profile: str | None = None):
|
||||
"""
|
||||
Refresh the profile list and ensure the active profile is visible.
|
||||
|
||||
Args:
|
||||
active_profile(str | None): The currently active profile name.
|
||||
show_empty_profile(bool): If True, show an explicit empty unsaved workspace entry.
|
||||
"""
|
||||
|
||||
current_text = active_profile or self.currentText()
|
||||
@@ -42,22 +39,9 @@ class ProfileComboBox(QComboBox):
|
||||
quick_profiles = self._quick_provider()
|
||||
quick_set = set(quick_profiles)
|
||||
|
||||
items: list[str] = []
|
||||
if show_empty_profile:
|
||||
items.append("")
|
||||
|
||||
items = list(quick_profiles)
|
||||
if active_profile and active_profile not in quick_set:
|
||||
items.append(active_profile)
|
||||
|
||||
for profile in quick_profiles:
|
||||
if profile not in items:
|
||||
items.append(profile)
|
||||
|
||||
if active_profile and active_profile not in quick_set:
|
||||
# keep active profile at the top when not in quick list
|
||||
items.remove(active_profile)
|
||||
insert_pos = 1 if show_empty_profile else 0
|
||||
items.insert(insert_pos, active_profile)
|
||||
items.insert(0, active_profile)
|
||||
|
||||
for profile in items:
|
||||
self.addItem(profile)
|
||||
@@ -68,15 +52,6 @@ class ProfileComboBox(QComboBox):
|
||||
self.setItemData(idx, None, Qt.ItemDataRole.ToolTipRole)
|
||||
self.setItemData(idx, None, Qt.ItemDataRole.ForegroundRole)
|
||||
|
||||
if profile == "":
|
||||
self.setItemData(idx, "Unsaved empty workspace", Qt.ItemDataRole.ToolTipRole)
|
||||
if active_profile is None:
|
||||
font = QFont(self.font())
|
||||
font.setItalic(True)
|
||||
self.setItemData(idx, font, Qt.ItemDataRole.FontRole)
|
||||
self.setCurrentIndex(idx)
|
||||
continue
|
||||
|
||||
if active_profile and profile == active_profile:
|
||||
tooltip = "Active workspace profile"
|
||||
if profile not in quick_set:
|
||||
@@ -94,23 +69,16 @@ class ProfileComboBox(QComboBox):
|
||||
self.setItemData(idx, "Not in quick select", Qt.ItemDataRole.ToolTipRole)
|
||||
|
||||
# Restore selection if possible
|
||||
if show_empty_profile and active_profile is None:
|
||||
empty_idx = self.findText("")
|
||||
if empty_idx >= 0:
|
||||
self.setCurrentIndex(empty_idx)
|
||||
else:
|
||||
index = self.findText(current_text)
|
||||
if index >= 0:
|
||||
self.setCurrentIndex(index)
|
||||
index = self.findText(current_text)
|
||||
if index >= 0:
|
||||
self.setCurrentIndex(index)
|
||||
|
||||
self.blockSignals(False)
|
||||
if active_profile and self.currentText() != active_profile:
|
||||
idx = self.findText(active_profile)
|
||||
if idx >= 0:
|
||||
self.setCurrentIndex(idx)
|
||||
if show_empty_profile and self.currentText() == "":
|
||||
self.setToolTip("Unsaved empty workspace")
|
||||
elif active_profile and active_profile not in quick_set:
|
||||
if active_profile and active_profile not in quick_set:
|
||||
self.setToolTip("Active profile is not in quick select")
|
||||
else:
|
||||
self.setToolTip("")
|
||||
@@ -118,7 +86,7 @@ class ProfileComboBox(QComboBox):
|
||||
|
||||
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a workspace toolbar bundle for BECDockArea.
|
||||
Creates a workspace toolbar bundle for AdvancedDockArea.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
@@ -171,7 +139,7 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
|
||||
|
||||
class WorkspaceConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for workspace actions in BECDockArea.
|
||||
Connection class for workspace actions in AdvancedDockArea.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
|
||||
@@ -101,12 +101,14 @@ class Explorer(BECWidget, QWidget):
|
||||
palette = get_theme_palette()
|
||||
separator_color = palette.mid().color()
|
||||
|
||||
self.splitter.setStyleSheet(f"""
|
||||
self.splitter.setStyleSheet(
|
||||
f"""
|
||||
QSplitter::handle {{
|
||||
height: 0.1px;
|
||||
background-color: rgba({separator_color.red()}, {separator_color.green()}, {separator_color.blue()}, 60);
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
def _update_spacer(self) -> None:
|
||||
"""Update the spacer size based on section states"""
|
||||
|
||||
@@ -63,7 +63,7 @@ class ScriptTreeWidget(QWidget):
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Create tree view
|
||||
self.tree = QTreeView(parent=self)
|
||||
self.tree = QTreeView()
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setRootIsDecorated(True)
|
||||
|
||||
@@ -71,12 +71,12 @@ class ScriptTreeWidget(QWidget):
|
||||
self.tree.setMouseTracking(True)
|
||||
|
||||
# Create file system model
|
||||
self.model = QFileSystemModel(parent=self)
|
||||
self.model = QFileSystemModel()
|
||||
self.model.setNameFilters(["*.py"])
|
||||
self.model.setNameFilterDisables(False)
|
||||
|
||||
# Create proxy model to filter out underscore directories
|
||||
self.proxy_model = QSortFilterProxyModel(parent=self)
|
||||
self.proxy_model = QSortFilterProxyModel()
|
||||
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
|
||||
self.proxy_model.setSourceModel(self.model)
|
||||
self.tree.setModel(self.proxy_model)
|
||||
|
||||
@@ -1,83 +1,27 @@
|
||||
import sys
|
||||
|
||||
from qtpy import QtGui, QtWidgets
|
||||
from qtpy.QtCore import QPoint, Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QProgressBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
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.WindowType.ToolTip
|
||||
| Qt.WindowType.FramelessWindowHint
|
||||
| Qt.WindowType.WindowStaysOnTopHint,
|
||||
)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
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(14, 14, 14, 14)
|
||||
|
||||
self._card = QFrame(self)
|
||||
self._card.setObjectName("WidgetTooltipCard")
|
||||
card_layout = QVBoxLayout(self._card)
|
||||
card_layout.setContentsMargins(12, 10, 12, 10)
|
||||
card_layout.addWidget(self.content)
|
||||
|
||||
shadow = QtWidgets.QGraphicsDropShadowEffect(self._card)
|
||||
shadow.setBlurRadius(18)
|
||||
shadow.setOffset(0, 2)
|
||||
shadow.setColor(QtGui.QColor(0, 0, 0, 140))
|
||||
self._card.setGraphicsEffect(shadow)
|
||||
|
||||
layout.addWidget(self._card)
|
||||
self.apply_theme()
|
||||
layout.setContentsMargins(6, 6, 6, 6)
|
||||
layout.addWidget(self.content)
|
||||
self.adjustSize()
|
||||
|
||||
def leaveEvent(self, _event) -> None:
|
||||
self.hide()
|
||||
|
||||
def apply_theme(self) -> None:
|
||||
palette = QApplication.palette()
|
||||
base = palette.color(QtGui.QPalette.ColorRole.Base)
|
||||
text = palette.color(QtGui.QPalette.ColorRole.Text)
|
||||
border = palette.color(QtGui.QPalette.ColorRole.Mid)
|
||||
background = QtGui.QColor(base)
|
||||
background.setAlpha(242)
|
||||
self._card.setStyleSheet(f"""
|
||||
QFrame#WidgetTooltipCard {{
|
||||
background: {background.name(QtGui.QColor.NameFormat.HexArgb)};
|
||||
border: 1px solid {border.name()};
|
||||
border-radius: 12px;
|
||||
}}
|
||||
QFrame#WidgetTooltipCard QLabel {{
|
||||
color: {text.name()};
|
||||
background: transparent;
|
||||
}}
|
||||
""")
|
||||
|
||||
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
|
||||
"""
|
||||
Show the tooltip above a global position, adjusting to stay within screen bounds.
|
||||
|
||||
Args:
|
||||
global_pos(QPoint): The global position to show above.
|
||||
offset(int, optional): The vertical offset from the global position. Defaults to 8 pixels.
|
||||
"""
|
||||
self.apply_theme()
|
||||
self.adjustSize()
|
||||
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
|
||||
screen_geo = screen.availableGeometry()
|
||||
@@ -86,43 +30,11 @@ class WidgetTooltip(QWidget):
|
||||
x = global_pos.x() - geom.width() // 2
|
||||
y = global_pos.y() - geom.height() - offset
|
||||
|
||||
self._navigate_screen_coordinates(screen_geo, geom, x, y)
|
||||
|
||||
def show_near(self, global_pos: QPoint, offset: QPoint | None = None) -> None:
|
||||
"""
|
||||
Show the tooltip near a global position, adjusting to stay within screen bounds.
|
||||
By default, it will try to show below and to the right of the position,
|
||||
but if that would cause it to go off-screen, it will flip to the other side.
|
||||
|
||||
Args:
|
||||
global_pos(QPoint): The global position to show near.
|
||||
offset(QPoint, optional): The offset from the global position. Defaults to QPoint(12, 16).
|
||||
"""
|
||||
|
||||
self.apply_theme()
|
||||
self.adjustSize()
|
||||
offset = offset or QPoint(12, 16)
|
||||
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
|
||||
screen_geo = screen.availableGeometry()
|
||||
geom = self.geometry()
|
||||
|
||||
x = global_pos.x() + offset.x()
|
||||
y = global_pos.y() + offset.y()
|
||||
|
||||
if x + geom.width() > screen_geo.right():
|
||||
x = global_pos.x() - geom.width() - abs(offset.x())
|
||||
if y + geom.height() > screen_geo.bottom():
|
||||
y = global_pos.y() - geom.height() - abs(offset.y())
|
||||
|
||||
self._navigate_screen_coordinates(screen_geo, geom, x, y)
|
||||
|
||||
def _navigate_screen_coordinates(self, screen_geo, geom, x, y):
|
||||
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()
|
||||
self.raise_()
|
||||
|
||||
|
||||
class HoverWidget(QWidget):
|
||||
|
||||
@@ -134,13 +134,15 @@ class NotificationToast(QFrame):
|
||||
bg.setAlphaF(0.30)
|
||||
icon_bg = bg.name(QtGui.QColor.HexArgb)
|
||||
icon_btn.setFixedSize(40, 40)
|
||||
icon_btn.setStyleSheet(f"""
|
||||
icon_btn.setStyleSheet(
|
||||
f"""
|
||||
QToolButton {{
|
||||
background: {icon_bg};
|
||||
border: none;
|
||||
border-radius: 20px; /* perfect circle */
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
title_lbl = QtWidgets.QLabel(self._title)
|
||||
|
||||
@@ -325,13 +327,15 @@ class NotificationToast(QFrame):
|
||||
bg = QtGui.QColor(SEVERITY[value.value]["color"])
|
||||
bg.setAlphaF(0.30)
|
||||
icon_bg = bg.name(QtGui.QColor.HexArgb)
|
||||
self._icon_btn.setStyleSheet(f"""
|
||||
self._icon_btn.setStyleSheet(
|
||||
f"""
|
||||
QToolButton {{
|
||||
background: {icon_bg};
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
self.apply_theme(self._theme)
|
||||
# keep injected gradient in sync
|
||||
if getattr(self, "_hg_enabled", False):
|
||||
@@ -387,7 +391,8 @@ class NotificationToast(QFrame):
|
||||
card_bg.setAlphaF(0.88)
|
||||
btn_hover = self._accent_color.name()
|
||||
|
||||
self.setStyleSheet(f"""
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
#NotificationToast {{
|
||||
background: {card_bg.name(QtGui.QColor.HexArgb)};
|
||||
border-radius: 12px;
|
||||
@@ -401,15 +406,18 @@ class NotificationToast(QFrame):
|
||||
font-size: 14px;
|
||||
}}
|
||||
#NotificationToast QPushButton:hover {{ color: {btn_hover}; }}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
# traceback panel colours
|
||||
trace_bg = "#1e1e1e" if theme == "dark" else "#f0f0f0"
|
||||
self.trace_view.setStyleSheet(f"""
|
||||
self.trace_view.setStyleSheet(
|
||||
f"""
|
||||
background:{trace_bg};
|
||||
color:{palette['body']};
|
||||
border:none;
|
||||
border-radius:8px;
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# icon glyph vs badge background: darker badge, lighter icon in light mode
|
||||
icon_fg = "#ffffff" if theme == "light" else self._accent_color.name()
|
||||
@@ -430,13 +438,15 @@ class NotificationToast(QFrame):
|
||||
else:
|
||||
badge_bg.setAlphaF(0.30)
|
||||
icon_bg = badge_bg.name(QtGui.QColor.HexArgb)
|
||||
self._icon_btn.setStyleSheet(f"""
|
||||
self._icon_btn.setStyleSheet(
|
||||
f"""
|
||||
QToolButton {{
|
||||
background: {icon_bg};
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# stronger accent wash in light mode, slightly stronger in dark too
|
||||
self._accent_alpha = 110 if theme == "light" else 60
|
||||
@@ -583,7 +593,8 @@ class NotificationCentre(QScrollArea):
|
||||
self.setWidgetResizable(True)
|
||||
# transparent background so only the toast cards are visible
|
||||
self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
|
||||
self.setStyleSheet("""
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
#NotificationCentre { background: transparent; }
|
||||
#NotificationCentre QScrollBar:vertical {
|
||||
background: transparent;
|
||||
@@ -599,7 +610,8 @@ class NotificationCentre(QScrollArea):
|
||||
#NotificationCentre QScrollBar::sub-line:vertical { height: 0; }
|
||||
#NotificationCentre QScrollBar::add-page:vertical,
|
||||
#NotificationCentre QScrollBar::sub-page:vertical { background: transparent; }
|
||||
""")
|
||||
"""
|
||||
)
|
||||
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||||
self.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||||
self.setFixedWidth(fixed_width)
|
||||
@@ -946,7 +958,8 @@ class NotificationIndicator(QWidget):
|
||||
self._group.buttonToggled.connect(self._button_toggled)
|
||||
|
||||
# minimalistic look: no frames or backgrounds on the buttons
|
||||
self.setStyleSheet("""
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QToolButton {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -957,7 +970,8 @@ class NotificationIndicator(QWidget):
|
||||
background: rgba(255, 255, 255, 40);
|
||||
font-weight: 600;
|
||||
}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# initial state: none checked (auto‑dismiss behaviour)
|
||||
for k in kinds:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib import messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
||||
@@ -31,17 +32,13 @@ from bec_widgets.widgets.containers.main_window.addons.notification_center.notif
|
||||
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
|
||||
from bec_widgets.widgets.utility.widget_hierarchy_tree.widget_hierarchy_tree import (
|
||||
WidgetHierarchyDialog,
|
||||
)
|
||||
from bec_widgets.widgets.utility.feedback_dialog.feedback_dialog import FeedbackDialog
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
# Ensure the application does not use the native menu bar on macOS to be consistent with linux development.
|
||||
QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontUseNativeMenuBar, True)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECMainWindow(BECWidget, QMainWindow):
|
||||
RPC = True
|
||||
@@ -54,7 +51,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
|
||||
self.app = QApplication.instance()
|
||||
self.status_bar = self.statusBar()
|
||||
self._launcher_window = None
|
||||
self.setWindowTitle(window_title)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||
|
||||
@@ -63,7 +59,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self.notification_broker = BECNotificationBroker(parent=self)
|
||||
self._nc_margin = 16
|
||||
self._position_notification_centre()
|
||||
self._widget_hierarchy_dialog: WidgetHierarchyDialog | None = None
|
||||
|
||||
# Init ui
|
||||
self._init_ui()
|
||||
@@ -196,18 +191,14 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
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, rpc_exposed=False, rpc_passthrough_children=False
|
||||
)
|
||||
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(self.SCAN_PROGRESS_HEIGHT)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
|
||||
self._scan_progress_bar_full = ScanProgressBar(
|
||||
self, rpc_exposed=False, rpc_passthrough_children=False
|
||||
)
|
||||
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
|
||||
)
|
||||
@@ -265,7 +256,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self.ui = loader.loader(ui_file)
|
||||
self.setCentralWidget(self.ui)
|
||||
|
||||
def fetch_theme(self) -> str:
|
||||
def _fetch_theme(self) -> str:
|
||||
return self.app.theme.theme
|
||||
|
||||
def _get_launcher_from_qapp(self):
|
||||
@@ -286,16 +277,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
Show the launcher if it exists.
|
||||
"""
|
||||
launcher = self._get_launcher_from_qapp()
|
||||
if launcher is None:
|
||||
from bec_widgets.applications.launch_window import LaunchWindow
|
||||
|
||||
cli_server = getattr(self.bec_dispatcher, "cli_server", None)
|
||||
if cli_server is None:
|
||||
logger.warning("Cannot open launcher: CLI server is not available.")
|
||||
return
|
||||
launcher = LaunchWindow(gui_id=f"{cli_server.gui_id}:launcher")
|
||||
launcher.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore[arg-type]
|
||||
self._launcher_window = launcher
|
||||
if launcher:
|
||||
launcher.show()
|
||||
launcher.activateWindow()
|
||||
@@ -333,11 +314,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
light_theme_action.triggered.connect(lambda: self.change_theme("light"))
|
||||
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
|
||||
|
||||
theme_menu.addSeparator()
|
||||
widget_tree_action = QAction("Show Widget Hierarchy", self)
|
||||
widget_tree_action.triggered.connect(self._show_widget_hierarchy_dialog)
|
||||
theme_menu.addAction(widget_tree_action)
|
||||
|
||||
# Set the default theme
|
||||
if hasattr(self.app, "theme") and self.app.theme:
|
||||
theme_name = self.app.theme.theme.lower()
|
||||
@@ -368,6 +344,34 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
help_menu.addAction(widgets_docs)
|
||||
help_menu.addAction(bug_report)
|
||||
|
||||
# Add separator before feedback
|
||||
help_menu.addSeparator()
|
||||
|
||||
# Feedback action
|
||||
feedback_icon = QApplication.style().standardIcon(
|
||||
QStyle.StandardPixmap.SP_MessageBoxQuestion
|
||||
)
|
||||
feedback_action = QAction("Feedback", self)
|
||||
feedback_action.setIcon(feedback_icon)
|
||||
feedback_action.triggered.connect(self._show_feedback_dialog)
|
||||
help_menu.addAction(feedback_action)
|
||||
|
||||
def _show_feedback_dialog(self):
|
||||
"""Show the feedback dialog and handle the submitted feedback."""
|
||||
dialog = FeedbackDialog(self)
|
||||
|
||||
def on_feedback_submitted(rating: int, comment: str, email: str):
|
||||
rating = max(1, min(rating, 5)) # Ensure rating is between 1 and 5
|
||||
username = os.getlogin()
|
||||
|
||||
message = messages.FeedbackMessage(
|
||||
feedback=comment, rating=rating, contact=email, username=username
|
||||
)
|
||||
self.bec_dispatcher.client.connector.send(MessageEndpoints.submit_feedback(), message)
|
||||
|
||||
dialog.feedback_submitted.connect(on_feedback_submitted)
|
||||
dialog.exec()
|
||||
|
||||
################################################################################
|
||||
# Status Bar Addons
|
||||
################################################################################
|
||||
@@ -421,23 +425,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
return True
|
||||
return super().event(event)
|
||||
|
||||
def _show_widget_hierarchy_dialog(self):
|
||||
if self._widget_hierarchy_dialog is None:
|
||||
dialog = WidgetHierarchyDialog(root_widget=None, parent=self)
|
||||
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||
dialog.destroyed.connect(lambda: setattr(self, "_widget_hierarchy_dialog", None))
|
||||
self._widget_hierarchy_dialog = dialog
|
||||
self._widget_hierarchy_dialog.refresh()
|
||||
self._widget_hierarchy_dialog.show()
|
||||
self._widget_hierarchy_dialog.raise_()
|
||||
self._widget_hierarchy_dialog.activateWindow()
|
||||
|
||||
def cleanup(self):
|
||||
# Widget hierarchy dialog cleanup
|
||||
if self._widget_hierarchy_dialog is not None:
|
||||
self._widget_hierarchy_dialog.close()
|
||||
self._widget_hierarchy_dialog = None
|
||||
|
||||
# Timer cleanup
|
||||
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
|
||||
self._client_info_expire_timer.stop()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .positioner_box_base import PositionerBoxBase
|
||||
|
||||
__ALL__ = ["PositionerBoxBase"]
|
||||
@@ -14,10 +14,10 @@ from qtpy.QtWidgets import (
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
|
||||
PositionIndicator,
|
||||
)
|
||||
@@ -43,7 +43,7 @@ class DeviceUpdateUIComponents(TypedDict):
|
||||
units: QLabel
|
||||
|
||||
|
||||
class PositionerBoxBase(BECWidget, QWidget):
|
||||
class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
"""Contains some core logic for positioner box widgets"""
|
||||
|
||||
current_path = ""
|
||||
@@ -57,10 +57,7 @@ class PositionerBoxBase(BECWidget, QWidget):
|
||||
parent: The parent widget.
|
||||
device (Positioner): The device to control.
|
||||
"""
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
super().__init__(parent=parent, layout=QVBoxLayout, **kwargs)
|
||||
self._dialog = None
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@@ -126,7 +123,7 @@ class PositionerBoxBase(BECWidget, QWidget):
|
||||
queue="emergency",
|
||||
metadata={"RID": request_id, "response": False},
|
||||
)
|
||||
self.client.connector.send(MessageEndpoints.scan_queue_request(self.client.username), msg)
|
||||
self.client.connector.send(MessageEndpoints.scan_queue_request(), msg)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def _on_device_readback(
|
||||
@@ -176,9 +173,11 @@ class PositionerBoxBase(BECWidget, QWidget):
|
||||
if is_moving:
|
||||
spinner.start()
|
||||
spinner.setToolTip("Device is moving")
|
||||
self.set_global_state("warning")
|
||||
else:
|
||||
spinner.stop()
|
||||
spinner.setToolTip("Device is idle")
|
||||
self.set_global_state("success")
|
||||
else:
|
||||
spinner.setVisible(False)
|
||||
|
||||
@@ -197,8 +196,9 @@ class PositionerBoxBase(BECWidget, QWidget):
|
||||
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
|
||||
position_indicator.set_value(pos)
|
||||
|
||||
@staticmethod
|
||||
def _update_limits_ui(limits: tuple[float, float], position_indicator, setpoint_validator):
|
||||
def _update_limits_ui(
|
||||
self, limits: tuple[float, float], position_indicator, setpoint_validator
|
||||
):
|
||||
if limits is not None and limits[0] != limits[1]:
|
||||
position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
|
||||
setpoint_validator.setRange(limits[0], limits[1])
|
||||
@@ -223,9 +223,8 @@ class PositionerBoxBase(BECWidget, QWidget):
|
||||
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
|
||||
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
|
||||
|
||||
@staticmethod
|
||||
def _toggle_enable_buttons(ui: DeviceUpdateUIComponents, enable: bool) -> None:
|
||||
"""Toggle enable/disable on available buttons
|
||||
def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
|
||||
"""Toogle enable/disable on available buttons
|
||||
|
||||
Args:
|
||||
enable (bool): Enable buttons
|
||||
@@ -14,9 +14,9 @@ from qtpy.QtWidgets import QDoubleSpinBox
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
DeviceUpdateUIComponents,
|
||||
PositionerBoxBase,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -63,10 +63,10 @@ class PositionerBox(PositionerBoxBase):
|
||||
|
||||
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
|
||||
|
||||
self.main_layout.addWidget(self.ui)
|
||||
self.main_layout.setSpacing(0)
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
|
||||
self.addWidget(self.ui)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
|
||||
ui_min_size = self.ui.minimumSize()
|
||||
ui_min_hint = self.ui.minimumSizeHint()
|
||||
self.setMinimumSize(
|
||||
@@ -115,6 +115,8 @@ class PositionerBox(PositionerBoxBase):
|
||||
return
|
||||
old_device = self._device
|
||||
self._device = value
|
||||
if not self.label:
|
||||
self.label = value
|
||||
self.device_changed.emit(old_device, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
|
||||
@@ -15,9 +15,9 @@ from qtpy.QtWidgets import QDoubleSpinBox
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
DeviceUpdateUIComponents,
|
||||
PositionerBoxBase,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -96,9 +96,9 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
|
||||
def connect_ui(self):
|
||||
"""Connect the UI components to signals, data, or routines"""
|
||||
self.main_layout.addWidget(self.ui)
|
||||
self.main_layout.setSpacing(0)
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.addWidget(self.ui)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
def _init_ui(val: QDoubleValidator, device_id: DeviceId):
|
||||
ui = self._device_ui_components_hv(device_id)
|
||||
@@ -200,6 +200,7 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
return
|
||||
old_device = self._device_hor
|
||||
self._device_hor = value
|
||||
self.label = f"{self._device_hor}, {self._device_ver}"
|
||||
self.device_changed_hor.emit(old_device, value)
|
||||
self._init_device(self.device_hor, self.position_update_hor.emit, self.update_limits_hor)
|
||||
|
||||
@@ -219,6 +220,7 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
return
|
||||
old_device = self._device_ver
|
||||
self._device_ver = value
|
||||
self.label = f"{self._device_hor}, {self._device_ver}"
|
||||
self.device_changed_ver.emit(old_device, value)
|
||||
self._init_device(self.device_ver, self.position_update_ver.emit, self.update_limits_ver)
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import os
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QSizePolicy
|
||||
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
|
||||
@@ -24,82 +22,7 @@ class PositionerControlLine(PositionerBox):
|
||||
device (Positioner): The device to control.
|
||||
"""
|
||||
self.current_path = os.path.dirname(__file__)
|
||||
self._indicator_switch_width = 0
|
||||
self._horizontal_indicator_width = 0
|
||||
self._vertical_indicator_width = 15
|
||||
self._indicator_thickness = 10
|
||||
self._indicator_is_horizontal = False
|
||||
self._line_height = self.dimensions[0]
|
||||
super().__init__(parent=parent, device=device, *args, **kwargs)
|
||||
self._configure_line_layout()
|
||||
self._update_indicator_orientation()
|
||||
|
||||
def _configure_line_layout(self):
|
||||
device_box = self.ui.device_box
|
||||
indicator = self.ui.position_indicator
|
||||
|
||||
self.main_layout.setAlignment(Qt.AlignmentFlag(0))
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.ui.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
device_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
|
||||
self._line_height = max(
|
||||
self.dimensions[0],
|
||||
self.ui.minimumSizeHint().height(),
|
||||
self.ui.sizeHint().height(),
|
||||
device_box.minimumSizeHint().height(),
|
||||
device_box.sizeHint().height(),
|
||||
)
|
||||
device_box.setFixedHeight(self._line_height)
|
||||
device_box.setMinimumWidth(self.dimensions[1])
|
||||
device_box.setMaximumWidth(16777215)
|
||||
self.setFixedHeight(self._line_height)
|
||||
self.setMinimumWidth(self.dimensions[1])
|
||||
|
||||
self.ui.verticalLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.ui.verticalLayout.setSpacing(0)
|
||||
self.ui.readback.setMaximumWidth(16777215)
|
||||
self.ui.setpoint.setMaximumWidth(16777215)
|
||||
self.ui.step_size.setMaximumWidth(16777215)
|
||||
|
||||
indicator_hint = indicator.minimumSizeHint()
|
||||
step_hint = self.ui.step_size.sizeHint()
|
||||
self._indicator_thickness = max(indicator_hint.height(), 10)
|
||||
self._vertical_indicator_width = max(indicator.minimumWidth(), 15)
|
||||
self._horizontal_indicator_width = max(90, step_hint.width())
|
||||
base_width = max(device_box.minimumSizeHint().width(), self.dimensions[1])
|
||||
self._indicator_switch_width = (
|
||||
base_width - self._vertical_indicator_width + self._horizontal_indicator_width
|
||||
)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._update_indicator_orientation()
|
||||
|
||||
def _update_indicator_orientation(self):
|
||||
if not hasattr(self, "ui"):
|
||||
return
|
||||
|
||||
indicator = self.ui.position_indicator
|
||||
available_width = self.ui.device_box.width() or self.width() or self.dimensions[1]
|
||||
should_use_horizontal = available_width >= self._indicator_switch_width
|
||||
if should_use_horizontal == self._indicator_is_horizontal:
|
||||
return
|
||||
|
||||
self._indicator_is_horizontal = should_use_horizontal
|
||||
indicator.vertical = not should_use_horizontal
|
||||
|
||||
if should_use_horizontal:
|
||||
indicator.setMinimumSize(self._horizontal_indicator_width, self._indicator_thickness)
|
||||
indicator.setMaximumHeight(self._indicator_thickness)
|
||||
indicator.setMaximumWidth(16777215)
|
||||
indicator.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed)
|
||||
else:
|
||||
indicator.setMinimumSize(self._vertical_indicator_width, self._indicator_thickness)
|
||||
indicator.setMaximumSize(self._vertical_indicator_width, 16777215)
|
||||
indicator.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
|
||||
|
||||
indicator.updateGeometry()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>592</width>
|
||||
<height>76</height>
|
||||
<width>612</width>
|
||||
<height>91</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
@@ -32,29 +26,8 @@
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="device_box">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Device Name</string>
|
||||
</property>
|
||||
@@ -254,12 +227,12 @@
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>PositionIndicator</class>
|
||||
<extends></extends>
|
||||
<extends>QWidget</extends>
|
||||
<header>position_indicator</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>SpinnerWidget</class>
|
||||
<extends></extends>
|
||||
<extends>QWidget</extends>
|
||||
<header>spinner_widget</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
|
||||
@@ -27,13 +27,30 @@ class PositionerGroupBox(QGroupBox):
|
||||
self.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self.layout().setSpacing(0)
|
||||
self.widget = PositionerBox(self, dev_name)
|
||||
self.widget.compact_view = True
|
||||
self.widget.expand_popup = False
|
||||
self.layout().addWidget(self.widget)
|
||||
self.widget.position_update.connect(self._on_position_update)
|
||||
self.widget.expand.connect(self._on_expand)
|
||||
self.setTitle(self.device_name)
|
||||
self.widget.force_update_readback()
|
||||
|
||||
def _on_expand(self, expand):
|
||||
if expand:
|
||||
self.setTitle("")
|
||||
self.setFlat(True)
|
||||
else:
|
||||
self.setTitle(self.device_name)
|
||||
self.setFlat(False)
|
||||
|
||||
def _on_position_update(self, pos: float):
|
||||
self.position_update.emit(pos)
|
||||
precision = getattr(self.widget.dev[self.widget.device], "precision", 8)
|
||||
try:
|
||||
precision = int(precision)
|
||||
except (TypeError, ValueError):
|
||||
precision = int(8)
|
||||
self.widget.label = f"{pos:.{precision}f}"
|
||||
|
||||
def close(self):
|
||||
self.widget.close()
|
||||
|
||||
@@ -266,7 +266,6 @@ class DeviceSignalInputBase(BECWidget):
|
||||
|
||||
Args:
|
||||
device(str): Device to validate.
|
||||
raise_on_false(bool): Raise ValueError if device is not found.
|
||||
"""
|
||||
if device in self.dev:
|
||||
return True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
from typing import Any, Callable, Generator, Iterable, TypeVar
|
||||
|
||||
from bec_lib.utils.json_extended import ExtendedEncoder
|
||||
from bec_lib.utils.json import ExtendedEncoder
|
||||
from qtpy.QtCore import QByteArray, QMimeData, QObject, Signal # type: ignore
|
||||
from qtpy.QtWidgets import QListWidgetItem
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ class _DeviceEntryWidget(QFrame):
|
||||
self.setToolTip(self._rich_text())
|
||||
|
||||
def _rich_text(self):
|
||||
return dedent(f"""
|
||||
return dedent(
|
||||
f"""
|
||||
<b><u><h2> {self._device_spec.name}: </h2></u></b>
|
||||
<table>
|
||||
<tr><td> description: </td><td><i> {self._device_spec.description} </i></td></tr>
|
||||
@@ -59,7 +60,8 @@ class _DeviceEntryWidget(QFrame):
|
||||
<tr><td> enabled: </td><td><i> {self._device_spec.enabled} </i></td></tr>
|
||||
<tr><td> read only: </td><td><i> {self._device_spec.readOnly} </i></td></tr>
|
||||
</table>
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
def setup_title_layout(self, device_spec: HashableDevice):
|
||||
self._title_layout = QHBoxLayout()
|
||||
|
||||
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
||||
from .available_device_group import AvailableDeviceGroup
|
||||
|
||||
|
||||
class _DeviceListWidget(QListWidget):
|
||||
class _DeviceListWiget(QListWidget):
|
||||
|
||||
def _item_iter(self):
|
||||
return (self.item(i) for i in range(self.count()))
|
||||
@@ -44,7 +44,7 @@ class Ui_AvailableDeviceGroup(object):
|
||||
self.n_included.setObjectName("n_included")
|
||||
title_layout.addWidget(self.n_included)
|
||||
|
||||
self.device_list = _DeviceListWidget(AvailableDeviceGroup)
|
||||
self.device_list = _DeviceListWiget(AvailableDeviceGroup)
|
||||
self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
||||
self.device_list.setObjectName("device_list")
|
||||
self.device_list.setFrameStyle(0)
|
||||
|
||||
@@ -34,13 +34,13 @@ class HashModel(str, Enum):
|
||||
class DeviceResourceBackend(Protocol):
|
||||
@property
|
||||
def tag_groups(self) -> dict[str, set[HashableDevice]]:
|
||||
"""A dictionary of all available devices separated by tag groups. The same device may
|
||||
"""A dictionary of all availble devices separated by tag groups. The same device may
|
||||
appear more than once (in different groups)."""
|
||||
...
|
||||
|
||||
@property
|
||||
def all_devices(self) -> set[HashableDevice]:
|
||||
"""A set of all available devices. The same device may not appear more than once."""
|
||||
"""A set of all availble devices. The same device may not appear more than once."""
|
||||
...
|
||||
|
||||
@property
|
||||
|
||||
@@ -5,8 +5,9 @@ in DeviceTableRow entries.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Tuple
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceModel
|
||||
from bec_lib.callback_handler import EventType
|
||||
@@ -18,7 +19,6 @@ from thefuzz import fuzz
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.fuzzy_search import is_match
|
||||
from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import (
|
||||
DeviceTableRow,
|
||||
)
|
||||
@@ -37,6 +37,34 @@ _DeviceCfgIter = Iterable[dict[str, Any]]
|
||||
# DeviceValidationResult: device_config, config_status, connection_status, error_message
|
||||
_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]]
|
||||
|
||||
FUZZY_SEARCH_THRESHOLD = 80
|
||||
|
||||
|
||||
def is_match(
|
||||
text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the text matches any of the relevant keys in the row data.
|
||||
|
||||
Args:
|
||||
text (str): The text to search for.
|
||||
row_data (dict[str, Any]): The row data to search in.
|
||||
relevant_keys (list[str]): The keys to consider for searching.
|
||||
enable_fuzzy (bool): Whether to use fuzzy matching.
|
||||
Returns:
|
||||
bool: True if a match is found, False otherwise.
|
||||
"""
|
||||
for key in relevant_keys:
|
||||
data = str(row_data.get(key, "") or "")
|
||||
if enable_fuzzy:
|
||||
match_ratio = fuzz.partial_ratio(text.lower(), data.lower())
|
||||
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
|
||||
return True
|
||||
else:
|
||||
if text.lower() in data.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TableSortOnHold:
|
||||
"""Context manager for putting table sorting on hold. Works with nested calls."""
|
||||
|
||||
@@ -9,7 +9,6 @@ from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
@@ -172,13 +171,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.layout.addStretch()
|
||||
|
||||
def _add_metadata_form(self):
|
||||
# Wrap metadata form in a group box
|
||||
self._metadata_group = QGroupBox("Scan Metadata", self)
|
||||
self._metadata_group.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
metadata_layout = QVBoxLayout(self._metadata_group)
|
||||
metadata_layout.addWidget(self._metadata_form)
|
||||
|
||||
self.layout.addWidget(self._metadata_group)
|
||||
self.layout.addWidget(self._metadata_form)
|
||||
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
|
||||
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
|
||||
self._metadata_form.form_data_updated.connect(self.update_scan_metadata)
|
||||
|
||||
@@ -347,14 +347,14 @@ class ScanGroupBox(QGroupBox):
|
||||
|
||||
def get_parameters(self, device_object: bool = True):
|
||||
"""
|
||||
Returns the parameters from the widgets in the scan control layout formatted to run scan from BEC.
|
||||
Returns the parameters from the widgets in the scan control layout formated to run scan from BEC.
|
||||
"""
|
||||
if self.box_type == "args":
|
||||
return self._get_arg_parameters(device_object=device_object)
|
||||
return self._get_arg_parameterts(device_object=device_object)
|
||||
elif self.box_type == "kwargs":
|
||||
return self._get_kwarg_parameters(device_object=device_object)
|
||||
|
||||
def _get_arg_parameters(self, device_object: bool = True):
|
||||
def _get_arg_parameterts(self, device_object: bool = True):
|
||||
args = []
|
||||
for i in range(1, self.layout.rowCount()):
|
||||
for j in range(self.layout.columnCount()):
|
||||
|
||||
@@ -2,19 +2,22 @@
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
from qtpy.QtWidgets import QComboBox
|
||||
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DapComboBox(BECWidget, QComboBox):
|
||||
class DapComboBox(BECWidget, QWidget):
|
||||
"""
|
||||
Editable combobox listing the available DAP models.
|
||||
The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.
|
||||
|
||||
The widget behaves as a plain QComboBox and keeps ``fit_model_combobox`` as an alias to itself
|
||||
for backwards compatibility with older call sites.
|
||||
Args:
|
||||
parent: Parent widget.
|
||||
client: BEC client object.
|
||||
gui_id: GUI ID.
|
||||
default: Default device name.
|
||||
"""
|
||||
|
||||
ICON_NAME = "data_exploration"
|
||||
@@ -42,20 +45,19 @@ class DapComboBox(BECWidget, QComboBox):
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
|
||||
self.fit_model_combobox = self # Just for backwards compatibility with older call sites, the widget itself is the combobox
|
||||
self._available_models: list[str] = []
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.fit_model_combobox = QComboBox(self)
|
||||
self.layout.addWidget(self.fit_model_combobox)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._available_models = None
|
||||
self._x_axis = None
|
||||
self._y_axis = None
|
||||
self._is_valid_input = False
|
||||
|
||||
self.setEditable(True)
|
||||
|
||||
self.populate_fit_model_combobox()
|
||||
self.currentTextChanged.connect(self._on_text_changed)
|
||||
self.fit_model_combobox.currentTextChanged.connect(self._update_current_fit)
|
||||
# Set default fit model
|
||||
self.select_default_fit(default_fit)
|
||||
self.check_validity(self.currentText())
|
||||
|
||||
def select_default_fit(self, default_fit: str | None = "GaussianModel"):
|
||||
def select_default_fit(self, default_fit: str | None):
|
||||
"""Set the default fit model.
|
||||
|
||||
Args:
|
||||
@@ -63,8 +65,8 @@ class DapComboBox(BECWidget, QComboBox):
|
||||
"""
|
||||
if self._validate_dap_model(default_fit):
|
||||
self.select_fit_model(default_fit)
|
||||
elif self.available_models:
|
||||
self.select_fit_model(self.available_models[0])
|
||||
else:
|
||||
self.select_fit_model("GaussianModel")
|
||||
|
||||
@property
|
||||
def available_models(self):
|
||||
@@ -112,40 +114,12 @@ class DapComboBox(BECWidget, QComboBox):
|
||||
self._y_axis = y_axis
|
||||
self.y_axis_updated.emit(y_axis)
|
||||
|
||||
@Slot(str)
|
||||
def _on_text_changed(self, fit_name: str):
|
||||
"""
|
||||
Validate and emit updates for the current text.
|
||||
|
||||
Args:
|
||||
fit_name(str): The current text in the combobox, representing the selected fit model.
|
||||
"""
|
||||
self.check_validity(fit_name)
|
||||
if not self._is_valid_input:
|
||||
return
|
||||
|
||||
def _update_current_fit(self, fit_name: str):
|
||||
"""Update the current fit."""
|
||||
self.fit_model_updated.emit(fit_name)
|
||||
if self.x_axis is not None and self.y_axis is not None:
|
||||
self.new_dap_config.emit(self._x_axis, self._y_axis, fit_name)
|
||||
|
||||
@Slot(str)
|
||||
def check_validity(self, fit_name: str):
|
||||
"""
|
||||
Highlight invalid manual entries similarly to DeviceComboBox.
|
||||
|
||||
Args:
|
||||
fit_name(str): The current text in the combobox, representing the selected fit model.
|
||||
"""
|
||||
if self._validate_dap_model(fit_name):
|
||||
self._is_valid_input = True
|
||||
self.setStyleSheet("border: 1px solid transparent;")
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
if self.isEnabled():
|
||||
self.setStyleSheet("border: 1px solid red;")
|
||||
else:
|
||||
self.setStyleSheet("border: 1px solid transparent;")
|
||||
|
||||
@Slot(str)
|
||||
def select_x_axis(self, x_axis: str):
|
||||
"""Slot to update the x axis.
|
||||
@@ -154,7 +128,7 @@ class DapComboBox(BECWidget, QComboBox):
|
||||
x_axis(str): X axis.
|
||||
"""
|
||||
self.x_axis = x_axis
|
||||
self._on_text_changed(self.currentText())
|
||||
self._update_current_fit(self.fit_model_combobox.currentText())
|
||||
|
||||
@Slot(str)
|
||||
def select_y_axis(self, y_axis: str):
|
||||
@@ -164,26 +138,25 @@ class DapComboBox(BECWidget, QComboBox):
|
||||
y_axis(str): Y axis.
|
||||
"""
|
||||
self.y_axis = y_axis
|
||||
self._on_text_changed(self.currentText())
|
||||
self._update_current_fit(self.fit_model_combobox.currentText())
|
||||
|
||||
@Slot(str)
|
||||
def select_fit_model(self, fit_name: str | None):
|
||||
"""Slot to update the fit model.
|
||||
|
||||
Args:
|
||||
fit_name(str): Fit model name.
|
||||
default_device(str): Default device name.
|
||||
"""
|
||||
if not self._validate_dap_model(fit_name):
|
||||
raise ValueError(f"Fit {fit_name} is not valid.")
|
||||
self.setCurrentText(fit_name)
|
||||
self.fit_model_combobox.setCurrentText(fit_name)
|
||||
|
||||
def populate_fit_model_combobox(self):
|
||||
"""Populate the fit_model_combobox with the devices."""
|
||||
# pylint: disable=protected-access
|
||||
available_plugins = getattr(getattr(self.client, "dap", None), "_available_dap_plugins", {})
|
||||
self.available_models = [model for model in available_plugins.keys()]
|
||||
self.clear()
|
||||
self.addItems(self.available_models)
|
||||
self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()]
|
||||
self.fit_model_combobox.clear()
|
||||
self.fit_model_combobox.addItems(self.available_models)
|
||||
|
||||
def _validate_dap_model(self, model: str | None) -> bool:
|
||||
"""Validate the DAP model.
|
||||
@@ -193,23 +166,23 @@ class DapComboBox(BECWidget, QComboBox):
|
||||
"""
|
||||
if model is None:
|
||||
return False
|
||||
return model in self.available_models
|
||||
|
||||
@property
|
||||
def is_valid_input(self) -> bool:
|
||||
"""Whether the current text matches an available DAP model."""
|
||||
return self._is_valid_input
|
||||
if model not in self.available_models:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app = QApplication([])
|
||||
apply_theme("dark")
|
||||
dialog = DapComboBox()
|
||||
dialog.show()
|
||||
sys.exit(app.exec_())
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
layout.addWidget(DapComboBox())
|
||||
widget.show()
|
||||
app.exec_()
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import os
|
||||
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QPushButton, QSizePolicy, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QPushButton, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
@@ -35,7 +34,7 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Initializes the LMFitDialog widget.
|
||||
Initialises the LMFitDialog widget.
|
||||
|
||||
Args:
|
||||
parent (QWidget): The parent widget.
|
||||
@@ -69,27 +68,6 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
self._hide_curve_selection = False
|
||||
self._hide_summary = False
|
||||
self._hide_parameters = False
|
||||
self._configure_embedded_size_policy()
|
||||
|
||||
def _configure_embedded_size_policy(self):
|
||||
"""Allow the compact dialog to shrink more gracefully in embedded layouts."""
|
||||
if self._ui_file != "lmfit_dialog_compact.ui":
|
||||
return
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
self.ui.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
|
||||
for group in (
|
||||
self.ui.group_curve_selection,
|
||||
self.ui.group_summary,
|
||||
self.ui.group_parameters,
|
||||
):
|
||||
group.setMinimumHeight(0)
|
||||
group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
|
||||
for view in (self.ui.curve_list, self.ui.summary_tree, self.ui.param_tree):
|
||||
view.setMinimumHeight(0)
|
||||
view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Ignored)
|
||||
|
||||
@property
|
||||
def enable_actions(self) -> bool:
|
||||
@@ -99,14 +77,8 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
@enable_actions.setter
|
||||
def enable_actions(self, enable: bool):
|
||||
self._enable_actions = enable
|
||||
valid_buttons = {}
|
||||
for name, button in self.action_buttons.items():
|
||||
# just to be sure we have a valid c++ object
|
||||
if button is None or not shiboken6.isValid(button):
|
||||
continue
|
||||
for button in self.action_buttons.values():
|
||||
button.setEnabled(enable)
|
||||
valid_buttons[name] = button
|
||||
self.action_buttons = valid_buttons
|
||||
|
||||
@SafeProperty(list)
|
||||
def active_action_list(self) -> list[str]:
|
||||
@@ -117,6 +89,16 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
def active_action_list(self, actions: list[str]):
|
||||
self._active_actions = actions
|
||||
|
||||
# This SafeSlot needed?
|
||||
@SafeSlot(bool)
|
||||
def set_actions_enabled(self, enable: bool) -> bool:
|
||||
"""SafeSlot to enable the move to buttons.
|
||||
|
||||
Args:
|
||||
enable (bool): Whether to enable the action buttons.
|
||||
"""
|
||||
self.enable_actions = enable
|
||||
|
||||
@SafeProperty(bool)
|
||||
def always_show_latest(self):
|
||||
"""SafeProperty to indicate if always the latest DAP update is displayed."""
|
||||
@@ -172,21 +154,19 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
self.ui.group_parameters.setVisible(not show)
|
||||
|
||||
@property
|
||||
def fit_curve_id(self) -> str | None:
|
||||
def fit_curve_id(self) -> str:
|
||||
"""SafeProperty for the currently displayed fit curve_id."""
|
||||
return self._fit_curve_id
|
||||
|
||||
@fit_curve_id.setter
|
||||
def fit_curve_id(self, curve_id: str | None):
|
||||
def fit_curve_id(self, curve_id: str):
|
||||
"""Setter for the currently displayed fit curve_id.
|
||||
|
||||
Args:
|
||||
curve_id (str | None): The curve_id of the fit curve to be displayed,
|
||||
or None to clear the selection.
|
||||
fit_curve_id (str): The curve_id of the fit curve to be displayed.
|
||||
"""
|
||||
self._fit_curve_id = curve_id
|
||||
if curve_id is not None:
|
||||
self.selected_fit.emit(curve_id)
|
||||
self.selected_fit.emit(curve_id)
|
||||
|
||||
@SafeSlot(str)
|
||||
def remove_dap_data(self, curve_id: str):
|
||||
@@ -196,15 +176,6 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
curve_id (str): The curve_id of the DAP data to be removed.
|
||||
"""
|
||||
self.summary_data.pop(curve_id, None)
|
||||
if self.fit_curve_id == curve_id:
|
||||
self.action_buttons = {}
|
||||
self.ui.summary_tree.clear()
|
||||
self.ui.param_tree.clear()
|
||||
remaining = list(self.summary_data.keys())
|
||||
if remaining:
|
||||
self.fit_curve_id = remaining[0]
|
||||
else:
|
||||
self._fit_curve_id = None
|
||||
self.refresh_curve_list()
|
||||
|
||||
@SafeSlot(str)
|
||||
@@ -280,7 +251,6 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
params (list): List of LMFit parameters for the fit curve.
|
||||
"""
|
||||
self._move_buttons = []
|
||||
self.action_buttons = {}
|
||||
self.ui.param_tree.clear()
|
||||
for param in params:
|
||||
param_name = param[0]
|
||||
@@ -299,16 +269,18 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
if param_name in self.active_action_list: # pylint: disable=unsupported-membership-test
|
||||
# Create a push button to move the motor to a specific position
|
||||
widget = QWidget()
|
||||
button = QPushButton("Move")
|
||||
button = QPushButton(f"Move to {param_name}")
|
||||
button.clicked.connect(self._create_move_action(param_name, param[1]))
|
||||
if self.enable_actions:
|
||||
if self.enable_actions is True:
|
||||
button.setEnabled(True)
|
||||
else:
|
||||
button.setEnabled(False)
|
||||
button.setStyleSheet(f"""
|
||||
button.setStyleSheet(
|
||||
f"""
|
||||
QPushButton:enabled {{ background-color: {self._accent_colors.success.name()};color: white; }}
|
||||
QPushButton:disabled {{ background-color: grey;color: white; }}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
self.action_buttons[param_name] = button
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self.action_buttons[param_name])
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item row="0" column="0">
|
||||
<widget class="QSplitter" name="splitter_2">
|
||||
<property name="sizePolicy">
|
||||
@@ -34,6 +22,15 @@
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::Shape::VLine</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Shadow::Plain</enum>
|
||||
</property>
|
||||
<property name="lineWidth">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
@@ -44,12 +41,6 @@
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<widget class="QGroupBox" name="group_curve_selection">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>120</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Select Curve</string>
|
||||
</property>
|
||||
@@ -67,36 +58,18 @@
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
<enum>Qt::Orientation::Vertical</enum>
|
||||
</property>
|
||||
<widget class="QGroupBox" name="group_summary">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>180</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Fit Summary</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QTreeWidget" name="summary_tree">
|
||||
<property name="alternatingRowColors">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="indentation">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rootIsDecorated">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="uniformRowHeights">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<attribute name="headerDefaultSectionSize">
|
||||
<number>90</number>
|
||||
</attribute>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Property</string>
|
||||
@@ -112,33 +85,12 @@
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QGroupBox" name="group_parameters">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>240</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Parameter Details</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QTreeWidget" name="param_tree">
|
||||
<property name="alternatingRowColors">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="indentation">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rootIsDecorated">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="columnCount">
|
||||
<number>4</number>
|
||||
</property>
|
||||
<attribute name="headerDefaultSectionSize">
|
||||
<number>80</number>
|
||||
</attribute>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Parameter</string>
|
||||
@@ -154,11 +106,6 @@
|
||||
<string>Std</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Action</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
|
||||
@@ -95,12 +95,6 @@
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="indentation">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rootIsDecorated">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="uniformRowHeights">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
@@ -153,12 +147,6 @@
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="indentation">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rootIsDecorated">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="columnCount">
|
||||
<number>4</number>
|
||||
|
||||
@@ -47,13 +47,15 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover:
|
||||
)
|
||||
|
||||
def _init_bec_kernel(self):
|
||||
self.execute("""
|
||||
self.execute(
|
||||
"""
|
||||
from bec_ipython_client.main import BECIPythonClient
|
||||
bec = BECIPythonClient()
|
||||
bec.start()
|
||||
dev = bec.device_manager.devices if bec else None
|
||||
scans = bec.scans if bec else None
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
def _cleanup_bec(self):
|
||||
if getattr(self, "ipyclient", None) is not None and self.inprocess is True:
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any, cast
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.macro_update_handler import has_executable_code
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtCore import QEvent, QTimer, Signal
|
||||
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget
|
||||
|
||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||
@@ -36,12 +36,12 @@ class MonacoDock(DockAreaWidget):
|
||||
**kwargs,
|
||||
)
|
||||
self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event)
|
||||
self.dock_manager.installEventFilter(self)
|
||||
self._last_focused_editor: CDockWidget | None = None
|
||||
self.focused_editor.connect(self._on_last_focused_editor_changed)
|
||||
initial_editor = self.add_editor()
|
||||
if isinstance(initial_editor, CDockWidget):
|
||||
self.last_focused_editor = initial_editor
|
||||
self._install_manager_scan_and_fix_guards()
|
||||
|
||||
def _create_editor_widget(self) -> MonacoWidget:
|
||||
"""Create a configured Monaco editor widget."""
|
||||
@@ -73,8 +73,7 @@ class MonacoDock(DockAreaWidget):
|
||||
logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}")
|
||||
self.save_enabled.emit(widget.modified)
|
||||
|
||||
@staticmethod
|
||||
def _update_tab_title_for_modification(dock: CDockWidget, modified: bool):
|
||||
def _update_tab_title_for_modification(self, dock: CDockWidget, modified: bool):
|
||||
"""Update the tab title to show modification status with a dot indicator."""
|
||||
current_title = dock.windowTitle()
|
||||
|
||||
@@ -99,12 +98,14 @@ class MonacoDock(DockAreaWidget):
|
||||
return
|
||||
|
||||
active_sig = signatures[signature.get("activeSignature", 0)]
|
||||
active_param = signature.get("activeParameter", 0) # TODO: Add highlight for active_param
|
||||
|
||||
# Get signature label and documentation
|
||||
label = active_sig.get("label", "")
|
||||
doc_obj = active_sig.get("documentation", {})
|
||||
documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj)
|
||||
|
||||
# Format the Markdown output
|
||||
# Format the markdown output
|
||||
markdown = f"```python\n{label}\n```\n\n{documentation}"
|
||||
self.signature_help.emit(markdown)
|
||||
|
||||
@@ -155,10 +156,9 @@ class MonacoDock(DockAreaWidget):
|
||||
if self.last_focused_editor is dock:
|
||||
self.last_focused_editor = None
|
||||
# After topology changes, make sure single-tab areas get a plus button
|
||||
self._scan_and_fix_areas()
|
||||
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||
|
||||
@staticmethod
|
||||
def reset_widget(widget: MonacoWidget):
|
||||
def reset_widget(self, widget: MonacoWidget):
|
||||
"""
|
||||
Reset the given Monaco editor widget to its initial state.
|
||||
|
||||
@@ -193,23 +193,23 @@ class MonacoDock(DockAreaWidget):
|
||||
# pylint: disable=protected-access
|
||||
area._monaco_plus_btn = plus_btn
|
||||
|
||||
def _install_manager_scan_and_fix_guards(self) -> None:
|
||||
"""
|
||||
Track ADS structural changes to trigger scan and fix of dock areas for plus button injection.
|
||||
"""
|
||||
self.dock_manager.dockAreaCreated.connect(self._scan_and_fix_areas)
|
||||
self.dock_manager.dockWidgetAdded.connect(self._scan_and_fix_areas)
|
||||
self.dock_manager.stateRestored.connect(self._scan_and_fix_areas)
|
||||
self.dock_manager.restoringState.connect(self._scan_and_fix_areas)
|
||||
self.dock_manager.focusedDockWidgetChanged.connect(self._scan_and_fix_areas)
|
||||
self._scan_and_fix_areas()
|
||||
|
||||
def _scan_and_fix_areas(self, *_arg):
|
||||
def _scan_and_fix_areas(self):
|
||||
# Find all dock areas under this manager and ensure each single-tab area has a plus button
|
||||
areas = self.dock_manager.findChildren(CDockAreaWidget)
|
||||
for a in areas:
|
||||
self._ensure_area_plus(a)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
# Track dock manager events
|
||||
if obj is self.dock_manager and event.type() in (
|
||||
QEvent.Type.ChildAdded,
|
||||
QEvent.Type.ChildRemoved,
|
||||
QEvent.Type.LayoutRequest,
|
||||
):
|
||||
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def add_editor(
|
||||
self, area: Any | None = None, title: str | None = None, tooltip: str | None = None
|
||||
) -> CDockWidget:
|
||||
@@ -258,7 +258,7 @@ class MonacoDock(DockAreaWidget):
|
||||
if area_widget is not None:
|
||||
self._ensure_area_plus(area_widget)
|
||||
|
||||
self._scan_and_fix_areas()
|
||||
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||
self.last_focused_editor = dock
|
||||
return dock
|
||||
|
||||
|
||||
@@ -362,7 +362,8 @@ if __name__ == "__main__": # pragma: no cover
|
||||
widget.set_language("python")
|
||||
widget.set_theme("vs-dark")
|
||||
widget.editor.set_minimap_enabled(False)
|
||||
widget.set_text("""
|
||||
widget.set_text(
|
||||
"""
|
||||
import numpy as np
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -379,7 +380,8 @@ if TYPE_CHECKING:
|
||||
# This is a comment
|
||||
def hello_world():
|
||||
print("Hello, world!")
|
||||
""")
|
||||
"""
|
||||
)
|
||||
widget.set_highlighted_lines(1, 3)
|
||||
widget.show()
|
||||
qapp.exec_()
|
||||
|
||||
@@ -53,8 +53,6 @@ class ScanMetadata(PydanticModelForm):
|
||||
|
||||
super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs)
|
||||
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._form_grid_container.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.addWidget(self._additional_md_box)
|
||||
self._additional_md_box_layout.addWidget(self._additional_metadata)
|
||||
|
||||
@@ -80,27 +78,12 @@ class ScanMetadata(PydanticModelForm):
|
||||
|
||||
def get_form_data(self):
|
||||
"""Get the entered metadata as a dict"""
|
||||
form_data = self._additional_metadata.dump_dict() | self._dict_from_grid()
|
||||
|
||||
# If scan_name is empty, set it to the current scan
|
||||
if "scan_name" in form_data and not form_data["scan_name"]:
|
||||
form_data["scan_name"] = self._scan_name
|
||||
|
||||
return form_data
|
||||
return self._additional_metadata.dump_dict() | self._dict_from_grid()
|
||||
|
||||
def populate(self):
|
||||
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
|
||||
super().populate()
|
||||
|
||||
# Set scan_name field to current scan if it exists and is empty
|
||||
if "scan_name" not in self.widget_dict:
|
||||
return
|
||||
scan_name_widget = self.widget_dict["scan_name"]
|
||||
if not hasattr(scan_name_widget, "getValue") or scan_name_widget.getValue():
|
||||
return
|
||||
if hasattr(scan_name_widget, "setValue"):
|
||||
scan_name_widget.setValue(self._scan_name)
|
||||
|
||||
def set_schema_from_scan(self, scan_name: str | None):
|
||||
self._scan_name = scan_name or ""
|
||||
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
|
||||
|
||||
@@ -35,8 +35,8 @@ logger = bec_logger.logger
|
||||
class HeatmapDeviceSignal(BaseModel):
|
||||
"""The configuration of a signal in the scatter waveform widget."""
|
||||
|
||||
device: str
|
||||
signal: str
|
||||
name: str
|
||||
entry: str
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
@@ -65,13 +65,13 @@ class HeatmapConfig(ConnectionConfig):
|
||||
lock_aspect_ratio: bool = Field(
|
||||
False, description="Whether to lock the aspect ratio of the image."
|
||||
)
|
||||
device_x: HeatmapDeviceSignal | None = Field(
|
||||
x_device: HeatmapDeviceSignal | None = Field(
|
||||
None, description="The x device signal of the heatmap."
|
||||
)
|
||||
device_y: HeatmapDeviceSignal | None = Field(
|
||||
y_device: HeatmapDeviceSignal | None = Field(
|
||||
None, description="The y device signal of the heatmap."
|
||||
)
|
||||
device_z: HeatmapDeviceSignal | None = Field(
|
||||
z_device: HeatmapDeviceSignal | None = Field(
|
||||
None, description="The z device signal of the heatmap."
|
||||
)
|
||||
|
||||
@@ -204,18 +204,18 @@ class Heatmap(ImageBase):
|
||||
"rois",
|
||||
"plot",
|
||||
# Device properties
|
||||
"device_x",
|
||||
"device_x.setter",
|
||||
"signal_x",
|
||||
"signal_x.setter",
|
||||
"device_y",
|
||||
"device_y.setter",
|
||||
"signal_y",
|
||||
"signal_y.setter",
|
||||
"device_z",
|
||||
"device_z.setter",
|
||||
"signal_z",
|
||||
"signal_z.setter",
|
||||
"x_device_name",
|
||||
"x_device_name.setter",
|
||||
"x_device_entry",
|
||||
"x_device_entry.setter",
|
||||
"y_device_name",
|
||||
"y_device_name.setter",
|
||||
"y_device_entry",
|
||||
"y_device_entry.setter",
|
||||
"z_device_name",
|
||||
"z_device_name.setter",
|
||||
"z_device_entry",
|
||||
"z_device_entry.setter",
|
||||
]
|
||||
|
||||
PLUGIN = True
|
||||
@@ -238,9 +238,9 @@ class Heatmap(ImageBase):
|
||||
interpolation="linear",
|
||||
oversampling_factor=1.0,
|
||||
lock_aspect_ratio=False,
|
||||
device_x=None,
|
||||
device_y=None,
|
||||
device_z=None,
|
||||
x_device=None,
|
||||
y_device=None,
|
||||
z_device=None,
|
||||
)
|
||||
super().__init__(parent=parent, config=config, theme_update=True, **kwargs)
|
||||
self._image_config = config
|
||||
@@ -314,12 +314,12 @@ class Heatmap(ImageBase):
|
||||
@SafeSlot(popup_error=True)
|
||||
def plot(
|
||||
self,
|
||||
device_x: str,
|
||||
device_y: str,
|
||||
device_z: str,
|
||||
signal_x: None | str = None,
|
||||
signal_y: None | str = None,
|
||||
signal_z: None | str = None,
|
||||
x_name: str,
|
||||
y_name: str,
|
||||
z_name: str,
|
||||
x_entry: None | str = None,
|
||||
y_entry: None | str = None,
|
||||
z_entry: None | str = None,
|
||||
color_map: str | None = "plasma",
|
||||
validate_bec: bool = True,
|
||||
interpolation: Literal["linear", "nearest"] | None = None,
|
||||
@@ -333,12 +333,12 @@ class Heatmap(ImageBase):
|
||||
Plot the heatmap with the given x, y, and z data.
|
||||
|
||||
Args:
|
||||
device_x (str): The name of the x-axis device signal.
|
||||
device_y (str): The name of the y-axis device signal.
|
||||
device_z (str): The name of the z-axis device signal.
|
||||
signal_x (str | None): The entry for the x-axis device signal.
|
||||
signal_y (str | None): The entry for the y-axis device signal.
|
||||
signal_z (str | None): The entry for the z-axis device signal.
|
||||
x_name (str): The name of the x-axis signal.
|
||||
y_name (str): The name of the y-axis signal.
|
||||
z_name (str): The name of the z-axis signal.
|
||||
x_entry (str | None): The entry for the x-axis signal.
|
||||
y_entry (str | None): The entry for the y-axis signal.
|
||||
z_entry (str | None): The entry for the z-axis signal.
|
||||
color_map (str | None): The color map to use for the heatmap.
|
||||
validate_bec (bool): Whether to validate the entries against BEC signals.
|
||||
interpolation (Literal["linear", "nearest"] | None): The interpolation method to use.
|
||||
@@ -349,13 +349,13 @@ class Heatmap(ImageBase):
|
||||
reload (bool): Whether to reload the heatmap with new data.
|
||||
"""
|
||||
if validate_bec:
|
||||
signal_x = self.entry_validator.validate_signal(device_x, signal_x)
|
||||
signal_y = self.entry_validator.validate_signal(device_y, signal_y)
|
||||
signal_z = self.entry_validator.validate_signal(device_z, signal_z)
|
||||
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
|
||||
y_entry = self.entry_validator.validate_signal(y_name, y_entry)
|
||||
z_entry = self.entry_validator.validate_signal(z_name, z_entry)
|
||||
|
||||
if signal_x is None or signal_y is None or signal_z is None:
|
||||
if x_entry is None or y_entry is None or z_entry is None:
|
||||
raise ValueError("x, y, and z entries must be provided.")
|
||||
if device_x is None or device_y is None or device_z is None:
|
||||
if x_name is None or y_name is None or z_name is None:
|
||||
raise ValueError("x, y, and z names must be provided.")
|
||||
|
||||
if interpolation is None:
|
||||
@@ -374,24 +374,24 @@ class Heatmap(ImageBase):
|
||||
show_config_label = self._image_config.show_config_label
|
||||
|
||||
def _device_key(device: HeatmapDeviceSignal | None) -> tuple[str | None, str | None]:
|
||||
return (device.device if device else None, device.signal if device else None)
|
||||
return (device.name if device else None, device.entry if device else None)
|
||||
|
||||
prev_cfg = getattr(self, "_image_config", None)
|
||||
config_changed = False
|
||||
if prev_cfg and prev_cfg.device_x and prev_cfg.device_y and prev_cfg.device_z:
|
||||
if prev_cfg and prev_cfg.x_device and prev_cfg.y_device and prev_cfg.z_device:
|
||||
config_changed = any(
|
||||
(
|
||||
_device_key(prev_cfg.device_x) != (device_x, signal_x),
|
||||
_device_key(prev_cfg.device_y) != (device_y, signal_y),
|
||||
_device_key(prev_cfg.device_z) != (device_z, signal_z),
|
||||
_device_key(prev_cfg.x_device) != (x_name, x_entry),
|
||||
_device_key(prev_cfg.y_device) != (y_name, y_entry),
|
||||
_device_key(prev_cfg.z_device) != (z_name, z_entry),
|
||||
)
|
||||
)
|
||||
|
||||
self._image_config = HeatmapConfig(
|
||||
parent_id=self.gui_id,
|
||||
device_x=HeatmapDeviceSignal(device=device_x, signal=signal_x),
|
||||
device_y=HeatmapDeviceSignal(device=device_y, signal=signal_y),
|
||||
device_z=HeatmapDeviceSignal(device=device_z, signal=signal_z),
|
||||
x_device=HeatmapDeviceSignal(name=x_name, entry=x_entry),
|
||||
y_device=HeatmapDeviceSignal(name=y_name, entry=y_entry),
|
||||
z_device=HeatmapDeviceSignal(name=z_name, entry=z_entry),
|
||||
color_map=color_map,
|
||||
color_bar=None,
|
||||
interpolation=interpolation,
|
||||
@@ -428,26 +428,26 @@ class Heatmap(ImageBase):
|
||||
return
|
||||
|
||||
# Safely get device names (might be None if not yet configured)
|
||||
device_x = self._image_config.device_x
|
||||
device_y = self._image_config.device_y
|
||||
device_z = self._image_config.device_z
|
||||
x_device = self._image_config.x_device
|
||||
y_device = self._image_config.y_device
|
||||
z_device = self._image_config.z_device
|
||||
|
||||
device_x_name = device_x.device if device_x else None
|
||||
device_y_name = device_y.device if device_y else None
|
||||
device_z_name = device_z.device if device_z else None
|
||||
x_name = x_device.name if x_device else None
|
||||
y_name = y_device.name if y_device else None
|
||||
z_name = z_device.name if z_device else None
|
||||
|
||||
if device_x_name is not None:
|
||||
self.x_label = device_x_name # type: ignore
|
||||
x_dev = self.dev.get(device_x_name)
|
||||
if x_name is not None:
|
||||
self.x_label = x_name # type: ignore
|
||||
x_dev = self.dev.get(x_name)
|
||||
if x_dev and hasattr(x_dev, "egu"):
|
||||
self.x_label_units = x_dev.egu()
|
||||
if device_y_name is not None:
|
||||
self.y_label = device_y_name # type: ignore
|
||||
y_dev = self.dev.get(device_y_name)
|
||||
if y_name is not None:
|
||||
self.y_label = y_name # type: ignore
|
||||
y_dev = self.dev.get(y_name)
|
||||
if y_dev and hasattr(y_dev, "egu"):
|
||||
self.y_label_units = y_dev.egu()
|
||||
if device_z_name is not None:
|
||||
self.title = device_z_name
|
||||
if z_name is not None:
|
||||
self.title = z_name
|
||||
|
||||
def _init_toolbar_heatmap(self):
|
||||
"""
|
||||
@@ -572,23 +572,23 @@ class Heatmap(ImageBase):
|
||||
if self._image_config is None:
|
||||
return
|
||||
try:
|
||||
device_x = self._image_config.device_x.device
|
||||
signal_x = self._image_config.device_x.signal
|
||||
device_y = self._image_config.device_y.device
|
||||
signal_y = self._image_config.device_y.signal
|
||||
device_z = self._image_config.device_z.device
|
||||
signal_z = self._image_config.device_z.signal
|
||||
x_name = self._image_config.x_device.name
|
||||
x_entry = self._image_config.x_device.entry
|
||||
y_name = self._image_config.y_device.name
|
||||
y_entry = self._image_config.y_device.entry
|
||||
z_name = self._image_config.z_device.name
|
||||
z_entry = self._image_config.z_device.entry
|
||||
except AttributeError:
|
||||
return
|
||||
|
||||
if access_key == "val":
|
||||
x_data = data.get(device_x, {}).get(signal_x, {}).get(access_key, None)
|
||||
y_data = data.get(device_y, {}).get(signal_y, {}).get(access_key, None)
|
||||
z_data = data.get(device_z, {}).get(signal_z, {}).get(access_key, None)
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
|
||||
y_data = data.get(y_name, {}).get(y_entry, {}).get(access_key, None)
|
||||
z_data = data.get(z_name, {}).get(z_entry, {}).get(access_key, None)
|
||||
else:
|
||||
x_data = data.get(device_x, {}).get(signal_x, {}).read().get("value", None)
|
||||
y_data = data.get(device_y, {}).get(signal_y, {}).read().get("value", None)
|
||||
z_data = data.get(device_z, {}).get(signal_z, {}).read().get("value", None)
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
|
||||
y_data = data.get(y_name, {}).get(y_entry, {}).read().get("value", None)
|
||||
z_data = data.get(z_name, {}).get(z_entry, {}).read().get("value", None)
|
||||
|
||||
if not isinstance(x_data, list):
|
||||
x_data = x_data.tolist() if isinstance(x_data, np.ndarray) else None
|
||||
@@ -839,6 +839,7 @@ class Heatmap(ImageBase):
|
||||
x_data (np.ndarray): The x data.
|
||||
y_data (np.ndarray): The y data.
|
||||
z_data (np.ndarray): The z data.
|
||||
msg (messages.ScanStatusMessage): The scan status message.
|
||||
|
||||
Returns:
|
||||
tuple[np.ndarray, QTransform]: The image data and the QTransform.
|
||||
@@ -853,7 +854,7 @@ class Heatmap(ImageBase):
|
||||
if len(z_data) < 4:
|
||||
# LinearNDInterpolator requires at least 4 points to interpolate
|
||||
return None, None
|
||||
return self.get_step_scan_image(x_data, y_data, z_data)
|
||||
return self.get_step_scan_image(x_data, y_data, z_data, msg)
|
||||
|
||||
def _is_grid_scan_supported(self, msg: messages.ScanStatusMessage) -> bool:
|
||||
"""Check if the scan can use optimized grid_scan rendering.
|
||||
@@ -870,11 +871,11 @@ class Heatmap(ImageBase):
|
||||
if msg.scan_name != "grid_scan" or self._image_config.enforce_interpolation:
|
||||
return False
|
||||
|
||||
signal_x = self._image_config.device_x.signal
|
||||
signal_y = self._image_config.device_y.signal
|
||||
device_x = self._image_config.x_device.entry
|
||||
device_y = self._image_config.y_device.entry
|
||||
return (
|
||||
signal_x in msg.request_inputs["arg_bundle"]
|
||||
and signal_y in msg.request_inputs["arg_bundle"]
|
||||
device_x in msg.request_inputs["arg_bundle"]
|
||||
and device_y in msg.request_inputs["arg_bundle"]
|
||||
)
|
||||
|
||||
def get_grid_scan_image(
|
||||
@@ -892,9 +893,9 @@ class Heatmap(ImageBase):
|
||||
|
||||
args = self.arg_bundle_to_dict(4, msg.request_inputs["arg_bundle"])
|
||||
|
||||
signal_x = self._image_config.device_x.signal
|
||||
signal_y = self._image_config.device_y.signal
|
||||
shape = (args[signal_x][-1], args[signal_y][-1])
|
||||
x_entry = self._image_config.x_device.entry
|
||||
y_entry = self._image_config.y_device.entry
|
||||
shape = (args[x_entry][-1], args[y_entry][-1])
|
||||
|
||||
data = self.main_image.raw_data
|
||||
|
||||
@@ -924,8 +925,8 @@ class Heatmap(ImageBase):
|
||||
return origin + np.linspace(start, stop, npts)
|
||||
return np.linspace(start, stop, npts)
|
||||
|
||||
x_levels = _axis_levels(signal_x, shape[0])
|
||||
y_levels = _axis_levels(signal_y, shape[1])
|
||||
x_levels = _axis_levels(x_entry, shape[0])
|
||||
y_levels = _axis_levels(y_entry, shape[1])
|
||||
|
||||
pixel_size_x = (
|
||||
float(x_levels[-1] - x_levels[0]) / max(shape[0] - 1, 1) if shape[0] > 1 else 1.0
|
||||
@@ -948,7 +949,7 @@ class Heatmap(ImageBase):
|
||||
if snaked and (slow_i % 2 == 1):
|
||||
fast_i = args[fast_entry][-1] - 1 - fast_i
|
||||
|
||||
if signal_x == fast_entry:
|
||||
if x_entry == fast_entry:
|
||||
x_i, y_i = fast_i, slow_i
|
||||
else:
|
||||
x_i, y_i = slow_i, fast_i
|
||||
@@ -958,7 +959,11 @@ class Heatmap(ImageBase):
|
||||
return data, transform
|
||||
|
||||
def get_step_scan_image(
|
||||
self, x_data: list[float], y_data: list[float], z_data: list[float]
|
||||
self,
|
||||
x_data: list[float],
|
||||
y_data: list[float],
|
||||
z_data: list[float],
|
||||
msg: messages.ScanStatusMessage,
|
||||
) -> tuple[np.ndarray, QTransform]:
|
||||
"""
|
||||
Get the image data for an arbitrary step scan.
|
||||
@@ -967,6 +972,7 @@ class Heatmap(ImageBase):
|
||||
x_data (list[float]): The x data.
|
||||
y_data (list[float]): The y data.
|
||||
z_data (list[float]): The z data.
|
||||
msg (messages.ScanStatusMessage): The scan status message.
|
||||
|
||||
Returns:
|
||||
tuple[np.ndarray, QTransform]: The image data and the QTransform.
|
||||
@@ -1027,7 +1033,7 @@ class Heatmap(ImageBase):
|
||||
to avoid recalculating the grid for the same scan.
|
||||
|
||||
Args:
|
||||
positions: positions of the data points.
|
||||
_scan_id (str): The scan ID. Needed for caching but not used in the function.
|
||||
|
||||
Returns:
|
||||
tuple[np.ndarray, np.ndarray, QTransform]: The grid x and y coordinates and the QTransform.
|
||||
@@ -1102,13 +1108,11 @@ class Heatmap(ImageBase):
|
||||
|
||||
return max(1, width_pixels), max(1, height_pixels)
|
||||
|
||||
@staticmethod
|
||||
def arg_bundle_to_dict(bundle_size: int, args: list) -> dict:
|
||||
def arg_bundle_to_dict(self, bundle_size: int, args: list) -> dict:
|
||||
"""
|
||||
Convert the argument bundle to a dictionary.
|
||||
|
||||
Args:
|
||||
bundle_size (int): The size of each argument bundle.
|
||||
args (list): The argument bundle.
|
||||
|
||||
Returns:
|
||||
@@ -1156,14 +1160,14 @@ class Heatmap(ImageBase):
|
||||
################################################################################
|
||||
|
||||
@SafeProperty(str)
|
||||
def device_x(self) -> str:
|
||||
def x_device_name(self) -> str:
|
||||
"""Device name for the X axis."""
|
||||
if self._image_config.device_x is None:
|
||||
if self._image_config.x_device is None:
|
||||
return ""
|
||||
return self._image_config.device_x.device or ""
|
||||
return self._image_config.x_device.name or ""
|
||||
|
||||
@device_x.setter
|
||||
def device_x(self, device_name: str) -> None:
|
||||
@x_device_name.setter
|
||||
def x_device_name(self, device_name: str) -> None:
|
||||
"""
|
||||
Set the X device name.
|
||||
|
||||
@@ -1175,27 +1179,27 @@ class Heatmap(ImageBase):
|
||||
# Get current entry or validate
|
||||
if device_name:
|
||||
try:
|
||||
signal = self.entry_validator.validate_signal(device_name, None)
|
||||
self._image_config.device_x = HeatmapDeviceSignal(device=device_name, signal=signal)
|
||||
self.property_changed.emit("device_x", device_name)
|
||||
entry = self.entry_validator.validate_signal(device_name, None)
|
||||
self._image_config.x_device = HeatmapDeviceSignal(name=device_name, entry=entry)
|
||||
self.property_changed.emit("x_device_name", device_name)
|
||||
self.update_labels() # Update axis labels
|
||||
self._try_auto_plot()
|
||||
except Exception:
|
||||
pass # Silently fail if device is not available yet
|
||||
else:
|
||||
self._image_config.device_x = None
|
||||
self.property_changed.emit("device_x", "")
|
||||
self._image_config.x_device = None
|
||||
self.property_changed.emit("x_device_name", "")
|
||||
self.update_labels() # Clear axis labels
|
||||
|
||||
@SafeProperty(str)
|
||||
def signal_x(self) -> str:
|
||||
def x_device_entry(self) -> str:
|
||||
"""Signal entry for the X axis device."""
|
||||
if self._image_config.device_x is None:
|
||||
if self._image_config.x_device is None:
|
||||
return ""
|
||||
return self._image_config.device_x.signal or ""
|
||||
return self._image_config.x_device.entry or ""
|
||||
|
||||
@signal_x.setter
|
||||
def signal_x(self, entry: str) -> None:
|
||||
@x_device_entry.setter
|
||||
def x_device_entry(self, entry: str) -> None:
|
||||
"""
|
||||
Set the X device entry.
|
||||
|
||||
@@ -1205,32 +1209,32 @@ class Heatmap(ImageBase):
|
||||
if not entry:
|
||||
return
|
||||
|
||||
if self._image_config.device_x is None:
|
||||
logger.warning("Cannot set signal_x without device_x set first.")
|
||||
if self._image_config.x_device is None:
|
||||
logger.warning("Cannot set x_device_entry without x_device_name set first.")
|
||||
return
|
||||
|
||||
device_name = self._image_config.device_x.device
|
||||
device_name = self._image_config.x_device.name
|
||||
try:
|
||||
# Validate the entry for this device
|
||||
validated_signal = self.entry_validator.validate_signal(device_name, entry)
|
||||
self._image_config.device_x = HeatmapDeviceSignal(
|
||||
device=device_name, signal=validated_signal
|
||||
validated_entry = self.entry_validator.validate_signal(device_name, entry)
|
||||
self._image_config.x_device = HeatmapDeviceSignal(
|
||||
name=device_name, entry=validated_entry
|
||||
)
|
||||
self.property_changed.emit("signal_x", validated_signal)
|
||||
self.property_changed.emit("x_device_entry", validated_entry)
|
||||
self.update_labels() # Update axis labels
|
||||
self._try_auto_plot()
|
||||
except Exception:
|
||||
pass # Silently fail if validation fails
|
||||
|
||||
@SafeProperty(str)
|
||||
def device_y(self) -> str:
|
||||
def y_device_name(self) -> str:
|
||||
"""Device name for the Y axis."""
|
||||
if self._image_config.device_y is None:
|
||||
if self._image_config.y_device is None:
|
||||
return ""
|
||||
return self._image_config.device_y.device or ""
|
||||
return self._image_config.y_device.name or ""
|
||||
|
||||
@device_y.setter
|
||||
def device_y(self, device_name: str) -> None:
|
||||
@y_device_name.setter
|
||||
def y_device_name(self, device_name: str) -> None:
|
||||
"""
|
||||
Set the Y device name.
|
||||
|
||||
@@ -1242,27 +1246,27 @@ class Heatmap(ImageBase):
|
||||
# Get current entry or validate
|
||||
if device_name:
|
||||
try:
|
||||
signal = self.entry_validator.validate_signal(device_name, None)
|
||||
self._image_config.device_y = HeatmapDeviceSignal(device=device_name, signal=signal)
|
||||
self.property_changed.emit("device_y", device_name)
|
||||
entry = self.entry_validator.validate_signal(device_name, None)
|
||||
self._image_config.y_device = HeatmapDeviceSignal(name=device_name, entry=entry)
|
||||
self.property_changed.emit("y_device_name", device_name)
|
||||
self.update_labels() # Update axis labels
|
||||
self._try_auto_plot()
|
||||
except Exception:
|
||||
pass # Silently fail if device is not available yet
|
||||
else:
|
||||
self._image_config.device_y = None
|
||||
self.property_changed.emit("device_y", "")
|
||||
self._image_config.y_device = None
|
||||
self.property_changed.emit("y_device_name", "")
|
||||
self.update_labels() # Clear axis labels
|
||||
|
||||
@SafeProperty(str)
|
||||
def signal_y(self) -> str:
|
||||
def y_device_entry(self) -> str:
|
||||
"""Signal entry for the Y axis device."""
|
||||
if self._image_config.device_y is None:
|
||||
if self._image_config.y_device is None:
|
||||
return ""
|
||||
return self._image_config.device_y.signal or ""
|
||||
return self._image_config.y_device.entry or ""
|
||||
|
||||
@signal_y.setter
|
||||
def signal_y(self, entry: str) -> None:
|
||||
@y_device_entry.setter
|
||||
def y_device_entry(self, entry: str) -> None:
|
||||
"""
|
||||
Set the Y device entry.
|
||||
|
||||
@@ -1272,18 +1276,18 @@ class Heatmap(ImageBase):
|
||||
if not entry:
|
||||
return
|
||||
|
||||
if self._image_config.device_y is None:
|
||||
logger.warning("Cannot set signal_y without device_y set first.")
|
||||
if self._image_config.y_device is None:
|
||||
logger.warning("Cannot set y_device_entry without y_device_name set first.")
|
||||
return
|
||||
|
||||
device_name = self._image_config.device_y.device
|
||||
device_name = self._image_config.y_device.name
|
||||
try:
|
||||
# Validate the entry for this device
|
||||
validated_signal = self.entry_validator.validate_signal(device_name, entry)
|
||||
self._image_config.device_y = HeatmapDeviceSignal(
|
||||
device=device_name, signal=validated_signal
|
||||
validated_entry = self.entry_validator.validate_signal(device_name, entry)
|
||||
self._image_config.y_device = HeatmapDeviceSignal(
|
||||
name=device_name, entry=validated_entry
|
||||
)
|
||||
self.property_changed.emit("signal_y", validated_signal)
|
||||
self.property_changed.emit("y_device_entry", validated_entry)
|
||||
self.update_labels() # Update axis labels
|
||||
self._try_auto_plot()
|
||||
except Exception as e:
|
||||
@@ -1291,14 +1295,14 @@ class Heatmap(ImageBase):
|
||||
pass # Silently fail if validation fails
|
||||
|
||||
@SafeProperty(str)
|
||||
def device_z(self) -> str:
|
||||
def z_device_name(self) -> str:
|
||||
"""Device name for the Z (color) axis."""
|
||||
if self._image_config.device_z is None:
|
||||
if self._image_config.z_device is None:
|
||||
return ""
|
||||
return self._image_config.device_z.device or ""
|
||||
return self._image_config.z_device.name or ""
|
||||
|
||||
@device_z.setter
|
||||
def device_z(self, device_name: str) -> None:
|
||||
@z_device_name.setter
|
||||
def z_device_name(self, device_name: str) -> None:
|
||||
"""
|
||||
Set the Z device name.
|
||||
|
||||
@@ -1310,28 +1314,28 @@ class Heatmap(ImageBase):
|
||||
# Get current entry or validate
|
||||
if device_name:
|
||||
try:
|
||||
signal = self.entry_validator.validate_signal(device_name, None)
|
||||
self._image_config.device_z = HeatmapDeviceSignal(device=device_name, signal=signal)
|
||||
self.property_changed.emit("device_z", device_name)
|
||||
entry = self.entry_validator.validate_signal(device_name, None)
|
||||
self._image_config.z_device = HeatmapDeviceSignal(name=device_name, entry=entry)
|
||||
self.property_changed.emit("z_device_name", device_name)
|
||||
self.update_labels() # Update axis labels (title)
|
||||
self._try_auto_plot()
|
||||
except Exception as e:
|
||||
logger.debug(f"Z device name validation failed: {e}")
|
||||
pass # Silently fail if device is not available yet
|
||||
else:
|
||||
self._image_config.device_z = None
|
||||
self.property_changed.emit("device_z", "")
|
||||
self._image_config.z_device = None
|
||||
self.property_changed.emit("z_device_name", "")
|
||||
self.update_labels() # Clear axis labels
|
||||
|
||||
@SafeProperty(str)
|
||||
def signal_z(self) -> str:
|
||||
def z_device_entry(self) -> str:
|
||||
"""Signal entry for the Z (color) axis device."""
|
||||
if self._image_config.device_z is None:
|
||||
if self._image_config.z_device is None:
|
||||
return ""
|
||||
return self._image_config.device_z.signal or ""
|
||||
return self._image_config.z_device.entry or ""
|
||||
|
||||
@signal_z.setter
|
||||
def signal_z(self, entry: str) -> None:
|
||||
@z_device_entry.setter
|
||||
def z_device_entry(self, entry: str) -> None:
|
||||
"""
|
||||
Set the Z device entry.
|
||||
|
||||
@@ -1341,18 +1345,18 @@ class Heatmap(ImageBase):
|
||||
if not entry:
|
||||
return
|
||||
|
||||
if self._image_config.device_z is None:
|
||||
logger.warning("Cannot set signal_z without device_z set first.")
|
||||
if self._image_config.z_device is None:
|
||||
logger.warning("Cannot set z_device_entry without z_device_name set first.")
|
||||
return
|
||||
|
||||
device_name = self._image_config.device_z.device
|
||||
device_name = self._image_config.z_device.name
|
||||
try:
|
||||
# Validate the entry for this device
|
||||
validated_signal = self.entry_validator.validate_signal(device_name, entry)
|
||||
self._image_config.device_z = HeatmapDeviceSignal(
|
||||
device=device_name, signal=validated_signal
|
||||
validated_entry = self.entry_validator.validate_signal(device_name, entry)
|
||||
self._image_config.z_device = HeatmapDeviceSignal(
|
||||
name=device_name, entry=validated_entry
|
||||
)
|
||||
self.property_changed.emit("signal_z", validated_signal)
|
||||
self.property_changed.emit("z_device_entry", validated_entry)
|
||||
self.update_labels() # Update axis labels (title)
|
||||
self._try_auto_plot()
|
||||
except Exception as e:
|
||||
@@ -1364,25 +1368,25 @@ class Heatmap(ImageBase):
|
||||
Attempt to automatically call plot() if all three devices are set.
|
||||
Similar to waveform's approach but requires all three devices.
|
||||
"""
|
||||
has_x = self._image_config.device_x is not None
|
||||
has_y = self._image_config.device_y is not None
|
||||
has_z = self._image_config.device_z is not None
|
||||
has_x = self._image_config.x_device is not None
|
||||
has_y = self._image_config.y_device is not None
|
||||
has_z = self._image_config.z_device is not None
|
||||
|
||||
if has_x and has_y and has_z:
|
||||
device_x = self._image_config.device_x.device
|
||||
signal_x = self._image_config.device_x.signal
|
||||
device_y = self._image_config.device_y.device
|
||||
signal_y = self._image_config.device_y.signal
|
||||
device_z = self._image_config.device_z.device
|
||||
signal_z = self._image_config.device_z.signal
|
||||
x_name = self._image_config.x_device.name
|
||||
x_entry = self._image_config.x_device.entry
|
||||
y_name = self._image_config.y_device.name
|
||||
y_entry = self._image_config.y_device.entry
|
||||
z_name = self._image_config.z_device.name
|
||||
z_entry = self._image_config.z_device.entry
|
||||
try:
|
||||
self.plot(
|
||||
device_x=device_x,
|
||||
device_y=device_y,
|
||||
device_z=device_z,
|
||||
signal_x=signal_x,
|
||||
signal_y=signal_y,
|
||||
signal_z=signal_z,
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
validate_bec=False, # Don't validate - entries already validated
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -1529,6 +1533,6 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
heatmap = Heatmap()
|
||||
heatmap.plot(device_x="samx", device_y="samy", device_z="bpm4i", oversampling_factor=5.0)
|
||||
heatmap.plot(x_name="samx", y_name="samy", z_name="bpm4i", oversampling_factor=5.0)
|
||||
heatmap.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -48,7 +48,7 @@ class HeatmapSettings(SettingWidget):
|
||||
if popup is False:
|
||||
self.ui.button_apply.clicked.connect(self.accept_changes)
|
||||
|
||||
self.ui.device_x.setFocus()
|
||||
self.ui.x_name.setFocus()
|
||||
|
||||
@SafeSlot()
|
||||
def fetch_all_properties(self):
|
||||
@@ -62,44 +62,44 @@ class HeatmapSettings(SettingWidget):
|
||||
color_map = getattr(self.target_widget, "color_map", None)
|
||||
|
||||
# Default values for device properties
|
||||
device_x, signal_x = None, None
|
||||
device_y, signal_y = None, None
|
||||
device_z, signal_z = None, None
|
||||
x_name, x_entry = None, None
|
||||
y_name, y_entry = None, None
|
||||
z_name, z_entry = None, None
|
||||
|
||||
# Safely access device properties
|
||||
if hasattr(self.target_widget, "_image_config") and self.target_widget._image_config:
|
||||
config = self.target_widget._image_config
|
||||
|
||||
if hasattr(config, "device_x") and config.device_x:
|
||||
device_x = getattr(config.device_x, "device", None)
|
||||
signal_x = getattr(config.device_x, "signal", None)
|
||||
if hasattr(config, "x_device") and config.x_device:
|
||||
x_name = getattr(config.x_device, "name", None)
|
||||
x_entry = getattr(config.x_device, "entry", None)
|
||||
|
||||
if hasattr(config, "device_y") and config.device_y:
|
||||
device_y = getattr(config.device_y, "device", None)
|
||||
signal_y = getattr(config.device_y, "signal", None)
|
||||
if hasattr(config, "y_device") and config.y_device:
|
||||
y_name = getattr(config.y_device, "name", None)
|
||||
y_entry = getattr(config.y_device, "entry", None)
|
||||
|
||||
if hasattr(config, "device_z") and config.device_z:
|
||||
device_z = getattr(config.device_z, "device", None)
|
||||
signal_z = getattr(config.device_z, "signal", None)
|
||||
if hasattr(config, "z_device") and config.z_device:
|
||||
z_name = getattr(config.z_device, "name", None)
|
||||
z_entry = getattr(config.z_device, "entry", None)
|
||||
|
||||
# Apply the properties to the settings widget
|
||||
if hasattr(self.ui, "color_map"):
|
||||
self.ui.color_map.colormap = color_map
|
||||
|
||||
if hasattr(self.ui, "device_x"):
|
||||
self.ui.device_x.set_device(device_x)
|
||||
if hasattr(self.ui, "signal_x") and signal_x is not None:
|
||||
self.ui.signal_x.set_to_obj_name(signal_x)
|
||||
if hasattr(self.ui, "x_name"):
|
||||
self.ui.x_name.set_device(x_name)
|
||||
if hasattr(self.ui, "x_entry") and x_entry is not None:
|
||||
self.ui.x_entry.set_to_obj_name(x_entry)
|
||||
|
||||
if hasattr(self.ui, "device_y"):
|
||||
self.ui.device_y.set_device(device_y)
|
||||
if hasattr(self.ui, "signal_y") and signal_y is not None:
|
||||
self.ui.signal_y.set_to_obj_name(signal_y)
|
||||
if hasattr(self.ui, "y_name"):
|
||||
self.ui.y_name.set_device(y_name)
|
||||
if hasattr(self.ui, "y_entry") and y_entry is not None:
|
||||
self.ui.y_entry.set_to_obj_name(y_entry)
|
||||
|
||||
if hasattr(self.ui, "device_z"):
|
||||
self.ui.device_z.set_device(device_z)
|
||||
if hasattr(self.ui, "signal_z") and signal_z is not None:
|
||||
self.ui.signal_z.set_to_obj_name(signal_z)
|
||||
if hasattr(self.ui, "z_name"):
|
||||
self.ui.z_name.set_device(z_name)
|
||||
if hasattr(self.ui, "z_entry") and z_entry is not None:
|
||||
self.ui.z_entry.set_to_obj_name(z_entry)
|
||||
|
||||
if hasattr(self.ui, "interpolation"):
|
||||
self.ui.interpolation.setCurrentText(
|
||||
@@ -119,12 +119,12 @@ class HeatmapSettings(SettingWidget):
|
||||
"""
|
||||
Apply all properties from the settings widget to the target widget.
|
||||
"""
|
||||
device_x = self.ui.device_x.currentText()
|
||||
signal_x = self.ui.signal_x.get_signal_name()
|
||||
device_y = self.ui.device_y.currentText()
|
||||
signal_y = self.ui.signal_y.get_signal_name()
|
||||
device_z = self.ui.device_z.currentText()
|
||||
signal_z = self.ui.signal_z.get_signal_name()
|
||||
x_name = self.ui.x_name.currentText()
|
||||
x_entry = self.ui.x_entry.get_signal_name()
|
||||
y_name = self.ui.y_name.currentText()
|
||||
y_entry = self.ui.y_entry.get_signal_name()
|
||||
z_name = self.ui.z_name.currentText()
|
||||
z_entry = self.ui.z_entry.get_signal_name()
|
||||
validate_bec = self.ui.validate_bec.checked
|
||||
color_map = self.ui.color_map.colormap
|
||||
interpolation = self.ui.interpolation.currentText()
|
||||
@@ -132,12 +132,12 @@ class HeatmapSettings(SettingWidget):
|
||||
enforce_interpolation = self.ui.enforce_interpolation.isChecked()
|
||||
|
||||
self.target_widget.plot(
|
||||
device_x=device_x,
|
||||
device_y=device_y,
|
||||
device_z=device_z,
|
||||
signal_x=signal_x,
|
||||
signal_y=signal_y,
|
||||
signal_z=signal_z,
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
color_map=color_map,
|
||||
validate_bec=validate_bec,
|
||||
interpolation=interpolation,
|
||||
@@ -147,17 +147,17 @@ class HeatmapSettings(SettingWidget):
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
self.ui.device_x.close()
|
||||
self.ui.device_x.deleteLater()
|
||||
self.ui.signal_x.close()
|
||||
self.ui.signal_x.deleteLater()
|
||||
self.ui.device_y.close()
|
||||
self.ui.device_y.deleteLater()
|
||||
self.ui.signal_y.close()
|
||||
self.ui.signal_y.deleteLater()
|
||||
self.ui.device_z.close()
|
||||
self.ui.device_z.deleteLater()
|
||||
self.ui.signal_z.close()
|
||||
self.ui.signal_z.deleteLater()
|
||||
self.ui.x_name.close()
|
||||
self.ui.x_name.deleteLater()
|
||||
self.ui.x_entry.close()
|
||||
self.ui.x_entry.deleteLater()
|
||||
self.ui.y_name.close()
|
||||
self.ui.y_name.deleteLater()
|
||||
self.ui.y_entry.close()
|
||||
self.ui.y_entry.deleteLater()
|
||||
self.ui.z_name.close()
|
||||
self.ui.z_name.deleteLater()
|
||||
self.ui.z_entry.close()
|
||||
self.ui.z_entry.deleteLater()
|
||||
self.ui.interpolation.close()
|
||||
self.ui.interpolation.deleteLater()
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="device_x">
|
||||
<widget class="DeviceComboBox" name="x_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -206,7 +206,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="signal_x">
|
||||
<widget class="SignalComboBox" name="x_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -236,7 +236,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="device_y">
|
||||
<widget class="DeviceComboBox" name="y_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -246,7 +246,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="signal_y">
|
||||
<widget class="SignalComboBox" name="y_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -276,7 +276,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="device_z">
|
||||
<widget class="DeviceComboBox" name="z_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -286,7 +286,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="signal_z">
|
||||
<widget class="SignalComboBox" name="z_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -322,21 +322,21 @@
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<tabstops>
|
||||
<tabstop>device_x</tabstop>
|
||||
<tabstop>device_y</tabstop>
|
||||
<tabstop>device_z</tabstop>
|
||||
<tabstop>signal_x</tabstop>
|
||||
<tabstop>signal_y</tabstop>
|
||||
<tabstop>signal_z</tabstop>
|
||||
<tabstop>x_name</tabstop>
|
||||
<tabstop>y_name</tabstop>
|
||||
<tabstop>z_name</tabstop>
|
||||
<tabstop>x_entry</tabstop>
|
||||
<tabstop>y_entry</tabstop>
|
||||
<tabstop>z_entry</tabstop>
|
||||
<tabstop>interpolation</tabstop>
|
||||
<tabstop>oversampling_factor</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>device_x</sender>
|
||||
<sender>x_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>signal_x</receiver>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -350,9 +350,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_x</sender>
|
||||
<sender>x_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>signal_x</receiver>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -366,9 +366,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_y</sender>
|
||||
<sender>y_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>signal_y</receiver>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -382,9 +382,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_y</sender>
|
||||
<sender>y_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>signal_y</receiver>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -398,9 +398,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_z</sender>
|
||||
<sender>z_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>signal_z</receiver>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -414,9 +414,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_z</sender>
|
||||
<sender>z_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>signal_z</receiver>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="device_x">
|
||||
<widget class="DeviceComboBox" name="x_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -79,7 +79,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="signal_x">
|
||||
<widget class="SignalComboBox" name="x_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -109,7 +109,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="device_y">
|
||||
<widget class="DeviceComboBox" name="y_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -119,7 +119,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="signal_y">
|
||||
<widget class="SignalComboBox" name="y_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -142,7 +142,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="device_z">
|
||||
<widget class="DeviceComboBox" name="z_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -152,7 +152,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="signal_z">
|
||||
<widget class="SignalComboBox" name="z_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -264,20 +264,20 @@
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<tabstops>
|
||||
<tabstop>device_x</tabstop>
|
||||
<tabstop>device_y</tabstop>
|
||||
<tabstop>device_z</tabstop>
|
||||
<tabstop>x_name</tabstop>
|
||||
<tabstop>y_name</tabstop>
|
||||
<tabstop>z_name</tabstop>
|
||||
<tabstop>button_apply</tabstop>
|
||||
<tabstop>signal_x</tabstop>
|
||||
<tabstop>signal_y</tabstop>
|
||||
<tabstop>signal_z</tabstop>
|
||||
<tabstop>x_entry</tabstop>
|
||||
<tabstop>y_entry</tabstop>
|
||||
<tabstop>z_entry</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>device_x</sender>
|
||||
<sender>x_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>signal_x</receiver>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -291,9 +291,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_x</sender>
|
||||
<sender>x_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>signal_x</receiver>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -307,9 +307,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_y</sender>
|
||||
<sender>y_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>signal_y</receiver>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -323,9 +323,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_y</sender>
|
||||
<sender>y_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>signal_y</receiver>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -339,9 +339,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_z</sender>
|
||||
<sender>z_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>signal_z</receiver>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -355,9 +355,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_z</sender>
|
||||
<sender>z_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>signal_z</receiver>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
|
||||
@@ -42,8 +42,8 @@ class ImageConfig(ConnectionConfig):
|
||||
|
||||
|
||||
class ImageLayerConfig(BaseModel):
|
||||
device: str = Field("", description="The device name to monitor.")
|
||||
signal: str = Field("", description="The signal/entry name to monitor on the device.")
|
||||
device_name: str = Field("", description="The device name to monitor.")
|
||||
device_entry: str = Field("", description="The signal/entry name to monitor on the device.")
|
||||
monitor_type: Literal["1d", "2d"] | None = Field(None, description="The type of monitor.")
|
||||
source: Literal["device_monitor_1d", "device_monitor_2d"] | None = Field(
|
||||
None, description="The source of the image data."
|
||||
@@ -80,10 +80,10 @@ class Image(ImageBase):
|
||||
"autorange.setter",
|
||||
"autorange_mode",
|
||||
"autorange_mode.setter",
|
||||
"device",
|
||||
"device.setter",
|
||||
"signal",
|
||||
"signal.setter",
|
||||
"device_name",
|
||||
"device_name.setter",
|
||||
"device_entry",
|
||||
"device_entry.setter",
|
||||
"enable_colorbar",
|
||||
"enable_simple_colorbar",
|
||||
"enable_simple_colorbar.setter",
|
||||
@@ -206,19 +206,19 @@ class Image(ImageBase):
|
||||
signal_text = device_selection.signal_combo_box.currentText()
|
||||
|
||||
if not device:
|
||||
self.device = ""
|
||||
self.device_name = ""
|
||||
return
|
||||
if not device_selection.device_combo_box.is_valid_input:
|
||||
return
|
||||
|
||||
if not device_selection.signal_combo_box.is_valid_input:
|
||||
if self._config.signal:
|
||||
self.signal = ""
|
||||
if device != self._config.device:
|
||||
self.device = device
|
||||
if self._config.device_entry:
|
||||
self.device_entry = ""
|
||||
if device != self._config.device_name:
|
||||
self.device_name = device
|
||||
return
|
||||
|
||||
if device == self._config.device and signal_text == self._config.signal:
|
||||
if device == self._config.device_name and signal_text == self._config.device_entry:
|
||||
return
|
||||
|
||||
# Get the signal config stored in the combobox
|
||||
@@ -235,8 +235,8 @@ class Image(ImageBase):
|
||||
|
||||
# Store signal config and set properties which will trigger the connection
|
||||
self._signal_configs["main"] = signal_config
|
||||
self.device = device
|
||||
self.signal = signal_text
|
||||
self.device_name = device
|
||||
self.device_entry = signal_text
|
||||
finally:
|
||||
self._device_selection_updating = False
|
||||
|
||||
@@ -244,63 +244,55 @@ class Image(ImageBase):
|
||||
# Data Acquisition
|
||||
|
||||
@SafeProperty(str, auto_emit=True)
|
||||
def device(self) -> str:
|
||||
def device_name(self) -> str:
|
||||
"""
|
||||
The name of the device to monitor for image data.
|
||||
"""
|
||||
return self._config.device
|
||||
return self._config.device_name
|
||||
|
||||
@device.setter
|
||||
def device(self, value: str):
|
||||
@device_name.setter
|
||||
def device_name(self, value: str):
|
||||
"""
|
||||
Set the device name for the image. This should be used together with signal.
|
||||
When both device and signal are set, the widget connects to that device signal.
|
||||
Set the device name for the image. This should be used together with device_entry.
|
||||
When both device_name and device_entry are set, the widget connects to that device signal.
|
||||
|
||||
Args:
|
||||
value(str): The name of the device to monitor.
|
||||
"""
|
||||
if not value:
|
||||
# Clear the monitor if empty device name
|
||||
if self._config.device:
|
||||
if self._config.device_name:
|
||||
self._disconnect_current_monitor()
|
||||
self._config.device = ""
|
||||
self._config.signal = ""
|
||||
self._config.device_name = ""
|
||||
self._config.device_entry = ""
|
||||
self._signal_configs.pop("main", None)
|
||||
self._set_connection_status("disconnected")
|
||||
return
|
||||
|
||||
old_device = self._config.device
|
||||
old_signal = self._config.signal
|
||||
old_config = self.subscriptions["main"]
|
||||
if old_device and old_signal and old_device != value:
|
||||
self._disconnect_monitor_subscription(
|
||||
device=old_device,
|
||||
signal=old_signal,
|
||||
source=old_config.source,
|
||||
async_update=self.async_update,
|
||||
async_signal_name=old_config.async_signal_name,
|
||||
)
|
||||
self._config.device = value
|
||||
old_device = self._config.device_name
|
||||
self._config.device_name = value
|
||||
|
||||
# If we have a signal, reconnect with the new device
|
||||
if self._config.signal:
|
||||
# If we have a device_entry, reconnect with the new device
|
||||
if self._config.device_entry:
|
||||
# Try to get fresh signal config for the new device
|
||||
try:
|
||||
device_obj = self.dev[value]
|
||||
# Try to get signal config for the current entry
|
||||
if self._config.signal in device_obj._info.get("signals", {}):
|
||||
self._signal_configs["main"] = device_obj._info["signals"][self._config.signal]
|
||||
if self._config.device_entry in device_obj._info.get("signals", {}):
|
||||
self._signal_configs["main"] = device_obj._info["signals"][
|
||||
self._config.device_entry
|
||||
]
|
||||
self._setup_connection()
|
||||
else:
|
||||
# Signal doesn't exist on new device
|
||||
logger.warning(
|
||||
f"Signal '{self._config.signal}' doesn't exist on device '{value}'"
|
||||
f"Signal '{self._config.device_entry}' doesn't exist on device '{value}'"
|
||||
)
|
||||
self._disconnect_current_monitor()
|
||||
self._config.signal = ""
|
||||
self._config.device_entry = ""
|
||||
self._signal_configs.pop("main", None)
|
||||
self._set_connection_status(
|
||||
"error", f"Signal '{self._config.signal}' doesn't exist"
|
||||
"error", f"Signal '{self._config.device_entry}' doesn't exist"
|
||||
)
|
||||
except (KeyError, AttributeError):
|
||||
# Device doesn't exist
|
||||
@@ -312,50 +304,40 @@ class Image(ImageBase):
|
||||
# Toolbar sync happens via SafeProperty auto_emit property_changed handling.
|
||||
|
||||
@SafeProperty(str, auto_emit=True)
|
||||
def signal(self) -> str:
|
||||
def device_entry(self) -> str:
|
||||
"""
|
||||
The signal/entry name to monitor on the device.
|
||||
"""
|
||||
return self._config.signal
|
||||
return self._config.device_entry
|
||||
|
||||
@signal.setter
|
||||
def signal(self, value: str):
|
||||
@device_entry.setter
|
||||
def device_entry(self, value: str):
|
||||
"""
|
||||
Set the device signal for the image. This should be used together with device.
|
||||
Set the device entry (signal) for the image. This should be used together with device_name.
|
||||
When set, it will connect to updates from that device signal.
|
||||
|
||||
Args:
|
||||
value(str): The signal name to monitor.
|
||||
"""
|
||||
if not value:
|
||||
if self._config.signal:
|
||||
if self._config.device_entry:
|
||||
self._disconnect_current_monitor()
|
||||
self._config.signal = ""
|
||||
self._config.device_entry = ""
|
||||
self._signal_configs.pop("main", None)
|
||||
self._set_connection_status("disconnected")
|
||||
return
|
||||
|
||||
old_signal = self._config.signal
|
||||
old_config = self.subscriptions["main"]
|
||||
if self._config.device and old_signal and old_signal != value:
|
||||
self._disconnect_monitor_subscription(
|
||||
device=self._config.device,
|
||||
signal=old_signal,
|
||||
source=old_config.source,
|
||||
async_update=self.async_update,
|
||||
async_signal_name=old_config.async_signal_name,
|
||||
)
|
||||
self._config.signal = value
|
||||
self._config.device_entry = value
|
||||
|
||||
# If we have a device, try to connect
|
||||
if self._config.device:
|
||||
# If we have a device_name, try to connect
|
||||
if self._config.device_name:
|
||||
try:
|
||||
device_obj = self.dev[self._config.device]
|
||||
device_obj = self.dev[self._config.device_name]
|
||||
signal_config = device_obj._info["signals"].get(value)
|
||||
if not isinstance(signal_config, dict) or not signal_config.get("signal_class"):
|
||||
logger.warning(
|
||||
f"Could not find valid configuration for signal '{value}' "
|
||||
f"on device '{self._config.device}'."
|
||||
f"on device '{self._config.device_name}'."
|
||||
)
|
||||
self._signal_configs.pop("main", None)
|
||||
self._set_connection_status("error", f"Signal '{value}' not found")
|
||||
@@ -365,14 +347,14 @@ class Image(ImageBase):
|
||||
self._setup_connection()
|
||||
except (KeyError, AttributeError):
|
||||
logger.warning(
|
||||
f"Could not find signal '{value}' on device '{self._config.device}'."
|
||||
f"Could not find signal '{value}' on device '{self._config.device_name}'."
|
||||
)
|
||||
# Remove signal config if it can't be fetched
|
||||
self._signal_configs.pop("main", None)
|
||||
self._set_connection_status("error", f"Signal '{value}' not found")
|
||||
|
||||
else:
|
||||
logger.debug(f"signal setter: No device set yet for signal '{value}'")
|
||||
logger.debug(f"device_entry setter: No device set yet for signal '{value}'")
|
||||
|
||||
@property
|
||||
def main_image(self) -> ImageItem:
|
||||
@@ -381,17 +363,17 @@ class Image(ImageBase):
|
||||
|
||||
def _setup_connection(self):
|
||||
"""
|
||||
Internal method to setup connection based on current device, signal, and signal_config.
|
||||
Internal method to setup connection based on current device_name, device_entry, and signal_config.
|
||||
"""
|
||||
if not self._config.device or not self._config.signal:
|
||||
logger.warning("Cannot setup connection without both device and signal")
|
||||
if not self._config.device_name or not self._config.device_entry:
|
||||
logger.warning("Cannot setup connection without both device_name and device_entry")
|
||||
self._set_connection_status("disconnected")
|
||||
return
|
||||
|
||||
signal_config = self._signal_configs.get("main")
|
||||
if not signal_config:
|
||||
logger.warning(
|
||||
f"Cannot setup connection for {self._config.device}.{self._config.signal} without signal_config"
|
||||
f"Cannot setup connection for {self._config.device_name}.{self._config.device_entry} without signal_config"
|
||||
)
|
||||
self._set_connection_status("error", "Missing signal config")
|
||||
return
|
||||
@@ -405,7 +387,7 @@ class Image(ImageBase):
|
||||
|
||||
if signal_class not in supported_classes:
|
||||
logger.warning(
|
||||
f"Signal '{self._config.device}.{self._config.signal}' has unsupported signal class '{signal_class}'. "
|
||||
f"Signal '{self._config.device_name}.{self._config.device_entry}' has unsupported signal class '{signal_class}'. "
|
||||
f"Supported classes: {supported_classes}"
|
||||
)
|
||||
self._set_connection_status("error", f"Unsupported signal class '{signal_class}'")
|
||||
@@ -417,7 +399,7 @@ class Image(ImageBase):
|
||||
|
||||
if ndim is None:
|
||||
logger.warning(
|
||||
f"Signal '{self._config.device}.{self._config.signal}' does not have a valid 'ndim' in its signal_info."
|
||||
f"Signal '{self._config.device_name}.{self._config.device_entry}' does not have a valid 'ndim' in its signal_info."
|
||||
)
|
||||
self._set_connection_status("error", "Missing ndim in signal_info")
|
||||
return
|
||||
@@ -432,12 +414,14 @@ class Image(ImageBase):
|
||||
if signal_class == "PreviewSignal":
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_preview(self._config.device, self._config.signal),
|
||||
MessageEndpoints.device_preview(
|
||||
self._config.device_name, self._config.device_entry
|
||||
),
|
||||
)
|
||||
elif signal_class in self.SUPPORTED_SIGNALS:
|
||||
self.async_update = True
|
||||
config.async_signal_name = signal_config.get(
|
||||
"obj_name", f"{self._config.device}_{self._config.signal}"
|
||||
"obj_name", f"{self._config.device_name}_{self._config.device_entry}"
|
||||
)
|
||||
self._setup_async_image(self.scan_id)
|
||||
elif ndim == 2:
|
||||
@@ -446,97 +430,90 @@ class Image(ImageBase):
|
||||
if signal_class == "PreviewSignal":
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_preview(self._config.device, self._config.signal),
|
||||
MessageEndpoints.device_preview(
|
||||
self._config.device_name, self._config.device_entry
|
||||
),
|
||||
)
|
||||
elif signal_class in self.SUPPORTED_SIGNALS:
|
||||
self.async_update = True
|
||||
config.async_signal_name = signal_config.get(
|
||||
"obj_name", f"{self._config.device}_{self._config.signal}"
|
||||
"obj_name", f"{self._config.device_name}_{self._config.device_entry}"
|
||||
)
|
||||
self._setup_async_image(self.scan_id)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unsupported ndim '{ndim}' for monitor '{self._config.device}.{self._config.signal}'."
|
||||
f"Unsupported ndim '{ndim}' for monitor '{self._config.device_name}.{self._config.device_entry}'."
|
||||
)
|
||||
self._set_connection_status("error", f"Unsupported ndim '{ndim}'")
|
||||
return
|
||||
|
||||
self._set_connection_status("connected")
|
||||
logger.info(
|
||||
f"Connected to {self._config.device}.{self._config.signal} with type {config.monitor_type}"
|
||||
f"Connected to {self._config.device_name}.{self._config.device_entry} with type {config.monitor_type}"
|
||||
)
|
||||
self._autorange_on_next_update = True
|
||||
|
||||
def _disconnect_monitor_subscription(
|
||||
self,
|
||||
*,
|
||||
device: str,
|
||||
signal: str,
|
||||
source: Literal["device_monitor_1d", "device_monitor_2d"] | None,
|
||||
async_update: bool,
|
||||
async_signal_name: str | None,
|
||||
) -> None:
|
||||
if not device or not signal:
|
||||
return
|
||||
|
||||
if async_update:
|
||||
async_signal_name = async_signal_name or signal
|
||||
ids_to_check = [self.scan_id, self.old_scan_id]
|
||||
|
||||
if source == "device_monitor_1d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_async_signal(scan_id, device, async_signal_name),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{device},Device Entry:{async_signal_name}"
|
||||
)
|
||||
elif source == "device_monitor_2d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_async_signal(scan_id, device, async_signal_name),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{device},Device Entry:{async_signal_name}"
|
||||
)
|
||||
return
|
||||
|
||||
if source == "device_monitor_1d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d, MessageEndpoints.device_preview(device, signal)
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting preview 1d update Device Name:{device}, Device Entry:{signal}"
|
||||
)
|
||||
elif source == "device_monitor_2d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d, MessageEndpoints.device_preview(device, signal)
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting preview 2d update Device Name:{device}, Device Entry:{signal}"
|
||||
)
|
||||
|
||||
def _disconnect_current_monitor(self):
|
||||
"""
|
||||
Internal method to disconnect the current monitor subscriptions.
|
||||
"""
|
||||
if not self._config.device or not self._config.signal:
|
||||
if not self._config.device_name or not self._config.device_entry:
|
||||
return
|
||||
|
||||
config = self.subscriptions["main"]
|
||||
self._disconnect_monitor_subscription(
|
||||
device=self._config.device,
|
||||
signal=self._config.signal,
|
||||
source=config.source,
|
||||
async_update=self.async_update,
|
||||
async_signal_name=config.async_signal_name,
|
||||
)
|
||||
|
||||
if self.async_update:
|
||||
async_signal_name = config.async_signal_name or self._config.device_entry
|
||||
ids_to_check = [self.scan_id, self.old_scan_id]
|
||||
|
||||
if config.source == "device_monitor_1d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_async_signal(
|
||||
scan_id, self._config.device_name, async_signal_name
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{self._config.device_name},Device Entry:{async_signal_name}"
|
||||
)
|
||||
elif config.source == "device_monitor_2d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_async_signal(
|
||||
scan_id, self._config.device_name, async_signal_name
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{self._config.device_name},Device Entry:{async_signal_name}"
|
||||
)
|
||||
|
||||
else:
|
||||
if config.source == "device_monitor_1d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_preview(
|
||||
self._config.device_name, self._config.device_entry
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting preview 1d update Device Name:{self._config.device_name}, Device Entry:{self._config.device_entry}"
|
||||
)
|
||||
elif config.source == "device_monitor_2d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_preview(
|
||||
self._config.device_name, self._config.device_entry
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting preview 2d update Device Name:{self._config.device_name}, Device Entry:{self._config.device_entry}"
|
||||
)
|
||||
|
||||
# Reset async state
|
||||
self.async_update = False
|
||||
@@ -549,8 +526,8 @@ class Image(ImageBase):
|
||||
@SafeSlot(popup_error=True)
|
||||
def image(
|
||||
self,
|
||||
device: str | None = None,
|
||||
signal: str | None = None,
|
||||
device_name: str | None = None,
|
||||
device_entry: str | None = None,
|
||||
color_map: str | None = None,
|
||||
color_bar: Literal["simple", "full"] | None = None,
|
||||
vrange: tuple[int, int] | None = None,
|
||||
@@ -559,8 +536,8 @@ class Image(ImageBase):
|
||||
Set the image source and update the image.
|
||||
|
||||
Args:
|
||||
device(str|None): The name of the device to monitor. If None or empty string, the current monitor will be disconnected.
|
||||
signal(str|None): The signal/entry name to monitor on the device.
|
||||
device_name(str|None): The name of the device to monitor. If None or empty string, the current monitor will be disconnected.
|
||||
device_entry(str|None): The signal/entry name to monitor on the device.
|
||||
color_map(str): The color map to use for the image.
|
||||
color_bar(str): The type of color bar to use. Options are "simple" or "full".
|
||||
vrange(tuple): The range of values to use for the color map.
|
||||
@@ -569,27 +546,27 @@ class Image(ImageBase):
|
||||
ImageItem: The image object, or None if connection failed.
|
||||
"""
|
||||
# Disconnect existing monitor if any
|
||||
if self._config.device and self._config.signal:
|
||||
if self._config.device_name and self._config.device_entry:
|
||||
self._disconnect_current_monitor()
|
||||
|
||||
if not device or not signal:
|
||||
if device or signal:
|
||||
logger.warning("Both device and signal must be specified")
|
||||
if not device_name or not device_entry:
|
||||
if device_name or device_entry:
|
||||
logger.warning("Both device_name and device_entry must be specified")
|
||||
else:
|
||||
logger.info("Disconnecting image monitor")
|
||||
self.device = ""
|
||||
self.device_name = ""
|
||||
return None
|
||||
|
||||
# Validate device
|
||||
self.entry_validator.validate_monitor(device)
|
||||
self.entry_validator.validate_monitor(device_name)
|
||||
|
||||
# Clear old entry first to avoid reconnect attempts on the new device
|
||||
if self._config.signal:
|
||||
self.signal = ""
|
||||
if self._config.device_entry:
|
||||
self.device_entry = ""
|
||||
|
||||
# Set properties to trigger connection
|
||||
self.device = device
|
||||
self.signal = signal
|
||||
self.device_name = device_name
|
||||
self.device_entry = device_entry
|
||||
|
||||
# Apply visual settings
|
||||
if color_map is not None:
|
||||
@@ -604,7 +581,7 @@ class Image(ImageBase):
|
||||
def _sync_device_selection(self):
|
||||
"""
|
||||
Synchronize the device and signal comboboxes with the current monitor state.
|
||||
This ensures the toolbar reflects the device and signal properties.
|
||||
This ensures the toolbar reflects the device_name and device_entry properties.
|
||||
"""
|
||||
try:
|
||||
device_selection_action = self.toolbar.components.get_action("device_selection")
|
||||
@@ -616,8 +593,8 @@ class Image(ImageBase):
|
||||
return
|
||||
|
||||
device_selection: DeviceSelection = device_selection_action.widget
|
||||
target_device = self._config.device or ""
|
||||
target_entry = self._config.signal or ""
|
||||
target_device = self._config.device_name or ""
|
||||
target_entry = self._config.device_entry or ""
|
||||
|
||||
# Check if already synced
|
||||
if (
|
||||
@@ -628,15 +605,15 @@ class Image(ImageBase):
|
||||
|
||||
device_selection.set_device_and_signal(target_device, target_entry)
|
||||
|
||||
def _sync_signal_from_toolbar(self) -> None:
|
||||
def _sync_device_entry_from_toolbar(self) -> None:
|
||||
"""
|
||||
Pull the signal selection from the toolbar if it differs from the current signal.
|
||||
This keeps CLI-driven device updates in sync with the signal combobox state.
|
||||
Pull the signal selection from the toolbar if it differs from the current device_entry.
|
||||
This keeps CLI-driven device_name updates in sync with the signal combobox state.
|
||||
"""
|
||||
if self._device_selection_updating:
|
||||
return
|
||||
|
||||
if not self._config.device:
|
||||
if not self._config.device_name:
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -648,17 +625,17 @@ class Image(ImageBase):
|
||||
return
|
||||
|
||||
device_selection: DeviceSelection = device_selection_action.widget
|
||||
if device_selection.device_combo_box.currentText() != self._config.device:
|
||||
if device_selection.device_combo_box.currentText() != self._config.device_name:
|
||||
return
|
||||
|
||||
signal_text = device_selection.signal_combo_box.currentText()
|
||||
if not signal_text or signal_text == self._config.signal:
|
||||
if not signal_text or signal_text == self._config.device_entry:
|
||||
return
|
||||
|
||||
signal_config = device_selection.signal_combo_box.get_signal_config()
|
||||
if not signal_config:
|
||||
try:
|
||||
device_obj = self.dev[self._config.device]
|
||||
device_obj = self.dev[self._config.device_name]
|
||||
signal_config = device_obj._info["signals"].get(signal_text, {})
|
||||
except (KeyError, AttributeError):
|
||||
signal_config = None
|
||||
@@ -669,7 +646,7 @@ class Image(ImageBase):
|
||||
self._signal_configs["main"] = signal_config
|
||||
self._device_selection_updating = True
|
||||
try:
|
||||
self.signal = signal_text
|
||||
self.device_entry = signal_text
|
||||
finally:
|
||||
self._device_selection_updating = False
|
||||
|
||||
@@ -818,17 +795,17 @@ class Image(ImageBase):
|
||||
|
||||
def _get_async_signal_name(self) -> tuple[str, str] | None:
|
||||
"""
|
||||
Returns device and async signal names used for endpoints/messages.
|
||||
Returns device name and async signal name used for endpoints/messages.
|
||||
|
||||
Returns:
|
||||
tuple[str, str] | None: (device, async_signal_name) or None if not available.
|
||||
tuple[str, str] | None: (device_name, async_signal_name) or None if not available.
|
||||
"""
|
||||
if not self._config.device or not self._config.signal:
|
||||
if not self._config.device_name or not self._config.device_entry:
|
||||
return None
|
||||
|
||||
config = self.subscriptions["main"]
|
||||
async_signal = config.async_signal_name or self._config.signal
|
||||
return self._config.device, async_signal
|
||||
async_signal = config.async_signal_name or self._config.device_entry
|
||||
return self._config.device_name, async_signal
|
||||
|
||||
def _setup_async_image(self, scan_id: str | None):
|
||||
"""
|
||||
@@ -846,7 +823,7 @@ class Image(ImageBase):
|
||||
logger.info("Async image setup skipped because monitor information is incomplete.")
|
||||
return
|
||||
|
||||
device, async_signal = async_names
|
||||
device_name, async_signal = async_names
|
||||
if config.monitor_type == "1d":
|
||||
slot = self.on_image_update_1d
|
||||
elif config.monitor_type == "2d":
|
||||
@@ -862,7 +839,7 @@ class Image(ImageBase):
|
||||
if prev_scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
slot, MessageEndpoints.device_async_signal(prev_scan_id, device, async_signal)
|
||||
slot, MessageEndpoints.device_async_signal(prev_scan_id, device_name, async_signal)
|
||||
)
|
||||
|
||||
if scan_id is None:
|
||||
@@ -871,41 +848,67 @@ class Image(ImageBase):
|
||||
|
||||
self.bec_dispatcher.connect_slot(
|
||||
slot,
|
||||
MessageEndpoints.device_async_signal(scan_id, device, async_signal),
|
||||
MessageEndpoints.device_async_signal(scan_id, device_name, async_signal),
|
||||
from_start=True,
|
||||
cb_info={"scan_id": scan_id},
|
||||
)
|
||||
logger.info(f"Setup async image for {device}.{async_signal} and scan {scan_id}.")
|
||||
logger.info(f"Setup async image for {device_name}.{async_signal} and scan {scan_id}.")
|
||||
|
||||
def disconnect_monitor(self, device: str | None = None, signal: str | None = None):
|
||||
def disconnect_monitor(self, device_name: str | None = None, device_entry: str | None = None):
|
||||
"""
|
||||
Disconnect the monitor from the image update signals, both 1D and 2D.
|
||||
|
||||
Args:
|
||||
device(str|None): The name of the device to disconnect. Defaults to current device.
|
||||
signal(str|None): The signal/entry name to disconnect. Defaults to current signal.
|
||||
device_name(str|None): The name of the device to disconnect. Defaults to current device.
|
||||
device_entry(str|None): The signal/entry name to disconnect. Defaults to current entry.
|
||||
"""
|
||||
config = self.subscriptions["main"]
|
||||
target_device = device or self._config.device
|
||||
target_entry = signal or self._config.signal
|
||||
target_device = device_name or self._config.device_name
|
||||
target_entry = device_entry or self._config.device_entry
|
||||
|
||||
if not target_device or not target_entry:
|
||||
logger.warning("Cannot disconnect monitor without both device and signal")
|
||||
logger.warning("Cannot disconnect monitor without both device_name and device_entry")
|
||||
return
|
||||
|
||||
if config.source not in {"device_monitor_1d", "device_monitor_2d"}:
|
||||
logger.warning(
|
||||
f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}"
|
||||
)
|
||||
return
|
||||
|
||||
self._disconnect_monitor_subscription(
|
||||
device=target_device,
|
||||
signal=target_entry,
|
||||
source=config.source,
|
||||
async_update=self.async_update,
|
||||
async_signal_name=config.async_signal_name,
|
||||
)
|
||||
if self.async_update:
|
||||
async_signal_name = config.async_signal_name or target_entry
|
||||
ids_to_check = [self.scan_id, self.old_scan_id]
|
||||
if config.source == "device_monitor_1d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_async_signal(
|
||||
scan_id, target_device, async_signal_name
|
||||
),
|
||||
)
|
||||
elif config.source == "device_monitor_2d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_async_signal(
|
||||
scan_id, target_device, async_signal_name
|
||||
),
|
||||
)
|
||||
else:
|
||||
if config.source == "device_monitor_1d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_preview(target_device, target_entry),
|
||||
)
|
||||
elif config.source == "device_monitor_2d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_preview(target_device, target_entry),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}"
|
||||
)
|
||||
return
|
||||
|
||||
self.subscriptions["main"].async_signal_name = None
|
||||
self.async_update = False
|
||||
@@ -1042,10 +1045,10 @@ class Image(ImageBase):
|
||||
if layer_name not in self.subscriptions:
|
||||
return
|
||||
# For the main layer, disconnect current monitor
|
||||
if layer_name == "main" and self._config.device and self._config.signal:
|
||||
if layer_name == "main" and self._config.device_name and self._config.device_entry:
|
||||
self._disconnect_current_monitor()
|
||||
self._config.device = ""
|
||||
self._config.signal = ""
|
||||
self._config.device_name = ""
|
||||
self._config.device_entry = ""
|
||||
self._signal_configs.pop("main", None)
|
||||
|
||||
def cleanup(self):
|
||||
@@ -1055,7 +1058,7 @@ class Image(ImageBase):
|
||||
self.layer_removed.disconnect(self._on_layer_removed)
|
||||
|
||||
# Disconnect current monitor
|
||||
if self._config.device and self._config.signal:
|
||||
if self._config.device_name and self._config.device_entry:
|
||||
self._disconnect_current_monitor()
|
||||
|
||||
self.subscriptions.clear()
|
||||
|
||||
@@ -64,32 +64,32 @@ class DeviceSelection(QWidget):
|
||||
layout.addWidget(self.device_combo_box, stretch=1)
|
||||
layout.addWidget(self.signal_combo_box, stretch=1)
|
||||
|
||||
def set_device_and_signal(self, device: str | None, signal: str | None) -> None:
|
||||
def set_device_and_signal(self, device_name: str | None, device_entry: str | None) -> None:
|
||||
"""Set the displayed device and signal without emitting selection signals."""
|
||||
device = device or ""
|
||||
signal = signal or ""
|
||||
device_name = device_name or ""
|
||||
device_entry = device_entry or ""
|
||||
|
||||
self.device_combo_box.blockSignals(True)
|
||||
self.signal_combo_box.blockSignals(True)
|
||||
|
||||
try:
|
||||
if device:
|
||||
if device_name:
|
||||
# Set device in device_combo_box
|
||||
index = self.device_combo_box.findText(device)
|
||||
index = self.device_combo_box.findText(device_name)
|
||||
if index >= 0:
|
||||
self.device_combo_box.setCurrentIndex(index)
|
||||
else:
|
||||
# Device not found in list, but still set it
|
||||
self.device_combo_box.setCurrentText(device)
|
||||
self.device_combo_box.setCurrentText(device_name)
|
||||
|
||||
# Only update signal combobox device filter if it's actually changing
|
||||
# This prevents redundant repopulation which can cause duplicates !!!!
|
||||
current_device = getattr(self.signal_combo_box, "_device", None)
|
||||
if current_device != device:
|
||||
self.signal_combo_box.set_device(device)
|
||||
if current_device != device_name:
|
||||
self.signal_combo_box.set_device(device_name)
|
||||
|
||||
# Sync signal combobox selection
|
||||
if signal:
|
||||
if device_entry:
|
||||
# Try to find the signal by component_name (which is what's displayed)
|
||||
found = False
|
||||
for i in range(self.signal_combo_box.count()):
|
||||
@@ -99,14 +99,14 @@ class DeviceSelection(QWidget):
|
||||
# Check if this matches our signal
|
||||
if config_data:
|
||||
component_name = config_data.get("component_name", "")
|
||||
if text == component_name or text == signal:
|
||||
if text == component_name or text == device_entry:
|
||||
self.signal_combo_box.setCurrentIndex(i)
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
# Fallback: try to match the signal directly
|
||||
index = self.signal_combo_box.findText(signal)
|
||||
# Fallback: try to match the device_entry directly
|
||||
index = self.signal_combo_box.findText(device_entry)
|
||||
if index >= 0:
|
||||
self.signal_combo_box.setCurrentIndex(index)
|
||||
else:
|
||||
@@ -187,8 +187,8 @@ class DeviceSelectionConnection(BundleConnection):
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
self._connected = False
|
||||
self.register_property_sync("device", self._sync_from_device)
|
||||
self.register_property_sync("signal", self._sync_from_signal)
|
||||
self.register_property_sync("device_name", self._sync_from_device_name)
|
||||
self.register_property_sync("device_entry", self._sync_from_device_entry)
|
||||
self.register_property_sync("connection_status", self._sync_connection_status)
|
||||
self.register_property_sync("connection_error", self._sync_connection_status)
|
||||
|
||||
@@ -222,22 +222,26 @@ class DeviceSelectionConnection(BundleConnection):
|
||||
self._connected = False
|
||||
widget.cleanup()
|
||||
|
||||
def _sync_from_device(self, _):
|
||||
def _sync_from_device_name(self, _):
|
||||
try:
|
||||
widget = self._widget()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
widget.set_device_and_signal(self.target_widget.device, self.target_widget.signal)
|
||||
self.target_widget._sync_signal_from_toolbar()
|
||||
widget.set_device_and_signal(
|
||||
self.target_widget.device_name, self.target_widget.device_entry
|
||||
)
|
||||
self.target_widget._sync_device_entry_from_toolbar()
|
||||
|
||||
def _sync_from_signal(self, _):
|
||||
def _sync_from_device_entry(self, _):
|
||||
try:
|
||||
widget = self._widget()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
widget.set_device_and_signal(self.target_widget.device, self.target_widget.signal)
|
||||
widget.set_device_and_signal(
|
||||
self.target_widget.device_name, self.target_widget.device_entry
|
||||
)
|
||||
|
||||
def _sync_connection_status(self, _):
|
||||
try:
|
||||
|
||||
@@ -48,14 +48,14 @@ class FilledRectItem(pg.GraphicsObject):
|
||||
|
||||
|
||||
class MotorConfig(BaseModel):
|
||||
device: str | None = Field(None, description="Motor name.")
|
||||
name: str | None = Field(None, description="Motor name.")
|
||||
limits: list[float] | None = Field(None, description="Motor limits.")
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class MotorMapConfig(ConnectionConfig):
|
||||
device_x: MotorConfig = Field(default_factory=MotorConfig, description="Motor X name.")
|
||||
device_y: MotorConfig = Field(default_factory=MotorConfig, description="Motor Y name.")
|
||||
x_motor: MotorConfig = Field(default_factory=MotorConfig, description="Motor X name.")
|
||||
y_motor: MotorConfig = Field(default_factory=MotorConfig, description="Motor Y name.")
|
||||
color: str | tuple | None = Field(
|
||||
(255, 255, 255, 255), description="The color of the last point of current position."
|
||||
)
|
||||
@@ -109,10 +109,10 @@ class MotorMap(PlotBase):
|
||||
"map",
|
||||
"reset_history",
|
||||
"get_data",
|
||||
"device_x",
|
||||
"device_x.setter",
|
||||
"device_y",
|
||||
"device_y.setter",
|
||||
"x_motor",
|
||||
"x_motor.setter",
|
||||
"y_motor",
|
||||
"y_motor.setter",
|
||||
]
|
||||
|
||||
update_signal = Signal()
|
||||
@@ -208,7 +208,7 @@ class MotorMap(PlotBase):
|
||||
return
|
||||
|
||||
if motor_x != "" and motor_y != "":
|
||||
if motor_x != self.config.device_x.device or motor_y != self.config.device_y.device:
|
||||
if motor_x != self.config.x_motor.name or motor_y != self.config.y_motor.name:
|
||||
self.map(motor_x, motor_y)
|
||||
|
||||
def _add_motor_map_settings(self):
|
||||
@@ -259,32 +259,32 @@ class MotorMap(PlotBase):
|
||||
################################################################################
|
||||
|
||||
@SafeProperty(str)
|
||||
def device_x(self) -> str:
|
||||
def x_motor(self) -> str:
|
||||
"""Name of the motor shown on the X axis."""
|
||||
return self.config.device_x.device or ""
|
||||
return self.config.x_motor.name or ""
|
||||
|
||||
@device_x.setter
|
||||
def device_x(self, motor_name: str) -> None:
|
||||
@x_motor.setter
|
||||
def x_motor(self, motor_name: str) -> None:
|
||||
motor_name = motor_name or ""
|
||||
if motor_name == (self.config.device_x.device or ""):
|
||||
if motor_name == (self.config.x_motor.name or ""):
|
||||
return
|
||||
if motor_name and self.device_y:
|
||||
self.map(motor_name, self.device_y, suppress_errors=True)
|
||||
if motor_name and self.y_motor:
|
||||
self.map(motor_name, self.y_motor, suppress_errors=True)
|
||||
return
|
||||
self._set_motor_name(axis="x", motor_name=motor_name)
|
||||
|
||||
@SafeProperty(str)
|
||||
def device_y(self) -> str:
|
||||
def y_motor(self) -> str:
|
||||
"""Name of the motor shown on the Y axis."""
|
||||
return self.config.device_y.device or ""
|
||||
return self.config.y_motor.name or ""
|
||||
|
||||
@device_y.setter
|
||||
def device_y(self, motor_name: str) -> None:
|
||||
@y_motor.setter
|
||||
def y_motor(self, motor_name: str) -> None:
|
||||
motor_name = motor_name or ""
|
||||
if motor_name == (self.config.device_y.device or ""):
|
||||
if motor_name == (self.config.y_motor.name or ""):
|
||||
return
|
||||
if motor_name and self.device_x:
|
||||
self.map(self.device_x, motor_name, suppress_errors=True)
|
||||
if motor_name and self.x_motor:
|
||||
self.map(self.x_motor, motor_name, suppress_errors=True)
|
||||
return
|
||||
self._set_motor_name(axis="y", motor_name=motor_name)
|
||||
|
||||
@@ -452,13 +452,13 @@ class MotorMap(PlotBase):
|
||||
Update stored motor name for given axis and optionally refresh the toolbar selection.
|
||||
"""
|
||||
motor_name = motor_name or ""
|
||||
motor_config = self.config.device_x if axis == "x" else self.config.device_y
|
||||
motor_config = self.config.x_motor if axis == "x" else self.config.y_motor
|
||||
|
||||
if motor_config.device == motor_name:
|
||||
if motor_config.name == motor_name:
|
||||
return
|
||||
|
||||
motor_config.device = motor_name
|
||||
self.property_changed.emit(f"device_{axis}", motor_name)
|
||||
motor_config.name = motor_name
|
||||
self.property_changed.emit(f"{axis}_motor", motor_name)
|
||||
|
||||
if sync_toolbar:
|
||||
self._sync_motor_map_selection_toolbar()
|
||||
@@ -468,14 +468,14 @@ class MotorMap(PlotBase):
|
||||
################################################################################
|
||||
@SafeSlot()
|
||||
def map(
|
||||
self, device_x: str, device_y: str, validate_bec: bool = True, suppress_errors=False
|
||||
self, x_name: str, y_name: str, validate_bec: bool = True, suppress_errors=False
|
||||
) -> None:
|
||||
"""
|
||||
Set the x and y motor names.
|
||||
|
||||
Args:
|
||||
device_x(str): The name of the x motor.
|
||||
device_y(str): The name of the y motor.
|
||||
x_name(str): The name of the x motor.
|
||||
y_name(str): The name of the y motor.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. If the validation fails, the changes are not applied.
|
||||
"""
|
||||
@@ -484,22 +484,22 @@ class MotorMap(PlotBase):
|
||||
if validate_bec:
|
||||
if suppress_errors:
|
||||
try:
|
||||
self.entry_validator.validate_signal(device_x, None)
|
||||
self.entry_validator.validate_signal(device_y, None)
|
||||
self.entry_validator.validate_signal(x_name, None)
|
||||
self.entry_validator.validate_signal(y_name, None)
|
||||
except Exception:
|
||||
return
|
||||
else:
|
||||
self.entry_validator.validate_signal(device_x, None)
|
||||
self.entry_validator.validate_signal(device_y, None)
|
||||
self.entry_validator.validate_signal(x_name, None)
|
||||
self.entry_validator.validate_signal(y_name, None)
|
||||
|
||||
self._set_motor_name(axis="x", motor_name=device_x, sync_toolbar=False)
|
||||
self._set_motor_name(axis="y", motor_name=device_y, sync_toolbar=False)
|
||||
self._set_motor_name(axis="x", motor_name=x_name, sync_toolbar=False)
|
||||
self._set_motor_name(axis="y", motor_name=y_name, sync_toolbar=False)
|
||||
|
||||
motor_x_limit = self._get_motor_limit(self.config.device_x.device)
|
||||
motor_y_limit = self._get_motor_limit(self.config.device_y.device)
|
||||
motor_x_limit = self._get_motor_limit(self.config.x_motor.name)
|
||||
motor_y_limit = self._get_motor_limit(self.config.y_motor.name)
|
||||
|
||||
self.config.device_x.limits = motor_x_limit
|
||||
self.config.device_y.limits = motor_y_limit
|
||||
self.config.x_motor.limits = motor_x_limit
|
||||
self.config.y_motor.limits = motor_y_limit
|
||||
|
||||
# reconnect the signals
|
||||
self._connect_motor_to_slots()
|
||||
@@ -574,19 +574,19 @@ class MotorMap(PlotBase):
|
||||
msg(dict): Message from the device readback.
|
||||
metadata(dict): Metadata of the message.
|
||||
"""
|
||||
device_x = self.config.device_x.device
|
||||
device_y = self.config.device_y.device
|
||||
x_motor = self.config.x_motor.name
|
||||
y_motor = self.config.y_motor.name
|
||||
|
||||
if device_x is None or device_y is None:
|
||||
if x_motor is None or y_motor is None:
|
||||
return
|
||||
|
||||
if device_x in msg["signals"]:
|
||||
x = msg["signals"][device_x]["value"]
|
||||
if x_motor in msg["signals"]:
|
||||
x = msg["signals"][x_motor]["value"]
|
||||
self._buffer["x"].append(x)
|
||||
self._buffer["y"].append(self._buffer["y"][-1])
|
||||
|
||||
elif device_y in msg["signals"]:
|
||||
y = msg["signals"][device_y]["value"]
|
||||
elif y_motor in msg["signals"]:
|
||||
y = msg["signals"][y_motor]["value"]
|
||||
self._buffer["y"].append(y)
|
||||
self._buffer["x"].append(self._buffer["x"][-1])
|
||||
|
||||
@@ -597,12 +597,12 @@ class MotorMap(PlotBase):
|
||||
self._disconnect_current_motors()
|
||||
|
||||
endpoints_readback = [
|
||||
MessageEndpoints.device_readback(self.config.device_x.device),
|
||||
MessageEndpoints.device_readback(self.config.device_y.device),
|
||||
MessageEndpoints.device_readback(self.config.x_motor.name),
|
||||
MessageEndpoints.device_readback(self.config.y_motor.name),
|
||||
]
|
||||
endpoints_limits = [
|
||||
MessageEndpoints.device_limits(self.config.device_x.device),
|
||||
MessageEndpoints.device_limits(self.config.device_y.device),
|
||||
MessageEndpoints.device_limits(self.config.x_motor.name),
|
||||
MessageEndpoints.device_limits(self.config.y_motor.name),
|
||||
]
|
||||
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoints_readback)
|
||||
@@ -610,14 +610,14 @@ class MotorMap(PlotBase):
|
||||
|
||||
def _disconnect_current_motors(self):
|
||||
"""Disconnect the current motors from the slots."""
|
||||
if self.config.device_x.device is not None and self.config.device_y.device is not None:
|
||||
if self.config.x_motor.name is not None and self.config.y_motor.name is not None:
|
||||
endpoints_readback = [
|
||||
MessageEndpoints.device_readback(self.config.device_x.device),
|
||||
MessageEndpoints.device_readback(self.config.device_y.device),
|
||||
MessageEndpoints.device_readback(self.config.x_motor.name),
|
||||
MessageEndpoints.device_readback(self.config.y_motor.name),
|
||||
]
|
||||
endpoints_limits = [
|
||||
MessageEndpoints.device_limits(self.config.device_x.device),
|
||||
MessageEndpoints.device_limits(self.config.device_y.device),
|
||||
MessageEndpoints.device_limits(self.config.x_motor.name),
|
||||
MessageEndpoints.device_limits(self.config.y_motor.name),
|
||||
]
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_readback, endpoints_readback)
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_limits, endpoints_limits)
|
||||
@@ -634,8 +634,8 @@ class MotorMap(PlotBase):
|
||||
msg(dict): Message from the device limits.
|
||||
metadata(dict): Metadata of the message.
|
||||
"""
|
||||
self.config.device_x.limits = self._get_motor_limit(self.config.device_x.device)
|
||||
self.config.device_y.limits = self._get_motor_limit(self.config.device_y.device)
|
||||
self.config.x_motor.limits = self._get_motor_limit(self.config.x_motor.name)
|
||||
self.config.y_motor.limits = self._get_motor_limit(self.config.y_motor.name)
|
||||
self._swap_limit_map()
|
||||
|
||||
def _get_motor_limit(self, motor: str) -> list | None:
|
||||
@@ -663,8 +663,8 @@ class MotorMap(PlotBase):
|
||||
Make the motor map.
|
||||
"""
|
||||
|
||||
motor_x_limit = self.config.device_x.limits
|
||||
motor_y_limit = self.config.device_y.limits
|
||||
motor_x_limit = self.config.x_motor.limits
|
||||
motor_y_limit = self.config.y_motor.limits
|
||||
|
||||
self._limit_map = self._make_limit_map(motor_x_limit, motor_y_limit)
|
||||
self.plot_item.addItem(self._limit_map)
|
||||
@@ -678,10 +678,10 @@ class MotorMap(PlotBase):
|
||||
|
||||
# Add the crosshair for initial motor coordinates
|
||||
initial_position_x = self._get_motor_init_position(
|
||||
self.config.device_x.device, self.config.precision
|
||||
self.config.x_motor.name, self.config.precision
|
||||
)
|
||||
initial_position_y = self._get_motor_init_position(
|
||||
self.config.device_y.device, self.config.precision
|
||||
self.config.y_motor.name, self.config.precision
|
||||
)
|
||||
|
||||
self._buffer["x"] = [initial_position_x]
|
||||
@@ -693,8 +693,8 @@ class MotorMap(PlotBase):
|
||||
self._add_coordinates_crosshair(initial_position_x, initial_position_y)
|
||||
|
||||
# Set default labels for the plot
|
||||
self.set_x_label_suffix(f"[{self.config.device_x.device}-{self.config.device_x.device}]")
|
||||
self.set_y_label_suffix(f"[{self.config.device_y.device}-{self.config.device_y.device}]")
|
||||
self.set_x_label_suffix(f"[{self.config.x_motor.name}-{self.config.x_motor.name}]")
|
||||
self.set_y_label_suffix(f"[{self.config.y_motor.name}-{self.config.y_motor.name}]")
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
@@ -794,8 +794,8 @@ class MotorMap(PlotBase):
|
||||
def _swap_limit_map(self):
|
||||
"""Swap the limit map."""
|
||||
self.plot_item.removeItem(self._limit_map)
|
||||
x_limits = self.config.device_x.limits
|
||||
y_limits = self.config.device_y.limits
|
||||
x_limits = self.config.x_motor.limits
|
||||
y_limits = self.config.y_motor.limits
|
||||
if x_limits is not None and y_limits is not None:
|
||||
self._limit_map = self._make_limit_map(x_limits, y_limits)
|
||||
self._limit_map.setZValue(-1)
|
||||
@@ -828,8 +828,8 @@ class MotorMap(PlotBase):
|
||||
if motor_selection_action is None:
|
||||
return
|
||||
motor_selection: MotorSelection = motor_selection_action.widget
|
||||
target_x = self.config.device_x.device or ""
|
||||
target_y = self.config.device_y.device or ""
|
||||
target_x = self.config.x_motor.name or ""
|
||||
target_y = self.config.y_motor.name or ""
|
||||
|
||||
if (
|
||||
motor_selection.motor_x.currentText() == target_x
|
||||
@@ -864,10 +864,10 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
self.setCentralWidget(self.main_widget)
|
||||
|
||||
self.motor_map_popup = MotorMap(popups=True)
|
||||
self.motor_map_popup.map(device_x="samx", device_y="samy", validate_bec=True)
|
||||
self.motor_map_popup.map(x_name="samx", y_name="samy", validate_bec=True)
|
||||
|
||||
self.motor_map_side = MotorMap(popups=False)
|
||||
self.motor_map_side.map(device_x="samx", device_y="samy", validate_bec=True)
|
||||
self.motor_map_side.map(x_name="samx", y_name="samy", validate_bec=True)
|
||||
|
||||
self.layout.addWidget(self.motor_map_side)
|
||||
self.layout.addWidget(self.motor_map_popup)
|
||||
|
||||
@@ -20,8 +20,8 @@ logger = bec_logger.logger
|
||||
class ScatterDeviceSignal(BaseModel):
|
||||
"""The configuration of a signal in the scatter waveform widget."""
|
||||
|
||||
device: str
|
||||
signal: str
|
||||
name: str
|
||||
entry: str
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
@@ -40,13 +40,13 @@ class ScatterCurveConfig(ConnectionConfig):
|
||||
color_map: str | None = Field(
|
||||
"plasma", description="The color palette of the figure widget.", validate_default=True
|
||||
)
|
||||
device_x: ScatterDeviceSignal | None = Field(
|
||||
x_device: ScatterDeviceSignal | None = Field(
|
||||
None, description="The x device signal of the scatter waveform."
|
||||
)
|
||||
device_y: ScatterDeviceSignal | None = Field(
|
||||
y_device: ScatterDeviceSignal | None = Field(
|
||||
None, description="The y device signal of the scatter waveform."
|
||||
)
|
||||
device_z: ScatterDeviceSignal | None = Field(
|
||||
z_device: ScatterDeviceSignal | None = Field(
|
||||
None, description="The z device signal of the scatter waveform."
|
||||
)
|
||||
|
||||
|
||||
@@ -49,18 +49,18 @@ class ScatterWaveform(PlotBase):
|
||||
"update_with_scan_history",
|
||||
"clear_all",
|
||||
# Device properties
|
||||
"device_x",
|
||||
"device_x.setter",
|
||||
"signal_x",
|
||||
"signal_x.setter",
|
||||
"device_y",
|
||||
"device_y.setter",
|
||||
"signal_y",
|
||||
"signal_y.setter",
|
||||
"device_z",
|
||||
"device_z.setter",
|
||||
"signal_z",
|
||||
"signal_z.setter",
|
||||
"x_device_name",
|
||||
"x_device_name.setter",
|
||||
"x_device_entry",
|
||||
"x_device_entry.setter",
|
||||
"y_device_name",
|
||||
"y_device_name.setter",
|
||||
"y_device_entry",
|
||||
"y_device_entry.setter",
|
||||
"z_device_name",
|
||||
"z_device_name.setter",
|
||||
"z_device_entry",
|
||||
"z_device_entry.setter",
|
||||
]
|
||||
|
||||
sync_signal_update = Signal()
|
||||
@@ -208,12 +208,12 @@ class ScatterWaveform(PlotBase):
|
||||
@SafeSlot(popup_error=True)
|
||||
def plot(
|
||||
self,
|
||||
device_x: str,
|
||||
device_y: str,
|
||||
device_z: str,
|
||||
signal_x: None | str = None,
|
||||
signal_y: None | str = None,
|
||||
signal_z: None | str = None,
|
||||
x_name: str,
|
||||
y_name: str,
|
||||
z_name: str,
|
||||
x_entry: None | str = None,
|
||||
y_entry: None | str = None,
|
||||
z_entry: None | str = None,
|
||||
color_map: str | None = "plasma",
|
||||
label: str | None = None,
|
||||
validate_bec: bool = True,
|
||||
@@ -222,12 +222,12 @@ class ScatterWaveform(PlotBase):
|
||||
Plot the data from the device signals.
|
||||
|
||||
Args:
|
||||
device_x (str): The name of the x device signal.
|
||||
device_y (str): The name of the y device signal.
|
||||
device_z (str): The name of the z device signal.
|
||||
signal_x (None | str): The x entry of the device signal.
|
||||
signal_y (None | str): The y entry of the device signal.
|
||||
signal_z (None | str): The z entry of the device signal.
|
||||
x_name (str): The name of the x device signal.
|
||||
y_name (str): The name of the y device signal.
|
||||
z_name (str): The name of the z device signal.
|
||||
x_entry (None | str): The x entry of the device signal.
|
||||
y_entry (None | str): The y entry of the device signal.
|
||||
z_entry (None | str): The z entry of the device signal.
|
||||
color_map (str | None): The color map of the scatter waveform.
|
||||
label (str | None): The label of the curve.
|
||||
validate_bec (bool): Whether to validate the device signals with current BEC instance.
|
||||
@@ -237,9 +237,9 @@ class ScatterWaveform(PlotBase):
|
||||
"""
|
||||
|
||||
if validate_bec:
|
||||
signal_x = self.entry_validator.validate_signal(device_x, signal_x)
|
||||
signal_y = self.entry_validator.validate_signal(device_y, signal_y)
|
||||
signal_z = self.entry_validator.validate_signal(device_z, signal_z)
|
||||
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
|
||||
y_entry = self.entry_validator.validate_signal(y_name, y_entry)
|
||||
z_entry = self.entry_validator.validate_signal(z_name, z_entry)
|
||||
|
||||
if color_map is not None:
|
||||
try:
|
||||
@@ -250,15 +250,15 @@ class ScatterWaveform(PlotBase):
|
||||
)
|
||||
|
||||
if label is None:
|
||||
label = f"{device_z}-{signal_z}"
|
||||
label = f"{z_name}-{z_entry}"
|
||||
|
||||
config = ScatterCurveConfig(
|
||||
parent_id=self.gui_id,
|
||||
label=label,
|
||||
color_map=color_map,
|
||||
device_x=ScatterDeviceSignal(device=device_x, signal=signal_x),
|
||||
device_y=ScatterDeviceSignal(device=device_y, signal=signal_y),
|
||||
device_z=ScatterDeviceSignal(device=device_z, signal=signal_z),
|
||||
x_device=ScatterDeviceSignal(name=x_name, entry=x_entry),
|
||||
y_device=ScatterDeviceSignal(name=y_name, entry=y_entry),
|
||||
z_device=ScatterDeviceSignal(name=z_name, entry=z_entry),
|
||||
)
|
||||
|
||||
# Add Curve
|
||||
@@ -350,23 +350,23 @@ class ScatterWaveform(PlotBase):
|
||||
return "none"
|
||||
|
||||
try:
|
||||
device_x = self._main_curve.config.device_x.device
|
||||
signal_x = self._main_curve.config.device_x.signal
|
||||
device_y = self._main_curve.config.device_y.device
|
||||
signal_y = self._main_curve.config.device_y.signal
|
||||
device_z = self._main_curve.config.device_z.device
|
||||
signal_z = self._main_curve.config.device_z.signal
|
||||
x_name = self._main_curve.config.x_device.name
|
||||
x_entry = self._main_curve.config.x_device.entry
|
||||
y_name = self._main_curve.config.y_device.name
|
||||
y_entry = self._main_curve.config.y_device.entry
|
||||
z_name = self._main_curve.config.z_device.name
|
||||
z_entry = self._main_curve.config.z_device.entry
|
||||
except AttributeError:
|
||||
return
|
||||
|
||||
if access_key == "val":
|
||||
x_data = data.get(device_x, {}).get(signal_x, {}).get(access_key, None)
|
||||
y_data = data.get(device_y, {}).get(signal_y, {}).get(access_key, None)
|
||||
z_data = data.get(device_z, {}).get(signal_z, {}).get(access_key, None)
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
|
||||
y_data = data.get(y_name, {}).get(y_entry, {}).get(access_key, None)
|
||||
z_data = data.get(z_name, {}).get(z_entry, {}).get(access_key, None)
|
||||
else:
|
||||
x_data = data.get(device_x, {}).get(signal_x, {}).read().get("value", None)
|
||||
y_data = data.get(device_y, {}).get(signal_y, {}).read().get("value", None)
|
||||
z_data = data.get(device_z, {}).get(signal_z, {}).read().get("value", None)
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
|
||||
y_data = data.get(y_name, {}).get(y_entry, {}).read().get("value", None)
|
||||
z_data = data.get(z_name, {}).get(z_entry, {}).read().get("value", None)
|
||||
|
||||
self._main_curve.set_data(x=x_data, y=y_data, z=z_data)
|
||||
|
||||
@@ -399,14 +399,14 @@ class ScatterWaveform(PlotBase):
|
||||
################################################################################
|
||||
|
||||
@SafeProperty(str)
|
||||
def device_x(self) -> str:
|
||||
def x_device_name(self) -> str:
|
||||
"""Device name for the X axis."""
|
||||
if self._main_curve is None or self._main_curve.config.device_x is None:
|
||||
if self._main_curve is None or self._main_curve.config.x_device is None:
|
||||
return ""
|
||||
return self._main_curve.config.device_x.device or ""
|
||||
return self._main_curve.config.x_device.name or ""
|
||||
|
||||
@device_x.setter
|
||||
def device_x(self, device_name: str) -> None:
|
||||
@x_device_name.setter
|
||||
def x_device_name(self, device_name: str) -> None:
|
||||
"""
|
||||
Set the X device name.
|
||||
|
||||
@@ -419,33 +419,33 @@ class ScatterWaveform(PlotBase):
|
||||
try:
|
||||
entry = self.entry_validator.validate_signal(device_name, None)
|
||||
# Update or create config
|
||||
if self._main_curve.config.device_x is None:
|
||||
self._main_curve.config.device_x = ScatterDeviceSignal(
|
||||
device=device_name, signal=entry
|
||||
if self._main_curve.config.x_device is None:
|
||||
self._main_curve.config.x_device = ScatterDeviceSignal(
|
||||
name=device_name, entry=entry
|
||||
)
|
||||
else:
|
||||
self._main_curve.config.device_x.device = device_name
|
||||
self._main_curve.config.device_x.signal = entry
|
||||
self.property_changed.emit("device_x", device_name)
|
||||
self._main_curve.config.x_device.name = device_name
|
||||
self._main_curve.config.x_device.entry = entry
|
||||
self.property_changed.emit("x_device_name", device_name)
|
||||
self.update_labels()
|
||||
self._try_auto_plot()
|
||||
except Exception:
|
||||
pass # Silently fail if device is not available yet
|
||||
else:
|
||||
if self._main_curve.config.device_x is not None:
|
||||
self._main_curve.config.device_x = None
|
||||
self.property_changed.emit("device_x", "")
|
||||
if self._main_curve.config.x_device is not None:
|
||||
self._main_curve.config.x_device = None
|
||||
self.property_changed.emit("x_device_name", "")
|
||||
self.update_labels()
|
||||
|
||||
@SafeProperty(str)
|
||||
def signal_x(self) -> str:
|
||||
def x_device_entry(self) -> str:
|
||||
"""Signal entry for the X axis device."""
|
||||
if self._main_curve is None or self._main_curve.config.device_x is None:
|
||||
if self._main_curve is None or self._main_curve.config.x_device is None:
|
||||
return ""
|
||||
return self._main_curve.config.device_x.signal or ""
|
||||
return self._main_curve.config.x_device.entry or ""
|
||||
|
||||
@signal_x.setter
|
||||
def signal_x(self, entry: str) -> None:
|
||||
@x_device_entry.setter
|
||||
def x_device_entry(self, entry: str) -> None:
|
||||
"""
|
||||
Set the X device entry.
|
||||
|
||||
@@ -455,29 +455,29 @@ class ScatterWaveform(PlotBase):
|
||||
if not entry:
|
||||
return
|
||||
|
||||
if self._main_curve.config.device_x is None:
|
||||
logger.warning("Cannot set signal_x without device_x set first.")
|
||||
if self._main_curve.config.x_device is None:
|
||||
logger.warning("Cannot set x_device_entry without x_device_name set first.")
|
||||
return
|
||||
|
||||
device_name = self._main_curve.config.device_x.device
|
||||
device_name = self._main_curve.config.x_device.name
|
||||
try:
|
||||
validated_signal = self.entry_validator.validate_signal(device_name, entry)
|
||||
self._main_curve.config.device_x.signal = validated_signal
|
||||
self.property_changed.emit("signal_x", validated_signal)
|
||||
validated_entry = self.entry_validator.validate_signal(device_name, entry)
|
||||
self._main_curve.config.x_device.entry = validated_entry
|
||||
self.property_changed.emit("x_device_entry", validated_entry)
|
||||
self.update_labels()
|
||||
self._try_auto_plot()
|
||||
except Exception:
|
||||
pass # Silently fail if validation fails
|
||||
|
||||
@SafeProperty(str)
|
||||
def device_y(self) -> str:
|
||||
def y_device_name(self) -> str:
|
||||
"""Device name for the Y axis."""
|
||||
if self._main_curve is None or self._main_curve.config.device_y is None:
|
||||
if self._main_curve is None or self._main_curve.config.y_device is None:
|
||||
return ""
|
||||
return self._main_curve.config.device_y.device or ""
|
||||
return self._main_curve.config.y_device.name or ""
|
||||
|
||||
@device_y.setter
|
||||
def device_y(self, device_name: str) -> None:
|
||||
@y_device_name.setter
|
||||
def y_device_name(self, device_name: str) -> None:
|
||||
"""
|
||||
Set the Y device name.
|
||||
|
||||
@@ -490,33 +490,33 @@ class ScatterWaveform(PlotBase):
|
||||
try:
|
||||
entry = self.entry_validator.validate_signal(device_name, None)
|
||||
# Update or create config
|
||||
if self._main_curve.config.device_y is None:
|
||||
self._main_curve.config.device_y = ScatterDeviceSignal(
|
||||
device=device_name, signal=entry
|
||||
if self._main_curve.config.y_device is None:
|
||||
self._main_curve.config.y_device = ScatterDeviceSignal(
|
||||
name=device_name, entry=entry
|
||||
)
|
||||
else:
|
||||
self._main_curve.config.device_y.device = device_name
|
||||
self._main_curve.config.device_y.signal = entry
|
||||
self.property_changed.emit("device_y", device_name)
|
||||
self._main_curve.config.y_device.name = device_name
|
||||
self._main_curve.config.y_device.entry = entry
|
||||
self.property_changed.emit("y_device_name", device_name)
|
||||
self.update_labels()
|
||||
self._try_auto_plot()
|
||||
except Exception:
|
||||
pass # Silently fail if device is not available yet
|
||||
else:
|
||||
if self._main_curve.config.device_y is not None:
|
||||
self._main_curve.config.device_y = None
|
||||
self.property_changed.emit("device_y", "")
|
||||
if self._main_curve.config.y_device is not None:
|
||||
self._main_curve.config.y_device = None
|
||||
self.property_changed.emit("y_device_name", "")
|
||||
self.update_labels()
|
||||
|
||||
@SafeProperty(str)
|
||||
def signal_y(self) -> str:
|
||||
def y_device_entry(self) -> str:
|
||||
"""Signal entry for the Y axis device."""
|
||||
if self._main_curve is None or self._main_curve.config.device_y is None:
|
||||
if self._main_curve is None or self._main_curve.config.y_device is None:
|
||||
return ""
|
||||
return self._main_curve.config.device_y.signal or ""
|
||||
return self._main_curve.config.y_device.entry or ""
|
||||
|
||||
@signal_y.setter
|
||||
def signal_y(self, entry: str) -> None:
|
||||
@y_device_entry.setter
|
||||
def y_device_entry(self, entry: str) -> None:
|
||||
"""
|
||||
Set the Y device entry.
|
||||
|
||||
@@ -526,29 +526,29 @@ class ScatterWaveform(PlotBase):
|
||||
if not entry:
|
||||
return
|
||||
|
||||
if self._main_curve.config.device_y is None:
|
||||
logger.warning("Cannot set signal_y without device_y set first.")
|
||||
if self._main_curve.config.y_device is None:
|
||||
logger.warning("Cannot set y_device_entry without y_device_name set first.")
|
||||
return
|
||||
|
||||
device_name = self._main_curve.config.device_y.device
|
||||
device_name = self._main_curve.config.y_device.name
|
||||
try:
|
||||
validated_signal = self.entry_validator.validate_signal(device_name, entry)
|
||||
self._main_curve.config.device_y.signal = validated_signal
|
||||
self.property_changed.emit("signal_y", validated_signal)
|
||||
validated_entry = self.entry_validator.validate_signal(device_name, entry)
|
||||
self._main_curve.config.y_device.entry = validated_entry
|
||||
self.property_changed.emit("y_device_entry", validated_entry)
|
||||
self.update_labels()
|
||||
self._try_auto_plot()
|
||||
except Exception:
|
||||
pass # Silently fail if validation fails
|
||||
|
||||
@SafeProperty(str)
|
||||
def device_z(self) -> str:
|
||||
def z_device_name(self) -> str:
|
||||
"""Device name for the Z (color) axis."""
|
||||
if self._main_curve is None or self._main_curve.config.device_z is None:
|
||||
if self._main_curve is None or self._main_curve.config.z_device is None:
|
||||
return ""
|
||||
return self._main_curve.config.device_z.device or ""
|
||||
return self._main_curve.config.z_device.name or ""
|
||||
|
||||
@device_z.setter
|
||||
def device_z(self, device_name: str) -> None:
|
||||
@z_device_name.setter
|
||||
def z_device_name(self, device_name: str) -> None:
|
||||
"""
|
||||
Set the Z device name.
|
||||
|
||||
@@ -561,33 +561,33 @@ class ScatterWaveform(PlotBase):
|
||||
try:
|
||||
entry = self.entry_validator.validate_signal(device_name, None)
|
||||
# Update or create config
|
||||
if self._main_curve.config.device_z is None:
|
||||
self._main_curve.config.device_z = ScatterDeviceSignal(
|
||||
device=device_name, signal=entry
|
||||
if self._main_curve.config.z_device is None:
|
||||
self._main_curve.config.z_device = ScatterDeviceSignal(
|
||||
name=device_name, entry=entry
|
||||
)
|
||||
else:
|
||||
self._main_curve.config.device_z.device = device_name
|
||||
self._main_curve.config.device_z.signal = entry
|
||||
self.property_changed.emit("device_z", device_name)
|
||||
self._main_curve.config.z_device.name = device_name
|
||||
self._main_curve.config.z_device.entry = entry
|
||||
self.property_changed.emit("z_device_name", device_name)
|
||||
self.update_labels()
|
||||
self._try_auto_plot()
|
||||
except Exception:
|
||||
pass # Silently fail if device is not available yet
|
||||
else:
|
||||
if self._main_curve.config.device_z is not None:
|
||||
self._main_curve.config.device_z = None
|
||||
self.property_changed.emit("device_z", "")
|
||||
if self._main_curve.config.z_device is not None:
|
||||
self._main_curve.config.z_device = None
|
||||
self.property_changed.emit("z_device_name", "")
|
||||
self.update_labels()
|
||||
|
||||
@SafeProperty(str)
|
||||
def signal_z(self) -> str:
|
||||
def z_device_entry(self) -> str:
|
||||
"""Signal entry for the Z (color) axis device."""
|
||||
if self._main_curve is None or self._main_curve.config.device_z is None:
|
||||
if self._main_curve is None or self._main_curve.config.z_device is None:
|
||||
return ""
|
||||
return self._main_curve.config.device_z.signal or ""
|
||||
return self._main_curve.config.z_device.entry or ""
|
||||
|
||||
@signal_z.setter
|
||||
def signal_z(self, entry: str) -> None:
|
||||
@z_device_entry.setter
|
||||
def z_device_entry(self, entry: str) -> None:
|
||||
"""
|
||||
Set the Z device entry.
|
||||
|
||||
@@ -597,15 +597,15 @@ class ScatterWaveform(PlotBase):
|
||||
if not entry:
|
||||
return
|
||||
|
||||
if self._main_curve.config.device_z is None:
|
||||
logger.warning("Cannot set signal_z without device_z set first.")
|
||||
if self._main_curve.config.z_device is None:
|
||||
logger.warning("Cannot set z_device_entry without z_device_name set first.")
|
||||
return
|
||||
|
||||
device_name = self._main_curve.config.device_z.device
|
||||
device_name = self._main_curve.config.z_device.name
|
||||
try:
|
||||
validated_signal = self.entry_validator.validate_signal(device_name, entry)
|
||||
self._main_curve.config.device_z.signal = validated_signal
|
||||
self.property_changed.emit("signal_z", validated_signal)
|
||||
validated_entry = self.entry_validator.validate_signal(device_name, entry)
|
||||
self._main_curve.config.z_device.entry = validated_entry
|
||||
self.property_changed.emit("z_device_entry", validated_entry)
|
||||
self.update_labels()
|
||||
self._try_auto_plot()
|
||||
except Exception:
|
||||
@@ -615,25 +615,25 @@ class ScatterWaveform(PlotBase):
|
||||
"""
|
||||
Attempt to automatically call plot() if all three devices are set.
|
||||
"""
|
||||
has_x = self._main_curve.config.device_x is not None
|
||||
has_y = self._main_curve.config.device_y is not None
|
||||
has_z = self._main_curve.config.device_z is not None
|
||||
has_x = self._main_curve.config.x_device is not None
|
||||
has_y = self._main_curve.config.y_device is not None
|
||||
has_z = self._main_curve.config.z_device is not None
|
||||
|
||||
if has_x and has_y and has_z:
|
||||
device_x = self._main_curve.config.device_x.device
|
||||
signal_x = self._main_curve.config.device_x.signal
|
||||
device_y = self._main_curve.config.device_y.device
|
||||
signal_y = self._main_curve.config.device_y.signal
|
||||
device_z = self._main_curve.config.device_z.device
|
||||
signal_z = self._main_curve.config.device_z.signal
|
||||
x_name = self._main_curve.config.x_device.name
|
||||
x_entry = self._main_curve.config.x_device.entry
|
||||
y_name = self._main_curve.config.y_device.name
|
||||
y_entry = self._main_curve.config.y_device.entry
|
||||
z_name = self._main_curve.config.z_device.name
|
||||
z_entry = self._main_curve.config.z_device.entry
|
||||
try:
|
||||
self.plot(
|
||||
device_x=device_x,
|
||||
device_y=device_y,
|
||||
device_z=device_z,
|
||||
signal_x=signal_x,
|
||||
signal_y=signal_y,
|
||||
signal_z=signal_z,
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
validate_bec=False, # Don't validate - entries already validated
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -650,21 +650,21 @@ class ScatterWaveform(PlotBase):
|
||||
config = self._main_curve.config
|
||||
|
||||
# Safely get device names
|
||||
device_x = config.device_x
|
||||
device_y = config.device_y
|
||||
x_device = config.x_device
|
||||
y_device = config.y_device
|
||||
|
||||
device_x = device_x.device if device_x else None
|
||||
device_y = device_y.device if device_y else None
|
||||
x_name = x_device.name if x_device else None
|
||||
y_name = y_device.name if y_device else None
|
||||
|
||||
if device_x is not None:
|
||||
self.x_label = device_x # type: ignore
|
||||
x_dev = self.dev.get(device_x)
|
||||
if x_name is not None:
|
||||
self.x_label = x_name # type: ignore
|
||||
x_dev = self.dev.get(x_name)
|
||||
if x_dev and hasattr(x_dev, "egu"):
|
||||
self.x_label_units = x_dev.egu()
|
||||
|
||||
if device_y is not None:
|
||||
self.y_label = device_y # type: ignore
|
||||
y_dev = self.dev.get(device_y)
|
||||
if y_name is not None:
|
||||
self.y_label = y_name # type: ignore
|
||||
y_dev = self.dev.get(y_name)
|
||||
if y_dev and hasattr(y_dev, "egu"):
|
||||
self.y_label_units = y_dev.egu()
|
||||
|
||||
@@ -756,7 +756,7 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
self.setCentralWidget(self.main_widget)
|
||||
|
||||
self.waveform_popup = ScatterWaveform(popups=True)
|
||||
self.waveform_popup.plot(device_x="samx", device_y="samy", device_z="bpm4i")
|
||||
self.waveform_popup.plot("samx", "samy", "bpm4i")
|
||||
|
||||
self.waveform_side = ScatterWaveform(popups=False)
|
||||
self.waveform_popup.plot("samx", "samy", "bpm3a")
|
||||
|
||||
@@ -58,81 +58,81 @@ class ScatterCurveSettings(SettingWidget):
|
||||
color_map = getattr(self.target_widget, "color_map", None)
|
||||
|
||||
# Default values for device properties
|
||||
device_x, signal_x = None, None
|
||||
device_y, signal_y = None, None
|
||||
device_z, signal_z = None, None
|
||||
x_name, x_entry = None, None
|
||||
y_name, y_entry = None, None
|
||||
z_name, z_entry = None, None
|
||||
|
||||
# Safely access device properties
|
||||
if hasattr(self.target_widget, "main_curve") and self.target_widget.main_curve:
|
||||
if hasattr(self.target_widget.main_curve, "config"):
|
||||
config = self.target_widget.main_curve.config
|
||||
|
||||
if hasattr(config, "device_x") and config.device_x:
|
||||
device_x = getattr(config.device_x, "device", None)
|
||||
signal_x = getattr(config.device_x, "signal", None)
|
||||
if hasattr(config, "x_device") and config.x_device:
|
||||
x_name = getattr(config.x_device, "name", None)
|
||||
x_entry = getattr(config.x_device, "entry", None)
|
||||
|
||||
if hasattr(config, "device_y") and config.device_y:
|
||||
device_y = getattr(config.device_y, "device", None)
|
||||
signal_y = getattr(config.device_y, "signal", None)
|
||||
if hasattr(config, "y_device") and config.y_device:
|
||||
y_name = getattr(config.y_device, "name", None)
|
||||
y_entry = getattr(config.y_device, "entry", None)
|
||||
|
||||
if hasattr(config, "device_z") and config.device_z:
|
||||
device_z = getattr(config.device_z, "device", None)
|
||||
signal_z = getattr(config.device_z, "signal", None)
|
||||
if hasattr(config, "z_device") and config.z_device:
|
||||
z_name = getattr(config.z_device, "name", None)
|
||||
z_entry = getattr(config.z_device, "entry", None)
|
||||
|
||||
# Apply the properties to the settings widget
|
||||
if hasattr(self.ui, "color_map"):
|
||||
self.ui.color_map.colormap = color_map
|
||||
|
||||
if hasattr(self.ui, "device_x"):
|
||||
self.ui.device_x.set_device(device_x)
|
||||
if hasattr(self.ui, "signal_x") and signal_x is not None:
|
||||
self.ui.signal_x.set_to_obj_name(signal_x)
|
||||
if hasattr(self.ui, "x_name"):
|
||||
self.ui.x_name.set_device(x_name)
|
||||
if hasattr(self.ui, "x_entry") and x_entry is not None:
|
||||
self.ui.x_entry.set_to_obj_name(x_entry)
|
||||
|
||||
if hasattr(self.ui, "device_y"):
|
||||
self.ui.device_y.set_device(device_y)
|
||||
if hasattr(self.ui, "signal_y") and signal_y is not None:
|
||||
self.ui.signal_y.set_to_obj_name(signal_y)
|
||||
if hasattr(self.ui, "y_name"):
|
||||
self.ui.y_name.set_device(y_name)
|
||||
if hasattr(self.ui, "y_entry") and y_entry is not None:
|
||||
self.ui.y_entry.set_to_obj_name(y_entry)
|
||||
|
||||
if hasattr(self.ui, "device_z"):
|
||||
self.ui.device_z.set_device(device_z)
|
||||
if hasattr(self.ui, "signal_z") and signal_z is not None:
|
||||
self.ui.signal_z.set_to_obj_name(signal_z)
|
||||
if hasattr(self.ui, "z_name"):
|
||||
self.ui.z_name.set_device(z_name)
|
||||
if hasattr(self.ui, "z_entry") and z_entry is not None:
|
||||
self.ui.z_entry.set_to_obj_name(z_entry)
|
||||
|
||||
@SafeSlot()
|
||||
def accept_changes(self):
|
||||
"""
|
||||
Apply all properties from the settings widget to the target widget.
|
||||
"""
|
||||
device_x = self.ui.device_x.currentText()
|
||||
signal_x = self.ui.signal_x.get_signal_name()
|
||||
device_y = self.ui.device_y.currentText()
|
||||
signal_y = self.ui.signal_y.get_signal_name()
|
||||
device_z = self.ui.device_z.currentText()
|
||||
signal_z = self.ui.signal_z.get_signal_name()
|
||||
x_name = self.ui.x_name.currentText()
|
||||
x_entry = self.ui.x_entry.get_signal_name()
|
||||
y_name = self.ui.y_name.currentText()
|
||||
y_entry = self.ui.y_entry.get_signal_name()
|
||||
z_name = self.ui.z_name.currentText()
|
||||
z_entry = self.ui.z_entry.get_signal_name()
|
||||
validate_bec = self.ui.validate_bec.checked
|
||||
color_map = self.ui.color_map.colormap
|
||||
|
||||
self.target_widget.plot(
|
||||
device_x=device_x,
|
||||
device_y=device_y,
|
||||
device_z=device_z,
|
||||
signal_x=signal_x,
|
||||
signal_y=signal_y,
|
||||
signal_z=signal_z,
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
color_map=color_map,
|
||||
validate_bec=validate_bec,
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
self.ui.device_x.close()
|
||||
self.ui.device_x.deleteLater()
|
||||
self.ui.signal_x.close()
|
||||
self.ui.signal_x.deleteLater()
|
||||
self.ui.device_y.close()
|
||||
self.ui.device_y.deleteLater()
|
||||
self.ui.signal_y.close()
|
||||
self.ui.signal_y.deleteLater()
|
||||
self.ui.device_z.close()
|
||||
self.ui.device_z.deleteLater()
|
||||
self.ui.signal_z.close()
|
||||
self.ui.signal_z.deleteLater()
|
||||
self.ui.x_name.close()
|
||||
self.ui.x_name.deleteLater()
|
||||
self.ui.x_entry.close()
|
||||
self.ui.x_entry.deleteLater()
|
||||
self.ui.y_name.close()
|
||||
self.ui.y_name.deleteLater()
|
||||
self.ui.y_entry.close()
|
||||
self.ui.y_entry.deleteLater()
|
||||
self.ui.z_name.close()
|
||||
self.ui.z_name.deleteLater()
|
||||
self.ui.z_entry.close()
|
||||
self.ui.z_entry.deleteLater()
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="device_x">
|
||||
<widget class="DeviceComboBox" name="x_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -71,7 +71,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="signal_x">
|
||||
<widget class="SignalComboBox" name="x_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -101,7 +101,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="device_y">
|
||||
<widget class="DeviceComboBox" name="y_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -111,7 +111,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="signal_y">
|
||||
<widget class="SignalComboBox" name="y_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -141,7 +141,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="device_z">
|
||||
<widget class="DeviceComboBox" name="z_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -151,7 +151,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="signal_z">
|
||||
<widget class="SignalComboBox" name="z_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
@@ -187,19 +187,19 @@
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<tabstops>
|
||||
<tabstop>device_x</tabstop>
|
||||
<tabstop>device_y</tabstop>
|
||||
<tabstop>device_z</tabstop>
|
||||
<tabstop>signal_x</tabstop>
|
||||
<tabstop>signal_y</tabstop>
|
||||
<tabstop>signal_z</tabstop>
|
||||
<tabstop>x_name</tabstop>
|
||||
<tabstop>y_name</tabstop>
|
||||
<tabstop>z_name</tabstop>
|
||||
<tabstop>x_entry</tabstop>
|
||||
<tabstop>y_entry</tabstop>
|
||||
<tabstop>z_entry</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>device_x</sender>
|
||||
<sender>x_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>signal_x</receiver>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -213,9 +213,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_y</sender>
|
||||
<sender>y_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>signal_y</receiver>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -229,9 +229,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_z</sender>
|
||||
<sender>z_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>signal_z</receiver>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -245,9 +245,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_x</sender>
|
||||
<sender>x_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>signal_x</receiver>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -261,9 +261,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_y</sender>
|
||||
<sender>y_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>signal_y</receiver>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -277,9 +277,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_z</sender>
|
||||
<sender>z_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>signal_z</receiver>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="device_x"/>
|
||||
<widget class="DeviceLineEdit" name="x_name"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
@@ -68,7 +68,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="signal_x"/>
|
||||
<widget class="QLineEdit" name="x_entry"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
@@ -87,7 +87,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="device_y"/>
|
||||
<widget class="DeviceLineEdit" name="y_name"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_4">
|
||||
@@ -97,7 +97,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="signal_y"/>
|
||||
<widget class="QLineEdit" name="y_entry"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
@@ -116,7 +116,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="device_z"/>
|
||||
<widget class="DeviceLineEdit" name="z_name"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_6">
|
||||
@@ -126,7 +126,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="signal_z"/>
|
||||
<widget class="QLineEdit" name="z_entry"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
@@ -153,9 +153,9 @@
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>device_x</sender>
|
||||
<sender>x_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<receiver>signal_x</receiver>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -169,9 +169,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_y</sender>
|
||||
<sender>y_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<receiver>signal_y</receiver>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
@@ -185,9 +185,9 @@
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_z</sender>
|
||||
<sender>z_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<receiver>signal_z</receiver>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
|
||||
@@ -20,11 +20,10 @@ logger = bec_logger.logger
|
||||
class DeviceSignal(BaseModel):
|
||||
"""The configuration of a signal in the 1D waveform widget."""
|
||||
|
||||
device: str
|
||||
signal: str
|
||||
dap: str | list[str] | None = None
|
||||
name: str
|
||||
entry: str
|
||||
dap: str | None = None
|
||||
dap_oversample: int = 1
|
||||
dap_parameters: dict | list | None = None
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ class CurveSetting(SettingWidget):
|
||||
signal_x = self.signal_x.currentText()
|
||||
signal_data = self.signal_x.itemData(self.signal_x.currentIndex())
|
||||
if signal_x != "":
|
||||
self.target_widget.signal_x = signal_data.get("obj_name", signal_x)
|
||||
self.target_widget.x_entry = signal_data.get("obj_name", signal_x)
|
||||
else:
|
||||
self.target_widget.x_mode = self.mode_combo.currentText()
|
||||
self.curve_manager.send_curve_json()
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from qtpy.QtGui import QValidator
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
|
||||
class ScanIndexValidator(QValidator):
|
||||
@@ -225,7 +226,7 @@ class CurveRow(QTreeWidgetItem):
|
||||
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
|
||||
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
|
||||
if self.config.signal:
|
||||
device_index = self.device_edit.findText(self.config.signal.device or "")
|
||||
device_index = self.device_edit.findText(self.config.signal.name or "")
|
||||
if device_index >= 0:
|
||||
self.device_edit.setCurrentIndex(device_index)
|
||||
# Force the entry_edit to update based on the device name
|
||||
@@ -234,7 +235,7 @@ class CurveRow(QTreeWidgetItem):
|
||||
# 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.signal):
|
||||
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
|
||||
@@ -308,15 +309,15 @@ class CurveRow(QTreeWidgetItem):
|
||||
dev_name = ""
|
||||
dev_entry = ""
|
||||
if self.config.signal:
|
||||
dev_name = self.config.signal.device
|
||||
dev_entry = self.config.signal.signal
|
||||
dev_name = self.config.signal.name
|
||||
dev_entry = self.config.signal.entry
|
||||
|
||||
# Create a new config for the DAP row
|
||||
dap_cfg = CurveConfig(
|
||||
widget_class="Curve",
|
||||
source="dap",
|
||||
parent_label=parent_label,
|
||||
signal=DeviceSignal(device=dev_name, signal=dev_entry),
|
||||
signal=DeviceSignal(name=dev_name, entry=dev_entry),
|
||||
)
|
||||
new_dap = CurveRow(self.tree, parent_item=self, config=dap_cfg, device_manager=self.dev)
|
||||
# Expand device row to show new child
|
||||
@@ -394,10 +395,10 @@ class CurveRow(QTreeWidgetItem):
|
||||
device_entry = device_entry_info.get("obj_name", device_entry)
|
||||
else:
|
||||
device_entry = self.entry_validator.validate_signal(
|
||||
device=device_name, signal=device_entry
|
||||
name=device_name, entry=device_entry
|
||||
)
|
||||
|
||||
self.config.signal = DeviceSignal(device=device_name, signal=device_entry)
|
||||
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
|
||||
scan_combo_text = self.scan_index_combo.currentText()
|
||||
if scan_combo_text == "live" or scan_combo_text == "":
|
||||
self.config.scan_number = None
|
||||
@@ -421,16 +422,16 @@ class CurveRow(QTreeWidgetItem):
|
||||
if self.parent_item:
|
||||
parent_conf_dict = self.parent_item.export_data()
|
||||
parent_conf = CurveConfig(**parent_conf_dict)
|
||||
device = ""
|
||||
signal = ""
|
||||
dev_name = ""
|
||||
dev_entry = ""
|
||||
if parent_conf.signal:
|
||||
device = parent_conf.signal.device
|
||||
signal = parent_conf.signal.signal
|
||||
dev_name = parent_conf.signal.name
|
||||
dev_entry = parent_conf.signal.entry
|
||||
# Dap from the DapComboBox
|
||||
new_dap = "GaussianModel"
|
||||
if hasattr(self, "dap_combo"):
|
||||
new_dap = self.dap_combo.fit_model_combobox.currentText()
|
||||
self.config.signal = DeviceSignal(device=device, signal=signal, dap=new_dap)
|
||||
self.config.signal = DeviceSignal(name=dev_name, entry=dev_entry, dap=new_dap)
|
||||
self.config.source = "dap"
|
||||
self.config.parent_label = parent_conf.label
|
||||
self.config.label = f"{parent_conf.label}-{new_dap}"
|
||||
@@ -612,12 +613,15 @@ class CurveTree(BECWidget, QWidget):
|
||||
item.config.color = new_col
|
||||
item.config.symbol_color = new_col
|
||||
|
||||
def add_new_curve(self, device: str = None, signal: str = None):
|
||||
def add_new_curve(self, name: str = None, entry: str = None):
|
||||
"""Add a new device-type CurveRow with an assigned colormap color.
|
||||
|
||||
Args:
|
||||
device (str, optional): Device name.
|
||||
signal (str, optional): Device entry.
|
||||
name (str, optional): Device name.
|
||||
entry (str, optional): Device entry.
|
||||
style (str, optional): Pen style. Defaults to "solid".
|
||||
width (int, optional): Pen width. Defaults to 4.
|
||||
symbol_size (int, optional): Symbol size. Defaults to 7.
|
||||
|
||||
Returns:
|
||||
CurveRow: The newly created top-level row.
|
||||
@@ -626,7 +630,7 @@ class CurveTree(BECWidget, QWidget):
|
||||
widget_class="Curve",
|
||||
parent_id=self.waveform.gui_id,
|
||||
source="device",
|
||||
signal=DeviceSignal(device=device or "", signal=signal or ""),
|
||||
signal=DeviceSignal(name=name or "", entry=entry or ""),
|
||||
)
|
||||
new_row = CurveRow(self.tree, parent_item=None, config=cfg, device_manager=self.dev)
|
||||
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, Qt, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors, get_theme_name
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AlignmentContext:
|
||||
"""Alignment state produced by `Waveform` and consumed by the controller.
|
||||
|
||||
Attributes:
|
||||
visible: Whether alignment mode is currently visible.
|
||||
positioner_name: Name of the resolved x-axis positioner, if available.
|
||||
precision: Decimal precision to use for readback and target labels.
|
||||
limits: Optional positioner limits for the draggable target line.
|
||||
readback: Current cached positioner readback value.
|
||||
has_dap_curves: Whether the waveform currently contains any DAP curves.
|
||||
force_readback: Whether the embedded positioner should refresh its readback immediately.
|
||||
"""
|
||||
|
||||
visible: bool
|
||||
positioner_name: str | None
|
||||
precision: int = 3
|
||||
limits: tuple[float, float] | None = None
|
||||
readback: float | None = None
|
||||
has_dap_curves: bool = False
|
||||
force_readback: bool = False
|
||||
|
||||
|
||||
class WaveformAlignmentController(QObject):
|
||||
"""Own the alignment plot overlays and synchronize them with the alignment panel."""
|
||||
|
||||
move_absolute_requested = Signal(float)
|
||||
autoscale_requested = Signal()
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem, panel: WaveformAlignmentPanel, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self._plot_item = plot_item
|
||||
self._panel = panel
|
||||
|
||||
self._visible = False
|
||||
self._positioner_name: str | None = None
|
||||
self._precision = 3
|
||||
self._limits: tuple[float, float] | None = None
|
||||
self._readback: float | None = None
|
||||
self._marker_line: pg.InfiniteLine | None = None
|
||||
self._target_line: pg.InfiniteLine | None = None
|
||||
|
||||
self._panel.position_readback_changed.connect(self.update_position)
|
||||
self._panel.target_toggled.connect(self._on_target_toggled)
|
||||
self._panel.target_move_requested.connect(self._on_target_move_requested)
|
||||
self._panel.fit_selection_changed.connect(self._on_fit_selection_changed)
|
||||
self._panel.fit_center_requested.connect(self._on_fit_center_requested)
|
||||
|
||||
@property
|
||||
def marker_line(self) -> pg.InfiniteLine | None:
|
||||
"""Return the current-position indicator line, if it exists."""
|
||||
return self._marker_line
|
||||
|
||||
@property
|
||||
def target_line(self) -> pg.InfiniteLine | None:
|
||||
"""Return the draggable target indicator line, if it exists."""
|
||||
return self._target_line
|
||||
|
||||
def update_context(self, context: AlignmentContext):
|
||||
"""Apply waveform-owned alignment context to the panel and plot overlays.
|
||||
|
||||
Args:
|
||||
context: Snapshot of the current alignment-relevant waveform/device state.
|
||||
"""
|
||||
previous_name = self._positioner_name
|
||||
self._visible = context.visible
|
||||
self._positioner_name = context.positioner_name
|
||||
self._precision = context.precision
|
||||
self._limits = context.limits
|
||||
self._readback = context.readback
|
||||
|
||||
self._panel.set_positioner_device(context.positioner_name)
|
||||
self._panel.set_positioner_enabled(context.visible and context.positioner_name is not None)
|
||||
self._panel.set_status_message(self._status_message_for_context(context))
|
||||
|
||||
if context.positioner_name is None or not context.visible:
|
||||
self.clear()
|
||||
self._refresh_fit_actions()
|
||||
self._refresh_target_controls()
|
||||
return
|
||||
|
||||
if previous_name != context.positioner_name:
|
||||
self._clear_marker()
|
||||
if self._panel.target_active:
|
||||
self._clear_target_line()
|
||||
|
||||
if context.readback is not None:
|
||||
self.update_position(context.readback)
|
||||
|
||||
if self._panel.target_active:
|
||||
if previous_name != context.positioner_name or self._target_line is None:
|
||||
self._show_target_line()
|
||||
else:
|
||||
self._refresh_target_line_metadata()
|
||||
self._on_target_line_changed()
|
||||
|
||||
if context.force_readback or previous_name != context.positioner_name:
|
||||
self._panel.force_positioner_readback()
|
||||
|
||||
self._refresh_fit_actions()
|
||||
self._refresh_target_controls()
|
||||
|
||||
@SafeSlot(float)
|
||||
def update_position(self, position: float):
|
||||
"""Update the live position marker from a positioner readback value.
|
||||
|
||||
Args:
|
||||
position: Current absolute position of the active alignment positioner.
|
||||
"""
|
||||
self._readback = float(position)
|
||||
if not self._visible or self._positioner_name is None:
|
||||
self._clear_marker()
|
||||
return
|
||||
|
||||
self._ensure_marker()
|
||||
self._marker_line.setValue(self._readback)
|
||||
self._marker_line.label.setText(
|
||||
f"{self._positioner_name}: {self._readback:.{self._precision}f}"
|
||||
)
|
||||
self.autoscale_requested.emit()
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def update_dap_summary(self, data: dict, metadata: dict):
|
||||
"""Forward DAP summary updates into the alignment fit panel.
|
||||
|
||||
Args:
|
||||
data: DAP fit summary payload.
|
||||
metadata: Metadata describing the emitting DAP curve.
|
||||
"""
|
||||
self._panel.update_dap_summary(data, metadata)
|
||||
self._refresh_fit_actions()
|
||||
|
||||
@SafeSlot(str)
|
||||
def remove_dap_curve(self, curve_id: str):
|
||||
"""Remove a deleted DAP curve from the alignment fit selection state.
|
||||
|
||||
Args:
|
||||
curve_id: Label of the DAP curve that was removed from the waveform.
|
||||
"""
|
||||
self._panel.remove_dap_curve(curve_id)
|
||||
self._panel.clear_fit_selection_if_missing()
|
||||
self._refresh_fit_actions()
|
||||
|
||||
def clear(self):
|
||||
"""Remove alignment overlay items from the plot and reset target state."""
|
||||
self._clear_marker()
|
||||
self._clear_target_line()
|
||||
|
||||
def cleanup(self):
|
||||
"""Disconnect panel signals and remove all controller-owned overlay items."""
|
||||
self.clear()
|
||||
self._disconnect_panel_signals()
|
||||
|
||||
def refresh_theme_colors(self):
|
||||
"""Reapply theme-aware styling to any existing alignment overlay items."""
|
||||
self._apply_marker_style()
|
||||
self._apply_target_style()
|
||||
|
||||
def _disconnect_panel_signals(self):
|
||||
signal_pairs = [
|
||||
(self._panel.position_readback_changed, self.update_position),
|
||||
(self._panel.target_toggled, self._on_target_toggled),
|
||||
(self._panel.target_move_requested, self._on_target_move_requested),
|
||||
(self._panel.fit_selection_changed, self._on_fit_selection_changed),
|
||||
(self._panel.fit_center_requested, self._on_fit_center_requested),
|
||||
]
|
||||
for signal, slot in signal_pairs:
|
||||
try:
|
||||
signal.disconnect(slot)
|
||||
except (RuntimeError, TypeError):
|
||||
continue
|
||||
|
||||
def _selected_fit_has_center(self) -> bool:
|
||||
data = self._panel.selected_fit_summary()
|
||||
params = data.get("params", []) if isinstance(data, dict) else []
|
||||
return any(param[0] == "center" for param in params if param)
|
||||
|
||||
@staticmethod
|
||||
def _status_message_for_context(context: AlignmentContext) -> str | None:
|
||||
if context.positioner_name is None:
|
||||
return "Alignment mode requires a positioner on the x axis."
|
||||
if not context.has_dap_curves:
|
||||
return "Add a DAP curve in Curve Settings to enable alignment fitting."
|
||||
return None
|
||||
|
||||
def _refresh_fit_actions(self):
|
||||
self._panel.set_fit_actions_enabled(
|
||||
self._visible and self._positioner_name is not None and self._selected_fit_has_center()
|
||||
)
|
||||
|
||||
def _refresh_target_controls(self):
|
||||
has_positioner = self._visible and self._positioner_name is not None
|
||||
self._panel.set_target_enabled(has_positioner)
|
||||
self._panel.set_target_move_enabled(has_positioner and self._target_line is not None)
|
||||
if self._target_line is None:
|
||||
self._panel.set_target_value(None)
|
||||
|
||||
def _ensure_marker(self):
|
||||
if self._marker_line is not None:
|
||||
return
|
||||
|
||||
warning = get_accent_colors().warning
|
||||
|
||||
self._marker_line = pg.InfiniteLine(
|
||||
angle=90,
|
||||
movable=False,
|
||||
pen=pg.mkPen(warning, width=4),
|
||||
label="",
|
||||
labelOpts={"position": 0.95, "color": warning},
|
||||
)
|
||||
self._apply_marker_style()
|
||||
self._plot_item.addItem(self._marker_line)
|
||||
|
||||
def _clear_marker(self):
|
||||
if self._marker_line is None:
|
||||
return
|
||||
self._plot_item.removeItem(self._marker_line)
|
||||
self._marker_line = None
|
||||
|
||||
def _show_target_line(self):
|
||||
if not self._visible or self._positioner_name is None:
|
||||
return
|
||||
|
||||
if self._target_line is None:
|
||||
accent_colors = get_accent_colors()
|
||||
label = f"{self._positioner_name} target={{value:0.{self._precision}f}}"
|
||||
self._target_line = pg.InfiniteLine(
|
||||
movable=True,
|
||||
angle=90,
|
||||
pen=pg.mkPen(accent_colors.default, width=2, style=Qt.PenStyle.DashLine),
|
||||
hoverPen=pg.mkPen(accent_colors.success, width=2),
|
||||
label=label,
|
||||
labelOpts={"movable": True, "color": accent_colors.default},
|
||||
)
|
||||
self._target_line.sigPositionChanged.connect(self._on_target_line_changed)
|
||||
self._apply_target_style()
|
||||
self._plot_item.addItem(self._target_line)
|
||||
self._refresh_target_line_metadata()
|
||||
|
||||
value = 0.0 if self._readback is None else self._readback
|
||||
if self._limits is not None:
|
||||
value = min(max(value, self._limits[0]), self._limits[1])
|
||||
self._target_line.setValue(value)
|
||||
self._on_target_line_changed()
|
||||
self.autoscale_requested.emit()
|
||||
|
||||
def _refresh_target_line_metadata(self):
|
||||
if self._target_line is None or self._positioner_name is None:
|
||||
return
|
||||
self._apply_target_style()
|
||||
self._target_line.label.setFormat(
|
||||
f"{self._positioner_name} target={{value:0.{self._precision}f}}"
|
||||
)
|
||||
if self._limits is not None:
|
||||
self._target_line.setBounds(list(self._limits))
|
||||
else:
|
||||
self._target_line.setBounds((None, None))
|
||||
if self._limits is not None:
|
||||
current_value = float(self._target_line.value())
|
||||
clamped_value = min(max(current_value, self._limits[0]), self._limits[1])
|
||||
if clamped_value != current_value:
|
||||
self._target_line.setValue(clamped_value)
|
||||
|
||||
def _clear_target_line(self):
|
||||
if self._target_line is not None:
|
||||
try:
|
||||
self._target_line.sigPositionChanged.disconnect(self._on_target_line_changed)
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
self._plot_item.removeItem(self._target_line)
|
||||
self._target_line = None
|
||||
self._panel.set_target_value(None)
|
||||
|
||||
def _apply_marker_style(self):
|
||||
if self._marker_line is None:
|
||||
return
|
||||
|
||||
accent_colors = get_accent_colors()
|
||||
warning = accent_colors.warning
|
||||
|
||||
self._marker_line.setPen(pg.mkPen(warning, width=4))
|
||||
self._marker_line.label.setColor(warning)
|
||||
self._marker_line.label.fill = pg.mkBrush(self._label_fill_color())
|
||||
|
||||
def _apply_target_style(self):
|
||||
if self._target_line is None:
|
||||
return
|
||||
|
||||
accent_colors = get_accent_colors()
|
||||
default = accent_colors.default
|
||||
success = accent_colors.success
|
||||
|
||||
self._target_line.setPen(pg.mkPen(default, width=2, style=Qt.PenStyle.DashLine))
|
||||
self._target_line.setHoverPen(pg.mkPen(success, width=2))
|
||||
self._target_line.label.setColor(default)
|
||||
self._target_line.label.fill = pg.mkBrush(self._label_fill_color())
|
||||
|
||||
@staticmethod
|
||||
def _label_fill_color() -> QColor:
|
||||
if get_theme_name() == "light":
|
||||
return QColor(244, 244, 244, 228)
|
||||
return QColor(48, 48, 48, 210)
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _on_target_toggled(self, checked: bool):
|
||||
if checked:
|
||||
self._show_target_line()
|
||||
else:
|
||||
self._clear_target_line()
|
||||
self._refresh_target_controls()
|
||||
|
||||
@SafeSlot(object)
|
||||
def _on_target_line_changed(self, _line=None):
|
||||
if self._target_line is None:
|
||||
return
|
||||
self._panel.set_target_value(float(self._target_line.value()), precision=self._precision)
|
||||
self._refresh_target_controls()
|
||||
self.autoscale_requested.emit()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_target_move_requested(self):
|
||||
if self._visible and self._positioner_name is not None and self._target_line is not None:
|
||||
self.move_absolute_requested.emit(float(self._target_line.value()))
|
||||
|
||||
@SafeSlot(str)
|
||||
def _on_fit_selection_changed(self, _curve_id: str):
|
||||
self._refresh_fit_actions()
|
||||
|
||||
@SafeSlot(float)
|
||||
def _on_fit_center_requested(self, value: float):
|
||||
if self._visible and self._positioner_name is not None:
|
||||
self.move_absolute_requested.emit(float(value))
|
||||
@@ -1,285 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors, get_theme_name
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
|
||||
PositionerControlLine,
|
||||
)
|
||||
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||
|
||||
|
||||
class WaveformAlignmentPanel(QWidget):
|
||||
"""Compact bottom panel used by Waveform alignment mode."""
|
||||
|
||||
position_readback_changed = Signal(float)
|
||||
target_toggled = Signal(bool)
|
||||
target_move_requested = Signal()
|
||||
fit_selection_changed = Signal(str)
|
||||
fit_center_requested = Signal(float)
|
||||
|
||||
def __init__(self, parent=None, client=None, gui_id: str | None = None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.setProperty("skip_settings", True)
|
||||
|
||||
self.positioner = PositionerControlLine(parent=self, client=client, gui_id=gui_id)
|
||||
self.positioner.hide_device_selection = True
|
||||
|
||||
self.fit_dialog = LMFitDialog(
|
||||
parent=self, client=client, gui_id=gui_id, ui_file="lmfit_dialog_compact.ui"
|
||||
)
|
||||
self.fit_dialog.active_action_list = ["center"]
|
||||
self.fit_dialog.enable_actions = False
|
||||
|
||||
self.target_toggle = QCheckBox("Target: --", parent=self)
|
||||
self.move_to_target_button = QPushButton("Move To Target", parent=self)
|
||||
self.move_to_target_button.setEnabled(False)
|
||||
self.target_group = QGroupBox("Target Position", parent=self)
|
||||
|
||||
self.status_label = QLabel(parent=self)
|
||||
self.status_label.setWordWrap(False)
|
||||
self.status_label.setAlignment(
|
||||
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
self.status_label.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
|
||||
self.status_label.setMaximumHeight(28)
|
||||
self.status_label.setVisible(False)
|
||||
|
||||
self._init_ui()
|
||||
self.fit_dialog.setMinimumHeight(0)
|
||||
self.target_group.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self._sync_target_group_size()
|
||||
self.refresh_theme_colors()
|
||||
self._connect_signals()
|
||||
|
||||
def _connect_signals(self):
|
||||
self.positioner.position_update.connect(self.position_readback_changed)
|
||||
self.target_toggle.toggled.connect(self.target_toggled)
|
||||
self.move_to_target_button.clicked.connect(self.target_move_requested)
|
||||
self.fit_dialog.selected_fit.connect(self.fit_selection_changed)
|
||||
self.fit_dialog.move_action.connect(self._forward_fit_move_action)
|
||||
|
||||
def _init_ui(self):
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
self.setMinimumHeight(260)
|
||||
|
||||
root = QGridLayout(self)
|
||||
root.setContentsMargins(8, 8, 8, 8)
|
||||
root.setSpacing(8)
|
||||
|
||||
self.fit_dialog.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
root.addWidget(
|
||||
self.status_label,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
alignment=Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter,
|
||||
)
|
||||
root.addWidget(self.fit_dialog, 1, 0, 1, 2)
|
||||
|
||||
target_layout = QHBoxLayout(self.target_group)
|
||||
target_layout.addWidget(self.target_toggle)
|
||||
target_layout.addWidget(self.move_to_target_button)
|
||||
|
||||
root.addWidget(self.positioner, 2, 0, alignment=Qt.AlignmentFlag.AlignTop)
|
||||
root.addWidget(self.target_group, 2, 1, alignment=Qt.AlignmentFlag.AlignTop)
|
||||
root.setColumnStretch(0, 1)
|
||||
root.setColumnStretch(1, 0)
|
||||
root.setRowStretch(1, 1)
|
||||
|
||||
def _sync_target_group_size(self):
|
||||
representative_text = "Target: -99999.999"
|
||||
label_width = max(
|
||||
self.target_toggle.sizeHint().width(),
|
||||
self.target_toggle.fontMetrics().horizontalAdvance(representative_text) + 24,
|
||||
)
|
||||
self.target_toggle.setMinimumWidth(label_width)
|
||||
|
||||
# To make those two box the same height
|
||||
target_height = max(
|
||||
self.positioner.height(),
|
||||
self.positioner.ui.device_box.minimumSizeHint().height(),
|
||||
self.positioner.ui.device_box.sizeHint().height(),
|
||||
)
|
||||
self.target_group.setFixedHeight(target_height)
|
||||
self.target_group.setFixedWidth(self.target_group.sizeHint().width() + 16)
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self._sync_target_group_size()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._sync_target_group_size()
|
||||
|
||||
def set_status_message(self, text: str | None):
|
||||
"""Show or hide the alignment status pill.
|
||||
|
||||
Args:
|
||||
text: Message to display. Pass `None` or an empty string to hide the pill.
|
||||
"""
|
||||
|
||||
text = text or ""
|
||||
self.status_label.setText(text)
|
||||
self.status_label.setVisible(bool(text))
|
||||
|
||||
@staticmethod
|
||||
def _qcolor_to_rgba(color: QColor, alpha: int | None = None) -> str:
|
||||
if alpha is not None:
|
||||
color = QColor(color)
|
||||
color.setAlpha(alpha)
|
||||
return f"rgba({color.red()}, {color.green()}, {color.blue()}, {color.alpha()})"
|
||||
|
||||
def refresh_theme_colors(self):
|
||||
"""Apply theme-aware accent styling to the status pill."""
|
||||
warning = get_accent_colors().warning
|
||||
is_light = get_theme_name() == "light"
|
||||
text_color = "#202124" if is_light else warning.name()
|
||||
fill_alpha = 72 if is_light else 48
|
||||
border_alpha = 220 if is_light else 160
|
||||
|
||||
self.status_label.setStyleSheet(f"""
|
||||
QLabel {{
|
||||
background-color: {self._qcolor_to_rgba(warning, fill_alpha)};
|
||||
border: 1px solid {self._qcolor_to_rgba(warning, border_alpha)};
|
||||
border-radius: 12px;
|
||||
padding: 4px 10px;
|
||||
color: {text_color};
|
||||
}}
|
||||
""")
|
||||
|
||||
def set_positioner_device(self, device: str | None):
|
||||
"""Bind the embedded positioner control to a fixed device.
|
||||
|
||||
Args:
|
||||
device: Name of the positioner device to display, or `None` to clear it.
|
||||
"""
|
||||
if device is None:
|
||||
self.positioner.ui.device_box.setTitle("No positioner selected")
|
||||
return
|
||||
if self.positioner.device != device:
|
||||
self.positioner.set_positioner(device)
|
||||
self.positioner.hide_device_selection = True
|
||||
|
||||
def set_positioner_enabled(self, enabled: bool):
|
||||
"""Enable or disable the embedded positioner widget.
|
||||
|
||||
Args:
|
||||
enabled: Whether the positioner widget should accept interaction.
|
||||
"""
|
||||
self.positioner.setEnabled(enabled)
|
||||
|
||||
def force_positioner_readback(self):
|
||||
"""Trigger an immediate readback refresh on the embedded positioner widget."""
|
||||
self.positioner.force_update_readback()
|
||||
|
||||
def set_target_enabled(self, enabled: bool):
|
||||
"""Enable or disable the target-line toggle.
|
||||
|
||||
Args:
|
||||
enabled: Whether the target toggle should accept interaction.
|
||||
"""
|
||||
self.target_toggle.setEnabled(enabled)
|
||||
|
||||
def set_target_move_enabled(self, enabled: bool):
|
||||
"""Enable or disable the move-to-target button.
|
||||
|
||||
Args:
|
||||
enabled: Whether the move button should accept interaction.
|
||||
"""
|
||||
self.move_to_target_button.setEnabled(enabled)
|
||||
|
||||
def set_target_active(self, active: bool):
|
||||
"""Programmatically toggle the draggable target-line state.
|
||||
|
||||
Args:
|
||||
active: Whether the target line should be considered active.
|
||||
"""
|
||||
blocker = self.target_toggle.blockSignals(True)
|
||||
self.target_toggle.setChecked(active)
|
||||
self.target_toggle.blockSignals(blocker)
|
||||
if not active:
|
||||
self.set_target_value(None)
|
||||
|
||||
def set_target_value(self, value: float | None, precision: int = 3) -> None:
|
||||
"""
|
||||
Update the target checkbox label for the draggable target line.
|
||||
|
||||
Args:
|
||||
value(float | None): The target value to display. If None, the label will show "--".
|
||||
precision(int): The number of decimal places to display for the target value.
|
||||
"""
|
||||
if value is None or not self.target_toggle.isChecked():
|
||||
self.target_toggle.setText("Target: --")
|
||||
return
|
||||
self.target_toggle.setText(f"Target: {value:.{precision}f}")
|
||||
|
||||
def set_fit_actions_enabled(self, enabled: bool):
|
||||
"""Enable or disable LMFit action buttons in the embedded fit dialog.
|
||||
|
||||
Args:
|
||||
enabled: Whether fit action buttons should be enabled.
|
||||
"""
|
||||
self.fit_dialog.enable_actions = enabled
|
||||
|
||||
def update_dap_summary(self, data: dict, metadata: dict):
|
||||
"""Forward a DAP summary update into the embedded fit dialog.
|
||||
|
||||
Args:
|
||||
data: DAP fit summary payload.
|
||||
metadata: Metadata describing the emitting DAP curve.
|
||||
"""
|
||||
self.fit_dialog.update_summary_tree(data, metadata)
|
||||
|
||||
def remove_dap_curve(self, curve_id: str):
|
||||
"""Remove DAP summary state for a deleted fit curve.
|
||||
|
||||
Args:
|
||||
curve_id: Label of the DAP curve that should be removed.
|
||||
"""
|
||||
self.fit_dialog.remove_dap_data(curve_id)
|
||||
|
||||
def clear_fit_selection_if_missing(self):
|
||||
"""Select a remaining fit curve if the current selection no longer exists."""
|
||||
fit_curve_id = self.fit_dialog.fit_curve_id
|
||||
if fit_curve_id is not None and fit_curve_id not in self.fit_dialog.summary_data:
|
||||
remaining = list(self.fit_dialog.summary_data)
|
||||
self.fit_dialog.fit_curve_id = remaining[0] if remaining else None
|
||||
|
||||
@property
|
||||
def target_active(self) -> bool:
|
||||
"""Whether the target-line checkbox is currently checked."""
|
||||
return self.target_toggle.isChecked()
|
||||
|
||||
@property
|
||||
def selected_fit_curve_id(self) -> str | None:
|
||||
"""Return the currently selected fit curve label, if any."""
|
||||
return self.fit_dialog.fit_curve_id
|
||||
|
||||
def selected_fit_summary(self) -> dict | None:
|
||||
"""Return the summary payload for the currently selected fit curve.
|
||||
|
||||
Returns:
|
||||
The selected fit summary, or `None` if no fit curve is selected.
|
||||
"""
|
||||
fit_curve_id = self.selected_fit_curve_id
|
||||
if fit_curve_id is None:
|
||||
return None
|
||||
return self.fit_dialog.summary_data.get(fit_curve_id)
|
||||
|
||||
def _forward_fit_move_action(self, action: tuple[str, float]):
|
||||
param_name, param_value = action
|
||||
if param_name == "center":
|
||||
self.fit_center_requested.emit(float(param_value))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,7 @@ from qtpy import QtCore, QtGui
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
@@ -41,7 +40,7 @@ class ProgressbarConfig(ConnectionConfig):
|
||||
line_width: int = Field(20, description="Line widths for the progress bars.")
|
||||
start_position: int = Field(
|
||||
90,
|
||||
description="Start position for the progress bars in degrees. Default is 90 degrees - corresponds to "
|
||||
description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to "
|
||||
"the top of the ring.",
|
||||
)
|
||||
min_value: int | float = Field(0, description="Minimum value for the progress bars.")
|
||||
@@ -60,7 +59,7 @@ class ProgressbarConfig(ConnectionConfig):
|
||||
)
|
||||
|
||||
|
||||
class Ring(BECWidget, QWidget):
|
||||
class Ring(BECConnector, QWidget):
|
||||
USER_ACCESS = [
|
||||
"set_value",
|
||||
"set_color",
|
||||
@@ -83,26 +82,8 @@ class Ring(BECWidget, QWidget):
|
||||
self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None
|
||||
self.RID = None
|
||||
self._gap = 5
|
||||
self._hovered = False
|
||||
self._hover_progress = 0.0
|
||||
self._hover_animation = QtCore.QPropertyAnimation(self, b"hover_progress", parent=self)
|
||||
self._hover_animation.setDuration(180)
|
||||
easing_curve = (
|
||||
QtCore.QEasingCurve.Type.OutCubic
|
||||
if hasattr(QtCore.QEasingCurve, "Type")
|
||||
else QtCore.QEasingCurve.Type.OutCubic
|
||||
)
|
||||
self._hover_animation.setEasingCurve(easing_curve)
|
||||
self.set_start_angle(self.config.start_position)
|
||||
|
||||
def _request_update(self, *, refresh_tooltip: bool = True):
|
||||
# NOTE why not just overwrite update() to always refresh the tooltip?
|
||||
# Because in some cases (e.g. hover animation) we want to update the widget without refreshing the tooltip, to avoid performance issues.
|
||||
if refresh_tooltip:
|
||||
if self.progress_container and self.progress_container.is_ring_hovered(self):
|
||||
self.progress_container.refresh_hover_tooltip(self)
|
||||
self.update()
|
||||
|
||||
def set_value(self, value: int | float):
|
||||
"""
|
||||
Set the value for the ring widget
|
||||
@@ -126,7 +107,7 @@ class Ring(BECWidget, QWidget):
|
||||
if self.config.link_colors:
|
||||
self._auto_set_background_color()
|
||||
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
def set_background(self, color: str | tuple | QColor):
|
||||
"""
|
||||
@@ -141,7 +122,7 @@ class Ring(BECWidget, QWidget):
|
||||
|
||||
self._background_color = self.convert_color(color)
|
||||
self.config.background_color = self._background_color.name()
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
def _auto_set_background_color(self):
|
||||
"""
|
||||
@@ -152,7 +133,7 @@ class Ring(BECWidget, QWidget):
|
||||
bg_color = Colors.subtle_background_color(self._color, bg)
|
||||
self.config.background_color = bg_color.name()
|
||||
self._background_color = bg_color
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
def set_colors_linked(self, linked: bool):
|
||||
"""
|
||||
@@ -165,7 +146,7 @@ class Ring(BECWidget, QWidget):
|
||||
self.config.link_colors = linked
|
||||
if linked:
|
||||
self._auto_set_background_color()
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
def set_line_width(self, width: int):
|
||||
"""
|
||||
@@ -175,7 +156,7 @@ class Ring(BECWidget, QWidget):
|
||||
width(int): Line width for the ring widget
|
||||
"""
|
||||
self.config.line_width = width
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
def set_min_max_values(self, min_value: int | float, max_value: int | float):
|
||||
"""
|
||||
@@ -187,7 +168,7 @@ class Ring(BECWidget, QWidget):
|
||||
"""
|
||||
self.config.min_value = min_value
|
||||
self.config.max_value = max_value
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
def set_start_angle(self, start_angle: int):
|
||||
"""
|
||||
@@ -197,7 +178,7 @@ class Ring(BECWidget, QWidget):
|
||||
start_angle(int): Start angle for the ring widget in degrees
|
||||
"""
|
||||
self.config.start_position = start_angle
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
def set_update(
|
||||
self, mode: Literal["manual", "scan", "device"], device: str = "", signal: str = ""
|
||||
@@ -256,7 +237,7 @@ class Ring(BECWidget, QWidget):
|
||||
precision(int): Precision for the ring widget
|
||||
"""
|
||||
self.config.precision = precision
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
def set_direction(self, direction: int):
|
||||
"""
|
||||
@@ -266,7 +247,7 @@ class Ring(BECWidget, QWidget):
|
||||
direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise.
|
||||
"""
|
||||
self.config.direction = direction
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
def _get_signals_for_device(self, device: str) -> dict[str, list[str]]:
|
||||
"""
|
||||
@@ -295,7 +276,7 @@ class Ring(BECWidget, QWidget):
|
||||
for obj in dev_obj._info["signals"].values()
|
||||
if obj["kind_str"] == "hinted"
|
||||
and obj["signal_class"]
|
||||
not in ["ProgressSignal", "AsyncSignal", "AsyncMultiSignal", "DynamicSignal"]
|
||||
not in ["ProgressSignal", "AyncSignal", "AsyncMultiSignal", "DynamicSignal"]
|
||||
]
|
||||
|
||||
normal_signals = [
|
||||
@@ -443,11 +424,8 @@ class Ring(BECWidget, QWidget):
|
||||
rect.adjust(max_ring_size, max_ring_size, -max_ring_size, -max_ring_size)
|
||||
|
||||
# Background arc
|
||||
base_line_width = float(self.config.line_width)
|
||||
hover_line_delta = min(3.0, round(base_line_width * 0.6, 1))
|
||||
current_line_width = base_line_width + (hover_line_delta * self._hover_progress)
|
||||
painter.setPen(
|
||||
QtGui.QPen(self._background_color, current_line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||
QtGui.QPen(self._background_color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||
)
|
||||
|
||||
gap: int = self.gap # type: ignore
|
||||
@@ -455,25 +433,13 @@ class Ring(BECWidget, QWidget):
|
||||
# Important: Qt uses a 16th of a degree for angles. start_position is therefore multiplied by 16.
|
||||
start_position: float = self.config.start_position * 16 # type: ignore
|
||||
|
||||
adjusted_rect = QtCore.QRectF(
|
||||
adjusted_rect = QtCore.QRect(
|
||||
rect.left() + gap, rect.top() + gap, rect.width() - 2 * gap, rect.height() - 2 * gap
|
||||
)
|
||||
if self._hover_progress > 0.0:
|
||||
hover_radius_delta = 4.0
|
||||
base_radius = adjusted_rect.width() / 2
|
||||
if base_radius > 0:
|
||||
target_radius = base_radius + (hover_radius_delta * self._hover_progress)
|
||||
scale = target_radius / base_radius
|
||||
center = adjusted_rect.center()
|
||||
new_width = adjusted_rect.width() * scale
|
||||
new_height = adjusted_rect.height() * scale
|
||||
adjusted_rect = QtCore.QRectF(
|
||||
center.x() - new_width / 2, center.y() - new_height / 2, new_width, new_height
|
||||
)
|
||||
painter.drawArc(adjusted_rect, start_position, 360 * 16)
|
||||
|
||||
# Foreground arc
|
||||
pen = QtGui.QPen(self.color, current_line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||
pen = QtGui.QPen(self.color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||
pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
proportion = (self.config.value - self.config.min_value) / (
|
||||
@@ -483,17 +449,7 @@ class Ring(BECWidget, QWidget):
|
||||
painter.drawArc(adjusted_rect, start_position, angle)
|
||||
painter.end()
|
||||
|
||||
def set_hovered(self, hovered: bool):
|
||||
if hovered == self._hovered:
|
||||
return
|
||||
self._hovered = hovered
|
||||
self._hover_animation.stop()
|
||||
self._hover_animation.setStartValue(self._hover_progress)
|
||||
self._hover_animation.setEndValue(1.0 if hovered else 0.0)
|
||||
self._hover_animation.start()
|
||||
|
||||
@staticmethod
|
||||
def convert_color(color: str | tuple | QColor) -> QColor:
|
||||
def convert_color(self, color: str | tuple | QColor) -> QColor:
|
||||
"""
|
||||
Convert the color to QColor
|
||||
|
||||
@@ -529,7 +485,7 @@ class Ring(BECWidget, QWidget):
|
||||
@gap.setter
|
||||
def gap(self, value: int):
|
||||
self._gap = value
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def link_colors(self) -> bool:
|
||||
@@ -566,7 +522,7 @@ class Ring(BECWidget, QWidget):
|
||||
float(max(self.config.min_value, min(self.config.max_value, value))),
|
||||
self.config.precision,
|
||||
)
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
@SafeProperty(float)
|
||||
def min_value(self) -> float:
|
||||
@@ -575,7 +531,7 @@ class Ring(BECWidget, QWidget):
|
||||
@min_value.setter
|
||||
def min_value(self, value: float):
|
||||
self.config.min_value = value
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
@SafeProperty(float)
|
||||
def max_value(self) -> float:
|
||||
@@ -584,7 +540,7 @@ class Ring(BECWidget, QWidget):
|
||||
@max_value.setter
|
||||
def max_value(self, value: float):
|
||||
self.config.max_value = value
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
@SafeProperty(str)
|
||||
def mode(self) -> str:
|
||||
@@ -593,7 +549,6 @@ class Ring(BECWidget, QWidget):
|
||||
@mode.setter
|
||||
def mode(self, value: str):
|
||||
self.set_update(value)
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(str)
|
||||
def device(self) -> str:
|
||||
@@ -602,7 +557,6 @@ class Ring(BECWidget, QWidget):
|
||||
@device.setter
|
||||
def device(self, value: str):
|
||||
self.config.device = value
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(str)
|
||||
def signal(self) -> str:
|
||||
@@ -611,7 +565,6 @@ class Ring(BECWidget, QWidget):
|
||||
@signal.setter
|
||||
def signal(self, value: str):
|
||||
self.config.signal = value
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(int)
|
||||
def line_width(self) -> int:
|
||||
@@ -620,7 +573,7 @@ class Ring(BECWidget, QWidget):
|
||||
@line_width.setter
|
||||
def line_width(self, value: int):
|
||||
self.config.line_width = value
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
@SafeProperty(int)
|
||||
def start_position(self) -> int:
|
||||
@@ -629,7 +582,7 @@ class Ring(BECWidget, QWidget):
|
||||
@start_position.setter
|
||||
def start_position(self, value: int):
|
||||
self.config.start_position = value
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
@SafeProperty(int)
|
||||
def precision(self) -> int:
|
||||
@@ -638,7 +591,7 @@ class Ring(BECWidget, QWidget):
|
||||
@precision.setter
|
||||
def precision(self, value: int):
|
||||
self.config.precision = value
|
||||
self._request_update()
|
||||
self.update()
|
||||
|
||||
@SafeProperty(int)
|
||||
def direction(self) -> int:
|
||||
@@ -647,27 +600,7 @@ class Ring(BECWidget, QWidget):
|
||||
@direction.setter
|
||||
def direction(self, value: int):
|
||||
self.config.direction = value
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(float)
|
||||
def hover_progress(self) -> float:
|
||||
return self._hover_progress
|
||||
|
||||
@hover_progress.setter
|
||||
def hover_progress(self, value: float):
|
||||
self._hover_progress = value
|
||||
self._request_update(refresh_tooltip=False)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the ring widget.
|
||||
Disconnect any registered slots.
|
||||
"""
|
||||
if self.registered_slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
|
||||
self.registered_slot = None
|
||||
self._hover_animation.stop()
|
||||
super().cleanup()
|
||||
self.update()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Literal
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QPointF, QSize, Qt
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
@@ -12,7 +12,6 @@ from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.main_window.addons.hover_widget import WidgetTooltip
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings
|
||||
|
||||
@@ -30,16 +29,7 @@ class RingProgressContainerWidget(QWidget):
|
||||
self.rings: list[Ring] = []
|
||||
self.gap = 20 # Gap between rings
|
||||
self.color_map: str = "turbo"
|
||||
self._hovered_ring: Ring | None = None
|
||||
self._last_hover_global_pos = None
|
||||
self._hover_tooltip_label = QLabel()
|
||||
self._hover_tooltip_label.setWordWrap(True)
|
||||
self._hover_tooltip_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self._hover_tooltip_label.setMaximumWidth(260)
|
||||
self._hover_tooltip_label.setStyleSheet("font-size: 12px;")
|
||||
self._hover_tooltip = WidgetTooltip(self._hover_tooltip_label)
|
||||
self.setLayout(QHBoxLayout())
|
||||
self.setMouseTracking(True)
|
||||
self.initialize_bars()
|
||||
self.initialize_center_label()
|
||||
|
||||
@@ -69,7 +59,6 @@ class RingProgressContainerWidget(QWidget):
|
||||
"""
|
||||
ring = Ring(parent=self)
|
||||
ring.setGeometry(self.rect())
|
||||
ring.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
ring.gap = self.gap * len(self.rings)
|
||||
ring.set_value(0)
|
||||
self.rings.append(ring)
|
||||
@@ -99,10 +88,6 @@ class RingProgressContainerWidget(QWidget):
|
||||
index = self.num_bars - 1
|
||||
index = self._validate_index(index)
|
||||
ring = self.rings[index]
|
||||
if ring is self._hovered_ring:
|
||||
self._hovered_ring = None
|
||||
self._last_hover_global_pos = None
|
||||
self._hover_tooltip.hide()
|
||||
ring.cleanup()
|
||||
ring.close()
|
||||
ring.deleteLater()
|
||||
@@ -121,7 +106,6 @@ class RingProgressContainerWidget(QWidget):
|
||||
|
||||
self.center_label = QLabel("", parent=self)
|
||||
self.center_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.center_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
layout.addWidget(self.center_label)
|
||||
|
||||
def _calculate_minimum_size(self):
|
||||
@@ -166,130 +150,6 @@ class RingProgressContainerWidget(QWidget):
|
||||
for ring in self.rings:
|
||||
ring.setGeometry(self.rect())
|
||||
|
||||
def enterEvent(self, event):
|
||||
self.setMouseTracking(True)
|
||||
super().enterEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
pos = event.position() if hasattr(event, "position") else QPointF(event.pos())
|
||||
self._last_hover_global_pos = (
|
||||
event.globalPosition().toPoint()
|
||||
if hasattr(event, "globalPosition")
|
||||
else event.globalPos()
|
||||
)
|
||||
ring = self._ring_at_pos(pos)
|
||||
self._set_hovered_ring(ring, event)
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self._last_hover_global_pos = None
|
||||
self._set_hovered_ring(None, event)
|
||||
super().leaveEvent(event)
|
||||
|
||||
def _set_hovered_ring(self, ring: Ring | None, event=None):
|
||||
if ring is self._hovered_ring:
|
||||
if ring is not None:
|
||||
self.refresh_hover_tooltip(ring, event)
|
||||
return
|
||||
if self._hovered_ring is not None:
|
||||
self._hovered_ring.set_hovered(False)
|
||||
self._hovered_ring = ring
|
||||
if self._hovered_ring is not None:
|
||||
self._hovered_ring.set_hovered(True)
|
||||
self.refresh_hover_tooltip(self._hovered_ring, event)
|
||||
else:
|
||||
self._hover_tooltip.hide()
|
||||
|
||||
def _ring_at_pos(self, pos: QPointF) -> Ring | None:
|
||||
if not self.rings:
|
||||
return None
|
||||
size = min(self.width(), self.height())
|
||||
if size <= 0:
|
||||
return None
|
||||
x_offset = (self.width() - size) / 2
|
||||
y_offset = (self.height() - size) / 2
|
||||
center_x = x_offset + size / 2
|
||||
center_y = y_offset + size / 2
|
||||
dx = pos.x() - center_x
|
||||
dy = pos.y() - center_y
|
||||
distance = (dx * dx + dy * dy) ** 0.5
|
||||
|
||||
max_ring_size = self.get_max_ring_size()
|
||||
base_radius = (size - 2 * max_ring_size) / 2
|
||||
if base_radius <= 0:
|
||||
return None
|
||||
|
||||
best_ring: Ring | None = None
|
||||
best_delta: float | None = None
|
||||
for ring in self.rings:
|
||||
radius = base_radius - ring.gap
|
||||
if radius <= 0:
|
||||
continue
|
||||
half_width = ring.config.line_width / 2
|
||||
inner = radius - half_width
|
||||
outer = radius + half_width
|
||||
if inner <= distance <= outer:
|
||||
delta = abs(distance - radius)
|
||||
if best_delta is None or delta < best_delta:
|
||||
best_delta = delta
|
||||
best_ring = ring
|
||||
|
||||
return best_ring
|
||||
|
||||
def is_ring_hovered(self, ring: Ring) -> bool:
|
||||
return ring is self._hovered_ring
|
||||
|
||||
def refresh_hover_tooltip(self, ring: Ring, event=None):
|
||||
text = self._build_tooltip_text(ring)
|
||||
if event is not None:
|
||||
self._last_hover_global_pos = (
|
||||
event.globalPosition().toPoint()
|
||||
if hasattr(event, "globalPosition")
|
||||
else event.globalPos()
|
||||
)
|
||||
if self._last_hover_global_pos is None:
|
||||
return
|
||||
self._hover_tooltip_label.setText(text)
|
||||
self._hover_tooltip.apply_theme()
|
||||
self._hover_tooltip.show_near(self._last_hover_global_pos)
|
||||
|
||||
@staticmethod
|
||||
def _build_tooltip_text(ring: Ring) -> str:
|
||||
mode = ring.config.mode
|
||||
mode_label = {"manual": "Manual", "scan": "Scan progress", "device": "Device"}.get(
|
||||
mode, mode
|
||||
)
|
||||
|
||||
precision = int(ring.config.precision)
|
||||
value = ring.config.value
|
||||
min_value = ring.config.min_value
|
||||
max_value = ring.config.max_value
|
||||
range_span = max(max_value - min_value, 1e-9)
|
||||
progress = max(0.0, min(100.0, ((value - min_value) / range_span) * 100))
|
||||
|
||||
lines = [
|
||||
f"Mode: {mode_label}",
|
||||
f"Progress: {value:.{precision}f} / {max_value:.{precision}f} ({progress:.1f}%)",
|
||||
]
|
||||
if min_value != 0:
|
||||
lines.append(f"Range: {min_value:.{precision}f} -> {max_value:.{precision}f}")
|
||||
if mode == "device" and ring.config.device:
|
||||
if ring.config.signal:
|
||||
lines.append(f"Device: {ring.config.device}:{ring.config.signal}")
|
||||
else:
|
||||
lines.append(f"Device: {ring.config.device}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def closeEvent(self, event):
|
||||
# Ensure the hover tooltip is properly cleaned up when this widget closes
|
||||
tooltip = getattr(self, "_hover_tooltip", None)
|
||||
if tooltip is not None:
|
||||
tooltip.close()
|
||||
tooltip.deleteLater()
|
||||
self._hover_tooltip = None
|
||||
super().closeEvent(event)
|
||||
|
||||
def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "RGB"):
|
||||
"""
|
||||
Set the colors for the progress bars from a colormap.
|
||||
@@ -370,9 +230,6 @@ class RingProgressContainerWidget(QWidget):
|
||||
"""
|
||||
Clear all rings from the widget.
|
||||
"""
|
||||
self._hovered_ring = None
|
||||
self._last_hover_global_pos = None
|
||||
self._hover_tooltip.hide()
|
||||
for ring in self.rings:
|
||||
ring.close()
|
||||
ring.deleteLater()
|
||||
|
||||
@@ -44,12 +44,14 @@ class RingCardWidget(QFrame):
|
||||
self.setObjectName("RingCardWidget")
|
||||
|
||||
bg = self._get_theme_color("BORDER")
|
||||
self.setStyleSheet(f"""
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
#RingCardWidget {{
|
||||
border: 1px solid {bg.name() if bg else '#CCCCCC'};
|
||||
border-radius: 4px;
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
@@ -63,8 +65,7 @@ class RingCardWidget(QFrame):
|
||||
self.mode_combo.setCurrentText(self._get_display_mode_string(self.ring.config.mode))
|
||||
self._set_widget_mode_enabled(self.ring.config.mode)
|
||||
|
||||
@staticmethod
|
||||
def _get_theme_color(color_name: str) -> QColor | None:
|
||||
def _get_theme_color(self, color_name: str) -> QColor | None:
|
||||
app = QApplication.instance()
|
||||
if not app:
|
||||
return
|
||||
@@ -250,13 +251,12 @@ class RingCardWidget(QFrame):
|
||||
def _on_signal_changed(self, signal: str):
|
||||
device = self.ui.device_combo_box.currentText()
|
||||
signal = self.ui.signal_combo_box.get_signal_name()
|
||||
if not device or device not in self.ring.bec_dispatcher.client.device_manager.devices:
|
||||
if not device or device not in self.container.bec_dispatcher.client.device_manager.devices:
|
||||
return
|
||||
self.ring.set_update("device", device=device, signal=signal)
|
||||
self.ring.config.signal = signal
|
||||
|
||||
@staticmethod
|
||||
def _unify_mode_string(mode: str) -> str:
|
||||
def _unify_mode_string(self, mode: str) -> str:
|
||||
"""Convert mode string to a unified format"""
|
||||
mode = mode.lower()
|
||||
if mode == "scan progress":
|
||||
@@ -265,8 +265,7 @@ class RingCardWidget(QFrame):
|
||||
return "device"
|
||||
return mode
|
||||
|
||||
@staticmethod
|
||||
def _get_display_mode_string(mode: str) -> str:
|
||||
def _get_display_mode_string(self, mode: str) -> str:
|
||||
"""Convert mode string to display format"""
|
||||
match mode:
|
||||
case "manual":
|
||||
|
||||
@@ -1,600 +0,0 @@
|
||||
"""Admin View panel for setting up account and messaging services in BEC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import DeploymentInfoMessage, ExperimentInfoMessage
|
||||
from qtpy.QtCore import QSize, Qt, QTimer, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_login import BECLogin
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
MaterialIconAction,
|
||||
WidgetAction,
|
||||
create_action_with_text,
|
||||
)
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_http_service import (
|
||||
AtlasEndpoints,
|
||||
AuthenticatedUserInfo,
|
||||
BECAtlasHTTPService,
|
||||
HTTPResponse,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_mat_card import (
|
||||
ExperimentMatCard,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_selection import (
|
||||
ExperimentSelection,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtWidgets import QToolBar
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class OverviewWidget(QGroupBox):
|
||||
"""Overview Widget for the BEC Atlas Admin view"""
|
||||
|
||||
login_requested = Signal(str, str)
|
||||
change_experiment_requested = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.setContentsMargins(12, 0, 12, 6)
|
||||
self._authenticated = False
|
||||
# Root layout
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.root_layout.setSpacing(0)
|
||||
|
||||
# Stacked Layout to switch between login form and overview content
|
||||
self.stacked_layout = QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
self.stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||
self.root_layout.addLayout(self.stacked_layout)
|
||||
|
||||
self._init_login_view()
|
||||
self._init_experiment_overview()
|
||||
self.stacked_layout.setCurrentWidget(self._login_widget)
|
||||
self._experiment_overview_widget.setVisible(False)
|
||||
|
||||
def set_experiment_info(self, experiment_info: ExperimentInfoMessage):
|
||||
"""Set the experiment information for the overview widget."""
|
||||
self._experiment_overview_widget.set_experiment_info(experiment_info)
|
||||
|
||||
@SafeSlot(bool)
|
||||
def set_authenticated(self, authenticated: bool):
|
||||
"""Set the authentication state of the overview widget."""
|
||||
self._authenticated = authenticated
|
||||
if authenticated:
|
||||
self.stacked_layout.setCurrentWidget(self._experiment_overview_widget)
|
||||
self._experiment_overview_widget.setVisible(True)
|
||||
else:
|
||||
self.stacked_layout.setCurrentWidget(self._login_widget)
|
||||
self._experiment_overview_widget.setVisible(False)
|
||||
|
||||
def _init_login_view(self):
|
||||
"""Initialize the login view."""
|
||||
self._login_widget = QWidget()
|
||||
layout = QHBoxLayout(self._login_widget)
|
||||
self._login_widget.setAutoFillBackground(True)
|
||||
self._login_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
layout.setSpacing(16)
|
||||
|
||||
content = QFrame()
|
||||
content_layout = QVBoxLayout(content)
|
||||
content.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
content.setFrameShadow(QFrame.Shadow.Raised)
|
||||
content.setStyleSheet("""
|
||||
QFrame
|
||||
{
|
||||
border: 1px solid #cccccc;
|
||||
}
|
||||
QLabel
|
||||
{
|
||||
border: none;
|
||||
}
|
||||
""")
|
||||
content_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
content.setFixedSize(400, 280)
|
||||
|
||||
self._login = BECLogin(parent=self)
|
||||
self._login.credentials_entered.connect(self.login_requested.emit)
|
||||
content_layout.addWidget(self._login)
|
||||
layout.addWidget(content)
|
||||
self.stacked_layout.addWidget(self._login_widget)
|
||||
|
||||
def _init_experiment_overview(self):
|
||||
"""Initialize the experiment overview content."""
|
||||
self._experiment_overview_widget = ExperimentMatCard(
|
||||
show_activate_button=True,
|
||||
parent=self,
|
||||
title="Current Experiment",
|
||||
button_text="Change Experiment",
|
||||
)
|
||||
self._experiment_overview_widget.experiment_selected.connect(self._on_experiment_selected)
|
||||
layout = QVBoxLayout(self._experiment_overview_widget)
|
||||
self._experiment_overview_widget.setAutoFillBackground(True)
|
||||
self._experiment_overview_widget.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
layout.setSpacing(16)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.stacked_layout.addWidget(self._experiment_overview_widget)
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _on_experiment_selected(self, _):
|
||||
"""Handle the change experiment button click."""
|
||||
self.change_experiment_requested.emit()
|
||||
|
||||
|
||||
class CustomLogoutAction(MaterialIconAction):
|
||||
"""Custom logout action that can be enabled/disabled based on authentication state."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(
|
||||
icon_name="logout",
|
||||
tooltip="Logout",
|
||||
label_text="Logout",
|
||||
text_position="under",
|
||||
parent=parent,
|
||||
filled=True,
|
||||
)
|
||||
self.action.setEnabled(False) # Initially disabled until authenticated
|
||||
self._tick_timer = QTimer(parent)
|
||||
self._tick_timer.setInterval(1000)
|
||||
self._tick_timer.timeout.connect(self._on_tick)
|
||||
self._login_remaining_s = 0
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the action to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar(QToolBar): The toolbar to add the action to.
|
||||
target(QWidget): The target widget for the action.
|
||||
"""
|
||||
create_action_with_text(toolbar_action=self, toolbar=toolbar, min_size=QSize(70, 40))
|
||||
|
||||
def set_authenticated(self, auth_info: AuthenticatedUserInfo | None):
|
||||
"""Enable or disable the logout action based on authentication state."""
|
||||
if not auth_info:
|
||||
self._tick_timer.stop()
|
||||
self._login_remaining_s = 0
|
||||
self.action.setEnabled(False)
|
||||
self.update_label() # Reset Label text
|
||||
return # No need to set the timer if we're not authenticated
|
||||
self._login_remaining_s = max(0, int(auth_info.exp - time.time())) if auth_info else 0
|
||||
self.action.setEnabled(True)
|
||||
if self._login_remaining_s > 0:
|
||||
self._tick_timer.start()
|
||||
|
||||
def _on_tick(self) -> None:
|
||||
"""Handle the timer countdown tick to update the remaining logout time."""
|
||||
self._login_remaining_s -= 1
|
||||
if self._login_remaining_s <= 0:
|
||||
self.set_authenticated(None) # This will disable the action and stop the timer
|
||||
return
|
||||
|
||||
self.update_label() # Optionally update the label to show remaining time
|
||||
|
||||
def update_label(self):
|
||||
"""Update the label text of the logout action."""
|
||||
if self._login_remaining_s > 0:
|
||||
label_text = f"{self.label_text}\n({self._login_remaining_s}s)"
|
||||
else:
|
||||
label_text = self.label_text
|
||||
self.action.setText(label_text)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the timer when the action is destroyed."""
|
||||
if self._tick_timer.isActive():
|
||||
self._tick_timer.stop()
|
||||
|
||||
|
||||
class AtlasConnectionInfo(QWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
layout.setContentsMargins(6, 6, 6, 12)
|
||||
layout.setSpacing(8)
|
||||
self._bl_info_label = QLabel(self)
|
||||
self._atlas_url_label = QLabel(self)
|
||||
layout.addWidget(self._bl_info_label)
|
||||
layout.addWidget(self._atlas_url_label)
|
||||
self._atlas_url_text = ""
|
||||
|
||||
def set_info(self, realm_id: str, bl_name: str, atlas_url: str):
|
||||
"""Set the connection information for the BEC Atlas API."""
|
||||
bl_info = f"{realm_id} @ {bl_name}"
|
||||
self._bl_info_label.setText(bl_info)
|
||||
self._atlas_url_label.setText(atlas_url)
|
||||
self._atlas_url_text = atlas_url
|
||||
|
||||
def set_logged_in(self, email: str):
|
||||
"""Show login status in the atlas info widget."""
|
||||
self._atlas_url_label.setText(f"{self._atlas_url_text} | {email}")
|
||||
|
||||
def clear_login(self):
|
||||
"""Clear login status from the atlas info widget."""
|
||||
self._atlas_url_label.setText(self._atlas_url_text)
|
||||
|
||||
|
||||
class BECAtlasAdminView(BECWidget, QWidget):
|
||||
|
||||
RPC = False
|
||||
|
||||
authenticated = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
atlas_url: str = "https://bec-atlas-prod.psi.ch/api/v1",
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
|
||||
# State variables
|
||||
self._current_deployment_info: DeploymentInfoMessage | None = None
|
||||
self._current_deployment_info = None
|
||||
self._current_session_info = None
|
||||
self._current_experiment_info = None
|
||||
self._authenticated = False
|
||||
self._atlas_url = atlas_url
|
||||
|
||||
# Root layout
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.root_layout.setSpacing(0)
|
||||
|
||||
# Toolbar for navigation between different views in the admin panel
|
||||
self.toolbar = ModularToolBar(self)
|
||||
self.init_toolbar()
|
||||
self.root_layout.insertWidget(0, self.toolbar)
|
||||
self.toolbar.show_bundles(["view", "atlas_info", "auth"])
|
||||
|
||||
# Stacked layout to switch between overview, experiment selection and messaging services
|
||||
# It is added below the toolbar
|
||||
self.stacked_layout = QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
self.stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||
self.root_layout.addLayout(self.stacked_layout)
|
||||
|
||||
# BEC Atlas HTTP Service
|
||||
self.atlas_http_service = BECAtlasHTTPService(
|
||||
parent=self, base_url=atlas_url, headers={"accept": "application/json"}
|
||||
)
|
||||
|
||||
# Overview widget
|
||||
self.overview_widget = OverviewWidget(parent=self)
|
||||
self.stacked_layout.addWidget(self.overview_widget)
|
||||
|
||||
# Experiment Selection widget
|
||||
self.experiment_selection = ExperimentSelection(parent=self)
|
||||
self.experiment_selection.setVisible(False)
|
||||
self.stacked_layout.addWidget(self.experiment_selection)
|
||||
|
||||
# Connect signals
|
||||
self.overview_widget.login_requested.connect(self._on_login_requested)
|
||||
self.overview_widget.change_experiment_requested.connect(
|
||||
self._on_experiment_selection_selected
|
||||
)
|
||||
self.authenticated.connect(self.overview_widget.set_authenticated)
|
||||
self.experiment_selection.experiment_selected.connect(self._on_experiment_selected)
|
||||
self.atlas_http_service.http_response.connect(self._on_http_response_received)
|
||||
self.atlas_http_service.authenticated.connect(self._on_authenticated)
|
||||
self._connect_dispatcher()
|
||||
|
||||
def _connect_dispatcher(self):
|
||||
self.bec_dispatcher.connect_slot(
|
||||
slot=self._update_deployment_info,
|
||||
topics=MessageEndpoints.deployment_info(),
|
||||
from_start=True,
|
||||
)
|
||||
|
||||
def init_toolbar(self):
|
||||
"""Initialize the toolbar for the admin view. This allows to switch between different views in the admin panel."""
|
||||
# Overview
|
||||
overview = MaterialIconAction(
|
||||
icon_name="home",
|
||||
tooltip="Show Overview Panel",
|
||||
label_text="Overview",
|
||||
text_position="under",
|
||||
parent=self,
|
||||
filled=True,
|
||||
)
|
||||
overview.action.triggered.connect(self._on_overview_selected)
|
||||
self.toolbar.components.add_safe("overview", overview)
|
||||
|
||||
# Experiment Selection
|
||||
experiment_selection = MaterialIconAction(
|
||||
icon_name="experiment",
|
||||
tooltip="Show Experiment Selection Panel",
|
||||
label_text="Experiment Selection",
|
||||
text_position="under",
|
||||
parent=self,
|
||||
filled=True,
|
||||
)
|
||||
experiment_selection.action.triggered.connect(self._on_experiment_selection_selected)
|
||||
experiment_selection.action.setEnabled(False) # Initially disabled until authenticated
|
||||
self.toolbar.components.add_safe("experiment_selection", experiment_selection)
|
||||
|
||||
# Messaging Services
|
||||
messaging_services = MaterialIconAction(
|
||||
icon_name="chat",
|
||||
tooltip="Show Messaging Services Panel",
|
||||
label_text="Messaging Services",
|
||||
text_position="under",
|
||||
parent=self,
|
||||
filled=True,
|
||||
)
|
||||
messaging_services.action.triggered.connect(self._on_messaging_services_selected)
|
||||
messaging_services.action.setEnabled(False) # Initially disabled until authenticated
|
||||
self.toolbar.components.add_safe("messaging_services", messaging_services)
|
||||
|
||||
# Atlas Info
|
||||
self._atlas_info_widget = AtlasConnectionInfo(parent=self)
|
||||
atlas_info = WidgetAction(widget=self._atlas_info_widget, parent=self)
|
||||
self.toolbar.components.add_safe("atlas_info", atlas_info)
|
||||
|
||||
logout_action = CustomLogoutAction(parent=self)
|
||||
logout_action.action.triggered.connect(self.logout)
|
||||
logout_action.action.setEnabled(False) # Initially disabled until authenticated
|
||||
self.toolbar.components.add_safe("logout", logout_action)
|
||||
|
||||
# Add view_bundle to toolbar
|
||||
view_bundle = ToolbarBundle("view", self.toolbar.components)
|
||||
view_bundle.add_action("overview")
|
||||
view_bundle.add_action("experiment_selection")
|
||||
view_bundle.add_action("messaging_services")
|
||||
self.toolbar.add_bundle(view_bundle)
|
||||
|
||||
# Add atlas_info to toolbar
|
||||
atlas_info_bundle = ToolbarBundle("atlas_info", self.toolbar.components)
|
||||
atlas_info_bundle.add_action("atlas_info")
|
||||
self.toolbar.add_bundle(atlas_info_bundle)
|
||||
|
||||
# Add auth_bundle to toolbar
|
||||
auth_bundle = ToolbarBundle("auth", self.toolbar.components)
|
||||
auth_bundle.add_action("logout")
|
||||
self.toolbar.add_bundle(auth_bundle)
|
||||
|
||||
########################
|
||||
## Toolbar icon slots
|
||||
########################
|
||||
|
||||
def _on_overview_selected(self):
|
||||
"""Show the overview panel."""
|
||||
self.overview_widget.setVisible(True)
|
||||
self.experiment_selection.setVisible(False)
|
||||
self.stacked_layout.setCurrentWidget(self.overview_widget)
|
||||
|
||||
def _on_experiment_selection_selected(self):
|
||||
"""Show the experiment selection panel."""
|
||||
if not self._authenticated:
|
||||
logger.warning("Attempted to access experiment selection without authentication.")
|
||||
return
|
||||
self.overview_widget.setVisible(False)
|
||||
self.experiment_selection.setVisible(True)
|
||||
self.stacked_layout.setCurrentWidget(self.experiment_selection)
|
||||
|
||||
def _on_messaging_services_selected(self):
|
||||
"""Show the messaging services panel."""
|
||||
logger.info("Messaging services panel is not implemented yet.")
|
||||
return
|
||||
|
||||
########################
|
||||
## Internal slots
|
||||
########################
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _on_experiment_selected(self, experiment_info: dict) -> None:
|
||||
"""Handle the experiment selected signal from the experiment selection widget"""
|
||||
experiment_info = ExperimentInfoMessage.model_validate(experiment_info)
|
||||
experiment_id = experiment_info.pgroup
|
||||
deployment_id = self._current_deployment_info.deployment_id
|
||||
self.set_experiment(experiment_id=experiment_id, deployment_id=deployment_id)
|
||||
|
||||
@SafeSlot(str, str, popup_error=True)
|
||||
def _on_login_requested(self, username: str, password: str):
|
||||
"""Handle login requested signal from the overview widget."""
|
||||
# Logout first to clear any existing session and cookies before attempting a new login
|
||||
if self._authenticated:
|
||||
logger.info("Existing session detected, logging out before attempting new login.")
|
||||
self.logout()
|
||||
# Now login with new credentials
|
||||
self.login(username, password)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def _update_deployment_info(self, msg: dict, _: dict) -> None:
|
||||
"""Fetch current deployment info from the server."""
|
||||
deployment = DeploymentInfoMessage.model_validate(msg)
|
||||
self._current_deployment_info = deployment
|
||||
self._current_session_info = deployment.active_session
|
||||
if self._current_session_info is not None:
|
||||
self._current_experiment_info = self._current_session_info.experiment
|
||||
self.overview_widget.set_experiment_info(self._current_experiment_info)
|
||||
|
||||
self._atlas_info_widget.set_info(
|
||||
realm_id=self._current_experiment_info.realm_id or "",
|
||||
bl_name=self._current_deployment_info.name or "",
|
||||
atlas_url=self._atlas_url,
|
||||
)
|
||||
self.atlas_http_service._set_current_deployment_info(deployment)
|
||||
|
||||
def _fetch_available_experiments(self):
|
||||
"""Fetch the list of available experiments for the authenticated user."""
|
||||
# What if this is None, should this be an optional user input in the UI?
|
||||
if self._current_experiment_info is None:
|
||||
logger.error(
|
||||
"No current experiment info available, cannot fetch available experiments."
|
||||
)
|
||||
return
|
||||
current_realm_id = self._current_experiment_info.realm_id
|
||||
if current_realm_id is None:
|
||||
logger.error(
|
||||
"Current experiment does not have a realm_id, cannot fetch available experiments."
|
||||
)
|
||||
return
|
||||
self.atlas_http_service.get_experiments_for_realm(current_realm_id)
|
||||
|
||||
########################
|
||||
## HTTP Service response handling
|
||||
########################
|
||||
|
||||
def _on_http_response_received(self, response: dict) -> None:
|
||||
"""Handle the HTTP response received from the BEC Atlas API."""
|
||||
response = HTTPResponse(**response)
|
||||
logger.debug(
|
||||
f"HTTP Response received: {response.request_url} with status {response.status}"
|
||||
)
|
||||
if AtlasEndpoints.REALMS_EXPERIMENTS in response.request_url:
|
||||
experiments = response.data if isinstance(response.data, list) else []
|
||||
self.experiment_selection.set_experiment_infos(experiments)
|
||||
elif AtlasEndpoints.SET_EXPERIMENT in response.request_url:
|
||||
self._on_overview_selected()
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _on_authenticated(self, auth_info: dict) -> None:
|
||||
"""Handle authentication state change."""
|
||||
authenticated = False
|
||||
# Only if the user has owner access to the deployment, we consider them to be fully authenticated
|
||||
# This means that although they may authenticate against atlas, they won't be able to see any
|
||||
# extra information here
|
||||
if auth_info:
|
||||
info = AuthenticatedUserInfo.model_validate(auth_info)
|
||||
if (
|
||||
self._current_deployment_info
|
||||
and info.deployment_id == self._current_deployment_info.deployment_id
|
||||
):
|
||||
authenticated = True
|
||||
else:
|
||||
logger.warning(
|
||||
f"Authenticated user {info.email} does not have access to the current deployment {self._current_deployment_info.name if self._current_deployment_info else '<no deployment>'}."
|
||||
)
|
||||
self._authenticated = authenticated
|
||||
|
||||
if authenticated:
|
||||
self.toolbar.components.get_action("experiment_selection").action.setEnabled(True)
|
||||
self.toolbar.components.get_action("messaging_services").action.setEnabled(
|
||||
False
|
||||
) # TODO activate once messaging is added
|
||||
self.toolbar.components.get_action("logout").action.setEnabled(True)
|
||||
self._fetch_available_experiments() # Fetch experiments upon successful authentication
|
||||
self._atlas_info_widget.set_logged_in(info.email)
|
||||
self.toolbar.components.get_action("logout").set_authenticated(info)
|
||||
else:
|
||||
self.toolbar.components.get_action("experiment_selection").action.setEnabled(False)
|
||||
self.toolbar.components.get_action("messaging_services").action.setEnabled(False)
|
||||
self.toolbar.components.get_action("logout").action.setEnabled(False)
|
||||
# Delete data in experiment selection widget upon logout
|
||||
self.experiment_selection.set_experiment_infos([])
|
||||
self._on_overview_selected() # Switch back to overview on logout
|
||||
self._atlas_info_widget.clear_login() # Clear login status in atlas info widget on logout
|
||||
self.toolbar.components.get_action("logout").set_authenticated(None)
|
||||
self.authenticated.emit(authenticated)
|
||||
|
||||
################
|
||||
## API Methods
|
||||
################
|
||||
|
||||
@SafeSlot(str, str, popup_error=True)
|
||||
def set_experiment(self, experiment_id: str, deployment_id: str) -> None:
|
||||
"""Set the experiment information for the current experiment."""
|
||||
self.atlas_http_service.set_experiment(experiment_id, deployment_id)
|
||||
|
||||
@SafeSlot(str, str, popup_error=True)
|
||||
def login(self, username: str, password: str) -> None:
|
||||
"""Login to the BEC Atlas API with the provided username and password."""
|
||||
self.atlas_http_service.login(username=username, password=password)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def logout(self) -> None:
|
||||
"""Logout from the BEC Atlas API."""
|
||||
self.atlas_http_service.logout()
|
||||
|
||||
def get_user_info(self):
|
||||
"""Get the current user information from the BEC Atlas API."""
|
||||
self.atlas_http_service.get_user_info()
|
||||
|
||||
###############
|
||||
## Cleanup
|
||||
###############
|
||||
|
||||
def cleanup(self):
|
||||
self.atlas_http_service.cleanup()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
apply_theme("light")
|
||||
window = BECAtlasAdminView()
|
||||
|
||||
exp_info_dict = {
|
||||
"_id": "p22622",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22622"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "12345967",
|
||||
"title": "Test Experiment for Mat Card Widget",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"email": "john.doe@psi.ch",
|
||||
"account": "doe_j",
|
||||
"pi_firstname": "Jane",
|
||||
"pi_lastname": "Smith",
|
||||
"pi_email": "jane.smith@psi.ch",
|
||||
"pi_account": "smith_j",
|
||||
"eaccount": "e22622",
|
||||
"pgroup": "p22622",
|
||||
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
|
||||
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
|
||||
"proposal_submitted": "15/12/2024",
|
||||
"proposal_expire": "31/12/2025",
|
||||
"proposal_status": "Scheduled",
|
||||
"delta_last_schedule": 30,
|
||||
"mainproposal": "",
|
||||
}
|
||||
from bec_lib.messages import DeploymentInfoMessage, ExperimentInfoMessage, SessionInfoMessage
|
||||
|
||||
# proposal_info = ExperimentInfoMessage(**exp_info_dict)
|
||||
# session_info = SessionInfoMessage(name="Test Session", experiment=proposal_info)
|
||||
# deployment_info = DeploymentInfoMessage(
|
||||
# deployment_id="test_deployment_001", active_session=session_info
|
||||
# )
|
||||
# window.set_experiment_info(proposal_info)
|
||||
window.resize(800, 600)
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,395 +0,0 @@
|
||||
"""
|
||||
HTTP service widget for interacting with the BEC Atlas API.
|
||||
|
||||
This module defines Qt-based classes that wrap ``QNetworkAccessManager`` to perform
|
||||
authenticated HTTP requests against the BEC Atlas backend, manage login and logout
|
||||
flows, and track authentication token expiry for the BEC Atlas Admin View.
|
||||
|
||||
It also provides Pydantic models that describe HTTP responses and authenticated user
|
||||
information, as well as an ``AuthenticatedTimer`` helper used to signal when
|
||||
authentication tokens expire.
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
import jwt
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import DeploymentInfoMessage
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtCore import QObject, QTimer, QUrl, QUrlQuery, Signal
|
||||
from qtpy.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
|
||||
from qtpy.QtWidgets import QMessageBox, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class AtlasEndpoints(StrEnum):
|
||||
"""Constants for BEC Atlas API endpoints."""
|
||||
|
||||
LOGIN = "/user/login"
|
||||
LOGOUT = "/user/logout"
|
||||
REALMS_EXPERIMENTS = "/realms/experiments"
|
||||
SET_EXPERIMENT = "/deployments/experiment"
|
||||
USER_INFO = "/user/me"
|
||||
DEPLOYMENT_INFO = "/deployments/id"
|
||||
|
||||
|
||||
class BECAtlasHTTPError(Exception):
|
||||
"""Custom exception for BEC Atlas HTTP errors."""
|
||||
|
||||
|
||||
class HTTPResponse(BaseModel):
|
||||
"""Model representing an HTTP response."""
|
||||
|
||||
request_url: str
|
||||
headers: dict
|
||||
status: int
|
||||
data: dict | list | str # Check with Klaus if str is deprecated
|
||||
|
||||
|
||||
class AuthenticatedUserInfo(BaseModel):
|
||||
"""Model representing authenticated user information."""
|
||||
|
||||
email: str
|
||||
exp: float
|
||||
groups: set[str]
|
||||
deployment_id: str
|
||||
|
||||
|
||||
class AuthenticatedTimer(QObject):
|
||||
"""Timer to track authentication expiration and emit a signal when the token expires."""
|
||||
|
||||
expired = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._on_expired)
|
||||
|
||||
def start(self, duration_seconds: float):
|
||||
"""Start the timer with the given duration in seconds."""
|
||||
self._timer.start(int(duration_seconds * 1000))
|
||||
|
||||
def stop(self):
|
||||
"""Stop the timer."""
|
||||
self._timer.stop()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_expired(self):
|
||||
"""Handle the timer expiration by emitting the expired signal."""
|
||||
logger.info("Authentication token has expired.")
|
||||
self.expired.emit()
|
||||
|
||||
|
||||
class BECAtlasHTTPService(QWidget):
|
||||
"""HTTP service using the QNetworkAccessManager to interact with the BEC Atlas API."""
|
||||
|
||||
http_response = Signal(dict) # HTTPResponse.model_dump() dict
|
||||
authenticated = Signal(dict) # AuthenticatedUserInfo.model_dump() dict or {}
|
||||
authentication_expires = Signal(float)
|
||||
|
||||
def __init__(self, parent=None, base_url: str = "", headers: dict | None = None):
|
||||
super().__init__(parent)
|
||||
if headers is None:
|
||||
headers = {"accept": "application/json"}
|
||||
self._headers = headers
|
||||
self._base_url = base_url
|
||||
self.network_manager = QNetworkAccessManager(self)
|
||||
self.network_manager.finished.connect(self._handle_response)
|
||||
self._auth_user_info: AuthenticatedUserInfo | None = None
|
||||
self._auth_timer = self._create_auth_timer()
|
||||
self._current_deployment_info = None
|
||||
|
||||
def _create_auth_timer(self) -> AuthenticatedTimer:
|
||||
"""Create and connect the authenticated timer to handle token expiration."""
|
||||
timer = AuthenticatedTimer(self)
|
||||
timer.expired.connect(self.__clear_login_info)
|
||||
return timer
|
||||
|
||||
@property
|
||||
def auth_user_info(self) -> AuthenticatedUserInfo | None:
|
||||
"""Get the authenticated user information, including email and token expiration time."""
|
||||
return self._auth_user_info
|
||||
|
||||
def __set_auth_info(self, login_info: dict[Literal["email", "exp"], str | float]):
|
||||
"""Set the authenticated user information after a successful login."""
|
||||
login_info.update({"groups": []}) # Initialize groups as empty until we fetch user info
|
||||
login_info.update(
|
||||
{
|
||||
"deployment_id": (
|
||||
self._current_deployment_info.deployment_id
|
||||
if self._current_deployment_info
|
||||
else ""
|
||||
)
|
||||
}
|
||||
)
|
||||
self._auth_user_info = AuthenticatedUserInfo(**login_info)
|
||||
# Start timer to clear auth info once token expires
|
||||
exp_time = login_info.get("exp", 0)
|
||||
current_time = time.time() # TODO should we use server time to avoid clock skew issues?
|
||||
duration = max(0, exp_time - current_time)
|
||||
self._auth_timer.start(duration)
|
||||
|
||||
def __set_auth_groups(self, groups: list[str]):
|
||||
"""Set the authenticated user's groups after fetching user info."""
|
||||
if self._auth_user_info is not None:
|
||||
self._auth_user_info.groups = set(groups)
|
||||
|
||||
def __check_access_to_owner_groups(self, groups: list[str]) -> bool:
|
||||
"""Check if the authenticated user has access to the current deployment based on their groups."""
|
||||
if self._auth_user_info is None or self._current_deployment_info is None:
|
||||
return False
|
||||
# Admin user
|
||||
has_both = {"admin", "atlas_func_account"}.issubset(groups)
|
||||
if has_both:
|
||||
return True
|
||||
# Regular user check with group intersection
|
||||
return not self.auth_user_info.groups.isdisjoint(groups)
|
||||
|
||||
def __clear_login_info(self, skip_logout: bool = False):
|
||||
"""Clear the authenticated user information after logout."""
|
||||
self._auth_user_info = None
|
||||
if not skip_logout:
|
||||
self.logout() # Ensure we also logout on the server side and invalidate the session
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.cleanup()
|
||||
return super().closeEvent(event)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup connection, destroy authenticate cookies."""
|
||||
logger.info("Cleaning up BECAtlasHTTPService: disconnecting signals and clearing cookies.")
|
||||
# Disconnect signals to avoid handling responses after cleanup
|
||||
self.network_manager.finished.disconnect(self._handle_response)
|
||||
|
||||
# Logout to invalidate session on server side
|
||||
self.logout()
|
||||
|
||||
# Stop the authentication timer
|
||||
self._auth_timer.stop()
|
||||
|
||||
# Delete all cookies related to the base URL
|
||||
for cookie in self.network_manager.cookieJar().cookiesForUrl(QUrl(self._base_url)):
|
||||
self.network_manager.cookieJar().deleteCookie(cookie)
|
||||
|
||||
@SafeSlot(QNetworkReply, popup_error=True)
|
||||
def _handle_response(self, reply: QNetworkReply):
|
||||
"""
|
||||
Handle the HTTP response from the server.
|
||||
|
||||
Args:
|
||||
reply (QNetworkReply): The network reply object containing the response.
|
||||
"""
|
||||
status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
|
||||
raw_bytes = reply.readAll().data()
|
||||
request_url = reply.url().toString()
|
||||
headers = dict([(i.data().decode(), j.data().decode()) for i, j in reply.rawHeaderPairs()])
|
||||
reply.deleteLater()
|
||||
|
||||
# Any unsuccessful status code should raise here
|
||||
if status != 200:
|
||||
raise BECAtlasHTTPError(
|
||||
f"HTTP request for {request_url} failed with status code {status} and response: {raw_bytes.decode('utf-8')}"
|
||||
)
|
||||
|
||||
if len(raw_bytes) > 0:
|
||||
data = json.loads(raw_bytes.decode())
|
||||
else:
|
||||
data = {}
|
||||
|
||||
if data is None:
|
||||
data = {}
|
||||
logger.warning(f"Received empty response for {request_url} with status code {status}.")
|
||||
|
||||
if not isinstance(data, (dict, list, str)):
|
||||
raise BECAtlasHTTPError(
|
||||
f"Expected response data to be a dict, list, or str for {request_url}, but got {type(data)}. Response content: {data}"
|
||||
)
|
||||
|
||||
if AtlasEndpoints.LOGIN.value in request_url:
|
||||
# If it's a login response, don't forward the token
|
||||
# but extract the expiration time and emit it
|
||||
token = data.get("access_token")
|
||||
data = jwt.decode(token, options={"verify_signature": False})
|
||||
self.authentication_expires.emit(data.get("exp", 0))
|
||||
# Now we set the auth info, and then fetch the user info to get the groups
|
||||
self.__set_auth_info(data)
|
||||
self.get_user_info() # Fetch groups, then emit authenticated once groups are set on auth_user
|
||||
elif AtlasEndpoints.LOGOUT.value in request_url:
|
||||
self._auth_timer.stop() # Stop the timer if it was running
|
||||
self.__clear_login_info(skip_logout=True) # Skip calling logout again
|
||||
self.authenticated.emit({})
|
||||
elif AtlasEndpoints.USER_INFO.value in request_url:
|
||||
groups = data.get("groups", [])
|
||||
email = data.get("email", "")
|
||||
# Second step of authentication: We also have all groups now
|
||||
if self.auth_user_info is not None and self.auth_user_info.email == email:
|
||||
self.__set_auth_groups(groups)
|
||||
if self._current_deployment_info is not None:
|
||||
# Now we need to fetch the deployment info to get the owner groups and check access rights,
|
||||
# Then we can emit the authenticated signal with the full user info including groups if access is
|
||||
# granted. Otherwise, we emit nothing and show a warning that the user does not have the access
|
||||
# rights for the current deployment.
|
||||
self.get_deployment_info(
|
||||
deployment_id=self._current_deployment_info.deployment_id
|
||||
)
|
||||
elif AtlasEndpoints.DEPLOYMENT_INFO.value in request_url:
|
||||
owner_groups = data.get("owner_groups", [])
|
||||
if self.__check_access_to_owner_groups(owner_groups):
|
||||
self.authenticated.emit(self.auth_user_info.model_dump())
|
||||
else:
|
||||
if self.auth_user_info is not None:
|
||||
warning_text = f"User {self.auth_user_info.email} does not have access to the active deployment {data.get('name', '<unknown>')}."
|
||||
else:
|
||||
warning_text = "Authenticated user information is missing. Cannot verify access to the active deployment."
|
||||
self._show_warning(warning_text)
|
||||
self.logout() # Logout to clear auth info and stop timer since user does not have access
|
||||
|
||||
response = HTTPResponse(request_url=request_url, headers=headers, status=status, data=data)
|
||||
self.http_response.emit(response.model_dump())
|
||||
|
||||
def _show_warning(self, text: str):
|
||||
"""Show a warning message to the user."""
|
||||
msg = QMessageBox(self)
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
msg.setText(text)
|
||||
msg.setWindowTitle("Authentication Warning")
|
||||
msg.exec_()
|
||||
|
||||
#######################
|
||||
# GET/POST Request Methods
|
||||
#######################
|
||||
|
||||
def _get_request(self, endpoint: str, query_parameters: dict | None = None):
|
||||
"""
|
||||
GET request to the API endpoint.
|
||||
|
||||
Args:
|
||||
endpoint (str): The API endpoint to send the GET request to.
|
||||
query_parameters (dict | None): Optional query parameters to include in the URL.
|
||||
"""
|
||||
url = QUrl(self._base_url + endpoint)
|
||||
if query_parameters:
|
||||
query = QUrlQuery()
|
||||
for key, value in query_parameters.items():
|
||||
query.addQueryItem(key, value)
|
||||
url.setQuery(query)
|
||||
request = QNetworkRequest(url)
|
||||
for key, value in self._headers.items():
|
||||
request.setRawHeader(key.encode(), value.encode())
|
||||
self.network_manager.get(request)
|
||||
|
||||
def _post_request(
|
||||
self, endpoint: str, payload: dict | None = None, query_parameters: dict | None = None
|
||||
):
|
||||
"""
|
||||
POST request to the API endpoint with a JSON payload.
|
||||
|
||||
Args:
|
||||
endpoint (str): The API endpoint to send the POST request to.
|
||||
payload (dict): The JSON payload to include in the POST request.
|
||||
query_parameters (dict | None): Optional query parameters to include in the URL.
|
||||
"""
|
||||
if payload is None:
|
||||
payload = {}
|
||||
url = QUrl(self._base_url + endpoint)
|
||||
if query_parameters:
|
||||
query = QUrlQuery()
|
||||
for key, value in query_parameters.items():
|
||||
query.addQueryItem(key, value)
|
||||
url.setQuery(query)
|
||||
request = QNetworkRequest(url)
|
||||
|
||||
# Headers
|
||||
for key, value in self._headers.items():
|
||||
request.setRawHeader(key.encode(), value.encode())
|
||||
request.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json")
|
||||
|
||||
payload_dump = json.dumps(payload).encode()
|
||||
self.network_manager.post(request, payload_dump)
|
||||
|
||||
def _set_current_deployment_info(self, deployment_info: dict | DeploymentInfoMessage):
|
||||
"""
|
||||
Set the current deployment information for the service.
|
||||
|
||||
Args:
|
||||
deployment_info (dict | DeploymentInfoMessage): The deployment information to set.
|
||||
"""
|
||||
if isinstance(deployment_info, dict):
|
||||
deployment_info = DeploymentInfoMessage.model_validate(deployment_info)
|
||||
self._current_deployment_info = deployment_info
|
||||
|
||||
################
|
||||
# API Methods
|
||||
################
|
||||
|
||||
@SafeSlot(str, str, popup_error=True)
|
||||
def login(self, username: str, password: str):
|
||||
"""
|
||||
Login to BEC Atlas with the provided username and password.
|
||||
|
||||
Args:
|
||||
username (str): The username for authentication.
|
||||
password (str): The password for authentication.
|
||||
"""
|
||||
self._post_request(
|
||||
endpoint=AtlasEndpoints.LOGIN.value,
|
||||
payload={"username": username, "password": password},
|
||||
)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def logout(self):
|
||||
"""Logout from BEC Atlas."""
|
||||
self._post_request(endpoint=AtlasEndpoints.LOGOUT.value)
|
||||
|
||||
@SafeSlot(str, popup_error=True)
|
||||
def get_experiments_for_realm(self, realm_id: str):
|
||||
"""
|
||||
Get the list of realms from BEC Atlas. Requires authentication.
|
||||
|
||||
Args:
|
||||
realm_id (str): The ID of the realm to retrieve experiments for.
|
||||
"""
|
||||
self._get_request(
|
||||
endpoint=AtlasEndpoints.REALMS_EXPERIMENTS.value,
|
||||
query_parameters={"realm_id": realm_id},
|
||||
)
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def set_experiment(self, experiment_id: str, deployment_id: str) -> None:
|
||||
"""
|
||||
Set the current experiment information for the service.
|
||||
|
||||
Args:
|
||||
experiment_id (str): The ID of the experiment to set.
|
||||
deployment_id (str): The ID of the deployment associated with the experiment.
|
||||
"""
|
||||
self._post_request(
|
||||
endpoint=AtlasEndpoints.SET_EXPERIMENT.value,
|
||||
query_parameters={"experiment_id": experiment_id, "deployment_id": deployment_id},
|
||||
)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def get_user_info(self):
|
||||
"""Get the current user information from BEC Atlas. Requires authentication."""
|
||||
self._get_request(endpoint=AtlasEndpoints.USER_INFO.value)
|
||||
|
||||
@SafeSlot(str, popup_error=True)
|
||||
def get_deployment_info(self, deployment_id: str):
|
||||
"""
|
||||
Get the deployment information for a given deployment ID. Requires authentication.
|
||||
|
||||
Args:
|
||||
deployment_id (str): The ID of the deployment to retrieve information for.
|
||||
"""
|
||||
self._get_request(
|
||||
endpoint=AtlasEndpoints.DEPLOYMENT_INFO.value,
|
||||
query_parameters={"deployment_id": deployment_id},
|
||||
)
|
||||
@@ -1,273 +0,0 @@
|
||||
"""Mat-card like widget to display experiment details. Optionally, a button on the bottom which the user can click to trigger the selection of the experiment."""
|
||||
|
||||
from bec_lib.messages import ExperimentInfoMessage
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QFrame,
|
||||
QGraphicsDropShadowEffect,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
|
||||
format_name,
|
||||
format_schedule,
|
||||
)
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
|
||||
class BorderLessLabel(QLabel):
|
||||
"""A QLabel that does not show any border, even when stylesheets try to apply one."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setStyleSheet("border: none;")
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
|
||||
|
||||
|
||||
class ExperimentMatCard(BECWidget, QWidget):
|
||||
|
||||
RPC = False
|
||||
|
||||
experiment_selected = Signal(dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
show_activate_button: bool = True,
|
||||
button_text: str = "Activate",
|
||||
title: str = "Next Experiment",
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, theme_update=True, **kwargs)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(12, 8, 12, 8)
|
||||
self.experiment_info = {}
|
||||
self._abstract_text = ""
|
||||
|
||||
# Add card frame with shadow and custom styling
|
||||
self._card_frame = QFrame(parent=self)
|
||||
layout = QVBoxLayout(self._card_frame)
|
||||
self._card_frame.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
||||
palette = get_theme_palette()
|
||||
self._card_frame.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
border: 1px solid {palette.mid().color().name()};
|
||||
background: {palette.base().color().name()};
|
||||
}}
|
||||
""")
|
||||
shadow = QGraphicsDropShadowEffect(self._card_frame)
|
||||
shadow.setBlurRadius(18)
|
||||
shadow.setOffset(0, 4)
|
||||
shadow.setColor(palette.shadow().color())
|
||||
self._card_frame.setGraphicsEffect(shadow)
|
||||
|
||||
self._group_box = QGroupBox(self._card_frame)
|
||||
self._group_box.setStyleSheet(
|
||||
"QGroupBox { border: none; }; QLabel { border: none; padding: 0px; }"
|
||||
)
|
||||
self._fill_group_box(
|
||||
title=title, show_activate_button=show_activate_button, button_text=button_text
|
||||
)
|
||||
self.apply_theme("light")
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
palette = get_theme_palette()
|
||||
self._card_frame.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
border: 1px solid {palette.mid().color().name()};
|
||||
background: {palette.base().color().name()};
|
||||
}}
|
||||
""")
|
||||
shadow = self._card_frame.graphicsEffect()
|
||||
if isinstance(shadow, QGraphicsDropShadowEffect):
|
||||
shadow.setColor(palette.shadow().color())
|
||||
|
||||
def _fill_group_box(
|
||||
self, title: str, show_activate_button: bool, button_text: str = "Activate"
|
||||
):
|
||||
group_layout = QVBoxLayout(self._group_box)
|
||||
group_layout.setContentsMargins(16, 16, 16, 16)
|
||||
group_layout.setSpacing(12)
|
||||
|
||||
title_row = QHBoxLayout()
|
||||
self._card_title = BorderLessLabel(title, self._group_box)
|
||||
self._card_title.setStyleSheet("""
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
""")
|
||||
|
||||
# Add title row and info button to QH layout, then add it to QV layout
|
||||
title_row.addWidget(self._card_title)
|
||||
title_row.addStretch(1)
|
||||
group_layout.addLayout(title_row)
|
||||
|
||||
self._card_grid = QGridLayout()
|
||||
self._card_grid.setHorizontalSpacing(12)
|
||||
self._card_grid.setVerticalSpacing(8)
|
||||
self._card_grid.setColumnStretch(1, 1)
|
||||
|
||||
self._card_pgroup = BorderLessLabel("-", self._group_box)
|
||||
self._card_title_value = BorderLessLabel("-", self._group_box)
|
||||
self._card_title_value.setWordWrap(True)
|
||||
self._card_title_value.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
self._card_name = BorderLessLabel("-", self._group_box)
|
||||
self._card_start = BorderLessLabel("-", self._group_box)
|
||||
self._card_end = BorderLessLabel("-", self._group_box)
|
||||
|
||||
self._card_row_labels = []
|
||||
|
||||
def _row_label(text):
|
||||
label = BorderLessLabel(text, self._group_box)
|
||||
self._card_row_labels.append(label)
|
||||
return label
|
||||
|
||||
self._card_grid.addWidget(_row_label("Name"), 0, 0)
|
||||
self._card_grid.addWidget(self._card_name, 0, 1)
|
||||
self._card_grid.addWidget(_row_label("Title"), 1, 0)
|
||||
self._card_grid.addWidget(self._card_title_value, 1, 1)
|
||||
self._card_grid.addWidget(_row_label("P-group"), 2, 0)
|
||||
self._card_grid.addWidget(self._card_pgroup, 2, 1)
|
||||
self._card_grid.addWidget(_row_label("Schedule (start)"), 3, 0)
|
||||
self._card_grid.addWidget(self._card_start, 3, 1)
|
||||
self._card_grid.addWidget(_row_label("Schedule (end)"), 4, 0)
|
||||
self._card_grid.addWidget(self._card_end, 4, 1)
|
||||
|
||||
# Add to groupbox
|
||||
group_layout.addLayout(self._card_grid)
|
||||
|
||||
# Add abstract field at the bottom of the card.
|
||||
self._abstract_label = BorderLessLabel("", self._group_box)
|
||||
self._abstract_label.setWordWrap(True)
|
||||
self._abstract_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
group_layout.addWidget(self._abstract_label)
|
||||
|
||||
# Add activate button at the bottom
|
||||
self._activate_button = QPushButton(button_text, self._group_box)
|
||||
self._activate_button.clicked.connect(self._emit_next_experiment)
|
||||
self._activate_button.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
|
||||
)
|
||||
group_layout.addWidget(self._activate_button, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
self._activate_button.setVisible(show_activate_button)
|
||||
self._activate_button.setEnabled(False)
|
||||
|
||||
self._card_frame.layout().setContentsMargins(12, 12, 12, 12)
|
||||
self._card_frame.layout().addWidget(self._group_box)
|
||||
|
||||
card_row = QHBoxLayout()
|
||||
card_row.addStretch(0)
|
||||
card_row.addWidget(self._card_frame)
|
||||
card_row.addStretch(0)
|
||||
|
||||
layout = self.layout()
|
||||
layout.addStretch(0)
|
||||
layout.addLayout(card_row)
|
||||
layout.addStretch(0)
|
||||
|
||||
def _emit_next_experiment(self):
|
||||
self.experiment_selected.emit(self.experiment_info)
|
||||
|
||||
def clear_experiment_info(self):
|
||||
"""
|
||||
Clear the experiment information displayed on the card and disable the activate button.
|
||||
"""
|
||||
self._card_pgroup.setText("-")
|
||||
self._card_title_value.setText("-")
|
||||
self._card_name.setText("-")
|
||||
self._card_start.setText("-")
|
||||
self._card_end.setText("-")
|
||||
self._abstract_text = ""
|
||||
self._abstract_label.setText("")
|
||||
self.experiment_info = {}
|
||||
self._activate_button.setEnabled(False)
|
||||
|
||||
def set_experiment_info(self, info: ExperimentInfoMessage | dict):
|
||||
"""
|
||||
Set the experiment information to display on the card.
|
||||
|
||||
Args:
|
||||
info (ExperimentInfoMessage | dict): The experiment information to display. Can be either a
|
||||
dictionary or an ExperimentInfoMessage instance.
|
||||
"""
|
||||
if isinstance(info, dict):
|
||||
info = ExperimentInfoMessage(**info)
|
||||
|
||||
start, end = format_schedule(info.schedule)
|
||||
self._card_pgroup.setText(info.pgroup or "-")
|
||||
self._card_title_value.setText(info.title or "-")
|
||||
self._card_name.setText(format_name(info))
|
||||
self._card_start.setText(start or "-")
|
||||
self._card_end.setText(end or "-")
|
||||
self._abstract_text = (info.abstract or "").strip()
|
||||
self._abstract_label.setText(self._abstract_text if self._abstract_text else "")
|
||||
self.experiment_info = info.model_dump()
|
||||
self._activate_button.setEnabled(True)
|
||||
|
||||
def set_title(self, title: str):
|
||||
"""
|
||||
Set the title displayed at the top of the card.
|
||||
|
||||
Args:
|
||||
title (str): The title text to display.
|
||||
"""
|
||||
self._card_title.setText(title)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
exp_info = {
|
||||
"_id": "p22622",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22622"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "12345967",
|
||||
"title": "Test Experiment for Mat Card Widget",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"email": "john.doe@psi.ch",
|
||||
"account": "doe_j",
|
||||
"pi_firstname": "Jane",
|
||||
"pi_lastname": "Smith",
|
||||
"pi_email": "jane.smith@psi.ch",
|
||||
"pi_account": "smith_j",
|
||||
"eaccount": "e22622",
|
||||
"pgroup": "p22622",
|
||||
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
|
||||
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
|
||||
"proposal_submitted": "15/12/2024",
|
||||
"proposal_expire": "31/12/2025",
|
||||
"proposal_status": "Scheduled",
|
||||
"delta_last_schedule": 30,
|
||||
"mainproposal": "",
|
||||
}
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
apply_theme("dark")
|
||||
w = QWidget()
|
||||
l = QVBoxLayout(w)
|
||||
button = DarkModeButton()
|
||||
widget = ExperimentMatCard()
|
||||
widget.set_experiment_info(exp_info)
|
||||
widget.set_title("Scheduled Experiment")
|
||||
l.addWidget(button)
|
||||
l.addWidget(widget)
|
||||
w.resize(w.sizeHint())
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,395 +0,0 @@
|
||||
"""Experiment Selection View for BEC Atlas Admin Widget"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSizePolicy,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from thefuzz import fuzz
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.fuzzy_search import is_match
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_mat_card import (
|
||||
ExperimentMatCard,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
|
||||
format_name,
|
||||
format_schedule,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ExperimentSelection(QWidget):
|
||||
experiment_selected = Signal(dict)
|
||||
|
||||
def __init__(self, experiment_infos=None, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self._experiment_infos = experiment_infos or []
|
||||
self._next_experiment = self._select_next_experiment(self._experiment_infos)
|
||||
self._enable_fuzzy_search: bool = True
|
||||
self._hidden_rows: set[int] = set()
|
||||
self._headers: dict[str, str] = {
|
||||
"pgroup": "pgroup",
|
||||
"title": "Title",
|
||||
"name": "Name",
|
||||
"schedule_start": "Schedule (start)",
|
||||
"schedule_end": "Schedule (end)",
|
||||
}
|
||||
self._table_infos: list[dict[str, Any]] = []
|
||||
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
# main_layout.setSpacing(12)
|
||||
main_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.setAutoFillBackground(True)
|
||||
|
||||
self._tabs = QTabWidget(self)
|
||||
main_layout.addWidget(self._tabs, stretch=1)
|
||||
|
||||
self._card_tab = ExperimentMatCard(
|
||||
parent=self, show_activate_button=True, button_text="Activate"
|
||||
)
|
||||
self._card_tab.experiment_selected.connect(self._emit_selected_experiment)
|
||||
if self._next_experiment:
|
||||
self._card_tab.set_experiment_info(self._next_experiment)
|
||||
self._table_tab = QWidget(self)
|
||||
self._tabs.addTab(self._card_tab, "Next Experiment")
|
||||
self._tabs.addTab(self._table_tab, "Manual Selection")
|
||||
|
||||
self._build_table_tab()
|
||||
self._tabs.currentChanged.connect(self._on_tab_changed)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
main_layout.addLayout(button_layout)
|
||||
self._apply_table_filters()
|
||||
self.restore_default_view()
|
||||
|
||||
def restore_default_view(self):
|
||||
"""Reset the view to the default state, showing the next experiment card."""
|
||||
self._tabs.setCurrentWidget(self._card_tab)
|
||||
|
||||
def set_experiment_infos(self, experiment_infos: list[dict]):
|
||||
"""
|
||||
Update the experiment information displayed in the view. It will in addition determine
|
||||
the next experiment to be shown in the card view. If no next experiment can be determined,
|
||||
the card view will be cleared.
|
||||
|
||||
Args:
|
||||
experiment_infos (list[dict]): A list of experiment information dictionaries.
|
||||
"""
|
||||
self._experiment_infos = experiment_infos
|
||||
self._next_experiment = self._select_next_experiment(self._experiment_infos)
|
||||
if self._next_experiment:
|
||||
self._card_tab.set_experiment_info(self._next_experiment)
|
||||
else:
|
||||
self._card_tab.clear_experiment_info()
|
||||
self._apply_table_filters()
|
||||
|
||||
def _setup_search(self, layout: QVBoxLayout):
|
||||
"""
|
||||
Create components related to the search functionality
|
||||
|
||||
Args:
|
||||
layout (QVBoxLayout): The layout to which the search components will be added.
|
||||
"""
|
||||
|
||||
# Create search bar
|
||||
search_layout = QHBoxLayout()
|
||||
self.search_label = QLabel("Search:")
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText("Filter experiments...")
|
||||
self.search_input.setClearButtonEnabled(True)
|
||||
self.search_input.textChanged.connect(self._apply_row_filter)
|
||||
search_layout.addWidget(self.search_label)
|
||||
search_layout.addWidget(self.search_input)
|
||||
|
||||
# Add exact match toggle
|
||||
fuzzy_layout = QHBoxLayout()
|
||||
self.fuzzy_label = QLabel("Exact Match:")
|
||||
self.fuzzy_is_disabled = QCheckBox()
|
||||
|
||||
self.fuzzy_is_disabled.stateChanged.connect(self._state_change_fuzzy_search)
|
||||
self.fuzzy_is_disabled.setToolTip(
|
||||
"Enable approximate matching (OFF) and exact matching (ON)"
|
||||
)
|
||||
self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)")
|
||||
fuzzy_layout.addWidget(self.fuzzy_label)
|
||||
fuzzy_layout.addWidget(self.fuzzy_is_disabled)
|
||||
fuzzy_layout.addStretch()
|
||||
|
||||
# Add both search components to the layout
|
||||
self.search_controls = QHBoxLayout()
|
||||
self.search_controls.addLayout(search_layout)
|
||||
self.search_controls.addSpacing(20) # Add some space between the search box and toggle
|
||||
self.search_controls.addLayout(fuzzy_layout)
|
||||
|
||||
# Add filter section for proposals
|
||||
|
||||
filter_layout = QHBoxLayout()
|
||||
filter_layout.setContentsMargins(12, 0, 12, 0)
|
||||
filter_layout.setSpacing(12)
|
||||
self._with_proposals = QCheckBox("Show experiments with proposals", self)
|
||||
self._without_proposals = QCheckBox("Show experiments without proposals", self)
|
||||
self._with_proposals.setChecked(True)
|
||||
self._without_proposals.setChecked(True)
|
||||
self._with_proposals.toggled.connect(self._apply_table_filters)
|
||||
self._without_proposals.toggled.connect(self._apply_table_filters)
|
||||
filter_layout.addWidget(self._with_proposals)
|
||||
filter_layout.addWidget(self._without_proposals)
|
||||
filter_layout.addStretch(1)
|
||||
self.search_controls.addLayout(filter_layout)
|
||||
|
||||
# Insert the search controls layout at the top of the table
|
||||
layout.addLayout(self.search_controls)
|
||||
|
||||
def _build_table_tab(self):
|
||||
layout = QVBoxLayout(self._table_tab)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self._setup_search(layout)
|
||||
|
||||
# Add table
|
||||
hor_layout = QHBoxLayout()
|
||||
self._table = QTableWidget(self._table_tab)
|
||||
self._table.setColumnCount(5)
|
||||
self._table.setHorizontalHeaderLabels(list(self._headers.values()))
|
||||
vh = self._table.verticalHeader()
|
||||
vh.setVisible(False)
|
||||
vh.setDefaultSectionSize(vh.minimumSectionSize())
|
||||
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self._table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||||
self._table.setWordWrap(True)
|
||||
self._table.setStyleSheet("QTableWidget::item { padding: 4px; }")
|
||||
|
||||
header = self._table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
|
||||
|
||||
self._table.itemSelectionChanged.connect(self._update_selection_state)
|
||||
hor_layout.addWidget(self._table, stretch=5)
|
||||
hor_layout.addSpacing(12) # Add space between table and side card
|
||||
|
||||
# Add side card for experiment details
|
||||
self._side_card = ExperimentMatCard(
|
||||
parent=self, show_activate_button=True, button_text="Activate"
|
||||
)
|
||||
self._side_card.experiment_selected.connect(self._emit_selected_experiment)
|
||||
hor_layout.addWidget(self._side_card, stretch=2) # Ratio 5:2 between table and card
|
||||
layout.addLayout(hor_layout)
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(int)
|
||||
@SafeSlot(bool) # Overload for buttons
|
||||
def _apply_table_filters(self, *args, **kwargs):
|
||||
if self._tabs.currentWidget() is not self._table_tab:
|
||||
return
|
||||
|
||||
show_with = self._with_proposals.isChecked()
|
||||
show_without = self._without_proposals.isChecked()
|
||||
|
||||
self._table_infos = []
|
||||
for info in self._experiment_infos:
|
||||
has_proposal = bool(info.get("proposal"))
|
||||
if has_proposal and not show_with:
|
||||
continue
|
||||
if not has_proposal and not show_without:
|
||||
continue
|
||||
self._table_infos.append(info)
|
||||
|
||||
self._populate_table()
|
||||
self._update_selection_state()
|
||||
|
||||
def _populate_table(self):
|
||||
# Clear table before populating, this keeps headers intact
|
||||
self._table.setRowCount(0)
|
||||
# Refill table
|
||||
self._table.setRowCount(len(self._table_infos))
|
||||
for row, info in enumerate(self._table_infos):
|
||||
pgroup = info.get("pgroup", "")
|
||||
title = info.get("title", "")
|
||||
name = format_name(info)
|
||||
start, end = format_schedule(info.get("schedule"))
|
||||
|
||||
self._table.setItem(row, 0, QTableWidgetItem(pgroup))
|
||||
self._table.setItem(row, 1, QTableWidgetItem(title))
|
||||
self._table.setItem(row, 2, QTableWidgetItem(name))
|
||||
self._table.setItem(row, 3, QTableWidgetItem(start))
|
||||
self._table.setItem(row, 4, QTableWidgetItem(end))
|
||||
|
||||
width = self._table.viewport().width()
|
||||
self._table.resizeRowsToContents()
|
||||
self._table.resize(width, self._table.height())
|
||||
# self._table.resizeRowsToContents()
|
||||
|
||||
@SafeSlot()
|
||||
def _update_selection_state(self):
|
||||
if self._tabs.currentWidget() is not self._table_tab:
|
||||
return
|
||||
index = self._table.selectionModel().selectedRows()
|
||||
if len(index) > 0:
|
||||
index = index[0]
|
||||
self._side_card.set_experiment_info(self._table_infos[index.row()])
|
||||
|
||||
def _emit_selected_experiment(self):
|
||||
if self._tabs.currentWidget() is self._card_tab and self._next_experiment:
|
||||
self.experiment_selected.emit(self._next_experiment)
|
||||
return
|
||||
selected = self._table.selectionModel().selectedRows()
|
||||
if not selected:
|
||||
return
|
||||
row = selected[0].row()
|
||||
if 0 <= row < len(self._table_infos):
|
||||
self.experiment_selected.emit(self._table_infos[row])
|
||||
logger.info(f"Emitting next experiment signal with info: {self._table_infos[row]}")
|
||||
|
||||
def _select_next_experiment(self, experiment_infos: list[dict]) -> dict | None:
|
||||
candidates = []
|
||||
for info in experiment_infos:
|
||||
start, _ = format_schedule(info.get("schedule"), as_datetime=True)
|
||||
if start is None:
|
||||
continue
|
||||
candidates.append((start, info))
|
||||
|
||||
if not candidates:
|
||||
return experiment_infos[0] if experiment_infos else None
|
||||
|
||||
now = datetime.now()
|
||||
future = [entry for entry in candidates if entry[0] >= now]
|
||||
pool = future or candidates
|
||||
return min(pool, key=lambda entry: abs(entry[0] - now))[1]
|
||||
|
||||
def _on_tab_changed(self, index):
|
||||
if self._tabs.widget(index) is self._table_tab:
|
||||
self._table.resizeRowsToContents()
|
||||
if self._next_experiment:
|
||||
self._side_card.set_experiment_info(self._next_experiment)
|
||||
self._apply_table_filters()
|
||||
|
||||
def _get_column_data(self, row) -> dict[str, str]:
|
||||
output = {}
|
||||
for ii, header in enumerate(self._headers.values()):
|
||||
item = self._table.item(row, ii)
|
||||
if item is None:
|
||||
output[header] = ""
|
||||
continue
|
||||
output[header] = item.text()
|
||||
return output
|
||||
|
||||
@SafeSlot(str)
|
||||
def _apply_row_filter(self, text_input: str):
|
||||
"""Apply a filter to the table rows based on the filter text."""
|
||||
if not text_input:
|
||||
for row in self._hidden_rows:
|
||||
self._table.setRowHidden(row, False)
|
||||
self._hidden_rows.clear()
|
||||
return
|
||||
for row in range(self._table.rowCount()):
|
||||
experiment_data = self._get_column_data(row)
|
||||
if is_match(
|
||||
text_input, experiment_data, list(self._headers.values()), self._enable_fuzzy_search
|
||||
):
|
||||
self._table.setRowHidden(row, False)
|
||||
self._hidden_rows.discard(row)
|
||||
else:
|
||||
self._table.setRowHidden(row, True)
|
||||
self._hidden_rows.add(row)
|
||||
|
||||
@SafeSlot(int)
|
||||
def _state_change_fuzzy_search(self, enabled: int):
|
||||
"""Handle state changes for the fuzzy search toggle."""
|
||||
self._enable_fuzzy_search = not bool(enabled)
|
||||
# Re-apply filter with updated fuzzy search setting
|
||||
current_text = self.search_input.text()
|
||||
self._apply_row_filter(current_text)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
experiment_infos = [
|
||||
{
|
||||
"_id": "p22622",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22622"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "12345967",
|
||||
"title": "Test Experiment for Mat Card Widget",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"email": "john.doe@psi.ch",
|
||||
"account": "doe_j",
|
||||
"pi_firstname": "Jane",
|
||||
"pi_lastname": "Smith",
|
||||
"pi_email": "jane.smith@psi.ch",
|
||||
"pi_account": "smith_j",
|
||||
"eaccount": "e22622",
|
||||
"pgroup": "p22622",
|
||||
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
|
||||
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
|
||||
"proposal_submitted": "15/12/2024",
|
||||
"proposal_expire": "31/12/2025",
|
||||
"proposal_status": "Scheduled",
|
||||
"delta_last_schedule": 30,
|
||||
"mainproposal": "",
|
||||
},
|
||||
{
|
||||
"_id": "p22623",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22623"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "",
|
||||
"title": "Experiment without Proposal",
|
||||
"firstname": "Alice",
|
||||
"lastname": "Johnson",
|
||||
"email": "alice.johnson@psi.ch",
|
||||
"account": "johnson_a",
|
||||
"pi_firstname": "Bob",
|
||||
"pi_lastname": "Brown",
|
||||
"pi_email": "bob.brown@psi.ch",
|
||||
"pi_account": "brown_b",
|
||||
"eaccount": "e22623",
|
||||
"pgroup": "p22623",
|
||||
"abstract": "",
|
||||
"schedule": [],
|
||||
"proposal_submitted": "",
|
||||
"proposal_expire": "",
|
||||
"proposal_status": "",
|
||||
"delta_last_schedule": None,
|
||||
"mainproposal": "",
|
||||
},
|
||||
]
|
||||
|
||||
app = QApplication([])
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
apply_theme("light")
|
||||
w = QWidget()
|
||||
l = QVBoxLayout(w)
|
||||
dark_button = DarkModeButton()
|
||||
l.addWidget(dark_button)
|
||||
widget = ExperimentSelection(experiment_infos)
|
||||
l.addWidget(widget)
|
||||
w.resize(1280, 920)
|
||||
w.show()
|
||||
app.exec()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user