1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-05-09 08:12:15 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
wakonig_k 6848a9e20b test(e2e): avoid timing issues in rpc_gui_obj test 2026-03-15 12:30:40 +01:00
161 changed files with 2520 additions and 7782 deletions
+1 -1
View File
@@ -62,4 +62,4 @@ runs:
uv pip install --system -e ./ophyd_devices uv pip install --system -e ./ophyd_devices
uv pip install --system -e ./bec/bec_lib[dev] uv pip install --system -e ./bec/bec_lib[dev]
uv pip install --system -e ./bec/bec_ipython_client uv pip install --system -e ./bec/bec_ipython_client
uv pip install --system -e ./bec_widgets[dev,qtermwidget] uv pip install --system -e ./bec_widgets[dev,pyside6]
+1 -1
View File
@@ -55,5 +55,5 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: pytest-logs name: pytest-logs
path: ./bec/logs/*.log path: ./logs/*.log
retention-days: 7 retention-days: 7
+1 -3
View File
@@ -177,6 +177,4 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
#
tombi.toml
-346
View File
@@ -1,352 +1,6 @@
# CHANGELOG # CHANGELOG
## v3.7.1 (2026-04-21)
### Bug Fixes
- **heatmap**: Fix access to status from metadata
([`55694ff`](https://github.com/bec-project/bec_widgets/commit/55694ff2b96581e03c63c8b8e068e2db79bcf780))
### Testing
- Fix exit status and status access in tests
([`91afc77`](https://github.com/bec-project/bec_widgets/commit/91afc775d59b4ba31bed3585847a67c301acf9b0))
## v3.7.0 (2026-04-21)
### Features
- Move companion app to applications
([`0cf84cd`](https://github.com/bec-project/bec_widgets/commit/0cf84cd1d839ac4a39ffb5fb9ba57d432e04348a))
### Refactoring
- Cleanup of imports
([`3e77f54`](https://github.com/bec-project/bec_widgets/commit/3e77f540345f56b9f184a332fcdd50d4d4c8c621))
## v3.6.0 (2026-04-21)
### Bug Fixes
- Change resize mode to interactive
([`a5db2dc`](https://github.com/bec-project/bec_widgets/commit/a5db2dc340f3386e68b300fd4528a44f87cbbf97))
- Small usability changes
([`5a497c3`](https://github.com/bec-project/bec_widgets/commit/5a497c3598c2d8f27916d91d53c646d5d6d3a4a7))
### Features
- Add button/slot to pause/unpause logs
([`23e3644`](https://github.com/bec-project/bec_widgets/commit/23e3644619de958bcfdb8a0b2ee1f7c2ce05b235))
- Add logpanel to menu
([`2e8f43f`](https://github.com/bec-project/bec_widgets/commit/2e8f43fcac581cd1c227308198565d142a1bf276))
- Migrate logpanel to table model/view
([`09bb112`](https://github.com/bec-project/bec_widgets/commit/09bb1121d83bac1f6e4827daa476fbe7cd5b3a80))
## v3.5.1 (2026-04-20)
### Bug Fixes
- Don't assume attr exists if we timed out waiting for it
([`f7a1ee4`](https://github.com/bec-project/bec_widgets/commit/f7a1ee49a42c58ba315c8957b45a80d862ffe745))
### Refactoring
- Don't import real widgets in client
([`8e51c1a`](https://github.com/bec-project/bec_widgets/commit/8e51c1adb6a7658c54846794cf97b774cbac2193))
## v3.5.0 (2026-04-14)
### Bug Fixes
- Connect signals the correct way around
([`f562c61`](https://github.com/bec-project/bec_widgets/commit/f562c61e3cec3387f6821bad74403beeb3436355))
- Create new bec shell if deleted
([`1754e75`](https://github.com/bec-project/bec_widgets/commit/1754e759f0c59f2f4063f661bacd334127326947))
- Formatting in plugin template
([`fa2ef83`](https://github.com/bec-project/bec_widgets/commit/fa2ef83bb9dfeeb4c5fc7cd77168c16101c32693))
- **bec_console**: Persistent bec session
([`9b0ec9d`](https://github.com/bec-project/bec_widgets/commit/9b0ec9dd79ad1adc5d211dd703db7441da965f34))
### Features
- Add qtermwidget plugin and replace web term
([`02cb393`](https://github.com/bec-project/bec_widgets/commit/02cb393bb086165dc64917b633d5570d02e1a2a9))
### Refactoring
- Code cleanup
([`bda5d38`](https://github.com/bec-project/bec_widgets/commit/bda5d389651bb2b13734cd31159679e85b1bd583))
## v3.4.4 (2026-04-14)
### Bug Fixes
- Check duplicate stream sub
([`c7de320`](https://github.com/bec-project/bec_widgets/commit/c7de320ca564264a31b84931f553170f25659685))
- Check for duplicate subscriptions in GUIClient
([`37747ba`](https://github.com/bec-project/bec_widgets/commit/37747babda407040333c6bd04646be9a49e0ee81))
- Make gui client registry callback non static
([`32f5d48`](https://github.com/bec-project/bec_widgets/commit/32f5d486d3fc8d41df2668c58932ae982819b285))
- Remove staticmethod subscription
([`0ff1fdc`](https://github.com/bec-project/bec_widgets/commit/0ff1fdc81578eec3ffc5d4030fca7b357a0b4c2f))
## v3.4.3 (2026-04-13)
### Bug Fixes
- Set OPHYD_CONTROL_LAYER to dummy for tests
([`5e84d3b`](https://github.com/bec-project/bec_widgets/commit/5e84d3bec608ae9f2ee6dae67db2e3e1387b1f59))
## 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) ## v3.2.0 (2026-03-11)
### Features ### Features
+18 -12
View File
@@ -1,13 +1,19 @@
import os
import sys
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
if sys.platform.startswith("linux"):
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
if qt_platform != "offscreen":
os.environ["QT_QPA_PLATFORM"] = "xcb"
# Default QtAds configuration
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
QtAds.CDockManager.setConfigFlag(
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
)
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"] __all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
def __getattr__(name):
if name == "BECWidget":
from bec_widgets.utils.bec_widget import BECWidget
return BECWidget
if name in {"SafeSlot", "SafeProperty"}:
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
return {"SafeSlot": SafeSlot, "SafeProperty": SafeProperty}[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
-15
View File
@@ -1,15 +0,0 @@
import os
import sys
import bec_widgets.widgets.containers.qt_ads as QtAds
if sys.platform.startswith("linux"):
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
if qt_platform != "offscreen":
os.environ["QT_QPA_PLATFORM"] = "xcb"
# Default QtAds configuration
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
QtAds.CDockManager.setConfigFlag(
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
)
+1 -1
View File
@@ -20,13 +20,13 @@ from qtpy.QtWidgets import (
) )
import bec_widgets import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.screen_utils import apply_window_geometry, centered_geometry_for_app from bec_widgets.utils.screen_utils import apply_window_geometry, centered_geometry_for_app
from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader from bec_widgets.utils.ui_loader import UILoader
+1 -18
View File
@@ -5,7 +5,6 @@ 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.reveal_animator import ANIMATION_DURATION
from bec_widgets.applications.navigation_centre.side_bar import SideBar 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.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.developer_view.developer_view import DeveloperView
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView
@@ -64,8 +63,6 @@ class BECMainApp(BECMainWindow):
self.dock_area = DockAreaView(self) self.dock_area = DockAreaView(self)
self.device_manager = DeviceManagerView(self) self.device_manager = DeviceManagerView(self)
# self.developer_view = DeveloperView(self) #TODO temporary disable until the bugs with BECShell are resolved # 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="widgets", title="Dock Area", widget=self.dock_area, mini_text="Docks")
self.add_view( self.add_view(
icon="display_settings", icon="display_settings",
@@ -81,13 +78,6 @@ class BECMainApp(BECMainWindow):
# mini_text="IDE", # mini_text="IDE",
# exclusive=True, # 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: if self._show_examples:
self.add_section("Examples", "examples") self.add_section("Examples", "examples")
@@ -191,12 +181,6 @@ class BECMainApp(BECMainWindow):
# Internal: route sidebar selection to the stack # Internal: route sidebar selection to the stack
def _on_view_selected(self, vid: str) -> None: 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 # Determine current view
current_index = self.stack.currentIndex() current_index = self.stack.currentIndex()
current_view = ( current_view = (
@@ -263,7 +247,7 @@ class BECMainApp(BECMainWindow):
developer_view_step = self.guided_tour.register_widget( developer_view_step = self.guided_tour.register_widget(
widget=sidebar_developer_view, widget=sidebar_developer_view,
title="Developer View", title="Developer View",
text="Click here to access the Developer view to write scripts and macros.", text="Click here to access the Developer view to write scripts and makros.",
) )
tour_steps.append(developer_view_step) tour_steps.append(developer_view_step)
@@ -394,7 +378,6 @@ def main(): # pragma: no cover
args, qt_args = parser.parse_known_args(sys.argv[1:]) args, qt_args = parser.parse_known_args(sys.argv[1:])
app = QApplication([sys.argv[0], *qt_args]) app = QApplication([sys.argv[0], *qt_args])
app.setApplicationName("BEC")
apply_theme("dark") apply_theme("dark")
w = BECMainApp(show_examples=args.examples) w = BECMainApp(show_examples=args.examples)
@@ -1,35 +0,0 @@
"""Module for Admin View."""
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
class AdminView(ViewBase):
"""
A view for administrators to change the current active experiment, manage messaging
services, and more tasks reserved for users with admin privileges.
"""
def __init__(
self,
parent: QWidget | None = None,
content: QWidget | None = None,
*,
view_id: str | None = None,
title: str | None = None,
**kwargs,
):
super().__init__(parent=parent, content=content, view_id=view_id, title=title)
self.admin_widget = BECAtlasAdminView(parent=self)
self.set_content(self.admin_widget)
@SafeSlot()
def on_exit(self) -> None:
"""Called before the view is hidden.
Default implementation does nothing. Override in subclasses.
"""
self.admin_widget.logout()
@@ -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.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.qt_ads import CDockWidget 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_dock import MonacoDock
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget 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 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 = BECShell(self, rpc_exposed=False)
self.console.setObjectName("BEC Shell") self.console.setObjectName("BEC Shell")
self.terminal = BecConsole(self, rpc_exposed=False) self.terminal = WebConsole(self, rpc_exposed=False)
self.terminal.setObjectName("Terminal") self.terminal.setObjectName("Terminal")
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False) self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
self.monaco.setObjectName("MonacoEditor") self.monaco.setObjectName("MonacoEditor")
@@ -410,3 +410,23 @@ class DeveloperWidget(DockAreaWidget):
"""Clean up resources used by the developer widget.""" """Clean up resources used by the developer widget."""
self.delete_all() self.delete_all()
return super().cleanup() 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_())
@@ -169,7 +169,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
self._upload_redis_dialog: UploadRedisDialog | None = None self._upload_redis_dialog: UploadRedisDialog | None = None
self._dialog_validation_connection: QMetaObject.Connection | None = None self._dialog_validation_connection: QMetaObject.Connection | None = None
# NOTE: We need here a separate config helper instance to avoid conflicts with # NOTE: We need here a seperate config helper instance to avoid conflicts with
# other communications to REDIS as uploading a config through a CommunicationConfigAction # 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 # will block if we use the config_helper from self.client.config._config_helper
self._config_helper = config_helper.ConfigHelper(self.client.connector) 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() self.device_table_view._is_config_in_sync_with_redis()
) )
validation_results = self.device_table_view.get_validation_results() validation_results = self.device_table_view.get_validation_results()
for config, config_status, connection_status in validation_results.values(): for config, config_status, connnection_status in validation_results.values():
if connection_status == ConnectionStatus.CONNECTED.value: if connnection_status == ConnectionStatus.CONNECTED.value:
self.device_table_view.update_device_validation( self.device_table_view.update_device_validation(
config, config_status, ConnectionStatus.CAN_CONNECT, "" config, config_status, ConnectionStatus.CAN_CONNECT, ""
) )
-1
View File
@@ -1 +0,0 @@
from bec_widgets.cli.rpc import rpc_base
+55 -164
View File
@@ -13,7 +13,7 @@ from typing import Literal, Optional
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module
logger = bec_logger.logger logger = bec_logger.logger
@@ -21,7 +21,7 @@ logger = bec_logger.logger
class _WidgetsEnumType(str, enum.Enum): class _WidgetsEnumType(str, enum.Enum):
"""Enum for the available widgets, to be generated programmatically""" """Enum for the available widgets, to be generated programatically"""
... ...
@@ -32,7 +32,6 @@ _Widgets = {
"BECQueue": "BECQueue", "BECQueue": "BECQueue",
"BECShell": "BECShell", "BECShell": "BECShell",
"BECStatusBox": "BECStatusBox", "BECStatusBox": "BECStatusBox",
"BecConsole": "BecConsole",
"DapComboBox": "DapComboBox", "DapComboBox": "DapComboBox",
"DeviceBrowser": "DeviceBrowser", "DeviceBrowser": "DeviceBrowser",
"Heatmap": "Heatmap", "Heatmap": "Heatmap",
@@ -57,43 +56,40 @@ _Widgets = {
"SignalLabel": "SignalLabel", "SignalLabel": "SignalLabel",
"TextBox": "TextBox", "TextBox": "TextBox",
"Waveform": "Waveform", "Waveform": "Waveform",
"WebConsole": "WebConsole",
"WebsiteWidget": "WebsiteWidget", "WebsiteWidget": "WebsiteWidget",
} }
try: try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module() plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(
f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !"
)
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass): for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase: if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name not in _Widgets:
_Widgets[plugin_name] = plugin_name
if plugin_name in globals(): if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning( logger.warning(
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!" f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
) )
continue continue
else: if plugin_name not in _overlap:
globals()[plugin_name] = plugin_class globals()[plugin_name] = plugin_class
Widgets = _WidgetsEnumType("Widgets", _Widgets)
except ImportError as e: except ImportError as e:
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(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"""
_IMPORT_MODULE = "bec_widgets.applications.views.admin_view.admin_view"
@rpc_call
def activate(self) -> "None":
"""
Switch the parent application to this view.
"""
class AutoUpdates(RPCBase): class AutoUpdates(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.containers.auto_update.auto_updates"
@property @property
@rpc_call @rpc_call
def enabled(self) -> "bool": def enabled(self) -> "bool":
@@ -130,8 +126,6 @@ class AutoUpdates(RPCBase):
class AvailableDeviceResources(RPCBase): class AvailableDeviceResources(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -152,8 +146,6 @@ class AvailableDeviceResources(RPCBase):
class BECDockArea(RPCBase): class BECDockArea(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.dock_area"
@rpc_call @rpc_call
def new( def new(
self, self,
@@ -389,8 +381,6 @@ class BECDockArea(RPCBase):
class BECMainWindow(RPCBase): class BECMainWindow(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.containers.main_window.main_window"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -413,8 +403,6 @@ class BECMainWindow(RPCBase):
class BECProgressBar(RPCBase): class BECProgressBar(RPCBase):
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template.""" """A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
_IMPORT_MODULE = "bec_widgets.widgets.progress.bec_progressbar.bec_progressbar"
@rpc_call @rpc_call
def set_value(self, value): def set_value(self, value):
""" """
@@ -488,8 +476,6 @@ class BECProgressBar(RPCBase):
class BECQueue(RPCBase): class BECQueue(RPCBase):
"""Widget to display the BEC queue.""" """Widget to display the BEC queue."""
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_queue.bec_queue"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -510,9 +496,7 @@ class BECQueue(RPCBase):
class BECShell(RPCBase): class BECShell(RPCBase):
"""A BecConsole pre-configured to run the BEC shell.""" """A WebConsole pre-configured to run the BEC shell."""
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
@rpc_call @rpc_call
def remove(self): def remove(self):
@@ -536,8 +520,6 @@ class BECShell(RPCBase):
class BECStatusBox(RPCBase): class BECStatusBox(RPCBase):
"""An autonomous widget to display the status of BEC services.""" """An autonomous widget to display the status of BEC services."""
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_status_box.bec_status_box"
@rpc_call @rpc_call
def get_server_state(self) -> "str": def get_server_state(self) -> "str":
""" """
@@ -573,8 +555,6 @@ class BECStatusBox(RPCBase):
class BaseROI(RPCBase): class BaseROI(RPCBase):
"""Base class for all Region of Interest (ROI) implementations.""" """Base class for all Region of Interest (ROI) implementations."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property @property
@rpc_call @rpc_call
def label(self) -> "str": def label(self) -> "str":
@@ -701,35 +681,9 @@ class BaseROI(RPCBase):
""" """
class BecConsole(RPCBase):
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class CircularROI(RPCBase): class CircularROI(RPCBase):
"""Circular Region of Interest with center/diameter tracking and auto-labeling.""" """Circular Region of Interest with center/diameter tracking and auto-labeling."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property @property
@rpc_call @rpc_call
def label(self) -> "str": def label(self) -> "str":
@@ -857,8 +811,6 @@ class CircularROI(RPCBase):
class Curve(RPCBase): class Curve(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.curve"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -1023,9 +975,7 @@ class Curve(RPCBase):
class DapComboBox(RPCBase): class DapComboBox(RPCBase):
"""Editable combobox listing the available DAP models.""" """The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC."""
_IMPORT_MODULE = "bec_widgets.widgets.dap.dap_combo_box.dap_combo_box"
@rpc_call @rpc_call
def select_y_axis(self, y_axis: str): def select_y_axis(self, y_axis: str):
@@ -1051,15 +1001,13 @@ class DapComboBox(RPCBase):
Slot to update the fit model. Slot to update the fit model.
Args: Args:
fit_name(str): Fit model name. default_device(str): Default device name.
""" """
class DeveloperView(RPCBase): class DeveloperView(RPCBase):
"""A view for users to write scripts and macros and execute them within the application.""" """A view for users to write scripts and macros and execute them within the application."""
_IMPORT_MODULE = "bec_widgets.applications.views.developer_view.developer_view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -1070,8 +1018,6 @@ class DeveloperView(RPCBase):
class DeviceBrowser(RPCBase): class DeviceBrowser(RPCBase):
"""DeviceBrowser is a widget that displays all available devices in the current BEC session.""" """DeviceBrowser is a widget that displays all available devices in the current BEC session."""
_IMPORT_MODULE = "bec_widgets.widgets.services.device_browser.device_browser"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -1094,8 +1040,6 @@ class DeviceBrowser(RPCBase):
class DeviceInitializationProgressBar(RPCBase): class DeviceInitializationProgressBar(RPCBase):
"""A progress bar that displays the progress of device initialization.""" """A progress bar that displays the progress of device initialization."""
_IMPORT_MODULE = "bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -1118,8 +1062,6 @@ class DeviceInitializationProgressBar(RPCBase):
class DeviceInputBase(RPCBase): class DeviceInputBase(RPCBase):
"""Mixin base class for device input widgets.""" """Mixin base class for device input widgets."""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_input.base_classes.device_input_base"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -1142,8 +1084,6 @@ class DeviceInputBase(RPCBase):
class DeviceManagerView(RPCBase): class DeviceManagerView(RPCBase):
"""A view for users to manage devices within the application.""" """A view for users to manage devices within the application."""
_IMPORT_MODULE = "bec_widgets.applications.views.device_manager_view.device_manager_view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -1154,8 +1094,6 @@ class DeviceManagerView(RPCBase):
class DockAreaView(RPCBase): class DockAreaView(RPCBase):
"""Modular dock area view for arranging and managing multiple dockable widgets.""" """Modular dock area view for arranging and managing multiple dockable widgets."""
_IMPORT_MODULE = "bec_widgets.applications.views.dock_area_view.dock_area_view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -1399,8 +1337,6 @@ class DockAreaView(RPCBase):
class DockAreaWidget(RPCBase): class DockAreaWidget(RPCBase):
"""Lightweight dock area that exposes the core Qt ADS docking helpers without any""" """Lightweight dock area that exposes the core Qt ADS docking helpers without any"""
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.basic_dock_area"
@rpc_call @rpc_call
def new( def new(
self, self,
@@ -1585,8 +1521,6 @@ class DockAreaWidget(RPCBase):
class EllipticalROI(RPCBase): class EllipticalROI(RPCBase):
"""Elliptical Region of Interest with centre/width/height tracking and auto-labelling.""" """Elliptical Region of Interest with centre/width/height tracking and auto-labelling."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property @property
@rpc_call @rpc_call
def label(self) -> "str": def label(self) -> "str":
@@ -1709,8 +1643,6 @@ class EllipticalROI(RPCBase):
class Heatmap(RPCBase): class Heatmap(RPCBase):
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis.""" """Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.heatmap.heatmap"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -2409,8 +2341,6 @@ class Heatmap(RPCBase):
class Image(RPCBase): class Image(RPCBase):
"""Image widget for displaying 2D data.""" """Image widget for displaying 2D data."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -3022,8 +2952,6 @@ class Image(RPCBase):
class ImageItem(RPCBase): class ImageItem(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image_item"
@property @property
@rpc_call @rpc_call
def color_map(self) -> "str": def color_map(self) -> "str":
@@ -3174,8 +3102,6 @@ class ImageItem(RPCBase):
class LaunchWindow(RPCBase): class LaunchWindow(RPCBase):
_IMPORT_MODULE = "bec_widgets.applications.launch_window"
@rpc_call @rpc_call
def show_launcher(self): def show_launcher(self):
""" """
@@ -3190,38 +3116,33 @@ class LaunchWindow(RPCBase):
class LogPanel(RPCBase): class LogPanel(RPCBase):
"""Live display of the BEC logs in a table view.""" """Displays a log panel"""
_IMPORT_MODULE = "bec_widgets.widgets.utility.logpanel.logpanel"
@rpc_call @rpc_call
def remove(self): def set_plain_text(self, text: str) -> None:
""" """
Cleanup the BECConnector Set the plain text of the widget.
Args:
text (str): The text to set.
""" """
@rpc_call @rpc_call
def attach(self): def set_html_text(self, text: str) -> None:
"""
None
""" """
Set the HTML text of the widget.
@rpc_call Args:
def detach(self): text (str): The text to set.
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
""" """
class Minesweeper(RPCBase): class Minesweeper(RPCBase): ...
_IMPORT_MODULE = "bec_widgets.widgets.games.minesweeper"
class MonacoDock(RPCBase): class MonacoDock(RPCBase):
"""MonacoDock is a dock widget that contains Monaco editor instances.""" """MonacoDock is a dock widget that contains Monaco editor instances."""
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_dock"
@rpc_call @rpc_call
def new( def new(
self, self,
@@ -3406,8 +3327,6 @@ class MonacoDock(RPCBase):
class MonacoWidget(RPCBase): class MonacoWidget(RPCBase):
"""A simple Monaco editor widget""" """A simple Monaco editor widget"""
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_widget"
@rpc_call @rpc_call
def set_text( def set_text(
self, text: "str", file_name: "str | None" = None, reset: "bool" = False self, text: "str", file_name: "str | None" = None, reset: "bool" = False
@@ -3582,8 +3501,6 @@ class MonacoWidget(RPCBase):
class MotorMap(RPCBase): class MotorMap(RPCBase):
"""Motor map widget for plotting motor positions in 2D including a trace of the last points.""" """Motor map widget for plotting motor positions in 2D including a trace of the last points."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.motor_map.motor_map"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -4054,8 +3971,6 @@ class MotorMap(RPCBase):
class MultiWaveform(RPCBase): class MultiWaveform(RPCBase):
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal.""" """MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.multi_waveform.multi_waveform"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -4515,8 +4430,6 @@ class MultiWaveform(RPCBase):
class PdfViewerWidget(RPCBase): class PdfViewerWidget(RPCBase):
"""A widget to display PDF documents with toolbar controls.""" """A widget to display PDF documents with toolbar controls."""
_IMPORT_MODULE = "bec_widgets.widgets.utility.pdf_viewer.pdf_viewer"
@rpc_call @rpc_call
def load_pdf(self, file_path: str): def load_pdf(self, file_path: str):
""" """
@@ -4648,10 +4561,6 @@ class PdfViewerWidget(RPCBase):
class PositionIndicator(RPCBase): class PositionIndicator(RPCBase):
"""Display a position within a defined range, e.g. motor limits.""" """Display a position within a defined range, e.g. motor limits."""
_IMPORT_MODULE = (
"bec_widgets.widgets.control.device_control.position_indicator.position_indicator"
)
@rpc_call @rpc_call
def set_value(self, position: float): def set_value(self, position: float):
""" """
@@ -4717,10 +4626,6 @@ class PositionIndicator(RPCBase):
class PositionerBox(RPCBase): class PositionerBox(RPCBase):
"""Simple Widget to control a positioner in box form""" """Simple Widget to control a positioner in box form"""
_IMPORT_MODULE = (
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box"
)
@rpc_call @rpc_call
def set_positioner(self, positioner: "str | Positioner"): def set_positioner(self, positioner: "str | Positioner"):
""" """
@@ -4753,8 +4658,6 @@ class PositionerBox(RPCBase):
class PositionerBox2D(RPCBase): class PositionerBox2D(RPCBase):
"""Simple Widget to control two positioners in box form""" """Simple Widget to control two positioners in box form"""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d"
@rpc_call @rpc_call
def set_positioner_hor(self, positioner: "str | Positioner"): def set_positioner_hor(self, positioner: "str | Positioner"):
""" """
@@ -4824,8 +4727,6 @@ class PositionerBox2D(RPCBase):
class PositionerControlLine(RPCBase): class PositionerControlLine(RPCBase):
"""A widget that controls a single device.""" """A widget that controls a single device."""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line"
@rpc_call @rpc_call
def set_positioner(self, positioner: "str | Positioner"): def set_positioner(self, positioner: "str | Positioner"):
""" """
@@ -4858,8 +4759,6 @@ class PositionerControlLine(RPCBase):
class PositionerGroup(RPCBase): class PositionerGroup(RPCBase):
"""Simple Widget to control a positioner in box form""" """Simple Widget to control a positioner in box form"""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_group.positioner_group"
@rpc_call @rpc_call
def set_positioners(self, device_names: "str"): def set_positioners(self, device_names: "str"):
""" """
@@ -4891,8 +4790,6 @@ class PositionerGroup(RPCBase):
class RectangularROI(RPCBase): class RectangularROI(RPCBase):
"""Defines a rectangular Region of Interest (ROI) with additional functionality.""" """Defines a rectangular Region of Interest (ROI) with additional functionality."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property @property
@rpc_call @rpc_call
def label(self) -> "str": def label(self) -> "str":
@@ -5022,8 +4919,6 @@ class RectangularROI(RPCBase):
class ResumeButton(RPCBase): class ResumeButton(RPCBase):
"""A button that continue scan queue.""" """A button that continue scan queue."""
_IMPORT_MODULE = "bec_widgets.widgets.control.buttons.button_resume.button_resume"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -5044,8 +4939,6 @@ class ResumeButton(RPCBase):
class Ring(RPCBase): class Ring(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring"
@rpc_call @rpc_call
def set_value(self, value: "int | float"): def set_value(self, value: "int | float"):
""" """
@@ -5139,8 +5032,6 @@ class Ring(RPCBase):
class RingProgressBar(RPCBase): class RingProgressBar(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -5220,14 +5111,12 @@ class RingProgressBar(RPCBase):
class SBBMonitor(RPCBase): class SBBMonitor(RPCBase):
"""A widget to display the SBB monitor website.""" """A widget to display the SBB monitor website."""
_IMPORT_MODULE = "bec_widgets.widgets.editors.sbb_monitor.sbb_monitor" ...
class ScanControl(RPCBase): class ScanControl(RPCBase):
"""Widget to submit new scans to the queue.""" """Widget to submit new scans to the queue."""
_IMPORT_MODULE = "bec_widgets.widgets.control.scan_control.scan_control"
@rpc_call @rpc_call
def attach(self): def attach(self):
""" """
@@ -5251,8 +5140,6 @@ class ScanControl(RPCBase):
class ScanProgressBar(RPCBase): class ScanProgressBar(RPCBase):
"""Widget to display a progress bar that is hooked up to the scan progress of a scan.""" """Widget to display a progress bar that is hooked up to the scan progress of a scan."""
_IMPORT_MODULE = "bec_widgets.widgets.progress.scan_progressbar.scan_progressbar"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -5275,8 +5162,6 @@ class ScanProgressBar(RPCBase):
class ScatterCurve(RPCBase): class ScatterCurve(RPCBase):
"""Scatter curve item for the scatter waveform widget.""" """Scatter curve item for the scatter waveform widget."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_curve"
@property @property
@rpc_call @rpc_call
def color_map(self) -> "str": def color_map(self) -> "str":
@@ -5286,8 +5171,6 @@ class ScatterCurve(RPCBase):
class ScatterWaveform(RPCBase): class ScatterWaveform(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_waveform"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -5755,8 +5638,6 @@ class ScatterWaveform(RPCBase):
class SignalLabel(RPCBase): class SignalLabel(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.utility.signal_label.signal_label"
@property @property
@rpc_call @rpc_call
def custom_label(self) -> "str": def custom_label(self) -> "str":
@@ -5901,8 +5782,6 @@ class SignalLabel(RPCBase):
class TextBox(RPCBase): class TextBox(RPCBase):
"""A widget that displays text in plain and HTML format""" """A widget that displays text in plain and HTML format"""
_IMPORT_MODULE = "bec_widgets.widgets.editors.text_box.text_box"
@rpc_call @rpc_call
def set_plain_text(self, text: str) -> None: def set_plain_text(self, text: str) -> None:
""" """
@@ -5925,8 +5804,6 @@ class TextBox(RPCBase):
class ViewBase(RPCBase): class ViewBase(RPCBase):
"""Wrapper for a content widget used inside the main app's stacked view.""" """Wrapper for a content widget used inside the main app's stacked view."""
_IMPORT_MODULE = "bec_widgets.applications.views.view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -5937,8 +5814,6 @@ class ViewBase(RPCBase):
class Waveform(RPCBase): class Waveform(RPCBase):
"""Widget for plotting waveforms.""" """Widget for plotting waveforms."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.waveform"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -6517,8 +6392,6 @@ class Waveform(RPCBase):
class WaveformViewInline(RPCBase): class WaveformViewInline(RPCBase):
_IMPORT_MODULE = "bec_widgets.applications.views.view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -6527,8 +6400,6 @@ class WaveformViewInline(RPCBase):
class WaveformViewPopup(RPCBase): class WaveformViewPopup(RPCBase):
_IMPORT_MODULE = "bec_widgets.applications.views.view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -6536,10 +6407,30 @@ class WaveformViewPopup(RPCBase):
""" """
class WebsiteWidget(RPCBase): class WebConsole(RPCBase):
"""A simple widget to display a website""" """A simple widget to display a website"""
_IMPORT_MODULE = "bec_widgets.widgets.editors.website.website" @rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class WebsiteWidget(RPCBase):
"""A simple widget to display a website"""
@rpc_call @rpc_call
def set_url(self, url: str) -> None: def set_url(self, url: str) -> None:
+13 -11
View File
@@ -10,9 +10,9 @@ import threading
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from threading import Lock from threading import Lock
from typing import TYPE_CHECKING, Callable, Literal, TypeAlias, cast from typing import TYPE_CHECKING, Literal, TypeAlias, cast
from bec_lib.endpoints import EndpointInfo, MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import, lazy_import_from from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from rich.console import Console from rich.console import Console
@@ -232,11 +232,6 @@ class BECGuiClient(RPCBase):
"""The launcher object.""" """The launcher object."""
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher") return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
def _safe_register_stream(self, endpoint: EndpointInfo, cb: Callable, **kwargs):
"""Check if already registered for registration in idempotent functions."""
if not self._client.connector.any_stream_is_registered(endpoint, cb=cb):
self._client.connector.register(endpoint, cb=cb, **kwargs)
def connect_to_gui_server(self, gui_id: str) -> None: def connect_to_gui_server(self, gui_id: str) -> None:
"""Connect to a GUI server""" """Connect to a GUI server"""
# Unregister the old callback # Unregister the old callback
@@ -252,9 +247,10 @@ class BECGuiClient(RPCBase):
self._ipython_registry = {} self._ipython_registry = {}
# Register the new callback # Register the new callback
self._safe_register_stream( self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id), MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update, cb=self._handle_registry_update,
parent=self,
from_start=True, from_start=True,
) )
@@ -535,14 +531,20 @@ class BECGuiClient(RPCBase):
def _start(self, wait: bool = False) -> None: def _start(self, wait: bool = False) -> None:
self._killed = False self._killed = False
self._safe_register_stream( self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
) )
return self._start_server(wait=wait) return self._start_server(wait=wait)
def _handle_registry_update(self, msg: dict[str, GUIRegistryStateMessage]) -> None: @staticmethod
def _handle_registry_update(
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
) -> None:
# This was causing a deadlock during shutdown, not sure why. # This was causing a deadlock during shutdown, not sure why.
# with self._lock: # with self._lock:
self = parent
self._server_registry = cast(dict[str, RegistryState], msg["data"].state) self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
self._update_dynamic_namespace(self._server_registry) self._update_dynamic_namespace(self._server_registry)
@@ -7,7 +7,6 @@ import inspect
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from typing import get_overloads
import black import black
import isort import isort
@@ -19,6 +18,20 @@ from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
logger = bec_logger.logger logger = bec_logger.logger
if sys.version_info >= (3, 11):
from typing import get_overloads
else:
print(
"Python version is less than 3.11, using dummy function for get_overloads. "
"If you want to use the real function 'typing.get_overloads()', please use Python 3.11 or later."
)
def get_overloads(_obj):
"""
Dummy function for Python versions before 3.11.
"""
return []
class ClientGenerator: class ClientGenerator:
def __init__(self, base=False): def __init__(self, base=False):
@@ -41,7 +54,7 @@ from __future__ import annotations
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
{"from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module" if self._base else ""} {"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger logger = bec_logger.logger
@@ -81,7 +94,7 @@ logger = bec_logger.logger
if self._base: if self._base:
self.content += """ self.content += """
class _WidgetsEnumType(str, enum.Enum): class _WidgetsEnumType(str, enum.Enum):
\"\"\" Enum for the available widgets, to be generated programmatically \"\"\" \"\"\" Enum for the available widgets, to be generated programatically \"\"\"
... ...
""" """
@@ -98,19 +111,27 @@ _Widgets = {
self.content += """ self.content += """
try: try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module() plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass): for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase: if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name not in _Widgets:
_Widgets[plugin_name] = plugin_name
if plugin_name in globals(): if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning( logger.warning(
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!" f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
) )
continue continue
else: if plugin_name not in _overlap:
globals()[plugin_name] = plugin_class globals()[plugin_name] = plugin_class
Widgets = _WidgetsEnumType("Widgets", _Widgets)
except ImportError as e: except ImportError as e:
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}") logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
""" """
@@ -125,8 +146,12 @@ except ImportError as e:
class_name = cls.__name__ class_name = cls.__name__
self.content += f""" if class_name == "BECDockArea":
class {class_name}(RPCBase):\n""" self.content += f"""
class {class_name}(RPCBase):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
if cls.__doc__: if cls.__doc__:
# We only want the first line of the docstring # We only want the first line of the docstring
@@ -137,9 +162,13 @@ class {class_name}(RPCBase):\n"""
else: else:
class_docs = cls.__doc__.split("\n")[1] class_docs = cls.__doc__.split("\n")[1]
self.content += f""" self.content += f"""
\"\"\"{class_docs}\"\"\"\n""" \"\"\"{class_docs}\"\"\"
"""
user_access_entries = self._get_user_access_entries(cls) user_access_entries = self._get_user_access_entries(cls)
self.content += f' _IMPORT_MODULE="{cls.__module__}"\n' if not user_access_entries:
self.content += """...
"""
for method_entry in user_access_entries: for method_entry in user_access_entries:
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry) method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
if obj is None: if obj is None:
+7 -4
View File
@@ -248,7 +248,9 @@ class RPCBase:
self._rpc_response = None self._rpc_response = None
self._msg_wait_event.clear() self._msg_wait_event.clear()
self._client.connector.register( self._client.connector.register(
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response MessageEndpoints.gui_instruction_response(request_id),
cb=self._on_rpc_response,
parent=self,
) )
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg) self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
@@ -274,10 +276,11 @@ class RPCBase:
self._rpc_response = None self._rpc_response = None
return self._create_widget_from_msg_result(msg_result) return self._create_widget_from_msg_result(msg_result)
def _on_rpc_response(self, msg_obj: MessageObject) -> None: @staticmethod
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
msg = cast(messages.RequestResponseMessage, msg_obj.value) msg = cast(messages.RequestResponseMessage, msg_obj.value)
self._rpc_response = msg parent._rpc_response = msg
self._msg_wait_event.set() parent._msg_wait_event.set()
def _create_widget_from_msg_result(self, msg_result): def _create_widget_from_msg_result(self, msg_result):
if msg_result is None: if msg_result is None:
@@ -19,8 +19,8 @@ from qtpy.QtWidgets import QApplication
import bec_widgets import bec_widgets
from bec_widgets.applications.launch_window import LaunchWindow from bec_widgets.applications.launch_window import LaunchWindow
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_dispatcher import BECDispatcher from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.rpc_register import RPCRegister
logger = bec_logger.logger logger = bec_logger.logger
@@ -25,8 +25,8 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.rpc_widget_handler import widget_handler
from bec_widgets.utils.widget_io import WidgetHierarchy as wh from bec_widgets.utils.widget_io import WidgetHierarchy as wh
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
+1 -8
View File
@@ -1,7 +1,6 @@
# pylint: skip-file # pylint: skip-file
from unittest.mock import MagicMock 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 Device as BECDevice
from bec_lib.device import Positioner as BECPositioner from bec_lib.device import Positioner as BECPositioner
from bec_lib.device import ReadoutPriority from bec_lib.device import ReadoutPriority
@@ -220,9 +219,7 @@ class Device(FakeDevice):
class DMMock: class DMMock:
def __init__(self, *args, **kwargs): def __init__(self):
self._service = args[0]
self.config_helper = ConfigHelper(self._service.connector, self._service._service_name)
self.devices = DeviceContainer() self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled] self.enabled_devices = [device for device in self.devices if device.enabled]
@@ -276,10 +273,6 @@ class DMMock:
configs.append(device._config) configs.append(device._config)
return configs return configs
def initialize(*_): ...
def shutdown(self): ...
DEVICES = [ DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0), FakePositioner("samx", limits=[-10, 10], read_value=2.0),
+12
View File
@@ -1 +1,13 @@
from qtpy.QtWebEngineWidgets import QWebEngineView
from .bec_connector import BECConnector, ConnectionConfig
from .bec_dispatcher import BECDispatcher
from .bec_table import BECTable
from .colors import Colors
from .container_utils import WidgetContainerUtils
from .crosshair import Crosshair
from .entry_validator import EntryValidator
from .layout_manager import GridLayoutManager
from .rpc_decorator import register_rpc_methods, rpc_public
from .ui_loader import UILoader
from .validator_delegate import DoubleValidationDelegate
+4 -4
View File
@@ -15,9 +15,9 @@ from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
from bec_widgets.utils.name_utils import sanitize_namespace from bec_widgets.utils.name_utils import sanitize_namespace
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
@@ -167,7 +167,7 @@ class BECConnector:
) )
self.config = ConnectionConfig(widget_class=self.__class__.__name__) self.config = ConnectionConfig(widget_class=self.__class__.__name__)
# If the gui_id is passed, it should be respected. However, this should be revisited since # If the gui_id is passed, it should be respected. However, this should be revisted since
# the gui_id has to be unique, and may no longer be. # the gui_id has to be unique, and may no longer be.
if gui_id: if gui_id:
self.config.gui_id = gui_id self.config.gui_id = gui_id
@@ -399,7 +399,7 @@ class BECConnector:
""" """
self.config = config self.config = config
# FIXME some thoughts are required to decide how this should work with rpc registry # FIXME some thoughts are required to decide how thhis should work with rpc registry
def apply_config(self, config: dict, generate_new_id: bool = True) -> None: def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
""" """
Apply the configuration to the widget. Apply the configuration to the widget.
@@ -417,7 +417,7 @@ class BECConnector:
else: else:
self.gui_id = self.config.gui_id self.gui_id = self.config.gui_id
# FIXME some thoughts are required to decide how this should work with rpc registry # FIXME some thoughts are required to decide how thhis should work with rpc registry
def load_config(self, path: str | None = None, gui: bool = False): def load_config(self, path: str | None = None, gui: bool = False):
""" """
Load the configuration of the widget from YAML. Load the configuration of the widget from YAML.
+12 -14
View File
@@ -123,16 +123,17 @@ class BECDispatcher:
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = ( self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
collections.defaultdict() collections.defaultdict()
) )
self.client = client
if client is None: if self.client is None:
if config is not None and not isinstance(config, ServiceConfig): if config is not None:
# config is supposed to be a path if not isinstance(config, ServiceConfig):
config = ServiceConfig(config) # config is supposed to be a path
config = ServiceConfig(config)
self.client = BECClient( self.client = BECClient(
config=config, connector_cls=QtRedisConnector, name="BECWidgets" config=config, connector_cls=QtRedisConnector, name="BECWidgets"
) )
else: else:
self.client = client
if self.client.started: if self.client.started:
# have to reinitialize client to use proper connector # have to reinitialize client to use proper connector
logger.info("Shutting down BECClient to switch to QtRedisConnector") logger.info("Shutting down BECClient to switch to QtRedisConnector")
@@ -175,15 +176,12 @@ class BECDispatcher:
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None. cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
""" """
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info) qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
if not self.client.connector.any_stream_is_registered(topics, qt_slot): if qt_slot not in self._registered_slots:
if qt_slot not in self._registered_slots: self._registered_slots[qt_slot] = qt_slot
self._registered_slots[qt_slot] = qt_slot qt_slot = self._registered_slots[qt_slot]
qt_slot = self._registered_slots[qt_slot] self.client.connector.register(topics, cb=qt_slot, **kwargs)
self.client.connector.register(topics, cb=qt_slot, **kwargs) topics_str, _ = self.client.connector._convert_endpointinfo(topics)
topics_str, _ = self.client.connector._convert_endpointinfo(topics) qt_slot.topics.update(set(topics_str))
qt_slot.topics.update(set(topics_str))
else:
logger.warning(f"Attempted to create duplicate stream subscription for {topics=}")
def disconnect_slot( def disconnect_slot(
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str] self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
+1 -1
View File
@@ -10,11 +10,11 @@ from qtpy.QtGui import QFont, QPixmap
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget
import bec_widgets.widgets.containers.qt_ads as QtAds import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.busy_loader import install_busy_loader from bec_widgets.utils.busy_loader import install_busy_loader
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
+1 -1
View File
@@ -43,7 +43,7 @@ class WidgetContainerUtils:
if list_of_names is None: if list_of_names is None:
list_of_names = [] list_of_names = []
ii = 0 ii = 0
while ii < 1000: # 1000 is arbitrary! while ii < 1000: # 1000 is arbritrary!
name_candidate = f"{name}_{ii}" name_candidate = f"{name}_{ii}"
if name_candidate not in list_of_names: if name_candidate not in list_of_names:
return name_candidate return name_candidate
+2 -2
View File
@@ -71,7 +71,7 @@ class FormItemSpec(BaseModel):
""" """
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo 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 to store most annotation info, since one of the main purposes is to store data for
forms generated from pydantic models, but can also be composed from other sources or by hand. forms genrated from pydantic models, but can also be composed from other sources or by hand.
""" """
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -192,7 +192,7 @@ class DynamicFormItem(QWidget):
@abstractmethod @abstractmethod
def _add_main_widget(self) -> None: def _add_main_widget(self) -> None:
self._main_widget: QWidget self._main_widget: QWidget
"""Add the main data entry widget to self._main_widget and apply any """Add the main data entry widget to self._main_widget and appply any
constraints from the field info""" constraints from the field info"""
@SafeSlot() @SafeSlot()
-35
View File
@@ -1,35 +0,0 @@
"""Module providing fuzzy search utilities for the BEC widgets."""
from __future__ import annotations
from typing import Any
from thefuzz import fuzz
FUZZY_SEARCH_THRESHOLD = 80
def is_match(
text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool
) -> bool:
"""
Check if the text matches any of the relevant keys in the row data.
Args:
text (str): The text to search for.
row_data (dict[str, Any]): The row data to search in.
relevant_keys (list[str]): The keys to consider for searching.
enable_fuzzy (bool): Whether to use fuzzy matching.
Returns:
bool: True if a match is found, False otherwise.
"""
for key in relevant_keys:
data = str(row_data.get(key, "") or "")
if enable_fuzzy:
match_ratio = fuzz.partial_ratio(text.lower(), data.lower())
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
return True
else:
if text.lower() in data.lower():
return True
return False
+1 -1
View File
@@ -15,7 +15,7 @@ class Kind(IFBase):
""" """
This is used in the .kind attribute of all OphydObj (Signals, Devices). This is used in the .kind attribute of all OphydObj (Signals, Devices).
A Device examines its components' .kind attribute to decide whether to A Device examines its components' .kind atttribute to decide whether to
traverse it in read(), read_configuration(), or neither. Additionally, if traverse it in read(), read_configuration(), or neither. Additionally, if
decides whether to include its name in `hints['fields']`. decides whether to include its name in `hints['fields']`.
""" """
@@ -22,7 +22,7 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
def createWidget(self, parent): def createWidget(self, parent):
if parent is None: if parent is None:
return QWidget() return QWidget()
t = {plugin_name_pascal}(parent) t = {plugin_name_pascal}(parent)
return t return t
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Iterable
from bec_lib.plugin_helper import _get_available_plugins from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils import BECConnector
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
+4 -4
View File
@@ -14,11 +14,11 @@ from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from redis.exceptions import RedisError from redis.exceptions import RedisError
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import ErrorPopupUtility from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.screen_utils import apply_window_geometry from bec_widgets.utils.screen_utils import apply_window_geometry
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
@@ -156,7 +156,7 @@ class RPCServer:
if method == "raise" and hasattr( if method == "raise" and hasattr(
obj, "setWindowState" obj, "setWindowState"
): # special case for raising windows, should work even if minimized ): # special case for raising windows, should work even if minimized
# this is a special case for raising windows for gnome on Red Hat (RHEL) 9 systems where changing focus is suppressed by default # this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
# The procedure is as follows: # The procedure is as follows:
# 1. Get the current window state to check if the window is minimized and remove minimized flag # 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 # 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.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop() self._heartbeat_timer.stop()
self.emit_heartbeat() self.emit_heartbeat()
logger.info("Succeeded in shutting down CLI server") logger.info("Succeded in shutting down CLI server")
self.client.shutdown() self.client.shutdown()
+1 -4
View File
@@ -35,19 +35,16 @@ logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__) MODULE_PATH = os.path.dirname(bec_widgets.__file__)
def create_action_with_text(toolbar_action, toolbar: QToolBar, min_size: QSize | None = None): def create_action_with_text(toolbar_action, toolbar: QToolBar):
""" """
Helper function to create a toolbar button with text beside or under the icon. Helper function to create a toolbar button with text beside or under the icon.
Args: Args:
toolbar_action(ToolBarAction): The toolbar action to create the button for. toolbar_action(ToolBarAction): The toolbar action to create the button for.
toolbar(ModularToolBar): The toolbar to add the button to. 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) btn = QToolButton(parent=toolbar)
if min_size is not None:
btn.setMinimumSize(min_size)
if getattr(toolbar_action, "label_text", None): if getattr(toolbar_action, "label_text", None):
toolbar_action.action.setText(toolbar_action.label_text) toolbar_action.action.setText(toolbar_action.label_text)
if getattr(toolbar_action, "tooltip", None): if getattr(toolbar_action, "tooltip", None):
+6 -6
View File
@@ -26,7 +26,7 @@ from qtpy.QtWidgets import (
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils import BECConnector
logger = bec_logger.logger logger = bec_logger.logger
@@ -418,7 +418,7 @@ class WidgetHierarchy:
only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget. only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget.
show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget. show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget.
""" """
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils import BECConnector
from bec_widgets.widgets.plots.waveform.waveform import Waveform from bec_widgets.widgets.plots.waveform.waveform import Waveform
for node in WidgetHierarchy.iter_widget_tree( for node in WidgetHierarchy.iter_widget_tree(
@@ -468,7 +468,7 @@ class WidgetHierarchy:
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils import BECConnector
from bec_widgets.widgets.plots.plot_base import PlotBase from bec_widgets.widgets.plots.plot_base import PlotBase
# 1) Gather ALL QWidget-based BECConnector objects # 1) Gather ALL QWidget-based BECConnector objects
@@ -534,7 +534,7 @@ class WidgetHierarchy:
Returns: Returns:
The nearest ancestor that is a BECConnector, or None if not found. The nearest ancestor that is a BECConnector, or None if not found.
""" """
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils import BECConnector
# Guard against deleted/invalid Qt wrappers # Guard against deleted/invalid Qt wrappers
if not shb.isValid(widget): if not shb.isValid(widget):
@@ -636,7 +636,7 @@ class WidgetHierarchy:
Return all BECConnector instances whose closest BECConnector ancestor is the given widget, Return all BECConnector instances whose closest BECConnector ancestor is the given widget,
including the widget itself if it is a BECConnector. including the widget itself if it is a BECConnector.
""" """
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils import BECConnector
connectors: list[BECConnector] = [] connectors: list[BECConnector] = []
if isinstance(widget, BECConnector): if isinstance(widget, BECConnector):
@@ -664,7 +664,7 @@ class WidgetHierarchy:
return None return None
try: try:
from bec_widgets.utils.bec_connector import BECConnector # local import to avoid cycles from bec_widgets.utils import BECConnector # local import to avoid cycles
is_bec_target = False is_bec_target = False
if isinstance(ancestor_class, str): if isinstance(ancestor_class, str):
@@ -13,9 +13,9 @@ from shiboken6 import isValid
import bec_widgets.widgets.containers.qt_ads as QtAds import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets import BECWidget, SafeSlot from bec_widgets import BECWidget, SafeSlot
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.property_editor import PropertyEditor from bec_widgets.utils.property_editor import PropertyEditor
from bec_widgets.utils.rpc_widget_handler import widget_handler
from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.containers.qt_ads import ( from bec_widgets.widgets.containers.qt_ads import (
CDockAreaWidget, CDockAreaWidget,
@@ -20,10 +20,10 @@ from qtpy.QtWidgets import (
import bec_widgets.widgets.containers.qt_ads as QtAds import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets import BECWidget, SafeProperty, SafeSlot from bec_widgets import BECWidget, SafeProperty, SafeSlot
from bec_widgets.applications.views.view import ViewTourSteps from bec_widgets.applications.views.view import ViewTourSteps
from bec_widgets.utils.bec_dispatcher import BECDispatcher from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.utils.rpc_widget_handler import widget_handler
from bec_widgets.utils.toolbars.actions import ( from bec_widgets.utils.toolbars.actions import (
ExpandableMenuAction, ExpandableMenuAction,
MaterialIconAction, MaterialIconAction,
@@ -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.containers.qt_ads import CDockWidget
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D 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.control.scan_control import ScanControl
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
@@ -79,7 +79,7 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform
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
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel from bec_widgets.widgets.utility.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger logger = bec_logger.logger
@@ -372,11 +372,10 @@ class BECDockArea(DockAreaWidget):
"Add Circular ProgressBar", "Add Circular ProgressBar",
"RingProgressBar", "RingProgressBar",
), ),
"terminal": (BecConsole.ICON_NAME, "Add Terminal", "BecConsole"), "terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"), "bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"), "log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"), "sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel", "LogPanel"),
} }
# Create expandable menu actions (original behavior) # Create expandable menu actions (original behavior)
@@ -488,7 +487,9 @@ class BECDockArea(DockAreaWidget):
# first two items not needed for this part # first two items not needed for this part
for key, (_, _, widget_type) in mapping.items(): for key, (_, _, widget_type) in mapping.items():
act = menu.actions[key].action act = menu.actions[key].action
if key == "terminal": if widget_type == "LogPanel":
act.setEnabled(False) # keep disabled per issue #644
elif key == "terminal":
act.triggered.connect( act.triggered.connect(
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None) lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
) )
@@ -509,7 +510,10 @@ class BECDockArea(DockAreaWidget):
for action_id, (_, _, widget_type) in mapping.items(): for action_id, (_, _, widget_type) in mapping.items():
flat_action_id = f"flat_{action_id}" flat_action_id = f"flat_{action_id}"
flat_action = self.toolbar.components.get_action(flat_action_id).action flat_action = self.toolbar.components.get_action(flat_action_id).action
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t)) if widget_type == "LogPanel":
flat_action.setEnabled(False) # keep disabled per issue #644
else:
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
_connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"]) _connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"])
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"]) _connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])
@@ -63,7 +63,7 @@ class ScriptTreeWidget(QWidget):
layout.setSpacing(0) layout.setSpacing(0)
# Create tree view # Create tree view
self.tree = QTreeView(parent=self) self.tree = QTreeView()
self.tree.setHeaderHidden(True) self.tree.setHeaderHidden(True)
self.tree.setRootIsDecorated(True) self.tree.setRootIsDecorated(True)
@@ -71,12 +71,12 @@ class ScriptTreeWidget(QWidget):
self.tree.setMouseTracking(True) self.tree.setMouseTracking(True)
# Create file system model # Create file system model
self.model = QFileSystemModel(parent=self) self.model = QFileSystemModel()
self.model.setNameFilters(["*.py"]) self.model.setNameFilters(["*.py"])
self.model.setNameFilterDisables(False) self.model.setNameFilterDisables(False)
# Create proxy model to filter out underscore directories # Create proxy model to filter out underscore directories
self.proxy_model = QSortFilterProxyModel(parent=self) self.proxy_model = QSortFilterProxyModel()
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*")) self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
self.proxy_model.setSourceModel(self.model) self.proxy_model.setSourceModel(self.model)
self.tree.setModel(self.proxy_model) self.tree.setModel(self.proxy_model)
@@ -22,7 +22,7 @@ from qtpy.QtWidgets import (
) )
from typeguard import typechecked from typeguard import typechecked
from bec_widgets.utils.rpc_widget_handler import widget_handler from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
class LayoutManagerWidget(QWidget): class LayoutManagerWidget(QWidget):
@@ -1,83 +1,27 @@
import sys import sys
from qtpy import QtGui, QtWidgets
from qtpy.QtCore import QPoint, Qt from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import ( from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QProgressBar,
QVBoxLayout,
QWidget,
)
class WidgetTooltip(QWidget): class WidgetTooltip(QWidget):
"""Frameless, always-on-top window that behaves like a tooltip.""" """Frameless, always-on-top window that behaves like a tooltip."""
def __init__(self, content: QWidget) -> None: def __init__(self, content: QWidget) -> None:
super().__init__( super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
None, self.setAttribute(Qt.WA_ShowWithoutActivating)
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.setMouseTracking(True)
self.content = content self.content = content
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(14, 14, 14, 14) layout.setContentsMargins(6, 6, 6, 6)
layout.addWidget(self.content)
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() self.adjustSize()
def leaveEvent(self, _event) -> None: def leaveEvent(self, _event) -> None:
self.hide() 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: 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() self.adjustSize()
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen() screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
screen_geo = screen.availableGeometry() screen_geo = screen.availableGeometry()
@@ -86,43 +30,11 @@ class WidgetTooltip(QWidget):
x = global_pos.x() - geom.width() // 2 x = global_pos.x() - geom.width() // 2
y = global_pos.y() - geom.height() - offset 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())) x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height())) y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
self.move(x, y) self.move(x, y)
self.show() self.show()
self.raise_()
class HoverWidget(QWidget): class HoverWidget(QWidget):
@@ -28,7 +28,7 @@ from qtpy.QtCore import QObject, QTimer
from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidget from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidget
from bec_widgets import SafeProperty, SafeSlot from bec_widgets import SafeProperty, SafeSlot
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils import BECConnector
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
@@ -18,10 +18,10 @@ from qtpy.QtWidgets import (
) )
import bec_widgets import bec_widgets
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import ( from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
BECNotificationBroker, BECNotificationBroker,
@@ -0,0 +1,3 @@
from .positioner_box_base import PositionerBoxBase
__ALL__ = ["PositionerBoxBase"]
@@ -14,10 +14,10 @@ from qtpy.QtWidgets import (
QLineEdit, QLineEdit,
QPushButton, QPushButton,
QVBoxLayout, QVBoxLayout,
QWidget,
) )
from bec_widgets.utils.bec_widget import BECWidget 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 ( from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
PositionIndicator, PositionIndicator,
) )
@@ -43,7 +43,7 @@ class DeviceUpdateUIComponents(TypedDict):
units: QLabel units: QLabel
class PositionerBoxBase(BECWidget, QWidget): class PositionerBoxBase(BECWidget, CompactPopupWidget):
"""Contains some core logic for positioner box widgets""" """Contains some core logic for positioner box widgets"""
current_path = "" current_path = ""
@@ -57,10 +57,7 @@ class PositionerBoxBase(BECWidget, QWidget):
parent: The parent widget. parent: The parent widget.
device (Positioner): The device to control. device (Positioner): The device to control.
""" """
super().__init__(parent=parent, **kwargs) super().__init__(parent=parent, layout=QVBoxLayout, **kwargs)
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self._dialog = None self._dialog = None
self.get_bec_shortcuts() self.get_bec_shortcuts()
@@ -176,9 +173,11 @@ class PositionerBoxBase(BECWidget, QWidget):
if is_moving: if is_moving:
spinner.start() spinner.start()
spinner.setToolTip("Device is moving") spinner.setToolTip("Device is moving")
self.set_global_state("warning")
else: else:
spinner.stop() spinner.stop()
spinner.setToolTip("Device is idle") spinner.setToolTip("Device is idle")
self.set_global_state("success")
else: else:
spinner.setVisible(False) spinner.setVisible(False)
@@ -197,8 +196,9 @@ class PositionerBoxBase(BECWidget, QWidget):
pos = (readback_val - limits[0]) / (limits[1] - limits[0]) pos = (readback_val - limits[0]) / (limits[1] - limits[0])
position_indicator.set_value(pos) position_indicator.set_value(pos)
@staticmethod def _update_limits_ui(
def _update_limits_ui(limits: tuple[float, float], position_indicator, setpoint_validator): self, limits: tuple[float, float], position_indicator, setpoint_validator
):
if limits is not None and limits[0] != limits[1]: if limits is not None and limits[0] != limits[1]:
position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}") position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
setpoint_validator.setRange(limits[0], limits[1]) setpoint_validator.setRange(limits[0], limits[1])
@@ -223,9 +223,8 @@ class PositionerBoxBase(BECWidget, QWidget):
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device)) self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device)) self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
@staticmethod def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
def _toggle_enable_buttons(ui: DeviceUpdateUIComponents, enable: bool) -> None: """Toogle enable/disable on available buttons
"""Toggle enable/disable on available buttons
Args: Args:
enable (bool): Enable buttons enable (bool): Enable buttons
@@ -11,12 +11,12 @@ from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QDoubleValidator from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox 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.colors import apply_theme, get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import ( from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
DeviceUpdateUIComponents, DeviceUpdateUIComponents,
PositionerBoxBase,
) )
logger = bec_logger.logger 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.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
self.main_layout.addWidget(self.ui) self.addWidget(self.ui)
self.main_layout.setSpacing(0) self.layout.setSpacing(0)
self.main_layout.setContentsMargins(0, 0, 0, 0) self.layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter) self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
ui_min_size = self.ui.minimumSize() ui_min_size = self.ui.minimumSize()
ui_min_hint = self.ui.minimumSizeHint() ui_min_hint = self.ui.minimumSizeHint()
self.setMinimumSize( self.setMinimumSize(
@@ -115,6 +115,8 @@ class PositionerBox(PositionerBoxBase):
return return
old_device = self._device old_device = self._device
self._device = value self._device = value
if not self.label:
self.label = value
self.device_changed.emit(old_device, value) self.device_changed.emit(old_device, value)
@SafeProperty(bool) @SafeProperty(bool)
@@ -12,12 +12,12 @@ from qtpy.QtCore import Signal
from qtpy.QtGui import QDoubleValidator from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import ( from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
DeviceUpdateUIComponents, DeviceUpdateUIComponents,
PositionerBoxBase,
) )
logger = bec_logger.logger logger = bec_logger.logger
@@ -96,9 +96,9 @@ class PositionerBox2D(PositionerBoxBase):
def connect_ui(self): def connect_ui(self):
"""Connect the UI components to signals, data, or routines""" """Connect the UI components to signals, data, or routines"""
self.main_layout.addWidget(self.ui) self.addWidget(self.ui)
self.main_layout.setSpacing(0) self.layout.setSpacing(0)
self.main_layout.setContentsMargins(0, 0, 0, 0) self.layout.setContentsMargins(0, 0, 0, 0)
def _init_ui(val: QDoubleValidator, device_id: DeviceId): def _init_ui(val: QDoubleValidator, device_id: DeviceId):
ui = self._device_ui_components_hv(device_id) ui = self._device_ui_components_hv(device_id)
@@ -200,6 +200,7 @@ class PositionerBox2D(PositionerBoxBase):
return return
old_device = self._device_hor old_device = self._device_hor
self._device_hor = value self._device_hor = value
self.label = f"{self._device_hor}, {self._device_ver}"
self.device_changed_hor.emit(old_device, value) self.device_changed_hor.emit(old_device, value)
self._init_device(self.device_hor, self.position_update_hor.emit, self.update_limits_hor) self._init_device(self.device_hor, self.position_update_hor.emit, self.update_limits_hor)
@@ -219,6 +220,7 @@ class PositionerBox2D(PositionerBoxBase):
return return
old_device = self._device_ver old_device = self._device_ver
self._device_ver = value self._device_ver = value
self.label = f"{self._device_hor}, {self._device_ver}"
self.device_changed_ver.emit(old_device, value) self.device_changed_ver.emit(old_device, value)
self._init_device(self.device_ver, self.position_update_ver.emit, self.update_limits_ver) self._init_device(self.device_ver, self.position_update_ver.emit, self.update_limits_ver)
@@ -1,8 +1,6 @@
import os import os
from bec_lib.device import Positioner 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 from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
@@ -24,82 +22,7 @@ class PositionerControlLine(PositionerBox):
device (Positioner): The device to control. device (Positioner): The device to control.
""" """
self.current_path = os.path.dirname(__file__) 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) 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 if __name__ == "__main__": # pragma: no cover
@@ -2,18 +2,12 @@
<ui version="4.0"> <ui version="4.0">
<class>Form</class> <class>Form</class>
<widget class="QWidget" name="Form"> <widget class="QWidget" name="Form">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="geometry"> <property name="geometry">
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>592</width> <width>612</width>
<height>76</height> <height>91</height>
</rect> </rect>
</property> </property>
<property name="minimumSize"> <property name="minimumSize">
@@ -32,29 +26,8 @@
<string>Form</string> <string>Form</string>
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout"> <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> <item>
<widget class="QGroupBox" name="device_box"> <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"> <property name="title">
<string>Device Name</string> <string>Device Name</string>
</property> </property>
@@ -254,12 +227,12 @@
<customwidgets> <customwidgets>
<customwidget> <customwidget>
<class>PositionIndicator</class> <class>PositionIndicator</class>
<extends></extends> <extends>QWidget</extends>
<header>position_indicator</header> <header>position_indicator</header>
</customwidget> </customwidget>
<customwidget> <customwidget>
<class>SpinnerWidget</class> <class>SpinnerWidget</class>
<extends></extends> <extends>QWidget</extends>
<header>spinner_widget</header> <header>spinner_widget</header>
</customwidget> </customwidget>
</customwidgets> </customwidgets>
@@ -27,13 +27,30 @@ class PositionerGroupBox(QGroupBox):
self.layout().setContentsMargins(0, 0, 0, 0) self.layout().setContentsMargins(0, 0, 0, 0)
self.layout().setSpacing(0) self.layout().setSpacing(0)
self.widget = PositionerBox(self, dev_name) self.widget = PositionerBox(self, dev_name)
self.widget.compact_view = True
self.widget.expand_popup = False
self.layout().addWidget(self.widget) self.layout().addWidget(self.widget)
self.widget.position_update.connect(self._on_position_update) self.widget.position_update.connect(self._on_position_update)
self.widget.expand.connect(self._on_expand)
self.setTitle(self.device_name) self.setTitle(self.device_name)
self.widget.force_update_readback() 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): def _on_position_update(self, pos: float):
self.position_update.emit(pos) 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): def close(self):
self.widget.close() self.widget.close()
@@ -7,7 +7,7 @@ from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from pydantic import field_validator from pydantic import field_validator
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.filter_io import FilterIO from bec_widgets.utils.filter_io import FilterIO
@@ -3,7 +3,7 @@ from bec_lib.device import Signal
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtCore import Property from qtpy.QtCore import Property
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO from bec_widgets.utils.filter_io import FilterIO
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
from .available_device_group import AvailableDeviceGroup from .available_device_group import AvailableDeviceGroup
class _DeviceListWidget(QListWidget): class _DeviceListWiget(QListWidget):
def _item_iter(self): def _item_iter(self):
return (self.item(i) for i in range(self.count())) return (self.item(i) for i in range(self.count()))
@@ -44,7 +44,7 @@ class Ui_AvailableDeviceGroup(object):
self.n_included.setObjectName("n_included") self.n_included.setObjectName("n_included")
title_layout.addWidget(self.n_included) title_layout.addWidget(self.n_included)
self.device_list = _DeviceListWidget(AvailableDeviceGroup) self.device_list = _DeviceListWiget(AvailableDeviceGroup)
self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
self.device_list.setObjectName("device_list") self.device_list.setObjectName("device_list")
self.device_list.setFrameStyle(0) self.device_list.setFrameStyle(0)
@@ -34,13 +34,13 @@ class HashModel(str, Enum):
class DeviceResourceBackend(Protocol): class DeviceResourceBackend(Protocol):
@property @property
def tag_groups(self) -> dict[str, set[HashableDevice]]: def tag_groups(self) -> dict[str, set[HashableDevice]]:
"""A dictionary of all available devices separated by tag groups. The same device may """A dictionary of all availble devices separated by tag groups. The same device may
appear more than once (in different groups).""" appear more than once (in different groups)."""
... ...
@property @property
def all_devices(self) -> set[HashableDevice]: def all_devices(self) -> set[HashableDevice]:
"""A set of all available devices. The same device may not appear more than once.""" """A set of all availble devices. The same device may not appear more than once."""
... ...
@property @property
@@ -5,8 +5,9 @@ in DeviceTableRow entries.
from __future__ import annotations from __future__ import annotations
import traceback
from copy import deepcopy from copy import deepcopy
from typing import TYPE_CHECKING, Any, Callable, Iterable, Tuple from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Tuple
from bec_lib.atlas_models import Device as DeviceModel from bec_lib.atlas_models import Device as DeviceModel
from bec_lib.callback_handler import EventType from bec_lib.callback_handler import EventType
@@ -18,7 +19,6 @@ from thefuzz import fuzz
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot 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 ( from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import (
DeviceTableRow, DeviceTableRow,
) )
@@ -37,6 +37,34 @@ _DeviceCfgIter = Iterable[dict[str, Any]]
# DeviceValidationResult: device_config, config_status, connection_status, error_message # DeviceValidationResult: device_config, config_status, connection_status, error_message
_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]] _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: class TableSortOnHold:
"""Context manager for putting table sorting on hold. Works with nested calls.""" """Context manager for putting table sorting on hold. Works with nested calls."""
@@ -19,7 +19,7 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme, get_accent_colors from bec_widgets.utils.colors import apply_theme, get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
@@ -347,14 +347,14 @@ class ScanGroupBox(QGroupBox):
def get_parameters(self, device_object: bool = True): def get_parameters(self, device_object: bool = True):
""" """
Returns the parameters from the widgets in the scan control layout formatted to run scan from BEC. Returns the parameters from the widgets in the scan control layout formated to run scan from BEC.
""" """
if self.box_type == "args": if self.box_type == "args":
return self._get_arg_parameters(device_object=device_object) return self._get_arg_parameterts(device_object=device_object)
elif self.box_type == "kwargs": elif self.box_type == "kwargs":
return self._get_kwarg_parameters(device_object=device_object) return self._get_kwarg_parameters(device_object=device_object)
def _get_arg_parameters(self, device_object: bool = True): def _get_arg_parameterts(self, device_object: bool = True):
args = [] args = []
for i in range(1, self.layout.rowCount()): for i in range(1, self.layout.rowCount()):
for j in range(self.layout.columnCount()): for j in range(self.layout.columnCount()):
@@ -2,19 +2,22 @@
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QComboBox from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger logger = bec_logger.logger
class DapComboBox(BECWidget, QComboBox): class DapComboBox(BECWidget, QWidget):
""" """
Editable combobox listing the available DAP models. The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.
The widget behaves as a plain QComboBox and keeps ``fit_model_combobox`` as an alias to itself Args:
for backwards compatibility with older call sites. parent: Parent widget.
client: BEC client object.
gui_id: GUI ID.
default: Default device name.
""" """
ICON_NAME = "data_exploration" ICON_NAME = "data_exploration"
@@ -42,20 +45,19 @@ class DapComboBox(BECWidget, QComboBox):
**kwargs, **kwargs,
): ):
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs) super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
self.fit_model_combobox = self # Just for backwards compatibility with older call sites, the widget itself is the combobox self.layout = QVBoxLayout(self)
self._available_models: list[str] = [] self.fit_model_combobox = QComboBox(self)
self.layout.addWidget(self.fit_model_combobox)
self.layout.setContentsMargins(0, 0, 0, 0)
self._available_models = None
self._x_axis = None self._x_axis = None
self._y_axis = None self._y_axis = None
self._is_valid_input = False
self.setEditable(True)
self.populate_fit_model_combobox() self.populate_fit_model_combobox()
self.currentTextChanged.connect(self._on_text_changed) self.fit_model_combobox.currentTextChanged.connect(self._update_current_fit)
# Set default fit model
self.select_default_fit(default_fit) self.select_default_fit(default_fit)
self.check_validity(self.currentText())
def select_default_fit(self, default_fit: str | None = "GaussianModel"): def select_default_fit(self, default_fit: str | None):
"""Set the default fit model. """Set the default fit model.
Args: Args:
@@ -63,8 +65,8 @@ class DapComboBox(BECWidget, QComboBox):
""" """
if self._validate_dap_model(default_fit): if self._validate_dap_model(default_fit):
self.select_fit_model(default_fit) self.select_fit_model(default_fit)
elif self.available_models: else:
self.select_fit_model(self.available_models[0]) self.select_fit_model("GaussianModel")
@property @property
def available_models(self): def available_models(self):
@@ -112,40 +114,12 @@ class DapComboBox(BECWidget, QComboBox):
self._y_axis = y_axis self._y_axis = y_axis
self.y_axis_updated.emit(y_axis) self.y_axis_updated.emit(y_axis)
@Slot(str) def _update_current_fit(self, fit_name: str):
def _on_text_changed(self, fit_name: str): """Update the current fit."""
"""
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) self.fit_model_updated.emit(fit_name)
if self.x_axis is not None and self.y_axis is not None: 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) 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) @Slot(str)
def select_x_axis(self, x_axis: str): def select_x_axis(self, x_axis: str):
"""Slot to update the x axis. """Slot to update the x axis.
@@ -154,7 +128,7 @@ class DapComboBox(BECWidget, QComboBox):
x_axis(str): X axis. x_axis(str): X axis.
""" """
self.x_axis = x_axis self.x_axis = x_axis
self._on_text_changed(self.currentText()) self._update_current_fit(self.fit_model_combobox.currentText())
@Slot(str) @Slot(str)
def select_y_axis(self, y_axis: str): def select_y_axis(self, y_axis: str):
@@ -164,26 +138,25 @@ class DapComboBox(BECWidget, QComboBox):
y_axis(str): Y axis. y_axis(str): Y axis.
""" """
self.y_axis = y_axis self.y_axis = y_axis
self._on_text_changed(self.currentText()) self._update_current_fit(self.fit_model_combobox.currentText())
@Slot(str) @Slot(str)
def select_fit_model(self, fit_name: str | None): def select_fit_model(self, fit_name: str | None):
"""Slot to update the fit model. """Slot to update the fit model.
Args: Args:
fit_name(str): Fit model name. default_device(str): Default device name.
""" """
if not self._validate_dap_model(fit_name): if not self._validate_dap_model(fit_name):
raise ValueError(f"Fit {fit_name} is not valid.") raise ValueError(f"Fit {fit_name} is not valid.")
self.setCurrentText(fit_name) self.fit_model_combobox.setCurrentText(fit_name)
def populate_fit_model_combobox(self): def populate_fit_model_combobox(self):
"""Populate the fit_model_combobox with the devices.""" """Populate the fit_model_combobox with the devices."""
# pylint: disable=protected-access # pylint: disable=protected-access
available_plugins = getattr(getattr(self.client, "dap", None), "_available_dap_plugins", {}) self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()]
self.available_models = [model for model in available_plugins.keys()] self.fit_model_combobox.clear()
self.clear() self.fit_model_combobox.addItems(self.available_models)
self.addItems(self.available_models)
def _validate_dap_model(self, model: str | None) -> bool: def _validate_dap_model(self, model: str | None) -> bool:
"""Validate the DAP model. """Validate the DAP model.
@@ -193,23 +166,23 @@ class DapComboBox(BECWidget, QComboBox):
""" """
if model is None: if model is None:
return False return False
return model in self.available_models if model not in self.available_models:
return False
@property return True
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 if __name__ == "__main__": # pragma: no cover
import sys # pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
app = QApplication(sys.argv) app = QApplication([])
apply_theme("dark") apply_theme("dark")
dialog = DapComboBox() widget = QWidget()
dialog.show() widget.setFixedSize(200, 200)
sys.exit(app.exec_()) layout = QVBoxLayout()
widget.setLayout(layout)
layout.addWidget(DapComboBox())
widget.show()
app.exec_()
@@ -1,14 +1,13 @@
import os import os
import shiboken6
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtCore import Signal from qtpy.QtCore import Signal
from qtpy.QtWidgets import QPushButton, QSizePolicy, QTreeWidgetItem, QVBoxLayout, QWidget from qtpy.QtWidgets import QPushButton, QTreeWidgetItem, QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.ui_loader import UILoader
logger = bec_logger.logger logger = bec_logger.logger
@@ -35,7 +34,7 @@ class LMFitDialog(BECWidget, QWidget):
**kwargs, **kwargs,
): ):
""" """
Initializes the LMFitDialog widget. Initialises the LMFitDialog widget.
Args: Args:
parent (QWidget): The parent widget. parent (QWidget): The parent widget.
@@ -69,27 +68,6 @@ class LMFitDialog(BECWidget, QWidget):
self._hide_curve_selection = False self._hide_curve_selection = False
self._hide_summary = False self._hide_summary = False
self._hide_parameters = 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 @property
def enable_actions(self) -> bool: def enable_actions(self) -> bool:
@@ -99,14 +77,8 @@ class LMFitDialog(BECWidget, QWidget):
@enable_actions.setter @enable_actions.setter
def enable_actions(self, enable: bool): def enable_actions(self, enable: bool):
self._enable_actions = enable self._enable_actions = enable
valid_buttons = {} for button in self.action_buttons.values():
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) button.setEnabled(enable)
valid_buttons[name] = button
self.action_buttons = valid_buttons
@SafeProperty(list) @SafeProperty(list)
def active_action_list(self) -> list[str]: def active_action_list(self) -> list[str]:
@@ -117,6 +89,16 @@ class LMFitDialog(BECWidget, QWidget):
def active_action_list(self, actions: list[str]): def active_action_list(self, actions: list[str]):
self._active_actions = actions 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) @SafeProperty(bool)
def always_show_latest(self): def always_show_latest(self):
"""SafeProperty to indicate if always the latest DAP update is displayed.""" """SafeProperty to indicate if always the latest DAP update is displayed."""
@@ -172,21 +154,19 @@ class LMFitDialog(BECWidget, QWidget):
self.ui.group_parameters.setVisible(not show) self.ui.group_parameters.setVisible(not show)
@property @property
def fit_curve_id(self) -> str | None: def fit_curve_id(self) -> str:
"""SafeProperty for the currently displayed fit curve_id.""" """SafeProperty for the currently displayed fit curve_id."""
return self._fit_curve_id return self._fit_curve_id
@fit_curve_id.setter @fit_curve_id.setter
def fit_curve_id(self, curve_id: str | None): def fit_curve_id(self, curve_id: str):
"""Setter for the currently displayed fit curve_id. """Setter for the currently displayed fit curve_id.
Args: Args:
curve_id (str | None): The curve_id of the fit curve to be displayed, fit_curve_id (str): The curve_id of the fit curve to be displayed.
or None to clear the selection.
""" """
self._fit_curve_id = curve_id self._fit_curve_id = curve_id
if curve_id is not None: self.selected_fit.emit(curve_id)
self.selected_fit.emit(curve_id)
@SafeSlot(str) @SafeSlot(str)
def remove_dap_data(self, curve_id: str): def remove_dap_data(self, curve_id: str):
@@ -196,15 +176,6 @@ class LMFitDialog(BECWidget, QWidget):
curve_id (str): The curve_id of the DAP data to be removed. curve_id (str): The curve_id of the DAP data to be removed.
""" """
self.summary_data.pop(curve_id, None) 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() self.refresh_curve_list()
@SafeSlot(str) @SafeSlot(str)
@@ -280,7 +251,6 @@ class LMFitDialog(BECWidget, QWidget):
params (list): List of LMFit parameters for the fit curve. params (list): List of LMFit parameters for the fit curve.
""" """
self._move_buttons = [] self._move_buttons = []
self.action_buttons = {}
self.ui.param_tree.clear() self.ui.param_tree.clear()
for param in params: for param in params:
param_name = param[0] param_name = param[0]
@@ -299,9 +269,9 @@ class LMFitDialog(BECWidget, QWidget):
if param_name in self.active_action_list: # pylint: disable=unsupported-membership-test 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 # Create a push button to move the motor to a specific position
widget = QWidget() widget = QWidget()
button = QPushButton("Move") button = QPushButton(f"Move to {param_name}")
button.clicked.connect(self._create_move_action(param_name, param[1])) button.clicked.connect(self._create_move_action(param_name, param[1]))
if self.enable_actions: if self.enable_actions is True:
button.setEnabled(True) button.setEnabled(True)
else: else:
button.setEnabled(False) button.setEnabled(False)
@@ -14,18 +14,6 @@
<string>Form</string> <string>Form</string>
</property> </property>
<layout class="QGridLayout" name="gridLayout"> <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"> <item row="0" column="0">
<widget class="QSplitter" name="splitter_2"> <widget class="QSplitter" name="splitter_2">
<property name="sizePolicy"> <property name="sizePolicy">
@@ -34,6 +22,15 @@
<verstretch>0</verstretch> <verstretch>0</verstretch>
</sizepolicy> </sizepolicy>
</property> </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"> <property name="orientation">
<enum>Qt::Orientation::Horizontal</enum> <enum>Qt::Orientation::Horizontal</enum>
</property> </property>
@@ -44,12 +41,6 @@
<bool>true</bool> <bool>true</bool>
</property> </property>
<widget class="QGroupBox" name="group_curve_selection"> <widget class="QGroupBox" name="group_curve_selection">
<property name="minimumSize">
<size>
<width>120</width>
<height>0</height>
</size>
</property>
<property name="title"> <property name="title">
<string>Select Curve</string> <string>Select Curve</string>
</property> </property>
@@ -67,36 +58,18 @@
</sizepolicy> </sizepolicy>
</property> </property>
<property name="orientation"> <property name="orientation">
<enum>Qt::Orientation::Horizontal</enum> <enum>Qt::Orientation::Vertical</enum>
</property> </property>
<widget class="QGroupBox" name="group_summary"> <widget class="QGroupBox" name="group_summary">
<property name="minimumSize">
<size>
<width>180</width>
<height>0</height>
</size>
</property>
<property name="title"> <property name="title">
<string>Fit Summary</string> <string>Fit Summary</string>
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout_2"> <layout class="QVBoxLayout" name="verticalLayout_2">
<item> <item>
<widget class="QTreeWidget" name="summary_tree"> <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"> <property name="uniformRowHeights">
<bool>false</bool> <bool>false</bool>
</property> </property>
<attribute name="headerDefaultSectionSize">
<number>90</number>
</attribute>
<column> <column>
<property name="text"> <property name="text">
<string>Property</string> <string>Property</string>
@@ -112,33 +85,12 @@
</layout> </layout>
</widget> </widget>
<widget class="QGroupBox" name="group_parameters"> <widget class="QGroupBox" name="group_parameters">
<property name="minimumSize">
<size>
<width>240</width>
<height>0</height>
</size>
</property>
<property name="title"> <property name="title">
<string>Parameter Details</string> <string>Parameter Details</string>
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout_3"> <layout class="QVBoxLayout" name="verticalLayout_3">
<item> <item>
<widget class="QTreeWidget" name="param_tree"> <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> <column>
<property name="text"> <property name="text">
<string>Parameter</string> <string>Parameter</string>
@@ -154,11 +106,6 @@
<string>Std</string> <string>Std</string>
</property> </property>
</column> </column>
<column>
<property name="text">
<string>Action</string>
</property>
</column>
</widget> </widget>
</item> </item>
</layout> </layout>
@@ -95,12 +95,6 @@
<height>0</height> <height>0</height>
</size> </size>
</property> </property>
<property name="indentation">
<number>0</number>
</property>
<property name="rootIsDecorated">
<bool>false</bool>
</property>
<property name="uniformRowHeights"> <property name="uniformRowHeights">
<bool>false</bool> <bool>false</bool>
</property> </property>
@@ -153,12 +147,6 @@
<width>0</width> <width>0</width>
<height>0</height> <height>0</height>
</size> </size>
</property>
<property name="indentation">
<number>0</number>
</property>
<property name="rootIsDecorated">
<bool>false</bool>
</property> </property>
<property name="columnCount"> <property name="columnCount">
<number>4</number> <number>4</number>
@@ -1,605 +0,0 @@
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from uuid import uuid4
from weakref import WeakValueDictionary
import shiboken6
from bec_lib.logger import bec_logger
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QMouseEvent
from qtpy.QtWidgets import (
QApplication,
QHBoxLayout,
QLabel,
QStackedLayout,
QTabWidget,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.utility.bec_term.protocol import BecTerminal
from bec_widgets.widgets.utility.bec_term.util import get_current_bec_term_class
logger = bec_logger.logger
_BecTermClass = get_current_bec_term_class()
# Note on definitions:
# Terminal: an instance of a terminal widget with a system shell
# Console: one of possibly several widgets which may share ownership of one single terminal
# Shell: a Console set to start the BEC IPython client in its terminal
class ConsoleMode(str, enum.Enum):
ACTIVE = "active"
INACTIVE = "inactive"
HIDDEN = "hidden"
@dataclass
class _TerminalOwnerInfo:
"""Should be managed only by the BecConsoleRegistry. Consoles should ask the registry for
necessary ownership info."""
owner_console_id: str | None = None
registered_console_ids: set[str] = field(default_factory=set)
instance: BecTerminal | None = None
terminal_id: str = ""
initialized: bool = False
persist_session: bool = False
fallback_holder: QWidget | None = None
class BecConsoleRegistry:
"""
A registry for the BecConsole class to manage its instances.
"""
def __init__(self):
"""
Initialize the registry.
"""
self._consoles: WeakValueDictionary[str, BecConsole] = WeakValueDictionary()
self._terminal_registry: dict[str, _TerminalOwnerInfo] = {}
@staticmethod
def _is_valid_qobject(obj: object | None) -> bool:
return obj is not None and shiboken6.isValid(obj)
def _connect_app_cleanup(self) -> None:
app = QApplication.instance()
if app is None:
return
app.aboutToQuit.connect(self.clear, Qt.ConnectionType.UniqueConnection)
@staticmethod
def _new_terminal_info(console: BecConsole) -> _TerminalOwnerInfo:
term = _BecTermClass()
return _TerminalOwnerInfo(
registered_console_ids={console.console_id},
owner_console_id=console.console_id,
instance=term,
terminal_id=console.terminal_id,
persist_session=console.persist_terminal_session,
)
@staticmethod
def _replace_terminal(info: _TerminalOwnerInfo, console: BecConsole) -> None:
info.instance = _BecTermClass()
info.initialized = False
info.owner_console_id = console.console_id
info.registered_console_ids.add(console.console_id)
info.persist_session = info.persist_session or console.persist_terminal_session
def _delete_terminal_info(self, info: _TerminalOwnerInfo) -> None:
if self._is_valid_qobject(info.instance):
info.instance.deleteLater() # type: ignore[union-attr]
info.instance = None
if self._is_valid_qobject(info.fallback_holder):
info.fallback_holder.deleteLater()
info.fallback_holder = None
def _parking_parent(
self,
info: _TerminalOwnerInfo,
console: BecConsole | None = None,
*,
avoid_console: bool = False,
) -> QWidget | None:
for console_id in info.registered_console_ids:
candidate = self._consoles.get(console_id)
if candidate is None or candidate is console:
continue
if self._is_valid_qobject(candidate):
return candidate._term_holder
if console is None or not self._is_valid_qobject(console):
return None
window = console.window()
if window is not None and window is not console and self._is_valid_qobject(window):
return window
if not avoid_console:
return console._term_holder
return None
def _fallback_holder(
self,
info: _TerminalOwnerInfo,
console: BecConsole | None = None,
*,
avoid_console: bool = False,
) -> QWidget:
if not self._is_valid_qobject(info.fallback_holder):
info.fallback_holder = QWidget(
parent=self._parking_parent(info, console, avoid_console=avoid_console)
)
info.fallback_holder.setObjectName(f"_bec_console_terminal_holder_{info.terminal_id}")
info.fallback_holder.hide()
return info.fallback_holder
def _park_terminal(
self,
info: _TerminalOwnerInfo,
console: BecConsole | None = None,
*,
avoid_console: bool = False,
) -> None:
if not self._is_valid_qobject(info.instance):
return
parent = self._parking_parent(info, console, avoid_console=avoid_console)
if parent is None and info.persist_session:
parent = self._fallback_holder(info, console, avoid_console=avoid_console)
info.instance.hide() # type: ignore[union-attr]
info.instance.setParent(parent) # type: ignore[union-attr]
def clear(self) -> None:
"""Delete every tracked terminal and holder."""
for info in list(self._terminal_registry.values()):
self._delete_terminal_info(info)
self._terminal_registry.clear()
self._consoles.clear()
def register(self, console: BecConsole):
"""
Register an instance of BecConsole. If there is already a terminal with the associated
terminal_id, this does not automatically grant ownership.
Args:
console (BecConsole): The instance to register.
"""
self._connect_app_cleanup()
self._consoles[console.console_id] = console
console_id, terminal_id = console.console_id, console.terminal_id
term_info = self._terminal_registry.get(terminal_id)
if term_info is None:
self._terminal_registry[terminal_id] = self._new_terminal_info(console)
return
term_info.persist_session = term_info.persist_session or console.persist_terminal_session
had_registered_consoles = bool(term_info.registered_console_ids)
term_info.registered_console_ids.add(console_id)
if not self._is_valid_qobject(term_info.instance):
self._replace_terminal(term_info, console)
return
if (
term_info.owner_console_id is not None
and term_info.owner_console_id not in self._consoles
):
term_info.owner_console_id = None
if term_info.owner_console_id is None and not had_registered_consoles:
term_info.owner_console_id = console_id
logger.info(f"Registered new console {console_id} for terminal {terminal_id}")
def unregister(self, console: BecConsole):
"""
Unregister an instance of BecConsole.
Args:
console (BecConsole): The instance to unregister.
"""
console_id, terminal_id = console.console_id, console.terminal_id
if console_id in self._consoles:
del self._consoles[console_id]
if (term_info := self._terminal_registry.get(terminal_id)) is None:
return
detached = console._detach_terminal_widget(term_info.instance)
if console_id in term_info.registered_console_ids:
term_info.registered_console_ids.remove(console_id)
if term_info.owner_console_id == console_id:
term_info.owner_console_id = None
if not term_info.registered_console_ids:
if term_info.persist_session and self._is_valid_qobject(term_info.instance):
self._park_terminal(term_info, console, avoid_console=True)
logger.info(f"Unregistered console {console_id} for terminal {terminal_id}")
return
self._delete_terminal_info(term_info)
del self._terminal_registry[terminal_id]
elif detached:
self._park_terminal(term_info, console, avoid_console=True)
logger.info(f"Unregistered console {console_id} for terminal {terminal_id}")
def is_owner(self, console: BecConsole):
"""Returns true if the given console is the owner of its terminal"""
if console not in self._consoles.values():
return False
if (info := self._terminal_registry.get(console.terminal_id)) is None:
logger.warning(f"Console {console.console_id} references an unknown terminal!")
return False
if not self._is_valid_qobject(info.instance):
return False
return info.owner_console_id == console.console_id
def take_ownership(self, console: BecConsole) -> BecTerminal | None:
"""
Transfer ownership of a terminal to the given console.
Args:
console: the console which wishes to take ownership of its associated terminal.
Returns:
BecTerminal | None: The instance if ownership transfer was successful, None otherwise.
"""
console_id, terminal_id = console.console_id, console.terminal_id
if terminal_id not in self._terminal_registry:
self.register(console)
instance_info = self._terminal_registry[terminal_id]
if not self._is_valid_qobject(instance_info.instance):
self._replace_terminal(instance_info, console)
if (old_owner_console_ide := instance_info.owner_console_id) is not None:
if (
old_owner_console_ide != console_id
and (old_owner := self._consoles.get(old_owner_console_ide)) is not None
):
old_owner.yield_ownership() # call this on the old owner to make sure it is updated
instance_info.owner_console_id = console_id
instance_info.registered_console_ids.add(console_id)
logger.info(f"Transferred ownership of terminal {terminal_id} to {console_id}")
return instance_info.instance
def try_get_term(self, console: BecConsole) -> BecTerminal | None:
"""
Return the terminal instance if the requesting console is the owner
Args:
console: the requesting console.
Returns:
BecTerminal | None: The instance if the console is the owner, None otherwise.
"""
console_id, terminal_id = console.console_id, console.terminal_id
logger.debug(f"checking term for {console_id}")
if terminal_id not in self._terminal_registry:
logger.warning(f"Terminal {terminal_id} not found in registry")
return None
instance_info = self._terminal_registry[terminal_id]
if not self._is_valid_qobject(instance_info.instance):
if instance_info.owner_console_id == console_id:
self._replace_terminal(instance_info, console)
else:
return None
if instance_info.owner_console_id == console_id:
return instance_info.instance
def yield_ownership(self, console: BecConsole):
"""
Yield ownership of an instance without destroying it. The instance remains in the
registry with no owner, available for another widget to claim.
Args:
console (BecConsole): The console which wishes to yield ownership of its associated terminal.
"""
console_id, terminal_id = console.console_id, console.terminal_id
logger.debug(f"Console {console_id} attempted to yield ownership")
if console_id not in self._consoles or terminal_id not in self._terminal_registry:
return
term_info = self._terminal_registry[terminal_id]
if term_info.owner_console_id != console_id:
logger.debug(f"But it was not the owner, which was {term_info.owner_console_id}!")
return
term_info.owner_console_id = None
console._detach_terminal_widget(term_info.instance)
self._park_terminal(term_info, console)
def should_initialize(self, console: BecConsole) -> bool:
"""Return true if the console should send its startup command to the terminal."""
info = self._terminal_registry.get(console.terminal_id)
if info is None:
return False
return (
info.owner_console_id == console.console_id
and not info.initialized
and self._is_valid_qobject(info.instance)
)
def mark_initialized(self, console: BecConsole) -> None:
info = self._terminal_registry.get(console.terminal_id)
if info is not None and info.owner_console_id == console.console_id:
info.initialized = True
def owner_is_visible(self, term_id: str) -> bool:
"""
Check if the owner of an instance is currently visible.
Args:
term_id (str): The terminal ID to check.
Returns:
bool: True if the owner is visible, False otherwise.
"""
instance_info = self._terminal_registry.get(term_id)
if (
instance_info is None
or instance_info.owner_console_id is None
or not self._is_valid_qobject(instance_info.instance)
):
return False
if (owner := self._consoles.get(instance_info.owner_console_id)) is None:
return False
return owner.isVisible()
_bec_console_registry = BecConsoleRegistry()
class _Overlay(QWidget):
def __init__(self, console: BecConsole):
super().__init__(parent=console)
self._console = console
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self._console.take_terminal_ownership()
event.accept()
return
return super().mousePressEvent(event)
class BecConsole(BECWidget, QWidget):
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
_js_callback = Signal(bool)
initialized = Signal()
PLUGIN = True
ICON_NAME = "terminal"
persist_terminal_session = False
def __init__(
self,
parent=None,
config=None,
client=None,
gui_id=None,
startup_cmd: str | None = None,
terminal_id: str | None = None,
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._mode = ConsoleMode.INACTIVE
self._startup_cmd = startup_cmd
self._is_initialized = False
self.terminal_id = terminal_id or str(uuid4())
self.console_id = self.gui_id
self.term: BecTerminal | None = 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.
"""
self._stacked_layout = QStackedLayout()
# self._stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
self._term_holder = QWidget()
self._term_layout = QVBoxLayout()
self._term_layout.setContentsMargins(0, 0, 0, 0)
self._term_holder.setLayout(self._term_layout)
self.setLayout(self._stacked_layout)
# prepare overlay
self._overlay = _Overlay(self)
layout = QVBoxLayout(self._overlay)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
label = QLabel("Click to activate terminal", self._overlay)
layout.addWidget(label)
self._stacked_layout.addWidget(self._term_holder)
self._stacked_layout.addWidget(self._overlay)
# will create a new terminal instance if there isn't already one for this ID
_bec_console_registry.register(self)
self._infer_mode()
self._ensure_startup_started()
def _infer_mode(self):
self.term = _bec_console_registry.try_get_term(self)
if self.term:
self._set_mode(ConsoleMode.ACTIVE)
elif self.isHidden():
self._set_mode(ConsoleMode.HIDDEN)
else:
self._set_mode(ConsoleMode.INACTIVE)
def _set_mode(self, mode: ConsoleMode):
"""
Set the mode of the web console.
Args:
mode (ConsoleMode): The mode to set.
"""
match mode:
case ConsoleMode.ACTIVE:
if self.term:
if self._term_layout.indexOf(self.term) == -1: # type: ignore[arg-type]
self._term_layout.addWidget(self.term) # type: ignore # BecTerminal is QWidget
self.term.show() # type: ignore[attr-defined]
self._stacked_layout.setCurrentIndex(0)
self._mode = mode
else:
self._stacked_layout.setCurrentIndex(1)
self._mode = ConsoleMode.INACTIVE
case ConsoleMode.INACTIVE:
self._stacked_layout.setCurrentIndex(1)
self._mode = mode
case ConsoleMode.HIDDEN:
self._stacked_layout.setCurrentIndex(1)
self._mode = mode
@property
def startup_cmd(self):
"""
Get the startup command for the web console.
"""
return self._startup_cmd
@startup_cmd.setter
def startup_cmd(self, cmd: str | None):
"""
Set the startup command for the console.
"""
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.
"""
if self.term:
self.term.write(data, send_return)
def _ensure_startup_started(self):
if not self.startup_cmd or not _bec_console_registry.should_initialize(self):
return
self.write(self.startup_cmd, True)
_bec_console_registry.mark_initialized(self)
def _detach_terminal_widget(self, term: BecTerminal | None) -> bool:
if term is None or not BecConsoleRegistry._is_valid_qobject(term):
if self.term is term:
self.term = None
return False
is_child = self.isAncestorOf(term) # type: ignore[arg-type]
if self._term_layout.indexOf(term) != -1: # type: ignore[arg-type]
self._term_layout.removeWidget(term) # type: ignore[arg-type]
is_child = True
if is_child:
term.hide() # type: ignore[attr-defined]
term.setParent(None) # type: ignore[attr-defined]
if self.term is term:
self.term = None
return is_child
def take_terminal_ownership(self):
"""
Take ownership of a web instance from the registry. This will transfer the instance
from its current owner (if any) to this widget.
"""
# Get the instance from registry
self.term = _bec_console_registry.take_ownership(self)
self._infer_mode()
self._ensure_startup_started()
if self._mode == ConsoleMode.ACTIVE:
logger.debug(f"Widget {self.gui_id} took ownership of instance {self.terminal_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.
"""
_bec_console_registry.yield_ownership(self)
self._infer_mode()
if self._mode != ConsoleMode.ACTIVE:
logger.debug(f"Widget {self.gui_id} yielded ownership of instance {self.terminal_id}")
def hideEvent(self, event):
"""Called when the widget is hidden. Automatically yields ownership."""
self.yield_ownership()
super().hideEvent(event)
def showEvent(self, event):
"""Called when the widget is shown. Updates UI state based on ownership."""
super().showEvent(event)
if not _bec_console_registry.is_owner(self):
if not _bec_console_registry.owner_is_visible(self.terminal_id):
self.take_terminal_ownership()
def cleanup(self):
"""Unregister this console on destruction."""
_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"
persist_terminal_session = True
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,
terminal_id="bec_shell",
**kwargs,
)
@property
def startup_cmd(self):
"""
Get the startup command for the 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}"
@startup_cmd.setter
def startup_cmd(self, cmd: str | None): ...
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
widget = QTabWidget()
# Create two consoles with different unique_ids
bec_console_1a = BecConsole(startup_cmd="htop", gui_id="console_1_a", terminal_id="terminal_1")
bec_console_1b = BecConsole(startup_cmd="htop", gui_id="console_1_b", terminal_id="terminal_1")
bec_console_1 = QWidget()
bec_console_1_layout = QHBoxLayout(bec_console_1)
bec_console_1_layout.addWidget(bec_console_1a)
bec_console_1_layout.addWidget(bec_console_1b)
bec_console2 = BECShell()
bec_console3 = BecConsole(gui_id="console_3", terminal_id="terminal_1")
widget.addTab(bec_console_1, "Console 1")
widget.addTab(bec_console2, "Console 2 - BEC Shell")
widget.addTab(bec_console3, "Console 3 -- mirror of Console 1")
widget.show()
widget.resize(800, 600)
sys.exit(app.exec_())
@@ -1 +0,0 @@
{'files': ['bec_console.py']}
@@ -1 +0,0 @@
{'files': ['bec_console.py']}
@@ -0,0 +1 @@
{'files': ['web_console.py']}
@@ -5,7 +5,7 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.bec_console.bec_console import BECShell from bec_widgets.widgets.editors.web_console.web_console import BECShell
DOM_XML = """ DOM_XML = """
<ui language='c++'> <ui language='c++'>
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.editors.bec_console.bec_shell_plugin import BECShellPlugin from bec_widgets.widgets.editors.web_console.bec_shell_plugin import BECShellPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECShellPlugin()) QPyDesignerCustomWidgetCollection.addCustomWidget(BECShellPlugin())
@@ -6,9 +6,9 @@ def main(): # pragma: no cover
return return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.editors.bec_console.bec_console_plugin import BecConsolePlugin from bec_widgets.widgets.editors.web_console.web_console_plugin import WebConsolePlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BecConsolePlugin()) QPyDesignerCustomWidgetCollection.addCustomWidget(WebConsolePlugin())
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
@@ -0,0 +1,705 @@
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_())
@@ -0,0 +1 @@
{'files': ['web_console.py']}
@@ -5,17 +5,17 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole from bec_widgets.widgets.editors.web_console.web_console import WebConsole
DOM_XML = """ DOM_XML = """
<ui language='c++'> <ui language='c++'>
<widget class='BecConsole' name='bec_console'> <widget class='WebConsole' name='web_console'>
</widget> </widget>
</ui> </ui>
""" """
class BecConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._form_editor = None self._form_editor = None
@@ -23,20 +23,20 @@ class BecConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def createWidget(self, parent): def createWidget(self, parent):
if parent is None: if parent is None:
return QWidget() return QWidget()
t = BecConsole(parent) t = WebConsole(parent)
return t return t
def domXml(self): def domXml(self):
return DOM_XML return DOM_XML
def group(self): def group(self):
return "" return "BEC Developer"
def icon(self): def icon(self):
return designer_material_icon(BecConsole.ICON_NAME) return designer_material_icon(WebConsole.ICON_NAME)
def includeFile(self): def includeFile(self):
return "bec_console" return "web_console"
def initialize(self, form_editor): def initialize(self, form_editor):
self._form_editor = form_editor self._form_editor = form_editor
@@ -48,10 +48,10 @@ class BecConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return self._form_editor is not None return self._form_editor is not None
def name(self): def name(self):
return "BecConsole" return "WebConsole"
def toolTip(self): def toolTip(self):
return "A console widget with access to a shared registry of terminals, such that instances can be moved around." return ""
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
+2 -2
View File
@@ -19,8 +19,8 @@ from scipy.interpolate import (
from scipy.spatial import cKDTree from scipy.spatial import cKDTree
from toolz import partition from toolz import partition
from bec_widgets.utils import Colors
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.actions import MaterialIconAction
@@ -611,7 +611,7 @@ class Heatmap(ImageBase):
scan_msg = self.scan_item.status_message scan_msg = self.scan_item.status_message
elif hasattr(self.scan_item, "metadata"): elif hasattr(self.scan_item, "metadata"):
metadata = self.scan_item.metadata["bec"] metadata = self.scan_item.metadata["bec"]
status = metadata["status"] status = metadata["exit_status"]
scan_id = metadata["scan_id"] scan_id = metadata["scan_id"]
scan_name = metadata["scan_name"] scan_name = metadata["scan_name"]
scan_type = metadata["scan_type"] scan_type = metadata["scan_type"]
@@ -4,9 +4,9 @@ import os
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.utils.ui_loader import UILoader
class HeatmapSettings(SettingWidget): class HeatmapSettings(SettingWidget):
+89 -96
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QTimer from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.colors import Colors, apply_theme from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.plots.image.image_base import ImageBase from bec_widgets.widgets.plots.image.image_base import ImageBase
@@ -270,16 +270,6 @@ class Image(ImageBase):
return return
old_device = self._config.device 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 self._config.device = value
# If we have a signal, reconnect with the new device # If we have a signal, reconnect with the new device
@@ -335,16 +325,6 @@ class Image(ImageBase):
self._set_connection_status("disconnected") self._set_connection_status("disconnected")
return return
old_signal = self._config.signal
old_config = self.subscriptions["main"]
if self._config.device and old_signal and old_signal != value:
self._disconnect_monitor_subscription(
device=self._config.device,
signal=old_signal,
source=old_config.source,
async_update=self.async_update,
async_signal_name=old_config.async_signal_name,
)
self._config.signal = value self._config.signal = value
# If we have a device, try to connect # If we have a device, try to connect
@@ -467,61 +447,6 @@ class Image(ImageBase):
) )
self._autorange_on_next_update = True 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): def _disconnect_current_monitor(self):
""" """
Internal method to disconnect the current monitor subscriptions. Internal method to disconnect the current monitor subscriptions.
@@ -530,13 +455,55 @@ class Image(ImageBase):
return return
config = self.subscriptions["main"] config = self.subscriptions["main"]
self._disconnect_monitor_subscription(
device=self._config.device, if self.async_update:
signal=self._config.signal, async_signal_name = config.async_signal_name or self._config.signal
source=config.source, ids_to_check = [self.scan_id, self.old_scan_id]
async_update=self.async_update,
async_signal_name=config.async_signal_name, 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}"
)
# Reset async state # Reset async state
self.async_update = False self.async_update = False
@@ -893,19 +860,45 @@ class Image(ImageBase):
logger.warning("Cannot disconnect monitor without both device and signal") logger.warning("Cannot disconnect monitor without both device and signal")
return return
if config.source not in {"device_monitor_1d", "device_monitor_2d"}: if self.async_update:
logger.warning( async_signal_name = config.async_signal_name or target_entry
f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}" ids_to_check = [self.scan_id, self.old_scan_id]
) if config.source == "device_monitor_1d":
return for scan_id in ids_to_check:
if scan_id is None:
self._disconnect_monitor_subscription( continue
device=target_device, self.bec_dispatcher.disconnect_slot(
signal=target_entry, self.on_image_update_1d,
source=config.source, MessageEndpoints.device_async_signal(
async_update=self.async_update, scan_id, target_device, async_signal_name
async_signal_name=config.async_signal_name, ),
) )
elif config.source == "device_monitor_2d":
for scan_id in ids_to_check:
if scan_id is None:
continue
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d,
MessageEndpoints.device_async_signal(
scan_id, target_device, async_signal_name
),
)
else:
if config.source == "device_monitor_1d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d,
MessageEndpoints.device_preview(target_device, target_entry),
)
elif config.source == "device_monitor_2d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d,
MessageEndpoints.device_preview(target_device, target_entry),
)
else:
logger.warning(
f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}"
)
return
self.subscriptions["main"].async_signal_name = None self.subscriptions["main"].async_signal_name = None
self.async_update = False self.async_update = False
@@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError
from qtpy.QtCore import QPointF, Signal, SignalInstance from qtpy.QtCore import QPointF, Signal, SignalInstance
from qtpy.QtWidgets import QDialog, QVBoxLayout from qtpy.QtWidgets import QDialog, QVBoxLayout
from bec_widgets.utils.colors import Colors from bec_widgets.utils import Colors
from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.side_panel import SidePanel from bec_widgets.utils.side_panel import SidePanel
@@ -9,8 +9,7 @@ from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Signal from qtpy.QtCore import Signal
from qtpy.QtGui import QTransform from qtpy.QtGui import QTransform
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.widgets.plots.image.image_processor import ( from bec_widgets.widgets.plots.image.image_processor import (
ImageProcessor, ImageProcessor,
ImageStats, ImageStats,
@@ -20,8 +20,7 @@ from qtpy.QtWidgets import (
) )
from bec_widgets import BECWidget from bec_widgets import BECWidget
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils import BECDispatcher, ConnectionConfig
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.toolbars.actions import WidgetAction from bec_widgets.utils.toolbars.actions import WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
@@ -10,8 +10,8 @@ from qtpy.QtCore import Signal
from qtpy.QtGui import QColor from qtpy.QtGui import QColor
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.colors import Colors, apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
@@ -2,9 +2,9 @@ import os
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
@@ -10,8 +10,7 @@ from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Signal from qtpy.QtCore import Signal
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.side_panel import SidePanel from bec_widgets.utils.side_panel import SidePanel
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
@@ -2,9 +2,9 @@ import os
from qtpy.QtWidgets import QVBoxLayout, QWidget from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
+1 -3
View File
@@ -8,10 +8,8 @@ from bec_lib import bec_logger
from qtpy.QtCore import QPoint, QPointF, Qt, Signal from qtpy.QtCore import QPoint, QPointF, Qt, Signal
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils import ConnectionConfig, Crosshair, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.crosshair import Crosshair
from bec_widgets.utils.entry_validator import EntryValidator
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.fps_counter import FPSCounter from bec_widgets.utils.fps_counter import FPSCounter
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
+1 -1
View File
@@ -10,7 +10,7 @@ from qtpy import QtCore
from qtpy.QtCore import QObject, Signal from qtpy.QtCore import QObject, Signal
from bec_widgets import SafeProperty from bec_widgets import SafeProperty
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig from bec_widgets.utils import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import Colors from bec_widgets.utils.colors import Colors
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -8,8 +8,7 @@ from bec_lib import bec_logger
from pydantic import BaseModel, Field, ValidationError, field_validator from pydantic import BaseModel, Field, ValidationError, field_validator
from qtpy import QtCore from qtpy import QtCore
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
from bec_widgets.utils.colors import Colors
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
@@ -7,8 +7,7 @@ from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import QTimer, Signal from qtpy.QtCore import QTimer, Signal
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
@@ -2,9 +2,9 @@ import os
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.utils.ui_loader import UILoader
class ScatterCurveSettings(SettingWidget): class ScatterCurveSettings(SettingWidget):
@@ -2,9 +2,9 @@ import os
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
+1 -2
View File
@@ -8,8 +8,7 @@ from bec_lib import bec_logger
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from qtpy import QtCore from qtpy import QtCore
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
from bec_widgets.utils.colors import Colors
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.plots.waveform.waveform import Waveform from bec_widgets.widgets.plots.waveform.waveform import Waveform
@@ -50,10 +50,9 @@ from qtpy.QtWidgets import (
) )
from bec_widgets import SafeSlot from bec_widgets import SafeSlot
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils import ConnectionConfig, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors from bec_widgets.utils.colors import Colors
from bec_widgets.utils.entry_validator import EntryValidator
from bec_widgets.utils.toolbars.actions import WidgetAction from bec_widgets.utils.toolbars.actions import WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
@@ -1,345 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
import pyqtgraph as pg
from qtpy.QtCore import QObject, Qt, Signal
from qtpy.QtGui import QColor
from bec_widgets.utils.colors import get_accent_colors, get_theme_name
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
@dataclass(slots=True)
class AlignmentContext:
"""Alignment state produced by `Waveform` and consumed by the controller.
Attributes:
visible: Whether alignment mode is currently visible.
positioner_name: Name of the resolved x-axis positioner, if available.
precision: Decimal precision to use for readback and target labels.
limits: Optional positioner limits for the draggable target line.
readback: Current cached positioner readback value.
has_dap_curves: Whether the waveform currently contains any DAP curves.
force_readback: Whether the embedded positioner should refresh its readback immediately.
"""
visible: bool
positioner_name: str | None
precision: int = 3
limits: tuple[float, float] | None = None
readback: float | None = None
has_dap_curves: bool = False
force_readback: bool = False
class WaveformAlignmentController(QObject):
"""Own the alignment plot overlays and synchronize them with the alignment panel."""
move_absolute_requested = Signal(float)
autoscale_requested = Signal()
def __init__(self, plot_item: pg.PlotItem, panel: WaveformAlignmentPanel, parent=None):
super().__init__(parent=parent)
self._plot_item = plot_item
self._panel = panel
self._visible = False
self._positioner_name: str | None = None
self._precision = 3
self._limits: tuple[float, float] | None = None
self._readback: float | None = None
self._marker_line: pg.InfiniteLine | None = None
self._target_line: pg.InfiniteLine | None = None
self._panel.position_readback_changed.connect(self.update_position)
self._panel.target_toggled.connect(self._on_target_toggled)
self._panel.target_move_requested.connect(self._on_target_move_requested)
self._panel.fit_selection_changed.connect(self._on_fit_selection_changed)
self._panel.fit_center_requested.connect(self._on_fit_center_requested)
@property
def marker_line(self) -> pg.InfiniteLine | None:
"""Return the current-position indicator line, if it exists."""
return self._marker_line
@property
def target_line(self) -> pg.InfiniteLine | None:
"""Return the draggable target indicator line, if it exists."""
return self._target_line
def update_context(self, context: AlignmentContext):
"""Apply waveform-owned alignment context to the panel and plot overlays.
Args:
context: Snapshot of the current alignment-relevant waveform/device state.
"""
previous_name = self._positioner_name
self._visible = context.visible
self._positioner_name = context.positioner_name
self._precision = context.precision
self._limits = context.limits
self._readback = context.readback
self._panel.set_positioner_device(context.positioner_name)
self._panel.set_positioner_enabled(context.visible and context.positioner_name is not None)
self._panel.set_status_message(self._status_message_for_context(context))
if context.positioner_name is None or not context.visible:
self.clear()
self._refresh_fit_actions()
self._refresh_target_controls()
return
if previous_name != context.positioner_name:
self._clear_marker()
if self._panel.target_active:
self._clear_target_line()
if context.readback is not None:
self.update_position(context.readback)
if self._panel.target_active:
if previous_name != context.positioner_name or self._target_line is None:
self._show_target_line()
else:
self._refresh_target_line_metadata()
self._on_target_line_changed()
if context.force_readback or previous_name != context.positioner_name:
self._panel.force_positioner_readback()
self._refresh_fit_actions()
self._refresh_target_controls()
@SafeSlot(float)
def update_position(self, position: float):
"""Update the live position marker from a positioner readback value.
Args:
position: Current absolute position of the active alignment positioner.
"""
self._readback = float(position)
if not self._visible or self._positioner_name is None:
self._clear_marker()
return
self._ensure_marker()
self._marker_line.setValue(self._readback)
self._marker_line.label.setText(
f"{self._positioner_name}: {self._readback:.{self._precision}f}"
)
self.autoscale_requested.emit()
@SafeSlot(dict, dict)
def update_dap_summary(self, data: dict, metadata: dict):
"""Forward DAP summary updates into the alignment fit panel.
Args:
data: DAP fit summary payload.
metadata: Metadata describing the emitting DAP curve.
"""
self._panel.update_dap_summary(data, metadata)
self._refresh_fit_actions()
@SafeSlot(str)
def remove_dap_curve(self, curve_id: str):
"""Remove a deleted DAP curve from the alignment fit selection state.
Args:
curve_id: Label of the DAP curve that was removed from the waveform.
"""
self._panel.remove_dap_curve(curve_id)
self._panel.clear_fit_selection_if_missing()
self._refresh_fit_actions()
def clear(self):
"""Remove alignment overlay items from the plot and reset target state."""
self._clear_marker()
self._clear_target_line()
def cleanup(self):
"""Disconnect panel signals and remove all controller-owned overlay items."""
self.clear()
self._disconnect_panel_signals()
def refresh_theme_colors(self):
"""Reapply theme-aware styling to any existing alignment overlay items."""
self._apply_marker_style()
self._apply_target_style()
def _disconnect_panel_signals(self):
signal_pairs = [
(self._panel.position_readback_changed, self.update_position),
(self._panel.target_toggled, self._on_target_toggled),
(self._panel.target_move_requested, self._on_target_move_requested),
(self._panel.fit_selection_changed, self._on_fit_selection_changed),
(self._panel.fit_center_requested, self._on_fit_center_requested),
]
for signal, slot in signal_pairs:
try:
signal.disconnect(slot)
except (RuntimeError, TypeError):
continue
def _selected_fit_has_center(self) -> bool:
data = self._panel.selected_fit_summary()
params = data.get("params", []) if isinstance(data, dict) else []
return any(param[0] == "center" for param in params if param)
@staticmethod
def _status_message_for_context(context: AlignmentContext) -> str | None:
if context.positioner_name is None:
return "Alignment mode requires a positioner on the x axis."
if not context.has_dap_curves:
return "Add a DAP curve in Curve Settings to enable alignment fitting."
return None
def _refresh_fit_actions(self):
self._panel.set_fit_actions_enabled(
self._visible and self._positioner_name is not None and self._selected_fit_has_center()
)
def _refresh_target_controls(self):
has_positioner = self._visible and self._positioner_name is not None
self._panel.set_target_enabled(has_positioner)
self._panel.set_target_move_enabled(has_positioner and self._target_line is not None)
if self._target_line is None:
self._panel.set_target_value(None)
def _ensure_marker(self):
if self._marker_line is not None:
return
warning = get_accent_colors().warning
self._marker_line = pg.InfiniteLine(
angle=90,
movable=False,
pen=pg.mkPen(warning, width=4),
label="",
labelOpts={"position": 0.95, "color": warning},
)
self._apply_marker_style()
self._plot_item.addItem(self._marker_line)
def _clear_marker(self):
if self._marker_line is None:
return
self._plot_item.removeItem(self._marker_line)
self._marker_line = None
def _show_target_line(self):
if not self._visible or self._positioner_name is None:
return
if self._target_line is None:
accent_colors = get_accent_colors()
label = f"{self._positioner_name} target={{value:0.{self._precision}f}}"
self._target_line = pg.InfiniteLine(
movable=True,
angle=90,
pen=pg.mkPen(accent_colors.default, width=2, style=Qt.PenStyle.DashLine),
hoverPen=pg.mkPen(accent_colors.success, width=2),
label=label,
labelOpts={"movable": True, "color": accent_colors.default},
)
self._target_line.sigPositionChanged.connect(self._on_target_line_changed)
self._apply_target_style()
self._plot_item.addItem(self._target_line)
self._refresh_target_line_metadata()
value = 0.0 if self._readback is None else self._readback
if self._limits is not None:
value = min(max(value, self._limits[0]), self._limits[1])
self._target_line.setValue(value)
self._on_target_line_changed()
self.autoscale_requested.emit()
def _refresh_target_line_metadata(self):
if self._target_line is None or self._positioner_name is None:
return
self._apply_target_style()
self._target_line.label.setFormat(
f"{self._positioner_name} target={{value:0.{self._precision}f}}"
)
if self._limits is not None:
self._target_line.setBounds(list(self._limits))
else:
self._target_line.setBounds((None, None))
if self._limits is not None:
current_value = float(self._target_line.value())
clamped_value = min(max(current_value, self._limits[0]), self._limits[1])
if clamped_value != current_value:
self._target_line.setValue(clamped_value)
def _clear_target_line(self):
if self._target_line is not None:
try:
self._target_line.sigPositionChanged.disconnect(self._on_target_line_changed)
except (RuntimeError, TypeError):
pass
self._plot_item.removeItem(self._target_line)
self._target_line = None
self._panel.set_target_value(None)
def _apply_marker_style(self):
if self._marker_line is None:
return
accent_colors = get_accent_colors()
warning = accent_colors.warning
self._marker_line.setPen(pg.mkPen(warning, width=4))
self._marker_line.label.setColor(warning)
self._marker_line.label.fill = pg.mkBrush(self._label_fill_color())
def _apply_target_style(self):
if self._target_line is None:
return
accent_colors = get_accent_colors()
default = accent_colors.default
success = accent_colors.success
self._target_line.setPen(pg.mkPen(default, width=2, style=Qt.PenStyle.DashLine))
self._target_line.setHoverPen(pg.mkPen(success, width=2))
self._target_line.label.setColor(default)
self._target_line.label.fill = pg.mkBrush(self._label_fill_color())
@staticmethod
def _label_fill_color() -> QColor:
if get_theme_name() == "light":
return QColor(244, 244, 244, 228)
return QColor(48, 48, 48, 210)
@SafeSlot(bool)
def _on_target_toggled(self, checked: bool):
if checked:
self._show_target_line()
else:
self._clear_target_line()
self._refresh_target_controls()
@SafeSlot(object)
def _on_target_line_changed(self, _line=None):
if self._target_line is None:
return
self._panel.set_target_value(float(self._target_line.value()), precision=self._precision)
self._refresh_target_controls()
self.autoscale_requested.emit()
@SafeSlot()
def _on_target_move_requested(self):
if self._visible and self._positioner_name is not None and self._target_line is not None:
self.move_absolute_requested.emit(float(self._target_line.value()))
@SafeSlot(str)
def _on_fit_selection_changed(self, _curve_id: str):
self._refresh_fit_actions()
@SafeSlot(float)
def _on_fit_center_requested(self, value: float):
if self._visible and self._positioner_name is not None:
self.move_absolute_requested.emit(float(value))
@@ -1,285 +0,0 @@
from __future__ import annotations
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QCheckBox,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QWidget,
)
from bec_widgets.utils.colors import get_accent_colors, get_theme_name
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
PositionerControlLine,
)
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
class WaveformAlignmentPanel(QWidget):
"""Compact bottom panel used by Waveform alignment mode."""
position_readback_changed = Signal(float)
target_toggled = Signal(bool)
target_move_requested = Signal()
fit_selection_changed = Signal(str)
fit_center_requested = Signal(float)
def __init__(self, parent=None, client=None, gui_id: str | None = None, **kwargs):
super().__init__(parent=parent, **kwargs)
self.setProperty("skip_settings", True)
self.positioner = PositionerControlLine(parent=self, client=client, gui_id=gui_id)
self.positioner.hide_device_selection = True
self.fit_dialog = LMFitDialog(
parent=self, client=client, gui_id=gui_id, ui_file="lmfit_dialog_compact.ui"
)
self.fit_dialog.active_action_list = ["center"]
self.fit_dialog.enable_actions = False
self.target_toggle = QCheckBox("Target: --", parent=self)
self.move_to_target_button = QPushButton("Move To Target", parent=self)
self.move_to_target_button.setEnabled(False)
self.target_group = QGroupBox("Target Position", parent=self)
self.status_label = QLabel(parent=self)
self.status_label.setWordWrap(False)
self.status_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
self.status_label.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
self.status_label.setMaximumHeight(28)
self.status_label.setVisible(False)
self._init_ui()
self.fit_dialog.setMinimumHeight(0)
self.target_group.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self._sync_target_group_size()
self.refresh_theme_colors()
self._connect_signals()
def _connect_signals(self):
self.positioner.position_update.connect(self.position_readback_changed)
self.target_toggle.toggled.connect(self.target_toggled)
self.move_to_target_button.clicked.connect(self.target_move_requested)
self.fit_dialog.selected_fit.connect(self.fit_selection_changed)
self.fit_dialog.move_action.connect(self._forward_fit_move_action)
def _init_ui(self):
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
self.setMinimumHeight(260)
root = QGridLayout(self)
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(8)
self.fit_dialog.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
root.addWidget(
self.status_label,
0,
0,
1,
2,
alignment=Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter,
)
root.addWidget(self.fit_dialog, 1, 0, 1, 2)
target_layout = QHBoxLayout(self.target_group)
target_layout.addWidget(self.target_toggle)
target_layout.addWidget(self.move_to_target_button)
root.addWidget(self.positioner, 2, 0, alignment=Qt.AlignmentFlag.AlignTop)
root.addWidget(self.target_group, 2, 1, alignment=Qt.AlignmentFlag.AlignTop)
root.setColumnStretch(0, 1)
root.setColumnStretch(1, 0)
root.setRowStretch(1, 1)
def _sync_target_group_size(self):
representative_text = "Target: -99999.999"
label_width = max(
self.target_toggle.sizeHint().width(),
self.target_toggle.fontMetrics().horizontalAdvance(representative_text) + 24,
)
self.target_toggle.setMinimumWidth(label_width)
# To make those two box the same height
target_height = max(
self.positioner.height(),
self.positioner.ui.device_box.minimumSizeHint().height(),
self.positioner.ui.device_box.sizeHint().height(),
)
self.target_group.setFixedHeight(target_height)
self.target_group.setFixedWidth(self.target_group.sizeHint().width() + 16)
def showEvent(self, event):
super().showEvent(event)
self._sync_target_group_size()
def resizeEvent(self, event):
super().resizeEvent(event)
self._sync_target_group_size()
def set_status_message(self, text: str | None):
"""Show or hide the alignment status pill.
Args:
text: Message to display. Pass `None` or an empty string to hide the pill.
"""
text = text or ""
self.status_label.setText(text)
self.status_label.setVisible(bool(text))
@staticmethod
def _qcolor_to_rgba(color: QColor, alpha: int | None = None) -> str:
if alpha is not None:
color = QColor(color)
color.setAlpha(alpha)
return f"rgba({color.red()}, {color.green()}, {color.blue()}, {color.alpha()})"
def refresh_theme_colors(self):
"""Apply theme-aware accent styling to the status pill."""
warning = get_accent_colors().warning
is_light = get_theme_name() == "light"
text_color = "#202124" if is_light else warning.name()
fill_alpha = 72 if is_light else 48
border_alpha = 220 if is_light else 160
self.status_label.setStyleSheet(f"""
QLabel {{
background-color: {self._qcolor_to_rgba(warning, fill_alpha)};
border: 1px solid {self._qcolor_to_rgba(warning, border_alpha)};
border-radius: 12px;
padding: 4px 10px;
color: {text_color};
}}
""")
def set_positioner_device(self, device: str | None):
"""Bind the embedded positioner control to a fixed device.
Args:
device: Name of the positioner device to display, or `None` to clear it.
"""
if device is None:
self.positioner.ui.device_box.setTitle("No positioner selected")
return
if self.positioner.device != device:
self.positioner.set_positioner(device)
self.positioner.hide_device_selection = True
def set_positioner_enabled(self, enabled: bool):
"""Enable or disable the embedded positioner widget.
Args:
enabled: Whether the positioner widget should accept interaction.
"""
self.positioner.setEnabled(enabled)
def force_positioner_readback(self):
"""Trigger an immediate readback refresh on the embedded positioner widget."""
self.positioner.force_update_readback()
def set_target_enabled(self, enabled: bool):
"""Enable or disable the target-line toggle.
Args:
enabled: Whether the target toggle should accept interaction.
"""
self.target_toggle.setEnabled(enabled)
def set_target_move_enabled(self, enabled: bool):
"""Enable or disable the move-to-target button.
Args:
enabled: Whether the move button should accept interaction.
"""
self.move_to_target_button.setEnabled(enabled)
def set_target_active(self, active: bool):
"""Programmatically toggle the draggable target-line state.
Args:
active: Whether the target line should be considered active.
"""
blocker = self.target_toggle.blockSignals(True)
self.target_toggle.setChecked(active)
self.target_toggle.blockSignals(blocker)
if not active:
self.set_target_value(None)
def set_target_value(self, value: float | None, precision: int = 3) -> None:
"""
Update the target checkbox label for the draggable target line.
Args:
value(float | None): The target value to display. If None, the label will show "--".
precision(int): The number of decimal places to display for the target value.
"""
if value is None or not self.target_toggle.isChecked():
self.target_toggle.setText("Target: --")
return
self.target_toggle.setText(f"Target: {value:.{precision}f}")
def set_fit_actions_enabled(self, enabled: bool):
"""Enable or disable LMFit action buttons in the embedded fit dialog.
Args:
enabled: Whether fit action buttons should be enabled.
"""
self.fit_dialog.enable_actions = enabled
def update_dap_summary(self, data: dict, metadata: dict):
"""Forward a DAP summary update into the embedded fit dialog.
Args:
data: DAP fit summary payload.
metadata: Metadata describing the emitting DAP curve.
"""
self.fit_dialog.update_summary_tree(data, metadata)
def remove_dap_curve(self, curve_id: str):
"""Remove DAP summary state for a deleted fit curve.
Args:
curve_id: Label of the DAP curve that should be removed.
"""
self.fit_dialog.remove_dap_data(curve_id)
def clear_fit_selection_if_missing(self):
"""Select a remaining fit curve if the current selection no longer exists."""
fit_curve_id = self.fit_dialog.fit_curve_id
if fit_curve_id is not None and fit_curve_id not in self.fit_dialog.summary_data:
remaining = list(self.fit_dialog.summary_data)
self.fit_dialog.fit_curve_id = remaining[0] if remaining else None
@property
def target_active(self) -> bool:
"""Whether the target-line checkbox is currently checked."""
return self.target_toggle.isChecked()
@property
def selected_fit_curve_id(self) -> str | None:
"""Return the currently selected fit curve label, if any."""
return self.fit_dialog.fit_curve_id
def selected_fit_summary(self) -> dict | None:
"""Return the summary payload for the currently selected fit curve.
Returns:
The selected fit summary, or `None` if no fit curve is selected.
"""
fit_curve_id = self.selected_fit_curve_id
if fit_curve_id is None:
return None
return self.fit_dialog.summary_data.get(fit_curve_id)
def _forward_fit_move_action(self, action: tuple[str, float]):
param_name, param_value = action
if param_name == "center":
self.fit_center_requested.emit(float(param_value))
+6 -197
View File
@@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Literal
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from bec_lib import bec_logger, messages from bec_lib import bec_logger, messages
from bec_lib.device import Positioner
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object
from bec_lib.scan_data_container import ScanDataContainer from bec_lib.scan_data_container import ScanDataContainer
@@ -25,24 +24,17 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
from bec_widgets.utils.colors import Colors, apply_theme from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog 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.utils.toolbars.toolbar import MaterialIconAction
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog 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.plot_base import PlotBase
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal 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.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.plots.waveform.utils.roi_manager import WaveformROIManager
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import ( from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
ScanHistoryBrowser, ScanHistoryBrowser,
@@ -164,12 +156,6 @@ class Waveform(PlotBase):
"label_suffix": "", "label_suffix": "",
} }
self._current_x_device: tuple[str, str] | None = None 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 # Specific GUI elements
self._init_roi_manager() self._init_roi_manager()
@@ -179,7 +165,6 @@ class Waveform(PlotBase):
self._add_waveform_specific_popup() self._add_waveform_specific_popup()
self._enable_roi_toolbar_action(False) # default state where are no dap curves self._enable_roi_toolbar_action(False) # default state where are no dap curves
self._init_curve_dialog() self._init_curve_dialog()
self._init_alignment_mode()
self.curve_settings_dialog = None self.curve_settings_dialog = None
# Largedataset guard # Largedataset guard
@@ -210,9 +195,7 @@ class Waveform(PlotBase):
# To fix the ViewAll action with clipToView activated # To fix the ViewAll action with clipToView activated
self._connect_viewbox_menu_actions() self._connect_viewbox_menu_actions()
self.toolbar.show_bundles( self.toolbar.show_bundles(["plot_export", "mouse_interaction", "roi", "axis_popup"])
["plot_export", "mouse_interaction", "roi", "alignment_mode", "axis_popup"]
)
def _connect_viewbox_menu_actions(self): def _connect_viewbox_menu_actions(self):
"""Connect the viewbox menu action ViewAll to the custom reset_view method.""" """Connect the viewbox menu action ViewAll to the custom reset_view method."""
@@ -238,12 +221,6 @@ class Waveform(PlotBase):
theme(str, optional): The theme to be applied. theme(str, optional): The theme to be applied.
""" """
self._refresh_colors() 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) super().apply_theme(theme)
def add_side_menus(self): def add_side_menus(self):
@@ -253,159 +230,6 @@ class Waveform(PlotBase):
super().add_side_menus() super().add_side_menus()
self._add_dap_summary_side_menu() 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): def _add_waveform_specific_popup(self):
""" """
Add popups to the Waveform widget. Add popups to the Waveform widget.
@@ -442,7 +266,7 @@ class Waveform(PlotBase):
Due to setting clipToView to True on the curves, the autoRange() method 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 of the ViewBox does no longer work as expected. This method deactivates the
setClipToView for all curves, calls autoRange() to circumvent that issue. setClipToView for all curves, calls autoRange() to circumvent that issue.
Afterward, it re-enables the setClipToView for all curves again. Afterwards, 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. It is hooked to the ViewAll action in the right-click menu of the pg.PlotItem ViewBox.
""" """
@@ -720,7 +544,6 @@ class Waveform(PlotBase):
self.sync_signal_update.emit() self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True) self.plot_item.enableAutoRange(x=True)
self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme
self._refresh_alignment_state(force_readback=True)
@SafeProperty(str) @SafeProperty(str)
def signal_x(self) -> str | None: def signal_x(self) -> str | None:
@@ -750,7 +573,6 @@ class Waveform(PlotBase):
self.sync_signal_update.emit() self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True) self.plot_item.enableAutoRange(x=True)
self.round_plot_widget.apply_plot_widget_style() self.round_plot_widget.apply_plot_widget_style()
self._refresh_alignment_state(force_readback=True)
@SafeProperty(str) @SafeProperty(str)
def color_palette(self) -> str: def color_palette(self) -> str:
@@ -805,8 +627,6 @@ class Waveform(PlotBase):
continue continue
config = CurveConfig(**cfg_dict) config = CurveConfig(**cfg_dict)
self._add_curve(config=config) self._add_curve(config=config)
self._refresh_alignment_state(force_readback=self._alignment_panel_visible)
self._refresh_dap_signals()
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"Failed to decode JSON: {e}") logger.error(f"Failed to decode JSON: {e}")
@@ -1182,7 +1002,6 @@ class Waveform(PlotBase):
QTimer.singleShot( QTimer.singleShot(
150, self.auto_range 150, self.auto_range
) # autorange with a delay to ensure the plot is updated ) # autorange with a delay to ensure the plot is updated
self._refresh_alignment_state()
return curve return curve
@@ -1438,7 +1257,6 @@ class Waveform(PlotBase):
self.remove_curve(curve.name()) self.remove_curve(curve.name())
if self.crosshair is not None: if self.crosshair is not None:
self.crosshair.clear_markers() self.crosshair.clear_markers()
self._refresh_alignment_state()
def get_curve(self, curve: int | str) -> Curve | None: def get_curve(self, curve: int | str) -> Curve | None:
""" """
@@ -1474,7 +1292,6 @@ class Waveform(PlotBase):
self._refresh_colors() self._refresh_colors()
self._categorise_device_curves() self._categorise_device_curves()
self._refresh_alignment_state()
def _remove_curve_by_name(self, name: str): def _remove_curve_by_name(self, name: str):
""" """
@@ -1525,8 +1342,6 @@ class Waveform(PlotBase):
and self.enable_side_panel is True and self.enable_side_panel is True
): ):
self.dap_summary.remove_dap_data(curve.name()) 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 # find a corresponding dap curve and remove it
for c in self.curves: for c in self.curves:
@@ -1963,7 +1778,7 @@ class Waveform(PlotBase):
if parent_curve is None: if parent_curve is None:
logger.warning( logger.warning(
f"No device curve found for DAP curve '{dap_curve.name()}'!" f"No device curve found for DAP curve '{dap_curve.name()}'!"
) # TODO triggered when DAP curve is removed from the curve dialog, why? ) # TODO triggerd when DAP curve is removed from the curve dialog, why?
continue continue
x_data, y_data = parent_curve.get_data() x_data, y_data = parent_curve.get_data()
@@ -2168,7 +1983,6 @@ class Waveform(PlotBase):
""" """
x_data = None x_data = None
new_suffix = None new_suffix = None
previous_x_device = self._current_x_device
data, access_key = self._fetch_scan_data_and_access() data, access_key = self._fetch_scan_data_and_access()
# 1 User wants custom signal # 1 User wants custom signal
@@ -2227,7 +2041,6 @@ class Waveform(PlotBase):
if not scan_report_devices: if not scan_report_devices:
x_data = None x_data = None
new_suffix = " (auto: index)" new_suffix = " (auto: index)"
self._current_x_device = None
else: else:
device_x = scan_report_devices[0] device_x = scan_report_devices[0]
signal_x = self.entry_validator.validate_signal(device_x, None) signal_x = self.entry_validator.validate_signal(device_x, None)
@@ -2237,10 +2050,8 @@ class Waveform(PlotBase):
entry_obj = data.get(device_x, {}).get(signal_x) entry_obj = data.get(device_x, {}).get(signal_x)
x_data = entry_obj.read()["value"] if entry_obj else None x_data = entry_obj.read()["value"] if entry_obj else None
new_suffix = f" (auto: {device_x}-{signal_x})" 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) 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 return x_data
def _update_x_label_suffix(self, new_suffix: str): def _update_x_label_suffix(self, new_suffix: str):
@@ -2285,7 +2096,7 @@ class Waveform(PlotBase):
def _categorise_device_curves(self) -> str: def _categorise_device_curves(self) -> str:
""" """
Categorize the device curves into sync and async based on the readout priority. Categorise the device curves into sync and async based on the readout priority.
""" """
if self.scan_item is None: if self.scan_item is None:
self.update_with_scan_history(-1) self.update_with_scan_history(-1)
@@ -2642,8 +2453,6 @@ class Waveform(PlotBase):
Cleanup the widget by disconnecting signals and closing dialogs. Cleanup the widget by disconnecting signals and closing dialogs.
""" """
self.proxy_dap_request.cleanup() self.proxy_dap_request.cleanup()
if self._alignment_controller is not None:
self._alignment_controller.cleanup()
self.clear_all() self.clear_all()
if self.curve_settings_dialog is not None: if self.curve_settings_dialog is not None:
self.curve_settings_dialog.reject() self.curve_settings_dialog.reject()
@@ -9,8 +9,7 @@ from qtpy import QtCore, QtGui
from qtpy.QtGui import QColor from qtpy.QtGui import QColor
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from bec_widgets import BECWidget from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.colors import Colors from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
@@ -41,7 +40,7 @@ class ProgressbarConfig(ConnectionConfig):
line_width: int = Field(20, description="Line widths for the progress bars.") line_width: int = Field(20, description="Line widths for the progress bars.")
start_position: int = Field( start_position: int = Field(
90, 90,
description="Start position for the progress bars in degrees. Default is 90 degrees - corresponds to " description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to "
"the top of the ring.", "the top of the ring.",
) )
min_value: int | float = Field(0, description="Minimum value for the progress bars.") min_value: int | float = Field(0, description="Minimum value for the progress bars.")
@@ -60,7 +59,7 @@ class ProgressbarConfig(ConnectionConfig):
) )
class Ring(BECWidget, QWidget): class Ring(BECConnector, QWidget):
USER_ACCESS = [ USER_ACCESS = [
"set_value", "set_value",
"set_color", "set_color",
@@ -83,26 +82,8 @@ class Ring(BECWidget, QWidget):
self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None
self.RID = None self.RID = None
self._gap = 5 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) 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): def set_value(self, value: int | float):
""" """
Set the value for the ring widget Set the value for the ring widget
@@ -126,7 +107,7 @@ class Ring(BECWidget, QWidget):
if self.config.link_colors: if self.config.link_colors:
self._auto_set_background_color() self._auto_set_background_color()
self._request_update() self.update()
def set_background(self, color: str | tuple | QColor): def set_background(self, color: str | tuple | QColor):
""" """
@@ -141,7 +122,7 @@ class Ring(BECWidget, QWidget):
self._background_color = self.convert_color(color) self._background_color = self.convert_color(color)
self.config.background_color = self._background_color.name() self.config.background_color = self._background_color.name()
self._request_update() self.update()
def _auto_set_background_color(self): def _auto_set_background_color(self):
""" """
@@ -152,7 +133,7 @@ class Ring(BECWidget, QWidget):
bg_color = Colors.subtle_background_color(self._color, bg) bg_color = Colors.subtle_background_color(self._color, bg)
self.config.background_color = bg_color.name() self.config.background_color = bg_color.name()
self._background_color = bg_color self._background_color = bg_color
self._request_update() self.update()
def set_colors_linked(self, linked: bool): def set_colors_linked(self, linked: bool):
""" """
@@ -165,7 +146,7 @@ class Ring(BECWidget, QWidget):
self.config.link_colors = linked self.config.link_colors = linked
if linked: if linked:
self._auto_set_background_color() self._auto_set_background_color()
self._request_update() self.update()
def set_line_width(self, width: int): def set_line_width(self, width: int):
""" """
@@ -175,7 +156,7 @@ class Ring(BECWidget, QWidget):
width(int): Line width for the ring widget width(int): Line width for the ring widget
""" """
self.config.line_width = width self.config.line_width = width
self._request_update() self.update()
def set_min_max_values(self, min_value: int | float, max_value: int | float): def set_min_max_values(self, min_value: int | float, max_value: int | float):
""" """
@@ -187,7 +168,7 @@ class Ring(BECWidget, QWidget):
""" """
self.config.min_value = min_value self.config.min_value = min_value
self.config.max_value = max_value self.config.max_value = max_value
self._request_update() self.update()
def set_start_angle(self, start_angle: int): def set_start_angle(self, start_angle: int):
""" """
@@ -197,7 +178,7 @@ class Ring(BECWidget, QWidget):
start_angle(int): Start angle for the ring widget in degrees start_angle(int): Start angle for the ring widget in degrees
""" """
self.config.start_position = start_angle self.config.start_position = start_angle
self._request_update() self.update()
def set_update( def set_update(
self, mode: Literal["manual", "scan", "device"], device: str = "", signal: str = "" self, mode: Literal["manual", "scan", "device"], device: str = "", signal: str = ""
@@ -256,7 +237,7 @@ class Ring(BECWidget, QWidget):
precision(int): Precision for the ring widget precision(int): Precision for the ring widget
""" """
self.config.precision = precision self.config.precision = precision
self._request_update() self.update()
def set_direction(self, direction: int): def set_direction(self, direction: int):
""" """
@@ -266,7 +247,7 @@ class Ring(BECWidget, QWidget):
direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise. direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise.
""" """
self.config.direction = direction self.config.direction = direction
self._request_update() self.update()
def _get_signals_for_device(self, device: str) -> dict[str, list[str]]: def _get_signals_for_device(self, device: str) -> dict[str, list[str]]:
""" """
@@ -295,7 +276,7 @@ class Ring(BECWidget, QWidget):
for obj in dev_obj._info["signals"].values() for obj in dev_obj._info["signals"].values()
if obj["kind_str"] == "hinted" if obj["kind_str"] == "hinted"
and obj["signal_class"] and obj["signal_class"]
not in ["ProgressSignal", "AsyncSignal", "AsyncMultiSignal", "DynamicSignal"] not in ["ProgressSignal", "AyncSignal", "AsyncMultiSignal", "DynamicSignal"]
] ]
normal_signals = [ normal_signals = [
@@ -443,11 +424,8 @@ class Ring(BECWidget, QWidget):
rect.adjust(max_ring_size, max_ring_size, -max_ring_size, -max_ring_size) rect.adjust(max_ring_size, max_ring_size, -max_ring_size, -max_ring_size)
# Background arc # 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( painter.setPen(
QtGui.QPen(self._background_color, current_line_width, QtCore.Qt.PenStyle.SolidLine) QtGui.QPen(self._background_color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
) )
gap: int = self.gap # type: ignore gap: int = self.gap # type: ignore
@@ -455,25 +433,13 @@ class Ring(BECWidget, QWidget):
# Important: Qt uses a 16th of a degree for angles. start_position is therefore multiplied by 16. # 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 start_position: float = self.config.start_position * 16 # type: ignore
adjusted_rect = QtCore.QRectF( adjusted_rect = QtCore.QRect(
rect.left() + gap, rect.top() + gap, rect.width() - 2 * gap, rect.height() - 2 * gap 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) painter.drawArc(adjusted_rect, start_position, 360 * 16)
# Foreground arc # Foreground arc
pen = QtGui.QPen(self.color, current_line_width, QtCore.Qt.PenStyle.SolidLine) pen = QtGui.QPen(self.color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
painter.setPen(pen) painter.setPen(pen)
proportion = (self.config.value - self.config.min_value) / ( proportion = (self.config.value - self.config.min_value) / (
@@ -483,17 +449,7 @@ class Ring(BECWidget, QWidget):
painter.drawArc(adjusted_rect, start_position, angle) painter.drawArc(adjusted_rect, start_position, angle)
painter.end() painter.end()
def set_hovered(self, hovered: bool): def convert_color(self, color: str | tuple | QColor) -> QColor:
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 Convert the color to QColor
@@ -529,7 +485,7 @@ class Ring(BECWidget, QWidget):
@gap.setter @gap.setter
def gap(self, value: int): def gap(self, value: int):
self._gap = value self._gap = value
self._request_update() self.update()
@SafeProperty(bool) @SafeProperty(bool)
def link_colors(self) -> bool: def link_colors(self) -> bool:
@@ -566,7 +522,7 @@ class Ring(BECWidget, QWidget):
float(max(self.config.min_value, min(self.config.max_value, value))), float(max(self.config.min_value, min(self.config.max_value, value))),
self.config.precision, self.config.precision,
) )
self._request_update() self.update()
@SafeProperty(float) @SafeProperty(float)
def min_value(self) -> float: def min_value(self) -> float:
@@ -575,7 +531,7 @@ class Ring(BECWidget, QWidget):
@min_value.setter @min_value.setter
def min_value(self, value: float): def min_value(self, value: float):
self.config.min_value = value self.config.min_value = value
self._request_update() self.update()
@SafeProperty(float) @SafeProperty(float)
def max_value(self) -> float: def max_value(self) -> float:
@@ -584,7 +540,7 @@ class Ring(BECWidget, QWidget):
@max_value.setter @max_value.setter
def max_value(self, value: float): def max_value(self, value: float):
self.config.max_value = value self.config.max_value = value
self._request_update() self.update()
@SafeProperty(str) @SafeProperty(str)
def mode(self) -> str: def mode(self) -> str:
@@ -593,7 +549,6 @@ class Ring(BECWidget, QWidget):
@mode.setter @mode.setter
def mode(self, value: str): def mode(self, value: str):
self.set_update(value) self.set_update(value)
self._request_update()
@SafeProperty(str) @SafeProperty(str)
def device(self) -> str: def device(self) -> str:
@@ -602,7 +557,6 @@ class Ring(BECWidget, QWidget):
@device.setter @device.setter
def device(self, value: str): def device(self, value: str):
self.config.device = value self.config.device = value
self._request_update()
@SafeProperty(str) @SafeProperty(str)
def signal(self) -> str: def signal(self) -> str:
@@ -611,7 +565,6 @@ class Ring(BECWidget, QWidget):
@signal.setter @signal.setter
def signal(self, value: str): def signal(self, value: str):
self.config.signal = value self.config.signal = value
self._request_update()
@SafeProperty(int) @SafeProperty(int)
def line_width(self) -> int: def line_width(self) -> int:
@@ -620,7 +573,7 @@ class Ring(BECWidget, QWidget):
@line_width.setter @line_width.setter
def line_width(self, value: int): def line_width(self, value: int):
self.config.line_width = value self.config.line_width = value
self._request_update() self.update()
@SafeProperty(int) @SafeProperty(int)
def start_position(self) -> int: def start_position(self) -> int:
@@ -629,7 +582,7 @@ class Ring(BECWidget, QWidget):
@start_position.setter @start_position.setter
def start_position(self, value: int): def start_position(self, value: int):
self.config.start_position = value self.config.start_position = value
self._request_update() self.update()
@SafeProperty(int) @SafeProperty(int)
def precision(self) -> int: def precision(self) -> int:
@@ -638,7 +591,7 @@ class Ring(BECWidget, QWidget):
@precision.setter @precision.setter
def precision(self, value: int): def precision(self, value: int):
self.config.precision = value self.config.precision = value
self._request_update() self.update()
@SafeProperty(int) @SafeProperty(int)
def direction(self) -> int: def direction(self) -> int:
@@ -647,27 +600,7 @@ class Ring(BECWidget, QWidget):
@direction.setter @direction.setter
def direction(self, value: int): def direction(self, value: int):
self.config.direction = value self.config.direction = value
self._request_update() self.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 if __name__ == "__main__": # pragma: no cover
@@ -3,16 +3,15 @@ from typing import Literal
import pyqtgraph as pg import pyqtgraph as pg
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtCore import QPointF, QSize, Qt from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils import Colors
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.toolbar import ModularToolBar 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 import Ring
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings
@@ -30,16 +29,7 @@ class RingProgressContainerWidget(QWidget):
self.rings: list[Ring] = [] self.rings: list[Ring] = []
self.gap = 20 # Gap between rings self.gap = 20 # Gap between rings
self.color_map: str = "turbo" 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.setLayout(QHBoxLayout())
self.setMouseTracking(True)
self.initialize_bars() self.initialize_bars()
self.initialize_center_label() self.initialize_center_label()
@@ -69,7 +59,6 @@ class RingProgressContainerWidget(QWidget):
""" """
ring = Ring(parent=self) ring = Ring(parent=self)
ring.setGeometry(self.rect()) ring.setGeometry(self.rect())
ring.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
ring.gap = self.gap * len(self.rings) ring.gap = self.gap * len(self.rings)
ring.set_value(0) ring.set_value(0)
self.rings.append(ring) self.rings.append(ring)
@@ -99,10 +88,6 @@ class RingProgressContainerWidget(QWidget):
index = self.num_bars - 1 index = self.num_bars - 1
index = self._validate_index(index) index = self._validate_index(index)
ring = self.rings[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.cleanup()
ring.close() ring.close()
ring.deleteLater() ring.deleteLater()
@@ -121,7 +106,6 @@ class RingProgressContainerWidget(QWidget):
self.center_label = QLabel("", parent=self) self.center_label = QLabel("", parent=self)
self.center_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.center_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.center_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
layout.addWidget(self.center_label) layout.addWidget(self.center_label)
def _calculate_minimum_size(self): def _calculate_minimum_size(self):
@@ -166,130 +150,6 @@ class RingProgressContainerWidget(QWidget):
for ring in self.rings: for ring in self.rings:
ring.setGeometry(self.rect()) 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"): def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "RGB"):
""" """
Set the colors for the progress bars from a colormap. Set the colors for the progress bars from a colormap.
@@ -370,9 +230,6 @@ class RingProgressContainerWidget(QWidget):
""" """
Clear all rings from the widget. 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: for ring in self.rings:
ring.close() ring.close()
ring.deleteLater() ring.deleteLater()
@@ -19,9 +19,9 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
@@ -63,8 +63,7 @@ class RingCardWidget(QFrame):
self.mode_combo.setCurrentText(self._get_display_mode_string(self.ring.config.mode)) self.mode_combo.setCurrentText(self._get_display_mode_string(self.ring.config.mode))
self._set_widget_mode_enabled(self.ring.config.mode) self._set_widget_mode_enabled(self.ring.config.mode)
@staticmethod def _get_theme_color(self, color_name: str) -> QColor | None:
def _get_theme_color(color_name: str) -> QColor | None:
app = QApplication.instance() app = QApplication.instance()
if not app: if not app:
return return
@@ -250,13 +249,12 @@ class RingCardWidget(QFrame):
def _on_signal_changed(self, signal: str): def _on_signal_changed(self, signal: str):
device = self.ui.device_combo_box.currentText() device = self.ui.device_combo_box.currentText()
signal = self.ui.signal_combo_box.get_signal_name() signal = self.ui.signal_combo_box.get_signal_name()
if not device or device not in self.ring.bec_dispatcher.client.device_manager.devices: if not device or device not in self.container.bec_dispatcher.client.device_manager.devices:
return return
self.ring.set_update("device", device=device, signal=signal) self.ring.set_update("device", device=device, signal=signal)
self.ring.config.signal = signal self.ring.config.signal = signal
@staticmethod def _unify_mode_string(self, mode: str) -> str:
def _unify_mode_string(mode: str) -> str:
"""Convert mode string to a unified format""" """Convert mode string to a unified format"""
mode = mode.lower() mode = mode.lower()
if mode == "scan progress": if mode == "scan progress":
@@ -265,8 +263,7 @@ class RingCardWidget(QFrame):
return "device" return "device"
return mode return mode
@staticmethod def _get_display_mode_string(self, mode: str) -> str:
def _get_display_mode_string(mode: str) -> str:
"""Convert mode string to display format""" """Convert mode string to display format"""
match mode: match mode:
case "manual": case "manual":
@@ -1,600 +0,0 @@
"""Admin View panel for setting up account and messaging services in BEC."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import DeploymentInfoMessage, ExperimentInfoMessage
from qtpy.QtCore import QSize, Qt, QTimer, Signal
from qtpy.QtWidgets import (
QFrame,
QGroupBox,
QHBoxLayout,
QLabel,
QSizePolicy,
QStackedLayout,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_login import BECLogin
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import (
MaterialIconAction,
WidgetAction,
create_action_with_text,
)
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_http_service import (
AtlasEndpoints,
AuthenticatedUserInfo,
BECAtlasHTTPService,
HTTPResponse,
)
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_mat_card import (
ExperimentMatCard,
)
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_selection import (
ExperimentSelection,
)
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QToolBar
logger = bec_logger.logger
class OverviewWidget(QGroupBox):
"""Overview Widget for the BEC Atlas Admin view"""
login_requested = Signal(str, str)
change_experiment_requested = Signal()
def __init__(self, parent=None):
super().__init__(parent=parent)
self.setContentsMargins(12, 0, 12, 6)
self._authenticated = False
# Root layout
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(0, 0, 0, 0)
self.root_layout.setSpacing(0)
# Stacked Layout to switch between login form and overview content
self.stacked_layout = QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)
self.stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
self.root_layout.addLayout(self.stacked_layout)
self._init_login_view()
self._init_experiment_overview()
self.stacked_layout.setCurrentWidget(self._login_widget)
self._experiment_overview_widget.setVisible(False)
def set_experiment_info(self, experiment_info: ExperimentInfoMessage):
"""Set the experiment information for the overview widget."""
self._experiment_overview_widget.set_experiment_info(experiment_info)
@SafeSlot(bool)
def set_authenticated(self, authenticated: bool):
"""Set the authentication state of the overview widget."""
self._authenticated = authenticated
if authenticated:
self.stacked_layout.setCurrentWidget(self._experiment_overview_widget)
self._experiment_overview_widget.setVisible(True)
else:
self.stacked_layout.setCurrentWidget(self._login_widget)
self._experiment_overview_widget.setVisible(False)
def _init_login_view(self):
"""Initialize the login view."""
self._login_widget = QWidget()
layout = QHBoxLayout(self._login_widget)
self._login_widget.setAutoFillBackground(True)
self._login_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
layout.setSpacing(16)
content = QFrame()
content_layout = QVBoxLayout(content)
content.setFrameShape(QFrame.Shape.StyledPanel)
content.setFrameShadow(QFrame.Shadow.Raised)
content.setStyleSheet("""
QFrame
{
border: 1px solid #cccccc;
}
QLabel
{
border: none;
}
""")
content_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
content.setFixedSize(400, 280)
self._login = BECLogin(parent=self)
self._login.credentials_entered.connect(self.login_requested.emit)
content_layout.addWidget(self._login)
layout.addWidget(content)
self.stacked_layout.addWidget(self._login_widget)
def _init_experiment_overview(self):
"""Initialize the experiment overview content."""
self._experiment_overview_widget = ExperimentMatCard(
show_activate_button=True,
parent=self,
title="Current Experiment",
button_text="Change Experiment",
)
self._experiment_overview_widget.experiment_selected.connect(self._on_experiment_selected)
layout = QVBoxLayout(self._experiment_overview_widget)
self._experiment_overview_widget.setAutoFillBackground(True)
self._experiment_overview_widget.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
layout.setSpacing(16)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.stacked_layout.addWidget(self._experiment_overview_widget)
@SafeSlot(dict)
def _on_experiment_selected(self, _):
"""Handle the change experiment button click."""
self.change_experiment_requested.emit()
class CustomLogoutAction(MaterialIconAction):
"""Custom logout action that can be enabled/disabled based on authentication state."""
def __init__(self, parent=None):
super().__init__(
icon_name="logout",
tooltip="Logout",
label_text="Logout",
text_position="under",
parent=parent,
filled=True,
)
self.action.setEnabled(False) # Initially disabled until authenticated
self._tick_timer = QTimer(parent)
self._tick_timer.setInterval(1000)
self._tick_timer.timeout.connect(self._on_tick)
self._login_remaining_s = 0
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the action to the toolbar.
Args:
toolbar(QToolBar): The toolbar to add the action to.
target(QWidget): The target widget for the action.
"""
create_action_with_text(toolbar_action=self, toolbar=toolbar, min_size=QSize(70, 40))
def set_authenticated(self, auth_info: AuthenticatedUserInfo | None):
"""Enable or disable the logout action based on authentication state."""
if not auth_info:
self._tick_timer.stop()
self._login_remaining_s = 0
self.action.setEnabled(False)
self.update_label() # Reset Label text
return # No need to set the timer if we're not authenticated
self._login_remaining_s = max(0, int(auth_info.exp - time.time())) if auth_info else 0
self.action.setEnabled(True)
if self._login_remaining_s > 0:
self._tick_timer.start()
def _on_tick(self) -> None:
"""Handle the timer countdown tick to update the remaining logout time."""
self._login_remaining_s -= 1
if self._login_remaining_s <= 0:
self.set_authenticated(None) # This will disable the action and stop the timer
return
self.update_label() # Optionally update the label to show remaining time
def update_label(self):
"""Update the label text of the logout action."""
if self._login_remaining_s > 0:
label_text = f"{self.label_text}\n({self._login_remaining_s}s)"
else:
label_text = self.label_text
self.action.setText(label_text)
def cleanup(self):
"""Cleanup the timer when the action is destroyed."""
if self._tick_timer.isActive():
self._tick_timer.stop()
class AtlasConnectionInfo(QWidget):
def __init__(self, parent=None):
super().__init__(parent=parent)
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
layout.setContentsMargins(6, 6, 6, 12)
layout.setSpacing(8)
self._bl_info_label = QLabel(self)
self._atlas_url_label = QLabel(self)
layout.addWidget(self._bl_info_label)
layout.addWidget(self._atlas_url_label)
self._atlas_url_text = ""
def set_info(self, realm_id: str, bl_name: str, atlas_url: str):
"""Set the connection information for the BEC Atlas API."""
bl_info = f"{realm_id} @ {bl_name}"
self._bl_info_label.setText(bl_info)
self._atlas_url_label.setText(atlas_url)
self._atlas_url_text = atlas_url
def set_logged_in(self, email: str):
"""Show login status in the atlas info widget."""
self._atlas_url_label.setText(f"{self._atlas_url_text} | {email}")
def clear_login(self):
"""Clear login status from the atlas info widget."""
self._atlas_url_label.setText(self._atlas_url_text)
class BECAtlasAdminView(BECWidget, QWidget):
RPC = False
authenticated = Signal(bool)
def __init__(
self,
parent=None,
atlas_url: str = "https://bec-atlas-prod.psi.ch/api/v1",
client=None,
**kwargs,
):
super().__init__(parent=parent, client=client, **kwargs)
# State variables
self._current_deployment_info: DeploymentInfoMessage | None = None
self._current_deployment_info = None
self._current_session_info = None
self._current_experiment_info = None
self._authenticated = False
self._atlas_url = atlas_url
# Root layout
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(0, 0, 0, 0)
self.root_layout.setSpacing(0)
# Toolbar for navigation between different views in the admin panel
self.toolbar = ModularToolBar(self)
self.init_toolbar()
self.root_layout.insertWidget(0, self.toolbar)
self.toolbar.show_bundles(["view", "atlas_info", "auth"])
# Stacked layout to switch between overview, experiment selection and messaging services
# It is added below the toolbar
self.stacked_layout = QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)
self.stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
self.root_layout.addLayout(self.stacked_layout)
# BEC Atlas HTTP Service
self.atlas_http_service = BECAtlasHTTPService(
parent=self, base_url=atlas_url, headers={"accept": "application/json"}
)
# Overview widget
self.overview_widget = OverviewWidget(parent=self)
self.stacked_layout.addWidget(self.overview_widget)
# Experiment Selection widget
self.experiment_selection = ExperimentSelection(parent=self)
self.experiment_selection.setVisible(False)
self.stacked_layout.addWidget(self.experiment_selection)
# Connect signals
self.overview_widget.login_requested.connect(self._on_login_requested)
self.overview_widget.change_experiment_requested.connect(
self._on_experiment_selection_selected
)
self.authenticated.connect(self.overview_widget.set_authenticated)
self.experiment_selection.experiment_selected.connect(self._on_experiment_selected)
self.atlas_http_service.http_response.connect(self._on_http_response_received)
self.atlas_http_service.authenticated.connect(self._on_authenticated)
self._connect_dispatcher()
def _connect_dispatcher(self):
self.bec_dispatcher.connect_slot(
slot=self._update_deployment_info,
topics=MessageEndpoints.deployment_info(),
from_start=True,
)
def init_toolbar(self):
"""Initialize the toolbar for the admin view. This allows to switch between different views in the admin panel."""
# Overview
overview = MaterialIconAction(
icon_name="home",
tooltip="Show Overview Panel",
label_text="Overview",
text_position="under",
parent=self,
filled=True,
)
overview.action.triggered.connect(self._on_overview_selected)
self.toolbar.components.add_safe("overview", overview)
# Experiment Selection
experiment_selection = MaterialIconAction(
icon_name="experiment",
tooltip="Show Experiment Selection Panel",
label_text="Experiment Selection",
text_position="under",
parent=self,
filled=True,
)
experiment_selection.action.triggered.connect(self._on_experiment_selection_selected)
experiment_selection.action.setEnabled(False) # Initially disabled until authenticated
self.toolbar.components.add_safe("experiment_selection", experiment_selection)
# Messaging Services
messaging_services = MaterialIconAction(
icon_name="chat",
tooltip="Show Messaging Services Panel",
label_text="Messaging Services",
text_position="under",
parent=self,
filled=True,
)
messaging_services.action.triggered.connect(self._on_messaging_services_selected)
messaging_services.action.setEnabled(False) # Initially disabled until authenticated
self.toolbar.components.add_safe("messaging_services", messaging_services)
# Atlas Info
self._atlas_info_widget = AtlasConnectionInfo(parent=self)
atlas_info = WidgetAction(widget=self._atlas_info_widget, parent=self)
self.toolbar.components.add_safe("atlas_info", atlas_info)
logout_action = CustomLogoutAction(parent=self)
logout_action.action.triggered.connect(self.logout)
logout_action.action.setEnabled(False) # Initially disabled until authenticated
self.toolbar.components.add_safe("logout", logout_action)
# Add view_bundle to toolbar
view_bundle = ToolbarBundle("view", self.toolbar.components)
view_bundle.add_action("overview")
view_bundle.add_action("experiment_selection")
view_bundle.add_action("messaging_services")
self.toolbar.add_bundle(view_bundle)
# Add atlas_info to toolbar
atlas_info_bundle = ToolbarBundle("atlas_info", self.toolbar.components)
atlas_info_bundle.add_action("atlas_info")
self.toolbar.add_bundle(atlas_info_bundle)
# Add auth_bundle to toolbar
auth_bundle = ToolbarBundle("auth", self.toolbar.components)
auth_bundle.add_action("logout")
self.toolbar.add_bundle(auth_bundle)
########################
## Toolbar icon slots
########################
def _on_overview_selected(self):
"""Show the overview panel."""
self.overview_widget.setVisible(True)
self.experiment_selection.setVisible(False)
self.stacked_layout.setCurrentWidget(self.overview_widget)
def _on_experiment_selection_selected(self):
"""Show the experiment selection panel."""
if not self._authenticated:
logger.warning("Attempted to access experiment selection without authentication.")
return
self.overview_widget.setVisible(False)
self.experiment_selection.setVisible(True)
self.stacked_layout.setCurrentWidget(self.experiment_selection)
def _on_messaging_services_selected(self):
"""Show the messaging services panel."""
logger.info("Messaging services panel is not implemented yet.")
return
########################
## Internal slots
########################
@SafeSlot(dict)
def _on_experiment_selected(self, experiment_info: dict) -> None:
"""Handle the experiment selected signal from the experiment selection widget"""
experiment_info = ExperimentInfoMessage.model_validate(experiment_info)
experiment_id = experiment_info.pgroup
deployment_id = self._current_deployment_info.deployment_id
self.set_experiment(experiment_id=experiment_id, deployment_id=deployment_id)
@SafeSlot(str, str, popup_error=True)
def _on_login_requested(self, username: str, password: str):
"""Handle login requested signal from the overview widget."""
# Logout first to clear any existing session and cookies before attempting a new login
if self._authenticated:
logger.info("Existing session detected, logging out before attempting new login.")
self.logout()
# Now login with new credentials
self.login(username, password)
@SafeSlot(dict, dict)
def _update_deployment_info(self, msg: dict, _: dict) -> None:
"""Fetch current deployment info from the server."""
deployment = DeploymentInfoMessage.model_validate(msg)
self._current_deployment_info = deployment
self._current_session_info = deployment.active_session
if self._current_session_info is not None:
self._current_experiment_info = self._current_session_info.experiment
self.overview_widget.set_experiment_info(self._current_experiment_info)
self._atlas_info_widget.set_info(
realm_id=self._current_experiment_info.realm_id or "",
bl_name=self._current_deployment_info.name or "",
atlas_url=self._atlas_url,
)
self.atlas_http_service._set_current_deployment_info(deployment)
def _fetch_available_experiments(self):
"""Fetch the list of available experiments for the authenticated user."""
# What if this is None, should this be an optional user input in the UI?
if self._current_experiment_info is None:
logger.error(
"No current experiment info available, cannot fetch available experiments."
)
return
current_realm_id = self._current_experiment_info.realm_id
if current_realm_id is None:
logger.error(
"Current experiment does not have a realm_id, cannot fetch available experiments."
)
return
self.atlas_http_service.get_experiments_for_realm(current_realm_id)
########################
## HTTP Service response handling
########################
def _on_http_response_received(self, response: dict) -> None:
"""Handle the HTTP response received from the BEC Atlas API."""
response = HTTPResponse(**response)
logger.debug(
f"HTTP Response received: {response.request_url} with status {response.status}"
)
if AtlasEndpoints.REALMS_EXPERIMENTS in response.request_url:
experiments = response.data if isinstance(response.data, list) else []
self.experiment_selection.set_experiment_infos(experiments)
elif AtlasEndpoints.SET_EXPERIMENT in response.request_url:
self._on_overview_selected()
@SafeSlot(dict)
def _on_authenticated(self, auth_info: dict) -> None:
"""Handle authentication state change."""
authenticated = False
# Only if the user has owner access to the deployment, we consider them to be fully authenticated
# This means that although they may authenticate against atlas, they won't be able to see any
# extra information here
if auth_info:
info = AuthenticatedUserInfo.model_validate(auth_info)
if (
self._current_deployment_info
and info.deployment_id == self._current_deployment_info.deployment_id
):
authenticated = True
else:
logger.warning(
f"Authenticated user {info.email} does not have access to the current deployment {self._current_deployment_info.name if self._current_deployment_info else '<no deployment>'}."
)
self._authenticated = authenticated
if authenticated:
self.toolbar.components.get_action("experiment_selection").action.setEnabled(True)
self.toolbar.components.get_action("messaging_services").action.setEnabled(
False
) # TODO activate once messaging is added
self.toolbar.components.get_action("logout").action.setEnabled(True)
self._fetch_available_experiments() # Fetch experiments upon successful authentication
self._atlas_info_widget.set_logged_in(info.email)
self.toolbar.components.get_action("logout").set_authenticated(info)
else:
self.toolbar.components.get_action("experiment_selection").action.setEnabled(False)
self.toolbar.components.get_action("messaging_services").action.setEnabled(False)
self.toolbar.components.get_action("logout").action.setEnabled(False)
# Delete data in experiment selection widget upon logout
self.experiment_selection.set_experiment_infos([])
self._on_overview_selected() # Switch back to overview on logout
self._atlas_info_widget.clear_login() # Clear login status in atlas info widget on logout
self.toolbar.components.get_action("logout").set_authenticated(None)
self.authenticated.emit(authenticated)
################
## API Methods
################
@SafeSlot(str, str, popup_error=True)
def set_experiment(self, experiment_id: str, deployment_id: str) -> None:
"""Set the experiment information for the current experiment."""
self.atlas_http_service.set_experiment(experiment_id, deployment_id)
@SafeSlot(str, str, popup_error=True)
def login(self, username: str, password: str) -> None:
"""Login to the BEC Atlas API with the provided username and password."""
self.atlas_http_service.login(username=username, password=password)
@SafeSlot(popup_error=True)
def logout(self) -> None:
"""Logout from the BEC Atlas API."""
self.atlas_http_service.logout()
def get_user_info(self):
"""Get the current user information from the BEC Atlas API."""
self.atlas_http_service.get_user_info()
###############
## Cleanup
###############
def cleanup(self):
self.atlas_http_service.cleanup()
return super().cleanup()
if __name__ == "__main__":
import sys
from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("light")
window = BECAtlasAdminView()
exp_info_dict = {
"_id": "p22622",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22622"],
"realm_id": "TestBeamline",
"proposal": "12345967",
"title": "Test Experiment for Mat Card Widget",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@psi.ch",
"account": "doe_j",
"pi_firstname": "Jane",
"pi_lastname": "Smith",
"pi_email": "jane.smith@psi.ch",
"pi_account": "smith_j",
"eaccount": "e22622",
"pgroup": "p22622",
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
"proposal_submitted": "15/12/2024",
"proposal_expire": "31/12/2025",
"proposal_status": "Scheduled",
"delta_last_schedule": 30,
"mainproposal": "",
}
from bec_lib.messages import DeploymentInfoMessage, ExperimentInfoMessage, SessionInfoMessage
# proposal_info = ExperimentInfoMessage(**exp_info_dict)
# session_info = SessionInfoMessage(name="Test Session", experiment=proposal_info)
# deployment_info = DeploymentInfoMessage(
# deployment_id="test_deployment_001", active_session=session_info
# )
# window.set_experiment_info(proposal_info)
window.resize(800, 600)
window.show()
sys.exit(app.exec_())
@@ -1,395 +0,0 @@
"""
HTTP service widget for interacting with the BEC Atlas API.
This module defines Qt-based classes that wrap ``QNetworkAccessManager`` to perform
authenticated HTTP requests against the BEC Atlas backend, manage login and logout
flows, and track authentication token expiry for the BEC Atlas Admin View.
It also provides Pydantic models that describe HTTP responses and authenticated user
information, as well as an ``AuthenticatedTimer`` helper used to signal when
authentication tokens expire.
"""
import json
import time
from enum import StrEnum
from typing import Literal
import jwt
from bec_lib.logger import bec_logger
from bec_lib.messages import DeploymentInfoMessage
from pydantic import BaseModel
from qtpy.QtCore import QObject, QTimer, QUrl, QUrlQuery, Signal
from qtpy.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
from qtpy.QtWidgets import QMessageBox, QWidget
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class AtlasEndpoints(StrEnum):
"""Constants for BEC Atlas API endpoints."""
LOGIN = "/user/login"
LOGOUT = "/user/logout"
REALMS_EXPERIMENTS = "/realms/experiments"
SET_EXPERIMENT = "/deployments/experiment"
USER_INFO = "/user/me"
DEPLOYMENT_INFO = "/deployments/id"
class BECAtlasHTTPError(Exception):
"""Custom exception for BEC Atlas HTTP errors."""
class HTTPResponse(BaseModel):
"""Model representing an HTTP response."""
request_url: str
headers: dict
status: int
data: dict | list | str # Check with Klaus if str is deprecated
class AuthenticatedUserInfo(BaseModel):
"""Model representing authenticated user information."""
email: str
exp: float
groups: set[str]
deployment_id: str
class AuthenticatedTimer(QObject):
"""Timer to track authentication expiration and emit a signal when the token expires."""
expired = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._on_expired)
def start(self, duration_seconds: float):
"""Start the timer with the given duration in seconds."""
self._timer.start(int(duration_seconds * 1000))
def stop(self):
"""Stop the timer."""
self._timer.stop()
@SafeSlot()
def _on_expired(self):
"""Handle the timer expiration by emitting the expired signal."""
logger.info("Authentication token has expired.")
self.expired.emit()
class BECAtlasHTTPService(QWidget):
"""HTTP service using the QNetworkAccessManager to interact with the BEC Atlas API."""
http_response = Signal(dict) # HTTPResponse.model_dump() dict
authenticated = Signal(dict) # AuthenticatedUserInfo.model_dump() dict or {}
authentication_expires = Signal(float)
def __init__(self, parent=None, base_url: str = "", headers: dict | None = None):
super().__init__(parent)
if headers is None:
headers = {"accept": "application/json"}
self._headers = headers
self._base_url = base_url
self.network_manager = QNetworkAccessManager(self)
self.network_manager.finished.connect(self._handle_response)
self._auth_user_info: AuthenticatedUserInfo | None = None
self._auth_timer = self._create_auth_timer()
self._current_deployment_info = None
def _create_auth_timer(self) -> AuthenticatedTimer:
"""Create and connect the authenticated timer to handle token expiration."""
timer = AuthenticatedTimer(self)
timer.expired.connect(self.__clear_login_info)
return timer
@property
def auth_user_info(self) -> AuthenticatedUserInfo | None:
"""Get the authenticated user information, including email and token expiration time."""
return self._auth_user_info
def __set_auth_info(self, login_info: dict[Literal["email", "exp"], str | float]):
"""Set the authenticated user information after a successful login."""
login_info.update({"groups": []}) # Initialize groups as empty until we fetch user info
login_info.update(
{
"deployment_id": (
self._current_deployment_info.deployment_id
if self._current_deployment_info
else ""
)
}
)
self._auth_user_info = AuthenticatedUserInfo(**login_info)
# Start timer to clear auth info once token expires
exp_time = login_info.get("exp", 0)
current_time = time.time() # TODO should we use server time to avoid clock skew issues?
duration = max(0, exp_time - current_time)
self._auth_timer.start(duration)
def __set_auth_groups(self, groups: list[str]):
"""Set the authenticated user's groups after fetching user info."""
if self._auth_user_info is not None:
self._auth_user_info.groups = set(groups)
def __check_access_to_owner_groups(self, groups: list[str]) -> bool:
"""Check if the authenticated user has access to the current deployment based on their groups."""
if self._auth_user_info is None or self._current_deployment_info is None:
return False
# Admin user
has_both = {"admin", "atlas_func_account"}.issubset(groups)
if has_both:
return True
# Regular user check with group intersection
return not self.auth_user_info.groups.isdisjoint(groups)
def __clear_login_info(self, skip_logout: bool = False):
"""Clear the authenticated user information after logout."""
self._auth_user_info = None
if not skip_logout:
self.logout() # Ensure we also logout on the server side and invalidate the session
def closeEvent(self, event):
self.cleanup()
return super().closeEvent(event)
def cleanup(self):
"""Cleanup connection, destroy authenticate cookies."""
logger.info("Cleaning up BECAtlasHTTPService: disconnecting signals and clearing cookies.")
# Disconnect signals to avoid handling responses after cleanup
self.network_manager.finished.disconnect(self._handle_response)
# Logout to invalidate session on server side
self.logout()
# Stop the authentication timer
self._auth_timer.stop()
# Delete all cookies related to the base URL
for cookie in self.network_manager.cookieJar().cookiesForUrl(QUrl(self._base_url)):
self.network_manager.cookieJar().deleteCookie(cookie)
@SafeSlot(QNetworkReply, popup_error=True)
def _handle_response(self, reply: QNetworkReply):
"""
Handle the HTTP response from the server.
Args:
reply (QNetworkReply): The network reply object containing the response.
"""
status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
raw_bytes = reply.readAll().data()
request_url = reply.url().toString()
headers = dict([(i.data().decode(), j.data().decode()) for i, j in reply.rawHeaderPairs()])
reply.deleteLater()
# Any unsuccessful status code should raise here
if status != 200:
raise BECAtlasHTTPError(
f"HTTP request for {request_url} failed with status code {status} and response: {raw_bytes.decode('utf-8')}"
)
if len(raw_bytes) > 0:
data = json.loads(raw_bytes.decode())
else:
data = {}
if data is None:
data = {}
logger.warning(f"Received empty response for {request_url} with status code {status}.")
if not isinstance(data, (dict, list, str)):
raise BECAtlasHTTPError(
f"Expected response data to be a dict, list, or str for {request_url}, but got {type(data)}. Response content: {data}"
)
if AtlasEndpoints.LOGIN.value in request_url:
# If it's a login response, don't forward the token
# but extract the expiration time and emit it
token = data.get("access_token")
data = jwt.decode(token, options={"verify_signature": False})
self.authentication_expires.emit(data.get("exp", 0))
# Now we set the auth info, and then fetch the user info to get the groups
self.__set_auth_info(data)
self.get_user_info() # Fetch groups, then emit authenticated once groups are set on auth_user
elif AtlasEndpoints.LOGOUT.value in request_url:
self._auth_timer.stop() # Stop the timer if it was running
self.__clear_login_info(skip_logout=True) # Skip calling logout again
self.authenticated.emit({})
elif AtlasEndpoints.USER_INFO.value in request_url:
groups = data.get("groups", [])
email = data.get("email", "")
# Second step of authentication: We also have all groups now
if self.auth_user_info is not None and self.auth_user_info.email == email:
self.__set_auth_groups(groups)
if self._current_deployment_info is not None:
# Now we need to fetch the deployment info to get the owner groups and check access rights,
# Then we can emit the authenticated signal with the full user info including groups if access is
# granted. Otherwise, we emit nothing and show a warning that the user does not have the access
# rights for the current deployment.
self.get_deployment_info(
deployment_id=self._current_deployment_info.deployment_id
)
elif AtlasEndpoints.DEPLOYMENT_INFO.value in request_url:
owner_groups = data.get("owner_groups", [])
if self.__check_access_to_owner_groups(owner_groups):
self.authenticated.emit(self.auth_user_info.model_dump())
else:
if self.auth_user_info is not None:
warning_text = f"User {self.auth_user_info.email} does not have access to the active deployment {data.get('name', '<unknown>')}."
else:
warning_text = "Authenticated user information is missing. Cannot verify access to the active deployment."
self._show_warning(warning_text)
self.logout() # Logout to clear auth info and stop timer since user does not have access
response = HTTPResponse(request_url=request_url, headers=headers, status=status, data=data)
self.http_response.emit(response.model_dump())
def _show_warning(self, text: str):
"""Show a warning message to the user."""
msg = QMessageBox(self)
msg.setIcon(QMessageBox.Icon.Warning)
msg.setText(text)
msg.setWindowTitle("Authentication Warning")
msg.exec_()
#######################
# GET/POST Request Methods
#######################
def _get_request(self, endpoint: str, query_parameters: dict | None = None):
"""
GET request to the API endpoint.
Args:
endpoint (str): The API endpoint to send the GET request to.
query_parameters (dict | None): Optional query parameters to include in the URL.
"""
url = QUrl(self._base_url + endpoint)
if query_parameters:
query = QUrlQuery()
for key, value in query_parameters.items():
query.addQueryItem(key, value)
url.setQuery(query)
request = QNetworkRequest(url)
for key, value in self._headers.items():
request.setRawHeader(key.encode(), value.encode())
self.network_manager.get(request)
def _post_request(
self, endpoint: str, payload: dict | None = None, query_parameters: dict | None = None
):
"""
POST request to the API endpoint with a JSON payload.
Args:
endpoint (str): The API endpoint to send the POST request to.
payload (dict): The JSON payload to include in the POST request.
query_parameters (dict | None): Optional query parameters to include in the URL.
"""
if payload is None:
payload = {}
url = QUrl(self._base_url + endpoint)
if query_parameters:
query = QUrlQuery()
for key, value in query_parameters.items():
query.addQueryItem(key, value)
url.setQuery(query)
request = QNetworkRequest(url)
# Headers
for key, value in self._headers.items():
request.setRawHeader(key.encode(), value.encode())
request.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json")
payload_dump = json.dumps(payload).encode()
self.network_manager.post(request, payload_dump)
def _set_current_deployment_info(self, deployment_info: dict | DeploymentInfoMessage):
"""
Set the current deployment information for the service.
Args:
deployment_info (dict | DeploymentInfoMessage): The deployment information to set.
"""
if isinstance(deployment_info, dict):
deployment_info = DeploymentInfoMessage.model_validate(deployment_info)
self._current_deployment_info = deployment_info
################
# API Methods
################
@SafeSlot(str, str, popup_error=True)
def login(self, username: str, password: str):
"""
Login to BEC Atlas with the provided username and password.
Args:
username (str): The username for authentication.
password (str): The password for authentication.
"""
self._post_request(
endpoint=AtlasEndpoints.LOGIN.value,
payload={"username": username, "password": password},
)
@SafeSlot(popup_error=True)
def logout(self):
"""Logout from BEC Atlas."""
self._post_request(endpoint=AtlasEndpoints.LOGOUT.value)
@SafeSlot(str, popup_error=True)
def get_experiments_for_realm(self, realm_id: str):
"""
Get the list of realms from BEC Atlas. Requires authentication.
Args:
realm_id (str): The ID of the realm to retrieve experiments for.
"""
self._get_request(
endpoint=AtlasEndpoints.REALMS_EXPERIMENTS.value,
query_parameters={"realm_id": realm_id},
)
@SafeSlot(str, str)
def set_experiment(self, experiment_id: str, deployment_id: str) -> None:
"""
Set the current experiment information for the service.
Args:
experiment_id (str): The ID of the experiment to set.
deployment_id (str): The ID of the deployment associated with the experiment.
"""
self._post_request(
endpoint=AtlasEndpoints.SET_EXPERIMENT.value,
query_parameters={"experiment_id": experiment_id, "deployment_id": deployment_id},
)
@SafeSlot(popup_error=True)
def get_user_info(self):
"""Get the current user information from BEC Atlas. Requires authentication."""
self._get_request(endpoint=AtlasEndpoints.USER_INFO.value)
@SafeSlot(str, popup_error=True)
def get_deployment_info(self, deployment_id: str):
"""
Get the deployment information for a given deployment ID. Requires authentication.
Args:
deployment_id (str): The ID of the deployment to retrieve information for.
"""
self._get_request(
endpoint=AtlasEndpoints.DEPLOYMENT_INFO.value,
query_parameters={"deployment_id": deployment_id},
)
@@ -1,273 +0,0 @@
"""Mat-card like widget to display experiment details. Optionally, a button on the bottom which the user can click to trigger the selection of the experiment."""
from bec_lib.messages import ExperimentInfoMessage
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import (
QFrame,
QGraphicsDropShadowEffect,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
format_name,
format_schedule,
)
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class BorderLessLabel(QLabel):
"""A QLabel that does not show any border, even when stylesheets try to apply one."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setStyleSheet("border: none;")
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
class ExperimentMatCard(BECWidget, QWidget):
RPC = False
experiment_selected = Signal(dict)
def __init__(
self,
parent=None,
show_activate_button: bool = True,
button_text: str = "Activate",
title: str = "Next Experiment",
**kwargs,
):
super().__init__(parent=parent, theme_update=True, **kwargs)
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 8, 12, 8)
self.experiment_info = {}
self._abstract_text = ""
# Add card frame with shadow and custom styling
self._card_frame = QFrame(parent=self)
layout = QVBoxLayout(self._card_frame)
self._card_frame.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
palette = get_theme_palette()
self._card_frame.setStyleSheet(f"""
QFrame {{
border: 1px solid {palette.mid().color().name()};
background: {palette.base().color().name()};
}}
""")
shadow = QGraphicsDropShadowEffect(self._card_frame)
shadow.setBlurRadius(18)
shadow.setOffset(0, 4)
shadow.setColor(palette.shadow().color())
self._card_frame.setGraphicsEffect(shadow)
self._group_box = QGroupBox(self._card_frame)
self._group_box.setStyleSheet(
"QGroupBox { border: none; }; QLabel { border: none; padding: 0px; }"
)
self._fill_group_box(
title=title, show_activate_button=show_activate_button, button_text=button_text
)
self.apply_theme("light")
def apply_theme(self, theme: str):
palette = get_theme_palette()
self._card_frame.setStyleSheet(f"""
QFrame {{
border: 1px solid {palette.mid().color().name()};
background: {palette.base().color().name()};
}}
""")
shadow = self._card_frame.graphicsEffect()
if isinstance(shadow, QGraphicsDropShadowEffect):
shadow.setColor(palette.shadow().color())
def _fill_group_box(
self, title: str, show_activate_button: bool, button_text: str = "Activate"
):
group_layout = QVBoxLayout(self._group_box)
group_layout.setContentsMargins(16, 16, 16, 16)
group_layout.setSpacing(12)
title_row = QHBoxLayout()
self._card_title = BorderLessLabel(title, self._group_box)
self._card_title.setStyleSheet("""
border: none;
font-size: 14px;
font-weight: 600;
""")
# Add title row and info button to QH layout, then add it to QV layout
title_row.addWidget(self._card_title)
title_row.addStretch(1)
group_layout.addLayout(title_row)
self._card_grid = QGridLayout()
self._card_grid.setHorizontalSpacing(12)
self._card_grid.setVerticalSpacing(8)
self._card_grid.setColumnStretch(1, 1)
self._card_pgroup = BorderLessLabel("-", self._group_box)
self._card_title_value = BorderLessLabel("-", self._group_box)
self._card_title_value.setWordWrap(True)
self._card_title_value.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
self._card_name = BorderLessLabel("-", self._group_box)
self._card_start = BorderLessLabel("-", self._group_box)
self._card_end = BorderLessLabel("-", self._group_box)
self._card_row_labels = []
def _row_label(text):
label = BorderLessLabel(text, self._group_box)
self._card_row_labels.append(label)
return label
self._card_grid.addWidget(_row_label("Name"), 0, 0)
self._card_grid.addWidget(self._card_name, 0, 1)
self._card_grid.addWidget(_row_label("Title"), 1, 0)
self._card_grid.addWidget(self._card_title_value, 1, 1)
self._card_grid.addWidget(_row_label("P-group"), 2, 0)
self._card_grid.addWidget(self._card_pgroup, 2, 1)
self._card_grid.addWidget(_row_label("Schedule (start)"), 3, 0)
self._card_grid.addWidget(self._card_start, 3, 1)
self._card_grid.addWidget(_row_label("Schedule (end)"), 4, 0)
self._card_grid.addWidget(self._card_end, 4, 1)
# Add to groupbox
group_layout.addLayout(self._card_grid)
# Add abstract field at the bottom of the card.
self._abstract_label = BorderLessLabel("", self._group_box)
self._abstract_label.setWordWrap(True)
self._abstract_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
group_layout.addWidget(self._abstract_label)
# Add activate button at the bottom
self._activate_button = QPushButton(button_text, self._group_box)
self._activate_button.clicked.connect(self._emit_next_experiment)
self._activate_button.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
)
group_layout.addWidget(self._activate_button, alignment=Qt.AlignmentFlag.AlignHCenter)
self._activate_button.setVisible(show_activate_button)
self._activate_button.setEnabled(False)
self._card_frame.layout().setContentsMargins(12, 12, 12, 12)
self._card_frame.layout().addWidget(self._group_box)
card_row = QHBoxLayout()
card_row.addStretch(0)
card_row.addWidget(self._card_frame)
card_row.addStretch(0)
layout = self.layout()
layout.addStretch(0)
layout.addLayout(card_row)
layout.addStretch(0)
def _emit_next_experiment(self):
self.experiment_selected.emit(self.experiment_info)
def clear_experiment_info(self):
"""
Clear the experiment information displayed on the card and disable the activate button.
"""
self._card_pgroup.setText("-")
self._card_title_value.setText("-")
self._card_name.setText("-")
self._card_start.setText("-")
self._card_end.setText("-")
self._abstract_text = ""
self._abstract_label.setText("")
self.experiment_info = {}
self._activate_button.setEnabled(False)
def set_experiment_info(self, info: ExperimentInfoMessage | dict):
"""
Set the experiment information to display on the card.
Args:
info (ExperimentInfoMessage | dict): The experiment information to display. Can be either a
dictionary or an ExperimentInfoMessage instance.
"""
if isinstance(info, dict):
info = ExperimentInfoMessage(**info)
start, end = format_schedule(info.schedule)
self._card_pgroup.setText(info.pgroup or "-")
self._card_title_value.setText(info.title or "-")
self._card_name.setText(format_name(info))
self._card_start.setText(start or "-")
self._card_end.setText(end or "-")
self._abstract_text = (info.abstract or "").strip()
self._abstract_label.setText(self._abstract_text if self._abstract_text else "")
self.experiment_info = info.model_dump()
self._activate_button.setEnabled(True)
def set_title(self, title: str):
"""
Set the title displayed at the top of the card.
Args:
title (str): The title text to display.
"""
self._card_title.setText(title)
if __name__ == "__main__": # pragma: no cover
import sys
from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
exp_info = {
"_id": "p22622",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22622"],
"realm_id": "TestBeamline",
"proposal": "12345967",
"title": "Test Experiment for Mat Card Widget",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@psi.ch",
"account": "doe_j",
"pi_firstname": "Jane",
"pi_lastname": "Smith",
"pi_email": "jane.smith@psi.ch",
"pi_account": "smith_j",
"eaccount": "e22622",
"pgroup": "p22622",
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
"proposal_submitted": "15/12/2024",
"proposal_expire": "31/12/2025",
"proposal_status": "Scheduled",
"delta_last_schedule": 30,
"mainproposal": "",
}
app = QApplication(sys.argv)
apply_theme("dark")
w = QWidget()
l = QVBoxLayout(w)
button = DarkModeButton()
widget = ExperimentMatCard()
widget.set_experiment_info(exp_info)
widget.set_title("Scheduled Experiment")
l.addWidget(button)
l.addWidget(widget)
w.resize(w.sizeHint())
w.show()
sys.exit(app.exec())

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