mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-05-05 22:34:19 +02:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dc373bd8e | |||
| 91afc775d5 | |||
| 55694ff2b9 | |||
| 5b68a51aaa | |||
| f13fa75e25 | |||
| 0cf84cd1d8 | |||
| 3e77f54034 | |||
| f7616102d8 | |||
| 5a497c3598 | |||
| 23e3644619 | |||
| a5db2dc340 | |||
| 2e8f43fcac | |||
| 09bb1121d8 | |||
| c9aaa77b3c | |||
| f7a1ee49a4 | |||
| 8e51c1adb6 | |||
| 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 |
@@ -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]
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+154
@@ -1,6 +1,160 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v3.7.1 (2026-04-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **heatmap**: Fix access to status from metadata
|
||||
([`55694ff`](https://github.com/bec-project/bec_widgets/commit/55694ff2b96581e03c63c8b8e068e2db79bcf780))
|
||||
|
||||
### Testing
|
||||
|
||||
- Fix exit status and status access in tests
|
||||
([`91afc77`](https://github.com/bec-project/bec_widgets/commit/91afc775d59b4ba31bed3585847a67c301acf9b0))
|
||||
|
||||
|
||||
## v3.7.0 (2026-04-21)
|
||||
|
||||
### Features
|
||||
|
||||
- Move companion app to applications
|
||||
([`0cf84cd`](https://github.com/bec-project/bec_widgets/commit/0cf84cd1d839ac4a39ffb5fb9ba57d432e04348a))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Cleanup of imports
|
||||
([`3e77f54`](https://github.com/bec-project/bec_widgets/commit/3e77f540345f56b9f184a332fcdd50d4d4c8c621))
|
||||
|
||||
|
||||
## v3.6.0 (2026-04-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Change resize mode to interactive
|
||||
([`a5db2dc`](https://github.com/bec-project/bec_widgets/commit/a5db2dc340f3386e68b300fd4528a44f87cbbf97))
|
||||
|
||||
- Small usability changes
|
||||
([`5a497c3`](https://github.com/bec-project/bec_widgets/commit/5a497c3598c2d8f27916d91d53c646d5d6d3a4a7))
|
||||
|
||||
### Features
|
||||
|
||||
- Add button/slot to pause/unpause logs
|
||||
([`23e3644`](https://github.com/bec-project/bec_widgets/commit/23e3644619de958bcfdb8a0b2ee1f7c2ce05b235))
|
||||
|
||||
- Add logpanel to menu
|
||||
([`2e8f43f`](https://github.com/bec-project/bec_widgets/commit/2e8f43fcac581cd1c227308198565d142a1bf276))
|
||||
|
||||
- Migrate logpanel to table model/view
|
||||
([`09bb112`](https://github.com/bec-project/bec_widgets/commit/09bb1121d83bac1f6e4827daa476fbe7cd5b3a80))
|
||||
|
||||
|
||||
## v3.5.1 (2026-04-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Don't assume attr exists if we timed out waiting for it
|
||||
([`f7a1ee4`](https://github.com/bec-project/bec_widgets/commit/f7a1ee49a42c58ba315c8957b45a80d862ffe745))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Don't import real widgets in client
|
||||
([`8e51c1a`](https://github.com/bec-project/bec_widgets/commit/8e51c1adb6a7658c54846794cf97b774cbac2193))
|
||||
|
||||
|
||||
## v3.5.0 (2026-04-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Connect signals the correct way around
|
||||
([`f562c61`](https://github.com/bec-project/bec_widgets/commit/f562c61e3cec3387f6821bad74403beeb3436355))
|
||||
|
||||
- Create new bec shell if deleted
|
||||
([`1754e75`](https://github.com/bec-project/bec_widgets/commit/1754e759f0c59f2f4063f661bacd334127326947))
|
||||
|
||||
- Formatting in plugin template
|
||||
([`fa2ef83`](https://github.com/bec-project/bec_widgets/commit/fa2ef83bb9dfeeb4c5fc7cd77168c16101c32693))
|
||||
|
||||
- **bec_console**: Persistent bec session
|
||||
([`9b0ec9d`](https://github.com/bec-project/bec_widgets/commit/9b0ec9dd79ad1adc5d211dd703db7441da965f34))
|
||||
|
||||
### Features
|
||||
|
||||
- Add qtermwidget plugin and replace web term
|
||||
([`02cb393`](https://github.com/bec-project/bec_widgets/commit/02cb393bb086165dc64917b633d5570d02e1a2a9))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Code cleanup
|
||||
([`bda5d38`](https://github.com/bec-project/bec_widgets/commit/bda5d389651bb2b13734cd31159679e85b1bd583))
|
||||
|
||||
|
||||
## v3.4.4 (2026-04-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Check duplicate stream sub
|
||||
([`c7de320`](https://github.com/bec-project/bec_widgets/commit/c7de320ca564264a31b84931f553170f25659685))
|
||||
|
||||
- Check for duplicate subscriptions in GUIClient
|
||||
([`37747ba`](https://github.com/bec-project/bec_widgets/commit/37747babda407040333c6bd04646be9a49e0ee81))
|
||||
|
||||
- Make gui client registry callback non static
|
||||
([`32f5d48`](https://github.com/bec-project/bec_widgets/commit/32f5d486d3fc8d41df2668c58932ae982819b285))
|
||||
|
||||
- Remove staticmethod subscription
|
||||
([`0ff1fdc`](https://github.com/bec-project/bec_widgets/commit/0ff1fdc81578eec3ffc5d4030fca7b357a0b4c2f))
|
||||
|
||||
|
||||
## v3.4.3 (2026-04-13)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Set OPHYD_CONTROL_LAYER to dummy for tests
|
||||
([`5e84d3b`](https://github.com/bec-project/bec_widgets/commit/5e84d3bec608ae9f2ee6dae67db2e3e1387b1f59))
|
||||
|
||||
|
||||
## v3.4.2 (2026-04-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Allow admin user to pass deployment group check
|
||||
([`e6c8cd0`](https://github.com/bec-project/bec_widgets/commit/e6c8cd0b1a1162302071c93a2ac51880b3cf1b7d))
|
||||
|
||||
- **bec-atlas-admin-view**: Fix atlas_url to bec-atlas-prod.psi.ch
|
||||
([`242f893`](https://github.com/bec-project/bec_widgets/commit/242f8933b246802f5f3a5b9df7de07901f151c82))
|
||||
|
||||
### Testing
|
||||
|
||||
- Add tests for admin access
|
||||
([`2dab16b`](https://github.com/bec-project/bec_widgets/commit/2dab16b68415806f3f291657f394bb2d8654229d))
|
||||
|
||||
|
||||
## v3.4.1 (2026-04-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **hover_widget**: Make it fancy + mouse tracking
|
||||
([`e25b660`](https://github.com/bec-project/bec_widgets/commit/e25b6604d195804bbd6ea6aac395d44dc00d6155))
|
||||
|
||||
- **ring**: Changed inheritance to BECWidget and added cleanup
|
||||
([`2f75aae`](https://github.com/bec-project/bec_widgets/commit/2f75aaea16a178e180e68d702cd1bdf85a768bcf))
|
||||
|
||||
- **ring**: Hook update hover to update method
|
||||
([`90ecd8e`](https://github.com/bec-project/bec_widgets/commit/90ecd8ea87faf06c3f545e3f9241f403b733d7eb))
|
||||
|
||||
- **ring**: Minor general fixes
|
||||
([`6775509`](https://github.com/bec-project/bec_widgets/commit/677550931b28fbf35fd55880bf6e001f7351b99b))
|
||||
|
||||
- **ring_progress_bar**: Added hover mouse effect
|
||||
([`96b5179`](https://github.com/bec-project/bec_widgets/commit/96b5179658c41fb39df7a40f4d96e82092605791))
|
||||
|
||||
### Testing
|
||||
|
||||
- **ring_progress_bar**: Add unit tests for hover behavior
|
||||
([`6e5f6e7`](https://github.com/bec-project/bec_widgets/commit/6e5f6e7fbb6f9680f6d026e105e6840d24c6591c))
|
||||
|
||||
|
||||
## v3.4.0 (2026-03-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+12
-18
@@ -1,19 +1,13 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
|
||||
if qt_platform != "offscreen":
|
||||
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||||
|
||||
# Default QtAds configuration
|
||||
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
|
||||
QtAds.CDockManager.setConfigFlag(
|
||||
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
|
||||
)
|
||||
|
||||
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name == "BECWidget":
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
return BECWidget
|
||||
if name in {"SafeSlot", "SafeProperty"}:
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
return {"SafeSlot": SafeSlot, "SafeProperty": SafeProperty}[name]
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
|
||||
if qt_platform != "offscreen":
|
||||
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||||
|
||||
# Default QtAds configuration
|
||||
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
|
||||
QtAds.CDockManager.setConfigFlag(
|
||||
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
|
||||
)
|
||||
|
||||
@@ -19,8 +19,8 @@ from qtpy.QtWidgets import QApplication
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.applications.launch_window import LaunchWindow
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.rpc_register import RPCRegister
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -20,13 +20,13 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.utils.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.screen_utils import apply_window_geometry, centered_geometry_for_app
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
|
||||
@@ -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_())
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from bec_widgets.cli.rpc import rpc_base
|
||||
|
||||
+151
-52
@@ -13,7 +13,7 @@ from typing import Literal, Optional
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module
|
||||
from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -32,6 +32,7 @@ _Widgets = {
|
||||
"BECQueue": "BECQueue",
|
||||
"BECShell": "BECShell",
|
||||
"BECStatusBox": "BECStatusBox",
|
||||
"BecConsole": "BecConsole",
|
||||
"DapComboBox": "DapComboBox",
|
||||
"DeviceBrowser": "DeviceBrowser",
|
||||
"Heatmap": "Heatmap",
|
||||
@@ -56,35 +57,24 @@ _Widgets = {
|
||||
"SignalLabel": "SignalLabel",
|
||||
"TextBox": "TextBox",
|
||||
"Waveform": "Waveform",
|
||||
"WebConsole": "WebConsole",
|
||||
"WebsiteWidget": "WebsiteWidget",
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
||||
plugin_client = get_plugin_client_module()
|
||||
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
||||
|
||||
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
|
||||
for _widget in _overlap:
|
||||
logger.warning(
|
||||
f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !"
|
||||
)
|
||||
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
||||
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
||||
if plugin_name not in _Widgets:
|
||||
_Widgets[plugin_name] = plugin_name
|
||||
if plugin_name in globals():
|
||||
conflicting_file = (
|
||||
inspect.getfile(_plugin_widgets[plugin_name])
|
||||
if plugin_name in _plugin_widgets
|
||||
else f"{plugin_client}"
|
||||
)
|
||||
logger.warning(
|
||||
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
|
||||
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
|
||||
)
|
||||
continue
|
||||
if plugin_name not in _overlap:
|
||||
else:
|
||||
globals()[plugin_name] = plugin_class
|
||||
Widgets = _WidgetsEnumType("Widgets", _Widgets)
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
|
||||
|
||||
@@ -92,6 +82,8 @@ except ImportError as e:
|
||||
class AdminView(RPCBase):
|
||||
"""A view for administrators to change the current active experiment, manage messaging"""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.admin_view.admin_view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -100,6 +92,8 @@ class AdminView(RPCBase):
|
||||
|
||||
|
||||
class AutoUpdates(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.containers.auto_update.auto_updates"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enabled(self) -> "bool":
|
||||
@@ -136,6 +130,8 @@ class AutoUpdates(RPCBase):
|
||||
|
||||
|
||||
class AvailableDeviceResources(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -156,6 +152,8 @@ class AvailableDeviceResources(RPCBase):
|
||||
|
||||
|
||||
class BECDockArea(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.dock_area"
|
||||
|
||||
@rpc_call
|
||||
def new(
|
||||
self,
|
||||
@@ -391,6 +389,8 @@ class BECDockArea(RPCBase):
|
||||
|
||||
|
||||
class BECMainWindow(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.containers.main_window.main_window"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -413,6 +413,8 @@ class BECMainWindow(RPCBase):
|
||||
class BECProgressBar(RPCBase):
|
||||
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.progress.bec_progressbar.bec_progressbar"
|
||||
|
||||
@rpc_call
|
||||
def set_value(self, value):
|
||||
"""
|
||||
@@ -486,6 +488,8 @@ class BECProgressBar(RPCBase):
|
||||
class BECQueue(RPCBase):
|
||||
"""Widget to display the BEC queue."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_queue.bec_queue"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -506,7 +510,9 @@ class BECQueue(RPCBase):
|
||||
|
||||
|
||||
class BECShell(RPCBase):
|
||||
"""A WebConsole pre-configured to run the BEC shell."""
|
||||
"""A BecConsole pre-configured to run the BEC shell."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
@@ -530,6 +536,8 @@ class BECShell(RPCBase):
|
||||
class BECStatusBox(RPCBase):
|
||||
"""An autonomous widget to display the status of BEC services."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_status_box.bec_status_box"
|
||||
|
||||
@rpc_call
|
||||
def get_server_state(self) -> "str":
|
||||
"""
|
||||
@@ -565,6 +573,8 @@ class BECStatusBox(RPCBase):
|
||||
class BaseROI(RPCBase):
|
||||
"""Base class for all Region of Interest (ROI) implementations."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
@@ -691,9 +701,35 @@ class BaseROI(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class BecConsole(RPCBase):
|
||||
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class CircularROI(RPCBase):
|
||||
"""Circular Region of Interest with center/diameter tracking and auto-labeling."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
@@ -821,6 +857,8 @@ class CircularROI(RPCBase):
|
||||
|
||||
|
||||
class Curve(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.curve"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -987,6 +1025,8 @@ class Curve(RPCBase):
|
||||
class DapComboBox(RPCBase):
|
||||
"""Editable combobox listing the available DAP models."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.dap.dap_combo_box.dap_combo_box"
|
||||
|
||||
@rpc_call
|
||||
def select_y_axis(self, y_axis: str):
|
||||
"""
|
||||
@@ -1018,6 +1058,8 @@ class DapComboBox(RPCBase):
|
||||
class DeveloperView(RPCBase):
|
||||
"""A view for users to write scripts and macros and execute them within the application."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.developer_view.developer_view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -1028,6 +1070,8 @@ class DeveloperView(RPCBase):
|
||||
class DeviceBrowser(RPCBase):
|
||||
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.services.device_browser.device_browser"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -1050,6 +1094,8 @@ class DeviceBrowser(RPCBase):
|
||||
class DeviceInitializationProgressBar(RPCBase):
|
||||
"""A progress bar that displays the progress of device initialization."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -1072,6 +1118,8 @@ class DeviceInitializationProgressBar(RPCBase):
|
||||
class DeviceInputBase(RPCBase):
|
||||
"""Mixin base class for device input widgets."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.device_input.base_classes.device_input_base"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -1094,6 +1142,8 @@ class DeviceInputBase(RPCBase):
|
||||
class DeviceManagerView(RPCBase):
|
||||
"""A view for users to manage devices within the application."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.device_manager_view.device_manager_view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -1104,6 +1154,8 @@ class DeviceManagerView(RPCBase):
|
||||
class DockAreaView(RPCBase):
|
||||
"""Modular dock area view for arranging and managing multiple dockable widgets."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.dock_area_view.dock_area_view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -1347,6 +1399,8 @@ class DockAreaView(RPCBase):
|
||||
class DockAreaWidget(RPCBase):
|
||||
"""Lightweight dock area that exposes the core Qt ADS docking helpers without any"""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.basic_dock_area"
|
||||
|
||||
@rpc_call
|
||||
def new(
|
||||
self,
|
||||
@@ -1531,6 +1585,8 @@ class DockAreaWidget(RPCBase):
|
||||
class EllipticalROI(RPCBase):
|
||||
"""Elliptical Region of Interest with centre/width/height tracking and auto-labelling."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
@@ -1653,6 +1709,8 @@ class EllipticalROI(RPCBase):
|
||||
class Heatmap(RPCBase):
|
||||
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.heatmap.heatmap"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -2351,6 +2409,8 @@ class Heatmap(RPCBase):
|
||||
class Image(RPCBase):
|
||||
"""Image widget for displaying 2D data."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -2962,6 +3022,8 @@ class Image(RPCBase):
|
||||
|
||||
|
||||
class ImageItem(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image_item"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def color_map(self) -> "str":
|
||||
@@ -3112,6 +3174,8 @@ class ImageItem(RPCBase):
|
||||
|
||||
|
||||
class LaunchWindow(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.applications.launch_window"
|
||||
|
||||
@rpc_call
|
||||
def show_launcher(self):
|
||||
"""
|
||||
@@ -3126,33 +3190,38 @@ class LaunchWindow(RPCBase):
|
||||
|
||||
|
||||
class LogPanel(RPCBase):
|
||||
"""Displays a log panel"""
|
||||
"""Live display of the BEC logs in a table view."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.utility.logpanel.logpanel"
|
||||
|
||||
@rpc_call
|
||||
def set_plain_text(self, text: str) -> None:
|
||||
def remove(self):
|
||||
"""
|
||||
Set the plain text of the widget.
|
||||
|
||||
Args:
|
||||
text (str): The text to set.
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_html_text(self, text: str) -> None:
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
Set the HTML text of the widget.
|
||||
|
||||
Args:
|
||||
text (str): The text to set.
|
||||
@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 Minesweeper(RPCBase): ...
|
||||
class Minesweeper(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.games.minesweeper"
|
||||
|
||||
|
||||
class MonacoDock(RPCBase):
|
||||
"""MonacoDock is a dock widget that contains Monaco editor instances."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_dock"
|
||||
|
||||
@rpc_call
|
||||
def new(
|
||||
self,
|
||||
@@ -3337,6 +3406,8 @@ class MonacoDock(RPCBase):
|
||||
class MonacoWidget(RPCBase):
|
||||
"""A simple Monaco editor widget"""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_widget"
|
||||
|
||||
@rpc_call
|
||||
def set_text(
|
||||
self, text: "str", file_name: "str | None" = None, reset: "bool" = False
|
||||
@@ -3511,6 +3582,8 @@ class MonacoWidget(RPCBase):
|
||||
class MotorMap(RPCBase):
|
||||
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.motor_map.motor_map"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -3981,6 +4054,8 @@ class MotorMap(RPCBase):
|
||||
class MultiWaveform(RPCBase):
|
||||
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.multi_waveform.multi_waveform"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -4440,6 +4515,8 @@ class MultiWaveform(RPCBase):
|
||||
class PdfViewerWidget(RPCBase):
|
||||
"""A widget to display PDF documents with toolbar controls."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.utility.pdf_viewer.pdf_viewer"
|
||||
|
||||
@rpc_call
|
||||
def load_pdf(self, file_path: str):
|
||||
"""
|
||||
@@ -4571,6 +4648,10 @@ class PdfViewerWidget(RPCBase):
|
||||
class PositionIndicator(RPCBase):
|
||||
"""Display a position within a defined range, e.g. motor limits."""
|
||||
|
||||
_IMPORT_MODULE = (
|
||||
"bec_widgets.widgets.control.device_control.position_indicator.position_indicator"
|
||||
)
|
||||
|
||||
@rpc_call
|
||||
def set_value(self, position: float):
|
||||
"""
|
||||
@@ -4636,6 +4717,10 @@ class PositionIndicator(RPCBase):
|
||||
class PositionerBox(RPCBase):
|
||||
"""Simple Widget to control a positioner in box form"""
|
||||
|
||||
_IMPORT_MODULE = (
|
||||
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box"
|
||||
)
|
||||
|
||||
@rpc_call
|
||||
def set_positioner(self, positioner: "str | Positioner"):
|
||||
"""
|
||||
@@ -4668,6 +4753,8 @@ class PositionerBox(RPCBase):
|
||||
class PositionerBox2D(RPCBase):
|
||||
"""Simple Widget to control two positioners in box form"""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d"
|
||||
|
||||
@rpc_call
|
||||
def set_positioner_hor(self, positioner: "str | Positioner"):
|
||||
"""
|
||||
@@ -4737,6 +4824,8 @@ class PositionerBox2D(RPCBase):
|
||||
class PositionerControlLine(RPCBase):
|
||||
"""A widget that controls a single device."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line"
|
||||
|
||||
@rpc_call
|
||||
def set_positioner(self, positioner: "str | Positioner"):
|
||||
"""
|
||||
@@ -4769,6 +4858,8 @@ class PositionerControlLine(RPCBase):
|
||||
class PositionerGroup(RPCBase):
|
||||
"""Simple Widget to control a positioner in box form"""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_group.positioner_group"
|
||||
|
||||
@rpc_call
|
||||
def set_positioners(self, device_names: "str"):
|
||||
"""
|
||||
@@ -4800,6 +4891,8 @@ class PositionerGroup(RPCBase):
|
||||
class RectangularROI(RPCBase):
|
||||
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
@@ -4929,6 +5022,8 @@ class RectangularROI(RPCBase):
|
||||
class ResumeButton(RPCBase):
|
||||
"""A button that continue scan queue."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.buttons.button_resume.button_resume"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -4949,6 +5044,8 @@ class ResumeButton(RPCBase):
|
||||
|
||||
|
||||
class Ring(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring"
|
||||
|
||||
@rpc_call
|
||||
def set_value(self, value: "int | float"):
|
||||
"""
|
||||
@@ -5042,6 +5139,8 @@ class Ring(RPCBase):
|
||||
|
||||
|
||||
class RingProgressBar(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -5121,12 +5220,14 @@ class RingProgressBar(RPCBase):
|
||||
class SBBMonitor(RPCBase):
|
||||
"""A widget to display the SBB monitor website."""
|
||||
|
||||
...
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.sbb_monitor.sbb_monitor"
|
||||
|
||||
|
||||
class ScanControl(RPCBase):
|
||||
"""Widget to submit new scans to the queue."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.scan_control.scan_control"
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
@@ -5150,6 +5251,8 @@ class ScanControl(RPCBase):
|
||||
class ScanProgressBar(RPCBase):
|
||||
"""Widget to display a progress bar that is hooked up to the scan progress of a scan."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.progress.scan_progressbar.scan_progressbar"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -5172,6 +5275,8 @@ class ScanProgressBar(RPCBase):
|
||||
class ScatterCurve(RPCBase):
|
||||
"""Scatter curve item for the scatter waveform widget."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_curve"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def color_map(self) -> "str":
|
||||
@@ -5181,6 +5286,8 @@ class ScatterCurve(RPCBase):
|
||||
|
||||
|
||||
class ScatterWaveform(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_waveform"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -5648,6 +5755,8 @@ class ScatterWaveform(RPCBase):
|
||||
|
||||
|
||||
class SignalLabel(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.utility.signal_label.signal_label"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def custom_label(self) -> "str":
|
||||
@@ -5792,6 +5901,8 @@ class SignalLabel(RPCBase):
|
||||
class TextBox(RPCBase):
|
||||
"""A widget that displays text in plain and HTML format"""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.text_box.text_box"
|
||||
|
||||
@rpc_call
|
||||
def set_plain_text(self, text: str) -> None:
|
||||
"""
|
||||
@@ -5814,6 +5925,8 @@ class TextBox(RPCBase):
|
||||
class ViewBase(RPCBase):
|
||||
"""Wrapper for a content widget used inside the main app's stacked view."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -5824,6 +5937,8 @@ class ViewBase(RPCBase):
|
||||
class Waveform(RPCBase):
|
||||
"""Widget for plotting waveforms."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.waveform"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -6402,6 +6517,8 @@ class Waveform(RPCBase):
|
||||
|
||||
|
||||
class WaveformViewInline(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -6410,6 +6527,8 @@ class WaveformViewInline(RPCBase):
|
||||
|
||||
|
||||
class WaveformViewPopup(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -6417,31 +6536,11 @@ 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"""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.website.website"
|
||||
|
||||
@rpc_call
|
||||
def set_url(self, url: str) -> None:
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -25,8 +25,8 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
|
||||
|
||||
@@ -1,13 +1 @@
|
||||
from qtpy.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from .bec_connector import BECConnector, ConnectionConfig
|
||||
from .bec_dispatcher import BECDispatcher
|
||||
from .bec_table import BECTable
|
||||
from .colors import Colors
|
||||
from .container_utils import WidgetContainerUtils
|
||||
from .crosshair import Crosshair
|
||||
from .entry_validator import EntryValidator
|
||||
from .layout_manager import GridLayoutManager
|
||||
from .rpc_decorator import register_rpc_methods, rpc_public
|
||||
from .ui_loader import UILoader
|
||||
from .validator_delegate import DoubleValidationDelegate
|
||||
|
||||
@@ -15,9 +15,9 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
|
||||
from bec_widgets.utils.name_utils import sanitize_namespace
|
||||
from bec_widgets.utils.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -10,11 +10,11 @@ from qtpy.QtGui import QFont, QPixmap
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.busy_loader import install_busy_loader
|
||||
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
from bec_widgets.utils.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import inspect
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import get_overloads
|
||||
|
||||
import black
|
||||
import isort
|
||||
@@ -18,20 +19,6 @@ from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import get_overloads
|
||||
else:
|
||||
print(
|
||||
"Python version is less than 3.11, using dummy function for get_overloads. "
|
||||
"If you want to use the real function 'typing.get_overloads()', please use Python 3.11 or later."
|
||||
)
|
||||
|
||||
def get_overloads(_obj):
|
||||
"""
|
||||
Dummy function for Python versions before 3.11.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
class ClientGenerator:
|
||||
def __init__(self, base=False):
|
||||
@@ -54,7 +41,7 @@ from __future__ import annotations
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
||||
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
|
||||
{"from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module" if self._base else ""}
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -111,27 +98,19 @@ _Widgets = {
|
||||
self.content += """
|
||||
|
||||
try:
|
||||
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
||||
plugin_client = get_plugin_client_module()
|
||||
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
||||
|
||||
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
|
||||
for _widget in _overlap:
|
||||
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
|
||||
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
||||
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
||||
if plugin_name not in _Widgets:
|
||||
_Widgets[plugin_name] = plugin_name
|
||||
if plugin_name in globals():
|
||||
conflicting_file = (
|
||||
inspect.getfile(_plugin_widgets[plugin_name])
|
||||
if plugin_name in _plugin_widgets
|
||||
else f"{plugin_client}"
|
||||
)
|
||||
logger.warning(
|
||||
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
|
||||
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
|
||||
)
|
||||
continue
|
||||
if plugin_name not in _overlap:
|
||||
else:
|
||||
globals()[plugin_name] = plugin_class
|
||||
Widgets = _WidgetsEnumType("Widgets", _Widgets)
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
|
||||
"""
|
||||
@@ -146,12 +125,8 @@ except ImportError as e:
|
||||
|
||||
class_name = cls.__name__
|
||||
|
||||
if class_name == "BECDockArea":
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
else:
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):\n"""
|
||||
|
||||
if cls.__doc__:
|
||||
# We only want the first line of the docstring
|
||||
@@ -162,13 +137,9 @@ class {class_name}(RPCBase):"""
|
||||
else:
|
||||
class_docs = cls.__doc__.split("\n")[1]
|
||||
self.content += f"""
|
||||
\"\"\"{class_docs}\"\"\"
|
||||
"""
|
||||
\"\"\"{class_docs}\"\"\"\n"""
|
||||
user_access_entries = self._get_user_access_entries(cls)
|
||||
if not user_access_entries:
|
||||
self.content += """...
|
||||
"""
|
||||
|
||||
self.content += f' _IMPORT_MODULE="{cls.__module__}"\n'
|
||||
for method_entry in user_access_entries:
|
||||
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
|
||||
if obj is None:
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Iterable
|
||||
from bec_lib.plugin_helper import _get_available_plugins
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
|
||||
@@ -14,11 +14,11 @@ from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.utils.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.screen_utils import apply_window_geometry
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
|
||||
|
||||
@@ -26,7 +26,7 @@ from qtpy.QtWidgets import (
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -418,7 +418,7 @@ class WidgetHierarchy:
|
||||
only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget.
|
||||
show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget.
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
for node in WidgetHierarchy.iter_widget_tree(
|
||||
@@ -468,7 +468,7 @@ class WidgetHierarchy:
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
|
||||
# 1) Gather ALL QWidget-based BECConnector objects
|
||||
@@ -534,7 +534,7 @@ class WidgetHierarchy:
|
||||
Returns:
|
||||
The nearest ancestor that is a BECConnector, or None if not found.
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
|
||||
# Guard against deleted/invalid Qt wrappers
|
||||
if not shb.isValid(widget):
|
||||
@@ -636,7 +636,7 @@ class WidgetHierarchy:
|
||||
Return all BECConnector instances whose closest BECConnector ancestor is the given widget,
|
||||
including the widget itself if it is a BECConnector.
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
|
||||
connectors: list[BECConnector] = []
|
||||
if isinstance(widget, BECConnector):
|
||||
@@ -664,7 +664,7 @@ class WidgetHierarchy:
|
||||
return None
|
||||
|
||||
try:
|
||||
from bec_widgets.utils import BECConnector # local import to avoid cycles
|
||||
from bec_widgets.utils.bec_connector import BECConnector # local import to avoid cycles
|
||||
|
||||
is_bec_target = False
|
||||
if isinstance(ancestor_class, str):
|
||||
|
||||
@@ -13,9 +13,9 @@ from shiboken6 import isValid
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets import BECWidget, SafeSlot
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.property_editor import PropertyEditor
|
||||
from bec_widgets.utils.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.widgets.containers.qt_ads import (
|
||||
CDockAreaWidget,
|
||||
|
||||
@@ -20,10 +20,10 @@ from qtpy.QtWidgets import (
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
from bec_widgets.applications.views.view import ViewTourSteps
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
from bec_widgets.utils.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
ExpandableMenuAction,
|
||||
MaterialIconAction,
|
||||
@@ -69,7 +69,7 @@ from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||
from bec_widgets.widgets.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
|
||||
@@ -79,7 +79,7 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||
from bec_widgets.widgets.utility.logpanel import LogPanel
|
||||
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -372,10 +372,11 @@ 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"),
|
||||
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel", "LogPanel"),
|
||||
}
|
||||
|
||||
# Create expandable menu actions (original behavior)
|
||||
@@ -487,9 +488,7 @@ class BECDockArea(DockAreaWidget):
|
||||
# first two items not needed for this part
|
||||
for key, (_, _, widget_type) in mapping.items():
|
||||
act = menu.actions[key].action
|
||||
if widget_type == "LogPanel":
|
||||
act.setEnabled(False) # keep disabled per issue #644
|
||||
elif key == "terminal":
|
||||
if key == "terminal":
|
||||
act.triggered.connect(
|
||||
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
|
||||
)
|
||||
@@ -510,10 +509,7 @@ class BECDockArea(DockAreaWidget):
|
||||
for action_id, (_, _, widget_type) in mapping.items():
|
||||
flat_action_id = f"flat_{action_id}"
|
||||
flat_action = self.toolbar.components.get_action(flat_action_id).action
|
||||
if widget_type == "LogPanel":
|
||||
flat_action.setEnabled(False) # keep disabled per issue #644
|
||||
else:
|
||||
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
|
||||
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
|
||||
|
||||
_connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"])
|
||||
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])
|
||||
|
||||
@@ -22,7 +22,7 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
from typeguard import typechecked
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils.rpc_widget_handler import widget_handler
|
||||
|
||||
|
||||
class LayoutManagerWidget(QWidget):
|
||||
|
||||
@@ -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
-1
@@ -28,7 +28,7 @@ from qtpy.QtCore import QObject, QTimer
|
||||
from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidget
|
||||
|
||||
from bec_widgets import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
|
||||
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
|
||||
BECNotificationBroker,
|
||||
|
||||
+1
-1
@@ -11,9 +11,9 @@ from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
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.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
|
||||
DeviceUpdateUIComponents,
|
||||
PositionerBoxBase,
|
||||
|
||||
+1
-1
@@ -12,9 +12,9 @@ from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
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.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
|
||||
DeviceUpdateUIComponents,
|
||||
PositionerBoxBase,
|
||||
|
||||
@@ -7,7 +7,7 @@ from bec_lib.device import Signal as BECSignal
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import field_validator
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.filter_io import FilterIO
|
||||
|
||||
@@ -3,7 +3,7 @@ from bec_lib.device import Signal
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.filter_io import FilterIO
|
||||
|
||||
@@ -19,7 +19,7 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
@@ -5,10 +5,10 @@ from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QPushButton, QSizePolicy, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@@ -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']}
|
||||
+9
-9
@@ -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']}
|
||||
+1
-1
@@ -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++'>
|
||||
+2
-2
@@ -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
|
||||
+1
-1
@@ -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']}
|
||||
@@ -19,8 +19,8 @@ from scipy.interpolate import (
|
||||
from scipy.spatial import cKDTree
|
||||
from toolz import partition
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
|
||||
@@ -4,9 +4,9 @@ import os
|
||||
|
||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
|
||||
|
||||
class HeatmapSettings(SettingWidget):
|
||||
|
||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors, apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
from qtpy.QtCore import QPointF, Signal, SignalInstance
|
||||
from qtpy.QtWidgets import QDialog, QVBoxLayout
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.side_panel import SidePanel
|
||||
|
||||
@@ -9,7 +9,8 @@ from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QTransform
|
||||
|
||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.widgets.plots.image.image_processor import (
|
||||
ImageProcessor,
|
||||
ImageStats,
|
||||
|
||||
@@ -20,7 +20,8 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils import BECDispatcher, ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||
|
||||
@@ -10,8 +10,8 @@ from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors, apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
|
||||
@@ -2,9 +2,9 @@ import os
|
||||
|
||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.side_panel import SidePanel
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
@@ -2,9 +2,9 @@ import os
|
||||
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ from bec_lib import bec_logger
|
||||
from qtpy.QtCore import QPoint, QPointF, Qt, Signal
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig, Crosshair, EntryValidator
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.crosshair import Crosshair
|
||||
from bec_widgets.utils.entry_validator import EntryValidator
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.fps_counter import FPSCounter
|
||||
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
|
||||
|
||||
@@ -10,7 +10,7 @@ from qtpy import QtCore
|
||||
from qtpy.QtCore import QObject, Signal
|
||||
|
||||
from bec_widgets import SafeProperty
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -8,7 +8,8 @@ from bec_lib import bec_logger
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
from qtpy import QtCore
|
||||
|
||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
|
||||
@@ -7,7 +7,8 @@ from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
|
||||
@@ -2,9 +2,9 @@ import os
|
||||
|
||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
|
||||
|
||||
class ScatterCurveSettings(SettingWidget):
|
||||
|
||||
@@ -2,9 +2,9 @@ import os
|
||||
|
||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ from bec_lib import bec_logger
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy import QtCore
|
||||
|
||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
@@ -50,9 +50,10 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
from bec_widgets import SafeSlot
|
||||
from bec_widgets.utils import ConnectionConfig, EntryValidator
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.entry_validator import EntryValidator
|
||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||
|
||||
@@ -25,7 +25,7 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
||||
from bec_widgets.utils.colors import Colors, apply_theme
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
|
||||
@@ -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]]:
|
||||
"""
|
||||
@@ -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,15 +3,16 @@ 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
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.utils.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()
|
||||
|
||||
@@ -19,9 +19,9 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
|
||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -251,7 +251,7 @@ class BECAtlasAdminView(BECWidget, QWidget):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
atlas_url: str = "https://bec-atlas-dev.psi.ch/api/v1",
|
||||
atlas_url: str = "https://bec-atlas-prod.psi.ch/api/v1",
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
|
||||
@@ -142,6 +142,17 @@ class BECAtlasHTTPService(QWidget):
|
||||
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
|
||||
@@ -231,9 +242,7 @@ class BECAtlasHTTPService(QWidget):
|
||||
)
|
||||
elif AtlasEndpoints.DEPLOYMENT_INFO.value in request_url:
|
||||
owner_groups = data.get("owner_groups", [])
|
||||
if self.auth_user_info is not None and not self.auth_user_info.groups.isdisjoint(
|
||||
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:
|
||||
|
||||
@@ -12,10 +12,10 @@ from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
|
||||
from bec_widgets.utils.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ class ScanHistoryMetadataViewer(BECWidget, QtWidgets.QGroupBox):
|
||||
"start_time": "Start Time",
|
||||
"end_time": "End Time",
|
||||
"elapsed_time": "Elapsed Time",
|
||||
"status": "Status",
|
||||
"exit_status": "Status",
|
||||
"scan_name": "Scan Name",
|
||||
"num_points": "Nr of Points",
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
@@ -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): ...
|
||||
@@ -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: ...
|
||||
@@ -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
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Utilities for filtering and formatting in the LogPanel"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import deque
|
||||
from typing import Callable, Iterator
|
||||
|
||||
from bec_lib.logger import LogLevel
|
||||
from bec_lib.messages import LogMessage
|
||||
from qtpy.QtCore import QDateTime
|
||||
|
||||
LinesHtmlFormatter = Callable[[deque[LogMessage]], Iterator[str]]
|
||||
LineFormatter = Callable[[LogMessage], str]
|
||||
LineFilter = Callable[[LogMessage], bool] | None
|
||||
|
||||
ANSI_ESCAPE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
||||
|
||||
|
||||
def replace_escapes(s: str):
|
||||
s = ANSI_ESCAPE_REGEX.sub("", s)
|
||||
return s.replace(" ", " ").replace("\n", "<br />").replace("\t", " ")
|
||||
|
||||
|
||||
def level_filter(msg: LogMessage, thresh: int):
|
||||
return LogLevel[msg.content["log_type"].upper()].value >= thresh
|
||||
|
||||
|
||||
def noop_format(line: LogMessage):
|
||||
_textline = line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
|
||||
return replace_escapes(_textline.strip()) + "<br />"
|
||||
|
||||
|
||||
def simple_color_format(line: LogMessage, colors: dict[LogLevel, str]):
|
||||
color = colors.get(LogLevel[line.content["log_type"].upper()]) or colors[LogLevel.INFO]
|
||||
return f'<font color="{color}">{noop_format(line)}</font>'
|
||||
|
||||
|
||||
def create_formatter(line_format: LineFormatter, line_filter: LineFilter) -> LinesHtmlFormatter:
|
||||
def _formatter(data: deque[LogMessage]):
|
||||
if line_filter is not None:
|
||||
return (line_format(line) for line in data if line_filter(line))
|
||||
else:
|
||||
return (line_format(line) for line in data)
|
||||
|
||||
return _formatter
|
||||
|
||||
|
||||
def log_txt(line):
|
||||
return line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
|
||||
|
||||
|
||||
def log_time(line):
|
||||
return QDateTime.fromMSecsSinceEpoch(int(line.log_msg["record"]["time"]["timestamp"] * 1000))
|
||||
|
||||
|
||||
def log_svc(line):
|
||||
return line.log_msg["service_name"]
|
||||
@@ -30,7 +30,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Services"
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(LogPanel.ICON_NAME)
|
||||
@@ -51,7 +51,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return "LogPanel"
|
||||
|
||||
def toolTip(self):
|
||||
return "Displays a log panel"
|
||||
return "LogPanel"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
|
||||
@@ -2,21 +2,31 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
import os
|
||||
import re
|
||||
from collections import deque
|
||||
from functools import partial, reduce
|
||||
from re import Pattern
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Iterable, Literal
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import LogLevel, bec_logger
|
||||
from bec_lib.messages import LogMessage, StatusMessage
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QDateTime, QObject, Qt, Signal
|
||||
from qtpy.QtGui import QFont
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtCore import (
|
||||
QAbstractTableModel,
|
||||
QCoreApplication,
|
||||
QDateTime,
|
||||
QModelIndex,
|
||||
QObject,
|
||||
QPersistentModelIndex,
|
||||
QSize,
|
||||
QSortFilterProxyModel,
|
||||
Qt,
|
||||
QTimer,
|
||||
)
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
@@ -25,204 +35,414 @@ from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QTextEdit,
|
||||
QSizePolicy,
|
||||
QTableView,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from thefuzz import fuzz
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.colors import apply_theme, get_theme_palette
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.editors.text_box.text_box import TextBox
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECServiceStatusMixin
|
||||
from bec_widgets.widgets.utility.logpanel._util import (
|
||||
LineFilter,
|
||||
LineFormatter,
|
||||
LinesHtmlFormatter,
|
||||
create_formatter,
|
||||
level_filter,
|
||||
log_svc,
|
||||
log_time,
|
||||
log_txt,
|
||||
noop_format,
|
||||
simple_color_format,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtCore import SignalInstance
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# TODO: improve log color handling
|
||||
DEFAULT_LOG_COLORS = {
|
||||
LogLevel.INFO: "#FFFFFF",
|
||||
LogLevel.SUCCESS: "#00FF00",
|
||||
LogLevel.WARNING: "#FFCC00",
|
||||
LogLevel.ERROR: "#FF0000",
|
||||
LogLevel.DEBUG: "#0000CC",
|
||||
_DEFAULT_LOG_COLORS = {
|
||||
LogLevel.INFO.name: QColor("#FFFFFF"),
|
||||
LogLevel.SUCCESS.name: QColor("#00FF00"),
|
||||
LogLevel.WARNING.name: QColor("#FFCC00"),
|
||||
LogLevel.ERROR.name: QColor("#FF0000"),
|
||||
LogLevel.DEBUG.name: QColor("#0000CC"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Constants:
|
||||
FUZZ_THRESHOLD = 80
|
||||
UPDATE_INTERVAL_MS = 200
|
||||
headers = ["level", "timestamp", "service_name", "message", "function"]
|
||||
|
||||
|
||||
_CONST = _Constants()
|
||||
|
||||
|
||||
class TimestampUpdate:
|
||||
def __init__(self, value: QDateTime | None, update_type: Literal["start", "end"]) -> None:
|
||||
self.value = value
|
||||
self.update_type = update_type
|
||||
|
||||
|
||||
class BecLogsQueue(BECConnector, QObject):
|
||||
"""Manages getting logs from BEC Redis and formatting them for display"""
|
||||
|
||||
RPC = False
|
||||
new_message = Signal()
|
||||
new_messages = Signal()
|
||||
paused = Signal(bool)
|
||||
_instance: BecLogsQueue | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QObject | None,
|
||||
maxlen: int = 1000,
|
||||
line_formatter: LineFormatter = noop_format,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
@classmethod
|
||||
def instance(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = cls(QCoreApplication.instance())
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, parent: QObject | None, maxlen: int = 2500, **kwargs) -> None:
|
||||
if BecLogsQueue._instance:
|
||||
raise RuntimeError("Create no more than one BecLogsQueue - use BecLogsQueue.instance()")
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._timestamp_start: QDateTime | None = None
|
||||
self._timestamp_end: QDateTime | None = None
|
||||
self._max_length = maxlen
|
||||
self._data: deque[LogMessage] = deque([], self._max_length)
|
||||
self._display_queue: deque[str] = deque([], self._max_length)
|
||||
self._log_level: str | None = None
|
||||
self._search_query: Pattern | str | None = None
|
||||
self._selected_services: set[str] | None = None
|
||||
self._set_formatter_and_update_filter(line_formatter)
|
||||
# instance attribute still accessible after c++ object is deleted, so the callback can be unregistered
|
||||
self._paused = False
|
||||
self._data = deque(
|
||||
(
|
||||
item["data"]
|
||||
for item in self.bec_dispatcher.client.connector.xread(
|
||||
MessageEndpoints.log(), count=self._max_length, id="0"
|
||||
)
|
||||
),
|
||||
maxlen=self._max_length,
|
||||
)
|
||||
self._incoming: deque[LogMessage] = deque([], maxlen=self._max_length)
|
||||
self.bec_dispatcher.connect_slot(self._process_incoming_log_msg, MessageEndpoints.log())
|
||||
|
||||
self._update_timer = QTimer(self, interval=_CONST.UPDATE_INTERVAL_MS)
|
||||
self._update_timer.timeout.connect(self._proc_update)
|
||||
QCoreApplication.instance().aboutToQuit.connect(self.cleanup) # type: ignore
|
||||
self._update_timer.start()
|
||||
|
||||
def __len__(self):
|
||||
return len(self._data)
|
||||
|
||||
@SafeSlot()
|
||||
def toggle_pause(self):
|
||||
self._paused = not self._paused
|
||||
self.paused.emit(self._paused)
|
||||
|
||||
def row_data(self, index: int) -> LogMessage | None:
|
||||
if index < 0 or index > (len(self._data) - 1):
|
||||
return None
|
||||
return self._data[index]
|
||||
|
||||
def cell_data(self, row: int, key: str):
|
||||
if key == "level":
|
||||
return self._data[row].log_type.upper()
|
||||
|
||||
msg_item = self._data[row].log_msg
|
||||
if isinstance(msg_item, str):
|
||||
return msg_item
|
||||
if key == "service_name":
|
||||
return msg_item.get(key)
|
||||
elif key in ["service_name", "function", "message"]:
|
||||
return msg_item.get("record", {}).get(key)
|
||||
elif key == "timestamp":
|
||||
return msg_item.get("record", {}).get("time", {}).get("repr")
|
||||
|
||||
def log_timestamp(self, row: int) -> float:
|
||||
msg_item = self._data[row].log_msg
|
||||
if isinstance(msg_item, str):
|
||||
return 0
|
||||
return msg_item.get("record", {}).get("time", {}).get("timestamp")
|
||||
|
||||
def cleanup(self, *_):
|
||||
"""Stop listening to the Redis log stream"""
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self._process_incoming_log_msg, [MessageEndpoints.log()]
|
||||
)
|
||||
self._update_timer.stop()
|
||||
BecLogsQueue._instance = None
|
||||
|
||||
@SafeSlot(verify_sender=True)
|
||||
def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
|
||||
try:
|
||||
_msg = LogMessage(**msg)
|
||||
self._data.append(_msg)
|
||||
if self.filter is None or self.filter(_msg):
|
||||
self._display_queue.append(self._line_formatter(_msg))
|
||||
self.new_message.emit()
|
||||
self._incoming.append(_msg)
|
||||
except Exception as e:
|
||||
if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
|
||||
return
|
||||
logger.warning(f"Error in LogPanel incoming message callback: {e}")
|
||||
|
||||
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
|
||||
self._line_formatter: LineFormatter = line_formatter
|
||||
self._queue_formatter: LinesHtmlFormatter = create_formatter(
|
||||
self._line_formatter, self.filter
|
||||
)
|
||||
|
||||
def _combine_filters(self, *args: LineFilter):
|
||||
return lambda msg: reduce(operator.and_, [filt(msg) for filt in args if filt is not None])
|
||||
|
||||
def _create_re_filter(self) -> LineFilter:
|
||||
if self._search_query is None:
|
||||
return None
|
||||
elif isinstance(self._search_query, str):
|
||||
return lambda line: self._search_query in log_txt(line)
|
||||
return lambda line: self._search_query.match(log_txt(line)) is not None
|
||||
|
||||
def _create_service_filter(self):
|
||||
return (
|
||||
lambda line: self._selected_services is None or log_svc(line) in self._selected_services
|
||||
)
|
||||
|
||||
def _create_timestamp_filter(self) -> LineFilter:
|
||||
s, e = self._timestamp_start, self._timestamp_end
|
||||
if s is e is None:
|
||||
return lambda msg: True
|
||||
|
||||
def _time_filter(msg):
|
||||
msg_time = log_time(msg)
|
||||
if s is None:
|
||||
return msg_time <= e
|
||||
if e is None:
|
||||
return s <= msg_time
|
||||
return s <= msg_time <= e
|
||||
|
||||
return _time_filter
|
||||
|
||||
@property
|
||||
def filter(self) -> LineFilter:
|
||||
"""A function which filters a log message based on all applied criteria"""
|
||||
thresh = LogLevel[self._log_level].value if self._log_level is not None else 0
|
||||
return self._combine_filters(
|
||||
partial(level_filter, thresh=thresh),
|
||||
self._create_re_filter(),
|
||||
self._create_timestamp_filter(),
|
||||
self._create_service_filter(),
|
||||
)
|
||||
|
||||
def update_level_filter(self, level: str):
|
||||
"""Change the log-level of the level filter"""
|
||||
if level not in [l.name for l in LogLevel]:
|
||||
logger.error(f"Logging level {level} unrecognized for filter!")
|
||||
@SafeSlot(verify_sender=True)
|
||||
def _proc_update(self):
|
||||
if self._paused or len(self._incoming) == 0:
|
||||
return
|
||||
self._log_level = level
|
||||
self._set_formatter_and_update_filter(self._line_formatter)
|
||||
self._data.extend(self._incoming)
|
||||
self._incoming.clear()
|
||||
self.new_messages.emit()
|
||||
|
||||
def update_search_filter(self, search_query: Pattern | str | None = None):
|
||||
"""Change the string or regex to filter against"""
|
||||
self._search_query = search_query
|
||||
self._set_formatter_and_update_filter(self._line_formatter)
|
||||
|
||||
def update_time_filter(self, start: QDateTime | None, end: QDateTime | None):
|
||||
"""Change the start and/or end times to filter against"""
|
||||
self._timestamp_start = start
|
||||
self._timestamp_end = end
|
||||
self._set_formatter_and_update_filter(self._line_formatter)
|
||||
class BecLogsTableModel(QAbstractTableModel):
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.log_queue = BecLogsQueue.instance()
|
||||
self.log_queue.new_messages.connect(self.handle_new_messages)
|
||||
self._headers = _CONST.headers
|
||||
|
||||
def update_service_filter(self, services: set[str]):
|
||||
"""Change the selected services to display"""
|
||||
self._selected_services = services
|
||||
self._set_formatter_and_update_filter(self._line_formatter)
|
||||
def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
|
||||
return len(self.log_queue)
|
||||
|
||||
def update_line_formatter(self, line_formatter: LineFormatter):
|
||||
"""Update the formatter"""
|
||||
self._set_formatter_and_update_filter(line_formatter)
|
||||
def columnCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
|
||||
return len(self._headers)
|
||||
|
||||
def display_all(self) -> str:
|
||||
"""Return formatted output for all log messages"""
|
||||
return "\n".join(self._queue_formatter(self._data.copy()))
|
||||
def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)):
|
||||
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
|
||||
return self._headers[section]
|
||||
return None
|
||||
|
||||
def format_new(self):
|
||||
"""Return formatted output for the display queue"""
|
||||
res = "\n".join(self._display_queue)
|
||||
self._display_queue = deque([], self._max_length)
|
||||
return res
|
||||
def get_row_data(self, index: QModelIndex) -> LogMessage | None:
|
||||
"""Return the row data for the given index."""
|
||||
if not index.isValid():
|
||||
return None
|
||||
return self.log_queue.row_data(index.row())
|
||||
|
||||
def clear_logs(self):
|
||||
"""Clear the cache and display queue"""
|
||||
self._data = deque([])
|
||||
self._display_queue = deque([])
|
||||
def timestamp(self, row: int):
|
||||
return QDateTime.fromMSecsSinceEpoch(int(self.log_queue.log_timestamp(row) * 1000))
|
||||
|
||||
def fetch_history(self):
|
||||
"""Fetch all available messages from Redis"""
|
||||
self._data = deque(
|
||||
item["data"]
|
||||
for item in self.bec_dispatcher.client.connector.xread(
|
||||
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length
|
||||
)
|
||||
def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)):
|
||||
"""Return data for the given index and role."""
|
||||
if not index.isValid():
|
||||
return
|
||||
if role in [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole]:
|
||||
return self.log_queue.cell_data(index.row(), self._headers[index.column()])
|
||||
if role in [Qt.ItemDataRole.ForegroundRole]:
|
||||
return self._map_log_level_color(self.log_queue.cell_data(index.row(), "level"))
|
||||
|
||||
def _map_log_level_color(self, data):
|
||||
return _DEFAULT_LOG_COLORS.get(data)
|
||||
|
||||
def handle_new_messages(self):
|
||||
self.dataChanged.emit(
|
||||
self.index(0, 0), self.index(self.rowCount() - 1, self.columnCount() - 1)
|
||||
)
|
||||
|
||||
def unique_service_names_from_history(self) -> set[str]:
|
||||
"""Go through the log history to determine active service names"""
|
||||
return set(msg.log_msg["service_name"] for msg in self._data)
|
||||
|
||||
class LogMsgProxyModel(QSortFilterProxyModel):
|
||||
show_service_column = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
service_filter: set[str] | None = None,
|
||||
level_filter: LogLevel | None = None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._service_filter = service_filter or set()
|
||||
self._level_filter: LogLevel | None = level_filter
|
||||
self._filter_text: str = ""
|
||||
self._fuzzy_search: bool = False
|
||||
self._time_filter_start: QDateTime | None = None
|
||||
self._time_filter_end: QDateTime | None = None
|
||||
|
||||
def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[LogMessage | None]:
|
||||
return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows)
|
||||
|
||||
def sourceModel(self) -> BecLogsTableModel:
|
||||
return super().sourceModel() # type: ignore
|
||||
|
||||
@SafeSlot(int, int)
|
||||
def refresh(self, *_):
|
||||
self.invalidateRowsFilter()
|
||||
|
||||
@SafeSlot(None)
|
||||
@SafeSlot(set)
|
||||
def update_service_filter(self, filter: set[str]):
|
||||
"""Filter to the selected services (show any service in the provided set)
|
||||
|
||||
Args:
|
||||
filter (set[str] | None): set of services for which to show logs"""
|
||||
self._service_filter = filter
|
||||
self.show_service_column.emit(len(filter) != 1)
|
||||
self.invalidateRowsFilter()
|
||||
|
||||
@SafeSlot(None)
|
||||
@SafeSlot(LogLevel)
|
||||
def update_level_filter(self, filter: LogLevel | None):
|
||||
"""Filter to the selected log level
|
||||
|
||||
Args:
|
||||
filter (str | None): lowest log level to show"""
|
||||
self._level_filter = filter
|
||||
self.invalidateRowsFilter()
|
||||
|
||||
@SafeSlot(str)
|
||||
def update_filter_text(self, filter: str):
|
||||
"""Filter messages based on text
|
||||
|
||||
Args:
|
||||
filter (str | None): set of services for which to show logs"""
|
||||
self._filter_text = filter
|
||||
self.invalidateRowsFilter()
|
||||
|
||||
@SafeSlot(bool)
|
||||
def update_fuzzy(self, state: bool):
|
||||
"""Set text filter to fuzzy search or not
|
||||
|
||||
Args:
|
||||
state (bool): fuzzy search on"""
|
||||
self._fuzzy_search = state
|
||||
self.invalidateRowsFilter()
|
||||
|
||||
@SafeSlot(TimestampUpdate)
|
||||
def update_timestamp(self, update: TimestampUpdate):
|
||||
if update.update_type == "start":
|
||||
self._time_filter_start = update.value
|
||||
else:
|
||||
self._time_filter_end = update.value
|
||||
self.invalidateRowsFilter()
|
||||
|
||||
def filterAcceptsRow(self, source_row: int, source_parent) -> bool:
|
||||
# No service filter, and no filter text, display everything
|
||||
possible_filters = [
|
||||
self._service_filter,
|
||||
self._level_filter,
|
||||
self._filter_text,
|
||||
self._time_filter_start,
|
||||
self._time_filter_end,
|
||||
]
|
||||
if not any(map(bool, possible_filters)):
|
||||
return True
|
||||
model = self.sourceModel()
|
||||
# Filter out services
|
||||
if self._service_filter:
|
||||
col = _CONST.headers.index("service_name")
|
||||
if model.data(model.index(source_row, col, source_parent)) not in self._service_filter:
|
||||
return False
|
||||
# Filter out levels
|
||||
if self._level_filter:
|
||||
col = _CONST.headers.index("level")
|
||||
level: str = model.data(model.index(source_row, col, source_parent)) # type: ignore
|
||||
if LogLevel[level] < self._level_filter:
|
||||
return False
|
||||
# Filter time
|
||||
if self._time_filter_start:
|
||||
if model.timestamp(source_row) < self._time_filter_start:
|
||||
return False
|
||||
if self._time_filter_end:
|
||||
if model.timestamp(source_row) > self._time_filter_end:
|
||||
return False
|
||||
# Filter message text - must go last because this can return True
|
||||
if self._filter_text:
|
||||
col = _CONST.headers.index("message")
|
||||
msg: str = model.data(model.index(source_row, col, source_parent)).lower() # type: ignore
|
||||
if self._fuzzy_search:
|
||||
return fuzz.partial_ratio(self._filter_text.lower(), msg) >= _CONST.FUZZ_THRESHOLD
|
||||
else:
|
||||
return self._filter_text.lower() in msg.lower()
|
||||
return True
|
||||
|
||||
|
||||
class BecLogTableView(QTableView):
|
||||
def __init__(self, *args, max_message_width: int = 1000, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
header = QHeaderView(Qt.Orientation.Horizontal, parent=self)
|
||||
header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
||||
header.setStretchLastSection(True)
|
||||
header.setMaximumSectionSize(max_message_width)
|
||||
self.setHorizontalHeader(header)
|
||||
|
||||
def model(self) -> LogMsgProxyModel:
|
||||
return super().model() # type: ignore
|
||||
|
||||
|
||||
class LogPanel(BECWidget, QWidget):
|
||||
"""Live display of the BEC logs in a table view."""
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "browse_activity"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
max_message_width: int = 1000,
|
||||
show_toolbar: bool = True,
|
||||
service_filter: set[str] | None = None,
|
||||
level_filter: LogLevel | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._setup_models(service_filter=service_filter, level_filter=level_filter)
|
||||
self._layout = QVBoxLayout()
|
||||
self.setLayout(self._layout)
|
||||
if show_toolbar:
|
||||
self._setup_toolbar(client=self.client)
|
||||
self._setup_table_view(max_message_width=max_message_width)
|
||||
self._update_service_filter(service_filter or set())
|
||||
if show_toolbar:
|
||||
self._connect_toolbar()
|
||||
self._proxy.show_service_column.connect(self._show_service_column)
|
||||
colors = QApplication.instance().theme.accent_colors # type: ignore
|
||||
dict_colors = QApplication.instance().theme.colors # type: ignore
|
||||
_DEFAULT_LOG_COLORS.update(
|
||||
{
|
||||
LogLevel.INFO.name: dict_colors["FG"],
|
||||
LogLevel.SUCCESS.name: colors.success,
|
||||
LogLevel.WARNING.name: colors.warning,
|
||||
LogLevel.ERROR.name: colors.emergency,
|
||||
LogLevel.DEBUG.name: dict_colors["BORDER"],
|
||||
}
|
||||
)
|
||||
self._table.scrollToBottom()
|
||||
|
||||
def _setup_models(self, service_filter: set[str] | None, level_filter: LogLevel | None):
|
||||
self._model = BecLogsTableModel(parent=self)
|
||||
self._proxy = LogMsgProxyModel(
|
||||
parent=self, service_filter=service_filter, level_filter=level_filter
|
||||
)
|
||||
self._proxy.setSourceModel(self._model)
|
||||
self._model.log_queue.new_messages.connect(self._proxy.refresh)
|
||||
|
||||
def _setup_table_view(self, max_message_width: int) -> None:
|
||||
"""Setup the table view."""
|
||||
self._table = BecLogTableView(self, max_message_width=max_message_width)
|
||||
self._table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self._layout.addWidget(self._table)
|
||||
self._table.setModel(self._proxy)
|
||||
self._table.setHorizontalScrollMode(QTableView.ScrollMode.ScrollPerPixel)
|
||||
self._table.setTextElideMode(Qt.TextElideMode.ElideRight)
|
||||
self._table.resizeColumnsToContents()
|
||||
|
||||
def _setup_toolbar(self, client: BECClient):
|
||||
self._toolbar = LogPanelToolbar(self, client)
|
||||
self._layout.addWidget(self._toolbar)
|
||||
|
||||
def _connect_toolbar(self):
|
||||
self._toolbar.services_selected.connect(self._proxy.update_service_filter)
|
||||
self._toolbar.search_textbox.textChanged.connect(self._proxy.update_filter_text)
|
||||
self._toolbar.level_changed.connect(self._proxy.update_level_filter)
|
||||
self._toolbar.fuzzy_changed.connect(self._proxy.update_fuzzy)
|
||||
self._toolbar.timestamp_update.connect(self._proxy.update_timestamp)
|
||||
self._toolbar.pause_button.clicked.connect(self._model.log_queue.toggle_pause)
|
||||
self._model.log_queue.paused.connect(self._toolbar._update_pause_button_icon)
|
||||
|
||||
def _update_service_filter(self, filter: set[str]):
|
||||
self._service_filter = filter
|
||||
self._proxy.update_service_filter(filter)
|
||||
self._table.setColumnHidden(
|
||||
_CONST.headers.index("service_name"), len(self._service_filter) == 1
|
||||
)
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _show_service_column(self, show: bool):
|
||||
self._table.setColumnHidden(_CONST.headers.index("service_name"), not show)
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(600, 300)
|
||||
|
||||
|
||||
class LogPanelToolbar(QWidget):
|
||||
services_selected = Signal(set)
|
||||
level_changed = Signal(LogLevel)
|
||||
fuzzy_changed = Signal(bool)
|
||||
timestamp_update = Signal(TimestampUpdate)
|
||||
|
||||
services_selected: SignalInstance = Signal(set)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
def __init__(self, parent: QWidget | None = None, client: BECClient | None = None) -> None:
|
||||
"""A toolbar for the logpanel, mainly used for managing the states of filters"""
|
||||
super().__init__(parent)
|
||||
|
||||
@@ -231,51 +451,69 @@ class LogPanelToolbar(QWidget):
|
||||
self._timestamp_end: QDateTime | None = None
|
||||
|
||||
self._unique_service_names: set[str] = set()
|
||||
self._services_selected: set[str] | None = None
|
||||
self._services_selected: set[str] = set()
|
||||
|
||||
self.layout = QHBoxLayout(self) # type: ignore
|
||||
self._layout = QHBoxLayout(self)
|
||||
|
||||
self.service_choice_button = QPushButton("Select services", self)
|
||||
self.layout.addWidget(self.service_choice_button)
|
||||
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
|
||||
if client is not None:
|
||||
self.client = client
|
||||
self.service_choice_button = QPushButton("Select services", self)
|
||||
self._layout.addWidget(self.service_choice_button)
|
||||
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
|
||||
self.service_list_update(self.client.service_status)
|
||||
self._services_selected = self._unique_service_names
|
||||
|
||||
self.filter_level_dropdown = self._log_level_box()
|
||||
self.layout.addWidget(self.filter_level_dropdown)
|
||||
|
||||
self.clear_button = QPushButton("Clear all", self)
|
||||
self.layout.addWidget(self.clear_button)
|
||||
self.fetch_button = QPushButton("Fetch history", self)
|
||||
self.layout.addWidget(self.fetch_button)
|
||||
self._layout.addWidget(self.filter_level_dropdown)
|
||||
self.filter_level_dropdown.currentTextChanged.connect(self._emit_level)
|
||||
|
||||
self._string_search_box()
|
||||
|
||||
self.timerange_button = QPushButton("Set time range", self)
|
||||
self.layout.addWidget(self.timerange_button)
|
||||
self._layout.addWidget(self.timerange_button)
|
||||
self.timerange_button.clicked.connect(self._open_datetime_dialog)
|
||||
|
||||
@property
|
||||
def time_start(self):
|
||||
return self._timestamp_start
|
||||
self.pause_button = QToolButton()
|
||||
self.pause_button.setIcon(material_icon("pause", size=(20, 20), convert_to_pixmap=False))
|
||||
self._PLAYING_TOOLTIP = "Pause live log updates."
|
||||
self._PAUSED_TOOLTIP = "Continue live log updates."
|
||||
self.pause_button.setToolTip(self._PLAYING_TOOLTIP)
|
||||
self._layout.addWidget(self.pause_button)
|
||||
|
||||
@property
|
||||
def time_end(self):
|
||||
return self._timestamp_end
|
||||
@SafeSlot(bool)
|
||||
def _update_pause_button_icon(self, paused):
|
||||
if paused:
|
||||
icon = "play_arrow"
|
||||
tooltip = self._PAUSED_TOOLTIP
|
||||
else:
|
||||
icon = "pause"
|
||||
tooltip = self._PLAYING_TOOLTIP
|
||||
self.pause_button.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
|
||||
self.pause_button.setToolTip(tooltip)
|
||||
|
||||
def _string_search_box(self):
|
||||
self.layout.addWidget(QLabel("Search: "))
|
||||
self._layout.addWidget(QLabel("Search: "))
|
||||
self.search_textbox = QLineEdit()
|
||||
self.layout.addWidget(self.search_textbox)
|
||||
self.layout.addWidget(QLabel("Use regex: "))
|
||||
self.regex_enabled = QCheckBox()
|
||||
self.layout.addWidget(self.regex_enabled)
|
||||
self.update_re_button = QPushButton("Update search", self)
|
||||
self.layout.addWidget(self.update_re_button)
|
||||
self._layout.addWidget(self.search_textbox)
|
||||
self._layout.addWidget(QLabel("Fuzzy: "))
|
||||
self.fuzzy = QCheckBox()
|
||||
self._layout.addWidget(self.fuzzy)
|
||||
self.fuzzy.checkStateChanged.connect(self._emit_fuzzy)
|
||||
|
||||
def _log_level_box(self):
|
||||
box = QComboBox()
|
||||
box.setToolTip("Display logs with equal or greater significance to the selected level.")
|
||||
[box.addItem(l.name) for l in LogLevel]
|
||||
[box.addItem(level.name) for level in LogLevel]
|
||||
return box
|
||||
|
||||
@SafeSlot(str)
|
||||
def _emit_level(self, level: str):
|
||||
self.level_changed.emit(LogLevel[level])
|
||||
|
||||
@SafeSlot(Qt.CheckState)
|
||||
def _emit_fuzzy(self, state: Qt.CheckState):
|
||||
self.fuzzy_changed.emit(state == Qt.CheckState.Checked)
|
||||
|
||||
def _current_ts(self, selection_type: Literal["start", "end"]):
|
||||
if selection_type == "start":
|
||||
return self._timestamp_start
|
||||
@@ -284,6 +522,7 @@ class LogPanelToolbar(QWidget):
|
||||
else:
|
||||
raise ValueError(f"timestamps can only be for the start or end, not {selection_type}")
|
||||
|
||||
@SafeSlot()
|
||||
def _open_datetime_dialog(self):
|
||||
"""Open dialog window for timestamp filter selection"""
|
||||
self._dt_dialog = QDialog(self)
|
||||
@@ -312,8 +551,8 @@ class LogPanelToolbar(QWidget):
|
||||
)
|
||||
_layout.addWidget(date_clear_button)
|
||||
|
||||
for v in [("start", label_start), ("end", label_end)]:
|
||||
date_button_set(*v)
|
||||
date_button_set("start", label_start)
|
||||
date_button_set("end", label_end)
|
||||
|
||||
close_button = QPushButton("Close", parent=self._dt_dialog)
|
||||
close_button.clicked.connect(self._dt_dialog.accept)
|
||||
@@ -352,27 +591,23 @@ class LogPanelToolbar(QWidget):
|
||||
self._timestamp_start = dt
|
||||
else:
|
||||
self._timestamp_end = dt
|
||||
self.timestamp_update.emit(TimestampUpdate(value=dt, update_type=selection_type))
|
||||
|
||||
@SafeSlot(dict, set)
|
||||
def service_list_update(
|
||||
self, services_info: dict[str, StatusMessage], services_from_history: set[str], *_, **__
|
||||
):
|
||||
def service_list_update(self, services_info: dict[str, StatusMessage]):
|
||||
"""Change the list of services which can be selected"""
|
||||
self._unique_service_names = set([s.split("/")[0] for s in services_info.keys()])
|
||||
self._unique_service_names |= services_from_history
|
||||
if self._services_selected is None:
|
||||
self._services_selected = self._unique_service_names
|
||||
|
||||
@SafeSlot()
|
||||
def _open_service_filter_dialog(self):
|
||||
self.service_list_update(self.client.service_status)
|
||||
if len(self._unique_service_names) == 0 or self._services_selected is None:
|
||||
return
|
||||
self._svc_dialog = QDialog(self)
|
||||
self._svc_dialog.setWindowTitle(f"Select services to show logs from")
|
||||
self._svc_dialog.setWindowTitle("Select services to show logs from")
|
||||
layout = QVBoxLayout()
|
||||
self._svc_dialog.setLayout(layout)
|
||||
|
||||
service_cb_grid = QGridLayout(parent=self._svc_dialog)
|
||||
service_cb_grid = QGridLayout()
|
||||
layout.addLayout(service_cb_grid)
|
||||
|
||||
def check_box(name: str, checked: Qt.CheckState):
|
||||
@@ -398,146 +633,6 @@ class LogPanelToolbar(QWidget):
|
||||
self._svc_dialog.deleteLater()
|
||||
|
||||
|
||||
class LogPanel(TextBox):
|
||||
"""Displays a log panel"""
|
||||
|
||||
ICON_NAME = "terminal"
|
||||
service_list_update = Signal(dict, set)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client: BECClient | None = None,
|
||||
service_status: BECServiceStatusMixin | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the LogPanel widget."""
|
||||
super().__init__(parent=parent, client=client, config={"text": ""}, **kwargs)
|
||||
self._update_colors()
|
||||
self._service_status = service_status or BECServiceStatusMixin(self, client=self.client) # type: ignore
|
||||
self._log_manager = BecLogsQueue(
|
||||
parent=self, line_formatter=partial(simple_color_format, colors=self._colors)
|
||||
)
|
||||
self._proxy_update = SignalProxy(
|
||||
self._log_manager.new_message, rateLimit=1, slot=self._on_append
|
||||
)
|
||||
|
||||
self.toolbar = LogPanelToolbar(parent=self)
|
||||
self.toolbar_area = QScrollArea()
|
||||
self.toolbar_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.toolbar_area.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents)
|
||||
self.toolbar_area.setFixedHeight(int(self.toolbar.clear_button.height() * 2))
|
||||
self.toolbar_area.setWidget(self.toolbar)
|
||||
|
||||
self.layout.addWidget(self.toolbar_area)
|
||||
self.toolbar.clear_button.clicked.connect(self._on_clear)
|
||||
self.toolbar.fetch_button.clicked.connect(self._on_fetch)
|
||||
self.toolbar.update_re_button.clicked.connect(self._on_re_update)
|
||||
self.toolbar.search_textbox.returnPressed.connect(self._on_re_update)
|
||||
self.toolbar.regex_enabled.checkStateChanged.connect(self._on_re_update)
|
||||
self.toolbar.filter_level_dropdown.currentTextChanged.connect(self._set_level_filter)
|
||||
|
||||
self.toolbar.timerange_button.clicked.connect(self._choose_datetime)
|
||||
self._service_status.services_update.connect(self._update_service_list)
|
||||
self.service_list_update.connect(self.toolbar.service_list_update)
|
||||
self.toolbar.services_selected.connect(self._update_service_filter)
|
||||
|
||||
self.text_box_text_edit.setFont(QFont("monospace", 12))
|
||||
self.text_box_text_edit.setHtml("")
|
||||
self.text_box_text_edit.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
@SafeSlot(set)
|
||||
def _update_service_filter(self, services: set[str]):
|
||||
self._log_manager.update_service_filter(services)
|
||||
self._on_redraw()
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def _update_service_list(self, services_info: dict[str, StatusMessage], *_, **__):
|
||||
self.service_list_update.emit(
|
||||
services_info, self._log_manager.unique_service_names_from_history()
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _choose_datetime(self):
|
||||
self.toolbar._open_datetime_dialog()
|
||||
self._set_time_filter()
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self._on_redraw) # type: ignore
|
||||
|
||||
def _update_colors(self):
|
||||
self._colors = DEFAULT_LOG_COLORS.copy()
|
||||
self._colors.update({LogLevel.INFO: get_theme_palette().text().color().name()})
|
||||
|
||||
def _cursor_to_end(self):
|
||||
c = self.text_box_text_edit.textCursor()
|
||||
c.movePosition(c.MoveOperation.End)
|
||||
self.text_box_text_edit.setTextCursor(c)
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
def _on_redraw(self, *_):
|
||||
self._update_colors()
|
||||
self._log_manager.update_line_formatter(partial(simple_color_format, colors=self._colors))
|
||||
self.set_html_text(self._log_manager.display_all())
|
||||
self._cursor_to_end()
|
||||
|
||||
@SafeSlot(verify_sender=True)
|
||||
def _on_append(self, *_):
|
||||
self.text_box_text_edit.insertHtml(self._log_manager.format_new())
|
||||
self._cursor_to_end()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_clear(self):
|
||||
self._log_manager.clear_logs()
|
||||
self.set_html_text(self._log_manager.display_all())
|
||||
self._cursor_to_end()
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(Qt.CheckState)
|
||||
def _on_re_update(self, *_):
|
||||
if self.toolbar.regex_enabled.isChecked():
|
||||
try:
|
||||
search_query = re.compile(self.toolbar.search_textbox.text())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compile search regex with error {e}")
|
||||
search_query = None
|
||||
logger.info(f"Setting LogPanel search regex to {search_query}")
|
||||
else:
|
||||
search_query = self.toolbar.search_textbox.text()
|
||||
logger.info(f'Setting LogPanel search string to "{search_query}"')
|
||||
self._log_manager.update_search_filter(search_query)
|
||||
self.set_html_text(self._log_manager.display_all())
|
||||
self._cursor_to_end()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_fetch(self):
|
||||
self._log_manager.fetch_history()
|
||||
self.set_html_text(self._log_manager.display_all())
|
||||
self._cursor_to_end()
|
||||
|
||||
@SafeSlot(str)
|
||||
def _set_level_filter(self, level: str):
|
||||
self._log_manager.update_level_filter(level)
|
||||
self._on_redraw()
|
||||
|
||||
@SafeSlot()
|
||||
def _set_time_filter(self):
|
||||
self._log_manager.update_time_filter(self.toolbar.time_start, self.toolbar.time_end)
|
||||
self._on_redraw()
|
||||
|
||||
def cleanup(self):
|
||||
self._service_status.cleanup()
|
||||
self._log_manager.cleanup()
|
||||
self._log_manager.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
@@ -545,7 +640,15 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
widget = LogPanel()
|
||||
panel = QWidget()
|
||||
queue = BecLogsQueue(panel)
|
||||
layout = QVBoxLayout(panel)
|
||||
layout.addWidget(QLabel("All logs, no filters:"))
|
||||
layout.addWidget(LogPanel())
|
||||
layout.addWidget(QLabel("All services, level filter WARNING preapplied:"))
|
||||
layout.addWidget(LogPanel(level_filter=LogLevel.WARNING))
|
||||
layout.addWidget(QLabel('All services, service filter {"DeviceServer"} preapplied:'))
|
||||
layout.addWidget(LogPanel(service_filter={"DeviceServer"}))
|
||||
|
||||
widget.show()
|
||||
panel.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -3,8 +3,8 @@ from qtpy import QtCore, QtGui
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import Colors
|
||||
|
||||
|
||||
class RoundedColorMapButton(ColorMapButton):
|
||||
|
||||
@@ -19,7 +19,7 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.widget_highlighter import WidgetHighlighter
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
|
||||
|
||||
+78
-72
@@ -1,54 +1,34 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "3.4.0"
|
||||
version = "3.7.1"
|
||||
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",
|
||||
"PyJWT~=2.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]
|
||||
@@ -56,10 +36,45 @@ 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.applications.companion_app:main"
|
||||
bw-generate-cli = "bec_widgets.utils.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 = ["*"]
|
||||
@@ -69,10 +84,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
|
||||
@@ -80,6 +91,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"]
|
||||
@@ -90,16 +107,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"]
|
||||
@@ -116,14 +133,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__":',
|
||||
]
|
||||
|
||||
+18
-5
@@ -1,3 +1,5 @@
|
||||
import traceback
|
||||
|
||||
import pytest
|
||||
import qtpy.QtCore
|
||||
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
||||
@@ -5,12 +7,14 @@ from qtpy.QtCore import QTimer
|
||||
|
||||
|
||||
class TestableQTimer(QTimer):
|
||||
_instances: list[tuple[QTimer, str]] = []
|
||||
_instances: list[tuple[QTimer, str, str]] = []
|
||||
_current_test_name: str = ""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
TestableQTimer._instances.append((self, TestableQTimer._current_test_name))
|
||||
tb = traceback.format_stack()
|
||||
init_line = list(filter(lambda msg: "QTimer(" in msg, tb))[-1]
|
||||
TestableQTimer._instances.append((self, TestableQTimer._current_test_name, init_line))
|
||||
|
||||
@classmethod
|
||||
def check_all_stopped(cls, qtbot):
|
||||
@@ -20,12 +24,21 @@ class TestableQTimer(QTimer):
|
||||
except RuntimeError as e:
|
||||
return "already deleted" in e.args[0]
|
||||
|
||||
def _format_timers(timers: list[tuple[QTimer, str, str]]):
|
||||
return "\n".join(
|
||||
f"Timer: {t[0]}\n in test: {t[1]}\n created at:{t[2]}" for t in timers
|
||||
)
|
||||
|
||||
try:
|
||||
qtbot.waitUntil(lambda: all(_is_done_or_deleted(timer) for timer, _ in cls._instances))
|
||||
qtbot.waitUntil(
|
||||
lambda: all(_is_done_or_deleted(timer) for timer, _, _ in cls._instances)
|
||||
)
|
||||
except QtBotTimeoutError as exc:
|
||||
active_timers = list(filter(lambda t: t[0].isActive(), cls._instances))
|
||||
(t.stop() for t, _ in cls._instances)
|
||||
raise TimeoutError(f"Failed to stop all timers: {active_timers}") from exc
|
||||
(t.stop() for t, _, _ in cls._instances)
|
||||
raise TimeoutError(
|
||||
f"Failed to stop all timers:\n{_format_timers(active_timers)}"
|
||||
) from exc
|
||||
cls._instances = []
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ 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 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
|
||||
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")
|
||||
@@ -51,6 +51,7 @@ def connected_client_gui_obj(qtbot, gui_id, bec_client_lib):
|
||||
qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
|
||||
yield gui
|
||||
finally:
|
||||
gui.bec.delete_all() # ensure clean state
|
||||
qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
|
||||
if (bec := getattr(gui, "bec", None)) is not None:
|
||||
bec.delete_all() # ensure clean state
|
||||
qtbot.waitUntil(lambda: len(bec.widget_list()) == 0, timeout=10000)
|
||||
gui.kill_server()
|
||||
|
||||
@@ -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
|
||||
|
||||
#############################
|
||||
|
||||
@@ -59,4 +59,5 @@ def test_run_line_scan_with_parameters_e2e(scan_control, bec_client_lib, qtbot):
|
||||
last_scan = queue.scan_storage.storage[-1]
|
||||
assert last_scan.status_message.info["scan_name"] == scan_name
|
||||
assert last_scan.status_message.info["exp_time"] == kwargs["exp_time"]
|
||||
assert last_scan.status_message.info["scan_motors"] == [args["device"]]
|
||||
assert last_scan.status_message.info["num_points"] == kwargs["steps"]
|
||||
|
||||
@@ -84,6 +84,7 @@ def test_scan_metadata_for_custom_scan(
|
||||
last_scan = queue.scan_storage.storage[-1]
|
||||
assert last_scan.status_message.info["scan_name"] == scan_name
|
||||
assert last_scan.status_message.info["exp_time"] == kwargs["exp_time"]
|
||||
assert last_scan.status_message.info["scan_motors"] == [args["device"]]
|
||||
assert last_scan.status_message.info["num_points"] == kwargs["steps"]
|
||||
|
||||
if valid:
|
||||
|
||||
@@ -260,22 +260,6 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# TODO re-enable when issue is resolved #560
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the LogPanel widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
|
||||
# widget: client.LogPanel
|
||||
|
||||
# # No rpc calls to check so far
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the MineSweeper widget."""
|
||||
|
||||
@@ -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,16 +18,16 @@ 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
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.tests.utils import DEVICES, DMMock
|
||||
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
|
||||
from bec_widgets.utils import error_popups
|
||||
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
|
||||
from bec_widgets.utils.rpc_register import RPCRegister
|
||||
|
||||
# Patch to set default RAISE_ERROR_DEFAULT to True for tests
|
||||
# This means that by default, error popups will raise exceptions during tests
|
||||
|
||||
@@ -81,12 +81,95 @@ class _FakeReply:
|
||||
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 http_service(self, qtbot):
|
||||
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
|
||||
@@ -224,7 +307,7 @@ class TestBECAtlasHTTPService:
|
||||
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, qtbot):
|
||||
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
|
||||
@@ -268,6 +351,25 @@ class TestBECAtlasHTTPService:
|
||||
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(
|
||||
@@ -297,70 +399,6 @@ class TestBECAtlasHTTPService:
|
||||
http_service._handle_response(reply, _override_slot_params={"raise_error": 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 TestBECAtlasExperimentSelection:
|
||||
|
||||
def test_format_name(self, experiment_info_message: ExperimentInfoMessage):
|
||||
@@ -546,7 +584,7 @@ class TestBECAtlasAdminView:
|
||||
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-dev.psi.ch/api/v1"
|
||||
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:
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.utils.error_popups import SafeSlot as Slot
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -71,6 +71,7 @@ def bec_queue_msg_full():
|
||||
},
|
||||
"report_instructions": [{"scan_progress": 20}],
|
||||
"scan_id": "2d704cc3-c172-404c-866d-608ce09fce40",
|
||||
"scan_motors": ["samx"],
|
||||
"scan_number": 1289,
|
||||
}
|
||||
],
|
||||
|
||||
@@ -9,7 +9,8 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
||||
|
||||
|
||||
class _TestGlobalPlugin(RPCBase): ...
|
||||
class _TestGlobalPlugin(RPCBase):
|
||||
_IMPORT_MODULE = "test.global.plugin.widgets"
|
||||
|
||||
|
||||
mock_client_module_globals = SimpleNamespace()
|
||||
@@ -25,12 +26,13 @@ mock_client_module_globals.Widgets = _TestGlobalPlugin
|
||||
def test_plugins_dont_clobber_client_globals(bec_logger: MagicMock):
|
||||
reload(client)
|
||||
bec_logger.logger.warning.assert_called_with(
|
||||
"Plugin widget Widgets from namespace(Widgets=<class 'tests.unit_tests.test_client_plugin_widgets._TestGlobalPlugin'>) conflicts with a built-in class!"
|
||||
"Plugin widget Widgets in test.global.plugin.widgets conflicts with a built-in class!"
|
||||
)
|
||||
assert isinstance(client.Widgets, enum.EnumType)
|
||||
|
||||
|
||||
class _TestDuplicatePlugin(RPCBase): ...
|
||||
class _TestDuplicatePlugin(RPCBase):
|
||||
_IMPORT_MODULE = "test.duplicate.plugin.module"
|
||||
|
||||
|
||||
mock_client_module_duplicate = SimpleNamespace()
|
||||
@@ -54,7 +56,7 @@ def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock):
|
||||
reload(client)
|
||||
assert (
|
||||
call(
|
||||
f"Detected duplicate widget Waveform in plugin repo file: {inspect.getfile(_TestDuplicatePlugin)} !"
|
||||
"Plugin widget Waveform in test.duplicate.plugin.module conflicts with a built-in class!"
|
||||
)
|
||||
in bec_logger.logger.warning.mock_calls
|
||||
)
|
||||
|
||||
@@ -4,9 +4,9 @@ from pydantic import ValidationError
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.colors import Colors, apply_theme
|
||||
from bec_widgets.widgets.plots.waveform.curve import CurveConfig
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
from qtpy.QtCore import QPointF, Qt
|
||||
from qtpy.QtGui import QTransform
|
||||
|
||||
from bec_widgets.utils import Crosshair
|
||||
from bec_widgets.utils.crosshair import Crosshair
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
|
||||
@@ -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)
|
||||
@@ -2229,7 +2229,6 @@ class TestFlatToolbarActions:
|
||||
"flat_progress_bar",
|
||||
"flat_terminal",
|
||||
"flat_bec_shell",
|
||||
"flat_log_panel",
|
||||
"flat_sbb_monitor",
|
||||
]
|
||||
|
||||
@@ -2272,7 +2271,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",
|
||||
}
|
||||
@@ -2289,11 +2288,6 @@ class TestFlatToolbarActions:
|
||||
action.trigger()
|
||||
mock_new.assert_called_once_with(widget_type)
|
||||
|
||||
def test_flat_log_panel_action_disabled(self, advanced_dock_area):
|
||||
"""Test that flat log panel action is disabled."""
|
||||
action = advanced_dock_area.toolbar.components.get_action("flat_log_panel").action
|
||||
assert not action.isEnabled()
|
||||
|
||||
|
||||
class TestModeTransitions:
|
||||
"""Test mode transitions and state consistency."""
|
||||
|
||||
@@ -5,7 +5,7 @@ import black
|
||||
import isort
|
||||
import pytest
|
||||
|
||||
from bec_widgets.cli.generate_cli import ClientGenerator
|
||||
from bec_widgets.utils.generate_cli import ClientGenerator
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
||||
|
||||
# pylint: disable=missing-function-docstring
|
||||
@@ -104,8 +104,7 @@ def test_client_generator_with_black_formatting():
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
||||
from bec_widgets.utils.bec_plugin_helper import (get_all_plugin_widgets,
|
||||
get_plugin_client_module)
|
||||
from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -123,31 +122,25 @@ def test_client_generator_with_black_formatting():
|
||||
|
||||
|
||||
try:
|
||||
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
||||
plugin_client = get_plugin_client_module()
|
||||
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
||||
|
||||
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
|
||||
for _widget in _overlap:
|
||||
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
|
||||
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
||||
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
||||
if plugin_name not in _Widgets:
|
||||
_Widgets[plugin_name] = plugin_name
|
||||
if plugin_name in globals():
|
||||
conflicting_file = (
|
||||
inspect.getfile(_plugin_widgets[plugin_name])
|
||||
if plugin_name in _plugin_widgets
|
||||
else f"{plugin_client}"
|
||||
)
|
||||
logger.warning(
|
||||
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
|
||||
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
|
||||
)
|
||||
continue
|
||||
if plugin_name not in _overlap:
|
||||
else:
|
||||
globals()[plugin_name] = plugin_class
|
||||
Widgets = _WidgetsEnumType("Widgets", _Widgets)
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
|
||||
|
||||
class MockBECFigure(RPCBase):
|
||||
_IMPORT_MODULE = "tests.unit_tests.test_generate_cli_client"
|
||||
|
||||
@rpc_call
|
||||
def add_plot(self, plot_id: str):
|
||||
"""
|
||||
@@ -162,6 +155,8 @@ def test_client_generator_with_black_formatting():
|
||||
|
||||
|
||||
class MockBECWaveform1D(RPCBase):
|
||||
_IMPORT_MODULE = "tests.unit_tests.test_generate_cli_client"
|
||||
|
||||
@rpc_call
|
||||
def set_frequency(self, frequency: float) -> list:
|
||||
"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user