mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-15 13:10:54 +02:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
846b6e6968 | ||
| f562c61e3c | |||
| bda5d38965 | |||
| 9b0ec9dd79 | |||
| 1754e759f0 | |||
| 308e84d0e1 | |||
| fa2ef83bb9 | |||
| 02cb393bb0 | |||
|
|
1d3e0214fd | ||
| 37747babda | |||
| 32f5d486d3 | |||
| 0ff1fdc815 | |||
| c7de320ca5 | |||
|
|
5b23dce3d0 | ||
| 5e84d3bec6 | |||
|
|
9a2396ee9c | ||
| 2dab16b684 | |||
| e6c8cd0b1a | |||
| 242f8933b2 | |||
|
|
83ac6bcd37 | ||
| 90ecd8ea87 | |||
|
|
6e5f6e7fbb | ||
| 2f75aaea16 | |||
| 677550931b | |||
| 96b5179658 | |||
| e25b6604d1 | |||
|
|
f74c5a4516 | ||
| a2923752c2 | |||
| a486c52058 | |||
| 31389a3dd0 | |||
|
|
1676efc1ea | ||
|
|
05c38d9b82 | ||
| f67b60ac98 | |||
| 5ec59d5dbb | |||
|
|
d46ffb59f0 | ||
| da400d20b6 | |||
|
|
20f06d8659 | ||
| 3d29a67c0b | |||
|
|
e7ef8a3891 | ||
| 90222f3082 | |||
| 79af15a88b | |||
|
|
ad01011a3e | ||
| d4ecefd80a | |||
| d4afcb6832 | |||
| 2b0f575733 | |||
| 0c6f3f8352 | |||
| 48c9c83bb0 | |||
| ab223d5fdc | |||
| 137e572a94 | |||
| b14b046882 | |||
| a7a9458180 | |||
| 23c146b3e6 | |||
| df44d9b50e | |||
| de941d1bc5 | |||
| 34e80ee8f9 | |||
| d1a1d85abd | |||
| 8e53ae2d39 | |||
| 889e9c0994 | |||
| f565deb71d | |||
| 895b318990 | |||
| 3a17a249ed | |||
| 598c453a18 | |||
| 63059a4ef8 | |||
| ec58fbd6d8 | |||
| 17708730fc | |||
| 1384a329ab | |||
|
|
da1dc85b44 | ||
| 28be696f7c |
2
.github/actions/bw_install/action.yml
vendored
2
.github/actions/bw_install/action.yml
vendored
@@ -62,4 +62,4 @@ runs:
|
||||
uv pip install --system -e ./ophyd_devices
|
||||
uv pip install --system -e ./bec/bec_lib[dev]
|
||||
uv pip install --system -e ./bec/bec_ipython_client
|
||||
uv pip install --system -e ./bec_widgets[dev,pyside6]
|
||||
uv pip install --system -e ./bec_widgets[dev,qtermwidget]
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -177,4 +177,6 @@ cython_debug/
|
||||
# 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
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
#.idea/
|
||||
#
|
||||
tombi.toml
|
||||
|
||||
247
CHANGELOG.md
247
CHANGELOG.md
@@ -1,6 +1,253 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -5,6 +5,7 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
|
||||
from bec_widgets.applications.views.admin_view.admin_view import AdminView
|
||||
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView
|
||||
@@ -63,6 +64,8 @@ class BECMainApp(BECMainWindow):
|
||||
self.dock_area = DockAreaView(self)
|
||||
self.device_manager = DeviceManagerView(self)
|
||||
# self.developer_view = DeveloperView(self) #TODO temporary disable until the bugs with BECShell are resolved
|
||||
self.admin_view = AdminView(self)
|
||||
|
||||
self.add_view(icon="widgets", title="Dock Area", widget=self.dock_area, mini_text="Docks")
|
||||
self.add_view(
|
||||
icon="display_settings",
|
||||
@@ -78,6 +81,13 @@ class BECMainApp(BECMainWindow):
|
||||
# mini_text="IDE",
|
||||
# exclusive=True,
|
||||
# )
|
||||
self.add_view(
|
||||
icon="admin_panel_settings",
|
||||
title="Admin View",
|
||||
widget=self.admin_view,
|
||||
mini_text="Admin",
|
||||
from_top=False,
|
||||
)
|
||||
|
||||
if self._show_examples:
|
||||
self.add_section("Examples", "examples")
|
||||
@@ -181,6 +191,12 @@ class BECMainApp(BECMainWindow):
|
||||
|
||||
# Internal: route sidebar selection to the stack
|
||||
def _on_view_selected(self, vid: str) -> None:
|
||||
# Special handling for views that can not be switched to (e.g. dark mode toggle)
|
||||
# Not registered as proper view with a stack index, so we ignore any logic below
|
||||
# as it will anyways not result in a stack switch.
|
||||
idx = self._view_index.get(vid)
|
||||
if idx is None or not (0 <= idx < self.stack.count()):
|
||||
return
|
||||
# Determine current view
|
||||
current_index = self.stack.currentIndex()
|
||||
current_view = (
|
||||
@@ -247,7 +263,7 @@ class BECMainApp(BECMainWindow):
|
||||
developer_view_step = self.guided_tour.register_widget(
|
||||
widget=sidebar_developer_view,
|
||||
title="Developer View",
|
||||
text="Click here to access the Developer view to write scripts and makros.",
|
||||
text="Click here to access the Developer view to write scripts and macros.",
|
||||
)
|
||||
tour_steps.append(developer_view_step)
|
||||
|
||||
@@ -378,6 +394,7 @@ def main(): # pragma: no cover
|
||||
args, qt_args = parser.parse_known_args(sys.argv[1:])
|
||||
|
||||
app = QApplication([sys.argv[0], *qt_args])
|
||||
app.setApplicationName("BEC")
|
||||
apply_theme("dark")
|
||||
w = BECMainApp(show_examples=args.examples)
|
||||
|
||||
|
||||
35
bec_widgets/applications/views/admin_view/admin_view.py
Normal file
35
bec_widgets/applications/views/admin_view/admin_view.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Module for Admin View."""
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
|
||||
|
||||
|
||||
class AdminView(ViewBase):
|
||||
"""
|
||||
A view for administrators to change the current active experiment, manage messaging
|
||||
services, and more tasks reserved for users with admin privileges.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, view_id=view_id, title=title)
|
||||
self.admin_widget = BECAtlasAdminView(parent=self)
|
||||
self.set_content(self.admin_widget)
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> None:
|
||||
"""Called before the view is hidden.
|
||||
|
||||
Default implementation does nothing. Override in subclasses.
|
||||
"""
|
||||
self.admin_widget.logout()
|
||||
@@ -16,9 +16,9 @@ from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell
|
||||
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ class DeveloperWidget(DockAreaWidget):
|
||||
|
||||
self.console = BECShell(self, rpc_exposed=False)
|
||||
self.console.setObjectName("BEC Shell")
|
||||
self.terminal = WebConsole(self, rpc_exposed=False)
|
||||
self.terminal = BecConsole(self, rpc_exposed=False)
|
||||
self.terminal.setObjectName("Terminal")
|
||||
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
|
||||
self.monaco.setObjectName("MonacoEditor")
|
||||
@@ -410,23 +410,3 @@ class DeveloperWidget(DockAreaWidget):
|
||||
"""Clean up resources used by the developer widget."""
|
||||
self.delete_all()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
_app.show()
|
||||
# developer_view.show()
|
||||
# developer_view.setWindowTitle("Developer View")
|
||||
# developer_view.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -169,7 +169,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
self._upload_redis_dialog: UploadRedisDialog | None = None
|
||||
self._dialog_validation_connection: QMetaObject.Connection | None = None
|
||||
|
||||
# NOTE: We need here a seperate config helper instance to avoid conflicts with
|
||||
# NOTE: We need here a separate config helper instance to avoid conflicts with
|
||||
# other communications to REDIS as uploading a config through a CommunicationConfigAction
|
||||
# will block if we use the config_helper from self.client.config._config_helper
|
||||
self._config_helper = config_helper.ConfigHelper(self.client.connector)
|
||||
@@ -607,8 +607,8 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
self.device_table_view._is_config_in_sync_with_redis()
|
||||
)
|
||||
validation_results = self.device_table_view.get_validation_results()
|
||||
for config, config_status, connnection_status in validation_results.values():
|
||||
if connnection_status == ConnectionStatus.CONNECTED.value:
|
||||
for config, config_status, connection_status in validation_results.values():
|
||||
if connection_status == ConnectionStatus.CONNECTED.value:
|
||||
self.device_table_view.update_device_validation(
|
||||
config, config_status, ConnectionStatus.CAN_CONNECT, ""
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ logger = bec_logger.logger
|
||||
|
||||
|
||||
class _WidgetsEnumType(str, enum.Enum):
|
||||
"""Enum for the available widgets, to be generated programatically"""
|
||||
"""Enum for the available widgets, to be generated programmatically"""
|
||||
|
||||
...
|
||||
|
||||
@@ -32,6 +32,7 @@ _Widgets = {
|
||||
"BECQueue": "BECQueue",
|
||||
"BECShell": "BECShell",
|
||||
"BECStatusBox": "BECStatusBox",
|
||||
"BecConsole": "BecConsole",
|
||||
"DapComboBox": "DapComboBox",
|
||||
"DeviceBrowser": "DeviceBrowser",
|
||||
"Heatmap": "Heatmap",
|
||||
@@ -56,7 +57,6 @@ _Widgets = {
|
||||
"SignalLabel": "SignalLabel",
|
||||
"TextBox": "TextBox",
|
||||
"Waveform": "Waveform",
|
||||
"WebConsole": "WebConsole",
|
||||
"WebsiteWidget": "WebsiteWidget",
|
||||
}
|
||||
|
||||
@@ -89,6 +89,16 @@ except ImportError as e:
|
||||
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
|
||||
|
||||
|
||||
class AdminView(RPCBase):
|
||||
"""A view for administrators to change the current active experiment, manage messaging"""
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
Switch the parent application to this view.
|
||||
"""
|
||||
|
||||
|
||||
class AutoUpdates(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
@@ -496,7 +506,7 @@ class BECQueue(RPCBase):
|
||||
|
||||
|
||||
class BECShell(RPCBase):
|
||||
"""A WebConsole pre-configured to run the BEC shell."""
|
||||
"""A BecConsole pre-configured to run the BEC shell."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
@@ -681,6 +691,28 @@ class BaseROI(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class BecConsole(RPCBase):
|
||||
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
|
||||
|
||||
@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):
|
||||
"""Circular Region of Interest with center/diameter tracking and auto-labeling."""
|
||||
|
||||
@@ -975,7 +1007,7 @@ class Curve(RPCBase):
|
||||
|
||||
|
||||
class DapComboBox(RPCBase):
|
||||
"""The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC."""
|
||||
"""Editable combobox listing the available DAP models."""
|
||||
|
||||
@rpc_call
|
||||
def select_y_axis(self, y_axis: str):
|
||||
@@ -1001,7 +1033,7 @@ class DapComboBox(RPCBase):
|
||||
Slot to update the fit model.
|
||||
|
||||
Args:
|
||||
default_device(str): Default device name.
|
||||
fit_name(str): Fit model name.
|
||||
"""
|
||||
|
||||
|
||||
@@ -6407,28 +6439,6 @@ class WaveformViewPopup(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class WebConsole(RPCBase):
|
||||
"""A simple widget to display a 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"""
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
|
||||
from typing import TYPE_CHECKING, Callable, Literal, TypeAlias, cast
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.endpoints import EndpointInfo, MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
from rich.console import Console
|
||||
@@ -232,6 +232,11 @@ class BECGuiClient(RPCBase):
|
||||
"""The launcher object."""
|
||||
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:
|
||||
"""Connect to a GUI server"""
|
||||
# Unregister the old callback
|
||||
@@ -247,10 +252,9 @@ class BECGuiClient(RPCBase):
|
||||
self._ipython_registry = {}
|
||||
|
||||
# Register the new callback
|
||||
self._client.connector.register(
|
||||
self._safe_register_stream(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id),
|
||||
cb=self._handle_registry_update,
|
||||
parent=self,
|
||||
from_start=True,
|
||||
)
|
||||
|
||||
@@ -531,20 +535,14 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
def _start(self, wait: bool = False) -> None:
|
||||
self._killed = False
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id),
|
||||
cb=self._handle_registry_update,
|
||||
parent=self,
|
||||
self._safe_register_stream(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
)
|
||||
return self._start_server(wait=wait)
|
||||
|
||||
@staticmethod
|
||||
def _handle_registry_update(
|
||||
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
|
||||
) -> None:
|
||||
def _handle_registry_update(self, msg: dict[str, GUIRegistryStateMessage]) -> None:
|
||||
# This was causing a deadlock during shutdown, not sure why.
|
||||
# with self._lock:
|
||||
self = parent
|
||||
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
|
||||
self._update_dynamic_namespace(self._server_registry)
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ logger = bec_logger.logger
|
||||
if self._base:
|
||||
self.content += """
|
||||
class _WidgetsEnumType(str, enum.Enum):
|
||||
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
|
||||
\"\"\" Enum for the available widgets, to be generated programmatically \"\"\"
|
||||
...
|
||||
"""
|
||||
|
||||
|
||||
@@ -248,9 +248,7 @@ class RPCBase:
|
||||
self._rpc_response = None
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
cb=self._on_rpc_response,
|
||||
parent=self,
|
||||
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
||||
)
|
||||
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
@@ -276,11 +274,10 @@ class RPCBase:
|
||||
self._rpc_response = None
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
|
||||
@staticmethod
|
||||
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
|
||||
def _on_rpc_response(self, msg_obj: MessageObject) -> None:
|
||||
msg = cast(messages.RequestResponseMessage, msg_obj.value)
|
||||
parent._rpc_response = msg
|
||||
parent._msg_wait_event.set()
|
||||
self._rpc_response = msg
|
||||
self._msg_wait_event.set()
|
||||
|
||||
def _create_widget_from_msg_result(self, msg_result):
|
||||
if msg_result is None:
|
||||
|
||||
@@ -167,7 +167,7 @@ class BECConnector:
|
||||
)
|
||||
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
|
||||
# If the gui_id is passed, it should be respected. However, this should be revisted since
|
||||
# If the gui_id is passed, it should be respected. However, this should be revisited since
|
||||
# the gui_id has to be unique, and may no longer be.
|
||||
if gui_id:
|
||||
self.config.gui_id = gui_id
|
||||
@@ -399,7 +399,7 @@ class BECConnector:
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
# FIXME some thoughts are required to decide how this should work with rpc registry
|
||||
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
|
||||
"""
|
||||
Apply the configuration to the widget.
|
||||
@@ -417,7 +417,7 @@ class BECConnector:
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
# FIXME some thoughts are required to decide how this should work with rpc registry
|
||||
def load_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Load the configuration of the widget from YAML.
|
||||
|
||||
@@ -175,12 +175,15 @@ class BECDispatcher:
|
||||
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
|
||||
"""
|
||||
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
|
||||
if qt_slot not in self._registered_slots:
|
||||
self._registered_slots[qt_slot] = qt_slot
|
||||
qt_slot = self._registered_slots[qt_slot]
|
||||
self.client.connector.register(topics, cb=qt_slot, **kwargs)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
qt_slot.topics.update(set(topics_str))
|
||||
if not self.client.connector.any_stream_is_registered(topics, qt_slot):
|
||||
if qt_slot not in self._registered_slots:
|
||||
self._registered_slots[qt_slot] = qt_slot
|
||||
qt_slot = self._registered_slots[qt_slot]
|
||||
self.client.connector.register(topics, cb=qt_slot, **kwargs)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
qt_slot.topics.update(set(topics_str))
|
||||
else:
|
||||
logger.warning(f"Attempted to create duplicate stream subscription for {topics=}")
|
||||
|
||||
def disconnect_slot(
|
||||
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
|
||||
|
||||
@@ -43,7 +43,7 @@ class WidgetContainerUtils:
|
||||
if list_of_names is None:
|
||||
list_of_names = []
|
||||
ii = 0
|
||||
while ii < 1000: # 1000 is arbritrary!
|
||||
while ii < 1000: # 1000 is arbitrary!
|
||||
name_candidate = f"{name}_{ii}"
|
||||
if name_candidate not in list_of_names:
|
||||
return name_candidate
|
||||
|
||||
@@ -71,7 +71,7 @@ class FormItemSpec(BaseModel):
|
||||
"""
|
||||
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
|
||||
to store most annotation info, since one of the main purposes is to store data for
|
||||
forms genrated from pydantic models, but can also be composed from other sources or by hand.
|
||||
forms generated from pydantic models, but can also be composed from other sources or by hand.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
@@ -192,7 +192,7 @@ class DynamicFormItem(QWidget):
|
||||
@abstractmethod
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget: QWidget
|
||||
"""Add the main data entry widget to self._main_widget and appply any
|
||||
"""Add the main data entry widget to self._main_widget and apply any
|
||||
constraints from the field info"""
|
||||
|
||||
@SafeSlot()
|
||||
|
||||
35
bec_widgets/utils/fuzzy_search.py
Normal file
35
bec_widgets/utils/fuzzy_search.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Module providing fuzzy search utilities for the BEC widgets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from thefuzz import fuzz
|
||||
|
||||
FUZZY_SEARCH_THRESHOLD = 80
|
||||
|
||||
|
||||
def is_match(
|
||||
text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the text matches any of the relevant keys in the row data.
|
||||
|
||||
Args:
|
||||
text (str): The text to search for.
|
||||
row_data (dict[str, Any]): The row data to search in.
|
||||
relevant_keys (list[str]): The keys to consider for searching.
|
||||
enable_fuzzy (bool): Whether to use fuzzy matching.
|
||||
Returns:
|
||||
bool: True if a match is found, False otherwise.
|
||||
"""
|
||||
for key in relevant_keys:
|
||||
data = str(row_data.get(key, "") or "")
|
||||
if enable_fuzzy:
|
||||
match_ratio = fuzz.partial_ratio(text.lower(), data.lower())
|
||||
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
|
||||
return True
|
||||
else:
|
||||
if text.lower() in data.lower():
|
||||
return True
|
||||
return False
|
||||
@@ -15,7 +15,7 @@ class Kind(IFBase):
|
||||
"""
|
||||
This is used in the .kind attribute of all OphydObj (Signals, Devices).
|
||||
|
||||
A Device examines its components' .kind atttribute to decide whether to
|
||||
A Device examines its components' .kind attribute to decide whether to
|
||||
traverse it in read(), read_configuration(), or neither. Additionally, if
|
||||
decides whether to include its name in `hints['fields']`.
|
||||
"""
|
||||
|
||||
@@ -22,7 +22,7 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
return QWidget()
|
||||
t = {plugin_name_pascal}(parent)
|
||||
return t
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ class RPCServer:
|
||||
if method == "raise" and hasattr(
|
||||
obj, "setWindowState"
|
||||
): # special case for raising windows, should work even if minimized
|
||||
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
|
||||
# this is a special case for raising windows for gnome on Red Hat (RHEL) 9 systems where changing focus is suppressed by default
|
||||
# The procedure is as follows:
|
||||
# 1. Get the current window state to check if the window is minimized and remove minimized flag
|
||||
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
|
||||
@@ -442,5 +442,5 @@ class RPCServer:
|
||||
self.status = messages.BECStatus.IDLE
|
||||
self._heartbeat_timer.stop()
|
||||
self.emit_heartbeat()
|
||||
logger.info("Succeded in shutting down CLI server")
|
||||
logger.info("Succeeded in shutting down CLI server")
|
||||
self.client.shutdown()
|
||||
|
||||
@@ -35,16 +35,19 @@ logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
def create_action_with_text(toolbar_action, toolbar: QToolBar):
|
||||
def create_action_with_text(toolbar_action, toolbar: QToolBar, min_size: QSize | None = None):
|
||||
"""
|
||||
Helper function to create a toolbar button with text beside or under the icon.
|
||||
|
||||
Args:
|
||||
toolbar_action(ToolBarAction): The toolbar action to create the button for.
|
||||
toolbar(ModularToolBar): The toolbar to add the button to.
|
||||
min_size(QSize, optional): The minimum size for the button. Defaults to None.
|
||||
"""
|
||||
|
||||
btn = QToolButton(parent=toolbar)
|
||||
if min_size is not None:
|
||||
btn.setMinimumSize(min_size)
|
||||
if getattr(toolbar_action, "label_text", None):
|
||||
toolbar_action.action.setText(toolbar_action.label_text)
|
||||
if getattr(toolbar_action, "tooltip", None):
|
||||
|
||||
@@ -69,7 +69,7 @@ from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole
|
||||
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||
@@ -372,7 +372,7 @@ class BECDockArea(DockAreaWidget):
|
||||
"Add Circular ProgressBar",
|
||||
"RingProgressBar",
|
||||
),
|
||||
"terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
|
||||
"terminal": (BecConsole.ICON_NAME, "Add Terminal", "BecConsole"),
|
||||
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
|
||||
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
|
||||
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
|
||||
|
||||
@@ -1,27 +1,83 @@
|
||||
import sys
|
||||
|
||||
from qtpy import QtGui, QtWidgets
|
||||
from qtpy.QtCore import QPoint, Qt
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QProgressBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class WidgetTooltip(QWidget):
|
||||
"""Frameless, always-on-top window that behaves like a tooltip."""
|
||||
|
||||
def __init__(self, content: QWidget) -> None:
|
||||
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
super().__init__(
|
||||
None,
|
||||
Qt.WindowType.ToolTip
|
||||
| Qt.WindowType.FramelessWindowHint
|
||||
| Qt.WindowType.WindowStaysOnTopHint,
|
||||
)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setMouseTracking(True)
|
||||
self.content = content
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(6, 6, 6, 6)
|
||||
layout.addWidget(self.content)
|
||||
layout.setContentsMargins(14, 14, 14, 14)
|
||||
|
||||
self._card = QFrame(self)
|
||||
self._card.setObjectName("WidgetTooltipCard")
|
||||
card_layout = QVBoxLayout(self._card)
|
||||
card_layout.setContentsMargins(12, 10, 12, 10)
|
||||
card_layout.addWidget(self.content)
|
||||
|
||||
shadow = QtWidgets.QGraphicsDropShadowEffect(self._card)
|
||||
shadow.setBlurRadius(18)
|
||||
shadow.setOffset(0, 2)
|
||||
shadow.setColor(QtGui.QColor(0, 0, 0, 140))
|
||||
self._card.setGraphicsEffect(shadow)
|
||||
|
||||
layout.addWidget(self._card)
|
||||
self.apply_theme()
|
||||
self.adjustSize()
|
||||
|
||||
def leaveEvent(self, _event) -> None:
|
||||
self.hide()
|
||||
|
||||
def apply_theme(self) -> None:
|
||||
palette = QApplication.palette()
|
||||
base = palette.color(QtGui.QPalette.ColorRole.Base)
|
||||
text = palette.color(QtGui.QPalette.ColorRole.Text)
|
||||
border = palette.color(QtGui.QPalette.ColorRole.Mid)
|
||||
background = QtGui.QColor(base)
|
||||
background.setAlpha(242)
|
||||
self._card.setStyleSheet(f"""
|
||||
QFrame#WidgetTooltipCard {{
|
||||
background: {background.name(QtGui.QColor.NameFormat.HexArgb)};
|
||||
border: 1px solid {border.name()};
|
||||
border-radius: 12px;
|
||||
}}
|
||||
QFrame#WidgetTooltipCard QLabel {{
|
||||
color: {text.name()};
|
||||
background: transparent;
|
||||
}}
|
||||
""")
|
||||
|
||||
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
|
||||
"""
|
||||
Show the tooltip above a global position, adjusting to stay within screen bounds.
|
||||
|
||||
Args:
|
||||
global_pos(QPoint): The global position to show above.
|
||||
offset(int, optional): The vertical offset from the global position. Defaults to 8 pixels.
|
||||
"""
|
||||
self.apply_theme()
|
||||
self.adjustSize()
|
||||
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
|
||||
screen_geo = screen.availableGeometry()
|
||||
@@ -30,11 +86,43 @@ class WidgetTooltip(QWidget):
|
||||
x = global_pos.x() - geom.width() // 2
|
||||
y = global_pos.y() - geom.height() - offset
|
||||
|
||||
self._navigate_screen_coordinates(screen_geo, geom, x, y)
|
||||
|
||||
def show_near(self, global_pos: QPoint, offset: QPoint | None = None) -> None:
|
||||
"""
|
||||
Show the tooltip near a global position, adjusting to stay within screen bounds.
|
||||
By default, it will try to show below and to the right of the position,
|
||||
but if that would cause it to go off-screen, it will flip to the other side.
|
||||
|
||||
Args:
|
||||
global_pos(QPoint): The global position to show near.
|
||||
offset(QPoint, optional): The offset from the global position. Defaults to QPoint(12, 16).
|
||||
"""
|
||||
|
||||
self.apply_theme()
|
||||
self.adjustSize()
|
||||
offset = offset or QPoint(12, 16)
|
||||
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
|
||||
screen_geo = screen.availableGeometry()
|
||||
geom = self.geometry()
|
||||
|
||||
x = global_pos.x() + offset.x()
|
||||
y = global_pos.y() + offset.y()
|
||||
|
||||
if x + geom.width() > screen_geo.right():
|
||||
x = global_pos.x() - geom.width() - abs(offset.x())
|
||||
if y + geom.height() > screen_geo.bottom():
|
||||
y = global_pos.y() - geom.height() - abs(offset.y())
|
||||
|
||||
self._navigate_screen_coordinates(screen_geo, geom, x, y)
|
||||
|
||||
def _navigate_screen_coordinates(self, screen_geo, geom, x, y):
|
||||
x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
|
||||
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
|
||||
|
||||
self.move(x, y)
|
||||
self.show()
|
||||
self.raise_()
|
||||
|
||||
|
||||
class HoverWidget(QWidget):
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .positioner_box_base import PositionerBoxBase
|
||||
|
||||
__ALL__ = ["PositionerBoxBase"]
|
||||
@@ -14,9 +14,9 @@ from qtpy.QtWidgets import QDoubleSpinBox
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
|
||||
DeviceUpdateUIComponents,
|
||||
PositionerBoxBase,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -63,10 +63,10 @@ class PositionerBox(PositionerBoxBase):
|
||||
|
||||
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
|
||||
|
||||
self.addWidget(self.ui)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
|
||||
self.main_layout.addWidget(self.ui)
|
||||
self.main_layout.setSpacing(0)
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
|
||||
ui_min_size = self.ui.minimumSize()
|
||||
ui_min_hint = self.ui.minimumSizeHint()
|
||||
self.setMinimumSize(
|
||||
@@ -115,8 +115,6 @@ class PositionerBox(PositionerBoxBase):
|
||||
return
|
||||
old_device = self._device
|
||||
self._device = value
|
||||
if not self.label:
|
||||
self.label = value
|
||||
self.device_changed.emit(old_device, value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
|
||||
@@ -15,9 +15,9 @@ from qtpy.QtWidgets import QDoubleSpinBox
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
|
||||
DeviceUpdateUIComponents,
|
||||
PositionerBoxBase,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -96,9 +96,9 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
|
||||
def connect_ui(self):
|
||||
"""Connect the UI components to signals, data, or routines"""
|
||||
self.addWidget(self.ui)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.addWidget(self.ui)
|
||||
self.main_layout.setSpacing(0)
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
def _init_ui(val: QDoubleValidator, device_id: DeviceId):
|
||||
ui = self._device_ui_components_hv(device_id)
|
||||
@@ -200,7 +200,6 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
return
|
||||
old_device = self._device_hor
|
||||
self._device_hor = value
|
||||
self.label = f"{self._device_hor}, {self._device_ver}"
|
||||
self.device_changed_hor.emit(old_device, value)
|
||||
self._init_device(self.device_hor, self.position_update_hor.emit, self.update_limits_hor)
|
||||
|
||||
@@ -220,7 +219,6 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
return
|
||||
old_device = self._device_ver
|
||||
self._device_ver = value
|
||||
self.label = f"{self._device_hor}, {self._device_ver}"
|
||||
self.device_changed_ver.emit(old_device, value)
|
||||
self._init_device(self.device_ver, self.position_update_ver.emit, self.update_limits_ver)
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ from qtpy.QtWidgets import (
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
|
||||
PositionIndicator,
|
||||
)
|
||||
@@ -43,7 +43,7 @@ class DeviceUpdateUIComponents(TypedDict):
|
||||
units: QLabel
|
||||
|
||||
|
||||
class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
class PositionerBoxBase(BECWidget, QWidget):
|
||||
"""Contains some core logic for positioner box widgets"""
|
||||
|
||||
current_path = ""
|
||||
@@ -57,7 +57,10 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
parent: The parent widget.
|
||||
device (Positioner): The device to control.
|
||||
"""
|
||||
super().__init__(parent=parent, layout=QVBoxLayout, **kwargs)
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
self._dialog = None
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@@ -173,11 +176,9 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
if is_moving:
|
||||
spinner.start()
|
||||
spinner.setToolTip("Device is moving")
|
||||
self.set_global_state("warning")
|
||||
else:
|
||||
spinner.stop()
|
||||
spinner.setToolTip("Device is idle")
|
||||
self.set_global_state("success")
|
||||
else:
|
||||
spinner.setVisible(False)
|
||||
|
||||
@@ -196,9 +197,8 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
|
||||
position_indicator.set_value(pos)
|
||||
|
||||
def _update_limits_ui(
|
||||
self, limits: tuple[float, float], position_indicator, setpoint_validator
|
||||
):
|
||||
@staticmethod
|
||||
def _update_limits_ui(limits: tuple[float, float], position_indicator, setpoint_validator):
|
||||
if limits is not None and limits[0] != limits[1]:
|
||||
position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
|
||||
setpoint_validator.setRange(limits[0], limits[1])
|
||||
@@ -223,8 +223,9 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
|
||||
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
|
||||
|
||||
def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
|
||||
"""Toogle enable/disable on available buttons
|
||||
@staticmethod
|
||||
def _toggle_enable_buttons(ui: DeviceUpdateUIComponents, enable: bool) -> None:
|
||||
"""Toggle enable/disable on available buttons
|
||||
|
||||
Args:
|
||||
enable (bool): Enable buttons
|
||||
@@ -1,6 +1,8 @@
|
||||
import os
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QSizePolicy
|
||||
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
|
||||
@@ -22,7 +24,82 @@ class PositionerControlLine(PositionerBox):
|
||||
device (Positioner): The device to control.
|
||||
"""
|
||||
self.current_path = os.path.dirname(__file__)
|
||||
self._indicator_switch_width = 0
|
||||
self._horizontal_indicator_width = 0
|
||||
self._vertical_indicator_width = 15
|
||||
self._indicator_thickness = 10
|
||||
self._indicator_is_horizontal = False
|
||||
self._line_height = self.dimensions[0]
|
||||
super().__init__(parent=parent, device=device, *args, **kwargs)
|
||||
self._configure_line_layout()
|
||||
self._update_indicator_orientation()
|
||||
|
||||
def _configure_line_layout(self):
|
||||
device_box = self.ui.device_box
|
||||
indicator = self.ui.position_indicator
|
||||
|
||||
self.main_layout.setAlignment(Qt.AlignmentFlag(0))
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.ui.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
device_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
|
||||
self._line_height = max(
|
||||
self.dimensions[0],
|
||||
self.ui.minimumSizeHint().height(),
|
||||
self.ui.sizeHint().height(),
|
||||
device_box.minimumSizeHint().height(),
|
||||
device_box.sizeHint().height(),
|
||||
)
|
||||
device_box.setFixedHeight(self._line_height)
|
||||
device_box.setMinimumWidth(self.dimensions[1])
|
||||
device_box.setMaximumWidth(16777215)
|
||||
self.setFixedHeight(self._line_height)
|
||||
self.setMinimumWidth(self.dimensions[1])
|
||||
|
||||
self.ui.verticalLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.ui.verticalLayout.setSpacing(0)
|
||||
self.ui.readback.setMaximumWidth(16777215)
|
||||
self.ui.setpoint.setMaximumWidth(16777215)
|
||||
self.ui.step_size.setMaximumWidth(16777215)
|
||||
|
||||
indicator_hint = indicator.minimumSizeHint()
|
||||
step_hint = self.ui.step_size.sizeHint()
|
||||
self._indicator_thickness = max(indicator_hint.height(), 10)
|
||||
self._vertical_indicator_width = max(indicator.minimumWidth(), 15)
|
||||
self._horizontal_indicator_width = max(90, step_hint.width())
|
||||
base_width = max(device_box.minimumSizeHint().width(), self.dimensions[1])
|
||||
self._indicator_switch_width = (
|
||||
base_width - self._vertical_indicator_width + self._horizontal_indicator_width
|
||||
)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._update_indicator_orientation()
|
||||
|
||||
def _update_indicator_orientation(self):
|
||||
if not hasattr(self, "ui"):
|
||||
return
|
||||
|
||||
indicator = self.ui.position_indicator
|
||||
available_width = self.ui.device_box.width() or self.width() or self.dimensions[1]
|
||||
should_use_horizontal = available_width >= self._indicator_switch_width
|
||||
if should_use_horizontal == self._indicator_is_horizontal:
|
||||
return
|
||||
|
||||
self._indicator_is_horizontal = should_use_horizontal
|
||||
indicator.vertical = not should_use_horizontal
|
||||
|
||||
if should_use_horizontal:
|
||||
indicator.setMinimumSize(self._horizontal_indicator_width, self._indicator_thickness)
|
||||
indicator.setMaximumHeight(self._indicator_thickness)
|
||||
indicator.setMaximumWidth(16777215)
|
||||
indicator.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed)
|
||||
else:
|
||||
indicator.setMinimumSize(self._vertical_indicator_width, self._indicator_thickness)
|
||||
indicator.setMaximumSize(self._vertical_indicator_width, 16777215)
|
||||
indicator.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
|
||||
|
||||
indicator.updateGeometry()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>612</width>
|
||||
<height>91</height>
|
||||
<width>592</width>
|
||||
<height>76</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
@@ -26,8 +32,29 @@
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="device_box">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Device Name</string>
|
||||
</property>
|
||||
@@ -227,12 +254,12 @@
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>PositionIndicator</class>
|
||||
<extends>QWidget</extends>
|
||||
<extends></extends>
|
||||
<header>position_indicator</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>SpinnerWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<extends></extends>
|
||||
<header>spinner_widget</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
|
||||
@@ -27,30 +27,13 @@ class PositionerGroupBox(QGroupBox):
|
||||
self.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self.layout().setSpacing(0)
|
||||
self.widget = PositionerBox(self, dev_name)
|
||||
self.widget.compact_view = True
|
||||
self.widget.expand_popup = False
|
||||
self.layout().addWidget(self.widget)
|
||||
self.widget.position_update.connect(self._on_position_update)
|
||||
self.widget.expand.connect(self._on_expand)
|
||||
self.setTitle(self.device_name)
|
||||
self.widget.force_update_readback()
|
||||
|
||||
def _on_expand(self, expand):
|
||||
if expand:
|
||||
self.setTitle("")
|
||||
self.setFlat(True)
|
||||
else:
|
||||
self.setTitle(self.device_name)
|
||||
self.setFlat(False)
|
||||
|
||||
def _on_position_update(self, pos: float):
|
||||
self.position_update.emit(pos)
|
||||
precision = getattr(self.widget.dev[self.widget.device], "precision", 8)
|
||||
try:
|
||||
precision = int(precision)
|
||||
except (TypeError, ValueError):
|
||||
precision = int(8)
|
||||
self.widget.label = f"{pos:.{precision}f}"
|
||||
|
||||
def close(self):
|
||||
self.widget.close()
|
||||
|
||||
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
||||
from .available_device_group import AvailableDeviceGroup
|
||||
|
||||
|
||||
class _DeviceListWiget(QListWidget):
|
||||
class _DeviceListWidget(QListWidget):
|
||||
|
||||
def _item_iter(self):
|
||||
return (self.item(i) for i in range(self.count()))
|
||||
@@ -44,7 +44,7 @@ class Ui_AvailableDeviceGroup(object):
|
||||
self.n_included.setObjectName("n_included")
|
||||
title_layout.addWidget(self.n_included)
|
||||
|
||||
self.device_list = _DeviceListWiget(AvailableDeviceGroup)
|
||||
self.device_list = _DeviceListWidget(AvailableDeviceGroup)
|
||||
self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
||||
self.device_list.setObjectName("device_list")
|
||||
self.device_list.setFrameStyle(0)
|
||||
|
||||
@@ -34,13 +34,13 @@ class HashModel(str, Enum):
|
||||
class DeviceResourceBackend(Protocol):
|
||||
@property
|
||||
def tag_groups(self) -> dict[str, set[HashableDevice]]:
|
||||
"""A dictionary of all availble devices separated by tag groups. The same device may
|
||||
"""A dictionary of all available devices separated by tag groups. The same device may
|
||||
appear more than once (in different groups)."""
|
||||
...
|
||||
|
||||
@property
|
||||
def all_devices(self) -> set[HashableDevice]:
|
||||
"""A set of all availble devices. The same device may not appear more than once."""
|
||||
"""A set of all available devices. The same device may not appear more than once."""
|
||||
...
|
||||
|
||||
@property
|
||||
|
||||
@@ -5,9 +5,8 @@ in DeviceTableRow entries.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Tuple
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceModel
|
||||
from bec_lib.callback_handler import EventType
|
||||
@@ -19,6 +18,7 @@ from thefuzz import fuzz
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.fuzzy_search import is_match
|
||||
from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import (
|
||||
DeviceTableRow,
|
||||
)
|
||||
@@ -37,34 +37,6 @@ _DeviceCfgIter = Iterable[dict[str, Any]]
|
||||
# DeviceValidationResult: device_config, config_status, connection_status, error_message
|
||||
_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]]
|
||||
|
||||
FUZZY_SEARCH_THRESHOLD = 80
|
||||
|
||||
|
||||
def is_match(
|
||||
text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the text matches any of the relevant keys in the row data.
|
||||
|
||||
Args:
|
||||
text (str): The text to search for.
|
||||
row_data (dict[str, Any]): The row data to search in.
|
||||
relevant_keys (list[str]): The keys to consider for searching.
|
||||
enable_fuzzy (bool): Whether to use fuzzy matching.
|
||||
Returns:
|
||||
bool: True if a match is found, False otherwise.
|
||||
"""
|
||||
for key in relevant_keys:
|
||||
data = str(row_data.get(key, "") or "")
|
||||
if enable_fuzzy:
|
||||
match_ratio = fuzz.partial_ratio(text.lower(), data.lower())
|
||||
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
|
||||
return True
|
||||
else:
|
||||
if text.lower() in data.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TableSortOnHold:
|
||||
"""Context manager for putting table sorting on hold. Works with nested calls."""
|
||||
|
||||
@@ -347,14 +347,14 @@ class ScanGroupBox(QGroupBox):
|
||||
|
||||
def get_parameters(self, device_object: bool = True):
|
||||
"""
|
||||
Returns the parameters from the widgets in the scan control layout formated to run scan from BEC.
|
||||
Returns the parameters from the widgets in the scan control layout formatted to run scan from BEC.
|
||||
"""
|
||||
if self.box_type == "args":
|
||||
return self._get_arg_parameterts(device_object=device_object)
|
||||
return self._get_arg_parameters(device_object=device_object)
|
||||
elif self.box_type == "kwargs":
|
||||
return self._get_kwarg_parameters(device_object=device_object)
|
||||
|
||||
def _get_arg_parameterts(self, device_object: bool = True):
|
||||
def _get_arg_parameters(self, device_object: bool = True):
|
||||
args = []
|
||||
for i in range(1, self.layout.rowCount()):
|
||||
for j in range(self.layout.columnCount()):
|
||||
|
||||
@@ -2,22 +2,19 @@
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QComboBox
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DapComboBox(BECWidget, QWidget):
|
||||
class DapComboBox(BECWidget, QComboBox):
|
||||
"""
|
||||
The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.
|
||||
Editable combobox listing the available DAP models.
|
||||
|
||||
Args:
|
||||
parent: Parent widget.
|
||||
client: BEC client object.
|
||||
gui_id: GUI ID.
|
||||
default: Default device name.
|
||||
The widget behaves as a plain QComboBox and keeps ``fit_model_combobox`` as an alias to itself
|
||||
for backwards compatibility with older call sites.
|
||||
"""
|
||||
|
||||
ICON_NAME = "data_exploration"
|
||||
@@ -45,19 +42,20 @@ class DapComboBox(BECWidget, QWidget):
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.fit_model_combobox = QComboBox(self)
|
||||
self.layout.addWidget(self.fit_model_combobox)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._available_models = None
|
||||
self.fit_model_combobox = self # Just for backwards compatibility with older call sites, the widget itself is the combobox
|
||||
self._available_models: list[str] = []
|
||||
self._x_axis = None
|
||||
self._y_axis = None
|
||||
self.populate_fit_model_combobox()
|
||||
self.fit_model_combobox.currentTextChanged.connect(self._update_current_fit)
|
||||
# Set default fit model
|
||||
self.select_default_fit(default_fit)
|
||||
self._is_valid_input = False
|
||||
|
||||
def select_default_fit(self, default_fit: str | None):
|
||||
self.setEditable(True)
|
||||
|
||||
self.populate_fit_model_combobox()
|
||||
self.currentTextChanged.connect(self._on_text_changed)
|
||||
self.select_default_fit(default_fit)
|
||||
self.check_validity(self.currentText())
|
||||
|
||||
def select_default_fit(self, default_fit: str | None = "GaussianModel"):
|
||||
"""Set the default fit model.
|
||||
|
||||
Args:
|
||||
@@ -65,8 +63,8 @@ class DapComboBox(BECWidget, QWidget):
|
||||
"""
|
||||
if self._validate_dap_model(default_fit):
|
||||
self.select_fit_model(default_fit)
|
||||
else:
|
||||
self.select_fit_model("GaussianModel")
|
||||
elif self.available_models:
|
||||
self.select_fit_model(self.available_models[0])
|
||||
|
||||
@property
|
||||
def available_models(self):
|
||||
@@ -114,12 +112,40 @@ class DapComboBox(BECWidget, QWidget):
|
||||
self._y_axis = y_axis
|
||||
self.y_axis_updated.emit(y_axis)
|
||||
|
||||
def _update_current_fit(self, fit_name: str):
|
||||
"""Update the current fit."""
|
||||
@Slot(str)
|
||||
def _on_text_changed(self, fit_name: str):
|
||||
"""
|
||||
Validate and emit updates for the current text.
|
||||
|
||||
Args:
|
||||
fit_name(str): The current text in the combobox, representing the selected fit model.
|
||||
"""
|
||||
self.check_validity(fit_name)
|
||||
if not self._is_valid_input:
|
||||
return
|
||||
|
||||
self.fit_model_updated.emit(fit_name)
|
||||
if self.x_axis is not None and self.y_axis is not None:
|
||||
self.new_dap_config.emit(self._x_axis, self._y_axis, fit_name)
|
||||
|
||||
@Slot(str)
|
||||
def check_validity(self, fit_name: str):
|
||||
"""
|
||||
Highlight invalid manual entries similarly to DeviceComboBox.
|
||||
|
||||
Args:
|
||||
fit_name(str): The current text in the combobox, representing the selected fit model.
|
||||
"""
|
||||
if self._validate_dap_model(fit_name):
|
||||
self._is_valid_input = True
|
||||
self.setStyleSheet("border: 1px solid transparent;")
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
if self.isEnabled():
|
||||
self.setStyleSheet("border: 1px solid red;")
|
||||
else:
|
||||
self.setStyleSheet("border: 1px solid transparent;")
|
||||
|
||||
@Slot(str)
|
||||
def select_x_axis(self, x_axis: str):
|
||||
"""Slot to update the x axis.
|
||||
@@ -128,7 +154,7 @@ class DapComboBox(BECWidget, QWidget):
|
||||
x_axis(str): X axis.
|
||||
"""
|
||||
self.x_axis = x_axis
|
||||
self._update_current_fit(self.fit_model_combobox.currentText())
|
||||
self._on_text_changed(self.currentText())
|
||||
|
||||
@Slot(str)
|
||||
def select_y_axis(self, y_axis: str):
|
||||
@@ -138,25 +164,26 @@ class DapComboBox(BECWidget, QWidget):
|
||||
y_axis(str): Y axis.
|
||||
"""
|
||||
self.y_axis = y_axis
|
||||
self._update_current_fit(self.fit_model_combobox.currentText())
|
||||
self._on_text_changed(self.currentText())
|
||||
|
||||
@Slot(str)
|
||||
def select_fit_model(self, fit_name: str | None):
|
||||
"""Slot to update the fit model.
|
||||
|
||||
Args:
|
||||
default_device(str): Default device name.
|
||||
fit_name(str): Fit model name.
|
||||
"""
|
||||
if not self._validate_dap_model(fit_name):
|
||||
raise ValueError(f"Fit {fit_name} is not valid.")
|
||||
self.fit_model_combobox.setCurrentText(fit_name)
|
||||
self.setCurrentText(fit_name)
|
||||
|
||||
def populate_fit_model_combobox(self):
|
||||
"""Populate the fit_model_combobox with the devices."""
|
||||
# pylint: disable=protected-access
|
||||
self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()]
|
||||
self.fit_model_combobox.clear()
|
||||
self.fit_model_combobox.addItems(self.available_models)
|
||||
available_plugins = getattr(getattr(self.client, "dap", None), "_available_dap_plugins", {})
|
||||
self.available_models = [model for model in available_plugins.keys()]
|
||||
self.clear()
|
||||
self.addItems(self.available_models)
|
||||
|
||||
def _validate_dap_model(self, model: str | None) -> bool:
|
||||
"""Validate the DAP model.
|
||||
@@ -166,23 +193,23 @@ class DapComboBox(BECWidget, QWidget):
|
||||
"""
|
||||
if model is None:
|
||||
return False
|
||||
if model not in self.available_models:
|
||||
return False
|
||||
return True
|
||||
return model in self.available_models
|
||||
|
||||
@property
|
||||
def is_valid_input(self) -> bool:
|
||||
"""Whether the current text matches an available DAP model."""
|
||||
return self._is_valid_input
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
layout.addWidget(DapComboBox())
|
||||
widget.show()
|
||||
app.exec_()
|
||||
dialog = DapComboBox()
|
||||
dialog.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import os
|
||||
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QPushButton, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QPushButton, QSizePolicy, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
@@ -34,7 +35,7 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Initialises the LMFitDialog widget.
|
||||
Initializes the LMFitDialog widget.
|
||||
|
||||
Args:
|
||||
parent (QWidget): The parent widget.
|
||||
@@ -68,6 +69,27 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
self._hide_curve_selection = False
|
||||
self._hide_summary = False
|
||||
self._hide_parameters = False
|
||||
self._configure_embedded_size_policy()
|
||||
|
||||
def _configure_embedded_size_policy(self):
|
||||
"""Allow the compact dialog to shrink more gracefully in embedded layouts."""
|
||||
if self._ui_file != "lmfit_dialog_compact.ui":
|
||||
return
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
self.ui.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
|
||||
for group in (
|
||||
self.ui.group_curve_selection,
|
||||
self.ui.group_summary,
|
||||
self.ui.group_parameters,
|
||||
):
|
||||
group.setMinimumHeight(0)
|
||||
group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
|
||||
for view in (self.ui.curve_list, self.ui.summary_tree, self.ui.param_tree):
|
||||
view.setMinimumHeight(0)
|
||||
view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Ignored)
|
||||
|
||||
@property
|
||||
def enable_actions(self) -> bool:
|
||||
@@ -77,8 +99,14 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
@enable_actions.setter
|
||||
def enable_actions(self, enable: bool):
|
||||
self._enable_actions = enable
|
||||
for button in self.action_buttons.values():
|
||||
valid_buttons = {}
|
||||
for name, button in self.action_buttons.items():
|
||||
# just to be sure we have a valid c++ object
|
||||
if button is None or not shiboken6.isValid(button):
|
||||
continue
|
||||
button.setEnabled(enable)
|
||||
valid_buttons[name] = button
|
||||
self.action_buttons = valid_buttons
|
||||
|
||||
@SafeProperty(list)
|
||||
def active_action_list(self) -> list[str]:
|
||||
@@ -89,16 +117,6 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
def active_action_list(self, actions: list[str]):
|
||||
self._active_actions = actions
|
||||
|
||||
# This SafeSlot needed?
|
||||
@SafeSlot(bool)
|
||||
def set_actions_enabled(self, enable: bool) -> bool:
|
||||
"""SafeSlot to enable the move to buttons.
|
||||
|
||||
Args:
|
||||
enable (bool): Whether to enable the action buttons.
|
||||
"""
|
||||
self.enable_actions = enable
|
||||
|
||||
@SafeProperty(bool)
|
||||
def always_show_latest(self):
|
||||
"""SafeProperty to indicate if always the latest DAP update is displayed."""
|
||||
@@ -154,19 +172,21 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
self.ui.group_parameters.setVisible(not show)
|
||||
|
||||
@property
|
||||
def fit_curve_id(self) -> str:
|
||||
def fit_curve_id(self) -> str | None:
|
||||
"""SafeProperty for the currently displayed fit curve_id."""
|
||||
return self._fit_curve_id
|
||||
|
||||
@fit_curve_id.setter
|
||||
def fit_curve_id(self, curve_id: str):
|
||||
def fit_curve_id(self, curve_id: str | None):
|
||||
"""Setter for the currently displayed fit curve_id.
|
||||
|
||||
Args:
|
||||
fit_curve_id (str): The curve_id of the fit curve to be displayed.
|
||||
curve_id (str | None): The curve_id of the fit curve to be displayed,
|
||||
or None to clear the selection.
|
||||
"""
|
||||
self._fit_curve_id = curve_id
|
||||
self.selected_fit.emit(curve_id)
|
||||
if curve_id is not None:
|
||||
self.selected_fit.emit(curve_id)
|
||||
|
||||
@SafeSlot(str)
|
||||
def remove_dap_data(self, curve_id: str):
|
||||
@@ -176,6 +196,15 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
curve_id (str): The curve_id of the DAP data to be removed.
|
||||
"""
|
||||
self.summary_data.pop(curve_id, None)
|
||||
if self.fit_curve_id == curve_id:
|
||||
self.action_buttons = {}
|
||||
self.ui.summary_tree.clear()
|
||||
self.ui.param_tree.clear()
|
||||
remaining = list(self.summary_data.keys())
|
||||
if remaining:
|
||||
self.fit_curve_id = remaining[0]
|
||||
else:
|
||||
self._fit_curve_id = None
|
||||
self.refresh_curve_list()
|
||||
|
||||
@SafeSlot(str)
|
||||
@@ -251,6 +280,7 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
params (list): List of LMFit parameters for the fit curve.
|
||||
"""
|
||||
self._move_buttons = []
|
||||
self.action_buttons = {}
|
||||
self.ui.param_tree.clear()
|
||||
for param in params:
|
||||
param_name = param[0]
|
||||
@@ -269,9 +299,9 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
if param_name in self.active_action_list: # pylint: disable=unsupported-membership-test
|
||||
# Create a push button to move the motor to a specific position
|
||||
widget = QWidget()
|
||||
button = QPushButton(f"Move to {param_name}")
|
||||
button = QPushButton("Move")
|
||||
button.clicked.connect(self._create_move_action(param_name, param[1]))
|
||||
if self.enable_actions is True:
|
||||
if self.enable_actions:
|
||||
button.setEnabled(True)
|
||||
else:
|
||||
button.setEnabled(False)
|
||||
|
||||
@@ -14,6 +14,18 @@
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item row="0" column="0">
|
||||
<widget class="QSplitter" name="splitter_2">
|
||||
<property name="sizePolicy">
|
||||
@@ -22,15 +34,6 @@
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::Shape::VLine</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Shadow::Plain</enum>
|
||||
</property>
|
||||
<property name="lineWidth">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
@@ -41,6 +44,12 @@
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<widget class="QGroupBox" name="group_curve_selection">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>120</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Select Curve</string>
|
||||
</property>
|
||||
@@ -58,18 +67,36 @@
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Vertical</enum>
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="QGroupBox" name="group_summary">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>180</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Fit Summary</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QTreeWidget" name="summary_tree">
|
||||
<property name="alternatingRowColors">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="indentation">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rootIsDecorated">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="uniformRowHeights">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<attribute name="headerDefaultSectionSize">
|
||||
<number>90</number>
|
||||
</attribute>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Property</string>
|
||||
@@ -85,12 +112,33 @@
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QGroupBox" name="group_parameters">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>240</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Parameter Details</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QTreeWidget" name="param_tree">
|
||||
<property name="alternatingRowColors">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="indentation">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rootIsDecorated">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="columnCount">
|
||||
<number>4</number>
|
||||
</property>
|
||||
<attribute name="headerDefaultSectionSize">
|
||||
<number>80</number>
|
||||
</attribute>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Parameter</string>
|
||||
@@ -106,6 +154,11 @@
|
||||
<string>Std</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Action</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
|
||||
@@ -95,6 +95,12 @@
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="indentation">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rootIsDecorated">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="uniformRowHeights">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
@@ -147,6 +153,12 @@
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="indentation">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rootIsDecorated">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="columnCount">
|
||||
<number>4</number>
|
||||
|
||||
0
bec_widgets/widgets/editors/bec_console/__init__.py
Normal file
0
bec_widgets/widgets/editors/bec_console/__init__.py
Normal file
605
bec_widgets/widgets/editors/bec_console/bec_console.py
Normal file
605
bec_widgets/widgets/editors/bec_console/bec_console.py
Normal file
@@ -0,0 +1,605 @@
|
||||
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_())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['bec_console.py']}
|
||||
@@ -5,17 +5,17 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='WebConsole' name='web_console'>
|
||||
<widget class='BecConsole' name='bec_console'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
class BecConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
@@ -23,20 +23,20 @@ class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = WebConsole(parent)
|
||||
t = BecConsole(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Developer"
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(WebConsole.ICON_NAME)
|
||||
return designer_material_icon(BecConsole.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "web_console"
|
||||
return "bec_console"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -48,10 +48,10 @@ class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "WebConsole"
|
||||
return "BecConsole"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
return "A console widget with access to a shared registry of terminals, such that instances can be moved around."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['bec_console.py']}
|
||||
@@ -5,7 +5,7 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.editors.web_console.web_console import BECShell
|
||||
from bec_widgets.widgets.editors.bec_console.bec_console import BECShell
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
@@ -6,9 +6,9 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.editors.web_console.web_console_plugin import WebConsolePlugin
|
||||
from bec_widgets.widgets.editors.bec_console.bec_console_plugin import BecConsolePlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(WebConsolePlugin())
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BecConsolePlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.editors.web_console.bec_shell_plugin import BECShellPlugin
|
||||
from bec_widgets.widgets.editors.bec_console.bec_shell_plugin import BECShellPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECShellPlugin())
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['web_console.py']}
|
||||
@@ -1,705 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import json
|
||||
import secrets
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from louie.saferef import safe_ref
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtCore import Qt, QTimer, QUrl, Signal, qInstallMessageHandler
|
||||
from qtpy.QtGui import QMouseEvent, QResizeEvent
|
||||
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QTabWidget, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ConsoleMode(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
HIDDEN = "hidden"
|
||||
|
||||
|
||||
class PageOwnerInfo(BaseModel):
|
||||
owner_gui_id: str | None = None
|
||||
widget_ids: list[str] = []
|
||||
page: QWebEnginePage | None = None
|
||||
initialized: bool = False
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
|
||||
class WebConsoleRegistry:
|
||||
"""
|
||||
A registry for the WebConsole class to manage its instances.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the registry.
|
||||
"""
|
||||
self._instances = {}
|
||||
self._server_process = None
|
||||
self._server_port = None
|
||||
self._token = secrets.token_hex(16)
|
||||
self._page_registry: dict[str, PageOwnerInfo] = {}
|
||||
|
||||
def register(self, instance: WebConsole):
|
||||
"""
|
||||
Register an instance of WebConsole.
|
||||
|
||||
Args:
|
||||
instance (WebConsole): The instance to register.
|
||||
"""
|
||||
self._instances[instance.gui_id] = safe_ref(instance)
|
||||
self.cleanup()
|
||||
|
||||
if instance._unique_id:
|
||||
self._register_page(instance)
|
||||
|
||||
if self._server_process is None:
|
||||
# Start the ttyd server if not already running
|
||||
self.start_ttyd()
|
||||
|
||||
def start_ttyd(self, use_zsh: bool | None = None):
|
||||
"""
|
||||
Start the ttyd server
|
||||
ttyd -q -W -t 'theme={"background": "black"}' zsh
|
||||
|
||||
Args:
|
||||
use_zsh (bool): Whether to use zsh or bash. If None, it will try to detect if zsh is available.
|
||||
"""
|
||||
|
||||
# First, check if ttyd is installed
|
||||
try:
|
||||
subprocess.run(["ttyd", "--version"], check=True, stdout=subprocess.PIPE)
|
||||
except FileNotFoundError:
|
||||
# pylint: disable=raise-missing-from
|
||||
raise RuntimeError("ttyd is not installed. Please install it first.")
|
||||
|
||||
if use_zsh is None:
|
||||
# Check if we can use zsh
|
||||
try:
|
||||
subprocess.run(["zsh", "--version"], check=True, stdout=subprocess.PIPE)
|
||||
use_zsh = True
|
||||
except FileNotFoundError:
|
||||
use_zsh = False
|
||||
|
||||
command = [
|
||||
"ttyd",
|
||||
"-p",
|
||||
"0",
|
||||
"-W",
|
||||
"-t",
|
||||
'theme={"background": "black"}',
|
||||
"-c",
|
||||
f"user:{self._token}",
|
||||
]
|
||||
if use_zsh:
|
||||
command.append("zsh")
|
||||
else:
|
||||
command.append("bash")
|
||||
|
||||
# Start the ttyd server
|
||||
self._server_process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
self._wait_for_server_port()
|
||||
|
||||
self._server_process.stdout.close()
|
||||
self._server_process.stderr.close()
|
||||
|
||||
def _wait_for_server_port(self, timeout: float = 10):
|
||||
"""
|
||||
Wait for the ttyd server to start and get the port number.
|
||||
|
||||
Args:
|
||||
timeout (float): The timeout in seconds to wait for the server to start.
|
||||
"""
|
||||
start_time = time.time()
|
||||
while True:
|
||||
output = self._server_process.stderr.readline()
|
||||
if output == b"" and self._server_process.poll() is not None:
|
||||
break
|
||||
if not output:
|
||||
continue
|
||||
|
||||
output = output.decode("utf-8").strip()
|
||||
if "Listening on" in output:
|
||||
# Extract the port number from the output
|
||||
self._server_port = int(output.split(":")[-1])
|
||||
logger.info(f"ttyd server started on port {self._server_port}")
|
||||
break
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError(
|
||||
"Timeout waiting for ttyd server to start. Please check if ttyd is installed and available in your PATH."
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the registry by removing any instances that are no longer valid.
|
||||
"""
|
||||
for gui_id, weak_ref in list(self._instances.items()):
|
||||
if weak_ref() is None:
|
||||
del self._instances[gui_id]
|
||||
|
||||
if not self._instances and self._server_process:
|
||||
# If no instances are left, terminate the server process
|
||||
self._server_process.terminate()
|
||||
self._server_process = None
|
||||
self._server_port = None
|
||||
logger.info("ttyd server terminated")
|
||||
|
||||
def unregister(self, instance: WebConsole):
|
||||
"""
|
||||
Unregister an instance of WebConsole.
|
||||
|
||||
Args:
|
||||
instance (WebConsole): The instance to unregister.
|
||||
"""
|
||||
if instance.gui_id in self._instances:
|
||||
del self._instances[instance.gui_id]
|
||||
|
||||
if instance._unique_id:
|
||||
self._unregister_page(instance._unique_id, instance.gui_id)
|
||||
|
||||
self.cleanup()
|
||||
|
||||
def _register_page(self, instance: WebConsole):
|
||||
"""
|
||||
Register a page in the registry. Please note that this does not transfer ownership
|
||||
for already existing pages; it simply records which widget currently owns the page.
|
||||
Use transfer_page_ownership to change ownership.
|
||||
|
||||
Args:
|
||||
instance (WebConsole): The instance to register.
|
||||
"""
|
||||
|
||||
unique_id = instance._unique_id
|
||||
gui_id = instance.gui_id
|
||||
|
||||
if unique_id is None:
|
||||
return
|
||||
|
||||
if unique_id not in self._page_registry:
|
||||
page = BECWebEnginePage()
|
||||
page.authenticationRequired.connect(instance._authenticate)
|
||||
self._page_registry[unique_id] = PageOwnerInfo(
|
||||
owner_gui_id=gui_id, widget_ids=[gui_id], page=page
|
||||
)
|
||||
logger.info(f"Registered new page {unique_id} for {gui_id}")
|
||||
return
|
||||
|
||||
if gui_id not in self._page_registry[unique_id].widget_ids:
|
||||
self._page_registry[unique_id].widget_ids.append(gui_id)
|
||||
|
||||
def _unregister_page(self, unique_id: str, gui_id: str):
|
||||
"""
|
||||
Unregister a page from the registry.
|
||||
|
||||
Args:
|
||||
unique_id (str): The unique identifier for the page.
|
||||
gui_id (str): The GUI ID of the widget.
|
||||
"""
|
||||
if unique_id not in self._page_registry:
|
||||
return
|
||||
page_info = self._page_registry[unique_id]
|
||||
if gui_id in page_info.widget_ids:
|
||||
page_info.widget_ids.remove(gui_id)
|
||||
if page_info.owner_gui_id == gui_id:
|
||||
page_info.owner_gui_id = None
|
||||
if not page_info.widget_ids:
|
||||
if page_info.page:
|
||||
page_info.page.deleteLater()
|
||||
del self._page_registry[unique_id]
|
||||
|
||||
logger.info(f"Unregistered page {unique_id} for {gui_id}")
|
||||
|
||||
def get_page_info(self, unique_id: str) -> PageOwnerInfo | None:
|
||||
"""
|
||||
Get a page from the registry.
|
||||
|
||||
Args:
|
||||
unique_id (str): The unique identifier for the page.
|
||||
|
||||
Returns:
|
||||
PageOwnerInfo | None: The page info if found, None otherwise.
|
||||
"""
|
||||
if unique_id not in self._page_registry:
|
||||
return None
|
||||
return self._page_registry[unique_id]
|
||||
|
||||
def take_page_ownership(self, unique_id: str, new_owner_gui_id: str) -> QWebEnginePage | None:
|
||||
"""
|
||||
Transfer ownership of a page to a new owner.
|
||||
|
||||
Args:
|
||||
unique_id (str): The unique identifier for the page.
|
||||
new_owner_gui_id (str): The GUI ID of the new owner.
|
||||
|
||||
Returns:
|
||||
QWebEnginePage | None: The page if ownership transfer was successful, None otherwise.
|
||||
"""
|
||||
if unique_id not in self._page_registry:
|
||||
logger.warning(f"Page {unique_id} not found in registry")
|
||||
return None
|
||||
|
||||
page_info = self._page_registry[unique_id]
|
||||
old_owner_gui_id = page_info.owner_gui_id
|
||||
if old_owner_gui_id:
|
||||
old_owner_ref = self._instances.get(old_owner_gui_id)
|
||||
if old_owner_ref:
|
||||
old_owner_instance = old_owner_ref()
|
||||
if old_owner_instance:
|
||||
old_owner_instance.yield_ownership()
|
||||
page_info.owner_gui_id = new_owner_gui_id
|
||||
|
||||
logger.info(f"Transferred ownership of page {unique_id} to {new_owner_gui_id}")
|
||||
return page_info.page
|
||||
|
||||
def yield_ownership(self, gui_id: str) -> bool:
|
||||
"""
|
||||
Yield ownership of a page without destroying it. The page remains in the
|
||||
registry with no owner, available for another widget to claim.
|
||||
|
||||
Args:
|
||||
gui_id (str): The GUI ID of the widget yielding ownership.
|
||||
|
||||
Returns:
|
||||
bool: True if ownership was yielded, False otherwise.
|
||||
"""
|
||||
if gui_id not in self._instances:
|
||||
return False
|
||||
|
||||
instance = self._instances[gui_id]()
|
||||
if instance is None:
|
||||
return False
|
||||
|
||||
unique_id = instance._unique_id
|
||||
if unique_id is None:
|
||||
return False
|
||||
|
||||
if unique_id not in self._page_registry:
|
||||
return False
|
||||
|
||||
page_owner_info = self._page_registry[unique_id]
|
||||
if page_owner_info.owner_gui_id != gui_id:
|
||||
return False
|
||||
|
||||
page_owner_info.owner_gui_id = None
|
||||
return True
|
||||
|
||||
def owner_is_visible(self, unique_id: str) -> bool:
|
||||
"""
|
||||
Check if the owner of a page is currently visible.
|
||||
|
||||
Args:
|
||||
unique_id (str): The unique identifier for the page.
|
||||
Returns:
|
||||
bool: True if the owner is visible, False otherwise.
|
||||
"""
|
||||
page_info = self.get_page_info(unique_id)
|
||||
if page_info is None or page_info.owner_gui_id is None:
|
||||
return False
|
||||
|
||||
owner_ref = self._instances.get(page_info.owner_gui_id)
|
||||
if owner_ref is None:
|
||||
return False
|
||||
|
||||
owner_instance = owner_ref()
|
||||
if owner_instance is None:
|
||||
return False
|
||||
|
||||
return owner_instance.isVisible()
|
||||
|
||||
|
||||
_web_console_registry = WebConsoleRegistry()
|
||||
|
||||
|
||||
def suppress_qt_messages(type_, context, msg):
|
||||
if context.category in ["js", "default"]:
|
||||
return
|
||||
print(msg)
|
||||
|
||||
|
||||
qInstallMessageHandler(suppress_qt_messages)
|
||||
|
||||
|
||||
class BECWebEnginePage(QWebEnginePage):
|
||||
def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID):
|
||||
logger.info(f"[JS Console] {level.name} at line {lineNumber} in {sourceID}: {message}")
|
||||
|
||||
|
||||
class WebConsole(BECWidget, QWidget):
|
||||
"""
|
||||
A simple widget to display a website
|
||||
"""
|
||||
|
||||
_js_callback = Signal(bool)
|
||||
initialized = Signal()
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "terminal"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
config=None,
|
||||
client=None,
|
||||
gui_id=None,
|
||||
startup_cmd: str | None = None,
|
||||
is_bec_shell: bool = False,
|
||||
unique_id: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self._mode = ConsoleMode.INACTIVE
|
||||
self._is_bec_shell = is_bec_shell
|
||||
self._startup_cmd = startup_cmd
|
||||
self._is_initialized = False
|
||||
self._unique_id = unique_id
|
||||
self.page = None # Will be set in _set_up_page
|
||||
|
||||
self._startup_timer = QTimer()
|
||||
self._startup_timer.setInterval(500)
|
||||
self._startup_timer.timeout.connect(self._check_page_ready)
|
||||
self._startup_timer.start()
|
||||
self._js_callback.connect(self._on_js_callback)
|
||||
|
||||
self._set_up_page()
|
||||
|
||||
def _set_up_page(self):
|
||||
"""
|
||||
Set up the web page and UI elements.
|
||||
"""
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.browser = QWebEngineView(self)
|
||||
|
||||
layout.addWidget(self.browser)
|
||||
self.setLayout(layout)
|
||||
|
||||
# prepare overlay
|
||||
self.overlay = QWidget(self)
|
||||
layout = QVBoxLayout(self.overlay)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
label = QLabel("Click to activate terminal", self.overlay)
|
||||
layout.addWidget(label)
|
||||
self.overlay.hide()
|
||||
|
||||
_web_console_registry.register(self)
|
||||
self._token = _web_console_registry._token
|
||||
|
||||
# If no unique_id is provided, create a new page
|
||||
if not self._unique_id:
|
||||
self.page = BECWebEnginePage(self)
|
||||
self.page.authenticationRequired.connect(self._authenticate)
|
||||
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
|
||||
self.browser.setPage(self.page)
|
||||
self._set_mode(ConsoleMode.ACTIVE)
|
||||
return
|
||||
|
||||
# Try to get the page from the registry
|
||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
||||
if page_info and page_info.page:
|
||||
self.page = page_info.page
|
||||
if not page_info.owner_gui_id or page_info.owner_gui_id == self.gui_id:
|
||||
self.browser.setPage(self.page)
|
||||
# Only set URL if this is a newly created page (no URL set yet)
|
||||
if self.page.url().isEmpty():
|
||||
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
|
||||
else:
|
||||
# We have an existing page, so we don't need the startup timer
|
||||
self._startup_timer.stop()
|
||||
if page_info.owner_gui_id != self.gui_id:
|
||||
self._set_mode(ConsoleMode.INACTIVE)
|
||||
else:
|
||||
self._set_mode(ConsoleMode.ACTIVE)
|
||||
|
||||
def _set_mode(self, mode: ConsoleMode):
|
||||
"""
|
||||
Set the mode of the web console.
|
||||
|
||||
Args:
|
||||
mode (ConsoleMode): The mode to set.
|
||||
"""
|
||||
if not self._unique_id:
|
||||
# For non-unique_id consoles, always active
|
||||
mode = ConsoleMode.ACTIVE
|
||||
|
||||
self._mode = mode
|
||||
match mode:
|
||||
case ConsoleMode.ACTIVE:
|
||||
self.browser.setVisible(True)
|
||||
self.overlay.hide()
|
||||
case ConsoleMode.INACTIVE:
|
||||
self.browser.setVisible(False)
|
||||
self.overlay.show()
|
||||
case ConsoleMode.HIDDEN:
|
||||
self.browser.setVisible(False)
|
||||
self.overlay.hide()
|
||||
|
||||
def _check_page_ready(self):
|
||||
"""
|
||||
Check if the page is ready and stop the timer if it is.
|
||||
"""
|
||||
if not self.page or self.page.isLoading():
|
||||
return
|
||||
|
||||
self.page.runJavaScript("window.term !== undefined", self._js_callback.emit)
|
||||
|
||||
def _on_js_callback(self, ready: bool):
|
||||
"""
|
||||
Callback for when the JavaScript is ready.
|
||||
"""
|
||||
if not ready:
|
||||
return
|
||||
self._is_initialized = True
|
||||
self._startup_timer.stop()
|
||||
if self.startup_cmd:
|
||||
if self._unique_id:
|
||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
||||
if page_info is None:
|
||||
return
|
||||
if not page_info.initialized:
|
||||
page_info.initialized = True
|
||||
self.write(self.startup_cmd)
|
||||
else:
|
||||
self.write(self.startup_cmd)
|
||||
self.initialized.emit()
|
||||
|
||||
@property
|
||||
def startup_cmd(self):
|
||||
"""
|
||||
Get the startup command for the web console.
|
||||
"""
|
||||
if self._is_bec_shell:
|
||||
if self.bec_dispatcher.cli_server is None:
|
||||
return "bec --nogui"
|
||||
return f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}"
|
||||
return self._startup_cmd
|
||||
|
||||
@startup_cmd.setter
|
||||
def startup_cmd(self, cmd: str):
|
||||
"""
|
||||
Set the startup command for the web console.
|
||||
"""
|
||||
if not isinstance(cmd, str):
|
||||
raise ValueError("Startup command must be a string.")
|
||||
self._startup_cmd = cmd
|
||||
|
||||
def write(self, data: str, send_return: bool = True):
|
||||
"""
|
||||
Send data to the web page
|
||||
|
||||
Args:
|
||||
data (str): The data to send.
|
||||
send_return (bool): Whether to send a return after the data.
|
||||
"""
|
||||
cmd = f"window.term.paste({json.dumps(data)});"
|
||||
if self.page is None:
|
||||
logger.warning("Cannot write to web console: page is not initialized.")
|
||||
return
|
||||
self.page.runJavaScript(cmd)
|
||||
if send_return:
|
||||
self.send_return()
|
||||
|
||||
def take_page_ownership(self, unique_id: str | None = None):
|
||||
"""
|
||||
Take ownership of a web page from the registry. This will transfer the page
|
||||
from its current owner (if any) to this widget.
|
||||
|
||||
Args:
|
||||
unique_id (str): The unique identifier of the page to take ownership of.
|
||||
If None, uses this widget's unique_id.
|
||||
"""
|
||||
if unique_id is None:
|
||||
unique_id = self._unique_id
|
||||
|
||||
if not unique_id:
|
||||
logger.warning("Cannot take page ownership without a unique_id")
|
||||
return
|
||||
|
||||
# Get the page from registry
|
||||
page = _web_console_registry.take_page_ownership(unique_id, self.gui_id)
|
||||
|
||||
if not page:
|
||||
logger.warning(f"Page {unique_id} not found in registry")
|
||||
return
|
||||
|
||||
self.page = page
|
||||
self.browser.setPage(page)
|
||||
self._set_mode(ConsoleMode.ACTIVE)
|
||||
logger.info(f"Widget {self.gui_id} took ownership of page {unique_id}")
|
||||
|
||||
def _on_ownership_lost(self):
|
||||
"""
|
||||
Called when this widget loses ownership of its page.
|
||||
Displays the overlay and hides the browser.
|
||||
"""
|
||||
self._set_mode(ConsoleMode.INACTIVE)
|
||||
logger.info(f"Widget {self.gui_id} lost ownership of page {self._unique_id}")
|
||||
|
||||
def yield_ownership(self):
|
||||
"""
|
||||
Yield ownership of the page. The page remains in the registry with no owner,
|
||||
available for another widget to claim. This is automatically called when the
|
||||
widget becomes hidden.
|
||||
"""
|
||||
if not self._unique_id:
|
||||
return
|
||||
success = _web_console_registry.yield_ownership(self.gui_id)
|
||||
if success:
|
||||
self._on_ownership_lost()
|
||||
logger.info(f"Widget {self.gui_id} yielded ownership of page {self._unique_id}")
|
||||
|
||||
def has_ownership(self) -> bool:
|
||||
"""
|
||||
Check if this widget currently has ownership of a page.
|
||||
|
||||
Returns:
|
||||
bool: True if this widget owns a page, False otherwise.
|
||||
"""
|
||||
if not self._unique_id:
|
||||
return False
|
||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
||||
if page_info is None:
|
||||
return False
|
||||
return page_info.owner_gui_id == self.gui_id
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""
|
||||
Called when the widget is hidden. Automatically yields ownership.
|
||||
"""
|
||||
if self.has_ownership():
|
||||
self.yield_ownership()
|
||||
self._set_mode(ConsoleMode.HIDDEN)
|
||||
super().hideEvent(event)
|
||||
|
||||
def showEvent(self, event):
|
||||
"""
|
||||
Called when the widget is shown. Updates UI state based on ownership.
|
||||
"""
|
||||
super().showEvent(event)
|
||||
if self._unique_id and not self.has_ownership():
|
||||
# Take ownership if the page does not have an owner or
|
||||
# the owner is not visible
|
||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
||||
if page_info is None:
|
||||
self._set_mode(ConsoleMode.INACTIVE)
|
||||
return
|
||||
if page_info.owner_gui_id is None or not _web_console_registry.owner_is_visible(
|
||||
self._unique_id
|
||||
):
|
||||
self.take_page_ownership(self._unique_id)
|
||||
return
|
||||
if page_info.owner_gui_id != self.gui_id:
|
||||
self._set_mode(ConsoleMode.INACTIVE)
|
||||
return
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
super().resizeEvent(event)
|
||||
self.overlay.resize(event.size())
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton and not self.has_ownership():
|
||||
self.take_page_ownership(self._unique_id)
|
||||
event.accept()
|
||||
return
|
||||
return super().mousePressEvent(event)
|
||||
|
||||
def _authenticate(self, _, auth):
|
||||
"""
|
||||
Authenticate the request with the provided username and password.
|
||||
"""
|
||||
auth.setUser("user")
|
||||
auth.setPassword(self._token)
|
||||
|
||||
def send_return(self):
|
||||
"""
|
||||
Send return to the web page
|
||||
"""
|
||||
self.page.runJavaScript(
|
||||
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 13}))"
|
||||
)
|
||||
|
||||
def send_ctrl_c(self):
|
||||
"""
|
||||
Send Ctrl+C to the web page
|
||||
"""
|
||||
self.page.runJavaScript(
|
||||
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
|
||||
)
|
||||
|
||||
def set_readonly(self, readonly: bool):
|
||||
"""
|
||||
Set the web console to read-only mode.
|
||||
"""
|
||||
if not isinstance(readonly, bool):
|
||||
raise ValueError("Readonly must be a boolean.")
|
||||
self.setEnabled(not readonly)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the registry by removing any instances that are no longer valid.
|
||||
"""
|
||||
self._startup_timer.stop()
|
||||
_web_console_registry.unregister(self)
|
||||
super().cleanup()
|
||||
|
||||
|
||||
class BECShell(WebConsole):
|
||||
"""
|
||||
A WebConsole pre-configured to run the BEC shell.
|
||||
We cannot simply expose the web console properties to Qt as we need to have a deterministic
|
||||
startup behavior for sharing the same shell instance across multiple widgets.
|
||||
"""
|
||||
|
||||
ICON_NAME = "hub"
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
config=config,
|
||||
client=client,
|
||||
gui_id=gui_id,
|
||||
is_bec_shell=True,
|
||||
unique_id="bec_shell",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = QTabWidget()
|
||||
|
||||
# Create two consoles with different unique_ids
|
||||
web_console1 = WebConsole(startup_cmd="bec --nogui", unique_id="console1")
|
||||
web_console2 = WebConsole(startup_cmd="htop")
|
||||
web_console3 = WebConsole(startup_cmd="bec --nogui", unique_id="console1")
|
||||
widget.addTab(web_console1, "Console 1")
|
||||
widget.addTab(web_console2, "Console 2")
|
||||
widget.addTab(web_console3, "Console 3 -- mirror of Console 1")
|
||||
widget.show()
|
||||
|
||||
# Demonstrate page sharing:
|
||||
# After initialization, web_console2 can take ownership of console1's page:
|
||||
# web_console2.take_page_ownership("console1")
|
||||
|
||||
widget.resize(800, 600)
|
||||
|
||||
def _close_cons1():
|
||||
web_console2.close()
|
||||
web_console2.deleteLater()
|
||||
|
||||
# QTimer.singleShot(3000, _close_cons1)
|
||||
|
||||
sys.exit(app.exec_())
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['web_console.py']}
|
||||
345
bec_widgets/widgets/plots/waveform/utils/alignment_controller.py
Normal file
345
bec_widgets/widgets/plots/waveform/utils/alignment_controller.py
Normal file
@@ -0,0 +1,345 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, Qt, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors, get_theme_name
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AlignmentContext:
|
||||
"""Alignment state produced by `Waveform` and consumed by the controller.
|
||||
|
||||
Attributes:
|
||||
visible: Whether alignment mode is currently visible.
|
||||
positioner_name: Name of the resolved x-axis positioner, if available.
|
||||
precision: Decimal precision to use for readback and target labels.
|
||||
limits: Optional positioner limits for the draggable target line.
|
||||
readback: Current cached positioner readback value.
|
||||
has_dap_curves: Whether the waveform currently contains any DAP curves.
|
||||
force_readback: Whether the embedded positioner should refresh its readback immediately.
|
||||
"""
|
||||
|
||||
visible: bool
|
||||
positioner_name: str | None
|
||||
precision: int = 3
|
||||
limits: tuple[float, float] | None = None
|
||||
readback: float | None = None
|
||||
has_dap_curves: bool = False
|
||||
force_readback: bool = False
|
||||
|
||||
|
||||
class WaveformAlignmentController(QObject):
|
||||
"""Own the alignment plot overlays and synchronize them with the alignment panel."""
|
||||
|
||||
move_absolute_requested = Signal(float)
|
||||
autoscale_requested = Signal()
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem, panel: WaveformAlignmentPanel, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self._plot_item = plot_item
|
||||
self._panel = panel
|
||||
|
||||
self._visible = False
|
||||
self._positioner_name: str | None = None
|
||||
self._precision = 3
|
||||
self._limits: tuple[float, float] | None = None
|
||||
self._readback: float | None = None
|
||||
self._marker_line: pg.InfiniteLine | None = None
|
||||
self._target_line: pg.InfiniteLine | None = None
|
||||
|
||||
self._panel.position_readback_changed.connect(self.update_position)
|
||||
self._panel.target_toggled.connect(self._on_target_toggled)
|
||||
self._panel.target_move_requested.connect(self._on_target_move_requested)
|
||||
self._panel.fit_selection_changed.connect(self._on_fit_selection_changed)
|
||||
self._panel.fit_center_requested.connect(self._on_fit_center_requested)
|
||||
|
||||
@property
|
||||
def marker_line(self) -> pg.InfiniteLine | None:
|
||||
"""Return the current-position indicator line, if it exists."""
|
||||
return self._marker_line
|
||||
|
||||
@property
|
||||
def target_line(self) -> pg.InfiniteLine | None:
|
||||
"""Return the draggable target indicator line, if it exists."""
|
||||
return self._target_line
|
||||
|
||||
def update_context(self, context: AlignmentContext):
|
||||
"""Apply waveform-owned alignment context to the panel and plot overlays.
|
||||
|
||||
Args:
|
||||
context: Snapshot of the current alignment-relevant waveform/device state.
|
||||
"""
|
||||
previous_name = self._positioner_name
|
||||
self._visible = context.visible
|
||||
self._positioner_name = context.positioner_name
|
||||
self._precision = context.precision
|
||||
self._limits = context.limits
|
||||
self._readback = context.readback
|
||||
|
||||
self._panel.set_positioner_device(context.positioner_name)
|
||||
self._panel.set_positioner_enabled(context.visible and context.positioner_name is not None)
|
||||
self._panel.set_status_message(self._status_message_for_context(context))
|
||||
|
||||
if context.positioner_name is None or not context.visible:
|
||||
self.clear()
|
||||
self._refresh_fit_actions()
|
||||
self._refresh_target_controls()
|
||||
return
|
||||
|
||||
if previous_name != context.positioner_name:
|
||||
self._clear_marker()
|
||||
if self._panel.target_active:
|
||||
self._clear_target_line()
|
||||
|
||||
if context.readback is not None:
|
||||
self.update_position(context.readback)
|
||||
|
||||
if self._panel.target_active:
|
||||
if previous_name != context.positioner_name or self._target_line is None:
|
||||
self._show_target_line()
|
||||
else:
|
||||
self._refresh_target_line_metadata()
|
||||
self._on_target_line_changed()
|
||||
|
||||
if context.force_readback or previous_name != context.positioner_name:
|
||||
self._panel.force_positioner_readback()
|
||||
|
||||
self._refresh_fit_actions()
|
||||
self._refresh_target_controls()
|
||||
|
||||
@SafeSlot(float)
|
||||
def update_position(self, position: float):
|
||||
"""Update the live position marker from a positioner readback value.
|
||||
|
||||
Args:
|
||||
position: Current absolute position of the active alignment positioner.
|
||||
"""
|
||||
self._readback = float(position)
|
||||
if not self._visible or self._positioner_name is None:
|
||||
self._clear_marker()
|
||||
return
|
||||
|
||||
self._ensure_marker()
|
||||
self._marker_line.setValue(self._readback)
|
||||
self._marker_line.label.setText(
|
||||
f"{self._positioner_name}: {self._readback:.{self._precision}f}"
|
||||
)
|
||||
self.autoscale_requested.emit()
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def update_dap_summary(self, data: dict, metadata: dict):
|
||||
"""Forward DAP summary updates into the alignment fit panel.
|
||||
|
||||
Args:
|
||||
data: DAP fit summary payload.
|
||||
metadata: Metadata describing the emitting DAP curve.
|
||||
"""
|
||||
self._panel.update_dap_summary(data, metadata)
|
||||
self._refresh_fit_actions()
|
||||
|
||||
@SafeSlot(str)
|
||||
def remove_dap_curve(self, curve_id: str):
|
||||
"""Remove a deleted DAP curve from the alignment fit selection state.
|
||||
|
||||
Args:
|
||||
curve_id: Label of the DAP curve that was removed from the waveform.
|
||||
"""
|
||||
self._panel.remove_dap_curve(curve_id)
|
||||
self._panel.clear_fit_selection_if_missing()
|
||||
self._refresh_fit_actions()
|
||||
|
||||
def clear(self):
|
||||
"""Remove alignment overlay items from the plot and reset target state."""
|
||||
self._clear_marker()
|
||||
self._clear_target_line()
|
||||
|
||||
def cleanup(self):
|
||||
"""Disconnect panel signals and remove all controller-owned overlay items."""
|
||||
self.clear()
|
||||
self._disconnect_panel_signals()
|
||||
|
||||
def refresh_theme_colors(self):
|
||||
"""Reapply theme-aware styling to any existing alignment overlay items."""
|
||||
self._apply_marker_style()
|
||||
self._apply_target_style()
|
||||
|
||||
def _disconnect_panel_signals(self):
|
||||
signal_pairs = [
|
||||
(self._panel.position_readback_changed, self.update_position),
|
||||
(self._panel.target_toggled, self._on_target_toggled),
|
||||
(self._panel.target_move_requested, self._on_target_move_requested),
|
||||
(self._panel.fit_selection_changed, self._on_fit_selection_changed),
|
||||
(self._panel.fit_center_requested, self._on_fit_center_requested),
|
||||
]
|
||||
for signal, slot in signal_pairs:
|
||||
try:
|
||||
signal.disconnect(slot)
|
||||
except (RuntimeError, TypeError):
|
||||
continue
|
||||
|
||||
def _selected_fit_has_center(self) -> bool:
|
||||
data = self._panel.selected_fit_summary()
|
||||
params = data.get("params", []) if isinstance(data, dict) else []
|
||||
return any(param[0] == "center" for param in params if param)
|
||||
|
||||
@staticmethod
|
||||
def _status_message_for_context(context: AlignmentContext) -> str | None:
|
||||
if context.positioner_name is None:
|
||||
return "Alignment mode requires a positioner on the x axis."
|
||||
if not context.has_dap_curves:
|
||||
return "Add a DAP curve in Curve Settings to enable alignment fitting."
|
||||
return None
|
||||
|
||||
def _refresh_fit_actions(self):
|
||||
self._panel.set_fit_actions_enabled(
|
||||
self._visible and self._positioner_name is not None and self._selected_fit_has_center()
|
||||
)
|
||||
|
||||
def _refresh_target_controls(self):
|
||||
has_positioner = self._visible and self._positioner_name is not None
|
||||
self._panel.set_target_enabled(has_positioner)
|
||||
self._panel.set_target_move_enabled(has_positioner and self._target_line is not None)
|
||||
if self._target_line is None:
|
||||
self._panel.set_target_value(None)
|
||||
|
||||
def _ensure_marker(self):
|
||||
if self._marker_line is not None:
|
||||
return
|
||||
|
||||
warning = get_accent_colors().warning
|
||||
|
||||
self._marker_line = pg.InfiniteLine(
|
||||
angle=90,
|
||||
movable=False,
|
||||
pen=pg.mkPen(warning, width=4),
|
||||
label="",
|
||||
labelOpts={"position": 0.95, "color": warning},
|
||||
)
|
||||
self._apply_marker_style()
|
||||
self._plot_item.addItem(self._marker_line)
|
||||
|
||||
def _clear_marker(self):
|
||||
if self._marker_line is None:
|
||||
return
|
||||
self._plot_item.removeItem(self._marker_line)
|
||||
self._marker_line = None
|
||||
|
||||
def _show_target_line(self):
|
||||
if not self._visible or self._positioner_name is None:
|
||||
return
|
||||
|
||||
if self._target_line is None:
|
||||
accent_colors = get_accent_colors()
|
||||
label = f"{self._positioner_name} target={{value:0.{self._precision}f}}"
|
||||
self._target_line = pg.InfiniteLine(
|
||||
movable=True,
|
||||
angle=90,
|
||||
pen=pg.mkPen(accent_colors.default, width=2, style=Qt.PenStyle.DashLine),
|
||||
hoverPen=pg.mkPen(accent_colors.success, width=2),
|
||||
label=label,
|
||||
labelOpts={"movable": True, "color": accent_colors.default},
|
||||
)
|
||||
self._target_line.sigPositionChanged.connect(self._on_target_line_changed)
|
||||
self._apply_target_style()
|
||||
self._plot_item.addItem(self._target_line)
|
||||
self._refresh_target_line_metadata()
|
||||
|
||||
value = 0.0 if self._readback is None else self._readback
|
||||
if self._limits is not None:
|
||||
value = min(max(value, self._limits[0]), self._limits[1])
|
||||
self._target_line.setValue(value)
|
||||
self._on_target_line_changed()
|
||||
self.autoscale_requested.emit()
|
||||
|
||||
def _refresh_target_line_metadata(self):
|
||||
if self._target_line is None or self._positioner_name is None:
|
||||
return
|
||||
self._apply_target_style()
|
||||
self._target_line.label.setFormat(
|
||||
f"{self._positioner_name} target={{value:0.{self._precision}f}}"
|
||||
)
|
||||
if self._limits is not None:
|
||||
self._target_line.setBounds(list(self._limits))
|
||||
else:
|
||||
self._target_line.setBounds((None, None))
|
||||
if self._limits is not None:
|
||||
current_value = float(self._target_line.value())
|
||||
clamped_value = min(max(current_value, self._limits[0]), self._limits[1])
|
||||
if clamped_value != current_value:
|
||||
self._target_line.setValue(clamped_value)
|
||||
|
||||
def _clear_target_line(self):
|
||||
if self._target_line is not None:
|
||||
try:
|
||||
self._target_line.sigPositionChanged.disconnect(self._on_target_line_changed)
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
self._plot_item.removeItem(self._target_line)
|
||||
self._target_line = None
|
||||
self._panel.set_target_value(None)
|
||||
|
||||
def _apply_marker_style(self):
|
||||
if self._marker_line is None:
|
||||
return
|
||||
|
||||
accent_colors = get_accent_colors()
|
||||
warning = accent_colors.warning
|
||||
|
||||
self._marker_line.setPen(pg.mkPen(warning, width=4))
|
||||
self._marker_line.label.setColor(warning)
|
||||
self._marker_line.label.fill = pg.mkBrush(self._label_fill_color())
|
||||
|
||||
def _apply_target_style(self):
|
||||
if self._target_line is None:
|
||||
return
|
||||
|
||||
accent_colors = get_accent_colors()
|
||||
default = accent_colors.default
|
||||
success = accent_colors.success
|
||||
|
||||
self._target_line.setPen(pg.mkPen(default, width=2, style=Qt.PenStyle.DashLine))
|
||||
self._target_line.setHoverPen(pg.mkPen(success, width=2))
|
||||
self._target_line.label.setColor(default)
|
||||
self._target_line.label.fill = pg.mkBrush(self._label_fill_color())
|
||||
|
||||
@staticmethod
|
||||
def _label_fill_color() -> QColor:
|
||||
if get_theme_name() == "light":
|
||||
return QColor(244, 244, 244, 228)
|
||||
return QColor(48, 48, 48, 210)
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _on_target_toggled(self, checked: bool):
|
||||
if checked:
|
||||
self._show_target_line()
|
||||
else:
|
||||
self._clear_target_line()
|
||||
self._refresh_target_controls()
|
||||
|
||||
@SafeSlot(object)
|
||||
def _on_target_line_changed(self, _line=None):
|
||||
if self._target_line is None:
|
||||
return
|
||||
self._panel.set_target_value(float(self._target_line.value()), precision=self._precision)
|
||||
self._refresh_target_controls()
|
||||
self.autoscale_requested.emit()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_target_move_requested(self):
|
||||
if self._visible and self._positioner_name is not None and self._target_line is not None:
|
||||
self.move_absolute_requested.emit(float(self._target_line.value()))
|
||||
|
||||
@SafeSlot(str)
|
||||
def _on_fit_selection_changed(self, _curve_id: str):
|
||||
self._refresh_fit_actions()
|
||||
|
||||
@SafeSlot(float)
|
||||
def _on_fit_center_requested(self, value: float):
|
||||
if self._visible and self._positioner_name is not None:
|
||||
self.move_absolute_requested.emit(float(value))
|
||||
285
bec_widgets/widgets/plots/waveform/utils/alignment_panel.py
Normal file
285
bec_widgets/widgets/plots/waveform/utils/alignment_panel.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors, get_theme_name
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
|
||||
PositionerControlLine,
|
||||
)
|
||||
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||
|
||||
|
||||
class WaveformAlignmentPanel(QWidget):
|
||||
"""Compact bottom panel used by Waveform alignment mode."""
|
||||
|
||||
position_readback_changed = Signal(float)
|
||||
target_toggled = Signal(bool)
|
||||
target_move_requested = Signal()
|
||||
fit_selection_changed = Signal(str)
|
||||
fit_center_requested = Signal(float)
|
||||
|
||||
def __init__(self, parent=None, client=None, gui_id: str | None = None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.setProperty("skip_settings", True)
|
||||
|
||||
self.positioner = PositionerControlLine(parent=self, client=client, gui_id=gui_id)
|
||||
self.positioner.hide_device_selection = True
|
||||
|
||||
self.fit_dialog = LMFitDialog(
|
||||
parent=self, client=client, gui_id=gui_id, ui_file="lmfit_dialog_compact.ui"
|
||||
)
|
||||
self.fit_dialog.active_action_list = ["center"]
|
||||
self.fit_dialog.enable_actions = False
|
||||
|
||||
self.target_toggle = QCheckBox("Target: --", parent=self)
|
||||
self.move_to_target_button = QPushButton("Move To Target", parent=self)
|
||||
self.move_to_target_button.setEnabled(False)
|
||||
self.target_group = QGroupBox("Target Position", parent=self)
|
||||
|
||||
self.status_label = QLabel(parent=self)
|
||||
self.status_label.setWordWrap(False)
|
||||
self.status_label.setAlignment(
|
||||
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
self.status_label.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
|
||||
self.status_label.setMaximumHeight(28)
|
||||
self.status_label.setVisible(False)
|
||||
|
||||
self._init_ui()
|
||||
self.fit_dialog.setMinimumHeight(0)
|
||||
self.target_group.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self._sync_target_group_size()
|
||||
self.refresh_theme_colors()
|
||||
self._connect_signals()
|
||||
|
||||
def _connect_signals(self):
|
||||
self.positioner.position_update.connect(self.position_readback_changed)
|
||||
self.target_toggle.toggled.connect(self.target_toggled)
|
||||
self.move_to_target_button.clicked.connect(self.target_move_requested)
|
||||
self.fit_dialog.selected_fit.connect(self.fit_selection_changed)
|
||||
self.fit_dialog.move_action.connect(self._forward_fit_move_action)
|
||||
|
||||
def _init_ui(self):
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
self.setMinimumHeight(260)
|
||||
|
||||
root = QGridLayout(self)
|
||||
root.setContentsMargins(8, 8, 8, 8)
|
||||
root.setSpacing(8)
|
||||
|
||||
self.fit_dialog.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
root.addWidget(
|
||||
self.status_label,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
alignment=Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter,
|
||||
)
|
||||
root.addWidget(self.fit_dialog, 1, 0, 1, 2)
|
||||
|
||||
target_layout = QHBoxLayout(self.target_group)
|
||||
target_layout.addWidget(self.target_toggle)
|
||||
target_layout.addWidget(self.move_to_target_button)
|
||||
|
||||
root.addWidget(self.positioner, 2, 0, alignment=Qt.AlignmentFlag.AlignTop)
|
||||
root.addWidget(self.target_group, 2, 1, alignment=Qt.AlignmentFlag.AlignTop)
|
||||
root.setColumnStretch(0, 1)
|
||||
root.setColumnStretch(1, 0)
|
||||
root.setRowStretch(1, 1)
|
||||
|
||||
def _sync_target_group_size(self):
|
||||
representative_text = "Target: -99999.999"
|
||||
label_width = max(
|
||||
self.target_toggle.sizeHint().width(),
|
||||
self.target_toggle.fontMetrics().horizontalAdvance(representative_text) + 24,
|
||||
)
|
||||
self.target_toggle.setMinimumWidth(label_width)
|
||||
|
||||
# To make those two box the same height
|
||||
target_height = max(
|
||||
self.positioner.height(),
|
||||
self.positioner.ui.device_box.minimumSizeHint().height(),
|
||||
self.positioner.ui.device_box.sizeHint().height(),
|
||||
)
|
||||
self.target_group.setFixedHeight(target_height)
|
||||
self.target_group.setFixedWidth(self.target_group.sizeHint().width() + 16)
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self._sync_target_group_size()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._sync_target_group_size()
|
||||
|
||||
def set_status_message(self, text: str | None):
|
||||
"""Show or hide the alignment status pill.
|
||||
|
||||
Args:
|
||||
text: Message to display. Pass `None` or an empty string to hide the pill.
|
||||
"""
|
||||
|
||||
text = text or ""
|
||||
self.status_label.setText(text)
|
||||
self.status_label.setVisible(bool(text))
|
||||
|
||||
@staticmethod
|
||||
def _qcolor_to_rgba(color: QColor, alpha: int | None = None) -> str:
|
||||
if alpha is not None:
|
||||
color = QColor(color)
|
||||
color.setAlpha(alpha)
|
||||
return f"rgba({color.red()}, {color.green()}, {color.blue()}, {color.alpha()})"
|
||||
|
||||
def refresh_theme_colors(self):
|
||||
"""Apply theme-aware accent styling to the status pill."""
|
||||
warning = get_accent_colors().warning
|
||||
is_light = get_theme_name() == "light"
|
||||
text_color = "#202124" if is_light else warning.name()
|
||||
fill_alpha = 72 if is_light else 48
|
||||
border_alpha = 220 if is_light else 160
|
||||
|
||||
self.status_label.setStyleSheet(f"""
|
||||
QLabel {{
|
||||
background-color: {self._qcolor_to_rgba(warning, fill_alpha)};
|
||||
border: 1px solid {self._qcolor_to_rgba(warning, border_alpha)};
|
||||
border-radius: 12px;
|
||||
padding: 4px 10px;
|
||||
color: {text_color};
|
||||
}}
|
||||
""")
|
||||
|
||||
def set_positioner_device(self, device: str | None):
|
||||
"""Bind the embedded positioner control to a fixed device.
|
||||
|
||||
Args:
|
||||
device: Name of the positioner device to display, or `None` to clear it.
|
||||
"""
|
||||
if device is None:
|
||||
self.positioner.ui.device_box.setTitle("No positioner selected")
|
||||
return
|
||||
if self.positioner.device != device:
|
||||
self.positioner.set_positioner(device)
|
||||
self.positioner.hide_device_selection = True
|
||||
|
||||
def set_positioner_enabled(self, enabled: bool):
|
||||
"""Enable or disable the embedded positioner widget.
|
||||
|
||||
Args:
|
||||
enabled: Whether the positioner widget should accept interaction.
|
||||
"""
|
||||
self.positioner.setEnabled(enabled)
|
||||
|
||||
def force_positioner_readback(self):
|
||||
"""Trigger an immediate readback refresh on the embedded positioner widget."""
|
||||
self.positioner.force_update_readback()
|
||||
|
||||
def set_target_enabled(self, enabled: bool):
|
||||
"""Enable or disable the target-line toggle.
|
||||
|
||||
Args:
|
||||
enabled: Whether the target toggle should accept interaction.
|
||||
"""
|
||||
self.target_toggle.setEnabled(enabled)
|
||||
|
||||
def set_target_move_enabled(self, enabled: bool):
|
||||
"""Enable or disable the move-to-target button.
|
||||
|
||||
Args:
|
||||
enabled: Whether the move button should accept interaction.
|
||||
"""
|
||||
self.move_to_target_button.setEnabled(enabled)
|
||||
|
||||
def set_target_active(self, active: bool):
|
||||
"""Programmatically toggle the draggable target-line state.
|
||||
|
||||
Args:
|
||||
active: Whether the target line should be considered active.
|
||||
"""
|
||||
blocker = self.target_toggle.blockSignals(True)
|
||||
self.target_toggle.setChecked(active)
|
||||
self.target_toggle.blockSignals(blocker)
|
||||
if not active:
|
||||
self.set_target_value(None)
|
||||
|
||||
def set_target_value(self, value: float | None, precision: int = 3) -> None:
|
||||
"""
|
||||
Update the target checkbox label for the draggable target line.
|
||||
|
||||
Args:
|
||||
value(float | None): The target value to display. If None, the label will show "--".
|
||||
precision(int): The number of decimal places to display for the target value.
|
||||
"""
|
||||
if value is None or not self.target_toggle.isChecked():
|
||||
self.target_toggle.setText("Target: --")
|
||||
return
|
||||
self.target_toggle.setText(f"Target: {value:.{precision}f}")
|
||||
|
||||
def set_fit_actions_enabled(self, enabled: bool):
|
||||
"""Enable or disable LMFit action buttons in the embedded fit dialog.
|
||||
|
||||
Args:
|
||||
enabled: Whether fit action buttons should be enabled.
|
||||
"""
|
||||
self.fit_dialog.enable_actions = enabled
|
||||
|
||||
def update_dap_summary(self, data: dict, metadata: dict):
|
||||
"""Forward a DAP summary update into the embedded fit dialog.
|
||||
|
||||
Args:
|
||||
data: DAP fit summary payload.
|
||||
metadata: Metadata describing the emitting DAP curve.
|
||||
"""
|
||||
self.fit_dialog.update_summary_tree(data, metadata)
|
||||
|
||||
def remove_dap_curve(self, curve_id: str):
|
||||
"""Remove DAP summary state for a deleted fit curve.
|
||||
|
||||
Args:
|
||||
curve_id: Label of the DAP curve that should be removed.
|
||||
"""
|
||||
self.fit_dialog.remove_dap_data(curve_id)
|
||||
|
||||
def clear_fit_selection_if_missing(self):
|
||||
"""Select a remaining fit curve if the current selection no longer exists."""
|
||||
fit_curve_id = self.fit_dialog.fit_curve_id
|
||||
if fit_curve_id is not None and fit_curve_id not in self.fit_dialog.summary_data:
|
||||
remaining = list(self.fit_dialog.summary_data)
|
||||
self.fit_dialog.fit_curve_id = remaining[0] if remaining else None
|
||||
|
||||
@property
|
||||
def target_active(self) -> bool:
|
||||
"""Whether the target-line checkbox is currently checked."""
|
||||
return self.target_toggle.isChecked()
|
||||
|
||||
@property
|
||||
def selected_fit_curve_id(self) -> str | None:
|
||||
"""Return the currently selected fit curve label, if any."""
|
||||
return self.fit_dialog.fit_curve_id
|
||||
|
||||
def selected_fit_summary(self) -> dict | None:
|
||||
"""Return the summary payload for the currently selected fit curve.
|
||||
|
||||
Returns:
|
||||
The selected fit summary, or `None` if no fit curve is selected.
|
||||
"""
|
||||
fit_curve_id = self.selected_fit_curve_id
|
||||
if fit_curve_id is None:
|
||||
return None
|
||||
return self.fit_dialog.summary_data.get(fit_curve_id)
|
||||
|
||||
def _forward_fit_move_action(self, action: tuple[str, float]):
|
||||
param_name, param_value = action
|
||||
if param_name == "center":
|
||||
self.fit_center_requested.emit(float(param_value))
|
||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Literal
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger, messages
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object
|
||||
from bec_lib.scan_data_container import ScanDataContainer
|
||||
@@ -30,11 +31,18 @@ from bec_widgets.utils.colors import Colors, apply_theme
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.side_panel import SidePanel
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal
|
||||
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
|
||||
from bec_widgets.widgets.plots.waveform.utils.alignment_controller import (
|
||||
AlignmentContext,
|
||||
WaveformAlignmentController,
|
||||
)
|
||||
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
|
||||
from bec_widgets.widgets.plots.waveform.utils.roi_manager import WaveformROIManager
|
||||
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
|
||||
ScanHistoryBrowser,
|
||||
@@ -156,6 +164,12 @@ class Waveform(PlotBase):
|
||||
"label_suffix": "",
|
||||
}
|
||||
self._current_x_device: tuple[str, str] | None = None
|
||||
self._alignment_panel_visible = False
|
||||
self._alignment_side_panel: SidePanel | None = None
|
||||
self._alignment_panel_index: int | None = None
|
||||
self._alignment_panel: WaveformAlignmentPanel | None = None
|
||||
self._alignment_controller: WaveformAlignmentController | None = None
|
||||
self._alignment_positioner_name: str | None = None
|
||||
|
||||
# Specific GUI elements
|
||||
self._init_roi_manager()
|
||||
@@ -165,6 +179,7 @@ class Waveform(PlotBase):
|
||||
self._add_waveform_specific_popup()
|
||||
self._enable_roi_toolbar_action(False) # default state where are no dap curves
|
||||
self._init_curve_dialog()
|
||||
self._init_alignment_mode()
|
||||
self.curve_settings_dialog = None
|
||||
|
||||
# Large‑dataset guard
|
||||
@@ -195,7 +210,9 @@ class Waveform(PlotBase):
|
||||
# To fix the ViewAll action with clipToView activated
|
||||
self._connect_viewbox_menu_actions()
|
||||
|
||||
self.toolbar.show_bundles(["plot_export", "mouse_interaction", "roi", "axis_popup"])
|
||||
self.toolbar.show_bundles(
|
||||
["plot_export", "mouse_interaction", "roi", "alignment_mode", "axis_popup"]
|
||||
)
|
||||
|
||||
def _connect_viewbox_menu_actions(self):
|
||||
"""Connect the viewbox menu action ViewAll to the custom reset_view method."""
|
||||
@@ -221,6 +238,12 @@ class Waveform(PlotBase):
|
||||
theme(str, optional): The theme to be applied.
|
||||
"""
|
||||
self._refresh_colors()
|
||||
alignment_panel = getattr(self, "_alignment_panel", None)
|
||||
alignment_controller = getattr(self, "_alignment_controller", None)
|
||||
if alignment_panel is not None:
|
||||
alignment_panel.refresh_theme_colors()
|
||||
if alignment_controller is not None:
|
||||
alignment_controller.refresh_theme_colors()
|
||||
super().apply_theme(theme)
|
||||
|
||||
def add_side_menus(self):
|
||||
@@ -230,6 +253,159 @@ class Waveform(PlotBase):
|
||||
super().add_side_menus()
|
||||
self._add_dap_summary_side_menu()
|
||||
|
||||
def _init_alignment_mode(self):
|
||||
"""
|
||||
Initialize the top alignment panel.
|
||||
"""
|
||||
self.toolbar.components.add_safe(
|
||||
"alignment_mode",
|
||||
MaterialIconAction(
|
||||
icon_name="align_horizontal_center",
|
||||
tooltip="Show Alignment Mode",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("alignment_mode", self.toolbar.components)
|
||||
bundle.add_action("alignment_mode")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
shown_bundles = list(self.toolbar.shown_bundles)
|
||||
if "alignment_mode" not in shown_bundles:
|
||||
shown_bundles.append("alignment_mode")
|
||||
self.toolbar.show_bundles(shown_bundles)
|
||||
|
||||
self._alignment_side_panel = SidePanel(
|
||||
parent=self, orientation="top", panel_max_width=320, show_toolbar=False
|
||||
)
|
||||
self.layout_manager.add_widget_relative(
|
||||
self._alignment_side_panel,
|
||||
self.round_plot_widget,
|
||||
position="top",
|
||||
shift_direction="down",
|
||||
)
|
||||
|
||||
self._alignment_panel = WaveformAlignmentPanel(parent=self, client=self.client)
|
||||
self._alignment_controller = WaveformAlignmentController(
|
||||
self.plot_item, self._alignment_panel, parent=self
|
||||
)
|
||||
self._alignment_panel_index = self._alignment_side_panel.add_menu(
|
||||
widget=self._alignment_panel
|
||||
)
|
||||
self._alignment_controller.move_absolute_requested.connect(self._move_alignment_positioner)
|
||||
self._alignment_controller.autoscale_requested.connect(self._autoscale_alignment_indicators)
|
||||
self.dap_summary_update.connect(self._alignment_controller.update_dap_summary)
|
||||
self.toolbar.components.get_action("alignment_mode").action.toggled.connect(
|
||||
self.toggle_alignment_mode
|
||||
)
|
||||
|
||||
self._refresh_alignment_state()
|
||||
|
||||
@SafeSlot(bool)
|
||||
def toggle_alignment_mode(self, checked: bool):
|
||||
"""
|
||||
Show or hide the alignment panel.
|
||||
|
||||
Args:
|
||||
checked(bool): Whether the panel should be visible.
|
||||
"""
|
||||
if self._alignment_side_panel is None or self._alignment_panel_index is None:
|
||||
return
|
||||
|
||||
self._alignment_panel_visible = checked
|
||||
if checked:
|
||||
self._alignment_side_panel.show_panel(self._alignment_panel_index)
|
||||
self._refresh_alignment_state(force_readback=True)
|
||||
self._refresh_dap_signals()
|
||||
else:
|
||||
self._alignment_side_panel.hide_panel()
|
||||
self._refresh_alignment_state()
|
||||
|
||||
def _refresh_alignment_state(self, force_readback: bool = False):
|
||||
"""
|
||||
Refresh the alignment panel state after waveform changes.
|
||||
|
||||
Args:
|
||||
force_readback(bool): Force a positioner readback refresh.
|
||||
"""
|
||||
if self._alignment_controller is None:
|
||||
return
|
||||
|
||||
context = self._build_alignment_context(force_readback=force_readback)
|
||||
self._alignment_positioner_name = context.positioner_name
|
||||
self._alignment_controller.update_context(context)
|
||||
|
||||
def _resolve_alignment_positioner(self) -> str | None:
|
||||
"""
|
||||
Resolve the active x-axis positioner for alignment mode.
|
||||
"""
|
||||
if self.x_axis_mode["name"] in {"index", "timestamp"}:
|
||||
return None
|
||||
|
||||
if self.x_axis_mode["name"] == "auto":
|
||||
device_name = self._current_x_device[0] if self._current_x_device is not None else None
|
||||
else:
|
||||
device_name = self.x_axis_mode["name"]
|
||||
|
||||
if not device_name or device_name not in self.dev:
|
||||
return None
|
||||
if not isinstance(self.dev[device_name], Positioner):
|
||||
return None
|
||||
return device_name
|
||||
|
||||
def _build_alignment_context(self, force_readback: bool = False) -> AlignmentContext:
|
||||
"""Build controller-facing alignment context from waveform/device state."""
|
||||
positioner_name = self._resolve_alignment_positioner()
|
||||
if positioner_name is None:
|
||||
return AlignmentContext(
|
||||
visible=self._alignment_panel_visible,
|
||||
positioner_name=None,
|
||||
has_dap_curves=bool(self._dap_curves),
|
||||
force_readback=force_readback,
|
||||
)
|
||||
|
||||
precision = getattr(self.dev[positioner_name], "precision", 3)
|
||||
try:
|
||||
precision = int(precision)
|
||||
except (TypeError, ValueError):
|
||||
precision = 3
|
||||
|
||||
limits = getattr(self.dev[positioner_name], "limits", None)
|
||||
parsed_limits: tuple[float, float] | None = None
|
||||
if limits is not None and len(limits) == 2:
|
||||
low, high = float(limits[0]), float(limits[1])
|
||||
if low != 0 or high != 0:
|
||||
if low > high:
|
||||
low, high = high, low
|
||||
parsed_limits = (low, high)
|
||||
|
||||
data = self.dev[positioner_name].read(cached=True)
|
||||
value = data.get(positioner_name, {}).get("value")
|
||||
readback = None if value is None else float(value)
|
||||
|
||||
return AlignmentContext(
|
||||
visible=self._alignment_panel_visible,
|
||||
positioner_name=positioner_name,
|
||||
precision=precision,
|
||||
limits=parsed_limits,
|
||||
readback=readback,
|
||||
has_dap_curves=bool(self._dap_curves),
|
||||
force_readback=force_readback,
|
||||
)
|
||||
|
||||
@SafeSlot(float)
|
||||
def _move_alignment_positioner(self, value: float):
|
||||
"""
|
||||
Move the active alignment positioner to an absolute value requested by the controller.
|
||||
"""
|
||||
if self._alignment_positioner_name is None:
|
||||
return
|
||||
self.dev[self._alignment_positioner_name].move(float(value), relative=False)
|
||||
|
||||
@SafeSlot()
|
||||
def _autoscale_alignment_indicators(self):
|
||||
"""Autoscale the waveform view after alignment indicator updates."""
|
||||
self._reset_view()
|
||||
|
||||
def _add_waveform_specific_popup(self):
|
||||
"""
|
||||
Add popups to the Waveform widget.
|
||||
@@ -266,7 +442,7 @@ class Waveform(PlotBase):
|
||||
Due to setting clipToView to True on the curves, the autoRange() method
|
||||
of the ViewBox does no longer work as expected. This method deactivates the
|
||||
setClipToView for all curves, calls autoRange() to circumvent that issue.
|
||||
Afterwards, it re-enables the setClipToView for all curves again.
|
||||
Afterward, it re-enables the setClipToView for all curves again.
|
||||
|
||||
It is hooked to the ViewAll action in the right-click menu of the pg.PlotItem ViewBox.
|
||||
"""
|
||||
@@ -544,6 +720,7 @@ class Waveform(PlotBase):
|
||||
self.sync_signal_update.emit()
|
||||
self.plot_item.enableAutoRange(x=True)
|
||||
self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme
|
||||
self._refresh_alignment_state(force_readback=True)
|
||||
|
||||
@SafeProperty(str)
|
||||
def signal_x(self) -> str | None:
|
||||
@@ -573,6 +750,7 @@ class Waveform(PlotBase):
|
||||
self.sync_signal_update.emit()
|
||||
self.plot_item.enableAutoRange(x=True)
|
||||
self.round_plot_widget.apply_plot_widget_style()
|
||||
self._refresh_alignment_state(force_readback=True)
|
||||
|
||||
@SafeProperty(str)
|
||||
def color_palette(self) -> str:
|
||||
@@ -627,6 +805,8 @@ class Waveform(PlotBase):
|
||||
continue
|
||||
config = CurveConfig(**cfg_dict)
|
||||
self._add_curve(config=config)
|
||||
self._refresh_alignment_state(force_readback=self._alignment_panel_visible)
|
||||
self._refresh_dap_signals()
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to decode JSON: {e}")
|
||||
|
||||
@@ -1002,6 +1182,7 @@ class Waveform(PlotBase):
|
||||
QTimer.singleShot(
|
||||
150, self.auto_range
|
||||
) # autorange with a delay to ensure the plot is updated
|
||||
self._refresh_alignment_state()
|
||||
|
||||
return curve
|
||||
|
||||
@@ -1257,6 +1438,7 @@ class Waveform(PlotBase):
|
||||
self.remove_curve(curve.name())
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.clear_markers()
|
||||
self._refresh_alignment_state()
|
||||
|
||||
def get_curve(self, curve: int | str) -> Curve | None:
|
||||
"""
|
||||
@@ -1292,6 +1474,7 @@ class Waveform(PlotBase):
|
||||
|
||||
self._refresh_colors()
|
||||
self._categorise_device_curves()
|
||||
self._refresh_alignment_state()
|
||||
|
||||
def _remove_curve_by_name(self, name: str):
|
||||
"""
|
||||
@@ -1342,6 +1525,8 @@ class Waveform(PlotBase):
|
||||
and self.enable_side_panel is True
|
||||
):
|
||||
self.dap_summary.remove_dap_data(curve.name())
|
||||
if curve.config.source == "dap" and self._alignment_controller is not None:
|
||||
self._alignment_controller.remove_dap_curve(curve.name())
|
||||
|
||||
# find a corresponding dap curve and remove it
|
||||
for c in self.curves:
|
||||
@@ -1778,7 +1963,7 @@ class Waveform(PlotBase):
|
||||
if parent_curve is None:
|
||||
logger.warning(
|
||||
f"No device curve found for DAP curve '{dap_curve.name()}'!"
|
||||
) # TODO triggerd when DAP curve is removed from the curve dialog, why?
|
||||
) # TODO triggered when DAP curve is removed from the curve dialog, why?
|
||||
continue
|
||||
|
||||
x_data, y_data = parent_curve.get_data()
|
||||
@@ -1983,6 +2168,7 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
x_data = None
|
||||
new_suffix = None
|
||||
previous_x_device = self._current_x_device
|
||||
data, access_key = self._fetch_scan_data_and_access()
|
||||
|
||||
# 1 User wants custom signal
|
||||
@@ -2041,6 +2227,7 @@ class Waveform(PlotBase):
|
||||
if not scan_report_devices:
|
||||
x_data = None
|
||||
new_suffix = " (auto: index)"
|
||||
self._current_x_device = None
|
||||
else:
|
||||
device_x = scan_report_devices[0]
|
||||
signal_x = self.entry_validator.validate_signal(device_x, None)
|
||||
@@ -2050,8 +2237,10 @@ class Waveform(PlotBase):
|
||||
entry_obj = data.get(device_x, {}).get(signal_x)
|
||||
x_data = entry_obj.read()["value"] if entry_obj else None
|
||||
new_suffix = f" (auto: {device_x}-{signal_x})"
|
||||
self._current_x_device = (device_x, signal_x)
|
||||
self._current_x_device = (device_x, signal_x)
|
||||
self._update_x_label_suffix(new_suffix)
|
||||
if previous_x_device != self._current_x_device:
|
||||
self._refresh_alignment_state(force_readback=True)
|
||||
return x_data
|
||||
|
||||
def _update_x_label_suffix(self, new_suffix: str):
|
||||
@@ -2096,7 +2285,7 @@ class Waveform(PlotBase):
|
||||
|
||||
def _categorise_device_curves(self) -> str:
|
||||
"""
|
||||
Categorise the device curves into sync and async based on the readout priority.
|
||||
Categorize the device curves into sync and async based on the readout priority.
|
||||
"""
|
||||
if self.scan_item is None:
|
||||
self.update_with_scan_history(-1)
|
||||
@@ -2453,6 +2642,8 @@ class Waveform(PlotBase):
|
||||
Cleanup the widget by disconnecting signals and closing dialogs.
|
||||
"""
|
||||
self.proxy_dap_request.cleanup()
|
||||
if self._alignment_controller is not None:
|
||||
self._alignment_controller.cleanup()
|
||||
self.clear_all()
|
||||
if self.curve_settings_dialog is not None:
|
||||
self.curve_settings_dialog.reject()
|
||||
|
||||
@@ -9,7 +9,8 @@ from qtpy import QtCore, QtGui
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
@@ -40,7 +41,7 @@ class ProgressbarConfig(ConnectionConfig):
|
||||
line_width: int = Field(20, description="Line widths for the progress bars.")
|
||||
start_position: int = Field(
|
||||
90,
|
||||
description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to "
|
||||
description="Start position for the progress bars in degrees. Default is 90 degrees - corresponds to "
|
||||
"the top of the ring.",
|
||||
)
|
||||
min_value: int | float = Field(0, description="Minimum value for the progress bars.")
|
||||
@@ -59,7 +60,7 @@ class ProgressbarConfig(ConnectionConfig):
|
||||
)
|
||||
|
||||
|
||||
class Ring(BECConnector, QWidget):
|
||||
class Ring(BECWidget, QWidget):
|
||||
USER_ACCESS = [
|
||||
"set_value",
|
||||
"set_color",
|
||||
@@ -82,8 +83,26 @@ class Ring(BECConnector, QWidget):
|
||||
self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None
|
||||
self.RID = None
|
||||
self._gap = 5
|
||||
self._hovered = False
|
||||
self._hover_progress = 0.0
|
||||
self._hover_animation = QtCore.QPropertyAnimation(self, b"hover_progress", parent=self)
|
||||
self._hover_animation.setDuration(180)
|
||||
easing_curve = (
|
||||
QtCore.QEasingCurve.Type.OutCubic
|
||||
if hasattr(QtCore.QEasingCurve, "Type")
|
||||
else QtCore.QEasingCurve.Type.OutCubic
|
||||
)
|
||||
self._hover_animation.setEasingCurve(easing_curve)
|
||||
self.set_start_angle(self.config.start_position)
|
||||
|
||||
def _request_update(self, *, refresh_tooltip: bool = True):
|
||||
# NOTE why not just overwrite update() to always refresh the tooltip?
|
||||
# Because in some cases (e.g. hover animation) we want to update the widget without refreshing the tooltip, to avoid performance issues.
|
||||
if refresh_tooltip:
|
||||
if self.progress_container and self.progress_container.is_ring_hovered(self):
|
||||
self.progress_container.refresh_hover_tooltip(self)
|
||||
self.update()
|
||||
|
||||
def set_value(self, value: int | float):
|
||||
"""
|
||||
Set the value for the ring widget
|
||||
@@ -107,7 +126,7 @@ class Ring(BECConnector, QWidget):
|
||||
if self.config.link_colors:
|
||||
self._auto_set_background_color()
|
||||
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_background(self, color: str | tuple | QColor):
|
||||
"""
|
||||
@@ -122,7 +141,7 @@ class Ring(BECConnector, QWidget):
|
||||
|
||||
self._background_color = self.convert_color(color)
|
||||
self.config.background_color = self._background_color.name()
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def _auto_set_background_color(self):
|
||||
"""
|
||||
@@ -133,7 +152,7 @@ class Ring(BECConnector, QWidget):
|
||||
bg_color = Colors.subtle_background_color(self._color, bg)
|
||||
self.config.background_color = bg_color.name()
|
||||
self._background_color = bg_color
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_colors_linked(self, linked: bool):
|
||||
"""
|
||||
@@ -146,7 +165,7 @@ class Ring(BECConnector, QWidget):
|
||||
self.config.link_colors = linked
|
||||
if linked:
|
||||
self._auto_set_background_color()
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_line_width(self, width: int):
|
||||
"""
|
||||
@@ -156,7 +175,7 @@ class Ring(BECConnector, QWidget):
|
||||
width(int): Line width for the ring widget
|
||||
"""
|
||||
self.config.line_width = width
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_min_max_values(self, min_value: int | float, max_value: int | float):
|
||||
"""
|
||||
@@ -168,7 +187,7 @@ class Ring(BECConnector, QWidget):
|
||||
"""
|
||||
self.config.min_value = min_value
|
||||
self.config.max_value = max_value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_start_angle(self, start_angle: int):
|
||||
"""
|
||||
@@ -178,7 +197,7 @@ class Ring(BECConnector, QWidget):
|
||||
start_angle(int): Start angle for the ring widget in degrees
|
||||
"""
|
||||
self.config.start_position = start_angle
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_update(
|
||||
self, mode: Literal["manual", "scan", "device"], device: str = "", signal: str = ""
|
||||
@@ -237,7 +256,7 @@ class Ring(BECConnector, QWidget):
|
||||
precision(int): Precision for the ring widget
|
||||
"""
|
||||
self.config.precision = precision
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_direction(self, direction: int):
|
||||
"""
|
||||
@@ -247,7 +266,7 @@ class Ring(BECConnector, QWidget):
|
||||
direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise.
|
||||
"""
|
||||
self.config.direction = direction
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def _get_signals_for_device(self, device: str) -> dict[str, list[str]]:
|
||||
"""
|
||||
@@ -276,7 +295,7 @@ class Ring(BECConnector, QWidget):
|
||||
for obj in dev_obj._info["signals"].values()
|
||||
if obj["kind_str"] == "hinted"
|
||||
and obj["signal_class"]
|
||||
not in ["ProgressSignal", "AyncSignal", "AsyncMultiSignal", "DynamicSignal"]
|
||||
not in ["ProgressSignal", "AsyncSignal", "AsyncMultiSignal", "DynamicSignal"]
|
||||
]
|
||||
|
||||
normal_signals = [
|
||||
@@ -424,8 +443,11 @@ class Ring(BECConnector, QWidget):
|
||||
rect.adjust(max_ring_size, max_ring_size, -max_ring_size, -max_ring_size)
|
||||
|
||||
# Background arc
|
||||
base_line_width = float(self.config.line_width)
|
||||
hover_line_delta = min(3.0, round(base_line_width * 0.6, 1))
|
||||
current_line_width = base_line_width + (hover_line_delta * self._hover_progress)
|
||||
painter.setPen(
|
||||
QtGui.QPen(self._background_color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||
QtGui.QPen(self._background_color, current_line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||
)
|
||||
|
||||
gap: int = self.gap # type: ignore
|
||||
@@ -433,13 +455,25 @@ class Ring(BECConnector, QWidget):
|
||||
# Important: Qt uses a 16th of a degree for angles. start_position is therefore multiplied by 16.
|
||||
start_position: float = self.config.start_position * 16 # type: ignore
|
||||
|
||||
adjusted_rect = QtCore.QRect(
|
||||
adjusted_rect = QtCore.QRectF(
|
||||
rect.left() + gap, rect.top() + gap, rect.width() - 2 * gap, rect.height() - 2 * gap
|
||||
)
|
||||
if self._hover_progress > 0.0:
|
||||
hover_radius_delta = 4.0
|
||||
base_radius = adjusted_rect.width() / 2
|
||||
if base_radius > 0:
|
||||
target_radius = base_radius + (hover_radius_delta * self._hover_progress)
|
||||
scale = target_radius / base_radius
|
||||
center = adjusted_rect.center()
|
||||
new_width = adjusted_rect.width() * scale
|
||||
new_height = adjusted_rect.height() * scale
|
||||
adjusted_rect = QtCore.QRectF(
|
||||
center.x() - new_width / 2, center.y() - new_height / 2, new_width, new_height
|
||||
)
|
||||
painter.drawArc(adjusted_rect, start_position, 360 * 16)
|
||||
|
||||
# Foreground arc
|
||||
pen = QtGui.QPen(self.color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||
pen = QtGui.QPen(self.color, current_line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||
pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
proportion = (self.config.value - self.config.min_value) / (
|
||||
@@ -449,7 +483,17 @@ class Ring(BECConnector, QWidget):
|
||||
painter.drawArc(adjusted_rect, start_position, angle)
|
||||
painter.end()
|
||||
|
||||
def convert_color(self, color: str | tuple | QColor) -> QColor:
|
||||
def set_hovered(self, hovered: bool):
|
||||
if hovered == self._hovered:
|
||||
return
|
||||
self._hovered = hovered
|
||||
self._hover_animation.stop()
|
||||
self._hover_animation.setStartValue(self._hover_progress)
|
||||
self._hover_animation.setEndValue(1.0 if hovered else 0.0)
|
||||
self._hover_animation.start()
|
||||
|
||||
@staticmethod
|
||||
def convert_color(color: str | tuple | QColor) -> QColor:
|
||||
"""
|
||||
Convert the color to QColor
|
||||
|
||||
@@ -485,7 +529,7 @@ class Ring(BECConnector, QWidget):
|
||||
@gap.setter
|
||||
def gap(self, value: int):
|
||||
self._gap = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def link_colors(self) -> bool:
|
||||
@@ -522,7 +566,7 @@ class Ring(BECConnector, QWidget):
|
||||
float(max(self.config.min_value, min(self.config.max_value, value))),
|
||||
self.config.precision,
|
||||
)
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(float)
|
||||
def min_value(self) -> float:
|
||||
@@ -531,7 +575,7 @@ class Ring(BECConnector, QWidget):
|
||||
@min_value.setter
|
||||
def min_value(self, value: float):
|
||||
self.config.min_value = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(float)
|
||||
def max_value(self) -> float:
|
||||
@@ -540,7 +584,7 @@ class Ring(BECConnector, QWidget):
|
||||
@max_value.setter
|
||||
def max_value(self, value: float):
|
||||
self.config.max_value = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(str)
|
||||
def mode(self) -> str:
|
||||
@@ -549,6 +593,7 @@ class Ring(BECConnector, QWidget):
|
||||
@mode.setter
|
||||
def mode(self, value: str):
|
||||
self.set_update(value)
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(str)
|
||||
def device(self) -> str:
|
||||
@@ -557,6 +602,7 @@ class Ring(BECConnector, QWidget):
|
||||
@device.setter
|
||||
def device(self, value: str):
|
||||
self.config.device = value
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(str)
|
||||
def signal(self) -> str:
|
||||
@@ -565,6 +611,7 @@ class Ring(BECConnector, QWidget):
|
||||
@signal.setter
|
||||
def signal(self, value: str):
|
||||
self.config.signal = value
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(int)
|
||||
def line_width(self) -> int:
|
||||
@@ -573,7 +620,7 @@ class Ring(BECConnector, QWidget):
|
||||
@line_width.setter
|
||||
def line_width(self, value: int):
|
||||
self.config.line_width = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(int)
|
||||
def start_position(self) -> int:
|
||||
@@ -582,7 +629,7 @@ class Ring(BECConnector, QWidget):
|
||||
@start_position.setter
|
||||
def start_position(self, value: int):
|
||||
self.config.start_position = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(int)
|
||||
def precision(self) -> int:
|
||||
@@ -591,7 +638,7 @@ class Ring(BECConnector, QWidget):
|
||||
@precision.setter
|
||||
def precision(self, value: int):
|
||||
self.config.precision = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(int)
|
||||
def direction(self) -> int:
|
||||
@@ -600,7 +647,27 @@ class Ring(BECConnector, QWidget):
|
||||
@direction.setter
|
||||
def direction(self, value: int):
|
||||
self.config.direction = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(float)
|
||||
def hover_progress(self) -> float:
|
||||
return self._hover_progress
|
||||
|
||||
@hover_progress.setter
|
||||
def hover_progress(self, value: float):
|
||||
self._hover_progress = value
|
||||
self._request_update(refresh_tooltip=False)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the ring widget.
|
||||
Disconnect any registered slots.
|
||||
"""
|
||||
if self.registered_slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
|
||||
self.registered_slot = None
|
||||
self._hover_animation.stop()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Literal
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtCore import QPointF, QSize, Qt
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
@@ -12,6 +12,7 @@ from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.main_window.addons.hover_widget import WidgetTooltip
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings
|
||||
|
||||
@@ -29,7 +30,16 @@ class RingProgressContainerWidget(QWidget):
|
||||
self.rings: list[Ring] = []
|
||||
self.gap = 20 # Gap between rings
|
||||
self.color_map: str = "turbo"
|
||||
self._hovered_ring: Ring | None = None
|
||||
self._last_hover_global_pos = None
|
||||
self._hover_tooltip_label = QLabel()
|
||||
self._hover_tooltip_label.setWordWrap(True)
|
||||
self._hover_tooltip_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self._hover_tooltip_label.setMaximumWidth(260)
|
||||
self._hover_tooltip_label.setStyleSheet("font-size: 12px;")
|
||||
self._hover_tooltip = WidgetTooltip(self._hover_tooltip_label)
|
||||
self.setLayout(QHBoxLayout())
|
||||
self.setMouseTracking(True)
|
||||
self.initialize_bars()
|
||||
self.initialize_center_label()
|
||||
|
||||
@@ -59,6 +69,7 @@ class RingProgressContainerWidget(QWidget):
|
||||
"""
|
||||
ring = Ring(parent=self)
|
||||
ring.setGeometry(self.rect())
|
||||
ring.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
ring.gap = self.gap * len(self.rings)
|
||||
ring.set_value(0)
|
||||
self.rings.append(ring)
|
||||
@@ -88,6 +99,10 @@ class RingProgressContainerWidget(QWidget):
|
||||
index = self.num_bars - 1
|
||||
index = self._validate_index(index)
|
||||
ring = self.rings[index]
|
||||
if ring is self._hovered_ring:
|
||||
self._hovered_ring = None
|
||||
self._last_hover_global_pos = None
|
||||
self._hover_tooltip.hide()
|
||||
ring.cleanup()
|
||||
ring.close()
|
||||
ring.deleteLater()
|
||||
@@ -106,6 +121,7 @@ class RingProgressContainerWidget(QWidget):
|
||||
|
||||
self.center_label = QLabel("", parent=self)
|
||||
self.center_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.center_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
layout.addWidget(self.center_label)
|
||||
|
||||
def _calculate_minimum_size(self):
|
||||
@@ -150,6 +166,130 @@ class RingProgressContainerWidget(QWidget):
|
||||
for ring in self.rings:
|
||||
ring.setGeometry(self.rect())
|
||||
|
||||
def enterEvent(self, event):
|
||||
self.setMouseTracking(True)
|
||||
super().enterEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
pos = event.position() if hasattr(event, "position") else QPointF(event.pos())
|
||||
self._last_hover_global_pos = (
|
||||
event.globalPosition().toPoint()
|
||||
if hasattr(event, "globalPosition")
|
||||
else event.globalPos()
|
||||
)
|
||||
ring = self._ring_at_pos(pos)
|
||||
self._set_hovered_ring(ring, event)
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self._last_hover_global_pos = None
|
||||
self._set_hovered_ring(None, event)
|
||||
super().leaveEvent(event)
|
||||
|
||||
def _set_hovered_ring(self, ring: Ring | None, event=None):
|
||||
if ring is self._hovered_ring:
|
||||
if ring is not None:
|
||||
self.refresh_hover_tooltip(ring, event)
|
||||
return
|
||||
if self._hovered_ring is not None:
|
||||
self._hovered_ring.set_hovered(False)
|
||||
self._hovered_ring = ring
|
||||
if self._hovered_ring is not None:
|
||||
self._hovered_ring.set_hovered(True)
|
||||
self.refresh_hover_tooltip(self._hovered_ring, event)
|
||||
else:
|
||||
self._hover_tooltip.hide()
|
||||
|
||||
def _ring_at_pos(self, pos: QPointF) -> Ring | None:
|
||||
if not self.rings:
|
||||
return None
|
||||
size = min(self.width(), self.height())
|
||||
if size <= 0:
|
||||
return None
|
||||
x_offset = (self.width() - size) / 2
|
||||
y_offset = (self.height() - size) / 2
|
||||
center_x = x_offset + size / 2
|
||||
center_y = y_offset + size / 2
|
||||
dx = pos.x() - center_x
|
||||
dy = pos.y() - center_y
|
||||
distance = (dx * dx + dy * dy) ** 0.5
|
||||
|
||||
max_ring_size = self.get_max_ring_size()
|
||||
base_radius = (size - 2 * max_ring_size) / 2
|
||||
if base_radius <= 0:
|
||||
return None
|
||||
|
||||
best_ring: Ring | None = None
|
||||
best_delta: float | None = None
|
||||
for ring in self.rings:
|
||||
radius = base_radius - ring.gap
|
||||
if radius <= 0:
|
||||
continue
|
||||
half_width = ring.config.line_width / 2
|
||||
inner = radius - half_width
|
||||
outer = radius + half_width
|
||||
if inner <= distance <= outer:
|
||||
delta = abs(distance - radius)
|
||||
if best_delta is None or delta < best_delta:
|
||||
best_delta = delta
|
||||
best_ring = ring
|
||||
|
||||
return best_ring
|
||||
|
||||
def is_ring_hovered(self, ring: Ring) -> bool:
|
||||
return ring is self._hovered_ring
|
||||
|
||||
def refresh_hover_tooltip(self, ring: Ring, event=None):
|
||||
text = self._build_tooltip_text(ring)
|
||||
if event is not None:
|
||||
self._last_hover_global_pos = (
|
||||
event.globalPosition().toPoint()
|
||||
if hasattr(event, "globalPosition")
|
||||
else event.globalPos()
|
||||
)
|
||||
if self._last_hover_global_pos is None:
|
||||
return
|
||||
self._hover_tooltip_label.setText(text)
|
||||
self._hover_tooltip.apply_theme()
|
||||
self._hover_tooltip.show_near(self._last_hover_global_pos)
|
||||
|
||||
@staticmethod
|
||||
def _build_tooltip_text(ring: Ring) -> str:
|
||||
mode = ring.config.mode
|
||||
mode_label = {"manual": "Manual", "scan": "Scan progress", "device": "Device"}.get(
|
||||
mode, mode
|
||||
)
|
||||
|
||||
precision = int(ring.config.precision)
|
||||
value = ring.config.value
|
||||
min_value = ring.config.min_value
|
||||
max_value = ring.config.max_value
|
||||
range_span = max(max_value - min_value, 1e-9)
|
||||
progress = max(0.0, min(100.0, ((value - min_value) / range_span) * 100))
|
||||
|
||||
lines = [
|
||||
f"Mode: {mode_label}",
|
||||
f"Progress: {value:.{precision}f} / {max_value:.{precision}f} ({progress:.1f}%)",
|
||||
]
|
||||
if min_value != 0:
|
||||
lines.append(f"Range: {min_value:.{precision}f} -> {max_value:.{precision}f}")
|
||||
if mode == "device" and ring.config.device:
|
||||
if ring.config.signal:
|
||||
lines.append(f"Device: {ring.config.device}:{ring.config.signal}")
|
||||
else:
|
||||
lines.append(f"Device: {ring.config.device}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def closeEvent(self, event):
|
||||
# Ensure the hover tooltip is properly cleaned up when this widget closes
|
||||
tooltip = getattr(self, "_hover_tooltip", None)
|
||||
if tooltip is not None:
|
||||
tooltip.close()
|
||||
tooltip.deleteLater()
|
||||
self._hover_tooltip = None
|
||||
super().closeEvent(event)
|
||||
|
||||
def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "RGB"):
|
||||
"""
|
||||
Set the colors for the progress bars from a colormap.
|
||||
@@ -230,6 +370,9 @@ class RingProgressContainerWidget(QWidget):
|
||||
"""
|
||||
Clear all rings from the widget.
|
||||
"""
|
||||
self._hovered_ring = None
|
||||
self._last_hover_global_pos = None
|
||||
self._hover_tooltip.hide()
|
||||
for ring in self.rings:
|
||||
ring.close()
|
||||
ring.deleteLater()
|
||||
|
||||
@@ -63,7 +63,8 @@ class RingCardWidget(QFrame):
|
||||
self.mode_combo.setCurrentText(self._get_display_mode_string(self.ring.config.mode))
|
||||
self._set_widget_mode_enabled(self.ring.config.mode)
|
||||
|
||||
def _get_theme_color(self, color_name: str) -> QColor | None:
|
||||
@staticmethod
|
||||
def _get_theme_color(color_name: str) -> QColor | None:
|
||||
app = QApplication.instance()
|
||||
if not app:
|
||||
return
|
||||
@@ -249,12 +250,13 @@ class RingCardWidget(QFrame):
|
||||
def _on_signal_changed(self, signal: str):
|
||||
device = self.ui.device_combo_box.currentText()
|
||||
signal = self.ui.signal_combo_box.get_signal_name()
|
||||
if not device or device not in self.container.bec_dispatcher.client.device_manager.devices:
|
||||
if not device or device not in self.ring.bec_dispatcher.client.device_manager.devices:
|
||||
return
|
||||
self.ring.set_update("device", device=device, signal=signal)
|
||||
self.ring.config.signal = signal
|
||||
|
||||
def _unify_mode_string(self, mode: str) -> str:
|
||||
@staticmethod
|
||||
def _unify_mode_string(mode: str) -> str:
|
||||
"""Convert mode string to a unified format"""
|
||||
mode = mode.lower()
|
||||
if mode == "scan progress":
|
||||
@@ -263,7 +265,8 @@ class RingCardWidget(QFrame):
|
||||
return "device"
|
||||
return mode
|
||||
|
||||
def _get_display_mode_string(self, mode: str) -> str:
|
||||
@staticmethod
|
||||
def _get_display_mode_string(mode: str) -> str:
|
||||
"""Convert mode string to display format"""
|
||||
match mode:
|
||||
case "manual":
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
"""Admin View panel for setting up account and messaging services in BEC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import DeploymentInfoMessage, ExperimentInfoMessage
|
||||
from qtpy.QtCore import QSize, Qt, QTimer, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_login import BECLogin
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
MaterialIconAction,
|
||||
WidgetAction,
|
||||
create_action_with_text,
|
||||
)
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_http_service import (
|
||||
AtlasEndpoints,
|
||||
AuthenticatedUserInfo,
|
||||
BECAtlasHTTPService,
|
||||
HTTPResponse,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_mat_card import (
|
||||
ExperimentMatCard,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_selection import (
|
||||
ExperimentSelection,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtWidgets import QToolBar
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class OverviewWidget(QGroupBox):
|
||||
"""Overview Widget for the BEC Atlas Admin view"""
|
||||
|
||||
login_requested = Signal(str, str)
|
||||
change_experiment_requested = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.setContentsMargins(12, 0, 12, 6)
|
||||
self._authenticated = False
|
||||
# Root layout
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.root_layout.setSpacing(0)
|
||||
|
||||
# Stacked Layout to switch between login form and overview content
|
||||
self.stacked_layout = QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
self.stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||
self.root_layout.addLayout(self.stacked_layout)
|
||||
|
||||
self._init_login_view()
|
||||
self._init_experiment_overview()
|
||||
self.stacked_layout.setCurrentWidget(self._login_widget)
|
||||
self._experiment_overview_widget.setVisible(False)
|
||||
|
||||
def set_experiment_info(self, experiment_info: ExperimentInfoMessage):
|
||||
"""Set the experiment information for the overview widget."""
|
||||
self._experiment_overview_widget.set_experiment_info(experiment_info)
|
||||
|
||||
@SafeSlot(bool)
|
||||
def set_authenticated(self, authenticated: bool):
|
||||
"""Set the authentication state of the overview widget."""
|
||||
self._authenticated = authenticated
|
||||
if authenticated:
|
||||
self.stacked_layout.setCurrentWidget(self._experiment_overview_widget)
|
||||
self._experiment_overview_widget.setVisible(True)
|
||||
else:
|
||||
self.stacked_layout.setCurrentWidget(self._login_widget)
|
||||
self._experiment_overview_widget.setVisible(False)
|
||||
|
||||
def _init_login_view(self):
|
||||
"""Initialize the login view."""
|
||||
self._login_widget = QWidget()
|
||||
layout = QHBoxLayout(self._login_widget)
|
||||
self._login_widget.setAutoFillBackground(True)
|
||||
self._login_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
layout.setSpacing(16)
|
||||
|
||||
content = QFrame()
|
||||
content_layout = QVBoxLayout(content)
|
||||
content.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
content.setFrameShadow(QFrame.Shadow.Raised)
|
||||
content.setStyleSheet("""
|
||||
QFrame
|
||||
{
|
||||
border: 1px solid #cccccc;
|
||||
}
|
||||
QLabel
|
||||
{
|
||||
border: none;
|
||||
}
|
||||
""")
|
||||
content_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
content.setFixedSize(400, 280)
|
||||
|
||||
self._login = BECLogin(parent=self)
|
||||
self._login.credentials_entered.connect(self.login_requested.emit)
|
||||
content_layout.addWidget(self._login)
|
||||
layout.addWidget(content)
|
||||
self.stacked_layout.addWidget(self._login_widget)
|
||||
|
||||
def _init_experiment_overview(self):
|
||||
"""Initialize the experiment overview content."""
|
||||
self._experiment_overview_widget = ExperimentMatCard(
|
||||
show_activate_button=True,
|
||||
parent=self,
|
||||
title="Current Experiment",
|
||||
button_text="Change Experiment",
|
||||
)
|
||||
self._experiment_overview_widget.experiment_selected.connect(self._on_experiment_selected)
|
||||
layout = QVBoxLayout(self._experiment_overview_widget)
|
||||
self._experiment_overview_widget.setAutoFillBackground(True)
|
||||
self._experiment_overview_widget.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
layout.setSpacing(16)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.stacked_layout.addWidget(self._experiment_overview_widget)
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _on_experiment_selected(self, _):
|
||||
"""Handle the change experiment button click."""
|
||||
self.change_experiment_requested.emit()
|
||||
|
||||
|
||||
class CustomLogoutAction(MaterialIconAction):
|
||||
"""Custom logout action that can be enabled/disabled based on authentication state."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(
|
||||
icon_name="logout",
|
||||
tooltip="Logout",
|
||||
label_text="Logout",
|
||||
text_position="under",
|
||||
parent=parent,
|
||||
filled=True,
|
||||
)
|
||||
self.action.setEnabled(False) # Initially disabled until authenticated
|
||||
self._tick_timer = QTimer(parent)
|
||||
self._tick_timer.setInterval(1000)
|
||||
self._tick_timer.timeout.connect(self._on_tick)
|
||||
self._login_remaining_s = 0
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the action to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar(QToolBar): The toolbar to add the action to.
|
||||
target(QWidget): The target widget for the action.
|
||||
"""
|
||||
create_action_with_text(toolbar_action=self, toolbar=toolbar, min_size=QSize(70, 40))
|
||||
|
||||
def set_authenticated(self, auth_info: AuthenticatedUserInfo | None):
|
||||
"""Enable or disable the logout action based on authentication state."""
|
||||
if not auth_info:
|
||||
self._tick_timer.stop()
|
||||
self._login_remaining_s = 0
|
||||
self.action.setEnabled(False)
|
||||
self.update_label() # Reset Label text
|
||||
return # No need to set the timer if we're not authenticated
|
||||
self._login_remaining_s = max(0, int(auth_info.exp - time.time())) if auth_info else 0
|
||||
self.action.setEnabled(True)
|
||||
if self._login_remaining_s > 0:
|
||||
self._tick_timer.start()
|
||||
|
||||
def _on_tick(self) -> None:
|
||||
"""Handle the timer countdown tick to update the remaining logout time."""
|
||||
self._login_remaining_s -= 1
|
||||
if self._login_remaining_s <= 0:
|
||||
self.set_authenticated(None) # This will disable the action and stop the timer
|
||||
return
|
||||
|
||||
self.update_label() # Optionally update the label to show remaining time
|
||||
|
||||
def update_label(self):
|
||||
"""Update the label text of the logout action."""
|
||||
if self._login_remaining_s > 0:
|
||||
label_text = f"{self.label_text}\n({self._login_remaining_s}s)"
|
||||
else:
|
||||
label_text = self.label_text
|
||||
self.action.setText(label_text)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the timer when the action is destroyed."""
|
||||
if self._tick_timer.isActive():
|
||||
self._tick_timer.stop()
|
||||
|
||||
|
||||
class AtlasConnectionInfo(QWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
layout.setContentsMargins(6, 6, 6, 12)
|
||||
layout.setSpacing(8)
|
||||
self._bl_info_label = QLabel(self)
|
||||
self._atlas_url_label = QLabel(self)
|
||||
layout.addWidget(self._bl_info_label)
|
||||
layout.addWidget(self._atlas_url_label)
|
||||
self._atlas_url_text = ""
|
||||
|
||||
def set_info(self, realm_id: str, bl_name: str, atlas_url: str):
|
||||
"""Set the connection information for the BEC Atlas API."""
|
||||
bl_info = f"{realm_id} @ {bl_name}"
|
||||
self._bl_info_label.setText(bl_info)
|
||||
self._atlas_url_label.setText(atlas_url)
|
||||
self._atlas_url_text = atlas_url
|
||||
|
||||
def set_logged_in(self, email: str):
|
||||
"""Show login status in the atlas info widget."""
|
||||
self._atlas_url_label.setText(f"{self._atlas_url_text} | {email}")
|
||||
|
||||
def clear_login(self):
|
||||
"""Clear login status from the atlas info widget."""
|
||||
self._atlas_url_label.setText(self._atlas_url_text)
|
||||
|
||||
|
||||
class BECAtlasAdminView(BECWidget, QWidget):
|
||||
|
||||
RPC = False
|
||||
|
||||
authenticated = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
atlas_url: str = "https://bec-atlas-prod.psi.ch/api/v1",
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
|
||||
# State variables
|
||||
self._current_deployment_info: DeploymentInfoMessage | None = None
|
||||
self._current_deployment_info = None
|
||||
self._current_session_info = None
|
||||
self._current_experiment_info = None
|
||||
self._authenticated = False
|
||||
self._atlas_url = atlas_url
|
||||
|
||||
# Root layout
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.root_layout.setSpacing(0)
|
||||
|
||||
# Toolbar for navigation between different views in the admin panel
|
||||
self.toolbar = ModularToolBar(self)
|
||||
self.init_toolbar()
|
||||
self.root_layout.insertWidget(0, self.toolbar)
|
||||
self.toolbar.show_bundles(["view", "atlas_info", "auth"])
|
||||
|
||||
# Stacked layout to switch between overview, experiment selection and messaging services
|
||||
# It is added below the toolbar
|
||||
self.stacked_layout = QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
self.stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||
self.root_layout.addLayout(self.stacked_layout)
|
||||
|
||||
# BEC Atlas HTTP Service
|
||||
self.atlas_http_service = BECAtlasHTTPService(
|
||||
parent=self, base_url=atlas_url, headers={"accept": "application/json"}
|
||||
)
|
||||
|
||||
# Overview widget
|
||||
self.overview_widget = OverviewWidget(parent=self)
|
||||
self.stacked_layout.addWidget(self.overview_widget)
|
||||
|
||||
# Experiment Selection widget
|
||||
self.experiment_selection = ExperimentSelection(parent=self)
|
||||
self.experiment_selection.setVisible(False)
|
||||
self.stacked_layout.addWidget(self.experiment_selection)
|
||||
|
||||
# Connect signals
|
||||
self.overview_widget.login_requested.connect(self._on_login_requested)
|
||||
self.overview_widget.change_experiment_requested.connect(
|
||||
self._on_experiment_selection_selected
|
||||
)
|
||||
self.authenticated.connect(self.overview_widget.set_authenticated)
|
||||
self.experiment_selection.experiment_selected.connect(self._on_experiment_selected)
|
||||
self.atlas_http_service.http_response.connect(self._on_http_response_received)
|
||||
self.atlas_http_service.authenticated.connect(self._on_authenticated)
|
||||
self._connect_dispatcher()
|
||||
|
||||
def _connect_dispatcher(self):
|
||||
self.bec_dispatcher.connect_slot(
|
||||
slot=self._update_deployment_info,
|
||||
topics=MessageEndpoints.deployment_info(),
|
||||
from_start=True,
|
||||
)
|
||||
|
||||
def init_toolbar(self):
|
||||
"""Initialize the toolbar for the admin view. This allows to switch between different views in the admin panel."""
|
||||
# Overview
|
||||
overview = MaterialIconAction(
|
||||
icon_name="home",
|
||||
tooltip="Show Overview Panel",
|
||||
label_text="Overview",
|
||||
text_position="under",
|
||||
parent=self,
|
||||
filled=True,
|
||||
)
|
||||
overview.action.triggered.connect(self._on_overview_selected)
|
||||
self.toolbar.components.add_safe("overview", overview)
|
||||
|
||||
# Experiment Selection
|
||||
experiment_selection = MaterialIconAction(
|
||||
icon_name="experiment",
|
||||
tooltip="Show Experiment Selection Panel",
|
||||
label_text="Experiment Selection",
|
||||
text_position="under",
|
||||
parent=self,
|
||||
filled=True,
|
||||
)
|
||||
experiment_selection.action.triggered.connect(self._on_experiment_selection_selected)
|
||||
experiment_selection.action.setEnabled(False) # Initially disabled until authenticated
|
||||
self.toolbar.components.add_safe("experiment_selection", experiment_selection)
|
||||
|
||||
# Messaging Services
|
||||
messaging_services = MaterialIconAction(
|
||||
icon_name="chat",
|
||||
tooltip="Show Messaging Services Panel",
|
||||
label_text="Messaging Services",
|
||||
text_position="under",
|
||||
parent=self,
|
||||
filled=True,
|
||||
)
|
||||
messaging_services.action.triggered.connect(self._on_messaging_services_selected)
|
||||
messaging_services.action.setEnabled(False) # Initially disabled until authenticated
|
||||
self.toolbar.components.add_safe("messaging_services", messaging_services)
|
||||
|
||||
# Atlas Info
|
||||
self._atlas_info_widget = AtlasConnectionInfo(parent=self)
|
||||
atlas_info = WidgetAction(widget=self._atlas_info_widget, parent=self)
|
||||
self.toolbar.components.add_safe("atlas_info", atlas_info)
|
||||
|
||||
logout_action = CustomLogoutAction(parent=self)
|
||||
logout_action.action.triggered.connect(self.logout)
|
||||
logout_action.action.setEnabled(False) # Initially disabled until authenticated
|
||||
self.toolbar.components.add_safe("logout", logout_action)
|
||||
|
||||
# Add view_bundle to toolbar
|
||||
view_bundle = ToolbarBundle("view", self.toolbar.components)
|
||||
view_bundle.add_action("overview")
|
||||
view_bundle.add_action("experiment_selection")
|
||||
view_bundle.add_action("messaging_services")
|
||||
self.toolbar.add_bundle(view_bundle)
|
||||
|
||||
# Add atlas_info to toolbar
|
||||
atlas_info_bundle = ToolbarBundle("atlas_info", self.toolbar.components)
|
||||
atlas_info_bundle.add_action("atlas_info")
|
||||
self.toolbar.add_bundle(atlas_info_bundle)
|
||||
|
||||
# Add auth_bundle to toolbar
|
||||
auth_bundle = ToolbarBundle("auth", self.toolbar.components)
|
||||
auth_bundle.add_action("logout")
|
||||
self.toolbar.add_bundle(auth_bundle)
|
||||
|
||||
########################
|
||||
## Toolbar icon slots
|
||||
########################
|
||||
|
||||
def _on_overview_selected(self):
|
||||
"""Show the overview panel."""
|
||||
self.overview_widget.setVisible(True)
|
||||
self.experiment_selection.setVisible(False)
|
||||
self.stacked_layout.setCurrentWidget(self.overview_widget)
|
||||
|
||||
def _on_experiment_selection_selected(self):
|
||||
"""Show the experiment selection panel."""
|
||||
if not self._authenticated:
|
||||
logger.warning("Attempted to access experiment selection without authentication.")
|
||||
return
|
||||
self.overview_widget.setVisible(False)
|
||||
self.experiment_selection.setVisible(True)
|
||||
self.stacked_layout.setCurrentWidget(self.experiment_selection)
|
||||
|
||||
def _on_messaging_services_selected(self):
|
||||
"""Show the messaging services panel."""
|
||||
logger.info("Messaging services panel is not implemented yet.")
|
||||
return
|
||||
|
||||
########################
|
||||
## Internal slots
|
||||
########################
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _on_experiment_selected(self, experiment_info: dict) -> None:
|
||||
"""Handle the experiment selected signal from the experiment selection widget"""
|
||||
experiment_info = ExperimentInfoMessage.model_validate(experiment_info)
|
||||
experiment_id = experiment_info.pgroup
|
||||
deployment_id = self._current_deployment_info.deployment_id
|
||||
self.set_experiment(experiment_id=experiment_id, deployment_id=deployment_id)
|
||||
|
||||
@SafeSlot(str, str, popup_error=True)
|
||||
def _on_login_requested(self, username: str, password: str):
|
||||
"""Handle login requested signal from the overview widget."""
|
||||
# Logout first to clear any existing session and cookies before attempting a new login
|
||||
if self._authenticated:
|
||||
logger.info("Existing session detected, logging out before attempting new login.")
|
||||
self.logout()
|
||||
# Now login with new credentials
|
||||
self.login(username, password)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def _update_deployment_info(self, msg: dict, _: dict) -> None:
|
||||
"""Fetch current deployment info from the server."""
|
||||
deployment = DeploymentInfoMessage.model_validate(msg)
|
||||
self._current_deployment_info = deployment
|
||||
self._current_session_info = deployment.active_session
|
||||
if self._current_session_info is not None:
|
||||
self._current_experiment_info = self._current_session_info.experiment
|
||||
self.overview_widget.set_experiment_info(self._current_experiment_info)
|
||||
|
||||
self._atlas_info_widget.set_info(
|
||||
realm_id=self._current_experiment_info.realm_id or "",
|
||||
bl_name=self._current_deployment_info.name or "",
|
||||
atlas_url=self._atlas_url,
|
||||
)
|
||||
self.atlas_http_service._set_current_deployment_info(deployment)
|
||||
|
||||
def _fetch_available_experiments(self):
|
||||
"""Fetch the list of available experiments for the authenticated user."""
|
||||
# What if this is None, should this be an optional user input in the UI?
|
||||
if self._current_experiment_info is None:
|
||||
logger.error(
|
||||
"No current experiment info available, cannot fetch available experiments."
|
||||
)
|
||||
return
|
||||
current_realm_id = self._current_experiment_info.realm_id
|
||||
if current_realm_id is None:
|
||||
logger.error(
|
||||
"Current experiment does not have a realm_id, cannot fetch available experiments."
|
||||
)
|
||||
return
|
||||
self.atlas_http_service.get_experiments_for_realm(current_realm_id)
|
||||
|
||||
########################
|
||||
## HTTP Service response handling
|
||||
########################
|
||||
|
||||
def _on_http_response_received(self, response: dict) -> None:
|
||||
"""Handle the HTTP response received from the BEC Atlas API."""
|
||||
response = HTTPResponse(**response)
|
||||
logger.debug(
|
||||
f"HTTP Response received: {response.request_url} with status {response.status}"
|
||||
)
|
||||
if AtlasEndpoints.REALMS_EXPERIMENTS in response.request_url:
|
||||
experiments = response.data if isinstance(response.data, list) else []
|
||||
self.experiment_selection.set_experiment_infos(experiments)
|
||||
elif AtlasEndpoints.SET_EXPERIMENT in response.request_url:
|
||||
self._on_overview_selected()
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _on_authenticated(self, auth_info: dict) -> None:
|
||||
"""Handle authentication state change."""
|
||||
authenticated = False
|
||||
# Only if the user has owner access to the deployment, we consider them to be fully authenticated
|
||||
# This means that although they may authenticate against atlas, they won't be able to see any
|
||||
# extra information here
|
||||
if auth_info:
|
||||
info = AuthenticatedUserInfo.model_validate(auth_info)
|
||||
if (
|
||||
self._current_deployment_info
|
||||
and info.deployment_id == self._current_deployment_info.deployment_id
|
||||
):
|
||||
authenticated = True
|
||||
else:
|
||||
logger.warning(
|
||||
f"Authenticated user {info.email} does not have access to the current deployment {self._current_deployment_info.name if self._current_deployment_info else '<no deployment>'}."
|
||||
)
|
||||
self._authenticated = authenticated
|
||||
|
||||
if authenticated:
|
||||
self.toolbar.components.get_action("experiment_selection").action.setEnabled(True)
|
||||
self.toolbar.components.get_action("messaging_services").action.setEnabled(
|
||||
False
|
||||
) # TODO activate once messaging is added
|
||||
self.toolbar.components.get_action("logout").action.setEnabled(True)
|
||||
self._fetch_available_experiments() # Fetch experiments upon successful authentication
|
||||
self._atlas_info_widget.set_logged_in(info.email)
|
||||
self.toolbar.components.get_action("logout").set_authenticated(info)
|
||||
else:
|
||||
self.toolbar.components.get_action("experiment_selection").action.setEnabled(False)
|
||||
self.toolbar.components.get_action("messaging_services").action.setEnabled(False)
|
||||
self.toolbar.components.get_action("logout").action.setEnabled(False)
|
||||
# Delete data in experiment selection widget upon logout
|
||||
self.experiment_selection.set_experiment_infos([])
|
||||
self._on_overview_selected() # Switch back to overview on logout
|
||||
self._atlas_info_widget.clear_login() # Clear login status in atlas info widget on logout
|
||||
self.toolbar.components.get_action("logout").set_authenticated(None)
|
||||
self.authenticated.emit(authenticated)
|
||||
|
||||
################
|
||||
## API Methods
|
||||
################
|
||||
|
||||
@SafeSlot(str, str, popup_error=True)
|
||||
def set_experiment(self, experiment_id: str, deployment_id: str) -> None:
|
||||
"""Set the experiment information for the current experiment."""
|
||||
self.atlas_http_service.set_experiment(experiment_id, deployment_id)
|
||||
|
||||
@SafeSlot(str, str, popup_error=True)
|
||||
def login(self, username: str, password: str) -> None:
|
||||
"""Login to the BEC Atlas API with the provided username and password."""
|
||||
self.atlas_http_service.login(username=username, password=password)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def logout(self) -> None:
|
||||
"""Logout from the BEC Atlas API."""
|
||||
self.atlas_http_service.logout()
|
||||
|
||||
def get_user_info(self):
|
||||
"""Get the current user information from the BEC Atlas API."""
|
||||
self.atlas_http_service.get_user_info()
|
||||
|
||||
###############
|
||||
## Cleanup
|
||||
###############
|
||||
|
||||
def cleanup(self):
|
||||
self.atlas_http_service.cleanup()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
apply_theme("light")
|
||||
window = BECAtlasAdminView()
|
||||
|
||||
exp_info_dict = {
|
||||
"_id": "p22622",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22622"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "12345967",
|
||||
"title": "Test Experiment for Mat Card Widget",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"email": "john.doe@psi.ch",
|
||||
"account": "doe_j",
|
||||
"pi_firstname": "Jane",
|
||||
"pi_lastname": "Smith",
|
||||
"pi_email": "jane.smith@psi.ch",
|
||||
"pi_account": "smith_j",
|
||||
"eaccount": "e22622",
|
||||
"pgroup": "p22622",
|
||||
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
|
||||
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
|
||||
"proposal_submitted": "15/12/2024",
|
||||
"proposal_expire": "31/12/2025",
|
||||
"proposal_status": "Scheduled",
|
||||
"delta_last_schedule": 30,
|
||||
"mainproposal": "",
|
||||
}
|
||||
from bec_lib.messages import DeploymentInfoMessage, ExperimentInfoMessage, SessionInfoMessage
|
||||
|
||||
# proposal_info = ExperimentInfoMessage(**exp_info_dict)
|
||||
# session_info = SessionInfoMessage(name="Test Session", experiment=proposal_info)
|
||||
# deployment_info = DeploymentInfoMessage(
|
||||
# deployment_id="test_deployment_001", active_session=session_info
|
||||
# )
|
||||
# window.set_experiment_info(proposal_info)
|
||||
window.resize(800, 600)
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
HTTP service widget for interacting with the BEC Atlas API.
|
||||
|
||||
This module defines Qt-based classes that wrap ``QNetworkAccessManager`` to perform
|
||||
authenticated HTTP requests against the BEC Atlas backend, manage login and logout
|
||||
flows, and track authentication token expiry for the BEC Atlas Admin View.
|
||||
|
||||
It also provides Pydantic models that describe HTTP responses and authenticated user
|
||||
information, as well as an ``AuthenticatedTimer`` helper used to signal when
|
||||
authentication tokens expire.
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
import jwt
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import DeploymentInfoMessage
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtCore import QObject, QTimer, QUrl, QUrlQuery, Signal
|
||||
from qtpy.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
|
||||
from qtpy.QtWidgets import QMessageBox, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class AtlasEndpoints(StrEnum):
|
||||
"""Constants for BEC Atlas API endpoints."""
|
||||
|
||||
LOGIN = "/user/login"
|
||||
LOGOUT = "/user/logout"
|
||||
REALMS_EXPERIMENTS = "/realms/experiments"
|
||||
SET_EXPERIMENT = "/deployments/experiment"
|
||||
USER_INFO = "/user/me"
|
||||
DEPLOYMENT_INFO = "/deployments/id"
|
||||
|
||||
|
||||
class BECAtlasHTTPError(Exception):
|
||||
"""Custom exception for BEC Atlas HTTP errors."""
|
||||
|
||||
|
||||
class HTTPResponse(BaseModel):
|
||||
"""Model representing an HTTP response."""
|
||||
|
||||
request_url: str
|
||||
headers: dict
|
||||
status: int
|
||||
data: dict | list | str # Check with Klaus if str is deprecated
|
||||
|
||||
|
||||
class AuthenticatedUserInfo(BaseModel):
|
||||
"""Model representing authenticated user information."""
|
||||
|
||||
email: str
|
||||
exp: float
|
||||
groups: set[str]
|
||||
deployment_id: str
|
||||
|
||||
|
||||
class AuthenticatedTimer(QObject):
|
||||
"""Timer to track authentication expiration and emit a signal when the token expires."""
|
||||
|
||||
expired = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._on_expired)
|
||||
|
||||
def start(self, duration_seconds: float):
|
||||
"""Start the timer with the given duration in seconds."""
|
||||
self._timer.start(int(duration_seconds * 1000))
|
||||
|
||||
def stop(self):
|
||||
"""Stop the timer."""
|
||||
self._timer.stop()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_expired(self):
|
||||
"""Handle the timer expiration by emitting the expired signal."""
|
||||
logger.info("Authentication token has expired.")
|
||||
self.expired.emit()
|
||||
|
||||
|
||||
class BECAtlasHTTPService(QWidget):
|
||||
"""HTTP service using the QNetworkAccessManager to interact with the BEC Atlas API."""
|
||||
|
||||
http_response = Signal(dict) # HTTPResponse.model_dump() dict
|
||||
authenticated = Signal(dict) # AuthenticatedUserInfo.model_dump() dict or {}
|
||||
authentication_expires = Signal(float)
|
||||
|
||||
def __init__(self, parent=None, base_url: str = "", headers: dict | None = None):
|
||||
super().__init__(parent)
|
||||
if headers is None:
|
||||
headers = {"accept": "application/json"}
|
||||
self._headers = headers
|
||||
self._base_url = base_url
|
||||
self.network_manager = QNetworkAccessManager(self)
|
||||
self.network_manager.finished.connect(self._handle_response)
|
||||
self._auth_user_info: AuthenticatedUserInfo | None = None
|
||||
self._auth_timer = self._create_auth_timer()
|
||||
self._current_deployment_info = None
|
||||
|
||||
def _create_auth_timer(self) -> AuthenticatedTimer:
|
||||
"""Create and connect the authenticated timer to handle token expiration."""
|
||||
timer = AuthenticatedTimer(self)
|
||||
timer.expired.connect(self.__clear_login_info)
|
||||
return timer
|
||||
|
||||
@property
|
||||
def auth_user_info(self) -> AuthenticatedUserInfo | None:
|
||||
"""Get the authenticated user information, including email and token expiration time."""
|
||||
return self._auth_user_info
|
||||
|
||||
def __set_auth_info(self, login_info: dict[Literal["email", "exp"], str | float]):
|
||||
"""Set the authenticated user information after a successful login."""
|
||||
login_info.update({"groups": []}) # Initialize groups as empty until we fetch user info
|
||||
login_info.update(
|
||||
{
|
||||
"deployment_id": (
|
||||
self._current_deployment_info.deployment_id
|
||||
if self._current_deployment_info
|
||||
else ""
|
||||
)
|
||||
}
|
||||
)
|
||||
self._auth_user_info = AuthenticatedUserInfo(**login_info)
|
||||
# Start timer to clear auth info once token expires
|
||||
exp_time = login_info.get("exp", 0)
|
||||
current_time = time.time() # TODO should we use server time to avoid clock skew issues?
|
||||
duration = max(0, exp_time - current_time)
|
||||
self._auth_timer.start(duration)
|
||||
|
||||
def __set_auth_groups(self, groups: list[str]):
|
||||
"""Set the authenticated user's groups after fetching user info."""
|
||||
if self._auth_user_info is not None:
|
||||
self._auth_user_info.groups = set(groups)
|
||||
|
||||
def __check_access_to_owner_groups(self, groups: list[str]) -> bool:
|
||||
"""Check if the authenticated user has access to the current deployment based on their groups."""
|
||||
if self._auth_user_info is None or self._current_deployment_info is None:
|
||||
return False
|
||||
# Admin user
|
||||
has_both = {"admin", "atlas_func_account"}.issubset(groups)
|
||||
if has_both:
|
||||
return True
|
||||
# Regular user check with group intersection
|
||||
return not self.auth_user_info.groups.isdisjoint(groups)
|
||||
|
||||
def __clear_login_info(self, skip_logout: bool = False):
|
||||
"""Clear the authenticated user information after logout."""
|
||||
self._auth_user_info = None
|
||||
if not skip_logout:
|
||||
self.logout() # Ensure we also logout on the server side and invalidate the session
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.cleanup()
|
||||
return super().closeEvent(event)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup connection, destroy authenticate cookies."""
|
||||
logger.info("Cleaning up BECAtlasHTTPService: disconnecting signals and clearing cookies.")
|
||||
# Disconnect signals to avoid handling responses after cleanup
|
||||
self.network_manager.finished.disconnect(self._handle_response)
|
||||
|
||||
# Logout to invalidate session on server side
|
||||
self.logout()
|
||||
|
||||
# Stop the authentication timer
|
||||
self._auth_timer.stop()
|
||||
|
||||
# Delete all cookies related to the base URL
|
||||
for cookie in self.network_manager.cookieJar().cookiesForUrl(QUrl(self._base_url)):
|
||||
self.network_manager.cookieJar().deleteCookie(cookie)
|
||||
|
||||
@SafeSlot(QNetworkReply, popup_error=True)
|
||||
def _handle_response(self, reply: QNetworkReply):
|
||||
"""
|
||||
Handle the HTTP response from the server.
|
||||
|
||||
Args:
|
||||
reply (QNetworkReply): The network reply object containing the response.
|
||||
"""
|
||||
status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
|
||||
raw_bytes = reply.readAll().data()
|
||||
request_url = reply.url().toString()
|
||||
headers = dict([(i.data().decode(), j.data().decode()) for i, j in reply.rawHeaderPairs()])
|
||||
reply.deleteLater()
|
||||
|
||||
# Any unsuccessful status code should raise here
|
||||
if status != 200:
|
||||
raise BECAtlasHTTPError(
|
||||
f"HTTP request for {request_url} failed with status code {status} and response: {raw_bytes.decode('utf-8')}"
|
||||
)
|
||||
|
||||
if len(raw_bytes) > 0:
|
||||
data = json.loads(raw_bytes.decode())
|
||||
else:
|
||||
data = {}
|
||||
|
||||
if data is None:
|
||||
data = {}
|
||||
logger.warning(f"Received empty response for {request_url} with status code {status}.")
|
||||
|
||||
if not isinstance(data, (dict, list, str)):
|
||||
raise BECAtlasHTTPError(
|
||||
f"Expected response data to be a dict, list, or str for {request_url}, but got {type(data)}. Response content: {data}"
|
||||
)
|
||||
|
||||
if AtlasEndpoints.LOGIN.value in request_url:
|
||||
# If it's a login response, don't forward the token
|
||||
# but extract the expiration time and emit it
|
||||
token = data.get("access_token")
|
||||
data = jwt.decode(token, options={"verify_signature": False})
|
||||
self.authentication_expires.emit(data.get("exp", 0))
|
||||
# Now we set the auth info, and then fetch the user info to get the groups
|
||||
self.__set_auth_info(data)
|
||||
self.get_user_info() # Fetch groups, then emit authenticated once groups are set on auth_user
|
||||
elif AtlasEndpoints.LOGOUT.value in request_url:
|
||||
self._auth_timer.stop() # Stop the timer if it was running
|
||||
self.__clear_login_info(skip_logout=True) # Skip calling logout again
|
||||
self.authenticated.emit({})
|
||||
elif AtlasEndpoints.USER_INFO.value in request_url:
|
||||
groups = data.get("groups", [])
|
||||
email = data.get("email", "")
|
||||
# Second step of authentication: We also have all groups now
|
||||
if self.auth_user_info is not None and self.auth_user_info.email == email:
|
||||
self.__set_auth_groups(groups)
|
||||
if self._current_deployment_info is not None:
|
||||
# Now we need to fetch the deployment info to get the owner groups and check access rights,
|
||||
# Then we can emit the authenticated signal with the full user info including groups if access is
|
||||
# granted. Otherwise, we emit nothing and show a warning that the user does not have the access
|
||||
# rights for the current deployment.
|
||||
self.get_deployment_info(
|
||||
deployment_id=self._current_deployment_info.deployment_id
|
||||
)
|
||||
elif AtlasEndpoints.DEPLOYMENT_INFO.value in request_url:
|
||||
owner_groups = data.get("owner_groups", [])
|
||||
if self.__check_access_to_owner_groups(owner_groups):
|
||||
self.authenticated.emit(self.auth_user_info.model_dump())
|
||||
else:
|
||||
if self.auth_user_info is not None:
|
||||
warning_text = f"User {self.auth_user_info.email} does not have access to the active deployment {data.get('name', '<unknown>')}."
|
||||
else:
|
||||
warning_text = "Authenticated user information is missing. Cannot verify access to the active deployment."
|
||||
self._show_warning(warning_text)
|
||||
self.logout() # Logout to clear auth info and stop timer since user does not have access
|
||||
|
||||
response = HTTPResponse(request_url=request_url, headers=headers, status=status, data=data)
|
||||
self.http_response.emit(response.model_dump())
|
||||
|
||||
def _show_warning(self, text: str):
|
||||
"""Show a warning message to the user."""
|
||||
msg = QMessageBox(self)
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
msg.setText(text)
|
||||
msg.setWindowTitle("Authentication Warning")
|
||||
msg.exec_()
|
||||
|
||||
#######################
|
||||
# GET/POST Request Methods
|
||||
#######################
|
||||
|
||||
def _get_request(self, endpoint: str, query_parameters: dict | None = None):
|
||||
"""
|
||||
GET request to the API endpoint.
|
||||
|
||||
Args:
|
||||
endpoint (str): The API endpoint to send the GET request to.
|
||||
query_parameters (dict | None): Optional query parameters to include in the URL.
|
||||
"""
|
||||
url = QUrl(self._base_url + endpoint)
|
||||
if query_parameters:
|
||||
query = QUrlQuery()
|
||||
for key, value in query_parameters.items():
|
||||
query.addQueryItem(key, value)
|
||||
url.setQuery(query)
|
||||
request = QNetworkRequest(url)
|
||||
for key, value in self._headers.items():
|
||||
request.setRawHeader(key.encode(), value.encode())
|
||||
self.network_manager.get(request)
|
||||
|
||||
def _post_request(
|
||||
self, endpoint: str, payload: dict | None = None, query_parameters: dict | None = None
|
||||
):
|
||||
"""
|
||||
POST request to the API endpoint with a JSON payload.
|
||||
|
||||
Args:
|
||||
endpoint (str): The API endpoint to send the POST request to.
|
||||
payload (dict): The JSON payload to include in the POST request.
|
||||
query_parameters (dict | None): Optional query parameters to include in the URL.
|
||||
"""
|
||||
if payload is None:
|
||||
payload = {}
|
||||
url = QUrl(self._base_url + endpoint)
|
||||
if query_parameters:
|
||||
query = QUrlQuery()
|
||||
for key, value in query_parameters.items():
|
||||
query.addQueryItem(key, value)
|
||||
url.setQuery(query)
|
||||
request = QNetworkRequest(url)
|
||||
|
||||
# Headers
|
||||
for key, value in self._headers.items():
|
||||
request.setRawHeader(key.encode(), value.encode())
|
||||
request.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json")
|
||||
|
||||
payload_dump = json.dumps(payload).encode()
|
||||
self.network_manager.post(request, payload_dump)
|
||||
|
||||
def _set_current_deployment_info(self, deployment_info: dict | DeploymentInfoMessage):
|
||||
"""
|
||||
Set the current deployment information for the service.
|
||||
|
||||
Args:
|
||||
deployment_info (dict | DeploymentInfoMessage): The deployment information to set.
|
||||
"""
|
||||
if isinstance(deployment_info, dict):
|
||||
deployment_info = DeploymentInfoMessage.model_validate(deployment_info)
|
||||
self._current_deployment_info = deployment_info
|
||||
|
||||
################
|
||||
# API Methods
|
||||
################
|
||||
|
||||
@SafeSlot(str, str, popup_error=True)
|
||||
def login(self, username: str, password: str):
|
||||
"""
|
||||
Login to BEC Atlas with the provided username and password.
|
||||
|
||||
Args:
|
||||
username (str): The username for authentication.
|
||||
password (str): The password for authentication.
|
||||
"""
|
||||
self._post_request(
|
||||
endpoint=AtlasEndpoints.LOGIN.value,
|
||||
payload={"username": username, "password": password},
|
||||
)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def logout(self):
|
||||
"""Logout from BEC Atlas."""
|
||||
self._post_request(endpoint=AtlasEndpoints.LOGOUT.value)
|
||||
|
||||
@SafeSlot(str, popup_error=True)
|
||||
def get_experiments_for_realm(self, realm_id: str):
|
||||
"""
|
||||
Get the list of realms from BEC Atlas. Requires authentication.
|
||||
|
||||
Args:
|
||||
realm_id (str): The ID of the realm to retrieve experiments for.
|
||||
"""
|
||||
self._get_request(
|
||||
endpoint=AtlasEndpoints.REALMS_EXPERIMENTS.value,
|
||||
query_parameters={"realm_id": realm_id},
|
||||
)
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def set_experiment(self, experiment_id: str, deployment_id: str) -> None:
|
||||
"""
|
||||
Set the current experiment information for the service.
|
||||
|
||||
Args:
|
||||
experiment_id (str): The ID of the experiment to set.
|
||||
deployment_id (str): The ID of the deployment associated with the experiment.
|
||||
"""
|
||||
self._post_request(
|
||||
endpoint=AtlasEndpoints.SET_EXPERIMENT.value,
|
||||
query_parameters={"experiment_id": experiment_id, "deployment_id": deployment_id},
|
||||
)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def get_user_info(self):
|
||||
"""Get the current user information from BEC Atlas. Requires authentication."""
|
||||
self._get_request(endpoint=AtlasEndpoints.USER_INFO.value)
|
||||
|
||||
@SafeSlot(str, popup_error=True)
|
||||
def get_deployment_info(self, deployment_id: str):
|
||||
"""
|
||||
Get the deployment information for a given deployment ID. Requires authentication.
|
||||
|
||||
Args:
|
||||
deployment_id (str): The ID of the deployment to retrieve information for.
|
||||
"""
|
||||
self._get_request(
|
||||
endpoint=AtlasEndpoints.DEPLOYMENT_INFO.value,
|
||||
query_parameters={"deployment_id": deployment_id},
|
||||
)
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Mat-card like widget to display experiment details. Optionally, a button on the bottom which the user can click to trigger the selection of the experiment."""
|
||||
|
||||
from bec_lib.messages import ExperimentInfoMessage
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QFrame,
|
||||
QGraphicsDropShadowEffect,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
|
||||
format_name,
|
||||
format_schedule,
|
||||
)
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
|
||||
class BorderLessLabel(QLabel):
|
||||
"""A QLabel that does not show any border, even when stylesheets try to apply one."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setStyleSheet("border: none;")
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
|
||||
|
||||
|
||||
class ExperimentMatCard(BECWidget, QWidget):
|
||||
|
||||
RPC = False
|
||||
|
||||
experiment_selected = Signal(dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
show_activate_button: bool = True,
|
||||
button_text: str = "Activate",
|
||||
title: str = "Next Experiment",
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, theme_update=True, **kwargs)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(12, 8, 12, 8)
|
||||
self.experiment_info = {}
|
||||
self._abstract_text = ""
|
||||
|
||||
# Add card frame with shadow and custom styling
|
||||
self._card_frame = QFrame(parent=self)
|
||||
layout = QVBoxLayout(self._card_frame)
|
||||
self._card_frame.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
||||
palette = get_theme_palette()
|
||||
self._card_frame.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
border: 1px solid {palette.mid().color().name()};
|
||||
background: {palette.base().color().name()};
|
||||
}}
|
||||
""")
|
||||
shadow = QGraphicsDropShadowEffect(self._card_frame)
|
||||
shadow.setBlurRadius(18)
|
||||
shadow.setOffset(0, 4)
|
||||
shadow.setColor(palette.shadow().color())
|
||||
self._card_frame.setGraphicsEffect(shadow)
|
||||
|
||||
self._group_box = QGroupBox(self._card_frame)
|
||||
self._group_box.setStyleSheet(
|
||||
"QGroupBox { border: none; }; QLabel { border: none; padding: 0px; }"
|
||||
)
|
||||
self._fill_group_box(
|
||||
title=title, show_activate_button=show_activate_button, button_text=button_text
|
||||
)
|
||||
self.apply_theme("light")
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
palette = get_theme_palette()
|
||||
self._card_frame.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
border: 1px solid {palette.mid().color().name()};
|
||||
background: {palette.base().color().name()};
|
||||
}}
|
||||
""")
|
||||
shadow = self._card_frame.graphicsEffect()
|
||||
if isinstance(shadow, QGraphicsDropShadowEffect):
|
||||
shadow.setColor(palette.shadow().color())
|
||||
|
||||
def _fill_group_box(
|
||||
self, title: str, show_activate_button: bool, button_text: str = "Activate"
|
||||
):
|
||||
group_layout = QVBoxLayout(self._group_box)
|
||||
group_layout.setContentsMargins(16, 16, 16, 16)
|
||||
group_layout.setSpacing(12)
|
||||
|
||||
title_row = QHBoxLayout()
|
||||
self._card_title = BorderLessLabel(title, self._group_box)
|
||||
self._card_title.setStyleSheet("""
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
""")
|
||||
|
||||
# Add title row and info button to QH layout, then add it to QV layout
|
||||
title_row.addWidget(self._card_title)
|
||||
title_row.addStretch(1)
|
||||
group_layout.addLayout(title_row)
|
||||
|
||||
self._card_grid = QGridLayout()
|
||||
self._card_grid.setHorizontalSpacing(12)
|
||||
self._card_grid.setVerticalSpacing(8)
|
||||
self._card_grid.setColumnStretch(1, 1)
|
||||
|
||||
self._card_pgroup = BorderLessLabel("-", self._group_box)
|
||||
self._card_title_value = BorderLessLabel("-", self._group_box)
|
||||
self._card_title_value.setWordWrap(True)
|
||||
self._card_title_value.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
self._card_name = BorderLessLabel("-", self._group_box)
|
||||
self._card_start = BorderLessLabel("-", self._group_box)
|
||||
self._card_end = BorderLessLabel("-", self._group_box)
|
||||
|
||||
self._card_row_labels = []
|
||||
|
||||
def _row_label(text):
|
||||
label = BorderLessLabel(text, self._group_box)
|
||||
self._card_row_labels.append(label)
|
||||
return label
|
||||
|
||||
self._card_grid.addWidget(_row_label("Name"), 0, 0)
|
||||
self._card_grid.addWidget(self._card_name, 0, 1)
|
||||
self._card_grid.addWidget(_row_label("Title"), 1, 0)
|
||||
self._card_grid.addWidget(self._card_title_value, 1, 1)
|
||||
self._card_grid.addWidget(_row_label("P-group"), 2, 0)
|
||||
self._card_grid.addWidget(self._card_pgroup, 2, 1)
|
||||
self._card_grid.addWidget(_row_label("Schedule (start)"), 3, 0)
|
||||
self._card_grid.addWidget(self._card_start, 3, 1)
|
||||
self._card_grid.addWidget(_row_label("Schedule (end)"), 4, 0)
|
||||
self._card_grid.addWidget(self._card_end, 4, 1)
|
||||
|
||||
# Add to groupbox
|
||||
group_layout.addLayout(self._card_grid)
|
||||
|
||||
# Add abstract field at the bottom of the card.
|
||||
self._abstract_label = BorderLessLabel("", self._group_box)
|
||||
self._abstract_label.setWordWrap(True)
|
||||
self._abstract_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
group_layout.addWidget(self._abstract_label)
|
||||
|
||||
# Add activate button at the bottom
|
||||
self._activate_button = QPushButton(button_text, self._group_box)
|
||||
self._activate_button.clicked.connect(self._emit_next_experiment)
|
||||
self._activate_button.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
|
||||
)
|
||||
group_layout.addWidget(self._activate_button, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
self._activate_button.setVisible(show_activate_button)
|
||||
self._activate_button.setEnabled(False)
|
||||
|
||||
self._card_frame.layout().setContentsMargins(12, 12, 12, 12)
|
||||
self._card_frame.layout().addWidget(self._group_box)
|
||||
|
||||
card_row = QHBoxLayout()
|
||||
card_row.addStretch(0)
|
||||
card_row.addWidget(self._card_frame)
|
||||
card_row.addStretch(0)
|
||||
|
||||
layout = self.layout()
|
||||
layout.addStretch(0)
|
||||
layout.addLayout(card_row)
|
||||
layout.addStretch(0)
|
||||
|
||||
def _emit_next_experiment(self):
|
||||
self.experiment_selected.emit(self.experiment_info)
|
||||
|
||||
def clear_experiment_info(self):
|
||||
"""
|
||||
Clear the experiment information displayed on the card and disable the activate button.
|
||||
"""
|
||||
self._card_pgroup.setText("-")
|
||||
self._card_title_value.setText("-")
|
||||
self._card_name.setText("-")
|
||||
self._card_start.setText("-")
|
||||
self._card_end.setText("-")
|
||||
self._abstract_text = ""
|
||||
self._abstract_label.setText("")
|
||||
self.experiment_info = {}
|
||||
self._activate_button.setEnabled(False)
|
||||
|
||||
def set_experiment_info(self, info: ExperimentInfoMessage | dict):
|
||||
"""
|
||||
Set the experiment information to display on the card.
|
||||
|
||||
Args:
|
||||
info (ExperimentInfoMessage | dict): The experiment information to display. Can be either a
|
||||
dictionary or an ExperimentInfoMessage instance.
|
||||
"""
|
||||
if isinstance(info, dict):
|
||||
info = ExperimentInfoMessage(**info)
|
||||
|
||||
start, end = format_schedule(info.schedule)
|
||||
self._card_pgroup.setText(info.pgroup or "-")
|
||||
self._card_title_value.setText(info.title or "-")
|
||||
self._card_name.setText(format_name(info))
|
||||
self._card_start.setText(start or "-")
|
||||
self._card_end.setText(end or "-")
|
||||
self._abstract_text = (info.abstract or "").strip()
|
||||
self._abstract_label.setText(self._abstract_text if self._abstract_text else "")
|
||||
self.experiment_info = info.model_dump()
|
||||
self._activate_button.setEnabled(True)
|
||||
|
||||
def set_title(self, title: str):
|
||||
"""
|
||||
Set the title displayed at the top of the card.
|
||||
|
||||
Args:
|
||||
title (str): The title text to display.
|
||||
"""
|
||||
self._card_title.setText(title)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
exp_info = {
|
||||
"_id": "p22622",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22622"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "12345967",
|
||||
"title": "Test Experiment for Mat Card Widget",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"email": "john.doe@psi.ch",
|
||||
"account": "doe_j",
|
||||
"pi_firstname": "Jane",
|
||||
"pi_lastname": "Smith",
|
||||
"pi_email": "jane.smith@psi.ch",
|
||||
"pi_account": "smith_j",
|
||||
"eaccount": "e22622",
|
||||
"pgroup": "p22622",
|
||||
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
|
||||
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
|
||||
"proposal_submitted": "15/12/2024",
|
||||
"proposal_expire": "31/12/2025",
|
||||
"proposal_status": "Scheduled",
|
||||
"delta_last_schedule": 30,
|
||||
"mainproposal": "",
|
||||
}
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
apply_theme("dark")
|
||||
w = QWidget()
|
||||
l = QVBoxLayout(w)
|
||||
button = DarkModeButton()
|
||||
widget = ExperimentMatCard()
|
||||
widget.set_experiment_info(exp_info)
|
||||
widget.set_title("Scheduled Experiment")
|
||||
l.addWidget(button)
|
||||
l.addWidget(widget)
|
||||
w.resize(w.sizeHint())
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Experiment Selection View for BEC Atlas Admin Widget"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSizePolicy,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from thefuzz import fuzz
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.fuzzy_search import is_match
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_mat_card import (
|
||||
ExperimentMatCard,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
|
||||
format_name,
|
||||
format_schedule,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ExperimentSelection(QWidget):
|
||||
experiment_selected = Signal(dict)
|
||||
|
||||
def __init__(self, experiment_infos=None, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self._experiment_infos = experiment_infos or []
|
||||
self._next_experiment = self._select_next_experiment(self._experiment_infos)
|
||||
self._enable_fuzzy_search: bool = True
|
||||
self._hidden_rows: set[int] = set()
|
||||
self._headers: dict[str, str] = {
|
||||
"pgroup": "pgroup",
|
||||
"title": "Title",
|
||||
"name": "Name",
|
||||
"schedule_start": "Schedule (start)",
|
||||
"schedule_end": "Schedule (end)",
|
||||
}
|
||||
self._table_infos: list[dict[str, Any]] = []
|
||||
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
# main_layout.setSpacing(12)
|
||||
main_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.setAutoFillBackground(True)
|
||||
|
||||
self._tabs = QTabWidget(self)
|
||||
main_layout.addWidget(self._tabs, stretch=1)
|
||||
|
||||
self._card_tab = ExperimentMatCard(
|
||||
parent=self, show_activate_button=True, button_text="Activate"
|
||||
)
|
||||
self._card_tab.experiment_selected.connect(self._emit_selected_experiment)
|
||||
if self._next_experiment:
|
||||
self._card_tab.set_experiment_info(self._next_experiment)
|
||||
self._table_tab = QWidget(self)
|
||||
self._tabs.addTab(self._card_tab, "Next Experiment")
|
||||
self._tabs.addTab(self._table_tab, "Manual Selection")
|
||||
|
||||
self._build_table_tab()
|
||||
self._tabs.currentChanged.connect(self._on_tab_changed)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
main_layout.addLayout(button_layout)
|
||||
self._apply_table_filters()
|
||||
self.restore_default_view()
|
||||
|
||||
def restore_default_view(self):
|
||||
"""Reset the view to the default state, showing the next experiment card."""
|
||||
self._tabs.setCurrentWidget(self._card_tab)
|
||||
|
||||
def set_experiment_infos(self, experiment_infos: list[dict]):
|
||||
"""
|
||||
Update the experiment information displayed in the view. It will in addition determine
|
||||
the next experiment to be shown in the card view. If no next experiment can be determined,
|
||||
the card view will be cleared.
|
||||
|
||||
Args:
|
||||
experiment_infos (list[dict]): A list of experiment information dictionaries.
|
||||
"""
|
||||
self._experiment_infos = experiment_infos
|
||||
self._next_experiment = self._select_next_experiment(self._experiment_infos)
|
||||
if self._next_experiment:
|
||||
self._card_tab.set_experiment_info(self._next_experiment)
|
||||
else:
|
||||
self._card_tab.clear_experiment_info()
|
||||
self._apply_table_filters()
|
||||
|
||||
def _setup_search(self, layout: QVBoxLayout):
|
||||
"""
|
||||
Create components related to the search functionality
|
||||
|
||||
Args:
|
||||
layout (QVBoxLayout): The layout to which the search components will be added.
|
||||
"""
|
||||
|
||||
# Create search bar
|
||||
search_layout = QHBoxLayout()
|
||||
self.search_label = QLabel("Search:")
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText("Filter experiments...")
|
||||
self.search_input.setClearButtonEnabled(True)
|
||||
self.search_input.textChanged.connect(self._apply_row_filter)
|
||||
search_layout.addWidget(self.search_label)
|
||||
search_layout.addWidget(self.search_input)
|
||||
|
||||
# Add exact match toggle
|
||||
fuzzy_layout = QHBoxLayout()
|
||||
self.fuzzy_label = QLabel("Exact Match:")
|
||||
self.fuzzy_is_disabled = QCheckBox()
|
||||
|
||||
self.fuzzy_is_disabled.stateChanged.connect(self._state_change_fuzzy_search)
|
||||
self.fuzzy_is_disabled.setToolTip(
|
||||
"Enable approximate matching (OFF) and exact matching (ON)"
|
||||
)
|
||||
self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)")
|
||||
fuzzy_layout.addWidget(self.fuzzy_label)
|
||||
fuzzy_layout.addWidget(self.fuzzy_is_disabled)
|
||||
fuzzy_layout.addStretch()
|
||||
|
||||
# Add both search components to the layout
|
||||
self.search_controls = QHBoxLayout()
|
||||
self.search_controls.addLayout(search_layout)
|
||||
self.search_controls.addSpacing(20) # Add some space between the search box and toggle
|
||||
self.search_controls.addLayout(fuzzy_layout)
|
||||
|
||||
# Add filter section for proposals
|
||||
|
||||
filter_layout = QHBoxLayout()
|
||||
filter_layout.setContentsMargins(12, 0, 12, 0)
|
||||
filter_layout.setSpacing(12)
|
||||
self._with_proposals = QCheckBox("Show experiments with proposals", self)
|
||||
self._without_proposals = QCheckBox("Show experiments without proposals", self)
|
||||
self._with_proposals.setChecked(True)
|
||||
self._without_proposals.setChecked(True)
|
||||
self._with_proposals.toggled.connect(self._apply_table_filters)
|
||||
self._without_proposals.toggled.connect(self._apply_table_filters)
|
||||
filter_layout.addWidget(self._with_proposals)
|
||||
filter_layout.addWidget(self._without_proposals)
|
||||
filter_layout.addStretch(1)
|
||||
self.search_controls.addLayout(filter_layout)
|
||||
|
||||
# Insert the search controls layout at the top of the table
|
||||
layout.addLayout(self.search_controls)
|
||||
|
||||
def _build_table_tab(self):
|
||||
layout = QVBoxLayout(self._table_tab)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self._setup_search(layout)
|
||||
|
||||
# Add table
|
||||
hor_layout = QHBoxLayout()
|
||||
self._table = QTableWidget(self._table_tab)
|
||||
self._table.setColumnCount(5)
|
||||
self._table.setHorizontalHeaderLabels(list(self._headers.values()))
|
||||
vh = self._table.verticalHeader()
|
||||
vh.setVisible(False)
|
||||
vh.setDefaultSectionSize(vh.minimumSectionSize())
|
||||
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self._table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||||
self._table.setWordWrap(True)
|
||||
self._table.setStyleSheet("QTableWidget::item { padding: 4px; }")
|
||||
|
||||
header = self._table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
|
||||
|
||||
self._table.itemSelectionChanged.connect(self._update_selection_state)
|
||||
hor_layout.addWidget(self._table, stretch=5)
|
||||
hor_layout.addSpacing(12) # Add space between table and side card
|
||||
|
||||
# Add side card for experiment details
|
||||
self._side_card = ExperimentMatCard(
|
||||
parent=self, show_activate_button=True, button_text="Activate"
|
||||
)
|
||||
self._side_card.experiment_selected.connect(self._emit_selected_experiment)
|
||||
hor_layout.addWidget(self._side_card, stretch=2) # Ratio 5:2 between table and card
|
||||
layout.addLayout(hor_layout)
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(int)
|
||||
@SafeSlot(bool) # Overload for buttons
|
||||
def _apply_table_filters(self, *args, **kwargs):
|
||||
if self._tabs.currentWidget() is not self._table_tab:
|
||||
return
|
||||
|
||||
show_with = self._with_proposals.isChecked()
|
||||
show_without = self._without_proposals.isChecked()
|
||||
|
||||
self._table_infos = []
|
||||
for info in self._experiment_infos:
|
||||
has_proposal = bool(info.get("proposal"))
|
||||
if has_proposal and not show_with:
|
||||
continue
|
||||
if not has_proposal and not show_without:
|
||||
continue
|
||||
self._table_infos.append(info)
|
||||
|
||||
self._populate_table()
|
||||
self._update_selection_state()
|
||||
|
||||
def _populate_table(self):
|
||||
# Clear table before populating, this keeps headers intact
|
||||
self._table.setRowCount(0)
|
||||
# Refill table
|
||||
self._table.setRowCount(len(self._table_infos))
|
||||
for row, info in enumerate(self._table_infos):
|
||||
pgroup = info.get("pgroup", "")
|
||||
title = info.get("title", "")
|
||||
name = format_name(info)
|
||||
start, end = format_schedule(info.get("schedule"))
|
||||
|
||||
self._table.setItem(row, 0, QTableWidgetItem(pgroup))
|
||||
self._table.setItem(row, 1, QTableWidgetItem(title))
|
||||
self._table.setItem(row, 2, QTableWidgetItem(name))
|
||||
self._table.setItem(row, 3, QTableWidgetItem(start))
|
||||
self._table.setItem(row, 4, QTableWidgetItem(end))
|
||||
|
||||
width = self._table.viewport().width()
|
||||
self._table.resizeRowsToContents()
|
||||
self._table.resize(width, self._table.height())
|
||||
# self._table.resizeRowsToContents()
|
||||
|
||||
@SafeSlot()
|
||||
def _update_selection_state(self):
|
||||
if self._tabs.currentWidget() is not self._table_tab:
|
||||
return
|
||||
index = self._table.selectionModel().selectedRows()
|
||||
if len(index) > 0:
|
||||
index = index[0]
|
||||
self._side_card.set_experiment_info(self._table_infos[index.row()])
|
||||
|
||||
def _emit_selected_experiment(self):
|
||||
if self._tabs.currentWidget() is self._card_tab and self._next_experiment:
|
||||
self.experiment_selected.emit(self._next_experiment)
|
||||
return
|
||||
selected = self._table.selectionModel().selectedRows()
|
||||
if not selected:
|
||||
return
|
||||
row = selected[0].row()
|
||||
if 0 <= row < len(self._table_infos):
|
||||
self.experiment_selected.emit(self._table_infos[row])
|
||||
logger.info(f"Emitting next experiment signal with info: {self._table_infos[row]}")
|
||||
|
||||
def _select_next_experiment(self, experiment_infos: list[dict]) -> dict | None:
|
||||
candidates = []
|
||||
for info in experiment_infos:
|
||||
start, _ = format_schedule(info.get("schedule"), as_datetime=True)
|
||||
if start is None:
|
||||
continue
|
||||
candidates.append((start, info))
|
||||
|
||||
if not candidates:
|
||||
return experiment_infos[0] if experiment_infos else None
|
||||
|
||||
now = datetime.now()
|
||||
future = [entry for entry in candidates if entry[0] >= now]
|
||||
pool = future or candidates
|
||||
return min(pool, key=lambda entry: abs(entry[0] - now))[1]
|
||||
|
||||
def _on_tab_changed(self, index):
|
||||
if self._tabs.widget(index) is self._table_tab:
|
||||
self._table.resizeRowsToContents()
|
||||
if self._next_experiment:
|
||||
self._side_card.set_experiment_info(self._next_experiment)
|
||||
self._apply_table_filters()
|
||||
|
||||
def _get_column_data(self, row) -> dict[str, str]:
|
||||
output = {}
|
||||
for ii, header in enumerate(self._headers.values()):
|
||||
item = self._table.item(row, ii)
|
||||
if item is None:
|
||||
output[header] = ""
|
||||
continue
|
||||
output[header] = item.text()
|
||||
return output
|
||||
|
||||
@SafeSlot(str)
|
||||
def _apply_row_filter(self, text_input: str):
|
||||
"""Apply a filter to the table rows based on the filter text."""
|
||||
if not text_input:
|
||||
for row in self._hidden_rows:
|
||||
self._table.setRowHidden(row, False)
|
||||
self._hidden_rows.clear()
|
||||
return
|
||||
for row in range(self._table.rowCount()):
|
||||
experiment_data = self._get_column_data(row)
|
||||
if is_match(
|
||||
text_input, experiment_data, list(self._headers.values()), self._enable_fuzzy_search
|
||||
):
|
||||
self._table.setRowHidden(row, False)
|
||||
self._hidden_rows.discard(row)
|
||||
else:
|
||||
self._table.setRowHidden(row, True)
|
||||
self._hidden_rows.add(row)
|
||||
|
||||
@SafeSlot(int)
|
||||
def _state_change_fuzzy_search(self, enabled: int):
|
||||
"""Handle state changes for the fuzzy search toggle."""
|
||||
self._enable_fuzzy_search = not bool(enabled)
|
||||
# Re-apply filter with updated fuzzy search setting
|
||||
current_text = self.search_input.text()
|
||||
self._apply_row_filter(current_text)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
experiment_infos = [
|
||||
{
|
||||
"_id": "p22622",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22622"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "12345967",
|
||||
"title": "Test Experiment for Mat Card Widget",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"email": "john.doe@psi.ch",
|
||||
"account": "doe_j",
|
||||
"pi_firstname": "Jane",
|
||||
"pi_lastname": "Smith",
|
||||
"pi_email": "jane.smith@psi.ch",
|
||||
"pi_account": "smith_j",
|
||||
"eaccount": "e22622",
|
||||
"pgroup": "p22622",
|
||||
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
|
||||
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
|
||||
"proposal_submitted": "15/12/2024",
|
||||
"proposal_expire": "31/12/2025",
|
||||
"proposal_status": "Scheduled",
|
||||
"delta_last_schedule": 30,
|
||||
"mainproposal": "",
|
||||
},
|
||||
{
|
||||
"_id": "p22623",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22623"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "",
|
||||
"title": "Experiment without Proposal",
|
||||
"firstname": "Alice",
|
||||
"lastname": "Johnson",
|
||||
"email": "alice.johnson@psi.ch",
|
||||
"account": "johnson_a",
|
||||
"pi_firstname": "Bob",
|
||||
"pi_lastname": "Brown",
|
||||
"pi_email": "bob.brown@psi.ch",
|
||||
"pi_account": "brown_b",
|
||||
"eaccount": "e22623",
|
||||
"pgroup": "p22623",
|
||||
"abstract": "",
|
||||
"schedule": [],
|
||||
"proposal_submitted": "",
|
||||
"proposal_expire": "",
|
||||
"proposal_status": "",
|
||||
"delta_last_schedule": None,
|
||||
"mainproposal": "",
|
||||
},
|
||||
]
|
||||
|
||||
app = QApplication([])
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
apply_theme("light")
|
||||
w = QWidget()
|
||||
l = QVBoxLayout(w)
|
||||
dark_button = DarkModeButton()
|
||||
l.addWidget(dark_button)
|
||||
widget = ExperimentSelection(experiment_infos)
|
||||
l.addWidget(widget)
|
||||
w.resize(1280, 920)
|
||||
w.show()
|
||||
app.exec()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Utility functions for experiment selection."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib.messages import ExperimentInfoMessage
|
||||
|
||||
|
||||
def format_name(info: dict | ExperimentInfoMessage) -> str:
|
||||
"""Format the name from the experiment info."""
|
||||
info = ExperimentInfoMessage.model_validate(info) if isinstance(info, dict) else info
|
||||
firstname = info.firstname
|
||||
lastname = info.lastname
|
||||
return " ".join(part for part in [firstname, lastname] if part)
|
||||
|
||||
|
||||
def format_schedule(
|
||||
schedule: list[dict[Literal["start", "end"], str]] | None, as_datetime: bool = False
|
||||
) -> tuple[str, str] | tuple[datetime | None, datetime | None]:
|
||||
"""Format the schedule information to display start and end times."""
|
||||
if not schedule:
|
||||
return (None, None) if as_datetime else ("", "")
|
||||
start, end = _pick_schedule_entry(schedule)
|
||||
if as_datetime:
|
||||
return start, end
|
||||
return format_datetime(start), format_datetime(end)
|
||||
|
||||
|
||||
def _pick_schedule_entry(
|
||||
schedule: list[dict[Literal["start", "end"], str]],
|
||||
) -> tuple[datetime | None, datetime | None]:
|
||||
"""Pick the most relevant schedule entry based on the current time."""
|
||||
now = datetime.now()
|
||||
candidates = []
|
||||
for item in schedule:
|
||||
if not item:
|
||||
continue
|
||||
start_raw = item.get("start")
|
||||
parsed = _parse_schedule_start(start_raw)
|
||||
if parsed is None:
|
||||
continue
|
||||
candidates.append((parsed, item))
|
||||
|
||||
if not candidates:
|
||||
return None, None
|
||||
|
||||
future = [entry for entry in candidates if entry[0] >= now]
|
||||
pool = future or candidates
|
||||
chosen_start, chosen_item = min(pool, key=lambda entry: abs(entry[0] - now))
|
||||
end_raw = chosen_item.get("end")
|
||||
return chosen_start, _parse_schedule_start(end_raw)
|
||||
|
||||
|
||||
def _parse_schedule_start(value) -> datetime | None:
|
||||
"""Parse a schedule start string into a datetime object."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value, "%d/%m/%Y %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def format_datetime(value) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return value.strftime("%Y-%m-%d %H:%M")
|
||||
@@ -88,7 +88,7 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
self.setLayout(layout)
|
||||
|
||||
def init_warning_label(self):
|
||||
self.ui.scan_running_warning.setText("Warning: editing diabled while scan is running!")
|
||||
self.ui.scan_running_warning.setText("Warning: editing disabled while scan is running!")
|
||||
self.ui.scan_running_warning.setStyleSheet(
|
||||
"background-color: #fcba03; color: rgb(0, 0, 0);"
|
||||
)
|
||||
|
||||
@@ -160,8 +160,8 @@ class ScanHistoryMetadataViewer(BECWidget, QtWidgets.QGroupBox):
|
||||
Clear the view by resetting the labels and values.
|
||||
"""
|
||||
layout = self.layout()
|
||||
lauout_counts = layout.count()
|
||||
for i in range(lauout_counts):
|
||||
layout_counts = layout.count()
|
||||
for i in range(layout_counts):
|
||||
item = layout.itemAt(i)
|
||||
if item.widget():
|
||||
item.widget().close()
|
||||
|
||||
@@ -305,7 +305,7 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
|
||||
def remove_scan(self, index: int):
|
||||
"""
|
||||
Remove a scan entry from the tree widget.
|
||||
We supoprt negative indexing where -1, -2, etc.
|
||||
We support negative indexing where -1, -2, etc.
|
||||
|
||||
Args:
|
||||
index (int): The index of the scan entry to remove.
|
||||
|
||||
11
bec_widgets/widgets/utility/bec_term/__init__.py
Normal file
11
bec_widgets/widgets/utility/bec_term/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from pyside6_qtermwidget import QTermWidget # pylint: disable=ungrouped-imports
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = QTermWidget()
|
||||
|
||||
widget.show()
|
||||
sys.exit(app.exec())
|
||||
8
bec_widgets/widgets/utility/bec_term/protocol.py
Normal file
8
bec_widgets/widgets/utility/bec_term/protocol.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class BecTerminal(Protocol):
|
||||
"""Implementors of this protocol must also be subclasses of QWidget"""
|
||||
|
||||
def write(self, text: str, add_newline: bool = True): ...
|
||||
241
bec_widgets/widgets/utility/bec_term/qtermwidget_wrapper.py
Normal file
241
bec_widgets/widgets/utility/bec_term/qtermwidget_wrapper.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""A wrapper for the optional external dependency pyside6_qtermwidget.
|
||||
Simply displays a message in a QLabel if the dependency is not installed."""
|
||||
|
||||
import os
|
||||
from functools import wraps
|
||||
from typing import Sequence
|
||||
|
||||
from qtpy.QtCore import QIODevice, QPoint, QSize, QUrl, Signal # type: ignore
|
||||
from qtpy.QtGui import QAction, QFont, QKeyEvent, QResizeEvent, Qt # type: ignore
|
||||
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
|
||||
|
||||
try:
|
||||
from pyside6_qtermwidget import QTermWidget
|
||||
except ImportError:
|
||||
QTermWidget = None
|
||||
|
||||
|
||||
def _forward(func):
|
||||
"""Apply to a private method to forward the call to the method on QTermWidget with the same name,
|
||||
(with leading '_' removed) if it is defined, otherwise do nothing."""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
target = getattr(self, "_main_widget")
|
||||
if QTermWidget:
|
||||
method = getattr(target, func.__name__[1:])
|
||||
return method(*args, **kwargs)
|
||||
else:
|
||||
...
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class BecQTerm(QWidget):
|
||||
activity = Signal()
|
||||
bell = Signal(str)
|
||||
copy_available = Signal(bool)
|
||||
current_directory_changed = Signal(str)
|
||||
finished = Signal()
|
||||
profile_changed = Signal(str)
|
||||
received_data = Signal(str)
|
||||
silence = Signal()
|
||||
term_got_focus = Signal()
|
||||
term_key_pressed = Signal(QKeyEvent)
|
||||
term_lost_focus = Signal()
|
||||
title_changed = Signal()
|
||||
url_activated = Signal(QUrl, bool)
|
||||
|
||||
def __init__(self, /, parent: QWidget | None = None, **kwargs) -> None:
|
||||
super().__init__(parent)
|
||||
self._layout = QVBoxLayout()
|
||||
self.setLayout(self._layout)
|
||||
if QTermWidget:
|
||||
self._main_widget = QTermWidget(parent=self)
|
||||
self._main_widget.activity.connect(self.activity)
|
||||
self._main_widget.bell.connect(self.bell)
|
||||
self._main_widget.copyAvailable.connect(self.copy_available)
|
||||
self._main_widget.currentDirectoryChanged.connect(self.current_directory_changed)
|
||||
self._main_widget.finished.connect(self.finished)
|
||||
self._main_widget.profileChanged.connect(self.profile_changed)
|
||||
self._main_widget.receivedData.connect(self.received_data)
|
||||
self._main_widget.silence.connect(self.silence)
|
||||
self._main_widget.termGetFocus.connect(self.term_got_focus)
|
||||
self._main_widget.termKeyPressed.connect(self.term_key_pressed)
|
||||
self._main_widget.termLostFocus.connect(self.term_lost_focus)
|
||||
self._main_widget.titleChanged.connect(self.title_changed)
|
||||
self._main_widget.urlActivated.connect(self.url_activated)
|
||||
self._setEnvironment([f"{k}={v}" for k, v in os.environ.items()])
|
||||
self._setColorScheme("Solarized")
|
||||
else:
|
||||
self._layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._main_widget = QLabel("pyside6_qterminal is not installed!")
|
||||
|
||||
self._layout.addWidget(self._main_widget)
|
||||
|
||||
def write(self, text: str, add_newline: bool = True):
|
||||
if add_newline:
|
||||
text += "\n"
|
||||
self._sendText(text)
|
||||
|
||||
# automatically forwarded to the widget only if it exists
|
||||
@_forward
|
||||
def _addCustomColorSchemeDir(self, custom_dir: str, /) -> None: ...
|
||||
@_forward
|
||||
def _autoHideMouseAfter(self, delay: int, /) -> None: ...
|
||||
@_forward
|
||||
def _availableColorSchemes(self) -> list[str]: ...
|
||||
@_forward
|
||||
def _availableKeyBindings(self) -> list[str]: ...
|
||||
@_forward
|
||||
def _bracketText(self, text: str, /) -> None: ...
|
||||
@_forward
|
||||
def _bracketedPasteModeIsDisabled(self, /) -> bool: ...
|
||||
@_forward
|
||||
def _changeDir(self, dir: str, /) -> None: ...
|
||||
@_forward
|
||||
def _clear(self, /) -> None: ...
|
||||
@_forward
|
||||
def _clearCustomKeyBindingsDir(self, /) -> None: ...
|
||||
@_forward
|
||||
def _copyClipboard(self, /) -> None: ...
|
||||
@_forward
|
||||
def _disableBracketedPasteMode(self, disable: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _filterActions(self, position: QPoint, /) -> list[QAction]: ...
|
||||
@_forward
|
||||
def _flowControlEnabled(self, /) -> bool: ...
|
||||
@_forward
|
||||
def _getAvailableColorSchemes(self, /) -> list[str]: ...
|
||||
@_forward
|
||||
def _getForegroundProcessId(self, /) -> int: ...
|
||||
@_forward
|
||||
def _getMargin(self, /) -> int: ...
|
||||
@_forward
|
||||
def _getPtySlaveFd(self, /) -> int: ...
|
||||
@_forward
|
||||
def _getSelectionEnd(self, row: int, column: int, /) -> None: ...
|
||||
@_forward
|
||||
def _getSelectionStart(self, row: int, column: int, /) -> None: ...
|
||||
@_forward
|
||||
def _getShellPID(self, /) -> int: ...
|
||||
@_forward
|
||||
def _getTerminalFont(self, /) -> QFont: ...
|
||||
@_forward
|
||||
def _historyLinesCount(self, /) -> int: ...
|
||||
@_forward
|
||||
def _historySize(self, /) -> int: ...
|
||||
@_forward
|
||||
def _icon(self, /) -> str: ...
|
||||
@_forward
|
||||
def _isBidiEnabled(self, /) -> bool: ...
|
||||
@_forward
|
||||
def _isTitleChanged(self, /) -> bool: ...
|
||||
@_forward
|
||||
def _keyBindings(self, /) -> str: ...
|
||||
@_forward
|
||||
def _pasteClipboard(self, /) -> None: ...
|
||||
@_forward
|
||||
def _pasteSelection(self, /) -> None: ...
|
||||
@_forward
|
||||
def _resizeEvent(self, arg__1: QResizeEvent, /) -> None: ...
|
||||
@_forward
|
||||
def _saveHistory(self, device: QIODevice, /) -> None: ...
|
||||
@_forward
|
||||
def _screenColumnsCount(self, /) -> int: ...
|
||||
@_forward
|
||||
def _screenLinesCount(self, /) -> int: ...
|
||||
@_forward
|
||||
def _scrollToEnd(self, /) -> None: ...
|
||||
@_forward
|
||||
def _selectedText(self, /, preserveLineBreaks: bool = ...) -> str: ...
|
||||
@_forward
|
||||
def _selectionChanged(self, textSelected: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _sendKeyEvent(self, e: QKeyEvent, /) -> None: ...
|
||||
@_forward
|
||||
def _sendText(self, text: str, /) -> None: ...
|
||||
@_forward
|
||||
def _sessionFinished(self, /) -> None: ...
|
||||
@_forward
|
||||
def _setArgs(self, args: Sequence[str], /) -> None: ...
|
||||
@_forward
|
||||
def _setAutoClose(self, arg__1: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _setBidiEnabled(self, enabled: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _setBlinkingCursor(self, blink: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _setBoldIntense(self, boldIntense: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _setColorScheme(self, name: str, /) -> None: ...
|
||||
@_forward
|
||||
def _setConfirmMultilinePaste(self, confirmMultilinePaste: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _setCustomKeyBindingsDir(self, custom_dir: str, /) -> None: ...
|
||||
@_forward
|
||||
def _setDrawLineChars(self, drawLineChars: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _setEnvironment(self, environment: Sequence[str], /) -> None: ...
|
||||
@_forward
|
||||
def _setFlowControlEnabled(self, enabled: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _setFlowControlWarningEnabled(self, enabled: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _setHistorySize(self, lines: int, /) -> None: ...
|
||||
@_forward
|
||||
def _setKeyBindings(self, kb: str, /) -> None: ...
|
||||
@_forward
|
||||
def _setMargin(self, arg__1: int, /) -> None: ...
|
||||
@_forward
|
||||
def _setMonitorActivity(self, arg__1: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _setMonitorSilence(self, arg__1: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _setMotionAfterPasting(self, arg__1: int, /) -> None: ...
|
||||
@_forward
|
||||
def _setSelectionEnd(self, row: int, column: int, /) -> None: ...
|
||||
@_forward
|
||||
def _setSelectionStart(self, row: int, column: int, /) -> None: ...
|
||||
@_forward
|
||||
def _setShellProgram(self, program: str, /) -> None: ...
|
||||
@_forward
|
||||
def _setSilenceTimeout(self, seconds: int, /) -> None: ...
|
||||
@_forward
|
||||
def _setSize(self, arg__1: QSize, /) -> None: ...
|
||||
@_forward
|
||||
def _setTerminalBackgroundImage(self, backgroundImage: str, /) -> None: ...
|
||||
@_forward
|
||||
def _setTerminalBackgroundMode(self, mode: int, /) -> None: ...
|
||||
@_forward
|
||||
def _setTerminalFont(self, font: QFont | str | Sequence[str], /) -> None: ...
|
||||
@_forward
|
||||
def _setTerminalOpacity(self, level: float, /) -> None: ...
|
||||
@_forward
|
||||
def _setTerminalSizeHint(self, enabled: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _setTrimPastedTrailingNewlines(self, trimPastedTrailingNewlines: bool, /) -> None: ...
|
||||
@_forward
|
||||
def _setWordCharacters(self, chars: str, /) -> None: ...
|
||||
@_forward
|
||||
def _setWorkingDirectory(self, dir: str, /) -> None: ...
|
||||
@_forward
|
||||
def _sizeHint(self, /) -> QSize: ...
|
||||
@_forward
|
||||
def _startShellProgram(self, /) -> None: ...
|
||||
@_forward
|
||||
def _startTerminalTeletype(self, /) -> None: ...
|
||||
@_forward
|
||||
def _terminalSizeHint(self, /) -> bool: ...
|
||||
@_forward
|
||||
def _title(self, /) -> str: ...
|
||||
@_forward
|
||||
def _toggleShowSearchBar(self, /) -> None: ...
|
||||
@_forward
|
||||
def _wordCharacters(self, /) -> str: ...
|
||||
@_forward
|
||||
def _workingDirectory(self, /) -> str: ...
|
||||
@_forward
|
||||
def _zoomIn(self, /) -> None: ...
|
||||
@_forward
|
||||
def _zoomOut(self, /) -> None: ...
|
||||
6
bec_widgets/widgets/utility/bec_term/util.py
Normal file
6
bec_widgets/widgets/utility/bec_term/util.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from bec_widgets.widgets.utility.bec_term.protocol import BecTerminal
|
||||
from bec_widgets.widgets.utility.bec_term.qtermwidget_wrapper import BecQTerm
|
||||
|
||||
|
||||
def get_current_bec_term_class() -> type[BecTerminal]:
|
||||
return BecQTerm
|
||||
@@ -31,7 +31,7 @@ api_reference/api_reference.md
|
||||
|
||||
## Introduction
|
||||
|
||||
An introduction into the single-resposibility principle and the modular design of BEC Widgets.
|
||||
An introduction into the single-responsibility principle and the modular design of BEC Widgets.
|
||||
```
|
||||
|
||||
```{grid-item-card}
|
||||
|
||||
@@ -19,7 +19,7 @@ cd bec_widgets
|
||||
```
|
||||
**Install in Editable Mode**:
|
||||
|
||||
Please install the package in editable mode into your BEC Python environemnt.
|
||||
Please install the package in editable mode into your BEC Python environment.
|
||||
```bash
|
||||
pip install -e '.[dev,pyside6]'
|
||||
```
|
||||
|
||||
@@ -16,7 +16,7 @@ that the widgets are discoverable.
|
||||
- make sure that the widget class inherits from both `BECWidget` as well as `QWidget` or a subclass
|
||||
of it, such as `QComboBox` or `QLineEdit`.
|
||||
- make sure it initialises each of these superclasses in its `__init__()` method, and passes the
|
||||
`parent` keyword argumment on to `QWidget.__init__()`.
|
||||
`parent` keyword argument on to `QWidget.__init__()`.
|
||||
- add `PLUGIN = True` as a class variable to the widget class
|
||||
- add `USER_ACCESS = [...]`, including any methods and properties which should be accessible in the
|
||||
client to the list, as strings.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
````
|
||||
|
||||
````{tab} Examples - CLI
|
||||
In the following examples, we will use `BECIPythonClient` as the main object to interact with the `BECDockArea`. These tutorials focus on how to work with the `BECDockArea` framework, such as adding and removing docks, saving and restoring layouts, and managing the docked widgets. By default the `BECDockArea` is refered as `gui` in `BECIPythonClient`. For more detailed examples of each individual component, please refer to the example sections of each individual [`widget`](user.widgets).
|
||||
In the following examples, we will use `BECIPythonClient` as the main object to interact with the `BECDockArea`. These tutorials focus on how to work with the `BECDockArea` framework, such as adding and removing docks, saving and restoring layouts, and managing the docked widgets. By default the `BECDockArea` is referred to as `gui` in `BECIPythonClient`. For more detailed examples of each individual component, please refer to the example sections of each individual [`widget`](user.widgets).
|
||||
|
||||
## Example 1 - Adding Docks to BECDockArea
|
||||
|
||||
@@ -62,7 +62,7 @@ dock_area.waveform_dock
|
||||
dock_area.motor_dock
|
||||
dock_area.image_dock
|
||||
|
||||
# If objects were closed, we will keep a refernce that will indicate that the dock was deleted
|
||||
# If objects were closed, we will keep a reference that will indicate that the dock was deleted
|
||||
# Try closing the window with the dock_area via mouse click on x
|
||||
|
||||
dock_area
|
||||
|
||||
@@ -79,7 +79,7 @@ if __name__ == "__main__":
|
||||
|
||||
````
|
||||
|
||||
````{tab} Examples - BEC desginer
|
||||
````{tab} Examples - BEC designer
|
||||
The various properties can also be set when the SignalLabel widget is added to a UI in BEC designer:
|
||||
|
||||
```{figure} ./designer_screenshot.png
|
||||
|
||||
@@ -215,7 +215,7 @@ Display custom text or HTML content.
|
||||
Display website content.
|
||||
```
|
||||
|
||||
```{grid-item-card} Toogle Widget
|
||||
```{grid-item-card} Toggle Widget
|
||||
:link: user.widgets.toggle
|
||||
:link-type: ref
|
||||
:img-top: /assets/widget_screenshots/toggle.png
|
||||
@@ -244,7 +244,7 @@ Modern progress bar for BEC.
|
||||
:link-type: ref
|
||||
:img-top: /assets/widget_screenshots/position_indicator.png
|
||||
|
||||
Display position of motor withing its limits.
|
||||
Display position of motor within its limits.
|
||||
```
|
||||
|
||||
```{grid-item-card} LMFit Dialog
|
||||
|
||||
151
pyproject.toml
151
pyproject.toml
@@ -1,53 +1,34 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "3.2.3"
|
||||
version = "3.5.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Scientific/Engineering",
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Scientific/Engineering",
|
||||
]
|
||||
dependencies = [
|
||||
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
|
||||
"bec_lib~=3.107,>=3.107.2",
|
||||
"bec_qthemes~=1.0, >=1.3.4",
|
||||
"black>=26,<27", # needed for bw-generate-cli
|
||||
"isort>=5.13, <9.0", # needed for bw-generate-cli
|
||||
"ophyd_devices~=1.29, >=1.29.1",
|
||||
"pydantic~=2.0",
|
||||
"pyqtgraph==0.13.7",
|
||||
"PySide6==6.9.0",
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
"thefuzz~=0.22",
|
||||
"qtmonaco~=0.8, >=0.8.1",
|
||||
"darkdetect~=0.8",
|
||||
"PySide6-QtAds==4.4.0",
|
||||
"pylsp-bec~=1.2",
|
||||
"copier~=9.7",
|
||||
"typer~=0.15",
|
||||
"markdown~=3.9",
|
||||
]
|
||||
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"coverage~=7.0",
|
||||
"fakeredis~=2.23, >=2.23.2",
|
||||
"pytest-bec-e2e>=2.21.4, <=4.0",
|
||||
"pytest-qt~=4.4",
|
||||
"pytest-random-order~=1.1",
|
||||
"pytest-timeout~=2.2",
|
||||
"pytest-xvfb~=3.0",
|
||||
"pytest~=8.0",
|
||||
"pytest-cov~=6.1.1",
|
||||
"watchdog~=6.0",
|
||||
"pre_commit~=4.2",
|
||||
"PyJWT~=2.9",
|
||||
"PySide6==6.9.0",
|
||||
"PySide6-QtAds==4.4.0",
|
||||
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
|
||||
"bec_lib~=3.107,>=3.107.2",
|
||||
"bec_qthemes~=1.0, >=1.3.4",
|
||||
"black>=26,<27", # needed for bw-generate-cli
|
||||
"copier~=9.7",
|
||||
"darkdetect~=0.8",
|
||||
"isort>=5.13, <9.0", # needed for bw-generate-cli
|
||||
"markdown~=3.9",
|
||||
"ophyd_devices~=1.29, >=1.29.1",
|
||||
"pydantic~=2.0",
|
||||
"pylsp-bec~=1.2",
|
||||
"pyqtgraph==0.13.7",
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtmonaco~=0.8, >=0.8.1",
|
||||
"qtpy~=2.4",
|
||||
"thefuzz~=0.22",
|
||||
"typer~=0.15",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -55,10 +36,47 @@ dev = [
|
||||
Homepage = "https://gitlab.psi.ch/bec/bec_widgets"
|
||||
|
||||
[project.scripts]
|
||||
bw-generate-cli = "bec_widgets.cli.generate_cli:main"
|
||||
bec-gui-server = "bec_widgets.cli.server:main"
|
||||
bec-designer = "bec_widgets.utils.bec_designer:main"
|
||||
bec-app = "bec_widgets.applications.main_app:main"
|
||||
bec-designer = "bec_widgets.utils.bec_designer:main"
|
||||
bec-gui-server = "bec_widgets.cli.server:main"
|
||||
bw-generate-cli = "bec_widgets.cli.generate_cli:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"coverage~=7.0",
|
||||
"fakeredis~=2.23, >=2.23.2",
|
||||
"pytest-bec-e2e>=2.21.4, <=4.0",
|
||||
"pytest-qt~=4.4",
|
||||
"pytest-random-order~=1.1",
|
||||
"pytest-timeout~=2.2",
|
||||
"pytest-xvfb~=3.0",
|
||||
"pytest~=8.0",
|
||||
"pytest-cov~=6.1.1",
|
||||
"watchdog~=6.0",
|
||||
"pre_commit~=4.2",
|
||||
]
|
||||
qtermwidget = [
|
||||
"pyside6_qtermwidget",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
skip-magic-trailing-comma = true
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"return NotImplemented",
|
||||
"raise NotImplementedError",
|
||||
"\\.\\.\\.",
|
||||
'if __name__ == "__main__":',
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = ["*"]
|
||||
@@ -68,10 +86,6 @@ exclude = ["docs/**", "tests/**"]
|
||||
include = ["*"]
|
||||
exclude = ["docs/**", "tests/**"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
skip-magic-trailing-comma = true
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 100
|
||||
@@ -79,6 +93,12 @@ multi_line_output = 3
|
||||
include_trailing_comma = true
|
||||
known_first_party = ["bec_widgets"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.format]
|
||||
skip-magic-trailing-comma = true
|
||||
|
||||
[tool.semantic_release]
|
||||
build_command = "pip install build wheel && python -m build"
|
||||
version_toml = ["pyproject.toml:project.version"]
|
||||
@@ -89,16 +109,16 @@ default = "semantic-release <semantic-release>"
|
||||
|
||||
[tool.semantic_release.commit_parser_options]
|
||||
allowed_tags = [
|
||||
"build",
|
||||
"chore",
|
||||
"ci",
|
||||
"docs",
|
||||
"feat",
|
||||
"fix",
|
||||
"perf",
|
||||
"style",
|
||||
"refactor",
|
||||
"test",
|
||||
"build",
|
||||
"chore",
|
||||
"ci",
|
||||
"docs",
|
||||
"feat",
|
||||
"fix",
|
||||
"perf",
|
||||
"style",
|
||||
"refactor",
|
||||
"test",
|
||||
]
|
||||
minor_tags = ["feat"]
|
||||
patch_tags = ["fix", "perf"]
|
||||
@@ -115,14 +135,3 @@ env = "GH_TOKEN"
|
||||
[tool.semantic_release.publish]
|
||||
dist_glob_patterns = ["dist/*"]
|
||||
upload_to_vcs_release = true
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"return NotImplemented",
|
||||
"raise NotImplementedError",
|
||||
"\\.\\.\\.",
|
||||
'if __name__ == "__main__":',
|
||||
]
|
||||
|
||||
@@ -32,8 +32,8 @@ def threads_check_fixture(threads_check):
|
||||
|
||||
@pytest.fixture
|
||||
def gui_id():
|
||||
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturbate"""
|
||||
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturbate
|
||||
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturb"""
|
||||
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturb
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from bec_widgets.cli.client import Image, MotorMap, Waveform
|
||||
from bec_widgets.cli.client_utils import BECGuiClient
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCReference
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@@ -122,7 +123,7 @@ def test_ring_bar(qtbot, connected_client_gui_obj):
|
||||
assert gui._ipython_registry[bar._gui_id].__class__.__name__ == "RingProgressBar"
|
||||
|
||||
|
||||
def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
|
||||
def test_rpc_gui_obj(connected_client_gui_obj: BECGuiClient, qtbot):
|
||||
gui = connected_client_gui_obj
|
||||
|
||||
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
|
||||
|
||||
@@ -93,8 +93,8 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
|
||||
if object_name == "BECShell":
|
||||
continue
|
||||
|
||||
# Skip WebConsole as ttyd is not installed
|
||||
if object_name == "WebConsole":
|
||||
# Skip BecConsole as ttyd is not installed
|
||||
if object_name == "BecConsole":
|
||||
continue
|
||||
|
||||
#############################
|
||||
|
||||
@@ -22,7 +22,7 @@ from bec_widgets.cli.client_utils import BECGuiClient
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def gui_id():
|
||||
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturbate"""
|
||||
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturb"""
|
||||
return f"figure_{random.randint(0,100)}"
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Force ophyd onto its dummy control layer in tests so importing it does not
|
||||
# try to create a real EPICS CA context.
|
||||
import os
|
||||
|
||||
os.environ.setdefault("OPHYD_CONTROL_LAYER", "dummy")
|
||||
import json
|
||||
import time
|
||||
from unittest import mock
|
||||
@@ -13,7 +18,7 @@ from bec_lib.client import BECClient
|
||||
from bec_lib.messages import _StoredDataInfo
|
||||
from bec_qthemes import apply_theme
|
||||
from bec_qthemes._theme import Theme
|
||||
from ophyd._pyepics_shim import _dispatcher
|
||||
from ophyd._dummy_shim import _dispatcher
|
||||
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
||||
from qtpy.QtCore import QEvent, QEventLoop
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
156
tests/unit_tests/test_alignment_controller.py
Normal file
156
tests/unit_tests/test_alignment_controller.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
from bec_widgets.widgets.plots.waveform.utils.alignment_controller import (
|
||||
AlignmentContext,
|
||||
WaveformAlignmentController,
|
||||
)
|
||||
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
from .test_waveform import make_alignment_fit_summary
|
||||
|
||||
|
||||
def create_alignment_controller(qtbot, mocked_client):
|
||||
plot_widget = pg.PlotWidget()
|
||||
qtbot.addWidget(plot_widget)
|
||||
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
|
||||
controller = WaveformAlignmentController(plot_widget.plotItem, panel, parent=plot_widget)
|
||||
return plot_widget, panel, controller
|
||||
|
||||
|
||||
def test_alignment_controller_shows_marker_only_when_visible(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(visible=False, positioner_name="samx", precision=3, readback=1.0)
|
||||
)
|
||||
controller.update_position(4.2)
|
||||
assert controller.marker_line is None
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=4.2)
|
||||
)
|
||||
|
||||
assert controller.marker_line is not None
|
||||
assert np.isclose(controller.marker_line.value(), 4.2)
|
||||
assert panel.target_toggle.isEnabled() is True
|
||||
|
||||
controller.update_context(AlignmentContext(visible=False, positioner_name="samx"))
|
||||
assert controller.marker_line is None
|
||||
|
||||
|
||||
def test_alignment_controller_target_line_uses_readback_and_limits(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(
|
||||
visible=True, positioner_name="samx", precision=3, limits=(0.0, 2.0), readback=5.0
|
||||
)
|
||||
)
|
||||
|
||||
panel.target_toggle.setChecked(True)
|
||||
assert controller.target_line is not None
|
||||
assert np.isclose(controller.target_line.value(), 2.0)
|
||||
|
||||
panel.target_toggle.setChecked(False)
|
||||
assert controller.target_line is None
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(
|
||||
visible=True, positioner_name="samx", precision=3, limits=None, readback=5.0
|
||||
)
|
||||
)
|
||||
panel.target_toggle.setChecked(True)
|
||||
assert controller.target_line is not None
|
||||
assert np.isclose(controller.target_line.value(), 5.0)
|
||||
|
||||
|
||||
def test_alignment_controller_preserves_dragged_target_on_context_refresh(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(
|
||||
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=1.0
|
||||
)
|
||||
)
|
||||
panel.target_toggle.setChecked(True)
|
||||
controller.target_line.setValue(3.0)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(
|
||||
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=2.0
|
||||
)
|
||||
)
|
||||
|
||||
assert controller.target_line is not None
|
||||
assert np.isclose(controller.target_line.value(), 3.0)
|
||||
|
||||
|
||||
def test_alignment_controller_emits_move_request_for_fit_center(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
move_callback = MagicMock()
|
||||
controller.move_absolute_requested.connect(move_callback)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
|
||||
)
|
||||
controller.update_dap_summary(make_alignment_fit_summary(center=2.5), {"curve_id": "fit"})
|
||||
|
||||
assert panel.fit_dialog.action_buttons["center"].isEnabled() is True
|
||||
panel.fit_dialog.action_buttons["center"].click()
|
||||
|
||||
move_callback.assert_called_once_with(2.5)
|
||||
|
||||
|
||||
def test_alignment_controller_requests_autoscale_for_marker_and_target(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
autoscale_callback = MagicMock()
|
||||
controller.autoscale_requested.connect(autoscale_callback)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
|
||||
)
|
||||
panel.target_toggle.setChecked(True)
|
||||
|
||||
assert autoscale_callback.call_count >= 2
|
||||
|
||||
|
||||
def test_alignment_controller_emits_move_request_for_target(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
move_callback = MagicMock()
|
||||
controller.move_absolute_requested.connect(move_callback)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(
|
||||
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=1.0
|
||||
)
|
||||
)
|
||||
panel.target_toggle.setChecked(True)
|
||||
controller.target_line.setValue(1.25)
|
||||
panel.move_to_target_button.click()
|
||||
|
||||
move_callback.assert_called_once_with(1.25)
|
||||
|
||||
|
||||
def test_alignment_controller_removes_deleted_dap_curve(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
|
||||
)
|
||||
controller.update_dap_summary(make_alignment_fit_summary(center=1.5), {"curve_id": "fit"})
|
||||
|
||||
assert "fit" in panel.fit_dialog.summary_data
|
||||
|
||||
controller.remove_dap_curve("fit")
|
||||
|
||||
assert "fit" not in panel.fit_dialog.summary_data
|
||||
assert panel.fit_dialog.fit_curve_id is None
|
||||
assert panel.fit_dialog.enable_actions is False
|
||||
40
tests/unit_tests/test_alignment_panel.py
Normal file
40
tests/unit_tests/test_alignment_panel.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
def test_alignment_panel_forwards_position_and_target_signals(qtbot, mocked_client):
|
||||
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
|
||||
|
||||
position_callback = MagicMock()
|
||||
target_toggle_callback = MagicMock()
|
||||
target_move_callback = MagicMock()
|
||||
|
||||
panel.position_readback_changed.connect(position_callback)
|
||||
panel.target_toggled.connect(target_toggle_callback)
|
||||
panel.target_move_requested.connect(target_move_callback)
|
||||
|
||||
panel.positioner.position_update.emit(1.25)
|
||||
panel.target_toggle.setChecked(True)
|
||||
panel.move_to_target_button.setEnabled(True)
|
||||
panel.move_to_target_button.click()
|
||||
|
||||
position_callback.assert_called_once_with(1.25)
|
||||
target_toggle_callback.assert_called_once_with(True)
|
||||
target_move_callback.assert_called_once_with()
|
||||
|
||||
|
||||
def test_alignment_panel_emits_only_center_fit_actions(qtbot, mocked_client):
|
||||
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
|
||||
|
||||
fit_center_callback = MagicMock()
|
||||
panel.fit_center_requested.connect(fit_center_callback)
|
||||
|
||||
panel.fit_dialog.move_action.emit(("sigma", 0.5))
|
||||
fit_center_callback.assert_not_called()
|
||||
|
||||
panel.fit_dialog.move_action.emit(("center", 2.5))
|
||||
fit_center_callback.assert_called_once_with(2.5)
|
||||
758
tests/unit_tests/test_atlas_admin_view.py
Normal file
758
tests/unit_tests/test_atlas_admin_view.py
Normal file
@@ -0,0 +1,758 @@
|
||||
import datetime
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from bec_lib.messages import (
|
||||
DeploymentInfoMessage,
|
||||
ExperimentInfoMessage,
|
||||
MessagingConfig,
|
||||
MessagingServiceScopeConfig,
|
||||
SessionInfoMessage,
|
||||
)
|
||||
from qtpy.QtCore import QByteArray, QUrl
|
||||
from qtpy.QtNetwork import QNetworkRequest
|
||||
|
||||
from bec_widgets.utils.fuzzy_search import is_match
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_http_service import (
|
||||
AtlasEndpoints,
|
||||
AuthenticatedUserInfo,
|
||||
BECAtlasHTTPError,
|
||||
BECAtlasHTTPService,
|
||||
HTTPResponse,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_mat_card import (
|
||||
ExperimentMatCard,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_selection import (
|
||||
ExperimentSelection,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
|
||||
format_datetime,
|
||||
format_name,
|
||||
format_schedule,
|
||||
)
|
||||
|
||||
|
||||
class _FakeQByteArray:
|
||||
def __init__(self, payload: bytes):
|
||||
self._payload = payload
|
||||
|
||||
def data(self) -> bytes:
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeReply:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
request_url: str,
|
||||
status: int = 200,
|
||||
payload: bytes = b"{}",
|
||||
headers: list[tuple[bytes, bytes]] | None = None,
|
||||
):
|
||||
self._request_url = request_url
|
||||
self._status = status
|
||||
self._payload = payload
|
||||
self._headers = (
|
||||
headers
|
||||
if headers is not None
|
||||
else [(QByteArray(b"content-type"), QByteArray(b"application/json"))]
|
||||
)
|
||||
self.deleted = False
|
||||
|
||||
def attribute(self, attr):
|
||||
assert attr == QNetworkRequest.Attribute.HttpStatusCodeAttribute
|
||||
return self._status
|
||||
|
||||
def readAll(self):
|
||||
return _FakeQByteArray(self._payload)
|
||||
|
||||
def url(self):
|
||||
return QUrl(self._request_url)
|
||||
|
||||
def rawHeaderPairs(self):
|
||||
return self._headers
|
||||
|
||||
def deleteLater(self):
|
||||
self.deleted = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def experiment_info_message() -> ExperimentInfoMessage:
|
||||
data = {
|
||||
"_id": "p22622",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22622"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "12345967",
|
||||
"title": "Test Experiment for Mat Card Widget",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"email": "john.doe@psi.ch",
|
||||
"account": "doe_j",
|
||||
"pi_firstname": "Jane",
|
||||
"pi_lastname": "Smith",
|
||||
"pi_email": "jane.smith@psi.ch",
|
||||
"pi_account": "smith_j",
|
||||
"eaccount": "e22622",
|
||||
"pgroup": "p22622",
|
||||
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
|
||||
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
|
||||
"proposal_submitted": "15/12/2024",
|
||||
"proposal_expire": "31/12/2025",
|
||||
"proposal_status": "Scheduled",
|
||||
"delta_last_schedule": 30,
|
||||
"mainproposal": "",
|
||||
}
|
||||
return ExperimentInfoMessage.model_validate(data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def experiment_info_list(experiment_info_message: ExperimentInfoMessage) -> list[dict]:
|
||||
"""Fixture to provide a list of experiment info dictionaries."""
|
||||
another_experiment_info = {
|
||||
"_id": "p22623",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22623"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "",
|
||||
"title": "Experiment without Proposal",
|
||||
"firstname": "Alice",
|
||||
"lastname": "Johnson",
|
||||
"email": "alice.johnson@psi.ch",
|
||||
"account": "johnson_a",
|
||||
"pi_firstname": "Bob",
|
||||
"pi_lastname": "Brown",
|
||||
"pi_email": "bob.brown@psi.ch",
|
||||
"pi_account": "brown_b",
|
||||
"eaccount": "e22623",
|
||||
"pgroup": "p22623",
|
||||
"abstract": "",
|
||||
"schedule": [],
|
||||
"proposal_submitted": "",
|
||||
"proposal_expire": "",
|
||||
"proposal_status": "",
|
||||
"delta_last_schedule": None,
|
||||
"mainproposal": "",
|
||||
}
|
||||
return [
|
||||
experiment_info_message.model_dump(),
|
||||
ExperimentInfoMessage.model_validate(another_experiment_info).model_dump(),
|
||||
]
|
||||
|
||||
|
||||
class TestBECAtlasHTTPService:
|
||||
|
||||
@pytest.fixture
|
||||
def deployment_info(
|
||||
self, experiment_info_message: ExperimentInfoMessage
|
||||
) -> DeploymentInfoMessage:
|
||||
"""Fixture to provide a DeploymentInfoMessage instance."""
|
||||
return DeploymentInfoMessage(
|
||||
deployment_id="dep-1",
|
||||
name="Test Deployment",
|
||||
messaging_config=MessagingConfig(
|
||||
signal=MessagingServiceScopeConfig(enabled=False),
|
||||
teams=MessagingServiceScopeConfig(enabled=False),
|
||||
scilog=MessagingServiceScopeConfig(enabled=False),
|
||||
),
|
||||
active_session=SessionInfoMessage(
|
||||
experiment=experiment_info_message, name="Test Session"
|
||||
),
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def http_service(self, deployment_info: DeploymentInfoMessage, qtbot):
|
||||
"""Fixture to create a BECAtlasHTTPService instance."""
|
||||
service = BECAtlasHTTPService(base_url="http://localhost:8000")
|
||||
service._set_current_deployment_info(deployment_info)
|
||||
qtbot.addWidget(service)
|
||||
qtbot.waitExposed(service)
|
||||
return service
|
||||
|
||||
def test_initialization(self, http_service):
|
||||
"""Test that the BECAtlasHTTPService initializes correctly."""
|
||||
assert http_service._base_url == "http://localhost:8000"
|
||||
assert http_service._auth_timer._timer.isActive() == False
|
||||
assert http_service._headers == {"accept": "application/json"}
|
||||
|
||||
def test_get_request_uses_network_manager_get(self, http_service):
|
||||
"""Test that _get_request uses the network manager's get method with correct parameters."""
|
||||
|
||||
with mock.patch.object(http_service.network_manager, "get") as mock_get:
|
||||
http_service._get_request(
|
||||
endpoint=AtlasEndpoints.REALMS_EXPERIMENTS.value,
|
||||
query_parameters={"realm_id": "realm-1"},
|
||||
)
|
||||
|
||||
mock_get.assert_called_once()
|
||||
request = mock_get.call_args.args[0]
|
||||
assert request.url().toString() == (
|
||||
"http://localhost:8000/realms/experiments?realm_id=realm-1"
|
||||
)
|
||||
assert request.rawHeader("accept") == QByteArray(b"application/json")
|
||||
|
||||
def test_post_request_uses_network_manager_post(self, http_service):
|
||||
"""Test that _post_request uses the network manager's post method with correct parameters."""
|
||||
|
||||
with mock.patch.object(http_service.network_manager, "post") as mock_post:
|
||||
http_service._post_request(
|
||||
endpoint=AtlasEndpoints.LOGIN.value, payload={"username": "alice", "password": "pw"}
|
||||
)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
request, payload = mock_post.call_args.args
|
||||
assert request.url().toString() == "http://localhost:8000/user/login"
|
||||
assert request.rawHeader("accept") == QByteArray(b"application/json")
|
||||
assert payload == b'{"username": "alice", "password": "pw"}'
|
||||
|
||||
def test_public_api(self, http_service):
|
||||
"""Test BEC ATLAS public API methods from the http service."""
|
||||
with mock.patch.object(http_service, "_get_request") as mock_get:
|
||||
# User info
|
||||
http_service.get_user_info()
|
||||
mock_get.assert_called_once_with(endpoint=AtlasEndpoints.USER_INFO.value)
|
||||
|
||||
mock_get.reset_mock()
|
||||
# Deployment info
|
||||
http_service.get_deployment_info("dep-1")
|
||||
mock_get.assert_called_once_with(
|
||||
endpoint=AtlasEndpoints.DEPLOYMENT_INFO.value,
|
||||
query_parameters={"deployment_id": "dep-1"},
|
||||
)
|
||||
|
||||
mock_get.reset_mock()
|
||||
# Realms experiments
|
||||
http_service.get_experiments_for_realm("realm-1")
|
||||
mock_get.assert_called_once_with(
|
||||
endpoint=AtlasEndpoints.REALMS_EXPERIMENTS.value,
|
||||
query_parameters={"realm_id": "realm-1"},
|
||||
)
|
||||
|
||||
with mock.patch.object(http_service, "_post_request") as mock_post:
|
||||
# Logout
|
||||
http_service.logout()
|
||||
mock_post.assert_called_once_with(endpoint=AtlasEndpoints.LOGOUT.value)
|
||||
|
||||
mock_post.reset_mock()
|
||||
# Login
|
||||
http_service.login("alice", "pw")
|
||||
mock_post.assert_called_once_with(
|
||||
endpoint=AtlasEndpoints.LOGIN.value, payload={"username": "alice", "password": "pw"}
|
||||
)
|
||||
|
||||
mock_post.reset_mock()
|
||||
# Set experiment
|
||||
http_service.set_experiment("exp-1", "dep-1")
|
||||
mock_post.assert_called_once_with(
|
||||
endpoint=AtlasEndpoints.SET_EXPERIMENT.value,
|
||||
query_parameters={"experiment_id": "exp-1", "deployment_id": "dep-1"},
|
||||
)
|
||||
|
||||
def test_handle_response_login(self, http_service, qtbot):
|
||||
"""Test that handling a login response correctly decodes the token and starts the auth timer."""
|
||||
exp = time.time() + 300
|
||||
token = jwt.encode({"email": "alice@example.org", "exp": exp}, "secret", algorithm="HS256")
|
||||
payload = ("{" f'"access_token": "{token}"' "}").encode()
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/user/login", status=200, payload=payload
|
||||
)
|
||||
|
||||
with mock.patch.object(http_service, "get_user_info") as mock_get_user_info:
|
||||
|
||||
with qtbot.waitSignal(http_service.authentication_expires, timeout=1000) as blocker:
|
||||
http_service._handle_response(reply)
|
||||
|
||||
assert blocker.args[0] == pytest.approx(exp)
|
||||
assert http_service.auth_user_info is not None
|
||||
assert http_service.auth_user_info.email == "alice@example.org"
|
||||
assert http_service.auth_user_info.groups == set()
|
||||
http_service.get_user_info.assert_called_once()
|
||||
|
||||
def test_handle_response_logout(self, http_service, qtbot):
|
||||
"""Test handle response for logout."""
|
||||
http_service._auth_user_info = AuthenticatedUserInfo(
|
||||
email="alice@example.org", exp=time.time() + 60, groups={"staff"}, deployment_id="dep-1"
|
||||
)
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/user/logout", status=200, payload=b"{}"
|
||||
)
|
||||
|
||||
with qtbot.waitSignal(http_service.authenticated, timeout=1000) as blocker:
|
||||
http_service._handle_response(reply)
|
||||
|
||||
assert blocker.args[0] == {}
|
||||
assert http_service.auth_user_info is None
|
||||
|
||||
def test_handle_response_user_info(self, http_service):
|
||||
"""Test handle response for user info endpoint correctly updates auth user info."""
|
||||
http_service._auth_user_info = AuthenticatedUserInfo(
|
||||
email="alice@example.org", exp=time.time() + 60, groups=set(), deployment_id="dep-1"
|
||||
)
|
||||
http_service._current_deployment_info = SimpleNamespace(deployment_id="dep-1")
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/user/me",
|
||||
status=200,
|
||||
payload=b'{"email": "alice@example.org", "groups": ["operators", "staff"]}',
|
||||
)
|
||||
|
||||
with mock.patch.object(http_service, "get_deployment_info") as mock_get_deployment_info:
|
||||
http_service._handle_response(reply)
|
||||
|
||||
assert http_service.auth_user_info is not None
|
||||
assert http_service.auth_user_info.groups == {"operators", "staff"}
|
||||
mock_get_deployment_info.assert_called_once_with(deployment_id="dep-1")
|
||||
|
||||
def test_handle_response_deployment_info(self, http_service: BECAtlasHTTPService, qtbot):
|
||||
"""Test handling deployment info response"""
|
||||
|
||||
# Groups match: should emit authenticated signal with user info
|
||||
http_service._auth_user_info = AuthenticatedUserInfo(
|
||||
email="alice@example.org",
|
||||
exp=time.time() + 60,
|
||||
groups={"operators"},
|
||||
deployment_id="dep-1",
|
||||
)
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/deployments/id?deployment_id=dep-1",
|
||||
status=200,
|
||||
payload=b'{"owner_groups": ["operators"], "name": "Beamline Deployment"}',
|
||||
)
|
||||
|
||||
with qtbot.waitSignal(http_service.authenticated, timeout=1000) as blocker:
|
||||
http_service._handle_response(reply)
|
||||
|
||||
assert blocker.args[0]["email"] == "alice@example.org"
|
||||
assert set(blocker.args[0]["groups"]) == {"operators"}
|
||||
assert blocker.args[0]["deployment_id"] == "dep-1"
|
||||
|
||||
# Groups do not match: should show warning and logout
|
||||
http_service._auth_user_info = AuthenticatedUserInfo(
|
||||
email="alice@example.org",
|
||||
exp=time.time() + 60,
|
||||
groups={"operators"},
|
||||
deployment_id="dep-1",
|
||||
)
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/deployments/id?deployment_id=dep-1",
|
||||
status=200,
|
||||
payload=b'{"owner_groups": ["no-operators"], "name": "Beamline Deployment"}',
|
||||
)
|
||||
with (
|
||||
mock.patch.object(http_service, "_show_warning") as mock_show_warning,
|
||||
mock.patch.object(http_service, "logout") as mock_logout,
|
||||
):
|
||||
http_service._handle_response(reply)
|
||||
|
||||
mock_show_warning.assert_called_once()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
def test_handle_response_deployment_info_admin_access(self, http_service, qtbot):
|
||||
http_service._auth_user_info = AuthenticatedUserInfo(
|
||||
email="alice@example.org",
|
||||
exp=time.time() + 60,
|
||||
groups={"operators"},
|
||||
deployment_id="dep-1",
|
||||
)
|
||||
# Admin user should authenticate regardless of group membership
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/deployments/id?deployment_id=dep-1",
|
||||
status=200,
|
||||
payload=b'{"owner_groups": ["admin", "atlas_func_account"], "name": "Beamline Deployment"}',
|
||||
)
|
||||
|
||||
with qtbot.waitSignal(http_service.authenticated, timeout=1000) as blocker:
|
||||
http_service._handle_response(reply)
|
||||
|
||||
assert blocker.args[0]["email"] == "alice@example.org"
|
||||
|
||||
def test_handle_response_emits_http_response(self, http_service, qtbot):
|
||||
"""Test that _handle_response emits the http_response signal with correct parameters for a generic response."""
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/realms/experiments?realm_id=realm-1",
|
||||
status=200,
|
||||
payload=b'{"items": []}',
|
||||
)
|
||||
|
||||
with qtbot.waitSignal(http_service.http_response, timeout=1000) as blocker:
|
||||
http_service._handle_response(reply)
|
||||
|
||||
assert blocker.args[0]["request_url"] == (
|
||||
"http://localhost:8000/realms/experiments?realm_id=realm-1"
|
||||
)
|
||||
assert blocker.args[0]["status"] == 200
|
||||
assert blocker.args[0]["headers"] == {"content-type": "application/json"}
|
||||
assert blocker.args[0]["data"] == {"items": []}
|
||||
|
||||
def test_handle_response_raises_for_invalid_status(self, http_service):
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/user/me",
|
||||
status=401,
|
||||
payload=b'{"detail": "Unauthorized"}',
|
||||
)
|
||||
|
||||
with pytest.raises(BECAtlasHTTPError):
|
||||
http_service._handle_response(reply, _override_slot_params={"raise_error": True})
|
||||
|
||||
|
||||
class TestBECAtlasExperimentSelection:
|
||||
|
||||
def test_format_name(self, experiment_info_message: ExperimentInfoMessage):
|
||||
"""Test utils format name"""
|
||||
assert format_name(experiment_info_message) == "John Doe"
|
||||
|
||||
def test_format_schedule(self, experiment_info_message: ExperimentInfoMessage):
|
||||
"""Test utils format schedule"""
|
||||
assert format_schedule(experiment_info_message.schedule) == (
|
||||
"2025-01-01 08:00",
|
||||
"2025-01-03 18:00",
|
||||
)
|
||||
assert format_schedule(experiment_info_message.schedule, as_datetime=True) == (
|
||||
datetime.datetime.strptime(
|
||||
experiment_info_message.schedule[0]["start"], "%d/%m/%Y %H:%M:%S"
|
||||
),
|
||||
datetime.datetime.strptime(
|
||||
experiment_info_message.schedule[0]["end"], "%d/%m/%Y %H:%M:%S"
|
||||
),
|
||||
)
|
||||
assert format_schedule([]) == ("", "")
|
||||
|
||||
def test_format_datetime(self):
|
||||
"""Test utils format datetime"""
|
||||
dt = datetime.datetime(2025, 1, 1, 8, 0)
|
||||
assert format_datetime(dt) == "2025-01-01 08:00"
|
||||
assert format_datetime(None) == ""
|
||||
|
||||
@pytest.fixture
|
||||
def mat_card(self, qtbot):
|
||||
"""Fixture to create an ExperimentMatCard instance."""
|
||||
card = ExperimentMatCard()
|
||||
qtbot.addWidget(card)
|
||||
qtbot.waitExposed(card)
|
||||
return card
|
||||
|
||||
def test_set_experiment_info(
|
||||
self, mat_card: ExperimentMatCard, experiment_info_message: ExperimentInfoMessage, qtbot
|
||||
):
|
||||
"""Test that set_experiment_info correctly updates the card's display based on the provided experiment info, whether it's a dictionary or an ExperimentInfoMessage instance."""
|
||||
# Test with ExperimentInfoMessage instance
|
||||
mat_card.set_experiment_info(experiment_info_message)
|
||||
assert mat_card._card_pgroup.text() == "p22622"
|
||||
assert mat_card._card_title.text() == "Next Experiment"
|
||||
assert mat_card._abstract_label.text() == experiment_info_message.abstract.strip()
|
||||
assert mat_card.experiment_info == experiment_info_message.model_dump()
|
||||
assert mat_card._activate_button.isEnabled()
|
||||
assert mat_card._activate_button.text() == "Activate"
|
||||
|
||||
# Test with dictionary input
|
||||
mat_card.set_experiment_info(experiment_info_message.model_dump())
|
||||
mat_card.set_title("Experiment Details")
|
||||
assert mat_card._card_pgroup.text() == "p22622"
|
||||
assert mat_card._card_title.text() == "Experiment Details"
|
||||
assert mat_card._abstract_label.text() == experiment_info_message.abstract.strip()
|
||||
assert mat_card.experiment_info == experiment_info_message.model_dump()
|
||||
assert mat_card._activate_button.isEnabled()
|
||||
assert mat_card._activate_button.text() == "Activate"
|
||||
|
||||
with qtbot.waitSignal(mat_card.experiment_selected, timeout=1000) as blocker:
|
||||
mat_card._activate_button.click()
|
||||
assert blocker.args[0] == experiment_info_message.model_dump()
|
||||
|
||||
def test_is_match(self):
|
||||
"""Test is_match utility function for search functionality."""
|
||||
data = {"name": "Test Experiment", "description": "This is a test."}
|
||||
relevant_keys = ["name", "description"]
|
||||
|
||||
# Test exact match
|
||||
assert is_match("Test Experiment", data, relevant_keys, enable_fuzzy=False)
|
||||
assert not is_match("Nonexistent", data, relevant_keys, enable_fuzzy=False)
|
||||
|
||||
# Test fuzzy match
|
||||
assert not is_match("Nonexistent", data, relevant_keys, enable_fuzzy=True)
|
||||
assert is_match("Test Experimnt", data, relevant_keys, enable_fuzzy=True)
|
||||
# Typo should still match with fuzzy enabled
|
||||
assert is_match("Test Experiement", data, relevant_keys, enable_fuzzy=True)
|
||||
|
||||
@pytest.fixture
|
||||
def experiment_selection(self, qtbot):
|
||||
"""Fixture to create an ExperimentSelection instance with sample experiment info."""
|
||||
selection = ExperimentSelection()
|
||||
qtbot.addWidget(selection)
|
||||
qtbot.waitExposed(selection)
|
||||
return selection
|
||||
|
||||
def test_set_experiments(
|
||||
self, experiment_selection: ExperimentSelection, experiment_info_list: list[dict]
|
||||
):
|
||||
"""Test that set_experiment_infos correctly populates the experiment selection with provided experiment info."""
|
||||
with mock.patch.object(
|
||||
experiment_selection._card_tab, "set_experiment_info"
|
||||
) as mock_set_experiment_info:
|
||||
experiment_selection.set_experiment_infos(experiment_info_list)
|
||||
assert len(experiment_selection._experiment_infos) == 2
|
||||
|
||||
# Next experiment should be the first one as the second one has no schedule
|
||||
mock_set_experiment_info.assert_called_once_with(experiment_info_list[0])
|
||||
|
||||
# Should be on card tab
|
||||
assert experiment_selection._tabs.currentWidget() == experiment_selection._card_tab
|
||||
|
||||
def test_filter_functionality(
|
||||
self, experiment_selection: ExperimentSelection, experiment_info_list: list[dict], qtbot
|
||||
):
|
||||
"""Test that the search functionality correctly filters experiments based on the search query."""
|
||||
wid = experiment_selection
|
||||
wid.set_experiment_infos(experiment_info_list)
|
||||
|
||||
# First move to the table tab
|
||||
wid._tabs.setCurrentWidget(wid._table_tab)
|
||||
assert wid._side_card.experiment_info == wid._next_experiment
|
||||
|
||||
# Initially, both experiments should be in the table
|
||||
assert wid._table.rowCount() == 2
|
||||
with qtbot.waitSignal(wid._with_proposals.toggled, timeout=1000):
|
||||
wid._with_proposals.setChecked(False) # Should hide one experiment
|
||||
assert wid._table.rowCount() == 1
|
||||
with qtbot.waitSignal(wid._without_proposals.toggled, timeout=1000):
|
||||
wid._without_proposals.setChecked(False) # Should hide the other experiment
|
||||
assert wid._table.rowCount() == 0
|
||||
with qtbot.waitSignals(
|
||||
[wid._without_proposals.toggled, wid._with_proposals.toggled], timeout=1000
|
||||
):
|
||||
wid._without_proposals.setChecked(True)
|
||||
wid._with_proposals.setChecked(True) # Should show both experiments again
|
||||
assert wid._table.rowCount() == 2
|
||||
|
||||
# Click on first experiment and check if side card updates
|
||||
with qtbot.waitSignal(wid._table.itemSelectionChanged, timeout=1000):
|
||||
wid._table.selectRow(0) # Select the first experiment
|
||||
pgroup = wid._table.item(0, 0).text() # pgroup
|
||||
exp = [exp for exp in experiment_info_list if exp["pgroup"] == pgroup][0]
|
||||
assert wid._side_card.experiment_info == exp
|
||||
|
||||
# Click on second experiment and check if side card updates
|
||||
with qtbot.waitSignal(wid._table.itemSelectionChanged, timeout=1000):
|
||||
wid._table.selectRow(1) # Select the second experiment
|
||||
|
||||
pgroup = wid._table.item(1, 0).text() # pgroup
|
||||
exp = [exp for exp in experiment_info_list if exp["pgroup"] == pgroup][0]
|
||||
assert wid._side_card.experiment_info == exp
|
||||
|
||||
wid.search_input.setText("Experiment without Proposal")
|
||||
with mock.patch.object(wid, "_apply_row_filter") as mock_apply_row_filter:
|
||||
with qtbot.waitSignal(wid.fuzzy_is_disabled.stateChanged, timeout=1000):
|
||||
wid.fuzzy_is_disabled.setChecked(True) # Disable fuzzy search
|
||||
mock_apply_row_filter.assert_called_once_with("Experiment without Proposal")
|
||||
|
||||
assert wid._enable_fuzzy_search is False
|
||||
|
||||
def test_emit_selected_experiment(
|
||||
self, experiment_selection: ExperimentSelection, experiment_info_list: list[dict], qtbot
|
||||
):
|
||||
"""Test that clicking the activate button on the side card emits the experiment_selected signal with the correct experiment info."""
|
||||
wid = experiment_selection
|
||||
wid.set_experiment_infos(experiment_info_list)
|
||||
|
||||
wid._tabs.setCurrentWidget(wid._table_tab)
|
||||
with qtbot.waitSignal(wid._table.itemSelectionChanged, timeout=1000):
|
||||
wid._table.selectRow(1) # Select the second experiment
|
||||
pgroup = wid._table.item(1, 0).text() # pgroup
|
||||
exp = [exp for exp in experiment_info_list if exp["pgroup"] == pgroup][0]
|
||||
|
||||
with qtbot.waitSignal(wid.experiment_selected, timeout=1000) as blocker:
|
||||
wid._side_card._activate_button.click()
|
||||
assert blocker.args == [exp]
|
||||
|
||||
|
||||
class TestBECAtlasAdminView:
|
||||
|
||||
@pytest.fixture
|
||||
def admin_view(self, qtbot):
|
||||
"""Fixture to create a BECAtlasAdminView instance."""
|
||||
with mock.patch(
|
||||
"bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view.BECAtlasAdminView._connect_dispatcher"
|
||||
):
|
||||
view = BECAtlasAdminView()
|
||||
qtbot.addWidget(view)
|
||||
qtbot.waitExposed(view)
|
||||
return view
|
||||
|
||||
def test_init_and_login(self, admin_view: BECAtlasAdminView, qtbot):
|
||||
"""Test that the BECAtlasAdminView initializes correctly."""
|
||||
# Check that the atlas URL is set correctly
|
||||
assert admin_view._atlas_url == "https://bec-atlas-prod.psi.ch/api/v1"
|
||||
|
||||
# Test that clicking the login button emits the credentials_entered signal with the correct username and password
|
||||
with mock.patch.object(admin_view.atlas_http_service, "login") as mock_login:
|
||||
with qtbot.waitSignal(admin_view.overview_widget.login_requested, timeout=1000):
|
||||
admin_view.overview_widget._login.username.setText("alice")
|
||||
admin_view.overview_widget._login.password.setText("password")
|
||||
admin_view.overview_widget._login._emit_credentials()
|
||||
|
||||
mock_login.assert_called_once_with(username="alice", password="password")
|
||||
mock_login.reset_mock()
|
||||
admin_view._authenticated = True
|
||||
with mock.patch.object(admin_view, "logout") as mock_logout:
|
||||
with qtbot.waitSignal(admin_view.overview_widget.login_requested, timeout=1000):
|
||||
admin_view.overview_widget._login.password.setText("password")
|
||||
admin_view.overview_widget._login._emit_credentials()
|
||||
mock_logout.assert_called_once()
|
||||
mock_login.assert_called_once_with(username="alice", password="password")
|
||||
|
||||
def test_on_experiment_selected(
|
||||
self, admin_view: BECAtlasAdminView, deployment_info: DeploymentInfoMessage, qtbot
|
||||
):
|
||||
"""Test that selecting an experiment in the overview widget correctly calls the HTTP service to set the experiment and updates the current experiment view."""
|
||||
# First we need to simulate that we are authenticated and have deployment info
|
||||
admin_view._update_deployment_info(deployment_info.model_dump(), {})
|
||||
with mock.patch.object(
|
||||
admin_view.atlas_http_service, "set_experiment"
|
||||
) as mock_set_experiment:
|
||||
with qtbot.waitSignal(
|
||||
admin_view.experiment_selection.experiment_selected, timeout=1000
|
||||
):
|
||||
admin_view.experiment_selection.experiment_selected.emit(
|
||||
deployment_info.active_session.experiment.model_dump()
|
||||
)
|
||||
mock_set_experiment.assert_called_once_with(
|
||||
deployment_info.active_session.experiment.pgroup, deployment_info.deployment_id
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def deployment_info(
|
||||
self, experiment_info_message: ExperimentInfoMessage
|
||||
) -> DeploymentInfoMessage:
|
||||
"""Fixture to provide a DeploymentInfoMessage instance."""
|
||||
return DeploymentInfoMessage(
|
||||
deployment_id="dep-1",
|
||||
name="Test Deployment",
|
||||
messaging_config=MessagingConfig(
|
||||
signal=MessagingServiceScopeConfig(enabled=False),
|
||||
teams=MessagingServiceScopeConfig(enabled=False),
|
||||
scilog=MessagingServiceScopeConfig(enabled=False),
|
||||
),
|
||||
active_session=SessionInfoMessage(
|
||||
experiment=experiment_info_message, name="Test Session"
|
||||
),
|
||||
)
|
||||
|
||||
def test_on_authenticated(
|
||||
self, admin_view: BECAtlasAdminView, deployment_info: DeploymentInfoMessage, qtbot
|
||||
):
|
||||
"""Test that the on_authenticated method correctly updates the UI based on authentication state."""
|
||||
# Simulate successful authentication
|
||||
auth_info = AuthenticatedUserInfo(
|
||||
email="alice@example.com",
|
||||
exp=time.time() + 300,
|
||||
groups={"operators"},
|
||||
deployment_id="dep-1",
|
||||
)
|
||||
|
||||
# First check that deployment info updates all fields correctly
|
||||
admin_view._update_deployment_info(deployment_info.model_dump(), {})
|
||||
|
||||
assert admin_view.atlas_http_service._current_deployment_info == deployment_info
|
||||
assert (
|
||||
admin_view._atlas_info_widget._bl_info_label.text()
|
||||
== f"{deployment_info.active_session.experiment.realm_id} @ {deployment_info.name}"
|
||||
)
|
||||
assert admin_view._atlas_info_widget._atlas_url_label.text() == admin_view._atlas_url
|
||||
|
||||
# Now run on_authenticated, this enables all toolbar buttons
|
||||
# and calls fetch experiments. It also switches the overview widget
|
||||
# to the current experiment view.
|
||||
# Default should be on the overview widget
|
||||
assert admin_view.stacked_layout.currentWidget() == admin_view.overview_widget
|
||||
with mock.patch.object(
|
||||
admin_view, "_fetch_available_experiments"
|
||||
) as mock_fetch_experiments:
|
||||
with qtbot.waitSignal(admin_view.authenticated, timeout=1000) as blocker:
|
||||
admin_view._on_authenticated(auth_info.model_dump())
|
||||
# Fetch experiments should be called
|
||||
mock_fetch_experiments.assert_called_once()
|
||||
assert blocker.args[0] is True
|
||||
assert (
|
||||
admin_view._atlas_info_widget._atlas_url_label.text()
|
||||
== f"{admin_view._atlas_info_widget._atlas_url_text} | {auth_info.email}"
|
||||
)
|
||||
assert (
|
||||
admin_view.toolbar.components.get_action("messaging_services").action.isEnabled()
|
||||
is False
|
||||
)
|
||||
|
||||
# Logout timer is running
|
||||
logout_action = admin_view.toolbar.components.get_action("logout")
|
||||
assert logout_action.action.isEnabled() is True
|
||||
assert logout_action._tick_timer.isActive() is True
|
||||
|
||||
# Current Experiment widget should be visible in the overview
|
||||
assert (
|
||||
admin_view.overview_widget.stacked_layout.currentWidget()
|
||||
== admin_view.overview_widget._experiment_overview_widget
|
||||
)
|
||||
|
||||
# Click toolbar to switch to experiment selection
|
||||
exp_select = admin_view.toolbar.components.get_action("experiment_selection")
|
||||
assert exp_select.action.isEnabled() is True
|
||||
with qtbot.waitSignal(exp_select.action.triggered, timeout=1000):
|
||||
exp_select.action.trigger()
|
||||
|
||||
assert admin_view.stacked_layout.currentWidget() == admin_view.experiment_selection
|
||||
|
||||
# Now we simulate that the authentication expires
|
||||
# This deactivates buttons, resets the overview widget
|
||||
# and emits authenticated signal with False
|
||||
with qtbot.waitSignal(admin_view.authenticated, timeout=1000) as blocker:
|
||||
admin_view._on_authenticated({}) # Simulate not authenticated anymore
|
||||
assert blocker.args[0] is False
|
||||
assert logout_action._tick_timer.isActive() is False
|
||||
assert admin_view._atlas_info_widget._atlas_url_label.text() == admin_view._atlas_url
|
||||
assert (
|
||||
admin_view.overview_widget.stacked_layout.currentWidget()
|
||||
== admin_view.overview_widget._login_widget
|
||||
)
|
||||
# View should switch back to overview
|
||||
assert admin_view.stacked_layout.currentWidget() == admin_view.overview_widget
|
||||
|
||||
def test_fetch_experiments(
|
||||
self, admin_view: BECAtlasAdminView, deployment_info: DeploymentInfoMessage, qtbot
|
||||
):
|
||||
"""Test that _fetch_available_experiments correctly calls the HTTP service and updates the experiment selection widget."""
|
||||
admin_view._update_deployment_info(deployment_info.model_dump(), {})
|
||||
with mock.patch.object(
|
||||
admin_view.atlas_http_service, "get_experiments_for_realm"
|
||||
) as mock_get_experiments:
|
||||
admin_view._fetch_available_experiments()
|
||||
mock_get_experiments.assert_called_once_with(
|
||||
deployment_info.active_session.experiment.realm_id
|
||||
)
|
||||
|
||||
def test_on_http_response_received(
|
||||
self, experiment_info_list: list[dict], admin_view: BECAtlasAdminView, qtbot
|
||||
):
|
||||
"""Test that _on_http_response_received correctly handles HTTP responses and updates the UI accordingly."""
|
||||
realms = HTTPResponse(
|
||||
request_url=f"{admin_view._atlas_url}/{AtlasEndpoints.REALMS_EXPERIMENTS}/experiments?realm_id=TestBeamline",
|
||||
status=200,
|
||||
headers={"content-type": "application/json"},
|
||||
data=experiment_info_list,
|
||||
)
|
||||
with mock.patch.object(
|
||||
admin_view.experiment_selection, "set_experiment_infos"
|
||||
) as mock_set_experiment_infos:
|
||||
admin_view._on_http_response_received(realms.model_dump())
|
||||
mock_set_experiment_infos.assert_called_once_with(experiment_info_list)
|
||||
|
||||
set_experiment = HTTPResponse(
|
||||
request_url=f"{admin_view._atlas_url}/{AtlasEndpoints.SET_EXPERIMENT}",
|
||||
status=200,
|
||||
headers={"content-type": "application/json"},
|
||||
data={},
|
||||
)
|
||||
with mock.patch.object(admin_view, "_on_overview_selected") as mock_on_overview_selected:
|
||||
admin_view._on_http_response_received(set_experiment.model_dump())
|
||||
mock_on_overview_selected.assert_called_once()
|
||||
252
tests/unit_tests/test_bec_console.py
Normal file
252
tests/unit_tests/test_bec_console.py
Normal file
@@ -0,0 +1,252 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import shiboken6
|
||||
from qtpy.QtCore import QEvent, QEventLoop, Qt
|
||||
from qtpy.QtGui import QHideEvent, QShowEvent
|
||||
from qtpy.QtTest import QTest
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
import bec_widgets.widgets.editors.bec_console.bec_console as bec_console_module
|
||||
from bec_widgets.widgets.editors.bec_console.bec_console import (
|
||||
BecConsole,
|
||||
BECShell,
|
||||
ConsoleMode,
|
||||
_bec_console_registry,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
def process_deferred_deletes():
|
||||
app = QApplication.instance()
|
||||
app.sendPostedEvents(None, QEvent.DeferredDelete)
|
||||
app.processEvents(QEventLoop.AllEvents)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_bec_console_registry():
|
||||
_bec_console_registry.clear()
|
||||
yield
|
||||
_bec_console_registry.clear()
|
||||
process_deferred_deletes()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_widget(qtbot):
|
||||
"""Create a BecConsole widget."""
|
||||
widget = BecConsole(client=mocked_client, gui_id="test_console", terminal_id="test_terminal")
|
||||
qtbot.addWidget(widget)
|
||||
return widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_console_widgets_same_terminal(qtbot):
|
||||
widget1 = BecConsole(client=mocked_client, gui_id="console_1", terminal_id="shared_terminal")
|
||||
widget2 = BecConsole(client=mocked_client, gui_id="console_2", terminal_id="shared_terminal")
|
||||
qtbot.addWidget(widget1)
|
||||
qtbot.addWidget(widget2)
|
||||
return widget1, widget2
|
||||
|
||||
|
||||
def test_bec_console_initialization(console_widget: BecConsole):
|
||||
assert console_widget.console_id == "test_console"
|
||||
assert console_widget.terminal_id == "test_terminal"
|
||||
assert console_widget._mode == ConsoleMode.ACTIVE
|
||||
assert console_widget.term is not None
|
||||
assert console_widget._overlay.isHidden()
|
||||
console_widget.show()
|
||||
assert console_widget.isVisible()
|
||||
assert _bec_console_registry.owner_is_visible(console_widget.terminal_id)
|
||||
|
||||
|
||||
def test_bec_console_yield_terminal_ownership(console_widget):
|
||||
console_widget.show()
|
||||
console_widget.take_terminal_ownership()
|
||||
console_widget.yield_ownership()
|
||||
assert console_widget.term is None
|
||||
assert console_widget._mode == ConsoleMode.INACTIVE
|
||||
|
||||
|
||||
def test_bec_console_hide_event_yields_ownership(console_widget):
|
||||
console_widget.take_terminal_ownership()
|
||||
console_widget.hideEvent(QHideEvent())
|
||||
assert console_widget.term is None
|
||||
assert console_widget._mode == ConsoleMode.HIDDEN
|
||||
|
||||
|
||||
def test_bec_console_show_event_takes_ownership(console_widget):
|
||||
console_widget.yield_ownership()
|
||||
console_widget.showEvent(QShowEvent())
|
||||
assert console_widget.term is not None
|
||||
assert console_widget._mode == ConsoleMode.ACTIVE
|
||||
|
||||
|
||||
def test_bec_console_overlay_click_takes_ownership(qtbot, console_widget):
|
||||
console_widget.yield_ownership()
|
||||
assert console_widget._mode == ConsoleMode.HIDDEN
|
||||
|
||||
QTest.mouseClick(console_widget._overlay, Qt.LeftButton)
|
||||
assert console_widget.term is not None
|
||||
assert console_widget._mode == ConsoleMode.ACTIVE
|
||||
assert not console_widget._overlay.isVisible()
|
||||
|
||||
|
||||
def test_two_consoles_shared_terminal(two_console_widgets_same_terminal):
|
||||
widget1, widget2 = two_console_widgets_same_terminal
|
||||
|
||||
# Widget1 takes ownership
|
||||
widget1.take_terminal_ownership()
|
||||
assert widget1.term is not None
|
||||
assert widget1._mode == ConsoleMode.ACTIVE
|
||||
assert widget2.term is None
|
||||
assert widget2._mode == ConsoleMode.HIDDEN
|
||||
|
||||
# Widget2 takes ownership
|
||||
widget2.take_terminal_ownership()
|
||||
assert widget2.term is not None
|
||||
assert widget2._mode == ConsoleMode.ACTIVE
|
||||
assert widget1.term is None
|
||||
assert widget1._mode == ConsoleMode.HIDDEN
|
||||
|
||||
|
||||
def test_bec_console_registry_cleanup(console_widget: BecConsole):
|
||||
console_widget.take_terminal_ownership()
|
||||
terminal_id = console_widget.terminal_id
|
||||
|
||||
assert terminal_id in _bec_console_registry._terminal_registry
|
||||
_bec_console_registry.unregister(console_widget)
|
||||
assert terminal_id not in _bec_console_registry._terminal_registry
|
||||
|
||||
|
||||
def test_bec_shell_initialization(qtbot):
|
||||
widget = BECShell(gui_id="bec_shell")
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget.console_id == "bec_shell"
|
||||
assert widget.terminal_id == "bec_shell"
|
||||
assert widget.startup_cmd is not None
|
||||
|
||||
|
||||
def test_bec_console_write(console_widget):
|
||||
console_widget.take_terminal_ownership()
|
||||
with mock.patch.object(console_widget.term, "write") as mock_write:
|
||||
console_widget.write("test command")
|
||||
mock_write.assert_called_once_with("test command", True)
|
||||
|
||||
|
||||
def test_is_owner(console_widget: BecConsole):
|
||||
assert _bec_console_registry.is_owner(console_widget)
|
||||
mock_console = mock.MagicMock()
|
||||
mock_console.console_id = "fake_console"
|
||||
_bec_console_registry._consoles["fake_console"] = mock_console
|
||||
assert not _bec_console_registry.is_owner(mock_console)
|
||||
mock_console.terminal_id = console_widget.terminal_id
|
||||
assert not _bec_console_registry.is_owner(mock_console)
|
||||
|
||||
|
||||
def test_closing_active_console_keeps_terminal_valid_for_remaining_console(qtbot):
|
||||
widget1 = BecConsole(client=mocked_client, gui_id="close_owner", terminal_id="shared_close")
|
||||
widget2 = BecConsole(client=mocked_client, gui_id="remaining", terminal_id="shared_close")
|
||||
qtbot.addWidget(widget2)
|
||||
|
||||
widget1.take_terminal_ownership()
|
||||
term = widget1.term
|
||||
assert term is not None
|
||||
|
||||
widget1.close()
|
||||
widget1.deleteLater()
|
||||
process_deferred_deletes()
|
||||
|
||||
assert shiboken6.isValid(term)
|
||||
widget2.take_terminal_ownership()
|
||||
|
||||
assert widget2.term is term
|
||||
assert widget2._mode == ConsoleMode.ACTIVE
|
||||
|
||||
|
||||
def test_active_console_detaches_terminal_before_destruction(qtbot):
|
||||
widget1 = BecConsole(client=mocked_client, gui_id="owner", terminal_id="shared_detach")
|
||||
widget2 = BecConsole(client=mocked_client, gui_id="survivor", terminal_id="shared_detach")
|
||||
qtbot.addWidget(widget1)
|
||||
qtbot.addWidget(widget2)
|
||||
|
||||
widget1.take_terminal_ownership()
|
||||
term = widget1.term
|
||||
assert term is not None
|
||||
assert widget1.isAncestorOf(term)
|
||||
|
||||
widget1.close()
|
||||
|
||||
assert shiboken6.isValid(term)
|
||||
assert not widget1.isAncestorOf(term)
|
||||
assert term.parent() is widget2._term_holder
|
||||
|
||||
|
||||
def test_bec_shell_terminal_persists_after_last_shell_unregisters(qtbot):
|
||||
shell = BECShell(gui_id="bec_shell_persistent")
|
||||
qtbot.addWidget(shell)
|
||||
term = shell.term
|
||||
assert term is not None
|
||||
|
||||
_bec_console_registry.unregister(shell)
|
||||
|
||||
info = _bec_console_registry._terminal_registry["bec_shell"]
|
||||
assert info.registered_console_ids == set()
|
||||
assert info.owner_console_id is None
|
||||
assert info.persist_session is True
|
||||
assert info.instance is term
|
||||
assert shiboken6.isValid(term)
|
||||
|
||||
|
||||
def test_new_bec_shell_claims_preserved_terminal(qtbot):
|
||||
shell1 = BECShell(gui_id="bec_shell_first")
|
||||
term = shell1.term
|
||||
assert term is not None
|
||||
|
||||
shell1.close()
|
||||
shell1.deleteLater()
|
||||
process_deferred_deletes()
|
||||
|
||||
assert "bec_shell" in _bec_console_registry._terminal_registry
|
||||
assert shiboken6.isValid(term)
|
||||
|
||||
shell2 = BECShell(gui_id="bec_shell_second")
|
||||
qtbot.addWidget(shell2)
|
||||
shell2.showEvent(QShowEvent())
|
||||
|
||||
assert shell2.term is term
|
||||
assert shell2._mode == ConsoleMode.ACTIVE
|
||||
|
||||
|
||||
def test_persistent_bec_shell_sends_startup_command_once(qtbot, monkeypatch):
|
||||
class RecordingTerminal(QWidget):
|
||||
writes = []
|
||||
|
||||
def write(self, text: str, add_newline: bool = True):
|
||||
self.writes.append((text, add_newline))
|
||||
|
||||
monkeypatch.setattr(bec_console_module, "_BecTermClass", RecordingTerminal)
|
||||
|
||||
shell1 = BECShell(gui_id="bec_shell_startup_first")
|
||||
shell1.close()
|
||||
shell1.deleteLater()
|
||||
process_deferred_deletes()
|
||||
|
||||
shell2 = BECShell(gui_id="bec_shell_startup_second")
|
||||
qtbot.addWidget(shell2)
|
||||
shell2.showEvent(QShowEvent())
|
||||
|
||||
assert len(RecordingTerminal.writes) == 1
|
||||
assert RecordingTerminal.writes[0][0].startswith("bec ")
|
||||
assert RecordingTerminal.writes[0][1] is True
|
||||
|
||||
|
||||
def test_plain_console_terminal_removed_after_last_unregister(qtbot):
|
||||
widget = BecConsole(client=mocked_client, gui_id="plain_console", terminal_id="plain_terminal")
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert "plain_terminal" in _bec_console_registry._terminal_registry
|
||||
_bec_console_registry.unregister(widget)
|
||||
|
||||
assert "plain_terminal" not in _bec_console_registry._terminal_registry
|
||||
@@ -17,6 +17,8 @@ def dap_combobox(qtbot, mocked_client):
|
||||
|
||||
def test_dap_combobox_init(dap_combobox):
|
||||
"""Test DapComboBox init."""
|
||||
assert dap_combobox.fit_model_combobox is dap_combobox
|
||||
assert dap_combobox.isEditable() is True
|
||||
assert dap_combobox.fit_model_combobox.currentText() == "GaussianModel"
|
||||
assert dap_combobox.available_models == ["GaussianModel", "LorentzModel", "SineModel"]
|
||||
assert dap_combobox._validate_dap_model("GaussianModel") is True
|
||||
@@ -30,7 +32,7 @@ def test_dap_combobox_set_axis(dap_combobox):
|
||||
container = []
|
||||
|
||||
def my_callback(msg: str):
|
||||
"""Calback function to store the messages."""
|
||||
"""Callback function to store the messages."""
|
||||
container.append(msg)
|
||||
|
||||
dap_combobox.x_axis_updated.connect(my_callback)
|
||||
@@ -49,7 +51,7 @@ def test_dap_combobox_select_fit(dap_combobox):
|
||||
container = []
|
||||
|
||||
def my_callback(msg: str):
|
||||
"""Calback function to store the messages."""
|
||||
"""Callback function to store the messages."""
|
||||
container.append(msg)
|
||||
|
||||
dap_combobox.fit_model_updated.connect(my_callback)
|
||||
@@ -64,10 +66,32 @@ def test_dap_combobox_currentTextchanged(dap_combobox):
|
||||
container = []
|
||||
|
||||
def my_callback(msg: str):
|
||||
"""Calback function to store the messages."""
|
||||
"""Callback function to store the messages."""
|
||||
container.append(msg)
|
||||
|
||||
assert dap_combobox.fit_model_combobox.currentText() == "GaussianModel"
|
||||
dap_combobox.fit_model_updated.connect(my_callback)
|
||||
dap_combobox.fit_model_combobox.setCurrentText("SineModel")
|
||||
assert container[0] == "SineModel"
|
||||
|
||||
|
||||
def test_dap_combobox_init_without_available_models(qtbot, mocked_client):
|
||||
mocked_client.dap._available_dap_plugins = {}
|
||||
|
||||
widget = create_widget(qtbot, DapComboBox, client=mocked_client)
|
||||
|
||||
assert widget.available_models == []
|
||||
assert widget.fit_model_combobox.count() == 0
|
||||
assert widget.fit_model_combobox.currentText() == ""
|
||||
|
||||
|
||||
def test_dap_combobox_invalid_manual_entry_highlighted(dap_combobox):
|
||||
dap_combobox.setCurrentText("not-a-model")
|
||||
|
||||
assert dap_combobox.is_valid_input is False
|
||||
assert "red" in dap_combobox.styleSheet()
|
||||
|
||||
dap_combobox.setCurrentText("GaussianModel")
|
||||
|
||||
assert dap_combobox.is_valid_input is True
|
||||
assert "transparent" in dap_combobox.styleSheet()
|
||||
|
||||
@@ -1422,7 +1422,7 @@ class TestDeviceConfigTemplate:
|
||||
qtbot.waitExposed(template)
|
||||
yield template
|
||||
|
||||
def test_device_config_teamplate_default_init(
|
||||
def test_device_config_template_default_init(
|
||||
self, device_config_template: DeviceConfigTemplate, qtbot
|
||||
):
|
||||
"""Test DeviceConfigTemplate default initialization."""
|
||||
|
||||
@@ -918,7 +918,7 @@ class TestToolbarFunctionality:
|
||||
action.trigger()
|
||||
if action_name == "terminal":
|
||||
mock_new.assert_called_once_with(
|
||||
widget="WebConsole", closable=True, startup_cmd=None
|
||||
widget="BecConsole", closable=True, startup_cmd=None
|
||||
)
|
||||
else:
|
||||
mock_new.assert_called_once_with(widget=widget_type)
|
||||
@@ -2272,7 +2272,7 @@ class TestFlatToolbarActions:
|
||||
"flat_queue": "BECQueue",
|
||||
"flat_status": "BECStatusBox",
|
||||
"flat_progress_bar": "RingProgressBar",
|
||||
"flat_terminal": "WebConsole",
|
||||
"flat_terminal": "BecConsole",
|
||||
"flat_bec_shell": "BECShell",
|
||||
"flat_sbb_monitor": "SBBMonitor",
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_client_generator_with_black_formatting():
|
||||
|
||||
|
||||
class _WidgetsEnumType(str, enum.Enum):
|
||||
"""Enum for the available widgets, to be generated programatically"""
|
||||
"""Enum for the available widgets, to be generated programmatically"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ def lmfit_message():
|
||||
|
||||
|
||||
def test_fit_curve_id(lmfit_dialog):
|
||||
"""Test hide_curve_selection property"""
|
||||
"""Test fit_curve_id property and selected_fit signal"""
|
||||
my_callback = mock.MagicMock()
|
||||
lmfit_dialog.selected_fit.connect(my_callback)
|
||||
assert lmfit_dialog.fit_curve_id is None
|
||||
@@ -148,6 +148,10 @@ def test_fit_curve_id(lmfit_dialog):
|
||||
assert lmfit_dialog.fit_curve_id == "test_curve_id"
|
||||
assert my_callback.call_count == 1
|
||||
assert my_callback.call_args == mock.call("test_curve_id")
|
||||
# Setting to None should not emit selected_fit
|
||||
lmfit_dialog.fit_curve_id = None
|
||||
assert lmfit_dialog.fit_curve_id is None
|
||||
assert my_callback.call_count == 1
|
||||
|
||||
|
||||
def test_remove_dap_data(lmfit_dialog):
|
||||
@@ -166,6 +170,35 @@ def test_remove_dap_data(lmfit_dialog):
|
||||
assert lmfit_dialog.ui.curve_list.count() == 1
|
||||
|
||||
|
||||
def test_remove_dap_data_selected_curve_switches_to_next(lmfit_dialog):
|
||||
"""Removing the currently selected curve should switch to the next available one"""
|
||||
my_callback = mock.MagicMock()
|
||||
lmfit_dialog.selected_fit.connect(my_callback)
|
||||
lmfit_dialog.summary_data = {"curve_a": "data_a", "curve_b": "data_b"}
|
||||
lmfit_dialog.fit_curve_id = "curve_a"
|
||||
my_callback.reset_mock()
|
||||
|
||||
lmfit_dialog.remove_dap_data("curve_a")
|
||||
|
||||
assert lmfit_dialog.fit_curve_id == "curve_b"
|
||||
assert my_callback.call_count == 1
|
||||
assert my_callback.call_args == mock.call("curve_b")
|
||||
|
||||
|
||||
def test_remove_dap_data_selected_curve_clears_when_last(lmfit_dialog):
|
||||
"""Removing the only/last selected curve should clear the selection without emitting"""
|
||||
my_callback = mock.MagicMock()
|
||||
lmfit_dialog.selected_fit.connect(my_callback)
|
||||
lmfit_dialog.summary_data = {"curve_a": "data_a"}
|
||||
lmfit_dialog.fit_curve_id = "curve_a"
|
||||
my_callback.reset_mock()
|
||||
|
||||
lmfit_dialog.remove_dap_data("curve_a")
|
||||
|
||||
assert lmfit_dialog.fit_curve_id is None
|
||||
assert my_callback.call_count == 0
|
||||
|
||||
|
||||
def test_update_summary_tree(lmfit_dialog, lmfit_message):
|
||||
"""Test display_fit_details method"""
|
||||
lmfit_dialog.active_action_list = ["center", "amplitude"]
|
||||
@@ -182,3 +215,18 @@ def test_update_summary_tree(lmfit_dialog, lmfit_message):
|
||||
assert lmfit_dialog.ui.param_tree.topLevelItemCount() == 4
|
||||
assert lmfit_dialog.ui.param_tree.topLevelItem(0).text(0) == "amplitude"
|
||||
assert lmfit_dialog.ui.param_tree.topLevelItem(0).text(1) == "1.582"
|
||||
|
||||
|
||||
def test_compact_ui_hides_curve_selection_and_keeps_action_column(
|
||||
qtbot, mocked_client, lmfit_message
|
||||
):
|
||||
dialog = create_widget(
|
||||
qtbot, LMFitDialog, client=mocked_client, ui_file="lmfit_dialog_compact.ui"
|
||||
)
|
||||
dialog.hide_curve_selection = True
|
||||
dialog.active_action_list = ["center"]
|
||||
dialog.update_summary_tree(data=lmfit_message, metadata={"curve_id": "test_curve_id"})
|
||||
|
||||
assert dialog.ui.group_curve_selection.isHidden()
|
||||
assert dialog.ui.param_tree.columnCount() == 4
|
||||
assert "center" in dialog.action_buttons
|
||||
|
||||
@@ -36,11 +36,11 @@ class PositionerWithoutPrecision(Positioner):
|
||||
def positioner_box(qtbot, mocked_client):
|
||||
"""Fixture for PositionerBox widget"""
|
||||
with mock.patch(
|
||||
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.uuid.uuid4"
|
||||
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.uuid.uuid4"
|
||||
) as mock_uuid:
|
||||
mock_uuid.return_value = "fake_uuid"
|
||||
with mock.patch(
|
||||
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.PositionerBoxBase._check_device_is_valid",
|
||||
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.PositionerBoxBase._check_device_is_valid",
|
||||
return_value=True,
|
||||
):
|
||||
db = create_widget(qtbot, PositionerBox, device="samx", client=mocked_client)
|
||||
@@ -141,7 +141,7 @@ def test_positioner_control_line(qtbot, mocked_client):
|
||||
Inherits from PositionerBox, but the layout is changed. Check dimensions only
|
||||
"""
|
||||
with mock.patch(
|
||||
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.uuid.uuid4"
|
||||
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.uuid.uuid4"
|
||||
) as mock_uuid:
|
||||
mock_uuid.return_value = "fake_uuid"
|
||||
with mock.patch(
|
||||
@@ -151,7 +151,8 @@ def test_positioner_control_line(qtbot, mocked_client):
|
||||
db = PositionerControlLine(device="samx", client=mocked_client)
|
||||
qtbot.addWidget(db)
|
||||
|
||||
assert db.ui.device_box.height() == 60
|
||||
assert db.ui.device_box.height() == db.height()
|
||||
assert db.ui.device_box.height() >= db.dimensions[0]
|
||||
assert db.ui.device_box.width() == 600
|
||||
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ from .conftest import create_widget
|
||||
def positioner_box_2d(qtbot, mocked_client):
|
||||
"""Fixture for PositionerBox widget"""
|
||||
with mock.patch(
|
||||
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.uuid.uuid4"
|
||||
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.uuid.uuid4"
|
||||
) as mock_uuid:
|
||||
mock_uuid.return_value = "fake_uuid"
|
||||
with mock.patch(
|
||||
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.PositionerBoxBase._check_device_is_valid",
|
||||
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.PositionerBoxBase._check_device_is_valid",
|
||||
return_value=True,
|
||||
):
|
||||
db = create_widget(
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import ValidationError
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from qtpy.QtGui import QColor, QMouseEvent
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import (
|
||||
RingProgressBar,
|
||||
RingProgressContainerWidget,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
@@ -432,8 +436,6 @@ def test_gap_affects_ring_positioning(ring_progress_bar):
|
||||
for _ in range(3):
|
||||
ring_progress_bar.add_ring()
|
||||
|
||||
initial_gap = ring_progress_bar.gap
|
||||
|
||||
# Change gap
|
||||
new_gap = 30
|
||||
ring_progress_bar.set_gap(new_gap)
|
||||
@@ -467,3 +469,275 @@ def test_rings_property_returns_correct_list(ring_progress_bar):
|
||||
# Should return the same list
|
||||
assert rings_via_property is rings_direct
|
||||
assert len(rings_via_property) == 3
|
||||
|
||||
|
||||
###################################
|
||||
# Hover behavior tests
|
||||
###################################
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def container(qtbot):
|
||||
widget = RingProgressContainerWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(200, 200)
|
||||
yield widget
|
||||
|
||||
|
||||
def _ring_center_pos(container):
|
||||
"""Return (center_x, center_y, base_radius) for a square container."""
|
||||
size = min(container.width(), container.height())
|
||||
center_x = container.width() / 2
|
||||
center_y = container.height() / 2
|
||||
max_ring_size = container.get_max_ring_size()
|
||||
base_radius = (size - 2 * max_ring_size) / 2
|
||||
return center_x, center_y, base_radius
|
||||
|
||||
|
||||
def _send_mouse_move(widget, pos: QPoint):
|
||||
global_pos = widget.mapToGlobal(pos)
|
||||
event = QMouseEvent(
|
||||
QEvent.Type.MouseMove,
|
||||
QPointF(pos),
|
||||
QPointF(global_pos),
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.KeyboardModifier.NoModifier,
|
||||
)
|
||||
QApplication.sendEvent(widget, event)
|
||||
|
||||
|
||||
def test_ring_at_pos_no_rings(container):
|
||||
assert container._ring_at_pos(QPointF(100, 100)) is None
|
||||
|
||||
|
||||
def test_ring_at_pos_center_is_inside_rings(container):
|
||||
"""The center of the widget is inside all rings; _ring_at_pos should return None."""
|
||||
container.add_ring()
|
||||
assert container._ring_at_pos(QPointF(100, 100)) is None
|
||||
|
||||
|
||||
def test_ring_at_pos_on_single_ring(container):
|
||||
"""A point on the ring arc should resolve to that ring."""
|
||||
ring = container.add_ring()
|
||||
cx, cy, base_radius = _ring_center_pos(container)
|
||||
# Point exactly on the ring centerline
|
||||
pos = QPointF(cx + base_radius, cy)
|
||||
assert container._ring_at_pos(pos) is ring
|
||||
|
||||
|
||||
def test_ring_at_pos_outside_all_rings(container):
|
||||
"""A point well outside the outermost ring returns None."""
|
||||
container.add_ring()
|
||||
cx, cy, base_radius = _ring_center_pos(container)
|
||||
line_width = container.rings[0].config.line_width
|
||||
# Place point clearly beyond the outer edge
|
||||
pos = QPointF(cx + base_radius + line_width + 5, cy)
|
||||
assert container._ring_at_pos(pos) is None
|
||||
|
||||
|
||||
def test_ring_at_pos_selects_correct_ring_from_multiple(qtbot):
|
||||
"""With multiple rings, each position resolves to the right ring."""
|
||||
container = RingProgressContainerWidget()
|
||||
qtbot.addWidget(container)
|
||||
container.resize(300, 300)
|
||||
|
||||
ring0 = container.add_ring()
|
||||
ring1 = container.add_ring()
|
||||
|
||||
size = min(container.width(), container.height())
|
||||
cx = container.width() / 2
|
||||
cy = container.height() / 2
|
||||
max_ring_size = container.get_max_ring_size()
|
||||
base_radius = (size - 2 * max_ring_size) / 2
|
||||
|
||||
radius0 = base_radius - ring0.gap
|
||||
radius1 = base_radius - ring1.gap
|
||||
|
||||
assert container._ring_at_pos(QPointF(cx + radius0, cy)) is ring0
|
||||
assert container._ring_at_pos(QPointF(cx + radius1, cy)) is ring1
|
||||
|
||||
|
||||
def test_set_hovered_ring_sets_flag(container):
|
||||
"""_set_hovered_ring marks the ring as hovered and updates _hovered_ring."""
|
||||
ring = container.add_ring()
|
||||
assert container._hovered_ring is None
|
||||
container._set_hovered_ring(ring)
|
||||
assert container._hovered_ring is ring
|
||||
assert ring._hovered is True
|
||||
|
||||
|
||||
def test_set_hovered_ring_to_none_clears_flag(container):
|
||||
"""Calling _set_hovered_ring(None) un-hovers the current ring."""
|
||||
ring = container.add_ring()
|
||||
container._set_hovered_ring(ring)
|
||||
container._set_hovered_ring(None)
|
||||
assert container._hovered_ring is None
|
||||
assert ring._hovered is False
|
||||
|
||||
|
||||
def test_set_hovered_ring_switches_between_rings(qtbot):
|
||||
"""Switching hover from one ring to another correctly updates both flags."""
|
||||
container = RingProgressContainerWidget()
|
||||
qtbot.addWidget(container)
|
||||
ring0 = container.add_ring()
|
||||
ring1 = container.add_ring()
|
||||
|
||||
container._set_hovered_ring(ring0)
|
||||
assert ring0._hovered is True
|
||||
assert ring1._hovered is False
|
||||
|
||||
container._set_hovered_ring(ring1)
|
||||
assert ring0._hovered is False
|
||||
assert ring1._hovered is True
|
||||
assert container._hovered_ring is ring1
|
||||
|
||||
|
||||
def test_build_tooltip_text_manual_mode(container):
|
||||
"""Manual mode tooltip contains mode label, value, max and percentage."""
|
||||
ring = container.add_ring()
|
||||
ring.set_value(50)
|
||||
ring.set_min_max_values(0, 100)
|
||||
|
||||
text = RingProgressContainerWidget._build_tooltip_text(ring)
|
||||
assert "Manual" in text
|
||||
assert "50.0%" in text
|
||||
assert "100" in text
|
||||
|
||||
|
||||
def test_build_tooltip_text_scan_mode(container):
|
||||
"""Scan mode tooltip labels the mode as 'Scan progress'."""
|
||||
ring = container.add_ring()
|
||||
ring.config.mode = "scan"
|
||||
ring.set_value(25)
|
||||
|
||||
text = RingProgressContainerWidget._build_tooltip_text(ring)
|
||||
assert "Scan progress" in text
|
||||
|
||||
|
||||
def test_build_tooltip_text_device_mode_with_signal(container):
|
||||
"""Device mode tooltip shows device:signal when both are set."""
|
||||
ring = container.add_ring()
|
||||
ring.config.mode = "device"
|
||||
ring.config.device = "samx"
|
||||
ring.config.signal = "readback"
|
||||
ring.set_value(10)
|
||||
|
||||
text = RingProgressContainerWidget._build_tooltip_text(ring)
|
||||
assert "Device" in text
|
||||
assert "samx:readback" in text
|
||||
|
||||
|
||||
def test_build_tooltip_text_device_mode_without_signal(container):
|
||||
"""Device mode tooltip shows only device name when signal is absent."""
|
||||
ring = container.add_ring()
|
||||
ring.config.mode = "device"
|
||||
ring.config.device = "samy"
|
||||
ring.config.signal = None
|
||||
ring.set_value(10)
|
||||
|
||||
text = RingProgressContainerWidget._build_tooltip_text(ring)
|
||||
assert "samy" in text
|
||||
assert ":" not in text.split("Device:")[-1].split("\n")[0]
|
||||
|
||||
|
||||
def test_build_tooltip_text_nonzero_min_shows_range(container):
|
||||
"""Tooltip includes Range line when min_value is not 0."""
|
||||
ring = container.add_ring()
|
||||
ring.set_min_max_values(10, 90)
|
||||
ring.set_value(50)
|
||||
|
||||
text = RingProgressContainerWidget._build_tooltip_text(ring)
|
||||
assert "Range" in text
|
||||
|
||||
|
||||
def test_build_tooltip_text_zero_min_no_range(container):
|
||||
"""Tooltip omits Range line when min_value is 0."""
|
||||
ring = container.add_ring()
|
||||
ring.set_min_max_values(0, 100)
|
||||
ring.set_value(50)
|
||||
|
||||
text = RingProgressContainerWidget._build_tooltip_text(ring)
|
||||
assert "Range" not in text
|
||||
|
||||
|
||||
def test_refresh_hover_tooltip_updates_label_on_value_change(container):
|
||||
"""refresh_hover_tooltip updates the label text after the ring value changes."""
|
||||
ring = container.add_ring()
|
||||
ring.set_value(30)
|
||||
container._hovered_ring = ring
|
||||
container._last_hover_global_pos = QPoint(100, 100)
|
||||
|
||||
container.refresh_hover_tooltip(ring)
|
||||
text_before = container._hover_tooltip_label.text()
|
||||
|
||||
ring.set_value(70)
|
||||
container.refresh_hover_tooltip(ring)
|
||||
text_after = container._hover_tooltip_label.text()
|
||||
|
||||
assert text_before != text_after
|
||||
assert "70" in text_after
|
||||
|
||||
|
||||
def test_refresh_hover_tooltip_no_pos_does_not_crash(container):
|
||||
"""refresh_hover_tooltip with no stored position should return without raising."""
|
||||
ring = container.add_ring()
|
||||
container._last_hover_global_pos = None
|
||||
# Should not raise
|
||||
container.refresh_hover_tooltip(ring)
|
||||
|
||||
|
||||
def test_mouse_move_sets_hovered_ring_and_updates_tooltip(qtbot, container):
|
||||
ring = container.add_ring()
|
||||
container._hover_tooltip.show_near = MagicMock()
|
||||
container.show()
|
||||
qtbot.waitExposed(container)
|
||||
|
||||
cx, cy, base_radius = _ring_center_pos(container)
|
||||
_send_mouse_move(container, QPoint(int(cx + base_radius), int(cy)))
|
||||
|
||||
assert container._hovered_ring is ring
|
||||
assert ring._hovered is True
|
||||
assert "Mode: Manual" in container._hover_tooltip_label.text()
|
||||
container._hover_tooltip.show_near.assert_called_once()
|
||||
|
||||
|
||||
def test_mouse_move_switches_hover_between_rings(qtbot):
|
||||
container = RingProgressContainerWidget()
|
||||
qtbot.addWidget(container)
|
||||
container.resize(300, 300)
|
||||
ring0 = container.add_ring()
|
||||
ring1 = container.add_ring()
|
||||
container._hover_tooltip.show_near = MagicMock()
|
||||
container.show()
|
||||
qtbot.waitExposed(container)
|
||||
|
||||
cx, cy, base_radius = _ring_center_pos(container)
|
||||
radius0 = base_radius - ring0.gap
|
||||
radius1 = base_radius - ring1.gap
|
||||
_send_mouse_move(container, QPoint(int(cx + radius0), int(cy)))
|
||||
|
||||
assert container._hovered_ring is ring0
|
||||
|
||||
_send_mouse_move(container, QPoint(int(cx + radius1), int(cy)))
|
||||
|
||||
assert container._hovered_ring is ring1
|
||||
assert ring0._hovered is False
|
||||
assert ring1._hovered is True
|
||||
|
||||
|
||||
def test_leave_event_clears_hover_and_hides_tooltip(qtbot, container):
|
||||
ring = container.add_ring()
|
||||
container._hover_tooltip.hide = MagicMock()
|
||||
container.show()
|
||||
qtbot.waitExposed(container)
|
||||
|
||||
cx, cy, base_radius = _ring_center_pos(container)
|
||||
_send_mouse_move(container, QPoint(int(cx + base_radius), int(cy)))
|
||||
|
||||
QApplication.sendEvent(container, QEvent(QEvent.Type.Leave))
|
||||
|
||||
assert container._hovered_ring is None
|
||||
assert ring._hovered is False
|
||||
assert container._last_hover_global_pos is None
|
||||
container._hover_tooltip.hide.assert_called()
|
||||
|
||||
@@ -240,6 +240,36 @@ def test_set_start_angle(ring_widget):
|
||||
assert ring_widget.config.start_position == 180
|
||||
|
||||
|
||||
def test_set_hovered_updates_animation_target(ring_widget):
|
||||
ring_widget.set_hovered(True)
|
||||
|
||||
assert ring_widget._hovered is True
|
||||
assert ring_widget._hover_animation.endValue() == 1.0
|
||||
|
||||
ring_widget.set_hovered(False)
|
||||
|
||||
assert ring_widget._hovered is False
|
||||
assert ring_widget._hover_animation.endValue() == 0.0
|
||||
|
||||
|
||||
def test_refresh_hover_tooltip_delegates_to_container(ring_widget):
|
||||
ring_widget.progress_container = MagicMock()
|
||||
ring_widget.progress_container.is_ring_hovered.return_value = True
|
||||
|
||||
ring_widget._request_update()
|
||||
|
||||
ring_widget.progress_container.refresh_hover_tooltip.assert_called_once_with(ring_widget)
|
||||
|
||||
|
||||
def test_refresh_hover_tooltip_skips_when_ring_is_not_hovered(ring_widget):
|
||||
ring_widget.progress_container = MagicMock()
|
||||
ring_widget.progress_container.is_ring_hovered.return_value = False
|
||||
|
||||
ring_widget._request_update()
|
||||
|
||||
ring_widget.progress_container.refresh_hover_tooltip.assert_not_called()
|
||||
|
||||
|
||||
###################################
|
||||
# Color management tests
|
||||
###################################
|
||||
|
||||
@@ -113,7 +113,7 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
|
||||
)
|
||||
|
||||
|
||||
def fill_commponents(components: dict[str, DynamicFormItem]):
|
||||
def fill_components(components: dict[str, DynamicFormItem]):
|
||||
components["sample_name"].setValue("test name")
|
||||
components["str_optional"].setValue(None)
|
||||
components["str_required"].setValue("something")
|
||||
@@ -147,7 +147,7 @@ def test_griditems_are_correct_class(
|
||||
|
||||
def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]):
|
||||
widget, components = metadata_widget = metadata_widget
|
||||
fill_commponents(components)
|
||||
fill_components(components)
|
||||
|
||||
assert widget._dict_from_grid() == TEST_DICT
|
||||
assert widget.get_form_data() == TEST_DICT | {"extra_field": "extra_data"}
|
||||
@@ -159,7 +159,7 @@ def test_validation(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormIt
|
||||
widget._validity.compact_status.default_led[:114]
|
||||
)
|
||||
|
||||
fill_commponents(components)
|
||||
fill_components(components)
|
||||
widget.validate_form()
|
||||
assert widget._validity_message.text() == "No errors!"
|
||||
|
||||
@@ -178,7 +178,7 @@ def test_numbers_clipped_to_limits(
|
||||
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
|
||||
):
|
||||
widget, components = metadata_widget = metadata_widget
|
||||
fill_commponents(components)
|
||||
fill_components(components)
|
||||
|
||||
components["decimal_dp_limits_nodefault"].setValue(-56)
|
||||
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(1.01)
|
||||
|
||||
@@ -36,6 +36,22 @@ from .conftest import create_widget
|
||||
##################################################
|
||||
|
||||
|
||||
def make_alignment_fit_summary(center: float | None = None) -> dict:
|
||||
params = []
|
||||
if center is not None:
|
||||
params.append(["center", center, True, None, -np.inf, np.inf, None, 0.1, {}, 0.0, None])
|
||||
params.append(["sigma", 0.5, True, None, 0.0, np.inf, None, 0.1, {}, 1.0, None])
|
||||
return {
|
||||
"model": "Model(test)",
|
||||
"method": "leastsq",
|
||||
"chisqr": 1.0,
|
||||
"redchi": 1.0,
|
||||
"rsquared": 0.99,
|
||||
"message": "Fit succeeded.",
|
||||
"params": params,
|
||||
}
|
||||
|
||||
|
||||
def test_waveform_initialization(qtbot, mocked_client):
|
||||
"""
|
||||
Test that a new Waveform widget initializes with the correct defaults.
|
||||
@@ -496,6 +512,218 @@ def test_add_dap_curve_custom_source(qtbot, mocked_client_with_dap):
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_alignment_mode_toggle_shows_bottom_panel(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
action = wf.toolbar.components.get_action("alignment_mode").action
|
||||
action.trigger()
|
||||
|
||||
assert wf._alignment_panel_visible is True
|
||||
assert wf._alignment_side_panel.panel_visible is True
|
||||
assert action.isChecked() is True
|
||||
|
||||
action.trigger()
|
||||
|
||||
assert wf._alignment_panel_visible is False
|
||||
assert wf._alignment_side_panel.panel_visible is False
|
||||
assert action.isChecked() is False
|
||||
|
||||
|
||||
def test_resolve_alignment_positioner(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
wf.x_mode = "samx"
|
||||
assert wf._resolve_alignment_positioner() == "samx"
|
||||
|
||||
wf.x_mode = "auto"
|
||||
wf._current_x_device = ("samx", "samx")
|
||||
assert wf._resolve_alignment_positioner() == "samx"
|
||||
|
||||
wf._current_x_device = ("bpm4i", "bpm4i")
|
||||
assert wf._resolve_alignment_positioner() is None
|
||||
|
||||
wf.x_mode = "index"
|
||||
assert wf._resolve_alignment_positioner() is None
|
||||
|
||||
wf.x_mode = "timestamp"
|
||||
assert wf._resolve_alignment_positioner() is None
|
||||
|
||||
|
||||
def test_alignment_panel_updates_when_auto_x_motor_changes(
|
||||
qtbot, mocked_client_with_dap, monkeypatch
|
||||
):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.x_mode = "auto"
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
|
||||
wf._current_x_device = ("samx", "samx")
|
||||
wf._alignment_panel.set_positioner_device("samx")
|
||||
wf.scan_item = create_dummy_scan_item()
|
||||
wf.scan_item.metadata["bec"]["scan_report_devices"] = ["samy"]
|
||||
|
||||
data = {
|
||||
"samy": {"samy": {"val": np.array([1.0, 2.0, 3.0])}},
|
||||
"bpm4i": {"bpm4i": {"val": np.array([10.0, 20.0, 30.0])}},
|
||||
}
|
||||
monkeypatch.setattr(wf, "_fetch_scan_data_and_access", lambda: (data, "val"))
|
||||
|
||||
wf._get_x_data("bpm4i", "bpm4i")
|
||||
|
||||
assert wf._current_x_device == ("samy", "samy")
|
||||
assert wf._alignment_positioner_name == "samy"
|
||||
assert wf._alignment_panel.positioner.device == "samy"
|
||||
|
||||
|
||||
def test_alignment_panel_disables_without_positioner(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i")
|
||||
wf.x_mode = "index"
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
|
||||
assert wf._alignment_panel.positioner.isEnabled() is False
|
||||
assert "positioner on the x axis" in wf._alignment_panel.status_label.text()
|
||||
|
||||
|
||||
def test_alignment_marker_updates_from_positioner_readback(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
wf.dev["samx"].signals["samx"]["value"] = 4.2
|
||||
wf._alignment_panel.positioner.force_update_readback()
|
||||
|
||||
assert wf._alignment_controller is not None
|
||||
assert wf._alignment_controller.marker_line is not None
|
||||
assert np.isclose(wf._alignment_controller.marker_line.value(), 4.2)
|
||||
assert "samx" in wf._alignment_controller.marker_line.label.toPlainText()
|
||||
assert "4.200" in wf._alignment_controller.marker_line.label.toPlainText()
|
||||
|
||||
|
||||
def test_alignment_panel_uses_existing_dap_curves_and_moves_positioner(
|
||||
qtbot, mocked_client_with_dap
|
||||
):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
source_curve = wf.plot(arg1="bpm4i")
|
||||
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
fit_summary = make_alignment_fit_summary(center=2.5)
|
||||
wf.dap_summary_update.emit(fit_summary, {"curve_id": dap_curve.name()})
|
||||
wf._alignment_panel.fit_dialog.select_curve(dap_curve.name())
|
||||
|
||||
move_spy = MagicMock()
|
||||
wf.dev["samx"].move = move_spy
|
||||
|
||||
assert wf._alignment_panel.fit_dialog.fit_curve_id == dap_curve.name()
|
||||
assert wf._alignment_panel.fit_dialog.action_buttons["center"].isEnabled() is True
|
||||
|
||||
wf._alignment_panel.fit_dialog.action_buttons["center"].click()
|
||||
|
||||
move_spy.assert_called_once_with(2.5, relative=False)
|
||||
|
||||
|
||||
def test_alignment_target_line_toggle_updates_target_value_label(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
wf._alignment_panel.target_toggle.setChecked(True)
|
||||
|
||||
assert wf._alignment_controller is not None
|
||||
assert wf._alignment_controller.target_line is not None
|
||||
assert wf._alignment_panel.move_to_target_button.isEnabled() is True
|
||||
|
||||
wf._alignment_controller.target_line.setValue(1.5)
|
||||
|
||||
assert "1.500" in wf._alignment_panel.target_toggle.text()
|
||||
|
||||
|
||||
def test_alignment_move_to_target_uses_draggable_line_value(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
wf._alignment_panel.target_toggle.setChecked(True)
|
||||
wf._alignment_controller.target_line.setValue(1.25)
|
||||
|
||||
move_spy = MagicMock()
|
||||
wf.dev["samx"].move = move_spy
|
||||
|
||||
wf._alignment_panel.move_to_target_button.click()
|
||||
|
||||
move_spy.assert_called_once_with(1.25, relative=False)
|
||||
|
||||
|
||||
def test_alignment_mode_toggle_off_keeps_user_dap_curve(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
source_curve = wf.plot(arg1="bpm4i")
|
||||
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
action = wf.toolbar.components.get_action("alignment_mode").action
|
||||
action.trigger()
|
||||
action.trigger()
|
||||
|
||||
assert wf.get_curve(dap_curve.name()) is not None
|
||||
|
||||
|
||||
def test_alignment_mode_toggle_off_clears_controller_overlays(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
action = wf.toolbar.components.get_action("alignment_mode").action
|
||||
action.trigger()
|
||||
wf._alignment_panel.target_toggle.setChecked(True)
|
||||
wf.dev["samx"].signals["samx"]["value"] = 2.0
|
||||
wf._alignment_panel.positioner.force_update_readback()
|
||||
|
||||
assert wf._alignment_controller.marker_line is not None
|
||||
assert wf._alignment_controller.target_line is not None
|
||||
|
||||
action.trigger()
|
||||
|
||||
assert wf._alignment_controller.marker_line is None
|
||||
assert wf._alignment_controller.target_line is None
|
||||
|
||||
|
||||
def test_alignment_panel_removes_deleted_dap_curve_from_fit_list(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
source_curve = wf.plot(arg1="bpm4i")
|
||||
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
wf.dap_summary_update.emit(
|
||||
make_alignment_fit_summary(center=1.5), {"curve_id": dap_curve.name()}
|
||||
)
|
||||
|
||||
assert dap_curve.name() in wf._alignment_panel.fit_dialog.summary_data
|
||||
|
||||
wf.remove_curve(dap_curve.name())
|
||||
|
||||
assert dap_curve.name() not in wf._alignment_panel.fit_dialog.summary_data
|
||||
|
||||
|
||||
def test_alignment_controller_move_request_moves_positioner(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
move_spy = MagicMock()
|
||||
wf.dev["samx"].move = move_spy
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
wf._alignment_controller.move_absolute_requested.emit(3.5)
|
||||
|
||||
move_spy.assert_called_once_with(3.5, relative=False)
|
||||
|
||||
|
||||
def test_curve_set_data_emits_dap_update(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")
|
||||
@@ -637,7 +865,7 @@ def test_fetch_scan_data_and_access(qtbot, mocked_client, monkeypatch):
|
||||
wf._fetch_scan_data_and_access()
|
||||
hist_mock.assert_called_once_with(-1)
|
||||
|
||||
# Ckeck live mode
|
||||
# Check live mode
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
wf.scan_item = dummy_scan
|
||||
data_dict, access_key = wf._fetch_scan_data_and_access()
|
||||
|
||||
@@ -1,476 +0,0 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import QHideEvent
|
||||
from qtpy.QtNetwork import QAuthenticator
|
||||
|
||||
from bec_widgets.widgets.editors.web_console.web_console import (
|
||||
BECShell,
|
||||
ConsoleMode,
|
||||
WebConsole,
|
||||
_web_console_registry,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mocked_server_startup():
|
||||
"""Mock the web console server startup process."""
|
||||
with mock.patch(
|
||||
"bec_widgets.widgets.editors.web_console.web_console.subprocess"
|
||||
) as mock_subprocess:
|
||||
with mock.patch.object(_web_console_registry, "_wait_for_server_port"):
|
||||
_web_console_registry._server_port = 12345
|
||||
yield mock_subprocess
|
||||
|
||||
|
||||
def static_console(qtbot, client, unique_id: str | None = None):
|
||||
"""Fixture to provide a static unique_id for WebConsole tests."""
|
||||
if unique_id is None:
|
||||
widget = WebConsole(client=client)
|
||||
else:
|
||||
widget = WebConsole(client=client, unique_id=unique_id)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
return widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_widget(qtbot, mocked_client, mocked_server_startup):
|
||||
"""Create a WebConsole widget with mocked server startup."""
|
||||
yield static_console(qtbot, mocked_client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bec_shell_widget(qtbot, mocked_client, mocked_server_startup):
|
||||
"""Create a BECShell widget with mocked server startup."""
|
||||
widget = BECShell(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_widget_with_static_id(qtbot, mocked_client, mocked_server_startup):
|
||||
"""Create a WebConsole widget with a static unique ID."""
|
||||
yield static_console(qtbot, mocked_client, unique_id="test_console")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_console_widgets_same_id(qtbot, mocked_client, mocked_server_startup):
|
||||
"""Create two WebConsole widgets sharing the same unique ID."""
|
||||
widget1 = static_console(qtbot, mocked_client, unique_id="shared_console")
|
||||
widget2 = static_console(qtbot, mocked_client, unique_id="shared_console")
|
||||
yield widget1, widget2
|
||||
|
||||
|
||||
def test_web_console_widget_initialization(console_widget):
|
||||
assert (
|
||||
console_widget.page.url().toString()
|
||||
== f"http://localhost:{_web_console_registry._server_port}"
|
||||
)
|
||||
|
||||
|
||||
def test_web_console_write(console_widget):
|
||||
# Test the write method
|
||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
||||
console_widget.write("Hello, World!")
|
||||
|
||||
assert mock.call('window.term.paste("Hello, World!");') in mock_run_js.mock_calls
|
||||
|
||||
|
||||
def test_web_console_write_no_return(console_widget):
|
||||
# Test the write method with send_return=False
|
||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
||||
console_widget.write("Hello, World!", send_return=False)
|
||||
|
||||
assert mock.call('window.term.paste("Hello, World!");') in mock_run_js.mock_calls
|
||||
assert mock_run_js.call_count == 1
|
||||
|
||||
|
||||
def test_web_console_send_return(console_widget):
|
||||
# Test the send_return method
|
||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
||||
console_widget.send_return()
|
||||
|
||||
script = mock_run_js.call_args[0][0]
|
||||
assert "new KeyboardEvent('keypress', {charCode: 13})" in script
|
||||
assert mock_run_js.call_count == 1
|
||||
|
||||
|
||||
def test_web_console_send_ctrl_c(console_widget):
|
||||
# Test the send_ctrl_c method
|
||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
||||
console_widget.send_ctrl_c()
|
||||
|
||||
script = mock_run_js.call_args[0][0]
|
||||
assert "new KeyboardEvent('keypress', {charCode: 3})" in script
|
||||
assert mock_run_js.call_count == 1
|
||||
|
||||
|
||||
def test_web_console_authenticate(console_widget):
|
||||
# Test the _authenticate method
|
||||
token = _web_console_registry._token
|
||||
mock_auth = mock.MagicMock(spec=QAuthenticator)
|
||||
console_widget._authenticate(None, mock_auth)
|
||||
mock_auth.setUser.assert_called_once_with("user")
|
||||
mock_auth.setPassword.assert_called_once_with(token)
|
||||
|
||||
|
||||
def test_web_console_registry_wait_for_server_port():
|
||||
# Test the _wait_for_server_port method
|
||||
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
|
||||
mock_subprocess.stderr.readline.side_effect = [b"Starting", b"Listening on port: 12345"]
|
||||
_web_console_registry._wait_for_server_port()
|
||||
assert _web_console_registry._server_port == 12345
|
||||
|
||||
|
||||
def test_web_console_registry_wait_for_server_port_timeout():
|
||||
# Test the _wait_for_server_port method with timeout
|
||||
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
|
||||
with pytest.raises(TimeoutError):
|
||||
_web_console_registry._wait_for_server_port(timeout=0.1)
|
||||
|
||||
|
||||
def test_web_console_startup_command_execution(console_widget, qtbot):
|
||||
"""Test that the startup command is triggered after successful initialization."""
|
||||
# Set a custom startup command
|
||||
console_widget.startup_cmd = "test startup command"
|
||||
|
||||
assert console_widget.startup_cmd == "test startup command"
|
||||
|
||||
# Generator to simulate JS initialization sequence
|
||||
def js_readiness_sequence():
|
||||
yield False # First call: not ready yet
|
||||
while True:
|
||||
yield True # Any subsequent calls: ready
|
||||
|
||||
readiness_gen = js_readiness_sequence()
|
||||
|
||||
def mock_run_js(script, callback=None):
|
||||
# Check if this is the initialization check call
|
||||
if "window.term !== undefined" in script and callback:
|
||||
ready = next(readiness_gen)
|
||||
callback(ready)
|
||||
else:
|
||||
# For other JavaScript calls (like paste), just call the callback
|
||||
if callback:
|
||||
callback(True)
|
||||
|
||||
with mock.patch.object(
|
||||
console_widget.page, "runJavaScript", side_effect=mock_run_js
|
||||
) as mock_run_js_method:
|
||||
# Reset initialization state and start the timer
|
||||
console_widget._is_initialized = False
|
||||
console_widget._startup_timer.start()
|
||||
|
||||
# Wait for the initialization to complete
|
||||
qtbot.waitUntil(lambda: console_widget._is_initialized, timeout=3000)
|
||||
|
||||
# Verify that the startup command was executed
|
||||
startup_calls = [
|
||||
call
|
||||
for call in mock_run_js_method.call_args_list
|
||||
if "test startup command" in str(call)
|
||||
]
|
||||
assert len(startup_calls) > 0, "Startup command should have been executed"
|
||||
|
||||
# Verify the initialized signal was emitted
|
||||
assert console_widget._is_initialized is True
|
||||
assert not console_widget._startup_timer.isActive()
|
||||
|
||||
|
||||
def test_bec_shell_startup_contains_gui_id(bec_shell_widget):
|
||||
"""Test that the BEC shell startup command includes the GUI ID."""
|
||||
bec_shell = bec_shell_widget
|
||||
|
||||
assert bec_shell._is_bec_shell
|
||||
assert bec_shell._unique_id == "bec_shell"
|
||||
|
||||
with mock.patch.object(bec_shell.bec_dispatcher, "cli_server", None):
|
||||
assert bec_shell.startup_cmd == "bec --nogui"
|
||||
|
||||
with mock.patch.object(bec_shell.bec_dispatcher.cli_server, "gui_id", "test_gui_id"):
|
||||
assert bec_shell.startup_cmd == "bec --gui-id test_gui_id"
|
||||
|
||||
|
||||
def test_web_console_set_readonly(console_widget):
|
||||
# Test the set_readonly method
|
||||
console_widget.set_readonly(True)
|
||||
assert not console_widget.isEnabled()
|
||||
|
||||
console_widget.set_readonly(False)
|
||||
assert console_widget.isEnabled()
|
||||
|
||||
|
||||
def test_web_console_with_unique_id(console_widget_with_static_id):
|
||||
"""Test creating a WebConsole with a unique_id."""
|
||||
widget = console_widget_with_static_id
|
||||
|
||||
assert widget._unique_id == "test_console"
|
||||
assert widget._unique_id in _web_console_registry._page_registry
|
||||
page_info = _web_console_registry.get_page_info("test_console")
|
||||
assert page_info is not None
|
||||
assert page_info.owner_gui_id == widget.gui_id
|
||||
assert widget.gui_id in page_info.widget_ids
|
||||
|
||||
|
||||
def test_web_console_page_sharing(two_console_widgets_same_id):
|
||||
"""Test that two widgets can share the same page using unique_id."""
|
||||
widget1, widget2 = two_console_widgets_same_id
|
||||
|
||||
# Both should reference the same page in the registry
|
||||
page_info = _web_console_registry.get_page_info("shared_console")
|
||||
assert page_info is not None
|
||||
assert widget1.gui_id in page_info.widget_ids
|
||||
assert widget2.gui_id in page_info.widget_ids
|
||||
assert widget1.page == widget2.page
|
||||
|
||||
|
||||
def test_web_console_has_ownership(console_widget_with_static_id):
|
||||
"""Test the has_ownership method."""
|
||||
widget = console_widget_with_static_id
|
||||
|
||||
# Widget should have ownership by default
|
||||
assert widget.has_ownership()
|
||||
|
||||
|
||||
def test_web_console_yield_ownership(console_widget_with_static_id):
|
||||
"""Test yielding ownership of a page."""
|
||||
widget = console_widget_with_static_id
|
||||
|
||||
assert widget.has_ownership()
|
||||
|
||||
# Yield ownership
|
||||
widget.yield_ownership()
|
||||
|
||||
# Widget should no longer have ownership
|
||||
assert not widget.has_ownership()
|
||||
page_info = _web_console_registry.get_page_info("test_console")
|
||||
assert page_info.owner_gui_id is None
|
||||
# Overlay should be shown
|
||||
assert widget._mode == ConsoleMode.INACTIVE
|
||||
|
||||
|
||||
def test_web_console_take_page_ownership(two_console_widgets_same_id):
|
||||
"""Test taking ownership of a page."""
|
||||
widget1, widget2 = two_console_widgets_same_id
|
||||
|
||||
# Widget1 should have ownership initially
|
||||
assert widget1.has_ownership()
|
||||
assert not widget2.has_ownership()
|
||||
|
||||
# Widget2 takes ownership
|
||||
widget2.take_page_ownership()
|
||||
|
||||
# Now widget2 should have ownership
|
||||
assert not widget1.has_ownership()
|
||||
assert widget2.has_ownership()
|
||||
|
||||
assert widget2._mode == ConsoleMode.ACTIVE
|
||||
assert widget1._mode == ConsoleMode.INACTIVE
|
||||
|
||||
|
||||
def test_web_console_hide_event_yields_ownership(qtbot, console_widget_with_static_id):
|
||||
"""Test that hideEvent yields ownership."""
|
||||
widget = console_widget_with_static_id
|
||||
|
||||
assert widget.has_ownership()
|
||||
|
||||
# Hide the widget. Note that we cannot call widget.hide() directly
|
||||
# because it doesn't trigger the hideEvent in tests as widgets are
|
||||
# not visible in the test environment.
|
||||
widget.hideEvent(QHideEvent())
|
||||
qtbot.wait(100) # Allow event processing
|
||||
|
||||
# Widget should have yielded ownership
|
||||
assert not widget.has_ownership()
|
||||
page_info = _web_console_registry.get_page_info("test_console")
|
||||
assert page_info.owner_gui_id is None
|
||||
|
||||
|
||||
def test_web_console_show_event_takes_ownership(console_widget_with_static_id):
|
||||
"""Test that showEvent takes ownership when page has no owner."""
|
||||
widget = console_widget_with_static_id
|
||||
|
||||
# Yield ownership
|
||||
widget.yield_ownership()
|
||||
assert not widget.has_ownership()
|
||||
|
||||
# Show the widget again
|
||||
widget.show()
|
||||
|
||||
# Widget should have reclaimed ownership
|
||||
assert widget.has_ownership()
|
||||
assert widget.browser.isVisible()
|
||||
assert not widget.overlay.isVisible()
|
||||
|
||||
|
||||
def test_web_console_mouse_press_takes_ownership(qtbot, two_console_widgets_same_id):
|
||||
"""Test that clicking on overlay takes ownership."""
|
||||
widget1, widget2 = two_console_widgets_same_id
|
||||
widget1.show()
|
||||
widget2.show()
|
||||
|
||||
# Widget1 has ownership, widget2 doesn't
|
||||
assert widget1.has_ownership()
|
||||
assert not widget2.has_ownership()
|
||||
assert widget1.isVisible()
|
||||
assert widget1._mode == ConsoleMode.ACTIVE
|
||||
assert widget2._mode == ConsoleMode.INACTIVE
|
||||
|
||||
qtbot.mouseClick(widget2, Qt.MouseButton.LeftButton)
|
||||
|
||||
# Widget2 should now have ownership
|
||||
assert widget2.has_ownership()
|
||||
assert not widget1.has_ownership()
|
||||
|
||||
|
||||
def test_web_console_registry_cleanup_removes_page(console_widget_with_static_id):
|
||||
"""Test that the registry cleans up pages when all widgets are removed."""
|
||||
widget = console_widget_with_static_id
|
||||
|
||||
assert widget._unique_id in _web_console_registry._page_registry
|
||||
|
||||
# Cleanup the widget
|
||||
widget.cleanup()
|
||||
|
||||
# Page should be removed from registry
|
||||
assert widget._unique_id not in _web_console_registry._page_registry
|
||||
|
||||
|
||||
def test_web_console_without_unique_id_no_page_sharing(console_widget):
|
||||
"""Test that widgets without unique_id don't participate in page sharing."""
|
||||
widget = console_widget
|
||||
|
||||
# Widget should not be in the page registry
|
||||
assert widget._unique_id is None
|
||||
assert not widget.has_ownership() # Should return False for non-unique widgets
|
||||
|
||||
|
||||
def test_web_console_registry_get_page_info_nonexistent(qtbot, mocked_client):
|
||||
"""Test getting page info for a non-existent page."""
|
||||
page_info = _web_console_registry.get_page_info("nonexistent")
|
||||
assert page_info is None
|
||||
|
||||
|
||||
def test_web_console_take_ownership_without_unique_id(console_widget):
|
||||
"""Test that take_page_ownership fails gracefully without unique_id."""
|
||||
widget = console_widget
|
||||
# Should not crash when taking ownership without unique_id
|
||||
widget.take_page_ownership()
|
||||
|
||||
|
||||
def test_web_console_yield_ownership_without_unique_id(console_widget):
|
||||
"""Test that yield_ownership fails gracefully without unique_id."""
|
||||
widget = console_widget
|
||||
# Should not crash when yielding ownership without unique_id
|
||||
widget.yield_ownership()
|
||||
|
||||
|
||||
def test_registry_yield_ownership_gui_id_not_in_instances():
|
||||
"""Test registry yield_ownership returns False when gui_id not in instances."""
|
||||
result = _web_console_registry.yield_ownership("nonexistent_gui_id")
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_registry_yield_ownership_instance_is_none(console_widget_with_static_id):
|
||||
"""Test registry yield_ownership returns False when instance weakref is dead."""
|
||||
widget = console_widget_with_static_id
|
||||
gui_id = widget.gui_id
|
||||
|
||||
# Store the gui_id and simulate the weakref being dead
|
||||
_web_console_registry._instances[gui_id] = lambda: None
|
||||
|
||||
result = _web_console_registry.yield_ownership(gui_id)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_registry_yield_ownership_unique_id_none(console_widget_with_static_id):
|
||||
"""Test registry yield_ownership returns False when page info's unique_id is None."""
|
||||
widget = console_widget_with_static_id
|
||||
gui_id = widget.gui_id
|
||||
unique_id = widget._unique_id
|
||||
widget._unique_id = None
|
||||
|
||||
result = _web_console_registry.yield_ownership(gui_id)
|
||||
assert result is False
|
||||
|
||||
widget._unique_id = unique_id # Restore for cleanup
|
||||
|
||||
|
||||
def test_registry_yield_ownership_unique_id_not_in_page_registry(console_widget_with_static_id):
|
||||
"""Test registry yield_ownership returns False when unique_id not in page registry."""
|
||||
widget = console_widget_with_static_id
|
||||
gui_id = widget.gui_id
|
||||
unique_id = widget._unique_id
|
||||
widget._unique_id = "nonexistent_unique_id"
|
||||
|
||||
result = _web_console_registry.yield_ownership(gui_id)
|
||||
assert result is False
|
||||
|
||||
widget._unique_id = unique_id # Restore for cleanup
|
||||
|
||||
|
||||
def test_registry_owner_is_visible_page_info_none():
|
||||
"""Test owner_is_visible returns False when page info doesn't exist."""
|
||||
result = _web_console_registry.owner_is_visible("nonexistent_page")
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_registry_owner_is_visible_no_owner(console_widget_with_static_id):
|
||||
"""Test owner_is_visible returns False when page has no owner."""
|
||||
widget = console_widget_with_static_id
|
||||
|
||||
# Yield ownership so there's no owner
|
||||
widget.yield_ownership()
|
||||
page_info = _web_console_registry.get_page_info(widget._unique_id)
|
||||
assert page_info.owner_gui_id is None
|
||||
|
||||
result = _web_console_registry.owner_is_visible(widget._unique_id)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_registry_owner_is_visible_owner_ref_none(console_widget_with_static_id):
|
||||
"""Test owner_is_visible returns False when owner ref doesn't exist in instances."""
|
||||
widget = console_widget_with_static_id
|
||||
unique_id = widget._unique_id
|
||||
|
||||
# Remove owner from instances dict
|
||||
del _web_console_registry._instances[widget.gui_id]
|
||||
|
||||
result = _web_console_registry.owner_is_visible(unique_id)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_registry_owner_is_visible_owner_instance_none(console_widget_with_static_id):
|
||||
"""Test owner_is_visible returns False when owner instance weakref is dead."""
|
||||
widget = console_widget_with_static_id
|
||||
unique_id = widget._unique_id
|
||||
gui_id = widget.gui_id
|
||||
|
||||
# Simulate dead weakref
|
||||
_web_console_registry._instances[gui_id] = lambda: None
|
||||
|
||||
result = _web_console_registry.owner_is_visible(unique_id)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_registry_owner_is_visible_owner_visible(console_widget_with_static_id):
|
||||
"""Test owner_is_visible returns True when owner is visible."""
|
||||
widget = console_widget_with_static_id
|
||||
widget.show()
|
||||
|
||||
result = _web_console_registry.owner_is_visible(widget._unique_id)
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_registry_owner_is_visible_owner_not_visible(console_widget_with_static_id):
|
||||
"""Test owner_is_visible returns False when owner is not visible."""
|
||||
widget = console_widget_with_static_id
|
||||
widget.hide()
|
||||
|
||||
result = _web_console_registry.owner_is_visible(widget._unique_id)
|
||||
assert result is False
|
||||
Reference in New Issue
Block a user