1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 01:37:53 +02:00

Compare commits

..

72 Commits

Author SHA1 Message Date
0505fa0a3a wip 2026-04-07 17:52:40 +02:00
semantic-release
9a2396ee9c 3.4.2
Automatically generated by python-semantic-release
2026-04-01 12:55:30 +00:00
2dab16b684 test: Add tests for admin access 2026-04-01 14:54:28 +02:00
e6c8cd0b1a fix: allow admin user to pass deployment group check 2026-04-01 14:54:28 +02:00
242f8933b2 fix(bec-atlas-admin-view): Fix atlas_url to bec-atlas-prod.psi.ch 2026-04-01 14:54:28 +02:00
semantic-release
83ac6bcd37 3.4.1
Automatically generated by python-semantic-release
2026-04-01 08:51:56 +00:00
90ecd8ea87 fix(ring): hook update hover to update method 2026-04-01 10:51:11 +02:00
copilot-swe-agent[bot]
6e5f6e7fbb test(ring_progress_bar): add unit tests for hover behavior 2026-04-01 10:51:11 +02:00
2f75aaea16 fix(ring): changed inheritance to BECWidget and added cleanup 2026-04-01 10:51:11 +02:00
677550931b fix(ring): minor general fixes 2026-04-01 10:51:11 +02:00
96b5179658 fix(ring_progress_bar): added hover mouse effect 2026-04-01 10:51:11 +02:00
e25b6604d1 fix(hover_widget): make it fancy + mouse tracking 2026-04-01 10:51:11 +02:00
semantic-release
f74c5a4516 3.4.0
Automatically generated by python-semantic-release
2026-03-26 11:25:40 +00:00
a2923752c2 fix(waveform): alignment panel indicators request autoscale if updated 2026-03-26 12:24:56 +01:00
a486c52058 feat(waveform): 1D alignment mode panel 2026-03-26 12:24:56 +01:00
31389a3dd0 fix(lmfit_dialog): compact layout size policy for better alignment panel UX 2026-03-26 12:24:56 +01:00
semantic-release
1676efc1ea 3.3.4
Automatically generated by python-semantic-release
2026-03-24 11:26:35 +00:00
copilot-swe-agent[bot]
05c38d9b82 fix(lmfit_dialog): fix fit_curve_id type annotation and remove_dap_data selection behavior
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
2026-03-24 12:25:44 +01:00
f67b60ac98 fix(lmfit_dialog): dialog compact adjustment and cleanup of stale methods 2026-03-24 12:25:44 +01:00
5ec59d5dbb fix(lmfit_dialog): fix cpp object deleted 2026-03-24 12:25:44 +01:00
semantic-release
d46ffb59f0 3.3.3
Automatically generated by python-semantic-release
2026-03-23 18:24:36 +00:00
da400d20b6 fix(positioner_box): remove CompactPopupWidget inheritance 2026-03-23 19:23:47 +01:00
semantic-release
20f06d8659 3.3.2
Automatically generated by python-semantic-release
2026-03-22 19:13:32 +00:00
3d29a67c0b fix: typos 2026-03-22 20:12:46 +01:00
semantic-release
e7ef8a3891 3.3.1
Automatically generated by python-semantic-release
2026-03-20 14:01:06 +00:00
90222f3082 fix(dap_combobox): rewritten as proper combobox 2026-03-20 15:00:12 +01:00
79af15a88b fix(dap_combobox): added safeguard for no DAP models 2026-03-20 15:00:12 +01:00
semantic-release
ad01011a3e 3.3.0
Automatically generated by python-semantic-release
2026-03-20 13:18:49 +00:00
d4ecefd80a fix: Fix black 2026 formatting 2026-03-20 14:17:41 +01:00
d4afcb6832 refactor(fuzzy-search): unify is_match for fuzzy search 2026-03-20 14:17:41 +01:00
2b0f575733 refactor(atlas-http-service): Rename AtlasEndpoints 2026-03-20 14:17:41 +01:00
0c6f3f8352 fix(admin-widget): Cleanup and minor improvements 2026-03-20 14:17:41 +01:00
48c9c83bb0 fix(admin_view): minor changes
fixed the styling and avoid shadowing styles
minor rewording
2026-03-20 14:17:41 +01:00
ab223d5fdc refactor: fix formatting, running black 2026.1 2026-03-20 14:17:41 +01:00
137e572a94 fix(admin-view): generate RPC interface for AdminView 2026-03-20 14:17:41 +01:00
b14b046882 fix(main-app): fix id for main-app init of AdminView 2026-03-20 14:17:41 +01:00
a7a9458180 refactor: address review comments 2026-03-20 14:17:41 +01:00
23c146b3e6 fix(bec-atlas-admin-view): Fix connect_slot for dispatcher 2026-03-20 14:17:41 +01:00
df44d9b50e test(bec-atlas-admin-view): complement tests for BECAtlasAdminView, ExperimentSelection, BECAtlasHTTPService 2026-03-20 14:17:41 +01:00
de941d1bc5 fix(actions): allow minimum icon size for actions in toolbar 2026-03-20 14:17:41 +01:00
34e80ee8f9 test(bec-atlas-http-service): add tests for http service 2026-03-20 14:17:41 +01:00
d1a1d85abd fix(login-dialog): remove login_dialog 2026-03-20 14:17:41 +01:00
8e53ae2d39 fix(RPC): fix rpc access 2026-03-20 14:17:41 +01:00
889e9c0994 fix(pyproject): add PyJWT as dependency 2026-03-20 14:17:41 +01:00
f565deb71d fix(main-app): skip on_enter/exit hooks if darkmodebutton clicked 2026-03-20 14:17:41 +01:00
895b318990 refactor: cleanup widgets 2026-03-20 14:17:41 +01:00
3a17a249ed refactor(admin-view): Refactor experiment selection, http service, admin view, and add main view 2026-03-20 14:17:41 +01:00
598c453a18 feat(experiment-selection): add experiment selection widget 2026-03-20 14:17:41 +01:00
63059a4ef8 feat(admin-view): add admin view to views 2026-03-20 14:17:41 +01:00
ec58fbd6d8 feat(bec-atlas-admin-view): Add initial admin view 2026-03-20 14:17:41 +01:00
17708730fc feat(bec-atlas-admin-view): add http service through QNetworkAccessManager 2026-03-20 14:17:41 +01:00
1384a329ab feat(bec-atlas-admin-view): Add login dilaog 2026-03-20 14:17:41 +01:00
semantic-release
da1dc85b44 3.2.4
Automatically generated by python-semantic-release
2026-03-19 17:23:19 +00:00
28be696f7c fix(main_app): setApplicationName("BEC") 2026-03-19 18:22:29 +01:00
semantic-release
008c3a223a 3.2.3
Automatically generated by python-semantic-release
2026-03-16 15:07:09 +00:00
b9145d762c fix: check adding parent for filesystemmodel 2026-03-16 16:06:22 +01:00
37a5dc2e9e fix: refactor client mock with global fakeredis 2026-03-16 16:06:22 +01:00
1351fcd47b ci: fix path for uploading logs on failure 2026-03-16 15:49:22 +01:00
semantic-release
14a6b04b11 3.2.2
Automatically generated by python-semantic-release
2026-03-16 14:28:24 +00:00
4c9d7fddce fix(image): disconnecting of 2d monitor 2026-03-16 15:26:40 +01:00
semantic-release
39ecb89196 3.2.1
Automatically generated by python-semantic-release
2026-03-16 14:08:42 +00:00
974f25997d fix(e2e): bec shell excluded from e2e testing 2026-03-16 15:07:51 +01:00
e061fa31a9 fix(e2e): bec dock rpc fixed synchronization 2026-03-16 15:07:51 +01:00
718f99527c fix(e2e): timeout for maybe_remove_dock_area 2026-03-16 15:07:51 +01:00
semantic-release
bd5aafc052 3.2.0
Automatically generated by python-semantic-release
2026-03-11 20:52:57 +00:00
b4f6f5aa8b feat(waveform): composite DAP with multiple models 2026-03-11 21:52:10 +01:00
14d51b8016 feat(curve, waveform): add dap_parameters for lmfit customization in DAP requests 2026-03-11 21:52:10 +01:00
semantic-release
e94554b471 3.1.4
Automatically generated by python-semantic-release
2026-03-11 11:58:34 +00:00
7e0e391888 build: increased minimal version of bec and bec qthemes 2026-03-11 12:57:40 +01:00
53e5ec42b8 fix(profile_utils): renamed to fetch widgets settings 2026-03-11 12:57:40 +01:00
semantic-release
0e49828a23 3.1.3
Automatically generated by python-semantic-release
2026-03-09 08:46:29 +00:00
278d8de058 fix(monaco_dock): optimization, removal of QTimer, eventFilter replaced by signal/slot 2026-03-09 09:45:40 +01:00
104 changed files with 6904 additions and 1445 deletions

View File

@@ -55,5 +55,5 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: pytest-logs
path: ./logs/*.log
path: ./bec/logs/*.log
retention-days: 7

View File

@@ -1,6 +1,271 @@
# 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

View File

@@ -5,6 +5,7 @@ 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
@@ -63,6 +64,8 @@ class BECMainApp(BECMainWindow):
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.add_view(icon="widgets", title="Dock Area", widget=self.dock_area, mini_text="Docks")
self.add_view(
icon="display_settings",
@@ -78,6 +81,13 @@ class BECMainApp(BECMainWindow):
# 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,
)
if self._show_examples:
self.add_section("Examples", "examples")
@@ -181,6 +191,12 @@ 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 = (
@@ -247,7 +263,7 @@ class BECMainApp(BECMainWindow):
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 makros.",
text="Click here to access the Developer view to write scripts and macros.",
)
tour_steps.append(developer_view_step)
@@ -378,6 +394,7 @@ 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)

View File

@@ -0,0 +1,35 @@
"""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()

View File

@@ -16,9 +16,9 @@ from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.qt_ads import CDockWidget
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
@@ -96,7 +96,7 @@ class DeveloperWidget(DockAreaWidget):
self.console = BECShell(self, rpc_exposed=False)
self.console.setObjectName("BEC Shell")
self.terminal = WebConsole(self, rpc_exposed=False)
self.terminal = BecConsole(self, rpc_exposed=False)
self.terminal.setObjectName("Terminal")
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
self.monaco.setObjectName("MonacoEditor")
@@ -410,23 +410,3 @@ class DeveloperWidget(DockAreaWidget):
"""Clean up resources used by the developer widget."""
self.delete_all()
return super().cleanup()
if __name__ == "__main__":
import sys
from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
from bec_widgets.applications.main_app import BECMainApp
app = QApplication(sys.argv)
apply_theme("dark")
_app = BECMainApp()
_app.show()
# developer_view.show()
# developer_view.setWindowTitle("Developer View")
# developer_view.resize(1920, 1080)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -169,7 +169,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
self._upload_redis_dialog: UploadRedisDialog | None = None
self._dialog_validation_connection: QMetaObject.Connection | None = None
# NOTE: We need here a seperate config helper instance to avoid conflicts with
# NOTE: We need here a separate 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 +607,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, connnection_status in validation_results.values():
if connnection_status == ConnectionStatus.CONNECTED.value:
for config, config_status, connection_status in validation_results.values():
if connection_status == ConnectionStatus.CONNECTED.value:
self.device_table_view.update_device_validation(
config, config_status, ConnectionStatus.CAN_CONNECT, ""
)

View File

@@ -21,7 +21,7 @@ logger = bec_logger.logger
class _WidgetsEnumType(str, enum.Enum):
"""Enum for the available widgets, to be generated programatically"""
"""Enum for the available widgets, to be generated programmatically"""
...
@@ -56,7 +56,7 @@ _Widgets = {
"SignalLabel": "SignalLabel",
"TextBox": "TextBox",
"Waveform": "Waveform",
"WebConsole": "WebConsole",
"BecConsole": "BecConsole",
"WebsiteWidget": "WebsiteWidget",
}
@@ -89,6 +89,16 @@ 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
@@ -496,7 +506,7 @@ class BECQueue(RPCBase):
class BECShell(RPCBase):
"""A WebConsole pre-configured to run the BEC shell."""
"""A BecConsole pre-configured to run the BEC shell."""
@rpc_call
def remove(self):
@@ -975,7 +985,7 @@ class Curve(RPCBase):
class DapComboBox(RPCBase):
"""The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC."""
"""Editable combobox listing the available DAP models."""
@rpc_call
def select_y_axis(self, y_axis: str):
@@ -1001,7 +1011,7 @@ class DapComboBox(RPCBase):
Slot to update the fit model.
Args:
default_device(str): Default device name.
fit_name(str): Fit model name.
"""
@@ -6249,7 +6259,8 @@ class Waveform(RPCBase):
signal_y: "str | None" = None,
color: "str | None" = None,
label: "str | None" = None,
dap: "str | None" = None,
dap: "str | list[str] | None" = None,
dap_parameters: "dict | list | lmfit.Parameters | None | object" = None,
scan_id: "str | None" = None,
scan_number: "int | None" = None,
**kwargs,
@@ -6271,9 +6282,14 @@ class Waveform(RPCBase):
signal_y(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): The dap model to use for the curve. When provided, a DAP curve is
dap(str | list[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.
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.
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
the ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan resets.
@@ -6287,9 +6303,10 @@ class Waveform(RPCBase):
def add_dap_curve(
self,
device_label: "str",
dap_name: "str",
dap_name: "str | list[str]",
color: "str | None" = None,
dap_oversample: "int" = 1,
dap_parameters: "dict | list | lmfit.Parameters | None" = None,
**kwargs,
) -> "Curve":
"""
@@ -6299,9 +6316,11 @@ class Waveform(RPCBase):
Args:
device_label(str): The label of the source curve to add DAP to.
dap_name(str): The name of the DAP model to use.
dap_name(str | list[str]): The name of the DAP model to use, or a list of model
names to build a composite model.
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:
@@ -6398,7 +6417,7 @@ class WaveformViewPopup(RPCBase):
"""
class WebConsole(RPCBase):
class BecConsole(RPCBase):
"""A simple widget to display a website"""
@rpc_call

View File

@@ -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 programatically \"\"\"
\"\"\" Enum for the available widgets, to be generated programmatically \"\"\"
...
"""

View File

@@ -1,6 +1,7 @@
# 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
@@ -219,7 +220,9 @@ class Device(FakeDevice):
class DMMock:
def __init__(self):
def __init__(self, *args, **kwargs):
self._service = args[0]
self.config_helper = ConfigHelper(self._service.connector, self._service._service_name)
self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled]
@@ -273,6 +276,10 @@ class DMMock:
configs.append(device._config)
return configs
def initialize(*_): ...
def shutdown(self): ...
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),

View File

@@ -167,7 +167,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 revisted since
# If the gui_id is passed, it should be respected. However, this should be revisited since
# the gui_id has to be unique, and may no longer be.
if gui_id:
self.config.gui_id = gui_id
@@ -399,7 +399,7 @@ class BECConnector:
"""
self.config = config
# FIXME some thoughts are required to decide how thhis should work with rpc registry
# FIXME some thoughts are required to decide how this 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 +417,7 @@ class BECConnector:
else:
self.gui_id = self.config.gui_id
# FIXME some thoughts are required to decide how thhis should work with rpc registry
# FIXME some thoughts are required to decide how this should work with rpc registry
def load_config(self, path: str | None = None, gui: bool = False):
"""
Load the configuration of the widget from YAML.

View File

@@ -123,17 +123,16 @@ class BECDispatcher:
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
collections.defaultdict()
)
self.client = client
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)
if client is None:
if config is not None and 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")

View File

@@ -43,7 +43,7 @@ class WidgetContainerUtils:
if list_of_names is None:
list_of_names = []
ii = 0
while ii < 1000: # 1000 is arbritrary!
while ii < 1000: # 1000 is arbitrary!
name_candidate = f"{name}_{ii}"
if name_candidate not in list_of_names:
return name_candidate

View File

@@ -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 genrated from pydantic models, but can also be composed from other sources or by hand.
forms generated 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 appply any
"""Add the main data entry widget to self._main_widget and apply any
constraints from the field info"""
@SafeSlot()

View File

@@ -0,0 +1,35 @@
"""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

View File

@@ -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 atttribute to decide whether to
A Device examines its components' .kind attribute to decide whether to
traverse it in read(), read_configuration(), or neither. Additionally, if
decides whether to include its name in `hints['fields']`.
"""

View File

@@ -156,7 +156,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 rethat 9 systems where changing focus is supressed by default
# this is a special case for raising windows for gnome on Red Hat (RHEL) 9 systems where changing focus is suppressed 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
@@ -442,5 +442,5 @@ class RPCServer:
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
logger.info("Succeded in shutting down CLI server")
logger.info("Succeeded in shutting down CLI server")
self.client.shutdown()

View File

@@ -35,16 +35,19 @@ logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
def create_action_with_text(toolbar_action, toolbar: QToolBar):
def create_action_with_text(toolbar_action, toolbar: QToolBar, min_size: QSize | None = None):
"""
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):

View File

@@ -69,7 +69,7 @@ from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.containers.qt_ads import CDockWidget
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D
from bec_widgets.widgets.control.scan_control import ScanControl
from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
@@ -372,7 +372,7 @@ class BECDockArea(DockAreaWidget):
"Add Circular ProgressBar",
"RingProgressBar",
),
"terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
"terminal": (BecConsole.ICON_NAME, "Add Terminal", "BecConsole"),
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),

View File

@@ -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("bec_widgets_settings")
bec_widgets_settings = client._service_config.config.get("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.environ.get("BECWIDGETS_PROFILE_DIR", default_path)
root = os.path.expanduser(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.join(base, ns) if ns else base
path = os.path.expanduser(os.path.join(base, ns) if ns else base)
os.makedirs(path, exist_ok=True)
return path

View File

@@ -63,7 +63,7 @@ class ScriptTreeWidget(QWidget):
layout.setSpacing(0)
# Create tree view
self.tree = QTreeView()
self.tree = QTreeView(parent=self)
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()
self.model = QFileSystemModel(parent=self)
self.model.setNameFilters(["*.py"])
self.model.setNameFilterDisables(False)
# Create proxy model to filter out underscore directories
self.proxy_model = QSortFilterProxyModel()
self.proxy_model = QSortFilterProxyModel(parent=self)
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
self.proxy_model.setSourceModel(self.model)
self.tree.setModel(self.proxy_model)

View File

@@ -1,27 +1,83 @@
import sys
from qtpy import QtGui, QtWidgets
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QProgressBar,
QVBoxLayout,
QWidget,
)
class WidgetTooltip(QWidget):
"""Frameless, always-on-top window that behaves like a tooltip."""
def __init__(self, content: QWidget) -> None:
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_ShowWithoutActivating)
super().__init__(
None,
Qt.WindowType.ToolTip
| Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint,
)
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setMouseTracking(True)
self.content = content
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.addWidget(self.content)
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()
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()
@@ -30,11 +86,43 @@ 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):

View File

@@ -1,3 +0,0 @@
from .positioner_box_base import PositionerBoxBase
__ALL__ = ["PositionerBoxBase"]

View File

@@ -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._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
from bec_widgets.widgets.control.device_control.positioner_box.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.addWidget(self.ui)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
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)
ui_min_size = self.ui.minimumSize()
ui_min_hint = self.ui.minimumSizeHint()
self.setMinimumSize(
@@ -115,8 +115,6 @@ 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)

View File

@@ -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._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
from bec_widgets.widgets.control.device_control.positioner_box.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.addWidget(self.ui)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.addWidget(self.ui)
self.main_layout.setSpacing(0)
self.main_layout.setContentsMargins(0, 0, 0, 0)
def _init_ui(val: QDoubleValidator, device_id: DeviceId):
ui = self._device_ui_components_hv(device_id)
@@ -200,7 +200,6 @@ 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)
@@ -220,7 +219,6 @@ 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)

View File

@@ -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, CompactPopupWidget):
class PositionerBoxBase(BECWidget, QWidget):
"""Contains some core logic for positioner box widgets"""
current_path = ""
@@ -57,7 +57,10 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
parent: The parent widget.
device (Positioner): The device to control.
"""
super().__init__(parent=parent, layout=QVBoxLayout, **kwargs)
super().__init__(parent=parent, **kwargs)
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self._dialog = None
self.get_bec_shortcuts()
@@ -173,11 +176,9 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
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)
@@ -196,9 +197,8 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
position_indicator.set_value(pos)
def _update_limits_ui(
self, limits: tuple[float, float], position_indicator, setpoint_validator
):
@staticmethod
def _update_limits_ui(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,8 +223,9 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
"""Toogle enable/disable on available buttons
@staticmethod
def _toggle_enable_buttons(ui: DeviceUpdateUIComponents, enable: bool) -> None:
"""Toggle enable/disable on available buttons
Args:
enable (bool): Enable buttons

View File

@@ -1,6 +1,8 @@
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
@@ -22,7 +24,82 @@ 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

View File

@@ -2,12 +2,18 @@
<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>612</width>
<height>91</height>
<width>592</width>
<height>76</height>
</rect>
</property>
<property name="minimumSize">
@@ -26,8 +32,29 @@
<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>
@@ -227,12 +254,12 @@
<customwidgets>
<customwidget>
<class>PositionIndicator</class>
<extends>QWidget</extends>
<extends></extends>
<header>position_indicator</header>
</customwidget>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<extends></extends>
<header>spinner_widget</header>
</customwidget>
</customwidgets>

View File

@@ -27,30 +27,13 @@ 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()

View File

@@ -13,7 +13,7 @@ if TYPE_CHECKING:
from .available_device_group import AvailableDeviceGroup
class _DeviceListWiget(QListWidget):
class _DeviceListWidget(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 = _DeviceListWiget(AvailableDeviceGroup)
self.device_list = _DeviceListWidget(AvailableDeviceGroup)
self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
self.device_list.setObjectName("device_list")
self.device_list.setFrameStyle(0)

View File

@@ -34,13 +34,13 @@ class HashModel(str, Enum):
class DeviceResourceBackend(Protocol):
@property
def tag_groups(self) -> dict[str, set[HashableDevice]]:
"""A dictionary of all availble devices separated by tag groups. The same device may
"""A dictionary of all available 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 availble devices. The same device may not appear more than once."""
"""A set of all available devices. The same device may not appear more than once."""
...
@property

View File

@@ -5,9 +5,8 @@ in DeviceTableRow entries.
from __future__ import annotations
import traceback
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Tuple
from typing import TYPE_CHECKING, Any, Callable, Iterable, Tuple
from bec_lib.atlas_models import Device as DeviceModel
from bec_lib.callback_handler import EventType
@@ -19,6 +18,7 @@ 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,34 +37,6 @@ _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."""

View File

@@ -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 formated to run scan from BEC.
Returns the parameters from the widgets in the scan control layout formatted to run scan from BEC.
"""
if self.box_type == "args":
return self._get_arg_parameterts(device_object=device_object)
return self._get_arg_parameters(device_object=device_object)
elif self.box_type == "kwargs":
return self._get_kwarg_parameters(device_object=device_object)
def _get_arg_parameterts(self, device_object: bool = True):
def _get_arg_parameters(self, device_object: bool = True):
args = []
for i in range(1, self.layout.rowCount()):
for j in range(self.layout.columnCount()):

View File

@@ -2,22 +2,19 @@
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
from qtpy.QtWidgets import QComboBox
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
class DapComboBox(BECWidget, QWidget):
class DapComboBox(BECWidget, QComboBox):
"""
The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.
Editable combobox listing the available DAP models.
Args:
parent: Parent widget.
client: BEC client object.
gui_id: GUI ID.
default: Default device name.
The widget behaves as a plain QComboBox and keeps ``fit_model_combobox`` as an alias to itself
for backwards compatibility with older call sites.
"""
ICON_NAME = "data_exploration"
@@ -45,19 +42,20 @@ class DapComboBox(BECWidget, QWidget):
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
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.fit_model_combobox = self # Just for backwards compatibility with older call sites, the widget itself is the combobox
self._available_models: list[str] = []
self._x_axis = None
self._y_axis = None
self.populate_fit_model_combobox()
self.fit_model_combobox.currentTextChanged.connect(self._update_current_fit)
# Set default fit model
self.select_default_fit(default_fit)
self._is_valid_input = False
def select_default_fit(self, default_fit: str | None):
self.setEditable(True)
self.populate_fit_model_combobox()
self.currentTextChanged.connect(self._on_text_changed)
self.select_default_fit(default_fit)
self.check_validity(self.currentText())
def select_default_fit(self, default_fit: str | None = "GaussianModel"):
"""Set the default fit model.
Args:
@@ -65,8 +63,8 @@ class DapComboBox(BECWidget, QWidget):
"""
if self._validate_dap_model(default_fit):
self.select_fit_model(default_fit)
else:
self.select_fit_model("GaussianModel")
elif self.available_models:
self.select_fit_model(self.available_models[0])
@property
def available_models(self):
@@ -114,12 +112,40 @@ class DapComboBox(BECWidget, QWidget):
self._y_axis = y_axis
self.y_axis_updated.emit(y_axis)
def _update_current_fit(self, fit_name: str):
"""Update the current fit."""
@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
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.
@@ -128,7 +154,7 @@ class DapComboBox(BECWidget, QWidget):
x_axis(str): X axis.
"""
self.x_axis = x_axis
self._update_current_fit(self.fit_model_combobox.currentText())
self._on_text_changed(self.currentText())
@Slot(str)
def select_y_axis(self, y_axis: str):
@@ -138,25 +164,26 @@ class DapComboBox(BECWidget, QWidget):
y_axis(str): Y axis.
"""
self.y_axis = y_axis
self._update_current_fit(self.fit_model_combobox.currentText())
self._on_text_changed(self.currentText())
@Slot(str)
def select_fit_model(self, fit_name: str | None):
"""Slot to update the fit model.
Args:
default_device(str): Default device name.
fit_name(str): Fit model name.
"""
if not self._validate_dap_model(fit_name):
raise ValueError(f"Fit {fit_name} is not valid.")
self.fit_model_combobox.setCurrentText(fit_name)
self.setCurrentText(fit_name)
def populate_fit_model_combobox(self):
"""Populate the fit_model_combobox with the devices."""
# pylint: disable=protected-access
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)
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)
def _validate_dap_model(self, model: str | None) -> bool:
"""Validate the DAP model.
@@ -166,23 +193,23 @@ class DapComboBox(BECWidget, QWidget):
"""
if model is None:
return False
if model not in self.available_models:
return False
return True
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 __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
import sys
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import apply_theme
app = QApplication([])
app = QApplication(sys.argv)
apply_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
layout.addWidget(DapComboBox())
widget.show()
app.exec_()
dialog = DapComboBox()
dialog.show()
sys.exit(app.exec_())

View File

@@ -1,8 +1,9 @@
import os
import shiboken6
from bec_lib.logger import bec_logger
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QPushButton, QTreeWidgetItem, QVBoxLayout, QWidget
from qtpy.QtWidgets import QPushButton, QSizePolicy, QTreeWidgetItem, QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
@@ -34,7 +35,7 @@ class LMFitDialog(BECWidget, QWidget):
**kwargs,
):
"""
Initialises the LMFitDialog widget.
Initializes the LMFitDialog widget.
Args:
parent (QWidget): The parent widget.
@@ -68,6 +69,27 @@ 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:
@@ -77,8 +99,14 @@ class LMFitDialog(BECWidget, QWidget):
@enable_actions.setter
def enable_actions(self, enable: bool):
self._enable_actions = enable
for button in self.action_buttons.values():
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
button.setEnabled(enable)
valid_buttons[name] = button
self.action_buttons = valid_buttons
@SafeProperty(list)
def active_action_list(self) -> list[str]:
@@ -89,16 +117,6 @@ 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."""
@@ -154,19 +172,21 @@ class LMFitDialog(BECWidget, QWidget):
self.ui.group_parameters.setVisible(not show)
@property
def fit_curve_id(self) -> str:
def fit_curve_id(self) -> str | None:
"""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):
def fit_curve_id(self, curve_id: str | None):
"""Setter for the currently displayed fit curve_id.
Args:
fit_curve_id (str): The curve_id of the fit curve to be displayed.
curve_id (str | None): The curve_id of the fit curve to be displayed,
or None to clear the selection.
"""
self._fit_curve_id = curve_id
self.selected_fit.emit(curve_id)
if curve_id is not None:
self.selected_fit.emit(curve_id)
@SafeSlot(str)
def remove_dap_data(self, curve_id: str):
@@ -176,6 +196,15 @@ 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)
@@ -251,6 +280,7 @@ 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]
@@ -269,9 +299,9 @@ 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(f"Move to {param_name}")
button = QPushButton("Move")
button.clicked.connect(self._create_move_action(param_name, param[1]))
if self.enable_actions is True:
if self.enable_actions:
button.setEnabled(True)
else:
button.setEnabled(False)

View File

@@ -14,6 +14,18 @@
<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">
@@ -22,15 +34,6 @@
<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>
@@ -41,6 +44,12 @@
<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>
@@ -58,18 +67,36 @@
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
<enum>Qt::Orientation::Horizontal</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>
@@ -85,12 +112,33 @@
</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>
@@ -106,6 +154,11 @@
<string>Std</string>
</property>
</column>
<column>
<property name="text">
<string>Action</string>
</property>
</column>
</widget>
</item>
</layout>

View File

@@ -95,6 +95,12 @@
<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>
@@ -147,6 +153,12 @@
<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>

View File

@@ -0,0 +1,537 @@
from __future__ import annotations
import enum
import json
import secrets
import subprocess
import time
from bec_lib.logger import bec_logger
from louie.saferef import safe_ref
from pydantic import BaseModel
from qtpy.QtCore import Qt, QTimer, Signal
from qtpy.QtGui import QMouseEvent, QResizeEvent
from qtpy.QtWidgets import QApplication, QLabel, QTabWidget, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.utility.bec_term.wtermwidget_wrapper import BecTerminal
logger = bec_logger.logger
class ConsoleMode(str, enum.Enum):
ACTIVE = "active"
INACTIVE = "inactive"
HIDDEN = "hidden"
class InstanceOwnerInfo(BaseModel):
owner_gui_id: str | None = None
widget_ids: list[str] = []
instance: BecConsole | None = None
initialized: bool = False
model_config = {"arbitrary_types_allowed": True}
class BecConsoleRegistry:
"""
A registry for the BecConsole class to manage its instances.
"""
def __init__(self):
"""
Initialize the registry.
"""
self._instances = {}
self._server_process = None
self._server_port = None
self._token = secrets.token_hex(16)
self._instance_registry: dict[str, InstanceOwnerInfo] = {}
def register(self, instance: BecConsole):
"""
Register an instance of BecConsole.
Args:
instance (BecConsole): The instance to register.
"""
self._instances[instance.gui_id] = safe_ref(instance)
self.cleanup()
if instance._unique_id:
self._register_instance(instance)
def cleanup(self):
"""
Clean up the registry by removing any instances that are no longer valid.
"""
for gui_id, weak_ref in list(self._instances.items()):
if weak_ref() is None:
del self._instances[gui_id]
def unregister(self, instance: BecConsole):
"""
Unregister an instance of BecConsole.
Args:
instance (BecConsole): The instance to unregister.
"""
if instance.gui_id in self._instances:
del self._instances[instance.gui_id]
if instance._unique_id:
self._unregister_instance(instance._unique_id, instance.gui_id)
self.cleanup()
def _register_instance(self, instance: BecConsole):
"""
Register a instance in the registry. Please note that this does not transfer ownership
for already existing instances; it simply records which widget currently owns the instance.
Use transfer_instance_ownership to change ownership.
Args:
instance (BecConsole): The instance to register.
"""
unique_id = instance._unique_id
gui_id = instance.gui_id
if unique_id is None:
return
if unique_id not in self._instance_registry:
instance = BecConsole()
self._instance_registry[unique_id] = InstanceOwnerInfo(
owner_gui_id=gui_id, widget_ids=[gui_id], instance=instance
)
logger.info(f"Registered new instance {unique_id} for {gui_id}")
return
if gui_id not in self._instance_registry[unique_id].widget_ids:
self._instance_registry[unique_id].widget_ids.append(gui_id)
def _unregister_instance(self, unique_id: str, gui_id: str):
"""
Unregister a instance from the registry.
Args:
unique_id (str): The unique identifier for the instance.
gui_id (str): The GUI ID of the widget.
"""
if unique_id not in self._instance_registry:
return
instance_info = self._instance_registry[unique_id]
if gui_id in instance_info.widget_ids:
instance_info.widget_ids.remove(gui_id)
if instance_info.owner_gui_id == gui_id:
instance_info.owner_gui_id = None
if not instance_info.widget_ids:
if instance_info.instance:
instance_info.instance.deleteLater()
del self._instance_registry[unique_id]
logger.info(f"Unregistered instance {unique_id} for {gui_id}")
def get_instance_info(self, unique_id: str) -> InstanceOwnerInfo | None:
"""
Get a instance from the registry.
Args:
unique_id (str): The unique identifier for the instance.
Returns:
InstanceOwnerInfo | None: The instance info if found, None otherwise.
"""
if unique_id not in self._instance_registry:
return None
return self._instance_registry[unique_id]
def take_instance_ownership(self, unique_id: str, new_owner_gui_id: str) -> BecConsole | None:
"""
Transfer ownership of a instance to a new owner.
Args:
unique_id (str): The unique identifier for the instance.
new_owner_gui_id (str): The GUI ID of the new owner.
Returns:
BecTerminal | None: The instance if ownership transfer was successful, None otherwise.
"""
if unique_id not in self._instance_registry:
logger.warning(f"Instance {unique_id} not found in registry")
return None
instance_info = self._instance_registry[unique_id]
old_owner_gui_id = instance_info.owner_gui_id
if old_owner_gui_id:
old_owner_ref = self._instances.get(old_owner_gui_id)
if old_owner_ref:
old_owner_instance = old_owner_ref()
if old_owner_instance:
old_owner_instance.yield_ownership()
instance_info.owner_gui_id = new_owner_gui_id
logger.info(f"Transferred ownership of instance {unique_id} to {new_owner_gui_id}")
return instance_info.instance
def yield_ownership(self, gui_id: str) -> bool:
"""
Yield ownership of a instance without destroying it. The instance remains in the
registry with no owner, available for another widget to claim.
Args:
gui_id (str): The GUI ID of the widget yielding ownership.
Returns:
bool: True if ownership was yielded, False otherwise.
"""
if gui_id not in self._instances:
return False
instance = self._instances[gui_id]()
if instance is None:
return False
unique_id = instance._unique_id
if unique_id is None:
return False
if unique_id not in self._instance_registry:
return False
instance_owner_info = self._instance_registry[unique_id]
if instance_owner_info.owner_gui_id != gui_id:
return False
instance_owner_info.owner_gui_id = None
return True
def owner_is_visible(self, unique_id: str) -> bool:
"""
Check if the owner of a instance is currently visible.
Args:
unique_id (str): The unique identifier for the instance.
Returns:
bool: True if the owner is visible, False otherwise.
"""
instance_info = self.get_instance_info(unique_id)
if instance_info is None or instance_info.owner_gui_id is None:
return False
owner_ref = self._instances.get(instance_info.owner_gui_id)
if owner_ref is None:
return False
owner_instance = owner_ref()
if owner_instance is None:
return False
return owner_instance.isVisible()
_bec_console_registry = BecConsoleRegistry()
class BecConsole(BECWidget, QWidget):
"""
A simple widget to display a website
"""
_js_callback = Signal(bool)
initialized = Signal()
PLUGIN = True
ICON_NAME = "terminal"
def __init__(
self,
parent=None,
config=None,
client=None,
gui_id=None,
startup_cmd: str | None = None,
is_bec_shell: bool = False,
unique_id: str | None = None,
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._mode = ConsoleMode.INACTIVE
self._is_bec_shell = is_bec_shell
self._startup_cmd = startup_cmd
self._is_initialized = False
self._unique_id = unique_id
self.instance = None # Will be set in _set_up_instance
self._set_up_instance()
def _set_up_instance(self):
"""
Set up the web instance and UI elements.
"""
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.term = BecTerminal(self)
layout.addWidget(self.term)
self.setLayout(layout)
# prepare overlay
self.overlay = QWidget(self)
layout = QVBoxLayout(self.overlay)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
label = QLabel("Click to activate terminal", self.overlay)
layout.addWidget(label)
self.overlay.hide()
_bec_console_registry.register(self)
self._token = _bec_console_registry._token
# If no unique_id is provided, create a new instance
if not self._unique_id:
self._set_mode(ConsoleMode.ACTIVE)
return
# Try to get the instance from the registry
if instance := _bec_console_registry.get_instance_info(self._unique_id):
if instance.owner_gui_id != self.gui_id:
self._set_mode(ConsoleMode.INACTIVE)
else:
self._set_mode(ConsoleMode.ACTIVE)
def _set_mode(self, mode: ConsoleMode):
"""
Set the mode of the web console.
Args:
mode (ConsoleMode): The mode to set.
"""
if not self._unique_id:
# For non-unique_id consoles, always active
mode = ConsoleMode.ACTIVE
self._mode = mode
match mode:
case ConsoleMode.ACTIVE:
self.term.setVisible(True)
self.overlay.hide()
case ConsoleMode.INACTIVE:
self.term.setVisible(False)
self.overlay.show()
case ConsoleMode.HIDDEN:
self.term.setVisible(False)
self.overlay.hide()
@property
def startup_cmd(self):
"""
Get the startup command for the web console.
"""
if self._is_bec_shell:
if self.bec_dispatcher.cli_server is None:
return "bec --nogui"
return f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}"
return self._startup_cmd
@startup_cmd.setter
def startup_cmd(self, cmd: str):
"""
Set the startup command for the console.
"""
if not isinstance(cmd, str):
raise ValueError("Startup command must be a string.")
self._startup_cmd = cmd
# def write(self, data: str, send_return: bool = True):
# """
# Send data to the console
# Args:
# data (str): The data to send.
# send_return (bool): Whether to send a return after the data.
# """
# cmd = f"window.term.paste({json.dumps(data)});"
# if self.instance is None:
# logger.warning("Cannot write to console: instance is not initialized.")
# return
# self.instance.runJavaScript(cmd)
# if send_return:
# self.send_return()
def take_instance_ownership(self, unique_id: str | None = None):
"""
Take ownership of a web instance from the registry. This will transfer the instance
from its current owner (if any) to this widget.
Args:
unique_id (str): The unique identifier of the instance to take ownership of.
If None, uses this widget's unique_id.
"""
if unique_id is None:
unique_id = self._unique_id
if not unique_id:
logger.warning("Cannot take instance ownership without a unique_id")
return
# Get the instance from registry
instance = _bec_console_registry.take_instance_ownership(unique_id, self.gui_id)
if not instance:
logger.warning(f"Instance {unique_id} not found in registry")
return
self.instance = instance
self._set_mode(ConsoleMode.ACTIVE)
logger.info(f"Widget {self.gui_id} took ownership of instance {unique_id}")
def _on_ownership_lost(self):
"""
Called when this widget loses ownership of its instance.
Displays the overlay and hides the browser.
"""
self._set_mode(ConsoleMode.INACTIVE)
logger.info(f"Widget {self.gui_id} lost ownership of instance {self._unique_id}")
def yield_ownership(self):
"""
Yield ownership of the instance. The instance remains in the registry with no owner,
available for another widget to claim. This is automatically called when the
widget becomes hidden.
"""
if not self._unique_id:
return
success = _bec_console_registry.yield_ownership(self.gui_id)
if success:
self._on_ownership_lost()
logger.info(f"Widget {self.gui_id} yielded ownership of instance {self._unique_id}")
def has_ownership(self) -> bool:
"""
Check if this widget currently has ownership of a instance.
Returns:
bool: True if this widget owns a instance, False otherwise.
"""
if not self._unique_id:
return False
instance_info = _bec_console_registry.get_instance_info(self._unique_id)
if instance_info is None:
return False
return instance_info.owner_gui_id == self.gui_id
def hideEvent(self, event):
"""
Called when the widget is hidden. Automatically yields ownership.
"""
if self.has_ownership():
self.yield_ownership()
self._set_mode(ConsoleMode.HIDDEN)
super().hideEvent(event)
def showEvent(self, event):
"""
Called when the widget is shown. Updates UI state based on ownership.
"""
super().showEvent(event)
if self._unique_id and not self.has_ownership():
# Take ownership if the instance does not have an owner or
# the owner is not visible
instance_info = _bec_console_registry.get_instance_info(self._unique_id)
if instance_info is None:
self._set_mode(ConsoleMode.INACTIVE)
return
if instance_info.owner_gui_id is None or not _bec_console_registry.owner_is_visible(
self._unique_id
):
self.take_instance_ownership(self._unique_id)
return
if instance_info.owner_gui_id != self.gui_id:
self._set_mode(ConsoleMode.INACTIVE)
return
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
self.overlay.resize(event.size())
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton and not self.has_ownership():
self.take_instance_ownership(self._unique_id)
event.accept()
return
return super().mousePressEvent(event)
def _authenticate(self, _, auth):
"""
Authenticate the request with the provided username and password.
"""
auth.setUser("user")
auth.setPassword(self._token)
def set_readonly(self, readonly: bool):
"""
Set the web console to read-only mode.
"""
if not isinstance(readonly, bool):
raise ValueError("Readonly must be a boolean.")
self.setEnabled(not readonly)
def cleanup(self):
"""
Clean up the registry by removing any instances that are no longer valid.
"""
_bec_console_registry.unregister(self)
super().cleanup()
class BECShell(BecConsole):
"""
A BecConsole pre-configured to run the BEC shell.
We cannot simply expose the web console properties to Qt as we need to have a deterministic
startup behavior for sharing the same shell instance across multiple widgets.
"""
ICON_NAME = "hub"
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(
parent=parent,
config=config,
client=client,
gui_id=gui_id,
is_bec_shell=True,
unique_id="bec_shell",
**kwargs,
)
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
widget = QTabWidget()
# Create two consoles with different unique_ids
bec_console1 = BecConsole(startup_cmd="bec --nogui", unique_id="console1")
bec_console2 = BecConsole(startup_cmd="htop")
bec_console3 = BecConsole(startup_cmd="bec --nogui", unique_id="console1")
widget.addTab(bec_console1, "Console 1")
widget.addTab(bec_console2, "Console 2")
widget.addTab(bec_console3, "Console 3 -- mirror of Console 1")
widget.show()
# Demonstrate instance sharing:
# After initialization, bec_console2 can take ownership of console1's instance:
# bec_console2.take_instance_ownership("console1")
widget.resize(800, 600)
def _close_cons1():
bec_console2.close()
bec_console2.deleteLater()
# QTimer.singleShot(3000, _close_cons1)
sys.exit(app.exec_())

View File

@@ -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 QEvent, QTimer, Signal
from qtpy.QtCore import 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,7 +73,8 @@ class MonacoDock(DockAreaWidget):
logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}")
self.save_enabled.emit(widget.modified)
def _update_tab_title_for_modification(self, dock: CDockWidget, modified: bool):
@staticmethod
def _update_tab_title_for_modification(dock: CDockWidget, modified: bool):
"""Update the tab title to show modification status with a dot indicator."""
current_title = dock.windowTitle()
@@ -98,14 +99,12 @@ 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)
@@ -156,9 +155,10 @@ 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
QTimer.singleShot(0, self._scan_and_fix_areas)
self._scan_and_fix_areas()
def reset_widget(self, widget: MonacoWidget):
@staticmethod
def reset_widget(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 _scan_and_fix_areas(self):
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):
# 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)
QTimer.singleShot(0, self._scan_and_fix_areas)
self._scan_and_fix_areas()
self.last_focused_editor = dock
return dock

View File

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

View File

@@ -1,57 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.web_console.web_console import BECShell
DOM_XML = """
<ui language='c++'>
<widget class='BECShell' name='bec_shell'>
</widget>
</ui>
"""
class BECShellPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = BECShell(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(BECShell.ICON_NAME)
def includeFile(self):
return "bec_shell"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECShell"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

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

View File

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

View File

@@ -1,705 +0,0 @@
from __future__ import annotations
import enum
import json
import secrets
import subprocess
import time
from bec_lib.logger import bec_logger
from louie.saferef import safe_ref
from pydantic import BaseModel
from qtpy.QtCore import Qt, QTimer, QUrl, Signal, qInstallMessageHandler
from qtpy.QtGui import QMouseEvent, QResizeEvent
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
from qtpy.QtWidgets import QApplication, QLabel, QTabWidget, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
class ConsoleMode(str, enum.Enum):
ACTIVE = "active"
INACTIVE = "inactive"
HIDDEN = "hidden"
class PageOwnerInfo(BaseModel):
owner_gui_id: str | None = None
widget_ids: list[str] = []
page: QWebEnginePage | None = None
initialized: bool = False
model_config = {"arbitrary_types_allowed": True}
class WebConsoleRegistry:
"""
A registry for the WebConsole class to manage its instances.
"""
def __init__(self):
"""
Initialize the registry.
"""
self._instances = {}
self._server_process = None
self._server_port = None
self._token = secrets.token_hex(16)
self._page_registry: dict[str, PageOwnerInfo] = {}
def register(self, instance: WebConsole):
"""
Register an instance of WebConsole.
Args:
instance (WebConsole): The instance to register.
"""
self._instances[instance.gui_id] = safe_ref(instance)
self.cleanup()
if instance._unique_id:
self._register_page(instance)
if self._server_process is None:
# Start the ttyd server if not already running
self.start_ttyd()
def start_ttyd(self, use_zsh: bool | None = None):
"""
Start the ttyd server
ttyd -q -W -t 'theme={"background": "black"}' zsh
Args:
use_zsh (bool): Whether to use zsh or bash. If None, it will try to detect if zsh is available.
"""
# First, check if ttyd is installed
try:
subprocess.run(["ttyd", "--version"], check=True, stdout=subprocess.PIPE)
except FileNotFoundError:
# pylint: disable=raise-missing-from
raise RuntimeError("ttyd is not installed. Please install it first.")
if use_zsh is None:
# Check if we can use zsh
try:
subprocess.run(["zsh", "--version"], check=True, stdout=subprocess.PIPE)
use_zsh = True
except FileNotFoundError:
use_zsh = False
command = [
"ttyd",
"-p",
"0",
"-W",
"-t",
'theme={"background": "black"}',
"-c",
f"user:{self._token}",
]
if use_zsh:
command.append("zsh")
else:
command.append("bash")
# Start the ttyd server
self._server_process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
self._wait_for_server_port()
self._server_process.stdout.close()
self._server_process.stderr.close()
def _wait_for_server_port(self, timeout: float = 10):
"""
Wait for the ttyd server to start and get the port number.
Args:
timeout (float): The timeout in seconds to wait for the server to start.
"""
start_time = time.time()
while True:
output = self._server_process.stderr.readline()
if output == b"" and self._server_process.poll() is not None:
break
if not output:
continue
output = output.decode("utf-8").strip()
if "Listening on" in output:
# Extract the port number from the output
self._server_port = int(output.split(":")[-1])
logger.info(f"ttyd server started on port {self._server_port}")
break
if time.time() - start_time > timeout:
raise TimeoutError(
"Timeout waiting for ttyd server to start. Please check if ttyd is installed and available in your PATH."
)
def cleanup(self):
"""
Clean up the registry by removing any instances that are no longer valid.
"""
for gui_id, weak_ref in list(self._instances.items()):
if weak_ref() is None:
del self._instances[gui_id]
if not self._instances and self._server_process:
# If no instances are left, terminate the server process
self._server_process.terminate()
self._server_process = None
self._server_port = None
logger.info("ttyd server terminated")
def unregister(self, instance: WebConsole):
"""
Unregister an instance of WebConsole.
Args:
instance (WebConsole): The instance to unregister.
"""
if instance.gui_id in self._instances:
del self._instances[instance.gui_id]
if instance._unique_id:
self._unregister_page(instance._unique_id, instance.gui_id)
self.cleanup()
def _register_page(self, instance: WebConsole):
"""
Register a page in the registry. Please note that this does not transfer ownership
for already existing pages; it simply records which widget currently owns the page.
Use transfer_page_ownership to change ownership.
Args:
instance (WebConsole): The instance to register.
"""
unique_id = instance._unique_id
gui_id = instance.gui_id
if unique_id is None:
return
if unique_id not in self._page_registry:
page = BECWebEnginePage()
page.authenticationRequired.connect(instance._authenticate)
self._page_registry[unique_id] = PageOwnerInfo(
owner_gui_id=gui_id, widget_ids=[gui_id], page=page
)
logger.info(f"Registered new page {unique_id} for {gui_id}")
return
if gui_id not in self._page_registry[unique_id].widget_ids:
self._page_registry[unique_id].widget_ids.append(gui_id)
def _unregister_page(self, unique_id: str, gui_id: str):
"""
Unregister a page from the registry.
Args:
unique_id (str): The unique identifier for the page.
gui_id (str): The GUI ID of the widget.
"""
if unique_id not in self._page_registry:
return
page_info = self._page_registry[unique_id]
if gui_id in page_info.widget_ids:
page_info.widget_ids.remove(gui_id)
if page_info.owner_gui_id == gui_id:
page_info.owner_gui_id = None
if not page_info.widget_ids:
if page_info.page:
page_info.page.deleteLater()
del self._page_registry[unique_id]
logger.info(f"Unregistered page {unique_id} for {gui_id}")
def get_page_info(self, unique_id: str) -> PageOwnerInfo | None:
"""
Get a page from the registry.
Args:
unique_id (str): The unique identifier for the page.
Returns:
PageOwnerInfo | None: The page info if found, None otherwise.
"""
if unique_id not in self._page_registry:
return None
return self._page_registry[unique_id]
def take_page_ownership(self, unique_id: str, new_owner_gui_id: str) -> QWebEnginePage | None:
"""
Transfer ownership of a page to a new owner.
Args:
unique_id (str): The unique identifier for the page.
new_owner_gui_id (str): The GUI ID of the new owner.
Returns:
QWebEnginePage | None: The page if ownership transfer was successful, None otherwise.
"""
if unique_id not in self._page_registry:
logger.warning(f"Page {unique_id} not found in registry")
return None
page_info = self._page_registry[unique_id]
old_owner_gui_id = page_info.owner_gui_id
if old_owner_gui_id:
old_owner_ref = self._instances.get(old_owner_gui_id)
if old_owner_ref:
old_owner_instance = old_owner_ref()
if old_owner_instance:
old_owner_instance.yield_ownership()
page_info.owner_gui_id = new_owner_gui_id
logger.info(f"Transferred ownership of page {unique_id} to {new_owner_gui_id}")
return page_info.page
def yield_ownership(self, gui_id: str) -> bool:
"""
Yield ownership of a page without destroying it. The page remains in the
registry with no owner, available for another widget to claim.
Args:
gui_id (str): The GUI ID of the widget yielding ownership.
Returns:
bool: True if ownership was yielded, False otherwise.
"""
if gui_id not in self._instances:
return False
instance = self._instances[gui_id]()
if instance is None:
return False
unique_id = instance._unique_id
if unique_id is None:
return False
if unique_id not in self._page_registry:
return False
page_owner_info = self._page_registry[unique_id]
if page_owner_info.owner_gui_id != gui_id:
return False
page_owner_info.owner_gui_id = None
return True
def owner_is_visible(self, unique_id: str) -> bool:
"""
Check if the owner of a page is currently visible.
Args:
unique_id (str): The unique identifier for the page.
Returns:
bool: True if the owner is visible, False otherwise.
"""
page_info = self.get_page_info(unique_id)
if page_info is None or page_info.owner_gui_id is None:
return False
owner_ref = self._instances.get(page_info.owner_gui_id)
if owner_ref is None:
return False
owner_instance = owner_ref()
if owner_instance is None:
return False
return owner_instance.isVisible()
_web_console_registry = WebConsoleRegistry()
def suppress_qt_messages(type_, context, msg):
if context.category in ["js", "default"]:
return
print(msg)
qInstallMessageHandler(suppress_qt_messages)
class BECWebEnginePage(QWebEnginePage):
def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID):
logger.info(f"[JS Console] {level.name} at line {lineNumber} in {sourceID}: {message}")
class WebConsole(BECWidget, QWidget):
"""
A simple widget to display a website
"""
_js_callback = Signal(bool)
initialized = Signal()
PLUGIN = True
ICON_NAME = "terminal"
def __init__(
self,
parent=None,
config=None,
client=None,
gui_id=None,
startup_cmd: str | None = None,
is_bec_shell: bool = False,
unique_id: str | None = None,
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._mode = ConsoleMode.INACTIVE
self._is_bec_shell = is_bec_shell
self._startup_cmd = startup_cmd
self._is_initialized = False
self._unique_id = unique_id
self.page = None # Will be set in _set_up_page
self._startup_timer = QTimer()
self._startup_timer.setInterval(500)
self._startup_timer.timeout.connect(self._check_page_ready)
self._startup_timer.start()
self._js_callback.connect(self._on_js_callback)
self._set_up_page()
def _set_up_page(self):
"""
Set up the web page and UI elements.
"""
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.browser = QWebEngineView(self)
layout.addWidget(self.browser)
self.setLayout(layout)
# prepare overlay
self.overlay = QWidget(self)
layout = QVBoxLayout(self.overlay)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
label = QLabel("Click to activate terminal", self.overlay)
layout.addWidget(label)
self.overlay.hide()
_web_console_registry.register(self)
self._token = _web_console_registry._token
# If no unique_id is provided, create a new page
if not self._unique_id:
self.page = BECWebEnginePage(self)
self.page.authenticationRequired.connect(self._authenticate)
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
self.browser.setPage(self.page)
self._set_mode(ConsoleMode.ACTIVE)
return
# Try to get the page from the registry
page_info = _web_console_registry.get_page_info(self._unique_id)
if page_info and page_info.page:
self.page = page_info.page
if not page_info.owner_gui_id or page_info.owner_gui_id == self.gui_id:
self.browser.setPage(self.page)
# Only set URL if this is a newly created page (no URL set yet)
if self.page.url().isEmpty():
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
else:
# We have an existing page, so we don't need the startup timer
self._startup_timer.stop()
if page_info.owner_gui_id != self.gui_id:
self._set_mode(ConsoleMode.INACTIVE)
else:
self._set_mode(ConsoleMode.ACTIVE)
def _set_mode(self, mode: ConsoleMode):
"""
Set the mode of the web console.
Args:
mode (ConsoleMode): The mode to set.
"""
if not self._unique_id:
# For non-unique_id consoles, always active
mode = ConsoleMode.ACTIVE
self._mode = mode
match mode:
case ConsoleMode.ACTIVE:
self.browser.setVisible(True)
self.overlay.hide()
case ConsoleMode.INACTIVE:
self.browser.setVisible(False)
self.overlay.show()
case ConsoleMode.HIDDEN:
self.browser.setVisible(False)
self.overlay.hide()
def _check_page_ready(self):
"""
Check if the page is ready and stop the timer if it is.
"""
if not self.page or self.page.isLoading():
return
self.page.runJavaScript("window.term !== undefined", self._js_callback.emit)
def _on_js_callback(self, ready: bool):
"""
Callback for when the JavaScript is ready.
"""
if not ready:
return
self._is_initialized = True
self._startup_timer.stop()
if self.startup_cmd:
if self._unique_id:
page_info = _web_console_registry.get_page_info(self._unique_id)
if page_info is None:
return
if not page_info.initialized:
page_info.initialized = True
self.write(self.startup_cmd)
else:
self.write(self.startup_cmd)
self.initialized.emit()
@property
def startup_cmd(self):
"""
Get the startup command for the web console.
"""
if self._is_bec_shell:
if self.bec_dispatcher.cli_server is None:
return "bec --nogui"
return f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}"
return self._startup_cmd
@startup_cmd.setter
def startup_cmd(self, cmd: str):
"""
Set the startup command for the web console.
"""
if not isinstance(cmd, str):
raise ValueError("Startup command must be a string.")
self._startup_cmd = cmd
def write(self, data: str, send_return: bool = True):
"""
Send data to the web page
Args:
data (str): The data to send.
send_return (bool): Whether to send a return after the data.
"""
cmd = f"window.term.paste({json.dumps(data)});"
if self.page is None:
logger.warning("Cannot write to web console: page is not initialized.")
return
self.page.runJavaScript(cmd)
if send_return:
self.send_return()
def take_page_ownership(self, unique_id: str | None = None):
"""
Take ownership of a web page from the registry. This will transfer the page
from its current owner (if any) to this widget.
Args:
unique_id (str): The unique identifier of the page to take ownership of.
If None, uses this widget's unique_id.
"""
if unique_id is None:
unique_id = self._unique_id
if not unique_id:
logger.warning("Cannot take page ownership without a unique_id")
return
# Get the page from registry
page = _web_console_registry.take_page_ownership(unique_id, self.gui_id)
if not page:
logger.warning(f"Page {unique_id} not found in registry")
return
self.page = page
self.browser.setPage(page)
self._set_mode(ConsoleMode.ACTIVE)
logger.info(f"Widget {self.gui_id} took ownership of page {unique_id}")
def _on_ownership_lost(self):
"""
Called when this widget loses ownership of its page.
Displays the overlay and hides the browser.
"""
self._set_mode(ConsoleMode.INACTIVE)
logger.info(f"Widget {self.gui_id} lost ownership of page {self._unique_id}")
def yield_ownership(self):
"""
Yield ownership of the page. The page remains in the registry with no owner,
available for another widget to claim. This is automatically called when the
widget becomes hidden.
"""
if not self._unique_id:
return
success = _web_console_registry.yield_ownership(self.gui_id)
if success:
self._on_ownership_lost()
logger.info(f"Widget {self.gui_id} yielded ownership of page {self._unique_id}")
def has_ownership(self) -> bool:
"""
Check if this widget currently has ownership of a page.
Returns:
bool: True if this widget owns a page, False otherwise.
"""
if not self._unique_id:
return False
page_info = _web_console_registry.get_page_info(self._unique_id)
if page_info is None:
return False
return page_info.owner_gui_id == self.gui_id
def hideEvent(self, event):
"""
Called when the widget is hidden. Automatically yields ownership.
"""
if self.has_ownership():
self.yield_ownership()
self._set_mode(ConsoleMode.HIDDEN)
super().hideEvent(event)
def showEvent(self, event):
"""
Called when the widget is shown. Updates UI state based on ownership.
"""
super().showEvent(event)
if self._unique_id and not self.has_ownership():
# Take ownership if the page does not have an owner or
# the owner is not visible
page_info = _web_console_registry.get_page_info(self._unique_id)
if page_info is None:
self._set_mode(ConsoleMode.INACTIVE)
return
if page_info.owner_gui_id is None or not _web_console_registry.owner_is_visible(
self._unique_id
):
self.take_page_ownership(self._unique_id)
return
if page_info.owner_gui_id != self.gui_id:
self._set_mode(ConsoleMode.INACTIVE)
return
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
self.overlay.resize(event.size())
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton and not self.has_ownership():
self.take_page_ownership(self._unique_id)
event.accept()
return
return super().mousePressEvent(event)
def _authenticate(self, _, auth):
"""
Authenticate the request with the provided username and password.
"""
auth.setUser("user")
auth.setPassword(self._token)
def send_return(self):
"""
Send return to the web page
"""
self.page.runJavaScript(
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 13}))"
)
def send_ctrl_c(self):
"""
Send Ctrl+C to the web page
"""
self.page.runJavaScript(
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
)
def set_readonly(self, readonly: bool):
"""
Set the web console to read-only mode.
"""
if not isinstance(readonly, bool):
raise ValueError("Readonly must be a boolean.")
self.setEnabled(not readonly)
def cleanup(self):
"""
Clean up the registry by removing any instances that are no longer valid.
"""
self._startup_timer.stop()
_web_console_registry.unregister(self)
super().cleanup()
class BECShell(WebConsole):
"""
A WebConsole pre-configured to run the BEC shell.
We cannot simply expose the web console properties to Qt as we need to have a deterministic
startup behavior for sharing the same shell instance across multiple widgets.
"""
ICON_NAME = "hub"
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(
parent=parent,
config=config,
client=client,
gui_id=gui_id,
is_bec_shell=True,
unique_id="bec_shell",
**kwargs,
)
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
widget = QTabWidget()
# Create two consoles with different unique_ids
web_console1 = WebConsole(startup_cmd="bec --nogui", unique_id="console1")
web_console2 = WebConsole(startup_cmd="htop")
web_console3 = WebConsole(startup_cmd="bec --nogui", unique_id="console1")
widget.addTab(web_console1, "Console 1")
widget.addTab(web_console2, "Console 2")
widget.addTab(web_console3, "Console 3 -- mirror of Console 1")
widget.show()
# Demonstrate page sharing:
# After initialization, web_console2 can take ownership of console1's page:
# web_console2.take_page_ownership("console1")
widget.resize(800, 600)
def _close_cons1():
web_console2.close()
web_console2.deleteLater()
# QTimer.singleShot(3000, _close_cons1)
sys.exit(app.exec_())

View File

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

View File

@@ -1,57 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
DOM_XML = """
<ui language='c++'>
<widget class='WebConsole' name='web_console'>
</widget>
</ui>
"""
class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = WebConsole(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Developer"
def icon(self):
return designer_material_icon(WebConsole.ICON_NAME)
def includeFile(self):
return "web_console"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "WebConsole"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -270,6 +270,16 @@ class Image(ImageBase):
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
# If we have a signal, reconnect with the new device
@@ -325,6 +335,16 @@ class Image(ImageBase):
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
# If we have a device, try to connect
@@ -447,6 +467,61 @@ class Image(ImageBase):
)
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.
@@ -455,55 +530,13 @@ class Image(ImageBase):
return
config = self.subscriptions["main"]
if self.async_update:
async_signal_name = config.async_signal_name or self._config.signal
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, async_signal_name
),
)
logger.info(
f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{self._config.device},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, async_signal_name
),
)
logger.info(
f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{self._config.device},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, self._config.signal),
)
logger.info(
f"Disconnecting preview 1d update Device Name:{self._config.device}, Device Entry:{self._config.signal}"
)
elif config.source == "device_monitor_2d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d,
MessageEndpoints.device_preview(self._config.device, self._config.signal),
)
logger.info(
f"Disconnecting preview 2d update Device Name:{self._config.device}, Device Entry:{self._config.signal}"
)
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,
)
# Reset async state
self.async_update = False
@@ -860,45 +893,19 @@ class Image(ImageBase):
logger.warning("Cannot disconnect monitor without both device and signal")
return
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
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,
)
self.subscriptions["main"].async_signal_name = None
self.async_update = False

View File

@@ -22,8 +22,9 @@ class DeviceSignal(BaseModel):
device: str
signal: str
dap: str | None = None
dap: str | list[str] | None = None
dap_oversample: int = 1
dap_parameters: dict | list | None = None
model_config: dict = {"validate_assignment": True}

View File

@@ -0,0 +1,345 @@
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))

View File

@@ -0,0 +1,285 @@
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))

View File

@@ -1,13 +1,14 @@
from __future__ import annotations
import json
from typing import Literal
from typing import TYPE_CHECKING, Literal
import lmfit
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger, messages
from bec_lib.device import Positioner
from bec_lib.endpoints import MessageEndpoints
from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object
from bec_lib.scan_data_container import ScanDataContainer
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Qt, QTimer, Signal
@@ -30,17 +31,36 @@ from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.side_panel import SidePanel
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
from bec_widgets.widgets.plots.waveform.utils.alignment_controller import (
AlignmentContext,
WaveformAlignmentController,
)
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
from bec_widgets.widgets.plots.waveform.utils.roi_manager import WaveformROIManager
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
ScanHistoryBrowser,
)
logger = bec_logger.logger
_DAP_PARAM = object()
if TYPE_CHECKING: # pragma: no cover
import lmfit # type: ignore
else:
try:
import lmfit # type: ignore
except Exception as e: # pragma: no cover
logger.warning(
f"lmfit could not be imported: {e}. Custom DAP functionality will be unavailable."
)
lmfit = None
# noinspection PyDataclass
@@ -144,6 +164,12 @@ class Waveform(PlotBase):
"label_suffix": "",
}
self._current_x_device: tuple[str, str] | None = None
self._alignment_panel_visible = False
self._alignment_side_panel: SidePanel | None = None
self._alignment_panel_index: int | None = None
self._alignment_panel: WaveformAlignmentPanel | None = None
self._alignment_controller: WaveformAlignmentController | None = None
self._alignment_positioner_name: str | None = None
# Specific GUI elements
self._init_roi_manager()
@@ -153,6 +179,7 @@ class Waveform(PlotBase):
self._add_waveform_specific_popup()
self._enable_roi_toolbar_action(False) # default state where are no dap curves
self._init_curve_dialog()
self._init_alignment_mode()
self.curve_settings_dialog = None
# Largedataset guard
@@ -183,7 +210,9 @@ class Waveform(PlotBase):
# To fix the ViewAll action with clipToView activated
self._connect_viewbox_menu_actions()
self.toolbar.show_bundles(["plot_export", "mouse_interaction", "roi", "axis_popup"])
self.toolbar.show_bundles(
["plot_export", "mouse_interaction", "roi", "alignment_mode", "axis_popup"]
)
def _connect_viewbox_menu_actions(self):
"""Connect the viewbox menu action ViewAll to the custom reset_view method."""
@@ -209,6 +238,12 @@ class Waveform(PlotBase):
theme(str, optional): The theme to be applied.
"""
self._refresh_colors()
alignment_panel = getattr(self, "_alignment_panel", None)
alignment_controller = getattr(self, "_alignment_controller", None)
if alignment_panel is not None:
alignment_panel.refresh_theme_colors()
if alignment_controller is not None:
alignment_controller.refresh_theme_colors()
super().apply_theme(theme)
def add_side_menus(self):
@@ -218,6 +253,159 @@ class Waveform(PlotBase):
super().add_side_menus()
self._add_dap_summary_side_menu()
def _init_alignment_mode(self):
"""
Initialize the top alignment panel.
"""
self.toolbar.components.add_safe(
"alignment_mode",
MaterialIconAction(
icon_name="align_horizontal_center",
tooltip="Show Alignment Mode",
checkable=True,
parent=self,
),
)
bundle = ToolbarBundle("alignment_mode", self.toolbar.components)
bundle.add_action("alignment_mode")
self.toolbar.add_bundle(bundle)
shown_bundles = list(self.toolbar.shown_bundles)
if "alignment_mode" not in shown_bundles:
shown_bundles.append("alignment_mode")
self.toolbar.show_bundles(shown_bundles)
self._alignment_side_panel = SidePanel(
parent=self, orientation="top", panel_max_width=320, show_toolbar=False
)
self.layout_manager.add_widget_relative(
self._alignment_side_panel,
self.round_plot_widget,
position="top",
shift_direction="down",
)
self._alignment_panel = WaveformAlignmentPanel(parent=self, client=self.client)
self._alignment_controller = WaveformAlignmentController(
self.plot_item, self._alignment_panel, parent=self
)
self._alignment_panel_index = self._alignment_side_panel.add_menu(
widget=self._alignment_panel
)
self._alignment_controller.move_absolute_requested.connect(self._move_alignment_positioner)
self._alignment_controller.autoscale_requested.connect(self._autoscale_alignment_indicators)
self.dap_summary_update.connect(self._alignment_controller.update_dap_summary)
self.toolbar.components.get_action("alignment_mode").action.toggled.connect(
self.toggle_alignment_mode
)
self._refresh_alignment_state()
@SafeSlot(bool)
def toggle_alignment_mode(self, checked: bool):
"""
Show or hide the alignment panel.
Args:
checked(bool): Whether the panel should be visible.
"""
if self._alignment_side_panel is None or self._alignment_panel_index is None:
return
self._alignment_panel_visible = checked
if checked:
self._alignment_side_panel.show_panel(self._alignment_panel_index)
self._refresh_alignment_state(force_readback=True)
self._refresh_dap_signals()
else:
self._alignment_side_panel.hide_panel()
self._refresh_alignment_state()
def _refresh_alignment_state(self, force_readback: bool = False):
"""
Refresh the alignment panel state after waveform changes.
Args:
force_readback(bool): Force a positioner readback refresh.
"""
if self._alignment_controller is None:
return
context = self._build_alignment_context(force_readback=force_readback)
self._alignment_positioner_name = context.positioner_name
self._alignment_controller.update_context(context)
def _resolve_alignment_positioner(self) -> str | None:
"""
Resolve the active x-axis positioner for alignment mode.
"""
if self.x_axis_mode["name"] in {"index", "timestamp"}:
return None
if self.x_axis_mode["name"] == "auto":
device_name = self._current_x_device[0] if self._current_x_device is not None else None
else:
device_name = self.x_axis_mode["name"]
if not device_name or device_name not in self.dev:
return None
if not isinstance(self.dev[device_name], Positioner):
return None
return device_name
def _build_alignment_context(self, force_readback: bool = False) -> AlignmentContext:
"""Build controller-facing alignment context from waveform/device state."""
positioner_name = self._resolve_alignment_positioner()
if positioner_name is None:
return AlignmentContext(
visible=self._alignment_panel_visible,
positioner_name=None,
has_dap_curves=bool(self._dap_curves),
force_readback=force_readback,
)
precision = getattr(self.dev[positioner_name], "precision", 3)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = 3
limits = getattr(self.dev[positioner_name], "limits", None)
parsed_limits: tuple[float, float] | None = None
if limits is not None and len(limits) == 2:
low, high = float(limits[0]), float(limits[1])
if low != 0 or high != 0:
if low > high:
low, high = high, low
parsed_limits = (low, high)
data = self.dev[positioner_name].read(cached=True)
value = data.get(positioner_name, {}).get("value")
readback = None if value is None else float(value)
return AlignmentContext(
visible=self._alignment_panel_visible,
positioner_name=positioner_name,
precision=precision,
limits=parsed_limits,
readback=readback,
has_dap_curves=bool(self._dap_curves),
force_readback=force_readback,
)
@SafeSlot(float)
def _move_alignment_positioner(self, value: float):
"""
Move the active alignment positioner to an absolute value requested by the controller.
"""
if self._alignment_positioner_name is None:
return
self.dev[self._alignment_positioner_name].move(float(value), relative=False)
@SafeSlot()
def _autoscale_alignment_indicators(self):
"""Autoscale the waveform view after alignment indicator updates."""
self._reset_view()
def _add_waveform_specific_popup(self):
"""
Add popups to the Waveform widget.
@@ -254,7 +442,7 @@ class Waveform(PlotBase):
Due to setting clipToView to True on the curves, the autoRange() method
of the ViewBox does no longer work as expected. This method deactivates the
setClipToView for all curves, calls autoRange() to circumvent that issue.
Afterwards, it re-enables the setClipToView for all curves again.
Afterward, it re-enables the setClipToView for all curves again.
It is hooked to the ViewAll action in the right-click menu of the pg.PlotItem ViewBox.
"""
@@ -532,6 +720,7 @@ class Waveform(PlotBase):
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme
self._refresh_alignment_state(force_readback=True)
@SafeProperty(str)
def signal_x(self) -> str | None:
@@ -561,6 +750,7 @@ class Waveform(PlotBase):
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
self.round_plot_widget.apply_plot_widget_style()
self._refresh_alignment_state(force_readback=True)
@SafeProperty(str)
def color_palette(self) -> str:
@@ -615,6 +805,8 @@ class Waveform(PlotBase):
continue
config = CurveConfig(**cfg_dict)
self._add_curve(config=config)
self._refresh_alignment_state(force_readback=self._alignment_panel_visible)
self._refresh_dap_signals()
except json.JSONDecodeError as e:
logger.error(f"Failed to decode JSON: {e}")
@@ -696,7 +888,8 @@ class Waveform(PlotBase):
signal_y: str | None = None,
color: str | None = None,
label: str | None = None,
dap: str | None = None,
dap: str | list[str] | None = None,
dap_parameters: dict | list | lmfit.Parameters | None | object = None,
scan_id: str | None = None,
scan_number: int | None = None,
**kwargs,
@@ -718,9 +911,14 @@ class Waveform(PlotBase):
signal_y(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): The dap model to use for the curve. When provided, a DAP curve is
dap(str | list[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.
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.
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
the ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan resets.
@@ -733,6 +931,8 @@ class Waveform(PlotBase):
source = "custom"
x_data = None
y_data = None
if dap_parameters is _DAP_PARAM:
dap_parameters = kwargs.pop("dap_parameters", None) or kwargs.pop("parameters", None)
# 1. Custom curve logic
if x is not None and y is not None:
@@ -810,7 +1010,9 @@ class Waveform(PlotBase):
curve = self._add_curve(config=config, x_data=x_data, y_data=y_data)
if dap is not None and curve.config.source in ("device", "history", "custom"):
self.add_dap_curve(device_label=curve.name(), dap_name=dap, **kwargs)
self.add_dap_curve(
device_label=curve.name(), dap_name=dap, dap_parameters=dap_parameters, **kwargs
)
return curve
@@ -820,9 +1022,10 @@ class Waveform(PlotBase):
def add_dap_curve(
self,
device_label: str,
dap_name: str,
dap_name: str | list[str],
color: str | None = None,
dap_oversample: int = 1,
dap_parameters: dict | list | lmfit.Parameters | None = None,
**kwargs,
) -> Curve:
"""
@@ -832,9 +1035,11 @@ class Waveform(PlotBase):
Args:
device_label(str): The label of the source curve to add DAP to.
dap_name(str): The name of the DAP model to use.
dap_name(str | list[str]): The name of the DAP model to use, or a list of model
names to build a composite model.
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:
@@ -859,7 +1064,7 @@ class Waveform(PlotBase):
dev_entry = "custom"
# 2) Build a label for the new DAP curve
dap_label = f"{device_label}-{dap_name}"
dap_label = f"{device_label}-{self._format_dap_label(dap_name)}"
# 3) Possibly raise if the DAP curve already exists
if self._check_curve_id(dap_label):
@@ -882,7 +1087,11 @@ class Waveform(PlotBase):
# Attach device signal with DAP
config.signal = DeviceSignal(
device=dev_name, signal=dev_entry, dap=dap_name, dap_oversample=dap_oversample
device=dev_name,
signal=dev_entry,
dap=dap_name,
dap_oversample=dap_oversample,
dap_parameters=self._normalize_dap_parameters(dap_parameters, dap_name=dap_name),
)
# 4) Create the DAP curve config using `_add_curve(...)`
@@ -973,6 +1182,7 @@ class Waveform(PlotBase):
QTimer.singleShot(
150, self.auto_range
) # autorange with a delay to ensure the plot is updated
self._refresh_alignment_state()
return curve
@@ -1228,6 +1438,7 @@ class Waveform(PlotBase):
self.remove_curve(curve.name())
if self.crosshair is not None:
self.crosshair.clear_markers()
self._refresh_alignment_state()
def get_curve(self, curve: int | str) -> Curve | None:
"""
@@ -1263,6 +1474,7 @@ class Waveform(PlotBase):
self._refresh_colors()
self._categorise_device_curves()
self._refresh_alignment_state()
def _remove_curve_by_name(self, name: str):
"""
@@ -1313,6 +1525,8 @@ class Waveform(PlotBase):
and self.enable_side_panel is True
):
self.dap_summary.remove_dap_data(curve.name())
if curve.config.source == "dap" and self._alignment_controller is not None:
self._alignment_controller.remove_dap_curve(curve.name())
# find a corresponding dap curve and remove it
for c in self.curves:
@@ -1749,12 +1963,14 @@ class Waveform(PlotBase):
if parent_curve is None:
logger.warning(
f"No device curve found for DAP curve '{dap_curve.name()}'!"
) # TODO triggerd when DAP curve is removed from the curve dialog, why?
) # TODO triggered when DAP curve is removed from the curve dialog, why?
continue
x_data, y_data = parent_curve.get_data()
model_name = dap_curve.config.signal.dap
model = getattr(self.dap, model_name)
model = None
if not isinstance(model_name, (list, tuple)):
model = getattr(self.dap, model_name)
try:
x_min, x_max = self.roi_region
x_data, y_data = self._crop_data(x_data, y_data, x_min, x_max)
@@ -1762,20 +1978,132 @@ class Waveform(PlotBase):
x_min = None
x_max = None
dap_parameters = getattr(dap_curve.config.signal, "dap_parameters", None)
dap_kwargs = {
"data_x": x_data,
"data_y": y_data,
"oversample": dap_curve.dap_oversample,
}
if dap_parameters:
dap_kwargs["parameters"] = dap_parameters
if model is not None:
class_args = model._plugin_info["class_args"]
class_kwargs = model._plugin_info["class_kwargs"]
else:
class_args = []
class_kwargs = {"model": model_name}
msg = messages.DAPRequestMessage(
dap_cls="LmfitService1D",
dap_type="on_demand",
config={
"args": [],
"kwargs": {"data_x": x_data, "data_y": y_data},
"class_args": model._plugin_info["class_args"],
"class_kwargs": model._plugin_info["class_kwargs"],
"kwargs": dap_kwargs,
"class_args": class_args,
"class_kwargs": class_kwargs,
"curve_label": dap_curve.name(),
},
metadata={"RID": f"{self.scan_id}-{self.gui_id}"},
)
self.client.connector.set_and_publish(MessageEndpoints.dap_request(), msg)
@staticmethod
def _normalize_dap_parameters(
parameters: dict | list | lmfit.Parameters | None, dap_name: str | list[str] | None = None
) -> dict | list | None:
"""
Normalize user-provided lmfit parameters into a JSON-serializable dict suitable for the DAP server.
Supports:
- `lmfit.Parameters` (single-model only)
- `dict[name -> number]` (treated as fixed parameter with `vary=False`)
- `dict[name -> dict]` (lmfit.Parameter fields; defaults to `vary=False` if unspecified)
- `dict[name -> lmfit.Parameter]`
- composite: `list[dict[param_name -> spec]]` aligned to model list
- composite: `dict[model_name -> dict[param_name -> spec]]` (unique model names only)
"""
if parameters is None:
return None
if isinstance(dap_name, (list, tuple)):
if lmfit is not None and isinstance(parameters, lmfit.Parameters):
raise TypeError("dap_parameters must be a dict when using composite dap models.")
if isinstance(parameters, (list, tuple)):
normalized_list: list[dict | None] = []
for idx, item in enumerate(parameters):
if item is None:
normalized_list.append(None)
continue
if not isinstance(item, dict):
raise TypeError(
f"dap_parameters list item {idx} must be a dict of parameter overrides."
)
normalized_list.append(Waveform._normalize_param_overrides(item))
return normalized_list or None
if not isinstance(parameters, dict):
raise TypeError(
"dap_parameters must be a dict of model->params when using composite dap models."
)
model_names = set(dap_name)
invalid_models = set(parameters.keys()) - model_names
if invalid_models:
raise TypeError(
f"Invalid dap_parameters keys for composite model: {sorted(invalid_models)}"
)
normalized_composite: dict[str, dict] = {}
for model_name in dap_name:
model_params = parameters.get(model_name)
if model_params is None:
continue
if not isinstance(model_params, dict):
raise TypeError(
f"dap_parameters for '{model_name}' must be a dict of parameter overrides."
)
normalized = Waveform._normalize_param_overrides(model_params)
if normalized:
normalized_composite[model_name] = normalized
return normalized_composite or None
if lmfit is not None and isinstance(parameters, lmfit.Parameters):
return serialize_lmfit_params(parameters)
if not isinstance(parameters, dict):
if lmfit is None:
raise TypeError(
"dap_parameters must be a dict when lmfit is not installed on the client."
)
raise TypeError("dap_parameters must be a dict or lmfit.Parameters (or omitted).")
return Waveform._normalize_param_overrides(parameters)
@staticmethod
def _normalize_param_overrides(parameters: dict) -> dict | None:
normalized: dict[str, dict] = {}
for name, spec in parameters.items():
if spec is None:
continue
if isinstance(spec, (int, float, np.number)):
normalized[name] = {"name": name, "value": float(spec), "vary": False}
continue
if lmfit is not None and isinstance(spec, lmfit.Parameter):
normalized[name] = serialize_param_object(spec)
continue
if isinstance(spec, dict):
normalized[name] = {"name": name, **spec}
if "vary" not in normalized[name]:
normalized[name]["vary"] = False
continue
raise TypeError(
f"Invalid dap_parameters entry for '{name}': expected number, dict, or lmfit.Parameter."
)
return normalized or None
@staticmethod
def _format_dap_label(dap_name: str | list[str]) -> str:
if isinstance(dap_name, (list, tuple)):
return "+".join(dap_name)
return dap_name
@SafeSlot(dict, dict)
def update_dap_curves(self, msg, metadata):
"""
@@ -1793,14 +2121,6 @@ class Waveform(PlotBase):
if not curve:
return
# Get data from the parent (device) curve
parent_curve = self._find_curve_by_label(curve.config.parent_label)
if parent_curve is None:
return
x_parent, _ = parent_curve.get_data()
if x_parent is None or len(x_parent) == 0:
return
# Retrieve and store the fit parameters and summary from the DAP server response
try:
curve.dap_params = msg["data"][1]["fit_parameters"]
@@ -1809,19 +2129,13 @@ class Waveform(PlotBase):
logger.warning(f"Failed to retrieve DAP data for curve '{curve.name()}'")
return
# Render model according to the DAP model name and parameters
model_name = curve.config.signal.dap
model_function = getattr(lmfit.models, model_name)()
x_min, x_max = x_parent.min(), x_parent.max()
oversample = curve.dap_oversample
new_x = np.linspace(x_min, x_max, int(len(x_parent) * oversample))
# Evaluate the model with the provided parameters to generate the y values
new_y = model_function.eval(**curve.dap_params, x=new_x)
# Update the curve with the new data
curve.setData(new_x, new_y)
# Plot the fitted curve using the server-provided output to avoid requiring lmfit on the client.
try:
fit_data = msg["data"][0]
curve.setData(np.asarray(fit_data["x"]), np.asarray(fit_data["y"]))
except Exception as e:
logger.exception(f"Failed to plot DAP result for curve '{curve.name()}', error: {e}")
return
metadata.update({"curve_id": curve_id})
self.dap_params_update.emit(curve.dap_params, metadata)
@@ -1854,6 +2168,7 @@ class Waveform(PlotBase):
"""
x_data = None
new_suffix = None
previous_x_device = self._current_x_device
data, access_key = self._fetch_scan_data_and_access()
# 1 User wants custom signal
@@ -1912,6 +2227,7 @@ class Waveform(PlotBase):
if not scan_report_devices:
x_data = None
new_suffix = " (auto: index)"
self._current_x_device = None
else:
device_x = scan_report_devices[0]
signal_x = self.entry_validator.validate_signal(device_x, None)
@@ -1921,8 +2237,10 @@ class Waveform(PlotBase):
entry_obj = data.get(device_x, {}).get(signal_x)
x_data = entry_obj.read()["value"] if entry_obj else None
new_suffix = f" (auto: {device_x}-{signal_x})"
self._current_x_device = (device_x, signal_x)
self._current_x_device = (device_x, signal_x)
self._update_x_label_suffix(new_suffix)
if previous_x_device != self._current_x_device:
self._refresh_alignment_state(force_readback=True)
return x_data
def _update_x_label_suffix(self, new_suffix: str):
@@ -1967,7 +2285,7 @@ class Waveform(PlotBase):
def _categorise_device_curves(self) -> str:
"""
Categorise the device curves into sync and async based on the readout priority.
Categorize the device curves into sync and async based on the readout priority.
"""
if self.scan_item is None:
self.update_with_scan_history(-1)
@@ -2324,6 +2642,8 @@ class Waveform(PlotBase):
Cleanup the widget by disconnecting signals and closing dialogs.
"""
self.proxy_dap_request.cleanup()
if self._alignment_controller is not None:
self._alignment_controller.cleanup()
self.clear_all()
if self.curve_settings_dialog is not None:
self.curve_settings_dialog.reject()
@@ -2341,24 +2661,20 @@ class DemoApp(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Waveform Demo")
self.resize(1200, 600)
self.resize(1600, 600)
self.main_widget = QWidget(self)
self.layout = QHBoxLayout(self.main_widget)
self.setCentralWidget(self.main_widget)
self.waveform_popup = Waveform(popups=True)
self.waveform_popup.plot(device_y="waveform")
self.waveform_side = Waveform(popups=False)
self.waveform_side.plot(device_y="bpm4i", signal_y="bpm4i", dap="GaussianModel")
self.waveform_side.plot(device_y="bpm3a", signal_y="bpm3a")
self.custom_waveform = Waveform(popups=True)
self._populate_custom_curve_demo()
self.layout.addWidget(self.waveform_side)
self.layout.addWidget(self.waveform_popup)
self.sine_waveform = Waveform(popups=True)
self.sine_waveform.dap_params_update.connect(self._log_sine_dap_params)
self._populate_sine_curve_demo()
self.layout.addWidget(self.custom_waveform)
self.layout.addWidget(self.sine_waveform)
def _populate_custom_curve_demo(self):
"""
@@ -2377,8 +2693,141 @@ class DemoApp(QMainWindow): # pragma: no cover
sigma = 0.8
y = amplitude * np.exp(-((x - center) ** 2) / (2 * sigma**2)) + noise
# 1) No explicit parameters: server will use lmfit defaults/guesses.
self.custom_waveform.plot(x=x, y=y, label="custom-gaussian", dap="GaussianModel")
# 2) Easy dict: numbers mean "fix this parameter to value" (vary=False).
self.custom_waveform.plot(
x=x,
y=y,
label="custom-gaussian-fixed-easy",
dap="GaussianModel",
dap_parameters={"amplitude": 1.0},
dap_oversample=5,
)
# 3) Partial parameter override: this should still trigger guessing on the server
# because not all Gaussian parameters are explicitly specified.
self.custom_waveform.plot(
x=x,
y=y,
label="custom-gaussian-partial-guess",
dap="GaussianModel",
dap_parameters={
"center": {"value": 1.2, "vary": True},
"sigma": {"value": sigma, "vary": False, "min": 0.0},
},
)
# 4) Complete parameter override: this should skip guessing on the server.
if lmfit is not None:
params_gauss = lmfit.models.GaussianModel().make_params()
params_gauss["amplitude"].set(value=amplitude, vary=False)
params_gauss["center"].set(value=center, vary=False)
params_gauss["sigma"].set(value=sigma, vary=False, min=0.0)
self.custom_waveform.plot(
x=x,
y=y,
label="custom-gaussian-complete-no-guess",
dap="GaussianModel",
dap_parameters=params_gauss,
)
else:
logger.info("Skipping lmfit.Parameters demo (lmfit not installed on client).")
# Composite example: spectrum with three Gaussians (DAP-only)
x_spec = np.linspace(-5, 5, 800)
rng_spec = np.random.default_rng(123)
centers = [-2.0, 0.6, 2.4]
amplitudes = [2.5, 3.2, 1.8]
sigmas = [0.35, 0.5, 0.3]
y_spec = (
amplitudes[0] * np.exp(-((x_spec - centers[0]) ** 2) / (2 * sigmas[0] ** 2))
+ amplitudes[1] * np.exp(-((x_spec - centers[1]) ** 2) / (2 * sigmas[1] ** 2))
+ amplitudes[2] * np.exp(-((x_spec - centers[2]) ** 2) / (2 * sigmas[2] ** 2))
+ rng_spec.normal(loc=0, scale=0.06, size=x_spec.size)
)
# 5) Composite model with partial overrides only: this should still trigger guessing.
self.custom_waveform.plot(
x=x_spec,
y=y_spec,
label="custom-gaussian-spectrum-partial-guess",
dap=["GaussianModel", "GaussianModel", "GaussianModel"],
dap_parameters=[
{"center": {"value": centers[0], "vary": False}},
{"center": {"value": centers[1], "vary": False}},
{"center": {"value": centers[2], "vary": False}},
],
)
# 6) Composite model with all component parameters specified: this should skip guessing.
self.custom_waveform.plot(
x=x_spec,
y=y_spec,
label="custom-gaussian-spectrum-complete-no-guess",
dap=["GaussianModel", "GaussianModel", "GaussianModel"],
dap_parameters=[
{
"amplitude": {"value": amplitudes[0], "vary": False},
"center": {"value": centers[0], "vary": False},
"sigma": {"value": sigmas[0], "vary": False, "min": 0.0},
},
{
"amplitude": {"value": amplitudes[1], "vary": False},
"center": {"value": centers[1], "vary": False},
"sigma": {"value": sigmas[1], "vary": False, "min": 0.0},
},
{
"amplitude": {"value": amplitudes[2], "vary": False},
"center": {"value": centers[2], "vary": False},
"sigma": {"value": sigmas[2], "vary": False, "min": 0.0},
},
],
)
def _populate_sine_curve_demo(self):
"""
Showcase how lmfit's base SineModel can struggle with a drifting baseline.
"""
x = np.linspace(0, 6 * np.pi, 600)
rng = np.random.default_rng(7)
amplitude = 1.6
frequency = 0.75
phase = 0.4
offset = 0.8
slope = 0.08
noise = rng.normal(loc=0, scale=0.12, size=x.size)
y = offset + slope * x + amplitude * np.sin(2 * np.pi * frequency * x + phase) + noise
# Base SineModel (no offset support) to show the mismatch
self.sine_waveform.plot(x=x, y=y, label="custom-sine-data", dap="SineModel")
# Composite model: Sine + Linear baseline (offset + slope)
self.sine_waveform.plot(
x=x,
y=y,
label="custom-sine-composite",
dap=["SineModel", "LinearModel"],
dap_oversample=4,
)
if lmfit is None:
logger.info("Skipping sine lmfit demo (lmfit not installed on client).")
return
return
@staticmethod
def _log_sine_dap_params(params: dict, metadata: dict):
curve_id = metadata.get("curve_id")
if curve_id not in {
"custom-sine-data-SineModel",
"custom-sine-composite-SineModel+LinearModel",
}:
return
logger.info(f"SineModel DAP fit params ({curve_id}): {params}")
if __name__ == "__main__": # pragma: no cover
import sys

View File

@@ -9,7 +9,8 @@ from qtpy import QtCore, QtGui
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets import BECWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
@@ -40,7 +41,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 - corespons to "
description="Start position for the progress bars in degrees. Default is 90 degrees - corresponds to "
"the top of the ring.",
)
min_value: int | float = Field(0, description="Minimum value for the progress bars.")
@@ -59,7 +60,7 @@ class ProgressbarConfig(ConnectionConfig):
)
class Ring(BECConnector, QWidget):
class Ring(BECWidget, QWidget):
USER_ACCESS = [
"set_value",
"set_color",
@@ -82,8 +83,26 @@ class Ring(BECConnector, 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
@@ -107,7 +126,7 @@ class Ring(BECConnector, QWidget):
if self.config.link_colors:
self._auto_set_background_color()
self.update()
self._request_update()
def set_background(self, color: str | tuple | QColor):
"""
@@ -122,7 +141,7 @@ class Ring(BECConnector, QWidget):
self._background_color = self.convert_color(color)
self.config.background_color = self._background_color.name()
self.update()
self._request_update()
def _auto_set_background_color(self):
"""
@@ -133,7 +152,7 @@ class Ring(BECConnector, QWidget):
bg_color = Colors.subtle_background_color(self._color, bg)
self.config.background_color = bg_color.name()
self._background_color = bg_color
self.update()
self._request_update()
def set_colors_linked(self, linked: bool):
"""
@@ -146,7 +165,7 @@ class Ring(BECConnector, QWidget):
self.config.link_colors = linked
if linked:
self._auto_set_background_color()
self.update()
self._request_update()
def set_line_width(self, width: int):
"""
@@ -156,7 +175,7 @@ class Ring(BECConnector, QWidget):
width(int): Line width for the ring widget
"""
self.config.line_width = width
self.update()
self._request_update()
def set_min_max_values(self, min_value: int | float, max_value: int | float):
"""
@@ -168,7 +187,7 @@ class Ring(BECConnector, QWidget):
"""
self.config.min_value = min_value
self.config.max_value = max_value
self.update()
self._request_update()
def set_start_angle(self, start_angle: int):
"""
@@ -178,7 +197,7 @@ class Ring(BECConnector, QWidget):
start_angle(int): Start angle for the ring widget in degrees
"""
self.config.start_position = start_angle
self.update()
self._request_update()
def set_update(
self, mode: Literal["manual", "scan", "device"], device: str = "", signal: str = ""
@@ -237,7 +256,7 @@ class Ring(BECConnector, QWidget):
precision(int): Precision for the ring widget
"""
self.config.precision = precision
self.update()
self._request_update()
def set_direction(self, direction: int):
"""
@@ -247,7 +266,7 @@ class Ring(BECConnector, QWidget):
direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise.
"""
self.config.direction = direction
self.update()
self._request_update()
def _get_signals_for_device(self, device: str) -> dict[str, list[str]]:
"""
@@ -276,7 +295,7 @@ class Ring(BECConnector, QWidget):
for obj in dev_obj._info["signals"].values()
if obj["kind_str"] == "hinted"
and obj["signal_class"]
not in ["ProgressSignal", "AyncSignal", "AsyncMultiSignal", "DynamicSignal"]
not in ["ProgressSignal", "AsyncSignal", "AsyncMultiSignal", "DynamicSignal"]
]
normal_signals = [
@@ -424,8 +443,11 @@ class Ring(BECConnector, 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, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
QtGui.QPen(self._background_color, current_line_width, QtCore.Qt.PenStyle.SolidLine)
)
gap: int = self.gap # type: ignore
@@ -433,13 +455,25 @@ class Ring(BECConnector, 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.QRect(
adjusted_rect = QtCore.QRectF(
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, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
pen = QtGui.QPen(self.color, current_line_width, QtCore.Qt.PenStyle.SolidLine)
pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
proportion = (self.config.value - self.config.min_value) / (
@@ -449,7 +483,17 @@ class Ring(BECConnector, QWidget):
painter.drawArc(adjusted_rect, start_position, angle)
painter.end()
def convert_color(self, color: str | tuple | QColor) -> QColor:
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:
"""
Convert the color to QColor
@@ -485,7 +529,7 @@ class Ring(BECConnector, QWidget):
@gap.setter
def gap(self, value: int):
self._gap = value
self.update()
self._request_update()
@SafeProperty(bool)
def link_colors(self) -> bool:
@@ -522,7 +566,7 @@ class Ring(BECConnector, QWidget):
float(max(self.config.min_value, min(self.config.max_value, value))),
self.config.precision,
)
self.update()
self._request_update()
@SafeProperty(float)
def min_value(self) -> float:
@@ -531,7 +575,7 @@ class Ring(BECConnector, QWidget):
@min_value.setter
def min_value(self, value: float):
self.config.min_value = value
self.update()
self._request_update()
@SafeProperty(float)
def max_value(self) -> float:
@@ -540,7 +584,7 @@ class Ring(BECConnector, QWidget):
@max_value.setter
def max_value(self, value: float):
self.config.max_value = value
self.update()
self._request_update()
@SafeProperty(str)
def mode(self) -> str:
@@ -549,6 +593,7 @@ class Ring(BECConnector, QWidget):
@mode.setter
def mode(self, value: str):
self.set_update(value)
self._request_update()
@SafeProperty(str)
def device(self) -> str:
@@ -557,6 +602,7 @@ class Ring(BECConnector, QWidget):
@device.setter
def device(self, value: str):
self.config.device = value
self._request_update()
@SafeProperty(str)
def signal(self) -> str:
@@ -565,6 +611,7 @@ class Ring(BECConnector, QWidget):
@signal.setter
def signal(self, value: str):
self.config.signal = value
self._request_update()
@SafeProperty(int)
def line_width(self) -> int:
@@ -573,7 +620,7 @@ class Ring(BECConnector, QWidget):
@line_width.setter
def line_width(self, value: int):
self.config.line_width = value
self.update()
self._request_update()
@SafeProperty(int)
def start_position(self) -> int:
@@ -582,7 +629,7 @@ class Ring(BECConnector, QWidget):
@start_position.setter
def start_position(self, value: int):
self.config.start_position = value
self.update()
self._request_update()
@SafeProperty(int)
def precision(self) -> int:
@@ -591,7 +638,7 @@ class Ring(BECConnector, QWidget):
@precision.setter
def precision(self, value: int):
self.config.precision = value
self.update()
self._request_update()
@SafeProperty(int)
def direction(self) -> int:
@@ -600,7 +647,27 @@ class Ring(BECConnector, QWidget):
@direction.setter
def direction(self, value: int):
self.config.direction = value
self.update()
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()
if __name__ == "__main__": # pragma: no cover

View File

@@ -3,7 +3,7 @@ from typing import Literal
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt
from qtpy.QtCore import QPointF, QSize, Qt
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils import Colors
@@ -12,6 +12,7 @@ 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
@@ -29,7 +30,16 @@ 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()
@@ -59,6 +69,7 @@ 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)
@@ -88,6 +99,10 @@ 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()
@@ -106,6 +121,7 @@ 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):
@@ -150,6 +166,130 @@ 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.
@@ -230,6 +370,9 @@ 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()

View File

@@ -63,7 +63,8 @@ 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)
def _get_theme_color(self, color_name: str) -> QColor | None:
@staticmethod
def _get_theme_color(color_name: str) -> QColor | None:
app = QApplication.instance()
if not app:
return
@@ -249,12 +250,13 @@ 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.container.bec_dispatcher.client.device_manager.devices:
if not device or device not in self.ring.bec_dispatcher.client.device_manager.devices:
return
self.ring.set_update("device", device=device, signal=signal)
self.ring.config.signal = signal
def _unify_mode_string(self, mode: str) -> str:
@staticmethod
def _unify_mode_string(mode: str) -> str:
"""Convert mode string to a unified format"""
mode = mode.lower()
if mode == "scan progress":
@@ -263,7 +265,8 @@ class RingCardWidget(QFrame):
return "device"
return mode
def _get_display_mode_string(self, mode: str) -> str:
@staticmethod
def _get_display_mode_string(mode: str) -> str:
"""Convert mode string to display format"""
match mode:
case "manual":

View File

@@ -0,0 +1,600 @@
"""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_())

View File

@@ -0,0 +1,395 @@
"""
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},
)

View File

@@ -0,0 +1,273 @@
"""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())

View File

@@ -0,0 +1,395 @@
"""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()

View File

@@ -0,0 +1,67 @@
"""Utility functions for experiment selection."""
from datetime import datetime
from typing import Literal
from bec_lib.messages import ExperimentInfoMessage
def format_name(info: dict | ExperimentInfoMessage) -> str:
"""Format the name from the experiment info."""
info = ExperimentInfoMessage.model_validate(info) if isinstance(info, dict) else info
firstname = info.firstname
lastname = info.lastname
return " ".join(part for part in [firstname, lastname] if part)
def format_schedule(
schedule: list[dict[Literal["start", "end"], str]] | None, as_datetime: bool = False
) -> tuple[str, str] | tuple[datetime | None, datetime | None]:
"""Format the schedule information to display start and end times."""
if not schedule:
return (None, None) if as_datetime else ("", "")
start, end = _pick_schedule_entry(schedule)
if as_datetime:
return start, end
return format_datetime(start), format_datetime(end)
def _pick_schedule_entry(
schedule: list[dict[Literal["start", "end"], str]],
) -> tuple[datetime | None, datetime | None]:
"""Pick the most relevant schedule entry based on the current time."""
now = datetime.now()
candidates = []
for item in schedule:
if not item:
continue
start_raw = item.get("start")
parsed = _parse_schedule_start(start_raw)
if parsed is None:
continue
candidates.append((parsed, item))
if not candidates:
return None, None
future = [entry for entry in candidates if entry[0] >= now]
pool = future or candidates
chosen_start, chosen_item = min(pool, key=lambda entry: abs(entry[0] - now))
end_raw = chosen_item.get("end")
return chosen_start, _parse_schedule_start(end_raw)
def _parse_schedule_start(value) -> datetime | None:
"""Parse a schedule start string into a datetime object."""
if not value:
return None
try:
return datetime.strptime(value, "%d/%m/%Y %H:%M:%S")
except ValueError:
return None
def format_datetime(value) -> str:
if not value:
return ""
return value.strftime("%Y-%m-%d %H:%M")

View File

@@ -88,7 +88,7 @@ class DeviceBrowser(BECWidget, QWidget):
self.setLayout(layout)
def init_warning_label(self):
self.ui.scan_running_warning.setText("Warning: editing diabled while scan is running!")
self.ui.scan_running_warning.setText("Warning: editing disabled while scan is running!")
self.ui.scan_running_warning.setStyleSheet(
"background-color: #fcba03; color: rgb(0, 0, 0);"
)

View File

@@ -160,8 +160,8 @@ class ScanHistoryMetadataViewer(BECWidget, QtWidgets.QGroupBox):
Clear the view by resetting the labels and values.
"""
layout = self.layout()
lauout_counts = layout.count()
for i in range(lauout_counts):
layout_counts = layout.count()
for i in range(layout_counts):
item = layout.itemAt(i)
if item.widget():
item.widget().close()

View File

@@ -305,7 +305,7 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
def remove_scan(self, index: int):
"""
Remove a scan entry from the tree widget.
We supoprt negative indexing where -1, -2, etc.
We support negative indexing where -1, -2, etc.
Args:
index (int): The index of the scan entry to remove.

View File

@@ -0,0 +1,11 @@
if __name__ == "__main__": # pragma: no cover
import sys
from pyside6_qtermwidget import QTermWidget # pylint: disable=ungrouped-imports
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
widget = QTermWidget()
widget.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,227 @@
from functools import wraps
from typing import Sequence
from PySide6.QtGui import QAction, QFont, QResizeEvent
from qtpy.QtCore import QIODevice, QPoint, QSize, QUrl, Signal # type: ignore
from qtpy.QtGui import QKeyEvent
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
try:
from pyside6_qtermwidget import QTermWidget
except ImportError:
QTermWidget = None
def _forward(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
target = getattr(self, "_main_widget")
method = getattr(target, func.__name__)
if QTermWidget:
return method(*args, **kwargs)
else:
...
return wrapper
class BecTerminal(QWidget):
activity = Signal()
bell = Signal(str)
copyAvailable = Signal(bool)
currentDirectoryChanged = Signal(str)
finished = Signal()
profileChanged = Signal(str)
receivedData = Signal(str)
silence = Signal()
termGetFocus = Signal()
termKeyPressed = Signal(QKeyEvent)
termLostFocus = Signal()
titleChanged = Signal()
urlActivated = Signal(QUrl, bool)
def __init__(self, /, parent: QWidget | None = None, **kwargs) -> None:
super().__init__(parent)
self._layout = QVBoxLayout()
self.setLayout(self._layout)
if QTermWidget:
self._main_widget = QTermWidget(parent=self)
self.activity.connect(self._main_widget.activity)
self.bell.connect(self._main_widget.bell)
self.copyAvailable.connect(self._main_widget.copyAvailable)
self.currentDirectoryChanged.connect(self._main_widget.currentDirectoryChanged)
self.finished.connect(self._main_widget.finished)
self.profileChanged.connect(self._main_widget.profileChanged)
self.receivedData.connect(self._main_widget.receivedData)
self.silence.connect(self._main_widget.silence)
self.termGetFocus.connect(self._main_widget.termGetFocus)
self.termKeyPressed.connect(self._main_widget.termKeyPressed)
self.termLostFocus.connect(self._main_widget.termLostFocus)
self.titleChanged.connect(self._main_widget.titleChanged)
self.urlActivated.connect(self._main_widget.urlActivated)
else:
self._main_widget = QLabel("pyside6_qterminal is not installed!")
self._layout.addWidget(self._main_widget)
@_forward
def addCustomColorSchemeDir(self, custom_dir: str, /) -> None: ...
@_forward
def autoHideMouseAfter(self, delay: int, /) -> None: ...
@_forward
def availableColorSchemes(self) -> list[str]: ...
@_forward
def availableKeyBindings(self) -> list[str]: ...
@_forward
def bracketText(self, text: str, /) -> None: ...
@_forward
def bracketedPasteModeIsDisabled(self, /) -> bool: ...
@_forward
def changeDir(self, dir: str, /) -> None: ...
@_forward
def clear(self, /) -> None: ...
@_forward
def clearCustomKeyBindingsDir(self, /) -> None: ...
@_forward
def copyClipboard(self, /) -> None: ...
@_forward
def disableBracketedPasteMode(self, disable: bool, /) -> None: ...
@_forward
def filterActions(self, position: QPoint, /) -> list[QAction]: ...
@_forward
def flowControlEnabled(self, /) -> bool: ...
@_forward
def getAvailableColorSchemes(self, /) -> list[str]: ...
@_forward
def getForegroundProcessId(self, /) -> int: ...
@_forward
def getMargin(self, /) -> int: ...
@_forward
def getPtySlaveFd(self, /) -> int: ...
@_forward
def getSelectionEnd(self, row: int, column: int, /) -> None: ...
@_forward
def getSelectionStart(self, row: int, column: int, /) -> None: ...
@_forward
def getShellPID(self, /) -> int: ...
@_forward
def getTerminalFont(self, /) -> QFont: ...
@_forward
def historyLinesCount(self, /) -> int: ...
@_forward
def historySize(self, /) -> int: ...
@_forward
def icon(self, /) -> str: ...
@_forward
def isBidiEnabled(self, /) -> bool: ...
@_forward
def isTitleChanged(self, /) -> bool: ...
@_forward
def keyBindings(self, /) -> str: ...
@_forward
def pasteClipboard(self, /) -> None: ...
@_forward
def pasteSelection(self, /) -> None: ...
@_forward
def resizeEvent(self, arg__1: QResizeEvent, /) -> None: ...
@_forward
def saveHistory(self, device: QIODevice, /) -> None: ...
@_forward
def screenColumnsCount(self, /) -> int: ...
@_forward
def screenLinesCount(self, /) -> int: ...
@_forward
def scrollToEnd(self, /) -> None: ...
@_forward
def selectedText(self, /, preserveLineBreaks: bool = ...) -> str: ...
@_forward
def selectionChanged(self, textSelected: bool, /) -> None: ...
@_forward
def sendKeyEvent(self, e: QKeyEvent, /) -> None: ...
@_forward
def sendText(self, text: str, /) -> None: ...
@_forward
def sessionFinished(self, /) -> None: ...
@_forward
def setArgs(self, args: Sequence[str], /) -> None: ...
@_forward
def setAutoClose(self, arg__1: bool, /) -> None: ...
@_forward
def setBidiEnabled(self, enabled: bool, /) -> None: ...
@_forward
def setBlinkingCursor(self, blink: bool, /) -> None: ...
@_forward
def setBoldIntense(self, boldIntense: bool, /) -> None: ...
@_forward
def setColorScheme(self, name: str, /) -> None: ...
@_forward
def setConfirmMultilinePaste(self, confirmMultilinePaste: bool, /) -> None: ...
@_forward
def setCustomKeyBindingsDir(self, custom_dir: str, /) -> None: ...
@_forward
def setDrawLineChars(self, drawLineChars: bool, /) -> None: ...
@_forward
def setEnvironment(self, environment: Sequence[str], /) -> None: ...
@_forward
def setFlowControlEnabled(self, enabled: bool, /) -> None: ...
@_forward
def setFlowControlWarningEnabled(self, enabled: bool, /) -> None: ...
@_forward
def setHistorySize(self, lines: int, /) -> None: ...
@_forward
def setKeyBindings(self, kb: str, /) -> None: ...
@_forward
def setMargin(self, arg__1: int, /) -> None: ...
@_forward
def setMonitorActivity(self, arg__1: bool, /) -> None: ...
@_forward
def setMonitorSilence(self, arg__1: bool, /) -> None: ...
@_forward
def setMotionAfterPasting(self, arg__1: int, /) -> None: ...
@_forward
def setSelectionEnd(self, row: int, column: int, /) -> None: ...
@_forward
def setSelectionStart(self, row: int, column: int, /) -> None: ...
@_forward
def setShellProgram(self, program: str, /) -> None: ...
@_forward
def setSilenceTimeout(self, seconds: int, /) -> None: ...
@_forward
def setSize(self, arg__1: QSize, /) -> None: ...
@_forward
def setTerminalBackgroundImage(self, backgroundImage: str, /) -> None: ...
@_forward
def setTerminalBackgroundMode(self, mode: int, /) -> None: ...
@_forward
def setTerminalFont(self, font: QFont | str | Sequence[str], /) -> None: ...
@_forward
def setTerminalOpacity(self, level: float, /) -> None: ...
@_forward
def setTerminalSizeHint(self, enabled: bool, /) -> None: ...
@_forward
def setTrimPastedTrailingNewlines(self, trimPastedTrailingNewlines: bool, /) -> None: ...
@_forward
def setWordCharacters(self, chars: str, /) -> None: ...
@_forward
def setWorkingDirectory(self, dir: str, /) -> None: ...
@_forward
def sizeHint(self, /) -> QSize: ...
@_forward
def startShellProgram(self, /) -> None: ...
@_forward
def startTerminalTeletype(self, /) -> None: ...
@_forward
def terminalSizeHint(self, /) -> bool: ...
@_forward
def title(self, /) -> str: ...
@_forward
def toggleShowSearchBar(self, /) -> None: ...
@_forward
def wordCharacters(self, /) -> str: ...
@_forward
def workingDirectory(self, /) -> str: ...
@_forward
def zoomIn(self, /) -> None: ...
@_forward
def zoomOut(self, /) -> None: ...

View File

@@ -31,7 +31,7 @@ api_reference/api_reference.md
## Introduction
An introduction into the single-resposibility principle and the modular design of BEC Widgets.
An introduction into the single-responsibility principle and the modular design of BEC Widgets.
```
```{grid-item-card}

View File

@@ -19,7 +19,7 @@ cd bec_widgets
```
**Install in Editable Mode**:
Please install the package in editable mode into your BEC Python environemnt.
Please install the package in editable mode into your BEC Python environment.
```bash
pip install -e '.[dev,pyside6]'
```

View File

@@ -16,7 +16,7 @@ that the widgets are discoverable.
- make sure that the widget class inherits from both `BECWidget` as well as `QWidget` or a subclass
of it, such as `QComboBox` or `QLineEdit`.
- make sure it initialises each of these superclasses in its `__init__()` method, and passes the
`parent` keyword argumment on to `QWidget.__init__()`.
`parent` keyword argument on to `QWidget.__init__()`.
- add `PLUGIN = True` as a class variable to the widget class
- add `USER_ACCESS = [...]`, including any methods and properties which should be accessible in the
client to the list, as strings.

View File

@@ -17,7 +17,7 @@
````
````{tab} Examples - CLI
In the following examples, we will use `BECIPythonClient` as the main object to interact with the `BECDockArea`. These tutorials focus on how to work with the `BECDockArea` framework, such as adding and removing docks, saving and restoring layouts, and managing the docked widgets. By default the `BECDockArea` is refered as `gui` in `BECIPythonClient`. For more detailed examples of each individual component, please refer to the example sections of each individual [`widget`](user.widgets).
In the following examples, we will use `BECIPythonClient` as the main object to interact with the `BECDockArea`. These tutorials focus on how to work with the `BECDockArea` framework, such as adding and removing docks, saving and restoring layouts, and managing the docked widgets. By default the `BECDockArea` is referred to as `gui` in `BECIPythonClient`. For more detailed examples of each individual component, please refer to the example sections of each individual [`widget`](user.widgets).
## Example 1 - Adding Docks to BECDockArea
@@ -62,7 +62,7 @@ dock_area.waveform_dock
dock_area.motor_dock
dock_area.image_dock
# If objects were closed, we will keep a refernce that will indicate that the dock was deleted
# If objects were closed, we will keep a reference that will indicate that the dock was deleted
# Try closing the window with the dock_area via mouse click on x
dock_area

View File

@@ -79,7 +79,7 @@ if __name__ == "__main__":
````
````{tab} Examples - BEC desginer
````{tab} Examples - BEC designer
The various properties can also be set when the SignalLabel widget is added to a UI in BEC designer:
```{figure} ./designer_screenshot.png

View File

@@ -215,7 +215,7 @@ Display custom text or HTML content.
Display website content.
```
```{grid-item-card} Toogle Widget
```{grid-item-card} Toggle Widget
:link: user.widgets.toggle
:link-type: ref
:img-top: /assets/widget_screenshots/toggle.png
@@ -244,7 +244,7 @@ Modern progress bar for BEC.
:link-type: ref
:img-top: /assets/widget_screenshots/position_indicator.png
Display position of motor withing its limits.
Display position of motor within its limits.
```
```{grid-item-card} LMFit Dialog

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "3.1.2"
version = "3.4.2"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [
@@ -13,14 +13,14 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
"bec_ipython_client~=3.106", # needed for jupyter console
"bec_lib~=3.106",
"bec_qthemes~=1.0, >=1.3.3",
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
"bec_lib~=3.107,>=3.107.2",
"bec_qthemes~=1.0, >=1.3.4",
"black>=26,<27", # needed for bw-generate-cli
"isort>=5.13, <9.0", # needed for bw-generate-cli
"ophyd_devices~=1.29, >=1.29.1",
"pydantic~=2.0",
"pyqtgraph==0.14.0",
"pyqtgraph==0.13.7",
"PySide6==6.9.0",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
@@ -32,6 +32,7 @@ dependencies = [
"copier~=9.7",
"typer~=0.15",
"markdown~=3.9",
"PyJWT~=2.9",
]

View File

@@ -32,8 +32,8 @@ def threads_check_fixture(threads_check):
@pytest.fixture
def gui_id():
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturbate"""
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturbate
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturb"""
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturb
@pytest.fixture(scope="function")

View File

@@ -75,6 +75,13 @@ def test_dock_manipulations_e2e(qtbot, connected_client_gui_obj):
w1 = dock_area.new("Waveform")
w2 = dock_area.new("Waveform")
qtbot.waitUntil(
lambda: all(
gui_id in gui._server_registry for gui_id in [w0._gui_id, w1._gui_id, w2._gui_id]
),
timeout=5000,
)
assert hasattr(gui.bec, "Waveform")
assert hasattr(gui.bec, "Waveform_0")
assert hasattr(gui.bec, "Waveform_1")
@@ -126,6 +133,7 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
xw = gui.new("X")
xw.delete_all()
qtbot.waitUntil(lambda: len(gui.windows) == 2, timeout=3000)
assert xw.__class__.__name__ == "RPCReference"
assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "BECDockArea"
assert len(gui.windows) == 2
@@ -145,12 +153,15 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
qtbot.waitUntil(wait_for_gui_started, timeout=3000)
# gui.windows should have bec with gui_id 'bec'
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
assert len(gui.windows) == 1
# communication should work, main dock area should have same id and be visible
yw = gui.new("Y")
yw.delete_all()
qtbot.waitUntil(lambda: len(gui.windows) == 2, timeout=3000)
assert len(gui.windows) == 2
yw.remove()
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
assert len(gui.windows) == 1 # only bec is left

View File

@@ -72,6 +72,7 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj):
"dap": None,
"device": "bpm4i",
"signal": "bpm4i",
"dap_parameters": None,
"dap_oversample": 1,
}
assert c1._config_dict["source"] == "device"

View File

@@ -89,12 +89,12 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
# Skip private attributes
if object_name.startswith("_"):
continue
# Skip VSCode widget as Code server is not available in the Docker image
if object_name == "VSCodeEditor":
# Skip BECShell as ttyd is not installed
if object_name == "BECShell":
continue
# Skip WebConsole as ttyd is not installed
if object_name == "WebConsole":
# Skip BecConsole as ttyd is not installed
if object_name == "BecConsole":
continue
#############################

View File

@@ -22,7 +22,7 @@ from bec_widgets.cli.client_utils import BECGuiClient
@pytest.fixture(scope="module")
def gui_id():
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturbate"""
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturb"""
return f"figure_{random.randint(0,100)}"

View File

@@ -128,13 +128,9 @@ def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Rand
random_int = random_int_gen.randint(0, 100)
if random_int >= 50:
# Needed, reference gets deleted in the gui
name = gui.dock_area.object_name
gui_id = gui.dock_area._gui_id
gui.dock_area.delete_all() # start fresh
gui.delete("dock_area")
wait_for_namespace_change(
qtbot, gui=gui, parent_widget=gui, object_name=name, widget_gui_id=gui_id, exists=False
)
qtbot.waitUntil(lambda: hasattr(gui, "dock_area") is False, timeout=5000)
@pytest.mark.timeout(PYTEST_TIMEOUT)

View File

@@ -1,47 +1,18 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from math import inf
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, PropertyMock, patch
import fakeredis
import pytest
from bec_lib.bec_service import messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.redis_connector import RedisConnector
from bec_lib.scan_history import ScanHistory
from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner
def fake_redis_server(host, port, **kwargs):
redis = fakeredis.FakeRedis()
return redis
from bec_widgets.tests.utils import FakePositioner, Positioner
@pytest.fixture(scope="function")
def mocked_client(bec_dispatcher):
connector = RedisConnector("localhost:1", redis_cls=fake_redis_server)
# Create a MagicMock object
client = MagicMock() # TODO change to real BECClient
# Shutdown the original client
bec_dispatcher.client.shutdown()
# Mock the connector attribute
bec_dispatcher.client = client
# Mock the device_manager.devices attribute
client.connector = connector
client.device_manager = DMMock()
client.device_manager.add_devices(DEVICES)
def mock_mv(*args, relative=False):
# Extracting motor and value pairs
for i in range(0, len(args), 2):
motor = args[i]
value = args[i + 1]
motor.move(value, relative=relative)
return MagicMock(wait=MagicMock())
client.scans = MagicMock(mv=mock_mv)
# Ensure isinstance check for Positioner passes
original_isinstance = isinstance
@@ -52,8 +23,8 @@ def mocked_client(bec_dispatcher):
return original_isinstance(obj, class_info)
with patch("builtins.isinstance", new=isinstance_mock):
yield client
connector.shutdown() # TODO change to real BECClient
yield bec_dispatcher.client
bec_dispatcher.client.connector.shutdown()
##################################################
@@ -190,17 +161,16 @@ def mocked_client_with_dap(mocked_client, dap_plugin_message):
name="LmfitService1D", status=1, info={}
),
}
client = mocked_client
client.service_status = dap_services
client.connector.set(
type(mocked_client).service_status = PropertyMock(return_value=dap_services)
mocked_client.connector.set(
topic=MessageEndpoints.dap_available_plugins("dap"), msg=dap_plugin_message
)
# Patch the client's DAP attribute so that the available models include "GaussianModel"
patched_models = {"GaussianModel": {}, "LorentzModel": {}, "SineModel": {}}
client.dap._available_dap_plugins = patched_models
mocked_client.dap._available_dap_plugins = patched_models
yield client
yield mocked_client
class DummyData:
@@ -233,7 +203,6 @@ def create_dummy_scan_item():
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
}
}
dummy_scan.status_message = MagicMock()
dummy_scan.status_message.info = {
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
"scan_report_devices": ["samx"],

View File

@@ -1,19 +1,28 @@
import json
import time
from unittest import mock
from unittest.mock import patch
import fakeredis
import h5py
import numpy as np
import pytest
from bec_lib import messages
from bec_lib import messages, service_config
from bec_lib.bec_service import messages
from bec_lib.client import BECClient
from bec_lib.messages import _StoredDataInfo
from bec_qthemes import apply_theme
from bec_qthemes._theme import Theme
from ophyd._pyepics_shim import _dispatcher
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtWidgets import QApplication, QMessageBox
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.tests.utils import DEVICES, DMMock
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
from bec_widgets.utils import error_popups
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
# Patch to set default RAISE_ERROR_DEFAULT to True for tests
# This means that by default, error popups will raise exceptions during tests
@@ -38,15 +47,20 @@ def process_all_deferred_deletes(qapp):
def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument
qapp = QApplication.instance()
process_all_deferred_deletes(qapp)
apply_theme("light")
qapp.processEvents()
if (
not hasattr(qapp, "theme")
or not isinstance(qapp.theme, Theme)
or qapp.theme.theme != "light"
):
apply_theme("light")
qapp.processEvents()
yield
# if the test failed, we don't want to check for open widgets as
# it simply pollutes the output
# stop pyepics dispatcher for leaking tests
from ophyd._pyepics_shim import _dispatcher
_dispatcher.stop()
if request.node.stash._storage.get("failed"):
@@ -71,9 +85,37 @@ def rpc_register():
RPCRegister.reset_singleton()
_REDIS_CONN: QtRedisConnector | None = None
def global_mock_qt_redis_connector(*_, **__):
global _REDIS_CONN
if _REDIS_CONN is None:
_REDIS_CONN = QtRedisConnector(bootstrap="localhost:1", redis_cls=fakeredis.FakeRedis)
return _REDIS_CONN
def mock_client(*_, **__):
with (
patch("bec_lib.client.DeviceManagerBase", DMMock),
patch("bec_lib.client.DAPPlugins"),
patch("bec_lib.client.Scans"),
patch("bec_lib.client.ScanManager"),
patch("bec_lib.bec_service.BECAccess"),
):
client = BECClient(
config=service_config.ServiceConfig(config={"redis": {"host": "localhost", "port": 1}}),
connector_cls=global_mock_qt_redis_connector,
)
client.start()
client.device_manager.add_devices(DEVICES)
return client
@pytest.fixture(autouse=True)
def bec_dispatcher(threads_check): # pylint: disable=unused-argument
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
with mock.patch.object(bec_dispatcher_module, "BECClient", mock_client):
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
yield bec_dispatcher
bec_dispatcher.disconnect_all()
# clean BEC client

View File

@@ -1,5 +1,7 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest.mock import MagicMock
import pytest
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
@@ -10,6 +12,7 @@ from .client_mocks import mocked_client
@pytest.fixture
def abort_button(qtbot, mocked_client):
widget = AbortButton(client=mocked_client)
widget.queue = MagicMock()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget

View File

@@ -0,0 +1,156 @@
from unittest.mock import MagicMock
import numpy as np
import pyqtgraph as pg
from bec_widgets.widgets.plots.waveform.utils.alignment_controller import (
AlignmentContext,
WaveformAlignmentController,
)
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
from .client_mocks import mocked_client
from .conftest import create_widget
from .test_waveform import make_alignment_fit_summary
def create_alignment_controller(qtbot, mocked_client):
plot_widget = pg.PlotWidget()
qtbot.addWidget(plot_widget)
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
controller = WaveformAlignmentController(plot_widget.plotItem, panel, parent=plot_widget)
return plot_widget, panel, controller
def test_alignment_controller_shows_marker_only_when_visible(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
controller.update_context(
AlignmentContext(visible=False, positioner_name="samx", precision=3, readback=1.0)
)
controller.update_position(4.2)
assert controller.marker_line is None
controller.update_context(
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=4.2)
)
assert controller.marker_line is not None
assert np.isclose(controller.marker_line.value(), 4.2)
assert panel.target_toggle.isEnabled() is True
controller.update_context(AlignmentContext(visible=False, positioner_name="samx"))
assert controller.marker_line is None
def test_alignment_controller_target_line_uses_readback_and_limits(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=(0.0, 2.0), readback=5.0
)
)
panel.target_toggle.setChecked(True)
assert controller.target_line is not None
assert np.isclose(controller.target_line.value(), 2.0)
panel.target_toggle.setChecked(False)
assert controller.target_line is None
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=None, readback=5.0
)
)
panel.target_toggle.setChecked(True)
assert controller.target_line is not None
assert np.isclose(controller.target_line.value(), 5.0)
def test_alignment_controller_preserves_dragged_target_on_context_refresh(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=1.0
)
)
panel.target_toggle.setChecked(True)
controller.target_line.setValue(3.0)
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=2.0
)
)
assert controller.target_line is not None
assert np.isclose(controller.target_line.value(), 3.0)
def test_alignment_controller_emits_move_request_for_fit_center(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
move_callback = MagicMock()
controller.move_absolute_requested.connect(move_callback)
controller.update_context(
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
)
controller.update_dap_summary(make_alignment_fit_summary(center=2.5), {"curve_id": "fit"})
assert panel.fit_dialog.action_buttons["center"].isEnabled() is True
panel.fit_dialog.action_buttons["center"].click()
move_callback.assert_called_once_with(2.5)
def test_alignment_controller_requests_autoscale_for_marker_and_target(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
autoscale_callback = MagicMock()
controller.autoscale_requested.connect(autoscale_callback)
controller.update_context(
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
)
panel.target_toggle.setChecked(True)
assert autoscale_callback.call_count >= 2
def test_alignment_controller_emits_move_request_for_target(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
move_callback = MagicMock()
controller.move_absolute_requested.connect(move_callback)
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=1.0
)
)
panel.target_toggle.setChecked(True)
controller.target_line.setValue(1.25)
panel.move_to_target_button.click()
move_callback.assert_called_once_with(1.25)
def test_alignment_controller_removes_deleted_dap_curve(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
controller.update_context(
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
)
controller.update_dap_summary(make_alignment_fit_summary(center=1.5), {"curve_id": "fit"})
assert "fit" in panel.fit_dialog.summary_data
controller.remove_dap_curve("fit")
assert "fit" not in panel.fit_dialog.summary_data
assert panel.fit_dialog.fit_curve_id is None
assert panel.fit_dialog.enable_actions is False

View File

@@ -0,0 +1,40 @@
from unittest.mock import MagicMock
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
from .client_mocks import mocked_client
from .conftest import create_widget
def test_alignment_panel_forwards_position_and_target_signals(qtbot, mocked_client):
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
position_callback = MagicMock()
target_toggle_callback = MagicMock()
target_move_callback = MagicMock()
panel.position_readback_changed.connect(position_callback)
panel.target_toggled.connect(target_toggle_callback)
panel.target_move_requested.connect(target_move_callback)
panel.positioner.position_update.emit(1.25)
panel.target_toggle.setChecked(True)
panel.move_to_target_button.setEnabled(True)
panel.move_to_target_button.click()
position_callback.assert_called_once_with(1.25)
target_toggle_callback.assert_called_once_with(True)
target_move_callback.assert_called_once_with()
def test_alignment_panel_emits_only_center_fit_actions(qtbot, mocked_client):
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
fit_center_callback = MagicMock()
panel.fit_center_requested.connect(fit_center_callback)
panel.fit_dialog.move_action.emit(("sigma", 0.5))
fit_center_callback.assert_not_called()
panel.fit_dialog.move_action.emit(("center", 2.5))
fit_center_callback.assert_called_once_with(2.5)

View File

@@ -0,0 +1,758 @@
import datetime
import time
from types import SimpleNamespace
from unittest import mock
import jwt
import pytest
from bec_lib.messages import (
DeploymentInfoMessage,
ExperimentInfoMessage,
MessagingConfig,
MessagingServiceScopeConfig,
SessionInfoMessage,
)
from qtpy.QtCore import QByteArray, QUrl
from qtpy.QtNetwork import QNetworkRequest
from bec_widgets.utils.fuzzy_search import is_match
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_http_service import (
AtlasEndpoints,
AuthenticatedUserInfo,
BECAtlasHTTPError,
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,
)
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
format_datetime,
format_name,
format_schedule,
)
class _FakeQByteArray:
def __init__(self, payload: bytes):
self._payload = payload
def data(self) -> bytes:
return self._payload
class _FakeReply:
def __init__(
self,
*,
request_url: str,
status: int = 200,
payload: bytes = b"{}",
headers: list[tuple[bytes, bytes]] | None = None,
):
self._request_url = request_url
self._status = status
self._payload = payload
self._headers = (
headers
if headers is not None
else [(QByteArray(b"content-type"), QByteArray(b"application/json"))]
)
self.deleted = False
def attribute(self, attr):
assert attr == QNetworkRequest.Attribute.HttpStatusCodeAttribute
return self._status
def readAll(self):
return _FakeQByteArray(self._payload)
def url(self):
return QUrl(self._request_url)
def rawHeaderPairs(self):
return self._headers
def deleteLater(self):
self.deleted = True
@pytest.fixture
def experiment_info_message() -> ExperimentInfoMessage:
data = {
"_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": "",
}
return ExperimentInfoMessage.model_validate(data)
@pytest.fixture
def experiment_info_list(experiment_info_message: ExperimentInfoMessage) -> list[dict]:
"""Fixture to provide a list of experiment info dictionaries."""
another_experiment_info = {
"_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": "",
}
return [
experiment_info_message.model_dump(),
ExperimentInfoMessage.model_validate(another_experiment_info).model_dump(),
]
class TestBECAtlasHTTPService:
@pytest.fixture
def deployment_info(
self, experiment_info_message: ExperimentInfoMessage
) -> DeploymentInfoMessage:
"""Fixture to provide a DeploymentInfoMessage instance."""
return DeploymentInfoMessage(
deployment_id="dep-1",
name="Test Deployment",
messaging_config=MessagingConfig(
signal=MessagingServiceScopeConfig(enabled=False),
teams=MessagingServiceScopeConfig(enabled=False),
scilog=MessagingServiceScopeConfig(enabled=False),
),
active_session=SessionInfoMessage(
experiment=experiment_info_message, name="Test Session"
),
)
@pytest.fixture
def http_service(self, deployment_info: DeploymentInfoMessage, qtbot):
"""Fixture to create a BECAtlasHTTPService instance."""
service = BECAtlasHTTPService(base_url="http://localhost:8000")
service._set_current_deployment_info(deployment_info)
qtbot.addWidget(service)
qtbot.waitExposed(service)
return service
def test_initialization(self, http_service):
"""Test that the BECAtlasHTTPService initializes correctly."""
assert http_service._base_url == "http://localhost:8000"
assert http_service._auth_timer._timer.isActive() == False
assert http_service._headers == {"accept": "application/json"}
def test_get_request_uses_network_manager_get(self, http_service):
"""Test that _get_request uses the network manager's get method with correct parameters."""
with mock.patch.object(http_service.network_manager, "get") as mock_get:
http_service._get_request(
endpoint=AtlasEndpoints.REALMS_EXPERIMENTS.value,
query_parameters={"realm_id": "realm-1"},
)
mock_get.assert_called_once()
request = mock_get.call_args.args[0]
assert request.url().toString() == (
"http://localhost:8000/realms/experiments?realm_id=realm-1"
)
assert request.rawHeader("accept") == QByteArray(b"application/json")
def test_post_request_uses_network_manager_post(self, http_service):
"""Test that _post_request uses the network manager's post method with correct parameters."""
with mock.patch.object(http_service.network_manager, "post") as mock_post:
http_service._post_request(
endpoint=AtlasEndpoints.LOGIN.value, payload={"username": "alice", "password": "pw"}
)
mock_post.assert_called_once()
request, payload = mock_post.call_args.args
assert request.url().toString() == "http://localhost:8000/user/login"
assert request.rawHeader("accept") == QByteArray(b"application/json")
assert payload == b'{"username": "alice", "password": "pw"}'
def test_public_api(self, http_service):
"""Test BEC ATLAS public API methods from the http service."""
with mock.patch.object(http_service, "_get_request") as mock_get:
# User info
http_service.get_user_info()
mock_get.assert_called_once_with(endpoint=AtlasEndpoints.USER_INFO.value)
mock_get.reset_mock()
# Deployment info
http_service.get_deployment_info("dep-1")
mock_get.assert_called_once_with(
endpoint=AtlasEndpoints.DEPLOYMENT_INFO.value,
query_parameters={"deployment_id": "dep-1"},
)
mock_get.reset_mock()
# Realms experiments
http_service.get_experiments_for_realm("realm-1")
mock_get.assert_called_once_with(
endpoint=AtlasEndpoints.REALMS_EXPERIMENTS.value,
query_parameters={"realm_id": "realm-1"},
)
with mock.patch.object(http_service, "_post_request") as mock_post:
# Logout
http_service.logout()
mock_post.assert_called_once_with(endpoint=AtlasEndpoints.LOGOUT.value)
mock_post.reset_mock()
# Login
http_service.login("alice", "pw")
mock_post.assert_called_once_with(
endpoint=AtlasEndpoints.LOGIN.value, payload={"username": "alice", "password": "pw"}
)
mock_post.reset_mock()
# Set experiment
http_service.set_experiment("exp-1", "dep-1")
mock_post.assert_called_once_with(
endpoint=AtlasEndpoints.SET_EXPERIMENT.value,
query_parameters={"experiment_id": "exp-1", "deployment_id": "dep-1"},
)
def test_handle_response_login(self, http_service, qtbot):
"""Test that handling a login response correctly decodes the token and starts the auth timer."""
exp = time.time() + 300
token = jwt.encode({"email": "alice@example.org", "exp": exp}, "secret", algorithm="HS256")
payload = ("{" f'"access_token": "{token}"' "}").encode()
reply = _FakeReply(
request_url="http://localhost:8000/user/login", status=200, payload=payload
)
with mock.patch.object(http_service, "get_user_info") as mock_get_user_info:
with qtbot.waitSignal(http_service.authentication_expires, timeout=1000) as blocker:
http_service._handle_response(reply)
assert blocker.args[0] == pytest.approx(exp)
assert http_service.auth_user_info is not None
assert http_service.auth_user_info.email == "alice@example.org"
assert http_service.auth_user_info.groups == set()
http_service.get_user_info.assert_called_once()
def test_handle_response_logout(self, http_service, qtbot):
"""Test handle response for logout."""
http_service._auth_user_info = AuthenticatedUserInfo(
email="alice@example.org", exp=time.time() + 60, groups={"staff"}, deployment_id="dep-1"
)
reply = _FakeReply(
request_url="http://localhost:8000/user/logout", status=200, payload=b"{}"
)
with qtbot.waitSignal(http_service.authenticated, timeout=1000) as blocker:
http_service._handle_response(reply)
assert blocker.args[0] == {}
assert http_service.auth_user_info is None
def test_handle_response_user_info(self, http_service):
"""Test handle response for user info endpoint correctly updates auth user info."""
http_service._auth_user_info = AuthenticatedUserInfo(
email="alice@example.org", exp=time.time() + 60, groups=set(), deployment_id="dep-1"
)
http_service._current_deployment_info = SimpleNamespace(deployment_id="dep-1")
reply = _FakeReply(
request_url="http://localhost:8000/user/me",
status=200,
payload=b'{"email": "alice@example.org", "groups": ["operators", "staff"]}',
)
with mock.patch.object(http_service, "get_deployment_info") as mock_get_deployment_info:
http_service._handle_response(reply)
assert http_service.auth_user_info is not None
assert http_service.auth_user_info.groups == {"operators", "staff"}
mock_get_deployment_info.assert_called_once_with(deployment_id="dep-1")
def test_handle_response_deployment_info(self, http_service: BECAtlasHTTPService, qtbot):
"""Test handling deployment info response"""
# Groups match: should emit authenticated signal with user info
http_service._auth_user_info = AuthenticatedUserInfo(
email="alice@example.org",
exp=time.time() + 60,
groups={"operators"},
deployment_id="dep-1",
)
reply = _FakeReply(
request_url="http://localhost:8000/deployments/id?deployment_id=dep-1",
status=200,
payload=b'{"owner_groups": ["operators"], "name": "Beamline Deployment"}',
)
with qtbot.waitSignal(http_service.authenticated, timeout=1000) as blocker:
http_service._handle_response(reply)
assert blocker.args[0]["email"] == "alice@example.org"
assert set(blocker.args[0]["groups"]) == {"operators"}
assert blocker.args[0]["deployment_id"] == "dep-1"
# Groups do not match: should show warning and logout
http_service._auth_user_info = AuthenticatedUserInfo(
email="alice@example.org",
exp=time.time() + 60,
groups={"operators"},
deployment_id="dep-1",
)
reply = _FakeReply(
request_url="http://localhost:8000/deployments/id?deployment_id=dep-1",
status=200,
payload=b'{"owner_groups": ["no-operators"], "name": "Beamline Deployment"}',
)
with (
mock.patch.object(http_service, "_show_warning") as mock_show_warning,
mock.patch.object(http_service, "logout") as mock_logout,
):
http_service._handle_response(reply)
mock_show_warning.assert_called_once()
mock_logout.assert_called_once()
def test_handle_response_deployment_info_admin_access(self, http_service, qtbot):
http_service._auth_user_info = AuthenticatedUserInfo(
email="alice@example.org",
exp=time.time() + 60,
groups={"operators"},
deployment_id="dep-1",
)
# Admin user should authenticate regardless of group membership
reply = _FakeReply(
request_url="http://localhost:8000/deployments/id?deployment_id=dep-1",
status=200,
payload=b'{"owner_groups": ["admin", "atlas_func_account"], "name": "Beamline Deployment"}',
)
with qtbot.waitSignal(http_service.authenticated, timeout=1000) as blocker:
http_service._handle_response(reply)
assert blocker.args[0]["email"] == "alice@example.org"
def test_handle_response_emits_http_response(self, http_service, qtbot):
"""Test that _handle_response emits the http_response signal with correct parameters for a generic response."""
reply = _FakeReply(
request_url="http://localhost:8000/realms/experiments?realm_id=realm-1",
status=200,
payload=b'{"items": []}',
)
with qtbot.waitSignal(http_service.http_response, timeout=1000) as blocker:
http_service._handle_response(reply)
assert blocker.args[0]["request_url"] == (
"http://localhost:8000/realms/experiments?realm_id=realm-1"
)
assert blocker.args[0]["status"] == 200
assert blocker.args[0]["headers"] == {"content-type": "application/json"}
assert blocker.args[0]["data"] == {"items": []}
def test_handle_response_raises_for_invalid_status(self, http_service):
reply = _FakeReply(
request_url="http://localhost:8000/user/me",
status=401,
payload=b'{"detail": "Unauthorized"}',
)
with pytest.raises(BECAtlasHTTPError):
http_service._handle_response(reply, _override_slot_params={"raise_error": True})
class TestBECAtlasExperimentSelection:
def test_format_name(self, experiment_info_message: ExperimentInfoMessage):
"""Test utils format name"""
assert format_name(experiment_info_message) == "John Doe"
def test_format_schedule(self, experiment_info_message: ExperimentInfoMessage):
"""Test utils format schedule"""
assert format_schedule(experiment_info_message.schedule) == (
"2025-01-01 08:00",
"2025-01-03 18:00",
)
assert format_schedule(experiment_info_message.schedule, as_datetime=True) == (
datetime.datetime.strptime(
experiment_info_message.schedule[0]["start"], "%d/%m/%Y %H:%M:%S"
),
datetime.datetime.strptime(
experiment_info_message.schedule[0]["end"], "%d/%m/%Y %H:%M:%S"
),
)
assert format_schedule([]) == ("", "")
def test_format_datetime(self):
"""Test utils format datetime"""
dt = datetime.datetime(2025, 1, 1, 8, 0)
assert format_datetime(dt) == "2025-01-01 08:00"
assert format_datetime(None) == ""
@pytest.fixture
def mat_card(self, qtbot):
"""Fixture to create an ExperimentMatCard instance."""
card = ExperimentMatCard()
qtbot.addWidget(card)
qtbot.waitExposed(card)
return card
def test_set_experiment_info(
self, mat_card: ExperimentMatCard, experiment_info_message: ExperimentInfoMessage, qtbot
):
"""Test that set_experiment_info correctly updates the card's display based on the provided experiment info, whether it's a dictionary or an ExperimentInfoMessage instance."""
# Test with ExperimentInfoMessage instance
mat_card.set_experiment_info(experiment_info_message)
assert mat_card._card_pgroup.text() == "p22622"
assert mat_card._card_title.text() == "Next Experiment"
assert mat_card._abstract_label.text() == experiment_info_message.abstract.strip()
assert mat_card.experiment_info == experiment_info_message.model_dump()
assert mat_card._activate_button.isEnabled()
assert mat_card._activate_button.text() == "Activate"
# Test with dictionary input
mat_card.set_experiment_info(experiment_info_message.model_dump())
mat_card.set_title("Experiment Details")
assert mat_card._card_pgroup.text() == "p22622"
assert mat_card._card_title.text() == "Experiment Details"
assert mat_card._abstract_label.text() == experiment_info_message.abstract.strip()
assert mat_card.experiment_info == experiment_info_message.model_dump()
assert mat_card._activate_button.isEnabled()
assert mat_card._activate_button.text() == "Activate"
with qtbot.waitSignal(mat_card.experiment_selected, timeout=1000) as blocker:
mat_card._activate_button.click()
assert blocker.args[0] == experiment_info_message.model_dump()
def test_is_match(self):
"""Test is_match utility function for search functionality."""
data = {"name": "Test Experiment", "description": "This is a test."}
relevant_keys = ["name", "description"]
# Test exact match
assert is_match("Test Experiment", data, relevant_keys, enable_fuzzy=False)
assert not is_match("Nonexistent", data, relevant_keys, enable_fuzzy=False)
# Test fuzzy match
assert not is_match("Nonexistent", data, relevant_keys, enable_fuzzy=True)
assert is_match("Test Experimnt", data, relevant_keys, enable_fuzzy=True)
# Typo should still match with fuzzy enabled
assert is_match("Test Experiement", data, relevant_keys, enable_fuzzy=True)
@pytest.fixture
def experiment_selection(self, qtbot):
"""Fixture to create an ExperimentSelection instance with sample experiment info."""
selection = ExperimentSelection()
qtbot.addWidget(selection)
qtbot.waitExposed(selection)
return selection
def test_set_experiments(
self, experiment_selection: ExperimentSelection, experiment_info_list: list[dict]
):
"""Test that set_experiment_infos correctly populates the experiment selection with provided experiment info."""
with mock.patch.object(
experiment_selection._card_tab, "set_experiment_info"
) as mock_set_experiment_info:
experiment_selection.set_experiment_infos(experiment_info_list)
assert len(experiment_selection._experiment_infos) == 2
# Next experiment should be the first one as the second one has no schedule
mock_set_experiment_info.assert_called_once_with(experiment_info_list[0])
# Should be on card tab
assert experiment_selection._tabs.currentWidget() == experiment_selection._card_tab
def test_filter_functionality(
self, experiment_selection: ExperimentSelection, experiment_info_list: list[dict], qtbot
):
"""Test that the search functionality correctly filters experiments based on the search query."""
wid = experiment_selection
wid.set_experiment_infos(experiment_info_list)
# First move to the table tab
wid._tabs.setCurrentWidget(wid._table_tab)
assert wid._side_card.experiment_info == wid._next_experiment
# Initially, both experiments should be in the table
assert wid._table.rowCount() == 2
with qtbot.waitSignal(wid._with_proposals.toggled, timeout=1000):
wid._with_proposals.setChecked(False) # Should hide one experiment
assert wid._table.rowCount() == 1
with qtbot.waitSignal(wid._without_proposals.toggled, timeout=1000):
wid._without_proposals.setChecked(False) # Should hide the other experiment
assert wid._table.rowCount() == 0
with qtbot.waitSignals(
[wid._without_proposals.toggled, wid._with_proposals.toggled], timeout=1000
):
wid._without_proposals.setChecked(True)
wid._with_proposals.setChecked(True) # Should show both experiments again
assert wid._table.rowCount() == 2
# Click on first experiment and check if side card updates
with qtbot.waitSignal(wid._table.itemSelectionChanged, timeout=1000):
wid._table.selectRow(0) # Select the first experiment
pgroup = wid._table.item(0, 0).text() # pgroup
exp = [exp for exp in experiment_info_list if exp["pgroup"] == pgroup][0]
assert wid._side_card.experiment_info == exp
# Click on second experiment and check if side card updates
with qtbot.waitSignal(wid._table.itemSelectionChanged, timeout=1000):
wid._table.selectRow(1) # Select the second experiment
pgroup = wid._table.item(1, 0).text() # pgroup
exp = [exp for exp in experiment_info_list if exp["pgroup"] == pgroup][0]
assert wid._side_card.experiment_info == exp
wid.search_input.setText("Experiment without Proposal")
with mock.patch.object(wid, "_apply_row_filter") as mock_apply_row_filter:
with qtbot.waitSignal(wid.fuzzy_is_disabled.stateChanged, timeout=1000):
wid.fuzzy_is_disabled.setChecked(True) # Disable fuzzy search
mock_apply_row_filter.assert_called_once_with("Experiment without Proposal")
assert wid._enable_fuzzy_search is False
def test_emit_selected_experiment(
self, experiment_selection: ExperimentSelection, experiment_info_list: list[dict], qtbot
):
"""Test that clicking the activate button on the side card emits the experiment_selected signal with the correct experiment info."""
wid = experiment_selection
wid.set_experiment_infos(experiment_info_list)
wid._tabs.setCurrentWidget(wid._table_tab)
with qtbot.waitSignal(wid._table.itemSelectionChanged, timeout=1000):
wid._table.selectRow(1) # Select the second experiment
pgroup = wid._table.item(1, 0).text() # pgroup
exp = [exp for exp in experiment_info_list if exp["pgroup"] == pgroup][0]
with qtbot.waitSignal(wid.experiment_selected, timeout=1000) as blocker:
wid._side_card._activate_button.click()
assert blocker.args == [exp]
class TestBECAtlasAdminView:
@pytest.fixture
def admin_view(self, qtbot):
"""Fixture to create a BECAtlasAdminView instance."""
with mock.patch(
"bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view.BECAtlasAdminView._connect_dispatcher"
):
view = BECAtlasAdminView()
qtbot.addWidget(view)
qtbot.waitExposed(view)
return view
def test_init_and_login(self, admin_view: BECAtlasAdminView, qtbot):
"""Test that the BECAtlasAdminView initializes correctly."""
# Check that the atlas URL is set correctly
assert admin_view._atlas_url == "https://bec-atlas-prod.psi.ch/api/v1"
# Test that clicking the login button emits the credentials_entered signal with the correct username and password
with mock.patch.object(admin_view.atlas_http_service, "login") as mock_login:
with qtbot.waitSignal(admin_view.overview_widget.login_requested, timeout=1000):
admin_view.overview_widget._login.username.setText("alice")
admin_view.overview_widget._login.password.setText("password")
admin_view.overview_widget._login._emit_credentials()
mock_login.assert_called_once_with(username="alice", password="password")
mock_login.reset_mock()
admin_view._authenticated = True
with mock.patch.object(admin_view, "logout") as mock_logout:
with qtbot.waitSignal(admin_view.overview_widget.login_requested, timeout=1000):
admin_view.overview_widget._login.password.setText("password")
admin_view.overview_widget._login._emit_credentials()
mock_logout.assert_called_once()
mock_login.assert_called_once_with(username="alice", password="password")
def test_on_experiment_selected(
self, admin_view: BECAtlasAdminView, deployment_info: DeploymentInfoMessage, qtbot
):
"""Test that selecting an experiment in the overview widget correctly calls the HTTP service to set the experiment and updates the current experiment view."""
# First we need to simulate that we are authenticated and have deployment info
admin_view._update_deployment_info(deployment_info.model_dump(), {})
with mock.patch.object(
admin_view.atlas_http_service, "set_experiment"
) as mock_set_experiment:
with qtbot.waitSignal(
admin_view.experiment_selection.experiment_selected, timeout=1000
):
admin_view.experiment_selection.experiment_selected.emit(
deployment_info.active_session.experiment.model_dump()
)
mock_set_experiment.assert_called_once_with(
deployment_info.active_session.experiment.pgroup, deployment_info.deployment_id
)
@pytest.fixture
def deployment_info(
self, experiment_info_message: ExperimentInfoMessage
) -> DeploymentInfoMessage:
"""Fixture to provide a DeploymentInfoMessage instance."""
return DeploymentInfoMessage(
deployment_id="dep-1",
name="Test Deployment",
messaging_config=MessagingConfig(
signal=MessagingServiceScopeConfig(enabled=False),
teams=MessagingServiceScopeConfig(enabled=False),
scilog=MessagingServiceScopeConfig(enabled=False),
),
active_session=SessionInfoMessage(
experiment=experiment_info_message, name="Test Session"
),
)
def test_on_authenticated(
self, admin_view: BECAtlasAdminView, deployment_info: DeploymentInfoMessage, qtbot
):
"""Test that the on_authenticated method correctly updates the UI based on authentication state."""
# Simulate successful authentication
auth_info = AuthenticatedUserInfo(
email="alice@example.com",
exp=time.time() + 300,
groups={"operators"},
deployment_id="dep-1",
)
# First check that deployment info updates all fields correctly
admin_view._update_deployment_info(deployment_info.model_dump(), {})
assert admin_view.atlas_http_service._current_deployment_info == deployment_info
assert (
admin_view._atlas_info_widget._bl_info_label.text()
== f"{deployment_info.active_session.experiment.realm_id} @ {deployment_info.name}"
)
assert admin_view._atlas_info_widget._atlas_url_label.text() == admin_view._atlas_url
# Now run on_authenticated, this enables all toolbar buttons
# and calls fetch experiments. It also switches the overview widget
# to the current experiment view.
# Default should be on the overview widget
assert admin_view.stacked_layout.currentWidget() == admin_view.overview_widget
with mock.patch.object(
admin_view, "_fetch_available_experiments"
) as mock_fetch_experiments:
with qtbot.waitSignal(admin_view.authenticated, timeout=1000) as blocker:
admin_view._on_authenticated(auth_info.model_dump())
# Fetch experiments should be called
mock_fetch_experiments.assert_called_once()
assert blocker.args[0] is True
assert (
admin_view._atlas_info_widget._atlas_url_label.text()
== f"{admin_view._atlas_info_widget._atlas_url_text} | {auth_info.email}"
)
assert (
admin_view.toolbar.components.get_action("messaging_services").action.isEnabled()
is False
)
# Logout timer is running
logout_action = admin_view.toolbar.components.get_action("logout")
assert logout_action.action.isEnabled() is True
assert logout_action._tick_timer.isActive() is True
# Current Experiment widget should be visible in the overview
assert (
admin_view.overview_widget.stacked_layout.currentWidget()
== admin_view.overview_widget._experiment_overview_widget
)
# Click toolbar to switch to experiment selection
exp_select = admin_view.toolbar.components.get_action("experiment_selection")
assert exp_select.action.isEnabled() is True
with qtbot.waitSignal(exp_select.action.triggered, timeout=1000):
exp_select.action.trigger()
assert admin_view.stacked_layout.currentWidget() == admin_view.experiment_selection
# Now we simulate that the authentication expires
# This deactivates buttons, resets the overview widget
# and emits authenticated signal with False
with qtbot.waitSignal(admin_view.authenticated, timeout=1000) as blocker:
admin_view._on_authenticated({}) # Simulate not authenticated anymore
assert blocker.args[0] is False
assert logout_action._tick_timer.isActive() is False
assert admin_view._atlas_info_widget._atlas_url_label.text() == admin_view._atlas_url
assert (
admin_view.overview_widget.stacked_layout.currentWidget()
== admin_view.overview_widget._login_widget
)
# View should switch back to overview
assert admin_view.stacked_layout.currentWidget() == admin_view.overview_widget
def test_fetch_experiments(
self, admin_view: BECAtlasAdminView, deployment_info: DeploymentInfoMessage, qtbot
):
"""Test that _fetch_available_experiments correctly calls the HTTP service and updates the experiment selection widget."""
admin_view._update_deployment_info(deployment_info.model_dump(), {})
with mock.patch.object(
admin_view.atlas_http_service, "get_experiments_for_realm"
) as mock_get_experiments:
admin_view._fetch_available_experiments()
mock_get_experiments.assert_called_once_with(
deployment_info.active_session.experiment.realm_id
)
def test_on_http_response_received(
self, experiment_info_list: list[dict], admin_view: BECAtlasAdminView, qtbot
):
"""Test that _on_http_response_received correctly handles HTTP responses and updates the UI accordingly."""
realms = HTTPResponse(
request_url=f"{admin_view._atlas_url}/{AtlasEndpoints.REALMS_EXPERIMENTS}/experiments?realm_id=TestBeamline",
status=200,
headers={"content-type": "application/json"},
data=experiment_info_list,
)
with mock.patch.object(
admin_view.experiment_selection, "set_experiment_infos"
) as mock_set_experiment_infos:
admin_view._on_http_response_received(realms.model_dump())
mock_set_experiment_infos.assert_called_once_with(experiment_info_list)
set_experiment = HTTPResponse(
request_url=f"{admin_view._atlas_url}/{AtlasEndpoints.SET_EXPERIMENT}",
status=200,
headers={"content-type": "application/json"},
data={},
)
with mock.patch.object(admin_view, "_on_overview_selected") as mock_on_overview_selected:
admin_view._on_http_response_received(set_experiment.model_dump())
mock_on_overview_selected.assert_called_once()

View File

@@ -4,10 +4,45 @@ import time
from unittest import mock
import pytest
from bec_lib import service_config
from bec_lib.messages import ScanMessage
from bec_lib.serialization import MsgpackSerialization
from bec_widgets.utils.bec_dispatcher import QtRedisConnector, QtThreadSafeCallback
from bec_widgets.utils.bec_dispatcher import BECDispatcher, QtRedisConnector, QtThreadSafeCallback
def test_init_handles_client_and_config_arg():
# Client passed
self_mock = mock.MagicMock(_initialized=False)
with mock.patch.object(BECDispatcher, "start_cli_server"):
BECDispatcher.__init__(self_mock, client=mock.MagicMock(name="test_client"))
assert "test_client" in repr(self_mock.client)
# No client, service config object
self_mock.reset_mock()
self_mock._initialized = False
with (
mock.patch.object(BECDispatcher, "start_cli_server"),
mock.patch("bec_widgets.utils.bec_dispatcher.BECClient") as client_cls,
):
config = service_config.ServiceConfig()
BECDispatcher.__init__(self_mock, client=None, config=config)
client_cls.assert_called_with(
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
)
# No client, service config string
self_mock.reset_mock()
self_mock._initialized = False
with (
mock.patch.object(BECDispatcher, "start_cli_server"),
mock.patch("bec_widgets.utils.bec_dispatcher.BECClient"),
mock.patch("bec_widgets.utils.bec_dispatcher.ServiceConfig") as svc_cfg,
mock.patch("bec_widgets.utils.bec_dispatcher.isinstance", return_value=False),
):
config = service_config.ServiceConfig()
BECDispatcher.__init__(self_mock, client=None, config="test_str")
svc_cfg.assert_called_with("test_str")
@pytest.fixture

View File

@@ -17,6 +17,8 @@ def dap_combobox(qtbot, mocked_client):
def test_dap_combobox_init(dap_combobox):
"""Test DapComboBox init."""
assert dap_combobox.fit_model_combobox is dap_combobox
assert dap_combobox.isEditable() is True
assert dap_combobox.fit_model_combobox.currentText() == "GaussianModel"
assert dap_combobox.available_models == ["GaussianModel", "LorentzModel", "SineModel"]
assert dap_combobox._validate_dap_model("GaussianModel") is True
@@ -30,7 +32,7 @@ def test_dap_combobox_set_axis(dap_combobox):
container = []
def my_callback(msg: str):
"""Calback function to store the messages."""
"""Callback function to store the messages."""
container.append(msg)
dap_combobox.x_axis_updated.connect(my_callback)
@@ -49,7 +51,7 @@ def test_dap_combobox_select_fit(dap_combobox):
container = []
def my_callback(msg: str):
"""Calback function to store the messages."""
"""Callback function to store the messages."""
container.append(msg)
dap_combobox.fit_model_updated.connect(my_callback)
@@ -64,10 +66,32 @@ def test_dap_combobox_currentTextchanged(dap_combobox):
container = []
def my_callback(msg: str):
"""Calback function to store the messages."""
"""Callback function to store the messages."""
container.append(msg)
assert dap_combobox.fit_model_combobox.currentText() == "GaussianModel"
dap_combobox.fit_model_updated.connect(my_callback)
dap_combobox.fit_model_combobox.setCurrentText("SineModel")
assert container[0] == "SineModel"
def test_dap_combobox_init_without_available_models(qtbot, mocked_client):
mocked_client.dap._available_dap_plugins = {}
widget = create_widget(qtbot, DapComboBox, client=mocked_client)
assert widget.available_models == []
assert widget.fit_model_combobox.count() == 0
assert widget.fit_model_combobox.currentText() == ""
def test_dap_combobox_invalid_manual_entry_highlighted(dap_combobox):
dap_combobox.setCurrentText("not-a-model")
assert dap_combobox.is_valid_input is False
assert "red" in dap_combobox.styleSheet()
dap_combobox.setCurrentText("GaussianModel")
assert dap_combobox.is_valid_input is True
assert "transparent" in dap_combobox.styleSheet()

View File

@@ -160,7 +160,7 @@ def test_signal_display(mocked_client, qtbot):
def test_signal_display_no_device(mocked_client, qtbot):
device_mock = mock.MagicMock()
mocked_client.client.device_manager.devices = {"test_device_1": device_mock}
mocked_client.device_manager.devices = {"test_device_1": device_mock}
signal_display = SignalDisplay(client=mocked_client, device="test_device_2")
qtbot.addWidget(signal_display)
assert (

View File

@@ -1422,7 +1422,7 @@ class TestDeviceConfigTemplate:
qtbot.waitExposed(template)
yield template
def test_device_config_teamplate_default_init(
def test_device_config_template_default_init(
self, device_config_template: DeviceConfigTemplate, qtbot
):
"""Test DeviceConfigTemplate default initialization."""

View File

@@ -146,12 +146,12 @@ def test_signal_lineedit(device_signal_line_edit):
def test_device_signal_input_base_cleanup(qtbot, mocked_client):
with mock.patch.object(mocked_client.callbacks, "remove"):
widget = DeviceInputWidget(client=mocked_client)
widget.close()
widget.deleteLater()
widget = DeviceInputWidget(client=mocked_client)
widget.close()
widget.deleteLater()
mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register)
mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register)
def test_signal_combobox_get_signal_name_with_item_data(qtbot, device_signal_combobox):

View File

@@ -918,7 +918,7 @@ class TestToolbarFunctionality:
action.trigger()
if action_name == "terminal":
mock_new.assert_called_once_with(
widget="WebConsole", closable=True, startup_cmd=None
widget="BecConsole", closable=True, startup_cmd=None
)
else:
mock_new.assert_called_once_with(widget=widget_type)
@@ -2272,7 +2272,7 @@ class TestFlatToolbarActions:
"flat_queue": "BECQueue",
"flat_status": "BECStatusBox",
"flat_progress_bar": "RingProgressBar",
"flat_terminal": "WebConsole",
"flat_terminal": "BecConsole",
"flat_bec_shell": "BECShell",
"flat_sbb_monitor": "SBBMonitor",
}

View File

@@ -113,7 +113,7 @@ def test_client_generator_with_black_formatting():
class _WidgetsEnumType(str, enum.Enum):
"""Enum for the available widgets, to be generated programatically"""
"""Enum for the available widgets, to be generated programmatically"""
...

View File

@@ -197,6 +197,163 @@ def test_image_setup_preview_signal_2d(qtbot, mocked_client):
np.testing.assert_array_equal(view.main_image.image, test_data)
def test_switching_device_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch):
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2)
_set_signal_config(mocked_client, "waveform1d", "img", signal_class="PreviewSignal", ndim=2)
connected = []
disconnected = []
monkeypatch.setattr(
view.bec_dispatcher,
"connect_slot",
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
)
monkeypatch.setattr(
view.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
)
view.image(device="eiger", signal="img")
connected.clear()
disconnected.clear()
view.device = "waveform1d"
assert MessageEndpoints.device_preview("eiger", "img") in disconnected
assert MessageEndpoints.device_preview("waveform1d", "img") in connected
def test_switching_device_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch):
"""
Verify that switching device while async_update=True disconnects device_async_signal
endpoints for both scan_id and old_scan_id on the old device before reconnecting to
the new device.
"""
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
)
_set_signal_config(
mocked_client, "waveform1d", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
)
connected = []
disconnected = []
monkeypatch.setattr(
view.bec_dispatcher,
"connect_slot",
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
)
monkeypatch.setattr(
view.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
)
view.image(device="eiger", signal="img")
assert view.async_update is True
assert view.subscriptions["main"].async_signal_name == "async_obj"
view.scan_id = "scan_current"
view.old_scan_id = "scan_previous"
connected.clear()
disconnected.clear()
view.device = "waveform1d"
# Both scan_id and old_scan_id endpoints for the old device must be disconnected
assert (
MessageEndpoints.device_async_signal("scan_current", "eiger", "async_obj") in disconnected
)
assert (
MessageEndpoints.device_async_signal("scan_previous", "eiger", "async_obj") in disconnected
)
# The new device's async endpoint for the current scan must be connected
assert (
MessageEndpoints.device_async_signal("scan_current", "waveform1d", "async_obj") in connected
)
def test_switching_signal_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch):
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(mocked_client, "eiger", "img_a", signal_class="PreviewSignal", ndim=2)
_set_signal_config(mocked_client, "eiger", "img_b", signal_class="PreviewSignal", ndim=2)
connected = []
disconnected = []
monkeypatch.setattr(
view.bec_dispatcher,
"connect_slot",
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
)
monkeypatch.setattr(
view.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
)
view.image(device="eiger", signal="img_a")
connected.clear()
disconnected.clear()
view.signal = "img_b"
assert MessageEndpoints.device_preview("eiger", "img_a") in disconnected
assert MessageEndpoints.device_preview("eiger", "img_b") in connected
def test_switching_signal_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch):
"""
When the current monitor is an async signal, switching to a different signal must
disconnect the previous async endpoint (based on scan_id/async_signal_name) before
reconnecting with the new signal's async endpoint.
"""
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(
mocked_client, "eiger", "img_a", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_a"
)
_set_signal_config(
mocked_client, "eiger", "img_b", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_b"
)
connected = []
disconnected = []
monkeypatch.setattr(
view.bec_dispatcher,
"connect_slot",
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
)
monkeypatch.setattr(
view.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
)
# Connect to img_a as an async signal; scan_id is None so no actual subscription is made
view.image(device="eiger", signal="img_a")
assert view.async_update is True
assert view.subscriptions["main"].async_signal_name == "async_obj_a"
assert view.subscriptions["main"].source == "device_monitor_2d"
# Simulate an active scan so that the async endpoint is real
view.scan_id = "scan_123"
connected.clear()
disconnected.clear()
# Switch to a different signal
view.signal = "img_b"
# The previous async endpoint for img_a must have been disconnected
expected_disconnect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_a")
assert expected_disconnect in disconnected
# The new async endpoint for img_b must have been connected
expected_connect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_b")
assert expected_connect in connected
def test_preview_signals_skip_0d_entries(qtbot, mocked_client, monkeypatch):
"""
Preview/async combobox should omit 0D signals.

View File

@@ -140,7 +140,7 @@ def lmfit_message():
def test_fit_curve_id(lmfit_dialog):
"""Test hide_curve_selection property"""
"""Test fit_curve_id property and selected_fit signal"""
my_callback = mock.MagicMock()
lmfit_dialog.selected_fit.connect(my_callback)
assert lmfit_dialog.fit_curve_id is None
@@ -148,6 +148,10 @@ def test_fit_curve_id(lmfit_dialog):
assert lmfit_dialog.fit_curve_id == "test_curve_id"
assert my_callback.call_count == 1
assert my_callback.call_args == mock.call("test_curve_id")
# Setting to None should not emit selected_fit
lmfit_dialog.fit_curve_id = None
assert lmfit_dialog.fit_curve_id is None
assert my_callback.call_count == 1
def test_remove_dap_data(lmfit_dialog):
@@ -166,6 +170,35 @@ def test_remove_dap_data(lmfit_dialog):
assert lmfit_dialog.ui.curve_list.count() == 1
def test_remove_dap_data_selected_curve_switches_to_next(lmfit_dialog):
"""Removing the currently selected curve should switch to the next available one"""
my_callback = mock.MagicMock()
lmfit_dialog.selected_fit.connect(my_callback)
lmfit_dialog.summary_data = {"curve_a": "data_a", "curve_b": "data_b"}
lmfit_dialog.fit_curve_id = "curve_a"
my_callback.reset_mock()
lmfit_dialog.remove_dap_data("curve_a")
assert lmfit_dialog.fit_curve_id == "curve_b"
assert my_callback.call_count == 1
assert my_callback.call_args == mock.call("curve_b")
def test_remove_dap_data_selected_curve_clears_when_last(lmfit_dialog):
"""Removing the only/last selected curve should clear the selection without emitting"""
my_callback = mock.MagicMock()
lmfit_dialog.selected_fit.connect(my_callback)
lmfit_dialog.summary_data = {"curve_a": "data_a"}
lmfit_dialog.fit_curve_id = "curve_a"
my_callback.reset_mock()
lmfit_dialog.remove_dap_data("curve_a")
assert lmfit_dialog.fit_curve_id is None
assert my_callback.call_count == 0
def test_update_summary_tree(lmfit_dialog, lmfit_message):
"""Test display_fit_details method"""
lmfit_dialog.active_action_list = ["center", "amplitude"]
@@ -182,3 +215,18 @@ def test_update_summary_tree(lmfit_dialog, lmfit_message):
assert lmfit_dialog.ui.param_tree.topLevelItemCount() == 4
assert lmfit_dialog.ui.param_tree.topLevelItem(0).text(0) == "amplitude"
assert lmfit_dialog.ui.param_tree.topLevelItem(0).text(1) == "1.582"
def test_compact_ui_hides_curve_selection_and_keeps_action_column(
qtbot, mocked_client, lmfit_message
):
dialog = create_widget(
qtbot, LMFitDialog, client=mocked_client, ui_file="lmfit_dialog_compact.ui"
)
dialog.hide_curve_selection = True
dialog.active_action_list = ["center"]
dialog.update_summary_tree(data=lmfit_message, metadata={"curve_id": "test_curve_id"})
assert dialog.ui.group_curve_selection.isHidden()
assert dialog.ui.param_tree.columnCount() == 4
assert "center" in dialog.action_buttons

View File

@@ -36,11 +36,11 @@ class PositionerWithoutPrecision(Positioner):
def positioner_box(qtbot, mocked_client):
"""Fixture for PositionerBox widget"""
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.uuid.uuid4"
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.uuid.uuid4"
) as mock_uuid:
mock_uuid.return_value = "fake_uuid"
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.PositionerBoxBase._check_device_is_valid",
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.PositionerBoxBase._check_device_is_valid",
return_value=True,
):
db = create_widget(qtbot, PositionerBox, device="samx", client=mocked_client)
@@ -141,7 +141,7 @@ def test_positioner_control_line(qtbot, mocked_client):
Inherits from PositionerBox, but the layout is changed. Check dimensions only
"""
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.uuid.uuid4"
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.uuid.uuid4"
) as mock_uuid:
mock_uuid.return_value = "fake_uuid"
with mock.patch(
@@ -151,7 +151,8 @@ def test_positioner_control_line(qtbot, mocked_client):
db = PositionerControlLine(device="samx", client=mocked_client)
qtbot.addWidget(db)
assert db.ui.device_box.height() == 60
assert db.ui.device_box.height() == db.height()
assert db.ui.device_box.height() >= db.dimensions[0]
assert db.ui.device_box.width() == 600

View File

@@ -12,11 +12,11 @@ from .conftest import create_widget
def positioner_box_2d(qtbot, mocked_client):
"""Fixture for PositionerBox widget"""
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.uuid.uuid4"
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.uuid.uuid4"
) as mock_uuid:
mock_uuid.return_value = "fake_uuid"
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.PositionerBoxBase._check_device_is_valid",
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.PositionerBoxBase._check_device_is_valid",
return_value=True,
):
db = create_widget(

View File

@@ -1,14 +1,18 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import json
from unittest.mock import MagicMock
import pytest
from bec_lib.endpoints import MessageEndpoints
from pydantic import ValidationError
from qtpy.QtGui import QColor
from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
from qtpy.QtGui import QColor, QMouseEvent
from qtpy.QtWidgets import QApplication
from bec_widgets.utils import Colors
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import (
RingProgressBar,
RingProgressContainerWidget,
)
from .client_mocks import mocked_client
@@ -432,8 +436,6 @@ def test_gap_affects_ring_positioning(ring_progress_bar):
for _ in range(3):
ring_progress_bar.add_ring()
initial_gap = ring_progress_bar.gap
# Change gap
new_gap = 30
ring_progress_bar.set_gap(new_gap)
@@ -467,3 +469,275 @@ def test_rings_property_returns_correct_list(ring_progress_bar):
# Should return the same list
assert rings_via_property is rings_direct
assert len(rings_via_property) == 3
###################################
# Hover behavior tests
###################################
@pytest.fixture
def container(qtbot):
widget = RingProgressContainerWidget()
qtbot.addWidget(widget)
widget.resize(200, 200)
yield widget
def _ring_center_pos(container):
"""Return (center_x, center_y, base_radius) for a square container."""
size = min(container.width(), container.height())
center_x = container.width() / 2
center_y = container.height() / 2
max_ring_size = container.get_max_ring_size()
base_radius = (size - 2 * max_ring_size) / 2
return center_x, center_y, base_radius
def _send_mouse_move(widget, pos: QPoint):
global_pos = widget.mapToGlobal(pos)
event = QMouseEvent(
QEvent.Type.MouseMove,
QPointF(pos),
QPointF(global_pos),
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
QApplication.sendEvent(widget, event)
def test_ring_at_pos_no_rings(container):
assert container._ring_at_pos(QPointF(100, 100)) is None
def test_ring_at_pos_center_is_inside_rings(container):
"""The center of the widget is inside all rings; _ring_at_pos should return None."""
container.add_ring()
assert container._ring_at_pos(QPointF(100, 100)) is None
def test_ring_at_pos_on_single_ring(container):
"""A point on the ring arc should resolve to that ring."""
ring = container.add_ring()
cx, cy, base_radius = _ring_center_pos(container)
# Point exactly on the ring centerline
pos = QPointF(cx + base_radius, cy)
assert container._ring_at_pos(pos) is ring
def test_ring_at_pos_outside_all_rings(container):
"""A point well outside the outermost ring returns None."""
container.add_ring()
cx, cy, base_radius = _ring_center_pos(container)
line_width = container.rings[0].config.line_width
# Place point clearly beyond the outer edge
pos = QPointF(cx + base_radius + line_width + 5, cy)
assert container._ring_at_pos(pos) is None
def test_ring_at_pos_selects_correct_ring_from_multiple(qtbot):
"""With multiple rings, each position resolves to the right ring."""
container = RingProgressContainerWidget()
qtbot.addWidget(container)
container.resize(300, 300)
ring0 = container.add_ring()
ring1 = container.add_ring()
size = min(container.width(), container.height())
cx = container.width() / 2
cy = container.height() / 2
max_ring_size = container.get_max_ring_size()
base_radius = (size - 2 * max_ring_size) / 2
radius0 = base_radius - ring0.gap
radius1 = base_radius - ring1.gap
assert container._ring_at_pos(QPointF(cx + radius0, cy)) is ring0
assert container._ring_at_pos(QPointF(cx + radius1, cy)) is ring1
def test_set_hovered_ring_sets_flag(container):
"""_set_hovered_ring marks the ring as hovered and updates _hovered_ring."""
ring = container.add_ring()
assert container._hovered_ring is None
container._set_hovered_ring(ring)
assert container._hovered_ring is ring
assert ring._hovered is True
def test_set_hovered_ring_to_none_clears_flag(container):
"""Calling _set_hovered_ring(None) un-hovers the current ring."""
ring = container.add_ring()
container._set_hovered_ring(ring)
container._set_hovered_ring(None)
assert container._hovered_ring is None
assert ring._hovered is False
def test_set_hovered_ring_switches_between_rings(qtbot):
"""Switching hover from one ring to another correctly updates both flags."""
container = RingProgressContainerWidget()
qtbot.addWidget(container)
ring0 = container.add_ring()
ring1 = container.add_ring()
container._set_hovered_ring(ring0)
assert ring0._hovered is True
assert ring1._hovered is False
container._set_hovered_ring(ring1)
assert ring0._hovered is False
assert ring1._hovered is True
assert container._hovered_ring is ring1
def test_build_tooltip_text_manual_mode(container):
"""Manual mode tooltip contains mode label, value, max and percentage."""
ring = container.add_ring()
ring.set_value(50)
ring.set_min_max_values(0, 100)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Manual" in text
assert "50.0%" in text
assert "100" in text
def test_build_tooltip_text_scan_mode(container):
"""Scan mode tooltip labels the mode as 'Scan progress'."""
ring = container.add_ring()
ring.config.mode = "scan"
ring.set_value(25)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Scan progress" in text
def test_build_tooltip_text_device_mode_with_signal(container):
"""Device mode tooltip shows device:signal when both are set."""
ring = container.add_ring()
ring.config.mode = "device"
ring.config.device = "samx"
ring.config.signal = "readback"
ring.set_value(10)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Device" in text
assert "samx:readback" in text
def test_build_tooltip_text_device_mode_without_signal(container):
"""Device mode tooltip shows only device name when signal is absent."""
ring = container.add_ring()
ring.config.mode = "device"
ring.config.device = "samy"
ring.config.signal = None
ring.set_value(10)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "samy" in text
assert ":" not in text.split("Device:")[-1].split("\n")[0]
def test_build_tooltip_text_nonzero_min_shows_range(container):
"""Tooltip includes Range line when min_value is not 0."""
ring = container.add_ring()
ring.set_min_max_values(10, 90)
ring.set_value(50)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Range" in text
def test_build_tooltip_text_zero_min_no_range(container):
"""Tooltip omits Range line when min_value is 0."""
ring = container.add_ring()
ring.set_min_max_values(0, 100)
ring.set_value(50)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Range" not in text
def test_refresh_hover_tooltip_updates_label_on_value_change(container):
"""refresh_hover_tooltip updates the label text after the ring value changes."""
ring = container.add_ring()
ring.set_value(30)
container._hovered_ring = ring
container._last_hover_global_pos = QPoint(100, 100)
container.refresh_hover_tooltip(ring)
text_before = container._hover_tooltip_label.text()
ring.set_value(70)
container.refresh_hover_tooltip(ring)
text_after = container._hover_tooltip_label.text()
assert text_before != text_after
assert "70" in text_after
def test_refresh_hover_tooltip_no_pos_does_not_crash(container):
"""refresh_hover_tooltip with no stored position should return without raising."""
ring = container.add_ring()
container._last_hover_global_pos = None
# Should not raise
container.refresh_hover_tooltip(ring)
def test_mouse_move_sets_hovered_ring_and_updates_tooltip(qtbot, container):
ring = container.add_ring()
container._hover_tooltip.show_near = MagicMock()
container.show()
qtbot.waitExposed(container)
cx, cy, base_radius = _ring_center_pos(container)
_send_mouse_move(container, QPoint(int(cx + base_radius), int(cy)))
assert container._hovered_ring is ring
assert ring._hovered is True
assert "Mode: Manual" in container._hover_tooltip_label.text()
container._hover_tooltip.show_near.assert_called_once()
def test_mouse_move_switches_hover_between_rings(qtbot):
container = RingProgressContainerWidget()
qtbot.addWidget(container)
container.resize(300, 300)
ring0 = container.add_ring()
ring1 = container.add_ring()
container._hover_tooltip.show_near = MagicMock()
container.show()
qtbot.waitExposed(container)
cx, cy, base_radius = _ring_center_pos(container)
radius0 = base_radius - ring0.gap
radius1 = base_radius - ring1.gap
_send_mouse_move(container, QPoint(int(cx + radius0), int(cy)))
assert container._hovered_ring is ring0
_send_mouse_move(container, QPoint(int(cx + radius1), int(cy)))
assert container._hovered_ring is ring1
assert ring0._hovered is False
assert ring1._hovered is True
def test_leave_event_clears_hover_and_hides_tooltip(qtbot, container):
ring = container.add_ring()
container._hover_tooltip.hide = MagicMock()
container.show()
qtbot.waitExposed(container)
cx, cy, base_radius = _ring_center_pos(container)
_send_mouse_move(container, QPoint(int(cx + base_radius), int(cy)))
QApplication.sendEvent(container, QEvent(QEvent.Type.Leave))
assert container._hovered_ring is None
assert ring._hovered is False
assert container._last_hover_global_pos is None
container._hover_tooltip.hide.assert_called()

View File

@@ -240,6 +240,36 @@ def test_set_start_angle(ring_widget):
assert ring_widget.config.start_position == 180
def test_set_hovered_updates_animation_target(ring_widget):
ring_widget.set_hovered(True)
assert ring_widget._hovered is True
assert ring_widget._hover_animation.endValue() == 1.0
ring_widget.set_hovered(False)
assert ring_widget._hovered is False
assert ring_widget._hover_animation.endValue() == 0.0
def test_refresh_hover_tooltip_delegates_to_container(ring_widget):
ring_widget.progress_container = MagicMock()
ring_widget.progress_container.is_ring_hovered.return_value = True
ring_widget._request_update()
ring_widget.progress_container.refresh_hover_tooltip.assert_called_once_with(ring_widget)
def test_refresh_hover_tooltip_skips_when_ring_is_not_hovered(ring_widget):
ring_widget.progress_container = MagicMock()
ring_widget.progress_container.is_ring_hovered.return_value = False
ring_widget._request_update()
ring_widget.progress_container.refresh_hover_tooltip.assert_not_called()
###################################
# Color management tests
###################################

View File

@@ -113,7 +113,7 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
)
def fill_commponents(components: dict[str, DynamicFormItem]):
def fill_components(components: dict[str, DynamicFormItem]):
components["sample_name"].setValue("test name")
components["str_optional"].setValue(None)
components["str_required"].setValue("something")
@@ -147,7 +147,7 @@ def test_griditems_are_correct_class(
def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]):
widget, components = metadata_widget = metadata_widget
fill_commponents(components)
fill_components(components)
assert widget._dict_from_grid() == TEST_DICT
assert widget.get_form_data() == TEST_DICT | {"extra_field": "extra_data"}
@@ -159,7 +159,7 @@ def test_validation(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormIt
widget._validity.compact_status.default_led[:114]
)
fill_commponents(components)
fill_components(components)
widget.validate_form()
assert widget._validity_message.text() == "No errors!"
@@ -178,7 +178,7 @@ def test_numbers_clipped_to_limits(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
widget, components = metadata_widget = metadata_widget
fill_commponents(components)
fill_components(components)
components["decimal_dp_limits_nodefault"].setValue(-56)
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(1.01)

Some files were not shown because too many files have changed in this diff Show More